@b4run/testing 0.8.28
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/LICENSE +21 -0
- package/README.md +51 -0
- package/dist/aimock-runner.d.ts +48 -0
- package/dist/aimock-runner.d.ts.map +1 -0
- package/dist/aimock-runner.js +81 -0
- package/dist/checkpointer-conformance.d.ts +43 -0
- package/dist/checkpointer-conformance.d.ts.map +1 -0
- package/dist/checkpointer-conformance.js +381 -0
- package/dist/fake-embedder.d.ts +9 -0
- package/dist/fake-embedder.d.ts.map +1 -0
- package/dist/fake-embedder.js +38 -0
- package/dist/fixture-builder.d.ts +29 -0
- package/dist/fixture-builder.d.ts.map +1 -0
- package/dist/fixture-builder.js +43 -0
- package/dist/fixture-file.d.ts +14 -0
- package/dist/fixture-file.d.ts.map +1 -0
- package/dist/fixture-file.js +49 -0
- package/dist/harness.d.ts +35 -0
- package/dist/harness.d.ts.map +1 -0
- package/dist/harness.js +211 -0
- package/dist/http-inject.d.ts +22 -0
- package/dist/http-inject.d.ts.map +1 -0
- package/dist/http-inject.js +55 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/matchers.d.ts +56 -0
- package/dist/matchers.d.ts.map +1 -0
- package/dist/matchers.js +262 -0
- package/dist/memory-conformance.d.ts +13 -0
- package/dist/memory-conformance.d.ts.map +1 -0
- package/dist/memory-conformance.js +1333 -0
- package/dist/memory.d.ts +11 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +20 -0
- package/dist/middleware-harness.d.ts +11 -0
- package/dist/middleware-harness.d.ts.map +1 -0
- package/dist/middleware-harness.js +35 -0
- package/dist/permissions-conformance.d.ts +24 -0
- package/dist/permissions-conformance.d.ts.map +1 -0
- package/dist/permissions-conformance.js +156 -0
- package/dist/record-fixtures.d.ts +21 -0
- package/dist/record-fixtures.d.ts.map +1 -0
- package/dist/record-fixtures.js +31 -0
- package/dist/record.d.ts +12 -0
- package/dist/record.d.ts.map +1 -0
- package/dist/record.js +22 -0
- package/dist/regex-safety.d.ts +2 -0
- package/dist/regex-safety.d.ts.map +1 -0
- package/dist/regex-safety.js +66 -0
- package/dist/run-result.d.ts +111 -0
- package/dist/run-result.d.ts.map +1 -0
- package/dist/run-result.js +287 -0
- package/dist/subprocess.d.ts +25 -0
- package/dist/subprocess.d.ts.map +1 -0
- package/dist/subprocess.js +223 -0
- package/dist/thread-access-harness.d.ts +40 -0
- package/dist/thread-access-harness.d.ts.map +1 -0
- package/dist/thread-access-harness.js +42 -0
- package/dist/threads-conformance.d.ts +18 -0
- package/dist/threads-conformance.d.ts.map +1 -0
- package/dist/threads-conformance.js +315 -0
- package/dist/tool-harness.d.ts +16 -0
- package/dist/tool-harness.d.ts.map +1 -0
- package/dist/tool-harness.js +25 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/workspace-harness.d.ts +15 -0
- package/dist/workspace-harness.d.ts.map +1 -0
- package/dist/workspace-harness.js +43 -0
- package/package.json +71 -0
|
@@ -0,0 +1,1333 @@
|
|
|
1
|
+
import { approveWithReconcile, BrowseQueryError, } from "@b4run/memory";
|
|
2
|
+
import { expect, test } from "vitest";
|
|
3
|
+
/** Asserted on `.code`, not on the message: the route branches on the code. */
|
|
4
|
+
async function expectContinuationInvalid(run) {
|
|
5
|
+
try {
|
|
6
|
+
await run();
|
|
7
|
+
}
|
|
8
|
+
catch (error) {
|
|
9
|
+
expect(error).toBeInstanceOf(BrowseQueryError);
|
|
10
|
+
expect(error.code).toBe("continuation-invalid");
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
expect.unreachable("should have rejected");
|
|
14
|
+
}
|
|
15
|
+
// A store whose keyset does not ADVANCE hands back the same full window forever.
|
|
16
|
+
// Unbounded, every walk below would spin to the suite timeout with no diagnosis; the
|
|
17
|
+
// bound plus the post-loop null assertion makes that a named failure instead. Third
|
|
18
|
+
// parties run this suite against their own stores, so only they can hit it.
|
|
19
|
+
const MAX_WALK_PAGES = 20;
|
|
20
|
+
function rec(over) {
|
|
21
|
+
return {
|
|
22
|
+
kind: "semantic",
|
|
23
|
+
data: {},
|
|
24
|
+
source: { type: "eval", id: "seed" },
|
|
25
|
+
confidence: 1,
|
|
26
|
+
tags: [],
|
|
27
|
+
status: "active",
|
|
28
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
29
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
30
|
+
...over,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const vec = (...xs) => new Float32Array(xs);
|
|
34
|
+
const D = (day) => `2026-08-${String(day).padStart(2, "0")}T00:00:00.000Z`;
|
|
35
|
+
/** Record fixture with episodic defaults (kind overridable, e.g. for prune's
|
|
36
|
+
* non-episodic immunity cases). */
|
|
37
|
+
function ep(over) {
|
|
38
|
+
return rec({
|
|
39
|
+
namespace: "route=/n",
|
|
40
|
+
content: over.id,
|
|
41
|
+
kind: "episodic",
|
|
42
|
+
source: { type: "run", id: "r" },
|
|
43
|
+
createdAt: D(1),
|
|
44
|
+
updatedAt: D(1),
|
|
45
|
+
...over,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The contract every MemoryStore must satisfy. Run against sqlite (in-process,
|
|
50
|
+
* always) and pgvector (real Postgres, gated) so backends cannot drift. Pass
|
|
51
|
+
* vitest's `describe`; `makeStore` returns a FRESH empty store per call.
|
|
52
|
+
*/
|
|
53
|
+
export function runMemoryStoreConformance(opts) {
|
|
54
|
+
const { name, makeStore, describe, close } = opts;
|
|
55
|
+
describe(`MemoryStore conformance: ${name}`, () => {
|
|
56
|
+
test("put + get round-trips", async () => {
|
|
57
|
+
const s = await makeStore();
|
|
58
|
+
try {
|
|
59
|
+
await s.put(rec({ id: "a", namespace: "ns", content: "hello billing" }));
|
|
60
|
+
expect((await s.get("a"))?.content).toBe("hello billing");
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await close?.(s);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
test("search is namespace-isolated", async () => {
|
|
67
|
+
const s = await makeStore();
|
|
68
|
+
try {
|
|
69
|
+
await s.put(rec({ id: "a", namespace: "ns1", content: "billing escalation" }));
|
|
70
|
+
await s.put(rec({ id: "b", namespace: "ns2", content: "billing escalation" }));
|
|
71
|
+
expect((await s.search({ namespace: "ns1", query: "billing" })).map((r) => r.id)).toEqual([
|
|
72
|
+
"a",
|
|
73
|
+
]);
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
await close?.(s);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
test("query-less search is pure recency order", async () => {
|
|
80
|
+
const s = await makeStore();
|
|
81
|
+
try {
|
|
82
|
+
await s.put(rec({ id: "old", namespace: "ns", content: "x", updatedAt: "2026-07-01T00:00:00.000Z" }));
|
|
83
|
+
await s.put(rec({ id: "new", namespace: "ns", content: "y", updatedAt: "2026-07-04T00:00:00.000Z" }));
|
|
84
|
+
expect((await s.search({ namespace: "ns" })).map((r) => r.id)).toEqual(["new", "old"]);
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
await close?.(s);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
test("query-less search equal updated_at rows order by codepoint id (C collation)", async () => {
|
|
91
|
+
const s = await makeStore();
|
|
92
|
+
try {
|
|
93
|
+
// Same updatedAt on every row: id ASC is the sole tiebreak, and both backends
|
|
94
|
+
// must agree byte-for-byte. Mixed case separates codepoint order ("B10" < "a9")
|
|
95
|
+
// from a locale collation, which folds case and puts "a9" first.
|
|
96
|
+
for (const id of ["b2", "B10", "a9"]) {
|
|
97
|
+
await s.put(rec({ id, namespace: "ns", content: id, updatedAt: D(2) }));
|
|
98
|
+
}
|
|
99
|
+
expect((await s.search({ namespace: "ns" })).map((r) => r.id)).toEqual(["B10", "a9", "b2"]);
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
await close?.(s);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
test("supersede: old→superseded, new active, link recorded", async () => {
|
|
106
|
+
const s = await makeStore();
|
|
107
|
+
try {
|
|
108
|
+
await s.put(rec({ id: "old", namespace: "ns", content: "v1" }));
|
|
109
|
+
await s.put(rec({ id: "new", namespace: "ns", content: "v2" }));
|
|
110
|
+
await s.supersede("old", "new");
|
|
111
|
+
expect((await s.get("old"))?.status).toBe("superseded");
|
|
112
|
+
expect((await s.get("new"))?.supersedes).toContain("old");
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
await close?.(s);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
test("candidate listing + delete", async () => {
|
|
119
|
+
const s = await makeStore();
|
|
120
|
+
try {
|
|
121
|
+
await s.put(rec({ id: "c", namespace: "ns", content: "cand", status: "candidate" }));
|
|
122
|
+
expect((await s.listCandidates("")).map((r) => r.id)).toContain("c");
|
|
123
|
+
await s.delete("c");
|
|
124
|
+
expect(await s.get("c")).toBeNull();
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
await close?.(s);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
test("update preserves the stored embedding (vector recall still finds it)", async () => {
|
|
131
|
+
const s = await makeStore();
|
|
132
|
+
try {
|
|
133
|
+
await s.put(rec({ id: "e", namespace: "ns", content: "faster shipping" }), {
|
|
134
|
+
embedding: vec(1, 0, 0),
|
|
135
|
+
embeddingModel: "fake:test",
|
|
136
|
+
});
|
|
137
|
+
await s.update("e", { confidence: 0.5 });
|
|
138
|
+
const out = await s.search({
|
|
139
|
+
namespace: "ns",
|
|
140
|
+
query: "expedite delivery",
|
|
141
|
+
queryEmbedding: vec(1, 0, 0),
|
|
142
|
+
embedderId: "fake:test",
|
|
143
|
+
now: "2026-07-05T00:00:00.000Z",
|
|
144
|
+
});
|
|
145
|
+
expect(out.map((r) => r.id)).toContain("e");
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
await close?.(s);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
test("hybrid: a 0-shared-token semantic match is recalled via the vector list", async () => {
|
|
152
|
+
const s = await makeStore();
|
|
153
|
+
try {
|
|
154
|
+
await s.put(rec({ id: "sem", namespace: "ns", content: "faster shipping preferred" }), {
|
|
155
|
+
embedding: vec(1, 0, 0),
|
|
156
|
+
embeddingModel: "fake:test",
|
|
157
|
+
});
|
|
158
|
+
await s.put(rec({ id: "kw", namespace: "ns", content: "acme billing" }), {
|
|
159
|
+
embedding: vec(0, 1, 0),
|
|
160
|
+
embeddingModel: "fake:test",
|
|
161
|
+
});
|
|
162
|
+
const out = await s.search({
|
|
163
|
+
namespace: "ns",
|
|
164
|
+
query: "expedite delivery",
|
|
165
|
+
queryEmbedding: vec(0.95, 0.05, 0),
|
|
166
|
+
embedderId: "fake:test",
|
|
167
|
+
now: "2026-07-05T00:00:00.000Z",
|
|
168
|
+
});
|
|
169
|
+
expect(out.map((r) => r.id)).toContain("sem");
|
|
170
|
+
expect(out[0]?.id).toBe("sem");
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
await close?.(s);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
test("browse lists across namespaces and statuses, newest first, with total", async () => {
|
|
177
|
+
const s = await makeStore();
|
|
178
|
+
try {
|
|
179
|
+
await s.put(rec({
|
|
180
|
+
id: "a",
|
|
181
|
+
namespace: "route=/x",
|
|
182
|
+
content: "a",
|
|
183
|
+
updatedAt: "2026-07-03T00:00:00.000Z",
|
|
184
|
+
}));
|
|
185
|
+
await s.put(rec({
|
|
186
|
+
id: "b",
|
|
187
|
+
namespace: "route=/y",
|
|
188
|
+
content: "b",
|
|
189
|
+
status: "candidate",
|
|
190
|
+
updatedAt: "2026-07-02T00:00:00.000Z",
|
|
191
|
+
}));
|
|
192
|
+
await s.put(rec({
|
|
193
|
+
id: "c",
|
|
194
|
+
namespace: "route=/x",
|
|
195
|
+
content: "c",
|
|
196
|
+
status: "superseded",
|
|
197
|
+
updatedAt: "2026-07-01T00:00:00.000Z",
|
|
198
|
+
}));
|
|
199
|
+
const page = await s.browse();
|
|
200
|
+
expect(page.total).toBe(3);
|
|
201
|
+
expect(page.records.map((r) => r.id)).toEqual(["a", "b", "c"]);
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
await close?.(s);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
test("browse orders equal-updated_at rows by id ASC", async () => {
|
|
208
|
+
const s = await makeStore();
|
|
209
|
+
try {
|
|
210
|
+
// Same updatedAt on every row: id ASC is the sole tiebreak, and both
|
|
211
|
+
// backends must agree byte-for-byte (BINARY / C collation). Mixed case
|
|
212
|
+
// distinguishes codepoint order ("B10" < "a9") from locale collation.
|
|
213
|
+
for (const id of ["b2", "B10", "a9"]) {
|
|
214
|
+
await s.put(rec({ id, namespace: "ns", content: id }));
|
|
215
|
+
}
|
|
216
|
+
expect((await s.browse()).records.map((r) => r.id)).toEqual(["B10", "a9", "b2"]);
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
await close?.(s);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
test("browse filters by namespacePrefix, status, kind, and sourceType individually", async () => {
|
|
223
|
+
const s = await makeStore();
|
|
224
|
+
try {
|
|
225
|
+
await s.put(rec({ id: "a", namespace: "route=/x", content: "a" }));
|
|
226
|
+
await s.put(rec({ id: "b", namespace: "route=/y", content: "b", status: "candidate" }));
|
|
227
|
+
await s.put(rec({ id: "h", namespace: "route=/x", content: "h", source: { type: "human", id: "u" } }));
|
|
228
|
+
await s.put(rec({ id: "e", namespace: "other", content: "e", kind: "episodic" }));
|
|
229
|
+
expect((await s.browse({ namespacePrefix: "route=/x" })).records.map((r) => r.id).sort()).toEqual(["a", "h"]);
|
|
230
|
+
expect((await s.browse({ status: "candidate" })).records.map((r) => r.id)).toEqual(["b"]);
|
|
231
|
+
expect((await s.browse({ kind: "episodic" })).records.map((r) => r.id)).toEqual(["e"]);
|
|
232
|
+
expect((await s.browse({ sourceType: "human" })).records.map((r) => r.id)).toEqual(["h"]);
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
await close?.(s);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
test("browse accepts a set of statuses or kinds, matching any of them", async () => {
|
|
239
|
+
const s = await makeStore();
|
|
240
|
+
try {
|
|
241
|
+
await s.put(rec({ id: "act", namespace: "route=/x", content: "act" }));
|
|
242
|
+
await s.put(rec({ id: "cand", namespace: "route=/x", content: "cand", status: "candidate" }));
|
|
243
|
+
await s.put(rec({ id: "sup", namespace: "route=/x", content: "sup", status: "superseded" }));
|
|
244
|
+
await s.put(rec({ id: "ep", namespace: "route=/x", content: "ep", kind: "episodic" }));
|
|
245
|
+
await s.put(rec({ id: "proc", namespace: "route=/x", content: "proc", kind: "procedural" }));
|
|
246
|
+
expect((await s.browse({ status: ["candidate", "superseded"] })).records.map((r) => r.id).sort()).toEqual(["cand", "sup"]);
|
|
247
|
+
expect((await s.browse({ kind: ["episodic", "procedural"] })).records.map((r) => r.id).sort()).toEqual(["ep", "proc"]);
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
await close?.(s);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
test("browse treats a one-element set exactly like the bare value", async () => {
|
|
254
|
+
const s = await makeStore();
|
|
255
|
+
try {
|
|
256
|
+
await s.put(rec({ id: "a", namespace: "route=/x", content: "a" }));
|
|
257
|
+
await s.put(rec({ id: "b", namespace: "route=/x", content: "b", status: "candidate" }));
|
|
258
|
+
const bare = await s.browse({ status: "candidate" });
|
|
259
|
+
const set = await s.browse({ status: ["candidate"] });
|
|
260
|
+
expect(set.records.map((r) => r.id)).toEqual(bare.records.map((r) => r.id));
|
|
261
|
+
expect(set.total).toBe(bare.total);
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
await close?.(s);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
test("browse matches nothing for an empty set — not everything", async () => {
|
|
268
|
+
const s = await makeStore();
|
|
269
|
+
try {
|
|
270
|
+
// "none of these" is an OR over zero options, which is false. Reading it
|
|
271
|
+
// as "no filter" would silently show every row to a UI that had just
|
|
272
|
+
// unticked its last box.
|
|
273
|
+
await s.put(rec({ id: "a", namespace: "route=/x", content: "a" }));
|
|
274
|
+
await s.put(rec({ id: "b", namespace: "route=/x", content: "b", status: "candidate" }));
|
|
275
|
+
const page = await s.browse({ status: [] });
|
|
276
|
+
expect(page.records).toEqual([]);
|
|
277
|
+
expect(page.total).toBe(0);
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
await close?.(s);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
test("browse counts a set with the same clause it selects with", async () => {
|
|
284
|
+
const s = await makeStore();
|
|
285
|
+
try {
|
|
286
|
+
for (let i = 0; i < 5; i += 1) {
|
|
287
|
+
await s.put(rec({ id: `c${i}`, namespace: "route=/x", content: `c${i}`, status: "candidate" }));
|
|
288
|
+
}
|
|
289
|
+
await s.put(rec({ id: "keep", namespace: "route=/x", content: "keep" }));
|
|
290
|
+
const page = await s.browse({ status: ["candidate", "superseded"], limit: 2 });
|
|
291
|
+
expect(page.records).toHaveLength(2);
|
|
292
|
+
// total reflects the whole matching set, not the page.
|
|
293
|
+
expect(page.total).toBe(5);
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
await close?.(s);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
test("browse ANDs a status set with the other filters", async () => {
|
|
300
|
+
const s = await makeStore();
|
|
301
|
+
try {
|
|
302
|
+
await s.put(rec({ id: "hit", namespace: "route=/x", content: "hit", status: "candidate" }));
|
|
303
|
+
await s.put(rec({ id: "wrongNs", namespace: "route=/y", content: "n", status: "candidate" }));
|
|
304
|
+
await s.put(rec({ id: "wrongStatus", namespace: "route=/x", content: "s" }));
|
|
305
|
+
const page = await s.browse({
|
|
306
|
+
namespacePrefix: "route=/x",
|
|
307
|
+
status: ["candidate", "superseded"],
|
|
308
|
+
});
|
|
309
|
+
expect(page.records.map((r) => r.id)).toEqual(["hit"]);
|
|
310
|
+
expect(page.total).toBe(1);
|
|
311
|
+
}
|
|
312
|
+
finally {
|
|
313
|
+
await close?.(s);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
test("browse combines multiple filters with AND (COUNT shares the clause)", async () => {
|
|
317
|
+
const s = await makeStore();
|
|
318
|
+
try {
|
|
319
|
+
// Strict-subset construction: each of b/h/y passes two of the three
|
|
320
|
+
// clauses but fails the third, so only "a" survives — and total:1
|
|
321
|
+
// proves the COUNT query applies the same WHERE as the row query.
|
|
322
|
+
await s.put(rec({ id: "a", namespace: "route=/x", content: "a" }));
|
|
323
|
+
await s.put(rec({ id: "b", namespace: "route=/x", content: "b", status: "candidate" }));
|
|
324
|
+
await s.put(rec({ id: "h", namespace: "route=/x", content: "h", source: { type: "human", id: "u" } }));
|
|
325
|
+
await s.put(rec({ id: "y", namespace: "route=/y", content: "y" }));
|
|
326
|
+
const page = await s.browse({
|
|
327
|
+
namespacePrefix: "route=/x",
|
|
328
|
+
status: "active",
|
|
329
|
+
sourceType: "eval",
|
|
330
|
+
});
|
|
331
|
+
expect(page.records.map((r) => r.id)).toEqual(["a"]);
|
|
332
|
+
expect(page.total).toBe(1);
|
|
333
|
+
}
|
|
334
|
+
finally {
|
|
335
|
+
await close?.(s);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
test("browse pages with limit/offset while total stays full", async () => {
|
|
339
|
+
const s = await makeStore();
|
|
340
|
+
try {
|
|
341
|
+
for (let i = 0; i < 5; i++) {
|
|
342
|
+
await s.put(rec({
|
|
343
|
+
id: `r${i}`,
|
|
344
|
+
namespace: "ns",
|
|
345
|
+
content: `r${i}`,
|
|
346
|
+
updatedAt: `2026-07-0${i + 1}T00:00:00.000Z`,
|
|
347
|
+
}));
|
|
348
|
+
}
|
|
349
|
+
const page = await s.browse({ limit: 2, offset: 2 });
|
|
350
|
+
expect(page.total).toBe(5);
|
|
351
|
+
expect(page.records.map((r) => r.id)).toEqual(["r2", "r1"]);
|
|
352
|
+
}
|
|
353
|
+
finally {
|
|
354
|
+
await close?.(s);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
test("browse returns an empty page on an empty store", async () => {
|
|
358
|
+
const s = await makeStore();
|
|
359
|
+
try {
|
|
360
|
+
expect(await s.browse()).toEqual({ records: [], total: 0, continuation: null });
|
|
361
|
+
}
|
|
362
|
+
finally {
|
|
363
|
+
await close?.(s);
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
test("browse returns empty records but full total when offset exceeds total", async () => {
|
|
367
|
+
const s = await makeStore();
|
|
368
|
+
try {
|
|
369
|
+
await s.put(rec({ id: "a", namespace: "ns", content: "a" }));
|
|
370
|
+
const page = await s.browse({ limit: 10, offset: 99 });
|
|
371
|
+
expect(page.records).toEqual([]);
|
|
372
|
+
expect(page.total).toBe(1);
|
|
373
|
+
}
|
|
374
|
+
finally {
|
|
375
|
+
await close?.(s);
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
test("browse/stats namespacePrefix treats LIKE metacharacters literally", async () => {
|
|
379
|
+
const s = await makeStore();
|
|
380
|
+
try {
|
|
381
|
+
// Underscore: "route=/foo_" must NOT also match "route=/fooXbar".
|
|
382
|
+
await s.put(rec({ id: "u", namespace: "route=/foo_bar", content: "u" }));
|
|
383
|
+
await s.put(rec({ id: "x", namespace: "route=/fooXbar", content: "x" }));
|
|
384
|
+
// Percent + trailing backslash: "50%_off\\" must match only the
|
|
385
|
+
// literal namespace, never wildcard-expand.
|
|
386
|
+
await s.put(rec({ id: "p", namespace: "50%_off\\x", content: "p" }));
|
|
387
|
+
await s.put(rec({ id: "q", namespace: "50Y_offx", content: "q" }));
|
|
388
|
+
const page = await s.browse({ namespacePrefix: "route=/foo_" });
|
|
389
|
+
expect(page.records.map((r) => r.id)).toEqual(["u"]);
|
|
390
|
+
expect(page.total).toBe(1);
|
|
391
|
+
expect((await s.stats({ namespacePrefix: "route=/foo_" })).total).toBe(1);
|
|
392
|
+
const pct = await s.browse({ namespacePrefix: "50%" });
|
|
393
|
+
expect(pct.records.map((r) => r.id)).toEqual(["p"]);
|
|
394
|
+
expect(pct.total).toBe(1);
|
|
395
|
+
const bs = await s.browse({ namespacePrefix: "50%_off\\" });
|
|
396
|
+
expect(bs.records.map((r) => r.id)).toEqual(["p"]);
|
|
397
|
+
expect(bs.total).toBe(1);
|
|
398
|
+
expect((await s.stats({ namespacePrefix: "50%_off\\" })).total).toBe(1);
|
|
399
|
+
}
|
|
400
|
+
finally {
|
|
401
|
+
await close?.(s);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
test("browse namespacePrefix is case-sensitive", async () => {
|
|
405
|
+
const s = await makeStore();
|
|
406
|
+
try {
|
|
407
|
+
await s.put(rec({ id: "a", namespace: "route=/x", content: "a" }));
|
|
408
|
+
expect(await s.browse({ namespacePrefix: "Route=/X" })).toEqual({
|
|
409
|
+
records: [],
|
|
410
|
+
total: 0,
|
|
411
|
+
continuation: null,
|
|
412
|
+
});
|
|
413
|
+
expect((await s.stats({ namespacePrefix: "Route=/X" })).total).toBe(0);
|
|
414
|
+
}
|
|
415
|
+
finally {
|
|
416
|
+
await close?.(s);
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
test("browse rejects an invalid query instead of silently matching zero rows", async () => {
|
|
420
|
+
const s = await makeStore();
|
|
421
|
+
try {
|
|
422
|
+
// A store that quietly returns [] for a malformed filter teaches the caller
|
|
423
|
+
// that its query was fine and the data was empty. Both are lies.
|
|
424
|
+
// Identity, not wording: the HTTP boundary maps a rejection to 400 by `name`, so
|
|
425
|
+
// a store that caught and rethrew a plain Error satisfies every regex below and
|
|
426
|
+
// still 500s. Asserted by name rather than `instanceof`, which is false across the
|
|
427
|
+
// two module copies a bundled route and a node_modules store resolve to.
|
|
428
|
+
await expect(s.browse({ status: "bogus" })).rejects.toMatchObject({
|
|
429
|
+
name: "BrowseQueryError",
|
|
430
|
+
code: "invalid-query",
|
|
431
|
+
});
|
|
432
|
+
await expect(s.browse({ status: "bogus" })).rejects.toThrow(/invalid status/);
|
|
433
|
+
await expect(s.browse({ filters: [{ field: "tags", op: "in", values: ["x"] }] })).rejects.toThrow(/unknown filter field/);
|
|
434
|
+
await expect(s.browse({ filters: [{ field: "status", op: "in", values: [] }] })).rejects.toThrow(/must not be empty/);
|
|
435
|
+
await expect(s.browse({ orderBy: [{ field: "content", dir: "asc" }] })).rejects.toThrow(/unknown sort field/);
|
|
436
|
+
await expect(s.browse({ limit: 0 })).rejects.toThrow(/limit must be an integer >= 1/);
|
|
437
|
+
await expect(s.browse({ since: "2026-08-09" })).rejects.toThrow(/full ISO-8601/);
|
|
438
|
+
}
|
|
439
|
+
finally {
|
|
440
|
+
await close?.(s);
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
test("browse does NOT impose the HTTP limit ceiling on in-process callers", async () => {
|
|
444
|
+
const s = await makeStore();
|
|
445
|
+
try {
|
|
446
|
+
// The CLI's consolidation scan browses with limit 10_000, so no store may pass
|
|
447
|
+
// `maxLimit` into the validator. Only that rejection is visible here: with one row
|
|
448
|
+
// seeded, a store that also clamped 10_000 down to the ceiling reads identically.
|
|
449
|
+
await s.put(rec({ id: "a", namespace: "ns", content: "a" }));
|
|
450
|
+
const page = await s.browse({ limit: 10_000 });
|
|
451
|
+
expect(page.total).toBe(1);
|
|
452
|
+
expect(page.records.map((r) => r.id)).toEqual(["a"]);
|
|
453
|
+
}
|
|
454
|
+
finally {
|
|
455
|
+
await close?.(s);
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
test("browse filters[] narrows by status/kind set, ANDed with everything else", async () => {
|
|
459
|
+
const s = await makeStore();
|
|
460
|
+
try {
|
|
461
|
+
await s.put(rec({ id: "a", namespace: "route=/x", content: "a" }));
|
|
462
|
+
await s.put(rec({ id: "b", namespace: "route=/x", content: "b", status: "candidate" }));
|
|
463
|
+
await s.put(rec({ id: "e", namespace: "route=/y", content: "e", kind: "episodic" }));
|
|
464
|
+
const inSet = await s.browse({
|
|
465
|
+
filters: [{ field: "status", op: "in", values: ["candidate", "superseded"] }],
|
|
466
|
+
});
|
|
467
|
+
expect(inSet.records.map((r) => r.id)).toEqual(["b"]);
|
|
468
|
+
expect(inSet.total).toBe(1);
|
|
469
|
+
const notIn = await s.browse({
|
|
470
|
+
filters: [{ field: "kind", op: "notIn", values: ["episodic"] }],
|
|
471
|
+
});
|
|
472
|
+
expect(notIn.records.map((r) => r.id).sort()).toEqual(["a", "b"]);
|
|
473
|
+
expect(notIn.total).toBe(2);
|
|
474
|
+
const anded = await s.browse({
|
|
475
|
+
namespacePrefix: "route=/x",
|
|
476
|
+
filters: [{ field: "status", op: "in", values: ["candidate"] }],
|
|
477
|
+
});
|
|
478
|
+
expect(anded.records.map((r) => r.id)).toEqual(["b"]);
|
|
479
|
+
expect(anded.total).toBe(1);
|
|
480
|
+
}
|
|
481
|
+
finally {
|
|
482
|
+
await close?.(s);
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
test("browse returns identical ordered ids for a composed filter on every backend", async () => {
|
|
486
|
+
const s = await makeStore();
|
|
487
|
+
try {
|
|
488
|
+
for (let index = 0; index < 40; index += 1) {
|
|
489
|
+
await s.put(rec({
|
|
490
|
+
id: `cmp-${String(index).padStart(3, "0")}`,
|
|
491
|
+
kind: index % 2 === 0 ? "semantic" : "episodic",
|
|
492
|
+
namespace: index % 5 === 0 ? "route=/a-archive" : "route=/a",
|
|
493
|
+
content: index % 3 === 0 ? "needle body" : "filler body",
|
|
494
|
+
confidence: (index % 4) / 4,
|
|
495
|
+
status: index % 3 === 0 ? "active" : "candidate",
|
|
496
|
+
// Ten records per stamp so the id tie-break decides order INSIDE the
|
|
497
|
+
// window, not only at a page seam this single window never reaches.
|
|
498
|
+
updatedAt: new Date(Date.UTC(2026, 0, 1) + Math.floor(index / 10) * 60_000).toISOString(),
|
|
499
|
+
}));
|
|
500
|
+
}
|
|
501
|
+
const page = await s.browse({
|
|
502
|
+
namespace: "route=/a",
|
|
503
|
+
limit: 10,
|
|
504
|
+
filters: [
|
|
505
|
+
{ field: "status", op: "in", values: ["active"] },
|
|
506
|
+
{ field: "kind", op: "in", values: ["semantic"] },
|
|
507
|
+
{ field: "content", op: "contains", value: "needle" },
|
|
508
|
+
],
|
|
509
|
+
orderBy: [{ field: "updatedAt", dir: "desc" }],
|
|
510
|
+
});
|
|
511
|
+
// Byte-exact expectation, computed the way both stores must compute it: no
|
|
512
|
+
// archive rows, active ∧ semantic ∧ needle, updatedAt DESC then id ASC.
|
|
513
|
+
//
|
|
514
|
+
// The survivors are the indices divisible by 6 — `% 2` picks semantic and
|
|
515
|
+
// `% 3` picks active-and-needle together — minus those divisible by 5, which
|
|
516
|
+
// the exact `namespace` puts in the archive: 6, 12, 18, 24, 36. Two of them
|
|
517
|
+
// (12 and 18) share a minute stamp, so their relative order is decided by the
|
|
518
|
+
// id terminator rather than by the sort key — which is the half of this a
|
|
519
|
+
// backend can silently get wrong while looking right.
|
|
520
|
+
expect(page.records.map((record) => record.id)).toEqual([
|
|
521
|
+
"cmp-036",
|
|
522
|
+
"cmp-024",
|
|
523
|
+
"cmp-012",
|
|
524
|
+
"cmp-018",
|
|
525
|
+
"cmp-006",
|
|
526
|
+
]);
|
|
527
|
+
expect(page.total).toBe(5);
|
|
528
|
+
expect(page.continuation).toBeNull();
|
|
529
|
+
}
|
|
530
|
+
finally {
|
|
531
|
+
await close?.(s);
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
test("browse content filters are case-insensitive substring matches, not LIKE patterns", async () => {
|
|
535
|
+
const s = await makeStore();
|
|
536
|
+
try {
|
|
537
|
+
await s.put(rec({ id: "a", namespace: "ns", content: "Acme threshold is 500" }));
|
|
538
|
+
await s.put(rec({ id: "b", namespace: "ns", content: "zed color is blue" }));
|
|
539
|
+
await s.put(rec({ id: "pct", namespace: "ns", content: "50% off today" }));
|
|
540
|
+
await s.put(rec({ id: "und", namespace: "ns", content: "50Xoff today" }));
|
|
541
|
+
const contains = await s.browse({
|
|
542
|
+
filters: [{ field: "content", op: "contains", value: "ACME" }],
|
|
543
|
+
});
|
|
544
|
+
expect(contains.records.map((r) => r.id)).toEqual(["a"]);
|
|
545
|
+
expect(contains.total).toBe(1);
|
|
546
|
+
expect((await s.browse({ filters: [{ field: "content", op: "notContains", value: "acme" }] })).records
|
|
547
|
+
.map((r) => r.id)
|
|
548
|
+
.sort()).toEqual(["b", "pct", "und"]);
|
|
549
|
+
expect((await s.browse({ filters: [{ field: "content", op: "startsWith", value: "acme " }] })).records.map((r) => r.id)).toEqual(["a"]);
|
|
550
|
+
expect((await s.browse({ filters: [{ field: "content", op: "endsWith", value: "IS BLUE" }] })).records.map((r) => r.id)).toEqual(["b"]);
|
|
551
|
+
expect((await s.browse({
|
|
552
|
+
filters: [{ field: "content", op: "equals", value: "zed color is blue" }],
|
|
553
|
+
})).records.map((r) => r.id)).toEqual(["b"]);
|
|
554
|
+
expect((await s.browse({
|
|
555
|
+
filters: [{ field: "content", op: "notEquals", value: "zed color is blue" }],
|
|
556
|
+
})).total).toBe(3);
|
|
557
|
+
// "%" and "_" are literal characters, not wildcards: this is why the stores
|
|
558
|
+
// use instr/position instead of LIKE. Both needles SEPARATE the two readings —
|
|
559
|
+
// under LIKE, "%" is "anything" and "_" is "any one character", so both would
|
|
560
|
+
// additionally admit "500" and "50Xoff". A needle either reading accepts (say
|
|
561
|
+
// "50% o") asserts nothing here.
|
|
562
|
+
expect((await s.browse({ filters: [{ field: "content", op: "contains", value: "50%" }] })).records.map((r) => r.id)).toEqual(["pct"]);
|
|
563
|
+
expect((await s.browse({ filters: [{ field: "content", op: "contains", value: "50_" }] })).records.map((r) => r.id)).toEqual([]);
|
|
564
|
+
}
|
|
565
|
+
finally {
|
|
566
|
+
await close?.(s);
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
test("browse namespace is EXACT while namespacePrefix stays a prefix", async () => {
|
|
570
|
+
const s = await makeStore();
|
|
571
|
+
try {
|
|
572
|
+
await s.put(rec({ id: "exact", namespace: "route=/a", content: "exact" }));
|
|
573
|
+
await s.put(rec({ id: "child", namespace: "route=/ab", content: "child" }));
|
|
574
|
+
const byPrefix = await s.browse({ namespacePrefix: "route=/a" });
|
|
575
|
+
expect(byPrefix.records.map((r) => r.id).sort()).toEqual(["child", "exact"]);
|
|
576
|
+
expect(byPrefix.total).toBe(2);
|
|
577
|
+
// The exact field is what kills the Inspector's client-side narrowing, where
|
|
578
|
+
// the server counted the prefix and the client displayed the equality.
|
|
579
|
+
const byExact = await s.browse({ namespace: "route=/a" });
|
|
580
|
+
expect(byExact.records.map((r) => r.id)).toEqual(["exact"]);
|
|
581
|
+
expect(byExact.total).toBe(1);
|
|
582
|
+
const byFilter = await s.browse({
|
|
583
|
+
filters: [{ field: "namespace", op: "equals", value: "route=/ab" }],
|
|
584
|
+
});
|
|
585
|
+
expect(byFilter.records.map((r) => r.id)).toEqual(["child"]);
|
|
586
|
+
expect(byFilter.total).toBe(1);
|
|
587
|
+
}
|
|
588
|
+
finally {
|
|
589
|
+
await close?.(s);
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
test("browse namespace startsWith keeps byte-exact, case-sensitive, metachar-literal semantics", async () => {
|
|
593
|
+
const s = await makeStore();
|
|
594
|
+
try {
|
|
595
|
+
await s.put(rec({ id: "u", namespace: "route=/foo_bar", content: "u" }));
|
|
596
|
+
await s.put(rec({ id: "x", namespace: "route=/fooXbar", content: "x" }));
|
|
597
|
+
await s.put(rec({ id: "unicode", namespace: "route=/日本語", content: "unicode" }));
|
|
598
|
+
const underscore = await s.browse({
|
|
599
|
+
filters: [{ field: "namespace", op: "startsWith", value: "route=/foo_" }],
|
|
600
|
+
});
|
|
601
|
+
expect(underscore.records.map((r) => r.id)).toEqual(["u"]);
|
|
602
|
+
expect(underscore.total).toBe(1);
|
|
603
|
+
expect((await s.browse({
|
|
604
|
+
filters: [{ field: "namespace", op: "startsWith", value: "ROUTE=/foo" }],
|
|
605
|
+
})).total).toBe(0);
|
|
606
|
+
// Multi-byte prefixes must not fall outside the computed range.
|
|
607
|
+
const unicode = await s.browse({
|
|
608
|
+
filters: [{ field: "namespace", op: "startsWith", value: "route=/日" }],
|
|
609
|
+
});
|
|
610
|
+
expect(unicode.records.map((r) => r.id)).toEqual(["unicode"]);
|
|
611
|
+
expect(unicode.total).toBe(1);
|
|
612
|
+
}
|
|
613
|
+
finally {
|
|
614
|
+
await close?.(s);
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
test("browse ANDs namespace with namespacePrefix instead of letting one win", async () => {
|
|
618
|
+
const s = await makeStore();
|
|
619
|
+
try {
|
|
620
|
+
await s.put(rec({ id: "exact", namespace: "route=/a", content: "exact" }));
|
|
621
|
+
await s.put(rec({ id: "child", namespace: "route=/ab", content: "child" }));
|
|
622
|
+
// The only query that binds both clauses at once, so it is the only one where
|
|
623
|
+
// a parameter pushed out of step with its clause shows up as a wrong answer
|
|
624
|
+
// rather than a bind error.
|
|
625
|
+
const both = await s.browse({ namespace: "route=/ab", namespacePrefix: "route=/a" });
|
|
626
|
+
expect(both.records.map((r) => r.id)).toEqual(["child"]);
|
|
627
|
+
expect(both.total).toBe(1);
|
|
628
|
+
const disjoint = await s.browse({ namespace: "route=/a", namespacePrefix: "route=/ab" });
|
|
629
|
+
expect(disjoint.records).toEqual([]);
|
|
630
|
+
expect(disjoint.total).toBe(0);
|
|
631
|
+
}
|
|
632
|
+
finally {
|
|
633
|
+
await close?.(s);
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
test("browse namespacePrefix above the last code point keeps only a lower bound", async () => {
|
|
637
|
+
const s = await makeStore();
|
|
638
|
+
try {
|
|
639
|
+
await s.put(rec({ id: "below", namespace: "route=/a", content: "below" }));
|
|
640
|
+
await s.put(rec({ id: "top", namespace: "\u{10FFFF}top", content: "top" }));
|
|
641
|
+
// Nothing sorts above U+10FFFF, so the prefix has no successor and the upper
|
|
642
|
+
// bound must be OMITTED — binding an absent one matches nothing at all.
|
|
643
|
+
const top = await s.browse({ namespacePrefix: "\u{10FFFF}" });
|
|
644
|
+
expect(top.records.map((r) => r.id)).toEqual(["top"]);
|
|
645
|
+
expect(top.total).toBe(1);
|
|
646
|
+
const viaFilter = await s.browse({
|
|
647
|
+
filters: [{ field: "namespace", op: "startsWith", value: "\u{10FFFF}" }],
|
|
648
|
+
});
|
|
649
|
+
expect(viaFilter.records.map((r) => r.id)).toEqual(["top"]);
|
|
650
|
+
expect(viaFilter.total).toBe(1);
|
|
651
|
+
}
|
|
652
|
+
finally {
|
|
653
|
+
await close?.(s);
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
test("browse filters by confidence, with between inclusive on both ends", async () => {
|
|
657
|
+
const s = await makeStore();
|
|
658
|
+
try {
|
|
659
|
+
// 0.9 is chosen deliberately: it is not representable in float4, so `eq`
|
|
660
|
+
// is asserted against the value READ BACK, which is the only one a backend
|
|
661
|
+
// that narrows on write can still match.
|
|
662
|
+
await s.put(rec({ id: "low", namespace: "ns", content: "low", confidence: 0.2 }));
|
|
663
|
+
await s.put(rec({ id: "mid", namespace: "ns", content: "mid", confidence: 0.5 }));
|
|
664
|
+
await s.put(rec({ id: "high", namespace: "ns", content: "high", confidence: 0.9 }));
|
|
665
|
+
const stored = (await s.get("high"))?.confidence;
|
|
666
|
+
expect((await s.browse({ filters: [{ field: "confidence", op: "eq", value: stored }] })).records.map((r) => r.id)).toEqual(["high"]);
|
|
667
|
+
expect((await s.browse({ filters: [{ field: "confidence", op: "gt", value: 0.5 }] })).records.map((r) => r.id)).toEqual(["high"]);
|
|
668
|
+
// Ids, not counts: at this spread `gte 0.5` and `lte 0.5` both match two rows,
|
|
669
|
+
// so a count-only assertion passes with the two operators transposed.
|
|
670
|
+
const gte = await s.browse({ filters: [{ field: "confidence", op: "gte", value: 0.5 }] });
|
|
671
|
+
expect(gte.records.map((r) => r.id).sort()).toEqual(["high", "mid"]);
|
|
672
|
+
expect(gte.total).toBe(2);
|
|
673
|
+
expect((await s.browse({ filters: [{ field: "confidence", op: "lt", value: 0.5 }] })).records.map((r) => r.id)).toEqual(["low"]);
|
|
674
|
+
const lte = await s.browse({ filters: [{ field: "confidence", op: "lte", value: 0.5 }] });
|
|
675
|
+
expect(lte.records.map((r) => r.id).sort()).toEqual(["low", "mid"]);
|
|
676
|
+
expect(lte.total).toBe(2);
|
|
677
|
+
const neq = await s.browse({ filters: [{ field: "confidence", op: "neq", value: 0.5 }] });
|
|
678
|
+
expect(neq.records.map((r) => r.id).sort()).toEqual(["high", "low"]);
|
|
679
|
+
expect(neq.total).toBe(2);
|
|
680
|
+
const between = await s.browse({
|
|
681
|
+
filters: [{ field: "confidence", op: "between", min: 0.2, max: 0.5 }],
|
|
682
|
+
});
|
|
683
|
+
expect(between.records.map((r) => r.id).sort()).toEqual(["low", "mid"]);
|
|
684
|
+
expect(between.total).toBe(2);
|
|
685
|
+
}
|
|
686
|
+
finally {
|
|
687
|
+
await close?.(s);
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
test("browse filters updatedAt by UTC day buckets", async () => {
|
|
691
|
+
const s = await makeStore();
|
|
692
|
+
try {
|
|
693
|
+
await s.put(rec({ id: "d1", namespace: "ns", content: "d1", updatedAt: "2026-08-01T23:59:59.999Z" }));
|
|
694
|
+
await s.put(rec({ id: "d2", namespace: "ns", content: "d2", updatedAt: "2026-08-02T00:00:00.000Z" }));
|
|
695
|
+
await s.put(rec({ id: "d3", namespace: "ns", content: "d3", updatedAt: "2026-08-03T12:00:00.000Z" }));
|
|
696
|
+
const onDay = await s.browse({
|
|
697
|
+
filters: [{ field: "updatedAt", op: "onDay", day: "2026-08-02" }],
|
|
698
|
+
});
|
|
699
|
+
expect(onDay.records.map((r) => r.id)).toEqual(["d2"]);
|
|
700
|
+
expect(onDay.total).toBe(1);
|
|
701
|
+
expect((await s.browse({
|
|
702
|
+
filters: [{ field: "updatedAt", op: "beforeDay", day: "2026-08-02" }],
|
|
703
|
+
})).records.map((r) => r.id)).toEqual(["d1"]);
|
|
704
|
+
expect((await s.browse({ filters: [{ field: "updatedAt", op: "afterDay", day: "2026-08-02" }] })).records.map((r) => r.id)).toEqual(["d3"]);
|
|
705
|
+
const span = await s.browse({
|
|
706
|
+
filters: [
|
|
707
|
+
{
|
|
708
|
+
field: "updatedAt",
|
|
709
|
+
op: "betweenDays",
|
|
710
|
+
fromDay: "2026-08-01",
|
|
711
|
+
untilDay: "2026-08-02",
|
|
712
|
+
},
|
|
713
|
+
],
|
|
714
|
+
});
|
|
715
|
+
expect(span.records.map((r) => r.id).sort()).toEqual(["d1", "d2"]);
|
|
716
|
+
expect(span.total).toBe(2);
|
|
717
|
+
}
|
|
718
|
+
finally {
|
|
719
|
+
await close?.(s);
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
test("stats returns count maps by status/kind/namespace/sourceType plus total", async () => {
|
|
723
|
+
const s = await makeStore();
|
|
724
|
+
try {
|
|
725
|
+
await s.put(rec({ id: "a", namespace: "route=/x", content: "a" }));
|
|
726
|
+
await s.put(rec({ id: "b", namespace: "route=/y", content: "b", status: "candidate" }));
|
|
727
|
+
await s.put(rec({ id: "h", namespace: "route=/x", content: "h", source: { type: "human", id: "u" } }));
|
|
728
|
+
const st = await s.stats();
|
|
729
|
+
expect(st.total).toBe(3);
|
|
730
|
+
expect(st.byStatus).toEqual({ active: 2, candidate: 1 });
|
|
731
|
+
expect(st.byKind).toEqual({ semantic: 3 });
|
|
732
|
+
expect(st.byNamespace).toEqual({ "route=/x": 2, "route=/y": 1 });
|
|
733
|
+
expect(st.bySourceType).toEqual({ eval: 2, human: 1 });
|
|
734
|
+
}
|
|
735
|
+
finally {
|
|
736
|
+
await close?.(s);
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
test("stats honors namespacePrefix", async () => {
|
|
740
|
+
const s = await makeStore();
|
|
741
|
+
try {
|
|
742
|
+
await s.put(rec({ id: "a", namespace: "route=/x", content: "a" }));
|
|
743
|
+
await s.put(rec({ id: "b", namespace: "route=/y", content: "b" }));
|
|
744
|
+
expect((await s.stats({ namespacePrefix: "route=/x" })).total).toBe(1);
|
|
745
|
+
}
|
|
746
|
+
finally {
|
|
747
|
+
await close?.(s);
|
|
748
|
+
}
|
|
749
|
+
});
|
|
750
|
+
test("stats returns zero total and empty maps on an empty store", async () => {
|
|
751
|
+
const s = await makeStore();
|
|
752
|
+
try {
|
|
753
|
+
expect(await s.stats()).toEqual({
|
|
754
|
+
total: 0,
|
|
755
|
+
byStatus: {},
|
|
756
|
+
byKind: {},
|
|
757
|
+
byNamespace: {},
|
|
758
|
+
bySourceType: {},
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
finally {
|
|
762
|
+
await close?.(s);
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
test("hybrid: mismatched embedder tag is excluded from the vector list", async () => {
|
|
766
|
+
const s = await makeStore();
|
|
767
|
+
try {
|
|
768
|
+
await s.put(rec({ id: "stale", namespace: "ns", content: "faster shipping" }), {
|
|
769
|
+
embedding: vec(1, 0, 0),
|
|
770
|
+
embeddingModel: "old:model",
|
|
771
|
+
});
|
|
772
|
+
const out = await s.search({
|
|
773
|
+
namespace: "ns",
|
|
774
|
+
query: "expedite delivery",
|
|
775
|
+
queryEmbedding: vec(1, 0, 0),
|
|
776
|
+
embedderId: "fake:test",
|
|
777
|
+
now: "2026-07-05T00:00:00.000Z",
|
|
778
|
+
});
|
|
779
|
+
expect(out.map((r) => r.id)).not.toContain("stale");
|
|
780
|
+
}
|
|
781
|
+
finally {
|
|
782
|
+
await close?.(s);
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
test("search window: since is inclusive, until is exclusive on effectiveAt", async () => {
|
|
786
|
+
const s = await makeStore();
|
|
787
|
+
try {
|
|
788
|
+
await s.put(ep({ id: "d1", effectiveAt: D(1) }));
|
|
789
|
+
await s.put(ep({ id: "d2", effectiveAt: D(2) }));
|
|
790
|
+
await s.put(ep({ id: "d3", effectiveAt: D(3) }));
|
|
791
|
+
const out = await s.search({ namespace: "route=/n", since: D(2), until: D(3) });
|
|
792
|
+
expect(out.map((r) => r.id)).toEqual(["d2"]);
|
|
793
|
+
}
|
|
794
|
+
finally {
|
|
795
|
+
await close?.(s);
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
test("search window falls back to createdAt for legacy rows without effectiveAt", async () => {
|
|
799
|
+
const s = await makeStore();
|
|
800
|
+
try {
|
|
801
|
+
await s.put(ep({ id: "legacy", createdAt: D(2), updatedAt: D(2) })); // no effectiveAt
|
|
802
|
+
await s.put(ep({ id: "outside", createdAt: D(5), updatedAt: D(5) }));
|
|
803
|
+
const out = await s.search({ namespace: "route=/n", since: D(1), until: D(3) });
|
|
804
|
+
expect(out.map((r) => r.id)).toEqual(["legacy"]);
|
|
805
|
+
}
|
|
806
|
+
finally {
|
|
807
|
+
await close?.(s);
|
|
808
|
+
}
|
|
809
|
+
});
|
|
810
|
+
test("windowed query-less search orders by event time, not update time", async () => {
|
|
811
|
+
const s = await makeStore();
|
|
812
|
+
try {
|
|
813
|
+
// updatedAt order is DELIBERATELY the reverse of effectiveAt order —
|
|
814
|
+
// proves windowed ordering uses event time, not update time.
|
|
815
|
+
await s.put(ep({ id: "b", effectiveAt: D(3), updatedAt: D(1) }));
|
|
816
|
+
await s.put(ep({ id: "a", effectiveAt: D(1), updatedAt: D(3) }));
|
|
817
|
+
await s.put(ep({ id: "c", effectiveAt: D(3), updatedAt: D(2) }));
|
|
818
|
+
const out = await s.search({ namespace: "route=/n", since: D(1) });
|
|
819
|
+
expect(out.map((r) => r.id)).toEqual(["b", "c", "a"]); // D3 pair id-ASC, then D1
|
|
820
|
+
}
|
|
821
|
+
finally {
|
|
822
|
+
await close?.(s);
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
test("windowed query-less equal event times order by codepoint id (C collation)", async () => {
|
|
826
|
+
const s = await makeStore();
|
|
827
|
+
try {
|
|
828
|
+
// Same effectiveAt on every row: id ASC is the sole tiebreak, and both
|
|
829
|
+
// backends must agree byte-for-byte (BINARY / C collation). Mixed case
|
|
830
|
+
// distinguishes codepoint order ("B10" < "a9") from locale collation.
|
|
831
|
+
for (const id of ["b2", "B10", "a9"]) {
|
|
832
|
+
await s.put(ep({ id, effectiveAt: D(2) }));
|
|
833
|
+
}
|
|
834
|
+
const out = await s.search({ namespace: "route=/n", since: D(1) });
|
|
835
|
+
expect(out.map((r) => r.id)).toEqual(["B10", "a9", "b2"]);
|
|
836
|
+
}
|
|
837
|
+
finally {
|
|
838
|
+
await close?.(s);
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
test("search with now excludes expired rows; without now shows everything", async () => {
|
|
842
|
+
const s = await makeStore();
|
|
843
|
+
try {
|
|
844
|
+
await s.put(ep({ id: "live", expiresAt: D(9) }));
|
|
845
|
+
await s.put(ep({ id: "dead", expiresAt: D(2) }));
|
|
846
|
+
const withNow = await s.search({ namespace: "route=/n", now: D(5) });
|
|
847
|
+
expect(withNow.map((r) => r.id)).toEqual(["live"]);
|
|
848
|
+
const withoutNow = await s.search({ namespace: "route=/n" });
|
|
849
|
+
expect(withoutNow.map((r) => r.id).sort()).toEqual(["dead", "live"]);
|
|
850
|
+
}
|
|
851
|
+
finally {
|
|
852
|
+
await close?.(s);
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
test("expiry boundary: expiresAt exactly equal to now is excluded", async () => {
|
|
856
|
+
const s = await makeStore();
|
|
857
|
+
try {
|
|
858
|
+
await s.put(ep({ id: "edge", expiresAt: D(5) }));
|
|
859
|
+
expect((await s.search({ namespace: "route=/n", now: D(5) })).length).toBe(0);
|
|
860
|
+
}
|
|
861
|
+
finally {
|
|
862
|
+
await close?.(s);
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
test("ranked (query) search shares the window + expiry clause", async () => {
|
|
866
|
+
const s = await makeStore();
|
|
867
|
+
try {
|
|
868
|
+
await s.put(ep({
|
|
869
|
+
id: "hit",
|
|
870
|
+
content: "deploy failed on staging",
|
|
871
|
+
effectiveAt: D(2),
|
|
872
|
+
expiresAt: D(9),
|
|
873
|
+
}));
|
|
874
|
+
await s.put(ep({
|
|
875
|
+
id: "expired",
|
|
876
|
+
content: "deploy failed on prod",
|
|
877
|
+
effectiveAt: D(2),
|
|
878
|
+
expiresAt: D(3),
|
|
879
|
+
}));
|
|
880
|
+
await s.put(ep({ id: "outside", content: "deploy failed early", effectiveAt: D(1) }));
|
|
881
|
+
const out = await s.search({
|
|
882
|
+
namespace: "route=/n",
|
|
883
|
+
query: "deploy failed",
|
|
884
|
+
since: D(2),
|
|
885
|
+
now: D(5),
|
|
886
|
+
});
|
|
887
|
+
expect(out.map((r) => r.id)).toEqual(["hit"]);
|
|
888
|
+
}
|
|
889
|
+
finally {
|
|
890
|
+
await close?.(s);
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
test("browse honors since/until + now, with total sharing the clause", async () => {
|
|
894
|
+
const s = await makeStore();
|
|
895
|
+
try {
|
|
896
|
+
await s.put(ep({ id: "live", effectiveAt: D(2), expiresAt: D(9) }));
|
|
897
|
+
await s.put(ep({ id: "dead", effectiveAt: D(2), expiresAt: D(3) }));
|
|
898
|
+
await s.put(ep({ id: "early", effectiveAt: D(1), expiresAt: D(9) }));
|
|
899
|
+
const page = await s.browse({ since: D(2), until: D(4), now: D(5) });
|
|
900
|
+
expect(page.records.map((r) => r.id)).toEqual(["live"]);
|
|
901
|
+
expect(page.total).toBe(1); // COUNT shares the full WHERE incl. expiry+window
|
|
902
|
+
}
|
|
903
|
+
finally {
|
|
904
|
+
await close?.(s);
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
test("prune deletes expired rows of any kind and reports the count", async () => {
|
|
908
|
+
const s = await makeStore();
|
|
909
|
+
try {
|
|
910
|
+
await s.put(ep({ id: "sem-dead", kind: "semantic", expiresAt: D(2) }));
|
|
911
|
+
await s.put(ep({ id: "ep-dead", expiresAt: D(2) }));
|
|
912
|
+
await s.put(ep({ id: "ep-live", expiresAt: D(9) }));
|
|
913
|
+
const res = await s.prune({ now: D(5) });
|
|
914
|
+
expect(res).toEqual({ deletedExpired: 2, deletedOverCap: 0 });
|
|
915
|
+
expect(await s.get("sem-dead")).toBeNull();
|
|
916
|
+
expect((await s.get("ep-live"))?.id).toBe("ep-live");
|
|
917
|
+
}
|
|
918
|
+
finally {
|
|
919
|
+
await close?.(s);
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
test("prune caps episodic rows per namespace, keeping the newest by event time", async () => {
|
|
923
|
+
const s = await makeStore();
|
|
924
|
+
try {
|
|
925
|
+
for (let i = 1; i <= 5; i++)
|
|
926
|
+
await s.put(ep({ id: `e${i}`, effectiveAt: D(i) }));
|
|
927
|
+
const res = await s.prune({ now: D(9), cap: 3 });
|
|
928
|
+
expect(res.deletedOverCap).toBe(2);
|
|
929
|
+
expect((await s.browse({ kind: "episodic" })).records.map((r) => r.id).sort()).toEqual([
|
|
930
|
+
"e3",
|
|
931
|
+
"e4",
|
|
932
|
+
"e5",
|
|
933
|
+
]);
|
|
934
|
+
}
|
|
935
|
+
finally {
|
|
936
|
+
await close?.(s);
|
|
937
|
+
}
|
|
938
|
+
});
|
|
939
|
+
test("prune cap is PER namespace and never touches non-episodic rows", async () => {
|
|
940
|
+
const s = await makeStore();
|
|
941
|
+
try {
|
|
942
|
+
await s.put(ep({ id: "sem", kind: "semantic" }));
|
|
943
|
+
for (let i = 1; i <= 3; i++) {
|
|
944
|
+
await s.put(ep({ id: `a${i}`, namespace: "route=/a", effectiveAt: D(i) }));
|
|
945
|
+
}
|
|
946
|
+
for (let i = 1; i <= 3; i++) {
|
|
947
|
+
await s.put(ep({ id: `b${i}`, namespace: "route=/b", effectiveAt: D(i) }));
|
|
948
|
+
}
|
|
949
|
+
const res = await s.prune({ now: D(9), cap: 2 });
|
|
950
|
+
expect(res.deletedOverCap).toBe(2); // one from each namespace
|
|
951
|
+
expect(await s.get("a1")).toBeNull();
|
|
952
|
+
expect(await s.get("b1")).toBeNull();
|
|
953
|
+
expect((await s.get("sem"))?.id).toBe("sem");
|
|
954
|
+
}
|
|
955
|
+
finally {
|
|
956
|
+
await close?.(s);
|
|
957
|
+
}
|
|
958
|
+
});
|
|
959
|
+
test("prune cap ranks episodic rows regardless of status (superseded counts)", async () => {
|
|
960
|
+
const s = await makeStore();
|
|
961
|
+
try {
|
|
962
|
+
// A superseded episodic row sits between two actives by event time. The
|
|
963
|
+
// cap pass ranks ALL episodic rows — status never exempts a row from the
|
|
964
|
+
// budget — so cap=2 keeps the two newest (one of them superseded) and
|
|
965
|
+
// evicts the oldest ACTIVE row.
|
|
966
|
+
await s.put(ep({ id: "new-active", effectiveAt: D(3) }));
|
|
967
|
+
await s.put(ep({ id: "mid-superseded", effectiveAt: D(2), status: "superseded" }));
|
|
968
|
+
await s.put(ep({ id: "old-active", effectiveAt: D(1) }));
|
|
969
|
+
const res = await s.prune({ now: D(9), cap: 2 });
|
|
970
|
+
expect(res.deletedOverCap).toBe(1);
|
|
971
|
+
expect(await s.get("old-active")).toBeNull();
|
|
972
|
+
expect((await s.get("mid-superseded"))?.id).toBe("mid-superseded");
|
|
973
|
+
expect((await s.get("new-active"))?.id).toBe("new-active");
|
|
974
|
+
}
|
|
975
|
+
finally {
|
|
976
|
+
await close?.(s);
|
|
977
|
+
}
|
|
978
|
+
});
|
|
979
|
+
test("prune equal event times evict by codepoint id order (deterministic tiebreak)", async () => {
|
|
980
|
+
const s = await makeStore();
|
|
981
|
+
try {
|
|
982
|
+
// Same effectiveAt; codepoint id order decides. Keep the NEWEST 2 under
|
|
983
|
+
// (effective DESC, id ASC) ordering ⇒ evict the last in that ordering:
|
|
984
|
+
// "b2" (0x62 highest codepoint sorts last among equals).
|
|
985
|
+
await s.put(ep({ id: "B10", effectiveAt: D(2) }));
|
|
986
|
+
await s.put(ep({ id: "a9", effectiveAt: D(2) }));
|
|
987
|
+
await s.put(ep({ id: "b2", effectiveAt: D(2) }));
|
|
988
|
+
await s.prune({ now: D(9), cap: 2 });
|
|
989
|
+
expect(await s.get("b2")).toBeNull();
|
|
990
|
+
expect((await s.get("B10"))?.id).toBe("B10");
|
|
991
|
+
expect((await s.get("a9"))?.id).toBe("a9");
|
|
992
|
+
}
|
|
993
|
+
finally {
|
|
994
|
+
await close?.(s);
|
|
995
|
+
}
|
|
996
|
+
});
|
|
997
|
+
test("prune namespacePrefix narrows both TTL and cap passes", async () => {
|
|
998
|
+
const s = await makeStore();
|
|
999
|
+
try {
|
|
1000
|
+
await s.put(ep({ id: "in", namespace: "route=/a", expiresAt: D(2) }));
|
|
1001
|
+
await s.put(ep({ id: "out", namespace: "route=/b", expiresAt: D(2) }));
|
|
1002
|
+
for (let i = 1; i <= 3; i++) {
|
|
1003
|
+
await s.put(ep({ id: `ka${i}`, namespace: "route=/a", effectiveAt: D(i + 2) }));
|
|
1004
|
+
}
|
|
1005
|
+
for (let i = 1; i <= 3; i++) {
|
|
1006
|
+
await s.put(ep({ id: `kb${i}`, namespace: "route=/b", effectiveAt: D(i + 2) }));
|
|
1007
|
+
}
|
|
1008
|
+
const res = await s.prune({ now: D(5), cap: 2, namespacePrefix: "route=/a" });
|
|
1009
|
+
expect(res.deletedExpired).toBe(1);
|
|
1010
|
+
expect(res.deletedOverCap).toBe(1);
|
|
1011
|
+
expect((await s.get("out"))?.id).toBe("out");
|
|
1012
|
+
expect(await s.get("ka1")).toBeNull();
|
|
1013
|
+
expect((await s.get("kb1"))?.id).toBe("kb1");
|
|
1014
|
+
}
|
|
1015
|
+
finally {
|
|
1016
|
+
await close?.(s);
|
|
1017
|
+
}
|
|
1018
|
+
});
|
|
1019
|
+
test("prune is idempotent — a second identical prune deletes nothing", async () => {
|
|
1020
|
+
const s = await makeStore();
|
|
1021
|
+
try {
|
|
1022
|
+
for (let i = 1; i <= 4; i++) {
|
|
1023
|
+
await s.put(ep({ id: `e${i}`, effectiveAt: D(i), ...(i === 1 ? { expiresAt: D(2) } : {}) }));
|
|
1024
|
+
}
|
|
1025
|
+
await s.prune({ now: D(5), cap: 2 });
|
|
1026
|
+
const second = await s.prune({ now: D(5), cap: 2 });
|
|
1027
|
+
expect(second).toEqual({ deletedExpired: 0, deletedOverCap: 0 });
|
|
1028
|
+
}
|
|
1029
|
+
finally {
|
|
1030
|
+
await close?.(s);
|
|
1031
|
+
}
|
|
1032
|
+
});
|
|
1033
|
+
test("episodic candidate approval is plain activation — no reconcile against actives", async () => {
|
|
1034
|
+
const s = await makeStore();
|
|
1035
|
+
try {
|
|
1036
|
+
// Identical data to an existing active row: a reconcile policy would
|
|
1037
|
+
// dedupe (delete the candidate); the episodic append policy activates it.
|
|
1038
|
+
await s.put(ep({ id: "prior", data: { event: "deploy" }, effectiveAt: D(1) }));
|
|
1039
|
+
await s.put(ep({ id: "cand", data: { event: "deploy" }, status: "candidate", effectiveAt: D(2) }));
|
|
1040
|
+
const res = await approveWithReconcile(s, "cand", {
|
|
1041
|
+
identityKeys: ["event"],
|
|
1042
|
+
now: D(3),
|
|
1043
|
+
});
|
|
1044
|
+
expect(res.action).toBe("activated");
|
|
1045
|
+
expect(res.superseded).toEqual([]);
|
|
1046
|
+
expect((await s.get("cand"))?.status).toBe("active");
|
|
1047
|
+
expect((await s.get("prior"))?.status).toBe("active");
|
|
1048
|
+
}
|
|
1049
|
+
finally {
|
|
1050
|
+
await close?.(s);
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
1053
|
+
test("supersede links and demotes episodic records (consolidation's dependency)", async () => {
|
|
1054
|
+
const s = await makeStore();
|
|
1055
|
+
try {
|
|
1056
|
+
// Consolidation writes a summary then supersedes each source episode.
|
|
1057
|
+
// Both backends must fan MANY sources into ONE summary's link list, and
|
|
1058
|
+
// active-only browse must stop surfacing the sources — otherwise recall
|
|
1059
|
+
// double-counts the events the summary already covers.
|
|
1060
|
+
await s.put(ep({ id: "src1", namespace: "ns", content: "run one" }));
|
|
1061
|
+
await s.put(ep({ id: "src2", namespace: "ns", content: "run two" }));
|
|
1062
|
+
await s.put(ep({ id: "sum", namespace: "ns", content: "summary", tags: ["consolidated"] }));
|
|
1063
|
+
await s.supersede("src1", "sum");
|
|
1064
|
+
await s.supersede("src2", "sum");
|
|
1065
|
+
await s.supersede("src1", "sum"); // repeat: links merge through a Set, never duplicate
|
|
1066
|
+
expect((await s.get("src1"))?.status).toBe("superseded");
|
|
1067
|
+
expect((await s.get("src2"))?.status).toBe("superseded");
|
|
1068
|
+
const links = (await s.get("sum"))?.supersedes;
|
|
1069
|
+
expect(links).toEqual(expect.arrayContaining(["src1", "src2"]));
|
|
1070
|
+
expect(links).toHaveLength(2);
|
|
1071
|
+
// active-only browse no longer surfaces the sources
|
|
1072
|
+
expect((await s.browse({ status: "active", kind: "episodic" })).records.map((r) => r.id)).toEqual(["sum"]);
|
|
1073
|
+
}
|
|
1074
|
+
finally {
|
|
1075
|
+
await close?.(s);
|
|
1076
|
+
}
|
|
1077
|
+
});
|
|
1078
|
+
test("browse applies orderBy in order and always terminates with the id tie-break", async () => {
|
|
1079
|
+
const s = await makeStore();
|
|
1080
|
+
try {
|
|
1081
|
+
// Deliberately tied on the leading key so the tie-break is the ONLY thing
|
|
1082
|
+
// deciding the order — and mixed-case ids so a locale collation would
|
|
1083
|
+
// disagree with byte order if the tie-break were not pinned.
|
|
1084
|
+
await s.put(rec({ id: "B", namespace: "ns", content: "B", confidence: 0.5, updatedAt: D(1) }));
|
|
1085
|
+
await s.put(rec({ id: "a", namespace: "ns", content: "a", confidence: 0.5, updatedAt: D(1) }));
|
|
1086
|
+
await s.put(rec({ id: "C", namespace: "ns", content: "C", confidence: 0.5, updatedAt: D(1) }));
|
|
1087
|
+
await s.put(rec({ id: "z", namespace: "ns", content: "z", confidence: 0.9, updatedAt: D(2) }));
|
|
1088
|
+
expect((await s.browse({ orderBy: [{ field: "confidence", dir: "desc" }] })).records.map((r) => r.id)).toEqual(["z", "B", "C", "a"]);
|
|
1089
|
+
expect((await s.browse({ orderBy: [{ field: "confidence", dir: "asc" }] })).records.map((r) => r.id)).toEqual(["B", "C", "a", "z"]);
|
|
1090
|
+
}
|
|
1091
|
+
finally {
|
|
1092
|
+
await close?.(s);
|
|
1093
|
+
}
|
|
1094
|
+
});
|
|
1095
|
+
test("browse honors a multi-key orderBy with mixed directions", async () => {
|
|
1096
|
+
const s = await makeStore();
|
|
1097
|
+
try {
|
|
1098
|
+
await s.put(rec({ id: "1", namespace: "ns=b", content: "1", confidence: 0.1 }));
|
|
1099
|
+
await s.put(rec({ id: "2", namespace: "ns=a", content: "2", confidence: 0.9 }));
|
|
1100
|
+
// "4" and "3" tie on BOTH keys, so the appended tie-break is the only thing
|
|
1101
|
+
// separating them — and they go in reverse id order so insertion order alone
|
|
1102
|
+
// cannot produce the expected sequence.
|
|
1103
|
+
await s.put(rec({ id: "4", namespace: "ns=a", content: "4", confidence: 0.1 }));
|
|
1104
|
+
await s.put(rec({ id: "3", namespace: "ns=a", content: "3", confidence: 0.1 }));
|
|
1105
|
+
expect((await s.browse({
|
|
1106
|
+
orderBy: [
|
|
1107
|
+
{ field: "namespace", dir: "asc" },
|
|
1108
|
+
{ field: "confidence", dir: "desc" },
|
|
1109
|
+
],
|
|
1110
|
+
})).records.map((r) => r.id)).toEqual(["2", "3", "4", "1"]);
|
|
1111
|
+
}
|
|
1112
|
+
finally {
|
|
1113
|
+
await close?.(s);
|
|
1114
|
+
}
|
|
1115
|
+
});
|
|
1116
|
+
test("browse orders by the enum columns kind and status, uncollated on both backends", async () => {
|
|
1117
|
+
const s = await makeStore();
|
|
1118
|
+
try {
|
|
1119
|
+
// kind/status are the only sort columns Postgres resolves WITHOUT COLLATE "C",
|
|
1120
|
+
// safe only because both are closed lowercase-ASCII enums. Ids are deliberately
|
|
1121
|
+
// NOT in enum order, so a store that ignored the key and fell through to the
|
|
1122
|
+
// default `updated_at DESC, id ASC` would return "1","2","3","4" and fail.
|
|
1123
|
+
const ids = async (field, dir) => (await s.browse({ orderBy: [{ field, dir }] })).records.map((r) => r.id);
|
|
1124
|
+
await s.put(rec({ id: "1", namespace: "ns", content: "1", kind: "semantic", status: "candidate" }));
|
|
1125
|
+
await s.put(rec({ id: "2", namespace: "ns", content: "2", kind: "episodic", status: "superseded" }));
|
|
1126
|
+
await s.put(rec({ id: "3", namespace: "ns", content: "3", kind: "reflection", status: "active" }));
|
|
1127
|
+
await s.put(rec({ id: "4", namespace: "ns", content: "4", kind: "procedural", status: "active" }));
|
|
1128
|
+
expect(await ids("kind", "asc")).toEqual(["2", "4", "3", "1"]);
|
|
1129
|
+
expect(await ids("kind", "desc")).toEqual(["1", "3", "4", "2"]);
|
|
1130
|
+
expect(await ids("status", "asc")).toEqual(["3", "4", "1", "2"]);
|
|
1131
|
+
}
|
|
1132
|
+
finally {
|
|
1133
|
+
await close?.(s);
|
|
1134
|
+
}
|
|
1135
|
+
});
|
|
1136
|
+
test("browse with an empty orderBy is the documented default order", async () => {
|
|
1137
|
+
const s = await makeStore();
|
|
1138
|
+
try {
|
|
1139
|
+
await s.put(rec({ id: "old", namespace: "ns", content: "old", updatedAt: D(1) }));
|
|
1140
|
+
await s.put(rec({ id: "new", namespace: "ns", content: "new", updatedAt: D(2) }));
|
|
1141
|
+
expect((await s.browse({ orderBy: [] })).records.map((r) => r.id)).toEqual(["new", "old"]);
|
|
1142
|
+
expect((await s.browse()).records.map((r) => r.id)).toEqual(["new", "old"]);
|
|
1143
|
+
}
|
|
1144
|
+
finally {
|
|
1145
|
+
await close?.(s);
|
|
1146
|
+
}
|
|
1147
|
+
});
|
|
1148
|
+
test("browse walks the whole dataset through continuations under a fixed now", async () => {
|
|
1149
|
+
const s = await makeStore();
|
|
1150
|
+
try {
|
|
1151
|
+
// Odd days, so the expired row below can land BETWEEN two live ones.
|
|
1152
|
+
for (let i = 0; i < 7; i += 1) {
|
|
1153
|
+
await s.put(rec({ id: `r${i}`, namespace: "ns", content: `r${i}`, updatedAt: D(2 * i + 1) }));
|
|
1154
|
+
}
|
|
1155
|
+
// Sorts between r4 and r3, i.e. inside the SECOND window: a store that applies
|
|
1156
|
+
// the expiry clause to the first page and drops it on continuations leaks it here.
|
|
1157
|
+
await s.put(rec({ id: "dead", namespace: "ns", content: "dead", updatedAt: D(8), expiresAt: D(2) }));
|
|
1158
|
+
// Holding one `now` across the walk is the POSITIVE half of the cursor's `now`
|
|
1159
|
+
// contract; rejection of a re-stamped one is pinned separately. Without this,
|
|
1160
|
+
// a store that rejected every cursor carrying a `now` would still pass.
|
|
1161
|
+
const query = { limit: 3, now: D(20) };
|
|
1162
|
+
const seen = [];
|
|
1163
|
+
let page = await s.browse(query);
|
|
1164
|
+
expect(page.total).toBe(7);
|
|
1165
|
+
expect(page.continuation).not.toBeNull();
|
|
1166
|
+
seen.push(...page.records.map((r) => r.id));
|
|
1167
|
+
for (let walked = 0; page.continuation && walked < MAX_WALK_PAGES; walked += 1) {
|
|
1168
|
+
page = await s.browse({ ...query, cursor: page.continuation });
|
|
1169
|
+
// total is the WHOLE matching set on every window, never what remains.
|
|
1170
|
+
expect(page.total).toBe(7);
|
|
1171
|
+
seen.push(...page.records.map((r) => r.id));
|
|
1172
|
+
}
|
|
1173
|
+
expect(page.continuation).toBeNull();
|
|
1174
|
+
expect(seen).toEqual(["r6", "r5", "r4", "r3", "r2", "r1", "r0"]);
|
|
1175
|
+
expect(new Set(seen).size).toBe(7);
|
|
1176
|
+
}
|
|
1177
|
+
finally {
|
|
1178
|
+
await close?.(s);
|
|
1179
|
+
}
|
|
1180
|
+
});
|
|
1181
|
+
test("browse returns a null continuation when the window did not fill", async () => {
|
|
1182
|
+
const s = await makeStore();
|
|
1183
|
+
try {
|
|
1184
|
+
await s.put(rec({ id: "a", namespace: "ns", content: "a" }));
|
|
1185
|
+
expect((await s.browse({ limit: 10 })).continuation).toBeNull();
|
|
1186
|
+
expect((await s.browse()).continuation).toBeNull();
|
|
1187
|
+
// A page that fills EXACTLY still issues one; following it is a legal
|
|
1188
|
+
// zero-row window, not an error.
|
|
1189
|
+
const full = await s.browse({ limit: 1 });
|
|
1190
|
+
expect(full.continuation).not.toBeNull();
|
|
1191
|
+
const after = await s.browse({ limit: 1, cursor: full.continuation });
|
|
1192
|
+
expect(after.records).toEqual([]);
|
|
1193
|
+
expect(after.total).toBe(1);
|
|
1194
|
+
expect(after.continuation).toBeNull();
|
|
1195
|
+
}
|
|
1196
|
+
finally {
|
|
1197
|
+
await close?.(s);
|
|
1198
|
+
}
|
|
1199
|
+
});
|
|
1200
|
+
test("browse continuations survive tied sort keys by falling through to id", async () => {
|
|
1201
|
+
const s = await makeStore();
|
|
1202
|
+
try {
|
|
1203
|
+
for (const id of ["a", "B", "c", "D"]) {
|
|
1204
|
+
await s.put(rec({ id, namespace: "ns", content: id, updatedAt: D(1) }));
|
|
1205
|
+
}
|
|
1206
|
+
const seen = [];
|
|
1207
|
+
let page = await s.browse({ limit: 2 });
|
|
1208
|
+
seen.push(...page.records.map((r) => r.id));
|
|
1209
|
+
for (let walked = 0; page.continuation && walked < MAX_WALK_PAGES; walked += 1) {
|
|
1210
|
+
page = await s.browse({ limit: 2, cursor: page.continuation });
|
|
1211
|
+
seen.push(...page.records.map((r) => r.id));
|
|
1212
|
+
}
|
|
1213
|
+
expect(page.continuation).toBeNull();
|
|
1214
|
+
expect(seen).toEqual(["B", "D", "a", "c"]);
|
|
1215
|
+
}
|
|
1216
|
+
finally {
|
|
1217
|
+
await close?.(s);
|
|
1218
|
+
}
|
|
1219
|
+
});
|
|
1220
|
+
test("browse continuations round-trip a float confidence key exactly", async () => {
|
|
1221
|
+
const s = await makeStore();
|
|
1222
|
+
try {
|
|
1223
|
+
// Postgres stores confidence as float4; a cursor key that is not cast back to
|
|
1224
|
+
// ::real compares false against the row it came from and the walk stalls or
|
|
1225
|
+
// repeats. These values are all inexact in float4.
|
|
1226
|
+
for (const [id, confidence] of [
|
|
1227
|
+
["a", 0.1],
|
|
1228
|
+
["b", 0.2],
|
|
1229
|
+
["c", 0.3],
|
|
1230
|
+
]) {
|
|
1231
|
+
await s.put(rec({ id, namespace: "ns", content: id, confidence }));
|
|
1232
|
+
}
|
|
1233
|
+
const seen = [];
|
|
1234
|
+
let page = await s.browse({ limit: 1, orderBy: [{ field: "confidence", dir: "desc" }] });
|
|
1235
|
+
seen.push(...page.records.map((r) => r.id));
|
|
1236
|
+
for (let walked = 0; page.continuation && walked < MAX_WALK_PAGES; walked += 1) {
|
|
1237
|
+
page = await s.browse({
|
|
1238
|
+
limit: 1,
|
|
1239
|
+
orderBy: [{ field: "confidence", dir: "desc" }],
|
|
1240
|
+
cursor: page.continuation,
|
|
1241
|
+
});
|
|
1242
|
+
seen.push(...page.records.map((r) => r.id));
|
|
1243
|
+
}
|
|
1244
|
+
expect(page.continuation).toBeNull();
|
|
1245
|
+
expect(seen).toEqual(["c", "b", "a"]);
|
|
1246
|
+
}
|
|
1247
|
+
finally {
|
|
1248
|
+
await close?.(s);
|
|
1249
|
+
}
|
|
1250
|
+
});
|
|
1251
|
+
test("browse rejects a continuation issued for a different query", async () => {
|
|
1252
|
+
const s = await makeStore();
|
|
1253
|
+
try {
|
|
1254
|
+
for (let i = 0; i < 3; i += 1) {
|
|
1255
|
+
await s.put(rec({ id: `r${i}`, namespace: "ns", content: `r${i}`, updatedAt: D(i + 1) }));
|
|
1256
|
+
}
|
|
1257
|
+
const page = await s.browse({ limit: 1 });
|
|
1258
|
+
const cursor = page.continuation;
|
|
1259
|
+
// A cursor carries its query's fingerprint, so it can never be replayed
|
|
1260
|
+
// against a different filter/sort and silently answer the wrong question.
|
|
1261
|
+
await expectContinuationInvalid(() => s.browse({ limit: 1, cursor, status: "active" }));
|
|
1262
|
+
await expectContinuationInvalid(() => s.browse({ limit: 1, cursor, orderBy: [{ field: "confidence", dir: "asc" }] }));
|
|
1263
|
+
await expectContinuationInvalid(() => s.browse({ limit: 1, cursor: "not-a-cursor" }));
|
|
1264
|
+
// `now` decides which rows are expired, so it is dataset identity too. A caller
|
|
1265
|
+
// that re-stamps it per request — the natural shape of an HTTP boundary — kills
|
|
1266
|
+
// its own walk at page two; it must hold `now` fixed for the whole walk.
|
|
1267
|
+
await expectContinuationInvalid(() => s.browse({ limit: 1, cursor, now: D(9) }));
|
|
1268
|
+
}
|
|
1269
|
+
finally {
|
|
1270
|
+
await close?.(s);
|
|
1271
|
+
}
|
|
1272
|
+
});
|
|
1273
|
+
test("browse continuations compose with filters, and total stays the filtered set", async () => {
|
|
1274
|
+
const s = await makeStore();
|
|
1275
|
+
try {
|
|
1276
|
+
for (let i = 0; i < 5; i += 1) {
|
|
1277
|
+
await s.put(rec({
|
|
1278
|
+
id: `c${i}`,
|
|
1279
|
+
namespace: "ns",
|
|
1280
|
+
content: `c${i}`,
|
|
1281
|
+
status: "candidate",
|
|
1282
|
+
updatedAt: D(i + 1),
|
|
1283
|
+
}));
|
|
1284
|
+
}
|
|
1285
|
+
await s.put(rec({ id: "keep", namespace: "ns", content: "keep", updatedAt: D(9) }));
|
|
1286
|
+
const query = {
|
|
1287
|
+
limit: 2,
|
|
1288
|
+
filters: [{ field: "status", op: "in", values: ["candidate"] }],
|
|
1289
|
+
};
|
|
1290
|
+
const seen = [];
|
|
1291
|
+
let page = await s.browse(query);
|
|
1292
|
+
expect(page.total).toBe(5);
|
|
1293
|
+
seen.push(...page.records.map((r) => r.id));
|
|
1294
|
+
for (let walked = 0; page.continuation && walked < MAX_WALK_PAGES; walked += 1) {
|
|
1295
|
+
page = await s.browse({ ...query, cursor: page.continuation });
|
|
1296
|
+
expect(page.total).toBe(5);
|
|
1297
|
+
seen.push(...page.records.map((r) => r.id));
|
|
1298
|
+
}
|
|
1299
|
+
expect(page.continuation).toBeNull();
|
|
1300
|
+
expect(seen).toEqual(["c4", "c3", "c2", "c1", "c0"]);
|
|
1301
|
+
}
|
|
1302
|
+
finally {
|
|
1303
|
+
await close?.(s);
|
|
1304
|
+
}
|
|
1305
|
+
});
|
|
1306
|
+
test("browse reads records and total from ONE snapshot, even under concurrent writes", async () => {
|
|
1307
|
+
const s = await makeStore();
|
|
1308
|
+
try {
|
|
1309
|
+
for (let i = 0; i < 60; i += 1) {
|
|
1310
|
+
await s.put(rec({ id: `r${String(i).padStart(2, "0")}`, namespace: "ns", content: `r${i}` }));
|
|
1311
|
+
}
|
|
1312
|
+
// Two non-transactional statements can count 30 and return 60 (or the
|
|
1313
|
+
// reverse) when a delete lands between them, and the UI then renders
|
|
1314
|
+
// "60 loaded of 30 matching". Inside one snapshot that is unrepresentable.
|
|
1315
|
+
const [page] = await Promise.all([
|
|
1316
|
+
s.browse({ limit: 1000 }),
|
|
1317
|
+
(async () => {
|
|
1318
|
+
for (let i = 0; i < 30; i += 1)
|
|
1319
|
+
await s.delete(`r${String(i).padStart(2, "0")}`);
|
|
1320
|
+
})(),
|
|
1321
|
+
]);
|
|
1322
|
+
expect(page.records.length).toBe(page.total);
|
|
1323
|
+
// And the model converges on the next read.
|
|
1324
|
+
const after = await s.browse({ limit: 1000 });
|
|
1325
|
+
expect(after.total).toBe(30);
|
|
1326
|
+
expect(after.records.length).toBe(30);
|
|
1327
|
+
}
|
|
1328
|
+
finally {
|
|
1329
|
+
await close?.(s);
|
|
1330
|
+
}
|
|
1331
|
+
});
|
|
1332
|
+
});
|
|
1333
|
+
}
|