274 lines
10 KiB
TypeScript
274 lines
10 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, 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<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));
|
|
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<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 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);
|
|
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 <check|migrate|rollback|status|generate|seed|studio [table]|new [name] [--from-models]> [--db=<name>]",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
} finally {
|
|
await db.close();
|
|
}
|
|
}
|