@fgv/ts-agent-memory 5.1.0-36 → 5.1.0-39
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/.rush/temp/{f6a88bfdd66517ccb98c2c7ae1be6e6fe9e15d38.tar.log → b82cf6bdece20481260e6bab946179eeec9d7b46.tar.log} +116 -2
- package/.rush/temp/chunked-rush-logs/ts-agent-memory.build.chunks.jsonl +4 -4
- package/.rush/temp/operation/build/all.log +4 -4
- package/.rush/temp/operation/build/log-chunks.jsonl +4 -4
- package/.rush/temp/operation/build/state.json +1 -1
- package/.rush/temp/shrinkwrap-deps.json +222 -221
- package/config/jest.config.json +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/packlets/ingest/cycleGuard.js +111 -0
- package/dist/packlets/ingest/cycleGuard.js.map +1 -0
- package/dist/packlets/ingest/hostStages.js +6 -0
- package/dist/packlets/ingest/hostStages.js.map +1 -0
- package/dist/packlets/ingest/index.js +9 -0
- package/dist/packlets/ingest/index.js.map +1 -0
- package/dist/packlets/ingest/model.js +6 -0
- package/dist/packlets/ingest/model.js.map +1 -0
- package/dist/packlets/ingest/orchestrator.js +438 -0
- package/dist/packlets/ingest/orchestrator.js.map +1 -0
- package/dist/packlets/retrieve/index.js +1 -0
- package/dist/packlets/retrieve/index.js.map +1 -1
- package/dist/packlets/retrieve/temporalRetrievers.js +172 -0
- package/dist/packlets/retrieve/temporalRetrievers.js.map +1 -0
- package/dist/packlets/store/fileTreeMemoryStore.js +323 -41
- package/dist/packlets/store/fileTreeMemoryStore.js.map +1 -1
- package/dist/packlets/tools/index.js +6 -0
- package/dist/packlets/tools/index.js.map +1 -0
- package/dist/packlets/tools/memoryTools.js +337 -0
- package/dist/packlets/tools/memoryTools.js.map +1 -0
- package/dist/packlets/types/identityCodec.js +115 -0
- package/dist/packlets/types/identityCodec.js.map +1 -1
- package/dist/packlets/types/index.js +1 -0
- package/dist/packlets/types/index.js.map +1 -1
- package/dist/packlets/types/temporal.js +85 -0
- package/dist/packlets/types/temporal.js.map +1 -0
- package/dist/packlets/types/writePolicy.js +96 -0
- package/dist/packlets/types/writePolicy.js.map +1 -1
- package/dist/test/unit/converters/antagonistRoundTrip.test.js +95 -0
- package/dist/test/unit/converters/antagonistRoundTrip.test.js.map +1 -0
- package/dist/test/unit/ingest/antagonistCycleAndParity.test.js +273 -0
- package/dist/test/unit/ingest/antagonistCycleAndParity.test.js.map +1 -0
- package/dist/test/unit/ingest/cycleGuard.test.js +54 -0
- package/dist/test/unit/ingest/cycleGuard.test.js.map +1 -0
- package/dist/test/unit/ingest/orchestrator.test.js +913 -0
- package/dist/test/unit/ingest/orchestrator.test.js.map +1 -0
- package/dist/test/unit/retrieve/temporalRetrievers.test.js +182 -0
- package/dist/test/unit/retrieve/temporalRetrievers.test.js.map +1 -0
- package/dist/test/unit/store/antagonistTemporalBoundary.test.js +120 -0
- package/dist/test/unit/store/antagonistTemporalBoundary.test.js.map +1 -0
- package/dist/test/unit/store/fileTreeMemoryStore.test.js +74 -7
- package/dist/test/unit/store/fileTreeMemoryStore.test.js.map +1 -1
- package/dist/test/unit/store/temporalStore.test.js +398 -0
- package/dist/test/unit/store/temporalStore.test.js.map +1 -0
- package/dist/test/unit/tools/memoryTools.test.js +572 -0
- package/dist/test/unit/tools/memoryTools.test.js.map +1 -0
- package/dist/test/unit/types/temporalCodec.test.js +203 -0
- package/dist/test/unit/types/temporalCodec.test.js.map +1 -0
- package/dist/test/unit/types/temporalPolicy.test.js +62 -0
- package/dist/test/unit/types/temporalPolicy.test.js.map +1 -0
- package/dist/ts-agent-memory.d.ts +1042 -10
- package/dist/tsdoc-metadata.json +1 -1
- package/etc/ts-agent-memory.api.md +284 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +2 -0
- package/lib/index.js.map +1 -1
- package/lib/packlets/ingest/cycleGuard.d.ts +41 -0
- package/lib/packlets/ingest/cycleGuard.d.ts.map +1 -0
- package/lib/packlets/ingest/cycleGuard.js +115 -0
- package/lib/packlets/ingest/cycleGuard.js.map +1 -0
- package/lib/packlets/ingest/hostStages.d.ts +88 -0
- package/lib/packlets/ingest/hostStages.d.ts.map +1 -0
- package/lib/packlets/ingest/hostStages.js +7 -0
- package/lib/packlets/ingest/hostStages.js.map +1 -0
- package/lib/packlets/ingest/index.d.ts +5 -0
- package/lib/packlets/ingest/index.d.ts.map +1 -0
- package/lib/packlets/ingest/index.js +25 -0
- package/lib/packlets/ingest/index.js.map +1 -0
- package/lib/packlets/ingest/model.d.ts +177 -0
- package/lib/packlets/ingest/model.d.ts.map +1 -0
- package/lib/packlets/ingest/model.js +7 -0
- package/lib/packlets/ingest/model.js.map +1 -0
- package/lib/packlets/ingest/orchestrator.d.ts +206 -0
- package/lib/packlets/ingest/orchestrator.d.ts.map +1 -0
- package/lib/packlets/ingest/orchestrator.js +442 -0
- package/lib/packlets/ingest/orchestrator.js.map +1 -0
- package/lib/packlets/retrieve/index.d.ts +1 -0
- package/lib/packlets/retrieve/index.d.ts.map +1 -1
- package/lib/packlets/retrieve/index.js +1 -0
- package/lib/packlets/retrieve/index.js.map +1 -1
- package/lib/packlets/retrieve/temporalRetrievers.d.ts +78 -0
- package/lib/packlets/retrieve/temporalRetrievers.d.ts.map +1 -0
- package/lib/packlets/retrieve/temporalRetrievers.js +178 -0
- package/lib/packlets/retrieve/temporalRetrievers.js.map +1 -0
- package/lib/packlets/store/fileTreeMemoryStore.d.ts +118 -9
- package/lib/packlets/store/fileTreeMemoryStore.d.ts.map +1 -1
- package/lib/packlets/store/fileTreeMemoryStore.js +322 -40
- package/lib/packlets/store/fileTreeMemoryStore.js.map +1 -1
- package/lib/packlets/tools/index.d.ts +2 -0
- package/lib/packlets/tools/index.d.ts.map +1 -0
- package/lib/packlets/tools/index.js +22 -0
- package/lib/packlets/tools/index.js.map +1 -0
- package/lib/packlets/tools/memoryTools.d.ts +139 -0
- package/lib/packlets/tools/memoryTools.d.ts.map +1 -0
- package/lib/packlets/tools/memoryTools.js +341 -0
- package/lib/packlets/tools/memoryTools.js.map +1 -0
- package/lib/packlets/types/identityCodec.d.ts +86 -0
- package/lib/packlets/types/identityCodec.d.ts.map +1 -1
- package/lib/packlets/types/identityCodec.js +118 -1
- package/lib/packlets/types/identityCodec.js.map +1 -1
- package/lib/packlets/types/index.d.ts +1 -0
- package/lib/packlets/types/index.d.ts.map +1 -1
- package/lib/packlets/types/index.js +1 -0
- package/lib/packlets/types/index.js.map +1 -1
- package/lib/packlets/types/temporal.d.ts +40 -0
- package/lib/packlets/types/temporal.d.ts.map +1 -0
- package/lib/packlets/types/temporal.js +92 -0
- package/lib/packlets/types/temporal.js.map +1 -0
- package/lib/packlets/types/writePolicy.d.ts +49 -0
- package/lib/packlets/types/writePolicy.d.ts.map +1 -1
- package/lib/packlets/types/writePolicy.js +98 -1
- package/lib/packlets/types/writePolicy.js.map +1 -1
- package/lib/test/unit/converters/antagonistRoundTrip.test.d.ts +10 -0
- package/lib/test/unit/converters/antagonistRoundTrip.test.d.ts.map +1 -0
- package/lib/test/unit/converters/antagonistRoundTrip.test.js +97 -0
- package/lib/test/unit/converters/antagonistRoundTrip.test.js.map +1 -0
- package/lib/test/unit/ingest/antagonistCycleAndParity.test.d.ts +9 -0
- package/lib/test/unit/ingest/antagonistCycleAndParity.test.d.ts.map +1 -0
- package/lib/test/unit/ingest/antagonistCycleAndParity.test.js +275 -0
- package/lib/test/unit/ingest/antagonistCycleAndParity.test.js.map +1 -0
- package/lib/test/unit/ingest/cycleGuard.test.d.ts +2 -0
- package/lib/test/unit/ingest/cycleGuard.test.d.ts.map +1 -0
- package/lib/test/unit/ingest/cycleGuard.test.js +56 -0
- package/lib/test/unit/ingest/cycleGuard.test.js.map +1 -0
- package/lib/test/unit/ingest/orchestrator.test.d.ts +2 -0
- package/lib/test/unit/ingest/orchestrator.test.d.ts.map +1 -0
- package/lib/test/unit/ingest/orchestrator.test.js +915 -0
- package/lib/test/unit/ingest/orchestrator.test.js.map +1 -0
- package/lib/test/unit/retrieve/temporalRetrievers.test.d.ts +2 -0
- package/lib/test/unit/retrieve/temporalRetrievers.test.d.ts.map +1 -0
- package/lib/test/unit/retrieve/temporalRetrievers.test.js +184 -0
- package/lib/test/unit/retrieve/temporalRetrievers.test.js.map +1 -0
- package/lib/test/unit/store/antagonistTemporalBoundary.test.d.ts +9 -0
- package/lib/test/unit/store/antagonistTemporalBoundary.test.d.ts.map +1 -0
- package/lib/test/unit/store/antagonistTemporalBoundary.test.js +122 -0
- package/lib/test/unit/store/antagonistTemporalBoundary.test.js.map +1 -0
- package/lib/test/unit/store/fileTreeMemoryStore.test.js +74 -7
- package/lib/test/unit/store/fileTreeMemoryStore.test.js.map +1 -1
- package/lib/test/unit/store/temporalStore.test.d.ts +2 -0
- package/lib/test/unit/store/temporalStore.test.d.ts.map +1 -0
- package/lib/test/unit/store/temporalStore.test.js +400 -0
- package/lib/test/unit/store/temporalStore.test.js.map +1 -0
- package/lib/test/unit/tools/memoryTools.test.d.ts +2 -0
- package/lib/test/unit/tools/memoryTools.test.d.ts.map +1 -0
- package/lib/test/unit/tools/memoryTools.test.js +574 -0
- package/lib/test/unit/tools/memoryTools.test.js.map +1 -0
- package/lib/test/unit/types/temporalCodec.test.d.ts +2 -0
- package/lib/test/unit/types/temporalCodec.test.d.ts.map +1 -0
- package/lib/test/unit/types/temporalCodec.test.js +205 -0
- package/lib/test/unit/types/temporalCodec.test.js.map +1 -0
- package/lib/test/unit/types/temporalPolicy.test.d.ts +2 -0
- package/lib/test/unit/types/temporalPolicy.test.d.ts.map +1 -0
- package/lib/test/unit/types/temporalPolicy.test.js +64 -0
- package/lib/test/unit/types/temporalPolicy.test.js.map +1 -0
- package/package.json +19 -19
- package/rush-logs/ts-agent-memory.build.cache.log +1 -1
- package/rush-logs/ts-agent-memory.build.log +4 -4
- package/src/index.ts +2 -0
- package/src/packlets/ingest/cycleGuard.ts +142 -0
- package/src/packlets/ingest/hostStages.ts +111 -0
- package/src/packlets/ingest/index.ts +9 -0
- package/src/packlets/ingest/model.ts +184 -0
- package/src/packlets/ingest/orchestrator.ts +797 -0
- package/src/packlets/retrieve/index.ts +1 -0
- package/src/packlets/retrieve/temporalRetrievers.ts +210 -0
- package/src/packlets/store/fileTreeMemoryStore.ts +460 -66
- package/src/packlets/tools/index.ts +6 -0
- package/src/packlets/tools/memoryTools.ts +579 -0
- package/src/packlets/types/identityCodec.ts +184 -0
- package/src/packlets/types/index.ts +1 -0
- package/src/packlets/types/temporal.ts +96 -0
- package/src/packlets/types/writePolicy.ts +127 -0
- package/src/test/unit/converters/antagonistRoundTrip.test.ts +110 -0
- package/src/test/unit/ingest/antagonistCycleAndParity.test.ts +362 -0
- package/src/test/unit/ingest/cycleGuard.test.ts +68 -0
- package/src/test/unit/ingest/orchestrator.test.ts +1158 -0
- package/src/test/unit/retrieve/temporalRetrievers.test.ts +226 -0
- package/src/test/unit/store/antagonistTemporalBoundary.test.ts +158 -0
- package/src/test/unit/store/fileTreeMemoryStore.test.ts +98 -7
- package/src/test/unit/store/temporalStore.test.ts +469 -0
- package/src/test/unit/tools/memoryTools.test.ts +771 -0
- package/src/test/unit/types/temporalCodec.test.ts +259 -0
- package/src/test/unit/types/temporalPolicy.test.ts +96 -0
- package/temp/build/lint/_eslint-5eVG3S6w.json +122 -46
- package/temp/build/typescript/ts_8nwakTlr.json +1 -1
- package/temp/ts-agent-memory.api.json +11984 -6314
- package/temp/ts-agent-memory.api.md +284 -0
|
@@ -14,12 +14,18 @@ import {
|
|
|
14
14
|
IMemoryEnvelope,
|
|
15
15
|
IMemoryRecord,
|
|
16
16
|
IProvenance,
|
|
17
|
+
ITemporalIdentityCodec,
|
|
17
18
|
IWritePolicy,
|
|
18
19
|
Kind,
|
|
19
20
|
KnowledgeLwwPolicy,
|
|
20
21
|
MemoryId,
|
|
21
22
|
MemoryScopeKey,
|
|
22
|
-
Tag
|
|
23
|
+
Tag,
|
|
24
|
+
isTemporalIdentityCodec,
|
|
25
|
+
isTemporalRecord,
|
|
26
|
+
isVersionCurrent,
|
|
27
|
+
selectCurrentVersion,
|
|
28
|
+
selectVersionAsOf
|
|
23
29
|
} from '../types';
|
|
24
30
|
import { IBodyConverterRegistry as IRegistry, parseMemoryFile, serializeMemoryFile } from '../converters';
|
|
25
31
|
import { IIndexedMemoryRecord, IMemoryIndex, MemoryIndex } from '../index';
|
|
@@ -47,8 +53,9 @@ export interface IMemoryStoreListFilter {
|
|
|
47
53
|
/** Restrict to records carrying this tag (exact match). */
|
|
48
54
|
readonly tag?: Tag;
|
|
49
55
|
/**
|
|
50
|
-
* For temporal kinds:
|
|
51
|
-
*
|
|
56
|
+
* For temporal (versioned) kinds: collapse each entity to the single version
|
|
57
|
+
* valid at this epoch ms. Non-temporal records are timeless and pass through
|
|
58
|
+
* unchanged. Absent = no temporal projection (every version is returned).
|
|
52
59
|
*/
|
|
53
60
|
readonly asOf?: number;
|
|
54
61
|
}
|
|
@@ -61,7 +68,9 @@ export interface IMemoryStore {
|
|
|
61
68
|
/**
|
|
62
69
|
* Keyed read by entity id. Resolves `entityId` to a storage address via the
|
|
63
70
|
* registered {@link IIdentityCodec} for `kind`. Returns `undefined` when no
|
|
64
|
-
* record exists.
|
|
71
|
+
* record exists. For a versioned (temporal) kind this returns the current
|
|
72
|
+
* version, resolved from the derived in-memory index (not re-read/re-verified
|
|
73
|
+
* from disk per call — the index is kept in sync with every write).
|
|
65
74
|
*/
|
|
66
75
|
get(kind: Kind, entityId: EntityId): Promise<Result<IMemoryRecord<unknown> | undefined>>;
|
|
67
76
|
|
|
@@ -86,7 +95,10 @@ export interface IMemoryStore {
|
|
|
86
95
|
|
|
87
96
|
/**
|
|
88
97
|
* Delete a record by `(kind, entityId)`. Non-temporal kinds physically delete
|
|
89
|
-
* the file
|
|
98
|
+
* the file and return the deleted record's {@link MemoryId}. Temporal
|
|
99
|
+
* (versioned) kinds SOFT-delete: the current version is invalidated
|
|
100
|
+
* (`invalid_at` set), history is retained, and the invalidated version's
|
|
101
|
+
* {@link MemoryId} is returned.
|
|
90
102
|
*/
|
|
91
103
|
delete(kind: Kind, entityId: EntityId): Promise<Result<MemoryId>>;
|
|
92
104
|
}
|
|
@@ -165,16 +177,6 @@ interface IPutOutcome {
|
|
|
165
177
|
readonly evicted: ReadonlyArray<MemoryId>;
|
|
166
178
|
}
|
|
167
179
|
|
|
168
|
-
/**
|
|
169
|
-
* The resolved storage address + content hash for a put, produced by the
|
|
170
|
-
* synchronous prefix of `_putLocked` and handed to the async write tail.
|
|
171
|
-
*/
|
|
172
|
-
interface IResolvedWriteAddress {
|
|
173
|
-
readonly scope: MemoryScopeKey;
|
|
174
|
-
readonly idStem: string;
|
|
175
|
-
readonly hash: string;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
180
|
interface IInternalParams {
|
|
179
181
|
readonly root: FileTree.IMutableFileTreeDirectoryItem;
|
|
180
182
|
readonly registry: IRegistry;
|
|
@@ -198,9 +200,11 @@ interface IInternalParams {
|
|
|
198
200
|
* write-lock so the index and the on-disk files never interleave.
|
|
199
201
|
*
|
|
200
202
|
* @remarks
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
203
|
+
* Bodies are string (markdown). Both layouts are supported: flat
|
|
204
|
+
* (one-file-per-entity, non-versioned) and versioned (subtree-per-entity,
|
|
205
|
+
* invalidate-don't-delete), dispatched per kind by the codec's `isVersioned`
|
|
206
|
+
* flag. A kind is flat with zero behavioral impact unless it opts into a
|
|
207
|
+
* {@link ITemporalIdentityCodec}.
|
|
204
208
|
* @public
|
|
205
209
|
*/
|
|
206
210
|
export class FileTreeMemoryStore implements IMemoryStore {
|
|
@@ -302,7 +306,16 @@ export class FileTreeMemoryStore implements IMemoryStore {
|
|
|
302
306
|
const result: Result<IMemoryRecord<unknown> | undefined> = this._codecFor(kind).onSuccess((codec) =>
|
|
303
307
|
codec.encode(entityId).onSuccess((addr) => {
|
|
304
308
|
if (addr.isVersioned) {
|
|
305
|
-
|
|
309
|
+
if (!isTemporalIdentityCodec(codec)) {
|
|
310
|
+
return fail(
|
|
311
|
+
`memory get '${entityId}': codec for versioned kind '${kind}' does not implement the temporal codec interface`
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
// Versioned keyed read resolves the CURRENT version (highest-seq
|
|
315
|
+
// version whose `invalid_at` is null/absent) from the entity subtree,
|
|
316
|
+
// read off the derived index. `asOf` resolution is via `list({ asOf })`
|
|
317
|
+
// and the temporal retrievers.
|
|
318
|
+
return succeed(this._readVersionedCurrent(addr.scope));
|
|
306
319
|
}
|
|
307
320
|
return this._readRecord(addr.scope, addr.idStem);
|
|
308
321
|
})
|
|
@@ -340,7 +353,48 @@ export class FileTreeMemoryStore implements IMemoryStore {
|
|
|
340
353
|
return true;
|
|
341
354
|
})
|
|
342
355
|
.map((entry) => entry.record);
|
|
343
|
-
|
|
356
|
+
if (filter?.asOf === undefined) {
|
|
357
|
+
// No temporal projection requested: byte-identical to the pre-temporal
|
|
358
|
+
// behavior (the flat-path guarantee — every version is returned).
|
|
359
|
+
return succeed(matches);
|
|
360
|
+
}
|
|
361
|
+
return succeed(FileTreeMemoryStore._projectAsOf(matches, filter.asOf));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Collapse temporal records to the single version valid at `asOf` per entity;
|
|
366
|
+
* non-temporal records are timeless and pass through unchanged (valid-time
|
|
367
|
+
* `asOf` applies only to versioned kinds; transaction-time / full bi-temporal
|
|
368
|
+
* filtering is deferred — OQ-9). An entity with no version valid at `asOf`
|
|
369
|
+
* contributes nothing.
|
|
370
|
+
*/
|
|
371
|
+
private static _projectAsOf(
|
|
372
|
+
records: ReadonlyArray<IMemoryRecord<unknown>>,
|
|
373
|
+
asOf: number
|
|
374
|
+
): ReadonlyArray<IMemoryRecord<unknown>> {
|
|
375
|
+
const passthrough: IMemoryRecord<unknown>[] = [];
|
|
376
|
+
const groups: Map<string, IMemoryRecord<unknown>[]> = new Map<string, IMemoryRecord<unknown>[]>();
|
|
377
|
+
for (const record of records) {
|
|
378
|
+
if (!isTemporalRecord(record)) {
|
|
379
|
+
passthrough.push(record);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const key: string = `${record.envelope.kind}\0${record.envelope.entityId}`;
|
|
383
|
+
const existing: IMemoryRecord<unknown>[] | undefined = groups.get(key);
|
|
384
|
+
if (existing === undefined) {
|
|
385
|
+
groups.set(key, [record]);
|
|
386
|
+
} else {
|
|
387
|
+
existing.push(record);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const result: IMemoryRecord<unknown>[] = [...passthrough];
|
|
391
|
+
for (const versions of groups.values()) {
|
|
392
|
+
const valid: IMemoryRecord<unknown> | undefined = selectVersionAsOf(versions, asOf);
|
|
393
|
+
if (valid !== undefined) {
|
|
394
|
+
result.push(valid);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return result;
|
|
344
398
|
}
|
|
345
399
|
|
|
346
400
|
/** {@inheritDoc IMemoryStore.put} */
|
|
@@ -479,32 +533,37 @@ export class FileTreeMemoryStore implements IMemoryStore {
|
|
|
479
533
|
);
|
|
480
534
|
}
|
|
481
535
|
const body: string = record.body;
|
|
482
|
-
// Resolve the
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
//
|
|
536
|
+
// Resolve the body converter + codec, then dispatch on layout. The flat
|
|
537
|
+
// (non-versioned) path keeps its exact dedup/policy ordering; the versioned
|
|
538
|
+
// path is a wholly separate branch so the flat path is behaviorally
|
|
539
|
+
// unchanged (the consumer adoption guarantee).
|
|
486
540
|
return this._registry
|
|
487
541
|
.convert(envelope.kind, body)
|
|
488
542
|
.withErrorFormat((msg) => `memory put '${envelope.id}': invalid body: ${msg}`)
|
|
489
543
|
.onSuccess(() => this._codecFor(envelope.kind))
|
|
490
|
-
.
|
|
491
|
-
(
|
|
492
|
-
|
|
493
|
-
if (
|
|
494
|
-
return
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
`memory put: envelope id '${envelope.id}' does not match codec-derived stem '${addr.idStem}'`
|
|
544
|
+
.thenOnSuccess((codec) =>
|
|
545
|
+
codec.encode(envelope.entityId).thenOnSuccess((addr) => {
|
|
546
|
+
if (addr.isVersioned) {
|
|
547
|
+
if (!isTemporalIdentityCodec(codec)) {
|
|
548
|
+
return Promise.resolve(
|
|
549
|
+
fail<IPutOutcome>(
|
|
550
|
+
`memory put '${envelope.entityId}': codec for versioned kind '${envelope.kind}' does not implement the temporal codec interface`
|
|
551
|
+
)
|
|
499
552
|
);
|
|
500
553
|
}
|
|
501
|
-
return this.
|
|
502
|
-
|
|
554
|
+
return this._putVersioned(record, body, codec, addr.scope);
|
|
555
|
+
}
|
|
556
|
+
if (envelope.id !== addr.idStem) {
|
|
557
|
+
return Promise.resolve(
|
|
558
|
+
fail<IPutOutcome>(
|
|
559
|
+
`memory put: envelope id '${envelope.id}' does not match codec-derived stem '${addr.idStem}'`
|
|
560
|
+
)
|
|
503
561
|
);
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
562
|
+
}
|
|
563
|
+
return this._contentHash(envelope.kind, body, envelope.links).thenOnSuccess((hash) =>
|
|
564
|
+
this._writeResolved(record, body, addr.scope, addr.idStem, hash)
|
|
565
|
+
);
|
|
566
|
+
})
|
|
508
567
|
);
|
|
509
568
|
}
|
|
510
569
|
|
|
@@ -533,14 +592,32 @@ export class FileTreeMemoryStore implements IMemoryStore {
|
|
|
533
592
|
// (Tags / provenance are metadata and are NOT part of the hash; see
|
|
534
593
|
// design-lock §2.5.)
|
|
535
594
|
if (dedupScope === 'content') {
|
|
536
|
-
|
|
595
|
+
// Cross-id content collapse: an identical { kind, body, links } triple under
|
|
596
|
+
// a DIFFERENT id is a no-op (knowledge family). A same-id match is excluded
|
|
597
|
+
// here and falls through to the LWW path below so a metadata-only revision
|
|
598
|
+
// (tags / provenance — outside the content hash but inside the policy's
|
|
599
|
+
// mutableFields) actually applies. Content-dedup must never shadow LWW for
|
|
600
|
+
// the same entity.
|
|
601
|
+
const duplicate: IMemoryRecord<unknown> | undefined = this._findByContentHash(
|
|
602
|
+
scope,
|
|
603
|
+
hash,
|
|
604
|
+
record.envelope.id
|
|
605
|
+
);
|
|
537
606
|
if (duplicate !== undefined) {
|
|
538
607
|
return succeed({ record: duplicate, evicted: [] });
|
|
539
608
|
}
|
|
540
609
|
}
|
|
541
610
|
return this._readRecord(scope, idStem).thenOnSuccess((existing) => {
|
|
542
|
-
|
|
543
|
-
|
|
611
|
+
// Same-id re-put is a no-op ONLY when the content hash matches AND the
|
|
612
|
+
// mutable metadata is also unchanged. The content hash covers
|
|
613
|
+
// { kind, body, links }; a matching hash with revised tags/provenance is a
|
|
614
|
+
// real update that must reach applyUpdate, not be swallowed as a duplicate
|
|
615
|
+
// (both dedup scopes).
|
|
616
|
+
if (
|
|
617
|
+
existing !== undefined &&
|
|
618
|
+
existing.envelope.contentHash === hash &&
|
|
619
|
+
this._isMutableMetadataUnchanged(existing, record)
|
|
620
|
+
) {
|
|
544
621
|
return Promise.resolve(succeed({ record: existing, evicted: [] }));
|
|
545
622
|
}
|
|
546
623
|
// The admission cohort is the set of records the policy's cap applies to:
|
|
@@ -774,31 +851,293 @@ export class FileTreeMemoryStore implements IMemoryStore {
|
|
|
774
851
|
}
|
|
775
852
|
|
|
776
853
|
private async _deleteLocked(kind: Kind, entityId: EntityId): Promise<Result<MemoryId>> {
|
|
777
|
-
return this._codecFor(kind)
|
|
778
|
-
.
|
|
779
|
-
.thenOnSuccess((addr) => {
|
|
854
|
+
return this._codecFor(kind).thenOnSuccess((codec) =>
|
|
855
|
+
codec.encode(entityId).thenOnSuccess((addr) => {
|
|
780
856
|
if (addr.isVersioned) {
|
|
781
|
-
|
|
782
|
-
|
|
857
|
+
if (!isTemporalIdentityCodec(codec)) {
|
|
858
|
+
return Promise.resolve(
|
|
859
|
+
fail<MemoryId>(
|
|
860
|
+
`memory delete '${entityId}': codec for versioned kind '${kind}' does not implement the temporal codec interface`
|
|
861
|
+
)
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
return this._deleteVersioned(entityId, addr.scope);
|
|
865
|
+
}
|
|
866
|
+
return this._deleteFlat(entityId, addr.scope, addr.idStem);
|
|
867
|
+
})
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Flat (non-versioned) delete: physically remove the record file + index
|
|
873
|
+
* entry, then prune the vector best-effort. Structurally unchanged from the
|
|
874
|
+
* pre-temporal delete path.
|
|
875
|
+
*/
|
|
876
|
+
private async _deleteFlat(
|
|
877
|
+
entityId: EntityId,
|
|
878
|
+
scope: MemoryScopeKey,
|
|
879
|
+
idStem: string
|
|
880
|
+
): Promise<Result<MemoryId>> {
|
|
881
|
+
return this._readRecord(scope, idStem).thenOnSuccess((existing) => {
|
|
882
|
+
if (existing === undefined) {
|
|
883
|
+
return Promise.resolve(fail<MemoryId>(`memory delete '${entityId}': no record found`));
|
|
884
|
+
}
|
|
885
|
+
// Delete the record file + index entry (authoritative), then prune the
|
|
886
|
+
// vector best-effort: a committed delete must not fail because the
|
|
887
|
+
// derived index could not be pruned.
|
|
888
|
+
return this._deleteFile(scope, idStem)
|
|
889
|
+
.onSuccess(() => this._index.patch('delete', { scope, record: existing }))
|
|
890
|
+
.thenOnSuccess(async () => {
|
|
891
|
+
await this._removeVectorBestEffort(existing.envelope.id);
|
|
892
|
+
return succeed(existing.envelope.id);
|
|
893
|
+
});
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Resolve the current version of a temporal entity from the derived index: the
|
|
899
|
+
* highest-`seq` version under the entity subtree `scope` whose `invalid_at` is
|
|
900
|
+
* null/absent. `undefined` when the entity has no current version (never
|
|
901
|
+
* written, or fully invalidated / soft-deleted).
|
|
902
|
+
*/
|
|
903
|
+
private _readVersionedCurrent(scope: MemoryScopeKey): IMemoryRecord<unknown> | undefined {
|
|
904
|
+
return selectCurrentVersion(this._versionsForEntity(scope));
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Every persisted version of the entity whose subtree is `scope`. All version
|
|
909
|
+
* files for one entity live under exactly that scope (which encodes the
|
|
910
|
+
* entityId), so a scope filter over the index isolates one entity's versions.
|
|
911
|
+
*/
|
|
912
|
+
private _versionsForEntity(scope: MemoryScopeKey): ReadonlyArray<IMemoryRecord<unknown>> {
|
|
913
|
+
return this._index
|
|
914
|
+
.entries()
|
|
915
|
+
.filter((entry) => entry.scope === scope)
|
|
916
|
+
.map((entry) => entry.record);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Versioned write (invalidate-don't-delete). Builds the new version's content
|
|
921
|
+
* (a first version from the incoming record, or a merge of the incoming patch
|
|
922
|
+
* over the current version), persists it as a NEW version file, then sets
|
|
923
|
+
* `invalid_at` on the prior current version.
|
|
924
|
+
*
|
|
925
|
+
* Durability order: the new version is persisted FIRST. It carries the highest
|
|
926
|
+
* `seq`, so a crash before the prior-version invalidation completes still
|
|
927
|
+
* resolves the new version as current (`selectCurrentVersion` breaks a
|
|
928
|
+
* two-current tie by highest `seq`), and `asOf` reads stay correct because each
|
|
929
|
+
* version's `valid_at` lower-bounds its interval.
|
|
930
|
+
*/
|
|
931
|
+
private async _putVersioned(
|
|
932
|
+
record: IMemoryRecord<unknown>,
|
|
933
|
+
body: string,
|
|
934
|
+
codec: ITemporalIdentityCodec,
|
|
935
|
+
scope: MemoryScopeKey
|
|
936
|
+
): Promise<Result<IPutOutcome>> {
|
|
937
|
+
const envelope: IMemoryEnvelope = record.envelope;
|
|
938
|
+
const entityId: EntityId = envelope.entityId;
|
|
939
|
+
const kind: Kind = envelope.kind;
|
|
940
|
+
// Snapshot the entity's versions BEFORE the write. `priorCurrents` is every
|
|
941
|
+
// still-current version at snapshot time — normally one, but two-or-more if a
|
|
942
|
+
// prior invalidation partially failed; invalidating all of them lets the write
|
|
943
|
+
// self-heal a stuck state (P2-7).
|
|
944
|
+
const versions: ReadonlyArray<IMemoryRecord<unknown>> = this._versionsForEntity(scope);
|
|
945
|
+
const priorCurrents: ReadonlyArray<IMemoryRecord<unknown>> = versions.filter(isVersionCurrent);
|
|
946
|
+
const current: IMemoryRecord<unknown> | undefined = selectCurrentVersion(versions);
|
|
947
|
+
const policy: IWritePolicy = this._policyFor(kind);
|
|
948
|
+
const dedupScope: DedupScope = policy.dedupScope ?? DEFAULT_DEDUP_SCOPE;
|
|
949
|
+
return this._contentHash(kind, body, envelope.links).thenOnSuccess((hash) => {
|
|
950
|
+
// Entity-scoped dedup: a re-put is a no-op only when the CURRENT content AND
|
|
951
|
+
// its mutable metadata are unchanged (does not spawn a redundant version).
|
|
952
|
+
// A metadata-only revision (tags/provenance — declared mutable by
|
|
953
|
+
// TemporalVersionedPolicy) must NOT be swallowed here: it mints a new
|
|
954
|
+
// version via the applyUpdate merge in `_buildVersionedRecord`, mirroring
|
|
955
|
+
// the flat path. Content-scoped dedup is not a versioning concern, so only
|
|
956
|
+
// the entity granularity is honored here.
|
|
957
|
+
if (
|
|
958
|
+
dedupScope === 'entity' &&
|
|
959
|
+
current !== undefined &&
|
|
960
|
+
current.envelope.contentHash === hash &&
|
|
961
|
+
this._isMutableMetadataUnchanged(current, record)
|
|
962
|
+
) {
|
|
963
|
+
return Promise.resolve(succeed<IPutOutcome>({ record: current, evicted: [] }));
|
|
964
|
+
}
|
|
965
|
+
// On the versioned path the admission cohort is the entity's ENTIRE version
|
|
966
|
+
// history (no target id to exclude — the new version does not exist yet),
|
|
967
|
+
// which differs from the flat path's "cohort excluding target id" shape.
|
|
968
|
+
return policy.admit(record, versions).thenOnSuccess((decision) => {
|
|
969
|
+
if (decision.decision === 'reject') {
|
|
970
|
+
return Promise.resolve(fail<IPutOutcome>(`memory put: rejected by policy: ${decision.reason}`));
|
|
971
|
+
}
|
|
972
|
+
// No culling on the versioned path — history is retained (invalidate-don't-delete).
|
|
973
|
+
const now: number = this._clock();
|
|
974
|
+
const seq: number = ++this._seq;
|
|
975
|
+
// World-truth start of the new version. Each prior current version's
|
|
976
|
+
// world-truth interval CLOSES at this same instant (not at `now`) so a
|
|
977
|
+
// backdated/future-dated `valid_at` leaves no gap or overlap on the
|
|
978
|
+
// valid-time axis.
|
|
979
|
+
const validAt: number = envelope.temporal?.valid_at ?? now;
|
|
980
|
+
return codec
|
|
981
|
+
.encodeVersion(entityId, seq)
|
|
982
|
+
.withErrorFormat((msg) => `memory put '${entityId}': ${msg}`)
|
|
983
|
+
.thenOnSuccess((versionStem) =>
|
|
984
|
+
this._buildVersionedRecord(record, body, current, policy, hash, versionStem, validAt, now, seq)
|
|
985
|
+
.thenOnSuccess((built) => this._embedOnWrite(built))
|
|
986
|
+
.onSuccess((embeddedBuilt) => this._persist(embeddedBuilt, scope, versionStem))
|
|
987
|
+
.onSuccess((persisted) =>
|
|
988
|
+
this._invalidateCurrents(scope, priorCurrents, validAt, now).onSuccess(() =>
|
|
989
|
+
succeed(persisted)
|
|
990
|
+
)
|
|
991
|
+
)
|
|
992
|
+
.onSuccess((persisted) => succeed({ record: persisted, evicted: [] }))
|
|
993
|
+
);
|
|
994
|
+
});
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Invalidate a set of still-current versions (close each world-truth interval at
|
|
1000
|
+
* `invalidAt`; stamp transaction-time `updated` = `now`). Chained so a mid-list
|
|
1001
|
+
* failure propagates. Invalidating every prior current — not just the
|
|
1002
|
+
* highest-`seq` pick — self-heals a state where a previous write's invalidation
|
|
1003
|
+
* only partially completed (P2-7).
|
|
1004
|
+
*/
|
|
1005
|
+
private _invalidateCurrents(
|
|
1006
|
+
scope: MemoryScopeKey,
|
|
1007
|
+
currents: ReadonlyArray<IMemoryRecord<unknown>>,
|
|
1008
|
+
invalidAt: number,
|
|
1009
|
+
now: number
|
|
1010
|
+
): Result<true> {
|
|
1011
|
+
return currents.reduce<Result<true>>(
|
|
1012
|
+
(acc, version) =>
|
|
1013
|
+
acc.onSuccess(() =>
|
|
1014
|
+
this._invalidateVersion(scope, version, invalidAt, now).onSuccess(() => succeed(true))
|
|
1015
|
+
),
|
|
1016
|
+
succeed(true)
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* Build the new version to persist. A first version takes the incoming
|
|
1022
|
+
* content verbatim (its dedup `hash` is reused); a subsequent version projects
|
|
1023
|
+
* the incoming record's mutable fields into a merge-patch and lets the policy
|
|
1024
|
+
* merge them over the CURRENT version (the merge-patch-under-versioning
|
|
1025
|
+
* contract), recomputing the content hash from the policy's output. Each
|
|
1026
|
+
* version is its own record with its own transaction time (`created` = `now`)
|
|
1027
|
+
* and a store-minted `id` = the version stem, so `id === filename stem` holds.
|
|
1028
|
+
*/
|
|
1029
|
+
private _buildVersionedRecord(
|
|
1030
|
+
incoming: IMemoryRecord<unknown>,
|
|
1031
|
+
body: string,
|
|
1032
|
+
current: IMemoryRecord<unknown> | undefined,
|
|
1033
|
+
policy: IWritePolicy,
|
|
1034
|
+
// Reused only for the first-version branch (its content is taken verbatim); a
|
|
1035
|
+
// subsequent version recomputes its hash from the merge-patched content.
|
|
1036
|
+
firstVersionHash: string,
|
|
1037
|
+
versionStem: string,
|
|
1038
|
+
validAt: number,
|
|
1039
|
+
now: number,
|
|
1040
|
+
seq: number
|
|
1041
|
+
): Result<IMemoryRecord<string>> {
|
|
1042
|
+
const mintedId: MemoryId = versionStem as MemoryId;
|
|
1043
|
+
if (current === undefined) {
|
|
1044
|
+
const envelope: IMemoryEnvelope = {
|
|
1045
|
+
...incoming.envelope,
|
|
1046
|
+
id: mintedId,
|
|
1047
|
+
entityId: incoming.envelope.entityId,
|
|
1048
|
+
created: now,
|
|
1049
|
+
updated: now,
|
|
1050
|
+
seq,
|
|
1051
|
+
contentHash: firstVersionHash,
|
|
1052
|
+
temporal: { valid_at: validAt }
|
|
1053
|
+
};
|
|
1054
|
+
return succeed({ envelope, body });
|
|
1055
|
+
}
|
|
1056
|
+
const patch: Record<string, unknown> = this._projectMutablePatch(incoming, policy.mutableFields);
|
|
1057
|
+
return policy
|
|
1058
|
+
.applyUpdate(current, patch)
|
|
1059
|
+
.withErrorFormat((msg) => `memory put '${incoming.envelope.entityId}': update failed: ${msg}`)
|
|
1060
|
+
.onSuccess((updated) => {
|
|
1061
|
+
if (typeof updated.body !== 'string') {
|
|
1062
|
+
return fail(
|
|
1063
|
+
`memory put '${
|
|
1064
|
+
incoming.envelope.entityId
|
|
1065
|
+
}': policy returned a non-string body (${typeof updated.body})`
|
|
783
1066
|
);
|
|
784
1067
|
}
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1068
|
+
const finalBody: string = updated.body;
|
|
1069
|
+
return this._contentHash(updated.envelope.kind, finalBody, updated.envelope.links).onSuccess(
|
|
1070
|
+
(finalHash) => {
|
|
1071
|
+
const envelope: IMemoryEnvelope = {
|
|
1072
|
+
...updated.envelope,
|
|
1073
|
+
id: mintedId,
|
|
1074
|
+
entityId: incoming.envelope.entityId,
|
|
1075
|
+
created: now,
|
|
1076
|
+
updated: now,
|
|
1077
|
+
seq,
|
|
1078
|
+
contentHash: finalHash,
|
|
1079
|
+
temporal: { valid_at: validAt }
|
|
1080
|
+
};
|
|
1081
|
+
return succeed({ envelope, body: finalBody });
|
|
788
1082
|
}
|
|
789
|
-
|
|
790
|
-
// vector best-effort: a committed delete must not fail because the
|
|
791
|
-
// derived index could not be pruned.
|
|
792
|
-
return this._deleteFile(addr.scope, addr.idStem)
|
|
793
|
-
.onSuccess(() => this._index.patch('delete', { scope: addr.scope, record: existing }))
|
|
794
|
-
.thenOnSuccess(async () => {
|
|
795
|
-
await this._removeVectorBestEffort(existing.envelope.id);
|
|
796
|
-
return succeed(existing.envelope.id);
|
|
797
|
-
});
|
|
798
|
-
});
|
|
1083
|
+
);
|
|
799
1084
|
});
|
|
800
1085
|
}
|
|
801
1086
|
|
|
1087
|
+
/**
|
|
1088
|
+
* Set `invalid_at` on a prior current version (invalidate-don't-delete) and
|
|
1089
|
+
* rewrite its file + index entry. The content hash is unchanged — `invalid_at`
|
|
1090
|
+
* is temporal metadata, not part of `{ kind, body, links }` — so the version's
|
|
1091
|
+
* identity is stable.
|
|
1092
|
+
*/
|
|
1093
|
+
private _invalidateVersion(
|
|
1094
|
+
scope: MemoryScopeKey,
|
|
1095
|
+
version: IMemoryRecord<unknown>,
|
|
1096
|
+
invalidAt: number,
|
|
1097
|
+
now: number
|
|
1098
|
+
): Result<IMemoryRecord<unknown>> {
|
|
1099
|
+
/* c8 ignore start -- defensive: every persisted version carries a string body (only string bodies are written) */
|
|
1100
|
+
if (typeof version.body !== 'string') {
|
|
1101
|
+
return fail(`memory put: cannot invalidate version '${version.envelope.id}': non-string body`);
|
|
1102
|
+
}
|
|
1103
|
+
/* c8 ignore stop */
|
|
1104
|
+
// `invalid_at` is the WORLD-TRUTH close of the interval (the superseding version's
|
|
1105
|
+
// `valid_at`, or the delete instant); `updated` is the transaction-time stamp.
|
|
1106
|
+
const invalidated: IMemoryRecord<string> = {
|
|
1107
|
+
envelope: {
|
|
1108
|
+
...version.envelope,
|
|
1109
|
+
updated: now,
|
|
1110
|
+
temporal: { ...version.envelope.temporal, invalid_at: invalidAt }
|
|
1111
|
+
},
|
|
1112
|
+
body: version.body
|
|
1113
|
+
};
|
|
1114
|
+
return this._persist(invalidated, scope, version.envelope.id);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* Versioned delete: SOFT delete (invalidate-don't-delete). Sets `invalid_at` on
|
|
1119
|
+
* the current version, leaving the entity's history intact and the entity with
|
|
1120
|
+
* no current version. Returns the invalidated version's {@link MemoryId}. Fails
|
|
1121
|
+
* with "no record found" when there is no current version — matching the flat
|
|
1122
|
+
* delete's not-found semantics. History is retained deliberately: temporal
|
|
1123
|
+
* kinds exist to preserve the audit trail (and the L3 `contradicts` interlock
|
|
1124
|
+
* builds on it), so a hard delete would defeat the purpose.
|
|
1125
|
+
*/
|
|
1126
|
+
private async _deleteVersioned(entityId: EntityId, scope: MemoryScopeKey): Promise<Result<MemoryId>> {
|
|
1127
|
+
const versions: ReadonlyArray<IMemoryRecord<unknown>> = this._versionsForEntity(scope);
|
|
1128
|
+
const currents: ReadonlyArray<IMemoryRecord<unknown>> = versions.filter(isVersionCurrent);
|
|
1129
|
+
const current: IMemoryRecord<unknown> | undefined = selectCurrentVersion(versions);
|
|
1130
|
+
if (current === undefined) {
|
|
1131
|
+
return fail(`memory delete '${entityId}': no record found`);
|
|
1132
|
+
}
|
|
1133
|
+
// A delete closes the world-truth interval at the delete instant (`now` for both
|
|
1134
|
+
// the transaction stamp and the `invalid_at` boundary). Every still-current
|
|
1135
|
+
// version is invalidated (self-heals a stuck two-current state — P2-7); the
|
|
1136
|
+
// highest-`seq` current's id is returned.
|
|
1137
|
+
const now: number = this._clock();
|
|
1138
|
+
return this._invalidateCurrents(scope, currents, now, now).onSuccess(() => succeed(current.envelope.id));
|
|
1139
|
+
}
|
|
1140
|
+
|
|
802
1141
|
/** Evict (physically delete) a single record file by id, patching the index. */
|
|
803
1142
|
private _evict(scope: MemoryScopeKey, id: MemoryId): Result<MemoryId> {
|
|
804
1143
|
return this._readRecord(scope, id).onSuccess((existing) => {
|
|
@@ -852,14 +1191,52 @@ export class FileTreeMemoryStore implements IMemoryStore {
|
|
|
852
1191
|
.map((entry) => entry.record);
|
|
853
1192
|
}
|
|
854
1193
|
|
|
855
|
-
/**
|
|
856
|
-
|
|
1194
|
+
/**
|
|
1195
|
+
* Find a record in `scope` whose `contentHash` equals `hash`, if any,
|
|
1196
|
+
* optionally excluding a specific id. `excludeId` lets the content-scoped
|
|
1197
|
+
* dedup skip the same-id record so it does not shadow the LWW update path.
|
|
1198
|
+
*/
|
|
1199
|
+
private _findByContentHash(
|
|
1200
|
+
scope: MemoryScopeKey,
|
|
1201
|
+
hash: string,
|
|
1202
|
+
excludeId?: string
|
|
1203
|
+
): IMemoryRecord<unknown> | undefined {
|
|
857
1204
|
const match: IIndexedMemoryRecord | undefined = this._index
|
|
858
1205
|
.entries()
|
|
859
|
-
.find(
|
|
1206
|
+
.find(
|
|
1207
|
+
(entry) =>
|
|
1208
|
+
entry.scope === scope &&
|
|
1209
|
+
entry.record.envelope.contentHash === hash &&
|
|
1210
|
+
entry.record.envelope.id !== excludeId
|
|
1211
|
+
);
|
|
860
1212
|
return match?.record;
|
|
861
1213
|
}
|
|
862
1214
|
|
|
1215
|
+
/**
|
|
1216
|
+
* True when `incoming`'s caller-authored mutable metadata (`tags` / `provenance`)
|
|
1217
|
+
* canonically equals `existing`'s. `body` and `links` are covered by the content
|
|
1218
|
+
* hash; `embeddingRef` is store-derived (not caller metadata) and is deliberately
|
|
1219
|
+
* excluded so a same-content re-put is not treated as changed merely because the
|
|
1220
|
+
* store already stamped an embedding. Used to keep an identical re-put a no-op
|
|
1221
|
+
* without swallowing a genuine metadata revision.
|
|
1222
|
+
*
|
|
1223
|
+
* Canonicalization never fails for a validated record (`tags`/`provenance` are
|
|
1224
|
+
* always plain JSON); a failure is defaulted to a non-matching sentinel so the
|
|
1225
|
+
* write flows to `applyUpdate` (which re-validates) rather than silently
|
|
1226
|
+
* no-op-ing on an un-canonicalizable value.
|
|
1227
|
+
*/
|
|
1228
|
+
private _isMutableMetadataUnchanged(
|
|
1229
|
+
existing: IMemoryRecord<unknown>,
|
|
1230
|
+
incoming: IMemoryRecord<unknown>
|
|
1231
|
+
): boolean {
|
|
1232
|
+
const key = (record: IMemoryRecord<unknown>): string =>
|
|
1233
|
+
this._hasher
|
|
1234
|
+
.canonicalize({ tags: record.envelope.tags, provenance: record.envelope.provenance })
|
|
1235
|
+
.orDefault('');
|
|
1236
|
+
const existingKey: string = key(existing);
|
|
1237
|
+
return existingKey !== '' && existingKey === key(incoming);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
863
1240
|
private _contentHash(kind: Kind, body: string, links: IMemoryEnvelope['links']): Result<string> {
|
|
864
1241
|
return this._hasher.computeHash({ kind, body, links });
|
|
865
1242
|
}
|
|
@@ -902,7 +1279,15 @@ export class FileTreeMemoryStore implements IMemoryStore {
|
|
|
902
1279
|
});
|
|
903
1280
|
}
|
|
904
1281
|
|
|
905
|
-
/**
|
|
1282
|
+
/**
|
|
1283
|
+
* Enforce `envelope.id === filename stem`, the codec round-trip, AND that the
|
|
1284
|
+
* envelope's own `entityId` agrees with the id decoded from the subtree scope.
|
|
1285
|
+
* The round-trip only validates (scope, stem) consistency; the codec derives
|
|
1286
|
+
* `entityId` from the scope path and never reads the envelope's `entityId`
|
|
1287
|
+
* field, so a tampered/corrupt file whose frontmatter declares a foreign
|
|
1288
|
+
* `entityId` would otherwise load undetected — and `entityId` is trusted
|
|
1289
|
+
* verbatim downstream (e.g. merge-into re-addressing). Cross-check it here.
|
|
1290
|
+
*/
|
|
906
1291
|
private _verifyLoaded(
|
|
907
1292
|
scope: MemoryScopeKey,
|
|
908
1293
|
file: FileTree.IFileTreeFileItem,
|
|
@@ -914,9 +1299,18 @@ export class FileTreeMemoryStore implements IMemoryStore {
|
|
|
914
1299
|
);
|
|
915
1300
|
}
|
|
916
1301
|
return this._codecFor(record.envelope.kind)
|
|
917
|
-
.onSuccess((codec) =>
|
|
1302
|
+
.onSuccess((codec) =>
|
|
1303
|
+
codec.verifyRoundTrip(scope, file.baseName).onSuccess(() => codec.decode(scope, file.baseName))
|
|
1304
|
+
)
|
|
918
1305
|
.withErrorFormat((msg) => `memory file '${file.absolutePath}': ${msg}`)
|
|
919
|
-
.onSuccess(() =>
|
|
1306
|
+
.onSuccess((decodedEntityId) => {
|
|
1307
|
+
if (decodedEntityId !== record.envelope.entityId) {
|
|
1308
|
+
return fail(
|
|
1309
|
+
`memory file '${file.absolutePath}': envelope entityId '${record.envelope.entityId}' does not match scope-derived entityId '${decodedEntityId}'`
|
|
1310
|
+
);
|
|
1311
|
+
}
|
|
1312
|
+
return succeed(record);
|
|
1313
|
+
});
|
|
920
1314
|
}
|
|
921
1315
|
|
|
922
1316
|
/**
|