@openparachute/vault 0.6.5-rc.12 → 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.
@@ -12,6 +12,7 @@
12
12
  "focus": "core",
13
13
  "adminCapabilities": ["config", "credentials"],
14
14
  "startCmd": ["parachute-vault", "serve"],
15
+ "websocket": true,
15
16
  "scopes": {
16
17
  "defines": ["vault:read", "vault:write", "vault:admin"]
17
18
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.6.5-rc.12",
3
+ "version": "0.6.5-rc.14",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Live-query frame PARITY + the pure WS helpers (WS-hibernation migration,
3
+ * self-host door).
4
+ *
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 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.)
14
+ *
15
+ * Plus unit coverage of the pure helpers that back the Bun.serve integration:
16
+ * snapshot chunking, first-message parsing, verb-rank, and the query rejects.
17
+ */
18
+ import { describe, it, expect } from "bun:test";
19
+ import { wsFrame, WsSink } from "./subscriptions.ts";
20
+ import {
21
+ buildSnapshotFrames,
22
+ parseClientMessage,
23
+ vaultVerbRank,
24
+ sameTagScope,
25
+ validateWsSubscribeQuery,
26
+ subscriptionCapResponse,
27
+ urlFromQuery,
28
+ SNAPSHOT_CHUNK_MAX_NOTES,
29
+ WS_CLOSE,
30
+ } from "./ws-subscribe.ts";
31
+ import { unsupportedSubscriptionReason } from "./live-match.ts";
32
+ import { FRAME_CORPUS, NOTE_A } from "./test-support/live-frame-corpus.ts";
33
+
34
+ describe("live-frame parity — self-host WS bytes === corpus === cloud WS bytes", () => {
35
+ for (const c of FRAME_CORPUS) {
36
+ it(`${c.name}: WS message folds the event into \`type\` over the corpus payload`, () => {
37
+ const ws = wsFrame(c.event, c.data);
38
+
39
+ // WS message is `{ type, ...data }` — the event name folds into `type`.
40
+ const parsedWs = JSON.parse(ws) as Record<string, unknown>;
41
+ expect(parsedWs.type).toBe(c.event);
42
+ const { type, ...rest } = parsedWs;
43
+ void type;
44
+ expect(rest).toEqual(c.data);
45
+
46
+ // BYTE parity of the inner payload: whatever value the event carries
47
+ // (`notes` / `note` / `id`) serializes IDENTICALLY to the corpus
48
+ // serialization (the cross-door invariant).
49
+ const innerKey = c.event === "snapshot" ? "notes" : c.event === "upsert" ? "note" : "id";
50
+ const innerBytes = JSON.stringify((c.data as Record<string, unknown>)[innerKey]);
51
+ expect(ws.includes(innerBytes)).toBe(true);
52
+ });
53
+ }
54
+ });
55
+
56
+ describe("sink classes render through the ONE formatter", () => {
57
+ it("WsSink.send emits exactly wsFrame(event, data)", () => {
58
+ const sent: string[] = [];
59
+ const sink = new WsSink({ send: (d: string) => sent.push(d), close: () => {} });
60
+ for (const c of FRAME_CORPUS) {
61
+ sink.send(c.event, c.data);
62
+ expect(sent.at(-1)).toBe(wsFrame(c.event, c.data));
63
+ }
64
+ });
65
+ });
66
+
67
+ describe("buildSnapshotFrames — chunking + done flag", () => {
68
+ it("empty set → exactly one done:true frame", () => {
69
+ const frames = buildSnapshotFrames([]);
70
+ expect(frames.length).toBe(1);
71
+ expect(JSON.parse(frames[0]!)).toEqual({ type: "snapshot", notes: [], done: true });
72
+ });
73
+
74
+ it("small set → one done:true frame carrying every note", () => {
75
+ const frames = buildSnapshotFrames([NOTE_A as any, NOTE_A as any]);
76
+ expect(frames.length).toBe(1);
77
+ const f = JSON.parse(frames[0]!);
78
+ expect(f.done).toBe(true);
79
+ expect(f.notes.length).toBe(2);
80
+ });
81
+
82
+ it("> SNAPSHOT_CHUNK_MAX_NOTES → multiple frames, only the last done:true, concat = full set", () => {
83
+ const many = Array.from({ length: SNAPSHOT_CHUNK_MAX_NOTES + 5 }, (_, i) => ({ ...NOTE_A, id: `n-${i}` }));
84
+ const frames = buildSnapshotFrames(many as any);
85
+ expect(frames.length).toBeGreaterThan(1);
86
+ const parsed = frames.map((f) => JSON.parse(f));
87
+ expect(parsed.filter((p) => p.done).length).toBe(1);
88
+ expect(parsed[parsed.length - 1]!.done).toBe(true);
89
+ parsed.slice(0, -1).forEach((p) => expect(p.done).toBe(false));
90
+ const concat = parsed.flatMap((p) => p.notes);
91
+ expect(concat.map((n: any) => n.id)).toEqual(many.map((n) => n.id));
92
+ });
93
+
94
+ it("each frame stays under the WS message ceiling with large notes", () => {
95
+ const big = Array.from({ length: 8 }, (_, i) => ({ ...NOTE_A, id: `big-${i}`, content: "x".repeat(200_000) }));
96
+ const frames = buildSnapshotFrames(big as any);
97
+ expect(frames.length).toBeGreaterThan(1);
98
+ for (const f of frames) expect(new TextEncoder().encode(f).byteLength).toBeLessThan(1_000_000);
99
+ expect(frames.flatMap((f) => JSON.parse(f).notes).length).toBe(8);
100
+ });
101
+ });
102
+
103
+ describe("parseClientMessage", () => {
104
+ it("valid auth", () => {
105
+ expect(parseClientMessage(JSON.stringify({ type: "auth", token: "t" }))).toEqual({ kind: "auth", token: "t" });
106
+ });
107
+ it("auth without a token → malformed", () => {
108
+ expect(parseClientMessage(JSON.stringify({ type: "auth" })).kind).toBe("malformed");
109
+ expect(parseClientMessage(JSON.stringify({ type: "auth", token: "" })).kind).toBe("malformed");
110
+ });
111
+ it("non-json / non-object → malformed", () => {
112
+ expect(parseClientMessage("not json").kind).toBe("malformed");
113
+ expect(parseClientMessage(JSON.stringify([1, 2])).kind).toBe("malformed");
114
+ });
115
+ it("other typed message → other", () => {
116
+ expect(parseClientMessage(JSON.stringify({ type: "hello" }))).toEqual({ kind: "other", type: "hello" });
117
+ });
118
+ it("decodes a Uint8Array frame", () => {
119
+ const bytes = new TextEncoder().encode(JSON.stringify({ type: "auth", token: "b" }));
120
+ expect(parseClientMessage(bytes)).toEqual({ kind: "auth", token: "b" });
121
+ });
122
+ });
123
+
124
+ describe("vaultVerbRank — narrow-or-equal ordering", () => {
125
+ it("ranks read < write < admin, -1 for none/other-vault", () => {
126
+ expect(vaultVerbRank(["vault:v:read"], "v")).toBe(0);
127
+ expect(vaultVerbRank(["vault:v:write"], "v")).toBe(1);
128
+ expect(vaultVerbRank(["vault:v:admin"], "v")).toBe(2);
129
+ expect(vaultVerbRank(["vault:other:read"], "v")).toBe(-1);
130
+ expect(vaultVerbRank([], "v")).toBe(-1);
131
+ });
132
+ it("a widen is strictly greater (the 4403 trigger)", () => {
133
+ expect(vaultVerbRank(["vault:v:admin"], "v") > vaultVerbRank(["vault:v:read"], "v")).toBe(true);
134
+ expect(vaultVerbRank(["vault:v:read"], "v") > vaultVerbRank(["vault:v:admin"], "v")).toBe(false);
135
+ });
136
+ });
137
+
138
+ describe("sameTagScope — the WS re-auth tag-scope-delta gate", () => {
139
+ it("both unscoped (null) → same", () => {
140
+ expect(sameTagScope(null, null)).toBe(true);
141
+ });
142
+ it("null vs scoped → differ (a drop/gain of scoping)", () => {
143
+ expect(sameTagScope(null, ["work/eng"])).toBe(false);
144
+ expect(sameTagScope(["work/eng"], null)).toBe(false);
145
+ });
146
+ it("same set (order/dupe-insensitive) → same", () => {
147
+ expect(sameTagScope(["a", "b"], ["b", "a"])).toBe(true);
148
+ expect(sameTagScope(["a", "a", "b"], ["b", "a"])).toBe(true);
149
+ });
150
+ it("different membership → differ", () => {
151
+ expect(sameTagScope(["work/eng"], ["work/sales"])).toBe(false);
152
+ expect(sameTagScope(["a"], ["a", "b"])).toBe(false);
153
+ });
154
+ });
155
+
156
+ describe("validateWsSubscribeQuery — same rejects as the SSE route (byte-identical bodies)", () => {
157
+ const reject = async (q: string) => {
158
+ const v = validateWsSubscribeQuery(new URL(`http://x/vault/v/api/subscribe?${q}`));
159
+ expect("error" in v).toBe(true);
160
+ const body = await (v as { error: Response }).error.json();
161
+ expect((v as { error: Response }).error.status).toBe(400);
162
+ expect(body.code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
163
+ };
164
+ it("rejects the URL-reachable unsupported shapes: search / near / cursor / has_links", async () => {
165
+ await reject("search=hi");
166
+ await reject("near[note_id]=abc");
167
+ await reject("cursor=xyz");
168
+ await reject("has_links=true");
169
+ });
170
+ it("accepts a supported query", () => {
171
+ const v = validateWsSubscribeQuery(new URL("http://x/vault/v/api/subscribe?tag=chat&path_prefix=meetings/"));
172
+ expect("queryOpts" in v).toBe(true);
173
+ });
174
+ it("shares the SSE route's queryOpts-level guard (cursor/has_links/date filters)", () => {
175
+ // The belt-and-suspenders layer both doors + the SSE route route through:
176
+ // a date filter isn't expressible as a flat URL param (removed 0.6.4), but
177
+ // if one ever reaches queryOpts it is rejected identically.
178
+ expect(unsupportedSubscriptionReason({ cursor: "x" } as any)).toBeTruthy();
179
+ expect(unsupportedSubscriptionReason({ hasLinks: true } as any)).toBeTruthy();
180
+ expect(unsupportedSubscriptionReason({ dateFrom: "2026-01-01" } as any)).toBeTruthy();
181
+ expect(unsupportedSubscriptionReason({ dateFilter: { field: "created_at" } } as any)).toBeTruthy();
182
+ expect(unsupportedSubscriptionReason({ tags: ["chat"] } as any)).toBeNull();
183
+ });
184
+ });
185
+
186
+ describe("misc pure helpers", () => {
187
+ it("subscriptionCapResponse → 503 SUBSCRIPTION_CAP_REACHED", async () => {
188
+ const res = subscriptionCapResponse();
189
+ expect(res.status).toBe(503);
190
+ expect((await res.json()).code).toBe("SUBSCRIPTION_CAP_REACHED");
191
+ });
192
+ it("urlFromQuery reconstructs searchParams from a raw query string", () => {
193
+ expect(urlFromQuery("?tag=chat&a=b").searchParams.get("tag")).toBe("chat");
194
+ expect(urlFromQuery("").search).toBe("");
195
+ });
196
+ it("WS_CLOSE carries the four application close codes", () => {
197
+ expect([WS_CLOSE.PROTOCOL, WS_CLOSE.UNAUTHORIZED, WS_CLOSE.FORBIDDEN, WS_CLOSE.AUTH_TIMEOUT]).toEqual([
198
+ 4400, 4401, 4403, 4408,
199
+ ]);
200
+ });
201
+ });
@@ -48,6 +48,14 @@ export interface VaultModuleManifest {
48
48
  readonly paths: readonly string[];
49
49
  readonly health: string;
50
50
  readonly stripPrefix?: boolean;
51
+ /**
52
+ * When `true`, vault's daemon accepts WebSocket upgrades (the live-query WS
53
+ * binding) and the hub's ws-bridge forwards `Upgrade: websocket` requests on
54
+ * vault's mounts. DENY BY DEFAULT on the hub side. Carried onto the
55
+ * self-registered services.json row too (`ServiceEntry.websocket`); the hub
56
+ * honors either source. See `parachute-hub/src/ws-bridge.ts`.
57
+ */
58
+ readonly websocket?: boolean;
51
59
  }
52
60
 
53
61
  /**
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 subscription (design 2026-06-08). Snapshot + scoped live
887
- // upsert/remove events over text/event-stream. Auth + tag-scope already
888
- // resolved above and threaded through, mirroring the /notes branch.
889
- if (apiPath === "/subscribe") return handleSubscribe(req, store, vaultName, tagScope);
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") {
@@ -382,6 +382,25 @@ describe("self-register", () => {
382
382
  });
383
383
  });
384
384
 
385
+ test("websocket capability flows from manifest to row when set (hub ws-bridge gate)", () => {
386
+ withParachuteHome((home) => {
387
+ const { log, warn } = captureLogs();
388
+ selfRegister({
389
+ version: "0.4.8-rc.3",
390
+ log,
391
+ warn,
392
+ readManifest: () => ({ ...TEST_MANIFEST, websocket: true }),
393
+ resolvePackageRoot: () => "/fake/install/dir",
394
+ listVaults: () => [],
395
+ readGlobalConfig: () => ({ port: 1940 }),
396
+ });
397
+
398
+ const raw = readFileSync(join(home, "services.json"), "utf8");
399
+ const parsed = JSON.parse(raw) as { services: Record<string, unknown>[] };
400
+ expect(parsed.services[0]!.websocket).toBe(true);
401
+ });
402
+ });
403
+
385
404
  test("changed=true when installDir differs from prior row", () => {
386
405
  withParachuteHome(() => {
387
406
  const { log, warn } = captureLogs();
@@ -191,6 +191,7 @@ export function selfRegister(deps: SelfRegisterDeps): SelfRegisterResult {
191
191
  displayName?: string;
192
192
  tagline?: string;
193
193
  stripPrefix?: boolean;
194
+ websocket?: boolean;
194
195
  } = {
195
196
  name: manifest.manifestName,
196
197
  port,
@@ -202,6 +203,9 @@ export function selfRegister(deps: SelfRegisterDeps): SelfRegisterResult {
202
203
  if (manifest.displayName !== undefined) entry.displayName = manifest.displayName;
203
204
  if (manifest.tagline !== undefined) entry.tagline = manifest.tagline;
204
205
  if (manifest.stripPrefix !== undefined) entry.stripPrefix = manifest.stripPrefix;
206
+ // Carry the live-query WS capability onto the row so the hub ws-bridge admits
207
+ // upgrades without having to read module.json (the row is its first source).
208
+ if (manifest.websocket !== undefined) entry.websocket = manifest.websocket;
205
209
 
206
210
  // Detect whether the existing row already matches (no-op idempotency
207
211
  // signal). We don't gate the write on this — `upsertService` itself is
@@ -232,7 +236,8 @@ export function selfRegister(deps: SelfRegisterDeps): SelfRegisterResult {
232
236
  priorRow.health !== health ||
233
237
  (priorRow as { displayName?: string }).displayName !== manifest.displayName ||
234
238
  (priorRow as { tagline?: string }).tagline !== manifest.tagline ||
235
- (priorRow as { stripPrefix?: boolean }).stripPrefix !== manifest.stripPrefix;
239
+ (priorRow as { stripPrefix?: boolean }).stripPrefix !== manifest.stripPrefix ||
240
+ (priorRow as { websocket?: boolean }).websocket !== manifest.websocket;
236
241
 
237
242
  try {
238
243
  upsertImpl(entry);
package/src/server.ts CHANGED
@@ -61,6 +61,7 @@ import {
61
61
  } from "./mirror-config.ts";
62
62
  import { GLOBAL_CONFIG_PATH } from "./config.ts";
63
63
  import { selfRegister } from "./self-register.ts";
64
+ import { createSubscribeWsBinding, isWebSocketUpgrade } from "./ws-server.ts";
64
65
  import { warnLegacyGlobalApiKeys } from "./auth.ts";
65
66
  import pkg from "../package.json" with { type: "json" };
66
67
 
@@ -490,10 +491,18 @@ const hostname = resolveBindHostname();
490
491
  }
491
492
  }
492
493
 
494
+ // Live-query WebSocket binding (WS-hibernation migration Phase 3). Same
495
+ // subscription contract as the SSE route, WebSocket transport. Handlers +
496
+ // upgrade decision live in ws-server.ts; the per-vault WS cap is closure state
497
+ // there. Advertised via `"websocket": true` in `.parachute/module.json` so the
498
+ // hub ws-bridge admits the upgrade.
499
+ const subscribeWs = createSubscribeWsBinding();
500
+
493
501
  const server = Bun.serve({
494
502
  port,
495
503
  hostname,
496
504
  idleTimeout: 120, // seconds — webhook triggers can take a while
505
+ websocket: subscribeWs.handlers,
497
506
  async fetch(req, server) {
498
507
  const url = new URL(req.url);
499
508
  const path = url.pathname;
@@ -517,6 +526,22 @@ const server = Bun.serve({
517
526
  return new Response(null, { status: 204, headers: corsHeaders });
518
527
  }
519
528
 
529
+ // Live-query WebSocket upgrade — detected BEFORE the fetch pipeline because
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.
533
+ if (isWebSocketUpgrade(req)) {
534
+ const verdict = subscribeWs.tryUpgrade(req, server, path);
535
+ if (verdict.kind === "upgraded") return undefined; // Bun owns the socket now
536
+ if (verdict.kind === "response") {
537
+ for (const [k, v] of Object.entries(corsHeaders)) {
538
+ verdict.response.headers.set(k, v);
539
+ }
540
+ return verdict.response;
541
+ }
542
+ // "pass" → not a subscribe WS upgrade; fall through to the pipeline.
543
+ }
544
+
520
545
  try {
521
546
  const start = Date.now();
522
547
  const response = await route(req, path);
@@ -15,6 +15,14 @@ export interface ServiceEntry {
15
15
  paths: string[];
16
16
  health: string;
17
17
  version: string;
18
+ /**
19
+ * When `true`, the hub's ws-bridge forwards `Upgrade: websocket` requests on
20
+ * this module's mounts to it (deny-by-default). Carried from the module's
21
+ * `.parachute/module.json` `websocket` field by self-registration; the hub
22
+ * honors either source. Optional + free-riding — extra fields survive the
23
+ * read/merge via the `...e` spread in `validateEntry` / `upsertService`.
24
+ */
25
+ websocket?: boolean;
18
26
  }
19
27
 
20
28
  export interface ServicesManifest {
@@ -1,6 +1,8 @@
1
1
  /**
2
- * Live-query subscription registry (live-query SSE design
3
- * `design/2026-06-08-live-query-sse.md`).
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,6 +10,24 @@
8
10
  * connection-scoped, driving a live UI. Same event source, same predicate,
9
11
  * different durability.
10
12
  *
13
+ * ## The live transport: WebSocket (WS-hibernation migration)
14
+ *
15
+ * The manager is transport-agnostic: it emits abstract `(event, data)` tuples
16
+ * and a {@link SubscriptionSink} renders them for its wire.
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`.
22
+ *
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`.
30
+ *
11
31
  * ## How fan-out works
12
32
  *
13
33
  * All vault stores share the process-wide `defaultHookRegistry`
@@ -54,16 +74,19 @@ export const DEFAULT_MAX_SUBSCRIPTIONS_PER_VAULT = 100;
54
74
  * Default bound on a single subscription's pending (unflushed) event buffer.
55
75
  * If a slow client lets the buffer grow past this, the stream is closed — it
56
76
  * reconnects and re-snapshots rather than the server growing memory unbounded.
77
+ * Only enforced for flush-tracking sinks (SSE); the WS transport delegates
78
+ * outgoing-buffer bounds to the Bun runtime.
57
79
  */
58
80
  export const DEFAULT_MAX_BUFFERED_EVENTS = 1000;
59
81
 
60
- /** A serialized SSE frame ready to write to the wire. */
61
- type SseFrame = string;
62
-
82
+ /**
83
+ * The abstract sink an event is rendered to. The manager calls `send(event,
84
+ * data)`; each transport formats for its wire. Returns `false` when the sink is
85
+ * gone (the manager then drops the subscription).
86
+ */
63
87
  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. */
88
+ send(event: string, data: unknown): boolean;
89
+ /** Close the underlying stream/socket (teardown). Idempotent. */
67
90
  close(): void;
68
91
  }
69
92
 
@@ -75,14 +98,56 @@ interface Subscription {
75
98
  /** Raw root-tag allowlist (null = unscoped) — `noteWithinTagScope` arg. */
76
99
  readonly tagScopeRaw: string[] | null;
77
100
  readonly sink: SubscriptionSink;
78
- /** Pending unflushed frame count (backpressure bound). */
101
+ /** SSE tracks unflushed frames to bound memory; WS delegates to the runtime. */
102
+ readonly tracksFlush: boolean;
103
+ /** Whether this sub counts against the per-vault manager cap (SSE) — the WS
104
+ * transport enforces its own cap via the live-socket count at upgrade. */
105
+ readonly countsTowardCap: boolean;
106
+ /** Pending unflushed frame count (backpressure bound; SSE only). */
79
107
  buffered: number;
80
108
  readonly maxBuffered: number;
81
109
  closed: boolean;
82
110
  }
83
111
 
84
- function sseEvent(event: string, data: unknown): SseFrame {
85
- return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
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. */
115
+ export function wsFrame(event: string, data: unknown): string {
116
+ const body = data && typeof data === "object" && !Array.isArray(data) ? (data as Record<string, unknown>) : {};
117
+ return JSON.stringify({ type: event, ...body });
118
+ }
119
+
120
+ /** Minimal structural shape of a Bun `ServerWebSocket` (or a test double) that
121
+ * {@link WsSink} needs — keeps this module free of a hard Bun-server import. */
122
+ export interface WsLike {
123
+ send(data: string): unknown;
124
+ close(code?: number, reason?: string): unknown;
125
+ }
126
+
127
+ /** WebSocket sink: renders `(event, data)` to a `{ type, ...data }` message and
128
+ * sends it on the socket. `sendRaw` is for pre-formatted frames (the chunked
129
+ * snapshot the WS binding builds directly). A send on a dead socket returns
130
+ * false → the manager drops the sub. */
131
+ export class WsSink implements SubscriptionSink {
132
+ constructor(private readonly ws: WsLike) {}
133
+ send(event: string, data: unknown): boolean {
134
+ return this.sendRaw(wsFrame(event, data));
135
+ }
136
+ sendRaw(frame: string): boolean {
137
+ try {
138
+ this.ws.send(frame);
139
+ return true;
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+ close(): void {
145
+ try {
146
+ this.ws.close(1011, "subscription closed");
147
+ } catch {
148
+ /* already closing/closed */
149
+ }
150
+ }
86
151
  }
87
152
 
88
153
  export class SubscriptionManager {
@@ -140,9 +205,15 @@ export class SubscriptionManager {
140
205
  tagScopeRaw: string[] | null;
141
206
  sink: SubscriptionSink;
142
207
  maxBuffered?: number;
208
+ /** SSE (default true) tracks unflushed frames; WS passes false. */
209
+ tracksFlush?: boolean;
210
+ /** SSE (default true) counts against the per-vault manager cap; WS passes
211
+ * false — the WS transport caps via the live-socket count at upgrade. */
212
+ countsTowardCap?: boolean;
143
213
  }): SubscriptionHandle | null {
214
+ const countsTowardCap = args.countsTowardCap ?? true;
144
215
  const current = this.countForVault(args.vaultName);
145
- if (current >= this.maxPerVault) return null;
216
+ if (countsTowardCap && current >= this.maxPerVault) return null;
146
217
 
147
218
  this.ensureHooks();
148
219
 
@@ -152,12 +223,14 @@ export class SubscriptionManager {
152
223
  tagScopeAllowed: args.tagScopeAllowed,
153
224
  tagScopeRaw: args.tagScopeRaw,
154
225
  sink: args.sink,
226
+ tracksFlush: args.tracksFlush ?? true,
227
+ countsTowardCap,
155
228
  buffered: 0,
156
229
  maxBuffered: args.maxBuffered ?? DEFAULT_MAX_BUFFERED_EVENTS,
157
230
  closed: false,
158
231
  };
159
232
  this.subs.add(sub);
160
- this.perVaultCount.set(args.vaultName, current + 1);
233
+ if (countsTowardCap) this.perVaultCount.set(args.vaultName, current + 1);
161
234
 
162
235
  return {
163
236
  /** Note that a previously-buffered frame flushed to the wire. */
@@ -172,9 +245,11 @@ export class SubscriptionManager {
172
245
  if (sub.closed) return;
173
246
  sub.closed = true;
174
247
  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);
248
+ if (sub.countsTowardCap) {
249
+ const n = this.perVaultCount.get(sub.vaultName) ?? 0;
250
+ if (n <= 1) this.perVaultCount.delete(sub.vaultName);
251
+ else this.perVaultCount.set(sub.vaultName, n - 1);
252
+ }
178
253
  try {
179
254
  sub.sink.close();
180
255
  } catch {
@@ -182,33 +257,21 @@ export class SubscriptionManager {
182
257
  }
183
258
  }
184
259
 
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 {
260
+ /** Emit an `(event, data)` tuple to one subscription, enforcing the buffer
261
+ * bound for flush-tracking (SSE) sinks. */
262
+ private emit(sub: Subscription, event: string, data: unknown): void {
200
263
  if (sub.closed) return;
201
- if (sub.buffered >= sub.maxBuffered) {
264
+ if (sub.tracksFlush && sub.buffered >= sub.maxBuffered) {
202
265
  // Backpressure: client can't keep up. Close → it reconnects + re-snapshots.
203
266
  this.remove(sub);
204
267
  return;
205
268
  }
206
- const ok = sub.sink.write(frame);
269
+ const ok = sub.sink.send(event, data);
207
270
  if (!ok) {
208
271
  this.remove(sub);
209
272
  return;
210
273
  }
211
- sub.buffered++;
274
+ if (sub.tracksFlush) sub.buffered++;
212
275
  }
213
276
 
214
277
  /** Lazily register the three broad hooks (once per manager). */
@@ -244,7 +307,7 @@ export class SubscriptionManager {
244
307
  // the client ignores ids it never held. Documented low-sensitivity
245
308
  // existence leak (see design §scope-intersection).
246
309
  const ref = payload as DeletedNoteRef;
247
- this.emit(sub, sseEvent("remove", { id: ref.id }));
310
+ this.emit(sub, "remove", { id: ref.id });
248
311
  continue;
249
312
  }
250
313
 
@@ -257,14 +320,14 @@ export class SubscriptionManager {
257
320
  const matches = sub.matcher.match(note) && inScope;
258
321
 
259
322
  if (matches) {
260
- this.emit(sub, sseEvent("upsert", { note }));
323
+ this.emit(sub, "upsert", { note });
261
324
  } else if (event === "updated" && inScope) {
262
325
  // Left the set (predicate no longer true) BUT still within this
263
326
  // token's scope, so the sub could have held it — idempotent remove
264
327
  // (client drops the id if held, no-op otherwise). When the note is
265
328
  // OUT of scope the sub never had it; emitting would leak its id, so
266
329
  // we stay silent.
267
- this.emit(sub, sseEvent("remove", { id: note.id }));
330
+ this.emit(sub, "remove", { id: note.id });
268
331
  }
269
332
  // created that doesn't match → nothing (it was never in the set).
270
333
  }
@@ -286,10 +349,5 @@ export interface SubscriptionHandle {
286
349
  close: () => void;
287
350
  }
288
351
 
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
352
  /** Process-wide manager — shared like `defaultHookRegistry`. */
295
353
  export const subscriptionManager = new SubscriptionManager();