UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

693 lines (607 loc) 21.7 kB
import { $inject, AlephaError, z } from "alepha"; import { $command } from "alepha/command"; import { $logger } from "alepha/logger"; import type { DatabaseProvider, DrizzleKitProvider, RepositoryProvider, } from "alepha/orm"; import { FileSystemProvider } from "alepha/system"; import { AppEntryProvider } from "../providers/AppEntryProvider.ts"; import { AlephaCliUtils } from "../services/AlephaCliUtils.ts"; import { PackageManagerUtils } from "../services/PackageManagerUtils.ts"; import { ViteUtils } from "../services/ViteUtils.ts"; const drizzleCommandFlags = z.object({ provider: z .text({ description: "Database provider name to target (e.g., 'postgres', 'sqlite')", }) .optional(), }); export class DbCommand { protected readonly log = $logger(); protected readonly fs = $inject(FileSystemProvider); protected readonly utils = $inject(AlephaCliUtils); protected readonly pm = $inject(PackageManagerUtils); protected readonly entryProvider = $inject(AppEntryProvider); protected readonly viteUtil = $inject(ViteUtils); /** * Check if database migrations are up to date. */ protected readonly check = $command({ name: "check", mode: true, description: "Check if migration files are up to date", args: z .text({ title: "path", description: "Path to the Alepha server entry file", }) .optional(), flags: drizzleCommandFlags, handler: async ({ flags, root }) => { const rootDir = root; this.log.debug(`Using project root: ${rootDir}`); const entry = await this.entryProvider.getAppEntry(root); const alepha = await this.utils.loadAlephaFromServerEntryFile({ mode: "development", entry, }); const repositoryProvider = alepha.inject<RepositoryProvider>("RepositoryProvider"); const drizzleKitProvider = alepha.inject<DrizzleKitProvider>("DrizzleKitProvider"); const accepted = new Set<string>([]); const drifted: Array<{ provider: string; statements: string[] }> = []; for (const primitive of repositoryProvider.getRepositories()) { const provider = primitive.provider; const providerName = provider.name; if (accepted.has(providerName)) { continue; } accepted.add(providerName); // Honor the --provider filter (previously declared but ignored here). if (flags.provider && flags.provider !== providerName) { continue; } const migrationDir = this.fs.join(rootDir, "migrations", providerName); const journalBuffer = await this.fs .readFile(this.fs.join(migrationDir, "meta", "_journal.json")) .catch(() => null); // `continue`, not `return`: a journal-less or clean provider must not // exit the whole handler and mask drift in the remaining providers // (e.g. a clean Postgres hiding a drifted SQLite/D1 migration set). if (!journalBuffer) { this.log.info(`No migration journal found for '${providerName}'.`); continue; } const journal = JSON.parse(journalBuffer.toString("utf-8")); const lastMigration = journal.entries[journal.entries.length - 1]; const snapshotBuffer = await this.fs.readFile( this.fs.join( migrationDir, "meta", `${String(lastMigration.idx).padStart(4, "0")}_snapshot.json`, ), ); const lastSnapshot = JSON.parse(snapshotBuffer.toString("utf-8")); const { statements: migrationStatements } = await drizzleKitProvider.generateMigration(provider, lastSnapshot, { withoutSchema: true, }); if (migrationStatements.length === 0) { this.log.info(`No changes detected for '${providerName}'.`); continue; } drifted.push({ provider: providerName, statements: migrationStatements, }); } // Report drift across ALL providers before failing. if (drifted.length > 0) { for (const { provider: providerName, statements } of drifted) { this.log.info(""); this.log.info(`Detected migration statements for '${providerName}':`); this.log.info(""); for (const stmt of statements) { this.log.info(stmt); } this.log.info(""); this.log.info(`At least ${statements.length} change(s) detected.`); } this.log.info(""); this.log.info( "Please, run 'alepha db migrations generate' to update the migration files.", ); this.log.info(""); throw new AlephaError( `Database migrations are not up to date (${drifted .map((d) => d.provider) .join(", ")}).`, ); } }, }); /** * Generate database migration files */ protected readonly create = $command({ name: "create", mode: true, description: "Generate migration files from current schema", args: z .text({ title: "path", description: "Path to the Alepha server entry file", }) .optional(), flags: drizzleCommandFlags.extend({ custom: z .boolean() .describe( "Generate an empty migration file for custom SQL (e.g., for data migrations or manual adjustments)", ) .optional(), name: z .text({ description: "Name for the generated migration file", }) .optional(), }), handler: async ({ args, flags, root }) => { const parts: string[] = []; if (flags.custom) parts.push(`--custom=1`); if (flags.name) parts.push(`--name=${flags.name}`); const commandFlags = parts.length > 0 ? parts.join(" ") : undefined; await this.runDrizzleKitCommand({ root, args, command: "generate", commandFlags, provider: flags.provider, logMessage: (providerName, dialect) => `Generate '${providerName}' migrations (${dialect}) ...`, }); }, }); /** * Push database schema changes directly to the database */ protected readonly push = $command({ name: "push", mode: true, description: "Push database schema changes directly to the database", args: z .text({ title: "path", description: "Path to the Alepha server entry file", }) .optional(), flags: drizzleCommandFlags.extend({ dryRun: z .boolean() .describe("Preview SQL statements without executing them") .optional(), }), handler: async ({ root, args, flags }) => { if (flags.dryRun) { const entry = await this.entryProvider.getAppEntry(root); const alepha = await this.utils.loadAlephaFromServerEntryFile({ mode: "development", entry, }); const drizzleKitProvider = alepha.inject<DrizzleKitProvider>("DrizzleKitProvider"); const repositoryProvider = alepha.inject<RepositoryProvider>("RepositoryProvider"); const accepted = new Set<string>([]); for (const primitive of repositoryProvider.getRepositories()) { const provider = primitive.provider; const providerName = provider.name; if (accepted.has(providerName)) continue; accepted.add(providerName); if (flags.provider && flags.provider !== providerName) continue; this.log.info(""); this.log.info( `Dry run for '${providerName}' (${provider.dialect}) ...`, ); await (provider as any).connect(); try { const result = await drizzleKitProvider.dryRunPush(provider); if (result.statements.length === 0) { this.log.info("No changes detected."); } else { if (result.hasDataLoss) { this.log.warn("WARNING: These changes would cause data loss!"); for (const warning of result.warnings) { this.log.warn(` ${warning}`); } } this.log.info(""); this.log.info( `${result.statements.length} statement(s) would be executed:`, ); this.log.info(""); for (const stmt of result.statements) { this.log.info(stmt); } } } finally { await (provider as any).close(); } } return; } await this.runDrizzleKitCommand({ root, args, command: "push", provider: flags.provider, logMessage: (providerName, dialect) => `Push '${providerName}' schema (${dialect}) ...`, }); }, }); /** * Apply pending database migrations */ protected readonly apply = $command({ name: "apply", mode: true, description: "Apply pending migrations to the database", args: z .text({ title: "path", description: "Path to the Alepha server entry file", }) .optional(), flags: drizzleCommandFlags, handler: async ({ root, run, mode }) => { const entry = await this.entryProvider.getAppEntry(root); await run({ name: `db migrate (${mode || "development"})`, handler: async () => { process.env.MIGRATE = "true"; const alepha = await this.viteUtil.runAlepha({ entry, mode: "production", }); await alepha.start(); }, }); }, }); /** * Launch Drizzle Studio database browser */ protected readonly studio = $command({ name: "studio", mode: true, description: "Launch Drizzle Studio database browser", args: z .text({ title: "path", description: "Path to the Alepha server entry file", }) .optional(), flags: drizzleCommandFlags, handler: async ({ root, args, flags }) => { await this.runDrizzleKitCommand({ root, args, command: "studio", provider: flags.provider, logMessage: (providerName, dialect) => `Launch Studio for '${providerName}' (${dialect}) ...`, }); }, }); /** * Parent command for migration operations. */ protected readonly migrations = $command({ name: "migrations", aliases: ["m"], description: "Manage database migrations", children: [this.check, this.create, this.apply], handler: async ({ help }) => { help(); }, }); /** * Parent command for database operations. */ public readonly db = $command({ name: "db", description: "Database management commands", children: [this.migrations, this.push, this.studio], handler: async ({ help }) => { help(); }, }); /** * Run a drizzle-kit command for all database providers in an Alepha instance. */ public async runDrizzleKitCommand(options: { root: string; args?: string; command: string; commandFlags?: string; provider?: string; logMessage: (providerName: string, dialect: string) => string; }): Promise<void> { const rootDir = options.root; this.log.debug(`Using project root: ${rootDir}`); const entry = await this.entryProvider.getAppEntry(rootDir); const alepha = await this.utils.loadAlephaFromServerEntryFile({ mode: "development", entry, }); const drizzleKitProvider = alepha.inject<DrizzleKitProvider>("DrizzleKitProvider"); const repositoryProvider = alepha.inject<RepositoryProvider>("RepositoryProvider"); const accepted = new Set<string>([]); for (const primitive of repositoryProvider.getRepositories()) { const provider = primitive.provider; const providerName = provider.name; const dialect = provider.dialect; if (providerName === "") { continue; } if (accepted.has(providerName)) { continue; } accepted.add(providerName); // Skip if provider filter is set and doesn't match if (options.provider && options.provider !== providerName) { this.log.debug( `Skipping provider '${providerName}' (filter: ${options.provider})`, ); continue; } this.log.info(""); this.log.info(options.logMessage(providerName, dialect)); const drizzleConfigJsPath = await this.prepareDrizzleConfig({ kit: drizzleKitProvider, provider, providerName, providerUrl: provider.url, providerDriver: provider.driver, dialect, entry: this.fs.join(rootDir, entry.server), rootDir, command: options.command, }); const migrationsDir = this.fs.join(rootDir, "migrations", providerName); const isGenerate = options.command === "generate"; // Snapshot the directory so the destructive-migration guard below only // inspects files THIS run created, not the whole applied history. const before = isGenerate ? new Set(await this.fs.ls(migrationsDir).catch(() => [])) : new Set<string>(); const flags = options.commandFlags ? ` ${options.commandFlags}` : ""; // drizzle-kit ships embedded in `alepha` — resolve and run it from // alepha's own install, so the project never declares it. // `global: true` because the command starts with `node` (a system // binary) — without it, exec tries to resolve `node` as a // node_modules bin and fails. const drizzleKit = this.utils.resolveBin("drizzle-kit"); await this.utils.exec( `node "${drizzleKit}" ${options.command} --config=${drizzleConfigJsPath}${flags}`, { global: true, env: { ALEPHA_CLI_IMPORT: "true", NODE_OPTIONS: [process.env.NODE_OPTIONS, "--import tsx"] .filter(Boolean) .join(" "), }, }, ); if (!isGenerate) { continue; } // Post-process generated SQL: strip explicit "public". schema qualifiers // from FK REFERENCES so migration files stay truly schema-free. // search_path handles resolution at runtime. if (dialect === "postgresql") { await this.stripPublicSchemaFromMigrations(migrationsDir); } const after = await this.fs.ls(migrationsDir).catch(() => []); await this.assertNoDestructiveMigrations( migrationsDir, after.filter((file) => !before.has(file)), ); } } /** * Remove `"public".` schema qualifiers from FK REFERENCES in SQL migration files. * * drizzle-kit generates `REFERENCES "public"."table"(...)` even for schema-free * models. This breaks when deploying to a non-public schema via search_path. */ protected async stripPublicSchemaFromMigrations( migrationsDir: string, ): Promise<void> { const files = await this.fs.ls(migrationsDir).catch(() => []); for (const file of files) { if (!file.endsWith(".sql")) continue; const filePath = this.fs.join(migrationsDir, file); const content = await this.fs.readFile(filePath); const sql = content.toString("utf-8"); const cleaned = sql.replaceAll('"public".', ""); if (cleaned !== sql) { await this.fs.writeFile(filePath, cleaned); this.log.debug(`Stripped "public". qualifiers from ${file}`); } } } /** * Refuse a freshly generated migration that drops a table. * * Drizzle rebuilds a SQLite table by dropping and recreating it. On * Cloudflare D1 that is a data-loss bomb: D1 ignores `PRAGMA * foreign_keys=OFF`, so dropping a table that other tables reference with * `ON DELETE CASCADE` silently wipes every child row — with no error, on * deploy, in production. * * The generated file is left on disk on purpose: the point is to force a * human to read it. If the drop really is intended, keep the file and move * on (a re-run detects no schema diff, so nothing is regenerated and this * guard stays quiet). */ protected async assertNoDestructiveMigrations( migrationsDir: string, files: string[], ): Promise<void> { const offenders: string[] = []; for (const file of files) { if (!file.endsWith(".sql")) continue; const content = await this.fs.readFile(this.fs.join(migrationsDir, file)); const drops = this.findDropTableStatements(content.toString("utf-8")); for (const drop of drops) { offenders.push(` ${file}: ${drop}`); } } if (offenders.length === 0) { return; } throw new AlephaError( [ `Refusing to generate a destructive migration: DROP TABLE found in ${offenders.length} statement(s).`, "", ...offenders, "", "On Cloudflare D1, dropping a table that CASCADE children reference wipes those child rows silently.", "Review the generated file above. If the drop is intentional, keep it and re-run — nothing will be regenerated.", "If it is not, delete the file and adjust your schema (e.g. keep the column, or drop it in a hand-written migration).", ].join("\n"), ); } /** * Extract `DROP TABLE` statements from a SQL migration, skipping any that sit * inside a `--` line comment. */ protected findDropTableStatements(sql: string): string[] { const statements: string[] = []; for (const rawLine of sql.split(/\r?\n/)) { const line = rawLine.trim(); if (line.startsWith("--")) continue; // Drop the trailing comment so `DROP TABLE x; -- ...` still matches on // the statement itself and a commented-out tail can't add a false hit. const code = line.split("--")[0]; if (/\bDROP\s+TABLE\b/i.test(code)) { statements.push(code.trim()); } } return statements; } /** * Prepare Drizzle configuration files for a database provider. */ public async prepareDrizzleConfig(options: { kit: any; provider: DatabaseProvider; providerName: string; providerUrl: string; providerDriver: string; dialect: string; entry: string; rootDir: string; command?: string; }): Promise<string> { // For migration generation, use schema-free models so the SQL output // doesn't contain hardcoded schema qualifiers (e.g. "myschema"."users"). // The schema is applied at runtime via search_path. const withoutSchema = options.command === "generate"; const models = withoutSchema ? Object.keys(options.kit.getModelsWithoutSchema(options.provider)) : Object.keys(options.kit.getModels(options.provider)); const entitiesJs = this.generateEntitiesJs( options.entry, options.providerName, models, withoutSchema, ); const entitiesJsPath = await this.utils.writeConfigFile( "entities.js", entitiesJs, options.rootDir, ); const config: Record<string, any> = { schema: entitiesJsPath, out: `./migrations/${options.providerName}`, dialect: options.dialect, dbCredentials: { url: options.providerUrl, }, }; // Use schema-specific migration table so multiple schemas sharing // the same database each track their own migration history. if (options.dialect === "postgresql") { config.migrations = { table: options.provider.migrationsTable, }; } // Schema filter is only needed for push/studio (introspection). // For generate, models are already schema-free. if (options.provider.schema && !withoutSchema) { config.schemaFilter = options.provider.schema; } if (options.providerDriver === "d1") { config.driver = "d1-http"; } if (options.providerDriver === "pglite") { config.driver = "pglite"; } if (options.dialect === "sqlite") { if (options.providerDriver === "d1") { // For D1, we need to fill D1 bindings in a way that drizzle-kit can use it, since D1 doesn't use a traditional connection URL } else { let url = options.providerUrl; url = url.replace("sqlite://", "").replace("file://", ""); url = this.fs.join(options.rootDir, url); config.dbCredentials = { url, }; } } const drizzleConfigJs = `export default ${JSON.stringify(config, null, 2)}`; return await this.utils.writeConfigFile( "drizzle.config.js", drizzleConfigJs, options.rootDir, ); } // =========================================== // Drizzle ORM & Kit Utilities // =========================================== /** * Generate JavaScript code for Drizzle entities export. * * When `withoutSchema` is true, uses `getModelsWithoutSchema()` to produce * schema-free models for migration generation. */ public generateEntitiesJs( entry: string, provider: string, models: string[] = [], withoutSchema = false, ): string { const getModelsCall = withoutSchema ? "kit.getModelsWithoutSchema(provider)" : "kit.getModels(provider)"; return ` import "${entry}"; import { DrizzleKitProvider, Repository } from "alepha/orm"; const alepha = globalThis.__alepha; const kit = alepha.inject(DrizzleKitProvider); const provider = alepha.services(Repository).find((it) => it.provider.name === "${provider}").provider; const models = ${getModelsCall}; ${models.map((it: string) => `export const ${it} = models["${it}"];`).join("\n")} `.trim(); } }