Files
WRNexusJS/packages/cli/src/db.ts
T
ClintchizandClaude Opus 5 b807f7b5a7
Quality / quality (ubuntu-latest) (push) Failing after 6m4s
Quality / quality (windows-latest) (push) Canceled after 0s
fix(db): refuse to roll back a migration that defines no down section
rollback ran a migration's down section when it had one and then deleted
its tracking row either way. A migration that changed the schema without a
`-- +down` section was therefore recorded as rolled back while its change
stayed in the database, and the next migrate replayed statements such as
ALTER TABLE ADD COLUMN that cannot run twice, leaving the database stuck
between the two states with no way forward or back.

It now refuses that case with WRN-DB-MIGRATION-IRREVERSIBLE and leaves the
tracking row alone, so the database stays consistent and the operator
chooses: add a down section, or write a new migration that reverses the
change. The check runs before the lock is taken, so a dry run reports the
same refusal rather than promising a rollback that would fail.

A migration whose up is empty still rolls back by dropping its tracking
row, since there is nothing to undo. That keeps placeholder migrations
working, and `wrnexus db rollback` prints the refusal as a message rather
than a stack trace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 09:57:43 +05:30

353 lines
13 KiB
TypeScript

/**
* `wrnexus db <cmd> [--db=<name>]` — database migrations & tooling.
*
* wrnexus db new <name> [--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=<name>`, they target the named database
* (`databases.<name>`), with files under `app/db/<name>/`.
*/
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import {
createPluginRunner,
discoverPlugins,
type PackageMigrationDefinition,
} from "@wrnexus/plugin";
import { loadAppConfig, type AppConfig } from "@wrnexus/styles";
import { writeGeneratedFile } from "./write-generated.ts";
import {
generateQueriesFile,
analyzeMigrations,
appliedMigrations,
applyMigrations,
loadMigrations,
migrate,
parseMigration,
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");
}
function packageMigrationName(definition: PackageMigrationDefinition, name?: string): string {
const clean = (value: string) => value.trim().replace(/[^a-zA-Z0-9_.-]+/g, "_");
return name ? `${clean(definition.id)}__${clean(name)}` : clean(definition.id);
}
function resolvePackageMigrations(
definitions: readonly PackageMigrationDefinition[],
database?: string,
) {
return definitions.flatMap((definition) => {
if ((definition.database?.trim() || "default") !== (database?.trim() || "default")) return [];
if (definition.source !== undefined) {
return [parseMigration(packageMigrationName(definition), definition.source)];
}
if (!definition.entry || !existsSync(definition.entry)) {
throw new Error(`WRN-PLUGIN-MIGRATION-MISSING: ${definition.id} has no readable entry.`);
}
if (statSync(definition.entry).isDirectory()) {
return loadMigrations(definition.entry).map((migration) => ({
...migration,
name: packageMigrationName(definition, migration.name),
}));
}
return [
parseMigration(
packageMigrationName(definition, basename(definition.entry, ".sql")),
readFileSync(definition.entry, "utf8"),
),
];
});
}
async function packageMigrations(root: string, config: AppConfig, database?: string) {
const discovered = await discoverPlugins(root, config.plugins, {
includeDevDependencies: true,
strict: true,
});
const runner = createPluginRunner(discovered, {
root,
mode: "development",
command: "dev",
metadata: new Map(),
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
});
await runner.configure(config as Record<string, unknown>);
await runner.configResolved(config as Readonly<Record<string, unknown>>);
return resolvePackageMigrations((await runner.contributions()).migrations, database);
}
/** List user tables for the connected database (dialect-aware introspection). */
async function listTables(db: import("@wrnexus/db").Db): Promise<string[]> {
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<Model> | 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/<name>). */
async function loadModelRefs(dbBase: string): Promise<ModelRef[]> {
const schemaFile = join(dbBase, "schema.ts");
if (!existsSync(schemaFile)) return [];
const mod = (await import(pathToFileURL(schemaFile).href)) as Record<string, unknown>;
return Object.entries(mod)
.filter(([, value]) => isModel(value))
.map(([varName, model]) => ({ varName, model: model as Model }));
}
async function loadModels(dbBase: string): Promise<Model[]> {
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/<name>/); null = the default.
*/
export async function regenerateQueries(
appDir: string,
driver: string | undefined,
dbName: string | null = null,
): Promise<number> {
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));
writeGeneratedFile(join(dbBase, "queries.gen.ts"), code);
return queries.length;
}
/** Regenerate typed queries for the default database and every named one. */
export async function regenerateAllQueries(appDir: string, config: AppConfig): Promise<void> {
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<void> {
const root = resolve(appRoot);
const appDir = join(root, "app");
const config = await loadAppConfig(root);
// --db=<name> targets a named database + its app/db/<name>/ 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 contributedMigrations = await packageMigrations(root, config, dbName ?? undefined);
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<void>;
seed?: (db: unknown) => Promise<void>;
};
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 <table>");
}
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)),
...(await applyMigrations(db, contributedMigrations)),
];
console.log(
applied.length
? `✓ Applied ${applied.length}${label}:\n ${applied.join("\n ")}`
: `Already up to date${label}.`,
);
break;
}
case "rollback": {
let name: string | null;
try {
name = await rollback(db, migrationsDir);
} catch (error) {
// A migration with no down section is refused rather than half-undone.
// That is an operator decision to make, not a stack trace to read.
const message = error instanceof Error ? error.message : String(error);
if (!message.startsWith("WRN-DB-MIGRATION-IRREVERSIBLE")) throw error;
console.error(`✗ ${message.replace("WRN-DB-MIGRATION-IRREVERSIBLE: ", "")}`);
process.exitCode = 1;
break;
}
console.log(name ? `✓ Rolled back ${name}${label}` : `Nothing to roll back${label}.`);
break;
}
case "status": {
const rows = await status(db, migrationsDir);
const applied = new Set(await appliedMigrations(db));
const packageRows = contributedMigrations.map(({ name }) => ({
name,
applied: applied.has(name),
}));
const allRows = [...rows, ...packageRows];
if (allRows.length === 0) console.log(`No migrations found in ${migrationsDir}.`);
else for (const r of allRows) console.log(` [${r.applied ? "x" : " "}] ${r.name}`);
break;
}
default:
console.error(
"Usage: wrnexus db <check|migrate|rollback|status|generate|seed|studio [table]|new [name] [--from-models]> [--db=<name>]",
);
process.exit(1);
}
} finally {
await db.close();
}
}