refactor(db): swap the query builder from Drizzle to Kysely (#305)
One commit, because a main that carries both builders is one where the next person converting a query has to guess which to reach for, and where two generated mirrors of one database can disagree. There was nothing to stage anyway: one file used the builder. The safety property that motivated adopting a builder at all is untouched, and was never the thing being traded. A value interpolated into a sql template becomes a bind parameter in either library, so #202's invariant stays a property of the type system and #180's hotspots retire either way. What changes is the three ways the old library made it easy to be quietly wrong, each verified in #297 against the SQL actually emitted: an array interpolating as a placeholder list unless every site remembered sql.param(), a column reference inside a raw fragment silently losing its table so a correlated subquery correlated with itself, and a camelCase mirror that had to be mapped back at every select or the JSON contract changed with no test noticing. CATEGORY_COLUMNS stops being a translation layer and becomes what it looks like — four column names four selects share. The generated types carry parent_id and sort_order because kysely-codegen emits the database's own names, so there is nothing left to map and nothing left to get wrong by forgetting to. The drift guard survives the swap rather than being rewritten, and loses its library name in the process: it is schemaMirror.integration.test.ts now, so the next such change renames nothing. It also got stricter for free. The Drizzle version had to match each column two ways and its own comment called that deliberately loose; a generated Kysely interface spells the database's name verbatim as a bare key, so one exact match is the whole rule and snakeToCamel is gone. isUniqueViolation keeps accepting both error shapes and now has a test behind it. Kysely uses the pg driver directly and should leave the SQLSTATE on err.code, but "should" is the word that turned two 409s into 500s when the last conversion moved it to err.cause.code with nothing failing to compile. Migrations are untouched. #219 stands, they remain hand-written node-pg-migrate files, and Kysely has no generator to refuse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -158,6 +158,20 @@ describe('admin categories', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.category_id).toBeNull();
|
||||
});
|
||||
|
||||
// The conversion hazard from #218, asserted rather than assumed. Drizzle
|
||||
// wrapped driver errors and moved the unique-violation SQLSTATE from
|
||||
// err.code to err.cause.code, which turned this 409 into a 500 with nothing
|
||||
// failing to compile. #305 changed builder again, so this proves where the
|
||||
// code actually lands rather than trusting that Kysely leaves it alone.
|
||||
it('refuses a duplicate sibling name with 409, not 500', async () => {
|
||||
await createCategory('Duplicate me');
|
||||
|
||||
const res = await request(app).post('/api/admin/categories').send({ name: 'Duplicate me' });
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toMatch(/already exists/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin tags', () => {
|
||||
|
||||
+25
-17
@@ -9,13 +9,22 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
const SCHEMA = readFileSync(
|
||||
path.join(__dirname, '..', '..', 'src', 'db-drizzle', 'schema.ts'),
|
||||
path.join(__dirname, '..', '..', 'src', 'db-kysely', 'schema.ts'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
/** Every table name the generated mirror declares. */
|
||||
/**
|
||||
* Every table name the generated mirror declares.
|
||||
*
|
||||
* Read from the `DB` interface, which is the one place kysely-codegen lists
|
||||
* them all, mapping the database's own snake_case name to the interface for
|
||||
* that table. Parsing the file as text rather than importing it is deliberate
|
||||
* and unchanged from the Drizzle version: these are TypeScript types, erased at
|
||||
* run time, so there is nothing to import and inspect.
|
||||
*/
|
||||
function mirroredTables(): string[] {
|
||||
return [...SCHEMA.matchAll(/pgTable\("([a-z_]+)"/g)].map((m) => m[1]!).sort();
|
||||
const block = /export interface DB \{([^}]*)\}/.exec(SCHEMA)?.[1] ?? '';
|
||||
return [...block.matchAll(/^\s*([a-z_]+):/gm)].map((m) => m[1]!).sort();
|
||||
}
|
||||
|
||||
async function liveTables(): Promise<string[]> {
|
||||
@@ -43,7 +52,7 @@ async function liveTables(): Promise<string[]> {
|
||||
* 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', () => {
|
||||
describe('the generated schema mirror', () => {
|
||||
it('declares every table the migrations create', async () => {
|
||||
const live = await liveTables();
|
||||
const mirrored = mirroredTables();
|
||||
@@ -60,8 +69,9 @@ describe('the Drizzle schema mirror', () => {
|
||||
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.
|
||||
// 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');
|
||||
});
|
||||
@@ -69,6 +79,11 @@ describe('the Drizzle schema mirror', () => {
|
||||
// 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, where the Drizzle version needed two and called itself
|
||||
// "deliberately loose" for it. kysely-codegen emits the database's own name
|
||||
// as a bare interface key, so ` column_name:` at the start of a line 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
|
||||
@@ -76,20 +91,13 @@ describe('the Drizzle schema mirror', () => {
|
||||
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 declared = new Set(
|
||||
[...SCHEMA.matchAll(/^\s{2}([a-z0-9_]+):/gm)].map((m) => m[1]!)
|
||||
);
|
||||
const missing = rows
|
||||
.filter((row) => !SCHEMA.includes(`"${row.column_name}"`))
|
||||
.filter((row) => !SCHEMA.includes(snakeToCamel(row.column_name)))
|
||||
.filter((row) => !declared.has(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());
|
||||
}
|
||||
Reference in New Issue
Block a user