@openparachute/vault 0.6.0-rc.1 → 0.6.1

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 (99) hide show
  1. package/.parachute/module.json +14 -3
  2. package/README.md +32 -7
  3. package/core/src/content-range.test.ts +374 -0
  4. package/core/src/content-range.ts +185 -0
  5. package/core/src/core.test.ts +279 -26
  6. package/core/src/expand-visibility.test.ts +102 -0
  7. package/core/src/expand.ts +31 -3
  8. package/core/src/indexed-fields.ts +1 -1
  9. package/core/src/link-count.test.ts +301 -0
  10. package/core/src/links.ts +172 -22
  11. package/core/src/mcp.ts +254 -34
  12. package/core/src/notes.ts +172 -48
  13. package/core/src/obsidian-alignment.test.ts +375 -0
  14. package/core/src/obsidian.ts +234 -14
  15. package/core/src/portable-md.test.ts +40 -0
  16. package/core/src/portable-md.ts +142 -16
  17. package/core/src/query-perf-routing.test.ts +208 -0
  18. package/core/src/schema.ts +87 -11
  19. package/core/src/store.ts +69 -22
  20. package/core/src/tag-expand-axis.test.ts +301 -0
  21. package/core/src/tag-hierarchy.ts +80 -0
  22. package/core/src/tag-schemas.ts +61 -46
  23. package/core/src/triggers-store.test.ts +100 -0
  24. package/core/src/triggers-store.ts +165 -0
  25. package/core/src/types.ts +68 -4
  26. package/core/src/vault-projection.ts +20 -0
  27. package/core/src/wikilinks.ts +2 -2
  28. package/package.json +2 -3
  29. package/src/admin-spa.test.ts +100 -10
  30. package/src/admin-spa.ts +48 -3
  31. package/src/auth-hub-jwt.test.ts +8 -1
  32. package/src/auth-status.ts +2 -2
  33. package/src/auth.test.ts +39 -3
  34. package/src/auth.ts +31 -2
  35. package/src/auto-transcribe.test.ts +51 -0
  36. package/src/auto-transcribe.ts +24 -6
  37. package/src/autostart.test.ts +75 -0
  38. package/src/autostart.ts +84 -0
  39. package/src/cli.ts +434 -140
  40. package/src/config.test.ts +109 -0
  41. package/src/config.ts +157 -10
  42. package/src/content-range-routes.test.ts +178 -0
  43. package/src/export-watch.test.ts +23 -0
  44. package/src/export-watch.ts +14 -0
  45. package/src/git-preflight.test.ts +70 -0
  46. package/src/git-preflight.ts +68 -0
  47. package/src/github-device-flow.test.ts +265 -6
  48. package/src/github-device-flow.ts +297 -45
  49. package/src/hub-jwt.test.ts +75 -2
  50. package/src/hub-jwt.ts +43 -6
  51. package/src/init-summary.test.ts +120 -5
  52. package/src/init-summary.ts +67 -25
  53. package/src/live-match.test.ts +198 -0
  54. package/src/live-match.ts +310 -0
  55. package/src/mcp-install.test.ts +93 -0
  56. package/src/mcp-install.ts +106 -0
  57. package/src/mcp-tools.ts +80 -7
  58. package/src/mirror-config.test.ts +14 -0
  59. package/src/mirror-config.ts +11 -0
  60. package/src/mirror-credentials.test.ts +20 -0
  61. package/src/mirror-credentials.ts +6 -2
  62. package/src/mirror-import.test.ts +110 -0
  63. package/src/mirror-import.ts +71 -13
  64. package/src/mirror-manager.test.ts +51 -0
  65. package/src/mirror-manager.ts +73 -11
  66. package/src/mirror-routes.test.ts +1331 -110
  67. package/src/mirror-routes.ts +787 -30
  68. package/src/oauth-discovery.test.ts +55 -0
  69. package/src/oauth-discovery.ts +24 -5
  70. package/src/routes.ts +763 -122
  71. package/src/routing.test.ts +451 -5
  72. package/src/routing.ts +121 -5
  73. package/src/scopes.ts +1 -1
  74. package/src/server.ts +66 -4
  75. package/src/storage.test.ts +162 -0
  76. package/src/subscribe.test.ts +588 -0
  77. package/src/subscribe.ts +248 -0
  78. package/src/subscriptions.ts +295 -0
  79. package/src/tag-expand-routes.test.ts +45 -0
  80. package/src/tag-scope.ts +68 -1
  81. package/src/token-store.ts +7 -7
  82. package/src/transcription-worker.test.ts +471 -5
  83. package/src/transcription-worker.ts +212 -44
  84. package/src/triggers-api.test.ts +533 -0
  85. package/src/triggers-api.ts +295 -0
  86. package/src/triggers.ts +93 -7
  87. package/src/usage.test.ts +362 -0
  88. package/src/usage.ts +318 -0
  89. package/src/vault-create.test.ts +340 -12
  90. package/src/vault-name.test.ts +61 -3
  91. package/src/vault-name.ts +62 -14
  92. package/src/vault-remove.test.ts +187 -0
  93. package/src/vault-store.ts +10 -3
  94. package/src/vault.test.ts +1353 -62
  95. package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
  96. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  97. package/web/ui/dist/index.html +2 -2
  98. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  99. package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
@@ -22,6 +22,22 @@ export interface ExpandContext {
22
22
  mode: ExpandMode;
23
23
  /** Note IDs already expanded in this query. Shared across all expansions. */
24
24
  expanded: Set<string>;
25
+ /**
26
+ * Optional visibility predicate (tag-scope confidentiality, vault security
27
+ * review). When set and it returns `false` for a resolved target note, the
28
+ * wikilink is left UNRESOLVED — byte-identical to the not-found/unresolved
29
+ * branch, so an out-of-scope target is indistinguishable from a missing one
30
+ * (we never reveal existence differently across the scope boundary).
31
+ *
32
+ * Core stays scope-unaware: the server constructs this predicate from its
33
+ * tag-scope machinery and injects it; core only ever *calls* it. When
34
+ * unset (every unscoped caller), expansion behaves exactly as before.
35
+ *
36
+ * Applied at every hop — multi-hop (`expand_depth > 1`) expansion runs the
37
+ * predicate on each resolved target before inlining, so a deep chain can't
38
+ * tunnel out-of-scope content through an in-scope intermediary.
39
+ */
40
+ isVisible?: (note: Note) => boolean;
25
41
  }
26
42
 
27
43
  /**
@@ -62,14 +78,26 @@ export function expandContent(
62
78
  const noteId = resolveWikilink(ctx.db, target);
63
79
  if (!noteId) return match; // unresolved or ambiguous — leave as-is
64
80
 
81
+ // Resolve the target BEFORE the dedup check so the visibility gate can run
82
+ // first — an out-of-scope target must never enter `expanded` (otherwise a
83
+ // second reference to it would render `(expanded above)` and leak its
84
+ // existence via a different output than a genuinely-missing target).
85
+ const note = noteOps.getNote(ctx.db, noteId);
86
+ if (!note) return match; // shouldn't happen, but be safe
87
+
88
+ // Tag-scope confidentiality (vault security review): if a visibility
89
+ // predicate is installed and the resolved target is out of scope, leave
90
+ // the wikilink UNRESOLVED — byte-identical to the not-found / unresolved
91
+ // branches above. The out-of-scope case must be indistinguishable from a
92
+ // missing target so the response can't leak the target's existence. Runs
93
+ // at every hop, so multi-hop expansion can't tunnel out-of-scope content.
94
+ if (ctx.isVisible && !ctx.isVisible(note)) return match;
95
+
65
96
  if (ctx.expanded.has(noteId)) {
66
97
  return `${match} (expanded above)`;
67
98
  }
68
99
  ctx.expanded.add(noteId);
69
100
 
70
- const note = noteOps.getNote(ctx.db, noteId);
71
- if (!note) return match; // shouldn't happen, but be safe
72
-
73
101
  if (ctx.mode === "summary") {
74
102
  // Summary mode doesn't recurse: depth > 1 has no additional effect.
75
103
  return renderSummary(note);
@@ -101,7 +101,7 @@ export function listIndexedFields(db: Database): IndexedField[] {
101
101
  export function getIndexedField(db: Database, field: string): IndexedField | null {
102
102
  const row = db
103
103
  .prepare("SELECT field, sqlite_type, declarer_tags FROM indexed_fields WHERE field = ?")
104
- .get(field) as IndexedFieldRow | undefined;
104
+ .get(field) as IndexedFieldRow | null;
105
105
  return row ? rowToField(row) : null;
106
106
  }
107
107
 
@@ -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
@@ -1,6 +1,6 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import type { Link, NoteSummary, HydratedLink } from "./types.js";
3
- import { getNoteTags } from "./notes.js";
3
+ import { getNoteTagsForNotes } from "./notes.js";
4
4
 
5
5
  export function createLink(
6
6
  db: Database,
@@ -103,28 +103,25 @@ function parseMetadata(raw: string | null): Record<string, unknown> | undefined
103
103
  try { return JSON.parse(raw); } catch { return undefined; }
104
104
  }
105
105
 
106
- function getNoteSummary(db: Database, noteId: string): NoteSummary | undefined {
107
- const row = db.prepare(
108
- "SELECT id, path, metadata, created_at, updated_at FROM notes WHERE id = ?",
109
- ).get(noteId) as SummaryRow | undefined;
110
- if (!row) return undefined;
111
- return {
112
- id: row.id,
113
- path: row.path ?? undefined,
114
- metadata: parseMetadata(row.metadata),
115
- createdAt: row.created_at,
116
- updatedAt: row.updated_at ?? undefined,
117
- tags: getNoteTags(db, row.id),
118
- };
119
- }
106
+ /** IN-list chunk size matches getLinkCounts' conservative bound-variable floor. */
107
+ const IN_CHUNK = 900;
120
108
 
121
109
  function getNoteSummaries(db: Database, noteIds: string[]): Map<string, NoteSummary> {
122
110
  const map = new Map<string, NoteSummary>();
123
111
  if (noteIds.length === 0) return map;
124
- const placeholders = noteIds.map(() => "?").join(", ");
125
- const rows = db.prepare(
126
- `SELECT id, path, metadata, created_at, updated_at FROM notes WHERE id IN (${placeholders})`,
127
- ).all(...noteIds) as SummaryRow[];
112
+ const ids = [...new Set(noteIds)];
113
+ const rows: SummaryRow[] = [];
114
+ for (let i = 0; i < ids.length; i += IN_CHUNK) {
115
+ const chunk = ids.slice(i, i + IN_CHUNK);
116
+ const placeholders = chunk.map(() => "?").join(", ");
117
+ rows.push(...db.prepare(
118
+ `SELECT id, path, metadata, created_at, updated_at FROM notes WHERE id IN (${placeholders})`,
119
+ ).all(...chunk) as SummaryRow[]);
120
+ }
121
+ // ONE batched tag lookup for every summary on the page — this used to be
122
+ // a per-summary query, which made hydrating a well-linked note cost
123
+ // O(linked notes) round-trips (2026-06-10 perf measurements).
124
+ const tagsById = getNoteTagsForNotes(db, rows.map((r) => r.id));
128
125
  for (const row of rows) {
129
126
  map.set(row.id, {
130
127
  id: row.id,
@@ -132,7 +129,7 @@ function getNoteSummaries(db: Database, noteIds: string[]): Map<string, NoteSumm
132
129
  metadata: parseMetadata(row.metadata),
133
130
  createdAt: row.created_at,
134
131
  updatedAt: row.updated_at ?? undefined,
135
- tags: getNoteTags(db, row.id),
132
+ tags: tagsById.get(row.id) ?? [],
136
133
  });
137
134
  }
138
135
  return map;
@@ -148,8 +145,11 @@ export function getLinksHydrated(
148
145
  opts?: { direction?: "outbound" | "inbound" | "both"; include_content?: boolean },
149
146
  ): HydratedLink[] {
150
147
  const links = getLinks(db, noteId, opts);
148
+ return hydrateLinks(db, links);
149
+ }
151
150
 
152
- // Collect all note IDs we need to hydrate
151
+ /** Attach source/target note summaries to a set of links (batched). */
152
+ function hydrateLinks(db: Database, links: Link[]): HydratedLink[] {
153
153
  const noteIds = new Set<string>();
154
154
  for (const link of links) {
155
155
  noteIds.add(link.sourceId);
@@ -165,6 +165,138 @@ export function getLinksHydrated(
165
165
  }));
166
166
  }
167
167
 
168
+ /**
169
+ * Batch variant of `getLinksHydrated` for the `include_links` enrichment
170
+ * loops (MCP query-notes list path, REST GET /api/notes): hydrates links for
171
+ * a whole PAGE of notes in a constant number of queries — two indexed
172
+ * IN-list scans over `links` per chunk, one summary fetch, one batched tag
173
+ * lookup — instead of (1 link query + 1 summary query + N tag queries) per
174
+ * note. See the 2026-06-10 perf measurements (include_links scaled
175
+ * per-returned-note).
176
+ *
177
+ * Returns a map keyed by every requested note id (empty array when the note
178
+ * has no links). Each note's list contains links touching it in either
179
+ * direction, ordered created_at DESC — same contract as the single-note
180
+ * `getLinksHydrated`. A link between two notes that are BOTH on the page
181
+ * appears in both notes' lists, exactly as the per-note calls produced.
182
+ */
183
+ export function getLinksHydratedForNotes(
184
+ db: Database,
185
+ noteIds: string[],
186
+ ): Map<string, HydratedLink[]> {
187
+ const result = new Map<string, HydratedLink[]>();
188
+ if (noteIds.length === 0) return result;
189
+ const ids = [...new Set(noteIds)];
190
+ for (const id of ids) result.set(id, []);
191
+
192
+ // Collect every link touching any requested note, deduped on the
193
+ // (source, target, relationship) primary key so a link whose endpoints
194
+ // are both on the page is fetched once.
195
+ const rowsByKey = new Map<string, LinkRow>();
196
+ for (let i = 0; i < ids.length; i += IN_CHUNK) {
197
+ const chunk = ids.slice(i, i + IN_CHUNK);
198
+ const placeholders = chunk.map(() => "?").join(", ");
199
+ for (const column of ["source_id", "target_id"] as const) {
200
+ const rows = db.prepare(
201
+ `SELECT * FROM links WHERE ${column} IN (${placeholders})`,
202
+ ).all(...chunk) as LinkRow[];
203
+ for (const row of rows) {
204
+ rowsByKey.set(`${row.source_id}|${row.target_id}|${row.relationship}`, row);
205
+ }
206
+ }
207
+ }
208
+
209
+ // Stable sort newest-first to mirror the single-note SQL's
210
+ // ORDER BY created_at DESC (ISO timestamps sort lexicographically).
211
+ const links = [...rowsByKey.values()]
212
+ .sort((a, b) => (a.created_at < b.created_at ? 1 : a.created_at > b.created_at ? -1 : 0))
213
+ .map(rowToLink);
214
+
215
+ const hydrated = hydrateLinks(db, links);
216
+ for (const link of hydrated) {
217
+ result.get(link.sourceId)?.push(link);
218
+ if (link.targetId !== link.sourceId) result.get(link.targetId)?.push(link);
219
+ }
220
+ return result;
221
+ }
222
+
223
+ /**
224
+ * Batch link-degree counter (vault feedback #4).
225
+ *
226
+ * Returns the link **degree** (raw row count) for each requested note id,
227
+ * never materializing link rows. Degree is defined as a sum of two
228
+ * directional row counts:
229
+ *
230
+ * - `outbound` = `COUNT(*) FROM links WHERE source_id = id`
231
+ * - `inbound` = `COUNT(*) FROM links WHERE target_id = id`
232
+ * - `both` = outbound + inbound (default)
233
+ *
234
+ * It is a **row count**, matching `getVaultStats.linkCount` (notes.ts):
235
+ * two typed links A→B (different `relationship`) count as 2, and a
236
+ * **self-loop** (source_id == target_id) counts as **degree 2** under
237
+ * `both` — once via the outbound query, once via the inbound query.
238
+ *
239
+ * ⚠️ This directional-sum definition MUST stay identical to the
240
+ * `order_by=link_count` sort key in `queryNotes` (notes.ts), which uses
241
+ * `(SELECT COUNT(*) ... source_id=n.id) + (SELECT COUNT(*) ... target_id=n.id)`.
242
+ * A single `COUNT(*) ... WHERE source_id=id OR target_id=id` would count a
243
+ * self-loop ONCE and diverge — do NOT use that shape here.
244
+ *
245
+ * Each direction is one grouped query over an existing B-tree index
246
+ * (`idx_links_source` / `idx_links_target`), so the whole page costs at
247
+ * most two index scans regardless of page size. The IN-list is chunked to
248
+ * stay under SQLite's bound-variable limit on very large pages.
249
+ *
250
+ * Returns 0 for ids with no links (every requested id is present in the map).
251
+ */
252
+ export function getLinkCounts(
253
+ db: Database,
254
+ noteIds: string[],
255
+ direction: "both" | "outbound" | "inbound" = "both",
256
+ ): Map<string, number> {
257
+ const counts = new Map<string, number>();
258
+ if (noteIds.length === 0) return counts;
259
+
260
+ // Dedupe so a repeated id doesn't inflate the IN-list or get summed twice
261
+ // when the same chunk runs the outbound + inbound grouped queries.
262
+ const ids = [...new Set(noteIds)];
263
+ for (const id of ids) counts.set(id, 0);
264
+
265
+ const wantOutbound = direction === "outbound" || direction === "both";
266
+ const wantInbound = direction === "inbound" || direction === "both";
267
+
268
+ // SQLite's default SQLITE_MAX_VARIABLE_NUMBER is 999 on older builds and
269
+ // 32766 on newer ones; chunk well under the conservative floor so the
270
+ // IN-list never trips the bind limit on a large page.
271
+ const CHUNK = 900;
272
+ for (let i = 0; i < ids.length; i += CHUNK) {
273
+ const chunk = ids.slice(i, i + CHUNK);
274
+ const placeholders = chunk.map(() => "?").join(", ");
275
+
276
+ if (wantOutbound) {
277
+ const rows = db.prepare(
278
+ `SELECT source_id AS id, COUNT(*) AS c FROM links
279
+ WHERE source_id IN (${placeholders}) GROUP BY source_id`,
280
+ ).all(...chunk) as { id: string; c: number }[];
281
+ for (const row of rows) {
282
+ counts.set(row.id, (counts.get(row.id) ?? 0) + row.c);
283
+ }
284
+ }
285
+
286
+ if (wantInbound) {
287
+ const rows = db.prepare(
288
+ `SELECT target_id AS id, COUNT(*) AS c FROM links
289
+ WHERE target_id IN (${placeholders}) GROUP BY target_id`,
290
+ ).all(...chunk) as { id: string; c: number }[];
291
+ for (const row of rows) {
292
+ counts.set(row.id, (counts.get(row.id) ?? 0) + row.c);
293
+ }
294
+ }
295
+ }
296
+
297
+ return counts;
298
+ }
299
+
168
300
  // ---- Deeper Link Queries ----
169
301
 
170
302
  export interface TraversalNode {
@@ -178,14 +310,26 @@ export interface TraversalNode {
178
310
  /**
179
311
  * Traverse the link graph from a starting note up to `maxDepth` hops.
180
312
  * Returns all reachable notes with their depth and how they were reached.
313
+ *
314
+ * `isTraversable` (vault#439) is an OPTIONAL per-note predicate. When
315
+ * provided, a neighbor that fails the predicate is treated as a WALL: it is
316
+ * neither added to the results nor pushed onto the frontier, so the BFS
317
+ * cannot reach further notes THROUGH it. This makes a tag-scoped traversal
318
+ * symmetric with `find-path` (which guards every hop) — scope acts as a wall,
319
+ * not a sieve. Omitted (every unscoped / internal caller) → the full graph is
320
+ * walked exactly as before. Core stays scope-unaware: it receives a plain
321
+ * `(noteId) => boolean` closure and never imports the server's tag-scope
322
+ * module. The anchor `noteId` is never passed through the predicate — the
323
+ * caller is responsible for confirming the anchor is in scope before calling.
181
324
  */
182
325
  export function traverseLinks(
183
326
  db: Database,
184
327
  noteId: string,
185
- opts?: { max_depth?: number; relationship?: string },
328
+ opts?: { max_depth?: number; relationship?: string; isTraversable?: (noteId: string) => boolean },
186
329
  ): TraversalNode[] {
187
330
  const maxDepth = opts?.max_depth ?? 2;
188
331
  const relFilter = opts?.relationship;
332
+ const isTraversable = opts?.isTraversable;
189
333
  const visited = new Set<string>([noteId]);
190
334
  const results: TraversalNode[] = [];
191
335
  let frontier = [noteId];
@@ -209,6 +353,10 @@ export function traverseLinks(
209
353
  for (const row of outbound) {
210
354
  if (!visited.has(row.target_id)) {
211
355
  visited.add(row.target_id);
356
+ // Wall (vault#439): an out-of-scope neighbor is marked visited (so
357
+ // it isn't re-evaluated) but is NOT added to the frontier or the
358
+ // results — the BFS can't traverse THROUGH it to reach notes beyond.
359
+ if (isTraversable && !isTraversable(row.target_id)) continue;
212
360
  nextFrontier.push(row.target_id);
213
361
  results.push({
214
362
  noteId: row.target_id,
@@ -234,6 +382,8 @@ export function traverseLinks(
234
382
  for (const row of inbound) {
235
383
  if (!visited.has(row.source_id)) {
236
384
  visited.add(row.source_id);
385
+ // Wall (vault#439): see the outbound branch above.
386
+ if (isTraversable && !isTraversable(row.source_id)) continue;
237
387
  nextFrontier.push(row.source_id);
238
388
  results.push({
239
389
  noteId: row.source_id,