@openparachute/vault 0.6.5-rc.12 → 0.6.5-rc.13

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.
@@ -8,6 +8,25 @@
8
8
  * connection-scoped, driving a live UI. Same event source, same predicate,
9
9
  * different durability.
10
10
  *
11
+ * ## Two transports, one contract (WS-hibernation migration, 2026-07-04)
12
+ *
13
+ * The manager is transport-agnostic: it emits abstract `(event, data)` tuples
14
+ * and a {@link SubscriptionSink} renders them for its wire.
15
+ * - {@link SseSink} renders `event:`/`data:` frames — bytes UNCHANGED from the
16
+ * original SSE contract (surface-client parses them identically).
17
+ * - {@link WsSink} renders a WebSocket message `{ type: event, ...data }` — the
18
+ * SSE `event:` name folds into a `type` discriminator; the inner payload
19
+ * (`note` / `id` / `notes`) serializes byte-for-byte identically to the SSE
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`.
24
+ *
25
+ * Both transports share ONE process-wide {@link SubscriptionManager}. Self-host
26
+ * has no hibernation (a self-run Bun box has no per-connection duration bill) —
27
+ * the WS binding adds bidirectionality + contract-congruence with cloud, nothing
28
+ * more. The Bun.serve WS integration lives in `ws-server.ts` + `ws-subscribe.ts`.
29
+ *
11
30
  * ## How fan-out works
12
31
  *
13
32
  * All vault stores share the process-wide `defaultHookRegistry`
@@ -54,16 +73,19 @@ export const DEFAULT_MAX_SUBSCRIPTIONS_PER_VAULT = 100;
54
73
  * Default bound on a single subscription's pending (unflushed) event buffer.
55
74
  * If a slow client lets the buffer grow past this, the stream is closed — it
56
75
  * reconnects and re-snapshots rather than the server growing memory unbounded.
76
+ * Only enforced for flush-tracking sinks (SSE); the WS transport delegates
77
+ * outgoing-buffer bounds to the Bun runtime.
57
78
  */
58
79
  export const DEFAULT_MAX_BUFFERED_EVENTS = 1000;
59
80
 
60
- /** A serialized SSE frame ready to write to the wire. */
61
- type SseFrame = string;
62
-
81
+ /**
82
+ * The abstract sink an event is rendered to. The manager calls `send(event,
83
+ * data)`; each transport formats for its wire. Returns `false` when the sink is
84
+ * gone (the manager then drops the subscription).
85
+ */
63
86
  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. */
87
+ send(event: string, data: unknown): boolean;
88
+ /** Close the underlying stream/socket (teardown). Idempotent. */
67
89
  close(): void;
68
90
  }
69
91
 
@@ -75,16 +97,84 @@ interface Subscription {
75
97
  /** Raw root-tag allowlist (null = unscoped) — `noteWithinTagScope` arg. */
76
98
  readonly tagScopeRaw: string[] | null;
77
99
  readonly sink: SubscriptionSink;
78
- /** Pending unflushed frame count (backpressure bound). */
100
+ /** SSE tracks unflushed frames to bound memory; WS delegates to the runtime. */
101
+ readonly tracksFlush: boolean;
102
+ /** Whether this sub counts against the per-vault manager cap (SSE) — the WS
103
+ * transport enforces its own cap via the live-socket count at upgrade. */
104
+ readonly countsTowardCap: boolean;
105
+ /** Pending unflushed frame count (backpressure bound; SSE only). */
79
106
  buffered: number;
80
107
  readonly maxBuffered: number;
81
108
  closed: boolean;
82
109
  }
83
110
 
84
- function sseEvent(event: string, data: unknown): SseFrame {
111
+ /** SSE wire frame: `event: <name>\ndata: <json>\n\n`. The ONE place SSE bytes
112
+ * are formatted (route + tests + SseSink all route through it). */
113
+ export function sseFrame(event: string, data: unknown): string {
85
114
  return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
86
115
  }
87
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. */
120
+ export function wsFrame(event: string, data: unknown): string {
121
+ const body = data && typeof data === "object" && !Array.isArray(data) ? (data as Record<string, unknown>) : {};
122
+ return JSON.stringify({ type: event, ...body });
123
+ }
124
+
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
+ /** Minimal structural shape of a Bun `ServerWebSocket` (or a test double) that
146
+ * {@link WsSink} needs — keeps this module free of a hard Bun-server import. */
147
+ export interface WsLike {
148
+ send(data: string): unknown;
149
+ close(code?: number, reason?: string): unknown;
150
+ }
151
+
152
+ /** WebSocket sink: renders `(event, data)` to a `{ type, ...data }` message and
153
+ * sends it on the socket. `sendRaw` is for pre-formatted frames (the chunked
154
+ * snapshot the WS binding builds directly). A send on a dead socket returns
155
+ * false → the manager drops the sub. */
156
+ export class WsSink implements SubscriptionSink {
157
+ constructor(private readonly ws: WsLike) {}
158
+ send(event: string, data: unknown): boolean {
159
+ return this.sendRaw(wsFrame(event, data));
160
+ }
161
+ sendRaw(frame: string): boolean {
162
+ try {
163
+ this.ws.send(frame);
164
+ return true;
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+ close(): void {
170
+ try {
171
+ this.ws.close(1011, "subscription closed");
172
+ } catch {
173
+ /* already closing/closed */
174
+ }
175
+ }
176
+ }
177
+
88
178
  export class SubscriptionManager {
89
179
  private subs = new Set<Subscription>();
90
180
  private perVaultCount = new Map<string, number>();
@@ -140,9 +230,15 @@ export class SubscriptionManager {
140
230
  tagScopeRaw: string[] | null;
141
231
  sink: SubscriptionSink;
142
232
  maxBuffered?: number;
233
+ /** SSE (default true) tracks unflushed frames; WS passes false. */
234
+ tracksFlush?: boolean;
235
+ /** SSE (default true) counts against the per-vault manager cap; WS passes
236
+ * false — the WS transport caps via the live-socket count at upgrade. */
237
+ countsTowardCap?: boolean;
143
238
  }): SubscriptionHandle | null {
239
+ const countsTowardCap = args.countsTowardCap ?? true;
144
240
  const current = this.countForVault(args.vaultName);
145
- if (current >= this.maxPerVault) return null;
241
+ if (countsTowardCap && current >= this.maxPerVault) return null;
146
242
 
147
243
  this.ensureHooks();
148
244
 
@@ -152,12 +248,14 @@ export class SubscriptionManager {
152
248
  tagScopeAllowed: args.tagScopeAllowed,
153
249
  tagScopeRaw: args.tagScopeRaw,
154
250
  sink: args.sink,
251
+ tracksFlush: args.tracksFlush ?? true,
252
+ countsTowardCap,
155
253
  buffered: 0,
156
254
  maxBuffered: args.maxBuffered ?? DEFAULT_MAX_BUFFERED_EVENTS,
157
255
  closed: false,
158
256
  };
159
257
  this.subs.add(sub);
160
- this.perVaultCount.set(args.vaultName, current + 1);
258
+ if (countsTowardCap) this.perVaultCount.set(args.vaultName, current + 1);
161
259
 
162
260
  return {
163
261
  /** Note that a previously-buffered frame flushed to the wire. */
@@ -172,9 +270,11 @@ export class SubscriptionManager {
172
270
  if (sub.closed) return;
173
271
  sub.closed = true;
174
272
  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);
273
+ if (sub.countsTowardCap) {
274
+ const n = this.perVaultCount.get(sub.vaultName) ?? 0;
275
+ if (n <= 1) this.perVaultCount.delete(sub.vaultName);
276
+ else this.perVaultCount.set(sub.vaultName, n - 1);
277
+ }
178
278
  try {
179
279
  sub.sink.close();
180
280
  } catch {
@@ -182,33 +282,21 @@ export class SubscriptionManager {
182
282
  }
183
283
  }
184
284
 
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 {
285
+ /** Emit an `(event, data)` tuple to one subscription, enforcing the buffer
286
+ * bound for flush-tracking (SSE) sinks. */
287
+ private emit(sub: Subscription, event: string, data: unknown): void {
200
288
  if (sub.closed) return;
201
- if (sub.buffered >= sub.maxBuffered) {
289
+ if (sub.tracksFlush && sub.buffered >= sub.maxBuffered) {
202
290
  // Backpressure: client can't keep up. Close → it reconnects + re-snapshots.
203
291
  this.remove(sub);
204
292
  return;
205
293
  }
206
- const ok = sub.sink.write(frame);
294
+ const ok = sub.sink.send(event, data);
207
295
  if (!ok) {
208
296
  this.remove(sub);
209
297
  return;
210
298
  }
211
- sub.buffered++;
299
+ if (sub.tracksFlush) sub.buffered++;
212
300
  }
213
301
 
214
302
  /** Lazily register the three broad hooks (once per manager). */
@@ -244,7 +332,7 @@ export class SubscriptionManager {
244
332
  // the client ignores ids it never held. Documented low-sensitivity
245
333
  // existence leak (see design §scope-intersection).
246
334
  const ref = payload as DeletedNoteRef;
247
- this.emit(sub, sseEvent("remove", { id: ref.id }));
335
+ this.emit(sub, "remove", { id: ref.id });
248
336
  continue;
249
337
  }
250
338
 
@@ -257,14 +345,14 @@ export class SubscriptionManager {
257
345
  const matches = sub.matcher.match(note) && inScope;
258
346
 
259
347
  if (matches) {
260
- this.emit(sub, sseEvent("upsert", { note }));
348
+ this.emit(sub, "upsert", { note });
261
349
  } else if (event === "updated" && inScope) {
262
350
  // Left the set (predicate no longer true) BUT still within this
263
351
  // token's scope, so the sub could have held it — idempotent remove
264
352
  // (client drops the id if held, no-op otherwise). When the note is
265
353
  // OUT of scope the sub never had it; emitting would leak its id, so
266
354
  // we stay silent.
267
- this.emit(sub, sseEvent("remove", { id: note.id }));
355
+ this.emit(sub, "remove", { id: note.id });
268
356
  }
269
357
  // created that doesn't match → nothing (it was never in the set).
270
358
  }
@@ -286,9 +374,9 @@ export interface SubscriptionHandle {
286
374
  close: () => void;
287
375
  }
288
376
 
289
- /** Serialize a snapshot frame (exported for the route + tests). */
290
- export function snapshotFrame(notes: Note[]): SseFrame {
291
- return sseEvent("snapshot", { notes });
377
+ /** Serialize a snapshot SSE frame (exported for the SSE route + tests). */
378
+ export function snapshotFrame(notes: Note[]): string {
379
+ return sseFrame("snapshot", { notes });
292
380
  }
293
381
 
294
382
  /** Process-wide manager — shared like `defaultHookRegistry`. */
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Shared live-query frame corpus — MIRRORED VERBATIM from the cloud door's
3
+ * `parachute-cloud/workers/vault/test/fixtures/live-frame-corpus.ts` (the SINGLE
4
+ * source of truth). Kept byte-identical here so the self-host WS/SSE parity test
5
+ * asserts against the EXACT same fixture the cloud door pins — the load-bearing
6
+ * cross-door conformance check (WS-hibernation migration, 2026-07-04). If the
7
+ * canonical corpus changes upstream, re-mirror this file.
8
+ *
9
+ * Each case is a transport-neutral `(event, data)` tuple. The invariant under
10
+ * test: for a given case, the SSE `data:` body and the WS message carry a
11
+ * byte-IDENTICAL serialization of the inner payload (`notes` / `note` / `id`) —
12
+ * the ONLY difference is that the WS message folds the SSE `event:` NAME into a
13
+ * `type` discriminator (`{ type, ...data }`), because a WebSocket message has no
14
+ * separate event-name framing. The snapshot case additionally chunks with a
15
+ * `done` flag on the WS side (transport framing under the ~1 MiB message cap);
16
+ * its note OBJECTS still serialize identically.
17
+ *
18
+ * The notes deliberately include the drift-prone bits: unicode, nested metadata,
19
+ * a null value, an empty array, quotes/newlines in content, and stable key
20
+ * ordering — anything a careless re-serialization would reorder or mangle.
21
+ */
22
+
23
+ /** A note-shaped payload (loosely typed — the parity test cares about bytes, not
24
+ * the full Note interface; the wire carries whatever the store returns). */
25
+ export interface CorpusNote {
26
+ id: string;
27
+ path: string;
28
+ content: string;
29
+ tags: string[];
30
+ metadata: Record<string, unknown>;
31
+ createdAt: string;
32
+ updatedAt: string;
33
+ extension: string;
34
+ }
35
+
36
+ export const NOTE_A: CorpusNote = {
37
+ id: "11111111-1111-4111-8111-111111111111",
38
+ path: "greetings/héllo",
39
+ content: 'line one\nline "two" with quotes\n#greeting [[world]]',
40
+ tags: ["greeting", "watch"],
41
+ metadata: { mood: "warm", priority: 5, pinned: true, note: null, aliases: [] },
42
+ createdAt: "2026-07-04T00:00:00.000Z",
43
+ updatedAt: "2026-07-04T00:00:01.000Z",
44
+ extension: "md",
45
+ };
46
+
47
+ export const NOTE_B: CorpusNote = {
48
+ id: "22222222-2222-4222-8222-222222222222",
49
+ path: "meetings/2026-07-04",
50
+ content: "standup — ☂️ parachute cloud",
51
+ tags: ["meeting"],
52
+ metadata: { attendees: ["aaron", "uni"], count: 2 },
53
+ createdAt: "2026-07-04T09:00:00.000Z",
54
+ updatedAt: "2026-07-04T09:30:00.000Z",
55
+ extension: "md",
56
+ };
57
+
58
+ export interface FrameCase {
59
+ name: string;
60
+ event: "snapshot" | "upsert" | "remove";
61
+ /** The inner payload — the SSE `data:` body is exactly `JSON.stringify(data)`;
62
+ * the WS message is `JSON.stringify({ type: event, ...data })`. */
63
+ data: Record<string, unknown>;
64
+ }
65
+
66
+ export const FRAME_CORPUS: FrameCase[] = [
67
+ { name: "snapshot (two notes)", event: "snapshot", data: { notes: [NOTE_A, NOTE_B] } },
68
+ { name: "snapshot (empty set)", event: "snapshot", data: { notes: [] } },
69
+ { name: "upsert (rich note)", event: "upsert", data: { note: NOTE_A } },
70
+ { name: "upsert (unicode note)", event: "upsert", data: { note: NOTE_B } },
71
+ { name: "remove (id)", event: "remove", data: { id: NOTE_A.id } },
72
+ ];