@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.
@@ -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 } } });
@@ -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\` (group_by + count/sum): pass \`aggregate: {group_by, op, field?}\` to get counts/sums instead of note rows — e.g. "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; \`op: "sum"\` additionally requires \`field\` to be an indexed NUMERIC field. Mutually exclusive with \`search\`/\`near\`/\`cursor\`.
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: ["group_by", "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. 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 `cursor` (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.",
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: {group_by, op, field?}`,
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 as string,
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
- if (aggVisibleIds.length === 0) return [];
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 { group_by, op } — group_by is an indexed metadata field or "tag"; op is "count" or "sum"`,
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 (typeof spec.group_by !== "string" || spec.group_by.length === 0) {
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
- `aggregate.group_by is required — an indexed metadata field name, or "tag"`,
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: `pass an indexed metadata field name, or "tag"`,
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: string;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.7",
3
+ "version": "0.7.8-rc.2",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -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. `group_by` + `op` are required together when ANY of the
981
- * three is present; `field` is optional at the parser level (its
982
- * requiredness depends on `op`, enforced by `aggregateNotes` itself). Value
983
- * validity beyond shape (indexed field, numeric type, sum-requires-field)
984
- * is ALSO enforced by `aggregateNotes` same FIELD_NOT_INDEXED /
985
- * INVALID_QUERY contract every other query surface uses.
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 `group_by`/`op`, or an unrecognized `op`).
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 groupBy = parseQuery(url, "aggregate[group_by]");
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 (groupBy === null || op === null) {
999
+ if (op === null) {
996
1000
  return {
997
1001
  error: json(
998
1002
  {
999
- error: `aggregate requires both aggregate[group_by] and aggregate[op] group_by is an indexed metadata field or "tag"; op is "count" or "sum".`,
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
- return { aggregate: { group_by: groupBy, op, field: field ?? undefined } };
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
- rows = visible.length === 0
1616
- ? []
1617
- : await store.aggregateNotes({ ids: visible.map((n) => n.id), aggregate: aggregateParsed.aggregate });
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