@openparachute/vault 0.6.0-rc.1 → 0.6.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/.parachute/module.json +14 -3
- package/README.md +7 -7
- package/core/src/core.test.ts +279 -26
- package/core/src/expand-visibility.test.ts +102 -0
- package/core/src/expand.ts +31 -3
- package/core/src/indexed-fields.ts +1 -1
- package/core/src/link-count.test.ts +301 -0
- package/core/src/links.ts +97 -2
- package/core/src/mcp.ts +201 -33
- package/core/src/notes.ts +44 -8
- package/core/src/obsidian-alignment.test.ts +375 -0
- package/core/src/obsidian.ts +234 -14
- package/core/src/portable-md.test.ts +40 -0
- package/core/src/portable-md.ts +142 -16
- package/core/src/schema.ts +58 -11
- package/core/src/store.ts +69 -22
- package/core/src/tag-expand-axis.test.ts +301 -0
- package/core/src/tag-hierarchy.ts +80 -0
- package/core/src/tag-schemas.ts +61 -46
- package/core/src/triggers-store.test.ts +100 -0
- package/core/src/triggers-store.ts +165 -0
- package/core/src/types.ts +68 -4
- package/core/src/vault-projection.ts +20 -0
- package/core/src/wikilinks.ts +2 -2
- package/package.json +2 -3
- package/src/admin-spa.test.ts +100 -10
- package/src/admin-spa.ts +48 -3
- package/src/auth-hub-jwt.test.ts +8 -1
- package/src/auth-status.ts +2 -2
- package/src/auth.test.ts +39 -3
- package/src/auth.ts +31 -2
- package/src/auto-transcribe.test.ts +51 -0
- package/src/auto-transcribe.ts +24 -6
- package/src/autostart.test.ts +75 -0
- package/src/autostart.ts +84 -0
- package/src/cli.ts +434 -140
- package/src/config.test.ts +109 -0
- package/src/config.ts +157 -10
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/hub-jwt.test.ts +75 -2
- package/src/hub-jwt.ts +43 -6
- package/src/init-summary.test.ts +120 -5
- package/src/init-summary.ts +67 -25
- package/src/live-match.test.ts +198 -0
- package/src/live-match.ts +310 -0
- package/src/mcp-install.test.ts +93 -0
- package/src/mcp-install.ts +106 -0
- package/src/mcp-tools.ts +80 -7
- package/src/mirror-config.test.ts +14 -0
- package/src/mirror-config.ts +11 -0
- package/src/mirror-import.test.ts +110 -0
- package/src/mirror-import.ts +71 -13
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +73 -11
- package/src/mirror-routes.test.ts +463 -1
- package/src/mirror-routes.ts +474 -4
- package/src/oauth-discovery.test.ts +55 -0
- package/src/oauth-discovery.ts +24 -5
- package/src/routes.ts +696 -121
- package/src/routing.test.ts +451 -5
- package/src/routing.ts +113 -5
- package/src/scopes.ts +1 -1
- package/src/server.ts +66 -4
- package/src/storage.test.ts +162 -0
- package/src/subscribe.test.ts +588 -0
- package/src/subscribe.ts +248 -0
- package/src/subscriptions.ts +295 -0
- package/src/tag-expand-routes.test.ts +45 -0
- package/src/tag-scope.ts +68 -1
- package/src/token-store.ts +7 -7
- package/src/transcription-worker.test.ts +471 -5
- package/src/transcription-worker.ts +212 -44
- package/src/triggers-api.test.ts +533 -0
- package/src/triggers-api.ts +295 -0
- package/src/triggers.ts +93 -7
- package/src/usage.test.ts +362 -0
- package/src/usage.ts +318 -0
- package/src/vault-create.test.ts +340 -12
- package/src/vault-name.test.ts +61 -3
- package/src/vault-name.ts +62 -14
- package/src/vault-remove.test.ts +187 -0
- package/src/vault-store.ts +10 -3
- package/src/vault.test.ts +1353 -62
- package/web/ui/dist/assets/index-CGL256oe.js +60 -0
- package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
- package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-query SSE subscription tests (design 2026-06-08).
|
|
3
|
+
*
|
|
4
|
+
* Exercises the route handler end-to-end over a real `text/event-stream`
|
|
5
|
+
* Response: snapshot correctness, live insert/update/delete events,
|
|
6
|
+
* set-transition (matching→non-matching → remove; non-matching→matching →
|
|
7
|
+
* upsert), the load-bearing SCOPE-INTERSECTION guarantee (a tag-scoped token
|
|
8
|
+
* never receives out-of-scope notes in the snapshot OR live), `search`/`near`
|
|
9
|
+
* → 400, and over-cap → 503.
|
|
10
|
+
*
|
|
11
|
+
* Hook dispatch is deferred (microtask + queued handler), so each mutation is
|
|
12
|
+
* followed by `settle()` before asserting the stream contents.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
16
|
+
import { Database } from "bun:sqlite";
|
|
17
|
+
import { SqliteStore } from "../core/src/store.ts";
|
|
18
|
+
import { HookRegistry } from "../core/src/hooks.ts";
|
|
19
|
+
import { handleSubscribe } from "./subscribe.ts";
|
|
20
|
+
import { SubscriptionManager } from "./subscriptions.ts";
|
|
21
|
+
import { expandTokenTagScope } from "./tag-scope.ts";
|
|
22
|
+
import type { TagScopeCtx } from "./routes.ts";
|
|
23
|
+
|
|
24
|
+
const VAULT = "testvault";
|
|
25
|
+
|
|
26
|
+
let db: Database;
|
|
27
|
+
let hooks: HookRegistry;
|
|
28
|
+
let store: SqliteStore;
|
|
29
|
+
let manager: SubscriptionManager;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
db = new Database(":memory:");
|
|
33
|
+
hooks = new HookRegistry({ concurrency: 4, logger: { error() {} } });
|
|
34
|
+
store = new SqliteStore(db, { hooks });
|
|
35
|
+
// Manager registers its broad hooks on THIS store's registry, and resolves
|
|
36
|
+
// every event to VAULT (the test store isn't in the global vault WeakMap).
|
|
37
|
+
manager = new SubscriptionManager(hooks, { resolveVault: () => VAULT });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
manager.shutdown();
|
|
42
|
+
db.close();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** Let deferred hook dispatch + handlers run. */
|
|
46
|
+
async function settle(): Promise<void> {
|
|
47
|
+
// queueMicrotask → handler runs under the semaphore (async). A couple of
|
|
48
|
+
// macrotask ticks is plenty for the in-memory path.
|
|
49
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** An SSE frame parsed off the wire. */
|
|
53
|
+
interface Frame {
|
|
54
|
+
event: string;
|
|
55
|
+
data: any;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Open an SSE reader over a subscribe Response. `frames()` returns everything
|
|
60
|
+
* parsed so far; `close()` cancels the stream (→ subscription teardown).
|
|
61
|
+
*/
|
|
62
|
+
function sseReader(res: Response) {
|
|
63
|
+
const reader = res.body!.getReader();
|
|
64
|
+
const decoder = new TextDecoder();
|
|
65
|
+
let buf = "";
|
|
66
|
+
const frames: Frame[] = [];
|
|
67
|
+
let done = false;
|
|
68
|
+
|
|
69
|
+
function drainBuffer() {
|
|
70
|
+
let idx: number;
|
|
71
|
+
while ((idx = buf.indexOf("\n\n")) !== -1) {
|
|
72
|
+
const raw = buf.slice(0, idx);
|
|
73
|
+
buf = buf.slice(idx + 2);
|
|
74
|
+
if (raw.startsWith(":")) continue; // keepalive comment
|
|
75
|
+
const lines = raw.split("\n");
|
|
76
|
+
let event = "message";
|
|
77
|
+
let dataStr = "";
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
if (line.startsWith("event:")) event = line.slice(6).trim();
|
|
80
|
+
else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
|
|
81
|
+
}
|
|
82
|
+
frames.push({ event, data: dataStr ? JSON.parse(dataStr) : null });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ONE continuous background read loop — a single outstanding `reader.read()`
|
|
87
|
+
// at a time (concurrent reads on one reader throw / steal chunks). It drains
|
|
88
|
+
// frames into `frames` as bytes arrive; `pump()` just waits a beat.
|
|
89
|
+
(async () => {
|
|
90
|
+
try {
|
|
91
|
+
while (true) {
|
|
92
|
+
const { value, done: d } = await reader.read();
|
|
93
|
+
if (d) {
|
|
94
|
+
done = true;
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
if (value) {
|
|
98
|
+
buf += decoder.decode(value, { stream: true });
|
|
99
|
+
drainBuffer();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
done = true;
|
|
104
|
+
}
|
|
105
|
+
})();
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
/** Wait a beat for in-flight frames to land. */
|
|
109
|
+
async pump() {
|
|
110
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
111
|
+
return frames;
|
|
112
|
+
},
|
|
113
|
+
frames: () => frames,
|
|
114
|
+
isDone: () => done,
|
|
115
|
+
async close() {
|
|
116
|
+
await reader.cancel().catch(() => {});
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function unscopedScope(): TagScopeCtx {
|
|
122
|
+
return { allowed: null, raw: null };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function scopedTo(roots: string[]): Promise<TagScopeCtx> {
|
|
126
|
+
return { allowed: await expandTokenTagScope(store, roots), raw: roots };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function subscribeReq(query: string): Request {
|
|
130
|
+
return new Request(`http://localhost/vault/${VAULT}/api/subscribe?${query}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
describe("handleSubscribe — snapshot", () => {
|
|
134
|
+
it("sends a snapshot of currently-matching notes", async () => {
|
|
135
|
+
await store.createNote("hello", { tags: ["chat"], metadata: {} });
|
|
136
|
+
await store.createNote("nope", { tags: ["other"] });
|
|
137
|
+
|
|
138
|
+
const res = await handleSubscribe(subscribeReq("tag=chat"), store, VAULT, unscopedScope(), manager);
|
|
139
|
+
expect(res.status).toBe(200);
|
|
140
|
+
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
|
|
141
|
+
|
|
142
|
+
const r = sseReader(res);
|
|
143
|
+
await r.pump();
|
|
144
|
+
const snap = r.frames().find((f) => f.event === "snapshot");
|
|
145
|
+
expect(snap).toBeTruthy();
|
|
146
|
+
expect(snap!.data.notes.length).toBe(1);
|
|
147
|
+
expect(snap!.data.notes[0].content).toBe("hello");
|
|
148
|
+
await r.close();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("handleSubscribe — live events", () => {
|
|
153
|
+
it("emits upsert on a matching insert after connect", async () => {
|
|
154
|
+
const res = await handleSubscribe(subscribeReq("tag=chat"), store, VAULT, unscopedScope(), manager);
|
|
155
|
+
const r = sseReader(res);
|
|
156
|
+
await r.pump();
|
|
157
|
+
expect(r.frames().filter((f) => f.event === "snapshot").length).toBe(1);
|
|
158
|
+
|
|
159
|
+
await store.createNote("live one", { tags: ["chat"] });
|
|
160
|
+
await settle();
|
|
161
|
+
await r.pump();
|
|
162
|
+
|
|
163
|
+
const upserts = r.frames().filter((f) => f.event === "upsert");
|
|
164
|
+
expect(upserts.length).toBe(1);
|
|
165
|
+
expect(upserts[0]!.data.note.content).toBe("live one");
|
|
166
|
+
await r.close();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("does NOT emit on a non-matching insert", async () => {
|
|
170
|
+
const res = await handleSubscribe(subscribeReq("tag=chat"), store, VAULT, unscopedScope(), manager);
|
|
171
|
+
const r = sseReader(res);
|
|
172
|
+
await r.pump();
|
|
173
|
+
|
|
174
|
+
await store.createNote("irrelevant", { tags: ["other"] });
|
|
175
|
+
await settle();
|
|
176
|
+
await r.pump();
|
|
177
|
+
|
|
178
|
+
expect(r.frames().filter((f) => f.event === "upsert").length).toBe(0);
|
|
179
|
+
await r.close();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("emits remove on hard delete", async () => {
|
|
183
|
+
const note = await store.createNote("doomed", { tags: ["chat"] });
|
|
184
|
+
const res = await handleSubscribe(subscribeReq("tag=chat"), store, VAULT, unscopedScope(), manager);
|
|
185
|
+
const r = sseReader(res);
|
|
186
|
+
await r.pump();
|
|
187
|
+
|
|
188
|
+
await store.deleteNote(note.id);
|
|
189
|
+
await settle();
|
|
190
|
+
await r.pump();
|
|
191
|
+
|
|
192
|
+
const removes = r.frames().filter((f) => f.event === "remove");
|
|
193
|
+
expect(removes.length).toBe(1);
|
|
194
|
+
expect(removes[0]!.data.id).toBe(note.id);
|
|
195
|
+
await r.close();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("handleSubscribe — set transitions", () => {
|
|
200
|
+
it("matching → non-matching update emits remove", async () => {
|
|
201
|
+
// channel field indexed so the snapshot operator query works.
|
|
202
|
+
const { generateMcpTools } = await import("../core/src/mcp.ts");
|
|
203
|
+
const updateTag = generateMcpTools(store).find((t) => t.name === "update-tag")!;
|
|
204
|
+
await updateTag.execute({ tag: "msg", fields: { channel: { type: "string", indexed: true } } });
|
|
205
|
+
|
|
206
|
+
const note = await store.createNote("m", { tags: ["msg"], metadata: { channel: "general" } });
|
|
207
|
+
const res = await handleSubscribe(
|
|
208
|
+
subscribeReq("tag=msg&meta[channel][eq]=general"),
|
|
209
|
+
store,
|
|
210
|
+
VAULT,
|
|
211
|
+
unscopedScope(),
|
|
212
|
+
manager,
|
|
213
|
+
);
|
|
214
|
+
const r = sseReader(res);
|
|
215
|
+
await r.pump();
|
|
216
|
+
expect(r.frames().find((f) => f.event === "snapshot")!.data.notes.length).toBe(1);
|
|
217
|
+
|
|
218
|
+
// Move the note out of the set (channel → random).
|
|
219
|
+
await store.updateNote(note.id, { metadata: { channel: "random" } });
|
|
220
|
+
await settle();
|
|
221
|
+
await r.pump();
|
|
222
|
+
|
|
223
|
+
const removes = r.frames().filter((f) => f.event === "remove");
|
|
224
|
+
expect(removes.length).toBe(1);
|
|
225
|
+
expect(removes[0]!.data.id).toBe(note.id);
|
|
226
|
+
await r.close();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("non-matching → matching update emits upsert", async () => {
|
|
230
|
+
const note = await store.createNote("m", { tags: ["other"] });
|
|
231
|
+
const res = await handleSubscribe(subscribeReq("tag=chat"), store, VAULT, unscopedScope(), manager);
|
|
232
|
+
const r = sseReader(res);
|
|
233
|
+
await r.pump();
|
|
234
|
+
expect(r.frames().find((f) => f.event === "snapshot")!.data.notes.length).toBe(0);
|
|
235
|
+
|
|
236
|
+
// Re-tag into the set. updateNote doesn't take tags directly; use tagNote
|
|
237
|
+
// then a metadata touch to fire an "updated" event carrying fresh tags.
|
|
238
|
+
await store.tagNote(note.id, ["chat"]);
|
|
239
|
+
await store.updateNote(note.id, { metadata: { touched: true } });
|
|
240
|
+
await settle();
|
|
241
|
+
await r.pump();
|
|
242
|
+
|
|
243
|
+
const upserts = r.frames().filter((f) => f.event === "upsert");
|
|
244
|
+
expect(upserts.length).toBeGreaterThanOrEqual(1);
|
|
245
|
+
expect(upserts.at(-1)!.data.note.tags).toContain("chat");
|
|
246
|
+
await r.close();
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe("handleSubscribe — SCOPE INTERSECTION (security)", () => {
|
|
251
|
+
it("snapshot withholds a note that MATCHES the predicate but is OUT OF SCOPE (predicate∧scope) (N3)", async () => {
|
|
252
|
+
// Both notes carry #work and so MATCH the `tag=work` predicate. Scope must
|
|
253
|
+
// still withhold the one outside the token's allowlist. The token is scoped
|
|
254
|
+
// to #work/eng, so #work/sales is in-predicate-but-out-of-scope.
|
|
255
|
+
await store.upsertTagRecord("work", { description: "work root" });
|
|
256
|
+
await store.upsertTagRecord("work/eng", { parent_names: ["work"] });
|
|
257
|
+
await store.upsertTagRecord("work/sales", { parent_names: ["work"] });
|
|
258
|
+
await store.createNote("eng note", { tags: ["work/eng"] });
|
|
259
|
+
await store.createNote("sales note", { tags: ["work/sales"] });
|
|
260
|
+
|
|
261
|
+
const scope = await scopedTo(["work/eng"]);
|
|
262
|
+
// Predicate `tag=work` matches BOTH (sales is a #work descendant) — only
|
|
263
|
+
// scope distinguishes them, exercising the AND-gate, not the predicate.
|
|
264
|
+
const res = await handleSubscribe(subscribeReq("tag=work"), store, VAULT, scope, manager);
|
|
265
|
+
const r = sseReader(res);
|
|
266
|
+
await r.pump();
|
|
267
|
+
const snap = r.frames().find((f) => f.event === "snapshot")!;
|
|
268
|
+
const contents = snap.data.notes.map((n: any) => n.content);
|
|
269
|
+
expect(contents).toContain("eng note");
|
|
270
|
+
expect(contents).not.toContain("sales note");
|
|
271
|
+
await r.close();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("a live INSERT matching the predicate but OUT OF SCOPE never reaches a scoped stream (N3)", async () => {
|
|
275
|
+
await store.upsertTagRecord("work", { description: "work root" });
|
|
276
|
+
await store.upsertTagRecord("work/eng", { parent_names: ["work"] });
|
|
277
|
+
await store.upsertTagRecord("work/sales", { parent_names: ["work"] });
|
|
278
|
+
const scope = await scopedTo(["work/eng"]);
|
|
279
|
+
const res = await handleSubscribe(subscribeReq("tag=work"), store, VAULT, scope, manager);
|
|
280
|
+
const r = sseReader(res);
|
|
281
|
+
await r.pump();
|
|
282
|
+
|
|
283
|
+
// Out-of-scope but predicate-matching (#work/sales is a #work descendant).
|
|
284
|
+
await store.createNote("sales leak", { tags: ["work/sales"] });
|
|
285
|
+
await settle();
|
|
286
|
+
await r.pump();
|
|
287
|
+
expect(r.frames().filter((f) => f.event === "upsert").length).toBe(0);
|
|
288
|
+
|
|
289
|
+
// Sanity: an in-scope, predicate-matching insert DOES arrive.
|
|
290
|
+
await store.createNote("eng allowed", { tags: ["work/eng"] });
|
|
291
|
+
await settle();
|
|
292
|
+
await r.pump();
|
|
293
|
+
const upserts = r.frames().filter((f) => f.event === "upsert");
|
|
294
|
+
expect(upserts.length).toBe(1);
|
|
295
|
+
expect(upserts[0]!.data.note.content).toBe("eng allowed");
|
|
296
|
+
await r.close();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("a live UPDATE of an out-of-scope predicate-matching note still never leaks (upsert OR remove) (M2)", async () => {
|
|
300
|
+
await store.upsertTagRecord("work", { description: "work root" });
|
|
301
|
+
await store.upsertTagRecord("work/sales", { parent_names: ["work"] });
|
|
302
|
+
const scope = await scopedTo(["work/eng"]);
|
|
303
|
+
// #work/sales matches `tag=work` but is OUT of the #work/eng scope.
|
|
304
|
+
const note = await store.createNote("sales secret", { tags: ["work/sales"] });
|
|
305
|
+
const res = await handleSubscribe(subscribeReq("tag=work"), store, VAULT, scope, manager);
|
|
306
|
+
const r = sseReader(res);
|
|
307
|
+
await r.pump();
|
|
308
|
+
|
|
309
|
+
await store.updateNote(note.id, { metadata: { touched: true } });
|
|
310
|
+
await settle();
|
|
311
|
+
await r.pump();
|
|
312
|
+
// No upsert (scope vetoes) AND no remove (would leak the uuid — M2).
|
|
313
|
+
expect(r.frames().filter((f) => f.event === "upsert").length).toBe(0);
|
|
314
|
+
expect(r.frames().filter((f) => f.event === "remove").length).toBe(0);
|
|
315
|
+
await r.close();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("M2 — scoped sub gets NO remove when an OUT-OF-SCOPE note leaves the set", async () => {
|
|
319
|
+
// channel indexed so the snapshot operator query works.
|
|
320
|
+
const { generateMcpTools } = await import("../core/src/mcp.ts");
|
|
321
|
+
const updateTag = generateMcpTools(store).find((t) => t.name === "update-tag")!;
|
|
322
|
+
await updateTag.execute({ tag: "msg", fields: { channel: { type: "string", indexed: true } } });
|
|
323
|
+
await store.upsertTagRecord("work", { description: "work root" });
|
|
324
|
+
await store.upsertTagRecord("work/sales", { parent_names: ["work"] });
|
|
325
|
+
|
|
326
|
+
const scope = await scopedTo(["work/eng"]);
|
|
327
|
+
// A #work/sales note (out of scope) that currently matches the predicate
|
|
328
|
+
// tag=work ∧ channel=general.
|
|
329
|
+
const note = await store.createNote("sales", {
|
|
330
|
+
tags: ["msg", "work/sales"],
|
|
331
|
+
metadata: { channel: "general" },
|
|
332
|
+
});
|
|
333
|
+
const res = await handleSubscribe(
|
|
334
|
+
subscribeReq("tag=work&meta[channel][eq]=general"),
|
|
335
|
+
store,
|
|
336
|
+
VAULT,
|
|
337
|
+
scope,
|
|
338
|
+
manager,
|
|
339
|
+
);
|
|
340
|
+
const r = sseReader(res);
|
|
341
|
+
await r.pump();
|
|
342
|
+
// Out of scope → not in the snapshot either.
|
|
343
|
+
expect(r.frames().find((f) => f.event === "snapshot")!.data.notes.length).toBe(0);
|
|
344
|
+
|
|
345
|
+
// Move it out of the predicate set (channel → random). It was never in the
|
|
346
|
+
// scoped sub's set; emitting a remove would leak its uuid → must stay silent.
|
|
347
|
+
await store.updateNote(note.id, { metadata: { channel: "random" } });
|
|
348
|
+
await settle();
|
|
349
|
+
await r.pump();
|
|
350
|
+
expect(r.frames().filter((f) => f.event === "remove").length).toBe(0);
|
|
351
|
+
await r.close();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("M2 — scoped sub DOES get a remove when an IN-SCOPE note it could see leaves the set", async () => {
|
|
355
|
+
const { generateMcpTools } = await import("../core/src/mcp.ts");
|
|
356
|
+
const updateTag = generateMcpTools(store).find((t) => t.name === "update-tag")!;
|
|
357
|
+
await updateTag.execute({ tag: "msg", fields: { channel: { type: "string", indexed: true } } });
|
|
358
|
+
await store.upsertTagRecord("work", { description: "work root" });
|
|
359
|
+
await store.upsertTagRecord("work/eng", { parent_names: ["work"] });
|
|
360
|
+
|
|
361
|
+
const scope = await scopedTo(["work/eng"]);
|
|
362
|
+
// In-scope note matching the predicate.
|
|
363
|
+
const note = await store.createNote("eng", {
|
|
364
|
+
tags: ["msg", "work/eng"],
|
|
365
|
+
metadata: { channel: "general" },
|
|
366
|
+
});
|
|
367
|
+
const res = await handleSubscribe(
|
|
368
|
+
subscribeReq("tag=work&meta[channel][eq]=general"),
|
|
369
|
+
store,
|
|
370
|
+
VAULT,
|
|
371
|
+
scope,
|
|
372
|
+
manager,
|
|
373
|
+
);
|
|
374
|
+
const r = sseReader(res);
|
|
375
|
+
await r.pump();
|
|
376
|
+
expect(r.frames().find((f) => f.event === "snapshot")!.data.notes.length).toBe(1);
|
|
377
|
+
|
|
378
|
+
// Leave the set (channel → random) while staying IN scope → a remove the
|
|
379
|
+
// sub legitimately needs (it held this id).
|
|
380
|
+
await store.updateNote(note.id, { metadata: { channel: "random" } });
|
|
381
|
+
await settle();
|
|
382
|
+
await r.pump();
|
|
383
|
+
const removes = r.frames().filter((f) => f.event === "remove");
|
|
384
|
+
expect(removes.length).toBe(1);
|
|
385
|
+
expect(removes[0]!.data.id).toBe(note.id);
|
|
386
|
+
await r.close();
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
describe("handleSubscribe — rejected query shapes", () => {
|
|
391
|
+
it("search → 400", async () => {
|
|
392
|
+
const res = await handleSubscribe(subscribeReq("search=hello"), store, VAULT, unscopedScope(), manager);
|
|
393
|
+
expect(res.status).toBe(400);
|
|
394
|
+
const body = await res.json();
|
|
395
|
+
expect(body.code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("near → 400", async () => {
|
|
399
|
+
const res = await handleSubscribe(
|
|
400
|
+
subscribeReq("near%5Bnote_id%5D=abc"),
|
|
401
|
+
store,
|
|
402
|
+
VAULT,
|
|
403
|
+
unscopedScope(),
|
|
404
|
+
manager,
|
|
405
|
+
);
|
|
406
|
+
expect(res.status).toBe(400);
|
|
407
|
+
const body = await res.json();
|
|
408
|
+
expect(body.code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("has_links → 400 (M1 — needs the links table)", async () => {
|
|
412
|
+
const res = await handleSubscribe(subscribeReq("has_links=true"), store, VAULT, unscopedScope(), manager);
|
|
413
|
+
expect(res.status).toBe(400);
|
|
414
|
+
const body = await res.json();
|
|
415
|
+
expect(body.code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
|
|
416
|
+
expect(body.error).toContain("has_links");
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("date filter → 400 (M1) — legacy date_from", async () => {
|
|
420
|
+
const res = await handleSubscribe(
|
|
421
|
+
subscribeReq("date_from=2026-01-01"),
|
|
422
|
+
store,
|
|
423
|
+
VAULT,
|
|
424
|
+
unscopedScope(),
|
|
425
|
+
manager,
|
|
426
|
+
);
|
|
427
|
+
expect(res.status).toBe(400);
|
|
428
|
+
const body = await res.json();
|
|
429
|
+
expect(body.code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
|
|
430
|
+
expect(body.error).toContain("date");
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("date filter → 400 (M1) — bracket date_filter on updated_at", async () => {
|
|
434
|
+
const res = await handleSubscribe(
|
|
435
|
+
subscribeReq("meta%5Bupdated_at%5D%5Bgte%5D=2026-01-01"),
|
|
436
|
+
store,
|
|
437
|
+
VAULT,
|
|
438
|
+
unscopedScope(),
|
|
439
|
+
manager,
|
|
440
|
+
);
|
|
441
|
+
expect(res.status).toBe(400);
|
|
442
|
+
const body = await res.json();
|
|
443
|
+
expect(body.code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
|
|
444
|
+
expect(body.error).toContain("date");
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
describe("handleSubscribe — snapshot completeness (M3)", () => {
|
|
449
|
+
it("a subscription matching >50 notes gets ALL of them in the snapshot", async () => {
|
|
450
|
+
// The default query limit is 50; seed 60 matching notes and assert the
|
|
451
|
+
// snapshot carries the full set (paging stripped for the snapshot query).
|
|
452
|
+
for (let i = 0; i < 60; i++) {
|
|
453
|
+
await store.createNote(`n${i}`, { tags: ["chat"] });
|
|
454
|
+
}
|
|
455
|
+
const res = await handleSubscribe(subscribeReq("tag=chat"), store, VAULT, unscopedScope(), manager);
|
|
456
|
+
const r = sseReader(res);
|
|
457
|
+
await r.pump();
|
|
458
|
+
const snap = r.frames().find((f) => f.event === "snapshot")!;
|
|
459
|
+
expect(snap.data.notes.length).toBe(60);
|
|
460
|
+
await r.close();
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
describe("handleSubscribe — expand axis snapshot↔live consistency", () => {
|
|
465
|
+
// vault tag `expand` axis (design 2026-06-09): a subscription's snapshot
|
|
466
|
+
// (query engine) and its live matcher MUST lower the IDENTICAL tag
|
|
467
|
+
// expansion. We seed the two-axis corpus, then for each `expand` mode assert
|
|
468
|
+
// the snapshot set EQUALS the set the live matcher would accept over every
|
|
469
|
+
// note in the vault.
|
|
470
|
+
async function seedTwoAxis() {
|
|
471
|
+
await store.upsertTagRecord("entity", { description: "entity root" });
|
|
472
|
+
await store.upsertTagRecord("person", { parent_names: ["entity"] }); // subtype, not name-prefixed
|
|
473
|
+
await store.upsertTagRecord("entity/archived", {}); // name-prefixed, not subtype
|
|
474
|
+
await store.upsertTagRecord("entity/person", { parent_names: ["entity"] }); // both
|
|
475
|
+
await store.createNote("literal", { tags: ["entity"] });
|
|
476
|
+
await store.createNote("subtype", { tags: ["person"] });
|
|
477
|
+
await store.createNote("filed", { tags: ["entity/archived"] });
|
|
478
|
+
await store.createNote("both", { tags: ["entity/person"] });
|
|
479
|
+
await store.createNote("unrelated", { tags: ["work"] });
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
for (const mode of ["subtypes", "namespace", "both", "exact"] as const) {
|
|
483
|
+
it(`expand=${mode}: snapshot set ≡ live-matcher acceptance`, async () => {
|
|
484
|
+
await seedTwoAxis();
|
|
485
|
+
const { buildLiveMatcher } = await import("./live-match.ts");
|
|
486
|
+
|
|
487
|
+
const res = await handleSubscribe(
|
|
488
|
+
subscribeReq(`tag=entity&expand=${mode}`),
|
|
489
|
+
store,
|
|
490
|
+
VAULT,
|
|
491
|
+
unscopedScope(),
|
|
492
|
+
manager,
|
|
493
|
+
);
|
|
494
|
+
expect(res.status).toBe(200);
|
|
495
|
+
const r = sseReader(res);
|
|
496
|
+
await r.pump();
|
|
497
|
+
const snap = r.frames().find((f) => f.event === "snapshot")!;
|
|
498
|
+
const snapIds = new Set<string>(snap.data.notes.map((n: any) => n.id));
|
|
499
|
+
|
|
500
|
+
// The matcher accepts/rejects each note in the vault — compute its set.
|
|
501
|
+
const matcher = await buildLiveMatcher(store, { tags: ["entity"], expand: mode });
|
|
502
|
+
const allNotes = await store.queryNotes({ limit: Number.MAX_SAFE_INTEGER });
|
|
503
|
+
const liveIds = new Set<string>(allNotes.filter((n) => matcher.match(n)).map((n) => n.id));
|
|
504
|
+
|
|
505
|
+
expect(liveIds).toEqual(snapIds);
|
|
506
|
+
await r.close();
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
it("expand=namespace: a live insert filed under entity/ arrives; a subtype-only insert does NOT", async () => {
|
|
511
|
+
await seedTwoAxis();
|
|
512
|
+
// The matcher freezes its tag expansion at subscribe time (mirroring the
|
|
513
|
+
// snapshot query — design "resolved ONCE at subscribe time"). So the tags
|
|
514
|
+
// these live notes carry must already be KNOWN at subscribe time:
|
|
515
|
+
// - entity/inbox: name-prefixed → in the frozen namespace set.
|
|
516
|
+
// - agent: declared subtype of entity, NOT name-prefixed → excluded by
|
|
517
|
+
// namespace mode.
|
|
518
|
+
await store.upsertTagRecord("entity/inbox", {});
|
|
519
|
+
await store.upsertTagRecord("agent", { parent_names: ["entity"] });
|
|
520
|
+
|
|
521
|
+
const res = await handleSubscribe(
|
|
522
|
+
subscribeReq("tag=entity&expand=namespace"),
|
|
523
|
+
store,
|
|
524
|
+
VAULT,
|
|
525
|
+
unscopedScope(),
|
|
526
|
+
manager,
|
|
527
|
+
);
|
|
528
|
+
const r = sseReader(res);
|
|
529
|
+
await r.pump();
|
|
530
|
+
|
|
531
|
+
await store.createNote("new filed", { tags: ["entity/inbox"] }); // name-prefixed → upsert
|
|
532
|
+
await store.createNote("new subtype", { tags: ["agent"] }); // subtype-only → no upsert
|
|
533
|
+
await settle();
|
|
534
|
+
await r.pump();
|
|
535
|
+
|
|
536
|
+
const upserts = r.frames().filter((f) => f.event === "upsert");
|
|
537
|
+
const contents = upserts.map((u) => u.data.note.content);
|
|
538
|
+
expect(contents).toContain("new filed");
|
|
539
|
+
expect(contents).not.toContain("new subtype");
|
|
540
|
+
await r.close();
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("invalid expand value → 400 INVALID_QUERY before any stream opens", async () => {
|
|
544
|
+
const res = await handleSubscribe(
|
|
545
|
+
subscribeReq("tag=entity&expand=bogus"),
|
|
546
|
+
store,
|
|
547
|
+
VAULT,
|
|
548
|
+
unscopedScope(),
|
|
549
|
+
manager,
|
|
550
|
+
);
|
|
551
|
+
expect(res.status).toBe(400);
|
|
552
|
+
const body = await res.json();
|
|
553
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
describe("handleSubscribe — cap", () => {
|
|
558
|
+
it("over-cap subscribe → 503", async () => {
|
|
559
|
+
const capped = new SubscriptionManager(hooks, { maxPerVault: 1, resolveVault: () => VAULT });
|
|
560
|
+
const r1 = await handleSubscribe(subscribeReq("tag=chat"), store, VAULT, unscopedScope(), capped);
|
|
561
|
+
expect(r1.status).toBe(200);
|
|
562
|
+
const reader1 = sseReader(r1);
|
|
563
|
+
await reader1.pump();
|
|
564
|
+
expect(capped.countForVault(VAULT)).toBe(1);
|
|
565
|
+
|
|
566
|
+
const r2 = await handleSubscribe(subscribeReq("tag=chat"), store, VAULT, unscopedScope(), capped);
|
|
567
|
+
expect(r2.status).toBe(503);
|
|
568
|
+
const body = await r2.json();
|
|
569
|
+
expect(body.code).toBe("SUBSCRIPTION_CAP_REACHED");
|
|
570
|
+
|
|
571
|
+
await reader1.close();
|
|
572
|
+
capped.shutdown();
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("closing a stream frees a cap slot", async () => {
|
|
576
|
+
const capped = new SubscriptionManager(hooks, { maxPerVault: 1, resolveVault: () => VAULT });
|
|
577
|
+
const r1 = await handleSubscribe(subscribeReq("tag=chat"), store, VAULT, unscopedScope(), capped);
|
|
578
|
+
const reader1 = sseReader(r1);
|
|
579
|
+
await reader1.pump();
|
|
580
|
+
expect(capped.countForVault(VAULT)).toBe(1);
|
|
581
|
+
|
|
582
|
+
await reader1.close();
|
|
583
|
+
// Cancel propagates to the manager teardown.
|
|
584
|
+
await settle();
|
|
585
|
+
expect(capped.countForVault(VAULT)).toBe(0);
|
|
586
|
+
capped.shutdown();
|
|
587
|
+
});
|
|
588
|
+
});
|