@openparachute/vault 0.7.1 → 0.7.2-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.
@@ -89,9 +89,12 @@ describe("transaction (sync)", () => {
89
89
  expect(calls).toEqual(["BEGIN IMMEDIATE", "COMMIT", "ROLLBACK"]);
90
90
  });
91
91
 
92
- it("preserves the callback error even when ROLLBACK also fails", () => {
92
+ it("preserves the callback error when ROLLBACK fails for the expected 'already-resolved' reason", () => {
93
93
  const callbackErr = new Error("callback boom");
94
- const rollbackErr = new Error("rollback boom");
94
+ // The one swallowed case: the txn was already resolved, so ROLLBACK is a
95
+ // no-op that reports "no transaction is active". The ORIGINAL callback
96
+ // error is the root cause and must still propagate.
97
+ const rollbackErr = new Error("cannot rollback - no transaction is active");
95
98
  const { db } = fakeDb({ rollbackThrows: rollbackErr });
96
99
  let thrown: unknown;
97
100
  try {
@@ -101,6 +104,22 @@ describe("transaction (sync)", () => {
101
104
  }
102
105
  expect(thrown).toBe(callbackErr);
103
106
  });
107
+
108
+ it("re-throws an UNEXPECTED ROLLBACK failure instead of silently masking it (vault#589 NIT 2)", () => {
109
+ const callbackErr = new Error("callback boom");
110
+ // A rollback failure that is NOT the benign "already-resolved" case (a
111
+ // corrupt connection / lost write lock) means the transaction may NOT have
112
+ // rolled back — surfacing it beats a false "clean rollback".
113
+ const rollbackErr = new Error("database disk image is malformed");
114
+ const { db } = fakeDb({ rollbackThrows: rollbackErr });
115
+ let thrown: unknown;
116
+ try {
117
+ transaction(db, () => { throw callbackErr; });
118
+ } catch (e) {
119
+ thrown = e;
120
+ }
121
+ expect(thrown).toBe(rollbackErr);
122
+ });
104
123
  });
105
124
 
106
125
  describe("transaction — native transactionSync preference (DO backend)", () => {
@@ -214,9 +233,9 @@ describe("transactionAsync", () => {
214
233
  expect(calls).toEqual(["BEGIN IMMEDIATE", "COMMIT", "ROLLBACK"]);
215
234
  });
216
235
 
217
- it("preserves the callback error even when ROLLBACK also fails", async () => {
236
+ it("preserves the callback error when ROLLBACK fails for the expected 'already-resolved' reason", async () => {
218
237
  const callbackErr = new Error("callback boom");
219
- const rollbackErr = new Error("rollback boom");
238
+ const rollbackErr = new Error("cannot rollback - no transaction is active");
220
239
  const { db } = fakeDb({ rollbackThrows: rollbackErr });
221
240
  let thrown: unknown;
222
241
  try {
@@ -226,4 +245,81 @@ describe("transactionAsync", () => {
226
245
  }
227
246
  expect(thrown).toBe(callbackErr);
228
247
  });
248
+
249
+ it("re-throws an UNEXPECTED ROLLBACK failure instead of silently masking it (vault#589 NIT 2)", async () => {
250
+ const callbackErr = new Error("callback boom");
251
+ const rollbackErr = new Error("database disk image is malformed");
252
+ const { db } = fakeDb({ rollbackThrows: rollbackErr });
253
+ let thrown: unknown;
254
+ try {
255
+ await transactionAsync(db, async () => { throw callbackErr; });
256
+ } catch (e) {
257
+ thrown = e;
258
+ }
259
+ expect(thrown).toBe(rollbackErr);
260
+ });
261
+ });
262
+
263
+ describe("transaction — re-entrancy via SAVEPOINTs (vault#589)", () => {
264
+ it("a nested sync transaction uses SAVEPOINT/RELEASE, not a second BEGIN", () => {
265
+ const { db, calls } = fakeDb();
266
+ const out = transaction(db, () => {
267
+ const inner = transaction(db, () => "inner");
268
+ expect(inner).toBe("inner");
269
+ return "outer";
270
+ });
271
+ expect(out).toBe("outer");
272
+ expect(calls).toEqual([
273
+ "BEGIN IMMEDIATE",
274
+ "SAVEPOINT parachute_sp_1",
275
+ "RELEASE parachute_sp_1",
276
+ "COMMIT",
277
+ ]);
278
+ });
279
+
280
+ it("an inner throw rolls back to the savepoint; the outer can still commit", () => {
281
+ const db = freshDb();
282
+ transaction(db, () => {
283
+ db.exec("INSERT INTO t (id, v) VALUES (1, 'keep')");
284
+ try {
285
+ transaction(db, () => {
286
+ db.exec("INSERT INTO t (id, v) VALUES (2, 'drop')");
287
+ throw new Error("inner boom");
288
+ });
289
+ } catch {
290
+ // Outer decides to continue past the failed nested block.
291
+ }
292
+ db.exec("INSERT INTO t (id, v) VALUES (3, 'keep2')");
293
+ });
294
+ // Only the savepoint'd insert (2) was undone; 1 and 3 committed.
295
+ const ids = (db.prepare("SELECT id FROM t ORDER BY id").all() as { id: number }[]).map((r) => r.id);
296
+ expect(ids).toEqual([1, 3]);
297
+ });
298
+
299
+ it("an UNCAUGHT inner throw rolls back the WHOLE outer transaction", () => {
300
+ const db = freshDb();
301
+ expect(() =>
302
+ transaction(db, () => {
303
+ db.exec("INSERT INTO t (id, v) VALUES (1, 'a')");
304
+ transaction(db, () => {
305
+ db.exec("INSERT INTO t (id, v) VALUES (2, 'b')");
306
+ throw new Error("boom");
307
+ });
308
+ }),
309
+ ).toThrow("boom");
310
+ expect((db.prepare("SELECT COUNT(*) AS c FROM t").get() as { c: number }).c).toBe(0);
311
+ });
312
+
313
+ it("transactionAsync composes a nested sync transaction via SAVEPOINT (the atomic blow-away shape)", async () => {
314
+ const { db, calls } = fakeDb();
315
+ await transactionAsync(db, async () => {
316
+ transaction(db, () => "nested-sync-write");
317
+ });
318
+ expect(calls).toEqual([
319
+ "BEGIN IMMEDIATE",
320
+ "SAVEPOINT parachute_sp_1",
321
+ "RELEASE parachute_sp_1",
322
+ "COMMIT",
323
+ ]);
324
+ });
229
325
  });
package/core/src/txn.ts CHANGED
@@ -14,12 +14,40 @@
14
14
  * one of them only ever wrote, so acquiring the write lock eagerly is a
15
15
  * strict improvement (no lazy busy-upgrade) and never a semantic change.
16
16
  *
17
- * **Nesting is unsupported**, matching the pre-seam code exactly: SQLite
18
- * throws "cannot start a transaction within a transaction" on a nested
19
- * `BEGIN`, and none of the migrated call sites nest (a batch wraps individual
20
- * `createNote`s, none of which open their own transaction). If a nested
21
- * caller ever appears, the fix is SAVEPOINT-based re-entrancy *in the bun
22
- * implementation here* the call sites stay untouched.
17
+ * **Nesting via SAVEPOINTs** (vault#589): the outermost `transaction` /
18
+ * `transactionAsync` on a connection opens a real `BEGIN IMMEDIATE`; a nested
19
+ * call (one whose connection is already inside a transaction) opens a
20
+ * `SAVEPOINT` instead, so composing an atomic block out of primitives that
21
+ * each open their own transaction never hits SQLite's "cannot start a
22
+ * transaction within a transaction". This is the re-entrancy the original
23
+ * seam anticipated ("the fix is SAVEPOINT-based re-entrancy in the bun
24
+ * implementation here — the call sites stay untouched"). The nesting depth is
25
+ * tracked per connection in {@link txnDepth}; a non-nested caller (depth 0 →
26
+ * 1) is byte-for-byte the old `BEGIN … COMMIT` / `ROLLBACK` path, so existing
27
+ * single-level callers are unaffected. The motivating nested caller is the
28
+ * atomic blow-away import (`importVault` wrapping schema/note/link writes,
29
+ * several of which open their own `transaction`).
30
+ *
31
+ * The `transactionSync` (Durable-Object) delegate path is unchanged — a DO
32
+ * backend supplies its own natively re-entrant primitive, so the SAVEPOINT
33
+ * bookkeeping here only governs the bun `BEGIN` path.
34
+ *
35
+ * **Shared-connection invariant (load-bearing for {@link transactionAsync}).**
36
+ * The bun backend is a single synchronous connection. A `transactionAsync`
37
+ * body — AND anything it calls — must not `await` genuine, slow I/O while the
38
+ * transaction is open: every `await` yields the event loop, and any work that
39
+ * runs during that yield and touches the store (a concurrent request, a
40
+ * mutation hook that writes before its first real await) will `SAVEPOINT`-JOIN
41
+ * this open transaction on the shared connection — committing/rolling back
42
+ * with it, its writes cross-wiped by a `ROLLBACK TO`. The blow-away import
43
+ * (the sole `transactionAsync` wrapping non-batch work) is safe by
44
+ * construction: its replay awaits only already-resolved promises over the
45
+ * synchronous bun store, so it never actually yields to unrelated work
46
+ * mid-transaction, and the mutation hooks it fires do no synchronous store
47
+ * writes (they defer via `queueMicrotask` and arm debounces / kick idempotent
48
+ * workers — see hooks.ts). Any NEW `transactionAsync` caller must preserve
49
+ * this: keep the body's awaits confined to the same synchronous connection,
50
+ * never awaiting network/disk/subprocess I/O with the transaction open.
23
51
  */
24
52
 
25
53
  /** The minimal DB surface the transaction seam needs — kept structural (no
@@ -38,6 +66,79 @@ export interface TxnCapableDb {
38
66
  transactionSync?<T>(fn: () => T): T;
39
67
  }
40
68
 
69
+ /**
70
+ * Per-connection nesting depth for the bun `BEGIN` path. Keyed on the db
71
+ * handle so two connections track independently; a `WeakMap` so a dropped
72
+ * connection's entry is collected. Depth 0 = no open transaction; the first
73
+ * `transaction`/`transactionAsync` opens `BEGIN IMMEDIATE` and sets depth 1;
74
+ * each further nested call opens a `SAVEPOINT` and bumps the depth. See the
75
+ * file header (vault#589).
76
+ */
77
+ const txnDepth = new WeakMap<TxnCapableDb, number>();
78
+
79
+ /** One entry on the nesting stack: whether this frame owns the outer
80
+ * `BEGIN … COMMIT`/`ROLLBACK` (outermost) or a `SAVEPOINT` (nested), plus the
81
+ * depth to restore on exit. */
82
+ interface TxnFrame {
83
+ outermost: boolean;
84
+ savepoint: string | null;
85
+ priorDepth: number;
86
+ }
87
+
88
+ /** Open a transaction frame: `BEGIN IMMEDIATE` at depth 0, else a uniquely
89
+ * named `SAVEPOINT`. Bumps {@link txnDepth} only after the SQL succeeds. */
90
+ function enterTxn(db: TxnCapableDb): TxnFrame {
91
+ const priorDepth = txnDepth.get(db) ?? 0;
92
+ if (priorDepth === 0) {
93
+ db.exec("BEGIN IMMEDIATE");
94
+ txnDepth.set(db, 1);
95
+ return { outermost: true, savepoint: null, priorDepth };
96
+ }
97
+ const savepoint = `parachute_sp_${priorDepth}`;
98
+ db.exec(`SAVEPOINT ${savepoint}`);
99
+ txnDepth.set(db, priorDepth + 1);
100
+ return { outermost: false, savepoint, priorDepth };
101
+ }
102
+
103
+ /** Commit a frame: `COMMIT` for the outermost, `RELEASE` for a savepoint
104
+ * (which merges its writes into the enclosing transaction). */
105
+ function commitTxn(db: TxnCapableDb, frame: TxnFrame): void {
106
+ if (frame.outermost) db.exec("COMMIT");
107
+ else db.exec(`RELEASE ${frame.savepoint}`);
108
+ txnDepth.set(db, frame.priorDepth);
109
+ }
110
+
111
+ /** Roll a frame back: `ROLLBACK` for the outermost (whole transaction),
112
+ * `ROLLBACK TO` + `RELEASE` for a savepoint (unwind just this frame; the
113
+ * error still propagates so an enclosing frame decides its own fate).
114
+ * Best-effort — if the COMMIT itself threw, the transaction is already
115
+ * resolved and these throw "no transaction"/"no such savepoint"; swallow so
116
+ * the original error propagates. */
117
+ function rollbackTxn(db: TxnCapableDb, frame: TxnFrame): void {
118
+ try {
119
+ if (frame.outermost) {
120
+ db.exec("ROLLBACK");
121
+ } else {
122
+ db.exec(`ROLLBACK TO ${frame.savepoint}`);
123
+ db.exec(`RELEASE ${frame.savepoint}`);
124
+ }
125
+ } catch (rollbackErr) {
126
+ // Only swallow the ONE expected case: the COMMIT/RELEASE itself already
127
+ // resolved the transaction, so there's nothing left to unwind — SQLite
128
+ // reports "cannot rollback - no transaction is active" / "no such
129
+ // savepoint". Any OTHER rollback failure is a real problem (a corrupt
130
+ // connection, a lost write lock) and must not be masked — re-throw it so
131
+ // the caller sees a failed rollback rather than a false success. Depth is
132
+ // restored first so the connection's bookkeeping stays consistent even on
133
+ // the re-throw path.
134
+ txnDepth.set(db, frame.priorDepth);
135
+ const msg = String((rollbackErr as Error)?.message ?? rollbackErr);
136
+ if (/no transaction is active|no such savepoint/i.test(msg)) return;
137
+ throw rollbackErr;
138
+ }
139
+ txnDepth.set(db, frame.priorDepth);
140
+ }
141
+
41
142
  /**
42
143
  * Run `fn` inside a single write transaction, committing its result or
43
144
  * rolling back on throw. Synchronous — the callback must not await (see
@@ -55,20 +156,13 @@ export function transaction<T>(db: TxnCapableDb, fn: () => T): T {
55
156
  if (typeof db.transactionSync === "function") {
56
157
  return db.transactionSync(fn);
57
158
  }
58
- db.exec("BEGIN IMMEDIATE");
159
+ const frame = enterTxn(db);
59
160
  try {
60
161
  const result = fn();
61
- db.exec("COMMIT");
162
+ commitTxn(db, frame);
62
163
  return result;
63
164
  } catch (err) {
64
- // Best-effort rollback — if the COMMIT itself threw the transaction is
65
- // already resolved and ROLLBACK would throw "no transaction is active";
66
- // swallow that so the original error is the one that propagates.
67
- try {
68
- db.exec("ROLLBACK");
69
- } catch {
70
- // no active transaction to roll back
71
- }
165
+ rollbackTxn(db, frame);
72
166
  throw err;
73
167
  }
74
168
  }
@@ -86,20 +180,21 @@ export function transaction<T>(db: TxnCapableDb, fn: () => T): T {
86
180
  * so it cannot wrap an `async` body that awaits the Store facade between writes.
87
181
  * A DO backend that needs an atomic async batch has to reshape the batch as a
88
182
  * synchronous unit (or use an async DO transaction primitive) — out of scope
89
- * for this seam; the raw `BEGIN IMMEDIATE` path stays the single implementation.
183
+ * for this seam; the bun `BEGIN IMMEDIATE` path stays the single implementation.
184
+ *
185
+ * Re-entrant (vault#589): if the connection is already inside a transaction —
186
+ * e.g. the atomic blow-away import wraps this around store writes that
187
+ * themselves call {@link transaction} — a nested call opens a SAVEPOINT rather
188
+ * than a second `BEGIN`. See the file header.
90
189
  */
91
190
  export async function transactionAsync<T>(db: TxnCapableDb, fn: () => Promise<T>): Promise<T> {
92
- db.exec("BEGIN IMMEDIATE");
191
+ const frame = enterTxn(db);
93
192
  try {
94
193
  const result = await fn();
95
- db.exec("COMMIT");
194
+ commitTxn(db, frame);
96
195
  return result;
97
196
  } catch (err) {
98
- try {
99
- db.exec("ROLLBACK");
100
- } catch {
101
- // no active transaction to roll back
102
- }
197
+ rollbackTxn(db, frame);
103
198
  throw err;
104
199
  }
105
200
  }
@@ -10,7 +10,10 @@ import {
10
10
  listUnresolvedWikilinks,
11
11
  getContentWikilinkWarnings,
12
12
  resolveOrQueueLink,
13
+ requeueInboundWikilinksForDelete,
14
+ getUnresolvedLinksForNote,
13
15
  } from "./wikilinks.js";
16
+ import { findPath } from "./links.js";
14
17
 
15
18
  let store: SqliteStore;
16
19
  let db: Database;
@@ -480,6 +483,178 @@ describe("path-based resolution", async () => {
480
483
  });
481
484
  });
482
485
 
486
+ // ---------------------------------------------------------------------------
487
+ // requeueInboundWikilinksForDelete (LB6) — a deleted note's inbound wikilink
488
+ // edges must re-queue so recreating a note at the same path/title heals
489
+ // them, instead of staying dead until the SOURCE note is individually
490
+ // re-saved.
491
+ // ---------------------------------------------------------------------------
492
+
493
+ describe("delete → recreate re-resolves inbound wikilinks (LB6)", () => {
494
+ it("A [[Foo]] survives Foo's delete-then-recreate without touching A", async () => {
495
+ const a = await store.createNote("Link to [[Foo]]", { path: "A" });
496
+
497
+ // Foo doesn't exist yet — the link is queued, not yet live.
498
+ expect(await store.getLinks(a.id, { direction: "outbound" })).toHaveLength(0);
499
+ const pendingBefore = db.prepare(
500
+ "SELECT target_path FROM unresolved_wikilinks WHERE source_id = ?",
501
+ ).all(a.id) as { target_path: string }[];
502
+ expect(pendingBefore.map((r) => r.target_path)).toEqual(["Foo"]);
503
+
504
+ // Create Foo — the wikilink resolves and the edge + backlink exist.
505
+ const foo1 = await store.createNote("First Foo", { path: "Foo" });
506
+ let links = await store.getLinks(a.id, { direction: "outbound" });
507
+ expect(links).toHaveLength(1);
508
+ expect(links[0]!.targetId).toBe(foo1.id);
509
+ expect(findPath(db, a.id, foo1.id)).not.toBeNull();
510
+ const backlinks = await store.getLinks(foo1.id, { direction: "inbound" });
511
+ expect(backlinks.some((l) => l.sourceId === a.id)).toBe(true);
512
+
513
+ // Delete Foo. Without the LB6 fix, `unresolved_wikilinks` stays empty —
514
+ // nothing pending — even though A's `[[Foo]]` text is untouched.
515
+ await store.deleteNote(foo1.id);
516
+ expect(await store.getLinks(a.id, { direction: "outbound" })).toHaveLength(0);
517
+ const pendingAfterDelete = db.prepare(
518
+ "SELECT target_path, relationship FROM unresolved_wikilinks WHERE source_id = ?",
519
+ ).all(a.id) as { target_path: string; relationship: string }[];
520
+ expect(pendingAfterDelete).toEqual([{ target_path: "Foo", relationship: "wikilink" }]);
521
+
522
+ // Recreate Foo (new note, new id — same path). A was never re-saved.
523
+ const foo2 = await store.createNote("Second Foo", { path: "Foo" });
524
+
525
+ // The A -> Foo edge is back, pointed at the NEW Foo, and find-path sees it.
526
+ links = await store.getLinks(a.id, { direction: "outbound" });
527
+ expect(links).toHaveLength(1);
528
+ expect(links[0]!.targetId).toBe(foo2.id);
529
+ expect(links[0]!.relationship).toBe("wikilink");
530
+ expect(findPath(db, a.id, foo2.id)).not.toBeNull();
531
+
532
+ // A's content was never touched.
533
+ const reread = await store.getNote(a.id);
534
+ expect(reread!.content).toBe("Link to [[Foo]]");
535
+
536
+ // The pending row is consumed on resolution.
537
+ const pendingAfterRecreate = db.prepare(
538
+ "SELECT * FROM unresolved_wikilinks WHERE source_id = ?",
539
+ ).all(a.id);
540
+ expect(pendingAfterRecreate).toHaveLength(0);
541
+ });
542
+
543
+ it("only re-queues wikilink-relationship inbound edges, not explicit typed links", async () => {
544
+ const source = await store.createNote("A", { id: "src", path: "Src" });
545
+ const target = await store.createNote("B", { id: "tgt", path: "Tgt" });
546
+ await store.createLink("src", "tgt", "related-to"); // hand-authored typed link, not a wikilink
547
+
548
+ requeueInboundWikilinksForDelete(db, target.id);
549
+
550
+ // getUnresolvedLinksForNote tolerates the table not existing at all
551
+ // (no wikilink was ever parsed in this test) — a raw SELECT would throw.
552
+ expect(getUnresolvedLinksForNote(db, source.id)).toHaveLength(0);
553
+ });
554
+
555
+ it("re-queues via basename resolution, not just exact path, and recovers on recreate at the same path", async () => {
556
+ // Target lives at a nested path; source links via the bare basename —
557
+ // resolved through resolveWikilink's basename fallback (rule #3), not
558
+ // an exact path match.
559
+ const target = await store.createNote("# Weekly Review\n\nBody.", { path: "Projects/Weekly Review" });
560
+ const source = await store.createNote("See [[Weekly Review]]", { path: "A" });
561
+ expect(await store.getLinks(source.id, { direction: "outbound" })).toHaveLength(1);
562
+
563
+ await store.deleteNote(target.id);
564
+
565
+ const pending = db.prepare(
566
+ "SELECT target_path FROM unresolved_wikilinks WHERE source_id = ?",
567
+ ).all(source.id) as { target_path: string }[];
568
+ // The re-queued key is the RAW bracket text ("Weekly Review"), not the
569
+ // deleted note's full path — that's what the basename-fallback lazy
570
+ // resolver (`resolveUnresolvedWikilinks`'s `? LIKE '%/' || target_path`
571
+ // suffix match) actually matches on.
572
+ expect(pending.map((r) => r.target_path)).toEqual(["Weekly Review"]);
573
+
574
+ // Recreate at the SAME nested path — the suffix match backfills it.
575
+ const target2 = await store.createNote("# Weekly Review v2\n\nBody.", { path: "Projects/Weekly Review" });
576
+ const links = await store.getLinks(source.id, { direction: "outbound" });
577
+ expect(links).toHaveLength(1);
578
+ expect(links[0]!.targetId).toBe(target2.id);
579
+ });
580
+
581
+ // BLOCKER (Fable review) — the delete→recreate re-heal must cover links
582
+ // that resolved via the H1-TITLE fallback and the explicit-EXTENSION form,
583
+ // not just path/basename. The re-queued row's target string is the raw
584
+ // bracket text (e.g. "John Doe" / "budget.csv"), which the OLD sweep
585
+ // matched against the new note by PATH TEXT ONLY — so a title- or
586
+ // extension-resolved link stayed queued (0 links rebuilt) forever. The
587
+ // sweep now runs each candidate through the SAME resolver write-time uses,
588
+ // so deferred resolution finally matches write-time on all four legs.
589
+ it("re-heals a TITLE-fallback link AND an EXTENSION-form link on delete→recreate at the same path+H1", async () => {
590
+ // A resolves to `people/jdoe` only via its H1 title "John Doe".
591
+ // B resolves to `budget` (ext csv) only via the explicit [[budget.csv]]
592
+ // form (the extension leg is exact-path, so `budget` lives at path
593
+ // "budget", disambiguating it from a same-named `.md` note).
594
+ const person = await store.createNote("# John Doe\n\nBio.", { path: "people/jdoe" });
595
+ const budget = await store.createNote("month,total\n2026-01,9000", { path: "budget", extension: "csv" });
596
+ const a = await store.createNote("met [[John Doe]] today", { path: "A" });
597
+ const b = await store.createNote("see [[budget.csv]]", { path: "B" });
598
+
599
+ // Both links live at write time (title fallback + extension form).
600
+ expect((await store.getLinks(a.id, { direction: "outbound" }))[0]?.targetId).toBe(person.id);
601
+ expect((await store.getLinks(b.id, { direction: "outbound" }))[0]?.targetId).toBe(budget.id);
602
+
603
+ await store.deleteNote(person.id);
604
+ await store.deleteNote(budget.id);
605
+
606
+ // Both re-queued with their raw bracket text — NOT the deleted notes' paths.
607
+ const pendingA = db.prepare("SELECT target_path FROM unresolved_wikilinks WHERE source_id = ?").all(a.id) as { target_path: string }[];
608
+ const pendingB = db.prepare("SELECT target_path FROM unresolved_wikilinks WHERE source_id = ?").all(b.id) as { target_path: string }[];
609
+ expect(pendingA.map((r) => r.target_path)).toEqual(["John Doe"]);
610
+ expect(pendingB.map((r) => r.target_path)).toEqual(["budget.csv"]);
611
+ expect(await store.getLinks(a.id, { direction: "outbound" })).toHaveLength(0);
612
+ expect(await store.getLinks(b.id, { direction: "outbound" })).toHaveLength(0);
613
+
614
+ // Recreate each at the SAME path + H1 (new note, new id). Neither A nor B
615
+ // is re-saved — only the deferred sweep can rebuild these edges.
616
+ const person2 = await store.createNote("# John Doe\n\nBio v2.", { path: "people/jdoe" });
617
+ const budget2 = await store.createNote("month,total\n2026-02,9500", { path: "budget", extension: "csv" });
618
+
619
+ // BOTH re-heal (RED before the sweep completion — title/ext legs missing).
620
+ const aLinks = await store.getLinks(a.id, { direction: "outbound" });
621
+ const bLinks = await store.getLinks(b.id, { direction: "outbound" });
622
+ expect(aLinks).toHaveLength(1);
623
+ expect(aLinks[0]!.targetId).toBe(person2.id);
624
+ expect(findPath(db, a.id, person2.id)).not.toBeNull();
625
+ expect(bLinks).toHaveLength(1);
626
+ expect(bLinks[0]!.targetId).toBe(budget2.id);
627
+
628
+ // Pending rows consumed on resolution.
629
+ expect(getUnresolvedLinksForNote(db, a.id)).toHaveLength(0);
630
+ expect(getUnresolvedLinksForNote(db, b.id)).toHaveLength(0);
631
+ });
632
+
633
+ // The completed sweep must NOT mis-resolve an AMBIGUOUS target — matching
634
+ // write-time's "don't guess" contract. A pending [[John Doe]] is swept when
635
+ // a same-titled note is created; if TWO notes already share that H1 by the
636
+ // time the sweep verifies, the resolver returns ambiguous and the row stays
637
+ // queued rather than the old path-only sweep's "link to whoever showed up."
638
+ // (Reached via a pathless first note — the sweep is path-gated, so it never
639
+ // fired to consume the row when only ONE John Doe existed.)
640
+ it("does not re-heal a title link when the target is ambiguous (two notes share the H1)", async () => {
641
+ const a = await store.createNote("met [[John Doe]]", { path: "A" });
642
+ expect(getUnresolvedLinksForNote(db, a.id).map((l) => l.target)).toEqual(["John Doe"]); // queued, no John Doe yet
643
+
644
+ // A PATHLESS note carrying the H1 — createNote's sweep is path-gated, so
645
+ // it does NOT fire here; A's pending row survives with one John Doe extant.
646
+ await store.createNote("# John Doe\n\nfirst.");
647
+ expect(getUnresolvedLinksForNote(db, a.id).map((l) => l.target)).toEqual(["John Doe"]); // still queued
648
+
649
+ // A SECOND note with the same H1, WITH a path → the sweep fires. Now two
650
+ // notes carry "John Doe" → resolveWikilinkDetailed is ambiguous → the row
651
+ // is left queued, nothing linked.
652
+ await store.createNote("# John Doe\n\nsecond.", { path: "people/jd" });
653
+ expect(await store.getLinks(a.id, { direction: "outbound" })).toHaveLength(0);
654
+ expect(getUnresolvedLinksForNote(db, a.id).map((l) => l.target)).toEqual(["John Doe"]);
655
+ });
656
+ });
657
+
483
658
  // ---------------------------------------------------------------------------
484
659
  // unresolved_wikilinks relationship-column migration — atomicity (vault#555
485
660
  // wire+generalist must-fix; W7's migrateToV25-interruption lesson applied).