@openparachute/vault 0.7.2-rc.3 → 0.7.2-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.
@@ -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).
@@ -1,7 +1,7 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import type { Note } from "./types.js";
3
3
  import * as linkOps from "./links.js";
4
- import { getNote, findNotesByTitle } from "./notes.js";
4
+ import { getNote, findNotesByTitle, extractH1Title } from "./notes.js";
5
5
  import { chunkForInClause } from "./sql-in.js";
6
6
  import { transaction } from "./txn.js";
7
7
  import type { QueryWarning } from "./query-warnings.js";
@@ -619,11 +619,32 @@ function syncUnresolvedWikilinks(
619
619
 
620
620
  /**
621
621
  * Try to resolve pending wikilinks AND pending structured-link forward-refs
622
- * (vault#555) that point to a given path. Called when a note is created or
622
+ * (vault#555) that point to a given note. Called when a note is created or
623
623
  * its path changes. Each pending row materializes with ITS OWN relationship
624
624
  * (a structured link queued via {@link queueUnresolvedLink} backfills with
625
625
  * the caller's original relationship, not "wikilink").
626
626
  *
627
+ * Deferred resolution now covers all four legs of
628
+ * {@link resolveWikilinkDetailed}: exact path, basename, H1 title, and the
629
+ * explicit `path.ext` form. (Caveat: the candidate pre-filter below matches the
630
+ * title/ext legs via SQL `COLLATE NOCASE`, which case-folds ASCII only — a
631
+ * non-ASCII title differing from the target only in letter case is missed at
632
+ * the candidate stage and re-heals on the source's next save instead. Rare;
633
+ * tracked as a follow-up.) Before this, the sweep matched a pending row to
634
+ * the new note by PATH TEXT ONLY (`target_path = path OR path LIKE
635
+ * '%/'||target_path`) — so a `[[John Doe]]` that resolved at write time via
636
+ * the H1-title fallback (its note's displayed title differs from its path,
637
+ * e.g. `people/jdoe`), or a `[[Foo.csv]]` that resolved via the extension
638
+ * leg, silently never re-healed on a delete→recreate (the exact LB6 gap) and
639
+ * more broadly never backfilled when the target note was created AFTER the
640
+ * referencing note. Each candidate pending row is now VERIFIED through
641
+ * `resolveWikilinkDetailed` against the current DB — a row is healed only
642
+ * when its target string actually resolves to THIS note. An AMBIGUOUS target
643
+ * (≥2 notes now share the path/title) resolves to neither and stays queued,
644
+ * identical to write-time's "don't guess" contract — this also closes the
645
+ * pre-existing asymmetry where the path-only sweep would link an ambiguous
646
+ * `[[Foo]]` to whichever colliding note happened to be created.
647
+ *
627
648
  * Returns the number of links resolved.
628
649
  */
629
650
  export function resolveUnresolvedWikilinks(
@@ -632,13 +653,32 @@ export function resolveUnresolvedWikilinks(
632
653
  noteId: string,
633
654
  ): number {
634
655
  ensureRelationshipColumn(db);
635
- let rows: { source_id: string; relationship: string }[];
656
+
657
+ // The newly-created / newly-repathed note supplies the two resolution keys
658
+ // the pending row's `target_path` (a bare path/basename string) can't carry
659
+ // on its own: the note's H1 title (title-fallback leg) and its extension
660
+ // (explicit `path.ext` leg). `getNote` is one indexed PK lookup; the title
661
+ // is parsed from content in JS (no separate query).
662
+ const note = getNote(db, noteId);
663
+ const h1Title = note?.content ? extractH1Title(note.content) : null;
664
+ const pathDotExt = note?.extension ? `${notePath}.${note.extension}` : null;
665
+
666
+ let rows: { source_id: string; target_path: string; relationship: string }[];
636
667
  try {
668
+ // Candidate pre-filter: every pending row whose `target_path` COULD
669
+ // resolve to this note under any resolveWikilinkDetailed leg — exact
670
+ // path, basename (target is the last path segment), H1 title, or the
671
+ // `path.ext` form. A `null` bind (no H1 heading / no extension) makes its
672
+ // clause never match (`target_path = NULL` is NULL, i.e. falsy in SQL).
673
+ // The verify step below is what enforces correctness; this clause only
674
+ // BOUNDS how many rows reach the (title-fallback-scanning) resolver.
637
675
  rows = db.prepare(`
638
- SELECT source_id, relationship FROM unresolved_wikilinks
676
+ SELECT source_id, target_path, relationship FROM unresolved_wikilinks
639
677
  WHERE target_path = ? COLLATE NOCASE
640
678
  OR ? LIKE '%/' || target_path
641
- `).all(notePath, notePath) as { source_id: string; relationship: string }[];
679
+ OR target_path = ? COLLATE NOCASE
680
+ OR target_path = ? COLLATE NOCASE
681
+ `).all(notePath, notePath, h1Title, pathDotExt) as typeof rows;
642
682
  } catch {
643
683
  return 0; // Table doesn't exist
644
684
  }
@@ -649,16 +689,24 @@ export function resolveUnresolvedWikilinks(
649
689
  for (const row of rows) {
650
690
  if (row.source_id === noteId) continue; // Skip self-links
651
691
 
692
+ // Verify against the SAME resolver write-time uses, so deferred
693
+ // resolution can't diverge from it: heal a pending row ONLY when its
694
+ // target string actually resolves to THIS note now. A miss or an
695
+ // ambiguous result leaves the row queued (surfaced as a visible broken
696
+ // link, and re-tried on the next matching note create).
697
+ const detail = resolveWikilinkDetailed(db, row.target_path);
698
+ if (!detail.resolved || detail.note_id !== noteId) continue;
699
+
652
700
  const relationship = row.relationship || WIKILINK_REL;
653
701
  linkOps.createLink(db, row.source_id, noteId, relationship);
654
702
  resolved++;
655
703
 
656
- // Remove the unresolved entry (this exact relationship only a
657
- // source may have BOTH a wikilink and a structured-link forward-ref
658
- // pending against the same target_path).
704
+ // Remove exactly this pending row a source may have BOTH a wikilink and
705
+ // a structured-link forward-ref pending against the same target_path
706
+ // (distinct relationships), so scope the delete to all three PK columns.
659
707
  db.prepare(
660
- "DELETE FROM unresolved_wikilinks WHERE source_id = ? AND relationship = ? AND (target_path = ? COLLATE NOCASE OR ? LIKE '%/' || target_path)",
661
- ).run(row.source_id, relationship, notePath, notePath);
708
+ "DELETE FROM unresolved_wikilinks WHERE source_id = ? AND target_path = ? AND relationship = ?",
709
+ ).run(row.source_id, row.target_path, relationship);
662
710
  }
663
711
 
664
712
  return resolved;
@@ -759,6 +807,35 @@ export function clearQueuedLink(db: Database, sourceId: string, relationship: st
759
807
  }
760
808
  }
761
809
 
810
+ /**
811
+ * Drop exactly ONE pending forward-ref — scoped by `targetPath` in addition
812
+ * to `sourceId`/`relationship` (the full `unresolved_wikilinks` primary
813
+ * key). Used by the `cardinality:"many"` reference-field array sync
814
+ * (core/src/store.ts's `syncReferenceFieldLinks`) when a SPECIFIC array
815
+ * element is removed from a field's value: unlike the scalar path (which
816
+ * owns the ENTIRE relationship namespace and can safely clear every queued
817
+ * row via {@link clearQueuedLink}), an array field can have MULTIPLE
818
+ * pending forward-refs under the same relationship at once (one per
819
+ * unresolved element) — blanket-clearing all of them on a single element's
820
+ * removal would silently drop the other still-pending elements' queue rows.
821
+ * Safe no-op when the table doesn't exist yet or nothing is queued for this
822
+ * exact (source, target, relationship) triple.
823
+ */
824
+ export function clearQueuedLinkTarget(
825
+ db: Database,
826
+ sourceId: string,
827
+ relationship: string,
828
+ targetPath: string,
829
+ ): void {
830
+ try {
831
+ db.prepare(
832
+ "DELETE FROM unresolved_wikilinks WHERE source_id = ? AND relationship = ? AND target_path = ? COLLATE NOCASE",
833
+ ).run(sourceId, relationship, targetPath);
834
+ } catch {
835
+ // Table may not exist yet — nothing to clear.
836
+ }
837
+ }
838
+
762
839
  /**
763
840
  * Resolve a structured link NOW, or queue it for lazy resolution when the
764
841
  * target doesn't exist yet — mirroring the wikilink forward-ref contract
@@ -840,3 +917,96 @@ export function getContentWikilinkWarnings(
840
917
 
841
918
  return warnings;
842
919
  }
920
+
921
+ // ---------------------------------------------------------------------------
922
+ // Delete-time re-queue (LB6) — a deleted note's INBOUND wikilink edges must
923
+ // come back to life if a note is later recreated at the same path/title.
924
+ // ---------------------------------------------------------------------------
925
+
926
+ /**
927
+ * Before a note is deleted, re-queue every INBOUND `wikilink`-relationship
928
+ * edge pointing at it into `unresolved_wikilinks`, so that recreating a note
929
+ * matching the original `[[target]]` text (same path, basename, H1 title, or
930
+ * `path.ext` form — anything {@link resolveWikilinkDetailed} would have
931
+ * matched) auto-heals the edge via {@link resolveUnresolvedWikilinks} — which
932
+ * now verifies each pending row through that same resolver — exactly as if
933
+ * the link had never resolved in the first place.
934
+ *
935
+ * Without this, `deleteNote`'s `DELETE FROM notes` cascades the `links` row
936
+ * away (FK `ON DELETE CASCADE`) but leaves `unresolved_wikilinks` untouched —
937
+ * a note recreated at the deleted note's path never gets linked back to,
938
+ * because nothing is pending resolution for it. Only a re-save of the
939
+ * SOURCE note (which reparses its own content from scratch) would recover
940
+ * it; a fresh `[[Foo]]` reference elsewhere would work, but the ORIGINAL
941
+ * source note's edge stayed dead despite its unchanged `[[Foo]]` text.
942
+ *
943
+ * MUST be called BEFORE the note row is deleted — it needs both the `links`
944
+ * row (about to cascade away) and, per source, the CURRENT content (to
945
+ * recover the raw `[[target]]` text the resolver actually matched on: a
946
+ * resolved link's row retains the target NOTE id, not the original bracket
947
+ * text, and title-fallback / basename resolution mean that text can differ
948
+ * from the deleted note's own path).
949
+ *
950
+ * Scope: only `relationship = "wikilink"` inbound edges are re-queued.
951
+ * Explicit typed `links` (structured `links: [{target, relationship}]`
952
+ * entries, vault#555) are left alone — those are hand-authored associations
953
+ * the caller owns, not content-derived, so silently resurrecting them as a
954
+ * forward-ref on an unrelated future note would be surprising. A source
955
+ * that links to itself is never queued (wikilinks never create self-links,
956
+ * so no such inbound row can exist) and is skipped defensively anyway.
957
+ */
958
+ export function requeueInboundWikilinksForDelete(db: Database, noteId: string): void {
959
+ let inbound: { source_id: string }[];
960
+ try {
961
+ inbound = db.prepare(
962
+ "SELECT DISTINCT source_id FROM links WHERE target_id = ? AND relationship = ?",
963
+ ).all(noteId, WIKILINK_REL) as { source_id: string }[];
964
+ } catch {
965
+ return; // links table missing (shouldn't happen post-schema-init, but never block a delete on this)
966
+ }
967
+ if (inbound.length === 0) return;
968
+
969
+ // Cheap string pre-filter (perf): a wikilink can only have resolved to the
970
+ // note being deleted if its raw target text equals one of THIS note's own
971
+ // resolution keys — its path, basename, H1 title, or `path.ext` form (the
972
+ // four legs {@link resolveWikilinkDetailed} matches on; every leg produces a
973
+ // target string equal to one of these, so the key set is a complete
974
+ // necessary-condition superset). Computed ONCE, then each source wikilink's
975
+ // target is gated on set membership BEFORE the expensive resolver call
976
+ // (whose title-fallback leg scans every note's content). Without this, a hub
977
+ // note with hundreds of inbound sources would fire hundreds of full-vault
978
+ // scans in one delete. The resolver still CONFIRMS each survivor — the
979
+ // pre-filter narrows, it doesn't decide.
980
+ const deleted = getNote(db, noteId);
981
+ const keys = new Set<string>();
982
+ const addKey = (s: string | null | undefined): void => {
983
+ const k = s?.trim().toLowerCase();
984
+ if (k) keys.add(k);
985
+ };
986
+ if (deleted?.path) {
987
+ addKey(deleted.path);
988
+ const slash = deleted.path.lastIndexOf("/");
989
+ addKey(slash >= 0 ? deleted.path.slice(slash + 1) : deleted.path); // basename
990
+ if (deleted.extension) addKey(`${deleted.path}.${deleted.extension}`);
991
+ }
992
+ if (deleted?.content) addKey(extractH1Title(deleted.content));
993
+ if (keys.size === 0) return; // no key any wikilink could have matched on
994
+
995
+ for (const { source_id } of inbound) {
996
+ if (source_id === noteId) continue; // defensive — wikilinks never self-link
997
+
998
+ const source = getNote(db, source_id);
999
+ if (!source || !source.content) continue;
1000
+
1001
+ const targetMap = dedupeWikilinkTargets(parseWikilinks(source.content));
1002
+ for (const [, wl] of targetMap) {
1003
+ // Skip the resolver unless this target text could match the deleted
1004
+ // note by one of its resolution keys (necessary-condition pre-filter).
1005
+ if (!keys.has(wl.target.trim().toLowerCase())) continue;
1006
+ const detail = resolveWikilinkDetailed(db, wl.target);
1007
+ if (detail.resolved && detail.note_id === noteId) {
1008
+ queueUnresolvedLink(db, source_id, wl.target, WIKILINK_REL);
1009
+ }
1010
+ }
1011
+ }
1012
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.2-rc.3",
3
+ "version": "0.7.2-rc.5",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/routes.ts CHANGED
@@ -178,6 +178,105 @@ function jsonWithWarnings(data: unknown, warnings: QueryWarning[], status = 200)
178
178
  return res;
179
179
  }
180
180
 
181
+ /**
182
+ * Sane upper bound on a mutating-route JSON request body (LB7c). Without
183
+ * this, a giant field (most commonly a note's `content`) sails straight
184
+ * through `req.json()` and into wikilink parsing + the store write with no
185
+ * gate at all — reproduced at 50MB `content` -> 201 Created, RSS 92MB ->
186
+ * 663MB (memory-DoS via a single request). 10MB is generous for
187
+ * hand-authored or transcribed note content (multiple hours of dense
188
+ * transcript) while bounding the failure mode. Mirrors `/upload`'s
189
+ * `MAX_UPLOAD_BYTES` file-size gate for the JSON-body transport.
190
+ */
191
+ export const MAX_JSON_BODY_BYTES = 10 * 1024 * 1024;
192
+
193
+ function payloadTooLargeResponse(bytes: number): Response {
194
+ return json(
195
+ {
196
+ error: `Request body too large (${(bytes / 1024 / 1024).toFixed(1)}MB). Max: ${Math.round(MAX_JSON_BODY_BYTES / 1024 / 1024)}MB`,
197
+ error_type: "payload_too_large",
198
+ limit: MAX_JSON_BODY_BYTES,
199
+ got: bytes,
200
+ },
201
+ 413,
202
+ );
203
+ }
204
+
205
+ /**
206
+ * Single choke point for every mutating route that reads a JSON request
207
+ * body (LB7 — door asymmetry fix). Three failure modes, all returning a
208
+ * clean 400/413 instead of falling through to server.ts's generic
209
+ * `{ error: "Internal server error" }` 500-with-no-`error_type` (LB7a), or
210
+ * silently succeeding on a wrong-shape body (LB7b — `null` used to throw a
211
+ * TypeError deep in the handler; `42`/`[]` used to sail through property
212
+ * access as `undefined` and create a blank note/no-op update):
213
+ *
214
+ * 1. **Oversize** — `Content-Length` over {@link MAX_JSON_BODY_BYTES}
215
+ * rejects BEFORE `req.json()` even buffers/parses the body (the cheap
216
+ * path). When the header is absent or understates the true size
217
+ * (chunked transfer, a lying client), the parsed body's own
218
+ * JSON-serialized size is checked as a backstop — one extra parse, but
219
+ * still cheaper than letting the oversized value reach the store and
220
+ * get re-processed (wikilink scan, indexing, etc.).
221
+ * 2. **Malformed JSON** — `req.json()` rejects; caught here rather than
222
+ * propagating to the top-level handler.
223
+ * 3. **Wrong shape** — valid JSON, but not a plain object (`null`, an
224
+ * array, or a bare primitive). Gated by `requireObject` (default
225
+ * `true`); every current caller wants this — the parameter exists so a
226
+ * future non-object-bodied route doesn't have to bypass the helper
227
+ * entirely to opt out.
228
+ *
229
+ * Callers: check `.ok` and return `.response` on failure; `.body` is the
230
+ * parsed JSON value (still `any` — this validates SHAPE, not the specific
231
+ * fields a given route requires, which callers keep checking themselves).
232
+ */
233
+ async function parseJsonBody(
234
+ req: Request,
235
+ opts?: { requireObject?: boolean },
236
+ ): Promise<{ ok: true; body: any } | { ok: false; response: Response }> {
237
+ const contentLengthHeader = req.headers.get("content-length");
238
+ if (contentLengthHeader) {
239
+ const declaredBytes = Number(contentLengthHeader);
240
+ if (Number.isFinite(declaredBytes) && declaredBytes > MAX_JSON_BODY_BYTES) {
241
+ return { ok: false, response: payloadTooLargeResponse(declaredBytes) };
242
+ }
243
+ }
244
+
245
+ const INVALID_JSON = Symbol("invalid-json");
246
+ const parsed = await req.json().catch(() => INVALID_JSON);
247
+ if (parsed === INVALID_JSON) {
248
+ return { ok: false, response: json({ error: "Invalid JSON body", error_type: "invalid_json" }, 400) };
249
+ }
250
+
251
+ if (!contentLengthHeader) {
252
+ const approxBytes = Buffer.byteLength(JSON.stringify(parsed) ?? "", "utf8");
253
+ if (approxBytes > MAX_JSON_BODY_BYTES) {
254
+ return { ok: false, response: payloadTooLargeResponse(approxBytes) };
255
+ }
256
+ }
257
+
258
+ const requireObject = opts?.requireObject ?? true;
259
+ if (requireObject && (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))) {
260
+ // The body PARSED as JSON but is the wrong SHAPE (a primitive/array/null,
261
+ // not an object) — a `invalid_request` per the taxonomy (parallel to
262
+ // `/tags/merge`'s `sources`/`target` shape errors). `invalid_json` is
263
+ // reserved for a genuine parse failure (the branch above).
264
+ return {
265
+ ok: false,
266
+ response: json(
267
+ {
268
+ error: "Request body must be a JSON object",
269
+ error_type: "invalid_request",
270
+ hint: `expected a JSON object body — got ${parsed === null ? "null" : Array.isArray(parsed) ? "an array" : typeof parsed}`,
271
+ },
272
+ 400,
273
+ ),
274
+ };
275
+ }
276
+
277
+ return { ok: true, body: parsed };
278
+ }
279
+
181
280
  /**
182
281
  * REST-only `removed_param` warning (vault#550) — the flat `date_field` /
183
282
  * `date_from` / `date_to` query-string params were removed at 0.6.4
@@ -1647,8 +1746,39 @@ async function handleNotesInner(
1647
1746
 
1648
1747
  // POST /notes — create (single or batch)
1649
1748
  if (method === "POST") {
1650
- const body = await req.json() as any;
1749
+ const parsedBody = await parseJsonBody(req);
1750
+ if (!parsedBody.ok) return parsedBody.response;
1751
+ const body = parsedBody.body as any;
1752
+ // `notes` (when present) must actually be an array — otherwise
1753
+ // `items` below iterates whatever `body.notes` happens to be (e.g. a
1754
+ // STRING iterates as one-character items), silently creating a batch
1755
+ // of blank notes (LB7b).
1756
+ if (body.notes !== undefined && !Array.isArray(body.notes)) {
1757
+ return json(
1758
+ {
1759
+ error: "notes must be an array",
1760
+ error_type: "invalid_request",
1761
+ field: "notes",
1762
+ hint: 'pass { "notes": [...] } for a batch, or a single note object with no `notes` key',
1763
+ },
1764
+ 400,
1765
+ );
1766
+ }
1651
1767
  const items: any[] = body.notes ?? [body];
1768
+ // Every batch item must itself be a plain object — a primitive/array
1769
+ // entry would otherwise read as an all-`undefined` note (LB7b).
1770
+ for (const item of items) {
1771
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
1772
+ return json(
1773
+ {
1774
+ error: "each note must be a JSON object",
1775
+ error_type: "invalid_request",
1776
+ hint: 'each entry in "notes" (or the top-level body, for a single-note POST) must be an object, e.g. {"content": "..."}',
1777
+ },
1778
+ 400,
1779
+ );
1780
+ }
1781
+ }
1652
1782
 
1653
1783
  // Batch cap (#213): refuse oversized batches before doing any work. 500
1654
1784
  // is the cap (Benjamin's number) — tighter blast radius than 1000 for
@@ -2052,7 +2182,9 @@ async function handleNotesInner(
2052
2182
  if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
2053
2183
  return json({ error: "Not found", error_type: "not_found" }, 404);
2054
2184
  }
2055
- const body = await req.json() as { path: string; mimeType: string; transcribe?: boolean };
2185
+ const parsedBody = await parseJsonBody(req);
2186
+ if (!parsedBody.ok) return parsedBody.response;
2187
+ const body = parsedBody.body as { path: string; mimeType: string; transcribe?: boolean };
2056
2188
  if (!body.path || !body.mimeType) {
2057
2189
  return json(
2058
2190
  { error: "path and mimeType are required", error_type: "missing_required_field", hint: "pass both `path` and `mimeType`" },
@@ -2235,7 +2367,9 @@ async function handleNotesInner(
2235
2367
  // Body is parsed up front so the `if_missing: "create"` branch
2236
2368
  // (vault#309) can fire when the note doesn't exist. Pre-#309
2237
2369
  // shape parsed the body only after the not-found check.
2238
- const body = await req.json() as any;
2370
+ const parsedBody = await parseJsonBody(req);
2371
+ if (!parsedBody.ok) return parsedBody.response;
2372
+ const body = parsedBody.body as any;
2239
2373
  const note = await resolveNote(store, idOrPath);
2240
2374
  if (!note) {
2241
2375
  // vault#309 — `if_missing: "create"` turns this PATCH into a
@@ -3100,7 +3234,9 @@ export async function handleTags(
3100
3234
  if (tagScope.allowed && !tagScope.allowed.has(putTagName)) {
3101
3235
  return tagScopeForbidden(tagScope.raw ?? []);
3102
3236
  }
3103
- const body = (await req.json()) as {
3237
+ const parsedBody = await parseJsonBody(req);
3238
+ if (!parsedBody.ok) return parsedBody.response;
3239
+ const body = parsedBody.body as {
3104
3240
  description?: string | null;
3105
3241
  fields?: Record<string, unknown> | null;
3106
3242
  relationships?: Record<string, unknown> | null;
@@ -3514,7 +3650,9 @@ export async function handleVault(
3514
3650
  }
3515
3651
 
3516
3652
  if (req.method === "PATCH") {
3517
- const body = await req.json() as {
3653
+ const parsedBody = await parseJsonBody(req);
3654
+ if (!parsedBody.ok) return parsedBody.response;
3655
+ const body = parsedBody.body as {
3518
3656
  description?: string;
3519
3657
  config?: { audio_retention?: string; auto_transcribe?: { enabled?: unknown } };
3520
3658
  };