alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
526 lines (525 loc) • 18.7 kB
JavaScript
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 { mkdir } from "node:fs/promises";
import { migrate } 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/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(this.db, {
migrationsFolder,
migrationsTable: this.migrationsTable
});
}
};
//#endregion
//#region ../../src/orm/postgres/index.bun.ts
const AlephaOrmPostgres = $module({
name: "alepha.orm.postgres",
services: [PostgresModelBuilder],
variants: [
PostgresProvider,
BunPostgresProvider,
PglitePostgresProvider
],
register: (alepha) => {
const url = alepha.parseEnv(databaseEnvSchema).DATABASE_URL;
if (url?.startsWith("pglite:")) alepha.with({
optional: true,
provide: DatabaseProvider,
use: PglitePostgresProvider
});
else if (url?.startsWith("postgres:")) alepha.with({
optional: true,
provide: DatabaseProvider,
use: BunPostgresProvider
});
alepha.with(AlephaOrm);
}
});
//#endregion
export { AlephaOrmPostgres, BunPostgresProvider, PglitePostgresProvider, PostgresModelBuilder, PostgresProvider, byte, postgresEnvSchema };
//# sourceMappingURL=index.bun.js.map