/** * Generates the Postgres and MySQL schema modules from the SQLite one. * * ## These files produce DDL. They are not used at runtime. * * drizzle-kit reads them to emit the migrations in drizzle/postgres and * drizzle/mysql. Nothing imports them to run a query. * * That is not an oversight. The query builder needs two things from a table * object — the identifiers to interpolate, and the encoders that turn JS values * into driver values — and the sqlite definitions supply both correctly for * every engine, which is why all 44 repositories import schema.ts directly: * * - text and integer encode as themselves everywhere * - integer({ mode: "boolean" }) writes 1/0, which Postgres and MySQL both * accept for a boolean column, and reads back through `Number(v) === 1`, * which is true for JS `true` as well as for 1 * - real is a plain number on all three * * What genuinely differs between the dialects is DDL — column types, key * lengths, autoincrement syntax — and DDL is exactly what these files exist to * generate. See scripts/verify-dialects.mjs, which asserts the round-trips * above against real servers rather than trusting this comment. * * The schema is declared once, in sqlite-core, and the other two dialects are * derived. Hand-maintaining three copies of 52 tables would mean a renamed * table has to land in three places consistently or a foreign key silently * points at the wrong one — and the schema is regular enough that the mapping * is mechanical. * * What varies between dialects is small and closed: * - booleans are integers on sqlite, native elsewhere * - autoincrement keys are `integer primary key autoincrement`, `serial`, * and `int auto_increment` * - MySQL cannot index unbounded TEXT, so any column that is a primary key, * is unique, or participates in a foreign key must be varchar * - MySQL rejects a bare DEFAULT CURRENT_TIMESTAMP on a text column, so it is * written as a parenthesised expression default * * Usage: node scripts/generate-dialect-schema.cjs [--check] * --check verifies the committed files match what would be generated, * for CI to catch a schema edit that forgot to regenerate. */ const fs = require("fs"); const path = require("path"); const ROOT = path.join(__dirname, ".."); const SOURCE = path.join(ROOT, "src/backend/database/db/schema.ts"); const TARGETS = { postgres: path.join(ROOT, "src/backend/database/db/schema.pg.ts"), mysql: path.join(ROOT, "src/backend/database/db/schema.mysql.ts"), }; const KEY_LENGTH = 255; /** * Columns that must be varchar rather than text on MySQL. A column qualifies if * it is a primary key, is unique, or is either end of a foreign key. */ function collectKeyColumns(source) { const keyed = new Set(); // `name: text("col")....primaryKey()` / `.unique()` / `.references(...)` const declaration = /(\w+):\s*text\("([a-z0-9_]+)"\)((?:\s*\.\w+\([^)]*\))*)/g; let match; while ((match = declaration.exec(source)) !== null) { const [, prop, column, modifiers] = match; if (/\.(primaryKey|unique|references)\(/.test(modifiers)) { keyed.add(column); } void prop; } // Multi-line form: the modifiers land on following lines. const multiline = /(\w+):\s*text\("([a-z0-9_]+)"\)\s*\n(\s*\.\w+\([\s\S]*?\),)/g; while ((match = multiline.exec(source)) !== null) { if (/\.(primaryKey|unique|references)\(/.test(match[3])) { keyed.add(match[2]); } } // A referenced column implies the referencing side too; both must match. const reference = /\.references\(\(\)\s*=>\s*\w+\.(\w+)/g; while ((match = reference.exec(source)) !== null) { keyed.add(camelToSnake(match[1])); } // Table-level indexes: `(table) => [uniqueIndex("x").on(table.a, table.b)]`, // and the same for the plain `index("x")` used by the performance indexes. // These were invisible here at first, and MySQL rejected the migration with // "BLOB/TEXT column used in key specification without a key length" — but // only on MySQL 8; MariaDB took it. const tableIndex = /\b(?:unique)?[iI]ndex\("[a-z0-9_]+"\)\.on\(([^)]*)\)/g; while ((match = tableIndex.exec(source)) !== null) { for (const column of match[1].split(",")) { const name = column.trim().replace(/^\w+\./, ""); if (name) keyed.add(camelToSnake(name)); } } return keyed; } function camelToSnake(value) { return value.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`); } function transform(source, dialect) { const keyed = collectKeyColumns(source); const isPg = dialect === "postgres"; let out = source; // Autoincrement primary keys, before the plain integer rule below. out = out.replace( /integer\("([a-z0-9_]+)"\)\.primaryKey\(\{\s*autoIncrement:\s*true\s*\}\)/g, (_, col) => isPg ? `serial("${col}").primaryKey()` : `int("${col}").autoincrement().primaryKey()`, ); // Integer-backed booleans become native ones. Prettier wraps the longer // declarations across lines, so this has to span newlines too. out = out.replace( /integer\(\s*"([a-z0-9_]+)",\s*\{\s*mode:\s*"boolean",?\s*\},?\s*\)/g, (_, col) => `boolean("${col}")`, ); // Remaining integers. if (!isPg) { out = out.replace( /\binteger\("([a-z0-9_]+)"\)/g, (_, col) => `int("${col}")`, ); // Timestamps are stored as text (see sql-timestamp.ts). MySQL only accepts // DEFAULT CURRENT_TIMESTAMP on a DATETIME or TIMESTAMP column — on a TEXT // one it is ER_INVALID_DEFAULT, "Invalid default value". Since 8.0.13 an // expression default works on any type, and an expression is written // parenthesised. MariaDB accepts the bare form, which is why this only // surfaces against real MySQL. out = out.replace(/sql`CURRENT_TIMESTAMP`/g, "sql`(CURRENT_TIMESTAMP)`"); } // Floating point. out = out.replace(/\breal\("([a-z0-9_]+)"\)/g, (_, col) => isPg ? `doublePrecision("${col}")` : `double("${col}")`, ); // Key-bearing strings must be indexable. out = out.replace(/\btext\("([a-z0-9_]+)"\)/g, (whole, col) => keyed.has(col) ? `varchar("${col}", { length: ${KEY_LENGTH} })` : whole, ); // text("x", { length: n }) is sqlite-only sugar; drop the length. out = out.replace( /\btext\("([a-z0-9_]+)",\s*\{\s*length:\s*\d+\s*\}\)/g, (_, col) => `text("${col}")`, ); out = out.replace(/\bsqliteTable\(/g, isPg ? "pgTable(" : "mysqlTable("); // Self-referencing FK callbacks are typed against the source dialect's // "any column" helper so TS can resolve the circular table reference. out = out.replace( /\bAnySQLiteColumn\b/g, isPg ? "AnyPgColumn" : "AnyMySqlColumn", ); const imports = isPg ? `import {\n pgTable,\n text,\n varchar,\n integer,\n serial,\n boolean,\n doublePrecision,\n index,\n uniqueIndex,\n type AnyPgColumn,\n} from "drizzle-orm/pg-core";` : `import {\n mysqlTable,\n text,\n varchar,\n int,\n boolean,\n double,\n index,\n uniqueIndex,\n type AnyMySqlColumn,\n} from "drizzle-orm/mysql-core";`; out = out.replace( /import\s*\{[^}]*\}\s*from\s*"drizzle-orm\/sqlite-core";/, imports, ); return `${header(dialect)}\n${out}`; } function header(dialect) { return `// GENERATED FILE — do not edit. // // Produced from schema.ts by scripts/generate-dialect-schema.cjs. // Edit the sqlite schema and re-run \`node scripts/generate-dialect-schema.cjs\`. // Target dialect: ${dialect}. // // DDL source for drizzle-kit. NOT imported to run queries — repositories use // schema.ts on every dialect. See the generator header for why that is correct. `; } function main() { const check = process.argv.includes("--check"); const source = fs.readFileSync(SOURCE, "utf8"); let drift = false; for (const [dialect, target] of Object.entries(TARGETS)) { const generated = transform(source, dialect); if (check) { const current = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : ""; if (current !== generated) { console.error( `[generate-dialect-schema] ${path.relative(ROOT, target)} is out of date`, ); drift = true; } continue; } fs.writeFileSync(target, generated); console.log( `[generate-dialect-schema] wrote ${path.relative(ROOT, target)}`, ); } if (drift) { console.error( "[generate-dialect-schema] run `node scripts/generate-dialect-schema.cjs` and commit the result", ); process.exit(1); } } module.exports = { transform, collectKeyColumns }; if (require.main === module) { main(); }