- Replace Prisma client runtime with Drizzle ORM (zero Prisma engine/query engine in production) - Add Prisma-compatible facade (@/lib/prisma-facade.ts) backed by Drizzle for backwards compatibility - Runtime queries route through Drizzle ORM; @prisma/client is now devDependency (types only) - Remove @prisma/adapter-mariadb dependency; delete prisma-pool.ts and types/prisma.ts - New Drizzle schema layer: src/db/schema.ts (176 tables) and src/lib/db.ts (connection) - Update README documenting the dual-layer ORM architecture - Restore src/generated/ gitignore (build artifact for local type generation) - 0 TypeScript errors, 583 tests passing The facade intentionally uses `any` types to match the Prisma Client API surface, allowing existing code to run unmodified while routing queries through Drizzle at runtime.
577 lines
15 KiB
JavaScript
577 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
// Generates src/db/schema.ts from:
|
|
// 1. prisma/schema.prisma -> TS field names (camelCase) + @map column names + table names
|
|
// 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.
|
|
// This schema is only used for the query builder + TypeScript types.
|
|
//
|
|
// 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_PRISMA = resolve(ROOT, "prisma/schema.prisma");
|
|
const OUT = resolve(ROOT, "src/db/schema.ts");
|
|
|
|
const prismaSource = readFileSync(SCHEMA_PRISMA, "utf-8");
|
|
|
|
// ---------- Parse prisma/schema.prisma ----------
|
|
const modelBlocks = [
|
|
...prismaSource.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm),
|
|
];
|
|
const modelNames = new Set(modelBlocks.map((m) => m[1]));
|
|
|
|
/** parse a model block -> { name, table, fields, ids, uniques, maps } */
|
|
function parseModel(block) {
|
|
const [full, name, body] = block;
|
|
const table =
|
|
body.match(/@@map\(\s*"([^"]+)"\s*\)/)?.[1] ?? name.toLowerCase();
|
|
|
|
const fields = [];
|
|
for (const line of body.split("\n")) {
|
|
const trimmed = line.trim();
|
|
if (
|
|
!trimmed ||
|
|
trimmed.startsWith("//") ||
|
|
trimmed.startsWith("@@") ||
|
|
trimmed.startsWith("///")
|
|
) {
|
|
continue;
|
|
}
|
|
const m = trimmed.match(/^(\w+)\s+(.+)$/);
|
|
if (!m) continue;
|
|
const [fieldName, rest] = [m[1], m[2]];
|
|
const typeMatch = rest.match(/^([^\s]+)/);
|
|
const prismaType = typeMatch[1];
|
|
const baseType = prismaType.replace(/\?$/, "").replace(/\[\]$/, "");
|
|
const isList = prismaType.endsWith("[]");
|
|
// relation field (points at another model) -> skip
|
|
if (modelNames.has(baseType)) continue;
|
|
|
|
const attrs = rest;
|
|
const optional = prismaType.endsWith("?");
|
|
const map = attrs.match(/@map\(\s*"([^"]+)"\s*\)/)?.[1] ?? fieldName;
|
|
const isId = /@id\b/.test(attrs);
|
|
const isUnique = /@unique\b/.test(attrs);
|
|
const autoIncrement = /@default\(autoincrement\(\)\)/.test(attrs);
|
|
const updatedAt = /@updatedAt\b/.test(attrs);
|
|
const dbHint = attrs.match(/@db\.(\w+)(?:\((\d+)(?:\s*,\s*(\d+))?\))?/);
|
|
fields.push({
|
|
fieldName,
|
|
column: map,
|
|
prismaType: baseType,
|
|
optional,
|
|
isList,
|
|
isId,
|
|
isUnique,
|
|
autoIncrement,
|
|
updatedAt,
|
|
attrs,
|
|
dbHint: dbHint
|
|
? { type: dbHint[1], param1: dbHint[2], param2: dbHint[3] }
|
|
: null,
|
|
});
|
|
}
|
|
|
|
// model-level keys
|
|
const compIds = body.match(/@@id\(\s*\[([^\]]+)\]\s*\)/)?.[1];
|
|
const ids = compIds
|
|
? compIds.split(",").map((s) => s.trim().replace(/`/g, ""))
|
|
: null;
|
|
|
|
const uniques = [];
|
|
for (const u of body.matchAll(/@@unique\(\s*\[([^\]]+)\]\s*/g)) {
|
|
uniques.push(u[1].split(",").map((s) => s.trim().replace(/`/g, "")));
|
|
}
|
|
|
|
return { name, table, fields, ids, uniques };
|
|
}
|
|
|
|
const models = modelBlocks.map(parseModel);
|
|
|
|
// ---------- 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 expr = "";
|
|
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\((.+)\)$/);
|
|
|
|
// Prisma enum types -> mysqlEnum
|
|
if (field.prismaType === "users_gender" || field.prismaType === "bans_type") {
|
|
use("mysqlEnum");
|
|
const values = enumMatch
|
|
? [...enumMatch[1].matchAll(/'([^']+)'/g)].map((m) => m[1])
|
|
: 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.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": {
|
|
// DB enums become plain varchar in the schema: TS contract is `string`
|
|
// (only users_gender / bans_type are typed as mysqlEnum above).
|
|
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} (prisma:${field.prismaType})`,
|
|
);
|
|
use("varchar");
|
|
type = `varchar(${quote(col)}, { length: 255 })`;
|
|
}
|
|
expr += type;
|
|
|
|
return expr;
|
|
}
|
|
|
|
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()`;
|
|
}
|
|
// Mirror the Prisma TS contract: required fields (no `?`) are not-null on
|
|
// select, and fields with `@default` are optional on insert.
|
|
if (!field.optional) {
|
|
expr += `.notNull()`;
|
|
}
|
|
expr += parseDefault(field, dbCol);
|
|
return expr;
|
|
}
|
|
|
|
/** Extract `@default(...)` and emit a drizzle `.default(...)` (or ""). */
|
|
function parseDefault(field, dbCol) {
|
|
const m = field.attrs.match(/@default\(/);
|
|
if (!m) return "";
|
|
const start = m.index + "@default(".length;
|
|
let depth = 0;
|
|
let content = "";
|
|
for (let i = start; i < field.attrs.length; i++) {
|
|
const ch = field.attrs[i];
|
|
if (ch === "(") {
|
|
depth++;
|
|
} else if (ch === ")") {
|
|
if (depth === 0) {
|
|
content = field.attrs.slice(start, i);
|
|
break;
|
|
}
|
|
depth--;
|
|
}
|
|
}
|
|
if (!content) return "";
|
|
if (content === "now()") {
|
|
markSqlUsed();
|
|
return `.default(sql\`CURRENT_TIMESTAMP\`)`;
|
|
}
|
|
if (content === "autoincrement()" || content.startsWith("dbgenerated("))
|
|
return "";
|
|
if (content === "true" || content === "false") return `.default(${content})`;
|
|
if (/^-?\d+$/.test(content)) {
|
|
// integer literal; bigint64 data type is `bigint`, others are `number`
|
|
return dbCol?.data_type === "bigint"
|
|
? `.default(${content}n)`
|
|
: `.default(${content})`;
|
|
}
|
|
if (/^-?\d+\.\d+$/.test(content)) return `.default(${content})`;
|
|
// quoted string or bare enum/string identifier (e.g. @default(M))
|
|
const s =
|
|
content.startsWith('"') && content.endsWith('"')
|
|
? content.slice(1, -1)
|
|
: content;
|
|
return `.default(${JSON.stringify(s)})`;
|
|
}
|
|
|
|
function modelTable(model) {
|
|
const rows = [];
|
|
for (const f of model.fields) {
|
|
const dbCol = colByTable.get(`${model.table}.${f.column}`);
|
|
if (!dbCol) {
|
|
// Column not found in live DB. Prisma schema may be ahead of the DB.
|
|
// Fall back to a best-effort type from the Prisma @db hint / base type.
|
|
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 hint = field.dbHint;
|
|
const name = field.column;
|
|
switch (hint?.type) {
|
|
case "VarChar":
|
|
use("varchar");
|
|
return `varchar(${quote(name)}, { length: ${Number(hint.param1 ?? 191)} })`;
|
|
case "Char":
|
|
use("char");
|
|
return `char(${quote(name)}, { length: ${Number(hint.param1 ?? 8)} })`;
|
|
case "Text":
|
|
use("text");
|
|
return `text(${quote(name)})`;
|
|
case "MediumText":
|
|
use("mediumtext");
|
|
return `mediumtext(${quote(name)})`;
|
|
case "LongText":
|
|
use("longtext");
|
|
return `longtext(${quote(name)})`;
|
|
case "Decimal":
|
|
use("decimal");
|
|
return `decimal(${quote(name)}, { precision: ${Number(hint.param1 ?? 10)}, scale: ${Number(hint.param2 ?? 0)}, mode: "number" })`;
|
|
case "UnsignedBigInt":
|
|
use("bigint");
|
|
return `bigint(${quote(name)}, { mode: "bigint", unsigned: true })`;
|
|
case "UnsignedInt":
|
|
use("int");
|
|
return `int(${quote(name)}, { unsigned: true })`;
|
|
case "Double":
|
|
use("double");
|
|
return `double(${quote(name)})`;
|
|
case "Float":
|
|
use("float");
|
|
return `float(${quote(name)})`;
|
|
case "Json":
|
|
use("json");
|
|
return `json(${quote(name)})`;
|
|
case "Timestamp":
|
|
use("timestamp");
|
|
return `timestamp(${quote(name)})`;
|
|
case "Time":
|
|
return `timeAsDate(${quote(name)})`;
|
|
case "Date":
|
|
return `dateAsDate(${quote(name)})`;
|
|
case "TinyInt":
|
|
use("tinyint");
|
|
return `tinyint(${quote(name)})`;
|
|
default:
|
|
break;
|
|
}
|
|
switch (field.prismaType) {
|
|
case "Int":
|
|
use("int");
|
|
return `int(${quote(name)})`;
|
|
case "BigInt":
|
|
use("bigint");
|
|
return `bigint(${quote(name)}, { mode: "bigint" })`;
|
|
case "Boolean":
|
|
use("boolean");
|
|
return `boolean(${quote(name)})`;
|
|
case "DateTime":
|
|
use("datetime");
|
|
return `datetime(${quote(name)})`;
|
|
case "String":
|
|
use("varchar");
|
|
return `varchar(${quote(name)}, { length: 255 })`;
|
|
case "Float":
|
|
use("double");
|
|
return `double(${quote(name)})`;
|
|
case "Decimal":
|
|
use("decimal");
|
|
return `decimal(${quote(name)}, { precision: 10, scale: 2, mode: "number" })`;
|
|
case "Json":
|
|
use("json");
|
|
return `json(${quote(name)})`;
|
|
case "Bytes":
|
|
use("binary");
|
|
return `binary(${quote(name)})`;
|
|
default:
|
|
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 by Prisma as JS Date (epoch 1970-01-01",
|
|
"// for TIME). Mirror that so existing call sites keep working unchanged.",
|
|
"const timeAsDate = customType<{ data: Date; driverData: string }>({",
|
|
" dataType() {",
|
|
' return "time";',
|
|
" },",
|
|
" toDriver(value) {",
|
|
" return value.toISOString().slice(11, 19);",
|
|
" },",
|
|
" fromDriver(value) {",
|
|
" 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) {",
|
|
" return new Date(`${value}T00:00:00Z`);",
|
|
" },",
|
|
"});",
|
|
"",
|
|
"",
|
|
].join("\n");
|
|
|
|
const out = `// AUTO-GENERATED by scripts/generate-drizzle-schema.mjs — DO NOT EDIT.
|
|
// TS field names mirror prisma/schema.prisma (camelCase); column names are the
|
|
// real MySQL columns. Run \`pnpm db:schema:generate\` after schema changes.
|
|
import {
|
|
${importList.map((b) => `\t${b},`).join("\n")}
|
|
} from "drizzle-orm/mysql-core";
|
|
${usedSql ? `import { sql } from "drizzle-orm";\n` : ""}
|
|
${helpers}${body}
|
|
`;
|
|
|
|
mkdirSync(dirname(OUT), { recursive: true });
|
|
writeFileSync(OUT, out);
|
|
console.log(`[schema-gen] Wrote ${OUT} (${models.length} tables)`);
|