UNPKG

alepha

Version:

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

334 lines (292 loc) 9.88 kB
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}`; } }