mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
264 lines
11 KiB
TypeScript
264 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 { migrateTempDb, tempDbPath } from './generate-entities';
|
|
|
|
import Database from 'better-sqlite3';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
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);
|
|
});
|
|
}
|