- Remove 25 unused imports across 14 test files - Remove 1 unused variable (rename with _ prefix) - Fix 2 noBannedTypes (Function → (...args: unknown[]) => unknown) - Fix 1 useTemplate lint (string concat → template literal in merge-config.cjs) - Fix 1 useNodejsImportProtocol (merge-config.cjs) - Fix 2 noTemplateCurlyInString (generate-drizzle-schema.mjs generator code) - Auto-fix formatting + import sorting across modified files - 221 remaining warnings: intentional noExplicitAny in prisma-facade.ts (Prisma compat layer) - 0 tsc errors, 583 tests passing
579 lines
16 KiB
JavaScript
579 lines
16 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) {",
|
|
// 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 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)`);
|