@objectstack/service-analytics 17.2.0 → 17.3.0

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/dist/index.d.cts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { IAnalyticsService, Logger, AnalyticsDriverCapabilities, AnalyticsStrategy, AnalyticsQuery, AnalyticsResult, DatasetSelection, CubeMeta, DatasetCompareTo, StrategyContext } from '@objectstack/spec/contracts';
2
2
  export { AnalyticsDriverCapabilities, AnalyticsStrategy, DatasetSelection, StrategyContext } from '@objectstack/spec/contracts';
3
- import { Cube, FilterCondition } from '@objectstack/spec/data';
3
+ import { Cube, FilterCondition, AggregationFunction } from '@objectstack/spec/data';
4
4
  import { ExecutionContext } from '@objectstack/spec/kernel';
5
5
  import { Dataset } from '@objectstack/spec/ui';
6
6
  import { Plugin, PluginContext } from '@objectstack/core';
@@ -328,10 +328,28 @@ interface AnalyticsServiceConfig {
328
328
  */
329
329
  executeAggregate?: (objectName: string, options: {
330
330
  groupBy?: string[];
331
+ /**
332
+ * The local mirror of `StrategyContext.executeAggregate`'s aggregation
333
+ * entries (`packages/spec/src/contracts/analytics-service.ts`), kept in
334
+ * lockstep with it member by member. The two that lockstep is load-bearing
335
+ * for:
336
+ *
337
+ * - `filter` (#10576, the #10413 phase-2 contract field) — a bridge that
338
+ * reconstructs the aggregation entries (as `AnalyticsServicePlugin`'s
339
+ * auto-bridge does, to rename `method` → the engine's `function`) MUST
340
+ * forward this field or a measure-scoped filter `ObjectQLStrategy`
341
+ * lowers never reaches storage.
342
+ * - `method` is the spec's OWN six-value `AggregationFunction`, not
343
+ * `string`: #12776 narrowed the contract, #12940 brought this mirror
344
+ * back into line. Widening it here again would not be a local matter —
345
+ * a bridge author types their handler against THIS declaration, so what
346
+ * they would get is a vocabulary the contract no longer has.
347
+ */
331
348
  aggregations?: Array<{
332
349
  field: string;
333
- method: string;
350
+ method: AggregationFunction;
334
351
  alias: string;
352
+ filter?: Record<string, unknown>;
335
353
  }>;
336
354
  filter?: Record<string, unknown>;
337
355
  /** Reference timezone (IANA) for date bucketing — ADR-0053 Phase 2. */
@@ -619,6 +637,27 @@ declare class AnalyticsService implements IAnalyticsService {
619
637
  * `getReadScope(objectName)` that already knows the active tenant.
620
638
  */
621
639
  private callCtx;
640
+ /**
641
+ * [#12230] Copy-on-write expansion of filter placeholders across everything
642
+ * a DIRECT analytics query compares on: `where` and each time dimension's
643
+ * `dateRange` — the same positions `DatasetExecutor.resolveSelectionTokens`
644
+ * covers for the dashboard door, minus the dataset-only channels it alone
645
+ * carries (measure filters ride the dataset-scope getter below).
646
+ *
647
+ * The input is never mutated: a query object can be caller-owned metadata
648
+ * (a saved report definition, a flow node's config) reused across requests,
649
+ * and resolving in place would bake one request's user id into every later
650
+ * render. Returns the SAME object when nothing resolved.
651
+ */
652
+ private resolveQueryTokens;
653
+ /**
654
+ * [#12230] A per-request `getDatasetScope` whose answers have their filter
655
+ * placeholders resolved against THIS caller. See `callCtx` for why the
656
+ * registry's copy cannot be handed out raw. Token-free scopes pass through
657
+ * by reference — `resolveFilterTokens` returns its input unchanged when the
658
+ * tree holds no placeholder, so the common case allocates nothing.
659
+ */
660
+ private resolvedDatasetScopeGetter;
622
661
  /**
623
662
  * Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
624
663
  * AND every joined object of the query's cube, keyed by object name. This is
@@ -644,7 +683,7 @@ declare class AnalyticsService implements IAnalyticsService {
644
683
  * aggregate bridge) instead of failing — or worse, fabricating empty rows.
645
684
  * Any other error propagates untouched.
646
685
  */
647
- query(query: AnalyticsQuery, context?: ExecutionContext): Promise<AnalyticsResult>;
686
+ query(queryInput: AnalyticsQuery, context?: ExecutionContext): Promise<AnalyticsResult>;
648
687
  /**
649
688
  * [#8286] Withhold the executed statement unless this host enabled the echo.
650
689
  *
@@ -686,7 +725,7 @@ declare class AnalyticsService implements IAnalyticsService {
686
725
  /**
687
726
  * Generate SQL for a query without executing it (dry-run).
688
727
  */
689
- generateSql(query: AnalyticsQuery, context?: ExecutionContext): Promise<{
728
+ generateSql(queryInput: AnalyticsQuery, context?: ExecutionContext): Promise<{
690
729
  sql: string;
691
730
  params: unknown[];
692
731
  }>;
@@ -922,10 +961,28 @@ interface AnalyticsServicePluginOptions {
922
961
  */
923
962
  executeAggregate?: (objectName: string, options: {
924
963
  groupBy?: string[];
964
+ /**
965
+ * The CUSTOM bridge's view of the aggregation entries — an app author's
966
+ * own `executeAggregate`, as opposed to the auto-bridge below. Mirrors
967
+ * `StrategyContext.executeAggregate`
968
+ * (`packages/spec/src/contracts/analytics-service.ts`) and must stay in
969
+ * lockstep with it; the two members that lockstep is load-bearing for:
970
+ *
971
+ * - `filter` (#10576, the #10413 contract field) — a custom bridge MUST
972
+ * forward it to the real engine the same way the auto-bridge does, or a
973
+ * measure-scoped filter this plugin lowers onto the aggregation
974
+ * silently never reaches storage.
975
+ * - `method` is the spec's OWN six-value `AggregationFunction`, not
976
+ * `string`: #12776 narrowed the contract, #12940 brought this mirror
977
+ * back into line. This is the declaration a custom-bridge author types
978
+ * their handler against, so it is where the compile-time vocabulary
979
+ * #12776 bought for strategy authors reaches them too.
980
+ */
925
981
  aggregations?: Array<{
926
982
  field: string;
927
- method: string;
983
+ method: AggregationFunction;
928
984
  alias: string;
985
+ filter?: Record<string, unknown>;
929
986
  }>;
930
987
  filter?: Record<string, unknown>;
931
988
  /** Reference timezone (IANA) for date bucketing — ADR-0053 Phase 2. */
@@ -1465,7 +1522,7 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1465
1522
  * the members inside were unreadable from the outside and the envelope check
1466
1523
  * could not reject what it could not see.
1467
1524
  *
1468
- * ## Two producers, one inventory (#10861)
1525
+ * ## Three producers, one inventory (#10861, #11461)
1469
1526
  *
1470
1527
  * The caller's `where` is not the only thing that reaches `engine.aggregate`
1471
1528
  * as a predicate. Since PR #10758 the compiled dataset's own definition-level
@@ -1480,6 +1537,36 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1480
1537
  * which driver will serve the dataset and would refuse a dataset that is
1481
1538
  * perfectly legal on a native-SQL deployment.
1482
1539
  *
1540
+ * [#11461] #10413 phase 2 then added a THIRD producer with the same reach and
1541
+ * none of the coverage: a compiled measure's own `filter`, lowered onto that
1542
+ * measure's `aggregations[].filter` entry (#10576). This view enumerated two
1543
+ * origins, so the third was invisible to the envelope check and the arm of
1544
+ * `planCrossObject` that inspects `query.measures` reads only each measure's
1545
+ * resolved FIELD, never its filter. Measured on the unfixed tree, one fixture,
1546
+ * both doors:
1547
+ *
1548
+ * ```
1549
+ * BEFORE execute() ACCEPTED -> aggregations: [{field:"*",method:"count",
1550
+ * alias:"west_count",
1551
+ * filter:{"account.region":"West"}}]
1552
+ * -> rows [{stage:"won",total_count:3,west_count:0}]
1553
+ * (the truthful west_count is 2; total_count
1554
+ * is right, so the wrong number arrived in
1555
+ * the same response shape as the right one)
1556
+ * generateSql() ACCEPTED -> COUNT(CASE WHEN account.region = $1 THEN 1 END)
1557
+ * over a FROM with no join in it at all
1558
+ * AFTER both doors REFUSED INVALID_FIELD / 400, engine never reached
1559
+ * ```
1560
+ *
1561
+ * The same maintainer ruling covers it — same hazard, same physical verdict,
1562
+ * one more producer — so it folds in HERE for the #10861 reason and not into
1563
+ * `dataset-compiler.ts`, which still cannot see which driver will serve the
1564
+ * dataset. Only the REQUESTED measures are folded: both doors' aggregation
1565
+ * loops read `measureFilters[m]` for `m of query.measures` and nothing else,
1566
+ * so a filter declared on a measure this query never asks for reaches no
1567
+ * engine, and refusing on it would reject a query for a member that was never
1568
+ * going to be evaluated.
1569
+ *
1483
1570
  * Structure is discarded on purpose — a member is cross-object or it is not,
1484
1571
  * and which branch of a disjunction it sits in cannot make
1485
1572
  * `engine.aggregate` able to join it. PROVENANCE is not discarded, because it
@@ -1489,9 +1576,12 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1489
1576
  * `planCrossObject`. The value slot carries that and nothing else; it never
1490
1577
  * reaches a driver.
1491
1578
  *
1492
- * Dataset leaves are inserted FIRST so a member named by BOTH producers keeps
1493
- * the caller's provenance (last write wins on a duplicate key): if it is in
1494
- * the request too, the request is the actionable place to fix it.
1579
+ * Insertion order is measure-filter, then dataset-filter, then `where`, and
1580
+ * last write wins on a duplicate key. Two things follow, in that order of
1581
+ * importance. A member named by the request too keeps the CALLER's provenance,
1582
+ * because if it is in the request that is the actionable place to fix it. And
1583
+ * every shape that was refused before #11461 keeps the exact message it had:
1584
+ * the new origin can only ever win a key no older producer names.
1495
1585
  *
1496
1586
  * Time-dimension WINDOWS are deliberately absent (they live in
1497
1587
  * `dateRangeBounds`, not in `where`). They need no arm here: a cross-object
@@ -1513,18 +1603,20 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1513
1603
  * THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
1514
1604
  * (needs a real join to evaluate), a cross-object leaf in the DATASET's own
1515
1605
  * definition-level `filter` (#10861 — same join it does not have, arriving
1516
- * from the producer PR #10758 added), a MULTI-HOP dimension (`a.b.c`), or a
1517
- * non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values
1518
- * cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
1606
+ * from the producer PR #10758 added), a cross-object leaf in ONE MEASURE's own
1607
+ * `filter` (#11461 the same join again, arriving from the producer #10413
1608
+ * phase 2 added), a MULTI-HOP dimension (`a.b.c`), or a non-recombinable
1609
+ * measure (`avg`/`count_distinct`, whose sub-bucket values cannot be merged).
1610
+ * A loud error beats the silent mis-bucket #3654 kills.
1519
1611
  * `generateSql()` calls this too, so the preview accepts/rejects the same set
1520
1612
  * — and since #10759 both callers derive `filter` from the one
1521
1613
  * {@link filterMemberView}, so that sentence is enforced by construction
1522
1614
  * instead of restated at two call sites.
1523
1615
  *
1524
- * [#5716] All five refusals below are `invalidMemberError` — `INVALID_FIELD` /
1616
+ * [#5716] All six refusals below are `invalidMemberError` — `INVALID_FIELD` /
1525
1617
  * 400, naming the member — and the four that predate #10861 keep their
1526
1618
  * MESSAGES unchanged (they are good diagnostics, and #5923's tests read
1527
- * them). Each is decided by two facts and nothing else: a member that will
1619
+ * them); so does #10861's own, which #11461 left untouched beside it. Each is decided by two facts and nothing else: a member that will
1528
1620
  * reach the engine's predicate, and whether that member resolves across a
1529
1621
  * join. Neither is an internal invariant — a cube where the member exists and
1530
1622
  * a driver that could serve it are both perfectly ordinary, which is exactly
@@ -1533,14 +1625,15 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1533
1625
  * because the fix is always to change or drop ONE named member, and because
1534
1626
  * four of them fire on `/analytics/query` where no dataset exists.
1535
1627
  *
1536
- * [#10861] The fifth is the exception that proves the rule and is written to
1537
- * it: it can only fire where a dataset DOES exist, and it is the one refusal
1538
- * here whose member no request key named — so it carries `cube` and no
1539
- * `param`, and says in its own words which document to go and edit. It stays
1628
+ * [#10861, #11461] The fifth and sixth are the exceptions that prove the rule
1629
+ * and are written to it: they can only fire where a dataset DOES exist, and
1630
+ * they are the two refusals here whose member no request key named — so each
1631
+ * carries `cube` and no `param`, and says in its own words which document to
1632
+ * go and edit, the sixth naming the MEASURE inside it as well. Both stay
1540
1633
  * `INVALID_FIELD` rather than becoming `DATASET_INVALID` because the verdict
1541
- * is the same physical one as its neighbour — this engine cannot join this
1634
+ * is the same physical one as their neighbours — this engine cannot join this
1542
1635
  * member — and splitting the code by PROVENANCE would make a caller branch on
1543
- * two wire shapes for one capability limit.
1636
+ * three wire shapes for one capability limit.
1544
1637
  *
1545
1638
  * Detection is on RESOLVED field names, so a dotted dimension the cube
1546
1639
  * flattens to a real column is treated as base, not cross-object.
@@ -1558,6 +1651,23 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1558
1651
  * are simply absent from the map (⇒ RESTRICTED bucket downstream).
1559
1652
  */
1560
1653
  private resolveFkAttr;
1654
+ /**
1655
+ * A measure's aggregate, restricted to the rows its own `filter` admits
1656
+ * (#10413 phase 2) — the same six functions `generateSql`'s unconditional
1657
+ * branch renders, wrapped in a `CASE WHEN`.
1658
+ *
1659
+ * Spelled `CASE WHEN` rather than SQL-standard `FILTER (WHERE …)`, mirroring
1660
+ * `NativeSQLStrategy.CONDITIONAL_AGGREGATE_SQL`: this string is DOCUMENTATION
1661
+ * of an execution that really goes through `engine.aggregate`'s per-driver
1662
+ * `aggregations[].filter` lowering (#10576), not a statement this class runs
1663
+ * itself, so there is no reason to pick a dialect-restricted spelling over
1664
+ * the portable one the SQL-executing sibling already settled on.
1665
+ *
1666
+ * `count` over `*` counts a constant (`COUNT(CASE WHEN p THEN 1 END)`, since
1667
+ * `COUNT(CASE WHEN p THEN * END)` is not valid SQL); over a real column it
1668
+ * counts that column's non-null values among the admitted rows.
1669
+ */
1670
+ private conditionalAggregateSql;
1561
1671
  /**
1562
1672
  * Render one normalized filter as a display SQL predicate for `generateSql`.
1563
1673
  *
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { IAnalyticsService, Logger, AnalyticsDriverCapabilities, AnalyticsStrategy, AnalyticsQuery, AnalyticsResult, DatasetSelection, CubeMeta, DatasetCompareTo, StrategyContext } from '@objectstack/spec/contracts';
2
2
  export { AnalyticsDriverCapabilities, AnalyticsStrategy, DatasetSelection, StrategyContext } from '@objectstack/spec/contracts';
3
- import { Cube, FilterCondition } from '@objectstack/spec/data';
3
+ import { Cube, FilterCondition, AggregationFunction } from '@objectstack/spec/data';
4
4
  import { ExecutionContext } from '@objectstack/spec/kernel';
5
5
  import { Dataset } from '@objectstack/spec/ui';
6
6
  import { Plugin, PluginContext } from '@objectstack/core';
@@ -328,10 +328,28 @@ interface AnalyticsServiceConfig {
328
328
  */
329
329
  executeAggregate?: (objectName: string, options: {
330
330
  groupBy?: string[];
331
+ /**
332
+ * The local mirror of `StrategyContext.executeAggregate`'s aggregation
333
+ * entries (`packages/spec/src/contracts/analytics-service.ts`), kept in
334
+ * lockstep with it member by member. The two that lockstep is load-bearing
335
+ * for:
336
+ *
337
+ * - `filter` (#10576, the #10413 phase-2 contract field) — a bridge that
338
+ * reconstructs the aggregation entries (as `AnalyticsServicePlugin`'s
339
+ * auto-bridge does, to rename `method` → the engine's `function`) MUST
340
+ * forward this field or a measure-scoped filter `ObjectQLStrategy`
341
+ * lowers never reaches storage.
342
+ * - `method` is the spec's OWN six-value `AggregationFunction`, not
343
+ * `string`: #12776 narrowed the contract, #12940 brought this mirror
344
+ * back into line. Widening it here again would not be a local matter —
345
+ * a bridge author types their handler against THIS declaration, so what
346
+ * they would get is a vocabulary the contract no longer has.
347
+ */
331
348
  aggregations?: Array<{
332
349
  field: string;
333
- method: string;
350
+ method: AggregationFunction;
334
351
  alias: string;
352
+ filter?: Record<string, unknown>;
335
353
  }>;
336
354
  filter?: Record<string, unknown>;
337
355
  /** Reference timezone (IANA) for date bucketing — ADR-0053 Phase 2. */
@@ -619,6 +637,27 @@ declare class AnalyticsService implements IAnalyticsService {
619
637
  * `getReadScope(objectName)` that already knows the active tenant.
620
638
  */
621
639
  private callCtx;
640
+ /**
641
+ * [#12230] Copy-on-write expansion of filter placeholders across everything
642
+ * a DIRECT analytics query compares on: `where` and each time dimension's
643
+ * `dateRange` — the same positions `DatasetExecutor.resolveSelectionTokens`
644
+ * covers for the dashboard door, minus the dataset-only channels it alone
645
+ * carries (measure filters ride the dataset-scope getter below).
646
+ *
647
+ * The input is never mutated: a query object can be caller-owned metadata
648
+ * (a saved report definition, a flow node's config) reused across requests,
649
+ * and resolving in place would bake one request's user id into every later
650
+ * render. Returns the SAME object when nothing resolved.
651
+ */
652
+ private resolveQueryTokens;
653
+ /**
654
+ * [#12230] A per-request `getDatasetScope` whose answers have their filter
655
+ * placeholders resolved against THIS caller. See `callCtx` for why the
656
+ * registry's copy cannot be handed out raw. Token-free scopes pass through
657
+ * by reference — `resolveFilterTokens` returns its input unchanged when the
658
+ * tree holds no placeholder, so the common case allocates nothing.
659
+ */
660
+ private resolvedDatasetScopeGetter;
622
661
  /**
623
662
  * Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
624
663
  * AND every joined object of the query's cube, keyed by object name. This is
@@ -644,7 +683,7 @@ declare class AnalyticsService implements IAnalyticsService {
644
683
  * aggregate bridge) instead of failing — or worse, fabricating empty rows.
645
684
  * Any other error propagates untouched.
646
685
  */
647
- query(query: AnalyticsQuery, context?: ExecutionContext): Promise<AnalyticsResult>;
686
+ query(queryInput: AnalyticsQuery, context?: ExecutionContext): Promise<AnalyticsResult>;
648
687
  /**
649
688
  * [#8286] Withhold the executed statement unless this host enabled the echo.
650
689
  *
@@ -686,7 +725,7 @@ declare class AnalyticsService implements IAnalyticsService {
686
725
  /**
687
726
  * Generate SQL for a query without executing it (dry-run).
688
727
  */
689
- generateSql(query: AnalyticsQuery, context?: ExecutionContext): Promise<{
728
+ generateSql(queryInput: AnalyticsQuery, context?: ExecutionContext): Promise<{
690
729
  sql: string;
691
730
  params: unknown[];
692
731
  }>;
@@ -922,10 +961,28 @@ interface AnalyticsServicePluginOptions {
922
961
  */
923
962
  executeAggregate?: (objectName: string, options: {
924
963
  groupBy?: string[];
964
+ /**
965
+ * The CUSTOM bridge's view of the aggregation entries — an app author's
966
+ * own `executeAggregate`, as opposed to the auto-bridge below. Mirrors
967
+ * `StrategyContext.executeAggregate`
968
+ * (`packages/spec/src/contracts/analytics-service.ts`) and must stay in
969
+ * lockstep with it; the two members that lockstep is load-bearing for:
970
+ *
971
+ * - `filter` (#10576, the #10413 contract field) — a custom bridge MUST
972
+ * forward it to the real engine the same way the auto-bridge does, or a
973
+ * measure-scoped filter this plugin lowers onto the aggregation
974
+ * silently never reaches storage.
975
+ * - `method` is the spec's OWN six-value `AggregationFunction`, not
976
+ * `string`: #12776 narrowed the contract, #12940 brought this mirror
977
+ * back into line. This is the declaration a custom-bridge author types
978
+ * their handler against, so it is where the compile-time vocabulary
979
+ * #12776 bought for strategy authors reaches them too.
980
+ */
925
981
  aggregations?: Array<{
926
982
  field: string;
927
- method: string;
983
+ method: AggregationFunction;
928
984
  alias: string;
985
+ filter?: Record<string, unknown>;
929
986
  }>;
930
987
  filter?: Record<string, unknown>;
931
988
  /** Reference timezone (IANA) for date bucketing — ADR-0053 Phase 2. */
@@ -1465,7 +1522,7 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1465
1522
  * the members inside were unreadable from the outside and the envelope check
1466
1523
  * could not reject what it could not see.
1467
1524
  *
1468
- * ## Two producers, one inventory (#10861)
1525
+ * ## Three producers, one inventory (#10861, #11461)
1469
1526
  *
1470
1527
  * The caller's `where` is not the only thing that reaches `engine.aggregate`
1471
1528
  * as a predicate. Since PR #10758 the compiled dataset's own definition-level
@@ -1480,6 +1537,36 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1480
1537
  * which driver will serve the dataset and would refuse a dataset that is
1481
1538
  * perfectly legal on a native-SQL deployment.
1482
1539
  *
1540
+ * [#11461] #10413 phase 2 then added a THIRD producer with the same reach and
1541
+ * none of the coverage: a compiled measure's own `filter`, lowered onto that
1542
+ * measure's `aggregations[].filter` entry (#10576). This view enumerated two
1543
+ * origins, so the third was invisible to the envelope check and the arm of
1544
+ * `planCrossObject` that inspects `query.measures` reads only each measure's
1545
+ * resolved FIELD, never its filter. Measured on the unfixed tree, one fixture,
1546
+ * both doors:
1547
+ *
1548
+ * ```
1549
+ * BEFORE execute() ACCEPTED -> aggregations: [{field:"*",method:"count",
1550
+ * alias:"west_count",
1551
+ * filter:{"account.region":"West"}}]
1552
+ * -> rows [{stage:"won",total_count:3,west_count:0}]
1553
+ * (the truthful west_count is 2; total_count
1554
+ * is right, so the wrong number arrived in
1555
+ * the same response shape as the right one)
1556
+ * generateSql() ACCEPTED -> COUNT(CASE WHEN account.region = $1 THEN 1 END)
1557
+ * over a FROM with no join in it at all
1558
+ * AFTER both doors REFUSED INVALID_FIELD / 400, engine never reached
1559
+ * ```
1560
+ *
1561
+ * The same maintainer ruling covers it — same hazard, same physical verdict,
1562
+ * one more producer — so it folds in HERE for the #10861 reason and not into
1563
+ * `dataset-compiler.ts`, which still cannot see which driver will serve the
1564
+ * dataset. Only the REQUESTED measures are folded: both doors' aggregation
1565
+ * loops read `measureFilters[m]` for `m of query.measures` and nothing else,
1566
+ * so a filter declared on a measure this query never asks for reaches no
1567
+ * engine, and refusing on it would reject a query for a member that was never
1568
+ * going to be evaluated.
1569
+ *
1483
1570
  * Structure is discarded on purpose — a member is cross-object or it is not,
1484
1571
  * and which branch of a disjunction it sits in cannot make
1485
1572
  * `engine.aggregate` able to join it. PROVENANCE is not discarded, because it
@@ -1489,9 +1576,12 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1489
1576
  * `planCrossObject`. The value slot carries that and nothing else; it never
1490
1577
  * reaches a driver.
1491
1578
  *
1492
- * Dataset leaves are inserted FIRST so a member named by BOTH producers keeps
1493
- * the caller's provenance (last write wins on a duplicate key): if it is in
1494
- * the request too, the request is the actionable place to fix it.
1579
+ * Insertion order is measure-filter, then dataset-filter, then `where`, and
1580
+ * last write wins on a duplicate key. Two things follow, in that order of
1581
+ * importance. A member named by the request too keeps the CALLER's provenance,
1582
+ * because if it is in the request that is the actionable place to fix it. And
1583
+ * every shape that was refused before #11461 keeps the exact message it had:
1584
+ * the new origin can only ever win a key no older producer names.
1495
1585
  *
1496
1586
  * Time-dimension WINDOWS are deliberately absent (they live in
1497
1587
  * `dateRangeBounds`, not in `where`). They need no arm here: a cross-object
@@ -1513,18 +1603,20 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1513
1603
  * THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
1514
1604
  * (needs a real join to evaluate), a cross-object leaf in the DATASET's own
1515
1605
  * definition-level `filter` (#10861 — same join it does not have, arriving
1516
- * from the producer PR #10758 added), a MULTI-HOP dimension (`a.b.c`), or a
1517
- * non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values
1518
- * cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
1606
+ * from the producer PR #10758 added), a cross-object leaf in ONE MEASURE's own
1607
+ * `filter` (#11461 the same join again, arriving from the producer #10413
1608
+ * phase 2 added), a MULTI-HOP dimension (`a.b.c`), or a non-recombinable
1609
+ * measure (`avg`/`count_distinct`, whose sub-bucket values cannot be merged).
1610
+ * A loud error beats the silent mis-bucket #3654 kills.
1519
1611
  * `generateSql()` calls this too, so the preview accepts/rejects the same set
1520
1612
  * — and since #10759 both callers derive `filter` from the one
1521
1613
  * {@link filterMemberView}, so that sentence is enforced by construction
1522
1614
  * instead of restated at two call sites.
1523
1615
  *
1524
- * [#5716] All five refusals below are `invalidMemberError` — `INVALID_FIELD` /
1616
+ * [#5716] All six refusals below are `invalidMemberError` — `INVALID_FIELD` /
1525
1617
  * 400, naming the member — and the four that predate #10861 keep their
1526
1618
  * MESSAGES unchanged (they are good diagnostics, and #5923's tests read
1527
- * them). Each is decided by two facts and nothing else: a member that will
1619
+ * them); so does #10861's own, which #11461 left untouched beside it. Each is decided by two facts and nothing else: a member that will
1528
1620
  * reach the engine's predicate, and whether that member resolves across a
1529
1621
  * join. Neither is an internal invariant — a cube where the member exists and
1530
1622
  * a driver that could serve it are both perfectly ordinary, which is exactly
@@ -1533,14 +1625,15 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1533
1625
  * because the fix is always to change or drop ONE named member, and because
1534
1626
  * four of them fire on `/analytics/query` where no dataset exists.
1535
1627
  *
1536
- * [#10861] The fifth is the exception that proves the rule and is written to
1537
- * it: it can only fire where a dataset DOES exist, and it is the one refusal
1538
- * here whose member no request key named — so it carries `cube` and no
1539
- * `param`, and says in its own words which document to go and edit. It stays
1628
+ * [#10861, #11461] The fifth and sixth are the exceptions that prove the rule
1629
+ * and are written to it: they can only fire where a dataset DOES exist, and
1630
+ * they are the two refusals here whose member no request key named — so each
1631
+ * carries `cube` and no `param`, and says in its own words which document to
1632
+ * go and edit, the sixth naming the MEASURE inside it as well. Both stay
1540
1633
  * `INVALID_FIELD` rather than becoming `DATASET_INVALID` because the verdict
1541
- * is the same physical one as its neighbour — this engine cannot join this
1634
+ * is the same physical one as their neighbours — this engine cannot join this
1542
1635
  * member — and splitting the code by PROVENANCE would make a caller branch on
1543
- * two wire shapes for one capability limit.
1636
+ * three wire shapes for one capability limit.
1544
1637
  *
1545
1638
  * Detection is on RESOLVED field names, so a dotted dimension the cube
1546
1639
  * flattens to a real column is treated as base, not cross-object.
@@ -1558,6 +1651,23 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1558
1651
  * are simply absent from the map (⇒ RESTRICTED bucket downstream).
1559
1652
  */
1560
1653
  private resolveFkAttr;
1654
+ /**
1655
+ * A measure's aggregate, restricted to the rows its own `filter` admits
1656
+ * (#10413 phase 2) — the same six functions `generateSql`'s unconditional
1657
+ * branch renders, wrapped in a `CASE WHEN`.
1658
+ *
1659
+ * Spelled `CASE WHEN` rather than SQL-standard `FILTER (WHERE …)`, mirroring
1660
+ * `NativeSQLStrategy.CONDITIONAL_AGGREGATE_SQL`: this string is DOCUMENTATION
1661
+ * of an execution that really goes through `engine.aggregate`'s per-driver
1662
+ * `aggregations[].filter` lowering (#10576), not a statement this class runs
1663
+ * itself, so there is no reason to pick a dialect-restricted spelling over
1664
+ * the portable one the SQL-executing sibling already settled on.
1665
+ *
1666
+ * `count` over `*` counts a constant (`COUNT(CASE WHEN p THEN 1 END)`, since
1667
+ * `COUNT(CASE WHEN p THEN * END)` is not valid SQL); over a real column it
1668
+ * counts that column's non-null values among the admitted rows.
1669
+ */
1670
+ private conditionalAggregateSql;
1561
1671
  /**
1562
1672
  * Render one normalized filter as a display SQL predicate for `generateSql`.
1563
1673
  *