You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/docs/superpowers/specs/2026-07-17-postgres-switch-...

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_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 IGNOREON CONFLICT DO NOTHING, nested db.transaction → savepoints, sqlite_masterinformation_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 runnerschema.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.