@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.3-rc.13",
3
+ "version": "0.7.3-rc.14",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -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
@@ -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
- this.emit(sub, "upsert", { note });
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
- const frames = buildSnapshotFrames(snapshotNotes);
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
  });
@@ -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
+ });
@@ -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
- return { queryOpts };
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[]): string[] {
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