@nanobpm/urban 0.69.0 → 0.70.0
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/context/retrieval/cache.d.ts +35 -0
- package/dist/context/retrieval/cache.js +75 -0
- package/dist/context/retrieval/embedding.d.ts +27 -0
- package/dist/context/retrieval/embedding.js +13 -0
- package/dist/context/retrieval/index.d.ts +5 -1
- package/dist/context/retrieval/index.js +21 -1
- package/dist/context/retrieval/query.d.ts +50 -0
- package/dist/context/retrieval/query.js +62 -0
- package/dist/context/retrieval/retriever.d.ts +63 -0
- package/dist/context/retrieval/retriever.js +136 -0
- package/dist/context/retrieval/source.d.ts +71 -0
- package/dist/context/retrieval/source.js +141 -0
- package/package.json +2 -2
- package/src/context/retrieval/NOTES.md +68 -0
- package/src/context/retrieval/cache.ts +95 -0
- package/src/context/retrieval/embedding.ts +45 -0
- package/src/context/retrieval/index.ts +47 -2
- package/src/context/retrieval/query.test.ts +72 -0
- package/src/context/retrieval/query.ts +118 -0
- package/src/context/retrieval/retriever.test.ts +187 -0
- package/src/context/retrieval/retriever.ts +193 -0
- package/src/context/retrieval/source.test.ts +118 -0
- package/src/context/retrieval/source.ts +218 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Slice S4 (retrieval) — integration tests. The substrate is populated via S3's
|
|
2
|
+
// REAL write path (ContextWriter over a local temp git repo); nothing touches
|
|
3
|
+
// the network. This proves retrieval reads the exact layout S3 writes and that
|
|
4
|
+
// the derived cache is a correct, disposable projection of git.
|
|
5
|
+
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { after, test } from "node:test";
|
|
12
|
+
import { promisify } from "node:util";
|
|
13
|
+
import { ContextWriter } from "../git/index.ts";
|
|
14
|
+
import type { MemoryRecord } from "../schema/index.ts";
|
|
15
|
+
import { ContextRetriever } from "./retriever.ts";
|
|
16
|
+
|
|
17
|
+
const execFileAsync = promisify(execFile);
|
|
18
|
+
const TEMP_ROOTS: string[] = [];
|
|
19
|
+
|
|
20
|
+
after(async () => {
|
|
21
|
+
await Promise.all(TEMP_ROOTS.map((dir) => rm(dir, { recursive: true, force: true })));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
async function git(cwd: string, ...args: string[]): Promise<void> {
|
|
25
|
+
await execFileAsync("git", args, { cwd });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A temp git repo with an initial commit on `main`, used as the substrate. */
|
|
29
|
+
async function makeSubstrate(): Promise<string> {
|
|
30
|
+
const dir = await mkdtemp(join(tmpdir(), "s4-substrate-"));
|
|
31
|
+
TEMP_ROOTS.push(dir);
|
|
32
|
+
await git(dir, "init");
|
|
33
|
+
await writeFile(join(dir, "README.md"), "# context substrate\n", "utf8");
|
|
34
|
+
await git(dir, "add", "-A");
|
|
35
|
+
await git(dir, "-c", "user.name=seed", "-c", "user.email=seed@nanobpm.local", "commit", "--no-gpg-sign", "-m", "seed");
|
|
36
|
+
await git(dir, "branch", "-M", "main");
|
|
37
|
+
return dir;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function rec(overrides: Partial<MemoryRecord>): MemoryRecord {
|
|
41
|
+
const base: MemoryRecord = {
|
|
42
|
+
schemaVersion: 1,
|
|
43
|
+
id: "rec-1",
|
|
44
|
+
scope: "epic",
|
|
45
|
+
scopeRef: "issue-303",
|
|
46
|
+
mode: "empirical",
|
|
47
|
+
provenance: "measured",
|
|
48
|
+
authority: "authoritative",
|
|
49
|
+
statement: "the measured throughput was 42 rps",
|
|
50
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
51
|
+
};
|
|
52
|
+
return { ...base, ...overrides };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Populate a fresh substrate with a mix of records via S3's write path. */
|
|
56
|
+
async function seededSubstrate(): Promise<{ dir: string; writer: ContextWriter }> {
|
|
57
|
+
const dir = await makeSubstrate();
|
|
58
|
+
const writer = new ContextWriter({ localPath: dir, ref: "main" });
|
|
59
|
+
await writer.appendRecord(rec({ id: "epic-measured", scope: "epic", scopeRef: "issue-303", statement: "the measured throughput was 42 rps" }));
|
|
60
|
+
await writer.appendRecord(rec({ id: "epic-human", scope: "epic", scopeRef: "issue-303", provenance: "human", mode: "normative", authority: "authoritative", statement: "prefer explicit errors over silent fallbacks" }));
|
|
61
|
+
await writer.appendRecord(rec({ id: "epic-other-ns", scope: "epic", scopeRef: "issue-999", provenance: "instance", mode: "empirical", authority: "authoritative", statement: "instance run observed a retry storm" }));
|
|
62
|
+
await writer.appendRecord(rec({ id: "repo-norm", scope: "repo", scopeRef: "nano-ide", provenance: "human", mode: "normative", authority: "authoritative", statement: "all writes go through governance" }));
|
|
63
|
+
await writer.appendRecord(rec({ id: "corpus-fact", scope: "corpus", scopeRef: undefined, provenance: "measured", mode: "empirical", authority: "authoritative", statement: "throughput baseline across the corpus" }));
|
|
64
|
+
return { dir, writer };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
test("retrieves all records written via the S3 write path", async () => {
|
|
68
|
+
const { dir } = await seededSubstrate();
|
|
69
|
+
const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
|
|
70
|
+
const all = await retriever.all();
|
|
71
|
+
assert.deepEqual(all.map((s) => s.record.id).sort(), [
|
|
72
|
+
"corpus-fact",
|
|
73
|
+
"epic-human",
|
|
74
|
+
"epic-measured",
|
|
75
|
+
"epic-other-ns",
|
|
76
|
+
"repo-norm",
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("is correct on a COLD cache (reads through to git without warming)", async () => {
|
|
81
|
+
const { dir } = await seededSubstrate();
|
|
82
|
+
const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
|
|
83
|
+
assert.equal(retriever.cache.isWarm, false);
|
|
84
|
+
|
|
85
|
+
// A path-scoped query on a cold cache reads just that partition and does NOT
|
|
86
|
+
// warm the whole index — retrieval is correct before any rebuild.
|
|
87
|
+
const ns = await retriever.byNamespace("epic", "issue-303");
|
|
88
|
+
assert.deepEqual(ns.map((s) => s.record.id).sort(), ["epic-human", "epic-measured"]);
|
|
89
|
+
assert.equal(retriever.cache.isWarm, false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("structured / path-scoped query by scope + namespace", async () => {
|
|
93
|
+
const { dir } = await seededSubstrate();
|
|
94
|
+
const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
|
|
95
|
+
|
|
96
|
+
const oneNs = await retriever.query({ scope: "epic", scopeRef: "issue-303" });
|
|
97
|
+
assert.deepEqual(oneNs.map((s) => s.record.id).sort(), ["epic-human", "epic-measured"]);
|
|
98
|
+
assert.equal(retriever.cache.isWarm, false, "single-namespace query must not force a full build");
|
|
99
|
+
|
|
100
|
+
const wholeScope = await retriever.query({ scope: "epic" });
|
|
101
|
+
assert.deepEqual(wholeScope.map((s) => s.record.id).sort(), ["epic-human", "epic-measured", "epic-other-ns"]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("frontmatter filtering on typed record fields (OR-sets, ANDed clauses)", async () => {
|
|
105
|
+
const { dir } = await seededSubstrate();
|
|
106
|
+
const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
|
|
107
|
+
|
|
108
|
+
const humans = await retriever.query({ provenance: "human" });
|
|
109
|
+
assert.deepEqual(humans.map((s) => s.record.id).sort(), ["epic-human", "repo-norm"]);
|
|
110
|
+
|
|
111
|
+
const measuredOrInstance = await retriever.query({ provenance: ["measured", "instance"] });
|
|
112
|
+
assert.deepEqual(measuredOrInstance.map((s) => s.record.id).sort(), ["corpus-fact", "epic-measured", "epic-other-ns"]);
|
|
113
|
+
|
|
114
|
+
const normativeHumansInEpic = await retriever.query({ scope: "epic", mode: "normative", provenance: "human" });
|
|
115
|
+
assert.deepEqual(normativeHumansInEpic.map((s) => s.record.id), ["epic-human"]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("text search across record bodies, composable with filters", async () => {
|
|
119
|
+
const { dir } = await seededSubstrate();
|
|
120
|
+
const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
|
|
121
|
+
|
|
122
|
+
const throughput = await retriever.search("throughput");
|
|
123
|
+
assert.deepEqual(throughput.map((s) => s.record.id).sort(), ["corpus-fact", "epic-measured"]);
|
|
124
|
+
|
|
125
|
+
const throughputMeasuredInEpic = await retriever.search("throughput", { scope: "epic" });
|
|
126
|
+
assert.deepEqual(throughputMeasuredInEpic.map((s) => s.record.id), ["epic-measured"]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("limit caps the result set", async () => {
|
|
130
|
+
const { dir } = await seededSubstrate();
|
|
131
|
+
const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
|
|
132
|
+
const limited = await retriever.query({ limit: 2 });
|
|
133
|
+
assert.equal(limited.length, 2);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("the derived cache is disposable: invalidate/rebuild re-projects from git", async () => {
|
|
137
|
+
const { dir, writer } = await seededSubstrate();
|
|
138
|
+
const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
|
|
139
|
+
|
|
140
|
+
// Warm the cache with the current store.
|
|
141
|
+
const before = await retriever.all();
|
|
142
|
+
assert.equal(retriever.cache.isWarm, true);
|
|
143
|
+
const beforeCount = before.length;
|
|
144
|
+
|
|
145
|
+
// Land a new record via the S3 write path AFTER warming.
|
|
146
|
+
await writer.appendRecord(rec({ id: "late-arrival", scope: "epic", scopeRef: "issue-303", statement: "measured tail latency dropped" }));
|
|
147
|
+
|
|
148
|
+
// The warm cache still serves the old projection (it is a snapshot, not live).
|
|
149
|
+
assert.equal((await retriever.all()).length, beforeCount);
|
|
150
|
+
|
|
151
|
+
// Invalidate → the next read re-projects from git and sees the new record.
|
|
152
|
+
retriever.invalidate();
|
|
153
|
+
assert.equal(retriever.cache.isWarm, false);
|
|
154
|
+
const after = await retriever.all();
|
|
155
|
+
assert.equal(after.length, beforeCount + 1);
|
|
156
|
+
assert.ok(after.some((s) => s.record.id === "late-arrival"));
|
|
157
|
+
|
|
158
|
+
// rebuild() is an eager equivalent.
|
|
159
|
+
await retriever.rebuild();
|
|
160
|
+
assert.equal(retriever.cache.isWarm, true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("get() reads a single record by its S3 layout path", async () => {
|
|
164
|
+
const { dir } = await seededSubstrate();
|
|
165
|
+
const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
|
|
166
|
+
const all = await retriever.all();
|
|
167
|
+
const target = all.find((s) => s.record.id === "repo-norm");
|
|
168
|
+
assert.ok(target);
|
|
169
|
+
const fetched = await retriever.get(target.path);
|
|
170
|
+
assert.equal(fetched?.record.id, "repo-norm");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("a ratified agent-retro prior is retrievable; provenance/authority survive the round-trip", async () => {
|
|
174
|
+
const dir = await makeSubstrate();
|
|
175
|
+
const writer = new ContextWriter({ localPath: dir, ref: "main" });
|
|
176
|
+
// agent-retro cannot be appended directly — it must be proposed then ratified.
|
|
177
|
+
const proposal = await writer.proposePrior(
|
|
178
|
+
rec({ id: "hypothesis-1", scope: "epic", scopeRef: "issue-303", provenance: "agent-retro", mode: "normative", authority: "hypothesis", statement: "hypothesis: batching reduces retries" }),
|
|
179
|
+
);
|
|
180
|
+
await writer.ratify(proposal);
|
|
181
|
+
|
|
182
|
+
const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
|
|
183
|
+
const priors = await retriever.query({ provenance: "agent-retro" });
|
|
184
|
+
assert.deepEqual(priors.map((s) => s.record.id), ["hypothesis-1"]);
|
|
185
|
+
// The schema invariant is preserved on read: an agent-retro prior is never authoritative.
|
|
186
|
+
assert.equal(priors[0]?.record.authority, "hypothesis");
|
|
187
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Slice S4 (retrieval) — the public retriever over the git system-of-record.
|
|
2
|
+
//
|
|
3
|
+
// `ContextRetriever` composes the three pieces of this slice into one API:
|
|
4
|
+
// - a RecordSource (git read seam, default GitRecordSource over a resolved
|
|
5
|
+
// substrate working copy),
|
|
6
|
+
// - a DerivedRecordIndex (disposable hot cache, rebuildable from git),
|
|
7
|
+
// - the pure query model (path-scoped + frontmatter + text search).
|
|
8
|
+
//
|
|
9
|
+
// Correctness does not depend on the cache: every query works from a cold/empty
|
|
10
|
+
// index by reading through to git, and a query narrowed to one namespace reads
|
|
11
|
+
// only that partition (a cheap, path-scoped read) without warming the whole
|
|
12
|
+
// index. Callers keep the cache honest by calling `invalidate()` after a write
|
|
13
|
+
// (git remains the source of truth); nothing here silently serves stale data
|
|
14
|
+
// beyond what the caller has chosen to keep warm.
|
|
15
|
+
|
|
16
|
+
import { DerivedRecordIndex } from "./cache.ts";
|
|
17
|
+
import type { EmbeddingIndex } from "./embedding.ts";
|
|
18
|
+
import {
|
|
19
|
+
compileQuery,
|
|
20
|
+
type CompiledQuery,
|
|
21
|
+
matchesQuery,
|
|
22
|
+
type RetrievalQuery,
|
|
23
|
+
} from "./query.ts";
|
|
24
|
+
import {
|
|
25
|
+
GitRecordSource,
|
|
26
|
+
type NamespaceSelector,
|
|
27
|
+
type RecordSource,
|
|
28
|
+
type ResolvedReadHandle,
|
|
29
|
+
type StoredRecord,
|
|
30
|
+
} from "./source.ts";
|
|
31
|
+
|
|
32
|
+
/** Options for {@link ContextRetriever}. All optional; the defaults are safe. */
|
|
33
|
+
export interface ContextRetrieverOptions {
|
|
34
|
+
/**
|
|
35
|
+
* Override the read source (seam for a future mutable/PII backend). Defaults
|
|
36
|
+
* to a {@link GitRecordSource} over the resolved handle's working copy.
|
|
37
|
+
*/
|
|
38
|
+
readonly source?: RecordSource;
|
|
39
|
+
/**
|
|
40
|
+
* A future opt-in derived embedding index. Purely a forward-compat seam — the
|
|
41
|
+
* MVP does not build or require one, and no method here depends on it.
|
|
42
|
+
*/
|
|
43
|
+
readonly embeddingIndex?: EmbeddingIndex;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Retrieval over the git-backed context store. Bind one to a resolved substrate
|
|
48
|
+
* handle (the local checkout S1 produces) and query it; the derived hot cache is
|
|
49
|
+
* built lazily and can be invalidated/rebuilt at will.
|
|
50
|
+
*/
|
|
51
|
+
export class ContextRetriever {
|
|
52
|
+
readonly #source: RecordSource;
|
|
53
|
+
readonly #index: DerivedRecordIndex;
|
|
54
|
+
readonly #embeddingIndex?: EmbeddingIndex;
|
|
55
|
+
|
|
56
|
+
constructor(
|
|
57
|
+
handle: ResolvedReadHandle | string,
|
|
58
|
+
options: ContextRetrieverOptions = {},
|
|
59
|
+
) {
|
|
60
|
+
this.#source = options.source ?? new GitRecordSource(handle);
|
|
61
|
+
this.#index = new DerivedRecordIndex(this.#source);
|
|
62
|
+
this.#embeddingIndex = options.embeddingIndex;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The derived hot cache (for explicit warm/invalidate/rebuild control). */
|
|
66
|
+
get cache(): DerivedRecordIndex {
|
|
67
|
+
return this.#index;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The read source this retriever reads git through. */
|
|
71
|
+
get source(): RecordSource {
|
|
72
|
+
return this.#source;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A future opt-in embedding index, if one was supplied. `undefined` in the
|
|
77
|
+
* MVP — retrieval is fully functional without it.
|
|
78
|
+
*/
|
|
79
|
+
get embeddingIndex(): EmbeddingIndex | undefined {
|
|
80
|
+
return this.#embeddingIndex;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Run a retrieval query. Structured/path-scoped clauses (`scope` + a single
|
|
85
|
+
* `scopeRef`) narrow the read to one layout partition when the cache is cold,
|
|
86
|
+
* so a targeted query is cheap and never forces a full index build; otherwise
|
|
87
|
+
* the frontmatter + text clauses filter the candidate set in memory. Results
|
|
88
|
+
* are ordered by layout path for determinism and capped by `limit`.
|
|
89
|
+
*/
|
|
90
|
+
async query(query: RetrievalQuery = {}): Promise<readonly StoredRecord[]> {
|
|
91
|
+
const compiled = compileQuery(query);
|
|
92
|
+
const candidates = await this.#candidates(compiled);
|
|
93
|
+
const matched = candidates.filter((stored) =>
|
|
94
|
+
matchesQuery(stored.record, compiled),
|
|
95
|
+
);
|
|
96
|
+
return applyLimit(matched, compiled);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Path-scoped structured query: every record in one namespace partition
|
|
101
|
+
* (`scope` + optional `scopeRef`), read cheaply from just that subtree when
|
|
102
|
+
* the cache is cold. Equivalent to `query({ scope, scopeRef })` but expresses
|
|
103
|
+
* the S3-partition intent directly.
|
|
104
|
+
*/
|
|
105
|
+
async byNamespace(
|
|
106
|
+
scope: NamespaceSelector["scope"],
|
|
107
|
+
scopeRef?: string,
|
|
108
|
+
): Promise<readonly StoredRecord[]> {
|
|
109
|
+
return this.#index.namespace({ scope, scopeRef });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Free-text search across record bodies, optionally combined with structured
|
|
114
|
+
* frontmatter filters. Convenience over {@link query} for the common case.
|
|
115
|
+
*/
|
|
116
|
+
async search(
|
|
117
|
+
text: string,
|
|
118
|
+
filters: Omit<RetrievalQuery, "text"> = {},
|
|
119
|
+
): Promise<readonly StoredRecord[]> {
|
|
120
|
+
return this.query({ ...filters, text });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Every record in the store, warming the cache. */
|
|
124
|
+
async all(): Promise<readonly StoredRecord[]> {
|
|
125
|
+
return this.#index.all();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Read a single record by its S3 layout path (bypasses the cache). */
|
|
129
|
+
async get(relPath: string): Promise<StoredRecord | undefined> {
|
|
130
|
+
return this.#source.read(relPath);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Drop the derived cache so the next query re-reads from git. */
|
|
134
|
+
invalidate(): void {
|
|
135
|
+
this.#index.invalidate();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Eagerly rebuild the derived cache from git. */
|
|
139
|
+
async rebuild(): Promise<readonly StoredRecord[]> {
|
|
140
|
+
return this.#index.rebuild();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The candidate set to filter: when a query pins exactly one scope we read
|
|
145
|
+
* only that scope's subtree (cheap path-scoped read, cold-cache friendly) —
|
|
146
|
+
* pinning a single scopeRef narrows it further to that one namespace
|
|
147
|
+
* partition, otherwise the whole scope subtree (spanning its refs) is read;
|
|
148
|
+
* when no single scope is pinned we read the whole (cached) store.
|
|
149
|
+
*/
|
|
150
|
+
async #candidates(query: CompiledQuery): Promise<readonly StoredRecord[]> {
|
|
151
|
+
const selector = singleNamespace(query);
|
|
152
|
+
if (selector !== undefined) {
|
|
153
|
+
return this.#index.namespace(selector);
|
|
154
|
+
}
|
|
155
|
+
return this.#index.all();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* If a compiled query pins exactly ONE scope (and at most one scopeRef), return
|
|
161
|
+
* the namespace partition it lives in so retrieval can read just that subtree.
|
|
162
|
+
* Returns `undefined` when the query spans multiple scopes/refs (or none), in
|
|
163
|
+
* which case the whole store is the candidate set.
|
|
164
|
+
*/
|
|
165
|
+
function singleNamespace(query: CompiledQuery): NamespaceSelector | undefined {
|
|
166
|
+
const scope = onlyMember(query.scope);
|
|
167
|
+
if (scope === undefined) {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
const scopeRef = onlyMember(query.scopeRef);
|
|
171
|
+
return { scope, ...(scopeRef === undefined ? {} : { scopeRef }) };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The sole member of a set of exactly one element, else `undefined`. */
|
|
175
|
+
function onlyMember<T>(set: ReadonlySet<T> | undefined): T | undefined {
|
|
176
|
+
if (set === undefined || set.size !== 1) {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
for (const value of set) {
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function applyLimit(
|
|
186
|
+
records: readonly StoredRecord[],
|
|
187
|
+
query: CompiledQuery,
|
|
188
|
+
): readonly StoredRecord[] {
|
|
189
|
+
if (query.limit === undefined || query.limit < 0) {
|
|
190
|
+
return records;
|
|
191
|
+
}
|
|
192
|
+
return records.slice(0, query.limit);
|
|
193
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Slice S4 (retrieval) — tests for the git read source. These write record
|
|
2
|
+
// files straight onto disk under the S3 layout (no git needed) to prove the
|
|
3
|
+
// source selects/parses/skips correctly and reads namespaces cheaply.
|
|
4
|
+
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { after, test } from "node:test";
|
|
10
|
+
import { recordRelativePath } from "../git/index.ts";
|
|
11
|
+
import type { MemoryRecord } from "../schema/index.ts";
|
|
12
|
+
import { GitRecordSource, RecordSourceError } from "./source.ts";
|
|
13
|
+
|
|
14
|
+
const TEMP_ROOTS: string[] = [];
|
|
15
|
+
|
|
16
|
+
after(async () => {
|
|
17
|
+
await Promise.all(TEMP_ROOTS.map((dir) => rm(dir, { recursive: true, force: true })));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
async function makeRoot(): Promise<string> {
|
|
21
|
+
const dir = await mkdtemp(join(tmpdir(), "s4-source-"));
|
|
22
|
+
TEMP_ROOTS.push(dir);
|
|
23
|
+
return dir;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function rec(overrides: Partial<MemoryRecord>): MemoryRecord {
|
|
27
|
+
const base: MemoryRecord = {
|
|
28
|
+
schemaVersion: 1,
|
|
29
|
+
id: "rec-1",
|
|
30
|
+
scope: "epic",
|
|
31
|
+
scopeRef: "issue-303",
|
|
32
|
+
mode: "empirical",
|
|
33
|
+
provenance: "measured",
|
|
34
|
+
authority: "authoritative",
|
|
35
|
+
statement: "the measured throughput was 42 rps",
|
|
36
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
37
|
+
};
|
|
38
|
+
return { ...base, ...overrides };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Write a record onto disk at its canonical S3 layout path. */
|
|
42
|
+
async function put(root: string, record: MemoryRecord): Promise<string> {
|
|
43
|
+
const relPath = recordRelativePath(record);
|
|
44
|
+
const abs = join(root, relPath);
|
|
45
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
46
|
+
await writeFile(abs, JSON.stringify(record, null, 2), "utf8");
|
|
47
|
+
return relPath;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test("constructor rejects a relative substrate root", () => {
|
|
51
|
+
assert.throws(() => new GitRecordSource("relative/path"), RecordSourceError);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("list() on an empty / missing store is [] (cold read degrades gracefully)", async () => {
|
|
55
|
+
const root = await makeRoot();
|
|
56
|
+
const source = new GitRecordSource(root);
|
|
57
|
+
assert.deepEqual(await source.list(), []);
|
|
58
|
+
assert.deepEqual(await source.list({ scope: "epic", scopeRef: "nope" }), []);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("list() reads every record under the layout, in deterministic path order", async () => {
|
|
62
|
+
const root = await makeRoot();
|
|
63
|
+
await put(root, rec({ id: "b", scope: "epic", scopeRef: "issue-303" }));
|
|
64
|
+
await put(root, rec({ id: "a", scope: "repo", scopeRef: undefined, provenance: "human", mode: "normative", authority: "authoritative" }));
|
|
65
|
+
const source = new GitRecordSource(root);
|
|
66
|
+
const all = await source.list();
|
|
67
|
+
assert.equal(all.length, 2);
|
|
68
|
+
// Sorted by substrate-relative path.
|
|
69
|
+
assert.deepEqual([...all].map((s) => s.path), [...all].map((s) => s.path).sort());
|
|
70
|
+
const ids = all.map((s) => s.record.id).sort();
|
|
71
|
+
assert.deepEqual(ids, ["a", "b"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("list(selector) reads only one namespace partition", async () => {
|
|
75
|
+
const root = await makeRoot();
|
|
76
|
+
await put(root, rec({ id: "x", scope: "epic", scopeRef: "issue-303" }));
|
|
77
|
+
await put(root, rec({ id: "y", scope: "epic", scopeRef: "issue-999" }));
|
|
78
|
+
await put(root, rec({ id: "z", scope: "repo", scopeRef: "nano-ide", provenance: "human", mode: "normative" }));
|
|
79
|
+
const source = new GitRecordSource(root);
|
|
80
|
+
|
|
81
|
+
const ns = await source.list({ scope: "epic", scopeRef: "issue-303" });
|
|
82
|
+
assert.deepEqual(ns.map((s) => s.record.id), ["x"]);
|
|
83
|
+
|
|
84
|
+
const scopeWide = await source.list({ scope: "epic" });
|
|
85
|
+
assert.deepEqual(scopeWide.map((s) => s.record.id).sort(), ["x", "y"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("non-record and corrupt files under the layout are skipped, not fatal", async () => {
|
|
89
|
+
const root = await makeRoot();
|
|
90
|
+
await put(root, rec({ id: "good" }));
|
|
91
|
+
// A corrupt JSON file at a record-looking path.
|
|
92
|
+
const badPath = join(root, "records", "epic", "issue-303", "broken.json");
|
|
93
|
+
await mkdir(dirname(badPath), { recursive: true });
|
|
94
|
+
await writeFile(badPath, "{ not json", "utf8");
|
|
95
|
+
// A valid JSON file that is NOT a valid record.
|
|
96
|
+
const notRecord = join(root, "records", "epic", "issue-303", "notarecord.json");
|
|
97
|
+
await writeFile(notRecord, JSON.stringify({ hello: "world" }), "utf8");
|
|
98
|
+
// A non-json file is ignored by isRecordPath.
|
|
99
|
+
await writeFile(join(root, "records", "epic", "issue-303", "README.md"), "hi", "utf8");
|
|
100
|
+
|
|
101
|
+
const source = new GitRecordSource(root);
|
|
102
|
+
const all = await source.list();
|
|
103
|
+
assert.deepEqual(all.map((s) => s.record.id), ["good"]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("read() returns a single record by layout path, undefined when absent/invalid", async () => {
|
|
107
|
+
const root = await makeRoot();
|
|
108
|
+
const relPath = await put(root, rec({ id: "solo" }));
|
|
109
|
+
const source = new GitRecordSource(root);
|
|
110
|
+
|
|
111
|
+
const found = await source.read(relPath);
|
|
112
|
+
assert.equal(found?.record.id, "solo");
|
|
113
|
+
|
|
114
|
+
assert.equal(await source.read("records/epic/issue-303/missing.json"), undefined);
|
|
115
|
+
// A path outside the layout is rejected.
|
|
116
|
+
assert.equal(await source.read("../escape.json"), undefined);
|
|
117
|
+
assert.equal(await source.read("secrets.json"), undefined);
|
|
118
|
+
});
|