@openparachute/vault 0.6.5-rc.13 → 0.6.5-rc.14
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/package.json +1 -1
- package/src/live-frame-parity.test.ts +16 -41
- package/src/routing.ts +17 -5
- package/src/server.ts +3 -2
- package/src/subscriptions.ts +20 -50
- package/src/ws-server.ts +1 -1
- package/src/subscribe.test.ts +0 -609
- package/src/subscribe.ts +0 -249
package/package.json
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Live-query frame PARITY + the pure WS helpers (WS-hibernation migration
|
|
3
|
-
*
|
|
2
|
+
* Live-query frame PARITY + the pure WS helpers (WS-hibernation migration,
|
|
3
|
+
* self-host door).
|
|
4
4
|
*
|
|
5
5
|
* The load-bearing CROSS-DOOR conformance check: for the shared frame corpus
|
|
6
|
-
* (mirrored byte-for-byte from the cloud door), the self-host WS
|
|
7
|
-
*
|
|
8
|
-
* payload (`notes` / `note` / `id`)
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
6
|
+
* (mirrored byte-for-byte from the cloud door), the self-host WS message folds
|
|
7
|
+
* the event name into a `type` discriminator (plus the snapshot `done` chunk
|
|
8
|
+
* flag) over an inner payload (`notes` / `note` / `id`) that serializes
|
|
9
|
+
* byte-IDENTICALLY to the corpus. Because the corpus AND the `wsFrame` formatter
|
|
10
|
+
* are byte-shaped identical to cloud's, this transitively proves: self-host WS
|
|
11
|
+
* bytes === corpus === cloud WS bytes. (SSE was the original transport; it was
|
|
12
|
+
* removed in Phase 5 — WebSocket is now the sole live transport, polling the
|
|
13
|
+
* client floor.)
|
|
13
14
|
*
|
|
14
15
|
* Plus unit coverage of the pure helpers that back the Bun.serve integration:
|
|
15
16
|
* snapshot chunking, first-message parsing, verb-rank, and the query rejects.
|
|
16
17
|
*/
|
|
17
18
|
import { describe, it, expect } from "bun:test";
|
|
18
|
-
import {
|
|
19
|
+
import { wsFrame, WsSink } from "./subscriptions.ts";
|
|
19
20
|
import {
|
|
20
21
|
buildSnapshotFrames,
|
|
21
22
|
parseClientMessage,
|
|
@@ -30,23 +31,11 @@ import {
|
|
|
30
31
|
import { unsupportedSubscriptionReason } from "./live-match.ts";
|
|
31
32
|
import { FRAME_CORPUS, NOTE_A } from "./test-support/live-frame-corpus.ts";
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
function sseDataBody(frame: string): string {
|
|
35
|
-
const line = frame.split("\n").find((l) => l.startsWith("data:"))!;
|
|
36
|
-
return line.slice("data:".length).replace(/^ /, "");
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
describe("live-frame parity — self-host WS bytes === corpus === SSE data body", () => {
|
|
34
|
+
describe("live-frame parity — self-host WS bytes === corpus === cloud WS bytes", () => {
|
|
40
35
|
for (const c of FRAME_CORPUS) {
|
|
41
|
-
it(`${c.name}:
|
|
42
|
-
const sse = sseFrame(c.event, c.data);
|
|
36
|
+
it(`${c.name}: WS message folds the event into \`type\` over the corpus payload`, () => {
|
|
43
37
|
const ws = wsFrame(c.event, c.data);
|
|
44
38
|
|
|
45
|
-
// SSE frame shape (byte-identical to the pre-WS-migration contract).
|
|
46
|
-
expect(sse).toBe(`event: ${c.event}\ndata: ${JSON.stringify(c.data)}\n\n`);
|
|
47
|
-
// SSE data body parses back to exactly the payload.
|
|
48
|
-
expect(JSON.parse(sseDataBody(sse))).toEqual(c.data);
|
|
49
|
-
|
|
50
39
|
// WS message is `{ type, ...data }` — the event name folds into `type`.
|
|
51
40
|
const parsedWs = JSON.parse(ws) as Record<string, unknown>;
|
|
52
41
|
expect(parsedWs.type).toBe(c.event);
|
|
@@ -55,21 +44,16 @@ describe("live-frame parity — self-host WS bytes === corpus === SSE data body"
|
|
|
55
44
|
expect(rest).toEqual(c.data);
|
|
56
45
|
|
|
57
46
|
// BYTE parity of the inner payload: whatever value the event carries
|
|
58
|
-
// (`notes` / `note` / `id`) serializes IDENTICALLY
|
|
59
|
-
//
|
|
47
|
+
// (`notes` / `note` / `id`) serializes IDENTICALLY to the corpus
|
|
48
|
+
// serialization (the cross-door invariant).
|
|
60
49
|
const innerKey = c.event === "snapshot" ? "notes" : c.event === "upsert" ? "note" : "id";
|
|
61
50
|
const innerBytes = JSON.stringify((c.data as Record<string, unknown>)[innerKey]);
|
|
62
|
-
expect(sse.includes(innerBytes)).toBe(true);
|
|
63
51
|
expect(ws.includes(innerBytes)).toBe(true);
|
|
64
52
|
});
|
|
65
53
|
}
|
|
66
|
-
|
|
67
|
-
it("snapshotFrame() is the SSE snapshot frame for a note set", () => {
|
|
68
|
-
expect(snapshotFrame([NOTE_A as any])).toBe(sseFrame("snapshot", { notes: [NOTE_A] }));
|
|
69
|
-
});
|
|
70
54
|
});
|
|
71
55
|
|
|
72
|
-
describe("sink classes render through the ONE formatter
|
|
56
|
+
describe("sink classes render through the ONE formatter", () => {
|
|
73
57
|
it("WsSink.send emits exactly wsFrame(event, data)", () => {
|
|
74
58
|
const sent: string[] = [];
|
|
75
59
|
const sink = new WsSink({ send: (d: string) => sent.push(d), close: () => {} });
|
|
@@ -78,15 +62,6 @@ describe("sink classes render through the ONE formatter each", () => {
|
|
|
78
62
|
expect(sent.at(-1)).toBe(wsFrame(c.event, c.data));
|
|
79
63
|
}
|
|
80
64
|
});
|
|
81
|
-
|
|
82
|
-
it("SseSink.send emits exactly sseFrame(event, data); comment() emits `:\\n\\n`", () => {
|
|
83
|
-
const pushed: string[] = [];
|
|
84
|
-
const sink = new SseSink((f) => (pushed.push(f), true), () => {});
|
|
85
|
-
sink.send("upsert", { note: NOTE_A });
|
|
86
|
-
expect(pushed.at(-1)).toBe(sseFrame("upsert", { note: NOTE_A }));
|
|
87
|
-
sink.comment();
|
|
88
|
-
expect(pushed.at(-1)).toBe(":\n\n");
|
|
89
|
-
});
|
|
90
65
|
});
|
|
91
66
|
|
|
92
67
|
describe("buildSnapshotFrames — chunking + done flag", () => {
|
package/src/routing.ts
CHANGED
|
@@ -73,7 +73,6 @@ import {
|
|
|
73
73
|
type TagScopeCtx,
|
|
74
74
|
type WriteCtx,
|
|
75
75
|
} from "./routes.ts";
|
|
76
|
-
import { handleSubscribe } from "./subscribe.ts";
|
|
77
76
|
import { handleTriggers } from "./triggers-api.ts";
|
|
78
77
|
import { expandTokenTagScope } from "./tag-scope.ts";
|
|
79
78
|
import {
|
|
@@ -883,10 +882,23 @@ export async function route(
|
|
|
883
882
|
};
|
|
884
883
|
|
|
885
884
|
if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6), vaultName, tagScope, writeCtx);
|
|
886
|
-
// Live-query SSE
|
|
887
|
-
//
|
|
888
|
-
//
|
|
889
|
-
|
|
885
|
+
// Live-query SSE transport REMOVED (WS-hibernation migration Phase 5). The
|
|
886
|
+
// WebSocket binding (an `Upgrade: websocket` GET /subscribe, handled in
|
|
887
|
+
// server.ts before this pipeline) is now the SOLE live transport; polling
|
|
888
|
+
// GET /notes is the client floor beneath it. A non-WS GET /subscribe — a
|
|
889
|
+
// straggler on a cached pre-WS notes-ui bundle — gets a clean 410 Gone
|
|
890
|
+
// pointing at the WS binding so its consumer degrades to polling gracefully;
|
|
891
|
+
// NEVER a 500 or an unhandled path.
|
|
892
|
+
if (apiPath === "/subscribe") {
|
|
893
|
+
return Response.json(
|
|
894
|
+
{
|
|
895
|
+
error:
|
|
896
|
+
"The live-query SSE transport has been removed. Reconnect with an `Upgrade: websocket` request to GET /api/subscribe, or poll GET /notes.",
|
|
897
|
+
code: "SSE_TRANSPORT_REMOVED",
|
|
898
|
+
},
|
|
899
|
+
{ status: 410 },
|
|
900
|
+
);
|
|
901
|
+
}
|
|
890
902
|
if (apiPath.startsWith("/tags")) return handleTags(req, store, apiPath.slice(5), tagScope);
|
|
891
903
|
if (apiPath === "/find-path") return handleFindPath(req, store, tagScope);
|
|
892
904
|
if (apiPath === "/vault") {
|
package/src/server.ts
CHANGED
|
@@ -527,8 +527,9 @@ const server = Bun.serve({
|
|
|
527
527
|
}
|
|
528
528
|
|
|
529
529
|
// Live-query WebSocket upgrade — detected BEFORE the fetch pipeline because
|
|
530
|
-
// WS upgrades don't traverse `route()`. Non-upgrade requests (incl.
|
|
531
|
-
//
|
|
530
|
+
// WS upgrades don't traverse `route()`. Non-upgrade requests (incl. a
|
|
531
|
+
// straggler non-WS GET /subscribe, which now gets a 410 Gone in routing.ts —
|
|
532
|
+
// the SSE transport was removed in Phase 5) fall straight through unchanged.
|
|
532
533
|
if (isWebSocketUpgrade(req)) {
|
|
533
534
|
const verdict = subscribeWs.tryUpgrade(req, server, path);
|
|
534
535
|
if (verdict.kind === "upgraded") return undefined; // Bun owns the socket now
|
package/src/subscriptions.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Live-query subscription registry
|
|
3
|
-
*
|
|
2
|
+
* Live-query subscription registry — fans out post-commit note events to live
|
|
3
|
+
* WebSocket subscriptions (originally an SSE fan-out — design
|
|
4
|
+
* `design/2026-06-08-live-query-sse.md`; the SSE transport was removed in
|
|
5
|
+
* Phase 5 of the WS-hibernation migration).
|
|
4
6
|
*
|
|
5
7
|
* A second consumer of the post-commit hook dispatcher (`core/src/hooks.ts`),
|
|
6
8
|
* alongside the durable webhook-trigger sink. Where a trigger survives across
|
|
@@ -8,24 +10,23 @@
|
|
|
8
10
|
* connection-scoped, driving a live UI. Same event source, same predicate,
|
|
9
11
|
* different durability.
|
|
10
12
|
*
|
|
11
|
-
* ##
|
|
13
|
+
* ## The live transport: WebSocket (WS-hibernation migration)
|
|
12
14
|
*
|
|
13
15
|
* The manager is transport-agnostic: it emits abstract `(event, data)` tuples
|
|
14
16
|
* and a {@link SubscriptionSink} renders them for its wire.
|
|
15
|
-
* - {@link
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* `data:` body. That parity is pinned by the shared frame-corpus fixture and
|
|
21
|
-
* is congruent with the cloud door (`parachute-cloud/workers/vault/src/
|
|
22
|
-
* live/subscriptions.ts`). See `parachute-cloud/workers/vault/docs/
|
|
23
|
-
* live-query-ws.md`.
|
|
17
|
+
* - {@link WsSink} renders a WebSocket message `{ type: event, ...data }`. The
|
|
18
|
+
* inner payload (`note` / `id` / `notes`) is pinned byte-for-byte against the
|
|
19
|
+
* shared frame-corpus fixture and is congruent with the cloud door
|
|
20
|
+
* (`parachute-cloud/workers/vault/src/live/subscriptions.ts`). See
|
|
21
|
+
* `parachute-cloud/workers/vault/docs/live-query-ws.md`.
|
|
24
22
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* the
|
|
28
|
-
*
|
|
23
|
+
* WebSocket is the SOLE live transport as of Phase 5 — the earlier SSE binding
|
|
24
|
+
* was removed once cached notes-ui bundles converged, and polling (client-side)
|
|
25
|
+
* is the floor beneath live. All subscriptions share ONE process-wide
|
|
26
|
+
* {@link SubscriptionManager}. Self-host has no hibernation (a self-run Bun box
|
|
27
|
+
* has no per-connection duration bill) — the WS binding gives bidirectionality +
|
|
28
|
+
* contract-congruence with cloud, nothing more. The Bun.serve WS integration
|
|
29
|
+
* lives in `ws-server.ts` + `ws-subscribe.ts`.
|
|
29
30
|
*
|
|
30
31
|
* ## How fan-out works
|
|
31
32
|
*
|
|
@@ -108,40 +109,14 @@ interface Subscription {
|
|
|
108
109
|
closed: boolean;
|
|
109
110
|
}
|
|
110
111
|
|
|
111
|
-
/**
|
|
112
|
-
*
|
|
113
|
-
|
|
114
|
-
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** WebSocket message: `{ type: <event>, ...data }`. The SSE `event:` name folds
|
|
118
|
-
* into `type`; the inner payload serializes identically to the SSE `data:`
|
|
119
|
-
* body. The ONE place WS bytes are formatted. */
|
|
112
|
+
/** WebSocket message: `{ type: <event>, ...data }` — the event name becomes the
|
|
113
|
+
* `type` discriminator and the inner payload (`note` / `id` / `notes`)
|
|
114
|
+
* serializes as-is. The ONE place WS bytes are formatted. */
|
|
120
115
|
export function wsFrame(event: string, data: unknown): string {
|
|
121
116
|
const body = data && typeof data === "object" && !Array.isArray(data) ? (data as Record<string, unknown>) : {};
|
|
122
117
|
return JSON.stringify({ type: event, ...body });
|
|
123
118
|
}
|
|
124
119
|
|
|
125
|
-
/** SSE sink: renders `(event, data)` to an SSE frame and hands it to `push`
|
|
126
|
-
* (the route's stream-queue writer). `push` returns false when the stream is
|
|
127
|
-
* gone. `comment()` emits a raw `:` keepalive (SSE-only; not part of the
|
|
128
|
-
* abstract contract). Bytes are byte-identical to the pre-migration SSE path. */
|
|
129
|
-
export class SseSink implements SubscriptionSink {
|
|
130
|
-
constructor(
|
|
131
|
-
private readonly push: (frame: string) => boolean,
|
|
132
|
-
private readonly onClose: () => void,
|
|
133
|
-
) {}
|
|
134
|
-
send(event: string, data: unknown): boolean {
|
|
135
|
-
return this.push(sseFrame(event, data));
|
|
136
|
-
}
|
|
137
|
-
comment(text = ""): boolean {
|
|
138
|
-
return this.push(`:${text}\n\n`);
|
|
139
|
-
}
|
|
140
|
-
close(): void {
|
|
141
|
-
this.onClose();
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
120
|
/** Minimal structural shape of a Bun `ServerWebSocket` (or a test double) that
|
|
146
121
|
* {@link WsSink} needs — keeps this module free of a hard Bun-server import. */
|
|
147
122
|
export interface WsLike {
|
|
@@ -374,10 +349,5 @@ export interface SubscriptionHandle {
|
|
|
374
349
|
close: () => void;
|
|
375
350
|
}
|
|
376
351
|
|
|
377
|
-
/** Serialize a snapshot SSE frame (exported for the SSE route + tests). */
|
|
378
|
-
export function snapshotFrame(notes: Note[]): string {
|
|
379
|
-
return sseFrame("snapshot", { notes });
|
|
380
|
-
}
|
|
381
|
-
|
|
382
352
|
/** Process-wide manager — shared like `defaultHookRegistry`. */
|
|
383
353
|
export const subscriptionManager = new SubscriptionManager();
|
package/src/ws-server.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* - `ws-subscribe.ts` — the PURE half (close codes, query validation, chunked
|
|
15
15
|
* snapshot, message parsing, verb-rank) shared byte-shaped with cloud.
|
|
16
16
|
* - `subscriptions.ts` — the transport-agnostic manager + `WsSink`
|
|
17
|
-
* (`{ type, ...data }`)
|
|
17
|
+
* (`{ type, ...data }`), the sole live-transport sink since Phase 5.
|
|
18
18
|
* - this module — the Bun.serve upgrade decision + `open`/`message`/`close`
|
|
19
19
|
* handlers + the per-vault live-socket registry (the WS cap).
|
|
20
20
|
*
|
package/src/subscribe.test.ts
DELETED
|
@@ -1,609 +0,0 @@
|
|
|
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) — bracket date_filter on created_at", async () => {
|
|
420
|
-
// The flat `date_from` param was removed in 0.6.4 (vault#288) and is now
|
|
421
|
-
// ignored, so it no longer reaches the M1 date guard. Bracket-style is the
|
|
422
|
-
// only query-string date filter; assert it's still rejected for live subs.
|
|
423
|
-
const res = await handleSubscribe(
|
|
424
|
-
subscribeReq("meta%5Bcreated_at%5D%5Bgte%5D=2026-01-01"),
|
|
425
|
-
store,
|
|
426
|
-
VAULT,
|
|
427
|
-
unscopedScope(),
|
|
428
|
-
manager,
|
|
429
|
-
);
|
|
430
|
-
expect(res.status).toBe(400);
|
|
431
|
-
const body = await res.json();
|
|
432
|
-
expect(body.code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
|
|
433
|
-
expect(body.error).toContain("date");
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
it("flat date_from is ignored → subscription created (vault#288 removal)", async () => {
|
|
437
|
-
// Documents the breaking change: the removed flat param no longer reaches
|
|
438
|
-
// the date guard, so a sub that ONLY carries it is now a valid (unfiltered)
|
|
439
|
-
// subscription rather than a 400.
|
|
440
|
-
const res = await handleSubscribe(
|
|
441
|
-
subscribeReq("date_from=2026-01-01"),
|
|
442
|
-
store,
|
|
443
|
-
VAULT,
|
|
444
|
-
unscopedScope(),
|
|
445
|
-
manager,
|
|
446
|
-
);
|
|
447
|
-
expect(res.status).toBe(200);
|
|
448
|
-
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
|
|
449
|
-
const r = sseReader(res);
|
|
450
|
-
await r.pump();
|
|
451
|
-
await r.close();
|
|
452
|
-
});
|
|
453
|
-
|
|
454
|
-
it("date filter → 400 (M1) — bracket date_filter on updated_at", async () => {
|
|
455
|
-
const res = await handleSubscribe(
|
|
456
|
-
subscribeReq("meta%5Bupdated_at%5D%5Bgte%5D=2026-01-01"),
|
|
457
|
-
store,
|
|
458
|
-
VAULT,
|
|
459
|
-
unscopedScope(),
|
|
460
|
-
manager,
|
|
461
|
-
);
|
|
462
|
-
expect(res.status).toBe(400);
|
|
463
|
-
const body = await res.json();
|
|
464
|
-
expect(body.code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
|
|
465
|
-
expect(body.error).toContain("date");
|
|
466
|
-
});
|
|
467
|
-
});
|
|
468
|
-
|
|
469
|
-
describe("handleSubscribe — snapshot completeness (M3)", () => {
|
|
470
|
-
it("a subscription matching >50 notes gets ALL of them in the snapshot", async () => {
|
|
471
|
-
// The default query limit is 50; seed 60 matching notes and assert the
|
|
472
|
-
// snapshot carries the full set (paging stripped for the snapshot query).
|
|
473
|
-
for (let i = 0; i < 60; i++) {
|
|
474
|
-
await store.createNote(`n${i}`, { tags: ["chat"] });
|
|
475
|
-
}
|
|
476
|
-
const res = await handleSubscribe(subscribeReq("tag=chat"), store, VAULT, unscopedScope(), manager);
|
|
477
|
-
const r = sseReader(res);
|
|
478
|
-
await r.pump();
|
|
479
|
-
const snap = r.frames().find((f) => f.event === "snapshot")!;
|
|
480
|
-
expect(snap.data.notes.length).toBe(60);
|
|
481
|
-
await r.close();
|
|
482
|
-
});
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
describe("handleSubscribe — expand axis snapshot↔live consistency", () => {
|
|
486
|
-
// vault tag `expand` axis (design 2026-06-09): a subscription's snapshot
|
|
487
|
-
// (query engine) and its live matcher MUST lower the IDENTICAL tag
|
|
488
|
-
// expansion. We seed the two-axis corpus, then for each `expand` mode assert
|
|
489
|
-
// the snapshot set EQUALS the set the live matcher would accept over every
|
|
490
|
-
// note in the vault.
|
|
491
|
-
async function seedTwoAxis() {
|
|
492
|
-
await store.upsertTagRecord("entity", { description: "entity root" });
|
|
493
|
-
await store.upsertTagRecord("person", { parent_names: ["entity"] }); // subtype, not name-prefixed
|
|
494
|
-
await store.upsertTagRecord("entity/archived", {}); // name-prefixed, not subtype
|
|
495
|
-
await store.upsertTagRecord("entity/person", { parent_names: ["entity"] }); // both
|
|
496
|
-
await store.createNote("literal", { tags: ["entity"] });
|
|
497
|
-
await store.createNote("subtype", { tags: ["person"] });
|
|
498
|
-
await store.createNote("filed", { tags: ["entity/archived"] });
|
|
499
|
-
await store.createNote("both", { tags: ["entity/person"] });
|
|
500
|
-
await store.createNote("unrelated", { tags: ["work"] });
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
for (const mode of ["subtypes", "namespace", "both", "exact"] as const) {
|
|
504
|
-
it(`expand=${mode}: snapshot set ≡ live-matcher acceptance`, async () => {
|
|
505
|
-
await seedTwoAxis();
|
|
506
|
-
const { buildLiveMatcher } = await import("./live-match.ts");
|
|
507
|
-
|
|
508
|
-
const res = await handleSubscribe(
|
|
509
|
-
subscribeReq(`tag=entity&expand=${mode}`),
|
|
510
|
-
store,
|
|
511
|
-
VAULT,
|
|
512
|
-
unscopedScope(),
|
|
513
|
-
manager,
|
|
514
|
-
);
|
|
515
|
-
expect(res.status).toBe(200);
|
|
516
|
-
const r = sseReader(res);
|
|
517
|
-
await r.pump();
|
|
518
|
-
const snap = r.frames().find((f) => f.event === "snapshot")!;
|
|
519
|
-
const snapIds = new Set<string>(snap.data.notes.map((n: any) => n.id));
|
|
520
|
-
|
|
521
|
-
// The matcher accepts/rejects each note in the vault — compute its set.
|
|
522
|
-
const matcher = await buildLiveMatcher(store, { tags: ["entity"], expand: mode });
|
|
523
|
-
const allNotes = await store.queryNotes({ limit: Number.MAX_SAFE_INTEGER });
|
|
524
|
-
const liveIds = new Set<string>(allNotes.filter((n) => matcher.match(n)).map((n) => n.id));
|
|
525
|
-
|
|
526
|
-
expect(liveIds).toEqual(snapIds);
|
|
527
|
-
await r.close();
|
|
528
|
-
});
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
it("expand=namespace: a live insert filed under entity/ arrives; a subtype-only insert does NOT", async () => {
|
|
532
|
-
await seedTwoAxis();
|
|
533
|
-
// The matcher freezes its tag expansion at subscribe time (mirroring the
|
|
534
|
-
// snapshot query — design "resolved ONCE at subscribe time"). So the tags
|
|
535
|
-
// these live notes carry must already be KNOWN at subscribe time:
|
|
536
|
-
// - entity/inbox: name-prefixed → in the frozen namespace set.
|
|
537
|
-
// - agent: declared subtype of entity, NOT name-prefixed → excluded by
|
|
538
|
-
// namespace mode.
|
|
539
|
-
await store.upsertTagRecord("entity/inbox", {});
|
|
540
|
-
await store.upsertTagRecord("agent", { parent_names: ["entity"] });
|
|
541
|
-
|
|
542
|
-
const res = await handleSubscribe(
|
|
543
|
-
subscribeReq("tag=entity&expand=namespace"),
|
|
544
|
-
store,
|
|
545
|
-
VAULT,
|
|
546
|
-
unscopedScope(),
|
|
547
|
-
manager,
|
|
548
|
-
);
|
|
549
|
-
const r = sseReader(res);
|
|
550
|
-
await r.pump();
|
|
551
|
-
|
|
552
|
-
await store.createNote("new filed", { tags: ["entity/inbox"] }); // name-prefixed → upsert
|
|
553
|
-
await store.createNote("new subtype", { tags: ["agent"] }); // subtype-only → no upsert
|
|
554
|
-
await settle();
|
|
555
|
-
await r.pump();
|
|
556
|
-
|
|
557
|
-
const upserts = r.frames().filter((f) => f.event === "upsert");
|
|
558
|
-
const contents = upserts.map((u) => u.data.note.content);
|
|
559
|
-
expect(contents).toContain("new filed");
|
|
560
|
-
expect(contents).not.toContain("new subtype");
|
|
561
|
-
await r.close();
|
|
562
|
-
});
|
|
563
|
-
|
|
564
|
-
it("invalid expand value → 400 INVALID_QUERY before any stream opens", async () => {
|
|
565
|
-
const res = await handleSubscribe(
|
|
566
|
-
subscribeReq("tag=entity&expand=bogus"),
|
|
567
|
-
store,
|
|
568
|
-
VAULT,
|
|
569
|
-
unscopedScope(),
|
|
570
|
-
manager,
|
|
571
|
-
);
|
|
572
|
-
expect(res.status).toBe(400);
|
|
573
|
-
const body = await res.json();
|
|
574
|
-
expect(body.code).toBe("INVALID_QUERY");
|
|
575
|
-
});
|
|
576
|
-
});
|
|
577
|
-
|
|
578
|
-
describe("handleSubscribe — cap", () => {
|
|
579
|
-
it("over-cap subscribe → 503", async () => {
|
|
580
|
-
const capped = new SubscriptionManager(hooks, { maxPerVault: 1, resolveVault: () => VAULT });
|
|
581
|
-
const r1 = await handleSubscribe(subscribeReq("tag=chat"), store, VAULT, unscopedScope(), capped);
|
|
582
|
-
expect(r1.status).toBe(200);
|
|
583
|
-
const reader1 = sseReader(r1);
|
|
584
|
-
await reader1.pump();
|
|
585
|
-
expect(capped.countForVault(VAULT)).toBe(1);
|
|
586
|
-
|
|
587
|
-
const r2 = await handleSubscribe(subscribeReq("tag=chat"), store, VAULT, unscopedScope(), capped);
|
|
588
|
-
expect(r2.status).toBe(503);
|
|
589
|
-
const body = await r2.json();
|
|
590
|
-
expect(body.code).toBe("SUBSCRIPTION_CAP_REACHED");
|
|
591
|
-
|
|
592
|
-
await reader1.close();
|
|
593
|
-
capped.shutdown();
|
|
594
|
-
});
|
|
595
|
-
|
|
596
|
-
it("closing a stream frees a cap slot", async () => {
|
|
597
|
-
const capped = new SubscriptionManager(hooks, { maxPerVault: 1, resolveVault: () => VAULT });
|
|
598
|
-
const r1 = await handleSubscribe(subscribeReq("tag=chat"), store, VAULT, unscopedScope(), capped);
|
|
599
|
-
const reader1 = sseReader(r1);
|
|
600
|
-
await reader1.pump();
|
|
601
|
-
expect(capped.countForVault(VAULT)).toBe(1);
|
|
602
|
-
|
|
603
|
-
await reader1.close();
|
|
604
|
-
// Cancel propagates to the manager teardown.
|
|
605
|
-
await settle();
|
|
606
|
-
expect(capped.countForVault(VAULT)).toBe(0);
|
|
607
|
-
capped.shutdown();
|
|
608
|
-
});
|
|
609
|
-
});
|
package/src/subscribe.ts
DELETED
|
@@ -1,249 +0,0 @@
|
|
|
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
|
-
SseSink,
|
|
28
|
-
type SubscriptionManager,
|
|
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
|
-
// SSE sink over the stream queue. `send(event, data)` and the `:` keepalive
|
|
164
|
-
// comment both route through this same push → the emitted bytes are identical
|
|
165
|
-
// to the pre-WS-migration SSE contract (surface-client parses them unchanged).
|
|
166
|
-
const push = (frame: string): boolean => {
|
|
167
|
-
if (cancelled) return false;
|
|
168
|
-
queue.push(frame);
|
|
169
|
-
flushQueue();
|
|
170
|
-
return true;
|
|
171
|
-
};
|
|
172
|
-
const sink = new SseSink(push, () => {
|
|
173
|
-
if (cancelled) return;
|
|
174
|
-
cancelled = true;
|
|
175
|
-
if (keepalive) {
|
|
176
|
-
clearInterval(keepalive);
|
|
177
|
-
keepalive = null;
|
|
178
|
-
}
|
|
179
|
-
try {
|
|
180
|
-
controllerRef?.close();
|
|
181
|
-
} catch {
|
|
182
|
-
/* already closed */
|
|
183
|
-
}
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
const stream = new ReadableStream<Uint8Array>({
|
|
187
|
-
start(controller) {
|
|
188
|
-
controllerRef = controller;
|
|
189
|
-
// 1. Snapshot first — written into the queue and flushed on this tick.
|
|
190
|
-
queue.push(snapshotFrame(snapshotNotes));
|
|
191
|
-
|
|
192
|
-
// 2. Register the live subscription. Hook dispatch is deferred to a
|
|
193
|
-
// microtask, and we register synchronously here within start(), so no
|
|
194
|
-
// live event can slip in front of the snapshot. Over-cap → 503; we
|
|
195
|
-
// can't return a Response from inside the stream, so the cap is also
|
|
196
|
-
// checked below before constructing the Response. (Re-check here for
|
|
197
|
-
// the race where two subscribes interleave.)
|
|
198
|
-
handle = manager.register({
|
|
199
|
-
vaultName,
|
|
200
|
-
matcher,
|
|
201
|
-
tagScopeAllowed: tagScope.allowed,
|
|
202
|
-
tagScopeRaw: tagScope.raw,
|
|
203
|
-
sink,
|
|
204
|
-
});
|
|
205
|
-
if (!handle) {
|
|
206
|
-
// Lost the cap race. Emit nothing further and close; the pre-check
|
|
207
|
-
// below normally catches this, so this path is rare.
|
|
208
|
-
flushQueue();
|
|
209
|
-
try {
|
|
210
|
-
controller.close();
|
|
211
|
-
} catch {
|
|
212
|
-
/* noop */
|
|
213
|
-
}
|
|
214
|
-
cancelled = true;
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
flushQueue();
|
|
219
|
-
|
|
220
|
-
// 3. Keepalive comments.
|
|
221
|
-
keepalive = setInterval(() => {
|
|
222
|
-
if (cancelled) return;
|
|
223
|
-
sink.comment(); // `:\n\n` — byte-identical keepalive comment.
|
|
224
|
-
}, KEEPALIVE_MS);
|
|
225
|
-
},
|
|
226
|
-
pull() {
|
|
227
|
-
flushQueue();
|
|
228
|
-
},
|
|
229
|
-
cancel() {
|
|
230
|
-
cancelled = true;
|
|
231
|
-
if (keepalive) {
|
|
232
|
-
clearInterval(keepalive);
|
|
233
|
-
keepalive = null;
|
|
234
|
-
}
|
|
235
|
-
handle?.close();
|
|
236
|
-
},
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
return new Response(stream, {
|
|
240
|
-
status: 200,
|
|
241
|
-
headers: {
|
|
242
|
-
"Content-Type": "text/event-stream",
|
|
243
|
-
"Cache-Control": "no-cache, no-transform",
|
|
244
|
-
Connection: "keep-alive",
|
|
245
|
-
// Defeat nginx-style proxy buffering of the event stream.
|
|
246
|
-
"X-Accel-Buffering": "no",
|
|
247
|
-
},
|
|
248
|
-
});
|
|
249
|
-
}
|