@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.
- package/.parachute/module.json +1 -0
- package/package.json +1 -1
- package/src/live-frame-parity.test.ts +226 -0
- package/src/module-manifest.ts +8 -0
- package/src/self-register.test.ts +19 -0
- package/src/self-register.ts +6 -1
- package/src/server.ts +24 -0
- package/src/services-manifest.ts +8 -0
- package/src/subscribe.ts +23 -22
- package/src/subscriptions.ts +125 -37
- package/src/test-support/live-frame-corpus.ts +72 -0
- package/src/ws-server.ts +408 -0
- package/src/ws-subscribe.test.ts +474 -0
- package/src/ws-subscribe.ts +242 -0
package/src/subscriptions.ts
CHANGED
|
@@ -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
|
-
/**
|
|
61
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
*
|
|
187
|
-
|
|
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.
|
|
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,
|
|
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,
|
|
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,
|
|
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[]):
|
|
291
|
-
return
|
|
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
|
+
];
|