/** * **DbWriterLease** — single in-process writer-lease arbitration over a persisted * SQLite row (T11627 ST-2 · local-mode engine). * * Heals the T5158 multi-writer corruption (`E_NOT_INITIALIZED` / `E_INTERNAL` on * the consolidated `cleo.db`) **while the supervisor daemon stays DISABLED in * production** by serializing writers through a `BEGIN IMMEDIATE` claim transaction * against a persisted `_writer_leases` row, fenced by an epoch-CAS. The supervisor * (ST-5, daemon-on) and Node (this module, daemon-off) share the SAME arbitration * primitive and row format — IPC is a coordination optimization over the persisted * source-of-truth, never a second source. * * ## Modes ({@link resolveLeaseMode}) * * `CLEO_WRITER_LEASE_MODE` ∈ `{ supervisor | local | off | require }`, default * `local`: * * - `local` — Node arbitrates the row directly. No IPC. **Heals T5158 with the * daemon off** (the shipping config). DEFAULT. * - `supervisor` — would prefer IPC to the supervisor; in ST-2 (no IPC client * wired) it transparently behaves as `local`, logging the demotion once. * - `off` — pure pass-through: {@link withWriterLease} just runs `fn` under the * existing `busy_timeout=30000`. Byte-identical to pre-lease behaviour. Rollback * escape hatch. * - `require` — strict: an acquire that cannot take the row throws * `E_LEASE_UNAVAILABLE`. No fallback. * * `busy_timeout=30000` (SSoT in `specs/sqlite-pragmas.json`) backstops every * `BEGIN IMMEDIATE`, so a contended claim degrades to today's bounded wait rather * than a hang in any mode. * * ## Re-entrancy * * A process-local grant memo (`Map<\`${scope}::${lane}\`, { handle, refcount }>`) * memoizes the active grant so a nested same-lane write in the SAME process shares * one lease (`refcount++` + the row's `reentrancy_depth++`) instead of re-running * the claim txn — no second `BEGIN IMMEDIATE`, no IPC. `release()` decrements; the * row is freed (`active = 0`) at depth 0. * * ## DB Open Guard (Gate 3) * * This module lives under `packages/core/src/store/**`, which is inside the Gate-3 * raw-open allowlist (the DB chokepoint). It obtains the native handle via * {@link openDualScopeDb} (no new raw `new DatabaseSync(`) and runs raw * `BEGIN IMMEDIATE` claim/reclaim/release transactions on it. * * ## Cold-open seam (T11627 ST-3 · Seam 0 — the T5158 heal) * * {@link withColdOpenLease} leases the `dual-scope-db.ts` cold-open critical * section (`reconcileJournal` + `migrateWithRetry` + the exodus-on-open hook) * against the SAME native handle the open just created — it does NOT route through * the default resolver (which would re-enter `openDualScopeDb` and recurse). It * idempotently bootstraps the lease tables on that handle FIRST (the full * migration that creates them runs INSIDE the leased section), so exactly one * process per scope runs the migrate/reconcile write-txn while peers * `BEGIN IMMEDIATE`-queue and then observe a ready DB. This is the heal that ships * with the supervisor daemon disabled. * * @module * @task T11627 * @epic T11625 * @see ./writer-lease-schema.ts — the drizzle table decls + bootstrap index assertion * @see ./dual-scope-db.ts — the open chokepoint this engine routes through (Seams 0 & 1) */ import type { DatabaseSync } from 'node:sqlite'; import type { LeaseLane, LeaseScope } from '@cleocode/contracts'; export type { LeaseLane, LeaseScope } from '@cleocode/contracts'; /** * The writer-lease arbitration mode. Resolved once at process start from * `CLEO_WRITER_LEASE_MODE`; default `'local'`. */ export type LeaseMode = 'supervisor' | 'local' | 'off' | 'require'; /** * A held writer-lease grant. Returned by {@link acquireWriterLease}; passed to the * callback of {@link withWriterLease}. */ export interface LeaseHandle { /** The cleo.db scope this lease arbitrates within. */ readonly scope: LeaseScope; /** The write lane this lease arbitrates within the scope. */ readonly lane: LeaseLane; /** * The epoch fence assigned to this grant. A write whose epoch no longer matches * the row (the lease was reclaimed) must fail rather than corrupt * (`E_WRITER_LEASE_STALE`). */ readonly epoch: number; /** * Release the lease. Decrements the process-local refcount and the row's * `reentrancy_depth`; frees the row (`active = 0`) and stops the heartbeat at * depth 0. Idempotent — a second call is a no-op. */ release(): Promise; /** * Advance the lease heartbeat (`heartbeat_at = now`) under the epoch guard. A * reclaimed holder's heartbeat no-ops. Called automatically by the internal * timer; exposed for callers that want to assert liveness mid-batch. */ heartbeat(): void; } /** Options accepted by {@link withWriterLease} / {@link acquireWriterLease}. */ export interface LeaseAcquireOptions { /** Advisory priority — lower acquires sooner. `0` = highest. Default `100`. */ priority?: number; /** Lease time-to-live in milliseconds. Default {@link DEFAULT_TTL_MS}. */ ttlMs?: number; /** * When true (default), a nested same-(scope,dbPath,lane) acquire in this process * re-enters the existing grant (refcount++) instead of contending. When false, * a nested acquire is forced to take a fresh row (used only by tests exercising * cross-holder contention within one process). */ reentrant?: boolean; /** * Pin the lease to a SPECIFIC cleo.db file path. When omitted the * canonical scope→path resolver is used. The path is normalized via * `path.resolve()` before memo-keying so alias paths share one grant * row (T12042 Finding 4). The chokepoint write primitives resolve * identity from their exact DB handle via {@link resolveDbIdentity}, * not from a process-global registry. */ dbPath?: string; } /** Default lease TTL — aligned to `busy_timeout=30000` (specs/sqlite-pragmas.json). */ export declare const DEFAULT_TTL_MS = 30000; /** The cold-open schema-bootstrap priority (highest). */ export declare const SCHEMA_BOOTSTRAP_PRIORITY = 0; /** * Resolve the writer-lease mode from `CLEO_WRITER_LEASE_MODE`, once per process. * * Unknown / unset values resolve to `'local'` — the production-safe default while * the supervisor daemon is disabled. * * @returns The resolved {@link LeaseMode}. * * @task T11627 */ export declare function resolveLeaseMode(): LeaseMode; /** * Reset cached process-global state (mode + demotion flag + grant memo). Tests * only — production resolves these once and never resets. * * @internal */ export declare function _resetWriterLeaseStateForTest(): void; /** * A resolved lease target: the native `cleo.db` handle for a scope PLUS the * absolute on-disk path that physically distinguishes it. * * The lease scope key is the EXISTING `cacheKey(scope, dbPath)` composite * (`${scope}::${dbPath}`), so two DIFFERENT project files opened in one process * are DISTINCT lease scopes (spec §6 Seam 0). Keying the grant memo + the * resolver on the abstract scope LABEL alone would mis-share an in-process grant * across two projects and route the second project's lease writes to the * cwd-default file. `dbPath` closes that latent cross-project gap. */ export interface LeaseTarget { /** The native `DatabaseSync` handle for the scope's `cleo.db`. */ readonly native: DatabaseSync; /** The absolute on-disk path of that `cleo.db` (the lease-scope discriminator). */ readonly dbPath: string; } /** * Resolver that yields the native handle + path for a scope's `cleo.db`. When * `dbPath` is supplied the resolver MUST open THAT file (an explicit-path lease, * e.g. a second project's cleo.db); otherwise it resolves the cwd-default * canonical path for the scope. */ export type NativeDbResolver = (scope: LeaseScope, dbPath?: string) => Promise; /** * Override how the engine obtains a scope's native `cleo.db` handle + path. Tests * inject a temp-dir handle here so arbitration runs against an isolated fixture * with no supervisor and no canonical-path side effects. * * For backward-compatibility a resolver that returns a bare `DatabaseSync` is * accepted and adapted to a {@link LeaseTarget} whose `dbPath` is a stable * synthetic per-scope token (`test://`) — sufficient for single-file test * fixtures, which key one file per scope. * * @param resolver - The resolver to use, or `undefined` to restore the default. * @internal */ export declare function _setNativeDbResolverForTest(resolver: ((scope: LeaseScope, dbPath?: string) => Promise) | undefined): void; /** * Writer-lease identity: explicit scope and canonical dbPath. * * Replaces the removed process-global last-writer scope with explicit per-call * identity so concurrent project A, project B, and global writers cannot steal, * release, resolve, or close each other's lease identity. Every lease * acquire/release/renew/assert and cold-open path uses this same explicit * identity — a release for A must never affect B/global even when concurrent. * * Identity is IMMUTABLE after construction: it is frozen and bound to the * exact drizzle DB handle at {@link DualScopeDbHandle} construction time via * {@link registerDbIdentity}. Callers resolve it through * {@link resolveDbIdentity} — never construct one by hand. * * @task T12042 (E6-L12b) * @task T11627 */ export interface WriterLeaseIdentity { /** The cleo.db scope this lease arbitrates within. */ readonly scope: LeaseScope; /** Absolute canonical on-disk path to this scope's cleo.db (normalized). */ readonly dbPath: string; } /** * Build a frozen {@link WriterLeaseIdentity} from scope + dbPath. * * The dbPath is normalized via `path.resolve()` before freezing — so alias paths * (`/a/../a/cleo.db`, `/a/cleo.db`) produce byte-identical identities and a * single memoized grant row. Callers MUST pass the identity through * {@link registerDbIdentity} to bind it to a concrete drizzle DB handle; the * chokepoint write primitives resolve identity from the handle itself. * * @param scope - The cleo.db scope. * @param dbPath - An absolute or relative path to the scope's cleo.db. Normalized * via `path.resolve()` before freezing. * @returns A frozen, normalized identity. * * @task T12042 (E6-L12b) */ export declare function makeWriterLeaseIdentity(scope: LeaseScope, dbPath: string): WriterLeaseIdentity; /** * Register a drizzle DB handle's immutable writer-lease identity. * * Called at {@link DualScopeDbHandle} construction time (both cached and * dedicated opens). Idempotent when the same DB handle is re-registered with * the SAME canonical identity (same scope AND same `dbPath`). Throws * `E_WRITER_LEASE_IDENTITY_MISMATCH` when the same DB handle is registered * with a DIFFERENT identity — a scope/path mismatch for the same handle is a * programming error. * * @param db - The drizzle DB handle (or any non-primitive key). Must be the * exact same object reference that {@link resolveDbIdentity} will look up. * @param identity - The frozen identity built by {@link makeWriterLeaseIdentity}. * @throws {Error} When the handle was previously registered with a different * scope or dbPath. * * @task T12042 (E6-L12b) */ export declare function registerDbIdentity(db: object, identity: WriterLeaseIdentity): void; /** * Resolve the immutable writer-lease identity bound to a drizzle DB handle. * * The chokepoint write primitives ({@link insertIdempotent} / * {@link upsertIdempotent}) call this with their `db` parameter to derive the * correct scope + dbPath for the writer lease. A handle opened through the * dual-scope chokepoint is always registered; a stub or synthetic handle * passed in tests must be registered via {@link registerDbIdentity} before a * write primitive reaches the identity-resolve point. * * @param db - The drizzle DB handle whose identity to look up. * @returns The frozen identity registered at construction time. * @throws {Error} `E_WRITER_LEASE_IDENTITY_UNREGISTERED` when the handle was * not registered — the caller MUST open the DB through the dual-scope * chokepoint or call {@link registerDbIdentity} explicitly. * * @task T12042 (E6-L12b) */ export declare function resolveDbIdentity(db: object): WriterLeaseIdentity; /** * Thrown when `require` mode cannot acquire the lease, or (future) when a * supervisor explicitly denies. Carries a stable `codeName` for envelope mapping. * * @public */ export declare class LeaseUnavailableError extends Error { /** Stable string error code for envelope `codeName` / log correlation. */ readonly codeName: "E_LEASE_UNAVAILABLE"; constructor(scope: LeaseScope, lane: LeaseLane, reason: string); } /** * Thrown when a write primitive opens the WRITE handle without a held lease * (T11627 ST-4 · Seam 2 · AC4). The brain worker (and any other dedicated-handle * writer outside the chokepoint) MUST hold its lane's grant before it writes — a * lease-less write is exactly the multi-writer race the lease exists to kill. * * Enforced (not merely advised): the brain writer bootstrap calls * {@link assertWriterLeaseHeld} before draining, and a lease-less drain throws * this. `off` mode (the rollback escape hatch) is exempt — there is no lease to * hold and the underlying `busy_timeout` serializes writes as before. * * @public * @task T11627 */ export declare class WriterLeaseRequiredError extends Error { /** Stable string error code for envelope `codeName` / log correlation. */ readonly codeName: "E_WRITER_LEASE_REQUIRED"; constructor(scope: LeaseScope, lane: LeaseLane); } /** * Whether THIS process currently holds an active grant for `(scope, lane)` in the * process-local grant memo. Used by dedicated-handle writers (the brain worker) to * assert AC4 without re-running the claim txn. * * @param scope - The cleo.db scope. * @param lane - The write lane. * @param dbPath - Optional explicit database path to inspect. * @returns `true` iff a memoized grant with `refcount > 0` exists for the key. * * @task T11627 */ export declare function hasActiveGrant(scope: LeaseScope, lane: LeaseLane, dbPath?: string): boolean; /** * AC4 guard. Assert this process holds the `(scope, lane)` writer lease before it * opens a dedicated WRITE handle. In `off` mode the assertion is a no-op (there is * no lease; `busy_timeout` serializes). In every other mode a missing grant throws * {@link WriterLeaseRequiredError}. * * @param scope - The cleo.db scope the handle writes to. * @param lane - The write lane that must be held. * @param dbPath - Optional explicit database path the write will target. * @throws {WriterLeaseRequiredError} when no grant is held and mode is not `off`. * * @task T11627 */ export declare function assertWriterLeaseHeld(scope: LeaseScope, lane: LeaseLane, dbPath?: string): void; /** * Acquire (or re-enter) the writer lease for `(scope, lane)`. Caller MUST call * `release()` on the returned handle (use {@link withWriterLease} to do so * automatically). * * - `off` mode → returns a no-op handle (pass-through; no row written). * - `local`/`supervisor` mode → arbitrates over the persisted row via * `BEGIN IMMEDIATE` (+ epoch-CAS). A nested same-(scope,lane) acquire in this * process re-enters the memoized grant (refcount++). * - `require` mode → throws {@link LeaseUnavailableError} if the row cannot be * taken within the acquire window (`min(ACQUIRE_DEADLINE_MS, ttlMs)`). * * @param scope - The cleo.db scope. * @param lane - The write lane within the scope. * @param opts - Priority / TTL / re-entrancy options. * @returns A held {@link LeaseHandle}. * * @task T11627 */ export declare function acquireWriterLease(scope: LeaseScope, lane: LeaseLane, opts?: LeaseAcquireOptions): Promise; /** * Primary surface: acquire → run `fn` → release (always, even on throw). * * Refcounted + re-entrant by `(scope, lane)`: a nested same-lane write in the same * process re-enters the memoized grant rather than re-running the claim txn. * * - `off` mode → pass-through (runs `fn` under today's busy_timeout, no row). * - `require` mode → an unacquirable lease throws {@link LeaseUnavailableError} * before `fn` runs. * * @param scope - The cleo.db scope. * @param lane - The write lane. * @param fn - The work to run while holding the lease; receives the handle. * @param opts - Priority / TTL / re-entrancy options. * @returns The resolved value of `fn`. * * @task T11627 */ export declare function withWriterLease(scope: LeaseScope, lane: LeaseLane, fn: (h: LeaseHandle) => Promise, opts?: LeaseAcquireOptions): Promise; /** * Lease the dual-scope-db COLD-OPEN critical section (Seam 0 — the T5158 heal). * * This is the high-value gate: it serializes the cold-open `reconcileJournal` + * `migrateWithRetry` write-txn — the precise span that races in T5158 — so EXACTLY * ONE process per scope runs it while peers `BEGIN IMMEDIATE`-queue and then observe * a ready DB. It heals the T5158 `E_NOT_INITIALIZED` / `E_INTERNAL` corruption * **with the supervisor daemon disabled** (`local` mode default). The caller * (`dual-scope-db.ts`) runs the exodus-on-open hook AFTER this lease releases — the * exodus engine owns its own single-flight lock + dedicated connections and * closes/re-opens handles, so it must not run under this row lease. * * Unlike {@link withWriterLease} it operates DIRECTLY on the already-opened native * handle — it never routes through the default resolver (which would re-enter * `openDualScopeDb` and recurse) and it bootstraps the lease tables first (the full * migration that creates them runs inside `fn`). The identity is bound to the * drizzle DB handle at construction via {@link registerDbIdentity} — this function * no longer records a process-global side effect (T12042). * * Mode semantics mirror {@link withWriterLease}: * - `off` → pass-through: runs `fn` under today's `busy_timeout=30000`, byte- * identical to pre-lease cold-open behaviour. * - `local` / `supervisor` (demoted) → claim the row via `BEGIN IMMEDIATE`; on a * contended live holder, spin under the busy-timeout-backstopped deadline, then * degrade to running `fn` (busy_timeout still serializes the migrate write-txn). * - `require` → throw {@link LeaseUnavailableError} if the row cannot be taken. * * @param scope - The cleo.db scope being cold-opened. * @param nativeDb - The native handle the cold-open just created (pragmas applied). * @param fn - The cold-open body to run while holding the lease. * @param opts - Priority / TTL options. Cold-open defaults to highest priority * ({@link SCHEMA_BOOTSTRAP_PRIORITY}) and a 60s TTL (schema bootstrap can be * slow). * @returns The resolved value of `fn`. * * @task T11627 * @task T12042 (E6-L12b — no process-global side effect) */ export declare function withColdOpenLease(scope: LeaseScope, nativeDb: DatabaseSync, fn: () => Promise, opts?: { priority?: number; ttlMs?: number; }): Promise; //# sourceMappingURL=writer-lease.d.ts.map