#!/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)`);