Files
TREK/server/scripts/generate-db-types.ts
T
Maurice 1ea435d730 ci: server and shared lint stop rewriting files, and Prettier is checked per file
shared's lint ran eslint --fix with prettier/prettier as an error, so in
lint-prettier.yml the format:check after it only ever saw files it had just
rewritten, and the server's format scripts pointed at a test/ directory that
does not exist. Now lint is check-only in both (lint:fix applies fixes), the
server scripts cover src/ and tests/, and a lint:format ratchet on the shared
library holds every file outside scripts/format-baseline.json to Prettier in
both workspaces: in the server job of test.yml and in lint-prettier.yml,
which keeps providing the lint check. Formatting leaves ESLint in shared, and
the unused eslint-plugin-prettier goes. Once the tree is reformatted in one
commit, --update empties the baselines and the check is a full prettier
--check.
2026-10-10 20:15:54 +02:00

265 lines
11 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}<`));
// 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<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}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<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}${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);
});
}