Files
EpicNext-Cms/scripts/apply-migrations.ts
T
Simo b5bc7f6daf Rebuild atomcms-next foundation on Prisma (align to habbo-next reference)
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.
2026-06-27 15:26:40 +02:00

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);
});