# HQ Console — Postgres Switch (D15 executed → D19) > **STATUS: IN BUILD, started 2026-07-17.** The dedicated slice queued after the D18 > go-live cluster and **before** the real APEX cutover — so production data is born in > Postgres and nothing is ever migrated. ## 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.