Files
WRNexusJS/packages/cli/src/db.ts
T
Clintchiz 586a6db8ff
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s
release: WRNexusJS 0.8.0
2026-08-02 23:18:51 +05:30

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 !== "_wire_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();
}
}