mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
5f17a5df50
The repositories typed their Kysely statements against 159 table interfaces written by hand, one or more per repository. Nothing compared them with the schema: a migration that renamed a column or changed its type left them stale while tsc stayed green, and several had already drifted (reservations.accommodation_id is TEXT, not INTEGER). scripts/generate-db-types.ts migrates a throwaway database with the real migrations, reads every table back and writes one interface per table into src/db/kysely/ plus the DB map in db.ts. Every hand-written interface is now Pick<DB, ...> of that type, and npm run check:db-types fails when the committed files no longer match the migrations. A column an insert may leave out (rowid, DEFAULT, nullable) is InsertOptional<T>, declared as ColumnType<T, T | void, T>. The server compiles without strictNullChecks, where Kysely's own Generated<T> still demands the column; that is why the old code kept separate insert-only interfaces. The ids callers pass as number | string are cast to the column type at the bind, so every statement binds the same value as before.
260 lines
10 KiB
TypeScript
260 lines
10 KiB
TypeScript
/**
|
|
* 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>.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<DB, 'trips' | 'days'>`.
|
|
*
|
|
* 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<T>`, 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<string> = 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>.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}<`));
|
|
const imports =
|
|
(has('GeneratedAlways') ? "import type { GeneratedAlways } from 'kysely';\n" : '') +
|
|
(has('InsertOptional') ? "import type { InsertOptional } from './columns';\n" : '');
|
|
const importBlock = imports === '' ? '' : `${imports}\n`;
|
|
const body = table.columns.map((c, i) => ` ${propertyKey(c.name)}: ${types[i]};`).join('\n');
|
|
return `${HEADER}\n${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<T>` 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}\nimport 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<T> = ColumnType<T, T | void, T>;\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}\n${imports}\n\n` +
|
|
'/** Every table of the migrated schema. A repository narrows it: `this.kysely<Pick<DB, \'trips\'>>()`. */\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<string, string> {
|
|
if (tables.length === 0) {
|
|
throw new Error('generate-db-types: the migrated schema has no tables; check the migrations path');
|
|
}
|
|
const files = new Map<string, string>();
|
|
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<Map<string, string>> {
|
|
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, string>): 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<string, string>): 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<void> {
|
|
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);
|
|
});
|
|
}
|