alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
693 lines (607 loc) • 21.7 kB
text/typescript
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();
}
}