chore(db): finish Prisma cutover to Drizzle Kit tooling
CI / check (push) Failing after 9s
CI / release (push) Skipped
CI / deploy (push) Skipped

Move CMS SQL to drizzle/migrations, drop prisma packages/schema, wire drizzle-kit scripts, and regenerate schema names from src/db/schema.ts.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-08-01 15:02:21 +02:00
1 parent d8199ea1e4
commit ba82789166
47 files changed
+365 -2787

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@ import { mysqlConnectionUrl } from "./db-url";
import { splitSqlStatements } from "./sql-statements";
const __dirname = dirname(fileURLToPath(import.meta.url));
const MIGRATIONS_DIR = resolve(__dirname, "../prisma/migrations");
const MIGRATIONS_DIR = resolve(__dirname, "../drizzle/migrations");
const TRACKING_TABLE = "cms_migrations";
interface MigrationFile {
+140 -195
View File
@@ -1,10 +1,11 @@
#!/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)
// 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.
// This schema is only used for the query builder + TypeScript types.
// 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";
@@ -27,84 +28,124 @@ function mysqlConnectionUrl(value) {
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 SCHEMA_TS = resolve(ROOT, "src/db/schema.ts");
const OUT = SCHEMA_TS;
const prismaSource = readFileSync(SCHEMA_PRISMA, "utf-8");
// ---------- Parse existing Drizzle schema (naming source of truth) ----------
const schemaSource = readFileSync(SCHEMA_TS, "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]));
/**
* @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;
/** 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();
let match;
const seen = new Set();
while ((match = re2.exec(source)) !== null) {
const [, name, table, body, extras] = match;
if (seen.has(name)) continue;
seen.add(name);
models.push(parseTableBody(name, table, body, extras ?? ""));
}
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("//") ||
trimmed.startsWith("@@") ||
trimmed.startsWith("///")
) {
continue;
}
const m = trimmed.match(/^(\w+)\s+(.+)$/);
if (!trimmed || trimmed.startsWith("//")) continue;
const m = trimmed.match(/^(\w+)\s*:\s*(.+?),?\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 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--;
}
}
}
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,
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,
});
}
// model-level keys
const compIds = body.match(/@@id\(\s*\[([^\]]+)\]\s*\)/)?.[1];
const ids = compIds
? compIds.split(",").map((s) => s.trim().replace(/`/g, ""))
: null;
let ids = null;
const uniques = [];
for (const u of body.matchAll(/@@unique\(\s*\[([^\]]+)\]\s*/g)) {
uniques.push(u[1].split(",").map((s) => s.trim().replace(/`/g, "")));
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 = modelBlocks.map(parseModel);
const models = parseDrizzleTables(schemaSource);
// ---------- Introspect live MySQL ----------
let url;
@@ -152,21 +193,20 @@ function quote(v) {
/** 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"];
: (field.enumValues ??
(field.prismaType === "users_gender"
? ["M", "F"]
: ["account", "ip", "machine", "super"]));
return `mysqlEnum(${quote(col)}, ${JSON.stringify(values)})`;
}
@@ -179,7 +219,8 @@ function columnExpr(field, dbCol) {
break;
case "tinyint": {
const isBool =
dbCol.column_type === "tinyint(1)" && field.prismaType === "Boolean";
dbCol.column_type === "tinyint(1)" &&
(field.isBoolean || field.prismaType === "Boolean");
if (isBool) {
use("boolean");
type = `boolean(${quote(col)})`;
@@ -209,8 +250,6 @@ function columnExpr(field, dbCol) {
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(
@@ -284,14 +323,13 @@ function columnExpr(field, dbCol) {
break;
default:
console.warn(
`[schema-gen] WARN unhandled DB type "${dbCol?.data_type}" for ${col} (prisma:${field.prismaType})`,
`[schema-gen] WARN unhandled DB type "${dbCol?.data_type}" for ${col}`,
);
use("varchar");
type = `varchar(${quote(col)}, { length: 255 })`;
}
expr += type;
return expr;
return type;
}
function modifiers(field, dbCol) {
@@ -306,55 +344,35 @@ function modifiers(field, dbCol) {
} 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);
expr += emitDefault(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--;
}
}
function emitDefault(field, dbCol) {
const content = field.defaultContent;
if (!content) return "";
if (content === "now()") {
if (content === "sql`CURRENT_TIMESTAMP`" || content.includes("CURRENT_TIMESTAMP")) {
markSqlUsed();
return `.default(sql\`CURRENT_TIMESTAMP\`)`;
}
if (content === "autoincrement()" || content.startsWith("dbgenerated("))
return "";
if (content === "true" || content === "false") return `.default(${content})`;
if (/^-?\d+n$/.test(content)) 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)})`;
if (
(content.startsWith('"') && content.endsWith('"')) ||
(content.startsWith("'") && content.endsWith("'"))
) {
return `.default(${JSON.stringify(content.slice(1, -1))})`;
}
return `.default(${content})`;
}
function modelTable(model) {
@@ -362,8 +380,6 @@ function modelTable(model) {
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(
@@ -407,96 +423,23 @@ ${uniqueRows.join("\n")}
],
);`;
}
return `export const ${model.name} = mysqlTable(
"${model.table}",
{
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;
if (field.enumValues) {
use("mysqlEnum");
return `mysqlEnum(${quote(name)}, ${JSON.stringify(field.enumValues)})`;
}
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 })`;
if (field.isBoolean || field.prismaType === "Boolean") {
use("boolean");
return `boolean(${quote(name)})`;
}
use("varchar");
return `varchar(${quote(name)}, { length: 255 })`;
}
// ---------- Generate file ----------
@@ -532,8 +475,8 @@ const IMPORTABLE = [
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.",
"// 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";',
@@ -563,13 +506,15 @@ const helpers = [
"",
].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 mirror prisma/schema.prisma (camelCase); column names are the
// real MySQL columns. Run \`pnpm db:schema:generate\` after schema changes.
import {
// 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";
${usedSql ? `import { sql } from "drizzle-orm";\n` : ""}
${helpers}${body}
`;
+1 -1
View File
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
describe("radio columns migration", () => {
it("adds every column idempotently for partially migrated databases", () => {
const sql = readFileSync(
resolve("prisma/migrations/0009_radio_contests_giveaways_columns.sql"),
resolve("drizzle/migrations/0009_radio_contests_giveaways_columns.sql"),
"utf8",
);
const additions = sql.match(/ADD COLUMN(?! IF NOT EXISTS)/gi) ?? [];