Files
redefined-designs/backend/tests/integration/drizzleSchema.integration.test.ts
T
bermudalambandClaude Opus 5 8e6f11111b feat(db): land the Drizzle schema, config and conventions (#217)
Infrastructure only. No route is converted, nothing changes at run time.

The mirror had already drifted, which settles how it should be maintained. schema.ts was missing item_drafts and upload_links from the moment #222 landed, because the spike pulled into ./drizzle and copied the file into src/ by hand, and nobody had reason to look at the copy for a week. So `out` now points at src/db-drizzle and pull refreshes in place — the copy step that made the drift possible is gone — and tablesFilter excludes pgmigrations, which is node-pg-migrate's bookkeeping and has no business in a model of the application's schema.

A stale mirror is worse than no mirror, because Drizzle infers row types from it: a converted query would type-check against a schema the database does not have and fail at run time on a column that does not exist. drizzleSchema.integration.test.ts fails when the two disagree, on tables and on columns. It was checked by removing item_drafts from the mirror and confirming the test fails naming it, rather than trusting a green run on a file that already matched.

pull also emits 0000_*.sql and meta/ into `out`, because that directory serves both purposes. Both are gitignored: this project's migration history is backend/migrations, hand-written and mostly prose, and #219 has not chosen otherwise — a stray SQL file in src/ is at best noise and at worst mistaken for real history.

db is exported beside pool and shares its connections. Both must work at once, since conversion is file by file across 187 sites; separate pools would make a transaction on one invisible to the other and silently double the configured limits.

The generated files are excluded from linting. #261 hand-fixed an unused-parameter warning in schema.ts and this re-pull put it straight back, which is the argument in one line: linting generated code buys a fix the next regeneration undoes. itemFilters.drizzle.ts, which is hand-written, is still linted.

CONVENTIONS.md records the sql.param() array trap before anyone hits it — the wrong form type-checks, reads correctly and fails at run time as invalid Postgres — and the reason the adoption is worth doing at all, which is that ${value} emits a bind parameter and there is no way to spell "interpolate this as SQL" by accident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 08:56:20 -05:00

96 lines
3.6 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-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<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-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());
}