Skip to content

Commit aa16721

Browse files
os-elonclaude
andauthored
fix(service-analytics): narrow the executeAggregate config mirrors' aggregations[].method to AggregationFunction (#13361)
#12776 narrowed StrategyContext.executeAggregate's aggregations[].method from `string` to the six-value AggregationFunction enum, but this package's own consumer-local mirrors of that slot kept declaring `string`, so a custom-bridge author still typed their handler against the open vocabulary. Narrows all three sites the tree carries: the AnalyticsServicePluginOptions mirror (plugin.ts), the AnalyticsServiceConfig twin (analytics-service.ts) and parseEngineAggregateFunction's parameter, and repairs the two comments that explained the auto-bridge's runtime parse by pointing at the old `method: string` declaration. The parse itself is untouched: with both ends of the `method` -> `function` rename now declaring the same enum, it is defence in depth behind a compile-time check rather than the only check. Type-only; no runtime change. Claude-Session: https://claude.ai/code/session_012WkdHQwHr2KQmaX7P1BHzi Co-authored-by: Claude <noreply@anthropic.com>
1 parent 41a2d44 commit aa16721

3 files changed

Lines changed: 110 additions & 30 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(service-analytics): the consumer-local `executeAggregate` config mirrors narrow `aggregations[].method` to `AggregationFunction` (#12940)
6+
7+
#12776 narrowed the contract — `StrategyContext.executeAggregate`'s
8+
`aggregations[].method` went from `string` to the six-value
9+
`AggregationFunction` — but this package's two CONSUMER-LOCAL config mirrors
10+
of that same slot kept declaring `string`, so the compile-time vocabulary the
11+
narrowing bought for strategy authors stopped at the package boundary and
12+
never reached the people who write a custom bridge.
13+
14+
FROM → TO, at all three sites the tree carries (the card enumerated two):
15+
16+
- `AnalyticsServicePluginOptions.executeAggregate` (`plugin.ts`) —
17+
`aggregations[].method: string``AggregationFunction`. This is the
18+
declaration an app author's own `executeAggregate` bridge is typed against.
19+
- `AnalyticsServiceConfig.executeAggregate` (`analytics-service.ts`) — the
20+
same narrowing on the config twin whose own comment says it is kept in
21+
lockstep with `StrategyContext.executeAggregate`; that claim is true again,
22+
and now names the member so the next drift is visible.
23+
- `parseEngineAggregateFunction`'s `method` parameter (`plugin.ts`), the
24+
auto-bridge's runtime parse — narrowed for the same reason: it was the
25+
third place a reader was told this vocabulary is open.
26+
27+
Who breaks at compile time on upgrade: CALLERS that fill `method` with a
28+
value typed `string` (or a literal outside the six) when invoking one of
29+
these bridges — the values the bridge already refused at runtime (#11833).
30+
IMPLEMENTORS are source-compatible: a handler that accepts `method: string`
31+
accepts a superset and stays assignable to the narrowed member (parameter
32+
contravariance), which is why the ~nine test doubles in this package that
33+
declare their own `{ field, method: string, alias }` mirrors still compile
34+
untouched.
35+
36+
No runtime change. The auto-bridge's runtime parse-and-refuse (#11833) stays
37+
exactly where it was — with both ends of the `method``function` rename now
38+
declaring the same enum, it is defence in depth behind a compile-time check
39+
rather than the only check, and the two comments that explained it by
40+
pointing at the old `method: string` declaration say so instead.

packages/services/service-analytics/src/analytics-service.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type {
77
CubeMeta,
88
DatasetSelection,
99
} from '@objectstack/spec/contracts';
10-
import { percentScaleOf, type Cube, type FilterCondition } from '@objectstack/spec/data';
10+
import { percentScaleOf, type AggregationFunction, type Cube, type FilterCondition } from '@objectstack/spec/data';
1111
import type { ExecutionContext } from '@objectstack/spec/kernel';
1212
import type { Dataset } from '@objectstack/spec/ui';
1313
// [#6761] The ONE shared `I18nLabel → string` resolver (#6765, maintainer
@@ -390,15 +390,23 @@ export interface AnalyticsServiceConfig {
390390
executeAggregate?: (objectName: string, options: {
391391
groupBy?: string[];
392392
/**
393-
* Per-aggregation `filter` (#10576, the #10413 phase-2 contract field) —
394-
* kept in lockstep with `StrategyContext.executeAggregate`
395-
* (`packages/spec/src/contracts/analytics-service.ts`), which this local
396-
* type otherwise mirrors. A bridge that reconstructs the aggregation
397-
* entries (as `AnalyticsServicePlugin`'s auto-bridge does, to rename
398-
* `method` → the engine's `function`) MUST forward this field or a
399-
* measure-scoped filter `ObjectQLStrategy` lowers never reaches storage.
393+
* The local mirror of `StrategyContext.executeAggregate`'s aggregation
394+
* entries (`packages/spec/src/contracts/analytics-service.ts`), kept in
395+
* lockstep with it member by member. The two that lockstep is load-bearing
396+
* for:
397+
*
398+
* - `filter` (#10576, the #10413 phase-2 contract field) — a bridge that
399+
* reconstructs the aggregation entries (as `AnalyticsServicePlugin`'s
400+
* auto-bridge does, to rename `method` → the engine's `function`) MUST
401+
* forward this field or a measure-scoped filter `ObjectQLStrategy`
402+
* lowers never reaches storage.
403+
* - `method` is the spec's OWN six-value `AggregationFunction`, not
404+
* `string`: #12776 narrowed the contract, #12940 brought this mirror
405+
* back into line. Widening it here again would not be a local matter —
406+
* a bridge author types their handler against THIS declaration, so what
407+
* they would get is a vocabulary the contract no longer has.
400408
*/
401-
aggregations?: Array<{ field: string; method: string; alias: string; filter?: Record<string, unknown> }>;
409+
aggregations?: Array<{ field: string; method: AggregationFunction; alias: string; filter?: Record<string, unknown> }>;
402410
filter?: Record<string, unknown>;
403411
/** Reference timezone (IANA) for date bucketing — ADR-0053 Phase 2. */
404412
timezone?: string;

packages/services/service-analytics/src/plugin.ts

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -67,18 +67,31 @@ type TemporalDriverSurface = Pick<
6767
>;
6868

6969
/**
70-
* Narrow a strategy-supplied aggregation `method` to the engine contract's
71-
* `AggregationFunction`, refusing anything else.
70+
* Re-parse a bridge-supplied aggregation `method` as the engine contract's
71+
* `AggregationFunction` before it is forwarded as `function`, refusing
72+
* anything else.
7273
*
73-
* The two sides genuinely differ: `IDataEngine.aggregate`'s
74-
* `aggregations[].function` is the six-value enum, while the analytics
75-
* strategy contract that feeds this bridge declares `aggregations[].method` as
76-
* `string`. Parsing with the spec's OWN enum keeps a single vocabulary — no
77-
* local literal list to drift, and `AggregationFunction`'s error map already
78-
* knows the retired `array_agg` / `string_agg` spellings.
74+
* Both sides now declare the same six-value enum: `IDataEngine.aggregate`'s
75+
* `aggregations[].function`, and — since #12776 — the analytics strategy
76+
* contract (`StrategyContext.executeAggregate`) plus the two consumer-local
77+
* config mirrors this package keeps in lockstep with it (#12940). So this
78+
* parse is DEFENCE IN DEPTH behind a compile-time check, not the only check
79+
* (#11833).
80+
*
81+
* That is a reason to keep it, not to delete it. Types are erased: a
82+
* JavaScript app supplying its own `executeAggregate`, or host drift arriving
83+
* through a cube object that never met `CubeSchema`'s parse (the path
84+
* `aggregate-bridge-function-vocabulary.test.ts` drives end to end), still
85+
* reaches this seam carrying a method the engine does not declare. What the
86+
* refusal buys is in `plugin.ts`'s forward below and in #12209: the engine is
87+
* never handed a `function` no driver declares.
88+
*
89+
* Parsing with the spec's OWN enum keeps a single vocabulary — no local
90+
* literal list to drift, and `AggregationFunction`'s error map already knows
91+
* the retired `array_agg` / `string_agg` spellings.
7992
*/
8093
function parseEngineAggregateFunction(
81-
method: string,
94+
method: AggregationFunction,
8295
alias: string,
8396
): NonNullable<Parameters<IDataEngine['aggregate']>[1]['aggregations']>[number]['function'] {
8497
const parsed = AggregationFunction.safeParse(method);
@@ -116,13 +129,23 @@ export interface AnalyticsServicePluginOptions {
116129
executeAggregate?: (objectName: string, options: {
117130
groupBy?: string[];
118131
/**
119-
* Per-aggregation `filter` (#10576, the #10413 contract field) — a
120-
* CUSTOM bridge (an app author's own `executeAggregate`, as opposed to
121-
* the auto-bridge below) MUST forward it to the real engine the same way
122-
* the auto-bridge does, or a measure-scoped filter this plugin lowers
123-
* onto the aggregation silently never reaches storage.
132+
* The CUSTOM bridge's view of the aggregation entries — an app author's
133+
* own `executeAggregate`, as opposed to the auto-bridge below. Mirrors
134+
* `StrategyContext.executeAggregate`
135+
* (`packages/spec/src/contracts/analytics-service.ts`) and must stay in
136+
* lockstep with it; the two members that lockstep is load-bearing for:
137+
*
138+
* - `filter` (#10576, the #10413 contract field) — a custom bridge MUST
139+
* forward it to the real engine the same way the auto-bridge does, or a
140+
* measure-scoped filter this plugin lowers onto the aggregation
141+
* silently never reaches storage.
142+
* - `method` is the spec's OWN six-value `AggregationFunction`, not
143+
* `string`: #12776 narrowed the contract, #12940 brought this mirror
144+
* back into line. This is the declaration a custom-bridge author types
145+
* their handler against, so it is where the compile-time vocabulary
146+
* #12776 bought for strategy authors reaches them too.
124147
*/
125-
aggregations?: Array<{ field: string; method: string; alias: string; filter?: Record<string, unknown> }>;
148+
aggregations?: Array<{ field: string; method: AggregationFunction; alias: string; filter?: Record<string, unknown> }>;
126149
filter?: Record<string, unknown>;
127150
/** Reference timezone (IANA) for date bucketing — ADR-0053 Phase 2. */
128151
timezone?: string;
@@ -288,15 +311,24 @@ export class AnalyticsServicePlugin implements Plugin {
288311
// vacuous-filter convention.
289312
aggregations: aggregations?.map((a) => ({
290313
// [#11833] `function` is the engine contract's SIX-value
291-
// `AggregationFunction`, while this bridge's own input declares
292-
// `method: string` (`StrategyContext.executeAggregate`, spec
293-
// `contracts/analytics-service.ts:300`). Narrowing the engine side
294-
// to the contract turned that forward into a compile error — the
314+
// `AggregationFunction`. This bridge's own input declared
315+
// `method: string` when that history was written
316+
// (`StrategyContext.executeAggregate`, spec
317+
// `contracts/analytics-service.ts`), so the two ends of this
318+
// rename spoke different vocabularies: narrowing the engine side
319+
// to the contract turned the forward into a compile error — the
295320
// correct signal, and the one the deleted structural type hid by
296321
// declaring `function: string` on both sides.
297322
//
298-
// Closed by PARSING with the spec enum itself rather than by
299-
// widening back to `string` (what hid it) or casting past it
323+
// Since #12776 (contract) and #12940 (this plugin's own config
324+
// mirror above), BOTH ends declare the enum, so the rename is
325+
// enum-to-enum and the parse below is defence in depth behind a
326+
// compile-time check rather than the only check — see
327+
// `parseEngineAggregateFunction` for why erased types still leave
328+
// it load-bearing.
329+
//
330+
// It was closed by PARSING with the spec enum itself rather than
331+
// by widening back to `string` (what hid it) or casting past it
300332
// (which keeps the hole and adds a lie). `AggregationFunction` is
301333
// the same schema `AggregationNodeSchema.function` is built from,
302334
// so there is one vocabulary, and its own error map already

0 commit comments

Comments
 (0)