UNPKG

alepha

Version:

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

234 lines (200 loc) 7.16 kB
import { $env, $hook, $inject, $pipeline, AlephaError } from "alepha"; import { $lock } from "alepha/lock"; import { DatabaseProvider, DbMigrationError, databaseEnvSchema, type SQLLike, } from "alepha/orm"; import { sql } from "drizzle-orm"; import type { PgDatabase } from "drizzle-orm/pg-core"; import { postgresEnvSchema } from "../schemas/postgresEnvSchema.ts"; import { PostgresModelBuilder } from "../services/PostgresModelBuilder.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()`. */ export abstract class PostgresProvider extends DatabaseProvider { protected readonly env = $env(databaseEnvSchema); protected readonly pgEnv = $env(postgresEnvSchema); protected readonly builder = $inject(PostgresModelBuilder); public override readonly dialect = "postgresql"; public get name() { return "postgres"; } /** * In testing mode, the schema name will be generated and deleted after the test. */ protected schemaForTesting = this.alepha.isTest() ? this.generateTestSchemaName() : undefined; public override 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. */ public override async execute( statement: SQLLike, ): Promise<Array<Record<string, unknown>>> { return await this.db.execute(statement); } /** * Get Postgres schema used by this provider. */ public override get schema(): string { if (this.schemaForTesting) { return this.schemaForTesting; } if (this.pgEnv.POSTGRES_SCHEMA) { return this.pgEnv.POSTGRES_SCHEMA; } return "public"; } public abstract override get db(): PgDatabase<any>; /** * Establish the database connection. */ public abstract connect(): Promise<void>; /** * Close the database connection. */ public abstract close(): Promise<void>; // ------------------------------------------------------------------------------------------------------------------- protected readonly onStart = $hook({ on: "start", handler: async () => { await this.connect(); await this.generateTestSchema(); // never migrate in serverless mode (vercel, netlify, ...) if (!this.alepha.isServerless()) { try { await this.migrateLock.run(); } catch (error) { throw new DbMigrationError(error); } } }, }); protected readonly onStop = $hook({ on: "stop", handler: async () => { await this.dropTestSchema(); await this.close(); }, }); protected migrateLock = $pipeline({ use: [$lock({ name: "postgres:migrate" })], handler: async () => { await this.migrate(); }, }); // ------------------------------------------------------------------------------------------------------------------- // Create unique schema for tests and clean up after. Format: test_alepha_{epoch_seconds}_{random8} // ------------------------------------------------------------------------------------------------------------------- protected async generateTestSchema() { if ( this.alepha.isTest() && this.schemaForTesting?.startsWith("test_alepha_") ) { // Self-healing: drop stale schemas left behind by crashed/failed test runs await this.cleanupStaleTestSchemas(); await this.execute( sql`CREATE SCHEMA IF NOT EXISTS ${sql.raw(this.schemaForTesting)}`, ); } } /** * Drop the current test schema if applicable. */ protected async dropTestSchema(): Promise<void> { if ( this.alepha.isTest() && this.schemaForTesting?.startsWith("test_alepha_") ) { this.log.info(`Deleting test schema '${this.schemaForTesting}' ...`); await this.execute( sql`DROP SCHEMA IF EXISTS ${sql.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. */ protected async cleanupStaleTestSchemas(): Promise<void> { try { // Non-blocking advisory lock — first process in cleans, others skip const [lock] = await this.execute( sql`SELECT pg_try_advisory_lock(hashtext('alepha:test:schema:cleanup')) AS acquired`, ); if (!lock?.acquired) { return; } try { const result = await this.execute( sql`SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'test_alepha_%'`, ); const now = this.dateTime.nowMillis(); const maxAge = 60 * 60 * 1000; // 1 hour for (const row of result) { const name = row.schema_name as string; // Skip our own schema (it was just created or is about to be) if (name === this.schemaForTesting) { continue; } const age = this.parseTestSchemaAge(name, now); if (age !== undefined && age > maxAge) { this.log.warn( `Dropping stale test schema '${name}' (age: ${Math.round(age / 60_000)}min) ...`, ); await this.execute( sql`DROP SCHEMA IF EXISTS ${sql.raw(name)} CASCADE`, ); } } } finally { await this.execute( sql`SELECT pg_advisory_unlock(hashtext('alepha:test:schema:cleanup'))`, ); } } catch (error) { // Don't fail test setup if stale cleanup fails 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. */ protected parseTestSchemaAge(name: string, now: number): number | undefined { const parts = name.split("_"); // test_alepha_{epoch}_{random} → ["test", "alepha", epoch, random] if (parts.length !== 4 || parts[0] !== "test" || parts[1] !== "alepha") { return undefined; } const epoch = Number(parts[2]); if (!Number.isFinite(epoch) || epoch <= 0) { return undefined; } return now - epoch * 1000; } }