/** * `wrnexus db [--db=]` — database migrations & tooling. * * wrnexus db new [--from-models] scaffold a migration (from TS models) * wrnexus db migrate apply all pending migrations * wrnexus db rollback revert the last applied migration * wrnexus db status list applied / pending migrations * wrnexus db generate regenerate typed queries * wrnexus db seed run the seed script * wrnexus db studio [table] inspect tables * * Without `--db`, commands target the DEFAULT database (`db` in wrnexus.config.ts), * with files under `app/db/`. With `--db=`, they target the named database * (`databases.`), with files under `app/db//`. */ import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { loadAppConfig, type AppConfig } from "@wrnexus/styles"; import { generateQueriesFile, analyzeMigrations, loadMigrations, migrate, parseQueries, rollback, scaffoldMigration, status, type Dialect, type Model, type ModelRef, } from "@wrnexus/db"; import { connectFromConfig } from "@wrnexus/db/connect"; function dialectOf(driver: string | undefined): Dialect { return driver === "postgres" || driver === "mysql" ? driver : "sqlite"; } /** The directory holding a database's schema/migrations/queries. */ function dbBaseOf(appDir: string, dbName: string | null): string { return dbName ? join(appDir, "db", dbName) : join(appDir, "db"); } /** List user tables for the connected database (dialect-aware introspection). */ async function listTables(db: import("@wrnexus/db").Db): Promise { const dialect = db.driver.dialect; const sql = dialect === "postgres" ? "SELECT tablename AS name FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename" : dialect === "mysql" ? "SELECT table_name AS name FROM information_schema.tables WHERE table_schema = DATABASE() ORDER BY table_name" : "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name"; const rows = await db.all<{ name: string }>(sql); return rows.map((r) => r.name).filter((n) => n !== "_wrn_migrations"); } function isModel(value: unknown): value is Model { const m = value as Partial | null; return ( !!m && typeof m === "object" && typeof m.name === "string" && typeof m.parse === "function" && typeof m.describe === "function" && !!m.columns ); } /** Load model refs from a database's `schema.ts` (dbBase is app/db or app/db/). */ async function loadModelRefs(dbBase: string): Promise { const schemaFile = join(dbBase, "schema.ts"); if (!existsSync(schemaFile)) return []; const mod = (await import(pathToFileURL(schemaFile).href)) as Record; return Object.entries(mod) .filter(([, value]) => isModel(value)) .map(([varName, model]) => ({ varName, model: model as Model })); } async function loadModels(dbBase: string): Promise { return (await loadModelRefs(dbBase)).map((r) => r.model); } /** * Regenerate one database's `queries.gen.ts` from its `queries/*.sql`. Returns the * number of queries generated, or -1 if there is no queries directory. `dbName` * selects a named database (files under app/db//); null = the default. */ export async function regenerateQueries( appDir: string, driver: string | undefined, dbName: string | null = null, ): Promise { const dbBase = dbBaseOf(appDir, dbName); const queriesDir = join(dbBase, "queries"); if (!existsSync(queriesDir)) return -1; const queries = readdirSync(queriesDir) .filter((f) => f.endsWith(".sql")) .flatMap((f) => parseQueries(readFileSync(join(queriesDir, f), "utf8"))); const refs = await loadModelRefs(dbBase); const code = generateQueriesFile(queries, refs, dialectOf(driver)); writeFileSync(join(dbBase, "queries.gen.ts"), code, "utf8"); return queries.length; } /** Regenerate typed queries for the default database and every named one. */ export async function regenerateAllQueries(appDir: string, config: AppConfig): Promise { await regenerateQueries(appDir, config.db?.driver, null); for (const [name, cfg] of Object.entries(config.databases ?? {})) { await regenerateQueries(appDir, cfg.driver, name); } } export async function runDbCommand( appRoot: string, sub: string | undefined, args: string[], ): Promise { const root = resolve(appRoot); const appDir = join(root, "app"); const config = await loadAppConfig(root); // --db= targets a named database + its app/db// folder. const dbFlag = args.find((a) => a.startsWith("--db=")); const dbName = dbFlag ? (dbFlag.split("=")[1] ?? "") : null; const dbConfig = dbName ? config.databases?.[dbName] : config.db; const dbBase = dbBaseOf(appDir, dbName); const migrationsDir = join(dbBase, "migrations"); const label = dbName ? ` (db: ${dbName})` : ""; const safetyIssues = analyzeMigrations(loadMigrations(migrationsDir)); if (sub === "check") { if (!safetyIssues.length) console.log(`✓ Migration rollout safety check passed${label}.`); for (const issue of safetyIssues) { console[issue.severity === "error" ? "error" : "warn"]( `${issue.code} ${issue.migration}: ${issue.statement}\n ${issue.recommendation}`, ); } if (safetyIssues.some((issue) => issue.severity === "error")) process.exitCode = 1; return; } if (dbName && !config.databases?.[dbName]) { console.error( `No database named '${dbName}' in wrnexus.config.ts. ` + `Add it under databases: { ${dbName}: { driver, url } }.`, ); process.exit(1); } if (sub === "new") { const name = args.find((a) => !a.startsWith("--")) ?? "migration"; const fromModels = args.includes("--from-models"); const models = fromModels ? await loadModels(dbBase) : []; if (fromModels && models.length === 0) { console.warn(`[wrnexus] no models found in ${join(dbBase, "schema.ts")}`); } console.log( `✓ Created ${scaffoldMigration(migrationsDir, name, dialectOf(dbConfig?.driver), models)}`, ); return; } if (sub === "generate") { const count = await regenerateQueries(appDir, dbConfig?.driver, dbName); if (count < 0) console.warn(`[wrnexus] no ${join(dbBase, "queries")} directory`); else console.log(`✓ Generated ${join(dbBase, "queries.gen.ts")} (${count} queries)`); return; } if (!dbConfig) { console.error( "No `db` config in wrnexus.config.ts. Add: db: { driver: 'sqlite', url: 'file:./dev.db' }", ); process.exit(1); } const db = connectFromConfig(dbConfig, root); try { switch (sub) { case "seed": { const seedFile = join(dbBase, "seed.ts"); if (!existsSync(seedFile)) { console.warn(`[wrnexus] no ${seedFile}`); break; } const mod = (await import(pathToFileURL(seedFile).href)) as { default?: (db: unknown) => Promise; seed?: (db: unknown) => Promise; }; const fn = mod.default ?? mod.seed; if (typeof fn !== "function") { console.error(`${seedFile} must export a default async function(db).`); process.exit(1); } await fn(db); console.log(`✓ Seeded${label}`); break; } case "studio": { const target = args.find((a) => !a.startsWith("--")); const tables = await listTables(db); if (target) { if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(target)) { console.error(`Invalid table name: ${target}`); process.exit(1); } if (!tables.includes(target)) { console.error(`No such table: ${target}. Available: ${tables.join(", ") || "(none)"}`); process.exit(1); } const rows = await db.all(`SELECT * FROM ${target} LIMIT 50`); console.log(`\n${target}${label} — first ${rows.length} row(s):`); console.table(rows); } else if (tables.length === 0) { console.log( `No tables found${label}. Run \`wrnexus db migrate${dbFlag ? " " + dbFlag : ""}\` first.`, ); } else { console.log(`\nTables${label}:`); for (const t of tables) { const count = await db.one<{ n: number }>(`SELECT COUNT(*) AS n FROM ${t}`); console.log(` ${t.padEnd(24)} ${Number(count?.n ?? 0)} rows`); } console.log("\nInspect one with: wrnexus db studio "); } break; } case "migrate": { const pending = new Set( (await status(db, migrationsDir)) .filter((migration) => !migration.applied) .map((migration) => migration.name), ); const pendingIssues = safetyIssues.filter((issue) => pending.has(issue.migration)); const blockers = pendingIssues.filter((issue) => issue.severity === "error"); for (const issue of pendingIssues.filter((item) => item.severity === "warning")) { console.warn(`${issue.code} ${issue.migration}: ${issue.recommendation}`); } if (blockers.length && !args.includes("--allow-breaking")) { throw new Error( `WRN-DB-UNSAFE-MIGRATION: ${blockers.length} breaking rollout operation(s) found. Run 'wrnexus db check' and use an expand/contract migration; --allow-breaking explicitly overrides this gate.`, ); } const applied = await migrate(db, migrationsDir); console.log( applied.length ? `✓ Applied ${applied.length}${label}:\n ${applied.join("\n ")}` : `Already up to date${label}.`, ); break; } case "rollback": { const name = await rollback(db, migrationsDir); console.log(name ? `✓ Rolled back ${name}${label}` : `Nothing to roll back${label}.`); break; } case "status": { const rows = await status(db, migrationsDir); if (rows.length === 0) console.log(`No migrations found in ${migrationsDir}.`); else for (const r of rows) console.log(` [${r.applied ? "x" : " "}] ${r.name}`); break; } default: console.error( "Usage: wrnexus db [--db=]", ); process.exit(1); } } finally { await db.close(); } }