@nanobpm/urban 0.69.1 → 0.71.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.
@@ -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
+ });
@@ -0,0 +1,218 @@
1
+ // Slice S4 (retrieval) — the READ seam over the git system-of-record.
2
+ //
3
+ // Retrieval treats git as the authoritative store: records live on disk under
4
+ // the exact layout S3 defines (`records/<scope>/<scopeRef-bucket>/<id>.json`),
5
+ // and this module reads them back. It is deliberately abstracted behind a
6
+ // {@link RecordSource} interface so the derived hot cache, the query layer, and
7
+ // a future PII/mutable backend all read through one seam rather than assuming a
8
+ // public-git working copy. {@link GitRecordSource} is the default MVP: it walks
9
+ // a resolved substrate working copy (the local checkout S1 produces, pinned to
10
+ // its ref) using the S3 layout helpers to select record files.
11
+ //
12
+ // The source NEVER caches — it always reflects what is on disk (== the git ref
13
+ // that is checked out). Caching is the derived index's job (see cache.ts), and
14
+ // the index is disposable: a cold/empty index falls back to reading here, so
15
+ // retrieval is correct even before any index is built.
16
+
17
+ import type { Dirent } from "node:fs";
18
+ import { readdir, readFile } from "node:fs/promises";
19
+ import { isAbsolute, join, relative, sep } from "node:path";
20
+ import {
21
+ isRecordPath,
22
+ LAYOUT_ROOT,
23
+ recordDir,
24
+ scopeDir,
25
+ } from "../git/index.ts";
26
+ import {
27
+ type MemoryRecord,
28
+ type MemoryScope,
29
+ validateMemoryRecord,
30
+ } from "../schema/index.ts";
31
+
32
+ /** A record as it is stored in the substrate: its layout path + the record. */
33
+ export interface StoredRecord {
34
+ /** Substrate-root-relative POSIX path the record lives at (S3 layout). */
35
+ readonly path: string;
36
+ /** The validated memory record parsed from that file. */
37
+ readonly record: MemoryRecord;
38
+ }
39
+
40
+ /**
41
+ * A path-scoped selector for a namespace partition in the S3 layout. Reading a
42
+ * single `(scope, scopeRef)` — or a whole `scope` — is a cheap structured query
43
+ * because the layout partitions records into one directory per namespace, so the
44
+ * source can walk just that subtree instead of the whole store.
45
+ */
46
+ export interface NamespaceSelector {
47
+ readonly scope: MemoryScope;
48
+ /** When present, narrows to one namespace bucket; absent lists the scope. */
49
+ readonly scopeRef?: string;
50
+ }
51
+
52
+ /**
53
+ * The read seam retrieval is built on. A future mutable/PII backend can
54
+ * implement this differently (e.g. against a database) without the cache, the
55
+ * query layer, or callers changing.
56
+ */
57
+ export interface RecordSource {
58
+ /**
59
+ * List every stored record, optionally narrowed to one namespace partition
60
+ * (a cheap, path-scoped read that walks only that subtree of the S3 layout).
61
+ * Invalid / unparseable files under the layout root are skipped, not fatal —
62
+ * a single corrupt file must never make the whole store unreadable.
63
+ */
64
+ list(selector?: NamespaceSelector): Promise<readonly StoredRecord[]>;
65
+ /**
66
+ * Read a single record by its substrate-relative layout path, or `undefined`
67
+ * when it is absent or not a valid record file.
68
+ */
69
+ read(relPath: string): Promise<StoredRecord | undefined>;
70
+ }
71
+
72
+ /** The minimal resolved-handle shape the read source consumes (from S1). */
73
+ export interface ResolvedReadHandle {
74
+ /** Absolute path to the local substrate working copy (checked out at `ref`). */
75
+ readonly localPath: string;
76
+ /** The git ref the working copy is pinned to (informational for reads). */
77
+ readonly ref?: string;
78
+ }
79
+
80
+ /** Thrown when the underlying substrate cannot be read at all. */
81
+ export class RecordSourceError extends Error {
82
+ constructor(message: string, options?: { cause?: unknown }) {
83
+ super(message, options);
84
+ this.name = "RecordSourceError";
85
+ }
86
+ }
87
+
88
+ function isEnoent(error: unknown): boolean {
89
+ return (
90
+ typeof error === "object" &&
91
+ error !== null &&
92
+ "code" in error &&
93
+ error.code === "ENOENT"
94
+ );
95
+ }
96
+
97
+ /** Convert a native absolute path to a substrate-root-relative POSIX path. */
98
+ function toPosixRelative(root: string, absPath: string): string {
99
+ return relative(root, absPath).split(sep).join("/");
100
+ }
101
+
102
+ /**
103
+ * The default git-backed read source: reads records straight off a local
104
+ * substrate working copy. Git is the system of record; this holds no state of
105
+ * its own, so every call reflects the current on-disk (== checked-out ref)
106
+ * content. That is what makes the derived index safe to throw away and rebuild.
107
+ */
108
+ export class GitRecordSource implements RecordSource {
109
+ readonly #root: string;
110
+
111
+ constructor(handle: ResolvedReadHandle | string) {
112
+ const root = typeof handle === "string" ? handle : handle.localPath;
113
+ if (!isAbsolute(root)) {
114
+ throw new RecordSourceError(
115
+ `substrate root must be an absolute path, got: ${root}`,
116
+ );
117
+ }
118
+ this.#root = root;
119
+ }
120
+
121
+ /** Absolute path of the substrate working copy this source reads from. */
122
+ get root(): string {
123
+ return this.#root;
124
+ }
125
+
126
+ async list(selector?: NamespaceSelector): Promise<readonly StoredRecord[]> {
127
+ const subtree = selectorSubtree(selector);
128
+ const walkRoot = join(this.#root, subtree);
129
+
130
+ let entries: Dirent[];
131
+ try {
132
+ entries = await readdir(walkRoot, { recursive: true, withFileTypes: true });
133
+ } catch (error) {
134
+ // A missing subtree (e.g. an empty store, or a namespace with no records)
135
+ // is an empty result, not an error — a cold read must degrade gracefully.
136
+ if (isEnoent(error)) {
137
+ return [];
138
+ }
139
+ throw new RecordSourceError(`failed to list substrate at ${walkRoot}`, {
140
+ cause: error,
141
+ });
142
+ }
143
+
144
+ const results: StoredRecord[] = [];
145
+ for (const entry of entries) {
146
+ if (!entry.isFile()) {
147
+ continue;
148
+ }
149
+ const absPath = join(entry.parentPath, entry.name);
150
+ const relPath = toPosixRelative(this.#root, absPath);
151
+ if (!isRecordPath(relPath)) {
152
+ continue;
153
+ }
154
+ const stored = await this.#readAbsolute(absPath, relPath);
155
+ if (stored !== undefined) {
156
+ results.push(stored);
157
+ }
158
+ }
159
+ // Deterministic order regardless of filesystem enumeration order.
160
+ results.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
161
+ return results;
162
+ }
163
+
164
+ async read(relPath: string): Promise<StoredRecord | undefined> {
165
+ const normalised = relPath.replace(/\\/g, "/").replace(/^\.\//, "");
166
+ if (!isRecordPath(normalised)) {
167
+ return undefined;
168
+ }
169
+ return this.#readAbsolute(join(this.#root, normalised), normalised);
170
+ }
171
+
172
+ async #readAbsolute(
173
+ absPath: string,
174
+ relPath: string,
175
+ ): Promise<StoredRecord | undefined> {
176
+ let raw: string;
177
+ try {
178
+ raw = await readFile(absPath, "utf8");
179
+ } catch (error) {
180
+ if (isEnoent(error)) {
181
+ return undefined;
182
+ }
183
+ throw new RecordSourceError(`failed to read record at ${relPath}`, {
184
+ cause: error,
185
+ });
186
+ }
187
+
188
+ let parsed: unknown;
189
+ try {
190
+ parsed = JSON.parse(raw);
191
+ } catch {
192
+ // A corrupt / non-JSON file under the layout is skipped, not fatal.
193
+ return undefined;
194
+ }
195
+
196
+ const result = validateMemoryRecord(parsed);
197
+ if (!result.ok) {
198
+ // A file that does not validate against the S2 schema is not a record we
199
+ // can safely surface — skip it rather than return an unchecked shape.
200
+ return undefined;
201
+ }
202
+ return { path: relPath, record: result.record };
203
+ }
204
+ }
205
+
206
+ /**
207
+ * The layout subtree to walk for a selector: one namespace bucket, one whole
208
+ * scope, or the entire layout root. Keeps path-scoping in one place so the
209
+ * cache and query layers never re-derive layout paths themselves.
210
+ */
211
+ export function selectorSubtree(selector?: NamespaceSelector): string {
212
+ if (selector === undefined) {
213
+ return LAYOUT_ROOT;
214
+ }
215
+ return selector.scopeRef === undefined
216
+ ? scopeDir(selector.scope)
217
+ : recordDir(selector.scope, selector.scopeRef);
218
+ }