Replace the superseded Drizzle monorepo scaffold with a single-app Prisma 7 data layer, mirroring the proven habbo-next approach to the shared Arcturus emulator DB (introspect/conform only, idempotent SQL migrations via a custom runner; never migrate-diff). - prisma/schema.prisma: User, UsersCurrency (composite PK), Ban, WebsiteSetting mapped to AtomCMS's default.sql columns; client generated to src/generated/prisma - src/lib/prisma.ts: PrismaMariaDb adapter singleton; src/env.ts zod env - src/lib/services: siteSettings (cached setting() equivalent) + CurrencyType enum - prisma/migrations + scripts/apply-migrations.ts: idempotent CMS-table runner Verified: prisma generate OK, tsc --noEmit exit 0, vitest 6/6 pass.
151 lines
4.8 KiB
TypeScript
151 lines
4.8 KiB
TypeScript
/**
|
|
* DB migration runner for the manual SQL files in prisma/migrations/.
|
|
*
|
|
* Why a custom runner: the database is shared with the Arcturus emulator, so we
|
|
* apply CMS-only schema changes via hand-written idempotent SQL (every file is
|
|
* `IF NOT EXISTS`-guarded) instead of `prisma migrate dev`, whose auto-diff
|
|
* would try to DROP emulator-owned tables. An `_applied_migrations` audit table
|
|
* tracks what ran so the workflow doesn't rely on memory.
|
|
*
|
|
* pnpm db:migrate run pending migrations
|
|
* pnpm db:migrate:status print pending vs applied
|
|
* pnpm db:migrate --mark <f> mark an already-applied file without running it
|
|
*/
|
|
|
|
import "dotenv/config";
|
|
import { spawnSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { readdir, readFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { prisma } from "@/lib/prisma";
|
|
|
|
const MIGRATIONS_DIR = join(process.cwd(), "prisma", "migrations");
|
|
|
|
async function ensureAuditTable(): Promise<void> {
|
|
await prisma.$executeRawUnsafe(`
|
|
CREATE TABLE IF NOT EXISTS _applied_migrations (
|
|
filename VARCHAR(255) NOT NULL,
|
|
applied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
sha256 CHAR(64) NULL,
|
|
PRIMARY KEY (filename)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
`);
|
|
}
|
|
|
|
async function appliedSet(): Promise<Set<string>> {
|
|
const rows = await prisma.$queryRawUnsafe<{ filename: string }[]>(
|
|
"SELECT filename FROM _applied_migrations",
|
|
);
|
|
return new Set(rows.map((r) => r.filename));
|
|
}
|
|
|
|
async function listMigrations(): Promise<string[]> {
|
|
const files = await readdir(MIGRATIONS_DIR);
|
|
return files.filter((f) => f.endsWith(".sql")).sort();
|
|
}
|
|
|
|
async function sha256(filename: string): Promise<string> {
|
|
const content = await readFile(join(MIGRATIONS_DIR, filename));
|
|
return createHash("sha256").update(content).digest("hex");
|
|
}
|
|
|
|
function runPrismaDbExecute(filename: string): boolean {
|
|
const file = join(MIGRATIONS_DIR, filename);
|
|
// shell:true so `npx` resolves to npx.cmd on Windows (direct .cmd spawn is
|
|
// blocked there since CVE-2024-27980). Quote the path for spaces.
|
|
const result = spawnSync("npx", ["prisma", "db", "execute", "--file", `"${file}"`], {
|
|
stdio: "inherit",
|
|
env: process.env,
|
|
shell: true,
|
|
});
|
|
return result.status === 0;
|
|
}
|
|
|
|
async function recordApplied(filename: string): Promise<void> {
|
|
const hash = await sha256(filename);
|
|
await prisma.$executeRawUnsafe(
|
|
"INSERT INTO _applied_migrations (filename, sha256) VALUES (?, ?) " +
|
|
"ON DUPLICATE KEY UPDATE applied_at = CURRENT_TIMESTAMP(3), sha256 = VALUES(sha256)",
|
|
filename,
|
|
hash,
|
|
);
|
|
}
|
|
|
|
async function statusOnly(): Promise<void> {
|
|
await ensureAuditTable();
|
|
const applied = await appliedSet();
|
|
const files = await listMigrations();
|
|
if (files.length === 0) {
|
|
console.log("No migrations found in prisma/migrations/.");
|
|
return;
|
|
}
|
|
console.log("Status:");
|
|
for (const f of files) {
|
|
const isApplied = applied.has(f);
|
|
console.log(` ${isApplied ? "OK" : " ."} ${f}${isApplied ? "" : " (PENDING)"}`);
|
|
}
|
|
const pending = files.filter((f) => !applied.has(f));
|
|
console.log(`\n${pending.length} pending / ${files.length} total`);
|
|
}
|
|
|
|
async function markApplied(filename: string): Promise<void> {
|
|
await ensureAuditTable();
|
|
const files = await listMigrations();
|
|
if (!files.includes(filename)) {
|
|
console.error(`File not found in prisma/migrations/: ${filename}`);
|
|
process.exit(1);
|
|
}
|
|
await recordApplied(filename);
|
|
console.log(`Marked ${filename} as applied (no SQL was run).`);
|
|
}
|
|
|
|
async function applyPending(): Promise<void> {
|
|
await ensureAuditTable();
|
|
const applied = await appliedSet();
|
|
const files = await listMigrations();
|
|
if (files.length === 0) {
|
|
console.log("No migrations found in prisma/migrations/.");
|
|
return;
|
|
}
|
|
let ran = 0;
|
|
for (const f of files) {
|
|
if (applied.has(f)) {
|
|
console.log(`OK ${f} (already applied)`);
|
|
continue;
|
|
}
|
|
console.log(`-> applying ${f} ...`);
|
|
if (!runPrismaDbExecute(f)) {
|
|
console.error(`FAILED ${f}; aborting`);
|
|
process.exit(1);
|
|
}
|
|
await recordApplied(f);
|
|
ran++;
|
|
}
|
|
console.log(ran === 0 ? "\nNothing to do — DB already in sync." : `\nApplied ${ran} migration(s).`);
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const args = process.argv.slice(2);
|
|
try {
|
|
if (args[0] === "--status") {
|
|
await statusOnly();
|
|
} else if (args[0] === "--mark") {
|
|
const filename = args[1];
|
|
if (!filename) {
|
|
console.error("Usage: pnpm db:migrate --mark <filename>");
|
|
process.exit(1);
|
|
}
|
|
await markApplied(filename);
|
|
} else {
|
|
await applyPending();
|
|
}
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error("FAILED:", e instanceof Error ? e.message : e);
|
|
process.exit(1);
|
|
});
|