From 08be48817eeef67e56ad137764504d018814e89f Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 17 Jul 2026 17:41:59 +0530 Subject: [PATCH] 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 --- apps/hq/package.json | 2 + .../2026-07-17-postgres-switch-design.md | 56 ++++++ package-lock.json | 160 ++++++++++++++++++ 3 files changed, 218 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-17-postgres-switch-design.md diff --git a/apps/hq/package.json b/apps/hq/package.json index ecbdf62..81069e2 100644 --- a/apps/hq/package.json +++ b/apps/hq/package.json @@ -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" } diff --git a/docs/superpowers/specs/2026-07-17-postgres-switch-design.md b/docs/superpowers/specs/2026-07-17-postgres-switch-design.md new file mode 100644 index 0000000..7ed415f --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-postgres-switch-design.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 68e2d36..ed96dd1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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",