alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
194 lines (168 loc) • 6.12 kB
text/typescript
import { $env, $hook, $inject, AlephaError, z } from "alepha";
import type { DrizzleD1Database } from "drizzle-orm/d1";
import type { PgDatabase } from "drizzle-orm/pg-core";
import { SqliteModelBuilder } from "../../services/SqliteModelBuilder.ts";
import { DatabaseProvider, type SQLLike } from "./DatabaseProvider.ts";
// ---------------------------------------------------------------------------------------------------------------------
/**
* D1Database interface matching Cloudflare's D1 API.
*/
export interface D1Database {
prepare(query: string): D1PreparedStatement;
batch<T = unknown>(statements: D1PreparedStatement[]): Promise<T[]>;
exec(query: string): Promise<D1ExecResult>;
dump(): Promise<ArrayBuffer>;
}
export interface D1PreparedStatement {
bind(...values: unknown[]): D1PreparedStatement;
first<T = unknown>(colName?: string): Promise<T | null>;
run(): Promise<D1Result>;
all<T = unknown>(): Promise<D1Result<T>>;
raw<T = unknown>(): Promise<T[]>;
}
export interface D1Result<T = unknown> {
results: T[];
success: boolean;
meta: {
duration: number;
changes: number;
last_row_id: number;
served_by: string;
internal_stats: unknown;
};
}
export interface D1ExecResult {
count: number;
duration: number;
}
// ---------------------------------------------------------------------------------------------------------------------
/**
* Cloudflare D1 SQLite provider using Drizzle ORM.
*/
export class CloudflareD1Provider extends DatabaseProvider {
protected readonly builder = $inject(SqliteModelBuilder);
protected readonly env = $env(
z.object({
DATABASE_URL: z.string().describe("Expect to be 'd1://binding'"),
}),
);
protected d1?: D1Database;
protected drizzleDb?: DrizzleD1Database;
public get name() {
return "sqlite";
}
public get driver() {
return "d1";
}
public override readonly dialect = "sqlite";
public override get url(): string {
return this.env.DATABASE_URL;
}
public override get db(): PgDatabase<any> {
if (!this.drizzleDb) {
throw new AlephaError("D1 database not initialized");
}
return this.drizzleDb as unknown as PgDatabase<any>;
}
public override async execute(
query: SQLLike,
): Promise<Array<Record<string, unknown>>> {
// Use `.all()` — it returns the row array directly, exactly like
// NodeSqliteProvider / BunSqliteProvider's `execute`. `.run()` returns
// drizzle's D1 result envelope (`{ results, success, meta }`), which
// has no `.rows` property: the previous `const { rows } = …run(query)`
// destructured `undefined`, so EVERY raw-SQL SELECT on D1
// (`Repository.query`, `DatabaseProvider.run`) silently returned `[]`.
return (this.db as any).all(query);
}
/**
* D1 does not support SQL-level transactions (BEGIN/COMMIT/ROLLBACK).
* It rejects these statements and requires the JS `batch()` API for atomic
* multi-statement operations instead.
*
* @see https://developers.cloudflare.com/d1/worker-api/d1-database/#batch-statements
* @see https://github.com/drizzle-team/drizzle-orm/issues/2463
*/
public override get supportsTransactions(): boolean {
return false;
}
protected readonly onStart = $hook({
on: "start",
handler: async () => {
try {
const [bindingName] = this.env.DATABASE_URL.replace("d1://", "").split(
":",
);
const cloudflareEnv = this.alepha.get("cloudflare.env") as
| Record<string, unknown>
| undefined;
if (!cloudflareEnv) {
throw new AlephaError(
"Cloudflare Workers environment not found in Alepha store under 'cloudflare.env'.",
);
}
const binding = cloudflareEnv[bindingName] as D1Database;
if (!binding) {
throw new AlephaError(
`D1 binding '${bindingName}' not found in Cloudflare Workers environment.`,
);
}
this.d1 = binding;
// Dynamic import to avoid crashes when not on Cloudflare
const { drizzle } = await import("drizzle-orm/d1");
this.drizzleDb = drizzle(this.d1) as DrizzleD1Database;
// Never migrate in serverless mode - D1 migrations must be applied
// via `wrangler d1 migrations apply` before deployment
if (!this.alepha.isServerless()) {
await this.migrate();
}
this.log.info("Using Cloudflare D1 database");
} catch (error) {
// Log the full error for debugging since Cloudflare Workers
// doesn't properly display error causes
const errorMessage =
error instanceof Error
? `${error.message}${error.stack ? `\n${error.stack}` : ""}`
: String(error);
this.log.error(`D1 initialization failed: ${errorMessage}`);
throw error;
}
},
});
protected async executeMigrations(migrationsFolder: string): Promise<void> {
this.log.debug(`Running D1 migrations from '${migrationsFolder}'...`);
try {
// Dynamic import for D1 migrator
const { migrate } = await import("drizzle-orm/d1/migrator");
await migrate(this.db as any, { migrationsFolder });
this.log.debug("D1 migrations completed successfully");
} catch (error) {
const errorMessage =
error instanceof Error
? `${error.name}: ${error.message}`
: String(error);
throw new AlephaError(
`D1 migration failed from '${migrationsFolder}': ${errorMessage}`,
{ cause: error },
);
}
}
/**
* D1 doesn't support push-based schema sync.
* Always use file-based migrations regardless of environment.
*/
public override async migrate(): Promise<void> {
const migrationsFolder = this.getMigrationsFolder();
try {
await this.executeMigrations(migrationsFolder);
} catch (error) {
if (this.alepha.isTest()) {
this.log.warn(
"D1 migrations failed in test environment - ensure migrations exist",
);
} else {
throw error;
}
}
}
}