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-drizzle', 'schema.ts'), 'utf8' ); /** Every table name the generated mirror declares. */ function mirroredTables(): string[] { return [...SCHEMA.matchAll(/pgTable\("([a-z_]+)"/g)].map((m) => m[1]!).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-drizzle/schema.ts` is generated by `drizzle-kit pull` and is a * read-only mirror of the real schema, which `backend/migrations` owns. Nothing * makes anyone re-pull 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. Drizzle infers row types 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. */ describe('the Drizzle 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 tablesFilter in // drizzle.config.ts. A re-pull without that filter 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. 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` ); // The mirror names a column either as a bare camelCase key or, when the // database name differs, as an explicit string argument. Checking the // snake_case name appears anywhere in the file is deliberately loose: it // catches a column the mirror has never heard of, which is the failure that // matters, without re-implementing drizzle-kit's naming rules. const missing = rows .filter((row) => !SCHEMA.includes(`"${row.column_name}"`)) .filter((row) => !SCHEMA.includes(snakeToCamel(row.column_name))) .map((row) => `${row.table_name}.${row.column_name}`); expect(missing).toEqual([]); }); }); function snakeToCamel(name: string): string { return name.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()); }