import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import type { Db } from "./connect"; const MIGRATIONS_DIR = join(import.meta.dir, "..", "migrations"); function statements(text: string): string[] { const noComments = text.split("\n").filter((l) => !l.trim().startsWith("--")).join("\n"); return noComments.split(";").map((s) => s.trim()).filter(Boolean); } /** Migration folders contributed by feature packages, keyed by package name. */ export type PackageMigrations = Record; /** * Applies pending migrations: core (`packages/db/migrations`) first, then each package's folders in alphabetical * order of package name, files in name order within a folder. `schema_migrations` is keyed by file name, so file * names must be unique across packages (each package prefixes its files, e.g. `0001_learner_auth.sql`). */ export async function migrate(db: Db, packages: PackageMigrations = {}): Promise { await db.sql.unsafe("CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)"); const done = new Set((await db.sql`SELECT name FROM schema_migrations`).map((r: { name: string }) => r.name)); const dirs = [MIGRATIONS_DIR, ...Object.keys(packages).sort().flatMap((k) => packages[k]!)]; const seen = new Map(); const applied: string[] = []; for (const dir of dirs) { for (const name of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) { const other = seen.get(name); if (other && other !== dir) throw new Error(`migration ${name} exists in both ${other} and ${dir}`); seen.set(name, dir); if (done.has(name)) continue; const stmts = statements(readFileSync(join(dir, name), "utf8")); await db.sql.begin(async (tx) => { for (const s of stmts) await tx.unsafe(s); await tx`INSERT INTO schema_migrations (name, applied_at) VALUES (${name}, ${new Date().toISOString()})`; }); done.add(name); applied.push(name); } } return applied; }