chore(db): finish Prisma cutover to Drizzle Kit tooling
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:
1 parent
d8199ea1e4
commit
ba82789166
47 files changed
+365
-2787
No files matched your search
@@ -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
@@ -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}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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) ?? [];
|
||||
|
||||
Reference in new issue
Block a user