@openparachute/vault 0.5.3-rc.3 → 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.
Files changed (41) hide show
  1. package/.parachute/module.json +14 -3
  2. package/core/src/mcp.ts +20 -0
  3. package/core/src/schema.ts +45 -1
  4. package/core/src/store.ts +66 -19
  5. package/core/src/tag-expand-axis.test.ts +301 -0
  6. package/core/src/tag-hierarchy.ts +80 -0
  7. package/core/src/triggers-store.test.ts +100 -0
  8. package/core/src/triggers-store.ts +165 -0
  9. package/core/src/types.ts +27 -1
  10. package/package.json +1 -1
  11. package/src/admin-spa.test.ts +100 -10
  12. package/src/admin-spa.ts +48 -3
  13. package/src/auto-transcribe.test.ts +51 -0
  14. package/src/auto-transcribe.ts +24 -6
  15. package/src/cli.ts +45 -18
  16. package/src/config.test.ts +27 -0
  17. package/src/config.ts +87 -0
  18. package/src/live-match.test.ts +198 -0
  19. package/src/live-match.ts +310 -0
  20. package/src/routes.ts +192 -78
  21. package/src/routing.test.ts +64 -0
  22. package/src/routing.ts +48 -1
  23. package/src/server.ts +49 -3
  24. package/src/subscribe.test.ts +588 -0
  25. package/src/subscribe.ts +248 -0
  26. package/src/subscriptions.ts +295 -0
  27. package/src/tag-expand-routes.test.ts +45 -0
  28. package/src/triggers-api.test.ts +533 -0
  29. package/src/triggers-api.ts +295 -0
  30. package/src/triggers.ts +93 -7
  31. package/src/vault-create.test.ts +35 -1
  32. package/src/vault-name.test.ts +61 -3
  33. package/src/vault-name.ts +62 -14
  34. package/src/vault-remove.test.ts +187 -0
  35. package/src/vault-store.ts +10 -3
  36. package/src/vault.test.ts +194 -0
  37. package/web/ui/dist/assets/index-CGL256oe.js +60 -0
  38. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  39. package/web/ui/dist/index.html +2 -2
  40. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  41. package/web/ui/dist/assets/index-DJL6Az--.js +0 -60
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Live-query SSE subscribe route (live-query SSE — design
3
+ * `design/2026-06-08-live-query-sse.md`).
4
+ *
5
+ * GET /vault/<name>/api/subscribe?<query params>
6
+ *
7
+ * Sends an `event: snapshot` of the currently-matching (scoped) notes, then
8
+ * live `upsert`/`remove` events as notes change. Auth + tag-scope are already
9
+ * resolved by the caller (routing.ts) and threaded in — the same `?key=` /
10
+ * header credential and the same `tagScope` the notes path uses. This route
11
+ * adds NO new auth plumbing.
12
+ *
13
+ * The query string is parsed by the SAME `parseNotesQueryOpts` the structured
14
+ * notes-query branch uses, so the snapshot predicate and the live matcher
15
+ * evaluate an identical `QueryOpts`. `search` (FTS) and `near` (graph BFS) are
16
+ * not evaluable against a single in-memory note, so a subscribe request using
17
+ * them is rejected with 400 BEFORE any stream opens.
18
+ */
19
+
20
+ import type { Store, QueryOpts } from "../core/src/types.ts";
21
+ import { parseNotesQueryOpts, type TagScopeCtx } from "./routes.ts";
22
+ import { filterNotesByTagScope } from "./tag-scope.ts";
23
+ import { buildLiveMatcher, unsupportedSubscriptionReason } from "./live-match.ts";
24
+ import {
25
+ snapshotFrame,
26
+ subscriptionManager,
27
+ type SubscriptionManager,
28
+ type SubscriptionSink,
29
+ } from "./subscriptions.ts";
30
+
31
+ /** Keepalive interval — `:` comment every ~25s to defeat idle-proxy timeouts. */
32
+ const KEEPALIVE_MS = 25_000;
33
+
34
+ function json(data: unknown, status = 200): Response {
35
+ return Response.json(data, { status });
36
+ }
37
+
38
+ /**
39
+ * Handle `GET /api/subscribe`. `vaultName` + `tagScope` come from routing.ts
40
+ * (auth already validated; tag-scope already expanded). `manager` is injectable
41
+ * for tests; defaults to the process-wide singleton.
42
+ */
43
+ export async function handleSubscribe(
44
+ req: Request,
45
+ store: Store,
46
+ vaultName: string,
47
+ tagScope: TagScopeCtx,
48
+ manager: SubscriptionManager = subscriptionManager,
49
+ ): Promise<Response> {
50
+ if (req.method !== "GET") {
51
+ return json({ error: "Method not allowed", message: "subscribe is GET-only" }, 405);
52
+ }
53
+
54
+ const url = new URL(req.url);
55
+
56
+ // Reject the un-live-evaluable query shapes up front (before any stream).
57
+ if (url.searchParams.get("search")) {
58
+ return json(
59
+ {
60
+ error: "search (full-text) is not supported for live subscriptions — FTS can't be evaluated against a single changed note. Drop `search` or poll GET /notes?search=.",
61
+ code: "UNSUPPORTED_SUBSCRIPTION_QUERY",
62
+ },
63
+ 400,
64
+ );
65
+ }
66
+ if (url.searchParams.get("near[note_id]")) {
67
+ return json(
68
+ {
69
+ error: "near (graph neighborhood) is not supported for live subscriptions — BFS can't be evaluated against a single changed note. Drop `near` or poll GET /notes?near[note_id]=.",
70
+ code: "UNSUPPORTED_SUBSCRIPTION_QUERY",
71
+ },
72
+ 400,
73
+ );
74
+ }
75
+
76
+ const parsed = parseNotesQueryOpts(url);
77
+ if (parsed.error) return parsed.error;
78
+ const queryOpts = parsed.queryOpts!;
79
+
80
+ // Belt-and-suspenders: reject cursor (paging) too — meaningless for a live set.
81
+ const unsupported = unsupportedSubscriptionReason(queryOpts);
82
+ if (unsupported) {
83
+ return json({ error: unsupported, code: "UNSUPPORTED_SUBSCRIPTION_QUERY" }, 400);
84
+ }
85
+
86
+ // Build the in-process matcher (resolves tag descendants once, same
87
+ // hierarchy the snapshot query uses) — may throw QueryError on a malformed
88
+ // metadata filter shape; surface as 400, same as the notes path.
89
+ let matcher;
90
+ try {
91
+ matcher = await buildLiveMatcher(store, queryOpts);
92
+ } catch (e: any) {
93
+ if (e && e.name === "QueryError") {
94
+ return json({ error: e.message, code: e.code ?? "INVALID_QUERY" }, 400);
95
+ }
96
+ throw e;
97
+ }
98
+
99
+ // Snapshot: the scoped query result. queryNotes throws QueryError on e.g. a
100
+ // non-indexed metadata operator field — surface as 400 (no stream opened).
101
+ //
102
+ // Strip paging (M3): the live matcher ignores limit/offset, so a default
103
+ // limit:50 would truncate the snapshot while live events deliver the full
104
+ // set — snapshot ⊊ live. The snapshot must be the COMPLETE matching set.
105
+ // queryNotes defaults an ABSENT limit to 100 (not unlimited), so pass a
106
+ // large sentinel to fetch every matching row.
107
+ const SNAPSHOT_UNBOUNDED = Number.MAX_SAFE_INTEGER;
108
+ const snapshotOpts: QueryOpts = { ...queryOpts, limit: SNAPSHOT_UNBOUNDED, offset: undefined };
109
+ let snapshotNotes;
110
+ try {
111
+ const raw = await store.queryNotes(snapshotOpts);
112
+ snapshotNotes = filterNotesByTagScope(raw, tagScope.allowed, tagScope.raw);
113
+ } catch (e: any) {
114
+ if (e && e.name === "QueryError") {
115
+ return json({ error: e.message, code: e.code ?? "INVALID_QUERY" }, 400);
116
+ }
117
+ throw e;
118
+ }
119
+
120
+ // Cap check BEFORE opening a stream so we can return a real 503. (The
121
+ // in-`start` re-check below only guards the rare interleave race where two
122
+ // subscribes pass this check before either registers.)
123
+ if (!manager.hasCapacity(vaultName)) {
124
+ return json(
125
+ {
126
+ error: "subscription cap reached for this vault — too many concurrent live subscriptions. Retry shortly or close idle streams.",
127
+ code: "SUBSCRIPTION_CAP_REACHED",
128
+ },
129
+ 503,
130
+ );
131
+ }
132
+
133
+ // ---- Build the SSE stream ----
134
+ //
135
+ // A pull ReadableStream with an internal frame queue. The manager's sink
136
+ // writes frames into the queue; `pull` drains them to the controller and
137
+ // notifies the manager (so the backpressure counter decrements). When the
138
+ // client disconnects, `cancel` fires → we unregister the subscription and
139
+ // clear the keepalive timer.
140
+ let handle: { flushed: () => void; close: () => void } | null = null;
141
+ let keepalive: ReturnType<typeof setInterval> | null = null;
142
+
143
+ const queue: string[] = [];
144
+ let controllerRef: ReadableStreamDefaultController<Uint8Array> | null = null;
145
+ let cancelled = false;
146
+ const encoder = new TextEncoder();
147
+
148
+ const flushQueue = () => {
149
+ if (!controllerRef || cancelled) return;
150
+ while (queue.length > 0) {
151
+ const frame = queue.shift()!;
152
+ try {
153
+ controllerRef.enqueue(encoder.encode(frame));
154
+ } catch {
155
+ // Controller closed underneath us — stop.
156
+ cancelled = true;
157
+ return;
158
+ }
159
+ handle?.flushed();
160
+ }
161
+ };
162
+
163
+ const sink: SubscriptionSink = {
164
+ write(frame: string): boolean {
165
+ if (cancelled) return false;
166
+ queue.push(frame);
167
+ flushQueue();
168
+ return true;
169
+ },
170
+ close(): void {
171
+ if (cancelled) return;
172
+ cancelled = true;
173
+ if (keepalive) {
174
+ clearInterval(keepalive);
175
+ keepalive = null;
176
+ }
177
+ try {
178
+ controllerRef?.close();
179
+ } catch {
180
+ /* already closed */
181
+ }
182
+ },
183
+ };
184
+
185
+ const stream = new ReadableStream<Uint8Array>({
186
+ start(controller) {
187
+ controllerRef = controller;
188
+ // 1. Snapshot first — written into the queue and flushed on this tick.
189
+ queue.push(snapshotFrame(snapshotNotes));
190
+
191
+ // 2. Register the live subscription. Hook dispatch is deferred to a
192
+ // microtask, and we register synchronously here within start(), so no
193
+ // live event can slip in front of the snapshot. Over-cap → 503; we
194
+ // can't return a Response from inside the stream, so the cap is also
195
+ // checked below before constructing the Response. (Re-check here for
196
+ // the race where two subscribes interleave.)
197
+ handle = manager.register({
198
+ vaultName,
199
+ matcher,
200
+ tagScopeAllowed: tagScope.allowed,
201
+ tagScopeRaw: tagScope.raw,
202
+ sink,
203
+ });
204
+ if (!handle) {
205
+ // Lost the cap race. Emit nothing further and close; the pre-check
206
+ // below normally catches this, so this path is rare.
207
+ flushQueue();
208
+ try {
209
+ controller.close();
210
+ } catch {
211
+ /* noop */
212
+ }
213
+ cancelled = true;
214
+ return;
215
+ }
216
+
217
+ flushQueue();
218
+
219
+ // 3. Keepalive comments.
220
+ keepalive = setInterval(() => {
221
+ if (cancelled) return;
222
+ sink.write(":\n\n");
223
+ }, KEEPALIVE_MS);
224
+ },
225
+ pull() {
226
+ flushQueue();
227
+ },
228
+ cancel() {
229
+ cancelled = true;
230
+ if (keepalive) {
231
+ clearInterval(keepalive);
232
+ keepalive = null;
233
+ }
234
+ handle?.close();
235
+ },
236
+ });
237
+
238
+ return new Response(stream, {
239
+ status: 200,
240
+ headers: {
241
+ "Content-Type": "text/event-stream",
242
+ "Cache-Control": "no-cache, no-transform",
243
+ Connection: "keep-alive",
244
+ // Defeat nginx-style proxy buffering of the event stream.
245
+ "X-Accel-Buffering": "no",
246
+ },
247
+ });
248
+ }
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Live-query subscription registry (live-query SSE — design
3
+ * `design/2026-06-08-live-query-sse.md`).
4
+ *
5
+ * A second consumer of the post-commit hook dispatcher (`core/src/hooks.ts`),
6
+ * alongside the durable webhook-trigger sink. Where a trigger survives across
7
+ * connections (wakes an offline session), a subscription is ephemeral —
8
+ * connection-scoped, driving a live UI. Same event source, same predicate,
9
+ * different durability.
10
+ *
11
+ * ## How fan-out works
12
+ *
13
+ * All vault stores share the process-wide `defaultHookRegistry`
14
+ * (`vault-store.ts`). We register exactly ONE broad hook per event type
15
+ * (`created`/`updated`/`deleted`) — with NO `when` filter, because a
16
+ * subscription must see EVERY mutation to detect a note LEAVING its set (a
17
+ * `when` that pre-filters to the query would hide the just-left-the-set
18
+ * update). On each event the manager:
19
+ *
20
+ * 1. resolves the event's vault from the store handle
21
+ * (`getVaultNameForStore`) — the shared registry fires for all vaults;
22
+ * 2. iterates only the subscriptions on THAT vault;
23
+ * 3. created/updated → `matcher.match(note) && noteWithinTagScope(...)` ?
24
+ * emit `upsert{note}` : (updated only) emit `remove{id}` (left-the-set,
25
+ * idempotent on the client);
26
+ * 4. deleted → broadcast `remove{id}` to all that vault's subs (the delete
27
+ * payload is a thin `{id, path?}` ref — no tags/metadata — so it can't
28
+ * be scope-matched; see the design's "Auth / scope intersection" §).
29
+ *
30
+ * O(writes × subscriptions). At vault scale (hundreds of notes, single-digit
31
+ * open tabs) this is free; documented ceiling, not a silent cap.
32
+ *
33
+ * ## Security: scope intersection (load-bearing)
34
+ *
35
+ * A subscription MUST NOT emit a note its token can't read. Every `upsert`
36
+ * passes BOTH the subscription predicate AND `noteWithinTagScope(note,
37
+ * allowed, rawRoots)` — the SAME check the REST notes path uses. The scope
38
+ * check is ANDed with the predicate and is not bypassable by query shape.
39
+ * The snapshot is separately scope-filtered at the route via
40
+ * `filterNotesByTagScope`.
41
+ */
42
+
43
+ import type { Note, Store } from "../core/src/types.ts";
44
+ import type { DeletedNoteRef, HookEvent, NoteHookPayload } from "../core/src/hooks.ts";
45
+ import { defaultHookRegistry } from "../core/src/hooks.ts";
46
+ import { getVaultNameForStore } from "./vault-store.ts";
47
+ import { noteWithinTagScope } from "./tag-scope.ts";
48
+ import type { LiveMatcher } from "./live-match.ts";
49
+
50
+ /** Default per-vault concurrent-subscription cap. Over it → 503. */
51
+ export const DEFAULT_MAX_SUBSCRIPTIONS_PER_VAULT = 100;
52
+
53
+ /**
54
+ * Default bound on a single subscription's pending (unflushed) event buffer.
55
+ * If a slow client lets the buffer grow past this, the stream is closed — it
56
+ * reconnects and re-snapshots rather than the server growing memory unbounded.
57
+ */
58
+ export const DEFAULT_MAX_BUFFERED_EVENTS = 1000;
59
+
60
+ /** A serialized SSE frame ready to write to the wire. */
61
+ type SseFrame = string;
62
+
63
+ export interface SubscriptionSink {
64
+ /** Enqueue a serialized SSE frame. Returns false if the sink is closed. */
65
+ write(frame: SseFrame): boolean;
66
+ /** Close the underlying stream (teardown). Idempotent. */
67
+ close(): void;
68
+ }
69
+
70
+ interface Subscription {
71
+ readonly vaultName: string;
72
+ readonly matcher: LiveMatcher;
73
+ /** Expanded tag-scope allowlist (null = unscoped). */
74
+ readonly tagScopeAllowed: Set<string> | null;
75
+ /** Raw root-tag allowlist (null = unscoped) — `noteWithinTagScope` arg. */
76
+ readonly tagScopeRaw: string[] | null;
77
+ readonly sink: SubscriptionSink;
78
+ /** Pending unflushed frame count (backpressure bound). */
79
+ buffered: number;
80
+ readonly maxBuffered: number;
81
+ closed: boolean;
82
+ }
83
+
84
+ function sseEvent(event: string, data: unknown): SseFrame {
85
+ return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
86
+ }
87
+
88
+ export class SubscriptionManager {
89
+ private subs = new Set<Subscription>();
90
+ private perVaultCount = new Map<string, number>();
91
+ private hooksRegistered = false;
92
+ private unregisters: Array<() => void> = [];
93
+ private readonly maxPerVault: number;
94
+ private readonly resolveVault: (store: Store) => string | undefined;
95
+
96
+ constructor(
97
+ private readonly registry = defaultHookRegistry,
98
+ opts: { maxPerVault?: number; resolveVault?: (store: Store) => string | undefined } = {},
99
+ ) {
100
+ this.maxPerVault = opts.maxPerVault ?? DEFAULT_MAX_SUBSCRIPTIONS_PER_VAULT;
101
+ // Resolve the event's vault from the store handle. Defaults to the
102
+ // process-wide WeakMap (vault-store.ts); injectable for tests that build
103
+ // a store directly without going through `getVaultStore`.
104
+ this.resolveVault = opts.resolveVault ?? getVaultNameForStore;
105
+ }
106
+
107
+ /** Active subscription count for a vault (for the cap check + tests). */
108
+ countForVault(vaultName: string): number {
109
+ return this.perVaultCount.get(vaultName) ?? 0;
110
+ }
111
+
112
+ /** The configured per-vault concurrent-subscription cap. */
113
+ get maxSubscriptionsPerVault(): number {
114
+ return this.maxPerVault;
115
+ }
116
+
117
+ /** True iff a new subscription on this vault would be under the cap. */
118
+ hasCapacity(vaultName: string): boolean {
119
+ return this.countForVault(vaultName) < this.maxPerVault;
120
+ }
121
+
122
+ /** Total active subscriptions (tests / diagnostics). */
123
+ get size(): number {
124
+ return this.subs.size;
125
+ }
126
+
127
+ /**
128
+ * Register a subscription. Returns the handle, or `null` if the vault is at
129
+ * its concurrent-subscription cap (the route maps null → 503). The caller
130
+ * is responsible for sending the initial `snapshot` frame BEFORE calling
131
+ * this — registration only wires the live stream so no live event can be
132
+ * missed between snapshot and registration (the caller registers
133
+ * synchronously after computing the snapshot; hook dispatch is deferred to
134
+ * a microtask, so a same-tick write can't slip in front of registration).
135
+ */
136
+ register(args: {
137
+ vaultName: string;
138
+ matcher: LiveMatcher;
139
+ tagScopeAllowed: Set<string> | null;
140
+ tagScopeRaw: string[] | null;
141
+ sink: SubscriptionSink;
142
+ maxBuffered?: number;
143
+ }): SubscriptionHandle | null {
144
+ const current = this.countForVault(args.vaultName);
145
+ if (current >= this.maxPerVault) return null;
146
+
147
+ this.ensureHooks();
148
+
149
+ const sub: Subscription = {
150
+ vaultName: args.vaultName,
151
+ matcher: args.matcher,
152
+ tagScopeAllowed: args.tagScopeAllowed,
153
+ tagScopeRaw: args.tagScopeRaw,
154
+ sink: args.sink,
155
+ buffered: 0,
156
+ maxBuffered: args.maxBuffered ?? DEFAULT_MAX_BUFFERED_EVENTS,
157
+ closed: false,
158
+ };
159
+ this.subs.add(sub);
160
+ this.perVaultCount.set(args.vaultName, current + 1);
161
+
162
+ return {
163
+ /** Note that a previously-buffered frame flushed to the wire. */
164
+ flushed: () => {
165
+ if (sub.buffered > 0) sub.buffered--;
166
+ },
167
+ close: () => this.remove(sub),
168
+ };
169
+ }
170
+
171
+ private remove(sub: Subscription): void {
172
+ if (sub.closed) return;
173
+ sub.closed = true;
174
+ this.subs.delete(sub);
175
+ const n = this.perVaultCount.get(sub.vaultName) ?? 0;
176
+ if (n <= 1) this.perVaultCount.delete(sub.vaultName);
177
+ else this.perVaultCount.set(sub.vaultName, n - 1);
178
+ try {
179
+ sub.sink.close();
180
+ } catch {
181
+ /* sink may already be torn down */
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Send a keepalive comment to every open subscription (the route's timer
187
+ * calls this). A `:` comment defeats idle-proxy timeouts; it's not an event
188
+ * so it never counts against the buffer bound.
189
+ */
190
+ keepaliveAll(): void {
191
+ for (const sub of this.subs) {
192
+ if (sub.closed) continue;
193
+ const ok = sub.sink.write(":\n\n");
194
+ if (!ok) this.remove(sub);
195
+ }
196
+ }
197
+
198
+ /** Emit a frame to one subscription, enforcing the buffer bound. */
199
+ private emit(sub: Subscription, frame: SseFrame): void {
200
+ if (sub.closed) return;
201
+ if (sub.buffered >= sub.maxBuffered) {
202
+ // Backpressure: client can't keep up. Close → it reconnects + re-snapshots.
203
+ this.remove(sub);
204
+ return;
205
+ }
206
+ const ok = sub.sink.write(frame);
207
+ if (!ok) {
208
+ this.remove(sub);
209
+ return;
210
+ }
211
+ sub.buffered++;
212
+ }
213
+
214
+ /** Lazily register the three broad hooks (once per manager). */
215
+ private ensureHooks(): void {
216
+ if (this.hooksRegistered) return;
217
+ this.hooksRegistered = true;
218
+ const onNoteEvent = (event: HookEvent) => (payload: NoteHookPayload, store: Store) => {
219
+ this.dispatch(event, payload, store);
220
+ };
221
+ // NO `when` — a subscription must see all events to detect set-exit.
222
+ this.unregisters.push(
223
+ this.registry.onNote({ name: "live-subscribe:created", event: "created", handler: onNoteEvent("created") }),
224
+ this.registry.onNote({ name: "live-subscribe:updated", event: "updated", handler: onNoteEvent("updated") }),
225
+ this.registry.onNote({ name: "live-subscribe:deleted", event: "deleted", handler: onNoteEvent("deleted") }),
226
+ );
227
+ }
228
+
229
+ /**
230
+ * Core fan-out. `payload` is the full `Note` for created/updated (re-read
231
+ * fresh by the hook runner) or a `DeletedNoteRef` for deleted.
232
+ */
233
+ private dispatch(event: HookEvent, payload: NoteHookPayload, store: Store): void {
234
+ const vaultName = this.resolveVault(store);
235
+ if (!vaultName) return; // store not tracked → can't scope; drop safely
236
+ if (this.subs.size === 0) return;
237
+
238
+ for (const sub of this.subs) {
239
+ if (sub.closed) continue;
240
+ if (sub.vaultName !== vaultName) continue;
241
+
242
+ if (event === "deleted") {
243
+ // Thin ref ({id, path?}) — un-scope-matchable. Broadcast remove{id};
244
+ // the client ignores ids it never held. Documented low-sensitivity
245
+ // existence leak (see design §scope-intersection).
246
+ const ref = payload as DeletedNoteRef;
247
+ this.emit(sub, sseEvent("remove", { id: ref.id }));
248
+ continue;
249
+ }
250
+
251
+ // created / updated: full Note in hand. Compute scope ONCE and gate
252
+ // BOTH the upsert and the left-the-set remove on it — emitting a
253
+ // `remove{id}` for an out-of-scope note would leak its UUID to a token
254
+ // that could never have held it (M2).
255
+ const note = payload as Note;
256
+ const inScope = noteWithinTagScope(note, sub.tagScopeAllowed, sub.tagScopeRaw);
257
+ const matches = sub.matcher.match(note) && inScope;
258
+
259
+ if (matches) {
260
+ this.emit(sub, sseEvent("upsert", { note }));
261
+ } else if (event === "updated" && inScope) {
262
+ // Left the set (predicate no longer true) BUT still within this
263
+ // token's scope, so the sub could have held it — idempotent remove
264
+ // (client drops the id if held, no-op otherwise). When the note is
265
+ // OUT of scope the sub never had it; emitting would leak its id, so
266
+ // we stay silent.
267
+ this.emit(sub, sseEvent("remove", { id: note.id }));
268
+ }
269
+ // created that doesn't match → nothing (it was never in the set).
270
+ }
271
+ }
272
+
273
+ /** Tear down all hooks + close all streams. For shutdown / tests. */
274
+ shutdown(): void {
275
+ for (const u of this.unregisters) u();
276
+ this.unregisters = [];
277
+ this.hooksRegistered = false;
278
+ for (const sub of Array.from(this.subs)) this.remove(sub);
279
+ }
280
+ }
281
+
282
+ export interface SubscriptionHandle {
283
+ /** Decrement the pending-frame counter when a frame flushes to the wire. */
284
+ flushed: () => void;
285
+ /** Unregister + close. Called on stream cancel/close. Idempotent. */
286
+ close: () => void;
287
+ }
288
+
289
+ /** Serialize a snapshot frame (exported for the route + tests). */
290
+ export function snapshotFrame(notes: Note[]): SseFrame {
291
+ return sseEvent("snapshot", { notes });
292
+ }
293
+
294
+ /** Process-wide manager — shared like `defaultHookRegistry`. */
295
+ export const subscriptionManager = new SubscriptionManager();
@@ -0,0 +1,45 @@
1
+ /**
2
+ * REST `?expand=` parsing for `parseNotesQueryOpts` — vault tag `expand` axis
3
+ * (design 2026-06-09). Validates the four-value enum, the 400 on a bad value,
4
+ * and that an ABSENT param leaves `expand` undefined (so the store resolves it
5
+ * to "subtypes" → byte-identical to pre-axis behavior).
6
+ */
7
+
8
+ import { describe, it, expect } from "bun:test";
9
+ import { parseNotesQueryOpts } from "./routes.ts";
10
+
11
+ function parse(qs: string) {
12
+ return parseNotesQueryOpts(new URL(`http://localhost/api/notes?${qs}`));
13
+ }
14
+
15
+ describe("parseNotesQueryOpts — expand axis", () => {
16
+ it("absent → queryOpts.expand is undefined (default = subtypes at the store)", () => {
17
+ const r = parse("tag=entity");
18
+ expect(r.error).toBeUndefined();
19
+ expect(r.queryOpts!.expand).toBeUndefined();
20
+ });
21
+
22
+ for (const mode of ["subtypes", "namespace", "both", "exact"] as const) {
23
+ it(`expand=${mode} parses through`, () => {
24
+ const r = parse(`tag=entity&expand=${mode}`);
25
+ expect(r.error).toBeUndefined();
26
+ expect(r.queryOpts!.expand).toBe(mode);
27
+ });
28
+ }
29
+
30
+ it("empty expand= is treated as absent (undefined)", () => {
31
+ const r = parse("tag=entity&expand=");
32
+ expect(r.error).toBeUndefined();
33
+ expect(r.queryOpts!.expand).toBeUndefined();
34
+ });
35
+
36
+ it("unknown expand value → 400 with INVALID_QUERY", async () => {
37
+ const r = parse("tag=entity&expand=bogus");
38
+ expect(r.queryOpts).toBeUndefined();
39
+ expect(r.error).toBeDefined();
40
+ expect(r.error!.status).toBe(400);
41
+ const body = await r.error!.json();
42
+ expect(body.code).toBe("INVALID_QUERY");
43
+ expect(body.error).toContain("expand");
44
+ });
45
+ });