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

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/core/src/mcp.ts CHANGED
@@ -23,7 +23,15 @@ import {
23
23
  resolveStructuredLinkNote,
24
24
  getUnresolvedLinksForNote,
25
25
  getUnresolvedLinksForNotes,
26
+ narrowByVisibleBrokenness,
27
+ sqlHasBrokenLinks,
28
+ getAmbiguousLinksForNote,
29
+ getAmbiguousLinksForNotes,
30
+ narrowByVisibleAmbiguity,
31
+ sqlHasAmbiguousLinks,
26
32
  getContentWikilinkWarnings,
33
+ ambiguousLinkWarning,
34
+ unresolvedLinkWarning,
27
35
  } from "./wikilinks.js";
28
36
  import * as tagSchemaOps from "./tag-schemas.js";
29
37
  import type { TagFieldSchema } from "./tag-schemas.js";
@@ -143,6 +151,17 @@ function structuredError(
143
151
  return Object.assign(new Error(message), fields);
144
152
  }
145
153
 
154
+ function requireNoteReference(value: unknown): string {
155
+ if (typeof value !== "string" || value.trim() === "") {
156
+ throw structuredError("`id` is required", {
157
+ error_type: "missing_required_field",
158
+ field: "id",
159
+ hint: "pass the note's id or path (or its unique H1 title)",
160
+ });
161
+ }
162
+ return value;
163
+ }
164
+
146
165
  /**
147
166
  * Resolve a note identifier — tries ID first, then case-insensitive
148
167
  * path match, then (additive fallback) an H1-title match. Works
@@ -470,6 +489,23 @@ export interface GenerateMcpToolsOpts {
470
489
  * path with no extra fetch.
471
490
  */
472
491
  aggregateVisibility?: (note: Note) => boolean;
492
+ /**
493
+ * `ambiguityVisible` (vault#581 auth review) is an OPTIONAL per-note
494
+ * predicate gating everything the ambiguous-links surface discloses.
495
+ * `candidate_count` is derived VAULT-WIDE, so a stored `2` on a `[[Dup]]`
496
+ * split across scopes tells a tag-scoped reader that a note it cannot see
497
+ * exists — and `has_ambiguous_links: true` would let it sweep its whole
498
+ * in-scope corpus for such collisions. When provided, each persisted row
499
+ * is re-resolved and its candidates narrowed to the visible ones: a row
500
+ * is disclosed only when ≥2 remain, `candidate_count` is the visible
501
+ * count, and `has_ambiguous_links` is answered against that same narrowed
502
+ * view (see `narrowByVisibleAmbiguity` / `sqlHasAmbiguousLinks` in
503
+ * core/src/wikilinks.ts). Same contract as `nearTraversable`: core stays
504
+ * scope-unaware and only invokes the injected `(noteId) => boolean`
505
+ * closure. Omitted (unscoped / internal callers) → the persisted counts
506
+ * are returned as-is and the SQL filter answers directly, unchanged.
507
+ */
508
+ ambiguityVisible?: (noteId: string) => boolean;
473
509
  /**
474
510
  * `AttachmentTicketProvider` seam (vault attachment-tickets design,
475
511
  * Wave 1 — D10 "tools omitted when unwired"). When provided,
@@ -549,6 +585,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
549
585
  const nearTraversable = opts?.nearTraversable;
550
586
  const ifExistsVisible = opts?.ifExistsVisible;
551
587
  const aggregateVisibility = opts?.aggregateVisibility;
588
+ const ambiguityVisible = opts?.ambiguityVisible;
552
589
  // Write-attribution (vault#298) — captured once at tool-generation time
553
590
  // (a fresh tool set is generated per MCP request, so this is request-scoped)
554
591
  // and folded into every create/update the tools perform.
@@ -600,6 +637,28 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
600
637
  {
601
638
  name: "query-notes",
602
639
  execute: async (params) => {
640
+ // --- Ambiguous-links scope split (vault#581 auth review) ---
641
+ // `requestedHasAmbiguous` is what the caller asked for;
642
+ // `sqlHasAmbiguous` is what is safe to push into SQL for a reader
643
+ // that will be narrowed by `narrowByVisibleAmbiguity` afterwards
644
+ // (`true` is a superset and stays; `false` is lifted). Identical for
645
+ // an unscoped reader, where no predicate is injected and the SQL
646
+ // filter alone is the whole answer.
647
+ const requestedHasAmbiguous = params.has_ambiguous_links as boolean | undefined;
648
+ const sqlHasAmbiguous = sqlHasAmbiguousLinks(requestedHasAmbiguous, Boolean(ambiguityVisible));
649
+
650
+ // --- Broken-links scope split (vault#239) ---
651
+ // Same shape as the ambiguity split above, one polarity stricter:
652
+ // NEITHER `true` nor `false` is safe to push into SQL for a scoped
653
+ // reader, because a note can be broken in that reader's sub-vault
654
+ // while carrying no `unresolved_wikilinks` row at all (its target's
655
+ // only candidates are invisible, so the row sits in
656
+ // `ambiguous_wikilinks`). `sqlHasBrokenLinks` lifts both and
657
+ // `narrowByVisibleBrokenness` re-decides per note. Identical for an
658
+ // unscoped reader, where the SQL filter alone is the whole answer.
659
+ const requestedHasBroken = params.has_broken_links as boolean | undefined;
660
+ const sqlHasBroken = sqlHasBrokenLinks(requestedHasBroken, Boolean(ambiguityVisible));
661
+
603
662
  // --- Link expansion config (shared across single + list paths) ---
604
663
  const expandLinks = params.expand_links === true;
605
664
  const expandMode = (params.expand_mode as ExpandMode) ?? "full";
@@ -671,7 +730,10 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
671
730
  result.links = linkOps.getLinksHydrated(db, note.id);
672
731
  }
673
732
  if (params.include_broken_links) {
674
- result.broken_links = getUnresolvedLinksForNote(db, note.id);
733
+ result.broken_links = getUnresolvedLinksForNote(db, note.id, ambiguityVisible);
734
+ }
735
+ if (params.include_ambiguous_links) {
736
+ result.ambiguous_links = getAmbiguousLinksForNote(db, note.id, ambiguityVisible);
675
737
  }
676
738
  if (params.include_attachments) {
677
739
  result.attachments = await store.getAttachments(note.id);
@@ -816,7 +878,8 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
816
878
  excludeTags: aggExcludeTags,
817
879
  hasTags: params.has_tags as boolean | undefined,
818
880
  hasLinks: params.has_links as boolean | undefined,
819
- hasBrokenLinks: params.has_broken_links as boolean | undefined,
881
+ hasBrokenLinks: sqlHasBroken,
882
+ hasAmbiguousLinks: sqlHasAmbiguous,
820
883
  path: params.path as string | undefined,
821
884
  pathPrefix: params.path_prefix as string | undefined,
822
885
  excludePathPrefix: normalizeTags(params.exclude_path_prefix ?? params.excludePathPrefix),
@@ -842,7 +905,23 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
842
905
  // set (reusing the `ids` semijoin `near` already pushes into SQL).
843
906
  // Core stays scope-unaware — it only invokes the plain closure.
844
907
  const aggAllMatches = await store.queryNotes({ ...aggFilterOpts, limit: 1000000 });
845
- const aggVisibleIds = aggAllMatches.filter(aggregateVisibility).map((n) => n.id);
908
+ // vault#581 auth review: a rollup over `has_ambiguous_links` is the
909
+ // same oracle as the note-level filter (a non-zero count still
910
+ // reveals the collision), so narrow the visible id set by the
911
+ // reader's own view of each row before aggregating.
912
+ // vault#239: the `has_broken_links` rollup is the same oracle for
913
+ // the same reason — re-decide brokenness on the sub-vault too.
914
+ const aggVisibleIds = narrowByVisibleBrokenness(
915
+ db,
916
+ narrowByVisibleAmbiguity(
917
+ db,
918
+ aggAllMatches.filter(aggregateVisibility),
919
+ requestedHasAmbiguous,
920
+ ambiguityVisible,
921
+ ),
922
+ requestedHasBroken,
923
+ ambiguityVisible,
924
+ ).map((n) => n.id);
846
925
  // Always run the rollup, even on an empty visible set: ungrouped
847
926
  // count (vault#626) must return `[{group:null,value:0}]`, not `[]`.
848
927
  return await store.aggregateNotes({ ids: aggVisibleIds, aggregate: aggregateSpec });
@@ -939,7 +1018,8 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
939
1018
  excludeTags,
940
1019
  hasTags: params.has_tags as boolean | undefined,
941
1020
  hasLinks: params.has_links as boolean | undefined,
942
- hasBrokenLinks: params.has_broken_links as boolean | undefined,
1021
+ hasBrokenLinks: sqlHasBroken,
1022
+ hasAmbiguousLinks: sqlHasAmbiguous,
943
1023
  path: params.path as string | undefined,
944
1024
  pathPrefix: params.path_prefix as string | undefined,
945
1025
  excludePathPrefix: normalizeTags(params.exclude_path_prefix ?? params.excludePathPrefix),
@@ -1022,7 +1102,8 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
1022
1102
  excludeTags,
1023
1103
  hasTags: params.has_tags as boolean | undefined,
1024
1104
  hasLinks: params.has_links as boolean | undefined,
1025
- hasBrokenLinks: params.has_broken_links as boolean | undefined,
1105
+ hasBrokenLinks: sqlHasBroken,
1106
+ hasAmbiguousLinks: sqlHasAmbiguous,
1026
1107
  path: params.path as string | undefined,
1027
1108
  pathPrefix: params.path_prefix as string | undefined,
1028
1109
  excludePathPrefix: normalizeTags(params.exclude_path_prefix ?? params.excludePathPrefix),
@@ -1091,7 +1172,8 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
1091
1172
  excludeTags,
1092
1173
  hasTags: params.has_tags as boolean | undefined,
1093
1174
  hasLinks: params.has_links as boolean | undefined,
1094
- hasBrokenLinks: params.has_broken_links as boolean | undefined,
1175
+ hasBrokenLinks: sqlHasBroken,
1176
+ hasAmbiguousLinks: sqlHasAmbiguous,
1095
1177
  path: params.path as string | undefined,
1096
1178
  pathPrefix: params.path_prefix as string | undefined,
1097
1179
  excludePathPrefix: normalizeTags(params.exclude_path_prefix ?? params.excludePathPrefix),
@@ -1148,6 +1230,17 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
1148
1230
  results = results.filter((n) => nearScope!.has(n.id));
1149
1231
  }
1150
1232
 
1233
+ // vault#581 auth review — the `has_ambiguous_links` filter must
1234
+ // answer on the reader's OWN sub-vault, not the whole vault. No-op
1235
+ // unscoped (no predicate injected) and when the filter wasn't asked
1236
+ // for. See `narrowByVisibleAmbiguity` for the superset contract and
1237
+ // the page-shortening effect.
1238
+ results = narrowByVisibleAmbiguity(db, results, requestedHasAmbiguous, ambiguityVisible);
1239
+ // vault#239 — same rule for `has_broken_links`: the SQL filter was
1240
+ // lifted for a scoped reader, so the real predicate is applied here
1241
+ // on that reader's own sub-vault. No-op unscoped.
1242
+ results = narrowByVisibleBrokenness(db, results, requestedHasBroken, ambiguityVisible);
1243
+
1151
1244
  // --- Format output ---
1152
1245
  const includeContent = params.include_content === true; // default false for list
1153
1246
  // Range params require content in the response — on lists that
@@ -1219,7 +1312,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
1219
1312
  }
1220
1313
 
1221
1314
  // --- Hydrate links/attachments/broken-links per note if requested ---
1222
- if (params.include_links || params.include_attachments || params.include_broken_links) {
1315
+ if (params.include_links || params.include_attachments || params.include_broken_links || params.include_ambiguous_links) {
1223
1316
  // Links hydrate for the WHOLE page in a constant number of
1224
1317
  // queries (see getLinksHydratedForNotes) — the per-note variant
1225
1318
  // cost (1 link query + 1 summary query + N tag queries) × page
@@ -1229,13 +1322,18 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
1229
1322
  : null;
1230
1323
  // Same one-batched-query-for-the-page shape as links (vault#555).
1231
1324
  const brokenLinksByNote = params.include_broken_links
1232
- ? getUnresolvedLinksForNotes(db, (output as any[]).map((n: any) => n.id))
1325
+ ? getUnresolvedLinksForNotes(db, (output as any[]).map((n: any) => n.id), ambiguityVisible)
1326
+ : null;
1327
+ // Same one-batched-query-for-the-page shape for the ambiguity twin (vault#581).
1328
+ const ambiguousLinksByNote = params.include_ambiguous_links
1329
+ ? getAmbiguousLinksForNotes(db, (output as any[]).map((n: any) => n.id), ambiguityVisible)
1233
1330
  : null;
1234
1331
  const enrichedOut: any[] = [];
1235
1332
  for (const n of output as any[]) {
1236
1333
  const enriched: any = { ...n };
1237
1334
  if (linksByNote) enriched.links = linksByNote.get(n.id) ?? [];
1238
1335
  if (brokenLinksByNote) enriched.broken_links = brokenLinksByNote.get(n.id) ?? [];
1336
+ if (ambiguousLinksByNote) enriched.ambiguous_links = ambiguousLinksByNote.get(n.id) ?? [];
1239
1337
  if (params.include_attachments) enriched.attachments = await store.getAttachments(n.id);
1240
1338
  enrichedOut.push(enriched);
1241
1339
  }
@@ -1559,20 +1657,9 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
1559
1657
  if (outcome.status === "resolved") {
1560
1658
  await store.createLink(sourceId, outcome.note_id, link.relationship);
1561
1659
  } else if (outcome.status === "ambiguous") {
1562
- pushLinkWarning(sourceId, {
1563
- code: "ambiguous_link",
1564
- message: `link target "${link.target}" (relationship "${link.relationship}") matched ${outcome.candidates.length} notes — ambiguous, no link created. Use a more specific path or the note's ID to disambiguate.`,
1565
- target: link.target,
1566
- relationship: link.relationship,
1567
- candidate_count: outcome.candidates.length,
1568
- });
1660
+ pushLinkWarning(sourceId, ambiguousLinkWarning(link.target, link.relationship, outcome.candidates.length));
1569
1661
  } else {
1570
- pushLinkWarning(sourceId, {
1571
- code: "unresolved_link",
1572
- message: `link target "${link.target}" (relationship "${link.relationship}") did not resolve to any note — queued and will backfill automatically if a matching note is created later.`,
1573
- target: link.target,
1574
- relationship: link.relationship,
1575
- });
1662
+ pushLinkWarning(sourceId, unresolvedLinkWarning(link.target, link.relationship));
1576
1663
  }
1577
1664
  }
1578
1665
  }
@@ -1730,7 +1817,8 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
1730
1817
  // branch using this same item's payload. Otherwise mirror the
1731
1818
  // existing `requireNote` behavior (throw "Note not found").
1732
1819
  // vault#309.
1733
- const resolved = resolveNote(db, item.id as string);
1820
+ const idOrPath = requireNoteReference(item.id);
1821
+ const resolved = resolveNote(db, idOrPath);
1734
1822
  if (!resolved) {
1735
1823
  if (item.if_missing === "create") {
1736
1824
  // Treat the update payload as a create payload. Minimum:
@@ -1765,7 +1853,6 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
1765
1853
  // processed and used by Gitcoin's sync; the
1766
1854
  // misleading wording is fixed here so a future
1767
1855
  // reader doesn't trust it and break the workflow.
1768
- const idOrPath = item.id as string;
1769
1856
  // Heuristic: if `path` isn't set AND the `id` looks like a
1770
1857
  // path (contains "/" or doesn't match a typical opaque-id
1771
1858
  // shape), use it as the path too. Otherwise treat it as a
@@ -2118,20 +2205,9 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
2118
2205
  if (outcome.status === "resolved") {
2119
2206
  await store.createLink(sourceId, outcome.note_id, link.relationship, link.metadata);
2120
2207
  } else if (outcome.status === "ambiguous") {
2121
- pushLinkWarning(sourceId, {
2122
- code: "ambiguous_link",
2123
- message: `link target "${link.target}" (relationship "${link.relationship}") matched ${outcome.candidates.length} notes — ambiguous, no link created. Use a more specific path or the note's ID to disambiguate.`,
2124
- target: link.target,
2125
- relationship: link.relationship,
2126
- candidate_count: outcome.candidates.length,
2127
- });
2208
+ pushLinkWarning(sourceId, ambiguousLinkWarning(link.target, link.relationship, outcome.candidates.length));
2128
2209
  } else {
2129
- pushLinkWarning(sourceId, {
2130
- code: "unresolved_link",
2131
- message: `link target "${link.target}" (relationship "${link.relationship}") did not resolve to any note — queued and will backfill automatically if a matching note is created later.`,
2132
- target: link.target,
2133
- relationship: link.relationship,
2134
- });
2210
+ pushLinkWarning(sourceId, unresolvedLinkWarning(link.target, link.relationship));
2135
2211
  }
2136
2212
  }
2137
2213
  }
@@ -2193,7 +2269,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
2193
2269
  {
2194
2270
  name: "delete-note",
2195
2271
  execute: async (params) => {
2196
- const note = requireNote(db, params.id as string);
2272
+ const note = requireNote(db, requireNoteReference(params.id));
2197
2273
  await store.deleteNote(note.id);
2198
2274
  return { deleted: true, id: note.id };
2199
2275
  },
@@ -3032,4 +3108,3 @@ export class BatchTooLargeError extends Error {
3032
3108
  this.got = got;
3033
3109
  }
3034
3110
  }
3035
-
package/core/src/notes.ts CHANGED
@@ -1053,8 +1053,9 @@ export function buildFilterConditions(db: Database, opts: QueryOpts): { conditio
1053
1053
  // Presence: has_broken_links (vault#555) — a dangling outbound wikilink or
1054
1054
  // structured `links` target that never resolved. The `unresolved_wikilinks`
1055
1055
  // table is created lazily (see wikilinks.ts:ensureUnresolvedTable) only when
1056
- // a link actually goes unresolved — a vault where nothing ever has won't
1057
- // have the table at all. Check existence first rather than reference it
1056
+ // a link actually goes unresolved — migrateToV28 heals a pre-#555 2-column
1057
+ // table at boot but does NOT create the table on a vault that never queued
1058
+ // one. Check existence first rather than reference it
1058
1059
  // unconditionally: a read-only query filter shouldn't have the side effect
1059
1060
  // of creating a table, and a bare `EXISTS`/`NOT EXISTS` against a missing
1060
1061
  // table would throw "no such table" instead of the correct empty answer.
@@ -1076,6 +1077,28 @@ export function buildFilterConditions(db: Database, opts: QueryOpts): { conditio
1076
1077
  }
1077
1078
  }
1078
1079
 
1080
+ // Presence: has_ambiguous_links (vault#581) — an outbound `[[wikilink]]` or
1081
+ // structured `links`/`reference` target that matched ≥2 notes, so no link
1082
+ // was created. Same lazily-created-table dance as `has_broken_links` above
1083
+ // (`ambiguous_wikilinks` is only created once a link actually goes
1084
+ // ambiguous), and for the same reasons: a read-only filter must not create
1085
+ // the table, and a bare EXISTS against a missing one throws instead of
1086
+ // answering "none".
1087
+ if (opts.hasAmbiguousLinks !== undefined) {
1088
+ const ambiguousTableExists = db.prepare(
1089
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'ambiguous_wikilinks'",
1090
+ ).get() !== null;
1091
+ if (!ambiguousTableExists) {
1092
+ if (opts.hasAmbiguousLinks) conditions.push("0 = 1");
1093
+ } else {
1094
+ conditions.push(
1095
+ opts.hasAmbiguousLinks
1096
+ ? `EXISTS (SELECT 1 FROM ambiguous_wikilinks ual WHERE ual.source_id = n.id)`
1097
+ : `NOT EXISTS (SELECT 1 FROM ambiguous_wikilinks ual WHERE ual.source_id = n.id)`,
1098
+ );
1099
+ }
1100
+ }
1101
+
1079
1102
  // ID set filter — used by `near` to push neighborhood scoping into SQL so
1080
1103
  // that LIMIT applies to the neighborhood, not the whole notes table.
1081
1104
  if (opts.ids !== undefined) {
@@ -1694,6 +1717,7 @@ function toQueryHashInputs(opts: QueryOpts): QueryHashInputs {
1694
1717
  hasTags: opts.hasTags,
1695
1718
  hasLinks: opts.hasLinks,
1696
1719
  hasBrokenLinks: opts.hasBrokenLinks,
1720
+ hasAmbiguousLinks: opts.hasAmbiguousLinks,
1697
1721
  path: opts.path,
1698
1722
  pathPrefix: opts.pathPrefix,
1699
1723
  excludePathPrefix: opts.excludePathPrefix,
@@ -2767,6 +2791,8 @@ export function mergeTags(
2767
2791
  const deleteNoteTagsStmt = db.prepare("DELETE FROM note_tags WHERE tag_name = ?");
2768
2792
  const deleteTagStmt = db.prepare("DELETE FROM tags WHERE name = ?");
2769
2793
  const countStmt = db.prepare("SELECT COUNT(*) as c FROM note_tags WHERE tag_name = ?");
2794
+ const sourceNoteIdsStmt = db.prepare("SELECT note_id FROM note_tags WHERE tag_name = ?");
2795
+ const affectedIds = new Set<string>();
2770
2796
 
2771
2797
  for (const source of uniqueSources) {
2772
2798
  const exists = db.prepare("SELECT 1 FROM tags WHERE name = ?").get(source);
@@ -2775,6 +2801,11 @@ export function mergeTags(
2775
2801
  continue;
2776
2802
  }
2777
2803
  const before = (countStmt.get(source) as { c: number }).c;
2804
+ // Collect BEFORE the delete so we can bump updated_at on every note
2805
+ // whose tags actually change (vault#567).
2806
+ for (const row of sourceNoteIdsStmt.all(source) as { note_id: string }[]) {
2807
+ affectedIds.add(row.note_id);
2808
+ }
2778
2809
  retagStmt.run(target, source);
2779
2810
  deleteNoteTagsStmt.run(source);
2780
2811
  // Dropping the tag row drops its identity (description, fields,
@@ -2783,6 +2814,24 @@ export function mergeTags(
2783
2814
  deleteTagStmt.run(source);
2784
2815
  merged[source] = before;
2785
2816
  }
2817
+
2818
+ // vault#567: a tags-only `update-note` already bumps `updated_at` so
2819
+ // cursor/sync consumers see the retag. `merge-tags` used to skip the
2820
+ // bump (flood-avoidance), which made the equivalent bulk retag
2821
+ // invisible to since-last-check loops. Bump every note that actually
2822
+ // lost a source tag; notes that never carried a merged-away source
2823
+ // are left untouched. `updated_at_ms` moves with `updated_at`
2824
+ // (vault#586) so the cursor keyset surfaces the change.
2825
+ if (affectedIds.size > 0) {
2826
+ const now = new Date().toISOString();
2827
+ const nowMs = timestampToMs(now) ?? Date.now();
2828
+ const bumpStmt = db.prepare(
2829
+ "UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?",
2830
+ );
2831
+ for (const id of affectedIds) {
2832
+ bumpStmt.run(now, nowMs, id);
2833
+ }
2834
+ }
2786
2835
  });
2787
2836
 
2788
2837
  return { merged, target };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Migration v27 → v28: fold the lazy unresolved_wikilinks relationship-column
3
+ * self-heal into the versioned chain (vault#567 item 2).
4
+ *
5
+ * The interesting path is an EXISTING vault whose `unresolved_wikilinks`
6
+ * table still has the pre-#555 2-column PK — not a fresh vault (those
7
+ * create the 3-column table lazily, or never create it). Gating is the
8
+ * load-bearing claim: a vault that never queued a dangling link must NOT
9
+ * grow the table on open.
10
+ */
11
+ import { describe, it, expect, beforeEach } from "bun:test";
12
+ import { Database } from "bun:sqlite";
13
+ import { initSchema, SCHEMA_VERSION } from "./schema.js";
14
+ import { SqliteStore } from "./store.js";
15
+ import { ensureRelationshipColumn } from "./wikilinks.js";
16
+
17
+ function hasTable(db: Database, name: string): boolean {
18
+ return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
19
+ }
20
+
21
+ function columnNames(db: Database, table: string): string[] {
22
+ return (db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map((c) => c.name);
23
+ }
24
+
25
+ describe("SCHEMA_VERSION v28", () => {
26
+ it("bumped SCHEMA_VERSION to at least 28 (unresolved_wikilinks heal is versioned)", () => {
27
+ expect(SCHEMA_VERSION).toBeGreaterThanOrEqual(28);
28
+ });
29
+ });
30
+
31
+ describe("migrateToV28 — does NOT create unresolved_wikilinks on a vault that never needed it", () => {
32
+ it("a fresh vault (no dangling links) still has no unresolved_wikilinks table after initSchema", () => {
33
+ const db = new Database(":memory:");
34
+ const store = new SqliteStore(db);
35
+ expect(store).toBeDefined();
36
+ expect(hasTable(db, "unresolved_wikilinks")).toBe(false);
37
+ expect(
38
+ (db.prepare("SELECT MAX(version) AS v FROM schema_version").get() as { v: number }).v,
39
+ ).toBe(SCHEMA_VERSION);
40
+ });
41
+ });
42
+
43
+ describe("migrateToV28 — heals a pre-#555 2-column table at boot", () => {
44
+ let db: Database;
45
+ let store: SqliteStore;
46
+ let sourceId: string;
47
+
48
+ beforeEach(async () => {
49
+ db = new Database(":memory:");
50
+ store = new SqliteStore(db);
51
+ const src = await store.createNote("plain body, no wikilinks", { path: "src-note" });
52
+ await store.createNote("plain target", { path: "Target A" });
53
+ sourceId = src.id;
54
+ db.exec(`
55
+ CREATE TABLE unresolved_wikilinks (
56
+ source_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
57
+ target_path TEXT NOT NULL COLLATE NOCASE,
58
+ PRIMARY KEY (source_id, target_path)
59
+ )
60
+ `);
61
+ db.prepare("INSERT INTO unresolved_wikilinks (source_id, target_path) VALUES (?, ?)").run(
62
+ src.id,
63
+ "Target B",
64
+ );
65
+ db.prepare("INSERT INTO unresolved_wikilinks (source_id, target_path) VALUES (?, ?)").run(
66
+ src.id,
67
+ "Target A",
68
+ );
69
+ });
70
+
71
+ it("initSchema rebuilds the 3-column PK and backfills relationship='wikilink'", () => {
72
+ expect(columnNames(db, "unresolved_wikilinks")).not.toContain("relationship");
73
+ initSchema(db);
74
+ expect(columnNames(db, "unresolved_wikilinks")).toContain("relationship");
75
+ expect(hasTable(db, "unresolved_wikilinks_pre_v555")).toBe(false);
76
+ const rows = db
77
+ .prepare("SELECT source_id, target_path, relationship FROM unresolved_wikilinks ORDER BY target_path")
78
+ .all() as { source_id: string; target_path: string; relationship: string }[];
79
+ expect(rows).toHaveLength(2);
80
+ expect(rows.every((r) => r.relationship === "wikilink")).toBe(true);
81
+ expect(rows.every((r) => r.source_id === sourceId)).toBe(true);
82
+ expect(
83
+ (db.prepare("SELECT MAX(version) AS v FROM schema_version").get() as { v: number }).v,
84
+ ).toBe(SCHEMA_VERSION);
85
+ });
86
+
87
+ it("is idempotent — a second initSchema neither throws nor duplicates rows", () => {
88
+ initSchema(db);
89
+ initSchema(db);
90
+ const count = (db.prepare("SELECT COUNT(*) AS c FROM unresolved_wikilinks").get() as { c: number }).c;
91
+ expect(count).toBe(2);
92
+ expect(columnNames(db, "unresolved_wikilinks")).toContain("relationship");
93
+ });
94
+
95
+ it("a 3-column table is a no-op (no rewrite)", () => {
96
+ initSchema(db); // first pass heals
97
+ const before = db.prepare("SELECT * FROM unresolved_wikilinks ORDER BY target_path").all();
98
+ ensureRelationshipColumn(db);
99
+ initSchema(db);
100
+ const after = db.prepare("SELECT * FROM unresolved_wikilinks ORDER BY target_path").all();
101
+ expect(after).toEqual(before);
102
+ });
103
+ });
@@ -4,8 +4,9 @@ import { rebuildIndexes, listIndexedFields } from "./indexed-fields.js";
4
4
  import { findMixedTypeIndexedFieldNotes } from "./doctor.js";
5
5
  import { transaction } from "./txn.js";
6
6
  import { timestampToMs } from "./cursor.js";
7
+ import { ensureRelationshipColumn } from "./wikilinks.js";
7
8
 
8
- export const SCHEMA_VERSION = 27;
9
+ export const SCHEMA_VERSION = 28;
9
10
 
10
11
  /**
11
12
  * Deterministic last-resort epoch for a note whose `updated_at` AND
@@ -621,6 +622,13 @@ export function initSchema(db: Database): void {
621
622
  // already reads as "needs embedding." See vault semantic-search MVP plan.
622
623
  migrateToV27(db);
623
624
 
625
+ // Migrate v27 → v28: fold the lazy unresolved_wikilinks `relationship`
626
+ // column self-heal into the versioned chain (vault#567 item 2). Gated:
627
+ // no table → no-op; 3-column table → no-op; only a pre-#555 2-column
628
+ // table is rebuilt. Does not create the table on vaults that never
629
+ // queued an unresolved link, and does not rewrite notes.
630
+ migrateToV28(db);
631
+
624
632
  // Rebuild any generated columns + indexes declared in indexed_fields.
625
633
  // No-op for a fresh vault; idempotent on existing vaults.
626
634
  rebuildIndexes(db);
@@ -1729,6 +1737,25 @@ function migrateToV27(db: Database): void {
1729
1737
  });
1730
1738
  }
1731
1739
 
1740
+ /**
1741
+ * Migrate v27 → v28: version the unresolved_wikilinks relationship-column
1742
+ * self-heal that used to run lazily on first touch (vault#567 item 2).
1743
+ *
1744
+ * COST: this does NOT rewrite every vault on open. `ensureRelationshipColumn`
1745
+ * gates on PRAGMA table_info:
1746
+ * - table missing (most vaults — lazy creation, never queued a dangling
1747
+ * link) → return immediately, no CREATE;
1748
+ * - `relationship` already present (post-#555 / fresh 3-column table) →
1749
+ * return immediately;
1750
+ * - pre-#555 2-column table only → one atomic 4-statement rebuild of
1751
+ * `unresolved_wikilinks` (typically tiny; not a notes rewrite).
1752
+ * Subsequent opens are the same PRAGMA no-op. The per-touch lazy call in
1753
+ * `wikilinks.ts` stays as a no-op safety net after this version.
1754
+ */
1755
+ function migrateToV28(db: Database): void {
1756
+ ensureRelationshipColumn(db);
1757
+ }
1758
+
1732
1759
  function hasTable(db: Database, name: string): boolean {
1733
1760
  const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
1734
1761
  return !!row;