/** * @mandujs/core/db/migrations/lock * * Per-dialect serialization for the migration apply loop. Prevents two * concurrent `MigrationRunner.apply()` calls — in the same process or * (for PG/MySQL) across processes — from stepping on each other. * * ## Strategy matrix * * | Provider | Strategy | Mechanism | * |-----------|-----------------------|------------------------------------------------------------------| * | postgres | `pg_advisory_lock` | `pg_advisory_lock($1)` — lockId = `hashtext('mandu:migrations')` | * | mysql | `mysql_get_lock` | `GET_LOCK('mandu:migrations', 60)` — blocks up to 60 s | * | sqlite | `sqlite_immediate` | `BEGIN IMMEDIATE` on a dedicated connection | * | (any) | `none` | No-op (tests / non-concurrent scenarios) | * * All strategies wrap the same conceptual lock — the lockId string * (`"mandu:migrations"` by default) is stable across runs, so two * processes agreeing on the same string will serialise. If callers pass * a custom `lockId`, every participant MUST use the same value. * * ## Release semantics * * `acquireMigrationLock(...)` returns a `MigrationLock` whose * `release()` is idempotent. Callers should invoke it in a `finally` * block — the `MigrationRunner.apply()` implementation does exactly * this. SQLite's `sqlite_immediate` releases by committing the wrapper * transaction; the other strategies issue an explicit unlock statement. * * @module db/migrations/lock */ import type { LockStrategy } from "../../resource/ddl/types"; import type { Db } from "../index"; // ─── Public API ───────────────────────────────────────────────────────────── /** A held migration lock. Calling `release()` twice is a no-op. */ export interface MigrationLock { release(): Promise; } /** * Default lockId — a stable string shared by every participant. For * `pg_advisory_lock` we hash this to a bigint via `hashtext()`; the * other dialects use the raw string. */ export const DEFAULT_LOCK_ID = "mandu:migrations"; /** MySQL `GET_LOCK` default timeout in seconds. */ const MYSQL_LOCK_TIMEOUT_SECONDS = 60; /** * Acquire the migration lock for the configured dialect. * * - `pg_advisory_lock` blocks the connection until the lock is granted. * - `mysql_get_lock` blocks up to {@link MYSQL_LOCK_TIMEOUT_SECONDS}; on * timeout we throw a descriptive error. * - `sqlite_immediate` enters a BEGIN IMMEDIATE transaction on a * dedicated connection; if another writer holds the database lock, * Bun.SQL surfaces `SQLITE_BUSY` which we rethrow. * - `none` is a no-op — useful for tests and single-shot CLI runs. * * @throws on MySQL lock timeout (`GET_LOCK` returns `0`), on explicit * `GET_LOCK` error (returns `null`), or on provider/strategy mismatch * (e.g. requesting `pg_advisory_lock` while `db.provider === "sqlite"`). */ export async function acquireMigrationLock( db: Db, strategy: LockStrategy, lockId: string = DEFAULT_LOCK_ID, ): Promise { switch (strategy) { case "pg_advisory_lock": assertProvider(db, "postgres", strategy); return await acquirePgAdvisoryLock(db, lockId); case "mysql_get_lock": assertProvider(db, "mysql", strategy); return await acquireMysqlLock(db, lockId); case "sqlite_immediate": assertProvider(db, "sqlite", strategy); return await acquireSqliteImmediate(db, lockId); case "none": return makeNoopLock(); } } // ─── Strategy impls ───────────────────────────────────────────────────────── async function acquirePgAdvisoryLock( db: Db, lockId: string, ): Promise { // Postgres advisory locks take a bigint. We derive it from the lockId // string via `hashtext()::bigint` so the value is deterministic for // the same input across instances. `hashtext` returns int4; cast to // bigint for compatibility with `pg_advisory_lock(bigint)`. // // `SELECT pg_advisory_lock(hashtext($1)::bigint)` is safer than // computing the hash client-side because it keeps every participant // in the same database agreeing on the value without language-specific // hash-function reproduction. await db`SELECT pg_advisory_lock(hashtext(${lockId})::bigint)`; let _released = false; return { async release(): Promise { if (_released) return; _released = true; try { await db`SELECT pg_advisory_unlock(hashtext(${lockId})::bigint)`; } catch (err) { // Best-effort release — if the connection has already died or // the lock is no longer held, we don't want to mask the caller's // unwind path. Surface the error name for diagnostics. const msg = err instanceof Error ? err.message : String(err); console.warn( `[@mandujs/core/db/migrations] pg_advisory_unlock failed: ${msg}`, ); } }, }; } async function acquireMysqlLock( db: Db, lockId: string, ): Promise { const processLock = await acquireMysqlProcessMutex(lockId); // `GET_LOCK` returns: // 1 — lock granted // 0 — timeout (still blocked after the timeout) // NULL — error (aborted, killed, etc.) // // Bun.SQL returns the single row as `[{ acquired: 1 }]`; we destructure // defensively to handle any driver-side aliasing. try { const timeoutSec = MYSQL_LOCK_TIMEOUT_SECONDS; const rows = await db<{ acquired: number | bigint | null }>` SELECT GET_LOCK(${lockId}, ${timeoutSec}) AS acquired `; const first = rows[0]; const value = first ? Number(first.acquired) : NaN; if (value !== 1) { throw new Error( `[@mandujs/core/db/migrations] GET_LOCK(${JSON.stringify(lockId)}, ${timeoutSec}) ` + `returned ${value === 0 ? "0 (timeout)" : "NULL (error)"}; ` + `another migration runner may be holding the lock.`, ); } } catch (err) { await processLock.release(); throw err; } let _released = false; return { async release(): Promise { if (_released) return; _released = true; try { await db`SELECT RELEASE_LOCK(${lockId})`; } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.warn( `[@mandujs/core/db/migrations] RELEASE_LOCK failed: ${msg}`, ); } finally { await processLock.release(); } }, }; } /** * Same-process MySQL runners are serialised before hitting GET_LOCK. * Cross-process safety still comes from MySQL's named lock; this mutex only * avoids concurrent GET_LOCK calls from separate Bun.SQL pools in one runtime. */ const MYSQL_LOCK_REGISTRY_SYMBOL = Symbol.for( "@mandujs/core/db/migrations/mysql-locks", ); interface MysqlLockRegistry { chains: Map>; } function getMysqlLockRegistry(): MysqlLockRegistry { const g = globalThis as unknown as Record; let reg = g[MYSQL_LOCK_REGISTRY_SYMBOL] as MysqlLockRegistry | undefined; if (!reg) { reg = { chains: new Map() }; g[MYSQL_LOCK_REGISTRY_SYMBOL] = reg; } return reg; } async function acquireMysqlProcessMutex(lockId: string): Promise { const registry = getMysqlLockRegistry(); const previous = registry.chains.get(lockId) ?? Promise.resolve(); let release!: () => void; const nextPromise = new Promise((resolve) => { release = resolve; }); const tail = previous.then(() => nextPromise); registry.chains.set(lockId, tail); await previous; let _released = false; return { async release(): Promise { if (_released) return; _released = true; release(); if (registry.chains.get(lockId) === tail) { registry.chains.delete(lockId); } }, }; } /** * Per-process mutex queues keyed by lockId. SQLite is single-writer at * the engine level, so cross-process writes already serialise — the * remaining failure mode is two `apply()` calls in the SAME process * interleaving their `db.transaction(...)` calls. A process-local * promise chain is the simplest correct coordinator. * * This is stored on `globalThis` rather than module scope so that * multiple copies of the module (e.g. reloads under watch mode) still * agree on the same queue. */ const SQLITE_LOCK_REGISTRY_SYMBOL = Symbol.for( "@mandujs/core/db/migrations/sqlite-locks", ); interface SqliteLockRegistry { /** Most-recent promise in each lockId's queue. */ chains: Map>; } function getSqliteLockRegistry(): SqliteLockRegistry { const g = globalThis as unknown as Record; let reg = g[SQLITE_LOCK_REGISTRY_SYMBOL] as SqliteLockRegistry | undefined; if (!reg) { reg = { chains: new Map() }; g[SQLITE_LOCK_REGISTRY_SYMBOL] = reg; } return reg; } async function acquireSqliteImmediate( db: Db, lockId: string, ): Promise { // Why an in-process mutex instead of `BEGIN IMMEDIATE`: // // - `BEGIN IMMEDIATE` at the handle level conflicts with the // `db.transaction()` calls we issue per-migration — SQLite // errors with "cannot start a transaction within a transaction". // - SQLite is already single-writer at the engine level, so // cross-process writes queue via the OS file lock; the only // race we need to close is two concurrent `apply()` calls in // the SAME process interleaving their statements. // - A promise-chain mutex is the minimal correct mechanism for // in-process serialisation. Every apply() awaits the previous // holder's release before entering the critical section. // // Cross-process migration coordination is v2 per RFC 0001 §8. // // Sanity probe: executing a trivial statement on the handle at // acquire time catches "pool closed" / handle validity issues // before the caller commits resources to the apply loop. await db`SELECT 1`; const registry = getSqliteLockRegistry(); const previous = registry.chains.get(lockId) ?? Promise.resolve(); let release!: () => void; const nextPromise = new Promise((resolve) => { release = resolve; }); const tail = previous.then(() => nextPromise); registry.chains.set(lockId, tail); // Wait for the previous holder to release. await previous; let _released = false; return { async release(): Promise { if (_released) return; _released = true; release(); // If no one queued behind us, drop the entry so the registry // doesn't grow unbounded across many apply() cycles. if (registry.chains.get(lockId) === tail) { registry.chains.delete(lockId); } }, }; } function makeNoopLock(): MigrationLock { let _released = false; return { async release(): Promise { _released = true; }, }; } // ─── Helpers ──────────────────────────────────────────────────────────────── function assertProvider( db: Db, expected: Db["provider"], strategy: LockStrategy, ): void { if (db.provider !== expected) { throw new Error( `[@mandujs/core/db/migrations] Lock strategy ${JSON.stringify(strategy)} requires ` + `provider ${JSON.stringify(expected)}, but db.provider is ${JSON.stringify(db.provider)}.`, ); } }