alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
334 lines (292 loc) • 9.88 kB
text/typescript
import { stat } from "node:fs/promises";
import {
$inject,
Alepha,
AlephaError,
type Static,
type TObject,
} from "alepha";
import { DateTimeProvider } from "alepha/datetime";
import { $logger } from "alepha/logger";
import { type SQLWrapper, sql } from "drizzle-orm";
import {
alias,
type PgDatabase,
type PgTableWithColumns,
type PgTransaction,
} from "drizzle-orm/pg-core";
import type { PgTransactionConfig } from "drizzle-orm/pg-core/session";
import { DbError } from "../../errors/DbError.ts";
import type {
EntityPrimitive,
SchemaToTableConfig,
} from "../../primitives/$entity.ts";
import type { SequencePrimitive } from "../../primitives/$sequence.ts";
import { databaseEnvSchema } from "../../schemas/databaseEnvSchema.ts";
import type { ModelBuilder } from "../../services/ModelBuilder.ts";
import { DrizzleKitProvider } from "../DrizzleKitProvider.ts";
export type SQLLike = SQLWrapper | string;
export abstract class DatabaseProvider {
protected readonly alepha = $inject(Alepha);
protected readonly dateTime = $inject(DateTimeProvider);
protected readonly log = $logger();
protected abstract readonly builder: ModelBuilder;
protected readonly kit = $inject(DrizzleKitProvider);
public abstract readonly db: PgDatabase<any>;
public abstract readonly dialect: "postgresql" | "sqlite";
public abstract readonly url: string;
public readonly enums = new Map<string, unknown>();
public readonly tables = new Map<string, unknown>();
public readonly sequences = new Map<string, unknown>();
public readonly schemas = new Map<string, unknown>();
protected readonly entityPrimitives: EntityPrimitive[] = [];
protected readonly sequencePrimitives: SequencePrimitive[] = [];
public get name() {
return "default";
}
public get driver(): string {
return this.dialect;
}
/**
* Whether this driver supports SQL-level transactions (BEGIN/COMMIT/ROLLBACK).
*
* Drivers that do not (e.g. PGlite, Cloudflare D1) should override to `false`.
*/
public get supportsTransactions(): boolean {
return true;
}
/**
* Raw database connection handle (e.g. DatabaseSync, bun:sqlite Database).
* Override in providers that expose native connections for introspection.
*/
public get nativeConnection(): unknown {
return undefined;
}
public get schema() {
return "public";
}
/**
* Migration tracking table name, scoped by schema.
*
* Returns `migrations_{schema}` so that multiple schemas sharing the same
* database each get their own migration history (e.g. `migrations_lore`,
* `migrations_public`).
*/
public get migrationsTable(): string {
return `migrations_${this.schema}`;
}
/**
* Log a database query with structured metadata for devtools inspection.
*/
protected logQuery(
sql: string,
params: unknown[],
duration: number,
rowCount: number,
error?: string,
): void {
const operation = this.parseOperation(sql);
const data = {
type: "db:query",
sql,
params,
operation,
duration: Math.round(duration * 100) / 100,
rowCount,
success: !error,
error,
};
if (error) {
this.log.warn(`Query failed (${operation})`, data);
} else {
this.log.debug(
`Query OK (${operation}, ${Math.round(duration)}ms)`,
data,
);
}
}
protected parseOperation(sql: string): string {
const trimmed = sql.trimStart().toUpperCase();
if (trimmed.startsWith("SELECT")) return "SELECT";
if (trimmed.startsWith("INSERT")) return "INSERT";
if (trimmed.startsWith("UPDATE")) return "UPDATE";
if (trimmed.startsWith("DELETE")) return "DELETE";
if (trimmed.startsWith("CREATE")) return "CREATE";
if (trimmed.startsWith("ALTER")) return "ALTER";
if (trimmed.startsWith("DROP")) return "DROP";
return "OTHER";
}
public table<T extends TObject>(
entity: EntityPrimitive<T>,
): PgTableWithColumns<SchemaToTableConfig<T>> {
const table = this.tables.get(entity.name);
if (!table) {
throw new AlephaError(`Table '${entity.name}' is not registered`);
}
const hasAlias = (entity as any).$alias;
if (hasAlias) {
return alias(
table as PgTableWithColumns<SchemaToTableConfig<T>>,
hasAlias,
) as PgTableWithColumns<SchemaToTableConfig<T>>;
}
return table as PgTableWithColumns<SchemaToTableConfig<T>>;
}
public registerEntity(entity: EntityPrimitive) {
this.entityPrimitives.push(entity);
this.builder.buildTable(entity, this);
}
public registerSequence(sequence: SequencePrimitive) {
this.sequencePrimitives.push(sequence);
this.builder.buildSequence(sequence, this);
}
/**
* Rebuild all models into fresh maps using a different schema.
*
* When called with `"public"`, produces schema-free models suitable for
* migration generation (no schema qualifiers in the SQL output).
*/
public rebuildModels(targetSchema: string): {
tables: Map<string, unknown>;
enums: Map<string, unknown>;
sequences: Map<string, unknown>;
schemas: Map<string, unknown>;
} {
const result = {
tables: new Map<string, unknown>(),
enums: new Map<string, unknown>(),
sequences: new Map<string, unknown>(),
schemas: new Map<string, unknown>(),
schema: targetSchema,
};
for (const entity of this.entityPrimitives) {
this.builder.buildTable(entity, result);
}
for (const seq of this.sequencePrimitives) {
this.builder.buildSequence(seq, result);
}
return result;
}
/**
* Run a function inside a database transaction with implicit tx propagation.
*
* The transaction object is stored in `alepha.store` so that all Repository
* operations within `fn` automatically participate in the transaction without
* explicit `{ tx }` drilling.
*
* Nesting is safe — if already inside a `transactional()` block, the inner
* call reuses the outer transaction (no nested PG transactions / savepoints).
*/
public async transactional<R>(
fn: () => Promise<R>,
config?: PgTransactionConfig,
): Promise<R> {
const existing = this.alepha.get("alepha.orm.tx");
if (existing) {
return fn();
}
if (!this.supportsTransactions) {
return fn();
}
return this.db.transaction(async (tx) => {
this.alepha.store.set("alepha.orm.tx", tx as PgTransaction<any>, {
skipEvents: true,
});
try {
return await fn();
} finally {
this.alepha.store.set("alepha.orm.tx", undefined, { skipEvents: true });
}
}, config);
}
public abstract execute(
statement: SQLLike,
): Promise<Record<string, unknown>[]>;
public async run<T extends TObject>(
statement: SQLLike,
schema: T,
): Promise<Array<Static<T>>> {
const result = await this.execute(statement);
if (result == null) {
return [];
}
if (!Array.isArray(result)) {
this.log.error("Unexpected query result format", { result });
throw new DbError(
"Unexpected query result format, expected array of rows",
);
}
return result.map((row) => this.alepha.codec.decode(schema, row));
}
/**
* Get migrations folder path - can be overridden
*/
protected getMigrationsFolder(): string {
return `migrations/${this.name}`;
}
/**
* Migration orchestration.
*
* - Production: file-based migrations from the migrations folder
* - Dev / Test: push-based sync (introspects actual DB, no snapshots)
* - Serverless: skipped (migrations should be applied during deployment)
*/
public async migrate(): Promise<void> {
if (this.alepha.isServerless()) {
return;
}
if (this.alepha.isProduction()) {
const migrationsFolder = this.getMigrationsFolder();
const exists = await stat(migrationsFolder).catch(() => false);
if (!exists) {
this.log.warn("Migration SKIPPED - no migrations found");
return;
}
// For schema-free migrations, ensure the target schema exists
// before running migration files. The schema is applied via
// search_path set at the provider's connection level.
if (this.dialect === "postgresql" && this.schema !== "public") {
this.log.debug(`Ensuring schema '${this.schema}' exists ...`);
await this.execute(
sql`CREATE SCHEMA IF NOT EXISTS ${sql.raw(this.schema)}`,
);
}
this.log.debug(`Migrate from '${migrationsFolder}' directory ...`);
await this.executeMigrations(migrationsFolder);
this.log.info("Migration OK");
} else {
const { DATABASE_SYNC } = this.alepha.parseEnv(databaseEnvSchema);
if (DATABASE_SYNC === false) {
this.log.info(
"Database synchronization disabled (DATABASE_SYNC=false)",
);
return;
}
try {
await this.kit.synchronize(this);
} catch (error) {
throw new DbError(
`Failed to synchronize ${this.dialect} database schema`,
error as Error,
);
}
}
}
/**
* Provider-specific migration execution
* MUST be implemented by each provider
*/
protected abstract executeMigrations(migrationsFolder: string): Promise<void>;
// -------------------------------------------------------------------------------------------------------------------
/**
* For testing purposes, generate a unique schema name.
*
* Format: `test_alepha_{epoch_seconds}_{random8}`
* Example: `test_alepha_1739871618_k3m9x2p1`
*/
protected generateTestSchemaName(): string {
const epoch = Math.floor(this.dateTime.nowMillis() / 1000);
const random = Math.random().toString(36).slice(2, 10).padEnd(8, "0");
return `test_alepha_${epoch}_${random}`;
}
}