/** * Generates the Kysely database type from the migrated schema. * * Repositories that need a statement the MikroORM QueryBuilder cannot express * go through `this.kysely<...>()`, and Kysely types every table and column it * touches from the type parameter. Those parameters used to be interfaces * written by hand in each repository, so a migration that renamed a column or * made it nullable left them stale while `tsc` stayed green. This script * derives them instead, the same way `generate-entities.ts` derives the * entities: it migrates a throwaway SQLite file with the real migrations, * reads every table's columns back and writes * * src/db/kysely/.table.ts one interface per table * src/db/kysely/db.ts `DB`, the map from table name to interface * * A repository narrows it with `Pick`. * * The column types describe what the driver hands back, which is the storage * model, not the domain one: an INTEGER flag is `number`, a timestamp is the * `string` it is stored as. A column an insert may leave out (an integer * primary key, anything with a DEFAULT, anything nullable) is * `InsertOptional`, declared in the generated `columns.ts`. * * Usage: * node --import tsx scripts/generate-db-types.ts # rewrite src/db/kysely/ * node --import tsx scripts/generate-db-types.ts --check # exit 1 when src/db/kysely/ drifted (CI) */ import fs from 'node:fs'; import path from 'node:path'; import Database from 'better-sqlite3'; import { migrateTempDb, tempDbPath } from './generate-entities'; const SERVER_ROOT = path.join(__dirname, '..'); export const KYSELY_DIR = path.join(SERVER_ROOT, 'src/db/kysely'); /** Tables the application never queries: the migrator's own bookkeeping. */ const SKIPPED_TABLES: ReadonlySet = new Set(['mikro_orm_migrations']); const HEADER = '// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:\n' + '// change the migration, then run `npm run gen:db-types`.\n'; export interface ColumnInfo { name: string; /** The declared type as `PRAGMA table_xinfo` reports it (may be empty). */ type: string; notnull: boolean; hasDefault: boolean; /** Position in the primary key, 0 when the column is not part of it. */ pk: number; /** 0 for an ordinary column, 2 or 3 for a generated (computed) one. */ hidden: number; } export interface TableInfo { name: string; columns: ColumnInfo[]; } /** * The TypeScript type a value of this declared type comes back as from * better-sqlite3, following SQLite's affinity rules (sqlite.org/datatype3.html * section 3.1). Date-like declarations have NUMERIC affinity on paper, but * every one of them in this schema holds ISO text, so they read as strings. */ export function tsTypeFor(declared: string): string { const t = declared.toUpperCase(); if (t.includes('INT')) return 'number'; if (t.includes('CHAR') || t.includes('CLOB') || t.includes('TEXT')) return 'string'; if (t.includes('BLOB')) return 'Buffer'; if (t.includes('REAL') || t.includes('FLOA') || t.includes('DOUB')) return 'number'; if (t.includes('DATE') || t.includes('TIME')) return 'string'; if (t.includes('BOOL') || t.includes('NUMERIC') || t.includes('DECIMAL')) return 'number'; if (t === '') return 'unknown'; throw new Error(`generate-db-types: no TypeScript mapping for the declared column type "${declared}"`); } /** `budget_items` -> `BudgetItems`. */ export function pascalCase(table: string): string { return table .split('_') .filter((part) => part !== '') .map((part) => part[0].toUpperCase() + part.slice(1)) .join(''); } /** The interface name for a table: `BudgetItemsTable`. */ export function interfaceName(table: string): string { return `${pascalCase(table)}Table`; } function columnType(table: TableInfo, column: ColumnInfo): string { // A lone INTEGER primary key aliases the rowid: it is never null and the // database assigns it when the insert leaves it out. const pkColumns = table.columns.filter((c) => c.pk > 0); const isRowid = column.pk > 0 && pkColumns.length === 1 && column.type.toUpperCase() === 'INTEGER'; const base = tsTypeFor(column.type); const nullable = !column.notnull && column.pk === 0; const value = nullable ? `${base} | null` : base; if (column.hidden === 2 || column.hidden === 3) return `GeneratedAlways<${value}>`; if (isRowid || column.hasDefault || nullable) return `InsertOptional<${value}>`; return value; } const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; function propertyKey(name: string): string { return IDENTIFIER.test(name) ? name : `'${name.replace(/'/g, "\\'")}'`; } /** The source of `
.table.ts`. */ export function renderTable(table: TableInfo): string { const types = table.columns.map((c) => columnType(table, c)); const has = (marker: string) => types.some((t) => t.startsWith(`${marker}<`)); // Laid out the way the server's Prettier import sorting leaves them, so the // output passes lint:format as written: the relative import first, one blank // line between the groups, and the header comment on the first import with // no blank line after it. A file without imports keeps its blank line. const groups = [ has('InsertOptional') ? "import type { InsertOptional } from './columns';\n" : '', has('GeneratedAlways') ? "import type { GeneratedAlways } from 'kysely';\n" : '', ].filter((group) => group !== ''); const importBlock = groups.length === 0 ? '\n' : `${groups.join('\n')}\n`; const body = table.columns.map((c, i) => ` ${propertyKey(c.name)}: ${types[i]};`).join('\n'); return `${HEADER}${importBlock}export interface ${interfaceName(table.name)} {\n${body}\n}\n`; } /** * The source of `columns.ts`. Kysely makes a column optional on insert when its * insert type can be null or undefined, which it tests with `NonNullable`. The * server compiles without `strictNullChecks`, where `T | undefined` and * `T | null` collapse to `T`, so Kysely's own `Generated` would still demand * the column. `void` survives that collapse and still refuses any other value, * so the insert stays typed. */ export function renderColumns(): string { return ( `${HEADER}import type { ColumnType } from 'kysely';\n\n` + '/** A column an insert may leave out: the database assigns it (rowid, DEFAULT) or stores NULL. */\n' + 'export type InsertOptional = ColumnType;\n' ); } /** The source of `db.ts`. */ export function renderDb(tables: readonly TableInfo[]): string { const imports = tables .map((t) => `import type { ${interfaceName(t.name)} } from './${pascalCase(t.name)}.table';`) .join('\n'); const body = tables.map((t) => ` ${propertyKey(t.name)}: ${interfaceName(t.name)};`).join('\n'); return ( `${HEADER}${imports}\n\n` + '/** Every table of the migrated schema. A repository narrows it: `this.kysely>()`. */\n' + `export interface DB {\n${body}\n}\n` ); } /** Reads every application table of a migrated database, sorted by name, columns in declaration order. */ export function readSchema(dbPath: string): TableInfo[] { const conn = new Database(dbPath, { readonly: true }); try { const names = conn .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name") .all() as { name: string }[]; return names .filter(({ name }) => !SKIPPED_TABLES.has(name)) .map(({ name }) => { const rows = conn.prepare(`PRAGMA table_xinfo("${name.replace(/"/g, '""')}")`).all() as { name: string; type: string; notnull: number; dflt_value: string | null; pk: number; hidden: number; }[]; return { name, columns: rows .filter((r) => r.hidden !== 1) .map((r) => ({ name: r.name, type: r.type, notnull: r.notnull === 1, hasDefault: r.dflt_value !== null, pk: r.pk, hidden: r.hidden, })), }; }); } finally { conn.close(); } } /** File name -> content for everything under src/db/kysely/. */ export function renderAll(tables: readonly TableInfo[]): Map { if (tables.length === 0) { throw new Error('generate-db-types: the migrated schema has no tables; check the migrations path'); } const files = new Map(); for (const table of tables) files.set(`${pascalCase(table.name)}.table.ts`, renderTable(table)); files.set('columns.ts', renderColumns()); files.set('db.ts', renderDb(tables)); return files; } export async function generateDbTypes(): Promise> { const dbPath = tempDbPath(); try { await migrateTempDb(dbPath); return renderAll(readSchema(dbPath)); } finally { fs.rmSync(dbPath, { force: true }); } } /** Every file that differs from, is missing from or is extra in `dir`. Read-only. */ export function checkDbTypes(dir: string, files: ReadonlyMap): string[] { const drift: string[] = []; for (const [fileName, content] of files) { const onDisk = path.join(dir, fileName); const current = fs.existsSync(onDisk) ? fs.readFileSync(onDisk, 'utf8').replace(/\r\n/g, '\n') : undefined; if (current !== content) drift.push(fileName); } const onDiskFiles = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith('.ts')) : []; for (const fileName of onDiskFiles) { if (!files.has(fileName)) drift.push(`${fileName} (not produced by the generator)`); } return drift.sort(); } /** Replaces the contents of `dir` with `files`, removing any .ts file the generator no longer produces. */ export function writeDbTypes(dir: string, files: ReadonlyMap): void { fs.mkdirSync(dir, { recursive: true }); for (const fileName of fs.readdirSync(dir)) { if (fileName.endsWith('.ts') && !files.has(fileName)) fs.rmSync(path.join(dir, fileName)); } for (const [fileName, content] of files) fs.writeFileSync(path.join(dir, fileName), content); } async function main(): Promise { const check = process.argv.slice(2).includes('--check'); const files = await generateDbTypes(); if (check) { const drift = checkDbTypes(KYSELY_DIR, files); for (const fileName of drift) console.error(`[generate-db-types] DRIFT: src/db/kysely/${fileName}`); if (drift.length > 0) { console.error('[generate-db-types] --check failed. Run: npm run gen:db-types'); process.exit(1); } console.log(`[generate-db-types] --check: ${files.size} files match the migrated schema`); return; } writeDbTypes(KYSELY_DIR, files); console.log(`[generate-db-types] wrote ${files.size} files to src/db/kysely/`); } if (require.main === module) { main().catch((err: unknown) => { console.error('[generate-db-types] failed:', err); process.exit(1); }); }