import { readFileSync } from 'fs'; import path from 'path'; import { pool } from '../../src/db'; import { closeDb } from './setup/testDb'; afterAll(async () => { await pool.end(); await closeDb(); }); const SCHEMA = readFileSync( path.join(__dirname, '..', '..', 'src', 'db-kysely', 'schema.ts'), 'utf8' ); /** * Every column the mirror declares, keyed by the database's own table name. * * The `DB` interface maps a table name to the interface that declares that * table's columns, so the two have to be read together. A flat set of every * column name appearing anywhere in the file is not the same assertion and is * far weaker than it looks: seventeen column names are declared on more than * one table and `created_at` is on fourteen of the eighteen, so a migration * adding one of those to a table that lacks it would pass without the mirror * knowing anything about it. * * Parsed as text rather than imported, because these are TypeScript types and * are erased at run time — there is nothing to import and inspect. */ function mirroredColumns(): Map> { const block = /export interface DB \{([^}]*)\}/.exec(SCHEMA)?.[1] ?? ''; const byTable = new Map>(); for (const [, table, iface] of block.matchAll(/^\s*([a-z0-9_]+):\s*([A-Za-z0-9_]+);/gm)) { const declaration = new RegExp(`export interface ${iface!} \\{([^}]*)\\}`).exec(SCHEMA)?.[1] ?? ''; byTable.set( table!, new Set([...declaration.matchAll(/^\s{2}([a-z0-9_]+):/gm)].map((m) => m[1]!)) ); } return byTable; } /** Every table name the generated mirror declares. */ function mirroredTables(): string[] { return [...mirroredColumns().keys()].sort(); } async function liveTables(): Promise { const { rows } = await pool.query<{ table_name: string }>( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE' AND table_name <> 'pgmigrations' ORDER BY table_name` ); return rows.map((r) => r.table_name); } /** * The guard for #217. * * `src/db-kysely/schema.ts` is generated by `npm run db:types` and is a * read-only mirror of the real schema, which `backend/migrations` owns. Nothing * makes anyone regenerate after writing a migration, and that is not * hypothetical: the mirror sat missing `item_drafts` and `upload_links` from * the moment #222 landed until #217, because it had been copied into src/ by * hand and nobody had reason to look at it. * * A stale mirror is worse than no mirror. Row types are inferred from it, so a * converted query would type-check against a schema the database does not have * and fail at run time with a column that does not exist — the exact class of * drift the adoption was meant to close. * * The generator changed in #305 and this test did not, because the drift it * guards is a property of generating a mirror at all rather than of any * library. */ describe('the generated schema mirror', () => { it('declares every table the migrations create', async () => { const live = await liveTables(); const mirrored = mirroredTables(); const missing = live.filter((name) => !mirrored.includes(name)); expect(missing).toEqual([]); }); it('declares no table the database does not have', async () => { const live = await liveTables(); const mirrored = mirroredTables(); const extra = mirrored.filter((name) => !live.includes(name)); expect(extra).toEqual([]); }); // pgmigrations is node-pg-migrate's bookkeeping, excluded by // --exclude-pattern in the db:types script. A regeneration without that flag // would quietly put it back. it('excludes node-pg-migrate bookkeeping', () => { expect(mirroredTables()).not.toContain('pgmigrations'); }); // Columns, not just tables: a migration that adds a column to a table the // mirror already knows about is the likelier drift, and the one a table-level // check would wave through. // // One exact match per table, where the Drizzle version needed two matches and // no table at all, and called itself "deliberately loose" for it. // kysely-codegen emits the database's own name as a bare interface key and // the DB interface says which interface belongs to which table, so the pair // is the whole of the naming rule and there is nothing to re-implement. it('declares every column of every table it mirrors', async () => { const { rows } = await pool.query<{ table_name: string; column_name: string }>( `SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name <> 'pgmigrations' ORDER BY table_name, column_name` ); const columns = mirroredColumns(); const missing = rows .filter((row) => !columns.get(row.table_name)?.has(row.column_name)) .map((row) => `${row.table_name}.${row.column_name}`); expect(missing).toEqual([]); }); });