@openparachute/vault 0.7.0-rc.8 → 0.7.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.
- 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 +1151 -0
- package/core/src/cursor.ts +1 -0
- package/core/src/doctor.ts +23 -1
- 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 +587 -79
- package/core/src/notes.ts +371 -18
- package/core/src/query-warnings.ts +60 -12
- package/core/src/schema-defaults.ts +7 -1
- package/core/src/seed-packs.test.ts +14 -0
- package/core/src/seed-packs.ts +42 -5
- package/core/src/store.ts +129 -5
- package/core/src/tag-schemas.ts +20 -7
- package/core/src/types.ts +98 -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 +244 -35
- package/package.json +1 -1
- package/src/aggregate-routes.test.ts +230 -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 +100 -19
- package/src/routes.ts +469 -39
- 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 +923 -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,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aggregation / rollup queries (top new-feature ask from a UX round).
|
|
3
|
+
*
|
|
4
|
+
* `aggregateNotes` (core/src/notes.ts) applies the SAME filter surface
|
|
5
|
+
* `queryNotes` does — via the shared `buildFilterConditions` — before
|
|
6
|
+
* grouping into `[{group, value}]` rows. This suite pins:
|
|
7
|
+
*
|
|
8
|
+
* - count by an indexed enum (string) field
|
|
9
|
+
* - sum of an indexed numeric (integer) field
|
|
10
|
+
* - a prefilter (tag / metadata) narrows the input set before aggregating
|
|
11
|
+
* - group_by "tag" (membership rollup, a note can land in several groups)
|
|
12
|
+
* - errors on a non-indexed group_by (FIELD_NOT_INDEXED)
|
|
13
|
+
* - errors on a non-indexed / non-numeric sum field
|
|
14
|
+
* - errors on a malformed aggregate spec (missing op/group_by/field)
|
|
15
|
+
* - a note missing the group_by field collects into `group: null`
|
|
16
|
+
*
|
|
17
|
+
* Tag-scope enforcement (server-layer, injected as an `ids` prefilter or an
|
|
18
|
+
* `aggregateVisibility` predicate) is covered separately at the MCP/REST
|
|
19
|
+
* integration layer (`src/mcp-query-notes-aggregate-scope.test.ts`,
|
|
20
|
+
* `src/aggregate-routes.test.ts`) — core stays scope-unaware by
|
|
21
|
+
* architecture, same division as every other query surface.
|
|
22
|
+
*/
|
|
23
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
24
|
+
import { Database } from "bun:sqlite";
|
|
25
|
+
import { SqliteStore } from "./store.js";
|
|
26
|
+
import { aggregateNotes } from "./notes.js";
|
|
27
|
+
import { QueryError } from "./query-operators.js";
|
|
28
|
+
|
|
29
|
+
let db: Database;
|
|
30
|
+
let store: SqliteStore;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
db = new Database(":memory:");
|
|
34
|
+
store = new SqliteStore(db);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("aggregateNotes — count by an indexed enum field", () => {
|
|
38
|
+
it("groups and counts", async () => {
|
|
39
|
+
await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
|
|
40
|
+
await store.createNote("a", { tags: ["task"], metadata: { status: "open" } });
|
|
41
|
+
await store.createNote("b", { tags: ["task"], metadata: { status: "open" } });
|
|
42
|
+
await store.createNote("c", { tags: ["task"], metadata: { status: "done" } });
|
|
43
|
+
|
|
44
|
+
const rows = aggregateNotes(db, { aggregate: { group_by: "status", op: "count" } });
|
|
45
|
+
const byGroup = Object.fromEntries(rows.map((r) => [r.group, r.value]));
|
|
46
|
+
expect(byGroup).toEqual({ open: 2, done: 1 });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("a note missing the group_by field collects into group: null", async () => {
|
|
50
|
+
await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
|
|
51
|
+
await store.createNote("a", { tags: ["task"], metadata: { status: "open" } });
|
|
52
|
+
await store.createNote("b", { tags: ["task"] }); // no status
|
|
53
|
+
|
|
54
|
+
const rows = aggregateNotes(db, { aggregate: { group_by: "status", op: "count" } });
|
|
55
|
+
const byGroup = Object.fromEntries(rows.map((r) => [r.group, r.value]));
|
|
56
|
+
expect(byGroup).toEqual({ open: 1, null: 1 });
|
|
57
|
+
expect(rows.find((r) => r.group === null)?.value).toBe(1);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("aggregateNotes — sum of an indexed numeric field", () => {
|
|
62
|
+
it("groups and sums", async () => {
|
|
63
|
+
await store.upsertTagRecord("expense", {
|
|
64
|
+
fields: {
|
|
65
|
+
category: { type: "string", indexed: true },
|
|
66
|
+
amount: { type: "integer", indexed: true },
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
await store.createNote("a", { tags: ["expense"], metadata: { category: "food", amount: 10 } });
|
|
70
|
+
await store.createNote("b", { tags: ["expense"], metadata: { category: "food", amount: 25 } });
|
|
71
|
+
await store.createNote("c", { tags: ["expense"], metadata: { category: "travel", amount: 100 } });
|
|
72
|
+
|
|
73
|
+
const rows = aggregateNotes(db, {
|
|
74
|
+
aggregate: { group_by: "category", op: "sum", field: "amount" },
|
|
75
|
+
});
|
|
76
|
+
const byGroup = Object.fromEntries(rows.map((r) => [r.group, r.value]));
|
|
77
|
+
expect(byGroup).toEqual({ food: 35, travel: 100 });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("sums boolean-backed indexed fields (count-of-true)", async () => {
|
|
81
|
+
await store.upsertTagRecord("task", {
|
|
82
|
+
fields: {
|
|
83
|
+
status: { type: "string", indexed: true },
|
|
84
|
+
done: { type: "boolean", indexed: true },
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
await store.createNote("a", { tags: ["task"], metadata: { status: "x", done: true } });
|
|
88
|
+
await store.createNote("b", { tags: ["task"], metadata: { status: "x", done: true } });
|
|
89
|
+
await store.createNote("c", { tags: ["task"], metadata: { status: "x", done: false } });
|
|
90
|
+
|
|
91
|
+
const rows = aggregateNotes(db, { aggregate: { group_by: "status", op: "sum", field: "done" } });
|
|
92
|
+
expect(rows).toEqual([{ group: "x", value: 2 }]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("aggregateNotes — respects a prefilter", () => {
|
|
97
|
+
it("a tag prefilter narrows the input set before aggregating", async () => {
|
|
98
|
+
await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
|
|
99
|
+
await store.createNote("a", { tags: ["task", "work"], metadata: { status: "open" } });
|
|
100
|
+
await store.createNote("b", { tags: ["task", "personal"], metadata: { status: "open" } });
|
|
101
|
+
await store.createNote("c", { tags: ["task", "work"], metadata: { status: "done" } });
|
|
102
|
+
|
|
103
|
+
const rows = aggregateNotes(db, {
|
|
104
|
+
tags: ["work"],
|
|
105
|
+
aggregate: { group_by: "status", op: "count" },
|
|
106
|
+
});
|
|
107
|
+
const byGroup = Object.fromEntries(rows.map((r) => [r.group, r.value]));
|
|
108
|
+
// Only the two #work notes count — the #personal "open" note is excluded.
|
|
109
|
+
expect(byGroup).toEqual({ open: 1, done: 1 });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("a metadata prefilter narrows the input set before aggregating", async () => {
|
|
113
|
+
await store.upsertTagRecord("task", {
|
|
114
|
+
fields: {
|
|
115
|
+
status: { type: "string", indexed: true },
|
|
116
|
+
priority: { type: "string", indexed: true },
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
await store.createNote("a", { tags: ["task"], metadata: { status: "open", priority: "high" } });
|
|
120
|
+
await store.createNote("b", { tags: ["task"], metadata: { status: "open", priority: "low" } });
|
|
121
|
+
await store.createNote("c", { tags: ["task"], metadata: { status: "done", priority: "high" } });
|
|
122
|
+
|
|
123
|
+
const rows = aggregateNotes(db, {
|
|
124
|
+
metadata: { priority: "high" },
|
|
125
|
+
aggregate: { group_by: "status", op: "count" },
|
|
126
|
+
});
|
|
127
|
+
const byGroup = Object.fromEntries(rows.map((r) => [r.group, r.value]));
|
|
128
|
+
expect(byGroup).toEqual({ open: 1, done: 1 });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("an `ids` prefilter narrows the input set before aggregating (the tag-scope injection point)", async () => {
|
|
132
|
+
await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
|
|
133
|
+
const a = await store.createNote("a", { tags: ["task"], metadata: { status: "open" } });
|
|
134
|
+
await store.createNote("b", { tags: ["task"], metadata: { status: "open" } });
|
|
135
|
+
await store.createNote("c", { tags: ["task"], metadata: { status: "done" } });
|
|
136
|
+
|
|
137
|
+
const rows = aggregateNotes(db, {
|
|
138
|
+
ids: [a.id],
|
|
139
|
+
aggregate: { group_by: "status", op: "count" },
|
|
140
|
+
});
|
|
141
|
+
expect(rows).toEqual([{ group: "open", value: 1 }]);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("aggregateNotes — group_by: \"tag\"", () => {
|
|
146
|
+
it("counts by tag membership — a note carrying N tags contributes to N groups", async () => {
|
|
147
|
+
await store.createNote("a", { tags: ["work", "urgent"] });
|
|
148
|
+
await store.createNote("b", { tags: ["work"] });
|
|
149
|
+
await store.createNote("c", { tags: ["personal"] });
|
|
150
|
+
|
|
151
|
+
const rows = aggregateNotes(db, { aggregate: { group_by: "tag", op: "count" } });
|
|
152
|
+
const byGroup = Object.fromEntries(rows.map((r) => [r.group, r.value]));
|
|
153
|
+
expect(byGroup).toEqual({ work: 2, urgent: 1, personal: 1 });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("sums by tag membership", async () => {
|
|
157
|
+
await store.upsertTagRecord("expense", { fields: { amount: { type: "integer", indexed: true } } });
|
|
158
|
+
await store.createNote("a", { tags: ["expense", "food"], metadata: { amount: 10 } });
|
|
159
|
+
await store.createNote("b", { tags: ["expense", "food"], metadata: { amount: 5 } });
|
|
160
|
+
await store.createNote("c", { tags: ["expense", "travel"], metadata: { amount: 100 } });
|
|
161
|
+
|
|
162
|
+
const rows = aggregateNotes(db, { aggregate: { group_by: "tag", op: "sum", field: "amount" } });
|
|
163
|
+
const byGroup = Object.fromEntries(rows.map((r) => [r.group, r.value]));
|
|
164
|
+
expect(byGroup).toEqual({ expense: 115, food: 15, travel: 100 });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("a tag prefilter composes with group_by: \"tag\" (untagged-by-the-filter tags still show up on filtered notes)", async () => {
|
|
168
|
+
await store.createNote("a", { tags: ["work", "urgent"] });
|
|
169
|
+
await store.createNote("b", { tags: ["work"] });
|
|
170
|
+
await store.createNote("c", { tags: ["personal", "urgent"] });
|
|
171
|
+
|
|
172
|
+
const rows = aggregateNotes(db, {
|
|
173
|
+
tags: ["work"],
|
|
174
|
+
aggregate: { group_by: "tag", op: "count" },
|
|
175
|
+
});
|
|
176
|
+
const byGroup = Object.fromEntries(rows.map((r) => [r.group, r.value]));
|
|
177
|
+
// Only notes a+b match the `work` prefilter; their full tag membership
|
|
178
|
+
// (including "urgent" on note a) is what's rolled up.
|
|
179
|
+
expect(byGroup).toEqual({ work: 2, urgent: 1 });
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("aggregateNotes — errors", () => {
|
|
184
|
+
it("throws FIELD_NOT_INDEXED on a non-indexed group_by", async () => {
|
|
185
|
+
await store.createNote("a", { metadata: { status: "open" } });
|
|
186
|
+
expect(() => aggregateNotes(db, { aggregate: { group_by: "status", op: "count" } })).toThrow(QueryError);
|
|
187
|
+
try {
|
|
188
|
+
aggregateNotes(db, { aggregate: { group_by: "status", op: "count" } });
|
|
189
|
+
throw new Error("expected throw");
|
|
190
|
+
} catch (e: any) {
|
|
191
|
+
expect(e.code).toBe("FIELD_NOT_INDEXED");
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("throws INVALID_QUERY when op is \"sum\" without a field", async () => {
|
|
196
|
+
await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
|
|
197
|
+
try {
|
|
198
|
+
aggregateNotes(db, { aggregate: { group_by: "status", op: "sum" } });
|
|
199
|
+
throw new Error("expected throw");
|
|
200
|
+
} catch (e: any) {
|
|
201
|
+
expect(e.name).toBe("QueryError");
|
|
202
|
+
expect(e.code).toBe("INVALID_QUERY");
|
|
203
|
+
expect(e.field).toBe("aggregate.field");
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("throws FIELD_NOT_INDEXED when the sum field isn't indexed", async () => {
|
|
208
|
+
await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
|
|
209
|
+
try {
|
|
210
|
+
aggregateNotes(db, { aggregate: { group_by: "status", op: "sum", field: "amount" } });
|
|
211
|
+
throw new Error("expected throw");
|
|
212
|
+
} catch (e: any) {
|
|
213
|
+
expect(e.code).toBe("FIELD_NOT_INDEXED");
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("throws INVALID_QUERY when the sum field is indexed but not numeric (TEXT-backed)", async () => {
|
|
218
|
+
await store.upsertTagRecord("task", {
|
|
219
|
+
fields: {
|
|
220
|
+
status: { type: "string", indexed: true },
|
|
221
|
+
label: { type: "string", indexed: true },
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
try {
|
|
225
|
+
aggregateNotes(db, { aggregate: { group_by: "status", op: "sum", field: "label" } });
|
|
226
|
+
throw new Error("expected throw");
|
|
227
|
+
} catch (e: any) {
|
|
228
|
+
expect(e.name).toBe("QueryError");
|
|
229
|
+
expect(e.code).toBe("INVALID_QUERY");
|
|
230
|
+
expect(e.field).toBe("aggregate.field");
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("throws INVALID_QUERY on an unrecognized op", async () => {
|
|
235
|
+
await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
|
|
236
|
+
try {
|
|
237
|
+
aggregateNotes(db, { aggregate: { group_by: "status", op: "average" as any } });
|
|
238
|
+
throw new Error("expected throw");
|
|
239
|
+
} catch (e: any) {
|
|
240
|
+
expect(e.name).toBe("QueryError");
|
|
241
|
+
expect(e.code).toBe("INVALID_QUERY");
|
|
242
|
+
expect(e.field).toBe("aggregate.op");
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("throws INVALID_QUERY when aggregate is entirely missing", () => {
|
|
247
|
+
expect(() => aggregateNotes(db, {})).toThrow(QueryError);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("throws INVALID_QUERY on an empty group_by", async () => {
|
|
251
|
+
try {
|
|
252
|
+
aggregateNotes(db, { aggregate: { group_by: "", op: "count" } });
|
|
253
|
+
throw new Error("expected throw");
|
|
254
|
+
} catch (e: any) {
|
|
255
|
+
expect(e.name).toBe("QueryError");
|
|
256
|
+
expect(e.code).toBe("INVALID_QUERY");
|
|
257
|
+
expect(e.field).toBe("aggregate.group_by");
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
});
|
|
@@ -115,7 +115,8 @@ describe("contract: taxonomy — #552 (flipped from test.todo)", () => {
|
|
|
115
115
|
const tools = generateMcpTools(store);
|
|
116
116
|
const renameTag = tools.find((t) => t.name === "rename-tag");
|
|
117
117
|
expect(renameTag).toBeDefined();
|
|
118
|
-
|
|
118
|
+
// Re-tier: rename-tag is taxonomy curation, not content — write → admin.
|
|
119
|
+
expect(renameTag!.requiredVerb).toBe("admin");
|
|
119
120
|
|
|
120
121
|
const result = await renameTag!.execute({ old_name: "proj", new_name: "initiative" }) as any;
|
|
121
122
|
expect("error" in result).toBe(false);
|
|
@@ -166,7 +167,8 @@ describe("contract: taxonomy — #552 (flipped from test.todo)", () => {
|
|
|
166
167
|
const tools = generateMcpTools(store);
|
|
167
168
|
const mergeTags = tools.find((t) => t.name === "merge-tags");
|
|
168
169
|
expect(mergeTags).toBeDefined();
|
|
169
|
-
|
|
170
|
+
// Re-tier: merge-tags is taxonomy curation, not content — write → admin.
|
|
171
|
+
expect(mergeTags!.requiredVerb).toBe("admin");
|
|
170
172
|
|
|
171
173
|
const result = await mergeTags!.execute({ sources: ["draft", "wip"], target: "active" }) as any;
|
|
172
174
|
expect(result.target).toBe("active");
|
|
@@ -292,4 +294,26 @@ describe("contract: taxonomy — #552 (flipped from test.todo)", () => {
|
|
|
292
294
|
expect(cleanReport.findings).toEqual([]);
|
|
293
295
|
expect(cleanReport.summary).toContain("clean");
|
|
294
296
|
});
|
|
297
|
+
|
|
298
|
+
it("dead_tag_metadata_reference does not false-positive on a schema-declared enum field whose values coincide with an unrelated live tag (vault#570)", async () => {
|
|
299
|
+
// "archived" is a real, unrelated live tag — coincidentally sharing a
|
|
300
|
+
// name with one of "status"'s declared enum values. Pre-fix, this ONE
|
|
301
|
+
// coincidental collision was enough to convince the heuristic that
|
|
302
|
+
// metadata.status "holds tag-shaped values", and then flag status's
|
|
303
|
+
// OTHER legitimate enum values ("active", "done") as stale tag
|
|
304
|
+
// references — even though neither was ever a tag at all.
|
|
305
|
+
await store.upsertTagRecord("archived", {});
|
|
306
|
+
await store.upsertTagRecord("task", {
|
|
307
|
+
fields: { status: { type: "string", enum: ["active", "archived", "done"] } },
|
|
308
|
+
});
|
|
309
|
+
await store.createNote("task one", { tags: ["task"], metadata: { status: "active" } });
|
|
310
|
+
await store.createNote("task two", { tags: ["task"], metadata: { status: "done" } });
|
|
311
|
+
await store.createNote("task three", { tags: ["task"], metadata: { status: "archived" } });
|
|
312
|
+
|
|
313
|
+
const report = await store.doctor();
|
|
314
|
+
const driftFindings = report.findings.filter(
|
|
315
|
+
(f) => f.type === "dead_tag_metadata_reference" && f.subject === "metadata.status",
|
|
316
|
+
);
|
|
317
|
+
expect(driftFindings).toEqual([]);
|
|
318
|
+
});
|
|
295
319
|
});
|
|
@@ -220,12 +220,14 @@ describe("contract: typed indexes — Decision B: explicit-default-only enum bac
|
|
|
220
220
|
});
|
|
221
221
|
|
|
222
222
|
describe("contract: typed indexes — Decision C: honest type list (#553, flipped from todo)", () => {
|
|
223
|
-
it("the update-tag field-type description clarifies only string/integer/boolean are indexable", () => {
|
|
223
|
+
it("the update-tag field-type description clarifies only string/integer/boolean/reference are indexable", () => {
|
|
224
224
|
const updateTag = generateMcpTools(store).find((t) => t.name === "update-tag")!;
|
|
225
225
|
const typeDesc = (updateTag.inputSchema as any).properties.fields.additionalProperties.properties.type.description as string;
|
|
226
226
|
// Honest about the full storage/advisory vocabulary AND the indexable subset.
|
|
227
227
|
expect(typeDesc).toContain("number");
|
|
228
|
-
|
|
228
|
+
// vault#typed-reference-field: `reference` joined the indexable subset
|
|
229
|
+
// alongside string/integer/boolean.
|
|
230
|
+
expect(typeDesc).toContain("Only string/integer/boolean/reference are INDEXABLE");
|
|
229
231
|
});
|
|
230
232
|
|
|
231
233
|
it("declaring indexed:true with an unindexable type (number) is rejected", async () => {
|