docs(spec): D19 Postgres switch design + pg driver dependency

Locked decisions: pg driver behind an async Db interface with dual
engines (sqlite for fast tests, pg for prod), bigint paise, TEXT ISO
dates, jsonb payloads, numbered migrations, ?->$n in the adapter,
DATABASE_URL selects the engine. Build order: async refactor first
(zero behavior change), then schema, adapter, dual-engine suite, smoke.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 4 days ago
parent 83f12685c4
commit 08be48817e

@ -15,11 +15,13 @@
"@sims/domain": "*",
"better-sqlite3": "^11.7.0",
"express": "^4.21.0",
"pg": "^8.22.0",
"puppeteer": "^23.11.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.11",
"@types/express": "^4.17.21",
"@types/pg": "^8.20.0",
"esbuild": "^0.25.0",
"tsx": "^4.19.0"
}

@ -0,0 +1,56 @@
# 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.

160
package-lock.json generated

@ -26,11 +26,13 @@
"@sims/domain": "*",
"better-sqlite3": "^11.7.0",
"express": "^4.21.0",
"pg": "^8.22.0",
"puppeteer": "^23.11.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.11",
"@types/express": "^4.17.21",
"@types/pg": "^8.20.0",
"esbuild": "^0.25.0",
"tsx": "^4.19.0"
}
@ -781,6 +783,18 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/qs": {
"version": "6.15.1",
"dev": true,
@ -2487,6 +2501,95 @@
"version": "1.2.0",
"license": "MIT"
},
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.15.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"license": "ISC"
@ -2529,6 +2632,45 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"license": "MIT",
@ -3062,6 +3204,15 @@
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"dev": true,
@ -3576,6 +3727,15 @@
}
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"license": "ISC",

Loading…
Cancel
Save