UNPKG

alepha

Version:

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

137 lines (118 loc) 4.39 kB
import { $env, $hook, $inject, AlephaError, z } from "alepha"; import { DatabaseProvider, type SQLLike } from "alepha/orm"; import { sql } from "drizzle-orm"; import type { PgDatabase } from "drizzle-orm/pg-core"; import { PostgresModelBuilder } from "../services/PostgresModelBuilder.ts"; /** * Cloudflare Hyperdrive PostgreSQL provider using Drizzle ORM. * * Connects to an external PostgreSQL database through Cloudflare Hyperdrive, * which provides connection pooling and caching at the edge. * * Creates a fresh connection per request, since Cloudflare Workers * cannot reuse I/O objects across request contexts. * * URL format: hyperdrive://BINDING_NAME */ export class CloudflareHyperdriveProvider extends DatabaseProvider { protected readonly builder = $inject(PostgresModelBuilder); protected readonly env = $env( z.object({ DATABASE_URL: z.string().describe("Expect to be 'hyperdrive://BINDING'"), POSTGRES_SCHEMA: z.text().optional(), }), ); public override get schema(): string { return this.env.POSTGRES_SCHEMA ?? "public"; } protected postgresFn?: any; protected drizzleFn?: any; protected bindingName?: string; public get name() { return "postgres"; } public get driver() { return "hyperdrive"; } public override readonly dialect = "postgresql"; public override get url(): string { return this.env.DATABASE_URL; } /** * Get a fresh Drizzle instance per request. * * Reads the current Hyperdrive binding from `cloudflare.env` * and creates a new postgres client each time, avoiding the * "Cannot perform I/O on behalf of a different request" error. */ public override get db(): PgDatabase<any> { if (!this.postgresFn || !this.drizzleFn || !this.bindingName) { throw new AlephaError("Hyperdrive database not initialized"); } 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[this.bindingName] as | { connectionString: string } | undefined; if (!binding?.connectionString) { throw new AlephaError( `Hyperdrive binding '${this.bindingName}' not found in Cloudflare Workers environment.`, ); } const pgOptions: Record<string, any> = { prepare: false }; // Set search_path so schema-free migration SQL resolves to the correct schema. if (this.schema !== "public") { pgOptions.connection = { search_path: `${this.schema}, public` }; } const client = this.postgresFn(binding.connectionString, pgOptions); return this.drizzleFn(client as any) as unknown as PgDatabase<any>; } public override async execute( query: SQLLike, ): Promise<Array<Record<string, unknown>>> { return this.db.execute(query); } protected readonly onStart = $hook({ on: "start", handler: async () => { this.bindingName = this.env.DATABASE_URL.replace("hyperdrive://", ""); // Pre-load modules so db getter is synchronous const pgModule = await import("postgres"); const drizzleModule = await import("drizzle-orm/postgres-js"); this.postgresFn = pgModule.default ?? pgModule; this.drizzleFn = drizzleModule.drizzle; this.log.info("Using Cloudflare Hyperdrive (PostgreSQL)"); }, }); protected async executeMigrations(migrationsFolder: string): Promise<void> { this.log.debug(`Running Postgres migrations from '${migrationsFolder}'...`); try { if (this.schema !== "public") { await this.db.execute( sql.raw(`SET search_path TO ${this.schema}, public`), ); } const { migrate } = await import("drizzle-orm/postgres-js/migrator"); await migrate(this.db as any, { migrationsFolder, migrationsTable: this.migrationsTable, }); this.log.debug("Postgres migrations completed successfully"); } catch (error) { const errorMessage = error instanceof Error ? `${error.name}: ${error.message}` : String(error); throw new AlephaError( `Postgres migration failed from '${migrationsFolder}': ${errorMessage}`, { cause: error }, ); } } }