feat(db): D19 P1-P3 — async DB interface, dual engines, Postgres adapter
P1: every repo, route, scheduler path and test now speaks one async DB interface (all/get/run/exec/transaction). better-sqlite3 stays underneath for dev/tests via SqliteDb (statement cache, manual BEGIN/SAVEPOINT nesting); openDb stays sync so fixtures remain one-liners. Zero behavior change: 346/346 tests, typecheck clean. P2: migrations-pg.ts — numbered Postgres migration set (001-init: bigint paise, integer flags, TEXT ISO dates, TEXT JSON; reminder CHECK born in final widened form) + schema_migrations runner. P3: db-pg.ts — pg Pool behind the same interface: ?->$n rewrite outside string literals, transactions pinned to one pooled client via AsyncLocalStorage with savepoint nesting, int8/numeric parsed to JS numbers so paise math is identical on both engines. Also: reminder upsert INSERT OR IGNORE -> ON CONFLICT DO NOTHING (portable, same changes-count semantics). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
08be48817e
commit
5f9f961d20
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,126 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||
import pg from 'pg'
|
||||
import type { DB } from './db'
|
||||
import { PG_MIGRATIONS } from './migrations-pg'
|
||||
|
||||
/**
|
||||
* Postgres engine behind the async DB interface (D19). Selected by DATABASE_URL.
|
||||
* Repos speak `?` placeholders and portable SQL; this adapter owns the dialect:
|
||||
* `?` → `$n`, transactions pinned to one pooled client via AsyncLocalStorage
|
||||
* (nested calls become savepoints), and bigint/numeric results parsed to JS
|
||||
* numbers so integer-paise math is identical on both engines.
|
||||
*/
|
||||
|
||||
// int8 (bigint paise, COUNT(*)) and numeric (SUM over bigint) arrive as strings by
|
||||
// default; paise totals sit far below 2^53, so Number is lossless here.
|
||||
pg.types.setTypeParser(20, Number)
|
||||
pg.types.setTypeParser(1700, Number)
|
||||
|
||||
/** Rewrite `?` placeholders to `$1..$n`, leaving single-quoted literals untouched. */
|
||||
export function toDollarParams(sql: string): string {
|
||||
let out = ''
|
||||
let n = 0
|
||||
let inQuote = false
|
||||
for (let i = 0; i < sql.length; i++) {
|
||||
const ch = sql[i]!
|
||||
if (inQuote) {
|
||||
out += ch
|
||||
if (ch === "'") {
|
||||
if (sql[i + 1] === "'") { out += "'"; i++ } else inQuote = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (ch === "'") { inQuote = true; out += ch; continue }
|
||||
out += ch === '?' ? `$${++n}` : ch
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
interface TxnStore { client: pg.PoolClient; depth: number }
|
||||
|
||||
export class PgDb implements DB {
|
||||
readonly engine = 'postgres' as const
|
||||
private als = new AsyncLocalStorage<TxnStore>()
|
||||
constructor(readonly pool: pg.Pool) {}
|
||||
|
||||
/** Queries inside a transaction ride its pinned client; otherwise the pool. */
|
||||
private q(): { query: (sql: string, args?: unknown[]) => Promise<pg.QueryResult> } {
|
||||
return this.als.getStore()?.client ?? this.pool
|
||||
}
|
||||
|
||||
async all<T>(sql: string, ...args: unknown[]): Promise<T[]> {
|
||||
return (await this.q().query(toDollarParams(sql), args)).rows as T[]
|
||||
}
|
||||
|
||||
async get<T>(sql: string, ...args: unknown[]): Promise<T | undefined> {
|
||||
return ((await this.q().query(toDollarParams(sql), args)).rows[0] ?? undefined) as T | undefined
|
||||
}
|
||||
|
||||
async run(sql: string, ...args: unknown[]): Promise<{ changes: number }> {
|
||||
const r = await this.q().query(toDollarParams(sql), args)
|
||||
return { changes: r.rowCount ?? 0 }
|
||||
}
|
||||
|
||||
async exec(sql: string): Promise<void> {
|
||||
await this.q().query(sql)
|
||||
}
|
||||
|
||||
async transaction<T>(fn: () => Promise<T> | T): Promise<T> {
|
||||
const store = this.als.getStore()
|
||||
if (store !== undefined) {
|
||||
// Nested: savepoint on the already-pinned client.
|
||||
const sp = `sp_d${++store.depth}`
|
||||
await store.client.query(`SAVEPOINT ${sp}`)
|
||||
try {
|
||||
const out = await fn()
|
||||
await store.client.query(`RELEASE SAVEPOINT ${sp}`)
|
||||
return out
|
||||
} catch (err) {
|
||||
await store.client.query(`ROLLBACK TO SAVEPOINT ${sp}`)
|
||||
await store.client.query(`RELEASE SAVEPOINT ${sp}`)
|
||||
throw err
|
||||
} finally {
|
||||
store.depth--
|
||||
}
|
||||
}
|
||||
const client = await this.pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
const out = await this.als.run({ client, depth: 0 }, () => Promise.resolve(fn()))
|
||||
await client.query('COMMIT')
|
||||
return out
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK')
|
||||
throw err
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end()
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply pending numbered migrations, each atomically, recorded in schema_migrations. */
|
||||
export async function runPgMigrations(db: PgDb): Promise<string[]> {
|
||||
await db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (id text PRIMARY KEY, applied_at text NOT NULL)`)
|
||||
const applied: string[] = []
|
||||
for (const m of PG_MIGRATIONS) {
|
||||
await db.transaction(async () => {
|
||||
const done = await db.get(`SELECT id FROM schema_migrations WHERE id=?`, m.id)
|
||||
if (done !== undefined) return
|
||||
await db.exec(m.sql)
|
||||
await db.run(`INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)`, m.id, new Date().toISOString())
|
||||
applied.push(m.id)
|
||||
})
|
||||
}
|
||||
return applied
|
||||
}
|
||||
|
||||
/** Open the Postgres engine: pool + migrations. Caller runs seedIfEmpty after. */
|
||||
export async function openPgDb(url: string): Promise<DB> {
|
||||
const db = new PgDb(new pg.Pool({ connectionString: url, max: 10 }))
|
||||
await runPgMigrations(db)
|
||||
return db
|
||||
}
|
||||
@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Postgres migration set (D19). Numbered, applied in order by db-pg.ts's runner,
|
||||
* each inside its own transaction, recorded in schema_migrations. Inline strings
|
||||
* (not .sql files) so esbuild bundles them into dist/server.cjs with no file I/O.
|
||||
*
|
||||
* Translation choices vs the SQLite SCHEMA (deliberate, recorded in the D19 spec):
|
||||
* - every *_paise / money column is BIGINT (int4 overflows at ₹2.15 crore in paise);
|
||||
* - 0/1 flags stay INTEGER — repos say `active=1` and that SQL must be identical on
|
||||
* both engines (boolean would break it);
|
||||
* - JSON payloads stay TEXT — node-pg auto-parses json/jsonb columns into objects,
|
||||
* which would break the repos' JSON.parse at the edge. A jsonb ALTER is a later,
|
||||
* pg-only optimisation if we ever index into payloads;
|
||||
* - dates stay TEXT ISO (house convention, index-friendly, no timezone surprises);
|
||||
* - the reminder.rule_kind CHECK is born in its final widened form (SQLite reaches
|
||||
* it via the guarded table rebuild; Postgres starts correct).
|
||||
*/
|
||||
|
||||
export interface PgMigration { id: string; sql: string }
|
||||
|
||||
export const PG_MIGRATIONS: PgMigration[] = [
|
||||
{
|
||||
id: '001-init',
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS staff_user (
|
||||
id text PRIMARY KEY, email text NOT NULL UNIQUE, display_name text NOT NULL,
|
||||
role text NOT NULL CHECK (role IN ('owner','manager','staff')),
|
||||
phone text, title text,
|
||||
pw_salt text NOT NULL, pw_hash text NOT NULL, active integer NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS session (
|
||||
token text PRIMARY KEY, staff_id text NOT NULL, expires_at text NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS client (
|
||||
id text PRIMARY KEY, code text NOT NULL UNIQUE, name text NOT NULL,
|
||||
gstin text, state_code text NOT NULL DEFAULT '32', address text NOT NULL DEFAULT '',
|
||||
contacts text NOT NULL DEFAULT '[]',
|
||||
status text NOT NULL DEFAULT 'active' CHECK (status IN ('lead','active','dormant','lost')),
|
||||
owner_id text,
|
||||
anydesk text, os text, district text, sector text, db_password_enc text,
|
||||
notes text NOT NULL DEFAULT '', source text NOT NULL DEFAULT 'hq', created_at text NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS module (
|
||||
id text PRIMARY KEY, code text NOT NULL UNIQUE, name text NOT NULL,
|
||||
sac text NOT NULL DEFAULT '998313',
|
||||
allowed_kinds text NOT NULL DEFAULT '["one_time","monthly","yearly","usage"]',
|
||||
multi_subscription integer NOT NULL DEFAULT 0, active integer NOT NULL DEFAULT 1,
|
||||
quote_content text NOT NULL DEFAULT '[]'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS module_price_book (
|
||||
id text PRIMARY KEY, module_id text NOT NULL, edition text NOT NULL DEFAULT 'standard',
|
||||
kind text NOT NULL, price_paise bigint NOT NULL, effective_from text NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS client_module (
|
||||
id text PRIMARY KEY, client_id text NOT NULL, module_id text NOT NULL,
|
||||
status text NOT NULL DEFAULT 'quoted' CHECK (status IN
|
||||
('quoted','ordered','installing','installed','trained','live','expired','cancelled')),
|
||||
kind text NOT NULL, edition text NOT NULL DEFAULT 'standard',
|
||||
installed_on text, completed_on text, trained_on text,
|
||||
next_renewal text, active integer NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tax_class (
|
||||
class_code text NOT NULL, rate_pct_bp integer NOT NULL,
|
||||
cess_pct_bp integer NOT NULL DEFAULT 0, effective_from text NOT NULL, effective_to text
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS doc_series (
|
||||
doc_type text NOT NULL, fy text NOT NULL, prefix text NOT NULL, next_seq integer NOT NULL,
|
||||
PRIMARY KEY (doc_type, fy)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS document (
|
||||
id text PRIMARY KEY, doc_type text NOT NULL CHECK (doc_type IN
|
||||
('QUOTATION','PROFORMA','INVOICE','RECEIPT','CREDIT_NOTE')),
|
||||
doc_no text, fy text NOT NULL, client_id text NOT NULL, doc_date text NOT NULL,
|
||||
due_date text,
|
||||
status text NOT NULL DEFAULT 'draft' CHECK (status IN
|
||||
('draft','sent','accepted','invoiced','part_paid','paid','lost','cancelled')),
|
||||
ref_doc_id text,
|
||||
taxable_paise bigint NOT NULL, cgst_paise bigint NOT NULL, sgst_paise bigint NOT NULL,
|
||||
igst_paise bigint NOT NULL, round_off_paise bigint NOT NULL, payable_paise bigint NOT NULL,
|
||||
payload text NOT NULL,
|
||||
source text NOT NULL DEFAULT 'hq',
|
||||
created_by text NOT NULL, created_at text NOT NULL,
|
||||
UNIQUE (doc_no)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS document_event (
|
||||
id text PRIMARY KEY, document_id text NOT NULL, at_wall text NOT NULL,
|
||||
kind text NOT NULL, meta text NOT NULL DEFAULT '{}'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS document_share (
|
||||
id text PRIMARY KEY, document_id text NOT NULL, token text NOT NULL UNIQUE,
|
||||
created_by text NOT NULL, created_at text NOT NULL,
|
||||
expires_at text,
|
||||
revoked integer NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS payment (
|
||||
id text PRIMARY KEY, client_id text NOT NULL, received_on text NOT NULL,
|
||||
mode text NOT NULL CHECK (mode IN ('bank','upi','cheque','cash','other')),
|
||||
reference text NOT NULL DEFAULT '', amount_paise bigint NOT NULL,
|
||||
tds_paise bigint NOT NULL DEFAULT 0, created_by text NOT NULL, created_at text NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS payment_allocation (
|
||||
id text PRIMARY KEY, payment_id text NOT NULL, document_id text NOT NULL,
|
||||
amount_paise bigint NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS email_account (
|
||||
id text PRIMARY KEY, address text NOT NULL, refresh_token_enc text NOT NULL,
|
||||
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','dead')), updated_at text NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS email_log (
|
||||
id text PRIMARY KEY, document_id text, to_addr text NOT NULL, subject text NOT NULL,
|
||||
status text NOT NULL CHECK (status IN ('sent','failed')), gmail_message_id text,
|
||||
error text, at_wall text NOT NULL,
|
||||
bounced integer NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS setting (
|
||||
key text PRIMARY KEY, value text NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id text PRIMARY KEY, at_wall text NOT NULL, user_id text NOT NULL, action text NOT NULL,
|
||||
entity text NOT NULL, entity_id text NOT NULL, before_json text, after_json text
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS stg_client (
|
||||
row_no integer PRIMARY KEY, code text, name text, gstin text, state_code text,
|
||||
address text, phone text, email text, status text,
|
||||
anydesk text, os text, district text, sector text,
|
||||
problems text NOT NULL DEFAULT '[]'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS stg_invoice (
|
||||
row_no integer PRIMARY KEY, client_code text, doc_no text, doc_date text,
|
||||
taxable_paise bigint, tax_paise bigint, total_paise bigint, paid integer,
|
||||
problems text NOT NULL DEFAULT '[]'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS recurring_plan (
|
||||
id text PRIMARY KEY, client_id text NOT NULL,
|
||||
client_module_id text,
|
||||
cadence text NOT NULL CHECK (cadence IN ('monthly','yearly')),
|
||||
amount_paise bigint,
|
||||
next_run text NOT NULL,
|
||||
policy text NOT NULL DEFAULT 'manual' CHECK (policy IN ('auto','manual')),
|
||||
active integer NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS amc_contract (
|
||||
id text PRIMARY KEY, client_id text NOT NULL, coverage text NOT NULL DEFAULT '',
|
||||
period_from text NOT NULL, period_to text NOT NULL, amount_paise bigint NOT NULL,
|
||||
renewal_reminder_days integer NOT NULL DEFAULT 30,
|
||||
legacy_paid integer,
|
||||
invoice_doc_id text, active integer NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS interaction_type (
|
||||
code text PRIMARY KEY, label text NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS interaction (
|
||||
id text PRIMARY KEY, client_id text NOT NULL, type_code text NOT NULL,
|
||||
on_date text NOT NULL, staff_id text NOT NULL, notes text NOT NULL DEFAULT '',
|
||||
outcome text CHECK (outcome IN ('positive','neutral','negative')),
|
||||
follow_up_on text, created_at text NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS reminder (
|
||||
id text PRIMARY KEY,
|
||||
rule_kind text NOT NULL CHECK (rule_kind IN
|
||||
('invoice_overdue','renewal_due','amc_expiring','follow_up','recurring_generated','email_bounced','quote_followup')),
|
||||
subject_id text NOT NULL, due_period text NOT NULL, client_id text NOT NULL,
|
||||
doc_id text,
|
||||
status text NOT NULL DEFAULT 'queued' CHECK (status IN ('queued','sent','failed','dismissed')),
|
||||
policy_applied text NOT NULL DEFAULT 'manual' CHECK (policy_applied IN ('auto','manual')),
|
||||
error text, created_at text NOT NULL, sent_at text,
|
||||
UNIQUE (rule_kind, subject_id, due_period)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS reminder_schedule (
|
||||
id text PRIMARY KEY, rule_kind text NOT NULL,
|
||||
effective_from text NOT NULL, effective_to text,
|
||||
day_offsets text NOT NULL,
|
||||
subject text, body text
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS aws_usage (
|
||||
id text PRIMARY KEY, client_id text NOT NULL,
|
||||
month text NOT NULL,
|
||||
storage_gb double precision NOT NULL DEFAULT 0,
|
||||
transfer_gb double precision NOT NULL DEFAULT 0,
|
||||
cost_paise bigint NOT NULL DEFAULT 0,
|
||||
source text NOT NULL DEFAULT 'auto' CHECK (source IN ('auto','manual')),
|
||||
updated_at text NOT NULL,
|
||||
UNIQUE (client_id, month)
|
||||
);
|
||||
`,
|
||||
},
|
||||
]
|
||||
Loading…
Reference in New Issue