@openparachute/vault 0.5.1 → 0.5.2-rc.2
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.
- package/core/src/core.test.ts +183 -26
- package/core/src/expand-visibility.test.ts +102 -0
- package/core/src/expand.ts +31 -3
- package/core/src/link-count.test.ts +301 -0
- package/core/src/links.ts +77 -0
- package/core/src/mcp.ts +130 -22
- package/core/src/notes.ts +36 -0
- package/core/src/portable-md.test.ts +40 -0
- package/core/src/schema.ts +7 -4
- package/core/src/store.ts +1 -1
- package/core/src/tag-schemas.ts +59 -44
- package/core/src/types.ts +31 -3
- package/package.json +1 -1
- package/src/auth.test.ts +37 -1
- package/src/auth.ts +29 -0
- package/src/cli.ts +286 -68
- package/src/config.test.ts +16 -0
- package/src/config.ts +39 -0
- package/src/init-summary.test.ts +77 -5
- package/src/init-summary.ts +37 -19
- package/src/mcp-tools.ts +60 -6
- package/src/routes.ts +486 -53
- package/src/routing.test.ts +185 -0
- package/src/routing.ts +32 -2
- package/src/server.ts +7 -0
- package/src/storage.test.ts +162 -0
- package/src/tag-scope.ts +68 -1
- package/src/transcription-worker.test.ts +471 -5
- package/src/transcription-worker.ts +212 -44
- package/src/usage.test.ts +362 -0
- package/src/usage.ts +318 -0
- package/src/vault-create.test.ts +298 -11
- package/src/vault.test.ts +1064 -7
|
@@ -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
|
@@ -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 {
|