@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.
Files changed (38) hide show
  1. package/core/src/aggregate.test.ts +260 -0
  2. package/core/src/contract-taxonomy.test.ts +26 -2
  3. package/core/src/contract-typed-index.test.ts +4 -2
  4. package/core/src/core.test.ts +1151 -0
  5. package/core/src/cursor.ts +1 -0
  6. package/core/src/doctor.ts +23 -1
  7. package/core/src/indexed-fields.test.ts +4 -1
  8. package/core/src/indexed-fields.ts +6 -1
  9. package/core/src/links.ts +22 -0
  10. package/core/src/mcp.ts +587 -79
  11. package/core/src/notes.ts +371 -18
  12. package/core/src/query-warnings.ts +60 -12
  13. package/core/src/schema-defaults.ts +7 -1
  14. package/core/src/seed-packs.test.ts +14 -0
  15. package/core/src/seed-packs.ts +42 -5
  16. package/core/src/store.ts +129 -5
  17. package/core/src/tag-schemas.ts +20 -7
  18. package/core/src/types.ts +98 -0
  19. package/core/src/ulid.test.ts +56 -0
  20. package/core/src/ulid.ts +116 -0
  21. package/core/src/vault-projection.ts +19 -1
  22. package/core/src/wikilinks.test.ts +205 -1
  23. package/core/src/wikilinks.ts +244 -35
  24. package/package.json +1 -1
  25. package/src/aggregate-routes.test.ts +230 -0
  26. package/src/contract-errors.test.ts +2 -1
  27. package/src/contract-search.test.ts +17 -0
  28. package/src/mcp-link-warnings-scope.test.ts +144 -0
  29. package/src/mcp-query-notes-aggregate-scope.test.ts +183 -0
  30. package/src/mcp-tools.ts +100 -19
  31. package/src/routes.ts +469 -39
  32. package/src/routing.test.ts +167 -0
  33. package/src/routing.ts +47 -11
  34. package/src/tag-integrity-mcp.test.ts +45 -6
  35. package/src/tag-scope.ts +10 -1
  36. package/src/vault.test.ts +923 -21
  37. package/web/ui/dist/assets/{index-B8pGeQam.js → index-CJ6NtIwh.js} +1 -1
  38. 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
- expect(renameTag!.requiredVerb).toBe("write");
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
- expect(mergeTags!.requiredVerb).toBe("write");
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
- expect(typeDesc).toContain("Only string/integer/boolean are INDEXABLE");
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 () => {