/**
* 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}<`));
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` 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 = 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}\n${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