/** * 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, per table. A column * qualifies if it is a primary key, is unique, is either end of a foreign key * or is part of an index in its own table. Keyed per table: a column name that * happens to be indexed in one table says nothing about another. */ function splitTables(source) { const starts = []; const header = /export const (\w+) = sqliteTable\(\s*"([a-z0-9_]+)"/g; let match; while ((match = header.exec(source)) !== null) { starts.push({ variable: match[1], table: match[2], index: match.index }); } return starts.map((entry, i) => ({ ...entry, end: i + 1 < starts.length ? starts[i + 1].index : source.length, })); } function collectKeyColumns(source) { const tables = splitTables(source); const byVariable = new Map(tables.map((t) => [t.variable, t.table])); const keyed = new Map(tables.map((t) => [t.table, new Set()])); const add = (table, column) => keyed.get(table)?.add(column); for (const { table, index, end } of tables) { const block = source.slice(index, end); let match; // `name: text("col")....primaryKey()` / `.unique()` / `.references(...)` const declaration = /(\w+):\s*text\("([a-z0-9_]+)"\)((?:\s*\.\w+\([^)]*\))*)/g; while ((match = declaration.exec(block)) !== null) { if (/\.(primaryKey|unique|references)\(/.test(match[3])) { add(table, match[2]); } } // 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(block)) !== null) { if (/\.(primaryKey|unique|references)\(/.test(match[3])) { add(table, match[2]); } } // A referenced column implies the target side too; both must match. const reference = /\.references\(\(\)\s*=>\s*(\w+)\.(\w+)/g; while ((match = reference.exec(block)) !== null) { const target = byVariable.get(match[1]); if (target) add(target, camelToSnake(match[2])); } // foreignKey({ columns: [table.a], foreignColumns: [other.b] }) const foreign = /columns:\s*\[([^\]]*)\],\s*foreignColumns:\s*\[([^\]]*)\]/g; while ((match = foreign.exec(block)) !== null) { for (const column of match[1].split(",")) { const name = column.trim().replace(/^\w+\./, ""); if (name) add(table, camelToSnake(name)); } for (const column of match[2].split(",")) { const [variable, name] = column.trim().split("."); const target = byVariable.get(variable); if (target && name) add(target, camelToSnake(name)); } } // Table-level indexes: `(table) => [uniqueIndex("x").on(table.a, table.b)]`, // and the same for the plain `index("x")` used by the performance indexes. // MySQL 8 rejects a TEXT column in a key without a length; MariaDB takes it. const tableIndex = /\b(?:unique)?[iI]ndex\("[a-z0-9_]+"\)\.on\(([^)]*)\)/g; while ((match = tableIndex.exec(block)) !== null) { for (const column of match[1].split(",")) { const name = column.trim().replace(/^\w+\./, ""); if (name) add(table, camelToSnake(name)); } } } return { tables, keyed }; } function camelToSnake(value) { return value.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`); } function keyTables(source, tables, keyed, dialect) { let out = ""; let cursor = 0; for (const { table, index, end } of tables) { out += source.slice(cursor, index); const columns = keyed.get(table); out += source .slice(index, end) .replace( /\btext\("([a-z0-9_]+)"(?:,\s*\{\s*enum:\s*(\[[^\]]*\]),?\s*\})?\)/g, (whole, col, values) => { // Closed sets stay compact without shortening identifiers or keys. if (values && dialect === "mysql") return `mysqlEnum("${col}", ${values})`; const options = values ? `, enum: ${values}` : ""; return columns.has(col) ? `varchar("${col}", { length: ${KEY_LENGTH}${options} })` : whole; }, ); cursor = end; } return out + source.slice(cursor); } function transform(source, dialect) { const { tables, keyed } = collectKeyColumns(source); const isPg = dialect === "postgres"; // Key-bearing strings must be indexable. First, while the table offsets // still match the source. let out = keyTables(source, tables, keyed, dialect); // 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}")`, ); // 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", ); // Only the helpers the output uses, so an unused one never fails lint. const helpers = isPg ? [ "pgTable", "text", "varchar", "integer", "serial", "boolean", "doublePrecision", "index", "uniqueIndex", "foreignKey", ] : [ "mysqlTable", "mysqlEnum", "text", "varchar", "int", "boolean", "double", "index", "uniqueIndex", "foreignKey", ]; const body = out.replace( /import\s*\{[^}]*\}\s*from\s*"drizzle-orm\/sqlite-core";/, "", ); const used = helpers.filter((name) => new RegExp("\\b" + name + "\\(").test(body), ); const anyColumn = isPg ? "AnyPgColumn" : "AnyMySqlColumn"; if (body.includes(anyColumn)) used.push(`type ${anyColumn}`); const imports = `import {\n${used.map((name) => ` ${name},`).join("\n")}\n} from "drizzle-orm/${isPg ? "pg-core" : "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(); }