@openparachute/vault 0.6.0-rc.1 → 0.6.0

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 (91) hide show
  1. package/.parachute/module.json +14 -3
  2. package/README.md +7 -7
  3. package/core/src/core.test.ts +279 -26
  4. package/core/src/expand-visibility.test.ts +102 -0
  5. package/core/src/expand.ts +31 -3
  6. package/core/src/indexed-fields.ts +1 -1
  7. package/core/src/link-count.test.ts +301 -0
  8. package/core/src/links.ts +97 -2
  9. package/core/src/mcp.ts +201 -33
  10. package/core/src/notes.ts +44 -8
  11. package/core/src/obsidian-alignment.test.ts +375 -0
  12. package/core/src/obsidian.ts +234 -14
  13. package/core/src/portable-md.test.ts +40 -0
  14. package/core/src/portable-md.ts +142 -16
  15. package/core/src/schema.ts +58 -11
  16. package/core/src/store.ts +69 -22
  17. package/core/src/tag-expand-axis.test.ts +301 -0
  18. package/core/src/tag-hierarchy.ts +80 -0
  19. package/core/src/tag-schemas.ts +61 -46
  20. package/core/src/triggers-store.test.ts +100 -0
  21. package/core/src/triggers-store.ts +165 -0
  22. package/core/src/types.ts +68 -4
  23. package/core/src/vault-projection.ts +20 -0
  24. package/core/src/wikilinks.ts +2 -2
  25. package/package.json +2 -3
  26. package/src/admin-spa.test.ts +100 -10
  27. package/src/admin-spa.ts +48 -3
  28. package/src/auth-hub-jwt.test.ts +8 -1
  29. package/src/auth-status.ts +2 -2
  30. package/src/auth.test.ts +39 -3
  31. package/src/auth.ts +31 -2
  32. package/src/auto-transcribe.test.ts +51 -0
  33. package/src/auto-transcribe.ts +24 -6
  34. package/src/autostart.test.ts +75 -0
  35. package/src/autostart.ts +84 -0
  36. package/src/cli.ts +434 -140
  37. package/src/config.test.ts +109 -0
  38. package/src/config.ts +157 -10
  39. package/src/export-watch.test.ts +23 -0
  40. package/src/export-watch.ts +14 -0
  41. package/src/git-preflight.test.ts +70 -0
  42. package/src/git-preflight.ts +68 -0
  43. package/src/hub-jwt.test.ts +75 -2
  44. package/src/hub-jwt.ts +43 -6
  45. package/src/init-summary.test.ts +120 -5
  46. package/src/init-summary.ts +67 -25
  47. package/src/live-match.test.ts +198 -0
  48. package/src/live-match.ts +310 -0
  49. package/src/mcp-install.test.ts +93 -0
  50. package/src/mcp-install.ts +106 -0
  51. package/src/mcp-tools.ts +80 -7
  52. package/src/mirror-config.test.ts +14 -0
  53. package/src/mirror-config.ts +11 -0
  54. package/src/mirror-import.test.ts +110 -0
  55. package/src/mirror-import.ts +71 -13
  56. package/src/mirror-manager.test.ts +51 -0
  57. package/src/mirror-manager.ts +73 -11
  58. package/src/mirror-routes.test.ts +463 -1
  59. package/src/mirror-routes.ts +474 -4
  60. package/src/oauth-discovery.test.ts +55 -0
  61. package/src/oauth-discovery.ts +24 -5
  62. package/src/routes.ts +696 -121
  63. package/src/routing.test.ts +451 -5
  64. package/src/routing.ts +113 -5
  65. package/src/scopes.ts +1 -1
  66. package/src/server.ts +66 -4
  67. package/src/storage.test.ts +162 -0
  68. package/src/subscribe.test.ts +588 -0
  69. package/src/subscribe.ts +248 -0
  70. package/src/subscriptions.ts +295 -0
  71. package/src/tag-expand-routes.test.ts +45 -0
  72. package/src/tag-scope.ts +68 -1
  73. package/src/token-store.ts +7 -7
  74. package/src/transcription-worker.test.ts +471 -5
  75. package/src/transcription-worker.ts +212 -44
  76. package/src/triggers-api.test.ts +533 -0
  77. package/src/triggers-api.ts +295 -0
  78. package/src/triggers.ts +93 -7
  79. package/src/usage.test.ts +362 -0
  80. package/src/usage.ts +318 -0
  81. package/src/vault-create.test.ts +340 -12
  82. package/src/vault-name.test.ts +61 -3
  83. package/src/vault-name.ts +62 -14
  84. package/src/vault-remove.test.ts +187 -0
  85. package/src/vault-store.ts +10 -3
  86. package/src/vault.test.ts +1353 -62
  87. package/web/ui/dist/assets/index-CGL256oe.js +60 -0
  88. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  89. package/web/ui/dist/index.html +2 -2
  90. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  91. package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
@@ -0,0 +1,301 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { SqliteStore } from "./store.js";
4
+ import { generateMcpTools } from "./mcp.js";
5
+ import { getLinkCounts } from "./links.js";
6
+
7
+ // Feature: link-count field (`include_link_count` / `linkCount`) +
8
+ // `order_by=link_count` (vault feedback #4).
9
+ //
10
+ // LOCKED SEMANTICS exercised here:
11
+ // - degree = row count = (#rows source_id=id) + (#rows target_id=id)
12
+ // - both directions by default; outbound/inbound variants
13
+ // - self-loop (source_id==target_id) counts as degree 2 under `both`
14
+ // - the order_by sort key uses the SAME directional-sum definition, so
15
+ // field value == sort key for EVERY note, self-loops included
16
+ // - two typed links between the same pair count as 2 (row count)
17
+
18
+ let store: SqliteStore;
19
+ let db: Database;
20
+
21
+ beforeEach(() => {
22
+ db = new Database(":memory:");
23
+ store = new SqliteStore(db);
24
+ });
25
+
26
+ describe("getLinkCounts (core helper)", () => {
27
+ it("counts both directions as a row sum", async () => {
28
+ await store.createNote("A", { id: "a" });
29
+ await store.createNote("B", { id: "b" });
30
+ await store.createNote("C", { id: "c" });
31
+ // a → b, a → c (a outbound 2)
32
+ // c → a (a inbound 1) => a degree 3
33
+ await store.createLink("a", "b", "mentions");
34
+ await store.createLink("a", "c", "mentions");
35
+ await store.createLink("c", "a", "mentions");
36
+
37
+ const both = getLinkCounts(db, ["a", "b", "c"]);
38
+ expect(both.get("a")).toBe(3); // 2 outbound + 1 inbound
39
+ expect(both.get("b")).toBe(1); // 1 inbound
40
+ expect(both.get("c")).toBe(2); // 1 outbound (c→a) + 1 inbound (a→c)
41
+ });
42
+
43
+ it("outbound and inbound variants count only one direction", async () => {
44
+ await store.createNote("A", { id: "a" });
45
+ await store.createNote("B", { id: "b" });
46
+ await store.createLink("a", "b", "mentions"); // a outbound, b inbound
47
+
48
+ expect(getLinkCounts(db, ["a"], "outbound").get("a")).toBe(1);
49
+ expect(getLinkCounts(db, ["a"], "inbound").get("a")).toBe(0);
50
+ expect(getLinkCounts(db, ["b"], "outbound").get("b")).toBe(0);
51
+ expect(getLinkCounts(db, ["b"], "inbound").get("b")).toBe(1);
52
+ });
53
+
54
+ it("returns 0 for ids with no links (id always present in map)", async () => {
55
+ await store.createNote("Lonely", { id: "lonely" });
56
+ const counts = getLinkCounts(db, ["lonely"]);
57
+ expect(counts.get("lonely")).toBe(0);
58
+ });
59
+
60
+ it("empty id list → empty map (no query)", () => {
61
+ expect(getLinkCounts(db, []).size).toBe(0);
62
+ });
63
+
64
+ it("self-loop counts as degree 2 under both (outbound + inbound)", async () => {
65
+ await store.createNote("S", { id: "s" });
66
+ await store.createLink("s", "s", "relates"); // self-loop
67
+
68
+ expect(getLinkCounts(db, ["s"], "both").get("s")).toBe(2);
69
+ expect(getLinkCounts(db, ["s"], "outbound").get("s")).toBe(1);
70
+ expect(getLinkCounts(db, ["s"], "inbound").get("s")).toBe(1);
71
+ });
72
+
73
+ it("two typed links between the same pair count as 2 (row count)", async () => {
74
+ await store.createNote("A", { id: "a" });
75
+ await store.createNote("B", { id: "b" });
76
+ await store.createLink("a", "b", "mentions");
77
+ await store.createLink("a", "b", "cites"); // same pair, different relationship
78
+
79
+ expect(getLinkCounts(db, ["a"], "both").get("a")).toBe(2);
80
+ expect(getLinkCounts(db, ["b"], "both").get("b")).toBe(2);
81
+ });
82
+
83
+ it("dedupes repeated ids in the request (no double-count)", async () => {
84
+ await store.createNote("A", { id: "a" });
85
+ await store.createNote("B", { id: "b" });
86
+ await store.createLink("a", "b", "mentions");
87
+ const counts = getLinkCounts(db, ["a", "a", "a"]);
88
+ expect(counts.get("a")).toBe(1);
89
+ });
90
+
91
+ it("batch correctness over a large page (exceeds the chunk size)", async () => {
92
+ // 1000 notes > the 900-id chunk boundary; each note i links to note 0,
93
+ // so note 0 has inbound degree 999 and every other note has outbound 1.
94
+ for (let i = 0; i < 1000; i++) {
95
+ await store.createNote(`n${i}`, { id: `n${i}` });
96
+ }
97
+ for (let i = 1; i < 1000; i++) {
98
+ await store.createLink(`n${i}`, "n0", "mentions");
99
+ }
100
+ const ids = Array.from({ length: 1000 }, (_, i) => `n${i}`);
101
+ const counts = getLinkCounts(db, ids);
102
+ expect(counts.size).toBe(1000); // every id present even across chunks
103
+ expect(counts.get("n0")).toBe(999); // all inbound
104
+ expect(counts.get("n1")).toBe(1); // one outbound
105
+ expect(counts.get("n999")).toBe(1);
106
+ });
107
+ });
108
+
109
+ describe("order_by=link_count (engine)", () => {
110
+ // Helper: assert the field value (via getLinkCounts, both) equals the
111
+ // sort-key ordering produced by order_by=link_count for EVERY note.
112
+ async function assertFieldEqualsSortOrder(sort: "asc" | "desc") {
113
+ const ordered = await store.queryNotes({ orderBy: "link_count", sort });
114
+ const degrees = getLinkCounts(
115
+ db,
116
+ ordered.map((n) => n.id),
117
+ "both",
118
+ );
119
+ const seq = ordered.map((n) => degrees.get(n.id) ?? 0);
120
+ // The emitted SQL sorts by the same directional-sum degree, so the
121
+ // degree sequence must already be monotonic in the requested direction.
122
+ const sorted = [...seq].sort((a, b) => (sort === "desc" ? b - a : a - b));
123
+ expect(seq).toEqual(sorted);
124
+ return { ordered, degrees };
125
+ }
126
+
127
+ it("sorts by degree descending and the field matches the sort key", async () => {
128
+ await store.createNote("Hub", { id: "hub" });
129
+ await store.createNote("Mid", { id: "mid" });
130
+ await store.createNote("Leaf", { id: "leaf" });
131
+ // hub degree 3, mid degree 1, leaf degree 0
132
+ await store.createLink("hub", "mid", "a");
133
+ await store.createLink("hub", "leaf", "b");
134
+ await store.createLink("mid", "hub", "c"); // hub inbound +1 => 3, mid outbound +1 => 2? recount below
135
+
136
+ // Recount precisely: hub source: hub→mid, hub→leaf (2); hub target: mid→hub (1) = 3
137
+ // mid source: mid→hub (1); mid target: hub→mid (1) = 2
138
+ // leaf target: hub→leaf (1) = 1
139
+ const { ordered } = await assertFieldEqualsSortOrder("desc");
140
+ expect(ordered.map((n) => n.id)).toEqual(["hub", "mid", "leaf"]);
141
+ });
142
+
143
+ it("sorts ascending with DISTINCT degrees: non-decreasing sequence, pinned IDs", async () => {
144
+ // Distinct degrees so ascending order is unambiguous (not just the
145
+ // tiebreaker case): low=0, mid=1, high=2. This genuinely pins
146
+ // field==sort-key for the ascending path.
147
+ await store.createNote("Low", { id: "low" }); // degree 0
148
+ await store.createNote("Mid", { id: "mid" }); // degree 1
149
+ await store.createNote("High", { id: "high" }); // degree 2
150
+ await store.createNote("Other", { id: "other" }); // link partner
151
+ await store.createLink("mid", "other", "a"); // mid out 1 => degree 1
152
+ await store.createLink("high", "other", "b"); // high out 1
153
+ await store.createLink("other", "high", "c"); // high in 1 => degree 2
154
+ // (other: out 1 + in 1 + in 1 = degree 3, sits above all — fine, we
155
+ // assert the low/mid/high prefix explicitly below.)
156
+
157
+ const { ordered, degrees } = await assertFieldEqualsSortOrder("asc");
158
+ const seq = ordered.map((n) => degrees.get(n.id) ?? 0);
159
+ // Non-decreasing (assertFieldEqualsSortOrder already checks this; assert
160
+ // explicitly here too for the distinct-degree case).
161
+ for (let i = 1; i < seq.length; i++) {
162
+ expect(seq[i]!).toBeGreaterThanOrEqual(seq[i - 1]!);
163
+ }
164
+ // Ascending: the three distinct-degree notes appear in 0,1,2 order.
165
+ expect(ordered.map((n) => n.id)).toEqual(["low", "mid", "high", "other"]);
166
+ expect(degrees.get("low")).toBe(0);
167
+ expect(degrees.get("mid")).toBe(1);
168
+ expect(degrees.get("high")).toBe(2);
169
+ expect(degrees.get("other")).toBe(3);
170
+ });
171
+
172
+ it("self-loop: linkCount==2 AND its order_by position matches that 2", async () => {
173
+ // selfy has a self-loop (degree 2), plain has one inbound (degree 1),
174
+ // zero has none (degree 0). Descending order must place selfy first,
175
+ // and selfy's field value must be exactly 2 (not 1).
176
+ await store.createNote("Selfy", { id: "selfy" });
177
+ await store.createNote("Plain", { id: "plain" });
178
+ await store.createNote("Zero", { id: "zero" });
179
+ await store.createLink("selfy", "selfy", "loop"); // degree 2
180
+ await store.createLink("zero", "plain", "ref"); // plain inbound 1, zero outbound 1
181
+
182
+ const { ordered, degrees } = await assertFieldEqualsSortOrder("desc");
183
+ // selfy degree 2 is the max → first.
184
+ expect(ordered[0]!.id).toBe("selfy");
185
+ expect(degrees.get("selfy")).toBe(2);
186
+ // The field value (2) equals the sort key — selfy outranks plain/zero
187
+ // (both degree 1) precisely because the order_by subquery also counts
188
+ // the self-loop twice. A single OR-COUNT would have made it 1 and tied.
189
+ expect(degrees.get("plain")).toBe(1);
190
+ expect(degrees.get("zero")).toBe(1);
191
+ });
192
+
193
+ it("created_at is the stable tiebreaker among equal degrees", async () => {
194
+ // Three zero-degree notes; descending order falls back to created_at
195
+ // descending (the tiebreaker honors direction).
196
+ await store.createNote("First", { id: "first", created_at: "2026-01-01T00:00:00.000Z" });
197
+ await store.createNote("Second", { id: "second", created_at: "2026-01-02T00:00:00.000Z" });
198
+ await store.createNote("Third", { id: "third", created_at: "2026-01-03T00:00:00.000Z" });
199
+ const desc = await store.queryNotes({ orderBy: "link_count", sort: "desc" });
200
+ expect(desc.map((n) => n.id)).toEqual(["third", "second", "first"]);
201
+ const asc = await store.queryNotes({ orderBy: "link_count", sort: "asc" });
202
+ expect(asc.map((n) => n.id)).toEqual(["first", "second", "third"]);
203
+ });
204
+
205
+ it("does NOT require the field to be indexed (pseudo-field bypass)", async () => {
206
+ await store.createNote("A", { id: "a" });
207
+ // Would throw FIELD_NOT_INDEXED if it routed through requireIndexedField.
208
+ const results = await store.queryNotes({ orderBy: "link_count" });
209
+ expect(results.length).toBe(1);
210
+ });
211
+ });
212
+
213
+ describe("query-notes MCP surface: include_link_count", () => {
214
+ async function seed() {
215
+ await store.createNote("Hub", { id: "hub" });
216
+ await store.createNote("Leaf", { id: "leaf" });
217
+ await store.createNote("Self", { id: "self" });
218
+ await store.createLink("hub", "leaf", "a"); // hub out 1, leaf in 1
219
+ await store.createLink("leaf", "hub", "b"); // hub in 1, leaf out 1 => both degree 2
220
+ await store.createLink("self", "self", "loop"); // self degree 2
221
+ }
222
+
223
+ it("list mode: include_link_count injects linkCount; absent flag → no key", async () => {
224
+ await seed();
225
+ const tools = generateMcpTools(store);
226
+ const query = tools.find((t) => t.name === "query-notes")!;
227
+
228
+ const withCount = (await query.execute({ include_link_count: true })) as any[];
229
+ const hub = withCount.find((n) => n.id === "hub");
230
+ expect(hub.linkCount).toBe(2);
231
+ const self = withCount.find((n) => n.id === "self");
232
+ expect(self.linkCount).toBe(2); // self-loop = 2
233
+
234
+ const without = (await query.execute({})) as any[];
235
+ expect(without.every((n) => !("linkCount" in n))).toBe(true);
236
+ });
237
+
238
+ it("single-note mode: include_link_count → correct degree", async () => {
239
+ await seed();
240
+ const tools = generateMcpTools(store);
241
+ const query = tools.find((t) => t.name === "query-notes")!;
242
+ const result = (await query.execute({ id: "self", include_link_count: true })) as any;
243
+ expect(result.linkCount).toBe(2);
244
+ });
245
+
246
+ it("direction variants on the MCP surface", async () => {
247
+ await seed();
248
+ const tools = generateMcpTools(store);
249
+ const query = tools.find((t) => t.name === "query-notes")!;
250
+ const out = (await query.execute({
251
+ id: "hub",
252
+ include_link_count: true,
253
+ link_count_direction: "outbound",
254
+ })) as any;
255
+ expect(out.linkCount).toBe(1); // hub→leaf only
256
+ const inb = (await query.execute({
257
+ id: "hub",
258
+ include_link_count: true,
259
+ link_count_direction: "inbound",
260
+ })) as any;
261
+ expect(inb.linkCount).toBe(1); // leaf→hub only
262
+ });
263
+
264
+ it("unrecognized link_count_direction falls back to both (MCP normalizeLinkCountDirection)", async () => {
265
+ await seed();
266
+ const tools = generateMcpTools(store);
267
+ const query = tools.find((t) => t.name === "query-notes")!;
268
+ // hub: both=2, outbound=1, inbound=1. A bogus value must degrade to
269
+ // `both` (2), distinct from either directional value (1).
270
+ const result = (await query.execute({
271
+ id: "hub",
272
+ include_link_count: true,
273
+ link_count_direction: "sideways",
274
+ })) as any;
275
+ expect(result.linkCount).toBe(2);
276
+ });
277
+
278
+ it("note with 0 links → linkCount: 0", async () => {
279
+ await store.createNote("Lonely", { id: "lonely" });
280
+ const tools = generateMcpTools(store);
281
+ const query = tools.find((t) => t.name === "query-notes")!;
282
+ const result = (await query.execute({ id: "lonely", include_link_count: true })) as any;
283
+ expect(result.linkCount).toBe(0);
284
+ });
285
+
286
+ it("order_by=link_count over MCP: field value == sort key for every note", async () => {
287
+ await seed();
288
+ const tools = generateMcpTools(store);
289
+ const query = tools.find((t) => t.name === "query-notes")!;
290
+ const ordered = (await query.execute({
291
+ order_by: "link_count",
292
+ sort: "desc",
293
+ include_link_count: true,
294
+ })) as any[];
295
+ // Every note carries linkCount, and the sequence is non-increasing.
296
+ const seq = ordered.map((n) => n.linkCount as number);
297
+ expect(seq).toEqual([...seq].sort((a, b) => b - a));
298
+ // hub (2), leaf (2), self (2) all degree 2 here.
299
+ for (const n of ordered) expect(typeof n.linkCount).toBe("number");
300
+ });
301
+ });
package/core/src/links.ts CHANGED
@@ -106,7 +106,7 @@ function parseMetadata(raw: string | null): Record<string, unknown> | undefined
106
106
  function getNoteSummary(db: Database, noteId: string): NoteSummary | undefined {
107
107
  const row = db.prepare(
108
108
  "SELECT id, path, metadata, created_at, updated_at FROM notes WHERE id = ?",
109
- ).get(noteId) as SummaryRow | undefined;
109
+ ).get(noteId) as SummaryRow | null;
110
110
  if (!row) return undefined;
111
111
  return {
112
112
  id: row.id,
@@ -165,6 +165,83 @@ export function getLinksHydrated(
165
165
  }));
166
166
  }
167
167
 
168
+ /**
169
+ * Batch link-degree counter (vault feedback #4).
170
+ *
171
+ * Returns the link **degree** (raw row count) for each requested note id,
172
+ * never materializing link rows. Degree is defined as a sum of two
173
+ * directional row counts:
174
+ *
175
+ * - `outbound` = `COUNT(*) FROM links WHERE source_id = id`
176
+ * - `inbound` = `COUNT(*) FROM links WHERE target_id = id`
177
+ * - `both` = outbound + inbound (default)
178
+ *
179
+ * It is a **row count**, matching `getVaultStats.linkCount` (notes.ts):
180
+ * two typed links A→B (different `relationship`) count as 2, and a
181
+ * **self-loop** (source_id == target_id) counts as **degree 2** under
182
+ * `both` — once via the outbound query, once via the inbound query.
183
+ *
184
+ * ⚠️ This directional-sum definition MUST stay identical to the
185
+ * `order_by=link_count` sort key in `queryNotes` (notes.ts), which uses
186
+ * `(SELECT COUNT(*) ... source_id=n.id) + (SELECT COUNT(*) ... target_id=n.id)`.
187
+ * A single `COUNT(*) ... WHERE source_id=id OR target_id=id` would count a
188
+ * self-loop ONCE and diverge — do NOT use that shape here.
189
+ *
190
+ * Each direction is one grouped query over an existing B-tree index
191
+ * (`idx_links_source` / `idx_links_target`), so the whole page costs at
192
+ * most two index scans regardless of page size. The IN-list is chunked to
193
+ * stay under SQLite's bound-variable limit on very large pages.
194
+ *
195
+ * Returns 0 for ids with no links (every requested id is present in the map).
196
+ */
197
+ export function getLinkCounts(
198
+ db: Database,
199
+ noteIds: string[],
200
+ direction: "both" | "outbound" | "inbound" = "both",
201
+ ): Map<string, number> {
202
+ const counts = new Map<string, number>();
203
+ if (noteIds.length === 0) return counts;
204
+
205
+ // Dedupe so a repeated id doesn't inflate the IN-list or get summed twice
206
+ // when the same chunk runs the outbound + inbound grouped queries.
207
+ const ids = [...new Set(noteIds)];
208
+ for (const id of ids) counts.set(id, 0);
209
+
210
+ const wantOutbound = direction === "outbound" || direction === "both";
211
+ const wantInbound = direction === "inbound" || direction === "both";
212
+
213
+ // SQLite's default SQLITE_MAX_VARIABLE_NUMBER is 999 on older builds and
214
+ // 32766 on newer ones; chunk well under the conservative floor so the
215
+ // IN-list never trips the bind limit on a large page.
216
+ const CHUNK = 900;
217
+ for (let i = 0; i < ids.length; i += CHUNK) {
218
+ const chunk = ids.slice(i, i + CHUNK);
219
+ const placeholders = chunk.map(() => "?").join(", ");
220
+
221
+ if (wantOutbound) {
222
+ const rows = db.prepare(
223
+ `SELECT source_id AS id, COUNT(*) AS c FROM links
224
+ WHERE source_id IN (${placeholders}) GROUP BY source_id`,
225
+ ).all(...chunk) as { id: string; c: number }[];
226
+ for (const row of rows) {
227
+ counts.set(row.id, (counts.get(row.id) ?? 0) + row.c);
228
+ }
229
+ }
230
+
231
+ if (wantInbound) {
232
+ const rows = db.prepare(
233
+ `SELECT target_id AS id, COUNT(*) AS c FROM links
234
+ WHERE target_id IN (${placeholders}) GROUP BY target_id`,
235
+ ).all(...chunk) as { id: string; c: number }[];
236
+ for (const row of rows) {
237
+ counts.set(row.id, (counts.get(row.id) ?? 0) + row.c);
238
+ }
239
+ }
240
+ }
241
+
242
+ return counts;
243
+ }
244
+
168
245
  // ---- Deeper Link Queries ----
169
246
 
170
247
  export interface TraversalNode {
@@ -178,14 +255,26 @@ export interface TraversalNode {
178
255
  /**
179
256
  * Traverse the link graph from a starting note up to `maxDepth` hops.
180
257
  * Returns all reachable notes with their depth and how they were reached.
258
+ *
259
+ * `isTraversable` (vault#439) is an OPTIONAL per-note predicate. When
260
+ * provided, a neighbor that fails the predicate is treated as a WALL: it is
261
+ * neither added to the results nor pushed onto the frontier, so the BFS
262
+ * cannot reach further notes THROUGH it. This makes a tag-scoped traversal
263
+ * symmetric with `find-path` (which guards every hop) — scope acts as a wall,
264
+ * not a sieve. Omitted (every unscoped / internal caller) → the full graph is
265
+ * walked exactly as before. Core stays scope-unaware: it receives a plain
266
+ * `(noteId) => boolean` closure and never imports the server's tag-scope
267
+ * module. The anchor `noteId` is never passed through the predicate — the
268
+ * caller is responsible for confirming the anchor is in scope before calling.
181
269
  */
182
270
  export function traverseLinks(
183
271
  db: Database,
184
272
  noteId: string,
185
- opts?: { max_depth?: number; relationship?: string },
273
+ opts?: { max_depth?: number; relationship?: string; isTraversable?: (noteId: string) => boolean },
186
274
  ): TraversalNode[] {
187
275
  const maxDepth = opts?.max_depth ?? 2;
188
276
  const relFilter = opts?.relationship;
277
+ const isTraversable = opts?.isTraversable;
189
278
  const visited = new Set<string>([noteId]);
190
279
  const results: TraversalNode[] = [];
191
280
  let frontier = [noteId];
@@ -209,6 +298,10 @@ export function traverseLinks(
209
298
  for (const row of outbound) {
210
299
  if (!visited.has(row.target_id)) {
211
300
  visited.add(row.target_id);
301
+ // Wall (vault#439): an out-of-scope neighbor is marked visited (so
302
+ // it isn't re-evaluated) but is NOT added to the frontier or the
303
+ // results — the BFS can't traverse THROUGH it to reach notes beyond.
304
+ if (isTraversable && !isTraversable(row.target_id)) continue;
212
305
  nextFrontier.push(row.target_id);
213
306
  results.push({
214
307
  noteId: row.target_id,
@@ -234,6 +327,8 @@ export function traverseLinks(
234
327
  for (const row of inbound) {
235
328
  if (!visited.has(row.source_id)) {
236
329
  visited.add(row.source_id);
330
+ // Wall (vault#439): see the outbound branch above.
331
+ if (isTraversable && !isTraversable(row.source_id)) continue;
237
332
  nextFrontier.push(row.source_id);
238
333
  results.push({
239
334
  noteId: row.source_id,