alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
234 lines (200 loc) • 7.16 kB
text/typescript
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;
}
}