Files
Termix/scripts/generate-dialect-schema.cjs
T
ZacharyZcR 710f545e34 fix(host-metrics): exclude image and firmware filesystems (#1469)
* fix(host-metrics): exclude image and firmware filesystems

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL
2026-09-26 18:18:12 -05:00

326 lines
11 KiB
JavaScript

/**
* 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();
}