UNPKG

alepha

Version:

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

194 lines (168 loc) 6.12 kB
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; } } } }