Skip to content

Commit 3f7b4ff

Browse files
huangyiireneclaude
andauthored
fix(spec): EngineAggregateOptions.groupBy declares the GroupByNodeSchema union the engine already reads (#8032) (#8236)
* fix(spec): declare EngineAggregateOptions.groupBy as the GroupByNodeSchema union (#8032) The engine has always read structured { field, dateGranularity, alias } buckets on this key (date bucketing, credential-aggregation guard); the declaration said string[], so every correct caller had to cast. Catch the declaration up to the enforced contract: - spec: EngineAggregateOptionsSchema.groupBy -> z.array(GroupByNodeSchema), pin tests in both directions (structured buckets parse; plain strings parse byte-identically; bad dateGranularity / field-less bucket rejected) - objectql: engine drops its own groupBy casts; the two landed test casts removed (internal-fields structured-bucket case now type-checks honestly; filter-array-lowering cast narrowed to the deliberately off-contract where slot) - mcp: McpDataBridge.aggregate retyped onto the engine's own EngineAggregateOptions slices - closes function:string vs the six-name enum, dateGranularity:string vs the five-name vocabulary, and distinct?:boolean vs the #6815 retiredKey tombstone; stdio bridge compiles with zero casts No engine/runtime behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0123k4cam2jEAkPmbJeoaY3r * docs(spec): regenerate references for the EngineAggregateOptions.groupBy union (#8032) check:generated proved exactly one artifact stale (gen:docs); regenerated that one only. gen:openapi restored after gen:schema's cleanup (untracked, kept for the local rest runs). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0123k4cam2jEAkPmbJeoaY3r * merge origin/main (os-regen artifacts taken from main; regeneration follows) * docs(spec): re-apply the groupBy union reference rows after os-regen merge (#8032) os-regen-merge took main's side of references/**; gen:docs re-applies the three EngineAggregateOptions.groupBy rows on top of the merged state. check:generated: all 13 artifacts up to date. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0123k4cam2jEAkPmbJeoaY3r --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 021d8dd commit 3f7b4ff

10 files changed

Lines changed: 108 additions & 46 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
`EngineAggregateOptionsSchema.groupBy` now declares the standard `GroupByNodeSchema` union — a bare field name, or a `{ field, dateGranularity?, alias? }` bucket object for date bucketing — the same vocabulary `QuerySchema.groupBy` has always declared (#8032). The engine, driver-mongodb and the in-memory aggregation path have always executed the structured form; the engine-options declaration was the one face still saying `string[]`, so every correct caller had to cast around it. This widens the declared accept-set only: plain-string `groupBy` payloads validate byte-identically and no runtime behavior changes.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@objectstack/mcp": patch
3+
---
4+
5+
`McpDataBridge.aggregate` now declares its `groupBy` / `aggregations` inputs as the engine's own `EngineAggregateOptions` slices instead of a hand-mirrored copy (#8032). The mirror had drifted in three places: `function: string` against the engine's six-name enum, `dateGranularity?: string` against the `day`/`week`/`month`/`quarter`/`year` vocabulary, and a `distinct?: boolean` the engine retired in `@objectstack/spec` 17 (#6815) — a caller passing `distinct: true` had it silently dropped, and now gets the retirement rejection at compile time instead. Delete the key; a deduplicated count is the `count_distinct` aggregation function. Runtime acceptance is unchanged on every path: the `aggregate_records` tool's zod schema already enforced exactly these shapes at the ingress, and the stdio bridge's engine call no longer needs its two casts.

content/docs/references/data/data-engine.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ Options for DataEngine.aggregate operations
6565
| :--- | :--- | :--- | :--- |
6666
| **method** | `'aggregate'` || |
6767
| **object** | `string` || |
68-
| **query** | `{ context?: object; where?: Record<string, any> \| any; groupBy?: string[]; aggregations?: object[]; … }` || |
68+
| **query** | `{ context?: object; where?: Record<string, any> \| any; groupBy?: (string \| object)[]; aggregations?: object[]; … }` || |
6969

7070

7171
---
@@ -328,7 +328,7 @@ This schema accepts one of the following structures:
328328
| :--- | :--- | :--- | :--- |
329329
| **method** | `'aggregate'` || |
330330
| **object** | `string` || |
331-
| **query** | `{ context?: object; where?: Record<string, any> \| any; groupBy?: string[]; aggregations?: object[]; … }` || |
331+
| **query** | `{ context?: object; where?: Record<string, any> \| any; groupBy?: (string \| object)[]; aggregations?: object[]; … }` || |
332332

333333
---
334334

@@ -466,7 +466,7 @@ QueryAST-aligned options for DataEngine.aggregate operations
466466
| :--- | :--- | :--- | :--- |
467467
| **context** | `{ userId?: string; actor?: string; attributedUserId?: string; email?: string; … }` | optional | |
468468
| **where** | `Record<string, any> \| any` | optional | |
469-
| **groupBy** | `string[]` | optional | |
469+
| **groupBy** | `(string \| { field: string; dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; alias?: string })[]` | optional | GROUP BY targets (strings or `{field, dateGranularity?}` objects for date bucketing) |
470470
| **aggregations** | `{ function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>; field?: string; alias: string; filter?: any }[]` | optional | |
471471
| **having** | `any` | optional | HAVING — filter over the aggregated rows (aggregation aliases + groupBy projections); applied engine-side after aggregation |
472472
| **timezone** | `string` | optional | |

packages/mcp/src/mcp-http-tools.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434

3535
import { z } from 'zod';
3636
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
37+
import type { EngineAggregateOptions } from '@objectstack/spec/data';
3738
import {
3839
MCP_OAUTH_SCOPE_DATA_READ,
3940
MCP_OAUTH_SCOPE_DATA_WRITE,
@@ -80,13 +81,21 @@ export interface McpDataBridge {
8081
* aggregation through the engine simply omits it and the
8182
* `aggregate_records` tool is not registered (graceful degradation, same
8283
* contract as {@link McpActionBridge}).
84+
*
85+
* `groupBy` / `aggregations` are the engine's own declarations
86+
* (`EngineAggregateOptions`, #8032) rather than a hand-mirrored copy: the
87+
* tool's zod schema below already enforces exactly these shapes at the
88+
* ingress, and a private restatement is where the two had drifted — this
89+
* interface used to declare `function: string` against the six-name enum
90+
* and a `distinct?: boolean` the engine retired (#6815), silently dropping
91+
* any caller who believed it.
8392
*/
8493
aggregate?(
8594
object: string,
8695
opts: {
8796
where?: Record<string, unknown>;
88-
groupBy?: Array<string | { field: string; dateGranularity?: string; alias?: string }>;
89-
aggregations: Array<{ function: string; field?: string; alias: string; distinct?: boolean }>;
97+
groupBy?: NonNullable<EngineAggregateOptions['groupBy']>;
98+
aggregations: NonNullable<EngineAggregateOptions['aggregations']>;
9099
timezone?: string;
91100
},
92101
): Promise<unknown[]>;

packages/mcp/src/stdio-data-bridge.ts

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,9 @@
4848
*/
4949

5050
import type { ExecutionContext } from '@objectstack/spec/kernel';
51-
import type { EngineAggregateOptions } from '@objectstack/spec/data';
5251
import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts';
5352
import type { McpDataBridge, McpObjectSummary } from './mcp-http-tools.js';
5453

55-
/** The engine's own aggregation-node list — see the cast note in `aggregate`. */
56-
type EngineAggregations = NonNullable<EngineAggregateOptions['aggregations']>;
57-
5854
/** What {@link createStdioDataBridge} needs from the host plugin. */
5955
export interface StdioDataBridgeDeps {
6056
/** The ObjectQL engine — the `objectql` service, where RLS/FLS/permissions run. */
@@ -210,27 +206,15 @@ export function createStdioDataBridge(deps: StdioDataBridgeDeps): McpDataBridge
210206
if (typeof engine.aggregate === 'function') {
211207
bridge.aggregate = async (object, opts) => {
212208
const context = await resolvePrincipal();
213-
// Two casts, one cause: `McpDataBridge.aggregate` declares a WIDER input
214-
// than `EngineAggregateOptions` accepts, and the HTTP path never noticed
215-
// because it reaches the engine through `callData`'s untyped `params`.
216-
//
217-
// - `groupBy`: the bridge (and the `aggregate_records` tool schema)
218-
// allow `{ field, dateGranularity, alias }` objects; the engine option
219-
// declares `string[]` — while the `timezone` doc three lines below it
220-
// in that same schema describes "groupBy items carrying a
221-
// dateGranularity". The runtime contract is the object form.
222-
// - `aggregations`: the bridge declares `function: string`; the engine's
223-
// `AggregationNode` closes it to the six-name enum. The tool's own zod
224-
// schema already enforces exactly that enum before a value reaches
225-
// here, so the wide spelling is the interface's, never the caller's.
226-
//
227-
// Casting keeps this transport's request byte-identical to the HTTP one
228-
// rather than narrowing the declared tool input on one transport only.
229-
// The declaration mismatch itself is filed rather than papered over here.
209+
// No casts: `McpDataBridge.aggregate` declares the engine's own
210+
// `EngineAggregateOptions` slices since #8032, so the honest call
211+
// compiles — the two `as unknown as` casts this line used to carry
212+
// existed only because the engine option declared `groupBy: string[]`
213+
// while reading structured buckets.
230214
const rows = await engine.aggregate(object, {
231215
...(opts?.where ? { where: opts.where } : {}),
232-
...(opts?.groupBy ? { groupBy: opts.groupBy as unknown as string[] } : {}),
233-
aggregations: opts.aggregations as unknown as EngineAggregations,
216+
...(opts?.groupBy ? { groupBy: opts.groupBy } : {}),
217+
aggregations: opts.aggregations,
234218
...(opts?.timezone ? { timezone: opts.timezone } : {}),
235219
context,
236220
});

packages/objectql/src/engine-filter-array-lowering.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -440,9 +440,15 @@ describe('Door 2 lowers FilterArray to FilterCondition before the driver (#5158)
440440
.rejects.toMatchObject({ status: 400 });
441441
await expect(engine.count('deal', { where } as unknown as EngineCountOptions))
442442
.rejects.toMatchObject({ status: 400 });
443+
// Only `where` is off-contract in this call (FilterArray, deliberately —
444+
// module note on `asFilterArrayQuery`); since #8032 `groupBy` and
445+
// `aggregations` type-check honestly, so the cast is scoped to the one
446+
// slot whose contract is being bypassed.
443447
await expect(engine.aggregate('deal', {
444-
where, groupBy: ['stage'], aggregations: [{ function: 'count', field: 'id', alias: 'n' }],
445-
} as unknown as EngineAggregateOptions)).rejects.toMatchObject({ status: 400 });
448+
where: where as unknown as EngineAggregateOptions['where'],
449+
groupBy: ['stage'],
450+
aggregations: [{ function: 'count', field: 'id', alias: 'n' }],
451+
})).rejects.toMatchObject({ status: 400 });
446452
await expect(engine.update('deal', { amount: 1 }, { where, multi: true } as any))
447453
.rejects.toMatchObject({ status: 400 });
448454
await expect(engine.delete('deal', { where, multi: true } as any))

packages/objectql/src/engine.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9897,8 +9897,8 @@ export class ObjectQL implements IObjectQLEngine {
98979897
const field = (agg as { field?: string })?.field;
98989898
if (field && field !== '*') referenced.add(field);
98999899
}
9900-
for (const g of (query?.groupBy as unknown[]) ?? []) {
9901-
const field = typeof g === 'string' ? g : (g as { field?: string })?.field;
9900+
for (const g of query?.groupBy ?? []) {
9901+
const field = typeof g === 'string' ? g : g?.field;
99029902
if (field) referenced.add(field);
99039903
}
99049904

@@ -9934,7 +9934,7 @@ export class ObjectQL implements IObjectQLEngine {
99349934
ast: {
99359935
object,
99369936
where: query.where,
9937-
groupBy: query.groupBy as any,
9937+
groupBy: query.groupBy,
99389938
aggregations: query.aggregations,
99399939
// ENFORCED since #4286 (step 3). On the ast so the FLS predicate
99409940
// guard walks its references (predicate-guard.ts) and a future
@@ -9961,11 +9961,11 @@ export class ObjectQL implements IObjectQLEngine {
99619961
// supported we can push the aggregate down to the driver; otherwise
99629962
// we fall back to driver.find() + in-memory bucketing so the result
99639963
// remains correct on partial-support dialects (e.g. SQLite + week).
9964-
const groupByItems = Array.isArray(query.groupBy) ? (query.groupBy as any[]) : [];
9964+
const groupByItems = Array.isArray(query.groupBy) ? query.groupBy : [];
99659965
const granularityCaps: Record<string, boolean> | undefined =
99669966
drv?.supports?.queryDateGranularity;
99679967
const structuredItems = groupByItems.filter((g) => typeof g !== 'string');
9968-
const allStructuredSupported = structuredItems.every((g: any) => {
9968+
const allStructuredSupported = structuredItems.every((g) => {
99699969
if (!g?.dateGranularity) return true; // plain {field} object is fine
99709970
return granularityCaps?.[g.dateGranularity] === true;
99719971
});
@@ -9977,7 +9977,7 @@ export class ObjectQL implements IObjectQLEngine {
99779977
// matching rows are fetched), but bucketing runs uniformly in JS so a
99789978
// row near a tz day-boundary lands identically on every driver.
99799979
const tz = query.timezone;
9980-
const hasDateBucket = structuredItems.some((g: any) => !!g?.dateGranularity);
9980+
const hasDateBucket = structuredItems.some((g) => !!g?.dateGranularity);
99819981
const tzRequiresInMemory = !!tz && tz !== 'UTC' && hasDateBucket;
99829982
if (typeof drv.aggregate === 'function' && allStructuredSupported && !tzRequiresInMemory) {
99839983
// HAVING is engine-owned (#4286): applied AFTER aggregation, over

packages/objectql/src/internal-fields.test.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
import { describe, it, expect, beforeEach } from 'vitest';
2626
import { ObjectQL, type EngineReadOptions } from './engine.js';
2727
import { collectInternalReadFields, SECRET_MASK } from './secret-fields.js';
28-
import type { EngineAggregateOptions, ServiceObject } from '@objectstack/spec/data';
28+
import type { ServiceObject } from '@objectstack/spec/data';
2929

3030
// ---- minimal stub driver (equality-only WHERE) ----------------------------
3131
// Rows leave the driver as COPIES, as a real driver's do — see the note in
@@ -366,16 +366,15 @@ describe('#7728: the `internal` field flag omits a value from the generic data p
366366

367367
it('rejects the flagged field as a structured {field} groupBy bucket', async () => {
368368
await seedThree();
369-
// `as unknown as` names the contract being bypassed rather than erasing
370-
// it: `EngineAggregateOptions.groupBy` is declared `string[]`, while the
371-
// engine reads structured `{ field, dateGranularity }` buckets too — so
372-
// this is deliberately off-contract input, and the guard must walk that
373-
// second spelling as well. (`as any` here would grow the #4918 ratchet.)
369+
// The structured bucket form is ON-contract since #8032
370+
// (`EngineAggregateOptions.groupBy` is the standard GroupByNodeSchema
371+
// union) — this case type-checks honestly and pins that the guard walks
372+
// the second spelling, not just the string form above.
374373
await expect(
375374
ctx.engine.aggregate('itest_api_key', {
376375
aggregations: [{ function: 'count', alias: 'n' }],
377376
groupBy: [{ field: 'key' }],
378-
} as unknown as EngineAggregateOptions, SYSTEM),
377+
}, SYSTEM),
379378
).rejects.toThrow(/key/);
380379
});
381380

packages/spec/src/data/data-engine.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,53 @@ describe('EngineAggregateOptionsSchema', () => {
476476
expect(options.groupBy).toHaveLength(1);
477477
expect(options.aggregations).toHaveLength(2);
478478
});
479+
480+
// #8032 — `groupBy` is the standard GroupByNodeSchema union, the same
481+
// vocabulary as `QuerySchema.groupBy`. The engine has always read the
482+
// structured `{ field, dateGranularity }` bucket form (date bucketing,
483+
// and the credential-aggregation guard walks it); the declaration used to
484+
// say `string[]`, so every correct caller had to cast. These pin the
485+
// declaration to the enforced contract, in both directions.
486+
it('accepts structured { field, dateGranularity, alias } groupBy buckets (#8032)', () => {
487+
const result = EngineAggregateOptionsSchema.safeParse({
488+
groupBy: ['region', { field: 'closed_at', dateGranularity: 'quarter' }, { field: 'owner_id', alias: 'owner' }],
489+
aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }],
490+
timezone: 'Asia/Shanghai',
491+
});
492+
expect(result.success).toBe(true);
493+
if (result.success) {
494+
expect(result.data.groupBy).toEqual([
495+
'region',
496+
{ field: 'closed_at', dateGranularity: 'quarter' },
497+
{ field: 'owner_id', alias: 'owner' },
498+
]);
499+
}
500+
});
501+
502+
it('keeps the plain-string groupBy form validating byte-identically', () => {
503+
const result = EngineAggregateOptionsSchema.safeParse({
504+
groupBy: ['status', 'category'],
505+
aggregations: [{ function: 'count', alias: 'n' }],
506+
});
507+
expect(result.success).toBe(true);
508+
if (result.success) expect(result.data.groupBy).toEqual(['status', 'category']);
509+
});
510+
511+
it('rejects a groupBy bucket whose dateGranularity is not in the vocabulary', () => {
512+
const result = EngineAggregateOptionsSchema.safeParse({
513+
groupBy: [{ field: 'closed_at', dateGranularity: 'decade' }],
514+
aggregations: [{ function: 'count', alias: 'n' }],
515+
});
516+
expect(result.success).toBe(false);
517+
});
518+
519+
it('rejects a groupBy bucket that names no field', () => {
520+
const result = EngineAggregateOptionsSchema.safeParse({
521+
groupBy: [{ dateGranularity: 'day' }],
522+
aggregations: [{ function: 'count', alias: 'n' }],
523+
});
524+
expect(result.success).toBe(false);
525+
});
479526
});
480527

481528
describe('EngineCountOptionsSchema', () => {

packages/spec/src/data/data-engine.zod.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { z } from 'zod';
44
import { FilterConditionSchema } from './filter.zod';
5-
import { SortNodeSchema, QuerySchema, FullTextSearchSchema, FieldNodeSchema, AggregationNodeSchema, QUERY_CURSOR_REMOVED, QUERY_DISTINCT_REMOVED } from './query.zod';
5+
import { SortNodeSchema, QuerySchema, FullTextSearchSchema, FieldNodeSchema, AggregationNodeSchema, GroupByNodeSchema, QUERY_CURSOR_REMOVED, QUERY_DISTINCT_REMOVED } from './query.zod';
66
import { retiredKey } from '../shared/retired-key';
77
import { ExecutionContextSchema } from '../kernel/execution-context.zod';
88

@@ -326,8 +326,15 @@ export const DataEngineDeleteOptionsSchema = lazySchema(() => BaseEngineOptionsS
326326
export const EngineAggregateOptionsSchema = lazySchema(() => BaseEngineOptionsSchema.extend({
327327
/** Filter conditions (WHERE) — standard QueryAST `where` */
328328
where: z.union([z.record(z.string(), z.unknown()), FilterConditionSchema]).optional(),
329-
/** Group By fields */
330-
groupBy: z.array(z.string()).optional(),
329+
/**
330+
* GROUP BY targets — standard {@link GroupByNodeSchema}, same as
331+
* `QuerySchema.groupBy`: a bare field name, or a
332+
* `{ field, dateGranularity?, alias? }` bucket object for date bucketing.
333+
* The engine has always read both spellings (#8032 caught the declaration
334+
* up to the enforced contract); the string form stays the canonical
335+
* short-hand and validates unchanged.
336+
*/
337+
groupBy: z.array(GroupByNodeSchema).optional().describe('GROUP BY targets (strings or `{field, dateGranularity?}` objects for date bucketing)'),
331338
/**
332339
* Aggregation definitions — uses standard AggregationNodeSchema (`function` key).
333340
* e.g. [{ function: 'sum', field: 'amount', alias: 'total' }]

0 commit comments

Comments
 (0)