@openparachute/vault 0.6.5-rc.11 → 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.
package/src/subscribe.ts CHANGED
@@ -24,8 +24,8 @@ import { buildLiveMatcher, unsupportedSubscriptionReason } from "./live-match.ts
24
24
  import {
25
25
  snapshotFrame,
26
26
  subscriptionManager,
27
+ SseSink,
27
28
  type SubscriptionManager,
28
- type SubscriptionSink,
29
29
  } from "./subscriptions.ts";
30
30
 
31
31
  /** Keepalive interval — `:` comment every ~25s to defeat idle-proxy timeouts. */
@@ -160,27 +160,28 @@ export async function handleSubscribe(
160
160
  }
161
161
  };
162
162
 
163
- const sink: SubscriptionSink = {
164
- write(frame: string): boolean {
165
- if (cancelled) return false;
166
- queue.push(frame);
167
- flushQueue();
168
- return true;
169
- },
170
- close(): void {
171
- if (cancelled) return;
172
- cancelled = true;
173
- if (keepalive) {
174
- clearInterval(keepalive);
175
- keepalive = null;
176
- }
177
- try {
178
- controllerRef?.close();
179
- } catch {
180
- /* already closed */
181
- }
182
- },
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;
183
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
+ });
184
185
 
185
186
  const stream = new ReadableStream<Uint8Array>({
186
187
  start(controller) {
@@ -219,7 +220,7 @@ export async function handleSubscribe(
219
220
  // 3. Keepalive comments.
220
221
  keepalive = setInterval(() => {
221
222
  if (cancelled) return;
222
- sink.write(":\n\n");
223
+ sink.comment(); // `:\n\n` — byte-identical keepalive comment.
223
224
  }, KEEPALIVE_MS);
224
225
  },
225
226
  pull() {
@@ -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`. */
package/src/tag-scope.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Tag-scope enforcement for tag-scoped tokens (patterns/tag-scoped-tokens.md).
2
+ * Tag-scope enforcement for tag-scoped tokens (docs/contracts/tag-scoped-tokens.md).
3
3
  *
4
4
  * A token's `scoped_tags` allowlist narrows its effective access to notes
5
5
  * carrying one of the allowlisted tags or a sub-tag thereof. The expansion
6
6
  * to descendants happens via the per-vault `_tags/<name>` config-note
7
7
  * hierarchy (see core/src/tag-hierarchy.ts).
8
8
  *
9
- * Auth check pseudocode (from patterns/tag-scoped-tokens.md):
9
+ * Auth check pseudocode (from docs/contracts/tag-scoped-tokens.md):
10
10
  *
11
11
  * if (!hasScope(token, ...)) return forbidden();
12
12
  * if (token.scoped_tags === null) return ok(); // unscoped
@@ -38,7 +38,7 @@ export async function expandTokenTagScope(
38
38
 
39
39
  /**
40
40
  * Return true iff the note's tag set intersects the expanded allowlist OR
41
- * — fail-open per patterns/tag-scoped-tokens.md §Storage — any of the
41
+ * — fail-open per docs/contracts/tag-scoped-tokens.md §Storage — any of the
42
42
  * note's tags has a string-form root inside `rawRoots`. The string-form
43
43
  * fallback covers the orphan-sub-tag case: a token allowlisted for
44
44
  * `health` should still see `#health/food` even when no `_tags/health/food`
@@ -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
+ ];
@@ -54,7 +54,7 @@ export interface Token {
54
54
  * access is the intersection of `scopes` and notes carrying one of these
55
55
  * tags or a sub-tag thereof (hierarchy expansion via getTagDescendants).
56
56
  * NULL = unscoped, full vault access per `scopes`. See
57
- * patterns/tag-scoped-tokens.md.
57
+ * docs/contracts/tag-scoped-tokens.md.
58
58
  */
59
59
  scoped_tags: string[] | null;
60
60
  /**
@@ -301,7 +301,7 @@ export function markMcpMintLedgerRevoked(
301
301
  * Returns display ID + label pairs (no token-hash exposure) so error
302
302
  * envelopes can name the offending tokens for the operator. The match is
303
303
  * exact on the root name — `scoped_tags` only ever stores roots per
304
- * patterns/tag-scoped-tokens.md.
304
+ * docs/contracts/tag-scoped-tokens.md.
305
305
  */
306
306
  export function findTokensReferencingTag(
307
307
  db: Database,
package/src/vault.test.ts CHANGED
@@ -1093,7 +1093,7 @@ describe("scoped MCP wrapper", async () => {
1093
1093
  close();
1094
1094
  });
1095
1095
 
1096
- // -- tag-scoped MCP wrappers (patterns/tag-scoped-tokens.md) ------------
1096
+ // -- tag-scoped MCP wrappers (docs/contracts/tag-scoped-tokens.md) ------------
1097
1097
  //
1098
1098
  // These pin the behavior of `applyTagScopeWrappers` in mcp-tools.ts: each
1099
1099
  // wrapped tool's execute() honors the auth's scoped_tags allowlist. The