import postgres from "npm:postgres@3"; /** * Adaptador delgado sobre postgres.js que imita la forma * `db.prepare(sql).get/all/run(...params)` que tenía el driver SQLite * (@db/sqlite), para poder migrar ~80 endpoints sin reescribir cada query * a mano en el mismo cambio que separa las bases/esquemas. Sigue siendo * 100% parametrizado (nunca concatena valores en el texto SQL) -- lo único * que cambia es la forma superficial de la llamada, no la seguridad. * * Traduce automáticamente placeholders `?` (estilo SQLite) a `$1, $2, ...` * (estilo Postgres). Todo el acceso es async (a diferencia de better-sqlite3 * style), así que cada call-site necesita `await`. */ export type PgRow = Record; function toPositional(text: string): string { let i = 0; return text.replace(/\?/g, () => `$${++i}`); } export class PreparedStatement { constructor(private sql: postgres.Sql, private text: string) {} async get(...params: unknown[]): Promise { const rows = await this.sql.unsafe(toPositional(this.text), params as never[]); return rows[0] as PgRow | undefined; } async all(...params: unknown[]): Promise { const rows = await this.sql.unsafe(toPositional(this.text), params as never[]); return rows as unknown as PgRow[]; } /** Igual que .all/.get pero no espera filas de vuelta (INSERT/UPDATE/DELETE). */ async run(...params: unknown[]): Promise<{ changes: number }> { const rows = await this.sql.unsafe(toPositional(this.text), params as never[]); return { changes: rows.count ?? rows.length }; } } /** Wrapper con la forma db.prepare()/db.exec() de @db/sqlite, sobre una * conexión (o conexión reservada) de postgres.js. */ export class PgDb { constructor(public raw: postgres.Sql) {} prepare(text: string): PreparedStatement { return new PreparedStatement(this.raw, text); } async exec(text: string): Promise { await this.raw.unsafe(text); } /** Equivalente a last_insert_rowid(): dentro de la MISMA conexión/ * transacción, lastval() devuelve el último valor de secuencia obtenido * en esta sesión. Solo es seguro si se llama justo después del INSERT * correspondiente, en la misma conexión reservada por request. */ async lastInsertId(): Promise { const rows = await this.raw.unsafe("SELECT lastval()::bigint AS id"); return Number((rows[0] as unknown as { id: number | bigint }).id); } } export function createPool(url: string, options: postgres.Options> = {}) { return postgres(url, { max: 10, idle_timeout: 30, connect_timeout: 10, types: { // postgres.js devuelve NUMERIC como string por defecto (evita perder // precisión en valores gigantes, tipo BigDecimal). Aquí los montos // son jornales/presupuestos -- ya vivían como REAL/float en SQLite, // así que parseFloat no introduce una regresión de precisión nueva, // y evita tener que tocar cada call-site que hace aritmética sobre // columnas NUMERIC (daily_wage, gross, balance, amount, etc.). numeric: { to: 1700, from: [1700], serialize: (x: number) => String(x), parse: (x: string) => Number.parseFloat(x), }, // postgres.js devuelve date/timestamp(tz) como objetos Date por // defecto. Todo el código heredado de SQLite trata fechas como // strings (comparaciones lexicográficas, .slice(0,10), template // literals) -- se fuerzan a texto aquí para no reescribir cada // call-site de fechas en el mismo cambio que separa las bases. date: { to: 1082, from: [1082], serialize: (x: string) => x, parse: (x: string) => x }, timestamp: { to: 1114, from: [1114], serialize: (x: string) => x, parse: (x: string) => x }, timestamptz: { to: 1184, from: [1184], serialize: (x: string) => x, parse: (x: string) => x }, // Todas las PK son BIGINT GENERATED ALWAYS AS IDENTITY (pensando en // escala futura), pero postgres.js devuelve bigint como string por // defecto (evita perder precisión más allá de Number.MAX_SAFE_INTEGER). // A esta escala de negocio los ids nunca se acercan a ese límite, y // el código heredado los trata como number en todas partes (Map, // comparaciones ===, etc.), así que se parsean a Number aquí. bigint: { to: 20, from: [20], serialize: (x: number) => String(x), parse: (x: string) => Number(x), }, }, ...options, }); } /** * Ejecuta `fn` dentro de una transacción con `app.tenant_id` fijado vía * set_config(..., is_local=true) -- así las políticas de Row Level Security * (db/iam/changesets/002-rls.sql, db/core/changesets/005-rls.sql) filtran * automáticamente por tenant, y el valor se limpia solo al terminar la * transacción sin importar qué conexión del pool se reutilice después. * * tenantId = null significa "sin tenant" (p. ej. platform_admin o la * API key legacy): las políticas de RLS son fail-closed, así que sin * tenant_id fijado NO se ve ninguna fila con tenant_id NOT NULL. Las * rutas que de verdad necesitan cruzar tenants deben pasar por el rol de * soporte explícito (ver Fase 4/nota de seguridad sobre el bypass de * X-API-Key), no por dejar tenantId en null "por si acaso". */ export async function withTenant( sql: postgres.Sql, tenantId: number | null, fn: (scoped: PgDb) => Promise, ): Promise { const result = await sql.begin(async (tx) => { if (tenantId != null) { await tx`SELECT set_config('app.tenant_id', ${String(tenantId)}, true)`; } return [await fn(new PgDb(tx as unknown as postgres.Sql))] as const; }); return (result as unknown as [T])[0]; } export type { postgres }; export default postgres;