The eslint config's ignores list and comment still named the deleted src/db-drizzle/schema.ts and relations.ts and never named src/db-kysely/schema.ts, so the generated mirror was being linted for the first time and tripping sonarjs/redundant-type-aliases — exactly the trap the config's own comment already described from #261 and #217. The ignores list now names src/db-kysely/schema.ts and the comment is updated to match. The schema mirror drift test built one flat Set of every two-space-indented key in the whole generated file and asked only whether a live column name appeared anywhere in it, rather than checking it against the specific table it belongs to. Seventeen column names are declared on two or more tables and created_at is on fourteen of eighteen, so a migration adding created_at, updated_at, status, name, sort_order, token, or expires_at to a table that lacks it would pass vacuously. Replaced mirroredTables with mirroredColumns, which reads the DB interface to map each table name to its declaring interface and then reads that interface's own columns, and changed the column-mirroring test to look up columns per table. Verified the guard can actually fail: removing customer_id from the Carts interface made the test fail naming carts.customer_id exactly, and restoring the file made it pass again. The root .gitignore still carried a comment block and two patterns for drizzle-kit pull output under backend/src/db-drizzle, a directory this branch deleted along with backend/drizzle.config.ts. kysely-codegen writes only the single tracked file it's pointed at, so nothing replaces the rule — deleted the block and both patterns. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
126 lines
4.9 KiB
TypeScript
126 lines
4.9 KiB
TypeScript
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<string, Set<string>> {
|
|
const block = /export interface DB \{([^}]*)\}/.exec(SCHEMA)?.[1] ?? '';
|
|
const byTable = new Map<string, Set<string>>();
|
|
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<string[]> {
|
|
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([]);
|
|
});
|
|
});
|