@cleocode/core 2026.7.1 → 2026.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/conduit/local-transport.d.ts +3 -6
- package/dist/conduit/local-transport.d.ts.map +1 -1
- package/dist/conduit/local-transport.js +6 -7
- package/dist/conduit/local-transport.js.map +1 -1
- package/dist/db/index.d.ts +15 -1
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +15 -1
- package/dist/db/index.js.map +1 -1
- package/dist/docs/export-document.js +16032 -22124
- package/dist/docs/export-document.js.map +3 -3
- package/dist/goal/store.d.ts.map +1 -1
- package/dist/goal/store.js.map +1 -1
- package/dist/hooks/payload-schemas.d.ts +9 -9
- package/dist/llm/pi/pi-session-storage.d.ts.map +1 -1
- package/dist/llm/pi/pi-session-storage.js +6 -1
- package/dist/llm/pi/pi-session-storage.js.map +1 -1
- package/dist/llm/plugin-facade.js +5532 -16583
- package/dist/llm/plugin-facade.js.map +3 -3
- package/dist/memory/brain-links.d.ts +26 -2
- package/dist/memory/brain-links.d.ts.map +1 -1
- package/dist/memory/brain-links.js +65 -19
- package/dist/memory/brain-links.js.map +1 -1
- package/dist/memory/brain-writer-thread.d.ts.map +1 -1
- package/dist/memory/brain-writer-thread.js +31 -40
- package/dist/memory/brain-writer-thread.js.map +1 -1
- package/dist/memory/claude-mem-migration.d.ts.map +1 -1
- package/dist/memory/claude-mem-migration.js +7 -1
- package/dist/memory/claude-mem-migration.js.map +1 -1
- package/dist/memory/dialectic-evaluator.d.ts +11 -6
- package/dist/memory/dialectic-evaluator.d.ts.map +1 -1
- package/dist/memory/dialectic-evaluator.js +8 -2
- package/dist/memory/dialectic-evaluator.js.map +1 -1
- package/dist/memory/graph-memory-bridge.d.ts.map +1 -1
- package/dist/memory/graph-memory-bridge.js +3 -1
- package/dist/memory/graph-memory-bridge.js.map +1 -1
- package/dist/memory/learnings.d.ts +8 -8
- package/dist/memory/patterns.d.ts +14 -14
- package/dist/memory/retrieval/build-retrieval-bundle.d.ts +1 -1
- package/dist/memory/retrieval/build-retrieval-bundle.d.ts.map +1 -1
- package/dist/memory/retrieval/build-retrieval-bundle.js.map +1 -1
- package/dist/memory/retrieval/observe.d.ts.map +1 -1
- package/dist/memory/retrieval/observe.js +31 -16
- package/dist/memory/retrieval/observe.js.map +1 -1
- package/dist/memory/session-memory.d.ts.map +1 -1
- package/dist/memory/session-memory.js +35 -1
- package/dist/memory/session-memory.js.map +1 -1
- package/dist/nexus/migrations/M-split-graph.d.ts.map +1 -1
- package/dist/nexus/migrations/M-split-graph.js +4 -1
- package/dist/nexus/migrations/M-split-graph.js.map +1 -1
- package/dist/nexus/sigil.d.ts +1 -2
- package/dist/nexus/sigil.d.ts.map +1 -1
- package/dist/nexus/user-profile.d.ts +1 -2
- package/dist/nexus/user-profile.d.ts.map +1 -1
- package/dist/orchestration/spawn-prompt.d.ts.map +1 -1
- package/dist/orchestration/spawn-prompt.js +4 -2
- package/dist/orchestration/spawn-prompt.js.map +1 -1
- package/dist/scaffold/ensure-skills.d.ts.map +1 -1
- package/dist/scaffold/ensure-skills.js +67 -0
- package/dist/scaffold/ensure-skills.js.map +1 -1
- package/dist/selfimprove/dhq-adapter.d.ts.map +1 -1
- package/dist/selfimprove/dhq-adapter.js +6 -2
- package/dist/selfimprove/dhq-adapter.js.map +1 -1
- package/dist/selfimprove/draft-pr.d.ts +93 -9
- package/dist/selfimprove/draft-pr.d.ts.map +1 -1
- package/dist/selfimprove/draft-pr.js +164 -23
- package/dist/selfimprove/draft-pr.js.map +1 -1
- package/dist/selfimprove/fix-gen.js +5635 -16565
- package/dist/selfimprove/fix-gen.js.map +3 -3
- package/dist/selfimprove/run-loop.d.ts +9 -1
- package/dist/selfimprove/run-loop.d.ts.map +1 -1
- package/dist/selfimprove/run-loop.js +7 -1
- package/dist/selfimprove/run-loop.js.map +1 -1
- package/dist/store/agent-registry-store.d.ts.map +1 -1
- package/dist/store/agent-registry-store.js +1 -2
- package/dist/store/agent-registry-store.js.map +1 -1
- package/dist/store/background-jobs.d.ts +1 -2
- package/dist/store/background-jobs.d.ts.map +1 -1
- package/dist/store/background-jobs.js.map +1 -1
- package/dist/store/conduit-sqlite.d.ts.map +1 -1
- package/dist/store/conduit-sqlite.js +1 -2
- package/dist/store/conduit-sqlite.js.map +1 -1
- package/dist/store/cross-db-cleanup.d.ts +38 -0
- package/dist/store/cross-db-cleanup.d.ts.map +1 -1
- package/dist/store/cross-db-cleanup.js +125 -0
- package/dist/store/cross-db-cleanup.js.map +1 -1
- package/dist/store/db-helpers.d.ts +1 -2
- package/dist/store/db-helpers.d.ts.map +1 -1
- package/dist/store/dual-scope-db.d.ts +304 -4
- package/dist/store/dual-scope-db.d.ts.map +1 -1
- package/dist/store/dual-scope-db.js +647 -262
- package/dist/store/dual-scope-db.js.map +1 -1
- package/dist/store/memory-accessor.d.ts +1 -1
- package/dist/store/memory-accessor.d.ts.map +1 -1
- package/dist/store/memory-accessor.js.map +1 -1
- package/dist/store/memory-sqlite.d.ts +1 -1
- package/dist/store/memory-sqlite.d.ts.map +1 -1
- package/dist/store/memory-sqlite.js +18 -7
- package/dist/store/memory-sqlite.js.map +1 -1
- package/dist/store/migration-manager.d.ts +7 -4
- package/dist/store/migration-manager.d.ts.map +1 -1
- package/dist/store/migration-manager.js +10 -10
- package/dist/store/migration-manager.js.map +1 -1
- package/dist/store/migration-sqlite.js +1 -1
- package/dist/store/migration-sqlite.js.map +1 -1
- package/dist/store/nexus-sqlite.d.ts +1 -1
- package/dist/store/nexus-sqlite.d.ts.map +1 -1
- package/dist/store/nexus-sqlite.js +1 -1
- package/dist/store/nexus-sqlite.js.map +1 -1
- package/dist/store/schema/attachments.d.ts +1 -1
- package/dist/store/schema/audit.d.ts +2 -2
- package/dist/store/schema/background-jobs.d.ts +1 -1
- package/dist/store/schema/cleo-global/agent-registry.d.ts +1 -1
- package/dist/store/schema/cleo-global/models-catalog.d.ts +1 -1
- package/dist/store/schema/cleo-global/skills.d.ts +2 -2
- package/dist/store/schema/cleo-project/audit.d.ts +2 -2
- package/dist/store/schema/cleo-project/docs.d.ts +1 -1
- package/dist/store/schema/cleo-project/lifecycle.d.ts +4 -4
- package/dist/store/schema/cleo-project/nexus-graph.d.ts +2 -2
- package/dist/store/schema/cleo-project/provenance-rest.d.ts +3 -3
- package/dist/store/schema/cleo-project/runtime.d.ts +2 -2
- package/dist/store/schema/cleo-project/tasks-core-batch2.d.ts +1 -1
- package/dist/store/schema/cleo-project/tasks-core.d.ts +3 -3
- package/dist/store/schema/cleo-shared/brain.d.ts +5 -5
- package/dist/store/schema/jsonb.js +1 -1
- package/dist/store/schema/jsonb.js.map +1 -1
- package/dist/store/schema/lifecycle.d.ts +4 -4
- package/dist/store/schema/memory-schema.d.ts +3 -3
- package/dist/store/schema/nexus-schema.d.ts +1 -1
- package/dist/store/schema/provenance/releases.d.ts +3 -3
- package/dist/store/schema/skills-schema.d.ts +2 -2
- package/dist/store/schema/tasks.d.ts +2 -2
- package/dist/store/skills-db.d.ts +1 -1
- package/dist/store/skills-db.d.ts.map +1 -1
- package/dist/store/skills-db.js +1 -1
- package/dist/store/skills-db.js.map +1 -1
- package/dist/store/sqlite.d.ts +1 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +30 -8
- package/dist/store/sqlite.js.map +1 -1
- package/dist/store/validation-schemas.d.ts +35 -35
- package/dist/store/writer-lease.d.ts +85 -53
- package/dist/store/writer-lease.d.ts.map +1 -1
- package/dist/store/writer-lease.js +194 -112
- package/dist/store/writer-lease.js.map +1 -1
- package/dist/tasks/add.d.ts.map +1 -1
- package/dist/tasks/add.js +9 -2
- package/dist/tasks/add.js.map +1 -1
- package/dist/tasks/backfill-child-projections.d.ts.map +1 -1
- package/dist/tasks/backfill-child-projections.js +3 -1
- package/dist/tasks/backfill-child-projections.js.map +1 -1
- package/dist/tasks/evidence.d.ts +17 -0
- package/dist/tasks/evidence.d.ts.map +1 -1
- package/dist/tasks/evidence.js +145 -35
- package/dist/tasks/evidence.js.map +1 -1
- package/dist/tasks/infer-add-params.d.ts.map +1 -1
- package/dist/tasks/infer-add-params.js +6 -1
- package/dist/tasks/infer-add-params.js.map +1 -1
- package/dist/tasks/tool-cache.d.ts +29 -0
- package/dist/tasks/tool-cache.d.ts.map +1 -1
- package/dist/tasks/tool-cache.js +166 -6
- package/dist/tasks/tool-cache.js.map +1 -1
- package/dist/tasks/tool-resolver.d.ts +5 -1
- package/dist/tasks/tool-resolver.d.ts.map +1 -1
- package/dist/tasks/tool-resolver.js +28 -20
- package/dist/tasks/tool-resolver.js.map +1 -1
- package/dist/telemetry/index.d.ts.map +1 -1
- package/dist/telemetry/index.js +4 -1
- package/dist/telemetry/index.js.map +1 -1
- package/dist/telemetry/sqlite.d.ts +1 -2
- package/dist/telemetry/sqlite.d.ts.map +1 -1
- package/dist/telemetry/sqlite.js +1 -2
- package/dist/telemetry/sqlite.js.map +1 -1
- package/package.json +14 -14
- package/schemas/project-context.schema.json +69 -0
- package/templates/workflows/release-prepare.yml.tmpl +10 -0
|
@@ -51,14 +51,14 @@
|
|
|
51
51
|
*/
|
|
52
52
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
53
53
|
import { createRequire } from 'node:module';
|
|
54
|
-
import { dirname, join } from 'node:path';
|
|
54
|
+
import { dirname, join, resolve } from 'node:path';
|
|
55
55
|
import { getLogger } from '../logger.js';
|
|
56
56
|
import { getCleoHome, resolveCleoDir } from '../paths.js';
|
|
57
57
|
import { getRecordedExodusAbort } from './exodus/abort-events.js';
|
|
58
58
|
import { migrateWithRetry, reconcileJournal } from './migration-manager.js';
|
|
59
59
|
import { resolveConsolidatedJournalSiblings, resolveCorePackageMigrationsFolder, } from './resolve-migrations-folder.js';
|
|
60
60
|
import { applyPerfPragmas } from './sqlite-pragmas.js';
|
|
61
|
-
import {
|
|
61
|
+
import { makeWriterLeaseIdentity, registerDbIdentity, resolveDbIdentity, withColdOpenLease, withWriterLease, } from './writer-lease.js';
|
|
62
62
|
/**
|
|
63
63
|
* Thrown by {@link assertWriteDurable} when a MUTATING caller is about to write
|
|
64
64
|
* through a {@link DualScopeDbHandle} whose first-open exodus auto-migration
|
|
@@ -204,24 +204,6 @@ function getDatabaseSyncCtor() {
|
|
|
204
204
|
}
|
|
205
205
|
return _DatabaseSyncCtor;
|
|
206
206
|
}
|
|
207
|
-
// ── Schema loading ───────────────────────────────────────────────────────────
|
|
208
|
-
// Dynamic imports for schema barrels. Loaded once per scope and cached.
|
|
209
|
-
// We use dynamic import() to avoid loading both schemas at module-init time —
|
|
210
|
-
// only the requested scope's schema is loaded.
|
|
211
|
-
let _projectSchema = null;
|
|
212
|
-
let _globalSchema = null;
|
|
213
|
-
async function loadProjectSchema() {
|
|
214
|
-
if (_projectSchema === null) {
|
|
215
|
-
_projectSchema = await import('./schema/cleo-project/index.js');
|
|
216
|
-
}
|
|
217
|
-
return _projectSchema;
|
|
218
|
-
}
|
|
219
|
-
async function loadGlobalSchema() {
|
|
220
|
-
if (_globalSchema === null) {
|
|
221
|
-
_globalSchema = await import('./schema/cleo-global/index.js');
|
|
222
|
-
}
|
|
223
|
-
return _globalSchema;
|
|
224
|
-
}
|
|
225
207
|
// ── Existence table for migration reconciliation ──────────────────────────────
|
|
226
208
|
/**
|
|
227
209
|
* The "existence table" used by {@link reconcileJournal} to detect whether
|
|
@@ -312,36 +294,58 @@ async function openDedicatedDualScopeDb(scope, dbPath, log) {
|
|
|
312
294
|
}
|
|
313
295
|
const DatabaseSyncCtor = getDatabaseSyncCtor();
|
|
314
296
|
const nativeDb = new DatabaseSyncCtor(dbPath, { allowExtension: true });
|
|
315
|
-
//
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
297
|
+
// Every operation after construction is wrapped so any exception —
|
|
298
|
+
// pragmas, Drizzle wrapping, migration-folder resolution, lease, or
|
|
299
|
+
// migration — closes the native handle before rethrow.
|
|
300
|
+
try {
|
|
301
|
+
// T11829: bound per-connection memory for one-shot/CLI opens (full SSoT for daemon).
|
|
302
|
+
applyPerfPragmas(nativeDb, memoryBoundedPragmaOverrides());
|
|
303
|
+
const drizzle = getDrizzle();
|
|
304
|
+
// biome-ignore lint/suspicious/noExplicitAny: dual-scope handle is untyped at construction; typed via DualScopeDbHandle<TScope>
|
|
305
|
+
const db = drizzle({ client: nativeDb });
|
|
306
|
+
const migrationsFolder = resolveCorePackageMigrationsFolder(migrationsSetName(scope));
|
|
307
|
+
// ── Cold-open lease (F4 · T12036) ──────────────────────────────────────
|
|
308
|
+
// Dedicated opens also serialize their cold-open migrations through the same
|
|
309
|
+
// writer-lease so they never race on the `__drizzle_migrations` journal
|
|
310
|
+
// even when two dedicated opens of a fresh file are concurrent.
|
|
311
|
+
const handle = await withColdOpenLease(scope, nativeDb, async () => {
|
|
312
|
+
reconcileJournal(nativeDb, migrationsFolder, existenceTable(scope), `dual-scope-db[${scope}]`, resolveConsolidatedJournalSiblings(migrationsSetName(scope)));
|
|
313
|
+
migrateWithRetry(db, migrationsFolder, nativeDb, existenceTable(scope), `dual-scope-db[${scope}]`);
|
|
314
|
+
log.debug({ scope, dbPath }, 'DEDICATED dual-scope cleo.db ready (T11782 FIX D)');
|
|
315
|
+
const identity = makeWriterLeaseIdentity(scope, dbPath);
|
|
316
|
+
registerDbIdentity(db, identity);
|
|
317
|
+
return {
|
|
318
|
+
db,
|
|
319
|
+
scope,
|
|
320
|
+
dbPath,
|
|
321
|
+
identity,
|
|
322
|
+
get isOpen() {
|
|
323
|
+
return nativeDb.isOpen;
|
|
324
|
+
},
|
|
325
|
+
close() {
|
|
326
|
+
// Dedicated handles are never cached — close only the native connection.
|
|
327
|
+
try {
|
|
328
|
+
nativeDb.close();
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
// Idempotent — ignore double-close errors.
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
});
|
|
336
|
+
return handle;
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
// Close the native handle on any failure after construction to avoid
|
|
340
|
+
// leaking a file descriptor. The primary error is rethrown unchanged.
|
|
341
|
+
try {
|
|
342
|
+
nativeDb.close();
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
// Already closed or never opened — safe to ignore.
|
|
346
|
+
}
|
|
347
|
+
throw err;
|
|
348
|
+
}
|
|
345
349
|
}
|
|
346
350
|
export async function openDualScopeDbAtPath(scope, dbPath,
|
|
347
351
|
/**
|
|
@@ -353,7 +357,10 @@ export async function openDualScopeDbAtPath(scope, dbPath,
|
|
|
353
357
|
*/
|
|
354
358
|
exodusCwd, options) {
|
|
355
359
|
const dedicated = options?.dedicated === true;
|
|
356
|
-
|
|
360
|
+
// Normalize the path before keying so equivalent spellings share one
|
|
361
|
+
// cache entry — the chokepoint key mirrors the runtime's path.resolve.
|
|
362
|
+
const normalizedPath = resolve(dbPath);
|
|
363
|
+
const key = cacheKey(scope, normalizedPath);
|
|
357
364
|
// A DEDICATED open (T11782 · FIX D) bypasses the singleton cache entirely: it
|
|
358
365
|
// opens a SECOND SQLite connection to the same file (WAL allows concurrent
|
|
359
366
|
// connections) so the exodus migrate engine can copy + (on abort) truncate on
|
|
@@ -370,206 +377,29 @@ exodusCwd, options) {
|
|
|
370
377
|
if (existing.initPromise) {
|
|
371
378
|
return existing.initPromise;
|
|
372
379
|
}
|
|
373
|
-
|
|
380
|
+
if (existing.handle && existing.nativeDb?.isOpen) {
|
|
381
|
+
return existing.handle;
|
|
382
|
+
}
|
|
383
|
+
_cache.delete(key);
|
|
374
384
|
}
|
|
375
385
|
}
|
|
376
386
|
const log = getLogger('dual-scope-db');
|
|
377
387
|
if (dedicated) {
|
|
378
|
-
return openDedicatedDualScopeDb(scope,
|
|
388
|
+
return openDedicatedDualScopeDb(scope, normalizedPath, log);
|
|
379
389
|
}
|
|
380
390
|
// Create a placeholder entry so concurrent callers wait for the same init.
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
// extensions. The brain domain (E6-L2 · T11522) loads the `sqlite-vec`
|
|
392
|
-
// extension on this shared handle for vector similarity search; node:sqlite
|
|
393
|
-
// requires the flag at construction time (it cannot be toggled afterwards
|
|
394
|
-
// via `enableLoadExtension`). Enabling the flag is harmless for every other
|
|
395
|
-
// domain — no extension is loaded automatically, and the cache stays
|
|
396
|
-
// single-keyed regardless of which domain opens the handle first.
|
|
397
|
-
const DatabaseSyncCtor = getDatabaseSyncCtor();
|
|
398
|
-
const nativeDb = new DatabaseSyncCtor(dbPath, { allowExtension: true });
|
|
399
|
-
// Apply canonical pragma set (specs/sqlite-pragmas.json SSoT), bounding
|
|
400
|
-
// per-connection memory for one-shot/CLI opens (full SSoT for daemon) — T11829.
|
|
401
|
-
applyPerfPragmas(nativeDb, memoryBoundedPragmaOverrides());
|
|
402
|
-
// Load the schema barrel for this scope.
|
|
403
|
-
const schema = scope === 'project' ? await loadProjectSchema() : await loadGlobalSchema();
|
|
404
|
-
// Create the Drizzle ORM wrapper.
|
|
405
|
-
// Drizzle v1 RC3 node-sqlite API: pass { client, schema } as a single config object.
|
|
406
|
-
const drizzle = getDrizzle();
|
|
407
|
-
// biome-ignore lint/suspicious/noExplicitAny: schema type is scope-specific; typed via DualScopeDbHandle<TScope>
|
|
408
|
-
const db = drizzle({ client: nativeDb, schema });
|
|
409
|
-
// Resolve the migrations folder for this scope.
|
|
410
|
-
const migrationsFolder = resolveCorePackageMigrationsFolder(migrationsSetName(scope));
|
|
411
|
-
// ── Seam 0 — cold-open critical section (THE T5158 HEAL · T11627 ST-3) ────
|
|
412
|
-
// Lease the migrate/reconcile cold-open write-txn against the just-opened
|
|
413
|
-
// native handle so EXACTLY ONE process per scope runs it while concurrent
|
|
414
|
-
// cold-open peers BEGIN IMMEDIATE-queue and then observe a ready DB. This heals
|
|
415
|
-
// the T5158 `E_NOT_INITIALIZED` / `E_INTERNAL` corruption (concurrent cold-open
|
|
416
|
-
// migrate write-txns racing the consolidated cleo.db's single shared
|
|
417
|
-
// `__drizzle_migrations` journal) WITH the supervisor daemon disabled (`local`
|
|
418
|
-
// mode default). `off` mode is a pass-through → byte-identical to pre-lease
|
|
419
|
-
// behaviour (busy_timeout=30000 still serializes the write-txn).
|
|
420
|
-
// `withColdOpenLease` also records the scope in the Seam-1 active-scope registry
|
|
421
|
-
// so chokepoint write primitives lease correctly.
|
|
422
|
-
//
|
|
423
|
-
// The lease wraps ONLY reconcileJournal + migrateWithRetry — the precise write-
|
|
424
|
-
// txn that races in T5158. The exodus-on-open hook runs AFTER the lease releases
|
|
425
|
-
// (below): it owns its OWN single-flight lock + dedicated migrate connections,
|
|
426
|
-
// and `runExodusMigrate` CLOSES + re-opens the scope handles, which would
|
|
427
|
-
// close the very handle the lease row lives on mid-section. Releasing first is
|
|
428
|
-
// both correct (exodus is already serialized) and necessary (no close-under-lease).
|
|
429
|
-
const handle = await withColdOpenLease(scope, nativeDb, async () => {
|
|
430
|
-
// Reconcile the migration journal (handles WAL/journal divergence across
|
|
431
|
-
// SQLite version upgrades — same pattern as sqlite.ts / memory-sqlite.ts).
|
|
432
|
-
// T11829: pass the OTHER lineages that share this scope's consolidated cleo.db
|
|
433
|
-
// journal so their rows are not deleted as cross-lineage orphans (the confirmed
|
|
434
|
-
// OOM root cause: each lineage previously deleted the others' rows so the shared
|
|
435
|
-
// journal never converged).
|
|
436
|
-
reconcileJournal(nativeDb, migrationsFolder, existenceTable(scope), `dual-scope-db[${scope}]`, resolveConsolidatedJournalSiblings(migrationsSetName(scope)));
|
|
437
|
-
// Run any pending migrations.
|
|
438
|
-
migrateWithRetry(db, migrationsFolder, nativeDb, existenceTable(scope), `dual-scope-db[${scope}]`);
|
|
439
|
-
log.debug({ scope, dbPath }, 'dual-scope cleo.db ready');
|
|
440
|
-
const built = {
|
|
441
|
-
db,
|
|
442
|
-
scope,
|
|
443
|
-
dbPath,
|
|
444
|
-
close() {
|
|
445
|
-
_cache.delete(key);
|
|
446
|
-
try {
|
|
447
|
-
nativeDb.close();
|
|
448
|
-
}
|
|
449
|
-
catch {
|
|
450
|
-
// Idempotent — ignore double-close errors.
|
|
451
|
-
}
|
|
452
|
-
},
|
|
453
|
-
};
|
|
454
|
-
// Update the cache entry to mark init complete.
|
|
455
|
-
const entry = _cache.get(key);
|
|
456
|
-
if (entry) {
|
|
457
|
-
entry.initPromise = null;
|
|
458
|
-
entry.handle = built;
|
|
459
|
-
}
|
|
460
|
-
return built;
|
|
461
|
-
},
|
|
462
|
-
// Record this open's resolved dbPath in the Seam-1 registry so the chokepoint
|
|
463
|
-
// write primitives lease their row in THIS file — not the cwd-default — when
|
|
464
|
-
// more than one project's cleo.db is open in this process (T11627 Finding 1).
|
|
465
|
-
{ dbPath });
|
|
466
|
-
// ── Exodus-on-open (E6 · T11553) — runs AFTER the cold-open lease releases ──
|
|
467
|
-
// Data-continuity safety net: on a canonical open where the consolidated
|
|
468
|
-
// cleo.db is empty but the legacy fleet (tasks.db/brain.db/…) has rows for
|
|
469
|
-
// THIS scope, lazily auto-migrate ONCE — gated by a parity verify, serialised
|
|
470
|
-
// by a single-flight lock, rolled back to empty on parity failure (legacy
|
|
471
|
-
// kept). Re-entrancy/concurrency are handled inside the hook. Lazy `import()`
|
|
472
|
-
// breaks the cycle (exodus/migrate.ts imports openDualScopeDb from here).
|
|
473
|
-
//
|
|
474
|
-
// NOTE: `runExodusMigrate` CLOSES + evicts the dual-scope handles it opened
|
|
475
|
-
// when it finishes, so after a `migrated`/`aborted` outcome the `handle`
|
|
476
|
-
// built above (and its `nativeDb`) is CLOSED. We therefore re-open this scope
|
|
477
|
-
// fresh (cache-miss, NOT armed — no `exodusCwd`) and return the new live
|
|
478
|
-
// handle. That re-open flows through the cold-open lease again on a FRESH handle
|
|
479
|
-
// (cheap claim/release — the DB is now migrated), with no contention against the
|
|
480
|
-
// already-released outer lease.
|
|
481
|
-
//
|
|
482
|
-
// Armed ONLY when `exodusCwd` was threaded through the canonical
|
|
483
|
-
// `openDualScopeDb` AND `dbPath` is the canonical path for that scope+cwd —
|
|
484
|
-
// never for explicit-path opens (test fixtures / legacy-path domains).
|
|
485
|
-
if (exodusCwd !== undefined && dbPath === resolveDualScopeDbPath(scope, exodusCwd)) {
|
|
486
|
-
// ── T12001 (Epic T11992) — db-heavy admission for the exodus auto-migrate ──
|
|
487
|
-
// The on-open exodus (reconcile + parity verify + migrate) is a heavy DB op;
|
|
488
|
-
// letting it co-schedule with builds/tests/agents is a historical OOM vector.
|
|
489
|
-
// Admit it through the governor's `db-heavy` class (machine-wide serialized;
|
|
490
|
-
// deferred under memory backoff). On a denial we SKIP the migration THIS open
|
|
491
|
-
// (kill-switch precedent CLEO_DISABLE_EXODUS_ON_OPEN) — NEVER block or defer
|
|
492
|
-
// the interactive command (interactive-cli is never gated) — and it re-runs
|
|
493
|
-
// idempotently on a calmer open. Fail-open: ANY governor error proceeds
|
|
494
|
-
// un-gated, byte-identical to pre-T12001 behaviour.
|
|
495
|
-
let releaseDbHeavy = null;
|
|
496
|
-
let dbHeavyDeferred = false;
|
|
497
|
-
try {
|
|
498
|
-
const { governor } = await import('../resources/governor.js');
|
|
499
|
-
const admit = await governor.tryAcquire('db-heavy');
|
|
500
|
-
if (admit.deferred) {
|
|
501
|
-
dbHeavyDeferred = true;
|
|
502
|
-
}
|
|
503
|
-
else {
|
|
504
|
-
releaseDbHeavy = admit.release;
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
catch {
|
|
508
|
-
// Governor unavailable — fail open (proceed un-gated).
|
|
509
|
-
}
|
|
510
|
-
if (dbHeavyDeferred) {
|
|
511
|
-
log.debug({ scope, dbPath }, 'exodus-on-open skipped this open — db-heavy deferred under memory pressure ' +
|
|
512
|
-
'(re-runs idempotently on a calmer open)');
|
|
513
|
-
return handle;
|
|
514
|
-
}
|
|
515
|
-
try {
|
|
516
|
-
const { maybeRunExodusOnOpen } = await import('./exodus/on-open.js');
|
|
517
|
-
const result = await maybeRunExodusOnOpen(scope, dbPath, nativeDb, exodusCwd);
|
|
518
|
-
if (result.outcome === 'migrated' || result.outcome === 'aborted') {
|
|
519
|
-
// The migrate engine closed our handle — re-open fresh (un-armed) so the
|
|
520
|
-
// caller receives a valid, live handle bound to the now-(de)populated DB.
|
|
521
|
-
const reopened = scope === 'project'
|
|
522
|
-
? await openDualScopeDbAtPath('project', dbPath)
|
|
523
|
-
: await openDualScopeDbAtPath('global', dbPath);
|
|
524
|
-
if (result.outcome === 'aborted') {
|
|
525
|
-
// T11828 (DHQ-059): the data-continuity gate aborted — the consolidated
|
|
526
|
-
// DB is empty + consistent, legacy kept as source. Surface this to a
|
|
527
|
-
// MUTATING caller (read-only callers ignore it) by (a) stamping a
|
|
528
|
-
// structured marker on the returned handle and (b) broadcasting a typed
|
|
529
|
-
// event. The non-zero error itself is raised on the write path via
|
|
530
|
-
// `assertWriteDurable(handle)` — NOT here, so read opens never throw.
|
|
531
|
-
const abort = {
|
|
532
|
-
scope,
|
|
533
|
-
dbPath,
|
|
534
|
-
reason: result.reason,
|
|
535
|
-
at: Date.now(),
|
|
536
|
-
};
|
|
537
|
-
log.warn({ scope, reason: result.reason }, 'exodus-on-open aborted; consolidated cleo.db left empty, legacy kept as source — ' +
|
|
538
|
-
'mutating callers must check handle.exodusAbort / call assertWriteDurable (T11828)');
|
|
539
|
-
const { emitExodusAbort } = await import('./exodus/abort-events.js');
|
|
540
|
-
emitExodusAbort(abort);
|
|
541
|
-
return { ...reopened, exodusAbort: abort };
|
|
542
|
-
}
|
|
543
|
-
// A subsequent SUCCESSFUL migration resolves any prior abort recorded
|
|
544
|
-
// for this scope, so writes are no longer rejected (T11828).
|
|
545
|
-
const { clearExodusAborts } = await import('./exodus/abort-events.js');
|
|
546
|
-
clearExodusAborts(scope);
|
|
547
|
-
return reopened;
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
catch (err) {
|
|
551
|
-
// Best-effort safety net: a hook failure must not make the DB
|
|
552
|
-
// unopenable. Warn and re-open fresh; `cleo exodus migrate` remains the
|
|
553
|
-
// manual path. (The handle may have been closed mid-migrate.)
|
|
554
|
-
log.warn({ err, scope }, 'exodus-on-open hook failed (non-fatal); re-opening consolidated handle');
|
|
555
|
-
return scope === 'project'
|
|
556
|
-
? openDualScopeDbAtPath('project', dbPath)
|
|
557
|
-
: openDualScopeDbAtPath('global', dbPath);
|
|
558
|
-
}
|
|
559
|
-
finally {
|
|
560
|
-
// Release the db-heavy slot on EVERY exit path (early returns above run
|
|
561
|
-
// `finally` first). Idempotent; release errors are swallowed.
|
|
562
|
-
if (releaseDbHeavy)
|
|
563
|
-
await releaseDbHeavy().catch(() => { });
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
return handle;
|
|
567
|
-
})();
|
|
568
|
-
// Store a placeholder with the in-flight promise so concurrent callers wait.
|
|
391
|
+
// MUST run BEFORE the async IIFE is defined: the IIFE starts executing
|
|
392
|
+
// synchronously, and the cold-open callback inside withColdOpenLease runs
|
|
393
|
+
// synchronously before the outer IIFE yields — if the placeholder entry
|
|
394
|
+
// doesn't exist yet, the callback cannot find and update it (T12035).
|
|
395
|
+
let initResolve = null;
|
|
396
|
+
let initReject = null;
|
|
397
|
+
const initPromise = new Promise((resolve, reject) => {
|
|
398
|
+
initResolve = resolve;
|
|
399
|
+
initReject = reject;
|
|
400
|
+
});
|
|
569
401
|
_cache.set(key, {
|
|
570
|
-
// biome-ignore lint/suspicious/noExplicitAny: placeholder until initPromise resolves
|
|
571
402
|
handle: null,
|
|
572
|
-
// biome-ignore lint/suspicious/noExplicitAny: nativeDb not available yet
|
|
573
403
|
nativeDb: null,
|
|
574
404
|
initPromise,
|
|
575
405
|
});
|
|
@@ -588,6 +418,218 @@ exodusCwd, options) {
|
|
|
588
418
|
_cache.delete(key);
|
|
589
419
|
}
|
|
590
420
|
});
|
|
421
|
+
// Start the actual init work. The IIFE is a fire-and-forget callback that
|
|
422
|
+
// resolves/rejects the externally-created initPromise. Separating promise
|
|
423
|
+
// creation from the async IIFE ensures the cache placeholder exists before
|
|
424
|
+
// any synchronous work runs inside withColdOpenLease's fn callback (T12035).
|
|
425
|
+
(async () => {
|
|
426
|
+
try {
|
|
427
|
+
log.debug({ scope, dbPath: normalizedPath }, 'opening dual-scope cleo.db');
|
|
428
|
+
// Ensure the directory exists before opening.
|
|
429
|
+
const dir = dirname(normalizedPath);
|
|
430
|
+
if (!existsSync(dir)) {
|
|
431
|
+
mkdirSync(dir, { recursive: true });
|
|
432
|
+
}
|
|
433
|
+
// Open the native SQLite handle.
|
|
434
|
+
//
|
|
435
|
+
// `allowExtension: true` permits — but does NOT load — SQLite loadable
|
|
436
|
+
// extensions. The brain domain (E6-L2 · T11522) loads the `sqlite-vec`
|
|
437
|
+
// extension on this shared handle for vector similarity search; node:sqlite
|
|
438
|
+
// requires the flag at construction time (it cannot be toggled afterwards
|
|
439
|
+
// via `enableLoadExtension`). Enabling the flag is harmless for every other
|
|
440
|
+
// domain — no extension is loaded automatically, and the cache stays
|
|
441
|
+
// single-keyed regardless of which domain opens the handle first.
|
|
442
|
+
const DatabaseSyncCtor = getDatabaseSyncCtor();
|
|
443
|
+
const nativeDb = new DatabaseSyncCtor(normalizedPath, { allowExtension: true });
|
|
444
|
+
// Apply canonical pragma set (specs/sqlite-pragmas.json SSoT), bounding
|
|
445
|
+
// per-connection memory for one-shot/CLI opens (full SSoT for daemon) — T11829.
|
|
446
|
+
applyPerfPragmas(nativeDb, memoryBoundedPragmaOverrides());
|
|
447
|
+
// Create the Drizzle ORM wrapper.
|
|
448
|
+
const drizzle = getDrizzle();
|
|
449
|
+
// biome-ignore lint/suspicious/noExplicitAny: dual-scope handle is untyped at construction; typed via DualScopeDbHandle<TScope>
|
|
450
|
+
const db = drizzle({ client: nativeDb });
|
|
451
|
+
// Resolve the migrations folder for this scope.
|
|
452
|
+
const migrationsFolder = resolveCorePackageMigrationsFolder(migrationsSetName(scope));
|
|
453
|
+
// ── Seam 0 — cold-open critical section (THE T5158 HEAL · T11627 ST-3) ────
|
|
454
|
+
// Lease the migrate/reconcile cold-open write-txn against the just-opened
|
|
455
|
+
// native handle so EXACTLY ONE process per scope runs it while concurrent
|
|
456
|
+
// cold-open peers BEGIN IMMEDIATE-queue and then observe a ready DB. This heals
|
|
457
|
+
// the T5158 `E_NOT_INITIALIZED` / `E_INTERNAL` corruption (concurrent cold-open
|
|
458
|
+
// migrate write-txns racing the consolidated cleo.db's single shared
|
|
459
|
+
// `__drizzle_migrations` journal) WITH the supervisor daemon disabled (`local`
|
|
460
|
+
// mode default). `off` mode is a pass-through → byte-identical to pre-lease
|
|
461
|
+
// behaviour (busy_timeout=30000 still serializes the write-txn).
|
|
462
|
+
// The identity is bound to the drizzle DB handle via
|
|
463
|
+
// registerDbIdentity during construction — the chokepoint write
|
|
464
|
+
// primitives resolve it from the exact handle binding (T12042), no
|
|
465
|
+
// longer from a process-global active-scope registry.
|
|
466
|
+
//
|
|
467
|
+
// The lease wraps ONLY reconcileJournal + migrateWithRetry — the precise write-
|
|
468
|
+
// txn that races in T5158. The exodus-on-open hook runs AFTER the lease releases
|
|
469
|
+
// (below): it owns its OWN single-flight lock + dedicated migrate connections,
|
|
470
|
+
// and `runExodusMigrate` CLOSES + re-opens the scope handles, which would
|
|
471
|
+
// close the very handle the lease row lives on mid-section. Releasing first is
|
|
472
|
+
// both correct (exodus is already serialized) and necessary (no close-under-lease).
|
|
473
|
+
const handle = await withColdOpenLease(scope, nativeDb, async () => {
|
|
474
|
+
// Reconcile the migration journal (handles WAL/journal divergence across
|
|
475
|
+
// SQLite version upgrades — same pattern as sqlite.ts / memory-sqlite.ts).
|
|
476
|
+
// T11829: pass the OTHER lineages that share this scope's consolidated cleo.db
|
|
477
|
+
// journal so their rows are not deleted as cross-lineage orphans (the confirmed
|
|
478
|
+
// OOM root cause: each lineage previously deleted the others' rows so the shared
|
|
479
|
+
// journal never converged).
|
|
480
|
+
reconcileJournal(nativeDb, migrationsFolder, existenceTable(scope), `dual-scope-db[${scope}]`, resolveConsolidatedJournalSiblings(migrationsSetName(scope)));
|
|
481
|
+
// Run any pending migrations.
|
|
482
|
+
migrateWithRetry(db, migrationsFolder, nativeDb, existenceTable(scope), `dual-scope-db[${scope}]`);
|
|
483
|
+
log.debug({ scope, dbPath: normalizedPath }, 'dual-scope cleo.db ready');
|
|
484
|
+
const identity = makeWriterLeaseIdentity(scope, normalizedPath);
|
|
485
|
+
registerDbIdentity(db, identity);
|
|
486
|
+
const built = {
|
|
487
|
+
db,
|
|
488
|
+
scope,
|
|
489
|
+
dbPath: normalizedPath,
|
|
490
|
+
identity,
|
|
491
|
+
get isOpen() {
|
|
492
|
+
return nativeDb.isOpen;
|
|
493
|
+
},
|
|
494
|
+
close() {
|
|
495
|
+
// Identity-guarded deletion: only evict from the singleton cache
|
|
496
|
+
// when the current entry still references THIS exact handle. A stale
|
|
497
|
+
// store close after a replacement was opened leaves the replacement
|
|
498
|
+
// intact. The native connection is always closed regardless.
|
|
499
|
+
const entry = _cache.get(key);
|
|
500
|
+
if (entry?.handle === built) {
|
|
501
|
+
_cache.delete(key);
|
|
502
|
+
}
|
|
503
|
+
try {
|
|
504
|
+
nativeDb.close();
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
// Idempotent — ignore double-close errors.
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
// Update the cache entry to mark init complete.
|
|
512
|
+
const entry = _cache.get(key);
|
|
513
|
+
if (entry) {
|
|
514
|
+
entry.initPromise = null;
|
|
515
|
+
entry.handle = built;
|
|
516
|
+
entry.nativeDb = nativeDb;
|
|
517
|
+
}
|
|
518
|
+
return built;
|
|
519
|
+
});
|
|
520
|
+
// ── Exodus-on-open (E6 · T11553) — runs AFTER the cold-open lease releases ──
|
|
521
|
+
// Data-continuity safety net: on a canonical open where the consolidated
|
|
522
|
+
// cleo.db is empty but the legacy fleet (tasks.db/brain.db/…) has rows for
|
|
523
|
+
// THIS scope, lazily auto-migrate ONCE — gated by a parity verify, serialised
|
|
524
|
+
// by a single-flight lock, rolled back to empty on parity failure (legacy
|
|
525
|
+
// kept). Re-entrancy/concurrency are handled inside the hook. Lazy `import()`
|
|
526
|
+
// breaks the cycle (exodus/migrate.ts imports openDualScopeDb from here).
|
|
527
|
+
//
|
|
528
|
+
// NOTE: `runExodusMigrate` opens DEDICATED connections (not cached).
|
|
529
|
+
// When the migration finishes those dedicated handles close only their own
|
|
530
|
+
// `DatabaseSync` — they never touch the singleton `_cache`. After a
|
|
531
|
+
// `migrated`/`aborted` outcome the `handle` built above (and its
|
|
532
|
+
// `nativeDb`) is CLOSED. We therefore re-open this scope fresh
|
|
533
|
+
// (cache-miss, NOT armed — no `exodusCwd`) and return the new live
|
|
534
|
+
// handle. That re-open flows through the cold-open lease again on a FRESH handle
|
|
535
|
+
// (cheap claim/release — the DB is now migrated), with no contention against the
|
|
536
|
+
// already-released outer lease.
|
|
537
|
+
//
|
|
538
|
+
// Armed ONLY when `exodusCwd` was threaded through the canonical
|
|
539
|
+
// `openDualScopeDb` AND `dbPath` is the canonical path for that scope+cwd —
|
|
540
|
+
// never for explicit-path opens (test fixtures / legacy-path domains).
|
|
541
|
+
let finalHandle = handle;
|
|
542
|
+
if (exodusCwd !== undefined && normalizedPath === resolveDualScopeDbPath(scope, exodusCwd)) {
|
|
543
|
+
// ── T12001 (Epic T11992) — db-heavy admission for the exodus auto-migrate ──
|
|
544
|
+
// The on-open exodus (reconcile + parity verify + migrate) is a heavy DB op;
|
|
545
|
+
// letting it co-schedule with builds/tests/agents is a historical OOM vector.
|
|
546
|
+
// Admit it through the governor's `db-heavy` class (machine-wide serialized;
|
|
547
|
+
// deferred under memory backoff). On a denial we SKIP the migration THIS open
|
|
548
|
+
// (kill-switch precedent CLEO_DISABLE_EXODUS_ON_OPEN) — NEVER block or defer
|
|
549
|
+
// the interactive command (interactive-cli is never gated) — and it re-runs
|
|
550
|
+
// idempotently on a calmer open. Fail-open: ANY governor error proceeds
|
|
551
|
+
// un-gated, byte-identical to pre-T12001 behaviour.
|
|
552
|
+
let releaseDbHeavy = null;
|
|
553
|
+
let dbHeavyDeferred = false;
|
|
554
|
+
try {
|
|
555
|
+
const { governor } = await import('../resources/governor.js');
|
|
556
|
+
const admit = await governor.tryAcquire('db-heavy');
|
|
557
|
+
if (admit.deferred) {
|
|
558
|
+
dbHeavyDeferred = true;
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
releaseDbHeavy = admit.release;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
catch {
|
|
565
|
+
// Governor unavailable — fail open (proceed un-gated).
|
|
566
|
+
}
|
|
567
|
+
if (dbHeavyDeferred) {
|
|
568
|
+
log.debug({ scope, dbPath: normalizedPath }, 'exodus-on-open skipped this open — db-heavy deferred under memory pressure ' +
|
|
569
|
+
'(re-runs idempotently on a calmer open)');
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
try {
|
|
573
|
+
const { maybeRunExodusOnOpen } = await import('./exodus/on-open.js');
|
|
574
|
+
const result = await maybeRunExodusOnOpen(scope, normalizedPath, nativeDb, exodusCwd);
|
|
575
|
+
if (result.outcome === 'migrated' || result.outcome === 'aborted') {
|
|
576
|
+
// The migrate engine closed our handle — re-open fresh (un-armed) so the
|
|
577
|
+
// caller receives a valid, live handle bound to the now-(de)populated DB.
|
|
578
|
+
const reopened = scope === 'project'
|
|
579
|
+
? await openDualScopeDbAtPath('project', normalizedPath)
|
|
580
|
+
: await openDualScopeDbAtPath('global', normalizedPath);
|
|
581
|
+
if (result.outcome === 'aborted') {
|
|
582
|
+
// T11828 (DHQ-059): the data-continuity gate aborted — the consolidated
|
|
583
|
+
// DB is empty + consistent, legacy kept as source. Surface this to a
|
|
584
|
+
// MUTATING caller (read-only callers ignore it) by (a) stamping a
|
|
585
|
+
// structured marker on the returned handle and (b) broadcasting a typed
|
|
586
|
+
// event. The non-zero error itself is raised on the write path via
|
|
587
|
+
// `assertWriteDurable(handle)` — NOT here, so read opens never throw.
|
|
588
|
+
const abort = {
|
|
589
|
+
scope,
|
|
590
|
+
dbPath: normalizedPath,
|
|
591
|
+
reason: result.reason,
|
|
592
|
+
at: Date.now(),
|
|
593
|
+
};
|
|
594
|
+
log.warn({ scope, reason: result.reason }, 'exodus-on-open aborted; consolidated cleo.db left empty, legacy kept as source — ' +
|
|
595
|
+
'mutating callers must check handle.exodusAbort / call assertWriteDurable (T11828)');
|
|
596
|
+
const { emitExodusAbort } = await import('./exodus/abort-events.js');
|
|
597
|
+
emitExodusAbort(abort);
|
|
598
|
+
finalHandle = { ...reopened, exodusAbort: abort };
|
|
599
|
+
}
|
|
600
|
+
else {
|
|
601
|
+
// A subsequent SUCCESSFUL migration resolves any prior abort recorded
|
|
602
|
+
// for this scope, so writes are no longer rejected (T11828).
|
|
603
|
+
const { clearExodusAborts } = await import('./exodus/abort-events.js');
|
|
604
|
+
clearExodusAborts(scope);
|
|
605
|
+
finalHandle = reopened;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
catch (err) {
|
|
610
|
+
// Best-effort safety net: a hook failure must not make the DB
|
|
611
|
+
// unopenable. Warn and re-open fresh; `cleo exodus migrate` remains the
|
|
612
|
+
// manual path. (The handle may have been closed mid-migrate.)
|
|
613
|
+
log.warn({ err, scope }, 'exodus-on-open hook failed (non-fatal); re-opening consolidated handle');
|
|
614
|
+
finalHandle =
|
|
615
|
+
scope === 'project'
|
|
616
|
+
? await openDualScopeDbAtPath('project', normalizedPath)
|
|
617
|
+
: await openDualScopeDbAtPath('global', normalizedPath);
|
|
618
|
+
}
|
|
619
|
+
finally {
|
|
620
|
+
// Release the db-heavy slot on EVERY exit path (early returns above run
|
|
621
|
+
// `finally` first). Idempotent; release errors are swallowed.
|
|
622
|
+
if (releaseDbHeavy)
|
|
623
|
+
await releaseDbHeavy().catch(() => { });
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
initResolve(finalHandle);
|
|
628
|
+
}
|
|
629
|
+
catch (err) {
|
|
630
|
+
initReject(err);
|
|
631
|
+
}
|
|
632
|
+
})();
|
|
591
633
|
return initPromise;
|
|
592
634
|
}
|
|
593
635
|
/**
|
|
@@ -628,13 +670,354 @@ export function _resetDualScopeDbCache(scope) {
|
|
|
628
670
|
// defensively in case the handle was a mid-init placeholder.
|
|
629
671
|
_cache.delete(key);
|
|
630
672
|
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Error thrown when a runtime entry is cancelled during initialization
|
|
676
|
+
* (e.g. {@link CleoRuntime.closeProject} called while an open is in flight).
|
|
677
|
+
*/
|
|
678
|
+
class EntryCancelledError extends Error {
|
|
679
|
+
constructor(scope, dbPath) {
|
|
680
|
+
super(`CleoRuntime entry cancelled during initialization: ${scope}::${dbPath}`);
|
|
681
|
+
this.name = 'EntryCancelledError';
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Thrown when the post-await liveness reacquisition exhausts its bounded
|
|
686
|
+
* retries — two consecutive opens returned a dead handle. The caller can
|
|
687
|
+
* distinguish a transient liveness loss (one retry succeeds) from a
|
|
688
|
+
* persistent failure (both handles dead).
|
|
689
|
+
*
|
|
690
|
+
* @task T12036 (E6-L12)
|
|
691
|
+
*/
|
|
692
|
+
class LivenessExhaustedError extends Error {
|
|
693
|
+
constructor(scope, dbPath) {
|
|
694
|
+
super(`CleoRuntime liveness exhausted after bounded reacquisition: ${scope}::${dbPath}`);
|
|
695
|
+
this.name = 'LivenessExhaustedError';
|
|
636
696
|
}
|
|
637
697
|
}
|
|
698
|
+
/**
|
|
699
|
+
* Concrete implementation of the {@link CleoRuntime} store registry.
|
|
700
|
+
*
|
|
701
|
+
* Maintains a private `Map<string, RuntimeEntry>` keyed by the scope-qualified
|
|
702
|
+
* composite `${scope}::${normalizedDbPath}`. Each call to {@link openProject}
|
|
703
|
+
* or {@link openGlobal} flows through the existing dual-scope chokepoint
|
|
704
|
+
* ({@link openDualScopeDbAtPath}) so migrations, pragmas, and the singleton
|
|
705
|
+
* `_cache` are shared — the runtime adds explicit per-entry wrapping, identity
|
|
706
|
+
* tracking, and scoped disposal on top.
|
|
707
|
+
*
|
|
708
|
+
* ## Key invariants
|
|
709
|
+
*
|
|
710
|
+
* 1. **Scope-qualified keys** — `'project::/abs/path'` and
|
|
711
|
+
* `'global::/abs/path'` are distinct keys even when the path is the same
|
|
712
|
+
* string. A `GlobalStore` can never be cast to a `ProjectStore` via a
|
|
713
|
+
* key collision.
|
|
714
|
+
* 2. **Entry identity** — every entry carries a monotonically-increasing `id`.
|
|
715
|
+
* After `await openDualScopeDbAtPath`, the init publishes ONLY when the
|
|
716
|
+
* current entry's `id` still matches. If another call (close/re-open)
|
|
717
|
+
* replaced the entry, the acquired handle is closed and the init rejects.
|
|
718
|
+
* 3. **Stale-safe close** — each store records its entry `id`. `close()` only
|
|
719
|
+
* evicts the registry entry when its `id` still matches; an old stale close
|
|
720
|
+
* is a no-op.
|
|
721
|
+
* 4. **Cache-hit liveness** — before returning a cached store, the runtime
|
|
722
|
+
* checks {@link ProjectStore.isOpen} / {@link GlobalStore.isOpen}
|
|
723
|
+
* (which delegates to {@link DualScopeDbHandle.isOpen}, a typed getter
|
|
724
|
+
* reflecting the underlying `DatabaseSync.isOpen`). A handle closed
|
|
725
|
+
* externally (e.g. `_resetDualScopeDbCache`) is evicted and re-opened.
|
|
726
|
+
* 5. **Path normalization** — all paths are normalized via `path.resolve()`
|
|
727
|
+
* before keying, so `/path/./to/db` and `/path/to/db` share one entry.
|
|
728
|
+
* 6. **Dedicated mode** — passing `{ dedicated: true }` opens a second
|
|
729
|
+
* `DatabaseSync` connection (WAL-allowed) that bypasses the shared cache.
|
|
730
|
+
* Dedicated stores are NOT registered or tracked; the caller is responsible
|
|
731
|
+
* for closing them.
|
|
732
|
+
*
|
|
733
|
+
* @task T12036 (E6-L12)
|
|
734
|
+
*/
|
|
735
|
+
class CleoRuntimeImpl {
|
|
736
|
+
_registry = new Map();
|
|
737
|
+
_nextId = 0;
|
|
738
|
+
/**
|
|
739
|
+
* Injectable opener function. Defaults to {@link openDualScopeDbAtPath}.
|
|
740
|
+
* Tests override this to return a pre-closed handle for exercising the
|
|
741
|
+
* post-await liveness branch. Not part of the public {@link CleoRuntime}
|
|
742
|
+
* interface.
|
|
743
|
+
*/
|
|
744
|
+
openDualScopeFn = openDualScopeDbAtPath;
|
|
745
|
+
/** @inheritdoc */
|
|
746
|
+
get openPaths() {
|
|
747
|
+
return new Set(this._registry.keys());
|
|
748
|
+
}
|
|
749
|
+
/** @inheritdoc */
|
|
750
|
+
async openProject(dbPath, options) {
|
|
751
|
+
const normalized = resolve(dbPath);
|
|
752
|
+
if (options?.dedicated) {
|
|
753
|
+
const handle = await openDualScopeDbAtPath('project', normalized, undefined, {
|
|
754
|
+
dedicated: true,
|
|
755
|
+
});
|
|
756
|
+
return this.buildStore('project', handle, 0);
|
|
757
|
+
}
|
|
758
|
+
return this.openEntry('project', normalized);
|
|
759
|
+
}
|
|
760
|
+
/** @inheritdoc */
|
|
761
|
+
async openGlobal(options) {
|
|
762
|
+
const dbPath = resolveDualScopeDbPath('global');
|
|
763
|
+
const normalized = resolve(dbPath);
|
|
764
|
+
if (options?.dedicated) {
|
|
765
|
+
const handle = await openDualScopeDbAtPath('global', normalized, undefined, {
|
|
766
|
+
dedicated: true,
|
|
767
|
+
});
|
|
768
|
+
return this.buildStore('global', handle, 0);
|
|
769
|
+
}
|
|
770
|
+
return this.openEntry('global', normalized);
|
|
771
|
+
}
|
|
772
|
+
/** @inheritdoc */
|
|
773
|
+
closeProject(dbPath) {
|
|
774
|
+
const normalized = resolve(dbPath);
|
|
775
|
+
this.closeEntry('project', normalized);
|
|
776
|
+
}
|
|
777
|
+
/** @inheritdoc */
|
|
778
|
+
closeAll() {
|
|
779
|
+
// Snapshot keys: closeEntry mutates the map during iteration.
|
|
780
|
+
for (const key of [...this._registry.keys()]) {
|
|
781
|
+
try {
|
|
782
|
+
this.closeEntryByKey(key);
|
|
783
|
+
}
|
|
784
|
+
catch {
|
|
785
|
+
// Continue closing remaining entries.
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
// ── Private helpers ──────────────────────────────────────────────────────
|
|
790
|
+
/**
|
|
791
|
+
* Open (or reuse) a scoped entry. All five key invariants are enforced
|
|
792
|
+
* on this single code path.
|
|
793
|
+
*
|
|
794
|
+
* @param depth - Reacquisition depth guard. Starts at 0; capped at 1.
|
|
795
|
+
* When the post-await liveness check finds a dead handle, the bounded
|
|
796
|
+
* retry calls this with `depth + 1`. A second dead handle at depth 1
|
|
797
|
+
* throws {@link LivenessExhaustedError} instead of recursing further.
|
|
798
|
+
*/
|
|
799
|
+
async openEntry(scope, dbPath, depth = 0) {
|
|
800
|
+
const key = cacheKey(scope, dbPath);
|
|
801
|
+
// ── Cache-hit liveness (invariant 4) ─────────────────────────────────
|
|
802
|
+
const existing = this._registry.get(key);
|
|
803
|
+
if (existing) {
|
|
804
|
+
if (existing.initPromise) {
|
|
805
|
+
return existing.initPromise;
|
|
806
|
+
}
|
|
807
|
+
if (existing.store?.isOpen) {
|
|
808
|
+
return existing.store;
|
|
809
|
+
}
|
|
810
|
+
// Handle was closed externally — evict and re-open.
|
|
811
|
+
this._registry.delete(key);
|
|
812
|
+
}
|
|
813
|
+
// ── Placeholder with identity (invariant 2) ──────────────────────────
|
|
814
|
+
const entryId = ++this._nextId;
|
|
815
|
+
let initResolve;
|
|
816
|
+
let initReject;
|
|
817
|
+
const initPromise = new Promise((resolve, reject) => {
|
|
818
|
+
initResolve = resolve;
|
|
819
|
+
initReject = reject;
|
|
820
|
+
});
|
|
821
|
+
this._registry.set(key, { id: entryId, store: null, initPromise });
|
|
822
|
+
// Evict on failure so a transient error doesn't poison.
|
|
823
|
+
initPromise.catch(() => {
|
|
824
|
+
const placeholder = this._registry.get(key);
|
|
825
|
+
if (placeholder?.initPromise === initPromise) {
|
|
826
|
+
this._registry.delete(key);
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
try {
|
|
830
|
+
// Dispatch on scope literal so the overloaded opener resolves to the
|
|
831
|
+
// correct typed return. The union `DualScope` cannot satisfy either
|
|
832
|
+
// literal overload directly.
|
|
833
|
+
const dualHandle = scope === 'project'
|
|
834
|
+
? await this.openDualScopeFn('project', dbPath)
|
|
835
|
+
: await this.openDualScopeFn('global', dbPath);
|
|
836
|
+
// ── Publish only if THIS init is still current (invariant 2) ───────
|
|
837
|
+
const current = this._registry.get(key);
|
|
838
|
+
if (!current || current.id !== entryId) {
|
|
839
|
+
// Cancelled during init. Do NOT close dualHandle — a replacement
|
|
840
|
+
// entry for the same scope/key may be awaiting the same in-flight
|
|
841
|
+
// chokepoint promise. The handle stays in the shared cache; the
|
|
842
|
+
// replacement inherits it live.
|
|
843
|
+
throw new EntryCancelledError(scope, dbPath);
|
|
844
|
+
}
|
|
845
|
+
// ── Post-await liveness (invariant 4b) ─────────────────────────
|
|
846
|
+
// The chokepoint resolved with a live handle, but an external close
|
|
847
|
+
// (e.g. _resetDualScopeDbCache or a test-injected dead-handle opener)
|
|
848
|
+
// between the await and this publish can kill it. If the handle is
|
|
849
|
+
// dead, close it (evict from cache), delete the placeholder, and
|
|
850
|
+
// perform ONE bounded reacquisition.
|
|
851
|
+
if (!dualHandle.isOpen) {
|
|
852
|
+
dualHandle.close();
|
|
853
|
+
this._registry.delete(key);
|
|
854
|
+
if (depth >= 1) {
|
|
855
|
+
throw new LivenessExhaustedError(scope, dbPath);
|
|
856
|
+
}
|
|
857
|
+
const retryStore = await this.openEntry(scope, dbPath, depth + 1);
|
|
858
|
+
initResolve(retryStore);
|
|
859
|
+
return retryStore;
|
|
860
|
+
}
|
|
861
|
+
const store = this.buildStore(scope, dualHandle, entryId);
|
|
862
|
+
current.store = store;
|
|
863
|
+
current.initPromise = null;
|
|
864
|
+
initResolve(store);
|
|
865
|
+
return store;
|
|
866
|
+
}
|
|
867
|
+
catch (err) {
|
|
868
|
+
initReject(err);
|
|
869
|
+
throw err;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Build a typed {@link ProjectStore} or {@link GlobalStore} from a
|
|
874
|
+
* {@link DualScopeDbHandle}. The store's `close()` is stale-safe
|
|
875
|
+
* (invariant 3): it only evicts the registry entry when the entry's
|
|
876
|
+
* identity still matches.
|
|
877
|
+
*/
|
|
878
|
+
buildStore(scope, dualHandle, entryId) {
|
|
879
|
+
const dbPath = dualHandle.dbPath;
|
|
880
|
+
const identity = dualHandle.identity;
|
|
881
|
+
const exodusAbort = dualHandle.exodusAbort;
|
|
882
|
+
const key = cacheKey(scope, dbPath);
|
|
883
|
+
if (scope === 'project') {
|
|
884
|
+
const store = {
|
|
885
|
+
scope: 'project',
|
|
886
|
+
dbPath,
|
|
887
|
+
identity,
|
|
888
|
+
db: dualHandle.db,
|
|
889
|
+
get isOpen() {
|
|
890
|
+
return dualHandle.isOpen;
|
|
891
|
+
},
|
|
892
|
+
exodusAbort,
|
|
893
|
+
close: () => {
|
|
894
|
+
dualHandle.close();
|
|
895
|
+
this.cleanupEntry(key, entryId);
|
|
896
|
+
},
|
|
897
|
+
};
|
|
898
|
+
return store;
|
|
899
|
+
}
|
|
900
|
+
const store = {
|
|
901
|
+
scope: 'global',
|
|
902
|
+
dbPath,
|
|
903
|
+
identity,
|
|
904
|
+
db: dualHandle.db,
|
|
905
|
+
get isOpen() {
|
|
906
|
+
return dualHandle.isOpen;
|
|
907
|
+
},
|
|
908
|
+
exodusAbort,
|
|
909
|
+
close: () => {
|
|
910
|
+
dualHandle.close();
|
|
911
|
+
this.cleanupEntry(key, entryId);
|
|
912
|
+
},
|
|
913
|
+
};
|
|
914
|
+
return store;
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Conditionally evict a registry entry. Only deletes when the current
|
|
918
|
+
* entry's identity still matches `entryId` — an old stale close is a no-op
|
|
919
|
+
* (invariant 3).
|
|
920
|
+
*/
|
|
921
|
+
cleanupEntry(key, entryId) {
|
|
922
|
+
const entry = this._registry.get(key);
|
|
923
|
+
if (entry && entry.id === entryId) {
|
|
924
|
+
this._registry.delete(key);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Close and evict a single scoped entry from the registry by composite
|
|
929
|
+
* key. Cancels in-flight opens by deleting the placeholder entry — the
|
|
930
|
+
* in-flight init detects the missing/id-mismatched entry and rejects
|
|
931
|
+
* after its `await` resolves (invariant 2).
|
|
932
|
+
*/
|
|
933
|
+
closeEntry(scope, dbPath) {
|
|
934
|
+
this.closeEntryByKey(cacheKey(scope, dbPath));
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Close and evict a single entry by composite registry key.
|
|
938
|
+
*/
|
|
939
|
+
closeEntryByKey(key) {
|
|
940
|
+
const entry = this._registry.get(key);
|
|
941
|
+
if (!entry)
|
|
942
|
+
return;
|
|
943
|
+
// Delete FIRST so in-flight inits observe a missing entry
|
|
944
|
+
// (invariant 2 — cancellation). Even if the store close below throws,
|
|
945
|
+
// the entry is gone — the caller won't get it back and the dual-scope
|
|
946
|
+
// chokepoint cache still owns the SQLite close.
|
|
947
|
+
this._registry.delete(key);
|
|
948
|
+
if (entry.store) {
|
|
949
|
+
try {
|
|
950
|
+
entry.store.close();
|
|
951
|
+
}
|
|
952
|
+
catch {
|
|
953
|
+
// Close is best-effort; the entry is already evicted.
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Override the dual-scope opener function used by the given
|
|
960
|
+
* {@link CleoRuntime} instance. Tests inject a custom opener to return a
|
|
961
|
+
* pre-closed handle, exercising the post-await liveness and bounded-
|
|
962
|
+
* reacquisition code paths.
|
|
963
|
+
*
|
|
964
|
+
* The injected function receives the same `(scope, dbPath)` signature as
|
|
965
|
+
* {@link openDualScopeDbAtPath} and must return a
|
|
966
|
+
* {@link DualScopeDbHandle}. Only non-dedicated opens (
|
|
967
|
+
* {@link CleoRuntime.openProject} and {@link CleoRuntime.openGlobal}
|
|
968
|
+
* without `{ dedicated: true }`) are affected. Dedicated opens always
|
|
969
|
+
* call {@link openDualScopeDbAtPath} directly — a custom opener can
|
|
970
|
+
* never turn a dedicated store into a cached/shared store.
|
|
971
|
+
*
|
|
972
|
+
* Pass `undefined` to restore the default (`openDualScopeDbAtPath`).
|
|
973
|
+
*
|
|
974
|
+
* @param runtime - The runtime instance to configure.
|
|
975
|
+
* @param fn - The opener to use, or `undefined` to reset.
|
|
976
|
+
*
|
|
977
|
+
* @internal Test seam; imported directly from this module, not the barrel.
|
|
978
|
+
*/
|
|
979
|
+
export function setRuntimeOpenFn(runtime, fn) {
|
|
980
|
+
runtime.openDualScopeFn = fn ?? openDualScopeDbAtPath;
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* Create a new {@link CleoRuntime} store registry.
|
|
984
|
+
*
|
|
985
|
+
* The returned runtime is the explicit composition root for project and
|
|
986
|
+
* global database entries. Use {@link CleoRuntime.openProject} to open a
|
|
987
|
+
* project's `cleo.db` at a canonical path, and {@link CleoRuntime.openGlobal}
|
|
988
|
+
* for the global scope. Closing a project via {@link CleoRuntime.closeProject}
|
|
989
|
+
* or the store's own `close()` disposes only that entry.
|
|
990
|
+
*
|
|
991
|
+
* ## Cross-runtime behavior
|
|
992
|
+
*
|
|
993
|
+
* Two separate runtime instances SHARE the **process-global dual-scope
|
|
994
|
+
* handle cache** (`_cache`). Closing a store in one runtime closes the
|
|
995
|
+
* shared `DatabaseSync` — another runtime referencing the same path
|
|
996
|
+
* will observe a closed connection. The **cache-hit liveness** check in
|
|
997
|
+
* this runtime reacquires a fresh handle on the next `openProject`/
|
|
998
|
+
* `openGlobal`. This is by design: the runtime owns **entry lifecycle**,
|
|
999
|
+
* not the underlying connection. Use a dedicated connection for isolation.
|
|
1000
|
+
*
|
|
1001
|
+
* @returns A fresh {@link CleoRuntime} instance with an empty registry.
|
|
1002
|
+
*
|
|
1003
|
+
* @example
|
|
1004
|
+
* ```ts
|
|
1005
|
+
* import { createCleoRuntime, resolveDualScopeDbPath } from '@cleocode/core/db';
|
|
1006
|
+
*
|
|
1007
|
+
* const runtime = createCleoRuntime();
|
|
1008
|
+
* const projectA = await runtime.openProject(resolveDualScopeDbPath('project', '/path/to/projA'));
|
|
1009
|
+
* const projectB = await runtime.openProject(resolveDualScopeDbPath('project', '/path/to/projB'));
|
|
1010
|
+
* // projectA and projectB are distinct, independent handles.
|
|
1011
|
+
* projectA.close(); // Only closes projectA; projectB and any global handle are intact.
|
|
1012
|
+
* ```
|
|
1013
|
+
*
|
|
1014
|
+
* @task T12036 (E6-L12)
|
|
1015
|
+
* @epic T11249 (E6)
|
|
1016
|
+
* @saga T11242
|
|
1017
|
+
*/
|
|
1018
|
+
export function createCleoRuntime() {
|
|
1019
|
+
return new CleoRuntimeImpl();
|
|
1020
|
+
}
|
|
638
1021
|
/**
|
|
639
1022
|
* Attempt to insert `row` into `table`. If a row with the same value for
|
|
640
1023
|
* `keyColumn` already exists (UNIQUE conflict), the insert is silently skipped.
|
|
@@ -656,6 +1039,11 @@ export function _resetDualScopeDbCache(scope) {
|
|
|
656
1039
|
* the consolidated-schema MUTATION primitives, so the guard is write-only and
|
|
657
1040
|
* never affects read paths.
|
|
658
1041
|
*
|
|
1042
|
+
* Derives the writer-lease identity from the DB handle itself via
|
|
1043
|
+
* {@link resolveDbIdentity} — the identity was registered at
|
|
1044
|
+
* {@link DualScopeDbHandle} construction, so a caller cannot pair file-A
|
|
1045
|
+
* identity with file-B DB (T12042).
|
|
1046
|
+
*
|
|
659
1047
|
* @example
|
|
660
1048
|
* ```ts
|
|
661
1049
|
* import { tasksTasksTable } from '@cleocode/core/store/schema/cleo-project';
|
|
@@ -664,6 +1052,7 @@ export function _resetDualScopeDbCache(scope) {
|
|
|
664
1052
|
*
|
|
665
1053
|
* @task T11513 (E4-T2)
|
|
666
1054
|
* @task T11828 (write-side exodus-abort guard)
|
|
1055
|
+
* @task T12042 (E6-L12b — exact DB-bound identity)
|
|
667
1056
|
* @epic T11247 (E4)
|
|
668
1057
|
* @saga T11242
|
|
669
1058
|
*/
|
|
@@ -673,17 +1062,11 @@ db, table, row,
|
|
|
673
1062
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
674
1063
|
_keyColumn) {
|
|
675
1064
|
assertNoRecordedExodusAbort();
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
// write in the process. The scope AND dbPath come from the process-local
|
|
679
|
-
// active-scope registry recorded at cold-open ({@link activeScope} /
|
|
680
|
-
// {@link activeScopeDbPath}) — no signature change. Pinning the dbPath keeps the
|
|
681
|
-
// lease row in the SAME file this write targets when multiple projects are open
|
|
682
|
-
// (T11627 Finding 1). `off` mode is a pass-through (busy_timeout serializes).
|
|
683
|
-
return withWriterLease(activeScope(), 'tasks', async () => {
|
|
1065
|
+
const identity = resolveDbIdentity(db);
|
|
1066
|
+
return withWriterLease(identity.scope, 'tasks', async () => {
|
|
684
1067
|
const result = await db.insert(table).values(row).onConflictDoNothing().returning();
|
|
685
1068
|
return result.length;
|
|
686
|
-
}, { dbPath:
|
|
1069
|
+
}, { dbPath: identity.dbPath });
|
|
687
1070
|
}
|
|
688
1071
|
/**
|
|
689
1072
|
* Upsert `row` into `table`, updating all non-key columns when a row with
|
|
@@ -706,6 +1089,9 @@ _keyColumn) {
|
|
|
706
1089
|
* Refuses the write (throws {@link ExodusAbortWriteUnsafeError}) when a prior
|
|
707
1090
|
* exodus-on-open aborted in this process (T11828 · DHQ-059) — write-only guard.
|
|
708
1091
|
*
|
|
1092
|
+
* Derives the writer-lease identity from the DB handle itself via
|
|
1093
|
+
* {@link resolveDbIdentity} (T12042 — exact DB-bound identity, no fallback).
|
|
1094
|
+
*
|
|
709
1095
|
* @example
|
|
710
1096
|
* ```ts
|
|
711
1097
|
* await upsertIdempotent(db, tasksTasksTable, updatedTask, 'idempotencyKey',
|
|
@@ -714,6 +1100,7 @@ _keyColumn) {
|
|
|
714
1100
|
*
|
|
715
1101
|
* @task T11513 (E4-T2)
|
|
716
1102
|
* @task T11828 (write-side exodus-abort guard)
|
|
1103
|
+
* @task T12042 (E6-L12b — exact DB-bound identity)
|
|
717
1104
|
* @epic T11247 (E4)
|
|
718
1105
|
* @saga T11242
|
|
719
1106
|
*/
|
|
@@ -725,10 +1112,8 @@ _keyColumn,
|
|
|
725
1112
|
// biome-ignore lint/suspicious/noExplicitAny: column reference type varies by table
|
|
726
1113
|
conflictTarget, set) {
|
|
727
1114
|
assertNoRecordedExodusAbort();
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
// no signature change.
|
|
731
|
-
return withWriterLease(activeScope(), 'tasks', async () => {
|
|
1115
|
+
const identity = resolveDbIdentity(db);
|
|
1116
|
+
return withWriterLease(identity.scope, 'tasks', async () => {
|
|
732
1117
|
const updateSet = set ?? row;
|
|
733
1118
|
const result = await db
|
|
734
1119
|
.insert(table)
|
|
@@ -740,6 +1125,6 @@ conflictTarget, set) {
|
|
|
740
1125
|
})
|
|
741
1126
|
.returning();
|
|
742
1127
|
return result.length;
|
|
743
|
-
}, { dbPath:
|
|
1128
|
+
}, { dbPath: identity.dbPath });
|
|
744
1129
|
}
|
|
745
1130
|
//# sourceMappingURL=dual-scope-db.js.map
|