527 lines
14 KiB
JavaScript
527 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
// Generates src/db/schema.ts from:
|
|
// 1. existing src/db/schema.ts -> TS export names, camelCase fields, column maps, keys
|
|
// 2. live MySQL introspection -> real column types (DB is authoritative for DDL)
|
|
//
|
|
// The DB is owned by the Arcturus emulator; we never run drizzle-kit migrate/push.
|
|
// CMS DDL lands in drizzle/migrations/*.sql via `pnpm db:migrate`.
|
|
// drizzle-kit (`pnpm db:generate` / studio / introspect) is draft/browse tooling only.
|
|
//
|
|
// Usage: pnpm db:schema:generate
|
|
import "dotenv/config";
|
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { dirname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const MYSQL2_UNSUPPORTED_OPTIONS = [
|
|
"connection_limit",
|
|
"pool_timeout",
|
|
"connect_timeout",
|
|
];
|
|
|
|
function mysqlConnectionUrl(value) {
|
|
const url = new URL(value);
|
|
for (const option of MYSQL2_UNSUPPORTED_OPTIONS)
|
|
url.searchParams.delete(option);
|
|
return url.toString();
|
|
}
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = resolve(__dirname, "..");
|
|
const SCHEMA_TS = resolve(ROOT, "src/db/schema.ts");
|
|
const OUT = SCHEMA_TS;
|
|
|
|
// ---------- Parse existing Drizzle schema (naming source of truth) ----------
|
|
const schemaSource = readFileSync(SCHEMA_TS, "utf-8");
|
|
|
|
/**
|
|
* @returns {{ name: string, table: string, fields: object[], ids: string[]|null, uniques: string[][] }}
|
|
*/
|
|
function parseDrizzleTables(source) {
|
|
const models = [];
|
|
const re2 =
|
|
/^export const (\w+) = mysqlTable\(\s*"([^"]+)"\s*,\s*\{([\s\S]*?)\n\}(?:,\s*\(t\)\s*=>\s*\[([\s\S]*?)\])?\s*\);/gm;
|
|
|
|
let match = re2.exec(source);
|
|
const seen = new Set();
|
|
while (match !== null) {
|
|
const [, name, table, body, extras] = match;
|
|
if (!seen.has(name)) {
|
|
seen.add(name);
|
|
models.push(parseTableBody(name, table, body, extras ?? ""));
|
|
}
|
|
match = re2.exec(source);
|
|
}
|
|
|
|
if (models.length === 0) {
|
|
throw new Error(
|
|
`[schema-gen] Failed to parse any mysqlTable exports from ${SCHEMA_TS}`,
|
|
);
|
|
}
|
|
return models;
|
|
}
|
|
|
|
function parseTableBody(name, table, body, extras) {
|
|
const fields = [];
|
|
for (const line of body.split("\n")) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith("//")) continue;
|
|
const m = trimmed.match(/^(\w+)\s*:\s*(.+?),?\s*$/);
|
|
if (!m) continue;
|
|
const fieldName = m[1];
|
|
const expr = m[2];
|
|
const colMatch = expr.match(/\(\s*"([^"]+)"/);
|
|
if (!colMatch) continue;
|
|
const column = colMatch[1];
|
|
|
|
const isBoolean = /\bboolean\s*\(/.test(expr);
|
|
const enumMatch = expr.match(/mysqlEnum\s*\(\s*"[^"]+"\s*,\s*(\[[^\]]*\])/);
|
|
let enumValues = null;
|
|
if (enumMatch) {
|
|
try {
|
|
enumValues = JSON.parse(enumMatch[1].replace(/'/g, '"'));
|
|
} catch {
|
|
enumValues = [...enumMatch[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]);
|
|
}
|
|
}
|
|
|
|
let defaultContent = null;
|
|
const defIdx = expr.indexOf(".default(");
|
|
if (defIdx >= 0) {
|
|
const start = defIdx + ".default(".length;
|
|
let depth = 0;
|
|
for (let i = start; i < expr.length; i++) {
|
|
const ch = expr[i];
|
|
if (ch === "(") depth++;
|
|
else if (ch === ")") {
|
|
if (depth === 0) {
|
|
defaultContent = expr.slice(start, i).trim();
|
|
break;
|
|
}
|
|
depth--;
|
|
}
|
|
}
|
|
}
|
|
|
|
fields.push({
|
|
fieldName,
|
|
column,
|
|
optional: !/\.notNull\s*\(/.test(expr),
|
|
isId: /\.primaryKey\s*\(/.test(expr),
|
|
isUnique: /\.unique\s*\(/.test(expr),
|
|
autoIncrement: /\.autoincrement\s*\(/.test(expr),
|
|
isBoolean,
|
|
enumValues,
|
|
defaultContent,
|
|
// legacy shape used by columnExpr / fallback
|
|
prismaType: isBoolean
|
|
? "Boolean"
|
|
: enumValues
|
|
? fieldName === "gender"
|
|
? "users_gender"
|
|
: fieldName === "type" && table === "bans"
|
|
? "bans_type"
|
|
: "String"
|
|
: "String",
|
|
attrs: defaultContent ? `@default(${defaultContent})` : "",
|
|
dbHint: null,
|
|
});
|
|
}
|
|
|
|
let ids = null;
|
|
const uniques = [];
|
|
if (extras) {
|
|
const pk = extras.match(
|
|
/primaryKey\(\s*\{\s*columns:\s*\[([^\]]+)\]\s*\}\s*\)/,
|
|
);
|
|
if (pk) {
|
|
ids = [...pk[1].matchAll(/t\.(\w+)/g)].map((m) => m[1]);
|
|
}
|
|
for (const u of extras.matchAll(/uniqueIndex\([^)]*\)\.on\(([^)]+)\)/g)) {
|
|
uniques.push([...u[1].matchAll(/t\.(\w+)/g)].map((m) => m[1]));
|
|
}
|
|
}
|
|
|
|
return { name, table, fields, ids, uniques };
|
|
}
|
|
|
|
const models = parseDrizzleTables(schemaSource);
|
|
|
|
// ---------- Introspect live MySQL ----------
|
|
let url;
|
|
try {
|
|
const raw = process.env.DATABASE_URL;
|
|
if (!raw) throw new Error("DATABASE_URL is required");
|
|
url = mysqlConnectionUrl(raw);
|
|
} catch (err) {
|
|
console.error("[schema-gen] No DATABASE_URL:", err.message);
|
|
process.exit(1);
|
|
}
|
|
const mysql = await import("mysql2/promise");
|
|
const conn = await mysql.createConnection(url);
|
|
|
|
const [cols] = await conn.query(
|
|
`SELECT table_name, column_name, data_type, column_type, is_nullable,
|
|
column_key, column_default, extra
|
|
FROM information_schema.columns
|
|
WHERE table_schema = DATABASE()`,
|
|
);
|
|
await conn.end();
|
|
|
|
const colByTable = new Map();
|
|
for (const c of cols) {
|
|
const key = `${c.table_name}.${c.column_name}`;
|
|
colByTable.set(key, c);
|
|
}
|
|
|
|
// ---------- Build drizzle column builders ----------
|
|
const used = new Set(["mysqlTable", "primaryKey", "uniqueIndex", "customType"]);
|
|
let usedSql = false;
|
|
|
|
function use(name) {
|
|
used.add(name);
|
|
}
|
|
|
|
function markSqlUsed() {
|
|
usedSql = true;
|
|
}
|
|
|
|
function quote(v) {
|
|
return `"${String(v).replace(/"/g, '\\"')}"`;
|
|
}
|
|
|
|
/** Map a DB column row to a drizzle column expression string. */
|
|
function columnExpr(field, dbCol) {
|
|
const col = field.column;
|
|
let type = "";
|
|
|
|
const unsigned = /unsigned/.test(dbCol?.column_type ?? "");
|
|
const decimalMatch = dbCol?.column_type?.match(/decimal\((\d+),(\d+)\)/);
|
|
const enumMatch = dbCol?.column_type?.match(/^enum\((.+)\)$/);
|
|
|
|
if (field.prismaType === "users_gender" || field.prismaType === "bans_type") {
|
|
use("mysqlEnum");
|
|
const values = enumMatch
|
|
? [...enumMatch[1].matchAll(/'([^']+)'/g)].map((m) => m[1])
|
|
: (field.enumValues ??
|
|
(field.prismaType === "users_gender"
|
|
? ["M", "F"]
|
|
: ["account", "ip", "machine", "super"]));
|
|
return `mysqlEnum(${quote(col)}, ${JSON.stringify(values)})`;
|
|
}
|
|
|
|
switch (dbCol?.data_type) {
|
|
case "int":
|
|
use("int");
|
|
type = unsigned
|
|
? `int(${quote(col)}, { unsigned: true })`
|
|
: `int(${quote(col)})`;
|
|
break;
|
|
case "tinyint": {
|
|
const isBool =
|
|
dbCol.column_type === "tinyint(1)" &&
|
|
(field.isBoolean || field.prismaType === "Boolean");
|
|
if (isBool) {
|
|
use("boolean");
|
|
type = `boolean(${quote(col)})`;
|
|
} else {
|
|
use("tinyint");
|
|
type = unsigned
|
|
? `tinyint(${quote(col)}, { unsigned: true })`
|
|
: `tinyint(${quote(col)})`;
|
|
}
|
|
break;
|
|
}
|
|
case "smallint":
|
|
use("smallint");
|
|
type = unsigned
|
|
? `smallint(${quote(col)}, { unsigned: true })`
|
|
: `smallint(${quote(col)})`;
|
|
break;
|
|
case "mediumint":
|
|
use("mediumint");
|
|
type = unsigned
|
|
? `mediumint(${quote(col)}, { unsigned: true })`
|
|
: `mediumint(${quote(col)})`;
|
|
break;
|
|
case "bigint":
|
|
use("bigint");
|
|
type = `bigint(${quote(col)}, { mode: "bigint", unsigned: ${unsigned} })`;
|
|
break;
|
|
case "varchar":
|
|
case "enum": {
|
|
use("varchar");
|
|
const maxLen = enumMatch
|
|
? Math.max(
|
|
...[...enumMatch[1].matchAll(/'([^']+)'/g)].map((m) => m[1].length),
|
|
1,
|
|
)
|
|
: Number(dbCol?.column_type?.match(/\((\d+)\)/)?.[1] ?? 255);
|
|
type = `varchar(${quote(col)}, { length: ${maxLen} })`;
|
|
break;
|
|
}
|
|
case "char":
|
|
use("char");
|
|
type = `char(${quote(col)}, { length: ${Number(dbCol?.column_type?.match(/\((\d+)\)/)?.[1] ?? 8)} })`;
|
|
break;
|
|
case "text":
|
|
use("text");
|
|
type = `text(${quote(col)})`;
|
|
break;
|
|
case "mediumtext":
|
|
use("mediumtext");
|
|
type = `mediumtext(${quote(col)})`;
|
|
break;
|
|
case "longtext":
|
|
use("longtext");
|
|
type = `longtext(${quote(col)})`;
|
|
break;
|
|
case "datetime": {
|
|
use("datetime");
|
|
const fsp = dbCol.column_type.match(/datetime\((\d+)\)/)?.[1];
|
|
type = fsp
|
|
? `datetime(${quote(col)}, { fsp: ${Number(fsp)} })`
|
|
: `datetime(${quote(col)})`;
|
|
break;
|
|
}
|
|
case "timestamp": {
|
|
use("timestamp");
|
|
const fsp = dbCol.column_type.match(/timestamp\((\d+)\)/)?.[1];
|
|
type = fsp
|
|
? `timestamp(${quote(col)}, { fsp: ${Number(fsp)} })`
|
|
: `timestamp(${quote(col)})`;
|
|
break;
|
|
}
|
|
case "double":
|
|
use("double");
|
|
type = `double(${quote(col)})`;
|
|
break;
|
|
case "float":
|
|
use("float");
|
|
type = `float(${quote(col)})`;
|
|
break;
|
|
case "decimal":
|
|
use("decimal");
|
|
type = `decimal(${quote(col)}, { precision: ${Number(decimalMatch?.[1] ?? 10)}, scale: ${Number(decimalMatch?.[2] ?? 0)}, mode: "number" })`;
|
|
break;
|
|
case "json":
|
|
use("json");
|
|
type = `json(${quote(col)})`;
|
|
break;
|
|
case "date":
|
|
type = `dateAsDate(${quote(col)})`;
|
|
break;
|
|
case "time":
|
|
type = `timeAsDate(${quote(col)})`;
|
|
break;
|
|
case "blob":
|
|
case "tinyblob":
|
|
case "mediumblob":
|
|
case "longblob":
|
|
use("binary");
|
|
type = `binary(${quote(col)})`;
|
|
break;
|
|
default:
|
|
console.warn(
|
|
`[schema-gen] WARN unhandled DB type "${dbCol?.data_type}" for ${col}`,
|
|
);
|
|
use("varchar");
|
|
type = `varchar(${quote(col)}, { length: 255 })`;
|
|
}
|
|
|
|
return type;
|
|
}
|
|
|
|
function modifiers(field, dbCol) {
|
|
let expr = "";
|
|
if (dbCol?.extra?.includes("auto_increment") || field.autoIncrement) {
|
|
expr += `.autoincrement()`;
|
|
}
|
|
if (field.isId) {
|
|
expr += `.primaryKey()`;
|
|
} else if (dbCol?.column_key === "UNI" && !field.isUnique) {
|
|
expr += `.unique()`;
|
|
} else if (field.isUnique) {
|
|
expr += `.unique()`;
|
|
}
|
|
if (!field.optional) {
|
|
expr += `.notNull()`;
|
|
}
|
|
expr += emitDefault(field, dbCol);
|
|
return expr;
|
|
}
|
|
|
|
function emitDefault(field, dbCol) {
|
|
const content = field.defaultContent;
|
|
if (!content) return "";
|
|
if (
|
|
content === "sql`CURRENT_TIMESTAMP`" ||
|
|
content.includes("CURRENT_TIMESTAMP")
|
|
) {
|
|
markSqlUsed();
|
|
return `.default(sql\`CURRENT_TIMESTAMP\`)`;
|
|
}
|
|
if (content === "true" || content === "false") return `.default(${content})`;
|
|
if (/^-?\d+n$/.test(content)) return `.default(${content})`;
|
|
if (/^-?\d+$/.test(content)) {
|
|
return dbCol?.data_type === "bigint"
|
|
? `.default(${content}n)`
|
|
: `.default(${content})`;
|
|
}
|
|
if (/^-?\d+\.\d+$/.test(content)) return `.default(${content})`;
|
|
if (
|
|
(content.startsWith('"') && content.endsWith('"')) ||
|
|
(content.startsWith("'") && content.endsWith("'"))
|
|
) {
|
|
return `.default(${JSON.stringify(content.slice(1, -1))})`;
|
|
}
|
|
return `.default(${content})`;
|
|
}
|
|
|
|
function modelTable(model) {
|
|
const rows = [];
|
|
for (const f of model.fields) {
|
|
const dbCol = colByTable.get(`${model.table}.${f.column}`);
|
|
if (!dbCol) {
|
|
const fallback = fallbackColumn(f) + modifiers(f, null);
|
|
rows.push(`\t${f.fieldName}: ${fallback},`);
|
|
console.warn(
|
|
`[schema-gen] WARN ${model.table}.${f.column} (${f.fieldName}) missing in DB, used fallback`,
|
|
);
|
|
continue;
|
|
}
|
|
rows.push(
|
|
`\t${f.fieldName}: ${columnExpr(f, dbCol)}${modifiers(f, dbCol)},`,
|
|
);
|
|
}
|
|
|
|
const uniqueRows = [];
|
|
if (model.ids) {
|
|
const idCols = model.ids
|
|
.map((n) => model.fields.find((f) => f.fieldName === n || f.column === n))
|
|
.filter(Boolean)
|
|
.map((f) => `t.${f.fieldName}`);
|
|
if (idCols.length)
|
|
uniqueRows.push(`\tprimaryKey({ columns: [${idCols.join(", ")}] }),`);
|
|
}
|
|
for (const u of model.uniques) {
|
|
const cols = u
|
|
.map((n) => model.fields.find((f) => f.fieldName === n || f.column === n))
|
|
.filter(Boolean)
|
|
.map((f) => `t.${f.fieldName}`);
|
|
if (cols.length)
|
|
uniqueRows.push(
|
|
`\tuniqueIndex("${model.table}_${u.join("_")}").on(${cols.join(", ")}),`,
|
|
);
|
|
}
|
|
|
|
if (uniqueRows.length) {
|
|
return `export const ${model.name} = mysqlTable(
|
|
"${model.table}",
|
|
{
|
|
${rows.join("\n")}
|
|
},
|
|
(t) => [
|
|
${uniqueRows.join("\n")}
|
|
],
|
|
);`;
|
|
}
|
|
return `export const ${model.name} = mysqlTable("${model.table}", {
|
|
${rows.join("\n")}
|
|
});`;
|
|
}
|
|
|
|
function fallbackColumn(field) {
|
|
const name = field.column;
|
|
if (field.enumValues) {
|
|
use("mysqlEnum");
|
|
return `mysqlEnum(${quote(name)}, ${JSON.stringify(field.enumValues)})`;
|
|
}
|
|
if (field.isBoolean || field.prismaType === "Boolean") {
|
|
use("boolean");
|
|
return `boolean(${quote(name)})`;
|
|
}
|
|
use("varchar");
|
|
return `varchar(${quote(name)}, { length: 255 })`;
|
|
}
|
|
|
|
// ---------- Generate file ----------
|
|
const body = models.map(modelTable).join("\n\n");
|
|
|
|
const IMPORTABLE = [
|
|
"bigint",
|
|
"binary",
|
|
"boolean",
|
|
"char",
|
|
"customType",
|
|
"date",
|
|
"datetime",
|
|
"decimal",
|
|
"double",
|
|
"float",
|
|
"int",
|
|
"json",
|
|
"longtext",
|
|
"mediumint",
|
|
"mediumtext",
|
|
"mysqlEnum",
|
|
"mysqlTable",
|
|
"primaryKey",
|
|
"smallint",
|
|
"text",
|
|
"time",
|
|
"timestamp",
|
|
"tinyint",
|
|
"uniqueIndex",
|
|
"varchar",
|
|
];
|
|
const importList = IMPORTABLE.filter((b) => used.has(b));
|
|
|
|
const helpers = [
|
|
"// MySQL TIME / DATE columns are hydrated as JS Date (epoch 1970-01-01",
|
|
"// for TIME). Keep this so existing call sites stay unchanged.",
|
|
"const timeAsDate = customType<{ data: Date; driverData: string }>({",
|
|
" dataType() {",
|
|
' return "time";',
|
|
" },",
|
|
" toDriver(value) {",
|
|
" return value.toISOString().slice(11, 19);",
|
|
" },",
|
|
" fromDriver(value) {",
|
|
// biome-ignore lint/suspicious/noTemplateCurlyInString: code generation template literal
|
|
" return new Date(`1970-01-01T${value}Z`);",
|
|
" },",
|
|
"});",
|
|
"",
|
|
"const dateAsDate = customType<{ data: Date; driverData: string }>({",
|
|
" dataType() {",
|
|
' return "date";',
|
|
" },",
|
|
" toDriver(value) {",
|
|
" return value.toISOString().slice(0, 10);",
|
|
" },",
|
|
" fromDriver(value) {",
|
|
// biome-ignore lint/suspicious/noTemplateCurlyInString: code generation template literal
|
|
" return new Date(`${value}T00:00:00Z`);",
|
|
" },",
|
|
"});",
|
|
"",
|
|
"",
|
|
].join("\n");
|
|
|
|
const sqlImport = usedSql ? 'import { sql } from "drizzle-orm";\n' : "";
|
|
|
|
const out = `// AUTO-GENERATED by scripts/generate-drizzle-schema.mjs — DO NOT EDIT.
|
|
// TS field names come from the previous src/db/schema.ts; column types from the
|
|
// live MySQL DB. Run \`pnpm db:schema:generate\` after schema/map changes.
|
|
${sqlImport}import {
|
|
${importList.map((b) => `\t${b},`).join("\n")}
|
|
} from "drizzle-orm/mysql-core";
|
|
|
|
${helpers}${body}
|
|
`;
|
|
|
|
mkdirSync(dirname(OUT), { recursive: true });
|
|
writeFileSync(OUT, out);
|
|
console.log(`[schema-gen] Wrote ${OUT} (${models.length} tables)`);
|