UNPKG

alepha

Version:

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

1,419 lines (1,418 loc) 111 kB
import { createRequire } from "node:module"; import { $atom, $context, $env, $hook, $inject, $mode, $module, $state, Alepha, AlephaError, KIND, Primitive, SchemaValidator, createMiddleware, createPagination, createPrimitive, pageQuerySchema, pageSchema, pageSchema as pageSchema$1, z } from "alepha"; import { AlephaDateTime, DateTimeProvider } from "alepha/datetime"; import { mkdir, stat } from "node:fs/promises"; import { $logger } from "alepha/logger"; import * as drizzle from "drizzle-orm"; import { and, arrayContained, arrayContains, arrayOverlaps, asc, avg, between, count, desc, eq, exists, getTableName, gt, gte, ilike, inArray, isNotNull, isNull, isSQLWrapper, like, lt, lte, max, min, ne, not, notBetween, notExists, notIlike, notInArray, notLike, or, sql, sql as sql$1, sum } from "drizzle-orm"; import { alias, customType } from "drizzle-orm/pg-core"; import { CryptoProvider } from "alepha/crypto"; import { currentTenantAtom, currentUserAtom } from "alepha/security"; import { isSQLWrapper as isSQLWrapper$1 } from "drizzle-orm/sql/sql"; import { dirname } from "node:path"; import { randomUUID } from "node:crypto"; import * as pg from "drizzle-orm/sqlite-core"; import { check, foreignKey, index, sqliteTable, unique, uniqueIndex } from "drizzle-orm/sqlite-core"; export * from "drizzle-orm/pg-core"; //#endregion //#region ../../src/orm/core/errors/DbError.ts var DbError = class extends AlephaError { name = "DbError"; constructor(message, cause) { super(message, { cause }); } }; //#endregion //#region ../../src/orm/core/schemas/databaseEnvSchema.ts /** * Base database environment schema. * * Defines the `DATABASE_URL` connection string used by all ORM providers * to determine the database driver and connection target. * * Supported URL formats: * - `sqlite://:memory:` or `sqlite://./path/to/db` — SQLite (Node.js or Bun) * - `postgres://user:password@host:port/database` — PostgreSQL (Node.js or Bun) * - `pglite://:memory:` or `pglite://./path` — PGlite (embedded Postgres) * - `d1://BINDING_NAME` — Cloudflare D1 * - `hyperdrive://BINDING_NAME` — Cloudflare Hyperdrive */ const databaseEnvSchema = z.object({ DATABASE_URL: z.text().optional(), /** * Enable or disable push-based schema synchronization (drizzle-kit push). * * Defaults to `true` in development and test, `false` in production. * Set to `false` in development to skip automatic schema sync * (e.g. when managing migrations manually). */ DATABASE_SYNC: z.boolean().optional() }); //#endregion //#region ../../src/orm/core/providers/DrizzleKitProvider.ts var DrizzleKitProvider = class { log = $logger(); dateTime = $inject(DateTimeProvider); alepha = $inject(Alepha); /** * Push-based synchronization using Drizzle Kit's introspection API. * * Reads the actual database state, diffs against current entity definitions, * and applies changes. No stored snapshots — no drift, no corruption. * * - SQLite: uses `pushSQLiteSchema` (requires sync driver — node:sqlite shim or bun-sqlite) * - PostgreSQL: uses `pushSchema` with schema filters * * Does nothing in production mode — use file-based migrations instead. */ async synchronize(provider) { if (this.alepha.isProduction()) { this.log.warn("Synchronization skipped in production mode."); return; } if (this.alepha.isTest()) { const { statements } = await this.generateMigration(provider); await this.executeStatements(statements.map((s) => s.replace(/^CREATE SCHEMA /i, "CREATE SCHEMA IF NOT EXISTS ")), provider); return; } const now = this.dateTime.nowMillis(); const kit = this.importDrizzleKit(); const models = this.getModels(provider); if (Object.keys(models).length === 0) { this.log.info(`No models to synchronize for '${provider.name}'`); return; } try { if (provider.dialect === "sqlite") await this.pushSqlite(kit, models, provider); else await this.pushPostgres(kit, models, provider); } catch (error) { this.log.debug("Push sync not available, falling back to migration generation", { error }); const { statements } = await this.generateMigration(provider); await this.executeStatementsLenient(statements, provider); } this.log.info(`Synchronization of '${provider.name}' OK [${this.dateTime.nowMillis() - now}ms]`); } /** * Generate SQL migration statements by diffing two schema states. * * Used by tests (schema validation) and CLI (`alepha db migrations generate`). * Not part of the push sync flow. * * When `withoutSchema` is true, models are rebuilt without schema qualifiers * so the generated SQL is portable across different PostgreSQL schemas. */ async generateMigration(provider, prevSnapshot, options) { const kit = this.importDrizzleKit(); const models = options?.withoutSchema ? this.getModelsWithoutSchema(provider) : this.getModels(provider); if (Object.keys(models).length > 0) { if (provider.dialect === "sqlite") { const prev = prevSnapshot ?? await kit.generateSQLiteDrizzleJson({}); const curr = await kit.generateSQLiteDrizzleJson(models); return { models, statements: await kit.generateSQLiteMigration(prev, curr), snapshot: curr }; } const prev = prevSnapshot ?? kit.generateDrizzleJson({}); const curr = kit.generateDrizzleJson(models); return { models, statements: await kit.generateMigration(prev, curr), snapshot: curr }; } return { models, statements: [], snapshot: {} }; } /** * Load all tables, enums, sequences, etc. from the provider's repositories. */ getModels(provider) { const models = {}; for (const [key, value] of provider.schemas.entries()) models[`__schema_${key}`] = value; for (const [key, value] of provider.tables.entries()) { if (models[key]) throw new AlephaError(`Model name conflict: '${key}' is already defined.`); models[key] = value; } for (const [key, value] of provider.enums.entries()) { if (models[key]) throw new AlephaError(`Model name conflict: '${key}' is already defined.`); models[key] = value; } for (const [key, value] of provider.sequences.entries()) { if (models[key]) throw new AlephaError(`Model name conflict: '${key}' is already defined.`); models[key] = value; } return models; } /** * Build schema-free models for migration generation. * * Rebuilds all entities with `schema = "public"` so Drizzle produces * SQL without schema qualifiers (e.g. `CREATE TABLE "users"` instead * of `CREATE TABLE "myschema"."users"`). * * The actual schema is applied at migration execution time via `search_path`. */ getModelsWithoutSchema(provider) { const maps = provider.rebuildModels("public"); const models = {}; for (const [key, value] of maps.tables.entries()) { if (models[key]) throw new AlephaError(`Model name conflict: '${key}' is already defined.`); models[key] = value; } for (const [key, value] of maps.enums.entries()) { if (models[key]) throw new AlephaError(`Model name conflict: '${key}' is already defined.`); models[key] = value; } for (const [key, value] of maps.sequences.entries()) { if (models[key]) throw new AlephaError(`Model name conflict: '${key}' is already defined.`); models[key] = value; } return models; } /** * Preview schema push without executing any statements. * * Returns the SQL statements that would be executed, warnings, and * whether data loss would occur. Does NOT execute any SQL. */ async dryRunPush(provider) { const kit = this.importDrizzleKit(); const models = this.getModels(provider); if (Object.keys(models).length === 0) return { statements: [], warnings: [], hasDataLoss: false }; let result; if (provider.dialect === "sqlite") result = await kit.pushSQLiteSchema(models, provider.db); else { const wrappedDb = this.wrapDbForDrizzleKit(provider.db); result = await kit.pushSchema(models, wrappedDb, [provider.schema]); } return { statements: result.statementsToExecute, warnings: result.warnings, hasDataLoss: result.hasDataLoss }; } async pushSqlite(kit, models, provider) { const { statementsToExecute } = await kit.pushSQLiteSchema(models, provider.db); await this.executeStatements(statementsToExecute, provider); } /** * Push schema changes to PostgreSQL using Drizzle Kit's pushSchema with schema filters. */ async pushPostgres(kit, models, provider) { if (provider.schema !== "public") await this.createSchemaIfNotExists(provider, provider.schema); const wrappedDb = this.wrapDbForDrizzleKit(provider.db); const { statementsToExecute } = await kit.pushSchema(models, wrappedDb, [provider.schema]); await this.executeStatements(statementsToExecute, provider); } /** * Execute a list of SQL statements against the provider. */ async executeStatements(statements, provider) { if (statements.length > 0) this.log.debug(`Executing ${statements.length} statements ...`, { statements }); for (const statement of statements) await provider.execute(sql$1.raw(statement)); } /** * Execute SQL statements, ignoring "already exists" errors. * * Used by the fallback migration path where push may have partially * applied changes before erroring, leaving some objects already created. */ async executeStatementsLenient(statements, provider) { if (statements.length > 0) this.log.debug(`Executing ${statements.length} statements (lenient) ...`, { statements }); for (const statement of statements) try { await provider.execute(sql$1.raw(statement)); } catch (error) { if ((error?.message ?? "").includes("already exists")) { this.log.debug(`Skipped (already exists): ${statement.slice(0, 80)}`); continue; } throw error; } } async createSchemaIfNotExists(provider, schemaName) { if (!/^[a-z0-9_]+$/i.test(schemaName)) throw new AlephaError(`Invalid schema name: ${schemaName}. Must only contain alphanumeric characters and underscores.`); const sqlSchema = sql$1.raw(schemaName); if (schemaName.startsWith("test_")) { this.log.info(`Drop test schema '${schemaName}' ...`, schemaName); await provider.execute(sql$1`DROP SCHEMA IF EXISTS ${sqlSchema} CASCADE`); } this.log.debug(`Ensuring schema '${schemaName}' exists`); await provider.execute(sql$1`CREATE SCHEMA IF NOT EXISTS ${sqlSchema}`); } /** * Wrap a Drizzle PgDatabase instance for compatibility with Drizzle Kit. * * Drizzle Kit's pushSchema expects execute() to return { rows: T[] } * (node-postgres/pg format), but postgres.js returns a Result that * extends Array directly — no .rows property. */ wrapDbForDrizzleKit(db) { return new Proxy(db, { get(target, prop, receiver) { if (prop === "execute") return async (...args) => { const res = await target.execute(...args); if (Array.isArray(res) && !("rows" in res)) return Object.assign(res, { rows: [...res] }); return res; }; return Reflect.get(target, prop, receiver); } }); } /** * Try to load the official Drizzle Kit API. */ importDrizzleKit() { try { return createRequire(import.meta.url)("drizzle-kit/api"); } catch (_) { throw new AlephaError("Drizzle Kit is not installed. Please install it with `npm install -D drizzle-kit`."); } } }; //#endregion //#region ../../src/orm/core/providers/drivers/DatabaseProvider.ts var DatabaseProvider = class { alepha = $inject(Alepha); dateTime = $inject(DateTimeProvider); log = $logger(); kit = $inject(DrizzleKitProvider); enums = /* @__PURE__ */ new Map(); tables = /* @__PURE__ */ new Map(); sequences = /* @__PURE__ */ new Map(); schemas = /* @__PURE__ */ new Map(); entityPrimitives = []; sequencePrimitives = []; get name() { return "default"; } get driver() { 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`. */ get supportsTransactions() { return true; } /** * Raw database connection handle (e.g. DatabaseSync, bun:sqlite Database). * Override in providers that expose native connections for introspection. */ get nativeConnection() {} 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`). */ get migrationsTable() { return `migrations_${this.schema}`; } /** * Log a database query with structured metadata for devtools inspection. */ logQuery(sql, params, duration, rowCount, error) { 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); } parseOperation(sql) { 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"; } table(entity) { const table = this.tables.get(entity.name); if (!table) throw new AlephaError(`Table '${entity.name}' is not registered`); const hasAlias = entity.$alias; if (hasAlias) return alias(table, hasAlias); return table; } registerEntity(entity) { this.entityPrimitives.push(entity); this.builder.buildTable(entity, this); } registerSequence(sequence) { 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). */ rebuildModels(targetSchema) { const result = { tables: /* @__PURE__ */ new Map(), enums: /* @__PURE__ */ new Map(), sequences: /* @__PURE__ */ new Map(), schemas: /* @__PURE__ */ new Map(), 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). */ async transactional(fn, config) { if (this.alepha.get("alepha.orm.tx")) return fn(); if (!this.supportsTransactions) return fn(); return this.db.transaction(async (tx) => { this.alepha.store.set("alepha.orm.tx", tx, { skipEvents: true }); try { return await fn(); } finally { this.alepha.store.set("alepha.orm.tx", void 0, { skipEvents: true }); } }, config); } async run(statement, schema) { 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 */ getMigrationsFolder() { 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) */ async migrate() { if (this.alepha.isServerless()) return; if (this.alepha.isProduction()) { const migrationsFolder = this.getMigrationsFolder(); if (!await stat(migrationsFolder).catch(() => false)) { this.log.warn("Migration SKIPPED - no migrations found"); return; } if (this.dialect === "postgresql" && this.schema !== "public") { this.log.debug(`Ensuring schema '${this.schema}' exists ...`); await this.execute(sql$1`CREATE SCHEMA IF NOT EXISTS ${sql$1.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); } } } /** * For testing purposes, generate a unique schema name. * * Format: `test_alepha_{epoch_seconds}_{random8}` * Example: `test_alepha_1739871618_k3m9x2p1` */ generateTestSchemaName() { return `test_alepha_${Math.floor(this.dateTime.nowMillis() / 1e3)}_${Math.random().toString(36).slice(2, 10).padEnd(8, "0")}`; } }; //#endregion //#region ../../src/orm/core/modes/DbMigrationMode.ts /** * Built-in migration mode. * * When `MIGRATE=true` (or `MODE=MIGRATE`) is set, runs database migrations * via `DatabaseProvider.migrate()`, then stops the process. * * Auto-registered by `AlephaOrm` — any app using the ORM module gets * `MIGRATE=true node app.js` for free. * * @example * ```bash * MIGRATE=true node app.js # runs migrations, then exits * MODE=MIGRATE node app.js # same effect * ``` */ var DbMigrationMode = class { db = $inject(DatabaseProvider); mode = $mode({ env: "MIGRATE", ready: async () => { await this.db.migrate(); } }); }; //#endregion //#region ../../src/orm/core/constants/PG_SYMBOLS.ts const PG_DEFAULT = Symbol.for("Alepha.Postgres.Default"); const PG_PRIMARY_KEY = Symbol.for("Alepha.Postgres.PrimaryKey"); const PG_CREATED_AT = Symbol.for("Alepha.Postgres.CreatedAt"); const PG_UPDATED_AT = Symbol.for("Alepha.Postgres.UpdatedAt"); const PG_DELETED_AT = Symbol.for("Alepha.Postgres.DeletedAt"); const PG_VERSION = Symbol.for("Alepha.Postgres.Version"); const PG_IDENTITY = Symbol.for("Alepha.Postgres.Identity"); const PG_ENUM = Symbol.for("Alepha.Postgres.Enum"); const PG_REF = Symbol.for("Alepha.Postgres.Ref"); const PG_GENERATED = Symbol.for("Alepha.Postgres.Generated"); const PG_ORGANIZATION = Symbol.for("Alepha.Postgres.Organization"); /** * @deprecated Use `PG_IDENTITY` instead. */ const PG_SERIAL = Symbol.for("Alepha.Postgres.Serial"); //#endregion //#region ../../src/orm/core/schemas/insertSchema.ts const insertSchema = (obj) => { const newProperties = {}; for (const key in obj.shape) { const prop = obj.shape[key]; if (PG_GENERATED in prop) continue; if (PG_DEFAULT in prop || PG_ORGANIZATION in prop) newProperties[key] = prop.optional(); else if (z.schema.isOptional(prop)) newProperties[key] = z.schema.unwrap(prop).nullable().optional(); else newProperties[key] = prop; } return z.object(newProperties); }; //#endregion //#region ../../src/orm/core/schemas/updateSchema.ts const updateSchema = (schema) => { const newProperties = {}; for (const key in schema.shape) { const prop = schema.shape[key]; if (PG_GENERATED in prop) continue; if (z.schema.isOptional(prop)) newProperties[key] = z.union([prop, z.null()]).optional(); else newProperties[key] = prop; } return z.object(newProperties); }; //#endregion //#region ../../src/orm/core/primitives/$entity.ts /** * Creates a database entity primitive that defines table structure using TypeBox schemas. * * @example * ```ts * import { z } from "alepha"; * import { $entity } from "alepha/orm"; * * const userEntity = $entity({ * name: "users", * schema: z.object({ * id: pg.primaryKey(), * name: z.text(), * email: z.email(), * }), * }); * ``` */ const $entity = (options) => { return new EntityPrimitive(options); }; var EntityPrimitive = class EntityPrimitive { options; constructor(options) { this.options = options; } alias(alias) { const aliased = new EntityPrimitive(this.options); return new Proxy(aliased, { get(target, prop, receiver) { if (prop === "$alias") return alias; return Reflect.get(target, prop, receiver); } }); } get cols() { const cols = {}; for (const key of Object.keys(this.schema.properties)) cols[key] = { name: key, entity: this }; return cols; } get name() { return this.options.name; } get schema() { return this.options.schema; } _insertSchema; get insertSchema() { this._insertSchema ??= insertSchema(this.options.schema); return this._insertSchema; } _updateSchema; get updateSchema() { this._updateSchema ??= updateSchema(this.options.schema); return this._updateSchema; } }; $entity[KIND] = EntityPrimitive; //#endregion //#region ../../src/orm/core/helpers/pgAttr.ts /** * Decorates a typebox schema with a Postgres attribute. * * > It's just a fancy way to add Symbols to a field. * * @example * ```ts * import { z } from "alepha"; * import { PG_UPDATED_AT } from "../constants/PG_SYMBOLS"; * * export const updatedAtSchema = pgAttr( * z.datetime(), PG_UPDATED_AT, * ); * ``` */ const pgAttr = (type, attr, value) => { Object.assign(type, { [attr]: value ?? {} }); return type; }; /** * Retrieves the fields of a schema that have a specific attribute. */ const getAttrFields = (schema, name) => { const fields = []; for (const key of Object.keys(schema.shape)) { const value = schema.shape[key]; if (name in value) fields.push({ type: value, key, data: value[name] }); } return fields; }; //#endregion //#region ../../src/orm/core/providers/DatabaseTypeProvider.ts var DatabaseTypeProvider = class { attr = pgAttr; /** * Creates a primary key with an identity column. */ identityPrimaryKey = (identity) => pgAttr(pgAttr(pgAttr(z.integer(), PG_PRIMARY_KEY), PG_IDENTITY, identity), PG_DEFAULT); /** * Creates a primary key with a big identity column. (default) */ bigIdentityPrimaryKey = (identity) => pgAttr(pgAttr(pgAttr(z.int64(), PG_PRIMARY_KEY), PG_IDENTITY, identity), PG_DEFAULT); /** * Creates a primary key with a UUID column. */ uuidPrimaryKey = () => pgAttr(pgAttr(z.uuid(), PG_PRIMARY_KEY), PG_DEFAULT); primaryKey(type, _options, identity) { if (!type || z.schema.isInteger(type)) return pgAttr(pgAttr(pgAttr(z.integer(), PG_PRIMARY_KEY), PG_IDENTITY, identity), PG_DEFAULT); if (z.schema.isString(type) && z.schema.format(type) === "uuid") return pgAttr(pgAttr(z.uuid(), PG_PRIMARY_KEY), PG_DEFAULT); if (z.schema.isNumber(type) && z.schema.format(type) === "int64") return pgAttr(pgAttr(pgAttr(z.number(), PG_PRIMARY_KEY), PG_IDENTITY, identity), PG_DEFAULT); if (z.schema.isBigInt(type)) return pgAttr(pgAttr(pgAttr(z.bigint(), PG_PRIMARY_KEY), PG_IDENTITY, identity), PG_DEFAULT); throw new AlephaError(`Unsupported type for primary key: ${type}`); } /** * Wrap a schema with "default" attribute. * This is used to set a default value for a column in the database. */ default = (type, value) => { if (value != null) Object.assign(type, { default: value }); return this.attr(type, PG_DEFAULT); }; /** * Creates a column 'version'. * * This is used to track the version of a row in the database. * * You can use it for optimistic concurrency control (OCC) with {@link RepositoryPrimitive#save}. * * @see {@link RepositoryPrimitive#save} * @see {@link PgVersionMismatchError} */ version = () => this.default(pgAttr(z.integer(), PG_VERSION), 0); /** * Creates a column Created At. So just a datetime column with a default value of the current timestamp. */ createdAt = () => pgAttr(pgAttr(z.datetime(), PG_CREATED_AT), PG_DEFAULT); /** * Creates a column Updated At. Like createdAt, but it is updated on every update of the row. */ updatedAt = () => pgAttr(pgAttr(z.datetime(), PG_UPDATED_AT), PG_DEFAULT); /** * Creates a column Deleted At for soft delete functionality. * This is used to mark rows as deleted without actually removing them from the database. * The column is nullable - NULL means not deleted, timestamp means deleted. */ deletedAt = () => pgAttr(z.datetime().optional(), PG_DELETED_AT); /** * Creates an organization column for multi-tenant row scoping. * * When present, queries are automatically filtered by the current user's organization. * On create, the column is auto-stamped with the current user's organization. * * @param options.nullable - When `false`, the column is NOT NULL in the database and * the ORM rejects inserts that arrive without an organization context. * Defaults to `true` (nullable) — unless `strict` is set, which flips the * default to non-nullable. NULL rows are visible to every tenant (the * historic "global row" semantics) only when the column is nullable AND * not strict. * @param options.strict - Fail-closed tenant scoping for security-sensitive * tables. Refuses reads/writes with no resolved tenant (instead of a * fail-open "see/write everything") and drops the `OR org IS NULL` escape * so a scoped tenant never sees global rows. Defaults to `false`. */ organization = (options) => { const strict = options?.strict ?? false; return pgAttr(options?.nullable ?? !strict ? z.uuid().optional() : z.uuid(), PG_ORGANIZATION, { strict }); }; /** * Creates a reference to another table or schema. Basically a foreign key. */ ref = (type, ref, actions) => { const finalActions = actions ?? { onDelete: z.schema.isOptional(type) ? "set null" : "cascade" }; return this.attr(type, PG_REF, { ref, actions: finalActions }); }; /** * Creates a page schema for a given object schema. * It's used by {@link Repository#paginate} method. */ page = (resource) => { return pageSchema$1(resource); }; }; /** * Wrapper of TypeProvider (`t`) for database types. * * Use `db` for improve TypeBox schema definitions with database-specific attributes. * * @example * ```ts * import { t } from "alepha"; * import { db } from "alepha/orm"; * * const userSchema = t.object({ * id: db.primaryKey(t.uuid()), * email: t.email(), * createdAt: db.createdAt(), * }); * ``` */ const db = new DatabaseTypeProvider(); //#endregion //#region ../../src/orm/core/entities/alephaSequences.ts /** * Storage table for the {@link SequenceProvider}. * * Each row represents one counter, identified by `(name, scope)`: * - `name` is the sequence primitive name (defaulted from its property key). * - `scope` is an arbitrary string passed by the caller, defaulted to "default" * for the global, unscoped form. Use this column to namespace per-tenant / * per-campaign / per-anything counters off the same primitive. * - `value` is the current counter value. Incremented atomically by * `SequenceProvider.advance()` through an `INSERT ... ON CONFLICT DO UPDATE` * on `(name, scope)` — works identically on Postgres, SQLite, and D1. */ const alephaSequences = $entity({ name: "alepha_sequences", schema: z.object({ id: db.primaryKey(z.uuid()), createdAt: db.createdAt(), updatedAt: db.updatedAt(), name: z.text({ description: "Sequence primitive name (from $sequence's property key)." }), scope: z.text({ description: "Caller-provided sub-scope. Defaults to 'default' for the unscoped form.", default: "default" }), value: z.integer().describe("Current counter value, advanced atomically on each call.") }), indexes: [{ columns: ["name", "scope"], unique: true }] }); //#endregion //#region ../../src/orm/core/errors/DbColumnNotFoundError.ts /** * Error thrown when a column does not exist. * * This typically happens when: * - Column name is misspelled * - Migrations haven't been run * - Using wrong schema version */ var DbColumnNotFoundError = class DbColumnNotFoundError extends DbError { name = "DbColumnNotFoundError"; status = 500; /** * The column that was not found. */ column; /** * The table where the column was expected. */ table; constructor(message, cause, options) { super(message, cause); this.column = options?.column; this.table = options?.table; } /** * Parse a database column not found error and create a DbColumnNotFoundError. * Supports both PostgreSQL and SQLite error formats. */ static fromDatabaseError(error) { const message = error.message; const pgMatch = message.match(/column "([^"]+)"(?: of relation "([^"]+)")? does not exist/); if (pgMatch) { const column = pgMatch[1]; const table = pgMatch[2]; const msg = table ? `Column '${column}' does not exist in table '${table}'` : `Column '${column}' does not exist`; return new DbColumnNotFoundError(msg, error, { column, table }); } const sqliteMatch = message.match(/no such column: (?:(\S+)\.)?(\S+)/); if (sqliteMatch) { const table = sqliteMatch[1]; const column = sqliteMatch[2]; const msg = table ? `Column '${column}' does not exist in table '${table}'` : `Column '${column}' does not exist`; return new DbColumnNotFoundError(msg, error, { column, table }); } return new DbColumnNotFoundError("Column does not exist", error); } }; //#endregion //#region ../../src/orm/core/errors/DbConflictError.ts var DbConflictError = class extends DbError { name = "DbConflictError"; status = 409; }; //#endregion //#region ../../src/orm/core/errors/DbDeadlockError.ts /** * Error thrown when a deadlock is detected. * * This happens when two or more transactions are waiting for each other * to release locks. The database automatically aborts one transaction * to resolve the deadlock. * * This error is useful for implementing retry logic. */ var DbDeadlockError = class DbDeadlockError extends DbError { name = "DbDeadlockError"; status = 409; /** * Whether this error is retryable. * Deadlocks are typically safe to retry. */ retryable = true; /** * Parse a database deadlock error message and create a DbDeadlockError. * Supports PostgreSQL. SQLite doesn't have true deadlocks (uses SQLITE_BUSY instead). */ static fromDatabaseError(error) { return new DbDeadlockError("Transaction deadlock detected. Please retry the operation.", error); } }; //#endregion //#region ../../src/orm/core/errors/DbEntityNotFoundError.ts var DbEntityNotFoundError = class extends DbError { name = "DbEntityNotFoundError"; status = 404; constructor(entityName) { super(`Entity from '${entityName}' was not found`); } }; //#endregion //#region ../../src/orm/core/errors/DbForeignKeyError.ts /** * Error thrown when a foreign key constraint is violated. * * This typically happens when trying to delete an entity that is * referenced by another entity. */ var DbForeignKeyError = class DbForeignKeyError extends DbError { name = "DbForeignKeyError"; status = 409; /** * The table that references the entity being deleted. */ referencingTable; /** * The constraint name that was violated. */ constraintName; constructor(message, cause, options) { super(message, cause); this.referencingTable = options?.referencingTable; this.constraintName = options?.constraintName; } /** * Parse a database foreign key error message and create a DbForeignKeyError. * Supports both PostgreSQL and SQLite error formats. */ static fromDatabaseError(error, tableName) { const pgMatch = error.message.match(/violates foreign key constraint "([^"]+)" on table "([^"]+)"/); if (pgMatch) { const constraintName = pgMatch[1]; const referencingTable = pgMatch[2]; return new DbForeignKeyError(`Cannot delete ${tableName}: it is referenced by ${referencingTable}`, error, { referencingTable, constraintName }); } return new DbForeignKeyError(`Cannot delete ${tableName}: it is referenced by another entity`, error); } }; //#endregion //#region ../../src/orm/core/errors/DbNotNullError.ts /** * Error thrown when a NOT NULL constraint is violated. * * This happens when inserting or updating a NULL value into a column * that has a NOT NULL constraint. */ var DbNotNullError = class DbNotNullError extends DbError { name = "DbNotNullError"; status = 400; /** * The column that violated the NOT NULL constraint. */ column; /** * The table containing the column. */ table; constructor(message, cause, options) { super(message, cause); this.column = options?.column; this.table = options?.table; } /** * Parse a database NOT NULL error message and create a DbNotNullError. * Supports both PostgreSQL and SQLite error formats. */ static fromDatabaseError(error, tableName) { const message = error.message; const pgMatch = message.match(/null value in column "([^"]+)" of relation "([^"]+)" violates not-null constraint/); if (pgMatch) { const column = pgMatch[1]; const table = pgMatch[2]; return new DbNotNullError(`Column '${column}' in '${table}' cannot be null`, error, { column, table }); } const sqliteMatch = message.match(/NOT NULL constraint failed: ([^.]+)\.(\S+)/); if (sqliteMatch) { const table = sqliteMatch[1]; const column = sqliteMatch[2]; return new DbNotNullError(`Column '${column}' in '${table}' cannot be null`, error, { column, table }); } return new DbNotNullError(`A required field in '${tableName}' cannot be null`, error); } }; //#endregion //#region ../../src/orm/core/errors/DbTableNotFoundError.ts /** * Error thrown when a table does not exist. * * This typically happens when: * - Migrations haven't been run * - Table name is misspelled * - Wrong database/schema is being used */ var DbTableNotFoundError = class DbTableNotFoundError extends DbError { name = "DbTableNotFoundError"; status = 500; /** * The table that was not found. */ table; constructor(message, cause, options) { super(message, cause); this.table = options?.table; } /** * Parse a database table not found error and create a DbTableNotFoundError. * Supports both PostgreSQL and SQLite error formats. */ static fromDatabaseError(error) { const message = error.message; const pgMatch = message.match(/relation "([^"]+)" does not exist/); if (pgMatch) { const table = pgMatch[1]; return new DbTableNotFoundError(`Table '${table}' does not exist. Have you run migrations?`, error, { table }); } const sqliteMatch = message.match(/no such table: (\S+)/); if (sqliteMatch) { const table = sqliteMatch[1]; return new DbTableNotFoundError(`Table '${table}' does not exist. Have you run migrations?`, error, { table }); } return new DbTableNotFoundError("Table does not exist. Have you run migrations?", error); } }; //#endregion //#region ../../src/orm/core/errors/DbVersionMismatchError.ts /** * Error thrown when there is a version mismatch. * It's thrown by {@link Repository#save} when the updated entity version does not match the one in the database. * This is used for optimistic concurrency control. */ var DbVersionMismatchError = class extends DbError { name = "DbVersionMismatchError"; constructor(table, id) { super(`Version mismatch for table '${table}' and id '${id}'`); } }; //#endregion //#region ../../src/orm/core/providers/DbCacheProvider.ts /** * Database query cache using a simple in-memory Map. * * Uses `{tableName}:{cacheKey}` as the storage key. * Provides per-table invalidation for write-through cache busting. * * This is intentionally self-contained (no external cache dependencies) * so the ORM module does not force `AlephaCache` on all consumers. */ var DbCacheProvider = class { dateTime = $inject(DateTimeProvider); store = /* @__PURE__ */ new Map(); storeKey(tableName, cacheKey) { return `${tableName}:${cacheKey}`; } /** * Get a cached query result. */ async get(tableName, cacheKey) { const key = this.storeKey(tableName, cacheKey); const entry = this.store.get(key); if (!entry) return void 0; if (entry.expiresAt && this.dateTime.nowMillis() > entry.expiresAt) { this.store.delete(key); return; } return entry.value; } /** * Store a query result in the cache. */ async set(tableName, cacheKey, value, ttl) { const key = this.storeKey(tableName, cacheKey); this.store.set(key, { value, expiresAt: ttl ? this.dateTime.nowMillis() + ttl : void 0 }); } /** * Invalidate all cached queries for a table. */ async invalidateTable(tableName) { const prefix = `${tableName}:`; for (const key of this.store.keys()) if (key.startsWith(prefix)) this.store.delete(key); } }; //#endregion //#region ../../src/orm/core/services/PgRelationManager.ts var PgRelationManager = class { schemaValidator = $inject(SchemaValidator); /** * Recursively build joins for the query builder based on the relations map */ buildJoins(provider, builder, joins, withRelations, table, parentKey) { for (const [key, join] of Object.entries(withRelations)) { const from = provider.table(join.join); const on = isSQLWrapper$1(join.on) ? join.on : sql$1`${table[join.on[0]]} = ${from[join.on[1].name]}`; if (join.type === "right") builder.rightJoin(from, on); else if (join.type === "inner") builder.innerJoin(from, on); else builder.leftJoin(from, on); joins.push({ key, table: getTableName(from), schema: join.join.schema, col: (name) => from[name], parent: parentKey }); if (join.with) this.buildJoins(provider, builder, joins, join.with, from, parentKey ? `${parentKey}.${key}` : key); } } /** * Map a row with its joined relations based on the joins definition */ mapRowWithJoins(record, row, schema, joins, parentKey) { for (const join of joins) if (join.parent === parentKey) { const joinedData = row[join.table]; if (this.isAllNull(joinedData)) record[join.key] = void 0; else { record[join.key] = joinedData; this.mapRowWithJoins(record[join.key], row, schema, joins, parentKey ? `${parentKey}.${join.key}` : join.key); } } return record; } /** * Check if all values in an object are null (indicates a left join with no match) */ isAllNull(obj) { if (obj === null || obj === void 0) return true; if (typeof obj !== "object") return false; return Object.values(obj).every((val) => val === null); } /** * Build a schema that includes all join properties recursively */ buildSchemaWithJoins(baseSchema, joins, parentPath) { const schema = this.schemaValidator.clone(baseSchema); const joinsAtThisLevel = joins.filter((j) => j.parent === parentPath); for (const join of joinsAtThisLevel) { const joinPath = parentPath ? `${parentPath}.${join.key}` : join.key; const childJoins = joins.filter((j) => j.parent === joinPath); let joinSchema = join.schema; if (childJoins.length > 0) joinSchema = this.buildSchemaWithJoins(join.schema, joins, joinPath); schema.properties[join.key] = joinSchema.optional(); } return schema; } }; //#endregion //#region ../../src/orm/core/services/QueryManager.ts var QueryManager = class { alepha = $inject(Alepha); /** * Convert a query object to a SQL query. */ toSQL(query, options) { const { schema, col, joins } = options; const conditions = []; if (isSQLWrapper(query)) conditions.push(query); else { const keys = Object.keys(query); for (const key of keys) { const operator = query[key]; if (typeof query[key] === "object" && query[key] != null && !Array.isArray(query[key]) && joins?.length) { const matchingJoins = joins.filter((j) => j.key === key); if (matchingJoins.length > 0) { const join = matchingJoins[0]; const joinPath = join.parent ? `${join.parent}.${key}` : key; const recursiveJoins = joins.filter((j) => { if (!j.parent) return false; return j.parent === joinPath || j.parent.startsWith(`${joinPath}.`); }).map((j) => { const newParent = j.parent === joinPath ? void 0 : j.parent.substring(joinPath.length + 1); return { ...j, parent: newParent }; }); const sql = this.toSQL(query[key], { schema: join.schema, col: join.col, joins: recursiveJoins.length > 0 ? recursiveJoins : void 0, dialect: options.dialect }); if (sql) conditions.push(sql); continue; } } if (Array.isArray(operator)) { const operations = operator.map((it) => { if (isSQLWrapper(it)) return it; return this.toSQL(it, { schema, col, joins, dialect: options.dialect }); }).filter((it) => it != null); if (key === "and") { const combined = and(...operations); if (combined) conditions.push(combined); continue; } if (key === "or") { const combined = or(...operations); if (combined) conditions.push(combined); continue; } } if (key === "not") { const where = this.toSQL(operator, { schema, col, joins, dialect: options.dialect }); if (where) conditions.push(not(where)); continue; } if (key === "exists") { conditions.push(exists(operator)); continue; } if (key === "notExists") { conditions.push(notExists(operator)); continue; } if (operator != null) { const column = col(key); const sql = this.mapOperatorToSql(operator, column, schema, key, options.dialect); if (sql) conditions.push(sql); } } } if (conditions.length === 1) return conditions[0]; return and(...conditions); } /** * Check if an object has any filter operator properties. */ hasFilterOperatorProperties(obj) { if (!obj || typeof obj !== "object") return false; return [ "eq", "ne", "gt", "gte", "lt", "lte", "inArray", "notInArray", "isNull", "isNotNull", "like", "notLike", "ilike", "notIlike", "contains", "startsWith", "endsWith", "between", "notBetween", "arrayContains", "arrayContained", "arrayOverlaps" ].some((key) => key in obj); } /** * Map a filter operator to a SQL query. */ mapOperatorToSql(operator, column, columnSchema, columnName, dialect = "postgresql") { const encodeValue = (value) => { if (value == null) return value; if (columnSchema && columnName) try { const fieldSchema = columnSchema.properties[columnName]; if (fieldSchema) return this.alepha.codec.encode(fieldSchema, value, { encoder: "drizzle" }); } catch (error) {} return value; }; const encodeArray = (values) => { return values.map((v) => encodeValue(v)); }; if (typeof operator !== "object" || operator == null || !this.hasFilterOperatorProperties(operator)) return eq(column, encodeValue(operator)); const conditions = []; if (operator?.eq != null) conditions.push(eq(column, encodeValue(operator.eq))); if (operator?.ne != null) conditions.push(ne(column, encodeValue(operator.ne))); if (operator?.gt != null) conditions.push(gt(column, encodeValue(operator.gt))); if (operator?.gte != null) conditions.push(gte(column, encodeValue(operator.gte))); if (operator?.lt != null) conditions.push(lt(column, encodeValue(operator.lt))); if (operator?.lte != null) conditions.push(lte(column, encodeValue(operator.lte))); if (operator?.inArray != null) { if (!Array.isArray(operator.inArray) || operator.inArray.length === 0) throw new AlephaError("inArray operator requires at least one value"); conditions.push(inArray(column, encodeArray(operator.inArray))); } if (operator?.notInArray != null) { if (!Array.isArray(operator.notInArray) || operator.notInArray.length === 0) throw new AlephaError("notInArray operator requires at least one value"); conditions.push(notInArray(column, encodeArray(operator.notInArray))); } if (operator?.isNull != null) conditions.push(isNull(column)); if (operator?.isNotNull != null) conditions.push(isNotNull(column)); if (operator?.like != null) conditions.push(like(column, encodeValue(operator.like))); if (operator?.notLike != null) conditions.push(notLike(column, encodeValue(operator.notLike))); if (operator?.ilike != null) if (dialect === "sqlite") conditions.push(sql$1`LOWER(${column}) LIKE LOWER(${encodeValue(operator.ilike)})`); else conditions.push(ilike(column, encodeValue(operator.ilike))); if (operator?.notIlike != null) if (dialect === "sqlite") conditions.push(sql$1`LOWER(${column}) NOT LIKE LOWER(${encodeValue(operator.notIlike)})`); else conditions.push(notIlike(column, encodeValue(operator.notIlike))); if (operator?.contains != null) { const escapedValue = String(operator.contains).replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_"); if (dialect === "sqlite") conditions.push(sql$1`LOWER(${column}) LIKE LOWER(${encodeValue(`%${escapedValue}%`)}) ESCAPE '\\'`); else conditions.push(ilike(column, encodeValue(`%${escapedValue}%`))); } if (operator?.startsWith != null) { const escapedValue = String(operator.startsWith).replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_"); if (dialect === "sqlite") conditions.push(sql$1`LOWER(${column}) LIKE LOWER(${encodeValue(`${escapedValue}%`)}) ESCAPE '\\'`); else conditions.push(ilike(column, encodeValue(`${escapedValue}%`))); } if (operator?.endsWith != null) { const escapedValue = String(operator.endsWith).replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_"); if (dialect === "sqlite") conditions.push(sql$1`LOWER(${column}) LIKE LOWER(${encodeValue(`%${escapedValue}`)}) ESCAPE '\\'`); else conditions.push(ilike(column, encodeValue(`%${escapedValue}`))); } if (operator?.between != null) { if (!Array.isArray(operator.between) || operator.between.length !== 2) throw new AlephaError("between operator requires exactly 2 values [min, max]"); conditions.push(between(column, encodeValue(operator.between[0]), encodeValue(operator.between[1]))); } if (operator?.notBetween != null) { if (!Array.isArray(operator.notBetween) || operator.notBetween.length !== 2) throw new AlephaError("notBetween operator requires exactly 2 values [min, max]"); conditions.push(notBetween(column, encodeValue(operator.notBetween[0]), encodeValue(operator.notBetween[1]))); } if (operator?.arrayContains != null) conditions.push(arrayContains(column, encodeValue(operator.arrayContains))); if (operator?.arrayContained != null) conditions.push(arrayContained(column, encodeValue(operator.arrayContained))); if (operator?.arrayOverlaps != null) conditions.push(arrayOverlaps(column, encodeValue(operator.arrayOverlaps))); if (conditions.length === 0) return; if (conditions.length === 1) return conditions[0]; return and(...conditions); } /** * Parse pagination sort string to orderBy format. * Format: "firstName,-lastName" -> [{ column: "firstName", direction: "asc" }, { column: "lastName", direction: "desc" }] * - Columns separated by comma * - Prefix with '-' for DESC direction * * @param sort Pagination sort string * @returns OrderBy array or single object */ parsePaginationSort(sort) { const orderByClauses = sort.split(",").map((field) => field.trim()).map((field) => { if (field.startsWith("-")) return { column: field.substring(1), direction: "desc" }; return { column: field, direction: "asc" }; }); return orderByClauses.length === 1 ? orderByClauses[0] : orderByClauses; } /** * Normalize orderBy parameter to array format. * Supports 3 modes: * 1. String: "name" -> [{ column: "name", direction: "asc" }] * 2. Object: { column: "name", direction: "desc" } -> [{ column: "name", direction: "desc" }] * 3. Array: [{ column: "name" }, { column: "age", direction: "desc" }] -> normalized array * * @param orderBy The orderBy parameter * @returns Normalized array of order by clauses */ normalizeOrderBy(orderBy) { if (typeof orderBy === "string") return [{ column: orderBy, direction: "asc" }]; if (!Array.isArray(orderBy) && typeof orderBy === "object") return [{ column: orderBy.column, direction: orderBy.direction ?? "asc" }]; if (Array.isArray(orderBy)) return orderBy.map((item) => ({ column: item.column, direction: item.direction ?? "asc" })); return []; } /** * Create a pagination object. * * @deprecated Use `createPagination` from alepha instead. * This method now delegates to the framework-level helper. * * @param entities The entities to paginate. * @param limit The limit of the pagination. * @param offset The offset of the pagination. * @param sort Optional sort metadata to include in response. */ createPagination(entities, limit = 10, offset = 0, sort) { return createPagination(entities, limit, offset, sort); } }; //#endregion //#region ../../src/orm/core/services/Repository.ts var Repository = class Repository { entity; provider; log = $logger(); relationManager = $inject(PgRelationManager); queryManager = $inject(QueryManager); dateTimeProvider = $inject(DateTimeProvider); dbCache = new DbCacheProvider(); alepha = $inject(Alepha); static of(entity, provider = DatabaseProvider) { return class InlineRepository extends Repository { constructor() { super(entity, provider); } }; } constructor(entity, provider = DatabaseProvider) { this.entity = entity; this.provider = this.alepha.inject(provider); this.provider.registerEntity(entity); } /** * Represents the primary key of the table. * - Key is the name of the primary key column. * - Type is the type (TypeBox) of the primary key column. * * ID is mandatory. If the table does not have a primary key, it will throw an error. */ get id() { return this.getPrimaryKey(this.entity.schema); } /** * Get Drizzle table object. */ get table() { return this.provider.table(this.entity); }