# 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_URL` engine selection, and a > 5-test Postgres integration suite (opt-in `HQ_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: `LIKE` is case-insensitive on SQLite > but case-sensitive on Postgres — client search/filters will match case-sensitively > in production until switched to `LOWER(...) 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) 1. **Async refactor, SQLite still underneath** — introduce the `Db` interface, convert every `repos-*.ts`, `api.ts`, scheduler, server and test to `async/await` with the sqlite implementation behind it. **Zero behavior change**; this is ~70% of the diff and is deliberately not mixed with any engine change. 2. **Postgres schema + migration runner** — `schema.pg.sql` (bigint paise, boolean, jsonb, real CHECKs) + `schema_migrations`. 3. **pg adapter** — Pool, `?`→`$n`, ON CONFLICT mapping, savepoint-nested transactions, bigint parsing. 4. **Dual-engine test run** — full suite on `:memory:` SQLite (default, fast) and on a real local Postgres via `TEST_PG=1` + `DATABASE_URL`. Both green before landing. 5. **Boot + smoke on local Postgres** — server on `DATABASE_URL`, real flows exercised (login → quote → issue → payment → PDF → share). 6. **Prod later**: RDS (Mumbai) when go-live infra is provisioned; same `DATABASE_URL` mechanism, then cutover per DEPLOY-HQ. ## 4. Environment (this machine) - PostgreSQL 17 installed locally as a Windows service (EDB installer, unattended); superuser `postgres`. App role `hq`; databases `hq` (dev runtime) and `hq_test` (suite). DBeaver connects to `localhost:5432` for 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.