@openparachute/vault 0.7.0-rc.9 → 0.7.2-rc.3
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/aggregate.test.ts +260 -0
- package/core/src/contract-taxonomy.test.ts +26 -2
- package/core/src/contract-typed-index.test.ts +4 -2
- package/core/src/core.test.ts +737 -2
- package/core/src/cursor-keyset-ms.test.ts +537 -0
- package/core/src/cursor.ts +76 -6
- package/core/src/doctor.ts +23 -1
- package/core/src/hooks.ts +9 -0
- package/core/src/indexed-fields.test.ts +4 -1
- package/core/src/indexed-fields.ts +6 -1
- package/core/src/links.ts +22 -0
- package/core/src/mcp.ts +322 -53
- package/core/src/notes.ts +518 -67
- package/core/src/paths.ts +4 -0
- package/core/src/portable-md.test.ts +161 -0
- package/core/src/portable-md.ts +140 -9
- package/core/src/query-warnings.ts +60 -12
- package/core/src/schema-defaults.ts +7 -1
- package/core/src/schema.ts +128 -2
- package/core/src/seed-packs.ts +55 -15
- package/core/src/store.ts +141 -7
- package/core/src/tag-schemas.ts +20 -7
- package/core/src/txn.test.ts +100 -4
- package/core/src/txn.ts +119 -24
- package/core/src/types.ts +89 -0
- package/core/src/ulid.test.ts +56 -0
- package/core/src/ulid.ts +116 -0
- package/core/src/vault-projection.ts +19 -1
- package/core/src/wikilinks.test.ts +205 -1
- package/core/src/wikilinks.ts +200 -35
- package/package.json +1 -1
- package/src/aggregate-routes.test.ts +230 -0
- package/src/cli.ts +6 -0
- package/src/contract-errors.test.ts +2 -1
- package/src/contract-search.test.ts +17 -0
- package/src/mcp-link-warnings-scope.test.ts +144 -0
- package/src/mcp-query-notes-aggregate-scope.test.ts +183 -0
- package/src/mcp-tools.ts +76 -17
- package/src/mirror-import.ts +5 -0
- package/src/routes.ts +298 -24
- package/src/routing.test.ts +167 -0
- package/src/routing.ts +47 -11
- package/src/tag-integrity-mcp.test.ts +45 -6
- package/src/tag-scope.ts +10 -1
- package/src/vault.test.ts +557 -21
- package/web/ui/dist/assets/{index-B8pGeQam.js → index-CJ6NtIwh.js} +1 -1
- package/web/ui/dist/index.html +1 -1
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST face of aggregation / rollup queries (top new-feature ask from a UX
|
|
3
|
+
* round) — `GET /notes?aggregate[group_by]=…&aggregate[op]=…&aggregate[field]=…`.
|
|
4
|
+
*
|
|
5
|
+
* Core-level SQL correctness (count/sum semantics, group_by "tag", NULL
|
|
6
|
+
* groups, the FIELD_NOT_INDEXED / INVALID_QUERY error contract) is pinned
|
|
7
|
+
* in `core/src/aggregate.test.ts`; this suite covers the HTTP wiring: param
|
|
8
|
+
* parsing, 400s, the response shape, and — the part that can't be tested at
|
|
9
|
+
* the core layer since core stays scope-unaware — that a tag-scoped caller's
|
|
10
|
+
* rollup is computed only over notes it can see (no out-of-scope leakage,
|
|
11
|
+
* and no undercounting of in-scope notes). Fully sandboxed — in-memory
|
|
12
|
+
* SQLite, no daemon.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
15
|
+
import { Database } from "bun:sqlite";
|
|
16
|
+
import { BunStore } from "./vault-store.ts";
|
|
17
|
+
import { handleNotes, type TagScopeCtx } from "./routes.ts";
|
|
18
|
+
|
|
19
|
+
let db: Database;
|
|
20
|
+
let store: BunStore;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
db = new Database(":memory:");
|
|
24
|
+
store = new BunStore(db);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
db.close();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const BASE = "http://localhost/api";
|
|
32
|
+
const NO_SCOPE: TagScopeCtx = { allowed: null, raw: null };
|
|
33
|
+
|
|
34
|
+
function get(path: string, tagScope: TagScopeCtx = NO_SCOPE): Promise<Response> {
|
|
35
|
+
return handleNotes(new Request(`${BASE}/notes${path}`, { method: "GET" }), store, "", undefined, tagScope);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("REST GET /notes — aggregate: count by an indexed enum field", () => {
|
|
39
|
+
it("returns [{group, value}] rows", async () => {
|
|
40
|
+
await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
|
|
41
|
+
await store.createNote("a", { tags: ["task"], metadata: { status: "open" } });
|
|
42
|
+
await store.createNote("b", { tags: ["task"], metadata: { status: "open" } });
|
|
43
|
+
await store.createNote("c", { tags: ["task"], metadata: { status: "done" } });
|
|
44
|
+
|
|
45
|
+
const res = await get("?aggregate[group_by]=status&aggregate[op]=count");
|
|
46
|
+
expect(res.status).toBe(200);
|
|
47
|
+
const body: any = await res.json();
|
|
48
|
+
const byGroup = Object.fromEntries(body.map((r: any) => [r.group, r.value]));
|
|
49
|
+
expect(byGroup).toEqual({ open: 2, done: 1 });
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("REST GET /notes — aggregate: sum a numeric field", () => {
|
|
54
|
+
it("sums per group", async () => {
|
|
55
|
+
await store.upsertTagRecord("expense", {
|
|
56
|
+
fields: {
|
|
57
|
+
category: { type: "string", indexed: true },
|
|
58
|
+
amount: { type: "integer", indexed: true },
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
await store.createNote("a", { tags: ["expense"], metadata: { category: "food", amount: 10 } });
|
|
62
|
+
await store.createNote("b", { tags: ["expense"], metadata: { category: "food", amount: 25 } });
|
|
63
|
+
await store.createNote("c", { tags: ["expense"], metadata: { category: "travel", amount: 100 } });
|
|
64
|
+
|
|
65
|
+
const res = await get("?aggregate[group_by]=category&aggregate[op]=sum&aggregate[field]=amount");
|
|
66
|
+
expect(res.status).toBe(200);
|
|
67
|
+
const body: any = await res.json();
|
|
68
|
+
const byGroup = Object.fromEntries(body.map((r: any) => [r.group, r.value]));
|
|
69
|
+
expect(byGroup).toEqual({ food: 35, travel: 100 });
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("REST GET /notes — aggregate respects a prefilter", () => {
|
|
74
|
+
it("a tag= prefilter narrows the input set before aggregating", async () => {
|
|
75
|
+
await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
|
|
76
|
+
await store.createNote("a", { tags: ["task", "work"], metadata: { status: "open" } });
|
|
77
|
+
await store.createNote("b", { tags: ["task", "personal"], metadata: { status: "open" } });
|
|
78
|
+
await store.createNote("c", { tags: ["task", "work"], metadata: { status: "done" } });
|
|
79
|
+
|
|
80
|
+
const res = await get("?tag=work&aggregate[group_by]=status&aggregate[op]=count");
|
|
81
|
+
expect(res.status).toBe(200);
|
|
82
|
+
const body: any = await res.json();
|
|
83
|
+
const byGroup = Object.fromEntries(body.map((r: any) => [r.group, r.value]));
|
|
84
|
+
expect(byGroup).toEqual({ open: 1, done: 1 });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("a meta[field][op] prefilter narrows the input set before aggregating", async () => {
|
|
88
|
+
await store.upsertTagRecord("task", {
|
|
89
|
+
fields: {
|
|
90
|
+
status: { type: "string", indexed: true },
|
|
91
|
+
priority: { type: "string", indexed: true },
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
await store.createNote("a", { tags: ["task"], metadata: { status: "open", priority: "high" } });
|
|
95
|
+
await store.createNote("b", { tags: ["task"], metadata: { status: "open", priority: "low" } });
|
|
96
|
+
await store.createNote("c", { tags: ["task"], metadata: { status: "done", priority: "high" } });
|
|
97
|
+
|
|
98
|
+
const res = await get("?meta[priority][eq]=high&aggregate[group_by]=status&aggregate[op]=count");
|
|
99
|
+
expect(res.status).toBe(200);
|
|
100
|
+
const body: any = await res.json();
|
|
101
|
+
const byGroup = Object.fromEntries(body.map((r: any) => [r.group, r.value]));
|
|
102
|
+
expect(byGroup).toEqual({ open: 1, done: 1 });
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("REST GET /notes — aggregate errors", () => {
|
|
107
|
+
it("400s on a non-indexed group_by (FIELD_NOT_INDEXED)", async () => {
|
|
108
|
+
await store.createNote("a", { metadata: { status: "open" } });
|
|
109
|
+
const res = await get("?aggregate[group_by]=status&aggregate[op]=count");
|
|
110
|
+
expect(res.status).toBe(400);
|
|
111
|
+
const body: any = await res.json();
|
|
112
|
+
expect(body.code).toBe("FIELD_NOT_INDEXED");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("400s when op is \"sum\" without a field", async () => {
|
|
116
|
+
await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
|
|
117
|
+
const res = await get("?aggregate[group_by]=status&aggregate[op]=sum");
|
|
118
|
+
expect(res.status).toBe(400);
|
|
119
|
+
const body: any = await res.json();
|
|
120
|
+
expect(body.field).toBe("aggregate.field");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("400s on an unrecognized op", async () => {
|
|
124
|
+
await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
|
|
125
|
+
const res = await get("?aggregate[group_by]=status&aggregate[op]=average");
|
|
126
|
+
expect(res.status).toBe(400);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("400s when group_by is present but op is missing", async () => {
|
|
130
|
+
const res = await get("?aggregate[group_by]=status");
|
|
131
|
+
expect(res.status).toBe(400);
|
|
132
|
+
const body: any = await res.json();
|
|
133
|
+
expect(body.field).toBe("aggregate");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("400s when aggregate is combined with cursor", async () => {
|
|
137
|
+
await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
|
|
138
|
+
const res = await get("?aggregate[group_by]=status&aggregate[op]=count&cursor=");
|
|
139
|
+
expect(res.status).toBe(400);
|
|
140
|
+
const body: any = await res.json();
|
|
141
|
+
expect(body.field).toBe("aggregate");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("400s when aggregate is combined with near", async () => {
|
|
145
|
+
await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
|
|
146
|
+
const anchor = await store.createNote("anchor", { tags: ["task"], metadata: { status: "open" } });
|
|
147
|
+
const res = await get(`?aggregate[group_by]=status&aggregate[op]=count&near[note_id]=${anchor.id}`);
|
|
148
|
+
expect(res.status).toBe(400);
|
|
149
|
+
const body: any = await res.json();
|
|
150
|
+
expect(body.field).toBe("aggregate");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("REST GET /notes — aggregate: group_by \"tag\"", () => {
|
|
155
|
+
it("counts by tag membership", async () => {
|
|
156
|
+
await store.createNote("a", { tags: ["work", "urgent"] });
|
|
157
|
+
await store.createNote("b", { tags: ["work"] });
|
|
158
|
+
await store.createNote("c", { tags: ["personal"] });
|
|
159
|
+
|
|
160
|
+
const res = await get("?aggregate[group_by]=tag&aggregate[op]=count");
|
|
161
|
+
expect(res.status).toBe(200);
|
|
162
|
+
const body: any = await res.json();
|
|
163
|
+
const byGroup = Object.fromEntries(body.map((r: any) => [r.group, r.value]));
|
|
164
|
+
expect(byGroup).toEqual({ work: 2, urgent: 1, personal: 1 });
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("REST GET /notes — aggregate: tag-scope respected (no out-of-scope leakage)", () => {
|
|
169
|
+
it("counts by an indexed field ONLY over notes the token can see", async () => {
|
|
170
|
+
await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
|
|
171
|
+
// Two in-scope (#work) notes, two out-of-scope (#personal) notes — all
|
|
172
|
+
// sharing the SAME status values, so a leak would silently inflate the
|
|
173
|
+
// in-scope counts rather than surfacing as an obviously-wrong shape.
|
|
174
|
+
await store.createNote("a", { tags: ["task", "work"], metadata: { status: "open" } });
|
|
175
|
+
await store.createNote("b", { tags: ["task", "work"], metadata: { status: "done" } });
|
|
176
|
+
await store.createNote("c", { tags: ["task", "personal"], metadata: { status: "open" } });
|
|
177
|
+
await store.createNote("d", { tags: ["task", "personal"], metadata: { status: "open" } });
|
|
178
|
+
|
|
179
|
+
const scoped: TagScopeCtx = { allowed: new Set(["work"]), raw: ["work"] };
|
|
180
|
+
const res = await get("?aggregate[group_by]=status&aggregate[op]=count", scoped);
|
|
181
|
+
expect(res.status).toBe(200);
|
|
182
|
+
const body: any = await res.json();
|
|
183
|
+
const byGroup = Object.fromEntries(body.map((r: any) => [r.group, r.value]));
|
|
184
|
+
// Only the two #work notes — the #personal "open" pair must not leak
|
|
185
|
+
// into (or inflate) the "open" group.
|
|
186
|
+
expect(byGroup).toEqual({ open: 1, done: 1 });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("group_by \"tag\" never surfaces an out-of-scope tag name — including an out-of-scope CO-TAG on an otherwise-visible note", async () => {
|
|
190
|
+
// Note "a" is visible (carries "work"), but ALSO carries "urgent" —
|
|
191
|
+
// an out-of-scope co-tag. Narrowing which NOTES count isn't enough:
|
|
192
|
+
// "urgent" must not surface as a group just because its note is
|
|
193
|
+
// visible via a different tag.
|
|
194
|
+
await store.createNote("a", { tags: ["work", "urgent"] });
|
|
195
|
+
await store.createNote("b", { tags: ["personal", "secret-project"] });
|
|
196
|
+
|
|
197
|
+
const scoped: TagScopeCtx = { allowed: new Set(["work"]), raw: ["work"] };
|
|
198
|
+
const res = await get("?aggregate[group_by]=tag&aggregate[op]=count", scoped);
|
|
199
|
+
expect(res.status).toBe(200);
|
|
200
|
+
const body: any = await res.json();
|
|
201
|
+
const groups = body.map((r: any) => r.group);
|
|
202
|
+
expect(groups).toEqual(["work"]);
|
|
203
|
+
expect(groups).not.toContain("urgent");
|
|
204
|
+
expect(groups).not.toContain("personal");
|
|
205
|
+
expect(groups).not.toContain("secret-project");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("an out-of-scope token aggregating over a filter that matches NO visible notes returns an empty rollup, not an error", async () => {
|
|
209
|
+
await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
|
|
210
|
+
await store.createNote("a", { tags: ["task", "personal"], metadata: { status: "open" } });
|
|
211
|
+
|
|
212
|
+
const scoped: TagScopeCtx = { allowed: new Set(["work"]), raw: ["work"] };
|
|
213
|
+
const res = await get("?aggregate[group_by]=status&aggregate[op]=count", scoped);
|
|
214
|
+
expect(res.status).toBe(200);
|
|
215
|
+
const body: any = await res.json();
|
|
216
|
+
expect(body).toEqual([]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("an unscoped token sees the full rollup (regression check against the scoped path above)", async () => {
|
|
220
|
+
await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
|
|
221
|
+
await store.createNote("a", { tags: ["task", "work"], metadata: { status: "open" } });
|
|
222
|
+
await store.createNote("b", { tags: ["task", "personal"], metadata: { status: "open" } });
|
|
223
|
+
|
|
224
|
+
const res = await get("?aggregate[group_by]=status&aggregate[op]=count");
|
|
225
|
+
expect(res.status).toBe(200);
|
|
226
|
+
const body: any = await res.json();
|
|
227
|
+
const byGroup = Object.fromEntries(body.map((r: any) => [r.group, r.value]));
|
|
228
|
+
expect(byGroup).toEqual({ open: 2 });
|
|
229
|
+
});
|
|
230
|
+
});
|
package/src/cli.ts
CHANGED
|
@@ -2983,6 +2983,12 @@ async function cmdImport(args: string[]) {
|
|
|
2983
2983
|
if (stats.skipped_attachments.length > 0) {
|
|
2984
2984
|
console.log(`Skipped ${stats.skipped_attachments.length} attachment(s) — see warnings above.`);
|
|
2985
2985
|
}
|
|
2986
|
+
if (stats.skipped_duplicate_ids.length > 0) {
|
|
2987
|
+
console.log(
|
|
2988
|
+
`Skipped ${stats.skipped_duplicate_ids.length} file(s) with duplicate/blank note id(s) — ` +
|
|
2989
|
+
`kept the first of each collision; see warnings above.`,
|
|
2990
|
+
);
|
|
2991
|
+
}
|
|
2986
2992
|
return;
|
|
2987
2993
|
}
|
|
2988
2994
|
|
|
@@ -315,7 +315,7 @@ describe("contract: error taxonomy — #554 (flipped from todo)", () => {
|
|
|
315
315
|
expect(record?.fields ?? null).toBeFalsy();
|
|
316
316
|
});
|
|
317
317
|
|
|
318
|
-
// Positive control — every one of the
|
|
318
|
+
// Positive control — every one of the seven recognized types (indexable or
|
|
319
319
|
// not) is accepted without complaint.
|
|
320
320
|
it("REST PUT /api/tags/:name accepts every recognized field type", async () => {
|
|
321
321
|
const req = new Request("http://localhost/api/tags/widget", {
|
|
@@ -328,6 +328,7 @@ describe("contract: error taxonomy — #554 (flipped from todo)", () => {
|
|
|
328
328
|
d: { type: "boolean" },
|
|
329
329
|
e: { type: "array" },
|
|
330
330
|
f: { type: "object" },
|
|
331
|
+
g: { type: "reference" },
|
|
331
332
|
},
|
|
332
333
|
}),
|
|
333
334
|
});
|
|
@@ -377,6 +377,23 @@ describe("contract: search — recall + ranking legibility (WS2B/C, #551, schema
|
|
|
377
377
|
expect(w.did_you_mean).toBe("vasquez");
|
|
378
378
|
});
|
|
379
379
|
|
|
380
|
+
it("did_you_mean suggests a real surface word, not a porter stem (vault#570)", async () => {
|
|
381
|
+
// "cactus" is porter-stemmed to "cactu" in notes_fts_vocab — the exact
|
|
382
|
+
// fragment cited as a live bug in issue #570. A close-but-wrong query
|
|
383
|
+
// must resolve back to the dictionary word a user actually typed
|
|
384
|
+
// ("cactus"), not the truncated stem.
|
|
385
|
+
await store.createNote("A desert cactus stores water efficiently.");
|
|
386
|
+
const res = await search("search=cactuz");
|
|
387
|
+
expect(res.status).toBe(200);
|
|
388
|
+
const body = await bodyOf(res);
|
|
389
|
+
expect(body).toEqual([]);
|
|
390
|
+
const warnings = decodeWarningsHeader(res);
|
|
391
|
+
expect(warnings).not.toBeNull();
|
|
392
|
+
const w = warnings!.find((x: any) => x.code === "search_did_you_mean");
|
|
393
|
+
expect(w).toBeDefined();
|
|
394
|
+
expect(w.did_you_mean).toBe("cactus");
|
|
395
|
+
});
|
|
396
|
+
|
|
380
397
|
it("a genuinely zero-result search with NO close vocabulary match carries no did_you_mean warning", async () => {
|
|
381
398
|
await store.createNote(NOTES.bothWords);
|
|
382
399
|
const res = await search("search=zzzznonexistentword");
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tag-scope interaction with create-note/update-note's `unresolved_link` /
|
|
3
|
+
* `ambiguous_link` warnings (vault#555, vault#570).
|
|
4
|
+
*
|
|
5
|
+
* `applyTagScopeWrappers`'s `query-notes` wrapper (src/mcp-tools.ts) DROPS
|
|
6
|
+
* its `warnings` array for a tag-scoped session — `unknown_tag`/
|
|
7
|
+
* `did_you_mean`/`search_did_you_mean` are computed against the FULL
|
|
8
|
+
* vault-wide tag catalog / FTS5 vocabulary, so surfacing them to a scoped
|
|
9
|
+
* caller would leak an out-of-scope tag's/note's existence (see
|
|
10
|
+
* docs/HTTP_API.md's "Tag-scoped tokens never see unknown_tag/did_you_mean
|
|
11
|
+
* or search_did_you_mean" paragraph, and docs/contracts/tag-scoped-tokens.md).
|
|
12
|
+
*
|
|
13
|
+
* `create-note`/`update-note`'s link warnings are a DIFFERENT case: they
|
|
14
|
+
* describe the CALLER'S OWN note's own outgoing link, named by the caller —
|
|
15
|
+
* not a vault-wide vocabulary scan the caller wouldn't otherwise see. The
|
|
16
|
+
* existing `unresolved_link` warning (structured `links`, vault#555) was
|
|
17
|
+
* never gated by `applyTagScopeWrappers`'s `create-note`/`update-note`
|
|
18
|
+
* wrappers (see `wrapReadTool(tools, "create-note", ...)` /
|
|
19
|
+
* `"update-note"` in src/mcp-tools.ts — neither calls `scrubNoteLinks` or
|
|
20
|
+
* strips `.warnings`), and docs/HTTP_API.md's "never see" list names only
|
|
21
|
+
* the vault-wide-vocabulary warnings above, NOT `unresolved_link`.
|
|
22
|
+
*
|
|
23
|
+
* This test locks in that vault#570's new content-wikilink `unresolved_link`
|
|
24
|
+
* and the new `ambiguous_link` warning (both content and structured) follow
|
|
25
|
+
* that SAME existing pattern — present under a tag-scoped session, exactly
|
|
26
|
+
* as they are unscoped — rather than silently introducing a NEW special
|
|
27
|
+
* case one way or the other.
|
|
28
|
+
*/
|
|
29
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
30
|
+
import { mkdirSync, rmSync, existsSync } from "fs";
|
|
31
|
+
import { join } from "path";
|
|
32
|
+
import { tmpdir } from "os";
|
|
33
|
+
import { writeVaultConfig } from "./config.ts";
|
|
34
|
+
import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
|
|
35
|
+
import { generateScopedMcpTools } from "./mcp-tools.ts";
|
|
36
|
+
import type { AuthResult } from "./auth.ts";
|
|
37
|
+
|
|
38
|
+
let tmpHome: string;
|
|
39
|
+
let prevHome: string | undefined;
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
tmpHome = join(tmpdir(), `vault-link-warnings-scope-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
43
|
+
mkdirSync(join(tmpHome, "vault", "data"), { recursive: true });
|
|
44
|
+
prevHome = process.env.PARACHUTE_HOME;
|
|
45
|
+
process.env.PARACHUTE_HOME = tmpHome;
|
|
46
|
+
clearVaultStoreCache();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
clearVaultStoreCache();
|
|
51
|
+
if (prevHome === undefined) delete process.env.PARACHUTE_HOME;
|
|
52
|
+
else process.env.PARACHUTE_HOME = prevHome;
|
|
53
|
+
if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
function seedVault(name: string): void {
|
|
57
|
+
writeVaultConfig({ name, api_keys: [], created_at: new Date().toISOString() });
|
|
58
|
+
getVaultStore(name);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function authFor(vaultName: string, scopedTags: string[] | null): AuthResult {
|
|
62
|
+
return {
|
|
63
|
+
permission: "full",
|
|
64
|
+
scopes: [`vault:${vaultName}:read`, `vault:${vaultName}:write`],
|
|
65
|
+
legacyDerived: false,
|
|
66
|
+
scoped_tags: scopedTags,
|
|
67
|
+
vault_name: null,
|
|
68
|
+
caller_jti: null,
|
|
69
|
+
actor: "test-user",
|
|
70
|
+
via: "api",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function toolsFor(vaultName: string, scopedTags: string[] | null) {
|
|
75
|
+
return generateScopedMcpTools(vaultName, authFor(vaultName, scopedTags));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe("create-note/update-note link warnings under a tag-scoped session (vault#570)", () => {
|
|
79
|
+
test("scoped create-note: content [[wikilink]] to a missing target still warns (unresolved_link) — same as unscoped", async () => {
|
|
80
|
+
seedVault("journal");
|
|
81
|
+
const tools = toolsFor("journal", ["health"]);
|
|
82
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
83
|
+
|
|
84
|
+
const result = (await createNote.execute({
|
|
85
|
+
content: "See [[Missing Health Note]].",
|
|
86
|
+
tags: ["health"],
|
|
87
|
+
})) as any;
|
|
88
|
+
|
|
89
|
+
expect(result.warnings).toBeDefined();
|
|
90
|
+
expect(result.warnings[0].code).toBe("unresolved_link");
|
|
91
|
+
expect(result.warnings[0].target).toBe("Missing Health Note");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("scoped create-note: content [[wikilink]] to an ambiguous target still warns (ambiguous_link), no edge — same as unscoped", async () => {
|
|
95
|
+
seedVault("journal");
|
|
96
|
+
const store = getVaultStore("journal");
|
|
97
|
+
await store.createNote("A", { path: "Folder1/ScopedDup", tags: ["health"] });
|
|
98
|
+
await store.createNote("B", { path: "Folder2/ScopedDup", tags: ["health"] });
|
|
99
|
+
|
|
100
|
+
const tools = toolsFor("journal", ["health"]);
|
|
101
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
102
|
+
const result = (await createNote.execute({
|
|
103
|
+
content: "See [[ScopedDup]].",
|
|
104
|
+
tags: ["health"],
|
|
105
|
+
})) as any;
|
|
106
|
+
|
|
107
|
+
expect(result.warnings).toBeDefined();
|
|
108
|
+
expect(result.warnings[0].code).toBe("ambiguous_link");
|
|
109
|
+
expect(result.warnings[0].candidate_count).toBe(2);
|
|
110
|
+
expect(await store.getLinks(result.id, { direction: "outbound" })).toHaveLength(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("scoped update-note: content update introducing a [[wikilink]] to a missing target still warns", async () => {
|
|
114
|
+
seedVault("journal");
|
|
115
|
+
const store = getVaultStore("journal");
|
|
116
|
+
const note = await store.createNote("plain", { path: "ScopedUpdatable", tags: ["health"] });
|
|
117
|
+
|
|
118
|
+
const tools = toolsFor("journal", ["health"]);
|
|
119
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
120
|
+
const result = (await updateNote.execute({
|
|
121
|
+
id: note.id,
|
|
122
|
+
content: "now references [[Still Missing Scoped]]",
|
|
123
|
+
force: true,
|
|
124
|
+
})) as any;
|
|
125
|
+
|
|
126
|
+
expect(result.warnings).toBeDefined();
|
|
127
|
+
expect(result.warnings[0].code).toBe("unresolved_link");
|
|
128
|
+
expect(result.warnings[0].target).toBe("Still Missing Scoped");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("unscoped session sees the identical warning shape (parity check)", async () => {
|
|
132
|
+
seedVault("journal");
|
|
133
|
+
const tools = toolsFor("journal", null);
|
|
134
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
135
|
+
|
|
136
|
+
const result = (await createNote.execute({
|
|
137
|
+
content: "See [[Missing Health Note]].",
|
|
138
|
+
})) as any;
|
|
139
|
+
|
|
140
|
+
expect(result.warnings).toBeDefined();
|
|
141
|
+
expect(result.warnings[0].code).toBe("unresolved_link");
|
|
142
|
+
expect(result.warnings[0].target).toBe("Missing Health Note");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tag-scope × `query-notes` `aggregate` mode (top new-feature ask from a UX
|
|
3
|
+
* round).
|
|
4
|
+
*
|
|
5
|
+
* A rollup response is `[{group, value}]` — no per-note `.tags` to post-hoc
|
|
6
|
+
* filter (`noteWithinTagScope`, `src/tag-scope.ts`), unlike every other
|
|
7
|
+
* `query-notes` shape. So `applyTagScopeWrappers`'s generic array-filter
|
|
8
|
+
* (`src/mcp-tools.ts`) is skipped for aggregate requests, and scope is
|
|
9
|
+
* instead enforced BEFORE aggregating via the `aggregateVisibility`
|
|
10
|
+
* predicate wired in `generateScopedMcpTools`: fetch every note the other
|
|
11
|
+
* filters match, narrow to the token's allowlist, THEN aggregate over just
|
|
12
|
+
* that visible id set (see `core/src/mcp.ts`'s aggregate branch). This
|
|
13
|
+
* suite is the self-verifying test for that path — mirrors the harness
|
|
14
|
+
* `src/mcp-query-notes-search-scope.test.ts` uses for the equivalent search
|
|
15
|
+
* × scope concern.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
18
|
+
import { mkdirSync, rmSync, existsSync } from "fs";
|
|
19
|
+
import { join } from "path";
|
|
20
|
+
import { tmpdir } from "os";
|
|
21
|
+
import { writeVaultConfig } from "./config.ts";
|
|
22
|
+
import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
|
|
23
|
+
import { generateScopedMcpTools } from "./mcp-tools.ts";
|
|
24
|
+
import type { AuthResult } from "./auth.ts";
|
|
25
|
+
|
|
26
|
+
let tmpHome: string;
|
|
27
|
+
let prevHome: string | undefined;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
tmpHome = join(tmpdir(), `vault-aggregate-scope-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
31
|
+
mkdirSync(join(tmpHome, "vault", "data"), { recursive: true });
|
|
32
|
+
prevHome = process.env.PARACHUTE_HOME;
|
|
33
|
+
process.env.PARACHUTE_HOME = tmpHome;
|
|
34
|
+
clearVaultStoreCache();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
clearVaultStoreCache();
|
|
39
|
+
if (prevHome === undefined) delete process.env.PARACHUTE_HOME;
|
|
40
|
+
else process.env.PARACHUTE_HOME = prevHome;
|
|
41
|
+
if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
function seedVault(name: string): void {
|
|
45
|
+
writeVaultConfig({ name, api_keys: [], created_at: new Date().toISOString() });
|
|
46
|
+
getVaultStore(name);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function authFor(vaultName: string, scopedTags: string[] | null): AuthResult {
|
|
50
|
+
return {
|
|
51
|
+
permission: "full",
|
|
52
|
+
scopes: [`vault:${vaultName}:read`, `vault:${vaultName}:write`],
|
|
53
|
+
legacyDerived: false,
|
|
54
|
+
scoped_tags: scopedTags,
|
|
55
|
+
vault_name: null,
|
|
56
|
+
caller_jti: null,
|
|
57
|
+
actor: "test-user",
|
|
58
|
+
via: "api",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function queryNotesTool(vaultName: string, scopedTags: string[] | null) {
|
|
63
|
+
const tools = generateScopedMcpTools(vaultName, authFor(vaultName, scopedTags));
|
|
64
|
+
return tools.find((t) => t.name === "query-notes")!;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function byGroup(result: unknown): Record<string, number> {
|
|
68
|
+
expect(Array.isArray(result)).toBe(true);
|
|
69
|
+
return Object.fromEntries((result as any[]).map((r) => [String(r.group), r.value]));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe("MCP query-notes aggregate × tag-scope — indexed-field group_by", () => {
|
|
73
|
+
test("count by an indexed field is computed ONLY over notes the scoped token can see", async () => {
|
|
74
|
+
seedVault("journal");
|
|
75
|
+
const store = getVaultStore("journal");
|
|
76
|
+
await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
|
|
77
|
+
|
|
78
|
+
// Same status values on both sides of the scope boundary, so a leak
|
|
79
|
+
// would silently inflate the in-scope counts rather than producing an
|
|
80
|
+
// obviously-wrong shape.
|
|
81
|
+
await store.createNote("in-scope open 1", { tags: ["task", "health"], metadata: { status: "open" } });
|
|
82
|
+
await store.createNote("in-scope done", { tags: ["task", "health"], metadata: { status: "done" } });
|
|
83
|
+
await store.createNote("out-of-scope open 1", { tags: ["task", "work"], metadata: { status: "open" } });
|
|
84
|
+
await store.createNote("out-of-scope open 2", { tags: ["task", "work"], metadata: { status: "open" } });
|
|
85
|
+
|
|
86
|
+
const scopedTool = await queryNotesTool("journal", ["health"]);
|
|
87
|
+
const result = await scopedTool.execute({ aggregate: { group_by: "status", op: "count" } });
|
|
88
|
+
expect(byGroup(result)).toEqual({ open: 1, done: 1 });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("sum of an indexed numeric field is computed ONLY over notes the scoped token can see", async () => {
|
|
92
|
+
seedVault("journal");
|
|
93
|
+
const store = getVaultStore("journal");
|
|
94
|
+
await store.upsertTagRecord("expense", {
|
|
95
|
+
fields: {
|
|
96
|
+
category: { type: "string", indexed: true },
|
|
97
|
+
amount: { type: "integer", indexed: true },
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
await store.createNote("in-scope", { tags: ["expense", "health"], metadata: { category: "food", amount: 10 } });
|
|
101
|
+
await store.createNote("out-of-scope", { tags: ["expense", "work"], metadata: { category: "food", amount: 1000 } });
|
|
102
|
+
|
|
103
|
+
const scopedTool = await queryNotesTool("journal", ["health"]);
|
|
104
|
+
const result = await scopedTool.execute({
|
|
105
|
+
aggregate: { group_by: "category", op: "sum", field: "amount" },
|
|
106
|
+
});
|
|
107
|
+
expect(byGroup(result)).toEqual({ food: 10 });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("unscoped session sees the full rollup across both sides (control)", async () => {
|
|
111
|
+
seedVault("journal");
|
|
112
|
+
const store = getVaultStore("journal");
|
|
113
|
+
await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
|
|
114
|
+
await store.createNote("a", { tags: ["task", "health"], metadata: { status: "open" } });
|
|
115
|
+
await store.createNote("b", { tags: ["task", "work"], metadata: { status: "open" } });
|
|
116
|
+
|
|
117
|
+
const unscopedTool = await queryNotesTool("journal", null);
|
|
118
|
+
const result = await unscopedTool.execute({ aggregate: { group_by: "status", op: "count" } });
|
|
119
|
+
expect(byGroup(result)).toEqual({ open: 2 });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a scoped token with no visible matches gets an empty rollup, not an error or a leak", async () => {
|
|
123
|
+
seedVault("journal");
|
|
124
|
+
const store = getVaultStore("journal");
|
|
125
|
+
await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
|
|
126
|
+
await store.createNote("out-of-scope only", { tags: ["task", "work"], metadata: { status: "open" } });
|
|
127
|
+
|
|
128
|
+
const scopedTool = await queryNotesTool("journal", ["health"]);
|
|
129
|
+
const result = await scopedTool.execute({ aggregate: { group_by: "status", op: "count" } });
|
|
130
|
+
expect(result).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("MCP query-notes aggregate × tag-scope — group_by \"tag\"", () => {
|
|
135
|
+
test("an out-of-scope tag name never appears as a group, even on a co-tagged in-scope note", async () => {
|
|
136
|
+
seedVault("journal");
|
|
137
|
+
const store = getVaultStore("journal");
|
|
138
|
+
// This note IS in scope (carries "health"), but also carries the
|
|
139
|
+
// out-of-scope tag "secret-project" — the rollup must not surface that
|
|
140
|
+
// second tag as a group just because the note itself is visible.
|
|
141
|
+
await store.createNote("co-tagged", { tags: ["health", "secret-project"] });
|
|
142
|
+
await store.createNote("out-of-scope only", { tags: ["work"] });
|
|
143
|
+
|
|
144
|
+
const scopedTool = await queryNotesTool("journal", ["health"]);
|
|
145
|
+
const result = await scopedTool.execute({ aggregate: { group_by: "tag", op: "count" } });
|
|
146
|
+
const groups = (result as any[]).map((r) => r.group);
|
|
147
|
+
expect(groups).toContain("health");
|
|
148
|
+
expect(groups).not.toContain("secret-project");
|
|
149
|
+
expect(groups).not.toContain("work");
|
|
150
|
+
// Belt-and-suspenders: the out-of-scope tag name must not appear ANYWHERE
|
|
151
|
+
// in the serialized response.
|
|
152
|
+
expect(JSON.stringify(result)).not.toContain("secret-project");
|
|
153
|
+
expect(JSON.stringify(result)).not.toContain("\"work\"");
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("MCP query-notes aggregate — mutual exclusivity with search/near/cursor", () => {
|
|
158
|
+
test("aggregate + search is rejected", async () => {
|
|
159
|
+
seedVault("journal");
|
|
160
|
+
const tool = await queryNotesTool("journal", null);
|
|
161
|
+
await expect(
|
|
162
|
+
tool.execute({ search: "x", aggregate: { group_by: "tag", op: "count" } }),
|
|
163
|
+
).rejects.toThrow();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("aggregate + cursor is rejected", async () => {
|
|
167
|
+
seedVault("journal");
|
|
168
|
+
const tool = await queryNotesTool("journal", null);
|
|
169
|
+
await expect(
|
|
170
|
+
tool.execute({ cursor: "", aggregate: { group_by: "tag", op: "count" } }),
|
|
171
|
+
).rejects.toThrow();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("aggregate + near is rejected", async () => {
|
|
175
|
+
seedVault("journal");
|
|
176
|
+
const store = getVaultStore("journal");
|
|
177
|
+
const anchor = await store.createNote("anchor");
|
|
178
|
+
const tool = await queryNotesTool("journal", null);
|
|
179
|
+
await expect(
|
|
180
|
+
tool.execute({ near: { note_id: anchor.id }, aggregate: { group_by: "tag", op: "count" } }),
|
|
181
|
+
).rejects.toThrow();
|
|
182
|
+
});
|
|
183
|
+
});
|