first commit
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* `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,
|
||||
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})` : "";
|
||||
|
||||
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 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 <migrate|rollback|status|generate|seed|studio [table]|new [name] [--from-models]> [--db=<name>]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user