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

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/core.test.ts +546 -0
  2. package/core/src/cursor.ts +1 -0
  3. package/core/src/link-count.test.ts +29 -0
  4. package/core/src/links.ts +43 -0
  5. package/core/src/mcp-manifest.ts +10 -6
  6. package/core/src/mcp.ts +129 -44
  7. package/core/src/notes.ts +51 -2
  8. package/core/src/schema-v28-unresolved-wikilinks.test.ts +103 -0
  9. package/core/src/schema.ts +28 -1
  10. package/core/src/store.ts +180 -57
  11. package/core/src/txn.test.ts +33 -1
  12. package/core/src/txn.ts +80 -7
  13. package/core/src/types.ts +11 -0
  14. package/core/src/vault-projection.ts +8 -1
  15. package/core/src/wikilinks.ts +873 -44
  16. package/package.json +2 -2
  17. package/src/aggregate-routes.test.ts +76 -0
  18. package/src/config.ts +8 -2
  19. package/src/mcp-http.test.ts +51 -1
  20. package/src/mcp-tools.ts +106 -5
  21. package/src/mirror-routes.test.ts +47 -0
  22. package/src/release-plan.test.ts +90 -1
  23. package/src/routes.ts +280 -66
  24. package/src/routing.test.ts +67 -0
  25. package/src/tag-scope-query-tag.test.ts +374 -0
  26. package/src/tag-scope.ts +116 -0
  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.test.ts +928 -1
  36. package/src/write-warnings-scope.test.ts +344 -0
  37. package/src/ws-server.ts +16 -3
  38. package/src/ws-subscribe.test.ts +76 -2
@@ -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;
package/core/src/store.ts CHANGED
@@ -12,16 +12,25 @@ import {
12
12
  } from "./indexed-fields.js";
13
13
  import {
14
14
  syncWikilinks,
15
+ parseWikilinks,
16
+ resolveWikilinkDetailed,
17
+ rewriteWikilinkTargets,
18
+ wikilinkPathForm,
19
+ wikilinkRenameCandidates,
15
20
  resolveUnresolvedWikilinks,
16
21
  resolveOrQueueLink,
17
22
  clearQueuedLink,
18
23
  clearQueuedLinkTarget,
24
+ clearAmbiguousLink,
25
+ clearAmbiguousLinkTarget,
26
+ refreshAmbiguousLinks,
27
+ noteResolutionKeys,
28
+ pathResolutionKeys,
19
29
  requeueInboundWikilinksForDelete,
20
30
  } from "./wikilinks.js";
21
31
  import { chunkForInClause } from "./sql-in.js";
22
- import { pathTitle } from "./paths.js";
23
32
  import { timestampToMs } from "./cursor.js";
24
- import { transaction } from "./txn.js";
33
+ import { afterCommit, transaction } from "./txn.js";
25
34
  import { HookRegistry } from "./hooks.js";
26
35
  import {
27
36
  loadTagHierarchy,
@@ -257,6 +266,9 @@ export class BunSqliteStore implements Store {
257
266
  // resolved target.
258
267
  linkOps.deleteLinksBySourceRelationship(this.db, note.id, fieldName);
259
268
  clearQueuedLink(this.db, note.id, fieldName);
269
+ // vault#581 twin: a recorded ambiguity for the OLD value must go too,
270
+ // or the field would keep reporting a collision it no longer has.
271
+ clearAmbiguousLink(this.db, note.id, fieldName);
260
272
 
261
273
  if (typeof nextValue === "string" && nextValue.trim() !== "") {
262
274
  // Resolves now, or queues a lazy forward-ref on a miss — same
@@ -374,7 +386,10 @@ export class BunSqliteStore implements Store {
374
386
  // still-present-but-unresolved element stays queued (re-queued
375
387
  // idempotently above).
376
388
  for (const value of priorSet) {
377
- if (!nextSet.has(value)) clearQueuedLinkTarget(this.db, noteId, fieldName, value);
389
+ if (!nextSet.has(value)) {
390
+ clearQueuedLinkTarget(this.db, noteId, fieldName, value);
391
+ clearAmbiguousLinkTarget(this.db, noteId, fieldName, value); // vault#581 twin
392
+ }
378
393
  }
379
394
  }
380
395
 
@@ -519,6 +534,10 @@ export class BunSqliteStore implements Store {
519
534
 
520
535
  if (note.path) {
521
536
  resolveUnresolvedWikilinks(this.db, note.path, note.id);
537
+ // vault#581 — this note becoming a NEW candidate can only make an
538
+ // existing ambiguity wider, but the recorded `candidate_count` has to
539
+ // stay honest. Bounded to rows whose target could name this note.
540
+ refreshAmbiguousLinks(this.db, noteResolutionKeys(note));
522
541
  }
523
542
 
524
543
  // Reference-field auto-link (vault#typed-reference-field) — no prior
@@ -601,71 +620,173 @@ export class BunSqliteStore implements Store {
601
620
  }
602
621
  }
603
622
 
604
- const note = noteOps.updateNote(this.db, id, updates);
623
+ // vault#708 — the rename cascade has to know which brackets resolved to
624
+ // THIS note, and resolution is only observable BEFORE the path moves.
625
+ // So the plan (source note -> the exact bracket texts that pointed here)
626
+ // is computed against the pre-write index; `cascadeRename` applies it
627
+ // after, when the new path is known.
628
+ const cascadePlan = oldPath && updates.path !== undefined && oldPath !== updates.path
629
+ ? this.planCascadeRename(id, oldPath)
630
+ : undefined;
631
+
632
+ // Keep the note-row write and every derived index/cascade write in one
633
+ // atomic unit. Any later failure must leave the note and its indexes at
634
+ // the pre-update state rather than committing only the first SQL write.
635
+ const note = this.transaction(() => {
636
+ const note = noteOps.updateNote(this.db, id, updates);
637
+
638
+ // Wikilink sync runs against the *resulting* content. For append/prepend
639
+ // we don't have the new value pre-write — read it back off the returned
640
+ // note so a `[[Foo]]` introduced via append still creates the link.
641
+ if (updates.content !== undefined || updates.append !== undefined || updates.prepend !== undefined) {
642
+ syncWikilinks(this.db, id, note.content);
643
+ }
605
644
 
606
- // Wikilink sync runs against the *resulting* content. For append/prepend
607
- // we don't have the new value pre-write — read it back off the returned
608
- // note so a `[[Foo]]` introduced via append still creates the link.
609
- if (updates.content !== undefined || updates.append !== undefined || updates.prepend !== undefined) {
610
- syncWikilinks(this.db, id, note.content);
611
- }
645
+ if (updates.path !== undefined && note.path) {
646
+ if (cascadePlan && oldPath && oldPath !== note.path) {
647
+ this.cascadeRename(cascadePlan, note, oldPath);
648
+ }
649
+ resolveUnresolvedWikilinks(this.db, note.path, id);
650
+ // vault#581 — a rename is one of the two ways an ambiguity stops being
651
+ // ambiguous (the other is a delete). Sweep the OLD path's keys as well
652
+ // as the new ones: it's the target the collision was recorded under.
653
+ refreshAmbiguousLinks(this.db, [
654
+ ...pathResolutionKeys(oldPath, note.extension),
655
+ ...noteResolutionKeys(note),
656
+ ]);
657
+ }
612
658
 
613
- if (updates.path !== undefined && note.path) {
614
- if (oldPath && oldPath !== note.path) {
615
- this.cascadeRename(oldPath, note.path);
659
+ // Reference-field auto-link sync (vault#typed-reference-field). Only
660
+ // when this call actually touched `metadata` — see the read above for
661
+ // why a content/tags/path-only update is skipped.
662
+ if (updates.metadata !== undefined) {
663
+ this.syncReferenceFieldLinks(note, priorMetadataForRefs);
616
664
  }
617
- resolveUnresolvedWikilinks(this.db, note.path, id);
618
- }
619
665
 
620
- // Reference-field auto-link sync (vault#typed-reference-field). Only
621
- // when this call actually touched `metadata` — see the read above for
622
- // why a content/tags/path-only update is skipped.
623
- if (updates.metadata !== undefined) {
624
- this.syncReferenceFieldLinks(note, priorMetadataForRefs);
625
- }
666
+ return note;
667
+ });
626
668
 
627
- // Invalidate before the hook dispatch so any handler that re-queries
628
- // the hierarchy from inside its own logic sees post-write state.
629
- // `metadata` updates can change the `parents` field on a config note
630
- // even when the path didn't change, so always invalidate when the
631
- // current path is in a config namespace.
632
- this.invalidateConfigCachesForPath(note.path, oldPath);
633
- this.hooks.dispatch("updated", note, this);
669
+ // Dispatch and invalidate only once the whole surrounding transaction
670
+ // stack commits. In a multi-item transactionAsync batch the inner Store
671
+ // transaction is a SAVEPOINT, so "after the inner closure" is not yet
672
+ // post-commit; afterCommit carries these through to the outer boundary.
673
+ afterCommit(this.db, () => {
674
+ this.invalidateConfigCachesForPath(note.path, oldPath);
675
+ this.hooks.dispatch("updated", note, this);
676
+ });
634
677
 
635
678
  return note;
636
679
  }
637
680
 
638
681
  /**
639
- * When a note is renamed, update [[wikilinks]] in other notes that referenced the old path.
640
- * Matches both full path and basename references.
682
+ * Plan the rename cascade (vault#708) — for a note about to move off
683
+ * `oldPath`, the exact `[[bracket]]` texts in each source note that
684
+ * RESOLVED TO THIS NOTE, keyed by source id.
685
+ *
686
+ * MUST run before the path write: resolution is a property of the index,
687
+ * and once the row moves, `[[Rename]]` no longer means what it meant.
688
+ *
689
+ * The source set comes from the `links` rows pointing at this note
690
+ * (`relationship = 'wikilink'`) — an index-driven prefilter that already
691
+ * excludes the two classes the old basename-matching cascade corrupted:
692
+ * - AMBIGUOUS brackets (`[[Rename]]` with `keep/Rename` AND `move/Rename`
693
+ * present) never get a `links` row at all — they live in
694
+ * `ambiguous_wikilinks` (vault#581/#707) and are healed by
695
+ * `refreshAmbiguousLinks` after the rename, not by a text rewrite.
696
+ * - brackets that resolved to a DIFFERENT same-basename note, whose
697
+ * `links` row points elsewhere.
698
+ * The note itself is added to the set because a self-referencing bracket
699
+ * is deliberately never given a `links` row (`syncWikilinks` skips
700
+ * self-links) yet the old cascade rewrote it — parity.
701
+ *
702
+ * `links` rows only say "this note links here", not WHICH bracket did it
703
+ * (`syncWikilinks` dedupes by resolved target id and stores no bracket
704
+ * text), so every candidate source is re-parsed and each bracket
705
+ * re-resolved. That is also what keeps a source's OTHER same-named
706
+ * brackets untouched.
641
707
  */
642
- private cascadeRename(oldPath: string, newPath: string): void {
643
- const oldTitle = pathTitle(oldPath);
644
- const newTitle = pathTitle(newPath);
645
-
646
- const candidates = this.db.prepare(`
647
- SELECT id, content FROM notes
648
- WHERE content LIKE ? OR content LIKE ?
649
- `).all(`%[[${oldPath}%`, `%[[${oldTitle}%`) as { id: string; content: string }[];
650
-
651
- for (const row of candidates) {
652
- let updated = row.content;
653
-
654
- updated = updated.replace(
655
- new RegExp(`\\[\\[${escapeRegex(oldPath)}([#|\\]])`, "g"),
656
- `[[${newPath}$1`,
657
- );
708
+ private planCascadeRename(id: string, oldPath: string): Map<string, string[]> {
709
+ const rows = this.db.prepare(
710
+ "SELECT source_id FROM links WHERE target_id = ? AND relationship = 'wikilink'",
711
+ ).all(id) as { source_id: string }[];
712
+ const sourceIds = new Set<string>(rows.map((r) => r.source_id));
713
+ sourceIds.add(id);
714
+
715
+ const plan = new Map<string, string[]>();
716
+ for (const sourceId of sourceIds) {
717
+ const row = this.db.prepare("SELECT content FROM notes WHERE id = ?")
718
+ .get(sourceId) as { content: string } | null;
719
+ if (!row?.content) continue;
720
+ const targets: string[] = [];
721
+ const seen = new Set<string>();
722
+ for (const wl of parseWikilinks(row.content)) {
723
+ const key = wl.target.toLowerCase();
724
+ if (seen.has(key)) continue;
725
+ seen.add(key);
726
+ // Only path-derived forms of the old path can be invalidated by a
727
+ // repath; an H1-title-fallback bracket keeps resolving and must not
728
+ // be touched.
729
+ if (!wikilinkPathForm(wl.target, oldPath)) continue;
730
+ const detail = resolveWikilinkDetailed(this.db, wl.target);
731
+ if (detail.resolved && detail.note_id === id) targets.push(wl.target);
732
+ }
733
+ if (targets.length > 0) plan.set(sourceId, targets);
734
+ }
735
+ return plan;
736
+ }
658
737
 
659
- if (oldTitle !== newTitle && oldTitle !== oldPath) {
660
- updated = updated.replace(
661
- new RegExp(`\\[\\[${escapeRegex(oldTitle)}([#|\\]])`, "g"),
662
- `[[${newTitle}$1`,
663
- );
738
+ /**
739
+ * Apply a {@link planCascadeRename} plan after the path write: rewrite only
740
+ * the brackets that resolved to the renamed note, preserving each one's
741
+ * shape (basename stays a basename, full path stays a full path, an
742
+ * explicit `.ext` keeps its `.ext`). A basename widens to the full path
743
+ * only when the NEW basename would no longer resolve back to this note —
744
+ * i.e. the move created a fresh collision.
745
+ *
746
+ * Each rewritten source is re-parsed via `syncWikilinks`, so `links`,
747
+ * `unresolved_wikilinks` and `ambiguous_wikilinks` stay consistent with
748
+ * the new text.
749
+ */
750
+ private cascadeRename(plan: Map<string, string[]>, note: Note, oldPath: string): void {
751
+ if (plan.size === 0 || !note.path) return;
752
+ const newPath = note.path;
753
+
754
+ // One resolution per distinct bracket text, shared across sources.
755
+ const replacement = new Map<string, string>();
756
+
757
+ for (const [sourceId, targets] of plan) {
758
+ const row = this.db.prepare("SELECT content FROM notes WHERE id = ?")
759
+ .get(sourceId) as { content: string } | null;
760
+ if (!row?.content) continue;
761
+
762
+ const mapping = new Map<string, string>();
763
+ for (const target of targets) {
764
+ const key = target.toLowerCase();
765
+ let next = replacement.get(key);
766
+ if (next === undefined) {
767
+ const form = wikilinkPathForm(target, oldPath);
768
+ if (!form) continue;
769
+ const candidates = wikilinkRenameCandidates(form, newPath, note.extension);
770
+ // First candidate that actually resolves back to this note wins;
771
+ // if none does (e.g. every shape is now ambiguous), keep the
772
+ // preferred shape rather than inventing a third one.
773
+ next = candidates.find((c) => {
774
+ const detail = resolveWikilinkDetailed(this.db, c);
775
+ return detail.resolved && detail.note_id === note.id;
776
+ }) ?? candidates[0]!;
777
+ replacement.set(key, next);
778
+ }
779
+ if (next !== target) mapping.set(key, next);
664
780
  }
781
+ if (mapping.size === 0) continue;
665
782
 
783
+ const updated = rewriteWikilinkTargets(
784
+ row.content,
785
+ (target) => mapping.get(target.toLowerCase()) ?? null,
786
+ );
666
787
  if (updated !== row.content) {
667
- noteOps.updateNote(this.db, row.id, { content: updated });
668
- syncWikilinks(this.db, row.id, updated);
788
+ noteOps.updateNote(this.db, sourceId, { content: updated });
789
+ syncWikilinks(this.db, sourceId, updated);
669
790
  }
670
791
  }
671
792
  }
@@ -706,7 +827,13 @@ export class BunSqliteStore implements Store {
706
827
  // comment for why this must run pre-delete and what it deliberately
707
828
  // excludes (typed `links`, not just wikilinks).
708
829
  requeueInboundWikilinksForDelete(this.db, id);
830
+ // vault#581 — the deleted note's resolution keys, captured BEFORE the row
831
+ // goes away and swept AFTER, so a `[[Dup]]` that was ambiguous only
832
+ // because of THIS note resolves (or, if it was the last candidate,
833
+ // demotes to an ordinary broken link).
834
+ const ambiguityKeys = noteResolutionKeys(existing);
709
835
  noteOps.deleteNote(this.db, id);
836
+ refreshAmbiguousLinks(this.db, ambiguityKeys);
710
837
  if (existing?.path) this.invalidateConfigCachesForPath(existing.path);
711
838
  // Dispatch even when `existing` was null — the caller asked for a
712
839
  // deletion, and downstream consumers (e.g. the mirror) reconcile via
@@ -1601,7 +1728,3 @@ export class BunSqliteStore implements Store {
1601
1728
  export const SqliteStore = BunSqliteStore;
1602
1729
  /** @deprecated Renamed to `BunSqliteStore`. */
1603
1730
  export type SqliteStore = BunSqliteStore;
1604
-
1605
- function escapeRegex(s: string): string {
1606
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1607
- }
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { describe, it, expect } from "bun:test";
14
14
  import { Database } from "bun:sqlite";
15
- import { transaction, transactionAsync, type TxnCapableDb } from "./txn.js";
15
+ import { afterCommit, transaction, transactionAsync, type TxnCapableDb } from "./txn.js";
16
16
 
17
17
  /** A fake `TxnCapableDb` that records exec calls and can be told to throw on
18
18
  * COMMIT and/or ROLLBACK — lets us drive the failure branches exactly. */
@@ -261,6 +261,38 @@ describe("transactionAsync", () => {
261
261
  });
262
262
 
263
263
  describe("transaction — re-entrancy via SAVEPOINTs (vault#589)", () => {
264
+ it("does not attempt rollback when an after-commit callback throws", () => {
265
+ const { db, calls } = fakeDb();
266
+ expect(() => transaction(db, () => {
267
+ afterCommit(db, () => { throw new Error("post-commit boom"); });
268
+ })).toThrow("post-commit boom");
269
+ expect(calls).toEqual(["BEGIN IMMEDIATE", "COMMIT"]);
270
+ });
271
+
272
+ it("runs nested after-commit work only after the outer commit", async () => {
273
+ const db = freshDb();
274
+ const calls: string[] = [];
275
+ await transactionAsync(db, async () => {
276
+ transaction(db, () => {
277
+ afterCommit(db, () => calls.push("after commit"));
278
+ });
279
+ expect(calls).toEqual([]);
280
+ });
281
+ expect(calls).toEqual(["after commit"]);
282
+ });
283
+
284
+ it("discards nested after-commit work when the outer transaction rolls back", async () => {
285
+ const db = freshDb();
286
+ const calls: string[] = [];
287
+ await expect(transactionAsync(db, async () => {
288
+ transaction(db, () => {
289
+ afterCommit(db, () => calls.push("after commit"));
290
+ });
291
+ throw new Error("outer boom");
292
+ })).rejects.toThrow("outer boom");
293
+ expect(calls).toEqual([]);
294
+ });
295
+
264
296
  it("a nested sync transaction uses SAVEPOINT/RELEASE, not a second BEGIN", () => {
265
297
  const { db, calls } = fakeDb();
266
298
  const out = transaction(db, () => {
package/core/src/txn.ts CHANGED
@@ -76,6 +76,49 @@ export interface TxnCapableDb {
76
76
  */
77
77
  const txnDepth = new WeakMap<TxnCapableDb, number>();
78
78
 
79
+ /**
80
+ * Post-commit callbacks, stacked per transaction frame. A nested transaction
81
+ * merges its callbacks into its parent when its SAVEPOINT is released; only
82
+ * the outermost successful commit runs them. A rollback discards the frame.
83
+ */
84
+ const afterCommitFrames = new WeakMap<TxnCapableDb, Array<Array<() => void>>>();
85
+
86
+ function enterAfterCommitFrame(db: TxnCapableDb): void {
87
+ const frames = afterCommitFrames.get(db) ?? [];
88
+ frames.push([]);
89
+ afterCommitFrames.set(db, frames);
90
+ }
91
+
92
+ function commitAfterCommitFrame(db: TxnCapableDb): void {
93
+ const frames = afterCommitFrames.get(db);
94
+ const callbacks = frames?.pop() ?? [];
95
+ const parent = frames?.at(-1);
96
+ if (parent) {
97
+ parent.push(...callbacks);
98
+ return;
99
+ }
100
+ afterCommitFrames.delete(db);
101
+ for (const callback of callbacks) callback();
102
+ }
103
+
104
+ function rollbackAfterCommitFrame(db: TxnCapableDb): void {
105
+ const frames = afterCommitFrames.get(db);
106
+ frames?.pop();
107
+ if (!frames || frames.length === 0) afterCommitFrames.delete(db);
108
+ }
109
+
110
+ /**
111
+ * Run a callback only after the surrounding transaction stack commits. When
112
+ * called outside a transaction it runs immediately. This keeps hooks and
113
+ * cache invalidation honest for synchronous store writes nested inside an
114
+ * outer {@link transactionAsync} batch.
115
+ */
116
+ export function afterCommit(db: TxnCapableDb, callback: () => void): void {
117
+ const frame = afterCommitFrames.get(db)?.at(-1);
118
+ if (frame) frame.push(callback);
119
+ else callback();
120
+ }
121
+
79
122
  /** One entry on the nesting stack: whether this frame owns the outer
80
123
  * `BEGIN … COMMIT`/`ROLLBACK` (outermost) or a `SAVEPOINT` (nested), plus the
81
124
  * depth to restore on exit. */
@@ -153,18 +196,38 @@ function rollbackTxn(db: TxnCapableDb, frame: TxnFrame): void {
153
196
  * below unchanged.
154
197
  */
155
198
  export function transaction<T>(db: TxnCapableDb, fn: () => T): T {
199
+ enterAfterCommitFrame(db);
156
200
  if (typeof db.transactionSync === "function") {
157
- return db.transactionSync(fn);
201
+ let result: T;
202
+ try {
203
+ result = db.transactionSync(fn);
204
+ } catch (err) {
205
+ rollbackAfterCommitFrame(db);
206
+ throw err;
207
+ }
208
+ commitAfterCommitFrame(db);
209
+ return result;
210
+ }
211
+ let frame: TxnFrame;
212
+ try {
213
+ frame = enterTxn(db);
214
+ } catch (err) {
215
+ rollbackAfterCommitFrame(db);
216
+ throw err;
158
217
  }
159
- const frame = enterTxn(db);
218
+ let result: T;
160
219
  try {
161
- const result = fn();
220
+ result = fn();
162
221
  commitTxn(db, frame);
163
- return result;
164
222
  } catch (err) {
223
+ rollbackAfterCommitFrame(db);
165
224
  rollbackTxn(db, frame);
166
225
  throw err;
167
226
  }
227
+ // The SQL transaction is already resolved. A callback error must propagate
228
+ // without attempting a misleading ROLLBACK after COMMIT.
229
+ commitAfterCommitFrame(db);
230
+ return result;
168
231
  }
169
232
 
170
233
  /**
@@ -188,13 +251,23 @@ export function transaction<T>(db: TxnCapableDb, fn: () => T): T {
188
251
  * than a second `BEGIN`. See the file header.
189
252
  */
190
253
  export async function transactionAsync<T>(db: TxnCapableDb, fn: () => Promise<T>): Promise<T> {
191
- const frame = enterTxn(db);
254
+ enterAfterCommitFrame(db);
255
+ let frame: TxnFrame;
256
+ try {
257
+ frame = enterTxn(db);
258
+ } catch (err) {
259
+ rollbackAfterCommitFrame(db);
260
+ throw err;
261
+ }
262
+ let result: T;
192
263
  try {
193
- const result = await fn();
264
+ result = await fn();
194
265
  commitTxn(db, frame);
195
- return result;
196
266
  } catch (err) {
267
+ rollbackAfterCommitFrame(db);
197
268
  rollbackTxn(db, frame);
198
269
  throw err;
199
270
  }
271
+ commitAfterCommitFrame(db);
272
+ return result;
200
273
  }
package/core/src/types.ts CHANGED
@@ -188,6 +188,17 @@ export interface QueryOpts {
188
188
  * had a broken link) — `true` matches nothing, `false` is a no-op.
189
189
  */
190
190
  hasBrokenLinks?: boolean;
191
+ /**
192
+ * Presence filter on the `ambiguous_wikilinks` table (vault#581):
193
+ * `true` → only notes with at least one AMBIGUOUS outbound link (a
194
+ * `[[wikilink]]` or structured `links` target that matched ≥2 notes, so
195
+ * no link was created and none was guessed at); `false` → only notes with
196
+ * none. Disjoint from `hasBrokenLinks`: a dangling target matched NOTHING,
197
+ * an ambiguous one matched too much. Safe on a vault where the
198
+ * `ambiguous_wikilinks` table has never been created (no link has ever
199
+ * been ambiguous) — `true` matches nothing, `false` is a no-op.
200
+ */
201
+ hasAmbiguousLinks?: boolean;
191
202
  path?: string; // exact path match (case-insensitive)
192
203
  pathPrefix?: string; // e.g., "Projects/Parachute" matches "Projects/Parachute/README"
193
204
  /**
@@ -358,7 +358,14 @@ export function projectionToMarkdown(args: {
358
358
 
359
359
  const lines: string[] = [];
360
360
  lines.push(`You are connected to Parachute Vault "${vaultName}".`);
361
- if (description && description.trim().length > 0) {
361
+ // vault#669 / cloud#87 recovery: a non-string description (poisoned
362
+ // via an unguarded write door) used to throw here (`description.trim
363
+ // is not a function`) and take down MCP `initialize` — the first
364
+ // frame, so the connected AI never reached a tool that could repair
365
+ // it. Skip a non-string the same way we skip null/empty: render the
366
+ // base brief. The write doors now reject this shape; this is the
367
+ // already-poisoned reconnect path.
368
+ if (typeof description === "string" && description.trim().length > 0) {
362
369
  lines.push("");
363
370
  lines.push(description.trim());
364
371
  }