@fgv/ts-agent-memory 5.1.0-50 → 5.1.0-52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/packlets/retrieve/fragmentSemanticRetriever.js +94 -5
- package/dist/packlets/retrieve/fragmentSemanticRetriever.js.map +1 -1
- package/dist/packlets/store/fileTreeMemoryStore.js +36 -123
- package/dist/packlets/store/fileTreeMemoryStore.js.map +1 -1
- package/dist/packlets/store/memoryStore.js.map +1 -1
- package/dist/packlets/store/storeFileAccess.js +99 -0
- package/dist/packlets/store/storeFileAccess.js.map +1 -0
- package/dist/packlets/store/storeIdentity.js +88 -0
- package/dist/packlets/store/storeIdentity.js.map +1 -0
- package/dist/packlets/types/identityResolver.js +6 -0
- package/dist/packlets/types/identityResolver.js.map +1 -0
- package/dist/packlets/types/index.js +1 -0
- package/dist/packlets/types/index.js.map +1 -1
- package/dist/packlets/vector/inMemoryFragmentCosineIndex.js +38 -17
- package/dist/packlets/vector/inMemoryFragmentCosineIndex.js.map +1 -1
- package/dist/packlets/vector/vectorIndex.js.map +1 -1
- package/dist/ts-agent-memory.d.ts +188 -41
- package/lib/packlets/retrieve/fragmentSemanticRetriever.d.ts +79 -1
- package/lib/packlets/retrieve/fragmentSemanticRetriever.d.ts.map +1 -1
- package/lib/packlets/retrieve/fragmentSemanticRetriever.js +94 -5
- package/lib/packlets/retrieve/fragmentSemanticRetriever.js.map +1 -1
- package/lib/packlets/store/fileTreeMemoryStore.d.ts +4 -19
- package/lib/packlets/store/fileTreeMemoryStore.d.ts.map +1 -1
- package/lib/packlets/store/fileTreeMemoryStore.js +36 -123
- package/lib/packlets/store/fileTreeMemoryStore.js.map +1 -1
- package/lib/packlets/store/memoryStore.d.ts +2 -2
- package/lib/packlets/store/memoryStore.d.ts.map +1 -1
- package/lib/packlets/store/memoryStore.js.map +1 -1
- package/lib/packlets/store/storeFileAccess.d.ts +40 -0
- package/lib/packlets/store/storeFileAccess.d.ts.map +1 -0
- package/lib/packlets/store/storeFileAccess.js +105 -0
- package/lib/packlets/store/storeFileAccess.js.map +1 -0
- package/lib/packlets/store/storeIdentity.d.ts +63 -0
- package/lib/packlets/store/storeIdentity.d.ts.map +1 -0
- package/lib/packlets/store/storeIdentity.js +94 -0
- package/lib/packlets/store/storeIdentity.js.map +1 -0
- package/lib/packlets/types/identityResolver.d.ts +42 -0
- package/lib/packlets/types/identityResolver.d.ts.map +1 -0
- package/lib/packlets/types/identityResolver.js +7 -0
- package/lib/packlets/types/identityResolver.js.map +1 -0
- 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/vector/inMemoryFragmentCosineIndex.d.ts +9 -17
- package/lib/packlets/vector/inMemoryFragmentCosineIndex.d.ts.map +1 -1
- package/lib/packlets/vector/inMemoryFragmentCosineIndex.js +38 -17
- package/lib/packlets/vector/inMemoryFragmentCosineIndex.js.map +1 -1
- package/lib/packlets/vector/vectorIndex.d.ts +57 -6
- package/lib/packlets/vector/vectorIndex.d.ts.map +1 -1
- package/lib/packlets/vector/vectorIndex.js.map +1 -1
- package/package.json +7 -7
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Copyright (c) 2026 Erik Fortune
|
|
3
3
|
* SPDX-License-Identifier: MIT
|
|
4
4
|
*/
|
|
5
|
-
import { fail, succeed } from '@fgv/ts-utils';
|
|
5
|
+
import { captureResult, fail, succeed } from '@fgv/ts-utils';
|
|
6
|
+
import { Convert } from '../types';
|
|
6
7
|
/**
|
|
7
8
|
* The loud-degradation message a {@link FragmentSemanticRetriever} returns when a
|
|
8
9
|
* fragment query is issued but no {@link IFragmentSemanticBackend | backend} is
|
|
@@ -10,6 +11,26 @@ import { fail, succeed } from '@fgv/ts-utils';
|
|
|
10
11
|
* @public
|
|
11
12
|
*/
|
|
12
13
|
export const FRAGMENT_SEMANTIC_UNWIRED_MESSAGE = 'fragment recall: no fragment index is wired; wire an IFragmentSemanticBackend to enable sub-document search';
|
|
14
|
+
/**
|
|
15
|
+
* The loud-degradation message returned when a query carries a record narrowing but
|
|
16
|
+
* no {@link IIdentityResolver} is wired to resolve it.
|
|
17
|
+
*
|
|
18
|
+
* @remarks
|
|
19
|
+
* Deliberately a `Failure` rather than a silently-global search: answering a scoped
|
|
20
|
+
* question with an unscoped result is the failure this narrowing exists to remove.
|
|
21
|
+
* @public
|
|
22
|
+
*/
|
|
23
|
+
export const FRAGMENT_NARROWING_UNRESOLVABLE_MESSAGE = 'fragment recall: a record narrowing was supplied but no identity resolver is wired; pass one to FragmentSemanticRetriever.create';
|
|
24
|
+
/**
|
|
25
|
+
* The message returned when exactly one of `entityId` / `kind` is supplied.
|
|
26
|
+
*
|
|
27
|
+
* @remarks
|
|
28
|
+
* They travel together because `kind` is what selects the identity codec, and the
|
|
29
|
+
* codec is what makes the resolution unambiguous. One without the other is not a
|
|
30
|
+
* partial narrowing that could be honored best-effort — it is not a narrowing at all.
|
|
31
|
+
* @public
|
|
32
|
+
*/
|
|
33
|
+
export const FRAGMENT_NARROWING_INCOMPLETE_MESSAGE = 'fragment recall: `entityId` and `kind` must be supplied together — `kind` selects the identity codec that resolves the narrowing';
|
|
13
34
|
/**
|
|
14
35
|
* The sub-document semantic-search retriever — the "discovery" half of a
|
|
15
36
|
* search-then-read contract. It embeds a fragment query, queries the
|
|
@@ -35,16 +56,26 @@ export const FRAGMENT_SEMANTIC_UNWIRED_MESSAGE = 'fragment recall: no fragment i
|
|
|
35
56
|
* @public
|
|
36
57
|
*/
|
|
37
58
|
export class FragmentSemanticRetriever {
|
|
38
|
-
constructor(backend) {
|
|
59
|
+
constructor(backend, identityResolver) {
|
|
39
60
|
this._backend = backend;
|
|
61
|
+
this._identityResolver = identityResolver;
|
|
40
62
|
}
|
|
41
63
|
/** What this retriever can do given its wiring. */
|
|
42
64
|
get capabilities() {
|
|
43
65
|
return { supportsFragmentRecall: this._backend !== undefined };
|
|
44
66
|
}
|
|
45
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Family-convention factory.
|
|
69
|
+
*
|
|
70
|
+
* @param params - `backend` wires fragment recall itself. `identityResolver`
|
|
71
|
+
* resolves a query's `(kind, entityId)` narrowing to a storage address;
|
|
72
|
+
* `IMemoryStore` implements it, so the usual wiring is
|
|
73
|
+
* `{ backend, identityResolver: store }`. It is optional because an unscoped
|
|
74
|
+
* fragment search needs nothing to resolve — but a query that *does* carry a
|
|
75
|
+
* narrowing fails loudly without it rather than quietly searching everything.
|
|
76
|
+
*/
|
|
46
77
|
static create(params) {
|
|
47
|
-
return succeed(new FragmentSemanticRetriever(params.backend));
|
|
78
|
+
return succeed(new FragmentSemanticRetriever(params.backend, params.identityResolver));
|
|
48
79
|
}
|
|
49
80
|
/**
|
|
50
81
|
* Embed `query.semantic`, query the fragment index, and return the per-fragment
|
|
@@ -55,6 +86,13 @@ export class FragmentSemanticRetriever {
|
|
|
55
86
|
return fail(FRAGMENT_SEMANTIC_UNWIRED_MESSAGE);
|
|
56
87
|
}
|
|
57
88
|
const backend = this._backend;
|
|
89
|
+
// Resolve the narrowing FIRST. It is synchronous, local, and cheap, while
|
|
90
|
+
// `embedQuery` is typically a paid network round trip — so a typo'd `kind`, a
|
|
91
|
+
// missing resolver, or a half-supplied narrowing should cost nothing.
|
|
92
|
+
const options = this._resolveOptions(query);
|
|
93
|
+
if (options.isFailure()) {
|
|
94
|
+
return fail(options.message);
|
|
95
|
+
}
|
|
58
96
|
// Consumer-supplied hooks may throw; normalize both a returned `fail` and a
|
|
59
97
|
// rejection into a single `fragment recall: <label> failed` Failure so
|
|
60
98
|
// `retrieve` always honors its `Promise<Result<...>>` contract.
|
|
@@ -62,7 +100,58 @@ export class FragmentSemanticRetriever {
|
|
|
62
100
|
if (embedded.isFailure()) {
|
|
63
101
|
return fail(embedded.message);
|
|
64
102
|
}
|
|
65
|
-
return FragmentSemanticRetriever._callBackend('fragment query', () => { var _a; return backend.fragmentIndex.query(embedded.value, (_a = query.topK) !== null && _a !== void 0 ? _a : 10,
|
|
103
|
+
return FragmentSemanticRetriever._callBackend('fragment query', () => { var _a; return backend.fragmentIndex.query(embedded.value, (_a = query.topK) !== null && _a !== void 0 ? _a : 10, options.value); });
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Turn the query's consumer-facing narrowing into the storage-address narrowing
|
|
107
|
+
* the index understands.
|
|
108
|
+
*
|
|
109
|
+
* @remarks
|
|
110
|
+
* `kind` selects the identity codec and the codec computes the record's storage
|
|
111
|
+
* address, so this is a deterministic resolution rather than a search —
|
|
112
|
+
* which is what makes a colliding `entityId` across kinds a non-issue.
|
|
113
|
+
*
|
|
114
|
+
* A **versioned** kind resolves to the entity's own subtree scope and deliberately
|
|
115
|
+
* carries no `id`, so the narrowing covers every version of the entity — including
|
|
116
|
+
* superseded ones, which are invalidated but never pruned from the index. A
|
|
117
|
+
* non-versioned kind resolves to exactly one record.
|
|
118
|
+
*/
|
|
119
|
+
_resolveOptions(query) {
|
|
120
|
+
const { entityId, kind, maxPerRecord } = query;
|
|
121
|
+
if (entityId === undefined && kind === undefined) {
|
|
122
|
+
return succeed({ maxPerRecord });
|
|
123
|
+
}
|
|
124
|
+
if (entityId === undefined || kind === undefined) {
|
|
125
|
+
return fail(FRAGMENT_NARROWING_INCOMPLETE_MESSAGE);
|
|
126
|
+
}
|
|
127
|
+
if (this._identityResolver === undefined) {
|
|
128
|
+
return fail(FRAGMENT_NARROWING_UNRESOLVABLE_MESSAGE);
|
|
129
|
+
}
|
|
130
|
+
const resolver = this._identityResolver;
|
|
131
|
+
// `identityResolver` is a consumer-injectable seam like the two backend hooks,
|
|
132
|
+
// so a throw has to become a `Failure` here rather than escaping `retrieve()`
|
|
133
|
+
// and breaking its `Promise<Result<...>>` contract. `captureResult` yields a
|
|
134
|
+
// nested `Result`, which the identity `onSuccess` flattens.
|
|
135
|
+
return captureResult(() => resolver.resolveIdentity(kind, entityId))
|
|
136
|
+
.onSuccess((resolved) => resolved)
|
|
137
|
+
.withErrorFormat((msg) => `fragment recall: cannot resolve '${kind}'/'${entityId}': ${msg}`)
|
|
138
|
+
.onSuccess((address) => {
|
|
139
|
+
// A versioned kind's every version lives under the entity subtree the
|
|
140
|
+
// codec returned, so omitting `id` is what makes the narrowing mean
|
|
141
|
+
// "this entity" rather than "one of its versions".
|
|
142
|
+
if (address.isVersioned) {
|
|
143
|
+
return succeed({ maxPerRecord, scope: address.scope });
|
|
144
|
+
}
|
|
145
|
+
// `idStem` is a plain `string` on the codec result, but a `MemoryId` IS the
|
|
146
|
+
// filename stem by contract and `Convert.memoryId` is what enforces that —
|
|
147
|
+
// so validate rather than assert. A resolver that returned a path-unsafe
|
|
148
|
+
// stem could otherwise smuggle it into the index query, where it would
|
|
149
|
+
// match nothing and look like an empty result rather than a caller bug.
|
|
150
|
+
return Convert.memoryId
|
|
151
|
+
.convert(address.idStem)
|
|
152
|
+
.withErrorFormat((msg) => `fragment recall: '${kind}'/'${entityId}' resolved to an unusable record id: ${msg}`)
|
|
153
|
+
.onSuccess((id) => succeed({ maxPerRecord, scope: address.scope, id }));
|
|
154
|
+
});
|
|
66
155
|
}
|
|
67
156
|
/**
|
|
68
157
|
* Invoke a consumer-supplied backend hook, normalizing both a returned `fail`
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fragmentSemanticRetriever.js","sourceRoot":"","sources":["../../../src/packlets/retrieve/fragmentSemanticRetriever.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAU,IAAI,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAItD;;;;;GAKG;AACH,MAAM,CAAC,MAAM,iCAAiC,GAC5C,6GAA6G,CAAC;AA0ChH;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,OAAO,yBAAyB;IAGpC,YAAoB,OAA6C;QAC/D,IAAI,CAAC,QAAQ,GAAG,OAAO,CAAC;IAC1B,CAAC;IAED,mDAAmD;IACnD,IAAW,YAAY;QACrB,OAAO,EAAE,sBAAsB,EAAE,IAAI,CAAC,QAAQ,KAAK,SAAS,EAAE,CAAC;IACjE,CAAC;IAED,iCAAiC;IAC1B,MAAM,CAAC,MAAM,CAAC,MAEpB;QACC,OAAO,OAAO,CAAC,IAAI,yBAAyB,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC;IAChE,CAAC;IAED;;;OAGG;IACI,KAAK,CAAC,QAAQ,CAAC,KAAqB;QACzC,IAAI,IAAI,CAAC,QAAQ,KAAK,SAAS,EAAE,CAAC;YAChC,OAAO,IAAI,CAAC,iCAAiC,CAAC,CAAC;QACjD,CAAC;QACD,MAAM,OAAO,GAA6B,IAAI,CAAC,QAAQ,CAAC;QACxD,4EAA4E;QAC5E,uEAAuE;QACvE,gEAAgE;QAChE,MAAM,QAAQ,GAAyB,MAAM,yBAAyB,CAAC,YAAY,CACjF,iBAAiB,EACjB,GAAG,EAAE,CAAC,OAAO,CAAC,UAAU,CAAC,KAAK,CAAC,QAAQ,CAAC,CACzC,CAAC;QACF,IAAI,QAAQ,CAAC,SAAS,EAAE,EAAE,CAAC;YACzB,OAAO,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;QAChC,CAAC;QACD,OAAO,yBAAyB,CAAC,YAAY,CAAC,gBAAgB,EAAE,GAAG,EAAE,WACnE,OAAA,OAAO,CAAC,aAAa,CAAC,KAAK,CAAC,QAAQ,CAAC,KAAK,EAAE,MAAA,KAAK,CAAC,IAAI,mCAAI,EAAE,EAAE,KAAK,CAAC,YAAY,CAAC,CAAA,EAAA,CAClF,CAAC;IACJ,CAAC;IAED;;;;OAIG;IACK,MAAM,CAAC,KAAK,CAAC,YAAY,CAAI,KAAa,EAAE,EAA4B;QAC9E,IAAI,CAAC;YACH,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,eAAe,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,oBAAoB,KAAK,YAAY,GAAG,EAAE,CAAC,CAAC;QAC3F,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,OAAO,IAAI,CAAC,oBAAoB,KAAK,YAAY,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAClE,CAAC;IACH,CAAC;CACF","sourcesContent":["/*\n * Copyright (c) 2026 Erik Fortune\n * SPDX-License-Identifier: MIT\n */\n\nimport { Result, fail, succeed } from '@fgv/ts-utils';\nimport { IFragmentVectorIndex, IVectorQueryHit } from '../vector';\nimport { QueryEmbedder } from './semanticRetriever';\n\n/**\n * The loud-degradation message a {@link FragmentSemanticRetriever} returns when a\n * fragment query is issued but no {@link IFragmentSemanticBackend | backend} is\n * wired — the discovery surface NEVER answers a fragment query with a silent empty.\n * @public\n */\nexport const FRAGMENT_SEMANTIC_UNWIRED_MESSAGE: string =\n 'fragment recall: no fragment index is wired; wire an IFragmentSemanticBackend to enable sub-document search';\n\n/**\n * The fragment backend wired into a {@link FragmentSemanticRetriever}: the fragment\n * index to query and the embedder that turns the query text into a vector. Both are\n * required together — a fragment index is useless without a way to embed the query.\n * @public\n */\nexport interface IFragmentSemanticBackend {\n /** The fragment-granular vector index to query. */\n readonly fragmentIndex: IFragmentVectorIndex;\n /** Turns the query text into a vector. */\n readonly embedQuery: QueryEmbedder;\n}\n\n/**\n * A sub-document semantic-search request: the natural-language `semantic` text to\n * match, an optional `topK` result cap (default 10), and an optional\n * `maxPerRecord` cap that keeps one long document from monopolizing the result.\n * @public\n */\nexport interface IFragmentQuery {\n /** The natural-language text to embed and match against stored fragments. */\n readonly semantic: string;\n /** Maximum number of fragment hits to return. Defaults to 10. */\n readonly topK?: number;\n /**\n * Maximum number of fragments any single record may contribute to the result.\n * Applied during selection (before the `topK` cut). Omit for uncapped.\n */\n readonly maxPerRecord?: number;\n}\n\n/**\n * What a {@link FragmentSemanticRetriever} can do given its wiring.\n * @public\n */\nexport interface IFragmentRetrieverCapabilities {\n /** `true` when a fragment backend is wired and fragment recall is operational. */\n readonly supportsFragmentRecall: boolean;\n}\n\n/**\n * The sub-document semantic-search retriever — the \"discovery\" half of a\n * search-then-read contract. It embeds a fragment query, queries the\n * {@link IFragmentVectorIndex}, and returns the raw per-fragment\n * {@link IVectorQueryHit | hits} (each carrying a record `target` AND whichever of\n * `locator` / `fragmentId` the stored fragment was indexed with), NOT resolved\n * records: the consumer re-reads each record and resolves the fragment on its own\n * read side. Note the `locator` span is advisory — see {@link IFragmentLocator}; it\n * is not a slice guaranteed to reproduce the fragment's text.\n *\n * @remarks\n * Deliberately NOT an {@link IMemoryRetriever}: memory recall is record-granular and\n * returns records; fragment discovery is fragment-granular and returns fragment\n * identities. Keeping it a distinct surface matches the consumer contract (memory\n * stays record-granular; sub-document knowledge uses a separate fragment index) and\n * avoids overloading the record retriever's return type with identity fields that\n * only make sense here.\n *\n * When no backend is wired, `supportsFragmentRecall` is `false` and any fragment\n * query degrades loudly ({@link FRAGMENT_SEMANTIC_UNWIRED_MESSAGE}) — it NEVER\n * returns a silent empty. A consumer-supplied backend that rejects (throws) is\n * normalized into a `Failure`.\n * @public\n */\nexport class FragmentSemanticRetriever {\n private readonly _backend: IFragmentSemanticBackend | undefined;\n\n private constructor(backend: IFragmentSemanticBackend | undefined) {\n this._backend = backend;\n }\n\n /** What this retriever can do given its wiring. */\n public get capabilities(): IFragmentRetrieverCapabilities {\n return { supportsFragmentRecall: this._backend !== undefined };\n }\n\n /** Family-convention factory. */\n public static create(params: {\n readonly backend?: IFragmentSemanticBackend;\n }): Result<FragmentSemanticRetriever> {\n return succeed(new FragmentSemanticRetriever(params.backend));\n }\n\n /**\n * Embed `query.semantic`, query the fragment index, and return the per-fragment\n * hits in descending score order. Fails loudly when no backend is wired.\n */\n public async retrieve(query: IFragmentQuery): Promise<Result<ReadonlyArray<IVectorQueryHit>>> {\n if (this._backend === undefined) {\n return fail(FRAGMENT_SEMANTIC_UNWIRED_MESSAGE);\n }\n const backend: IFragmentSemanticBackend = this._backend;\n // Consumer-supplied hooks may throw; normalize both a returned `fail` and a\n // rejection into a single `fragment recall: <label> failed` Failure so\n // `retrieve` always honors its `Promise<Result<...>>` contract.\n const embedded: Result<Float32Array> = await FragmentSemanticRetriever._callBackend(\n 'query embedding',\n () => backend.embedQuery(query.semantic)\n );\n if (embedded.isFailure()) {\n return fail(embedded.message);\n }\n return FragmentSemanticRetriever._callBackend('fragment query', () =>\n backend.fragmentIndex.query(embedded.value, query.topK ?? 10, query.maxPerRecord)\n );\n }\n\n /**\n * Invoke a consumer-supplied backend hook, normalizing both a returned `fail`\n * and a thrown/rejected promise into a single `fragment recall: <label> failed`\n * `Failure`.\n */\n private static async _callBackend<T>(label: string, op: () => Promise<Result<T>>): Promise<Result<T>> {\n try {\n return (await op()).withErrorFormat((msg) => `fragment recall: ${label} failed: ${msg}`);\n } catch (err) {\n return fail(`fragment recall: ${label} failed: ${String(err)}`);\n }\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"fragmentSemanticRetriever.js","sourceRoot":"","sources":["../../../src/packlets/retrieve/fragmentSemanticRetriever.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Copyright (c) 2026 Erik Fortune\n * SPDX-License-Identifier: MIT\n */\n\nimport { Result, captureResult, fail, succeed } from '@fgv/ts-utils';\nimport { Convert, EntityId, IIdentityCodecResult, IIdentityResolver, Kind, MemoryId } from '../types';\nimport { IFragmentQueryOptions, IFragmentVectorIndex, IVectorQueryHit } from '../vector';\nimport { QueryEmbedder } from './semanticRetriever';\n\n/**\n * The loud-degradation message a {@link FragmentSemanticRetriever} returns when a\n * fragment query is issued but no {@link IFragmentSemanticBackend | backend} is\n * wired — the discovery surface NEVER answers a fragment query with a silent empty.\n * @public\n */\nexport const FRAGMENT_SEMANTIC_UNWIRED_MESSAGE: string =\n 'fragment recall: no fragment index is wired; wire an IFragmentSemanticBackend to enable sub-document search';\n\n/**\n * The loud-degradation message returned when a query carries a record narrowing but\n * no {@link IIdentityResolver} is wired to resolve it.\n *\n * @remarks\n * Deliberately a `Failure` rather than a silently-global search: answering a scoped\n * question with an unscoped result is the failure this narrowing exists to remove.\n * @public\n */\nexport const FRAGMENT_NARROWING_UNRESOLVABLE_MESSAGE: string =\n 'fragment recall: a record narrowing was supplied but no identity resolver is wired; pass one to FragmentSemanticRetriever.create';\n\n/**\n * The message returned when exactly one of `entityId` / `kind` is supplied.\n *\n * @remarks\n * They travel together because `kind` is what selects the identity codec, and the\n * codec is what makes the resolution unambiguous. One without the other is not a\n * partial narrowing that could be honored best-effort — it is not a narrowing at all.\n * @public\n */\nexport const FRAGMENT_NARROWING_INCOMPLETE_MESSAGE: string =\n 'fragment recall: `entityId` and `kind` must be supplied together — `kind` selects the identity codec that resolves the narrowing';\n\n/**\n * The fragment backend wired into a {@link FragmentSemanticRetriever}: the fragment\n * index to query and the embedder that turns the query text into a vector. Both are\n * required together — a fragment index is useless without a way to embed the query.\n * @public\n */\nexport interface IFragmentSemanticBackend {\n /** The fragment-granular vector index to query. */\n readonly fragmentIndex: IFragmentVectorIndex;\n /** Turns the query text into a vector. */\n readonly embedQuery: QueryEmbedder;\n}\n\n/**\n * A sub-document semantic-search request: the natural-language `semantic` text to\n * match, an optional `topK` result cap (default 10), and an optional\n * `maxPerRecord` cap that keeps one long document from monopolizing the result.\n * @public\n */\nexport interface IFragmentQuery {\n /** The natural-language text to embed and match against stored fragments. */\n readonly semantic: string;\n /** Maximum number of fragment hits to return. Defaults to 10. */\n readonly topK?: number;\n /**\n * Maximum number of fragments any single record may contribute to the result.\n * Applied during selection (before the `topK` cut). Omit for uncapped.\n */\n readonly maxPerRecord?: number;\n\n /**\n * Narrow the search to one record's fragments: the consumer-supplied domain key\n * of the record to search within. **Must be supplied with\n * {@link IFragmentQuery.kind}.**\n *\n * @remarks\n * The narrowing is applied **during selection, before the `topK` cut**, so the\n * `topK` you ask for is the `topK` you get. Filtering a global result afterwards\n * is not equivalent: it truncates to `topK` across every record first, so a scoped\n * search would come back short whenever other records outscored this one's\n * fragments.\n *\n * For a versioned kind this narrows to **every version of the entity** — literally\n * every version, superseded ones included, because invalidation stamps `invalid_at`\n * without pruning that version's fragments. Nothing on a hit distinguishes a\n * current fragment from a historical one. That matches the record-granular vector\n * lane; it is not currency filtering.\n */\n readonly entityId?: EntityId;\n\n /**\n * The kind of the record named by {@link IFragmentQuery.entityId}. **Must be\n * supplied with it.**\n *\n * @remarks\n * This is not decoration and not a filter: `kind` **selects the identity codec**,\n * and the codec computes the storage address. An `EntityId` promises no uniqueness\n * beyond a scope — the same id under two kinds is the ordinary case, not a\n * pathological one — so without `kind` the resolution is ambiguous, and with it\n * ambiguity is structurally impossible.\n */\n readonly kind?: Kind;\n}\n\n/**\n * What a {@link FragmentSemanticRetriever} can do given its wiring.\n * @public\n */\nexport interface IFragmentRetrieverCapabilities {\n /** `true` when a fragment backend is wired and fragment recall is operational. */\n readonly supportsFragmentRecall: boolean;\n}\n\n/**\n * The sub-document semantic-search retriever — the \"discovery\" half of a\n * search-then-read contract. It embeds a fragment query, queries the\n * {@link IFragmentVectorIndex}, and returns the raw per-fragment\n * {@link IVectorQueryHit | hits} (each carrying a record `target` AND whichever of\n * `locator` / `fragmentId` the stored fragment was indexed with), NOT resolved\n * records: the consumer re-reads each record and resolves the fragment on its own\n * read side. Note the `locator` span is advisory — see {@link IFragmentLocator}; it\n * is not a slice guaranteed to reproduce the fragment's text.\n *\n * @remarks\n * Deliberately NOT an {@link IMemoryRetriever}: memory recall is record-granular and\n * returns records; fragment discovery is fragment-granular and returns fragment\n * identities. Keeping it a distinct surface matches the consumer contract (memory\n * stays record-granular; sub-document knowledge uses a separate fragment index) and\n * avoids overloading the record retriever's return type with identity fields that\n * only make sense here.\n *\n * When no backend is wired, `supportsFragmentRecall` is `false` and any fragment\n * query degrades loudly ({@link FRAGMENT_SEMANTIC_UNWIRED_MESSAGE}) — it NEVER\n * returns a silent empty. A consumer-supplied backend that rejects (throws) is\n * normalized into a `Failure`.\n * @public\n */\nexport class FragmentSemanticRetriever {\n private readonly _backend: IFragmentSemanticBackend | undefined;\n private readonly _identityResolver: IIdentityResolver | undefined;\n\n private constructor(\n backend: IFragmentSemanticBackend | undefined,\n identityResolver: IIdentityResolver | undefined\n ) {\n this._backend = backend;\n this._identityResolver = identityResolver;\n }\n\n /** What this retriever can do given its wiring. */\n public get capabilities(): IFragmentRetrieverCapabilities {\n return { supportsFragmentRecall: this._backend !== undefined };\n }\n\n /**\n * Family-convention factory.\n *\n * @param params - `backend` wires fragment recall itself. `identityResolver`\n * resolves a query's `(kind, entityId)` narrowing to a storage address;\n * `IMemoryStore` implements it, so the usual wiring is\n * `{ backend, identityResolver: store }`. It is optional because an unscoped\n * fragment search needs nothing to resolve — but a query that *does* carry a\n * narrowing fails loudly without it rather than quietly searching everything.\n */\n public static create(params: {\n readonly backend?: IFragmentSemanticBackend;\n readonly identityResolver?: IIdentityResolver;\n }): Result<FragmentSemanticRetriever> {\n return succeed(new FragmentSemanticRetriever(params.backend, params.identityResolver));\n }\n\n /**\n * Embed `query.semantic`, query the fragment index, and return the per-fragment\n * hits in descending score order. Fails loudly when no backend is wired.\n */\n public async retrieve(query: IFragmentQuery): Promise<Result<ReadonlyArray<IVectorQueryHit>>> {\n if (this._backend === undefined) {\n return fail(FRAGMENT_SEMANTIC_UNWIRED_MESSAGE);\n }\n const backend: IFragmentSemanticBackend = this._backend;\n // Resolve the narrowing FIRST. It is synchronous, local, and cheap, while\n // `embedQuery` is typically a paid network round trip — so a typo'd `kind`, a\n // missing resolver, or a half-supplied narrowing should cost nothing.\n const options: Result<IFragmentQueryOptions> = this._resolveOptions(query);\n if (options.isFailure()) {\n return fail(options.message);\n }\n // Consumer-supplied hooks may throw; normalize both a returned `fail` and a\n // rejection into a single `fragment recall: <label> failed` Failure so\n // `retrieve` always honors its `Promise<Result<...>>` contract.\n const embedded: Result<Float32Array> = await FragmentSemanticRetriever._callBackend(\n 'query embedding',\n () => backend.embedQuery(query.semantic)\n );\n if (embedded.isFailure()) {\n return fail(embedded.message);\n }\n return FragmentSemanticRetriever._callBackend('fragment query', () =>\n backend.fragmentIndex.query(embedded.value, query.topK ?? 10, options.value)\n );\n }\n\n /**\n * Turn the query's consumer-facing narrowing into the storage-address narrowing\n * the index understands.\n *\n * @remarks\n * `kind` selects the identity codec and the codec computes the record's storage\n * address, so this is a deterministic resolution rather than a search —\n * which is what makes a colliding `entityId` across kinds a non-issue.\n *\n * A **versioned** kind resolves to the entity's own subtree scope and deliberately\n * carries no `id`, so the narrowing covers every version of the entity — including\n * superseded ones, which are invalidated but never pruned from the index. A\n * non-versioned kind resolves to exactly one record.\n */\n private _resolveOptions(query: IFragmentQuery): Result<IFragmentQueryOptions> {\n const { entityId, kind, maxPerRecord } = query;\n if (entityId === undefined && kind === undefined) {\n return succeed({ maxPerRecord });\n }\n if (entityId === undefined || kind === undefined) {\n return fail(FRAGMENT_NARROWING_INCOMPLETE_MESSAGE);\n }\n if (this._identityResolver === undefined) {\n return fail(FRAGMENT_NARROWING_UNRESOLVABLE_MESSAGE);\n }\n const resolver: IIdentityResolver = this._identityResolver;\n // `identityResolver` is a consumer-injectable seam like the two backend hooks,\n // so a throw has to become a `Failure` here rather than escaping `retrieve()`\n // and breaking its `Promise<Result<...>>` contract. `captureResult` yields a\n // nested `Result`, which the identity `onSuccess` flattens.\n return captureResult(() => resolver.resolveIdentity(kind, entityId))\n .onSuccess((resolved: Result<IIdentityCodecResult>) => resolved)\n .withErrorFormat((msg) => `fragment recall: cannot resolve '${kind}'/'${entityId}': ${msg}`)\n .onSuccess((address: IIdentityCodecResult) => {\n // A versioned kind's every version lives under the entity subtree the\n // codec returned, so omitting `id` is what makes the narrowing mean\n // \"this entity\" rather than \"one of its versions\".\n if (address.isVersioned) {\n return succeed({ maxPerRecord, scope: address.scope });\n }\n // `idStem` is a plain `string` on the codec result, but a `MemoryId` IS the\n // filename stem by contract and `Convert.memoryId` is what enforces that —\n // so validate rather than assert. A resolver that returned a path-unsafe\n // stem could otherwise smuggle it into the index query, where it would\n // match nothing and look like an empty result rather than a caller bug.\n return Convert.memoryId\n .convert(address.idStem)\n .withErrorFormat(\n (msg) => `fragment recall: '${kind}'/'${entityId}' resolved to an unusable record id: ${msg}`\n )\n .onSuccess((id: MemoryId) => succeed({ maxPerRecord, scope: address.scope, id }));\n });\n }\n\n /**\n * Invoke a consumer-supplied backend hook, normalizing both a returned `fail`\n * and a thrown/rejected promise into a single `fragment recall: <label> failed`\n * `Failure`.\n */\n private static async _callBackend<T>(label: string, op: () => Promise<Result<T>>): Promise<Result<T>> {\n try {\n return (await op()).withErrorFormat((msg) => `fragment recall: ${label} failed: ${msg}`);\n } catch (err) {\n return fail(`fragment recall: ${label} failed: ${String(err)}`);\n }\n }\n}\n"]}
|
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
* SPDX-License-Identifier: MIT
|
|
4
4
|
*/
|
|
5
5
|
import { Hash, Logging, fail, mapResults, mapSuccess, succeed } from '@fgv/ts-utils';
|
|
6
|
-
import { FileTree } from '@fgv/ts-json-base';
|
|
7
6
|
import { DEFAULT_DEDUP_SCOPE, KnowledgeLwwPolicy, isTemporalIdentityCodec, isTemporalRecord, isVersionCurrent, selectCurrentVersion, selectVersionAsOf } from '../types';
|
|
8
7
|
import { parseMemoryFile, serializeMemoryFile, splitFrontmatter } from '../converters';
|
|
9
8
|
import { VectorMaintenance } from './vectorMaintenance';
|
|
10
9
|
import { computeCoverage } from './storeCoverage';
|
|
10
|
+
import { codecFor, resolveIdentity, verifyLoadedIdentity, verifyOccupantKind } from './storeIdentity';
|
|
11
|
+
import { deleteRecordFile, resolveScopeDir, writeRecordFile } from './storeFileAccess';
|
|
11
12
|
import { reconcileVectors } from './storeReconcile';
|
|
12
13
|
import { MemoryIndex } from '../index';
|
|
13
14
|
import { defaultMemoryScopeEncoding } from './scopeEncoding';
|
|
@@ -119,7 +120,7 @@ export class FileTreeMemoryStore {
|
|
|
119
120
|
/** {@inheritDoc IMemoryStore.get} */
|
|
120
121
|
async get(kind, entityId) {
|
|
121
122
|
var _a;
|
|
122
|
-
const result = this.
|
|
123
|
+
const result = codecFor(this._codecs, this._defaultCodec, kind).onSuccess((codec) => codec.encode(entityId).onSuccess((addr) => {
|
|
123
124
|
if (addr.isVersioned) {
|
|
124
125
|
if (!isTemporalIdentityCodec(codec)) {
|
|
125
126
|
return fail(`memory get '${entityId}': codec for versioned kind '${kind}' does not implement the temporal codec interface`);
|
|
@@ -128,9 +129,9 @@ export class FileTreeMemoryStore {
|
|
|
128
129
|
// version whose `invalid_at` is null/absent) from the entity subtree,
|
|
129
130
|
// read off the derived index. `asOf` resolution is via `list({ asOf })`
|
|
130
131
|
// and the temporal retrievers.
|
|
131
|
-
return this._readVersionedCurrent(addr.scope);
|
|
132
|
+
return this._readVersionedCurrent(addr.scope, kind);
|
|
132
133
|
}
|
|
133
|
-
return this._readRecord(addr.scope, addr.idStem);
|
|
134
|
+
return this._readRecord(addr.scope, addr.idStem, kind);
|
|
134
135
|
}));
|
|
135
136
|
await this._fireObservation('read', kind, entityId, {
|
|
136
137
|
outcome: result.isSuccess() ? 'success' : 'failure',
|
|
@@ -225,6 +226,13 @@ export class FileTreeMemoryStore {
|
|
|
225
226
|
resolveRecord(scope, id) {
|
|
226
227
|
return this._readRecord(scope, id);
|
|
227
228
|
}
|
|
229
|
+
_codec(kind) {
|
|
230
|
+
return codecFor(this._codecs, this._defaultCodec, kind);
|
|
231
|
+
}
|
|
232
|
+
/** {@inheritDoc IIdentityResolver.resolveIdentity} */
|
|
233
|
+
resolveIdentity(kind, entityId) {
|
|
234
|
+
return resolveIdentity(this._codecs, this._defaultCodec, kind, entityId);
|
|
235
|
+
}
|
|
228
236
|
/**
|
|
229
237
|
* Materialize a selected set of entries into records, dropping any that have
|
|
230
238
|
* vanished since selection.
|
|
@@ -414,7 +422,7 @@ export class FileTreeMemoryStore {
|
|
|
414
422
|
}
|
|
415
423
|
/** Resolve a scope for an observation, best-effort (undefined when unresolvable). */
|
|
416
424
|
_scopeBestEffort(kind, entityId) {
|
|
417
|
-
return this.
|
|
425
|
+
return this._codec(kind)
|
|
418
426
|
.onSuccess((codec) => codec.encode(entityId))
|
|
419
427
|
.onSuccess((addr) => succeed(addr.scope))
|
|
420
428
|
.orDefault();
|
|
@@ -432,7 +440,7 @@ export class FileTreeMemoryStore {
|
|
|
432
440
|
return this._registry
|
|
433
441
|
.convert(envelope.kind, body)
|
|
434
442
|
.withErrorFormat((msg) => `memory put '${envelope.id}': invalid body: ${msg}`)
|
|
435
|
-
.onSuccess(() => this.
|
|
443
|
+
.onSuccess(() => this._codec(envelope.kind))
|
|
436
444
|
.thenOnSuccess((codec) => codec.encode(envelope.entityId).thenOnSuccess((addr) => {
|
|
437
445
|
if (addr.isVersioned) {
|
|
438
446
|
if (!isTemporalIdentityCodec(codec)) {
|
|
@@ -482,7 +490,7 @@ export class FileTreeMemoryStore {
|
|
|
482
490
|
return succeed({ record: duplicate.value, evicted: [] });
|
|
483
491
|
}
|
|
484
492
|
}
|
|
485
|
-
return this._readRecord(scope, idStem).thenOnSuccess((existing) => {
|
|
493
|
+
return this._readRecord(scope, idStem, record.envelope.kind).thenOnSuccess((existing) => {
|
|
486
494
|
// Same-id re-put is a no-op ONLY when the content hash matches AND the
|
|
487
495
|
// mutable metadata is also unchanged. The content hash covers
|
|
488
496
|
// { kind, body, links }; a matching hash with revised tags/provenance is a
|
|
@@ -608,19 +616,19 @@ export class FileTreeMemoryStore {
|
|
|
608
616
|
/** Serialize and write a fully-stamped record, then patch the index. */
|
|
609
617
|
_persist(record, scope, idStem) {
|
|
610
618
|
return serializeMemoryFile(record.envelope, record.body)
|
|
611
|
-
.onSuccess((raw) => this.
|
|
619
|
+
.onSuccess((raw) => writeRecordFile(this._root, this._scopeEncoding, scope, idStem, raw))
|
|
612
620
|
.onSuccess(() => this._index.patch('put', { scope, record }))
|
|
613
621
|
.onSuccess(() => succeed(record));
|
|
614
622
|
}
|
|
615
623
|
async _deleteLocked(kind, entityId) {
|
|
616
|
-
return this.
|
|
624
|
+
return this._codec(kind).thenOnSuccess((codec) => codec.encode(entityId).thenOnSuccess((addr) => {
|
|
617
625
|
if (addr.isVersioned) {
|
|
618
626
|
if (!isTemporalIdentityCodec(codec)) {
|
|
619
627
|
return Promise.resolve(fail(`memory delete '${entityId}': codec for versioned kind '${kind}' does not implement the temporal codec interface`));
|
|
620
628
|
}
|
|
621
|
-
return this._deleteVersioned(entityId, addr.scope);
|
|
629
|
+
return this._deleteVersioned(entityId, addr.scope, kind);
|
|
622
630
|
}
|
|
623
|
-
return this._deleteFlat(entityId, addr.scope, addr.idStem);
|
|
631
|
+
return this._deleteFlat(entityId, addr.scope, addr.idStem, kind);
|
|
624
632
|
}));
|
|
625
633
|
}
|
|
626
634
|
/**
|
|
@@ -628,15 +636,15 @@ export class FileTreeMemoryStore {
|
|
|
628
636
|
* entry, then prune the vector best-effort. Structurally unchanged from the
|
|
629
637
|
* pre-temporal delete path.
|
|
630
638
|
*/
|
|
631
|
-
async _deleteFlat(entityId, scope, idStem) {
|
|
632
|
-
return this._readRecord(scope, idStem).thenOnSuccess((existing) => {
|
|
639
|
+
async _deleteFlat(entityId, scope, idStem, kind) {
|
|
640
|
+
return this._readRecord(scope, idStem, kind).thenOnSuccess((existing) => {
|
|
633
641
|
if (existing === undefined) {
|
|
634
642
|
return Promise.resolve(fail(`memory delete '${entityId}': no record found`));
|
|
635
643
|
}
|
|
636
644
|
// Delete the record file + index entry (authoritative), then prune the
|
|
637
645
|
// vector best-effort: a committed delete must not fail because the
|
|
638
646
|
// derived index could not be pruned.
|
|
639
|
-
return this.
|
|
647
|
+
return deleteRecordFile(this._root, this._scopeEncoding, scope, idStem)
|
|
640
648
|
.onSuccess(() => this._index.patch('delete', { scope, record: existing }))
|
|
641
649
|
.thenOnSuccess(async () => {
|
|
642
650
|
await this._vectors.removeAll({ scope, id: existing.envelope.id });
|
|
@@ -650,7 +658,7 @@ export class FileTreeMemoryStore {
|
|
|
650
658
|
* null/absent. `undefined` when the entity has no current version (never
|
|
651
659
|
* written, or fully invalidated / soft-deleted).
|
|
652
660
|
*/
|
|
653
|
-
_readVersionedCurrent(scope) {
|
|
661
|
+
_readVersionedCurrent(scope, expectedKind) {
|
|
654
662
|
// Select over ENVELOPES, then materialize the one winner — which is what
|
|
655
663
|
// `IMemoryStore.get`'s docstring promises and what `selectCurrentVersion`
|
|
656
664
|
// being generic over `IEnvelopeCarrier` exists for. Materializing every
|
|
@@ -660,21 +668,21 @@ export class FileTreeMemoryStore {
|
|
|
660
668
|
if (current === undefined) {
|
|
661
669
|
return succeed(undefined);
|
|
662
670
|
}
|
|
663
|
-
return this._resolveRequired(current);
|
|
671
|
+
return this._resolveRequired(current).onSuccess((materialized) => verifyOccupantKind(expectedKind, scope, materialized.envelope.id, materialized));
|
|
664
672
|
}
|
|
665
673
|
/**
|
|
666
674
|
* Every persisted version of the entity whose subtree is `scope`. All version
|
|
667
675
|
* files for one entity live under exactly that scope (which encodes the
|
|
668
676
|
* entityId), so a scope filter over the index isolates one entity's versions.
|
|
669
677
|
*/
|
|
670
|
-
_versionsForEntity(scope) {
|
|
678
|
+
_versionsForEntity(scope, expectedKind) {
|
|
671
679
|
// Selected on the envelope (scope), materialized after — so a versioned write
|
|
672
680
|
// reads only that entity's versions, never the vault. Bounded by the entity's
|
|
673
681
|
// version count.
|
|
674
682
|
return mapResults(this._index
|
|
675
683
|
.entries()
|
|
676
684
|
.filter((entry) => entry.scope === scope)
|
|
677
|
-
.map((entry) => this._resolveRequired(entry)));
|
|
685
|
+
.map((entry) => this._resolveRequired(entry).onSuccess((r) => verifyOccupantKind(expectedKind, scope, r.envelope.id, r))));
|
|
678
686
|
}
|
|
679
687
|
/**
|
|
680
688
|
* Versioned write (invalidate-don't-delete). Builds the new version's content
|
|
@@ -696,7 +704,7 @@ export class FileTreeMemoryStore {
|
|
|
696
704
|
// still-current version at snapshot time — normally one, but two-or-more if a
|
|
697
705
|
// prior invalidation partially failed; invalidating all of them lets the write
|
|
698
706
|
// self-heal a stuck state (P2-7).
|
|
699
|
-
const snapshot = this._versionsForEntity(scope);
|
|
707
|
+
const snapshot = this._versionsForEntity(scope, kind);
|
|
700
708
|
if (snapshot.isFailure()) {
|
|
701
709
|
return fail(snapshot.message);
|
|
702
710
|
}
|
|
@@ -825,8 +833,8 @@ export class FileTreeMemoryStore {
|
|
|
825
833
|
* kinds exist to preserve the audit trail (and the L3 `contradicts` interlock
|
|
826
834
|
* builds on it), so a hard delete would defeat the purpose.
|
|
827
835
|
*/
|
|
828
|
-
async _deleteVersioned(entityId, scope) {
|
|
829
|
-
const snapshot = this._versionsForEntity(scope);
|
|
836
|
+
async _deleteVersioned(entityId, scope, kind) {
|
|
837
|
+
const snapshot = this._versionsForEntity(scope, kind);
|
|
830
838
|
if (snapshot.isFailure()) {
|
|
831
839
|
return fail(snapshot.message);
|
|
832
840
|
}
|
|
@@ -849,7 +857,7 @@ export class FileTreeMemoryStore {
|
|
|
849
857
|
if (existing === undefined) {
|
|
850
858
|
return fail(`memory put: cannot evict '${id}' in scope '${scope}': not found`);
|
|
851
859
|
}
|
|
852
|
-
return this.
|
|
860
|
+
return deleteRecordFile(this._root, this._scopeEncoding, scope, id)
|
|
853
861
|
.onSuccess(() => this._index.patch('delete', { scope, record: existing }))
|
|
854
862
|
.onSuccess(() => succeed(id));
|
|
855
863
|
});
|
|
@@ -999,7 +1007,7 @@ export class FileTreeMemoryStore {
|
|
|
999
1007
|
* already-consistent store touches no files.
|
|
1000
1008
|
*/
|
|
1001
1009
|
_rewriteEnvelope(scope, id, mutate) {
|
|
1002
|
-
return this.
|
|
1010
|
+
return resolveScopeDir(this._root, this._scopeEncoding, scope).onSuccess((scopeDir) => {
|
|
1003
1011
|
/* c8 ignore next 3 - defensive: the scope dir exists for any indexed record */
|
|
1004
1012
|
if (scopeDir === undefined) {
|
|
1005
1013
|
return fail(`'${id}': scope '${scope}' not found`);
|
|
@@ -1074,14 +1082,6 @@ export class FileTreeMemoryStore {
|
|
|
1074
1082
|
return { envelope: Object.assign(Object.assign({}, record.envelope), { rank: undefined }), body: record.body };
|
|
1075
1083
|
}
|
|
1076
1084
|
}
|
|
1077
|
-
_codecFor(kind) {
|
|
1078
|
-
var _a;
|
|
1079
|
-
const codec = (_a = this._codecs.get(kind)) !== null && _a !== void 0 ? _a : this._defaultCodec;
|
|
1080
|
-
if (codec === undefined) {
|
|
1081
|
-
return fail(`no identity codec registered for kind '${kind}'`);
|
|
1082
|
-
}
|
|
1083
|
-
return succeed(codec);
|
|
1084
|
-
}
|
|
1085
1085
|
_policyFor(kind) {
|
|
1086
1086
|
var _a;
|
|
1087
1087
|
return (_a = this._writePolicies.get(kind)) !== null && _a !== void 0 ? _a : this._defaultPolicy;
|
|
@@ -1102,8 +1102,8 @@ export class FileTreeMemoryStore {
|
|
|
1102
1102
|
* when the scope directory or file is absent. Verifies the on-disk id ↔
|
|
1103
1103
|
* filename round-trip on every load.
|
|
1104
1104
|
*/
|
|
1105
|
-
_readRecord(scope, idStem) {
|
|
1106
|
-
return this.
|
|
1105
|
+
_readRecord(scope, idStem, expectedKind) {
|
|
1106
|
+
return resolveScopeDir(this._root, this._scopeEncoding, scope).onSuccess((scopeDir) => {
|
|
1107
1107
|
if (scopeDir === undefined) {
|
|
1108
1108
|
return succeed(undefined);
|
|
1109
1109
|
}
|
|
@@ -1116,7 +1116,8 @@ export class FileTreeMemoryStore {
|
|
|
1116
1116
|
return file
|
|
1117
1117
|
.getRawContents()
|
|
1118
1118
|
.onSuccess((raw) => parseMemoryFile(raw, this._registry))
|
|
1119
|
-
.onSuccess((parsedRecord) => this._verifyLoaded(scope, file, parsedRecord))
|
|
1119
|
+
.onSuccess((parsedRecord) => this._verifyLoaded(scope, file, parsedRecord))
|
|
1120
|
+
.onSuccess((loaded) => verifyOccupantKind(expectedKind, scope, idStem, loaded));
|
|
1120
1121
|
});
|
|
1121
1122
|
});
|
|
1122
1123
|
}
|
|
@@ -1130,95 +1131,7 @@ export class FileTreeMemoryStore {
|
|
|
1130
1131
|
* verbatim downstream (e.g. merge-into re-addressing). Cross-check it here.
|
|
1131
1132
|
*/
|
|
1132
1133
|
_verifyLoaded(scope, file, record) {
|
|
1133
|
-
|
|
1134
|
-
return fail(`memory file '${file.absolutePath}': envelope id '${record.envelope.id}' does not match filename stem '${file.baseName}'`);
|
|
1135
|
-
}
|
|
1136
|
-
return this._codecFor(record.envelope.kind)
|
|
1137
|
-
.onSuccess((codec) => codec.verifyRoundTrip(scope, file.baseName).onSuccess(() => codec.decode(scope, file.baseName)))
|
|
1138
|
-
.withErrorFormat((msg) => `memory file '${file.absolutePath}': ${msg}`)
|
|
1139
|
-
.onSuccess((decodedEntityId) => {
|
|
1140
|
-
if (decodedEntityId !== record.envelope.entityId) {
|
|
1141
|
-
return fail(`memory file '${file.absolutePath}': envelope entityId '${record.envelope.entityId}' does not match scope-derived entityId '${decodedEntityId}'`);
|
|
1142
|
-
}
|
|
1143
|
-
return succeed(record);
|
|
1144
|
-
});
|
|
1145
|
-
}
|
|
1146
|
-
/**
|
|
1147
|
-
* Resolve the directory for a scope, returning `undefined` when it does not
|
|
1148
|
-
* exist. Navigation only — does not create. Folds the path segments through
|
|
1149
|
-
* `getChildren` so an absent segment short-circuits to `undefined`.
|
|
1150
|
-
*/
|
|
1151
|
-
_resolveScopeDir(scope) {
|
|
1152
|
-
return this._scopeEncoding(scope).onSuccess((encoded) => {
|
|
1153
|
-
const segments = encoded.split('/').filter((s) => s.length > 0);
|
|
1154
|
-
return segments.reduce((acc, segment) => acc.onSuccess((current) => {
|
|
1155
|
-
if (current === undefined) {
|
|
1156
|
-
return succeed(undefined);
|
|
1157
|
-
}
|
|
1158
|
-
return current
|
|
1159
|
-
.getChildren()
|
|
1160
|
-
.onSuccess((children) => succeed(children.find((c) => c.type === 'directory' && c.name === segment)));
|
|
1161
|
-
}), succeed(this._root));
|
|
1162
|
-
});
|
|
1163
|
-
}
|
|
1164
|
-
/** Ensure the scope directory exists, creating segments as needed. */
|
|
1165
|
-
_ensureScopeDir(scope) {
|
|
1166
|
-
return this._scopeEncoding(scope).onSuccess((encoded) => {
|
|
1167
|
-
const segments = encoded.split('/').filter((s) => s.length > 0);
|
|
1168
|
-
return segments.reduce((acc, segment) => acc.onSuccess((current) => current.getChildren().onSuccess((children) => {
|
|
1169
|
-
const existing = children.find((c) => c.type === 'directory' && c.name === segment);
|
|
1170
|
-
if (existing === undefined) {
|
|
1171
|
-
return current.createChildDirectory(segment);
|
|
1172
|
-
}
|
|
1173
|
-
/* c8 ignore next 3 -- defensive: a child of a mutable in-memory/fs tree is itself mutable; the guard protects against a read-only adapter handed in as root */
|
|
1174
|
-
if (!FileTree.isMutableDirectoryItem(existing)) {
|
|
1175
|
-
return fail(`${existing.absolutePath}: directory is not mutable`);
|
|
1176
|
-
}
|
|
1177
|
-
return succeed(existing);
|
|
1178
|
-
})), succeed(this._root));
|
|
1179
|
-
});
|
|
1180
|
-
}
|
|
1181
|
-
/** Write (create or overwrite) `<scope>/<idStem>.md` with `raw`. */
|
|
1182
|
-
_writeFile(scope, idStem, raw) {
|
|
1183
|
-
return this._ensureScopeDir(scope).onSuccess((scopeDir) => scopeDir.getChildren().onSuccess((children) => {
|
|
1184
|
-
const fileName = `${idStem}${MEMORY_FILE_EXTENSION}`;
|
|
1185
|
-
const existing = children.find((c) => c.type === 'file' && c.name === fileName);
|
|
1186
|
-
if (existing === undefined) {
|
|
1187
|
-
return scopeDir.createChildFile(fileName, raw).onSuccess(() => succeed(true));
|
|
1188
|
-
}
|
|
1189
|
-
/* c8 ignore next 3 -- defensive: a file in a mutable tree is mutable; guards a read-only adapter */
|
|
1190
|
-
if (!FileTree.isMutableFileItem(existing)) {
|
|
1191
|
-
return fail(`${existing.absolutePath}: file is not mutable`);
|
|
1192
|
-
}
|
|
1193
|
-
return existing.setRawContents(raw).onSuccess(() => succeed(true));
|
|
1194
|
-
}));
|
|
1195
|
-
}
|
|
1196
|
-
/**
|
|
1197
|
-
* Physically delete `<scope>/<idStem>.md`. The scope-missing and file-missing
|
|
1198
|
-
* guards are unreachable through the callers (`delete` / `_evict` both read the
|
|
1199
|
-
* record first, so the directory and file exist) but are kept so a future
|
|
1200
|
-
* direct caller degrades loudly rather than silently.
|
|
1201
|
-
*/
|
|
1202
|
-
_deleteFile(scope, idStem) {
|
|
1203
|
-
return this._resolveScopeDir(scope).onSuccess((scopeDir) => {
|
|
1204
|
-
/* c8 ignore next 3 -- unreachable: callers read the record (hence the scope dir) first */
|
|
1205
|
-
if (scopeDir === undefined) {
|
|
1206
|
-
return fail(`memory delete: scope '${scope}' not found`);
|
|
1207
|
-
}
|
|
1208
|
-
const fileName = `${idStem}${MEMORY_FILE_EXTENSION}`;
|
|
1209
|
-
return scopeDir.getChildren().onSuccess((children) => {
|
|
1210
|
-
const file = children.find((c) => c.type === 'file' && c.name === fileName);
|
|
1211
|
-
/* c8 ignore next 3 -- unreachable: callers read the record (hence the file) first */
|
|
1212
|
-
if (file === undefined) {
|
|
1213
|
-
return fail(`memory delete: file '${fileName}' not found in scope '${scope}'`);
|
|
1214
|
-
}
|
|
1215
|
-
/* c8 ignore next 3 -- defensive: a file in a mutable tree is mutable; guards a read-only adapter */
|
|
1216
|
-
if (!FileTree.isMutableFileItem(file)) {
|
|
1217
|
-
return fail(`${file.absolutePath}: file is not mutable`);
|
|
1218
|
-
}
|
|
1219
|
-
return file.delete().onSuccess(() => succeed(true));
|
|
1220
|
-
});
|
|
1221
|
-
});
|
|
1134
|
+
return verifyLoadedIdentity(this._codec(record.envelope.kind), scope, file, record);
|
|
1222
1135
|
}
|
|
1223
1136
|
/**
|
|
1224
1137
|
* Walk the FileTree once and rebuild the index. Also resumes the `seq`
|