@openparachute/vault 0.7.3-rc.13 → 0.7.3-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 +21 -0
- package/src/subscriptions.ts +13 -1
- package/src/ws-server.ts +9 -1
- package/src/ws-subscribe.test.ts +87 -0
- package/src/ws-subscribe.ts +25 -6
package/package.json
CHANGED
|
@@ -98,6 +98,18 @@ describe("buildSnapshotFrames — chunking + done flag", () => {
|
|
|
98
98
|
for (const f of frames) expect(new TextEncoder().encode(f).byteLength).toBeLessThan(1_000_000);
|
|
99
99
|
expect(frames.flatMap((f) => JSON.parse(f).notes).length).toBe(8);
|
|
100
100
|
});
|
|
101
|
+
|
|
102
|
+
it("is shape-agnostic — frames a lean NoteIndex entry verbatim", () => {
|
|
103
|
+
// A lean subscription hands `toNoteIndex`-projected entries; the framer
|
|
104
|
+
// serializes them byte-for-byte, no content field re-added.
|
|
105
|
+
const lean = { id: "x", byteSize: 3, preview: "abc", displayTitle: "abc", tags: ["chat"], metadata: {} };
|
|
106
|
+
const frames = buildSnapshotFrames([lean as any]);
|
|
107
|
+
expect(frames.length).toBe(1);
|
|
108
|
+
const f = JSON.parse(frames[0]!);
|
|
109
|
+
expect(f.done).toBe(true);
|
|
110
|
+
expect(f.notes).toEqual([lean]);
|
|
111
|
+
expect(f.notes[0].content).toBeUndefined();
|
|
112
|
+
});
|
|
101
113
|
});
|
|
102
114
|
|
|
103
115
|
describe("parseClientMessage", () => {
|
|
@@ -171,6 +183,15 @@ describe("validateWsSubscribeQuery — same rejects as the SSE route (byte-ident
|
|
|
171
183
|
const v = validateWsSubscribeQuery(new URL("http://x/vault/v/api/subscribe?tag=chat&path_prefix=meetings/"));
|
|
172
184
|
expect("queryOpts" in v).toBe(true);
|
|
173
185
|
});
|
|
186
|
+
it("resolves include_content — default TRUE (full), `false`/`0` → lean, `true`/`1` → full", () => {
|
|
187
|
+
const at = (q: string) =>
|
|
188
|
+
validateWsSubscribeQuery(new URL(`http://x/vault/v/api/subscribe?tag=chat${q}`)) as { includeContent: boolean };
|
|
189
|
+
expect(at("").includeContent).toBe(true); // absent → full (byte-unchanged default)
|
|
190
|
+
expect(at("&include_content=false").includeContent).toBe(false); // opt into lean
|
|
191
|
+
expect(at("&include_content=0").includeContent).toBe(false);
|
|
192
|
+
expect(at("&include_content=true").includeContent).toBe(true);
|
|
193
|
+
expect(at("&include_content=1").includeContent).toBe(true);
|
|
194
|
+
});
|
|
174
195
|
it("shares the SSE route's queryOpts-level guard (cursor/has_links/date filters)", () => {
|
|
175
196
|
// The belt-and-suspenders layer both doors + the SSE route route through:
|
|
176
197
|
// a date filter isn't expressible as a flat URL param (removed 0.6.4), but
|
package/src/subscriptions.ts
CHANGED
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
import type { Note, Store } from "../core/src/types.ts";
|
|
64
64
|
import type { DeletedNoteRef, HookEvent, NoteHookPayload } from "../core/src/hooks.ts";
|
|
65
65
|
import { defaultHookRegistry } from "../core/src/hooks.ts";
|
|
66
|
+
import { toNoteIndex } from "../core/src/notes.ts";
|
|
66
67
|
import { getVaultNameForStore } from "./vault-store.ts";
|
|
67
68
|
import { noteWithinTagScope } from "./tag-scope.ts";
|
|
68
69
|
import type { LiveMatcher } from "./live-match.ts";
|
|
@@ -98,6 +99,11 @@ interface Subscription {
|
|
|
98
99
|
/** Raw root-tag allowlist (null = unscoped) — `noteWithinTagScope` arg. */
|
|
99
100
|
readonly tagScopeRaw: string[] | null;
|
|
100
101
|
readonly sink: SubscriptionSink;
|
|
102
|
+
/** When true, `upsert` payloads carry the lean `NoteIndex` projection
|
|
103
|
+
* (`toNoteIndex`) instead of the full `Note` — for a subscription that opted
|
|
104
|
+
* into `include_content=false` (list views). `remove` is unaffected (already
|
|
105
|
+
* a thin `{id}` ref); the initial snapshot is projected at the route. */
|
|
106
|
+
readonly lean: boolean;
|
|
101
107
|
/** SSE tracks unflushed frames to bound memory; WS delegates to the runtime. */
|
|
102
108
|
readonly tracksFlush: boolean;
|
|
103
109
|
/** Whether this sub counts against the per-vault manager cap (SSE) — the WS
|
|
@@ -205,6 +211,9 @@ export class SubscriptionManager {
|
|
|
205
211
|
tagScopeRaw: string[] | null;
|
|
206
212
|
sink: SubscriptionSink;
|
|
207
213
|
maxBuffered?: number;
|
|
214
|
+
/** Opt into the lean `NoteIndex` upsert shape (default false = full `Note`).
|
|
215
|
+
* Set for a list subscription that requested `include_content=false`. */
|
|
216
|
+
lean?: boolean;
|
|
208
217
|
/** SSE (default true) tracks unflushed frames; WS passes false. */
|
|
209
218
|
tracksFlush?: boolean;
|
|
210
219
|
/** SSE (default true) counts against the per-vault manager cap; WS passes
|
|
@@ -223,6 +232,7 @@ export class SubscriptionManager {
|
|
|
223
232
|
tagScopeAllowed: args.tagScopeAllowed,
|
|
224
233
|
tagScopeRaw: args.tagScopeRaw,
|
|
225
234
|
sink: args.sink,
|
|
235
|
+
lean: args.lean ?? false,
|
|
226
236
|
tracksFlush: args.tracksFlush ?? true,
|
|
227
237
|
countsTowardCap,
|
|
228
238
|
buffered: 0,
|
|
@@ -320,7 +330,9 @@ export class SubscriptionManager {
|
|
|
320
330
|
const matches = sub.matcher.match(note) && inScope;
|
|
321
331
|
|
|
322
332
|
if (matches) {
|
|
323
|
-
|
|
333
|
+
// A lean subscription (list view, `include_content=false`) carries the
|
|
334
|
+
// same `NoteIndex` projection REST lists return — never the full body.
|
|
335
|
+
this.emit(sub, "upsert", { note: sub.lean ? toNoteIndex(note) : note });
|
|
324
336
|
} else if (event === "updated" && inScope) {
|
|
325
337
|
// Left the set (predicate no longer true) BUT still within this
|
|
326
338
|
// token's scope, so the sub could have held it — idempotent remove
|
package/src/ws-server.ts
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
|
|
33
33
|
import type { Store } from "../core/src/types.ts";
|
|
34
|
+
import { toNoteIndex } from "../core/src/notes.ts";
|
|
34
35
|
import type { VaultConfig } from "./config.ts";
|
|
35
36
|
import { readVaultConfig } from "./config.ts";
|
|
36
37
|
import { getVaultStore } from "./vault-store.ts";
|
|
@@ -229,6 +230,9 @@ export function createSubscribeWsBinding(deps: SubscribeWsDeps = {}): {
|
|
|
229
230
|
closeWs(ws, WS_CLOSE.PROTOCOL, "invalid subscription query");
|
|
230
231
|
return;
|
|
231
232
|
}
|
|
233
|
+
// Lean list subscriptions (`include_content=false`) ship the `NoteIndex`
|
|
234
|
+
// projection — snapshot + live upserts — instead of full note bodies.
|
|
235
|
+
const lean = !validated.includeContent;
|
|
232
236
|
|
|
233
237
|
let tagScopeAllowed: Set<string> | null;
|
|
234
238
|
let matcher;
|
|
@@ -247,7 +251,10 @@ export function createSubscribeWsBinding(deps: SubscribeWsDeps = {}): {
|
|
|
247
251
|
closeWs(ws, WS_CLOSE.PROTOCOL, "snapshot query failed");
|
|
248
252
|
return;
|
|
249
253
|
}
|
|
250
|
-
|
|
254
|
+
// Project AFTER the tag-scope filter (which reads note.tags). `toNoteIndex`
|
|
255
|
+
// is the SAME lean shape the REST list route returns, so a client that
|
|
256
|
+
// renders REST lists renders these snapshot frames unchanged.
|
|
257
|
+
const frames = buildSnapshotFrames(lean ? snapshotNotes.map(toNoteIndex) : snapshotNotes);
|
|
251
258
|
|
|
252
259
|
// --- SYNCHRONOUS from here (no await) so no write interleaves between
|
|
253
260
|
// register and the snapshot flush.
|
|
@@ -261,6 +268,7 @@ export function createSubscribeWsBinding(deps: SubscribeWsDeps = {}): {
|
|
|
261
268
|
tagScopeAllowed,
|
|
262
269
|
tagScopeRaw: auth.scoped_tags,
|
|
263
270
|
sink: new WsSink(ws),
|
|
271
|
+
lean,
|
|
264
272
|
tracksFlush: false,
|
|
265
273
|
countsTowardCap: false,
|
|
266
274
|
});
|
package/src/ws-subscribe.test.ts
CHANGED
|
@@ -486,3 +486,90 @@ describe("WS live-query — bad query + cap", () => {
|
|
|
486
486
|
expect(ok.kind).toBe("upgraded");
|
|
487
487
|
});
|
|
488
488
|
});
|
|
489
|
+
|
|
490
|
+
describe("WS live-query — lean list subscriptions (include_content=false)", () => {
|
|
491
|
+
it("ships the lean NoteIndex snapshot shape — no bodies, has preview/displayTitle/byteSize", async () => {
|
|
492
|
+
await store.createNote("# Title line\n\nbody paragraph here", { tags: ["chat"] });
|
|
493
|
+
const { server } = makeServer();
|
|
494
|
+
|
|
495
|
+
const h = connect(server.port, `/vault/${VAULT}/api/subscribe?tag=chat&include_content=false`);
|
|
496
|
+
await h.ready();
|
|
497
|
+
h.send({ type: "auth", token: "good" });
|
|
498
|
+
|
|
499
|
+
const snap = await h.readSnapshot();
|
|
500
|
+
expect(snap.notes.length).toBe(1);
|
|
501
|
+
const n = snap.notes[0];
|
|
502
|
+
// Lean shape = what REST lists return: no `content`, but the index fields.
|
|
503
|
+
expect(n.content).toBeUndefined();
|
|
504
|
+
expect(typeof n.byteSize).toBe("number");
|
|
505
|
+
expect(typeof n.preview).toBe("string");
|
|
506
|
+
expect(n.displayTitle).toBe("Title line");
|
|
507
|
+
expect(n.tags).toEqual(["chat"]);
|
|
508
|
+
h.close();
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it("a default subscription still ships FULL notes (regression: note-view path unchanged)", async () => {
|
|
512
|
+
await store.createNote("full body content", { tags: ["chat"] });
|
|
513
|
+
const { server } = makeServer();
|
|
514
|
+
|
|
515
|
+
// No include_content param → full content (the byte-unchanged default).
|
|
516
|
+
const h = connect(server.port, `/vault/${VAULT}/api/subscribe?tag=chat`);
|
|
517
|
+
await h.ready();
|
|
518
|
+
h.send({ type: "auth", token: "good" });
|
|
519
|
+
|
|
520
|
+
const snap = await h.readSnapshot();
|
|
521
|
+
expect(snap.notes[0].content).toBe("full body content");
|
|
522
|
+
// Full Note carries no lean-only fields.
|
|
523
|
+
expect(snap.notes[0].byteSize).toBeUndefined();
|
|
524
|
+
expect(snap.notes[0].preview).toBeUndefined();
|
|
525
|
+
h.close();
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("emits a LEAN upsert on a matching insert", async () => {
|
|
529
|
+
const { server } = makeServer();
|
|
530
|
+
const h = connect(server.port, `/vault/${VAULT}/api/subscribe?tag=chat&include_content=false`);
|
|
531
|
+
await h.ready();
|
|
532
|
+
h.send({ type: "auth", token: "good" });
|
|
533
|
+
await h.readSnapshot();
|
|
534
|
+
|
|
535
|
+
await store.createNote("live lean note", { tags: ["chat"] });
|
|
536
|
+
const m = await h.nextMessage();
|
|
537
|
+
expect(m.type).toBe("upsert");
|
|
538
|
+
expect(m.note.content).toBeUndefined();
|
|
539
|
+
expect(m.note.displayTitle).toBe("live lean note");
|
|
540
|
+
expect(typeof m.note.byteSize).toBe("number");
|
|
541
|
+
h.close();
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it("emits a LEAN upsert when a note in the set CHANGES (live-update correctness)", async () => {
|
|
545
|
+
const note = await store.createNote("before", { tags: ["chat"] });
|
|
546
|
+
const { server } = makeServer();
|
|
547
|
+
const h = connect(server.port, `/vault/${VAULT}/api/subscribe?tag=chat&include_content=false`);
|
|
548
|
+
await h.ready();
|
|
549
|
+
h.send({ type: "auth", token: "good" });
|
|
550
|
+
await h.readSnapshot();
|
|
551
|
+
|
|
552
|
+
await store.updateNote(note.id, { content: "after edit" });
|
|
553
|
+
const m = await h.nextMessage();
|
|
554
|
+
expect(m.type).toBe("upsert");
|
|
555
|
+
expect(m.note.id).toBe(note.id);
|
|
556
|
+
expect(m.note.content).toBeUndefined();
|
|
557
|
+
expect(m.note.displayTitle).toBe("after edit");
|
|
558
|
+
h.close();
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("still emits a thin remove{id} under a lean subscription", async () => {
|
|
562
|
+
const note = await store.createNote("doomed lean", { tags: ["chat"] });
|
|
563
|
+
const { server } = makeServer();
|
|
564
|
+
const h = connect(server.port, `/vault/${VAULT}/api/subscribe?tag=chat&include_content=false`);
|
|
565
|
+
await h.ready();
|
|
566
|
+
h.send({ type: "auth", token: "good" });
|
|
567
|
+
await h.readSnapshot();
|
|
568
|
+
|
|
569
|
+
await store.deleteNote(note.id);
|
|
570
|
+
const m = await h.nextMessage();
|
|
571
|
+
expect(m.type).toBe("remove");
|
|
572
|
+
expect(m.id).toBe(note.id);
|
|
573
|
+
h.close();
|
|
574
|
+
});
|
|
575
|
+
});
|
package/src/ws-subscribe.ts
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* visible to browser JS: 4400 protocol, 4401 unauthorized/expired/revoked,
|
|
31
31
|
* 4403 scope, 4408 auth-timeout.
|
|
32
32
|
*/
|
|
33
|
-
import type { Note, QueryOpts } from "../core/src/types.ts";
|
|
33
|
+
import type { Note, NoteIndex, QueryOpts } from "../core/src/types.ts";
|
|
34
34
|
import { parseNotesQueryOpts } from "./routes.ts";
|
|
35
35
|
import { unsupportedSubscriptionReason } from "./live-match.ts";
|
|
36
36
|
import { hasScopeForVault, type VaultVerb } from "./scopes.ts";
|
|
@@ -82,9 +82,20 @@ function json(data: unknown, status = 200): Response {
|
|
|
82
82
|
* filters can't be evaluated against a single changed note. The 400 bodies +
|
|
83
83
|
* `UNSUPPORTED_SUBSCRIPTION_QUERY` code are byte-identical to the SSE route (and
|
|
84
84
|
* to the cloud door) so all three agree. Returns a ready 400 Response on
|
|
85
|
-
* rejection, else the parsed `QueryOpts
|
|
85
|
+
* rejection, else the parsed `QueryOpts` plus the resolved `includeContent`
|
|
86
|
+
* intent.
|
|
87
|
+
*
|
|
88
|
+
* `include_content` — the lean-snapshot knob. Unlike the REST list
|
|
89
|
+
* route (which defaults `include_content=false`, i.e. lean), a live subscription
|
|
90
|
+
* defaults to `true` (FULL notes) so every already-deployed subscriber — cached
|
|
91
|
+
* notes-ui bundles, surface-client — keeps receiving byte-identical full-note
|
|
92
|
+
* snapshots/upserts. A subscriber opts INTO the lean `NoteIndex` wire shape (the
|
|
93
|
+
* same projection REST lists return) by passing `include_content=false`; list
|
|
94
|
+
* views do this, the single-note view leaves it default → full. Truthiness
|
|
95
|
+
* mirrors the REST route's `parseBool` (`true`/`1` → full; anything else → lean)
|
|
96
|
+
* so the two doors read the flag identically.
|
|
86
97
|
*/
|
|
87
|
-
export function validateWsSubscribeQuery(url: URL): { error: Response } | { queryOpts: QueryOpts } {
|
|
98
|
+
export function validateWsSubscribeQuery(url: URL): { error: Response } | { queryOpts: QueryOpts; includeContent: boolean } {
|
|
88
99
|
if (url.searchParams.get("search")) {
|
|
89
100
|
return {
|
|
90
101
|
error: json(
|
|
@@ -118,7 +129,11 @@ export function validateWsSubscribeQuery(url: URL): { error: Response } | { quer
|
|
|
118
129
|
if (unsupported) {
|
|
119
130
|
return { error: json({ error: unsupported, code: "UNSUPPORTED_SUBSCRIPTION_QUERY" }, 400) };
|
|
120
131
|
}
|
|
121
|
-
|
|
132
|
+
// Default TRUE (full) — the live protocol has always shipped full notes;
|
|
133
|
+
// a subscriber opts into the lean shape with `include_content=false`.
|
|
134
|
+
const rawIncludeContent = url.searchParams.get("include_content");
|
|
135
|
+
const includeContent = rawIncludeContent === null ? true : rawIncludeContent === "true" || rawIncludeContent === "1";
|
|
136
|
+
return { queryOpts, includeContent };
|
|
122
137
|
}
|
|
123
138
|
|
|
124
139
|
/** 503 body for the per-vault WS-subscription cap (mirrors the SSE 503). */
|
|
@@ -149,11 +164,15 @@ export function urlFromQuery(q: string): URL {
|
|
|
149
164
|
* exactly once per (re)connect. The consumer concatenates `notes` across frames
|
|
150
165
|
* until `done:true`, then replaces its set (the SSE self-correcting-reconnect
|
|
151
166
|
* semantics). Byte-shaped identically to the cloud door's `buildSnapshotFrames`.
|
|
167
|
+
*
|
|
168
|
+
* Shape-agnostic: the framing only `JSON.stringify`s each entry, so
|
|
169
|
+
* it carries whichever note shape the caller projected — full `Note` (the
|
|
170
|
+
* default) or the lean `NoteIndex` for an `include_content=false` subscription.
|
|
152
171
|
*/
|
|
153
|
-
export function buildSnapshotFrames(notes: Note
|
|
172
|
+
export function buildSnapshotFrames(notes: Array<Note | NoteIndex>): string[] {
|
|
154
173
|
const frames: string[] = [];
|
|
155
174
|
const enc = new TextEncoder();
|
|
156
|
-
let batch: Note
|
|
175
|
+
let batch: Array<Note | NoteIndex> = [];
|
|
157
176
|
let batchBytes = 0;
|
|
158
177
|
for (const note of notes) {
|
|
159
178
|
const noteBytes = enc.encode(JSON.stringify(note)).byteLength + 1; // +1 ~ comma
|