UNPKG

alepha

Version:

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

692 lines (691 loc) 24.5 kB
import { createRequire } from "node:module"; import { $env, $hook, $inject, $module, $pipeline, AlephaError, z } from "alepha"; import { AlephaOrm, DatabaseProvider, DbMigrationError, ModelBuilder, PG_CREATED_AT, PG_GENERATED, PG_IDENTITY, PG_PRIMARY_KEY, PG_REF, PG_SERIAL, PG_UPDATED_AT, databaseEnvSchema, schema, sql } from "alepha/orm"; import { sql as sql$1 } from "drizzle-orm"; import { $lock } from "alepha/lock"; import * as pg from "drizzle-orm/pg-core"; import { check, customType, foreignKey, index, pgEnum, pgSchema, pgSequence, pgTable, unique, uniqueIndex } from "drizzle-orm/pg-core"; import { drizzle } from "drizzle-orm/postgres-js"; import { migrate } from "drizzle-orm/postgres-js/migrator"; import postgres from "postgres"; import { mkdir } from "node:fs/promises"; import { migrate as migrate$1 } from "drizzle-orm/pglite/migrator"; //#region ../../src/orm/postgres/schemas/postgresEnvSchema.ts /** * PostgreSQL-specific environment schema. * * Additional env vars for PostgreSQL providers on top of `databaseEnvSchema`. */ const postgresEnvSchema = z.object({ /** * PostgreSQL schema name (defaults to `"public"` when unset). */ POSTGRES_SCHEMA: z.text().optional(), /** * Maximum number of connections in the pool. */ POOL_MAX: z.integer().optional(), /** * Seconds a connection can be idle before being closed. */ POOL_IDLE_TIMEOUT: z.integer().optional(), /** * Seconds to wait when establishing a new connection. */ POOL_CONNECT_TIMEOUT: z.integer().optional() }); //#endregion //#region ../../src/orm/postgres/types/byte.ts /** * Postgres bytea type. */ const byte = customType({ dataType: () => "bytea" }); //#endregion //#region ../../src/orm/postgres/services/PostgresModelBuilder.ts var PostgresModelBuilder = class extends ModelBuilder { schemas = /* @__PURE__ */ new Map(); /** * Create a primary key column with UUID v7 */ getPrimaryKeyUUID(key) { return pg.uuid(key).default(sql`uuidv7()`); } getPgSchema(name) { if (!this.schemas.has(name) && name !== "public") this.schemas.set(name, pgSchema(name)); const nsp = name !== "public" ? this.schemas.get(name) : { enum: pgEnum, table: pgTable, sequence: pgSequence }; if (!nsp) throw new AlephaError(`Postgres schema ${name} not found`); return nsp; } buildTable(entity, options) { const tableName = entity.name; if (options.tables.has(tableName)) return; const nsp = this.getPgSchema(options.schema); if (options.schema !== "public" && !options.schemas.has(options.schema)) options.schemas.set(options.schema, nsp); const columns = this.schemaToPgColumns(tableName, entity.schema, nsp, options.enums, options.tables); const configFn = this.getTableConfig(entity, options.tables); const table = nsp.table(tableName, columns, configFn); options.tables.set(tableName, table); } buildSequence(sequence, options) { const sequenceName = sequence.name; if (options.sequences.has(sequenceName)) return; const nsp = this.getPgSchema(options.schema); options.sequences.set(sequenceName, nsp.sequence(sequenceName, sequence.options)); } /** * Get PostgreSQL-specific config builder for the table. */ getTableConfig(entity, tables) { const pgBuilders = { index, uniqueIndex, unique, check, foreignKey }; const tableResolver = (entityName) => { return tables.get(entityName); }; return this.buildTableConfig(entity, pgBuilders, tableResolver); } schemaToPgColumns = (tableName, schema, nsp, enums, tables) => { return Object.entries(schema.properties).reduce((columns, [key, value]) => { let col = this.mapFieldToColumn(tableName, key, value, nsp, enums); const defaultValue = z.schema.getDefault(value); if (defaultValue != null) col = col.default(defaultValue); if (PG_PRIMARY_KEY in value) col = col.primaryKey(); if (PG_REF in value) { const config = value[PG_REF]; col = col.references(() => { const ref = config.ref(); const table = tables.get(ref.entity.name); if (!table) throw new AlephaError(`Referenced table ${ref.entity.name} not found for ${tableName}.${key}`); const target = table[ref.name]; if (!target) throw new AlephaError(`Referenced column ${ref.name} not found in table ${ref.entity.name} for ${tableName}.${key}`); return target; }, config.actions); } if (PG_GENERATED in value) { const gen = value[PG_GENERATED]; col = col.generatedAlwaysAs(gen.expression); } if (z.schema.requiredKeys(schema).includes(key)) col = col.notNull(); columns[key] = col; return columns; }, {}); }; mapFieldToColumn = (tableName, fieldName, value, nsp, enums) => { const key = this.toColumnName(fieldName); value = z.schema.unwrap(value); if (z.schema.isInteger(value) && z.schema.format(value) === "int64") { if (PG_IDENTITY in value) { const options = value[PG_IDENTITY]; if (options.mode === "byDefault") return pg.bigint(key, { mode: "number" }).generatedByDefaultAsIdentity(options); return pg.bigint(key, { mode: "number" }).generatedAlwaysAsIdentity(options); } return pg.bigint(key, { mode: "number" }); } if (z.schema.isInteger(value)) { if (PG_SERIAL in value) return pg.serial(key); if (PG_IDENTITY in value) { const options = value[PG_IDENTITY]; if (options.mode === "byDefault") return pg.integer(key).generatedByDefaultAsIdentity(options); return pg.integer(key).generatedAlwaysAsIdentity(options); } return pg.integer(key); } if (z.schema.isBigInt(value)) { if (PG_IDENTITY in value) { const options = value[PG_IDENTITY]; if (options.mode === "byDefault") return pg.bigint(key, { mode: "bigint" }).generatedByDefaultAsIdentity(options); return pg.bigint(key, { mode: "bigint" }).generatedAlwaysAsIdentity(options); } return pg.bigint(key, { mode: "bigint" }); } if (z.schema.isNumber(value)) { if (PG_IDENTITY in value) { const options = value[PG_IDENTITY]; if (options.mode === "byDefault") return pg.bigint(key, { mode: "number" }).generatedByDefaultAsIdentity(options); return pg.bigint(key, { mode: "number" }).generatedAlwaysAsIdentity(options); } if (z.schema.format(value) === "int64") return pg.bigint(key, { mode: "number" }); return pg.doublePrecision(key); } if (z.schema.isString(value) && !z.schema.isEnum(value)) return this.mapStringToColumn(key, value); if (z.schema.isBoolean(value)) return pg.boolean(key); if (z.schema.isObject(value)) return schema(key, value); if (z.schema.isRecord(value)) return schema(key, value); if (z.schema.isArray(value)) { const items = value.items; if (z.schema.isObject(items)) return schema(key, value); if (z.schema.isRecord(items)) return schema(key, value); if (z.schema.isString(items)) return pg.text(key).array(); if (z.schema.isInteger(items)) return pg.integer(key).array(); if (z.schema.isNumber(items)) return pg.doublePrecision(key).array(); if (z.schema.isBoolean(items)) return pg.boolean(key).array(); if (z.schema.isEnum(items)) return pg.text(key).array(); } if (z.schema.isEnum(value)) { const enumVals = z.schema.enumValues(value); if (!enumVals.every((it) => typeof it === "string")) throw new AlephaError(`Enum for ${fieldName} must be an array of strings, got ${JSON.stringify(enumVals)}`); const enumMeta = value.meta?.() ?? {}; if (enumMeta.mode !== "text") { const enumName = enumMeta.name ?? `${tableName}_${key}_enum`; if (enums.has(enumName)) { const values = enums.get(enumName).enumValues.join(","); const newValues = enumVals.join(","); if (values !== newValues) throw new AlephaError(`Enum name conflict for ${enumName}: [${values}] vs [${newValues}]`); } enums.set(enumName, nsp.enum(enumName, enumVals)); return enums.get(enumName)(key); } return this.mapStringToColumn(key, value); } throw new AlephaError(`Unsupported schema type for ${fieldName} as ${JSON.stringify(value)}`); }; /** * Map a string to a PG column. * * @param key The key of the field. * @param value The value of the field. */ mapStringToColumn = (key, value) => { const format = z.schema.format(value); if (format === "uuid") { if (PG_PRIMARY_KEY in value) return this.getPrimaryKeyUUID(key); return pg.uuid(key); } if (format === "binary") return byte(key); if (format === "date-time") { if (PG_CREATED_AT in value) return pg.timestamp(key, { mode: "string", withTimezone: true }).defaultNow(); if (PG_UPDATED_AT in value) return pg.timestamp(key, { mode: "string", withTimezone: true }).defaultNow(); return pg.timestamp(key, { mode: "string", withTimezone: true }); } if (format === "date") return pg.date(key, { mode: "string" }); return pg.text(key); }; }; //#endregion //#region ../../src/orm/postgres/providers/PostgresProvider.ts /** * Abstract base class for PostgreSQL database providers. * * Provides shared logic for Node.js and Bun PostgreSQL providers: * - Environment variable handling (DATABASE_URL, POSTGRES_SCHEMA) * - Schema name resolution (with test schema generation) * - SQL execution with error wrapping * - Lifecycle hooks (start with migration lock, stop with test cleanup) * * Subclasses must implement `connect()`, `close()`, and `executeMigrations()`. */ var PostgresProvider = class extends DatabaseProvider { env = $env(databaseEnvSchema); pgEnv = $env(postgresEnvSchema); builder = $inject(PostgresModelBuilder); dialect = "postgresql"; get name() { return "postgres"; } /** * In testing mode, the schema name will be generated and deleted after the test. */ schemaForTesting = this.alepha.isTest() ? this.generateTestSchemaName() : void 0; get url() { if (!this.env.DATABASE_URL) throw new AlephaError("DATABASE_URL is not defined in the environment"); return this.env.DATABASE_URL; } /** * Execute a SQL statement. */ async execute(statement) { return await this.db.execute(statement); } /** * Get Postgres schema used by this provider. */ get schema() { if (this.schemaForTesting) return this.schemaForTesting; if (this.pgEnv.POSTGRES_SCHEMA) return this.pgEnv.POSTGRES_SCHEMA; return "public"; } onStart = $hook({ on: "start", handler: async () => { await this.connect(); await this.generateTestSchema(); if (!this.alepha.isServerless()) try { await this.migrateLock.run(); } catch (error) { throw new DbMigrationError(error); } } }); onStop = $hook({ on: "stop", handler: async () => { await this.dropTestSchema(); await this.close(); } }); migrateLock = $pipeline({ use: [$lock({ name: "postgres:migrate" })], handler: async () => { await this.migrate(); } }); async generateTestSchema() { if (this.alepha.isTest() && this.schemaForTesting?.startsWith("test_alepha_")) { await this.cleanupStaleTestSchemas(); await this.execute(sql$1`CREATE SCHEMA IF NOT EXISTS ${sql$1.raw(this.schemaForTesting)}`); } } /** * Drop the current test schema if applicable. */ async dropTestSchema() { if (this.alepha.isTest() && this.schemaForTesting?.startsWith("test_alepha_")) { this.log.info(`Deleting test schema '${this.schemaForTesting}' ...`); await this.execute(sql$1`DROP SCHEMA IF EXISTS ${sql$1.raw(this.schemaForTesting)} CASCADE`); this.log.info(`Test schema '${this.schemaForTesting}' deleted`); } } /** * Remove stale test schemas older than 1 hour. * * Parses the embedded epoch from schema names (format: test_alepha_{epoch}_{random8}) * and drops any that exceed the TTL. This handles schemas left behind by crashed tests, * killed processes, or failed startups where stop() never fired. * * Uses a PG advisory lock so only one concurrent test process performs the cleanup. */ async cleanupStaleTestSchemas() { try { const [lock] = await this.execute(sql$1`SELECT pg_try_advisory_lock(hashtext('alepha:test:schema:cleanup')) AS acquired`); if (!lock?.acquired) return; try { const result = await this.execute(sql$1`SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'test_alepha_%'`); const now = this.dateTime.nowMillis(); const maxAge = 3600 * 1e3; for (const row of result) { const name = row.schema_name; if (name === this.schemaForTesting) continue; const age = this.parseTestSchemaAge(name, now); if (age !== void 0 && age > maxAge) { this.log.warn(`Dropping stale test schema '${name}' (age: ${Math.round(age / 6e4)}min) ...`); await this.execute(sql$1`DROP SCHEMA IF EXISTS ${sql$1.raw(name)} CASCADE`); } } } finally { await this.execute(sql$1`SELECT pg_advisory_unlock(hashtext('alepha:test:schema:cleanup'))`); } } catch (error) { this.log.warn("Failed to clean up stale test schemas", { error }); } } /** * Parse the age in milliseconds from a test schema name. * Format: test_alepha_{epoch_seconds}_{random8} * Returns undefined if the name doesn't match the expected format. */ parseTestSchemaAge(name, now) { const parts = name.split("_"); if (parts.length !== 4 || parts[0] !== "test" || parts[1] !== "alepha") return; const epoch = Number(parts[2]); if (!Number.isFinite(epoch) || epoch <= 0) return; return now - epoch * 1e3; } }; //#endregion //#region ../../src/orm/postgres/providers/BunPostgresProvider.ts /** * Bun PostgreSQL provider using Drizzle ORM with Bun's native SQL client. * * This provider uses Bun's built-in SQL class for PostgreSQL connections, * which provides excellent performance on the Bun runtime. * * @example * ```ts * // Set DATABASE_URL environment variable * // DATABASE_URL=postgres://user:password@localhost:5432/database * * // Or configure programmatically * alepha.with({ * provide: DatabaseProvider, * use: BunPostgresProvider, * }); * ``` */ var BunPostgresProvider = class extends PostgresProvider { client; bunDb; /** * Get the Drizzle Postgres database instance. */ get db() { if (!this.bunDb) throw new AlephaError("Database not initialized"); return this.bunDb; } async executeMigrations(migrationsFolder) { if (this.schema !== "public") await this.db.execute(sql$1.raw(`SET search_path TO ${this.schema}, public`)); const { migrate } = await import("drizzle-orm/bun-sql/migrator"); await migrate(this.bunDb, { migrationsFolder, migrationsTable: this.migrationsTable }); } async connect() { this.log.debug("Connect .."); if (typeof Bun === "undefined") throw new AlephaError("BunPostgresProvider requires the Bun runtime. Use NodePostgresProvider for Node.js."); const { drizzle } = await import("drizzle-orm/bun-sql"); let connectionUrl = this.url; if (this.schema !== "public") { const separator = connectionUrl.includes("?") ? "&" : "?"; connectionUrl += `${separator}search_path=${this.schema},public`; } const bunOptions = { url: connectionUrl }; if (this.pgEnv.POOL_MAX != null) bunOptions.max = this.pgEnv.POOL_MAX; if (this.pgEnv.POOL_IDLE_TIMEOUT != null) bunOptions.idleTimeout = this.pgEnv.POOL_IDLE_TIMEOUT; if (this.pgEnv.POOL_CONNECT_TIMEOUT != null) bunOptions.connectionTimeout = this.pgEnv.POOL_CONNECT_TIMEOUT; this.client = new Bun.SQL(bunOptions); await this.client.unsafe("SELECT 1"); this.bunDb = drizzle({ client: this.client, logger: { logQuery: (query, params) => { this.log.trace(query, { params }); } } }); this.log.info("Connection OK"); } async close() { if (this.client) { this.log.debug("Close..."); await this.client.close(); this.client = void 0; this.bunDb = void 0; this.log.info("Connection closed"); } } }; //#endregion //#region ../../src/orm/postgres/providers/CloudflareHyperdriveProvider.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 */ var CloudflareHyperdriveProvider = class extends DatabaseProvider { builder = $inject(PostgresModelBuilder); env = $env(z.object({ DATABASE_URL: z.string().describe("Expect to be 'hyperdrive://BINDING'"), POSTGRES_SCHEMA: z.text().optional() })); get schema() { return this.env.POSTGRES_SCHEMA ?? "public"; } postgresFn; drizzleFn; bindingName; get name() { return "postgres"; } get driver() { return "hyperdrive"; } dialect = "postgresql"; get url() { 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. */ get db() { if (!this.postgresFn || !this.drizzleFn || !this.bindingName) throw new AlephaError("Hyperdrive database not initialized"); const cloudflareEnv = this.alepha.get("cloudflare.env"); if (!cloudflareEnv) throw new AlephaError("Cloudflare Workers environment not found in Alepha store under 'cloudflare.env'."); const binding = cloudflareEnv[this.bindingName]; if (!binding?.connectionString) throw new AlephaError(`Hyperdrive binding '${this.bindingName}' not found in Cloudflare Workers environment.`); const pgOptions = { prepare: false }; if (this.schema !== "public") pgOptions.connection = { search_path: `${this.schema}, public` }; const client = this.postgresFn(binding.connectionString, pgOptions); return this.drizzleFn(client); } async execute(query) { return this.db.execute(query); } onStart = $hook({ on: "start", handler: async () => { this.bindingName = this.env.DATABASE_URL.replace("hyperdrive://", ""); 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)"); } }); async executeMigrations(migrationsFolder) { this.log.debug(`Running Postgres migrations from '${migrationsFolder}'...`); try { if (this.schema !== "public") await this.db.execute(sql$1.raw(`SET search_path TO ${this.schema}, public`)); const { migrate } = await import("drizzle-orm/postgres-js/migrator"); await migrate(this.db, { migrationsFolder, migrationsTable: this.migrationsTable }); this.log.debug("Postgres migrations completed successfully"); } catch (error) { throw new AlephaError(`Postgres migration failed from '${migrationsFolder}': ${error instanceof Error ? `${error.name}: ${error.message}` : String(error)}`, { cause: error }); } } }; //#endregion //#region ../../src/orm/postgres/providers/NodePostgresProvider.ts var NodePostgresProvider = class NodePostgresProvider extends PostgresProvider { static SSL_MODES = [ "require", "allow", "prefer", "verify-full" ]; client; pg; /** * Get the Drizzle Postgres database instance. */ get db() { if (!this.pg) throw new AlephaError("Database not initialized"); return this.pg; } async executeMigrations(migrationsFolder) { if (this.schema !== "public") await this.db.execute(sql$1.raw(`SET search_path TO ${this.schema}, public`)); await migrate(this.db, { migrationsFolder, migrationsTable: this.migrationsTable }); } async connect() { const options = this.getClientOptions(); this.log.debug("Connect ..", { ...options, password: options.password ? "****" : void 0 }); const client = postgres(options); await client`SELECT 1`; this.client = client; this.pg = drizzle(client, { logger: { logQuery: (query, params) => { this.log.trace(query, { params }); } } }); this.log.info("Connection OK"); } async close() { if (this.client) { this.log.debug("Close..."); await this.client.end(); this.client = void 0; this.pg = void 0; this.log.info("Connection closed"); } } /** * Map the DATABASE_URL to postgres client options. */ getClientOptions() { const url = new URL(this.url); const options = { host: url.hostname, user: decodeURIComponent(url.username), database: decodeURIComponent(url.pathname.replace("/", "")), password: decodeURIComponent(url.password), port: Number(url.port || 5432), ssl: this.ssl(url), onnotice: () => {} }; if (this.pgEnv.POOL_MAX != null) options.max = this.pgEnv.POOL_MAX; if (this.pgEnv.POOL_IDLE_TIMEOUT != null) options.idle_timeout = this.pgEnv.POOL_IDLE_TIMEOUT; if (this.pgEnv.POOL_CONNECT_TIMEOUT != null) options.connect_timeout = this.pgEnv.POOL_CONNECT_TIMEOUT; if (this.schema !== "public") options.connection = { search_path: `${this.schema}, public` }; return options; } ssl(url) { const mode = url.searchParams.get("sslmode"); for (const it of NodePostgresProvider.SSL_MODES) if (mode === it) return it; } }; //#endregion //#region ../../src/orm/postgres/providers/PglitePostgresProvider.ts var PglitePostgresProvider = class PglitePostgresProvider extends DatabaseProvider { static importPglite() { try { return createRequire(import.meta.url)("@electric-sql/pglite"); } catch {} } get schema() { return this.pgEnv.POSTGRES_SCHEMA ?? "public"; } env = $env(databaseEnvSchema); pgEnv = $env(postgresEnvSchema); builder = $inject(PostgresModelBuilder); client; pglite; get name() { return "postgres"; } get driver() { return "pglite"; } dialect = "postgresql"; get supportsTransactions() { return false; } get url() { let path = this.env.DATABASE_URL; if (!path) if (this.alepha.isTest()) path = ":memory:"; else path = "node_modules/.alepha/pglite"; else if (path.includes(":memory:")) path = ":memory:"; else if (path.startsWith("file://")) path = path.replace("file://", ""); return path; } get db() { if (!this.pglite) throw new AlephaError("Database not initialized"); return this.pglite; } async execute(statement) { const { rows } = await this.db.execute(statement); return rows; } onStart = $hook({ on: "start", handler: async () => { if (Object.keys(this.kit.getModels(this)).length === 0) return; const module = PglitePostgresProvider.importPglite(); if (!module) throw new AlephaError("@electric-sql/pglite is not installed. Please install it to use the pglite driver."); const { drizzle } = createRequire(import.meta.url)("drizzle-orm/pglite"); const path = this.url; if (path !== ":memory:") { await mkdir(path, { recursive: true }).catch(() => null); this.client = new module.PGlite(path); } else this.client = new module.PGlite(); this.pglite = drizzle({ client: this.client }); await this.migrate(); this.log.info(`Using PGlite database at ${path}`); } }); onStop = $hook({ on: "stop", handler: async () => { if (this.client) { this.log.debug("Closing PGlite connection..."); await this.client.close(); this.client = void 0; this.pglite = void 0; this.log.info("PGlite connection closed"); } } }); async executeMigrations(migrationsFolder) { if (this.schema !== "public") await this.db.execute(sql$1.raw(`SET search_path TO ${this.schema}, public`)); await migrate$1(this.db, { migrationsFolder, migrationsTable: this.migrationsTable }); } }; //#endregion //#region ../../src/orm/postgres/index.ts const AlephaOrmPostgres = $module({ name: "alepha.orm.postgres", services: [PostgresModelBuilder], variants: [ PostgresProvider, CloudflareHyperdriveProvider, NodePostgresProvider, BunPostgresProvider, PglitePostgresProvider ], register: (alepha) => { const url = alepha.parseEnv(databaseEnvSchema).DATABASE_URL; const isBun = alepha.isBun(); if (url?.startsWith("hyperdrive:")) alepha.with({ optional: true, provide: DatabaseProvider, use: CloudflareHyperdriveProvider }); else if (url?.startsWith("pglite:")) alepha.with({ optional: true, provide: DatabaseProvider, use: PglitePostgresProvider }); else if (url?.startsWith("postgres:")) alepha.with({ optional: true, provide: DatabaseProvider, use: isBun ? BunPostgresProvider : NodePostgresProvider }); alepha.with(AlephaOrm); } }); //#endregion export { AlephaOrmPostgres, BunPostgresProvider, CloudflareHyperdriveProvider, NodePostgresProvider, PglitePostgresProvider, PostgresModelBuilder, PostgresProvider, byte, postgresEnvSchema }; //# sourceMappingURL=index.js.map