@openparachute/vault 0.7.7 → 0.7.8-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/aggregate.test.ts +45 -0
- package/core/src/mcp-manifest.ts +5 -5
- package/core/src/mcp.ts +5 -4
- package/core/src/notes.ts +30 -13
- package/core/src/types.ts +6 -2
- package/package.json +1 -1
- package/src/aggregate-routes.test.ts +66 -0
- package/src/mcp-query-notes-aggregate-scope.test.ts +42 -0
- package/src/routes.ts +72 -15
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* - errors on a non-indexed / non-numeric sum field
|
|
14
14
|
* - errors on a malformed aggregate spec (missing op/group_by/field)
|
|
15
15
|
* - a note missing the group_by field collects into `group: null`
|
|
16
|
+
* - count without group_by is the filtered total (vault#626)
|
|
16
17
|
*
|
|
17
18
|
* Tag-scope enforcement (server-layer, injected as an `ids` prefilter or an
|
|
18
19
|
* `aggregateVisibility` predicate) is covered separately at the MCP/REST
|
|
@@ -34,6 +35,50 @@ beforeEach(() => {
|
|
|
34
35
|
store = new SqliteStore(db);
|
|
35
36
|
});
|
|
36
37
|
|
|
38
|
+
describe("aggregateNotes — count without group_by (vault#626 filtered total)", () => {
|
|
39
|
+
it("returns [{group: null, value: N}] over the whole set", async () => {
|
|
40
|
+
await store.createNote("a");
|
|
41
|
+
await store.createNote("b");
|
|
42
|
+
await store.createNote("c");
|
|
43
|
+
expect(aggregateNotes(db, { aggregate: { op: "count" } })).toEqual([
|
|
44
|
+
{ group: null, value: 3 },
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("an empty match still returns one zero row, not []", () => {
|
|
49
|
+
expect(aggregateNotes(db, { aggregate: { op: "count" } })).toEqual([
|
|
50
|
+
{ group: null, value: 0 },
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("a tag prefilter narrows the total", async () => {
|
|
55
|
+
await store.createNote("a", { tags: ["work"] });
|
|
56
|
+
await store.createNote("b", { tags: ["work"] });
|
|
57
|
+
await store.createNote("c", { tags: ["personal"] });
|
|
58
|
+
expect(aggregateNotes(db, { tags: ["work"], aggregate: { op: "count" } })).toEqual([
|
|
59
|
+
{ group: null, value: 2 },
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("ids: [] (tag-scope empty) is a zero total, not []", () => {
|
|
64
|
+
expect(aggregateNotes(db, { ids: [], aggregate: { op: "count" } })).toEqual([
|
|
65
|
+
{ group: null, value: 0 },
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("sum without group_by is still INVALID_QUERY", async () => {
|
|
70
|
+
await store.upsertTagRecord("expense", { fields: { amount: { type: "integer", indexed: true } } });
|
|
71
|
+
try {
|
|
72
|
+
aggregateNotes(db, { aggregate: { op: "sum", field: "amount" } });
|
|
73
|
+
throw new Error("expected throw");
|
|
74
|
+
} catch (e: any) {
|
|
75
|
+
expect(e.name).toBe("QueryError");
|
|
76
|
+
expect(e.code).toBe("INVALID_QUERY");
|
|
77
|
+
expect(e.field).toBe("aggregate.group_by");
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
37
82
|
describe("aggregateNotes — count by an indexed enum field", () => {
|
|
38
83
|
it("groups and counts", async () => {
|
|
39
84
|
await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
|
package/core/src/mcp-manifest.ts
CHANGED
|
@@ -71,7 +71,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
71
71
|
- Warnings present (e.g. an unrecognized \`tag\`) and NOT in cursor mode: \`{notes: [...], warnings: [...]}\`. Cursor mode + warnings compose: \`{notes, next_cursor, warnings}\`. Absent \`warnings\` key means nothing to flag — don't assume its presence either way.
|
|
72
72
|
- \`aggregate\` mode: \`[{group, value}]\` — a rollup row per group, NOT notes. See \`aggregate\` below.
|
|
73
73
|
|
|
74
|
-
\`aggregate\` (
|
|
74
|
+
\`aggregate\` (count/sum, optional group_by): pass \`aggregate: {op, group_by?, field?}\` to get counts/sums instead of note rows. \`{op: "count"}\` with no \`group_by\` is the filtered total — one row \`[{group: null, value: N}]\` (vault#626). Grouped examples: "how many notes per status" (\`{group_by: "status", op: "count"}\`) or "total amount per category" (\`{group_by: "category", op: "sum", field: "amount"}\`). Every other filter (\`tag\`, \`metadata\`, date range, ...) narrows the input set FIRST, exactly like a normal query. \`group_by\` is either \"tag\" (group by tag membership) or an indexed metadata field; omit it on \`count\` for the total. \`op: "sum"\` requires both \`group_by\` and \`field\` (indexed NUMERIC). Mutually exclusive with \`search\`/\`near\`/\`cursor\`/\`semantic\`.
|
|
75
75
|
|
|
76
76
|
\`search\` is literal-by-default (vault#551): your text is escaped and phrase-quoted before it reaches FTS5, so ordinary punctuation ("didn't", "eleven-day", "18.6") is matched as literal content instead of being parsed as query syntax (a bare hyphen used to mean NOT; an apostrophe or decimal point used to break the parse and silently return \`[]\`). Pass \`search_mode: "advanced"\` to opt back into raw FTS5 syntax (AND/OR/NOT, manual phrase quoting, prefix \`*\`) — a malformed advanced query now throws a structured error instead of silently returning \`[]\`. \`sort\` is honored under \`search\` too: omit it for relevance ranking (default), or pass "asc"/"desc" to order by \`created_at\` instead.
|
|
77
77
|
|
|
@@ -188,12 +188,12 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
188
188
|
aggregate: {
|
|
189
189
|
type: "object",
|
|
190
190
|
properties: {
|
|
191
|
-
group_by: { type: "string", description: "What to group by: an indexed metadata field name (declared `indexed: true` in a tag schema — same FIELD_NOT_INDEXED contract as `metadata` operator queries / `order_by`), or the special value \"tag\" to group by tag membership. Under \"tag\", a note carrying N of the tags present in the filtered result set contributes to N separate groups (a membership rollup, not a partition)." },
|
|
192
|
-
op: { type: "string", enum: ["count", "sum"], description: "\"count\": number of matching notes per group. \"sum\": sum of `field` per group." },
|
|
191
|
+
group_by: { type: "string", description: "What to group by: an indexed metadata field name (declared `indexed: true` in a tag schema — same FIELD_NOT_INDEXED contract as `metadata` operator queries / `order_by`), or the special value \"tag\" to group by tag membership. Under \"tag\", a note carrying N of the tags present in the filtered result set contributes to N separate groups (a membership rollup, not a partition). Optional when op is \"count\" — omit it for a single filtered-total row `{group: null, value: N}` (vault#626). Required for \"sum\"." },
|
|
192
|
+
op: { type: "string", enum: ["count", "sum"], description: "\"count\": number of matching notes per group, or the filtered total when group_by is omitted. \"sum\": sum of `field` per group." },
|
|
193
193
|
field: { type: "string", description: "Required when `op` is \"sum\"; ignored for \"count\". Must be an indexed metadata field with a numeric storage type (declared `type: \"integer\"` or `type: \"boolean\"` — the only indexable numeric shapes; a bare `type: \"number\"` field is never indexed and a TEXT-backed field can't be summed)." },
|
|
194
194
|
},
|
|
195
|
-
required: ["
|
|
196
|
-
description: "Aggregation / rollup mode. Every OTHER filter above (tag, metadata, date range, write-attribution, ...) is applied FIRST, exactly as a normal query would; the matching notes are then grouped and the response becomes `[{group, value}]` instead of note rows — one row per group, `value` is the count/sum. A note whose group_by value is absent collects into one `{group: null, value: ...}` row rather than being dropped. Mutually exclusive with `search`, `near`, and `
|
|
195
|
+
required: ["op"],
|
|
196
|
+
description: "Aggregation / rollup mode. Every OTHER filter above (tag, metadata, date range, write-attribution, ...) is applied FIRST, exactly as a normal query would; the matching notes are then grouped and the response becomes `[{group, value}]` instead of note rows — one row per group, `value` is the count/sum. Omit `group_by` with `op: \"count\"` for a filtered total (`[{group: null, value: N}]`, including `value: 0` on an empty match). A note whose group_by value is absent collects into one `{group: null, value: ...}` row rather than being dropped. Mutually exclusive with `search`, `near`, `cursor`, and `semantic` (a rollup has no pagination/ranking/graph-neighborhood shape). Tag-scoped sessions see the SAME visibility enforcement as every other read — the rollup is computed only over notes the token can see.",
|
|
197
197
|
},
|
|
198
198
|
near: {
|
|
199
199
|
type: "object",
|
package/core/src/mcp.ts
CHANGED
|
@@ -757,7 +757,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
757
757
|
}
|
|
758
758
|
|
|
759
759
|
// --- Aggregation / rollup mode (top new-feature ask from a UX round) ---
|
|
760
|
-
// Mutually exclusive with `search`/`near`/`cursor` — a rollup returns
|
|
760
|
+
// Mutually exclusive with `search`/`near`/`cursor`/`semantic` — a rollup returns
|
|
761
761
|
// one row per group, not a paginated / graph-scoped / ranked note
|
|
762
762
|
// list — so reject those combos loudly before touching the DB.
|
|
763
763
|
if (params.aggregate) {
|
|
@@ -792,7 +792,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
792
792
|
const aggRaw = params.aggregate as Record<string, unknown>;
|
|
793
793
|
if (typeof aggRaw !== "object" || aggRaw === null || Array.isArray(aggRaw)) {
|
|
794
794
|
throw new QueryError(
|
|
795
|
-
`aggregate must be an object: {
|
|
795
|
+
`aggregate must be an object: {op, group_by?, field?}`,
|
|
796
796
|
"INVALID_QUERY",
|
|
797
797
|
{ error_type: "invalid_query", field: "aggregate", got: aggRaw },
|
|
798
798
|
);
|
|
@@ -802,7 +802,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
802
802
|
// `aggregateNotes` itself, reusing the exact FIELD_NOT_INDEXED /
|
|
803
803
|
// INVALID_QUERY contract every other query surface uses.
|
|
804
804
|
const aggregateSpec = {
|
|
805
|
-
group_by: aggRaw.group_by
|
|
805
|
+
...(typeof aggRaw.group_by === "string" ? { group_by: aggRaw.group_by } : {}),
|
|
806
806
|
op: aggRaw.op as "count" | "sum",
|
|
807
807
|
field: aggRaw.field as string | undefined,
|
|
808
808
|
};
|
|
@@ -843,7 +843,8 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
843
843
|
// Core stays scope-unaware — it only invokes the plain closure.
|
|
844
844
|
const aggAllMatches = await store.queryNotes({ ...aggFilterOpts, limit: 1000000 });
|
|
845
845
|
const aggVisibleIds = aggAllMatches.filter(aggregateVisibility).map((n) => n.id);
|
|
846
|
-
|
|
846
|
+
// Always run the rollup, even on an empty visible set: ungrouped
|
|
847
|
+
// count (vault#626) must return `[{group:null,value:0}]`, not `[]`.
|
|
847
848
|
return await store.aggregateNotes({ ids: aggVisibleIds, aggregate: aggregateSpec });
|
|
848
849
|
}
|
|
849
850
|
|
package/core/src/notes.ts
CHANGED
|
@@ -1507,6 +1507,11 @@ export function queryNotes(db: Database, opts: QueryOpts, _outUpdatedAtMs?: Map<
|
|
|
1507
1507
|
* `indexed-fields.ts`'s `TYPE_MAP` — and a `TEXT`-backed field can't be
|
|
1508
1508
|
* summed). `op: "count"` ignores `field`.
|
|
1509
1509
|
*
|
|
1510
|
+
* `op: "count"` with no `group_by` (vault#626) is the filtered total: one
|
|
1511
|
+
* row `[{group: null, value: N}]` so list parsers keep a single shape.
|
|
1512
|
+
* `op: "sum"` still requires `group_by`. An empty match set still returns
|
|
1513
|
+
* that one row with `value: 0`, never `[]`.
|
|
1514
|
+
*
|
|
1510
1515
|
* A note whose group_by value is absent/null collects into one
|
|
1511
1516
|
* `{group: null, ...}` row — standard SQL `GROUP BY` behavior, not silently
|
|
1512
1517
|
* dropped.
|
|
@@ -1520,29 +1525,34 @@ export function aggregateNotes(db: Database, opts: QueryOpts): AggregateRow[] {
|
|
|
1520
1525
|
{
|
|
1521
1526
|
error_type: "invalid_query",
|
|
1522
1527
|
field: "aggregate",
|
|
1523
|
-
hint: `pass {
|
|
1528
|
+
hint: `pass { op, group_by? } — op is "count" or "sum"; group_by is an indexed metadata field or "tag" (required for sum, optional for count)`,
|
|
1524
1529
|
},
|
|
1525
1530
|
);
|
|
1526
1531
|
}
|
|
1527
|
-
if (
|
|
1532
|
+
if (spec.op !== "count" && spec.op !== "sum") {
|
|
1533
|
+
throw new QueryError(
|
|
1534
|
+
`invalid aggregate.op: ${JSON.stringify(spec.op)} — must be "count" or "sum"`,
|
|
1535
|
+
"INVALID_QUERY",
|
|
1536
|
+
{ error_type: "invalid_query", field: "aggregate.op", got: spec.op, hint: `pass "count" or "sum"` },
|
|
1537
|
+
);
|
|
1538
|
+
}
|
|
1539
|
+
const ungroupedCount = spec.op === "count" && spec.group_by === undefined;
|
|
1540
|
+
if (!ungroupedCount && (typeof spec.group_by !== "string" || spec.group_by.length === 0)) {
|
|
1528
1541
|
throw new QueryError(
|
|
1529
|
-
|
|
1542
|
+
spec.op === "sum"
|
|
1543
|
+
? `aggregate.group_by is required when aggregate.op is "sum" — an indexed metadata field name, or "tag"`
|
|
1544
|
+
: `aggregate.group_by is required — an indexed metadata field name, or "tag"`,
|
|
1530
1545
|
"INVALID_QUERY",
|
|
1531
1546
|
{
|
|
1532
1547
|
error_type: "invalid_query",
|
|
1533
1548
|
field: "aggregate.group_by",
|
|
1534
1549
|
got: spec.group_by,
|
|
1535
|
-
hint:
|
|
1550
|
+
hint: spec.op === "count"
|
|
1551
|
+
? `omit group_by for a filtered total, or pass an indexed metadata field name / "tag"`
|
|
1552
|
+
: `pass an indexed metadata field name, or "tag"`,
|
|
1536
1553
|
},
|
|
1537
1554
|
);
|
|
1538
1555
|
}
|
|
1539
|
-
if (spec.op !== "count" && spec.op !== "sum") {
|
|
1540
|
-
throw new QueryError(
|
|
1541
|
-
`invalid aggregate.op: ${JSON.stringify(spec.op)} — must be "count" or "sum"`,
|
|
1542
|
-
"INVALID_QUERY",
|
|
1543
|
-
{ error_type: "invalid_query", field: "aggregate.op", got: spec.op, hint: `pass "count" or "sum"` },
|
|
1544
|
-
);
|
|
1545
|
-
}
|
|
1546
1556
|
if (spec.op === "sum" && (typeof spec.field !== "string" || spec.field.length === 0)) {
|
|
1547
1557
|
throw new QueryError(
|
|
1548
1558
|
`aggregate.field is required when aggregate.op is "sum"`,
|
|
@@ -1556,6 +1566,14 @@ export function aggregateNotes(db: Database, opts: QueryOpts): AggregateRow[] {
|
|
|
1556
1566
|
}
|
|
1557
1567
|
|
|
1558
1568
|
const { conditions, params } = buildFilterConditions(db, opts);
|
|
1569
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
1570
|
+
|
|
1571
|
+
if (ungroupedCount) {
|
|
1572
|
+
const row = db.prepare(
|
|
1573
|
+
`SELECT COUNT(*) AS value FROM notes n ${whereClause}`,
|
|
1574
|
+
).get(...params) as { value: number | null } | null;
|
|
1575
|
+
return [{ group: null, value: row?.value ?? 0 }];
|
|
1576
|
+
}
|
|
1559
1577
|
|
|
1560
1578
|
const groupByTag = spec.group_by === "tag";
|
|
1561
1579
|
let groupExpr: string;
|
|
@@ -1567,7 +1585,7 @@ export function aggregateNotes(db: Database, opts: QueryOpts): AggregateRow[] {
|
|
|
1567
1585
|
// `group_by` came from indexed_fields (validated via FIELD_NAME_RE at
|
|
1568
1586
|
// declaration time), so interpolating the column name is safe — same
|
|
1569
1587
|
// justification `orderBy`/`buildOperatorClause` use.
|
|
1570
|
-
requireIndexedField(db, spec.group_by);
|
|
1588
|
+
requireIndexedField(db, spec.group_by!);
|
|
1571
1589
|
groupExpr = `"meta_${spec.group_by}"`;
|
|
1572
1590
|
fromClause = "FROM notes n";
|
|
1573
1591
|
}
|
|
@@ -1592,7 +1610,6 @@ export function aggregateNotes(db: Database, opts: QueryOpts): AggregateRow[] {
|
|
|
1592
1610
|
valueExpr = `SUM("meta_${spec.field}")`;
|
|
1593
1611
|
}
|
|
1594
1612
|
|
|
1595
|
-
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
1596
1613
|
const sql = `
|
|
1597
1614
|
SELECT ${groupExpr} AS group_key, ${valueExpr} AS value
|
|
1598
1615
|
${fromClause}
|
package/core/src/types.ts
CHANGED
|
@@ -337,9 +337,13 @@ export interface AggregateSpec {
|
|
|
337
337
|
* `"tag"` to group by tag membership. Under `"tag"`, a note carrying N of
|
|
338
338
|
* the tags in the (filtered) result set contributes to N separate groups
|
|
339
339
|
* — this is a membership rollup, not a partition.
|
|
340
|
+
*
|
|
341
|
+
* Optional when `op` is `"count"` (vault#626): omitting `group_by` returns
|
|
342
|
+
* a single filtered-total row `[{group: null, value: N}]` instead of a
|
|
343
|
+
* per-group rollup. Required for `"sum"`.
|
|
340
344
|
*/
|
|
341
|
-
group_by
|
|
342
|
-
/** `"count"` — number of matching notes per group. `"sum"` — sum of `field` per group. */
|
|
345
|
+
group_by?: string;
|
|
346
|
+
/** `"count"` — number of matching notes per group (or the filtered total when `group_by` is omitted). `"sum"` — sum of `field` per group. */
|
|
343
347
|
op: "count" | "sum";
|
|
344
348
|
/**
|
|
345
349
|
* Required when `op` is `"sum"`; ignored for `"count"`. Must be an
|
package/package.json
CHANGED
|
@@ -149,6 +149,72 @@ describe("REST GET /notes — aggregate errors", () => {
|
|
|
149
149
|
const body: any = await res.json();
|
|
150
150
|
expect(body.field).toBe("aggregate");
|
|
151
151
|
});
|
|
152
|
+
|
|
153
|
+
it("400s when aggregate is combined with search (no silent fall-through to rows)", async () => {
|
|
154
|
+
const res = await get("?search=hello&aggregate[op]=count");
|
|
155
|
+
expect(res.status).toBe(400);
|
|
156
|
+
const body: any = await res.json();
|
|
157
|
+
expect(body.field).toBe("aggregate");
|
|
158
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
159
|
+
expect(body.error_type).toBe("invalid_query");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("400s when aggregate is combined with semantic (no silent fall-through to rows)", async () => {
|
|
163
|
+
const res = await get("?semantic=true&near_text=hello&aggregate[op]=count");
|
|
164
|
+
expect(res.status).toBe(400);
|
|
165
|
+
const body: any = await res.json();
|
|
166
|
+
expect(body.field).toBe("aggregate");
|
|
167
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
168
|
+
expect(body.error_type).toBe("invalid_query");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("400s when sum has no group_by", async () => {
|
|
172
|
+
await store.upsertTagRecord("expense", { fields: { amount: { type: "integer", indexed: true } } });
|
|
173
|
+
const res = await get("?aggregate[op]=sum&aggregate[field]=amount");
|
|
174
|
+
expect(res.status).toBe(400);
|
|
175
|
+
const body: any = await res.json();
|
|
176
|
+
expect(body.field).toBe("aggregate.group_by");
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("REST GET /notes — aggregate: count without group_by (vault#626)", () => {
|
|
181
|
+
it("returns [{group: null, value: N}] for the filtered set", async () => {
|
|
182
|
+
await store.createNote("a", { tags: ["work"] });
|
|
183
|
+
await store.createNote("b", { tags: ["work"] });
|
|
184
|
+
await store.createNote("c", { tags: ["personal"] });
|
|
185
|
+
|
|
186
|
+
const all = await get("?aggregate[op]=count");
|
|
187
|
+
expect(all.status).toBe(200);
|
|
188
|
+
expect(await all.json()).toEqual([{ group: null, value: 3 }]);
|
|
189
|
+
|
|
190
|
+
const filtered = await get("?tag=work&aggregate[op]=count");
|
|
191
|
+
expect(filtered.status).toBe(200);
|
|
192
|
+
expect(await filtered.json()).toEqual([{ group: null, value: 2 }]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("an empty vault still returns the zero row, not []", async () => {
|
|
196
|
+
const res = await get("?aggregate[op]=count");
|
|
197
|
+
expect(res.status).toBe(200);
|
|
198
|
+
expect(await res.json()).toEqual([{ group: null, value: 0 }]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("a scoped token with no visible matches gets a zero total, not a leak", async () => {
|
|
202
|
+
await store.createNote("out-of-scope only", { tags: ["work"] });
|
|
203
|
+
const scoped: TagScopeCtx = { allowed: new Set(["health"]), raw: ["health"] };
|
|
204
|
+
const res = await get("?aggregate[op]=count", scoped);
|
|
205
|
+
expect(res.status).toBe(200);
|
|
206
|
+
expect(await res.json()).toEqual([{ group: null, value: 0 }]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("a scoped token's total excludes out-of-scope notes", async () => {
|
|
210
|
+
await store.createNote("in", { tags: ["health"] });
|
|
211
|
+
await store.createNote("out 1", { tags: ["work"] });
|
|
212
|
+
await store.createNote("out 2", { tags: ["work"] });
|
|
213
|
+
const scoped: TagScopeCtx = { allowed: new Set(["health"]), raw: ["health"] };
|
|
214
|
+
const res = await get("?aggregate[op]=count", scoped);
|
|
215
|
+
expect(res.status).toBe(200);
|
|
216
|
+
expect(await res.json()).toEqual([{ group: null, value: 1 }]);
|
|
217
|
+
});
|
|
152
218
|
});
|
|
153
219
|
|
|
154
220
|
describe("REST GET /notes — aggregate: group_by \"tag\"", () => {
|
|
@@ -154,6 +154,48 @@ describe("MCP query-notes aggregate × tag-scope — group_by \"tag\"", () => {
|
|
|
154
154
|
});
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
+
describe("MCP query-notes aggregate — count without group_by (vault#626)", () => {
|
|
158
|
+
test("returns [{group: null, value: N}] for the filtered set", async () => {
|
|
159
|
+
seedVault("journal");
|
|
160
|
+
const store = getVaultStore("journal");
|
|
161
|
+
await store.createNote("a", { tags: ["work"] });
|
|
162
|
+
await store.createNote("b", { tags: ["work"] });
|
|
163
|
+
await store.createNote("c", { tags: ["personal"] });
|
|
164
|
+
|
|
165
|
+
const tool = await queryNotesTool("journal", null);
|
|
166
|
+
expect(await tool.execute({ aggregate: { op: "count" } })).toEqual([
|
|
167
|
+
{ group: null, value: 3 },
|
|
168
|
+
]);
|
|
169
|
+
expect(await tool.execute({ tag: "work", aggregate: { op: "count" } })).toEqual([
|
|
170
|
+
{ group: null, value: 2 },
|
|
171
|
+
]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("a scoped token's total excludes out-of-scope notes", async () => {
|
|
175
|
+
seedVault("journal");
|
|
176
|
+
const store = getVaultStore("journal");
|
|
177
|
+
await store.createNote("in", { tags: ["health"] });
|
|
178
|
+
await store.createNote("out 1", { tags: ["work"] });
|
|
179
|
+
await store.createNote("out 2", { tags: ["work"] });
|
|
180
|
+
|
|
181
|
+
const scopedTool = await queryNotesTool("journal", ["health"]);
|
|
182
|
+
expect(await scopedTool.execute({ aggregate: { op: "count" } })).toEqual([
|
|
183
|
+
{ group: null, value: 1 },
|
|
184
|
+
]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("a scoped token with no visible matches gets a zero total, not []", async () => {
|
|
188
|
+
seedVault("journal");
|
|
189
|
+
const store = getVaultStore("journal");
|
|
190
|
+
await store.createNote("out-of-scope only", { tags: ["work"] });
|
|
191
|
+
|
|
192
|
+
const scopedTool = await queryNotesTool("journal", ["health"]);
|
|
193
|
+
expect(await scopedTool.execute({ aggregate: { op: "count" } })).toEqual([
|
|
194
|
+
{ group: null, value: 0 },
|
|
195
|
+
]);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
157
199
|
describe("MCP query-notes aggregate — mutual exclusivity with search/near/cursor", () => {
|
|
158
200
|
test("aggregate + search is rejected", async () => {
|
|
159
201
|
seedVault("journal");
|
package/src/routes.ts
CHANGED
|
@@ -977,30 +977,34 @@ export function parseNotesQueryOpts(url: URL): {
|
|
|
977
977
|
* aggregation params (bracket-style, consistent with `meta[field][op]=`
|
|
978
978
|
* above) into an `AggregateSpec`. Absent entirely (none of the three keys
|
|
979
979
|
* present) → `{}` — no aggregate intent, the caller falls through to a
|
|
980
|
-
* normal query. `
|
|
981
|
-
*
|
|
982
|
-
*
|
|
983
|
-
*
|
|
984
|
-
*
|
|
985
|
-
*
|
|
980
|
+
* normal query. `op` is required when ANY of the three is present.
|
|
981
|
+
* `group_by` is required for `sum` and optional for `count` (vault#626 —
|
|
982
|
+
* `?aggregate[op]=count` alone is the filtered total). `field` is optional
|
|
983
|
+
* at the parser level (its requiredness depends on `op`, enforced by
|
|
984
|
+
* `aggregateNotes` itself). Value validity beyond shape (indexed field,
|
|
985
|
+
* numeric type, sum-requires-field) is ALSO enforced by `aggregateNotes` —
|
|
986
|
+
* same FIELD_NOT_INDEXED / INVALID_QUERY contract every other query
|
|
987
|
+
* surface uses.
|
|
986
988
|
*
|
|
987
989
|
* Returns `{ aggregate? }` or `{ error }` (a 400 Response) on a malformed
|
|
988
|
-
* shape (missing `
|
|
990
|
+
* shape (missing `op`, `sum` without `group_by`, or an unrecognized `op`).
|
|
989
991
|
*/
|
|
990
992
|
function parseAggregateParam(url: URL): { aggregate?: AggregateSpec; error?: Response } {
|
|
991
|
-
const
|
|
993
|
+
const groupByRaw = parseQuery(url, "aggregate[group_by]");
|
|
992
994
|
const op = parseQuery(url, "aggregate[op]");
|
|
993
995
|
const field = parseQuery(url, "aggregate[field]");
|
|
996
|
+
// Empty `aggregate[group_by]=` is omitted, not an empty grouping key.
|
|
997
|
+
const groupBy = groupByRaw === "" ? null : groupByRaw;
|
|
994
998
|
if (groupBy === null && op === null && field === null) return {};
|
|
995
|
-
if (
|
|
999
|
+
if (op === null) {
|
|
996
1000
|
return {
|
|
997
1001
|
error: json(
|
|
998
1002
|
{
|
|
999
|
-
error: `aggregate requires
|
|
1003
|
+
error: `aggregate requires aggregate[op] ("count" or "sum"). group_by is optional for count (filtered total) and required for sum.`,
|
|
1000
1004
|
code: "INVALID_QUERY",
|
|
1001
1005
|
error_type: "invalid_query",
|
|
1002
1006
|
field: "aggregate",
|
|
1003
|
-
hint: `pass ?aggregate[group_by]=<field|tag>&aggregate[op]=<count|sum>[&aggregate[field]=<numeric field>]`,
|
|
1007
|
+
hint: `pass ?aggregate[op]=count or ?aggregate[group_by]=<field|tag>&aggregate[op]=<count|sum>[&aggregate[field]=<numeric field>]`,
|
|
1004
1008
|
},
|
|
1005
1009
|
400,
|
|
1006
1010
|
),
|
|
@@ -1021,7 +1025,27 @@ function parseAggregateParam(url: URL): { aggregate?: AggregateSpec; error?: Res
|
|
|
1021
1025
|
),
|
|
1022
1026
|
};
|
|
1023
1027
|
}
|
|
1024
|
-
|
|
1028
|
+
if (op === "sum" && groupBy === null) {
|
|
1029
|
+
return {
|
|
1030
|
+
error: json(
|
|
1031
|
+
{
|
|
1032
|
+
error: `aggregate[group_by] is required when aggregate[op] is "sum"`,
|
|
1033
|
+
code: "INVALID_QUERY",
|
|
1034
|
+
error_type: "invalid_query",
|
|
1035
|
+
field: "aggregate.group_by",
|
|
1036
|
+
hint: `pass ?aggregate[group_by]=<field|tag>&aggregate[op]=sum&aggregate[field]=<numeric field>`,
|
|
1037
|
+
},
|
|
1038
|
+
400,
|
|
1039
|
+
),
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
return {
|
|
1043
|
+
aggregate: {
|
|
1044
|
+
...(groupBy !== null ? { group_by: groupBy } : {}),
|
|
1045
|
+
op,
|
|
1046
|
+
field: field ?? undefined,
|
|
1047
|
+
},
|
|
1048
|
+
};
|
|
1025
1049
|
}
|
|
1026
1050
|
|
|
1027
1051
|
/**
|
|
@@ -1259,6 +1283,20 @@ async function handleNotesInner(
|
|
|
1259
1283
|
400,
|
|
1260
1284
|
);
|
|
1261
1285
|
}
|
|
1286
|
+
const semanticAggregate = parseAggregateParam(url);
|
|
1287
|
+
if (semanticAggregate.error) return semanticAggregate.error;
|
|
1288
|
+
if (semanticAggregate.aggregate) {
|
|
1289
|
+
return json(
|
|
1290
|
+
{
|
|
1291
|
+
error: "aggregate is incompatible with semantic search — a rollup returns groups, not ranked notes.",
|
|
1292
|
+
code: "INVALID_QUERY",
|
|
1293
|
+
error_type: "invalid_query",
|
|
1294
|
+
field: "aggregate",
|
|
1295
|
+
hint: "drop `semantic`/`near_text` when using `aggregate`",
|
|
1296
|
+
},
|
|
1297
|
+
400,
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1262
1300
|
if (parseQuery(url, "cursor") !== null) {
|
|
1263
1301
|
return json(
|
|
1264
1302
|
{
|
|
@@ -1374,6 +1412,25 @@ async function handleNotesInner(
|
|
|
1374
1412
|
);
|
|
1375
1413
|
}
|
|
1376
1414
|
|
|
1415
|
+
// vault#626 — `search=` used to silently ignore `aggregate[...]` and
|
|
1416
|
+
// return note rows. MCP already rejects the combo; REST must too.
|
|
1417
|
+
if (search) {
|
|
1418
|
+
const searchAggregate = parseAggregateParam(url);
|
|
1419
|
+
if (searchAggregate.error) return searchAggregate.error;
|
|
1420
|
+
if (searchAggregate.aggregate) {
|
|
1421
|
+
return json(
|
|
1422
|
+
{
|
|
1423
|
+
error: "aggregate is incompatible with full-text search — pick one.",
|
|
1424
|
+
code: "INVALID_QUERY",
|
|
1425
|
+
error_type: "invalid_query",
|
|
1426
|
+
field: "aggregate",
|
|
1427
|
+
hint: "drop `search` when using `aggregate`",
|
|
1428
|
+
},
|
|
1429
|
+
400,
|
|
1430
|
+
);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1377
1434
|
// Full-text search
|
|
1378
1435
|
if (search) {
|
|
1379
1436
|
// vault#647: parse the structured filter grammar here too. Pre-fix
|
|
@@ -1612,9 +1669,9 @@ async function handleNotesInner(
|
|
|
1612
1669
|
// (reusing the `ids` filter `near` already pushes into SQL).
|
|
1613
1670
|
const allMatches = await store.queryNotes({ ...queryOpts, limit: 1000000, offset: 0 });
|
|
1614
1671
|
const visible = filterNotesByTagScope(allMatches, tagScope.allowed, tagScope.raw);
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1672
|
+
// Always run the rollup, even on an empty visible set: ungrouped
|
|
1673
|
+
// count (vault#626) must return `[{group:null,value:0}]`, not `[]`.
|
|
1674
|
+
rows = await store.aggregateNotes({ ids: visible.map((n) => n.id), aggregate: aggregateParsed.aggregate });
|
|
1618
1675
|
// That note-level narrowing isn't sufficient on its own under
|
|
1619
1676
|
// `group_by: "tag"`: a note can be in scope via one tag while
|
|
1620
1677
|
// ALSO carrying an out-of-scope co-tag, and a tag rollup's
|