/** * Dual-scope SQLite DB open chokepoint for the SG-DB-SUBSTRATE-V2 consolidated schema. * * ## Overview (D1″ lifecycle split · T11246/E3 + T11247/E4) * * The owner-ratified D1″ decision (2026-05-30) collapses the CLEO SQLite fleet * into exactly **two `cleo.db` files per machine view**: * * - **Project scope** — `/.cleo/cleo.db` * Contains every project-tier domain: `tasks_*` / `brain_*` (project-local * memory) / `conduit_*` / `docs_*` / `telemetry_*` / lifecycle / provenance / * chain / playbooks / agents (87 tables / 903 columns, T11360 count). * * - **Global scope** — `$XDG_DATA_HOME/cleo/cleo.db` * Contains every cross-project domain: `nexus_*` / `skills_*` / * `signaldock_*` / `brain_*` (global cross-project memory) * (49 tables / 555 columns, T11361 count). * * ## Lifecycle * * `openDualScopeDb` is the **single chokepoint** for all opens of the * consolidated schema. It: * 1. Resolves the DB file path from scope + `cwd` (project) or `getCleoHome()` * (global). * 2. Opens a `node:sqlite` `DatabaseSync` handle. * 3. Applies the canonical pragma set from `specs/sqlite-pragmas.json` via * {@link applyPerfPragmas}. * 4. Runs the drizzle-kit migrate step against the scope-appropriate * migrations folder (`drizzle-cleo-project` or `drizzle-cleo-global`). * 5. Returns a cached, typed `NodeSQLiteDatabase` handle. * Subsequent calls for the same (scope, cwd) return the cached handle. * * ## Note on co-existence with legacy openCleoDb * * During the E3/E4 → E6 exodus transition, `openCleoDb` (the existing * 8-role chokepoint) and `openDualScopeDb` (this module) co-exist. `openCleoDb` * will be updated by E3 to delegate to this function for the consolidated * schema. Until the E6 store rewrite, individual store modules still open their * own legacy DBs via `openCleoDb`. The E6 milestone removes the legacy opens. * * @module * @task T11512 (E4-T1) * @task T11513 (E4-T2 — idempotent write helpers in this same file) * @epic T11247 (E4) * @saga T11242 (SG-DB-SUBSTRATE-V2) * @adr ADR-068, ADR-069 * @see packages/core/src/store/schema/cleo-project/index.ts — project schema * @see packages/core/src/store/schema/cleo-global/index.ts — global schema * @see packages/core/migrations/drizzle-cleo-project — project migrations * @see packages/core/migrations/drizzle-cleo-global — global migrations */ import type { NodeSQLiteDatabase } from 'drizzle-orm/node-sqlite'; import { type ExodusAbortDetail } from './exodus/abort-events.js'; import type * as CleoGlobalSchemaTypes from './schema/cleo-global/index.js'; import type * as CleoProjectSchemaTypes from './schema/cleo-project/index.js'; /** * The two canonical scopes for the consolidated dual-scope `cleo.db` substrate. * * - `'project'` — per-project DB at `/.cleo/cleo.db` * - `'global'` — per-user DB at `$XDG_DATA_HOME/cleo/cleo.db` */ export type DualScope = 'project' | 'global'; /** Typed Drizzle handle for the project-scope `cleo.db`. */ export type CleoProjectDb = NodeSQLiteDatabase; /** Typed Drizzle handle for the global-scope `cleo.db`. */ export type CleoGlobalDb = NodeSQLiteDatabase; /** * Handle returned by {@link openDualScopeDb}. * * `TScope extends DualScope` narrows `db` to the correct schema type: * - `openDualScopeDb('project')` → `DualScopeDbHandle<'project'>` with `db: CleoProjectDb` * - `openDualScopeDb('global')` → `DualScopeDbHandle<'global'>` with `db: CleoGlobalDb` */ export interface DualScopeDbHandle { /** The Drizzle ORM handle typed against the consolidated schema for `scope`. */ readonly db: TScope extends 'project' ? CleoProjectDb : CleoGlobalDb; /** The scope this handle was opened against. */ readonly scope: TScope; /** Absolute path to the underlying SQLite file. */ readonly dbPath: string; /** * Set ONLY when the exodus-on-open data-continuity gate ABORTED the first-open * auto-migration for this scope (T11828 · DHQ-059). When present, the handle is * live and the consolidated `cleo.db` is internally consistent but EMPTY — the * user's real data is still in the legacy fleet, which was kept as the source * of truth. A read-only caller may safely ignore this marker; a MUTATING caller * MUST treat its write as not-durable-against-source and react (see * {@link assertWriteDurable}). `undefined` on every normal (migrated / skipped / * fresh-install) open. */ readonly exodusAbort?: ExodusAbortDetail; /** * Close the underlying native handle and evict this entry from the * singleton cache. Safe to call multiple times (idempotent). */ close(): void; } /** * Options for {@link openDualScopeDbAtPath}. * * @task T11782 (FIX D — dedicated migrate connection) */ export interface OpenDualScopeAtPathOptions { /** * When `true`, open a DEDICATED, NON-cached connection — a second SQLite * handle to the same file, independent of the singleton `_cache`. Used by the * exodus migrate engine so its copy + rollback transactions are isolated from * the caller's cached handle (and any concurrent task INSERTs sharing it). The * returned handle's `close()` closes only the native connection and never * mutates the cache; the caller MUST close it to avoid a descriptor leak. * * @default false */ readonly dedicated?: boolean; } /** * Thrown by {@link assertWriteDurable} when a MUTATING caller is about to write * through a {@link DualScopeDbHandle} whose first-open exodus auto-migration * ABORTED (T11828 · DHQ-059). * * The consolidated `cleo.db` is internally consistent but EMPTY: the user's real * data is still in the legacy fleet (kept as the source of truth). Writing here * would land in a DB that does not reflect that data, so the write is NOT durable * against the source of truth. Read paths never raise this — they intentionally * skip {@link assertWriteDurable} and operate on the empty-but-consistent DB. * * Self-contained (mirrors `BackupRecoverError`) rather than a `CleoError` subclass * so the store layer does not need a new numeric `ExitCode` in `@cleocode/contracts` * for a condition that is surfaced structurally on the handle. * * @task T11828 * @epic T11833 * @saga T11242 * @public */ export declare class ExodusAbortWriteUnsafeError extends Error { /** Stable string error code for envelope `codeName` / log correlation. */ readonly codeName: "E_EXODUS_ABORT_WRITE_UNSAFE"; /** The structured abort detail carried by the handle. */ readonly detail: ExodusAbortDetail; /** Remediation hint surfaced to the operator. */ readonly fix: string; /** * @param detail - The {@link ExodusAbortDetail} stamped on the handle. */ constructor(detail: ExodusAbortDetail); } /** * Assert that a {@link DualScopeDbHandle} is safe to WRITE through. * * Call this at the head of a MUTATING code path (insert/update/delete) that holds * a dual-scope handle. If the handle carries an {@link DualScopeDbHandle.exodusAbort} * marker — i.e. the first-open auto-migration aborted and the consolidated DB is * empty with legacy kept as source — this throws {@link ExodusAbortWriteUnsafeError} * so the write is rejected with a non-zero signal rather than silently landing in * a DB that does not hold the user's data. * * READ-only callers MUST NOT call this — they are expected to operate on the * empty-but-consistent consolidated DB without error, exactly as before T11828. * * @param handle - The handle returned by {@link openDualScopeDb}. * @throws {ExodusAbortWriteUnsafeError} When `handle.exodusAbort` is set. * * @example * ```ts * const h = await openDualScopeDb('project', cwd); * assertWriteDurable(h); // throws if a prior exodus-on-open aborted * await h.db.insert(table).values(row); * ``` * * @task T11828 (DHQ-059) * @epic T11833 * @saga T11242 * @public */ export declare function assertWriteDurable(handle: DualScopeDbHandle): void; /** * Resolve the absolute path to the dual-scope `cleo.db` for the given scope. * * - `project`: `resolveCleoDir(cwd)` + `'cleo.db'` (falls under `/.cleo/`) * - `global`: `getCleoHome()` + `'cleo.db'` (falls under XDG data home `/cleo/`) */ export declare function resolveDualScopeDbPath(scope: DualScope, cwd?: string): string; /** * Open (or re-use) the consolidated dual-scope `cleo.db` for the given scope. * * @param scope - `'project'` for the per-project DB; `'global'` for the * per-user cross-project DB. * @param cwd - Optional working directory used to resolve the project root for * the `'project'` scope. Ignored for `'global'`. * @returns A typed {@link DualScopeDbHandle} wrapping the Drizzle ORM instance * bound to the consolidated schema for the requested scope. The handle is * cached per (scope, dbPath) — subsequent calls return the same instance. * * @example * ```ts * const proj = await openDualScopeDb('project', process.cwd()); * const global = await openDualScopeDb('global'); * ``` * * @task T11512 * @epic T11247 (E4) * @saga T11242 */ export declare function openDualScopeDb(scope: 'project', cwd?: string): Promise>; export declare function openDualScopeDb(scope: 'global', cwd?: string): Promise>; /** * Open (or re-use) a consolidated dual-scope `cleo.db` at an EXPLICIT path, * bypassing the scope→path resolver. * * This is the path-aware sibling of {@link openDualScopeDb}. Production callers * MUST prefer {@link openDualScopeDb}, which resolves the canonical path from * `cwd` / `getCleoHome()`. The explicit-path form exists for two cases: * * 1. Tests that materialise an isolated consolidated `cleo.db` under a * `mkdtemp` directory (e.g. the skills-db `{ path }` override, E6-L5), * without having to monkey-patch `getCleoHome()`. * 2. Domain modules whose legacy lifecycle API accepted an explicit on-disk * path and must keep that contract while still flowing every open through * the single dual-scope chokepoint (so DB Open Guard Gate 3 stays green). * * The handle is cached per (scope, dbPath) exactly like {@link openDualScopeDb}; * a test path and the canonical path are distinct cache keys and never collide. * * @param scope - The consolidated schema scope (`'project'` | `'global'`). * @param dbPath - The absolute path to the consolidated `cleo.db` file. The * parent directory is created if absent. * @returns A typed {@link DualScopeDbHandle} bound to the scope's schema. * * @task T11525 (E6-L5) * @epic T11249 (E6) * @saga T11242 */ export declare function openDualScopeDbAtPath(scope: 'project', dbPath: string, exodusCwd?: string, options?: OpenDualScopeAtPathOptions): Promise>; export declare function openDualScopeDbAtPath(scope: 'global', dbPath: string, exodusCwd?: string, options?: OpenDualScopeAtPathOptions): Promise>; /** * Reset cached dual-scope handles. Primarily for use in tests between test * cases and by domain `closeDb()`/`resetDbState()` paths. Closes the targeted * open handles before evicting them from the cache. * * ## Scope filter (E6-L4 · T11524) * * Pass `scope` to evict ONLY that scope's entries. This matters because the * `'project'` and `'global'` scopes now share this cache: the tasks/brain/conduit * domains hold the project-scope `cleo.db`, while nexus/signaldock/skills hold the * global-scope `cleo.db`. A project-domain reset (`closeDb`/`resetDbState` in * sqlite.ts) must NOT close the global handle out from under an in-flight nexus * query — and vice-versa. When `scope` is omitted, ALL entries are evicted (the * coordinated full teardown used by `closeAllDatabases` and test global resets). * * @param scope - When provided, only entries opened against this scope are * closed + evicted. When omitted, every cached handle is reset. * @internal */ export declare function _resetDualScopeDbCache(scope?: DualScope): void; import type { InferInsertModel } from 'drizzle-orm'; import type { SQLiteTableWithColumns, TableConfig } from 'drizzle-orm/sqlite-core'; /** * Attempt to insert `row` into `table`. If a row with the same value for * `keyColumn` already exists (UNIQUE conflict), the insert is silently skipped. * * Wraps Drizzle v1's `.onConflictDoNothing()` to provide a type-safe, * retry-safe idempotent insert for tables that carry an `idempotency_key` * column or any other UNIQUE column. * * @param db - The Drizzle database handle (project or global scope). * @param table - The Drizzle table reference from the consolidated schema. * @param row - The row data to insert (all required columns). * @param _keyColumn - The column name to conflict on (informational; the * conflict resolution is applied table-wide via `.onConflictDoNothing()`). * Pass the column name as a hint for documentation purposes. * @returns The number of rows actually inserted (0 or 1). * * Refuses the write (throws {@link ExodusAbortWriteUnsafeError}) when a prior * exodus-on-open aborted in this process (T11828 · DHQ-059) — these helpers are * the consolidated-schema MUTATION primitives, so the guard is write-only and * never affects read paths. * * @example * ```ts * import { tasksTasksTable } from '@cleocode/core/store/schema/cleo-project'; * const inserted = await insertIdempotent(db, tasksTasksTable, newTask, 'idempotencyKey'); * ``` * * @task T11513 (E4-T2) * @task T11828 (write-side exodus-abort guard) * @epic T11247 (E4) * @saga T11242 */ export declare function insertIdempotent>(db: NodeSQLiteDatabase, table: TTable, row: InferInsertModel, _keyColumn: string): Promise; /** * Upsert `row` into `table`, updating all non-key columns when a row with * the same `keyColumn` value already exists. * * Wraps Drizzle v1's `.onConflictDoUpdate()` for retry-safe upsert semantics. * * @param db - The Drizzle database handle. * @param table - The Drizzle table reference. * @param row - The row data to insert or update. * @param keyColumn - The conflict-target column name (must be a UNIQUE or * PRIMARY KEY column on the table). * @param conflictTarget - The column reference used as the `.target` for * `.onConflictDoUpdate()`. Pass the Drizzle column reference (e.g. * `table.idempotencyKey`). * @param set - The columns to update on conflict. If omitted, all columns * in `row` are used as the update set. * @returns The number of rows inserted or updated (always 1). * * Refuses the write (throws {@link ExodusAbortWriteUnsafeError}) when a prior * exodus-on-open aborted in this process (T11828 · DHQ-059) — write-only guard. * * @example * ```ts * await upsertIdempotent(db, tasksTasksTable, updatedTask, 'idempotencyKey', * tasksTasksTable.idempotencyKey); * ``` * * @task T11513 (E4-T2) * @task T11828 (write-side exodus-abort guard) * @epic T11247 (E4) * @saga T11242 */ export declare function upsertIdempotent>(db: NodeSQLiteDatabase, table: TTable, row: InferInsertModel, /** The conflict-target column name (informational hint for callers). */ _keyColumn: string, conflictTarget: any, set?: Partial>): Promise; //# sourceMappingURL=dual-scope-db.d.ts.map