@seanhogg/builderforce-memory 2026.9.34 → 2026.10.2

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 (97) 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/index.d.ts +5 -5
  5. package/dist/handoff/index.js +5 -5
  6. package/dist/handoff/note.d.ts +1 -1
  7. package/dist/handoff/note.js +1 -1
  8. package/dist/handoff/pack.d.ts +13 -4
  9. package/dist/handoff/pack.d.ts.map +1 -1
  10. package/dist/handoff/pack.js +31 -10
  11. package/dist/handoff/pack.js.map +1 -1
  12. package/dist/memory/MemoryStore.d.ts +120 -181
  13. package/dist/memory/MemoryStore.d.ts.map +1 -1
  14. package/dist/memory/MemoryStore.js +201 -389
  15. package/dist/memory/MemoryStore.js.map +1 -1
  16. package/dist/memory/SemanticRecall.d.ts +64 -0
  17. package/dist/memory/SemanticRecall.d.ts.map +1 -0
  18. package/dist/memory/SemanticRecall.js +188 -0
  19. package/dist/memory/SemanticRecall.js.map +1 -0
  20. package/dist/memory/records.d.ts +26 -0
  21. package/dist/memory/records.d.ts.map +1 -0
  22. package/dist/memory/records.js +71 -0
  23. package/dist/memory/records.js.map +1 -0
  24. package/dist/memory/ttlSweeper.d.ts +12 -0
  25. package/dist/memory/ttlSweeper.d.ts.map +1 -0
  26. package/dist/memory/ttlSweeper.js +28 -0
  27. package/dist/memory/ttlSweeper.js.map +1 -0
  28. package/dist/memory/types.d.ts +142 -0
  29. package/dist/memory/types.d.ts.map +1 -0
  30. package/dist/memory/types.js +6 -0
  31. package/dist/memory/types.js.map +1 -0
  32. package/dist/storage/ScopedFactRepository.d.ts +156 -0
  33. package/dist/storage/ScopedFactRepository.d.ts.map +1 -0
  34. package/dist/storage/ScopedFactRepository.js +278 -0
  35. package/dist/storage/ScopedFactRepository.js.map +1 -0
  36. package/dist/storage/factImport.d.ts +21 -0
  37. package/dist/storage/factImport.d.ts.map +1 -0
  38. package/dist/storage/factImport.js +61 -0
  39. package/dist/storage/factImport.js.map +1 -0
  40. package/dist/storage/factMutations.d.ts +46 -0
  41. package/dist/storage/factMutations.d.ts.map +1 -0
  42. package/dist/storage/factMutations.js +73 -0
  43. package/dist/storage/factMutations.js.map +1 -0
  44. package/dist/storage/factQueries.d.ts +16 -0
  45. package/dist/storage/factQueries.d.ts.map +1 -0
  46. package/dist/storage/factQueries.js +36 -0
  47. package/dist/storage/factQueries.js.map +1 -0
  48. package/dist/storage/factRetention.d.ts +30 -0
  49. package/dist/storage/factRetention.d.ts.map +1 -0
  50. package/dist/storage/factRetention.js +65 -0
  51. package/dist/storage/factRetention.js.map +1 -0
  52. package/dist/storage/fileLock.d.ts +56 -0
  53. package/dist/storage/fileLock.d.ts.map +1 -0
  54. package/dist/storage/fileLock.js +138 -0
  55. package/dist/storage/fileLock.js.map +1 -0
  56. package/dist/storage/fileTransaction.d.ts +33 -0
  57. package/dist/storage/fileTransaction.d.ts.map +1 -0
  58. package/dist/storage/fileTransaction.js +108 -0
  59. package/dist/storage/fileTransaction.js.map +1 -0
  60. package/dist/storage/idbAdapter.d.ts +95 -0
  61. package/dist/storage/idbAdapter.d.ts.map +1 -0
  62. package/dist/storage/idbAdapter.js +311 -0
  63. package/dist/storage/idbAdapter.js.map +1 -0
  64. package/dist/storage/nodeFileAdapter.d.ts +127 -0
  65. package/dist/storage/nodeFileAdapter.d.ts.map +1 -0
  66. package/dist/storage/nodeFileAdapter.js +275 -0
  67. package/dist/storage/nodeFileAdapter.js.map +1 -0
  68. package/dist/storage/syncJournal.d.ts +19 -0
  69. package/dist/storage/syncJournal.d.ts.map +1 -0
  70. package/dist/storage/syncJournal.js +46 -0
  71. package/dist/storage/syncJournal.js.map +1 -0
  72. package/dist/storage/types.d.ts +207 -0
  73. package/dist/storage/types.d.ts.map +1 -0
  74. package/dist/storage/types.js +90 -0
  75. package/dist/storage/types.js.map +1 -0
  76. package/package.json +3 -3
  77. package/src/errors/SSMError.ts +1 -0
  78. package/src/handoff/handoff.test.ts +52 -2
  79. package/src/handoff/index.ts +5 -5
  80. package/src/handoff/note.ts +1 -1
  81. package/src/handoff/pack.ts +32 -9
  82. package/src/memory/MemoryStore.ts +245 -525
  83. package/src/memory/SemanticRecall.ts +204 -0
  84. package/src/memory/records.ts +79 -0
  85. package/src/memory/ttlSweeper.ts +29 -0
  86. package/src/memory/types.ts +149 -0
  87. package/src/storage/ScopedFactRepository.ts +373 -0
  88. package/src/storage/factImport.ts +73 -0
  89. package/src/storage/factMutations.ts +106 -0
  90. package/src/storage/factQueries.ts +41 -0
  91. package/src/storage/factRetention.ts +82 -0
  92. package/src/storage/fileLock.ts +162 -0
  93. package/src/storage/fileTransaction.ts +116 -0
  94. package/src/storage/idbAdapter.ts +373 -0
  95. package/src/storage/nodeFileAdapter.ts +331 -0
  96. package/src/storage/syncJournal.ts +47 -0
  97. package/src/storage/types.ts +262 -0
@@ -0,0 +1,373 @@
1
+ /**
2
+ * storage/ScopedFactRepository.ts – Scoped, journaled, revision-tracked fact
3
+ * repository over a {@link FactStoragePort}.
4
+ *
5
+ * This is the unit-of-work owner the offline-memory scope requires:
6
+ *
7
+ * - Scope partitions (M03): every operation takes an explicit
8
+ * {@link MemoryScope}; records are physically partitioned by scoped record
9
+ * id and logically filtered by partition, so no code path can recall across
10
+ * account/tenant/project/repository/chat boundaries. The unbound scope is
11
+ * an explicit partition for personal, never-connected memory.
12
+ * - Atomic commits (M02): fact mutation + provenance + tombstone + sync
13
+ * journal + durable counters commit in ONE storage transaction.
14
+ * - Durable identity (M04): every record carries a durable `revision` (the
15
+ * store generation that wrote it) and `opId`; deletes write tombstones;
16
+ * writes may be compare-and-set against a base revision, so concurrent
17
+ * evidence conflicts surface as explicit conflicts instead of unconditional
18
+ * last-write-wins.
19
+ * - Sync-aware retention (M06): facts with `pendingSync` are never evicted by
20
+ * caps and are never dropped by import; TTL expiry is an intentional
21
+ * deletion and writes a tombstone when sync is enabled.
22
+ *
23
+ * The shared write rules live in `factMutations.ts`, retention in
24
+ * `factRetention.ts`, import in `factImport.ts`, sync acknowledgement in
25
+ * `syncJournal.ts`.
26
+ */
27
+
28
+ import { factsInScope, indexedFacts } from './factQueries.js';
29
+ import {
30
+ WRITE_STORES,
31
+ bumpOp,
32
+ clearTombstone,
33
+ deleteWithTombstone,
34
+ journal,
35
+ readCounters,
36
+ writeCounters,
37
+ type MutationContext,
38
+ } from './factMutations.js';
39
+ import { importInto, type ImportResult } from './factImport.js';
40
+ import * as retention from './factRetention.js';
41
+ import * as syncJournal from './syncJournal.js';
42
+ import {
43
+ FACT_KEY_INDEX,
44
+ FACT_TAG_INDEX,
45
+ MemoryStorageError,
46
+ scopedRecordId,
47
+ scopePartitionKey,
48
+ type ConsistencyNote,
49
+ type FactProvenance,
50
+ type FactStoragePort,
51
+ type JournalEntry,
52
+ type MemoryScope,
53
+ type StoredFactRecord,
54
+ type TombstoneRecord,
55
+ } from './types.js';
56
+
57
+ export interface ScopedFactRepositoryOptions {
58
+ /**
59
+ * When true, mutations are journaled (unsynced) until {@link markSynced}
60
+ * acknowledges and prunes them, deletes write tombstones, and cap eviction
61
+ * skips unsynced facts. When false (default — no sync target configured)
62
+ * nothing is journaled and retention behaves exactly like the legacy store.
63
+ */
64
+ syncEnabled?: boolean;
65
+ /** Identity of this repository context for opIds and change notes. */
66
+ contextId?: string;
67
+ /** Injectable clock (ms). Default: Date.now. */
68
+ now?: () => number;
69
+ }
70
+
71
+ export interface RememberRecordInput {
72
+ content: string;
73
+ /** Explicit source time (import). Default: now. */
74
+ timestamp?: number;
75
+ /** Explicit source sequence (import). Default: next durable sequence. */
76
+ seq?: number;
77
+ ttlMs?: number;
78
+ type?: StoredFactRecord['type'];
79
+ tags?: string[];
80
+ importance?: number;
81
+ provenance?: FactProvenance;
82
+ /**
83
+ * Compare-and-set base: `null` asserts the record does not exist; a number
84
+ * asserts the current revision; undefined is a blind write (legacy).
85
+ */
86
+ expectedRevision?: number | null;
87
+ /** Explicit op identity (import/replay). Default: generated from the durable op counter. */
88
+ opId?: string;
89
+ }
90
+
91
+ export interface WriteOutcome {
92
+ record: StoredFactRecord;
93
+ /** True when an existing tombstone for this key was cleared by the write. */
94
+ resurrected: boolean;
95
+ }
96
+
97
+ export type { ImportResult };
98
+
99
+ export class ScopedFactRepository {
100
+ private readonly _storage: FactStoragePort;
101
+ private readonly _ctx: MutationContext;
102
+
103
+ constructor(storage: FactStoragePort, opts: ScopedFactRepositoryOptions = {}) {
104
+ this._storage = storage;
105
+ this._ctx = {
106
+ syncEnabled: opts.syncEnabled ?? false,
107
+ contextId : opts.contextId ?? 'repo',
108
+ now : opts.now ?? (() => Date.now()),
109
+ };
110
+ }
111
+
112
+ get storage(): FactStoragePort {
113
+ return this._storage;
114
+ }
115
+
116
+ get contextId(): string {
117
+ return this._ctx.contextId;
118
+ }
119
+
120
+ get syncEnabled(): boolean {
121
+ return this._ctx.syncEnabled;
122
+ }
123
+
124
+ // ── Reads ─────────────────────────────────────────────────────────────────
125
+
126
+ /** Current durable store generation (0 on a fresh store). */
127
+ async revision(): Promise<number> {
128
+ return this._storage.transact(['meta'], 'readonly', async (tx) => (await readCounters(tx)).revision);
129
+ }
130
+
131
+ /**
132
+ * Fetches one record by (scope, key), or undefined. Tombstoned = absent.
133
+ * Uses the `byKey` index when the adapter has it; falls back to the primary
134
+ * key (itself an index on the scoped id) only when that index is missing.
135
+ */
136
+ async get(scope: MemoryScope | undefined, key: string): Promise<StoredFactRecord | undefined> {
137
+ const id = scopedRecordId(scope, key);
138
+ return this._storage.transact(['facts'], 'readonly', async (tx) => {
139
+ const indexed = await indexedFacts(tx, FACT_KEY_INDEX, key);
140
+ if (indexed !== undefined) return indexed.find((r) => r.id === id);
141
+ return tx.get<StoredFactRecord>('facts', id);
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Facts in `scope` carrying `tag`. Uses the multi-entry `byTag` index when
147
+ * present. A full scan runs only when the index is missing (legacy schema).
148
+ */
149
+ async getByTag(scope: MemoryScope | undefined, tag: string): Promise<StoredFactRecord[]> {
150
+ const partition = scopePartitionKey(scope);
151
+ return this._storage.transact(['facts'], 'readonly', async (tx) => {
152
+ const indexed = await indexedFacts(tx, FACT_TAG_INDEX, tag);
153
+ if (indexed !== undefined) {
154
+ return indexed.filter((r) => scopePartitionKey(r.scope) === partition);
155
+ }
156
+ return (await factsInScope(tx, scope)).filter((r) => r.tags?.includes(tag) ?? false);
157
+ });
158
+ }
159
+
160
+ /**
161
+ * All records in exactly this scope partition — never a wider or narrower
162
+ * set. Expired records are included; TTL filtering is the caller's policy.
163
+ */
164
+ async getAll(scope: MemoryScope | undefined): Promise<StoredFactRecord[]> {
165
+ return this._storage.transact(['facts'], 'readonly', (tx) => factsInScope(tx, scope));
166
+ }
167
+
168
+ /** Tombstones, optionally narrowed to one scope partition. */
169
+ async tombstones(scope?: MemoryScope): Promise<TombstoneRecord[]> {
170
+ return this._storage.transact(['tombstones'], 'readonly', async (tx) => {
171
+ const all = await tx.getAll<TombstoneRecord>('tombstones');
172
+ if (scope === undefined) return all;
173
+ const partition = scopePartitionKey(scope);
174
+ return all.filter((t) => scopePartitionKey(t.scope) === partition);
175
+ });
176
+ }
177
+
178
+ /** Journal entries not yet acknowledged by sync — the pending operation set. */
179
+ async pendingOps(): Promise<JournalEntry[]> {
180
+ return syncJournal.pendingOps(this._storage);
181
+ }
182
+
183
+ // ── Writes (unit of work: fact + journal + counters in one transaction) ───
184
+
185
+ /**
186
+ * Stores or overwrites a fact in `scope`. With `expectedRevision` set this
187
+ * is a compare-and-set: a mismatch throws a STORAGE_CONFLICT error carrying
188
+ * the current record, so a concurrent writer's evidence is never silently
189
+ * overwritten (M04/M05).
190
+ */
191
+ async remember(scope: MemoryScope | undefined, key: string, input: RememberRecordInput): Promise<WriteOutcome> {
192
+ const id = scopedRecordId(scope, key);
193
+ const outcome = await this._storage.transact(WRITE_STORES, 'readwrite', async (tx): Promise<WriteOutcome> => {
194
+ const counters = await readCounters(tx);
195
+ const existing = await tx.get<StoredFactRecord>('facts', id);
196
+ if (input.expectedRevision !== undefined) {
197
+ const current = existing?.revision ?? null;
198
+ if (current !== input.expectedRevision) {
199
+ throw new MemoryStorageError(
200
+ 'STORAGE_CONFLICT',
201
+ `Conflict writing "${key}": expected revision ${String(input.expectedRevision)}, found ${String(current)}. Re-read and reconcile instead of overwriting.`,
202
+ );
203
+ }
204
+ }
205
+ const bumped = bumpOp(counters, this._ctx);
206
+ const opId = input.opId ?? bumped.opId;
207
+ const next = { ...bumped.counters, seq: Math.max(counters.seq + 1, input.seq ?? 0) };
208
+ const record: StoredFactRecord = {
209
+ id,
210
+ key,
211
+ scope : { ...(scope ?? {}) },
212
+ content : input.content,
213
+ timestamp : input.timestamp ?? this._ctx.now(),
214
+ seq : next.seq,
215
+ revision : next.revision,
216
+ opId,
217
+ ttlMs : input.ttlMs,
218
+ type : input.type,
219
+ tags : input.tags,
220
+ importance : input.importance,
221
+ provenance : input.provenance,
222
+ pendingSync: this._ctx.syncEnabled,
223
+ };
224
+ await tx.put('facts', record);
225
+ const resurrected = await clearTombstone(tx, id);
226
+ await journal(tx, this._ctx, {
227
+ opId,
228
+ kind : 'remember',
229
+ id,
230
+ key,
231
+ scope : record.scope,
232
+ revision : next.revision,
233
+ baseRevision : input.expectedRevision ?? undefined,
234
+ timestamp : record.timestamp,
235
+ });
236
+ await writeCounters(tx, next);
237
+ return { record, resurrected };
238
+ });
239
+ this._publish(scope, outcome.record.revision);
240
+ return outcome;
241
+ }
242
+
243
+ /**
244
+ * Deletes a fact. With sync enabled the delete is a durable tombstone +
245
+ * journal entry committed in the same transaction, so the deletion
246
+ * propagates and a stale replica cannot resurrect the fact (M04). Without
247
+ * sync this is the legacy hard delete. Deleting an absent key is a no-op.
248
+ */
249
+ async forget(scope: MemoryScope | undefined, key: string, opts: { expectedRevision?: number } = {}): Promise<boolean> {
250
+ const id = scopedRecordId(scope, key);
251
+ const revision = await this._storage.transact(WRITE_STORES, 'readwrite', async (tx): Promise<number> => {
252
+ const existing = await tx.get<StoredFactRecord>('facts', id);
253
+ if (!existing) return 0;
254
+ if (opts.expectedRevision !== undefined && existing.revision !== opts.expectedRevision) {
255
+ throw new MemoryStorageError(
256
+ 'STORAGE_CONFLICT',
257
+ `Conflict deleting "${key}": expected revision ${opts.expectedRevision}, found ${existing.revision}.`,
258
+ );
259
+ }
260
+ const next = await deleteWithTombstone(tx, this._ctx, existing, await readCounters(tx), opts.expectedRevision);
261
+ await writeCounters(tx, next);
262
+ return next.revision;
263
+ });
264
+ if (revision > 0) this._publish(scope, revision);
265
+ return revision > 0;
266
+ }
267
+
268
+ /** Removes every fact in the scope. Tombstones each deletion when sync is enabled. */
269
+ async clear(scope: MemoryScope | undefined): Promise<number> {
270
+ const { removed, revision } = await this._storage.transact(WRITE_STORES, 'readwrite', async (tx) => {
271
+ const mine = await factsInScope(tx, scope);
272
+ if (mine.length === 0) return { removed: 0, revision: 0 };
273
+ let counters = await readCounters(tx);
274
+ for (const record of mine) counters = await deleteWithTombstone(tx, this._ctx, record, counters);
275
+ await writeCounters(tx, counters);
276
+ return { removed: mine.length, revision: counters.revision };
277
+ });
278
+ if (removed > 0) this._publish(scope, revision);
279
+ return removed;
280
+ }
281
+
282
+ // ── Import / export (M03/M06: scope + source time + provenance preserved) ──
283
+
284
+ /**
285
+ * Imports records INTO `scope`, preserving each record's source timestamp,
286
+ * sequence, tags, importance, TTL and provenance. `importedFrom` is stamped
287
+ * onto the provenance so the snapshot origin survives. 'merge' keeps the
288
+ * record with the newer source timestamp; 'overwrite' always writes.
289
+ * Imported facts are pendingSync when sync is enabled — they are new to
290
+ * THIS replica even if another store already had them.
291
+ */
292
+ async importRecords(
293
+ scope: MemoryScope | undefined,
294
+ records: StoredFactRecord[],
295
+ strategy: 'merge' | 'overwrite',
296
+ importedFrom?: string,
297
+ ): Promise<ImportResult> {
298
+ const { written, skipped, revision } = await importInto(this._storage, this._ctx, scope, records, strategy, importedFrom);
299
+ if (written > 0) this._publish(scope, revision);
300
+ return { written, skipped };
301
+ }
302
+
303
+ /** Exports every record in the scope, with scope/revision/provenance intact (M03). */
304
+ async exportRecords(scope: MemoryScope | undefined): Promise<StoredFactRecord[]> {
305
+ return this.getAll(scope);
306
+ }
307
+
308
+ // ── Sync acknowledgement (M04/M06) ────────────────────────────────────────
309
+
310
+ /**
311
+ * Acknowledges synced operations: their journal entries are pruned and the
312
+ * facts/tombstones they produced clear `pendingSync`, making cap eviction
313
+ * eligible again. Only after this may retention touch those records.
314
+ */
315
+ async markSynced(opIds: string[]): Promise<void> {
316
+ await syncJournal.markSynced(this._storage, opIds);
317
+ }
318
+
319
+ // ── Retention (M06) ───────────────────────────────────────────────────────
320
+
321
+ /**
322
+ * Local-only cap eviction: removes the lowest-value records past
323
+ * `maxEntries`, EXCLUDING records with unsynced operations — pending
324
+ * knowledge is never evicted to satisfy a cache cap. Eviction of synced
325
+ * records writes no tombstone and no journal entry by design: it is local
326
+ * cache management, not a deletion to propagate. Returns the evicted keys.
327
+ */
328
+ async evictForCap(
329
+ scope: MemoryScope | undefined,
330
+ maxEntries: number,
331
+ isExpired: (record: StoredFactRecord) => boolean,
332
+ ): Promise<string[]> {
333
+ return retention.evictForCap(this._storage, scope, maxEntries, isExpired);
334
+ }
335
+
336
+ /**
337
+ * Intentional retention: removes expired records. With sync enabled each
338
+ * expiry is a scoped deletion — tombstone + journal in one transaction — so
339
+ * the expiry propagates instead of resurrecting from a replica. Returns the
340
+ * number of records purged.
341
+ */
342
+ async purgeExpired(scope: MemoryScope | undefined, isExpired: (record: StoredFactRecord) => boolean): Promise<number> {
343
+ const { purged, revision } = await retention.purgeExpired(this._storage, this._ctx, scope, isExpired);
344
+ if (purged > 0) this._publish(scope, revision);
345
+ return purged;
346
+ }
347
+
348
+ // ── Cross-context consistency seam (M05) ──────────────────────────────────
349
+
350
+ /**
351
+ * Subscribes to change notes committed by OTHER contexts sharing this
352
+ * storage (other tabs, extension windows, MCP processes). In-process
353
+ * serialization alone cannot be the distributed consistency boundary; this
354
+ * is the invalidation half of the seam, and compare-and-set is the
355
+ * write-conflict half.
356
+ */
357
+ subscribe(listener: (note: ConsistencyNote) => void): () => void {
358
+ if (!this._storage.subscribe) return () => undefined;
359
+ return this._storage.subscribe((note) => {
360
+ if (note.origin === this._ctx.contextId) return;
361
+ listener(note);
362
+ });
363
+ }
364
+
365
+ private _publish(scope: MemoryScope | undefined, revision: number): void {
366
+ this._storage.publish?.({
367
+ kind : 'facts-changed',
368
+ scope : scope ? { ...scope } : undefined,
369
+ revision,
370
+ origin : this._ctx.contextId,
371
+ });
372
+ }
373
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * storage/factImport.ts – Import INTO a scope (M03/M06): source timestamp,
3
+ * sequence, tags, importance, TTL and provenance survive; `importedFrom` is
4
+ * stamped onto the provenance; imported facts are `pendingSync` when sync is
5
+ * enabled (new to THIS replica even if another store already had them).
6
+ */
7
+
8
+ import { WRITE_STORES, bumpOp, clearTombstone, journal, readCounters, writeCounters, type MutationContext } from './factMutations.js';
9
+ import { scopedRecordId, type FactStoragePort, type MemoryScope, type StoredFactRecord } from './types.js';
10
+
11
+ export interface ImportResult {
12
+ written: number;
13
+ skipped: number;
14
+ }
15
+
16
+ /**
17
+ * Writes `records` into `scope` in one transaction. 'merge' keeps the record
18
+ * with the newer source timestamp; 'overwrite' always writes. Returns the
19
+ * counts plus the store revision after the import (0 when nothing was written).
20
+ */
21
+ export async function importInto(
22
+ storage: FactStoragePort,
23
+ ctx: MutationContext,
24
+ scope: MemoryScope | undefined,
25
+ records: StoredFactRecord[],
26
+ strategy: 'merge' | 'overwrite',
27
+ importedFrom?: string,
28
+ ): Promise<ImportResult & { revision: number }> {
29
+ return storage.transact(WRITE_STORES, 'readwrite', async (tx) => {
30
+ let counters = await readCounters(tx);
31
+ let written = 0;
32
+ let skipped = 0;
33
+ for (const incoming of records) {
34
+ const id = scopedRecordId(scope, incoming.key);
35
+ const existing = await tx.get<StoredFactRecord>('facts', id);
36
+ if (strategy === 'merge' && existing && existing.timestamp >= incoming.timestamp) {
37
+ skipped++;
38
+ continue;
39
+ }
40
+ const bumped = bumpOp(counters, ctx);
41
+ counters = { ...bumped.counters, seq: Math.max(counters.seq + 1, incoming.seq ?? 0) };
42
+ const record: StoredFactRecord = {
43
+ ...incoming,
44
+ id,
45
+ key : incoming.key,
46
+ scope : { ...(scope ?? {}) },
47
+ seq : incoming.seq ?? counters.seq,
48
+ revision : counters.revision,
49
+ opId : bumped.opId,
50
+ provenance : {
51
+ ...incoming.provenance,
52
+ ...(importedFrom ? { importedFrom } : {}),
53
+ originalTimestamp: incoming.provenance?.originalTimestamp ?? incoming.timestamp,
54
+ },
55
+ pendingSync : ctx.syncEnabled,
56
+ };
57
+ await tx.put('facts', record);
58
+ await clearTombstone(tx, id);
59
+ await journal(tx, ctx, {
60
+ opId : bumped.opId,
61
+ kind : 'remember',
62
+ id,
63
+ key : incoming.key,
64
+ scope : record.scope,
65
+ revision : counters.revision,
66
+ timestamp: record.timestamp,
67
+ });
68
+ written++;
69
+ }
70
+ if (written > 0) await writeCounters(tx, counters);
71
+ return { written, skipped, revision: written > 0 ? counters.revision : 0 };
72
+ });
73
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * storage/factMutations.ts – The write primitives every repository mutation
3
+ * shares, so the counter bump / tombstone / journal rules live in ONE place:
4
+ *
5
+ * - {@link bumpOp}: next durable revision + operation id.
6
+ * - {@link journal}: append a sync-journal entry — ONLY when sync is enabled.
7
+ * Without a sync target there is nothing to acknowledge, so writing rows
8
+ * already marked synced just grew the journal forever.
9
+ * - {@link deleteWithTombstone}: delete a fact, tombstone it when sync is on,
10
+ * journal the delete — one call for forget, clear and TTL purge.
11
+ * - {@link clearTombstone}: a write resurrects a key (remember, import).
12
+ */
13
+
14
+ import type {
15
+ JournalEntry,
16
+ MemoryScope,
17
+ MetaCounters,
18
+ StorageStoreName,
19
+ StorageTransaction,
20
+ TombstoneRecord,
21
+ } from './types.js';
22
+
23
+ export const COUNTERS_ID = 'counters';
24
+
25
+ /** The stores a mutating unit of work spans. */
26
+ export const WRITE_STORES: StorageStoreName[] = ['facts', 'tombstones', 'journal', 'meta'];
27
+
28
+ /** Who is writing, and under which retention rules. */
29
+ export interface MutationContext {
30
+ readonly syncEnabled: boolean;
31
+ readonly contextId: string;
32
+ readonly now: () => number;
33
+ }
34
+
35
+ type CounterReader = Pick<StorageTransaction, 'get'>;
36
+
37
+ export async function readCounters(tx: CounterReader): Promise<MetaCounters> {
38
+ const existing = await tx.get<MetaCounters>('meta', COUNTERS_ID);
39
+ return existing ?? { key: COUNTERS_ID, seq: 0, revision: 0, opSeq: 0 };
40
+ }
41
+
42
+ export async function writeCounters(tx: StorageTransaction, counters: MetaCounters): Promise<void> {
43
+ await tx.put('meta', { ...counters, key: COUNTERS_ID } satisfies MetaCounters);
44
+ }
45
+
46
+ /** The next revision + operation identity after `counters`. */
47
+ export function bumpOp(counters: MetaCounters, ctx: MutationContext): { counters: MetaCounters; opId: string } {
48
+ const next = { ...counters, revision: counters.revision + 1, opSeq: counters.opSeq + 1 };
49
+ return { counters: next, opId: `${ctx.contextId}:${next.opSeq}` };
50
+ }
51
+
52
+ /** Appends an unsynced journal entry when sync is enabled; a no-op otherwise. */
53
+ export async function journal(
54
+ tx: StorageTransaction,
55
+ ctx: MutationContext,
56
+ entry: Omit<JournalEntry, 'synced'>,
57
+ ): Promise<void> {
58
+ if (!ctx.syncEnabled) return;
59
+ await tx.put('journal', { ...entry, synced: false } satisfies JournalEntry);
60
+ }
61
+
62
+ /** Clears any tombstone for `id`; true when one existed (the write resurrected the key). */
63
+ export async function clearTombstone(tx: StorageTransaction, id: string): Promise<boolean> {
64
+ const tombstone = await tx.get<TombstoneRecord>('tombstones', id);
65
+ if (tombstone) await tx.delete('tombstones', id);
66
+ return !!tombstone;
67
+ }
68
+
69
+ /**
70
+ * Deletes one fact as a durable, propagating deletion: bump the counters,
71
+ * delete, tombstone (sync on), journal (sync on). Returns the bumped counters;
72
+ * the caller persists them once per transaction with {@link writeCounters}.
73
+ */
74
+ export async function deleteWithTombstone(
75
+ tx: StorageTransaction,
76
+ ctx: MutationContext,
77
+ record: { id: string; key: string; scope: MemoryScope },
78
+ counters: MetaCounters,
79
+ baseRevision?: number,
80
+ ): Promise<MetaCounters> {
81
+ const { counters: next, opId } = bumpOp(counters, ctx);
82
+ const at = ctx.now();
83
+ await tx.delete('facts', record.id);
84
+ if (ctx.syncEnabled) {
85
+ await tx.put('tombstones', {
86
+ id : record.id,
87
+ key : record.key,
88
+ scope : record.scope,
89
+ deletedAt : at,
90
+ revision : next.revision,
91
+ opId,
92
+ pendingSync: true,
93
+ } satisfies TombstoneRecord);
94
+ }
95
+ await journal(tx, ctx, {
96
+ opId,
97
+ kind : 'forget',
98
+ id : record.id,
99
+ key : record.key,
100
+ scope : record.scope,
101
+ revision : next.revision,
102
+ baseRevision,
103
+ timestamp: at,
104
+ });
105
+ return next;
106
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * storage/factQueries.ts – Read helpers shared by the repository and its
3
+ * retention policy: partition filtering and index lookups with the
4
+ * "missing index ⇒ fall back, empty hit ⇒ real miss" contract.
5
+ */
6
+
7
+ import { scopePartitionKey, type MemoryScope, type StorageTransaction, type StoredFactRecord } from './types.js';
8
+
9
+ /** Every fact in exactly `scope`'s partition (never wider, never narrower). */
10
+ export async function factsInScope(tx: StorageTransaction, scope: MemoryScope | undefined): Promise<StoredFactRecord[]> {
11
+ const partition = scopePartitionKey(scope);
12
+ const all = await tx.getAll<StoredFactRecord>('facts');
13
+ return all.filter((r) => scopePartitionKey(r.scope) === partition);
14
+ }
15
+
16
+ function dedupeById(records: StoredFactRecord[]): StoredFactRecord[] {
17
+ const seen = new Set<string>();
18
+ const out: StoredFactRecord[] = [];
19
+ for (const record of records) {
20
+ if (seen.has(record.id)) continue;
21
+ seen.add(record.id);
22
+ out.push(record);
23
+ }
24
+ return out;
25
+ }
26
+
27
+ /**
28
+ * Index lookup. `undefined` means the named index is absent and the caller
29
+ * must fall back. An empty array is a real miss — do not scan after it.
30
+ * Multi-entry tag indexes can return the same record once per matching tag
31
+ * occurrence, so hits are de-duplicated by id.
32
+ */
33
+ export async function indexedFacts(
34
+ tx: StorageTransaction,
35
+ indexName: string,
36
+ query: string,
37
+ ): Promise<StoredFactRecord[] | undefined> {
38
+ if (!tx.getByIndex) return undefined;
39
+ const hits = await tx.getByIndex<StoredFactRecord>('facts', indexName, query);
40
+ return hits === undefined ? undefined : dedupeById(hits);
41
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * storage/factRetention.ts – Retention policy over the fact store (M06):
3
+ * local cap eviction and intentional TTL purge.
4
+ *
5
+ * - Cap eviction is cache management: synced records only, no tombstone, no
6
+ * journal. It counts in a READONLY transaction first and opens a read-write
7
+ * one only when the scope is actually over the cap — on the file adapter
8
+ * every read-write transaction rewrites and fsyncs the whole document, so
9
+ * an under-cap `remember` must not pay for a second one.
10
+ * - TTL purge is a deletion: each expiry goes through
11
+ * {@link deleteWithTombstone} so it propagates when sync is enabled.
12
+ */
13
+
14
+ import { factsInScope } from './factQueries.js';
15
+ import {
16
+ WRITE_STORES,
17
+ deleteWithTombstone,
18
+ readCounters,
19
+ writeCounters,
20
+ type MutationContext,
21
+ } from './factMutations.js';
22
+ import type { FactStoragePort, MemoryScope, StoredFactRecord } from './types.js';
23
+
24
+ type IsExpired = (record: StoredFactRecord) => boolean;
25
+
26
+ /** Lowest value first: expired, then least important, then oldest. */
27
+ function evictionOrder(isExpired: IsExpired): (a: StoredFactRecord, b: StoredFactRecord) => number {
28
+ const rank = (e: StoredFactRecord): [number, number, number, number] =>
29
+ [isExpired(e) ? 0 : 1, e.importance ?? 0.5, e.timestamp, e.seq];
30
+ return (a, b) => {
31
+ const ra = rank(a), rb = rank(b);
32
+ for (let i = 0; i < ra.length; i++) {
33
+ if (ra[i] !== rb[i]) return ra[i]! - rb[i]!;
34
+ }
35
+ return 0;
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Removes the lowest-value SYNCED records in `scope` past `maxEntries`.
41
+ * Returns the evicted keys (empty when under the cap — no write happens).
42
+ */
43
+ export async function evictForCap(
44
+ storage: FactStoragePort,
45
+ scope: MemoryScope | undefined,
46
+ maxEntries: number,
47
+ isExpired: IsExpired,
48
+ ): Promise<string[]> {
49
+ const count = await storage.transact(['facts'], 'readonly', async (tx) => (await factsInScope(tx, scope)).length);
50
+ if (count <= maxEntries) return [];
51
+ return storage.transact(['facts'], 'readwrite', async (tx) => {
52
+ // Re-read inside the write: another writer may have changed the set.
53
+ const mine = await factsInScope(tx, scope);
54
+ if (mine.length <= maxEntries) return [];
55
+ const victims = mine
56
+ .filter((r) => !r.pendingSync)
57
+ .sort(evictionOrder(isExpired))
58
+ .slice(0, Math.max(0, mine.length - maxEntries));
59
+ for (const v of victims) await tx.delete('facts', v.id);
60
+ return victims.map((v) => v.key);
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Deletes every expired record in `scope` as a propagating deletion.
66
+ * Returns how many were purged and the store revision after the purge.
67
+ */
68
+ export async function purgeExpired(
69
+ storage: FactStoragePort,
70
+ ctx: MutationContext,
71
+ scope: MemoryScope | undefined,
72
+ isExpired: IsExpired,
73
+ ): Promise<{ purged: number; revision: number }> {
74
+ return storage.transact(WRITE_STORES, 'readwrite', async (tx) => {
75
+ const expired = (await factsInScope(tx, scope)).filter(isExpired);
76
+ if (expired.length === 0) return { purged: 0, revision: 0 };
77
+ let counters = await readCounters(tx);
78
+ for (const record of expired) counters = await deleteWithTombstone(tx, ctx, record, counters);
79
+ await writeCounters(tx, counters);
80
+ return { purged: expired.length, revision: counters.revision };
81
+ });
82
+ }