@seanhogg/builderforce-memory 2026.9.33 → 2026.10.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.
Files changed (116) hide show
  1. package/dist/errors/SSMError.d.ts +1 -1
  2. package/dist/errors/SSMError.d.ts.map +1 -1
  3. package/dist/errors/SSMError.js.map +1 -1
  4. package/dist/handoff/budget.d.ts +34 -0
  5. package/dist/handoff/budget.d.ts.map +1 -0
  6. package/dist/handoff/budget.js +77 -0
  7. package/dist/handoff/budget.js.map +1 -0
  8. package/dist/handoff/index.d.ts +18 -0
  9. package/dist/handoff/index.d.ts.map +1 -0
  10. package/dist/handoff/index.js +17 -0
  11. package/dist/handoff/index.js.map +1 -0
  12. package/dist/handoff/note.d.ts +35 -0
  13. package/dist/handoff/note.d.ts.map +1 -0
  14. package/dist/handoff/note.js +82 -0
  15. package/dist/handoff/note.js.map +1 -0
  16. package/dist/handoff/pack.d.ts +26 -0
  17. package/dist/handoff/pack.d.ts.map +1 -0
  18. package/dist/handoff/pack.js +131 -0
  19. package/dist/handoff/pack.js.map +1 -0
  20. package/dist/handoff/segments.d.ts +18 -0
  21. package/dist/handoff/segments.d.ts.map +1 -0
  22. package/dist/handoff/segments.js +36 -0
  23. package/dist/handoff/segments.js.map +1 -0
  24. package/dist/handoff/types.d.ts +59 -0
  25. package/dist/handoff/types.d.ts.map +1 -0
  26. package/dist/handoff/types.js +7 -0
  27. package/dist/handoff/types.js.map +1 -0
  28. package/dist/memory/MemoryStore.d.ts +120 -181
  29. package/dist/memory/MemoryStore.d.ts.map +1 -1
  30. package/dist/memory/MemoryStore.js +201 -389
  31. package/dist/memory/MemoryStore.js.map +1 -1
  32. package/dist/memory/SemanticRecall.d.ts +64 -0
  33. package/dist/memory/SemanticRecall.d.ts.map +1 -0
  34. package/dist/memory/SemanticRecall.js +188 -0
  35. package/dist/memory/SemanticRecall.js.map +1 -0
  36. package/dist/memory/records.d.ts +26 -0
  37. package/dist/memory/records.d.ts.map +1 -0
  38. package/dist/memory/records.js +71 -0
  39. package/dist/memory/records.js.map +1 -0
  40. package/dist/memory/ttlSweeper.d.ts +12 -0
  41. package/dist/memory/ttlSweeper.d.ts.map +1 -0
  42. package/dist/memory/ttlSweeper.js +28 -0
  43. package/dist/memory/ttlSweeper.js.map +1 -0
  44. package/dist/memory/types.d.ts +142 -0
  45. package/dist/memory/types.d.ts.map +1 -0
  46. package/dist/memory/types.js +6 -0
  47. package/dist/memory/types.js.map +1 -0
  48. package/dist/storage/ScopedFactRepository.d.ts +156 -0
  49. package/dist/storage/ScopedFactRepository.d.ts.map +1 -0
  50. package/dist/storage/ScopedFactRepository.js +278 -0
  51. package/dist/storage/ScopedFactRepository.js.map +1 -0
  52. package/dist/storage/factImport.d.ts +21 -0
  53. package/dist/storage/factImport.d.ts.map +1 -0
  54. package/dist/storage/factImport.js +61 -0
  55. package/dist/storage/factImport.js.map +1 -0
  56. package/dist/storage/factMutations.d.ts +46 -0
  57. package/dist/storage/factMutations.d.ts.map +1 -0
  58. package/dist/storage/factMutations.js +73 -0
  59. package/dist/storage/factMutations.js.map +1 -0
  60. package/dist/storage/factQueries.d.ts +16 -0
  61. package/dist/storage/factQueries.d.ts.map +1 -0
  62. package/dist/storage/factQueries.js +36 -0
  63. package/dist/storage/factQueries.js.map +1 -0
  64. package/dist/storage/factRetention.d.ts +30 -0
  65. package/dist/storage/factRetention.d.ts.map +1 -0
  66. package/dist/storage/factRetention.js +65 -0
  67. package/dist/storage/factRetention.js.map +1 -0
  68. package/dist/storage/fileLock.d.ts +56 -0
  69. package/dist/storage/fileLock.d.ts.map +1 -0
  70. package/dist/storage/fileLock.js +138 -0
  71. package/dist/storage/fileLock.js.map +1 -0
  72. package/dist/storage/fileTransaction.d.ts +33 -0
  73. package/dist/storage/fileTransaction.d.ts.map +1 -0
  74. package/dist/storage/fileTransaction.js +108 -0
  75. package/dist/storage/fileTransaction.js.map +1 -0
  76. package/dist/storage/idbAdapter.d.ts +95 -0
  77. package/dist/storage/idbAdapter.d.ts.map +1 -0
  78. package/dist/storage/idbAdapter.js +311 -0
  79. package/dist/storage/idbAdapter.js.map +1 -0
  80. package/dist/storage/nodeFileAdapter.d.ts +127 -0
  81. package/dist/storage/nodeFileAdapter.d.ts.map +1 -0
  82. package/dist/storage/nodeFileAdapter.js +275 -0
  83. package/dist/storage/nodeFileAdapter.js.map +1 -0
  84. package/dist/storage/syncJournal.d.ts +19 -0
  85. package/dist/storage/syncJournal.d.ts.map +1 -0
  86. package/dist/storage/syncJournal.js +46 -0
  87. package/dist/storage/syncJournal.js.map +1 -0
  88. package/dist/storage/types.d.ts +207 -0
  89. package/dist/storage/types.d.ts.map +1 -0
  90. package/dist/storage/types.js +90 -0
  91. package/dist/storage/types.js.map +1 -0
  92. package/package.json +8 -3
  93. package/src/errors/SSMError.ts +1 -0
  94. package/src/handoff/budget.ts +78 -0
  95. package/src/handoff/handoff.test.ts +247 -0
  96. package/src/handoff/index.ts +24 -0
  97. package/src/handoff/note.ts +102 -0
  98. package/src/handoff/pack.ts +156 -0
  99. package/src/handoff/segments.ts +42 -0
  100. package/src/handoff/types.ts +63 -0
  101. package/src/memory/MemoryStore.ts +245 -525
  102. package/src/memory/SemanticRecall.ts +204 -0
  103. package/src/memory/records.ts +79 -0
  104. package/src/memory/ttlSweeper.ts +29 -0
  105. package/src/memory/types.ts +149 -0
  106. package/src/storage/ScopedFactRepository.ts +373 -0
  107. package/src/storage/factImport.ts +73 -0
  108. package/src/storage/factMutations.ts +106 -0
  109. package/src/storage/factQueries.ts +41 -0
  110. package/src/storage/factRetention.ts +82 -0
  111. package/src/storage/fileLock.ts +162 -0
  112. package/src/storage/fileTransaction.ts +116 -0
  113. package/src/storage/idbAdapter.ts +373 -0
  114. package/src/storage/nodeFileAdapter.ts +331 -0
  115. package/src/storage/syncJournal.ts +47 -0
  116. package/src/storage/types.ts +262 -0
@@ -0,0 +1,162 @@
1
+ /**
2
+ * storage/fileLock.ts – Cross-process exclusive lock for the node-file adapter.
3
+ *
4
+ * Two MCP / extension-host processes sharing one memory file each ran
5
+ * refresh → mutate → tmp+rename with nothing between them, so the later rename
6
+ * silently dropped the other's commit. A read-write transaction now holds an
7
+ * exclusive lock file for its whole refresh + mutate + persist:
8
+ *
9
+ * - Acquire = `open(<file>.lock, 'wx')` (atomic create-if-absent), then write
10
+ * our PID into it.
11
+ * - A lock whose recorded PID is no longer alive is STALE and is reclaimed.
12
+ * Staleness is decided by PID liveness, never by age: a slow but live
13
+ * writer (a large fsync) must not have its lock stolen.
14
+ * - Waiting is bounded: past `timeoutMs` the transaction rejects with
15
+ * STORAGE_UNAVAILABLE naming the holder, instead of hanging forever.
16
+ */
17
+
18
+ import { MemoryStorageError } from './types.js';
19
+
20
+ /** The subset of node:fs the lock needs. */
21
+ export interface LockFs {
22
+ openSync(path: string, flags: string): number;
23
+ writeFileSync(fd: number, data: string): void;
24
+ closeSync(fd: number): void;
25
+ readFileSync(path: string, encoding: 'utf8'): string;
26
+ unlinkSync(path: string): void;
27
+ }
28
+
29
+ export interface FileLockOptions {
30
+ /** Give up waiting after this long (ms). Default 10 000. */
31
+ timeoutMs?: number;
32
+ /** Poll interval while the lock is held by a live process (ms). Default 20. */
33
+ retryMs?: number;
34
+ /** This process's PID. Default: `process.pid`. */
35
+ pid?: number;
36
+ /** PID liveness probe. Default: `process.kill(pid, 0)`. */
37
+ isAlive?: (pid: number) => boolean;
38
+ /** Injectable sleep (tests). */
39
+ sleep?: (ms: number) => Promise<void>;
40
+ }
41
+
42
+ interface NodeProcessLike {
43
+ pid: number;
44
+ kill(pid: number, signal: number): void;
45
+ }
46
+
47
+ function nodeProcess(): NodeProcessLike | undefined {
48
+ return (globalThis as { process?: NodeProcessLike }).process;
49
+ }
50
+
51
+ function errorCode(err: unknown): string | undefined {
52
+ return (err as { code?: string } | null)?.code;
53
+ }
54
+
55
+ /** `kill(pid, 0)`: ESRCH = gone; EPERM = alive under another user. */
56
+ export function defaultIsAlive(pid: number): boolean {
57
+ const proc = nodeProcess();
58
+ if (!proc) return true;
59
+ try {
60
+ proc.kill(pid, 0);
61
+ return true;
62
+ } catch (err) {
63
+ return errorCode(err) === 'EPERM';
64
+ }
65
+ }
66
+
67
+ function readHolder(fs: LockFs, lockPath: string): number | undefined {
68
+ let text: string;
69
+ try {
70
+ text = fs.readFileSync(lockPath, 'utf8');
71
+ } catch (err) {
72
+ // Released between our failed create and this read: retry the create.
73
+ if (errorCode(err) === 'ENOENT') return undefined;
74
+ throw err;
75
+ }
76
+ const pid = Number.parseInt(text.trim(), 10);
77
+ return Number.isInteger(pid) && pid > 0 ? pid : undefined;
78
+ }
79
+
80
+ export class FileLock {
81
+ private readonly _timeoutMs: number;
82
+ private readonly _retryMs: number;
83
+ private readonly _pid: number;
84
+ private readonly _isAlive: (pid: number) => boolean;
85
+ private readonly _sleep: (ms: number) => Promise<void>;
86
+
87
+ constructor(private readonly _fs: LockFs, readonly lockPath: string, opts: FileLockOptions = {}) {
88
+ this._timeoutMs = opts.timeoutMs ?? 10_000;
89
+ this._retryMs = opts.retryMs ?? 20;
90
+ this._pid = opts.pid ?? nodeProcess()?.pid ?? 0;
91
+ this._isAlive = opts.isAlive ?? defaultIsAlive;
92
+ this._sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
93
+ }
94
+
95
+ /** Runs `fn` while holding the lock; always releases it. */
96
+ async run<T>(fn: () => Promise<T> | T): Promise<T> {
97
+ await this._acquire();
98
+ try {
99
+ return await fn();
100
+ } finally {
101
+ this._release();
102
+ }
103
+ }
104
+
105
+ private _tryCreate(): boolean {
106
+ let fd: number;
107
+ try {
108
+ fd = this._fs.openSync(this.lockPath, 'wx');
109
+ } catch (err) {
110
+ if (errorCode(err) === 'EEXIST') return false;
111
+ throw new MemoryStorageError('STORAGE_UNAVAILABLE', `Cannot create lock "${this.lockPath}": ${String(err)}`, err);
112
+ }
113
+ try {
114
+ this._fs.writeFileSync(fd, String(this._pid));
115
+ } finally {
116
+ this._fs.closeSync(fd);
117
+ }
118
+ return true;
119
+ }
120
+
121
+ private async _acquire(): Promise<void> {
122
+ const deadline = Date.now() + this._timeoutMs;
123
+ let holder: number | undefined;
124
+ for (;;) {
125
+ if (this._tryCreate()) return;
126
+ holder = readHolder(this._fs, this.lockPath);
127
+ if (holder !== undefined && holder !== this._pid && !this._isAlive(holder)) {
128
+ this._reclaim(holder);
129
+ continue;
130
+ }
131
+ // A live holder, or a lock whose PID is not written yet: wait.
132
+ if (Date.now() >= deadline) {
133
+ throw new MemoryStorageError(
134
+ 'STORAGE_UNAVAILABLE',
135
+ `Timed out after ${this._timeoutMs}ms waiting for "${this.lockPath}" (held by pid ${holder ?? 'unknown'}).`,
136
+ );
137
+ }
138
+ await this._sleep(this._retryMs);
139
+ }
140
+ }
141
+
142
+ /** Removes a dead holder's lock — only if it still names that holder. */
143
+ private _reclaim(deadPid: number): void {
144
+ if (readHolder(this._fs, this.lockPath) !== deadPid) return;
145
+ try {
146
+ this._fs.unlinkSync(this.lockPath);
147
+ } catch (err) {
148
+ // Another waiter reclaimed it first; the next create decides.
149
+ if (errorCode(err) !== 'ENOENT') throw err;
150
+ }
151
+ }
152
+
153
+ private _release(): void {
154
+ try {
155
+ this._fs.unlinkSync(this.lockPath);
156
+ } catch (err) {
157
+ if (errorCode(err) !== 'ENOENT') {
158
+ throw new MemoryStorageError('STORAGE_UNAVAILABLE', `Cannot release lock "${this.lockPath}": ${String(err)}`, err);
159
+ }
160
+ }
161
+ }
162
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * storage/fileTransaction.ts – The {@link StorageTransaction} the node-file
3
+ * adapter hands to a unit of work: direct reads/writes on the in-memory
4
+ * document, plus lazily rebuilt key/tag indexes over its facts.
5
+ */
6
+
7
+ import {
8
+ FACT_KEY_INDEX,
9
+ FACT_TAG_INDEX,
10
+ type JournalEntry,
11
+ type MetaCounters,
12
+ type StorageStoreName,
13
+ type StorageTransaction,
14
+ type StoredFactRecord,
15
+ type TombstoneRecord,
16
+ } from './types.js';
17
+
18
+ /** The on-disk document shape. Versioned so future migrations are explicit. */
19
+ export interface NodeFileDocument {
20
+ formatVersion: 1;
21
+ facts: StoredFactRecord[];
22
+ tombstones: TombstoneRecord[];
23
+ journal: JournalEntry[];
24
+ meta: MetaCounters;
25
+ }
26
+
27
+ export function emptyDocument(): NodeFileDocument {
28
+ return {
29
+ formatVersion: 1,
30
+ facts: [],
31
+ tombstones: [],
32
+ journal: [],
33
+ meta: { key: 'counters', seq: 0, revision: 0, opSeq: 0 },
34
+ };
35
+ }
36
+
37
+ export class FileTransaction implements StorageTransaction {
38
+ /** Lazy key/tag indexes over the in-memory document. Rebuilt after mutations. */
39
+ private _indexDirty = true;
40
+ private readonly _byKey = new Map<string, StoredFactRecord[]>();
41
+ private readonly _byTag = new Map<string, StoredFactRecord[]>();
42
+
43
+ constructor(private readonly _doc: NodeFileDocument) {}
44
+
45
+ private _table(store: StorageStoreName): Array<{ id?: string; opId?: string; key?: string }> {
46
+ return this._doc[store] as unknown as Array<{ id?: string; opId?: string; key?: string }>;
47
+ }
48
+
49
+ /** The id field of a table store (`meta` is the singleton and never reaches here). */
50
+ private _idField(store: Exclude<StorageStoreName, 'meta'>): 'id' | 'opId' {
51
+ return store === 'journal' ? 'opId' : 'id';
52
+ }
53
+
54
+ // `meta` is ONE counters record on disk (`doc.meta`), not an array: the other stores
55
+ // are tables keyed by their id field, `meta` is a singleton keyed 'counters'.
56
+ async get<T>(store: StorageStoreName, id: string): Promise<T | undefined> {
57
+ if (store === 'meta') return (this._doc.meta?.key === id ? this._doc.meta : undefined) as T | undefined;
58
+ const f = this._idField(store);
59
+ return this._table(store).find((r) => r[f] === id) as T | undefined;
60
+ }
61
+ async getAll<T>(store: StorageStoreName): Promise<T[]> {
62
+ if (store === 'meta') return (this._doc.meta ? [this._doc.meta] : []) as unknown as T[];
63
+ return this._table(store).slice() as unknown as T[];
64
+ }
65
+ async put(store: StorageStoreName, value: unknown): Promise<void> {
66
+ if (store === 'meta') {
67
+ this._doc.meta = value as MetaCounters;
68
+ return;
69
+ }
70
+ const f = this._idField(store);
71
+ const table = this._table(store);
72
+ const id = (value as Record<string, string>)[f]!;
73
+ const idx = table.findIndex((r) => r[f] === id);
74
+ if (idx >= 0) table[idx] = value as never;
75
+ else table.push(value as never);
76
+ if (store === 'facts') this._indexDirty = true;
77
+ }
78
+ async delete(store: StorageStoreName, id: string): Promise<void> {
79
+ if (store === 'meta') {
80
+ if (this._doc.meta?.key === id) this._doc.meta = emptyDocument().meta;
81
+ return;
82
+ }
83
+ const f = this._idField(store);
84
+ const table = this._table(store);
85
+ const idx = table.findIndex((r) => r[f] === id);
86
+ if (idx >= 0) table.splice(idx, 1);
87
+ if (store === 'facts') this._indexDirty = true;
88
+ }
89
+
90
+ async getByIndex<T>(store: StorageStoreName, indexName: string, query: string): Promise<T[] | undefined> {
91
+ if (store !== 'facts') return undefined;
92
+ if (indexName !== FACT_KEY_INDEX && indexName !== FACT_TAG_INDEX) return undefined;
93
+ this._ensureFactIndexes();
94
+ const hits = indexName === FACT_KEY_INDEX
95
+ ? this._byKey.get(query)
96
+ : this._byTag.get(query);
97
+ return (hits ?? []).slice() as T[];
98
+ }
99
+
100
+ private _ensureFactIndexes(): void {
101
+ if (!this._indexDirty) return;
102
+ this._byKey.clear();
103
+ this._byTag.clear();
104
+ for (const fact of this._doc.facts) {
105
+ const keyHits = this._byKey.get(fact.key);
106
+ if (keyHits) keyHits.push(fact);
107
+ else this._byKey.set(fact.key, [fact]);
108
+ for (const tag of fact.tags ?? []) {
109
+ const tagHits = this._byTag.get(tag);
110
+ if (tagHits) tagHits.push(fact);
111
+ else this._byTag.set(tag, [fact]);
112
+ }
113
+ }
114
+ this._indexDirty = false;
115
+ }
116
+ }
@@ -0,0 +1,373 @@
1
+ /**
2
+ * storage/idbAdapter.ts – Durable browser/IndexedDB implementation of the
3
+ * storage port.
4
+ *
5
+ * Two properties the legacy MemoryStore write helpers did NOT have (M02):
6
+ * 1. `transact()` resolves only on `tx.oncomplete` — the durable commit point —
7
+ * and rejects on `tx.onabort`. Request-level `onsuccess` is used internally
8
+ * to chain operations, never to signal durability.
9
+ * 2. One `transact()` call is one unit of work spanning facts, tombstones,
10
+ * journal and meta, so a fact and its sync journal entry commit together.
11
+ *
12
+ * Schema (database version 3):
13
+ * - 'facts' keyPath 'id' — StoredFactRecord (scoped composite id)
14
+ * index 'byKey' on 'key' — key lookup without a scan
15
+ * index 'byTag' on 'tags' multiEntry — tag lookup without a scan
16
+ * - 'tombstones' keyPath 'id' — TombstoneRecord
17
+ * - 'journal' keyPath 'opId' — JournalEntry (the sync journal)
18
+ * - 'meta' keyPath 'key' — MetaCounters (durable seq / revision / opSeq)
19
+ * - 'weights' (out-of-line) — legacy weight blob store, retained
20
+ *
21
+ * Version 3 adds the key and tag indexes only. Existing fact records, including
22
+ * their timestamps and TTLs, are not rewritten.
23
+ *
24
+ * Version-1 databases (facts keyed by the raw key, no journal) are migrated in
25
+ * the upgrade transaction: records are copied to the new 'id'-keyed store with
26
+ * the unbound scope, their seq preserved, revision 1, and no pending sync state.
27
+ *
28
+ * Cross-context consistency (M05): when BroadcastChannel is available the
29
+ * adapter publishes a change note after every committed write and delivers
30
+ * foreign notes to subscribers, so sibling tabs/windows invalidate their caches
31
+ * instead of serving stale recalls. The channel opens lazily — on the first
32
+ * publish, or once the database is open for a subscriber — and is `unref()`ed
33
+ * where supported (Node), so a store that is never closed cannot keep the
34
+ * process alive.
35
+ */
36
+
37
+ import {
38
+ FACT_KEY_INDEX,
39
+ FACT_TAG_INDEX,
40
+ MemoryStorageError,
41
+ scopedRecordId,
42
+ type ConsistencyNote,
43
+ type FactStoragePort,
44
+ type MetaCounters,
45
+ type StorageStoreName,
46
+ type StorageTransaction,
47
+ type StoredFactRecord,
48
+ } from './types.js';
49
+
50
+ const FACTS_STORE = 'facts';
51
+ const TOMBSTONES_STORE = 'tombstones';
52
+ const JOURNAL_STORE = 'journal';
53
+ const META_STORE = 'meta';
54
+ const WEIGHTS_STORE = 'weights';
55
+ const DB_VERSION = 3;
56
+
57
+ const ALL_STORES: StorageStoreName[] = [FACTS_STORE, TOMBSTONES_STORE, JOURNAL_STORE, META_STORE];
58
+
59
+ export interface IdbStorageAdapterOptions {
60
+ /** IDBFactory override (tests, non-standard hosts). NOT a durability shim. */
61
+ idbFactory?: IDBFactory;
62
+ /** Identity of this context for cross-context notes. Default: random. */
63
+ contextId?: string;
64
+ /** BroadcastChannel override (tests). Default: global, when present. */
65
+ channelFactory?: (name: string) => BroadcastChannelLike | undefined;
66
+ }
67
+
68
+ /** Minimal BroadcastChannel surface (Node's worker_threads channel satisfies it too). */
69
+ export interface BroadcastChannelLike {
70
+ postMessage(message: unknown): void;
71
+ close(): void;
72
+ onmessage: ((event: { data: unknown }) => void) | null;
73
+ /** Node only: stop this channel from keeping the event loop alive. */
74
+ unref?(): void;
75
+ }
76
+
77
+ interface LegacyFactRecord {
78
+ key: string;
79
+ content: string;
80
+ timestamp: number;
81
+ seq?: number;
82
+ ttlMs?: number;
83
+ type?: StoredFactRecord['type'];
84
+ tags?: string[];
85
+ importance?: number;
86
+ }
87
+
88
+ function reqToPromise<R>(req: IDBRequest<R>): Promise<R> {
89
+ return new Promise<R>((resolve, reject) => {
90
+ req.onsuccess = () => resolve(req.result);
91
+ req.onerror = () => reject(req.error ?? new Error('IndexedDB request failed'));
92
+ });
93
+ }
94
+
95
+ class IdbStorageTransaction implements StorageTransaction {
96
+ constructor(private readonly _tx: IDBTransaction) {}
97
+
98
+ async get<T>(store: StorageStoreName, id: string): Promise<T | undefined> {
99
+ return reqToPromise(this._tx.objectStore(store).get(id) as IDBRequest<T | undefined>);
100
+ }
101
+ async getAll<T>(store: StorageStoreName): Promise<T[]> {
102
+ return reqToPromise(this._tx.objectStore(store).getAll() as IDBRequest<T[]>);
103
+ }
104
+ async put(store: StorageStoreName, value: unknown): Promise<void> {
105
+ await reqToPromise(this._tx.objectStore(store).put(value));
106
+ }
107
+ async delete(store: StorageStoreName, id: string): Promise<void> {
108
+ await reqToPromise(this._tx.objectStore(store).delete(id));
109
+ }
110
+
111
+ async getByIndex<T>(store: StorageStoreName, indexName: string, query: string): Promise<T[] | undefined> {
112
+ const objectStore = this._tx.objectStore(store);
113
+ // Legacy databases opened before the index existed fall through to a scan.
114
+ if (!objectStore.indexNames.contains(indexName)) return undefined;
115
+ return reqToPromise(objectStore.index(indexName).getAll(query) as IDBRequest<T[]>);
116
+ }
117
+ }
118
+
119
+ export class IdbStorageAdapter implements FactStoragePort {
120
+ readonly kind = 'indexedDB' as const;
121
+
122
+ private readonly _idb: IDBFactory | undefined;
123
+ private readonly _contextId: string;
124
+ private readonly _channelFactory: ((name: string) => BroadcastChannelLike | undefined) | undefined;
125
+ private _db: IDBDatabase | null = null;
126
+ private _channel: BroadcastChannelLike | null = null;
127
+ private _channelFailed = false;
128
+ private readonly _listeners = new Set<(note: ConsistencyNote) => void>();
129
+
130
+ constructor(
131
+ private readonly _dbName: string,
132
+ opts: IdbStorageAdapterOptions = {},
133
+ ) {
134
+ this._idb = opts.idbFactory;
135
+ this._contextId = opts.contextId ?? `ctx-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
136
+ this._channelFactory = opts.channelFactory;
137
+ }
138
+
139
+ /** Identity of this context, stamped onto change notes and opIds. */
140
+ get contextId(): string {
141
+ return this._contextId;
142
+ }
143
+
144
+ private _open(): Promise<IDBDatabase> {
145
+ if (this._db) return Promise.resolve(this._db);
146
+
147
+ return new Promise((resolve, reject) => {
148
+ const factory = this._idb ?? (typeof indexedDB !== 'undefined' ? indexedDB : undefined);
149
+ if (!factory) {
150
+ reject(new MemoryStorageError(
151
+ 'STORAGE_UNAVAILABLE',
152
+ 'IndexedDB is not available in this environment. For durable Node.js storage use NodeFileStorageAdapter; fake-indexeddb is a test double, not durable storage.',
153
+ ));
154
+ return;
155
+ }
156
+
157
+ const req = factory.open(this._dbName, DB_VERSION);
158
+
159
+ req.onupgradeneeded = (e) => {
160
+ const db = (e.target as IDBOpenDBRequest).result;
161
+ const tx = (e.target as IDBOpenDBRequest).transaction!;
162
+ migrateSchema(db, tx, e.oldVersion);
163
+ };
164
+
165
+ req.onsuccess = () => {
166
+ this._db = req.result;
167
+ // Subscribers registered before the first open start listening now.
168
+ if (this._listeners.size > 0) this._ensureChannel();
169
+ resolve(req.result);
170
+ };
171
+ /* istanbul ignore next -- IDB open onerror fires only on storage faults; not reproducible with fake-indexeddb */
172
+ req.onerror = () => reject(new MemoryStorageError(
173
+ 'STORAGE_UNAVAILABLE',
174
+ `Failed to open IndexedDB "${this._dbName}": ${req.error?.message ?? 'unknown'}`,
175
+ req.error,
176
+ ));
177
+ });
178
+ }
179
+
180
+ /**
181
+ * One unit of work: runs `fn` inside a single IndexedDB transaction and
182
+ * resolves ONLY on `tx.oncomplete`. If `fn` throws, the transaction is
183
+ * aborted so no partial writes survive; if the browser aborts the
184
+ * transaction (quota, corruption, crash mid-commit) the promise rejects.
185
+ */
186
+ async transact<T>(
187
+ stores: StorageStoreName[],
188
+ mode: 'readonly' | 'readwrite',
189
+ fn: (tx: StorageTransaction) => Promise<T> | T,
190
+ ): Promise<T> {
191
+ const db = await this._open();
192
+ return new Promise<T>((resolve, reject) => {
193
+ let settled = false;
194
+ const fail = (err: unknown) => {
195
+ if (settled) return;
196
+ settled = true;
197
+ reject(err instanceof MemoryStorageError ? err : new MemoryStorageError(
198
+ 'STORAGE_ABORTED',
199
+ `Transaction aborted on "${this._dbName}": ${err instanceof Error ? err.message : String(err)}`,
200
+ err,
201
+ ));
202
+ };
203
+
204
+ let tx: IDBTransaction;
205
+ try {
206
+ tx = db.transaction(stores, mode);
207
+ } catch (err) {
208
+ fail(err);
209
+ return;
210
+ }
211
+
212
+ let result: T;
213
+ tx.oncomplete = () => {
214
+ if (settled) return;
215
+ settled = true;
216
+ resolve(result);
217
+ };
218
+ /* istanbul ignore next -- abort fires only on storage faults or explicit abort; fault path not reproducible with fake-indexeddb */
219
+ tx.onabort = () => fail(tx.error ?? new Error('transaction aborted'));
220
+ /* istanbul ignore next -- request errors surface through the per-request rejections and the abort handler */
221
+ tx.onerror = () => { /* handled via request promises + onabort */ };
222
+
223
+ const stx = new IdbStorageTransaction(tx);
224
+ Promise.resolve()
225
+ .then(() => fn(stx))
226
+ .then((r) => { result = r; })
227
+ .catch((err: unknown) => {
228
+ // The unit of work failed — abort so facts/journal/tombstones
229
+ // never commit partially (M02), then surface the ORIGINAL error.
230
+ try { tx.abort(); } catch { /* already finished */ }
231
+ if (!settled) {
232
+ settled = true;
233
+ reject(err);
234
+ }
235
+ });
236
+ });
237
+ }
238
+
239
+ // ── Cross-context consistency seam (M05) ──────────────────────────────────
240
+
241
+ publish(note: ConsistencyNote): void {
242
+ const channel = this._ensureChannel();
243
+ if (!channel) return;
244
+ try {
245
+ channel.postMessage(note);
246
+ } catch {
247
+ // A dead channel must never fail a committed write.
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Registers `listener` for foreign change notes. The channel itself opens
253
+ * when the database does (or on the first publish), not here: subscribing
254
+ * from a constructor must not open a global channel.
255
+ */
256
+ subscribe(listener: (note: ConsistencyNote) => void): () => void {
257
+ this._listeners.add(listener);
258
+ if (this._db) this._ensureChannel();
259
+ return () => this._listeners.delete(listener);
260
+ }
261
+
262
+ private _ensureChannel(): BroadcastChannelLike | null {
263
+ if (this._channel || this._channelFailed) return this._channel;
264
+ try {
265
+ const factory = this._channelFactory ?? defaultChannelFactory;
266
+ const channel = factory(`bf-memory:${this._dbName}`);
267
+ if (channel) {
268
+ channel.onmessage = (e) => {
269
+ const note = e.data as ConsistencyNote;
270
+ if (!note || note.kind !== 'facts-changed') return;
271
+ if (note.origin === this._contextId) return; // our own write
272
+ for (const l of this._listeners) l(note);
273
+ };
274
+ channel.unref?.();
275
+ this._channel = channel;
276
+ }
277
+ } catch {
278
+ this._channelFailed = true;
279
+ }
280
+ return this._channel;
281
+ }
282
+
283
+ close(): void {
284
+ this._channel?.close();
285
+ this._channel = null;
286
+ this._db?.close();
287
+ this._db = null;
288
+ }
289
+ }
290
+
291
+ function defaultChannelFactory(name: string): BroadcastChannelLike | undefined {
292
+ if (typeof BroadcastChannel === 'undefined') return undefined;
293
+ return new BroadcastChannel(name) as unknown as BroadcastChannelLike;
294
+ }
295
+
296
+ // ── Schema migration ──────────────────────────────────────────────────────────
297
+
298
+ function ensureFactIndexes(store: IDBObjectStore): void {
299
+ if (!store.indexNames.contains(FACT_KEY_INDEX)) {
300
+ store.createIndex(FACT_KEY_INDEX, 'key', { unique: false });
301
+ }
302
+ if (!store.indexNames.contains(FACT_TAG_INDEX)) {
303
+ // One index row per tag, so recallByTag is a lookup rather than a scan.
304
+ store.createIndex(FACT_TAG_INDEX, 'tags', { unique: false, multiEntry: true });
305
+ }
306
+ }
307
+
308
+ function migrateSchema(db: IDBDatabase, tx: IDBTransaction, oldVersion: number): void {
309
+ if (oldVersion < 1) {
310
+ // Fresh database.
311
+ const facts = db.createObjectStore(FACTS_STORE, { keyPath: 'id' });
312
+ ensureFactIndexes(facts);
313
+ db.createObjectStore(WEIGHTS_STORE);
314
+ } else if (oldVersion < 2 && db.objectStoreNames.contains(FACTS_STORE)) {
315
+ // v1 → v2: facts were keyed by the raw key; move them onto the scoped
316
+ // record shape in the unbound partition. Copy out, drop, recreate, refill —
317
+ // all inside the upgrade transaction, so a crash here rolls everything back.
318
+ const legacy: LegacyFactRecord[] = [];
319
+ const cursorReq = tx.objectStore(FACTS_STORE).openCursor();
320
+ cursorReq.onsuccess = () => {
321
+ const cursor = cursorReq.result;
322
+ if (cursor) {
323
+ legacy.push(cursor.value as LegacyFactRecord);
324
+ cursor.continue();
325
+ } else {
326
+ db.deleteObjectStore(FACTS_STORE);
327
+ const store = db.createObjectStore(FACTS_STORE, { keyPath: 'id' });
328
+ ensureFactIndexes(store);
329
+ for (const e of legacy) {
330
+ const record: StoredFactRecord = {
331
+ id : scopedRecordId(undefined, e.key),
332
+ key : e.key,
333
+ scope : {},
334
+ content : e.content,
335
+ timestamp : e.timestamp,
336
+ seq : e.seq ?? 0,
337
+ revision : 1,
338
+ opId : 'migrated:v1',
339
+ ttlMs : e.ttlMs,
340
+ type : e.type,
341
+ tags : e.tags,
342
+ importance : e.importance,
343
+ pendingSync : false,
344
+ };
345
+ store.put(record);
346
+ }
347
+ }
348
+ };
349
+ } else if (!db.objectStoreNames.contains(FACTS_STORE)) {
350
+ const facts = db.createObjectStore(FACTS_STORE, { keyPath: 'id' });
351
+ ensureFactIndexes(facts);
352
+ } else if (oldVersion < 3) {
353
+ // v2 → v3: add key/tag indexes. Do not rewrite records — timestamps,
354
+ // TTLs, tags and content already on disk must survive the upgrade.
355
+ ensureFactIndexes(tx.objectStore(FACTS_STORE));
356
+ }
357
+
358
+ if (!db.objectStoreNames.contains(WEIGHTS_STORE)) {
359
+ db.createObjectStore(WEIGHTS_STORE);
360
+ }
361
+ if (!db.objectStoreNames.contains(TOMBSTONES_STORE)) {
362
+ db.createObjectStore(TOMBSTONES_STORE, { keyPath: 'id' });
363
+ }
364
+ if (!db.objectStoreNames.contains(JOURNAL_STORE)) {
365
+ db.createObjectStore(JOURNAL_STORE, { keyPath: 'opId' });
366
+ }
367
+ if (!db.objectStoreNames.contains(META_STORE)) {
368
+ db.createObjectStore(META_STORE, { keyPath: 'key' });
369
+ }
370
+ }
371
+
372
+ export { ALL_STORES, FACTS_STORE, TOMBSTONES_STORE, JOURNAL_STORE, META_STORE, WEIGHTS_STORE, DB_VERSION };
373
+ export type { MetaCounters };