@openparachute/vault 0.7.9-rc.3 → 0.7.9-rc.5

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.
Files changed (38) hide show
  1. package/core/src/compact.test.ts +248 -0
  2. package/core/src/delta.test.ts +49 -0
  3. package/core/src/delta.ts +21 -0
  4. package/core/src/doctor.ts +41 -1
  5. package/core/src/history-capture.test.ts +244 -0
  6. package/core/src/history.test.ts +229 -0
  7. package/core/src/history.ts +679 -0
  8. package/core/src/link-count.test.ts +29 -0
  9. package/core/src/links.ts +43 -0
  10. package/core/src/mcp-manifest.ts +17 -6
  11. package/core/src/mcp.ts +42 -14
  12. package/core/src/notes.ts +6 -1
  13. package/core/src/portable-md.ts +1 -1
  14. package/core/src/schema-v29-note-versions.test.ts +63 -0
  15. package/core/src/schema-v30-note-deltas.test.ts +51 -0
  16. package/core/src/schema.ts +202 -1
  17. package/core/src/store.ts +103 -14
  18. package/core/src/types.ts +35 -2
  19. package/core/src/vendor/fossil-delta.ts +472 -0
  20. package/core/src/wikilinks.ts +68 -1
  21. package/package.json +1 -1
  22. package/src/config.ts +42 -0
  23. package/src/mcp-tools.ts +12 -2
  24. package/src/release-plan.test.ts +90 -1
  25. package/src/routes.ts +276 -95
  26. package/src/routing.ts +8 -3
  27. package/src/test-support/vault-714-find-path.json +58 -0
  28. package/src/test-support/vault-714-graph.json +27 -0
  29. package/src/test-support/vault-714-has_links.json +96 -0
  30. package/src/test-support/vault-714-include_broken_links.json +118 -0
  31. package/src/test-support/vault-714-include_link_count.json +348 -0
  32. package/src/test-support/vault-714-include_links.json +86 -0
  33. package/src/test-support/vault-714-near.json +78 -0
  34. package/src/test-support/vault-714-unresolved-wikilinks.json +6 -0
  35. package/src/vault-compact-routes.test.ts +257 -0
  36. package/src/vault-history-routes.test.ts +562 -0
  37. package/src/vault-store.ts +11 -0
  38. package/src/vault.test.ts +243 -1
@@ -299,3 +299,32 @@ describe("query-notes MCP surface: include_link_count", () => {
299
299
  for (const n of ordered) expect(typeof n.linkCount).toBe("number");
300
300
  });
301
301
  });
302
+
303
+
304
+ describe("vault#714 scoped link degree", () => {
305
+ async function seedScope() {
306
+ for (const id of ["a", "b", "hidden"]) await store.createNote(id, { id });
307
+ await store.createLink("a", "b", "visible");
308
+ await store.createLink("a", "hidden", "hidden-out");
309
+ await store.createLink("hidden", "a", "hidden-in");
310
+ }
311
+ for (const [direction, expected] of [["both", 1], ["outbound", 1], ["inbound", 0]] as const) {
312
+ it(`vault#714 scoped ${direction}`, async () => {
313
+ await seedScope();
314
+ expect(getLinkCounts(db, ["a"], direction, (id) => id !== "hidden").get("a")).toBe(expected);
315
+ });
316
+ }
317
+ it("vault#714 visible self-loop remains degree two", async () => {
318
+ await seedScope(); await store.createLink("b", "b", "self");
319
+ expect(getLinkCounts(db, ["b"], "both", (id) => id === "b").get("b")).toBe(2);
320
+ });
321
+ it("vault#714 absent predicate preserves global degree", async () => {
322
+ await seedScope();
323
+ expect(getLinkCounts(db, ["a"], "both")).toEqual(new Map([["a", 3]]));
324
+ expect(getLinkCounts(db, ["a"], "both", undefined)).toEqual(getLinkCounts(db, ["a"], "both"));
325
+ });
326
+ it("vault#714 repeated ids and typed rows retain row-count semantics", async () => {
327
+ await seedScope(); await store.createLink("a", "b", "second");
328
+ expect(getLinkCounts(db, ["a", "a"], "both", (id) => id !== "hidden").get("a")).toBe(2);
329
+ });
330
+ });
package/core/src/links.ts CHANGED
@@ -267,12 +267,16 @@ export function getLinksHydratedForNotes(
267
267
  * most two index scans regardless of page size. The IN-list is chunked to
268
268
  * stay under SQLite's bound-variable limit on very large pages.
269
269
  *
270
+ * With a visibility predicate, callers supply visible note ids and only rows
271
+ * whose other endpoint is visible count. The indexed selects return neighbour
272
+ * ids instead of grouped counts; absent the predicate, SQL is unchanged.
270
273
  * Returns 0 for ids with no links (every requested id is present in the map).
271
274
  */
272
275
  export function getLinkCounts(
273
276
  db: Database,
274
277
  noteIds: string[],
275
278
  direction: "both" | "outbound" | "inbound" = "both",
279
+ visible?: (noteId: string) => boolean,
276
280
  ): Map<string, number> {
277
281
  const counts = new Map<string, number>();
278
282
  if (noteIds.length === 0) return counts;
@@ -290,6 +294,28 @@ export function getLinkCounts(
290
294
  for (const chunk of chunkForInClause(ids)) {
291
295
  const placeholders = chunk.map(() => "?").join(", ");
292
296
 
297
+ // Scoped callers supply visible page-note ids. Count a row only when
298
+ // its other endpoint is visible too; a visible self-loop still adds 2.
299
+ if (visible) {
300
+ if (wantOutbound) {
301
+ const rows = db.prepare(
302
+ `SELECT source_id AS id, target_id AS other FROM links WHERE source_id IN (${placeholders})`,
303
+ ).all(...chunk) as { id: string; other: string }[];
304
+ for (const row of rows) {
305
+ if (visible(row.other)) counts.set(row.id, counts.get(row.id)! + 1);
306
+ }
307
+ }
308
+ if (wantInbound) {
309
+ const rows = db.prepare(
310
+ `SELECT target_id AS id, source_id AS other FROM links WHERE target_id IN (${placeholders})`,
311
+ ).all(...chunk) as { id: string; other: string }[];
312
+ for (const row of rows) {
313
+ if (visible(row.other)) counts.set(row.id, counts.get(row.id)! + 1);
314
+ }
315
+ }
316
+ continue;
317
+ }
318
+
293
319
  if (wantOutbound) {
294
320
  const rows = db.prepare(
295
321
  `SELECT source_id AS id, COUNT(*) AS c FROM links
@@ -314,6 +340,23 @@ export function getLinkCounts(
314
340
  return counts;
315
341
  }
316
342
 
343
+ /** Lift both has_links polarities from SQL under scope (vault#714). */
344
+ export function sqlHasLinks(wanted: boolean | undefined, scoped: boolean): boolean | undefined {
345
+ return scoped ? undefined : wanted;
346
+ }
347
+
348
+ /** Presence and degree share one oracle; narrowing can shorten a scoped page. */
349
+ export function narrowByVisibleLinks<T extends { id: string }>(
350
+ db: Database,
351
+ notes: T[],
352
+ wanted: boolean | undefined,
353
+ visible: ((noteId: string) => boolean) | undefined,
354
+ ): T[] {
355
+ if (wanted === undefined || !visible || notes.length === 0) return notes;
356
+ const counts = getLinkCounts(db, notes.map((n) => n.id), "both", visible);
357
+ return notes.filter((n) => (counts.get(n.id)! > 0) === wanted);
358
+ }
359
+
317
360
  // ---- Deeper Link Queries ----
318
361
 
319
362
  export interface TraversalNode {
@@ -121,8 +121,8 @@ Response shape (vault#550 — three variants, pick by what you passed):
121
121
  description: "Alias for `exclude_tags` (singular). Same shape and semantics — accepts a single tag or an array.",
122
122
  },
123
123
  has_tags: { type: "boolean", description: "Presence filter: true = only notes with at least one tag; false = only untagged notes. Ignored when `tag` is set." },
124
- has_links: { type: "boolean", description: "Presence filter: true = only notes with at least one inbound or outbound link; false = only orphaned notes (no links in either direction)." },
125
- has_broken_links: { type: "boolean", description: "Presence filter (vault#555): true = only notes with at least one dangling outbound link — a [[wikilink]] or structured `links` target that never resolved to a note; false = only notes with none. Backed by the unresolved_wikilinks table (same data `doctor`/list-unresolved surfaces); safe on a vault where no link has ever gone unresolved (true matches nothing, false is a no-op). For a TAG-SCOPED session both polarities are answered on the notes the session can see (vault#239): a target whose candidates are ALL out of scope matches nothing in that session's sub-vault, so it counts as broken there even though the vault-wide record calls it ambiguous — otherwise the note would only become broken once the last invisible candidate was deleted. Decided after the page is drawn, so a scoped page may come back shorter than `limit` while more results remain." },
124
+ has_links: { type: "boolean", description: "Presence filter: true = only notes with at least one inbound or outbound link; false = only orphaned notes (no links in either direction). For a TAG-SCOPED session both endpoints must be visible. Both polarities are decided after the page is drawn, so a scoped page may be shorter than `limit` while more results remain; this filter does not participate in a scoped cursor query hash." },
125
+ has_broken_links: { type: "boolean", description: "Presence filter (vault#555): true = only notes with at least one dangling outbound link — a [[wikilink]] or structured `links` target that never resolved to a note; false = only notes with none. Backed by the unresolved_wikilinks table (same data `doctor`/list-unresolved surfaces); safe on a vault where no link has ever gone unresolved (true matches nothing, false is a no-op). For a TAG-SCOPED session both polarities are answered on the notes the session can see (vault#239): a target whose candidates are ALL out of scope matches nothing in that session's sub-vault, so it counts as broken there even though the vault-wide record calls it ambiguous or a content wikilink resolves to an invisible note. Resolved structured links cannot recover the original caller string and are not named as broken — otherwise the note would only become broken once the last invisible candidate was deleted. Decided after the page is drawn, so a scoped page may come back shorter than `limit` while more results remain." },
126
126
  has_ambiguous_links: { type: "boolean", description: "Presence filter (vault#581): true = only notes with at least one AMBIGUOUS outbound link — a [[wikilink]] or structured `links` target that matched TWO OR MORE notes, so no link was created and none was guessed at; false = only notes with none. Disjoint from `has_broken_links` (dangling = matched nothing; ambiguous = matched too much). Backed by the ambiguous_wikilinks table — the same source `create-note`/`update-note`'s `ambiguous_link` warning draws from; safe on a vault where no link has ever been ambiguous (true matches nothing, false is a no-op). For a TAG-SCOPED session both polarities are answered on the notes the session can see — a collision between a visible and an invisible note is neither reported by `true` nor excluded by `false` — so a scoped page may come back shorter than `limit` while more results remain." },
127
127
  path: { type: "string", description: "Exact path match (case-insensitive)" },
128
128
  path_prefix: { type: "string", description: "Path prefix match (e.g., 'Projects/')" },
@@ -176,7 +176,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
176
176
  last_updated_by: { type: "string", description: "Write-attribution filter (vault#298): only notes whose MOST RECENT write was attributed to this principal. Exact match; indexed." },
177
177
  created_via: { type: "string", description: "Write-attribution filter (vault#298): only notes FIRST written through this interface/channel — e.g. `mcp`, `surface:<name>`, `agent:<id>`, `nostr:<64-hex-pubkey>`, `operator`, `api`. `nostr:<pubkey>` (vault#698) is the Nostr key that SIGNED the request, and is the axis that tells two agents apart when they share one hub user (`created_by`). Emitted by BOTH doors — self-hosted hub (parachute-hub#937) and cloud (parachute-cloud#277). Exact match; indexed." },
178
178
  last_updated_via: { type: "string", description: "Write-attribution filter (vault#298): only notes whose MOST RECENT write came through this interface/channel — same vocabulary as `created_via`, including `nostr:<64-hex-pubkey>` for the signing key. Exact match; indexed." },
179
- order_by: { type: "string", description: "Sort by an indexed metadata field instead of `created_at`. Field must be declared `indexed: true`; errors otherwise. Two special values need no declaration: `link_count` sorts by link DEGREE (both-directions raw row count), matching the `include_link_count` field for every note; `updated_at` (vault#585) sorts on the integer `updated_at_ms` mirror column — correct on non-canonical/imported timestamps — with `id` as the tiebreaker. Direction is taken from `sort` (default 'asc'); for other fields `created_at` is appended as a stable tiebreaker." },
179
+ order_by: { type: "string", description: "Sort by an indexed metadata field instead of `created_at`. Field must be declared `indexed: true`; errors otherwise. Two special values need no declaration: `link_count` sorts by link DEGREE (both-directions raw row count), matching the `include_link_count` field for unscoped both-direction counts. Under tag scope ordering still uses GLOBAL degree and may disagree with `linkCount`; `updated_at` (vault#585) sorts on the integer `updated_at_ms` mirror column — correct on non-canonical/imported timestamps — with `id` as the tiebreaker. Direction is taken from `sort` (default 'asc'); for other fields `created_at` is appended as a stable tiebreaker." },
180
180
  date_from: { type: "string", description: "Start date (ISO, inclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', from }`." },
181
181
  date_to: { type: "string", description: "End date (ISO, exclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', to }`." },
182
182
  date_filter: {
@@ -208,6 +208,17 @@ Response shape (vault#550 — three variants, pick by what you passed):
208
208
  required: ["note_id"],
209
209
  description: "Scope results to notes within N hops of an anchor note",
210
210
  },
211
+ versions: {
212
+ type: "object",
213
+ properties: {
214
+ note_id: { type: "string", description: "Note ID or path" },
215
+ version_ix: { type: "integer", minimum: 0 },
216
+ limit: { type: "integer", minimum: 0, maximum: 200 },
217
+ offset: { type: "integer", minimum: 0 },
218
+ },
219
+ required: ["note_id"],
220
+ description: "Read note history; mutually exclusive with id, search, near, cursor, aggregate, and semantic. Tag-scoped sessions see the SAME visibility enforcement as every other read — a note outside the token's tag scope answers `not_found`, and a deleted note's history is not readable by a scoped session at all. Deleted-note history is also unavailable to unscoped MCP sessions; restore is REST-only.",
221
+ },
211
222
  sort: {
212
223
  type: "string",
213
224
  enum: ["asc", "desc"],
@@ -240,18 +251,18 @@ Response shape (vault#550 — three variants, pick by what you passed):
240
251
  description: "Control metadata in response: true (all, default), false (none), or array of field names to include",
241
252
  },
242
253
  include_links: { type: "boolean", description: "Include inbound + outbound links per note (default: false)" },
243
- include_broken_links: { type: "boolean", description: "Include each note's dangling outbound links as `broken_links: [{target, relationship}]` (default: false; vault#555). `target` is the unresolved path/title the [[wikilink]] or structured `links` entry named; `relationship` is \"wikilink\" for content-parsed links or the caller's own relationship string for a structured link. Empty array when the note has none. One batched query per request regardless of page size — mirrors `has_broken_links` (same backing table) and `include_links`. For a TAG-SCOPED session this also lists a target whose candidates are all out of scope, which is broken in that session's sub-vault (vault#239)." },
254
+ include_broken_links: { type: "boolean", description: "Include each note's dangling outbound links as `broken_links: [{target, relationship}]` (default: false; vault#555). `target` is the unresolved path/title the [[wikilink]] or structured `links` entry named; `relationship` is \"wikilink\" for content-parsed links or the caller's own relationship string for a structured link. Empty array when the note has none. One batched query per request regardless of page size — mirrors `has_broken_links` (same backing table) and `include_links`. For a TAG-SCOPED session this also lists a target whose candidates are all out of scope, including a content wikilink resolved to an invisible note (vault#239, vault#714). Resolved structured links to invisible targets are suppressed but not named as broken: their original caller string is no longer stored, and the hidden target path must not be disclosed." },
244
255
  include_ambiguous_links: { type: "boolean", description: "Include each note's ambiguous outbound links as `ambiguous_links: [{target, relationship, candidate_count}]` (default: false; vault#581). `target` is the path/title the [[wikilink]] or structured `links` entry named; `relationship` is \"wikilink\" for content-parsed links or the caller's own relationship string; `candidate_count` is how many notes it matched. For a TAG-SCOPED session the candidates are re-counted within the session's scope, and a target with fewer than two visible candidates is omitted — a scoped reader gets exactly what an unscoped one would get on a vault holding only the notes it can see. Empty array when the note has none. One batched query per request regardless of page size — mirrors `has_ambiguous_links` (same backing table) and `include_broken_links`." },
245
256
  include_link_count: {
246
257
  type: "boolean",
247
258
  description:
248
- "Include the note's link DEGREE as a `linkCount` field, without hauling the link objects (default: false). Degree is a raw row count: outbound (source) + inbound (target). A self-loop counts as 2. Cheap COUNT over indexes; batched once per request. For a tag-scoped token, `linkCount` is the raw degree and MAY include edges to notes the token can't see — only the number leaks, not the neighbor.",
259
+ "Include the note's link DEGREE as a `linkCount` field, without hauling the link objects (default: false). Degree is a raw row count: outbound (source) + inbound (target). A self-loop counts as 2. Cheap COUNT over indexes; batched once per request. For a tag-scoped token, only edges with BOTH endpoints visible count, in every direction; a visible self-loop still counts as 2. Scoped counts select neighbour ids over the same indexes; unscoped counts are unchanged.",
249
260
  },
250
261
  link_count_direction: {
251
262
  type: "string",
252
263
  enum: ["both", "outbound", "inbound"],
253
264
  description:
254
- "Which edges `include_link_count` counts: both (default), outbound only (source_id), or inbound only (target_id). order_by=link_count always uses the both-directions degree.",
265
+ "Which edges `include_link_count` counts: both (default), outbound only (source_id), or inbound only (target_id). For tag-scoped tokens only edges with both endpoints visible count in all three directions. order_by=link_count always uses GLOBAL both-directions degree, even under scope, and may disagree with linkCount.",
255
266
  },
256
267
  include_attachments: { type: "boolean", description: "Include attachment records (default: false)" },
257
268
  expand_links: { type: "boolean", description: "Inline [[wikilinks]] in returned content (default: false). Has no effect if content is not included (e.g., default list mode with include_content=false); wikilinks inside fenced or inline code are not expanded." },
package/core/src/mcp.ts CHANGED
@@ -184,7 +184,7 @@ function requireNoteReference(value: unknown): string {
184
184
  * match required. Same [[wikilink]] semantics as `resolveWikilink`; exact
185
185
  * id/path always wins first.
186
186
  */
187
- function resolveNote(db: Database, idOrPath: string): Note | null {
187
+ export function resolveNote(db: Database, idOrPath: string): Note | null {
188
188
  // Try ID match first (fast, indexed)
189
189
  const byId = noteOps.getNote(db, idOrPath);
190
190
  if (byId) return byId;
@@ -659,6 +659,10 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
659
659
  const requestedHasBroken = params.has_broken_links as boolean | undefined;
660
660
  const sqlHasBroken = sqlHasBrokenLinks(requestedHasBroken, Boolean(ambiguityVisible));
661
661
 
662
+ // vault#714: degree and presence use the same visible-edge count.
663
+ const requestedHasLinks = params.has_links as boolean | undefined;
664
+ const sqlLinks = linkOps.sqlHasLinks(requestedHasLinks, Boolean(ambiguityVisible));
665
+
662
666
  // --- Link expansion config (shared across single + list paths) ---
663
667
  const expandLinks = params.expand_links === true;
664
668
  const expandMode = (params.expand_mode as ExpandMode) ?? "full";
@@ -688,6 +692,24 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
688
692
  // stays byte-identical to the no-pagination behavior.
689
693
  const contentRange = parseContentRange(params.content_offset, params.content_length);
690
694
 
695
+ if (params.versions) {
696
+ for (const key of ["search", "near", "cursor", "aggregate", "semantic", "id"] as const) {
697
+ if (params[key] !== undefined) throw new QueryError(`versions is incompatible with ${key}`, "INVALID_QUERY", {
698
+ error_type: "invalid_query", field: "versions", hint: `drop ${key} when using versions`,
699
+ });
700
+ }
701
+ const v = params.versions as { note_id: string; version_ix?: number; limit?: number; offset?: number };
702
+ const note = requireNote(db, requireNoteReference(v.note_id));
703
+ if (typeof v.version_ix === "number") {
704
+ const version = await store.getNoteVersion(note.id, v.version_ix);
705
+ if (!version) return { error: `Version not found: "${v.note_id}"@${v.version_ix}`, error_type: "not_found", id: v.note_id, version_ix: v.version_ix };
706
+ return version;
707
+ }
708
+ const versions = await store.listNoteVersions(note.id, { limit: Math.max(0, Math.min(v.limit ?? 50, 200)), offset: v.offset ?? 0 });
709
+ const total = await store.countNoteVersions(note.id);
710
+ return { versions, total };
711
+ }
712
+
691
713
  // --- Single note by ID/path ---
692
714
  if (params.id) {
693
715
  const note = resolveNote(db, params.id as string);
@@ -742,7 +764,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
742
764
  // links/attachments above; filterMetadata only touches `metadata`.
743
765
  if (params.include_link_count) {
744
766
  const dir = normalizeLinkCountDirection(params.link_count_direction);
745
- result.linkCount = linkOps.getLinkCounts(db, [note.id], dir).get(note.id) ?? 0;
767
+ result.linkCount = linkOps.getLinkCounts(db, [note.id], dir, ambiguityVisible).get(note.id) ?? 0;
746
768
  }
747
769
  return result;
748
770
  }
@@ -877,7 +899,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
877
899
  expand,
878
900
  excludeTags: aggExcludeTags,
879
901
  hasTags: params.has_tags as boolean | undefined,
880
- hasLinks: params.has_links as boolean | undefined,
902
+ hasLinks: sqlLinks,
881
903
  hasBrokenLinks: sqlHasBroken,
882
904
  hasAmbiguousLinks: sqlHasAmbiguous,
883
905
  path: params.path as string | undefined,
@@ -911,15 +933,20 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
911
933
  // reader's own view of each row before aggregating.
912
934
  // vault#239: the `has_broken_links` rollup is the same oracle for
913
935
  // the same reason — re-decide brokenness on the sub-vault too.
914
- const aggVisibleIds = narrowByVisibleBrokenness(
936
+ const aggVisibleIds = linkOps.narrowByVisibleLinks(
915
937
  db,
916
- narrowByVisibleAmbiguity(
938
+ narrowByVisibleBrokenness(
917
939
  db,
918
- aggAllMatches.filter(aggregateVisibility),
919
- requestedHasAmbiguous,
940
+ narrowByVisibleAmbiguity(
941
+ db,
942
+ aggAllMatches.filter(aggregateVisibility),
943
+ requestedHasAmbiguous,
944
+ ambiguityVisible,
945
+ ),
946
+ requestedHasBroken,
920
947
  ambiguityVisible,
921
948
  ),
922
- requestedHasBroken,
949
+ requestedHasLinks,
923
950
  ambiguityVisible,
924
951
  ).map((n) => n.id);
925
952
  // Always run the rollup, even on an empty visible set: ungrouped
@@ -1017,7 +1044,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
1017
1044
  expand,
1018
1045
  excludeTags,
1019
1046
  hasTags: params.has_tags as boolean | undefined,
1020
- hasLinks: params.has_links as boolean | undefined,
1047
+ hasLinks: sqlLinks,
1021
1048
  hasBrokenLinks: sqlHasBroken,
1022
1049
  hasAmbiguousLinks: sqlHasAmbiguous,
1023
1050
  path: params.path as string | undefined,
@@ -1101,7 +1128,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
1101
1128
  expand,
1102
1129
  excludeTags,
1103
1130
  hasTags: params.has_tags as boolean | undefined,
1104
- hasLinks: params.has_links as boolean | undefined,
1131
+ hasLinks: sqlLinks,
1105
1132
  hasBrokenLinks: sqlHasBroken,
1106
1133
  hasAmbiguousLinks: sqlHasAmbiguous,
1107
1134
  path: params.path as string | undefined,
@@ -1171,7 +1198,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
1171
1198
  expand,
1172
1199
  excludeTags,
1173
1200
  hasTags: params.has_tags as boolean | undefined,
1174
- hasLinks: params.has_links as boolean | undefined,
1201
+ hasLinks: sqlLinks,
1175
1202
  hasBrokenLinks: sqlHasBroken,
1176
1203
  hasAmbiguousLinks: sqlHasAmbiguous,
1177
1204
  path: params.path as string | undefined,
@@ -1240,6 +1267,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
1240
1267
  // lifted for a scoped reader, so the real predicate is applied here
1241
1268
  // on that reader's own sub-vault. No-op unscoped.
1242
1269
  results = narrowByVisibleBrokenness(db, results, requestedHasBroken, ambiguityVisible);
1270
+ results = linkOps.narrowByVisibleLinks(db, results, requestedHasLinks, ambiguityVisible);
1243
1271
 
1244
1272
  // --- Format output ---
1245
1273
  const includeContent = params.include_content === true; // default false for list
@@ -1307,7 +1335,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
1307
1335
  // survives. Don't casually swap the order.
1308
1336
  if (params.include_link_count) {
1309
1337
  const dir = normalizeLinkCountDirection(params.link_count_direction);
1310
- const counts = linkOps.getLinkCounts(db, output.map((n: any) => n.id), dir);
1338
+ const counts = linkOps.getLinkCounts(db, output.map((n: any) => n.id), dir, ambiguityVisible);
1311
1339
  for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
1312
1340
  }
1313
1341
 
@@ -2270,7 +2298,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
2270
2298
  name: "delete-note",
2271
2299
  execute: async (params) => {
2272
2300
  const note = requireNote(db, requireNoteReference(params.id));
2273
- await store.deleteNote(note.id);
2301
+ await store.deleteNote(note.id, { actor: writeActor, via: writeVia });
2274
2302
  return { deleted: true, id: note.id };
2275
2303
  },
2276
2304
  },
@@ -2484,7 +2512,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
2484
2512
  field: "new_name",
2485
2513
  });
2486
2514
  }
2487
- const result = await store.renameTag(oldName, newName);
2515
+ const result = await store.renameTag(oldName, newName, { actor: writeActor ?? undefined, via: writeVia ?? undefined });
2488
2516
  if ("error" in result) {
2489
2517
  if (result.error === "not_found") {
2490
2518
  throw structuredError(`rename-tag: tag "${oldName}" not found`, {
package/core/src/notes.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  type SearchMode,
32
32
  } from "./search-query.js";
33
33
  import { generateUlid } from "./ulid.js";
34
+ import { captureVersion, readPriorNoteRow, DEFAULT_HISTORY_POLICY, type HistoryPolicy } from "./history.js";
34
35
 
35
36
  /**
36
37
  * Write-attribution context (vault#298) — the two axes of provenance threaded
@@ -2390,7 +2391,7 @@ export type RenameTagResult =
2390
2391
  * (the store wrapper) bust both `_tagHierarchy` and `_schemaConfig`
2391
2392
  * after the cascade returns.
2392
2393
  */
2393
- export function renameTag(db: Database, oldName: string, newName: string): RenameTagResult {
2394
+ export function renameTag(db: Database, oldName: string, newName: string, attr?: { actor?: string | null; via?: string | null }, policy: HistoryPolicy = DEFAULT_HISTORY_POLICY): RenameTagResult {
2394
2395
  // Normalize the TARGET so a rename can never create a `#`-prefixed tag. The
2395
2396
  // SOURCE (`oldName`) is left LITERAL on purpose — it's the transitional escape
2396
2397
  // hatch that lets the `#legacy/*` → `legacy/*` data migration find the
@@ -2590,6 +2591,8 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
2590
2591
  for (const row of candidates) {
2591
2592
  const next = rewriteNoteBody(row.content, renames);
2592
2593
  if (next === row.content) continue;
2594
+ const prior = readPriorNoteRow(db, row.id);
2595
+ if (prior) captureVersion(db, prior, { actor: attr?.actor ?? null, via: attr?.via ?? null, op: "tag-rename", policy });
2593
2596
  updateStmt.run(next, now, nowMs, row.id);
2594
2597
  notesRewritten++;
2595
2598
  }
@@ -2612,6 +2615,8 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
2612
2615
  for (const row of candidates) {
2613
2616
  const next = rewriteTagConfigPath(row.path, renames);
2614
2617
  if (next === row.path) continue;
2618
+ const prior = readPriorNoteRow(db, row.id);
2619
+ if (prior) captureVersion(db, prior, { actor: attr?.actor ?? null, via: attr?.via ?? null, op: "tag-rename", policy });
2615
2620
  updateStmt.run(next, now, nowMs, row.id);
2616
2621
  pathsRenamed++;
2617
2622
  }
@@ -1961,7 +1961,7 @@ async function importVaultReplay(
1961
1961
  const batch = await store.queryNotes({ sort: "asc", limit: EXPORT_BATCH_SIZE });
1962
1962
  if (batch.length === 0) break;
1963
1963
  for (const note of batch) {
1964
- await store.deleteNote(note.id);
1964
+ await store.deleteNote(note.id, { captureHistory: false });
1965
1965
  stats.notes_wiped++;
1966
1966
  }
1967
1967
  }
@@ -0,0 +1,63 @@
1
+ import { it, expect, beforeEach, afterEach } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { initSchema } from "./schema.js";
4
+ import { BunSqliteStore } from "./store.js";
5
+ let db: Database;
6
+ beforeEach(() => {
7
+ db = new Database(":memory:");
8
+ initSchema(db);
9
+ });
10
+ afterEach(() => db.close());
11
+ function dropHistory() {
12
+ db.exec(
13
+ "DROP TABLE IF EXISTS note_versions; DROP TABLE IF EXISTS note_blobs;",
14
+ );
15
+ }
16
+ it("P14a v28 upgrade creates both tables and three indexes without backfill", () => {
17
+ dropHistory();
18
+ db.prepare("UPDATE schema_version SET version=28").run();
19
+ initSchema(db);
20
+ expect(
21
+ db.prepare("SELECT MAX(version) AS version FROM schema_version").get(),
22
+ ).toEqual({ version: 30 });
23
+ const tables = db
24
+ .prepare(
25
+ "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('note_versions','note_blobs') ORDER BY name",
26
+ )
27
+ .all();
28
+ expect(tables).toEqual([{ name: "note_blobs" }, { name: "note_versions" }]);
29
+ expect(
30
+ db
31
+ .prepare(
32
+ "SELECT name FROM sqlite_master WHERE type='index' AND name IN ('idx_note_versions_note','idx_note_versions_hash','idx_note_versions_superseded')",
33
+ )
34
+ .all(),
35
+ ).toHaveLength(3);
36
+ expect(db.prepare("SELECT COUNT(*) AS n FROM note_versions").get()).toEqual({
37
+ n: 0,
38
+ });
39
+ });
40
+ it("P14b repeated init is idempotent", () => {
41
+ initSchema(db);
42
+ initSchema(db);
43
+ expect(db.prepare("SELECT COUNT(*) AS n FROM note_versions").get()).toEqual({
44
+ n: 0,
45
+ });
46
+ });
47
+ it("P14c rerunning the v29 migration preserves captured rows", async () => {
48
+ const store = new BunSqliteStore(db);
49
+ const n = await store.createNote("a");
50
+ await store.updateNote(n.id, { content: "b" });
51
+ db.prepare("UPDATE schema_version SET version=28").run();
52
+ initSchema(db);
53
+ expect((await store.getNoteVersion(n.id, 0))!.content).toBe("a");
54
+ });
55
+ it("P14d missing tables before the first capture degrade to no history", async () => {
56
+ const store = new BunSqliteStore(db);
57
+ const n = await store.createNote("a");
58
+ // Drop before this handle has probed true. A drop after capture is an
59
+ // accepted throw because historyTablesPresent memoises true per handle.
60
+ dropHistory();
61
+ await store.updateNote(n.id, { content: "b" });
62
+ expect((await store.getNote(n.id))!.content).toBe("b");
63
+ });
@@ -0,0 +1,51 @@
1
+ import { test, expect } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { BunSqliteStore } from "./store.js";
4
+ import { initSchema, SCHEMA_VERSION } from "./schema.js";
5
+ test("P12 v29 opens losslessly, fresh and upgraded columns/indexes agree", async () => {
6
+ const db = new Database(":memory:"), fresh = new Database(":memory:");
7
+ try {
8
+ const store = new BunSqliteStore(db);
9
+ const a = await store.createNote("one"), b = await store.createNote("other");
10
+ await store.updateNote(a.id, { content: "two" });
11
+ await store.updateNote(a.id, { content: "three" });
12
+ await store.deleteNote(a.id);
13
+ await store.updateNote(b.id, { content: "changed" });
14
+ // Remove only v30 additions to recreate the actual v29 table shape.
15
+ db.exec("DROP INDEX IF EXISTS idx_note_blobs_delta_of");
16
+ const cols = (table: string) => db.prepare(`PRAGMA table_info(${table})`).all() as {
17
+ name: string;
18
+ }[];
19
+ if (cols("note_blobs").some(c => c.name === "delta_of"))
20
+ db.exec("ALTER TABLE note_blobs DROP COLUMN delta_of");
21
+ if (cols("note_blobs").some(c => c.name === "encoding"))
22
+ db.exec("ALTER TABLE note_blobs DROP COLUMN encoding");
23
+ if (cols("note_versions").some(c => c.name === "created_at"))
24
+ db.exec("ALTER TABLE note_versions DROP COLUMN created_at");
25
+ db.exec("UPDATE schema_version SET version=29");
26
+ const snap = () => ({ blobs: db.prepare("SELECT hash,content,byte_size FROM note_blobs ORDER BY hash").all(), versions: db.prepare("SELECT note_id,version_ix,content_hash,path,metadata,extension,superseded_at,actor,via,op,content_len,encoding FROM note_versions ORDER BY note_id,version_ix").all() });
27
+ const before = snap();
28
+ expect(() => initSchema(db)).not.toThrow();
29
+ expect(SCHEMA_VERSION).toBe(30);
30
+ expect(snap()).toEqual(before);
31
+ expect(db.prepare("SELECT hash FROM note_blobs WHERE delta_of IS NOT NULL OR encoding IS NOT NULL").all()).toEqual([]);
32
+ expect(db.prepare("SELECT note_id FROM note_versions WHERE created_at IS NOT NULL").all()).toEqual([]);
33
+ initSchema(fresh);
34
+ for (const table of ["note_blobs", "note_versions"]) {
35
+ expect(cols(table).map(c => c.name).sort()).toEqual((fresh.prepare(`PRAGMA table_info(${table})`).all() as {
36
+ name: string;
37
+ }[]).map(c => c.name).sort());
38
+ }
39
+ for (const handle of [db, fresh])
40
+ expect(handle.prepare("SELECT name FROM sqlite_master WHERE name='idx_note_blobs_delta_of'").get()).not.toBeNull();
41
+ expect(db.prepare("PRAGMA integrity_check").get()).toEqual({ integrity_check: "ok" });
42
+ expect(db.prepare("PRAGMA foreign_key_check").all()).toEqual([]);
43
+ initSchema(db);
44
+ expect(snap()).toEqual(before);
45
+ expect(() => db.exec("UPDATE note_blobs SET delta_of='nosuchhash'")).toThrow("FOREIGN KEY");
46
+ }
47
+ finally {
48
+ db.close();
49
+ fresh.close();
50
+ }
51
+ });