@openparachute/vault 0.6.0 → 0.6.2-rc.1

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/routes.ts CHANGED
@@ -15,6 +15,12 @@ import type { Store, Note, QueryOpts } from "../core/src/types.ts";
15
15
  import { TAG_EXPAND_MODES, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
16
16
  import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
17
17
  import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
18
+ import {
19
+ parseContentRange,
20
+ applyContentRange,
21
+ contentRangeRequiresContent,
22
+ type ContentRange,
23
+ } from "../core/src/content-range.ts";
18
24
  import { attachValidationStatus } from "../core/src/mcp.ts";
19
25
  import * as linkOps from "../core/src/links.ts";
20
26
  import * as tagSchemaOps from "../core/src/tag-schemas.ts";
@@ -103,6 +109,37 @@ function parseQueryList(url: URL, key: string): string[] | undefined {
103
109
  return val ? val.split(",") : undefined;
104
110
  }
105
111
 
112
+ /**
113
+ * Parse `?content_offset=` / `?content_length=` (content range — bounded
114
+ * reads for large notes; unit is UTF-8 bytes, see core/src/content-range.ts
115
+ * for the codepoint-boundary slicing rules). Returns `{ range: null }` when
116
+ * neither param is present (response byte-identical to the no-pagination
117
+ * shape), or `{ error }` (400 INVALID_QUERY) on invalid values or when the
118
+ * response shape excludes content — range params on a content-less shape
119
+ * error loudly rather than silently no-op, same policy as `?expand=`.
120
+ */
121
+ function parseContentRangeQuery(
122
+ url: URL,
123
+ includeContent: boolean,
124
+ ): { range: ContentRange | null; error?: Response } {
125
+ try {
126
+ const range = parseContentRange(
127
+ parseQuery(url, "content_offset") ?? undefined,
128
+ parseQuery(url, "content_length") ?? undefined,
129
+ );
130
+ if (range && !includeContent) throw contentRangeRequiresContent();
131
+ return { range };
132
+ } catch (e: any) {
133
+ // Duck-type on `name` — core is a separate module, so `instanceof`
134
+ // is fragile across bundling boundaries (same note as the QueryError
135
+ // handling in the structured-query path below).
136
+ if (e && e.name === "QueryError") {
137
+ return { range: null, error: json({ error: e.message, code: e.code ?? "INVALID_QUERY" }, 400) };
138
+ }
139
+ throw e;
140
+ }
141
+ }
142
+
106
143
  /**
107
144
  * Parse the extension query parameter (vault#328). Two accepted shapes:
108
145
  * - `?extension=csv` (single value → string)
@@ -706,12 +743,18 @@ async function handleNotesInner(
706
743
  return json({ error: "Note not found", id }, 404);
707
744
  }
708
745
  const includeContent = parseBool(parseQuery(url, "include_content"), true);
746
+ const contentRange = parseContentRangeQuery(url, includeContent);
747
+ if (contentRange.error) return contentRange.error;
709
748
  let result: any = includeContent ? { ...note } : toNoteIndex(note);
710
749
  const expand = parseExpandParams(url, db, tagScope);
711
750
  if (expand && includeContent && typeof result.content === "string") {
712
751
  expand.ctx.expanded.add(note.id);
713
752
  result.content = expandContent(result.content, expand.ctx, expand.depth);
714
753
  }
754
+ // Content range applies to the FINAL returned content (post-
755
+ // expansion) — the window the client pages through is the same
756
+ // document it would have received unpaged.
757
+ if (contentRange.range && includeContent) applyContentRange(result, contentRange.range);
715
758
  result = filterMetadata(result, parseIncludeMetadata(url));
716
759
  if (parseBool(parseQuery(url, "include_links"), false)) {
717
760
  // Tag-scope: drop links whose neighbor is out of scope so the
@@ -769,6 +812,8 @@ async function handleNotesInner(
769
812
  // returns 200 [] (consistent with "no matches"), not 403.
770
813
  const results = filterNotesByTagScope(rawResults, tagScope.allowed, tagScope.raw);
771
814
  const includeContent = parseBool(parseQuery(url, "include_content"), false);
815
+ const contentRange = parseContentRangeQuery(url, includeContent);
816
+ if (contentRange.error) return contentRange.error;
772
817
  const inclMeta = parseIncludeMetadata(url);
773
818
  let output: any[] = includeContent ? results.map((n) => ({ ...n })) : results.map(toNoteIndex);
774
819
  const expand = parseExpandParams(url, db, tagScope);
@@ -780,6 +825,10 @@ async function handleNotesInner(
780
825
  }
781
826
  }
782
827
  }
828
+ // Content range — per-note, post-expansion (see core/src/content-range.ts).
829
+ if (contentRange.range && includeContent) {
830
+ for (const n of output) applyContentRange(n, contentRange.range);
831
+ }
783
832
  if (inclMeta !== undefined && inclMeta !== true) {
784
833
  output = output.map((n: any) => filterMetadata(n, inclMeta));
785
834
  }
@@ -910,6 +959,8 @@ async function handleNotesInner(
910
959
  results = filterNotesByTagScope(results, tagScope.allowed, tagScope.raw);
911
960
 
912
961
  const includeContent = parseBool(parseQuery(url, "include_content"), false);
962
+ const contentRange = parseContentRangeQuery(url, includeContent);
963
+ if (contentRange.error) return contentRange.error;
913
964
  const includeLinks = parseBool(parseQuery(url, "include_links"), false);
914
965
  const includeAttachments = parseBool(parseQuery(url, "include_attachments"), false);
915
966
  const includeLinkCount = parseBool(parseQuery(url, "include_link_count"), false);
@@ -924,6 +975,10 @@ async function handleNotesInner(
924
975
  }
925
976
  }
926
977
  }
978
+ // Content range — per-note, post-expansion (see core/src/content-range.ts).
979
+ if (contentRange.range && includeContent) {
980
+ for (const n of output) applyContentRange(n, contentRange.range);
981
+ }
927
982
  if (inclMeta !== undefined && inclMeta !== true) {
928
983
  output = output.map((n: any) => filterMetadata(n, inclMeta));
929
984
  }
@@ -952,8 +1007,9 @@ async function handleNotesInner(
952
1007
  const nodes = output.map((n: any) => ({ id: n.id, path: n.path ?? null, tags: n.tags ?? [] }));
953
1008
  const edges: { source: string; target: string; relationship: string }[] = [];
954
1009
  if (includeLinks) {
1010
+ const linksByNote = linkOps.getLinksHydratedForNotes(db, results.map((n) => n.id));
955
1011
  for (const n of results) {
956
- for (const link of linkOps.getLinksHydrated(db, n.id)) {
1012
+ for (const link of linksByNote.get(n.id) ?? []) {
957
1013
  // Only include edges where source is this note and target is in the result set
958
1014
  if (link.sourceId === n.id && resultIds.has(link.targetId)) {
959
1015
  edges.push({ source: link.sourceId, target: link.targetId, relationship: link.relationship });
@@ -965,13 +1021,19 @@ async function handleNotesInner(
965
1021
  }
966
1022
 
967
1023
  if (includeLinks || includeAttachments) {
1024
+ // Whole-page link hydration in a constant number of queries — the
1025
+ // per-note variant cost (1 link query + 1 summary query + N tag
1026
+ // queries) × page size. 2026-06-10 perf measurements.
1027
+ const linksByNote = includeLinks
1028
+ ? linkOps.getLinksHydratedForNotes(db, output.map((n: any) => n.id))
1029
+ : null;
968
1030
  const enrichedOut: any[] = [];
969
1031
  for (const n of output) {
970
1032
  const enriched: any = { ...n };
971
- if (includeLinks) {
1033
+ if (linksByNote) {
972
1034
  // Tag-scope: strip out-of-scope-neighbor links (no-op unscoped).
973
1035
  enriched.links = filterHydratedLinksByTagScope(
974
- linkOps.getLinksHydrated(db, n.id),
1036
+ linksByNote.get(n.id) ?? [],
975
1037
  tagScope.allowed,
976
1038
  tagScope.raw,
977
1039
  );
@@ -1252,12 +1314,16 @@ async function handleNotesInner(
1252
1314
  return json({ error: "Not found" }, 404);
1253
1315
  }
1254
1316
  const includeContent = parseBool(parseQuery(url, "include_content"), true);
1317
+ const contentRange = parseContentRangeQuery(url, includeContent);
1318
+ if (contentRange.error) return contentRange.error;
1255
1319
  let result: any = includeContent ? { ...note } : toNoteIndex(note);
1256
1320
  const expand = parseExpandParams(url, db, tagScope);
1257
1321
  if (expand && includeContent && typeof result.content === "string") {
1258
1322
  expand.ctx.expanded.add(note.id);
1259
1323
  result.content = expandContent(result.content, expand.ctx, expand.depth);
1260
1324
  }
1325
+ // Content range applies to the FINAL returned content (post-expansion).
1326
+ if (contentRange.range && includeContent) applyContentRange(result, contentRange.range);
1261
1327
  result = filterMetadata(result, parseIncludeMetadata(url));
1262
1328
  if (parseBool(parseQuery(url, "include_links"), false)) {
1263
1329
  // Tag-scope: drop out-of-scope-neighbor links (no-op unscoped).
package/src/routing.ts CHANGED
@@ -87,6 +87,7 @@ import {
87
87
  handleAuthGet,
88
88
  handleAuthGithubCreateRepo,
89
89
  handleAuthGithubDeviceCode,
90
+ handleAuthGithubInstallations,
90
91
  handleAuthGithubPoll,
91
92
  handleAuthGithubRepos,
92
93
  handleAuthGithubSelectRepo,
@@ -735,6 +736,13 @@ export async function route(
735
736
  if (req.method === "POST") return handleAuthGithubPoll(req, manager);
736
737
  return Response.json({ error: "Method not allowed" }, { status: 405 });
737
738
  }
739
+ if (subpath === "/.parachute/mirror/auth/github/installations") {
740
+ // Install state (vault#480) — which app, installed-anywhere?, install
741
+ // link, per-account installations. Explicitly-network (probes GitHub);
742
+ // the offline status read stays on GET /.parachute/mirror/auth.
743
+ if (req.method === "GET") return handleAuthGithubInstallations(manager);
744
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
745
+ }
738
746
  if (subpath === "/.parachute/mirror/auth/github/repos") {
739
747
  if (req.method === "GET") return handleAuthGithubRepos(manager);
740
748
  return Response.json({ error: "Method not allowed" }, { status: 405 });
package/src/vault.test.ts CHANGED
@@ -4543,6 +4543,62 @@ describe("HTTP PATCH /notes/:idOrPath if_missing=create (vault#309)", async () =
4543
4543
  expect(body.metadata.v).toBe(2);
4544
4544
  });
4545
4545
 
4546
+ // Cross-repo guard for the parachute-agent #agent/thread upsert seam: a single-threaded
4547
+ // thread note lives at a SLASH path (e.g. "Threads/<channel>/<name>") and the agent
4548
+ // module addresses it as ONE URL segment via encodeURIComponent (so `/`→`%2F`). The
4549
+ // full round-trip it relies on — GET an existing note by the encoded-slash path
4550
+ // (readThreadNote read-back) then PATCH-update it by the same encoded-slash path
4551
+ // (if_missing:create upsert, turn 2) — must resolve to the decoded path, or the agent's
4552
+ // turn_count/usage aggregates silently reset every turn. This proves the route resolves
4553
+ // the encoded slash on GET + PATCH-update of an EXISTING note (4505 above only covers
4554
+ // create). See parachute-agent#110.
4555
+ test("encoded-slash path round-trips: GET read-back + PATCH-update resolve a %2F path (the #agent/thread upsert seam)", async () => {
4556
+ const enc = encodeURIComponent("Threads/eng/eng");
4557
+ expect(enc).toBe("Threads%2Feng%2Feng"); // no literal slash — one URL segment.
4558
+
4559
+ // Turn 1 — PATCH if_missing:create by the encoded-slash path → CREATES.
4560
+ const create = await handleNotes(
4561
+ mkReq("PATCH", `/notes/${enc}`, {
4562
+ content: "## Summary\n\nturn 1",
4563
+ tags: ["#agent/thread"],
4564
+ metadata: { turn_count: "1", status: "ok" },
4565
+ if_missing: "create",
4566
+ force: true,
4567
+ }),
4568
+ store,
4569
+ `/${enc}`,
4570
+ );
4571
+ expect(create.status).toBe(200);
4572
+ expect((await create.json() as any).created).toBe(true);
4573
+
4574
+ // Read-back — GET by the SAME encoded-slash path resolves the created note (NOT 404).
4575
+ const get = await handleNotes(mkReq("GET", `/notes/${enc}`), store, `/${enc}`);
4576
+ expect(get.status).toBe(200);
4577
+ const got = await get.json() as any;
4578
+ expect(got.path).toBe("Threads/eng/eng");
4579
+ expect(got.metadata.turn_count).toBe("1");
4580
+
4581
+ // Turn 2 — PATCH if_missing:create by the SAME encoded-slash path → UPDATES in place.
4582
+ const update = await handleNotes(
4583
+ mkReq("PATCH", `/notes/${enc}`, {
4584
+ content: "## Summary\n\nturn 2",
4585
+ metadata: { turn_count: "2", status: "ok" },
4586
+ if_missing: "create",
4587
+ force: true,
4588
+ }),
4589
+ store,
4590
+ `/${enc}`,
4591
+ );
4592
+ expect(update.status).toBe(200);
4593
+ expect((await update.json() as any).created).toBe(false); // updated, NOT a second note.
4594
+
4595
+ // Exactly ONE note at the decoded path, updated (the upsert worked end-to-end).
4596
+ const final = await store.getNoteByPath("Threads/eng/eng");
4597
+ expect(final).not.toBeNull();
4598
+ expect(String(final!.metadata.turn_count)).toBe("2");
4599
+ expect(final!.content).toContain("turn 2");
4600
+ });
4601
+
4546
4602
  test("missing note without if_missing returns 404 (back-compat)", async () => {
4547
4603
  const res = await handleNotes(
4548
4604
  mkReq("PATCH", "/notes/m309c-nope", {