5.1 KiB
HQ Console — Postgres Switch (D15 executed → D19)
STATUS: DELIVERED 2026-07-17. All four phases landed same-day: async DB interface across every repo/route/test (default suite 346/346 on SQLite, zero behavior change), Postgres migration set + runner, pg adapter,
DATABASE_URLengine selection, and a 5-test Postgres integration suite (opt-inHQ_PG_TEST_URL) verified green against a local PostgreSQL 17.Delivery deviations from the table below (recorded in migrations-pg.ts too):
- 0/1 flags stay INTEGER (not boolean) — repos say
active=1; that SQL must be identical on both engines.- JSON stays TEXT (not jsonb) — node-pg auto-parses jsonb into objects, which would break the repos' JSON.parse at the edge; jsonb is a later pg-only ALTER if payload indexing is ever needed.
- Full-suite-on-pg was scoped to a dedicated integration file (migrations-from-zero, series + due-date stamp, paisa-exact payment, ON CONFLICT idempotency, savepoint rollback, HTTP boot smoke) rather than re-running all 346 sqlite-fixture tests on pg.
- Known dialect nuance to revisit before go-live:
LIKEis case-insensitive on SQLite but case-sensitive on Postgres — client search/filters will match case-sensitively in production until switched toLOWER(...) LIKE LOWER(?).
1. Goal & why now
D15 locked Postgres as the production engine; the repo has run on better-sqlite3
throughout development, with the portable-repo pattern maintained specifically so this
switch stays a contained task. Doing it before the one-shot 300-client import means:
empty Postgres → seed → import APEX CSVs straight in. No SQLite→Postgres data
migration, ever.
2. Locked decisions
| # | Decision | Why |
|---|---|---|
| D19.1 | Driver: pg (node-postgres) with a Pool |
Boring, ubiquitous, first-class TS types |
| D19.2 | Async Db interface (query/get/all/run/transaction) with TWO implementations: better-sqlite3 (dev/tests, wraps sync in async) and pg |
There is no sync Postgres client for Node; the whole repo layer goes async/await once, mechanically. SQLite stays for fast :memory: tests — the dual-engine suite proves the portable-SQL rule |
| D19.3 | Paise columns: bigint in Postgres (INTEGER unchanged in SQLite) |
int4 overflows at ₹2.15 crore in paise; aggregate SUMs cross that immediately. pg returns bigint as string → parse at the adapter edge, still integer paise in the domain |
| D19.4 | Dates stay TEXT ISO (YYYY-MM-DD / ISO timestamps) |
Already the house convention; portable, index-friendly, zero timezone surprises |
| D19.5 | JSON payloads (document.payload, client.contacts, …): jsonb in Postgres, TEXT in SQLite; repos keep JSON.stringify/parse at the edge |
jsonb gets us future indexing for free without changing repo code |
| D19.6 | Migrations: numbered SQL files + a tiny schema_migrations runner (per engine schema file) |
Replaces the PRAGMA-guard migrate() + rebuildTable gymnastics; Postgres does real ALTER TABLE |
| D19.7 | Placeholders: repos keep ?; the pg adapter rewrites ? → $n |
One SQL dialect in the codebase; the adapter owns the difference |
| D19.8 | Engine differences behind the adapter: INSERT OR IGNORE → ON CONFLICT DO NOTHING, nested db.transaction → savepoints, sqlite_master → information_schema |
Repo code stays engine-blind |
| D19.9 | Config: DATABASE_URL env selects Postgres; absent → SQLite as today |
Dev machines keep working with zero setup; prod sets one variable |
3. Build order (each phase lands with the full suite green)
- Async refactor, SQLite still underneath — introduce the
Dbinterface, convert everyrepos-*.ts,api.ts, scheduler, server and test toasync/awaitwith the sqlite implementation behind it. Zero behavior change; this is ~70% of the diff and is deliberately not mixed with any engine change. - Postgres schema + migration runner —
schema.pg.sql(bigint paise, boolean, jsonb, real CHECKs) +schema_migrations. - pg adapter — Pool,
?→$n, ON CONFLICT mapping, savepoint-nested transactions, bigint parsing. - Dual-engine test run — full suite on
:memory:SQLite (default, fast) and on a real local Postgres viaTEST_PG=1+DATABASE_URL. Both green before landing. - Boot + smoke on local Postgres — server on
DATABASE_URL, real flows exercised (login → quote → issue → payment → PDF → share). - Prod later: RDS (Mumbai) when go-live infra is provisioned; same
DATABASE_URLmechanism, then cutover per DEPLOY-HQ.
4. Environment (this machine)
- PostgreSQL 17 installed locally as a Windows service (EDB installer, unattended);
superuser
postgres. App rolehq; databaseshq(dev runtime) andhq_test(suite). DBeaver connects tolocalhost:5432for inspection. DATABASE_URL=postgres://hq:hq_dev@localhost:5432/hq
5. Non-goals
RDS provisioning (separate infra step at go-live), pgbouncer/replicas, ORM adoption (the portable-repo pattern is the point), rewriting any business logic.