@objectstack/service-analytics 17.1.0 → 17.2.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
@@ -1308,6 +1308,11 @@ declare class NativeSQLStrategy implements AnalyticsStrategy {
1308
1308
  */
1309
1309
  private lookupMember;
1310
1310
  private resolveDimensionSql;
1311
+ /**
1312
+ * @param predicate - The measure's own scoped filter, already compiled to a
1313
+ * SQL boolean (`null` = the measure declares none, or declares one that
1314
+ * constrains nothing — `compileFilterNode`'s TRUE). #10298.
1315
+ */
1311
1316
  private resolveMeasureSql;
1312
1317
  private resolveFieldSql;
1313
1318
  /**
@@ -1443,6 +1448,59 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1443
1448
  private withReadScope;
1444
1449
  /** Is `field` a resolved cross-object (relationship-traversal) reference? */
1445
1450
  private isCrossObjectField;
1451
+ /**
1452
+ * The member view {@link planCrossObject} judges a filter by: EVERY member
1453
+ * that will end up in the engine's predicate, structure discarded, keyed by
1454
+ * RESOLVED field name (#10759), valued by WHERE THE MEMBER CAME FROM
1455
+ * (#10861).
1456
+ *
1457
+ * Both call sites — `execute()` and `generateSql()` — are handed this and
1458
+ * nothing else, which is what makes the invariant `planCrossObject` states
1459
+ * for itself ("the preview accepts/rejects the same set") structural rather
1460
+ * than a coincidence maintained by hand. They used to build the view
1461
+ * separately: the echo flattened the tree, `execute()` passed the ENGINE
1462
+ * FILTER, and a filter record answers a different question — it is a
1463
+ * predicate to evaluate, not an inventory of members. An `$or`, a `$not` or
1464
+ * an unmergeable nested `$and` travels in it as one opaque `$and` entry, so
1465
+ * the members inside were unreadable from the outside and the envelope check
1466
+ * could not reject what it could not see.
1467
+ *
1468
+ * ## Two producers, one inventory (#10861)
1469
+ *
1470
+ * The caller's `where` is not the only thing that reaches `engine.aggregate`
1471
+ * as a predicate. Since PR #10758 the compiled dataset's own definition-level
1472
+ * `filter` is lowered onto `execute()`'s `conjuncts` and rendered by
1473
+ * `generateSql()`, so a dataset declaring `filter: { 'account.region': 'West' }`
1474
+ * sent `{"$and":[{"account.region":"West"}]}` to an engine that cannot join —
1475
+ * measured on both doors, which AGREED in accepting it, so #10759's
1476
+ * preview/execution symmetry had nothing to restore. Refusing it is a
1477
+ * widening of the refusal set, ruled by the maintainer on 2026-08-22 (Option
1478
+ * A, query-time refusal): fold the scope's leaves in HERE, where driver
1479
+ * capability is known, rather than in `dataset-compiler.ts`, which cannot see
1480
+ * which driver will serve the dataset and would refuse a dataset that is
1481
+ * perfectly legal on a native-SQL deployment.
1482
+ *
1483
+ * Structure is discarded on purpose — a member is cross-object or it is not,
1484
+ * and which branch of a disjunction it sits in cannot make
1485
+ * `engine.aggregate` able to join it. PROVENANCE is not discarded, because it
1486
+ * decides what the refusal can tell the caller to go fix: `AnalyticsRequestKey`
1487
+ * is the analytics REQUEST vocabulary and a dataset's `filter` is not in it,
1488
+ * so a scope-borne member must not be reported as `param: 'where'` — see
1489
+ * `planCrossObject`. The value slot carries that and nothing else; it never
1490
+ * reaches a driver.
1491
+ *
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.
1495
+ *
1496
+ * Time-dimension WINDOWS are deliberately absent (they live in
1497
+ * `dateRangeBounds`, not in `where`). They need no arm here: a cross-object
1498
+ * time dimension is refused by `planCrossObject`'s own first loop, over
1499
+ * `query.timeDimensions`, and refused as the time dimension the author wrote
1500
+ * rather than as the lowered predicate it becomes — which is the better
1501
+ * diagnostic and the reason that loop runs first.
1502
+ */
1503
+ private filterMemberView;
1446
1504
  /**
1447
1505
  * Plan how to serve cross-object references on this join-less path (#3654).
1448
1506
  *
@@ -1453,21 +1511,36 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1453
1511
  * query (direct path), a plan for an in-envelope cross-object query.
1454
1512
  *
1455
1513
  * THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
1456
- * (needs a real join to evaluate), a MULTI-HOP dimension (`a.b.c`), or a
1514
+ * (needs a real join to evaluate), a cross-object leaf in the DATASET's own
1515
+ * 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
1457
1517
  * non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values
1458
1518
  * cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
1459
- * `generateSql()` calls this too, so the preview accepts/rejects the same set.
1460
- *
1461
- * [#5716] All four refusals below are `invalidMemberError` `INVALID_FIELD` /
1462
- * 400, naming the member and the MESSAGES are unchanged (they are good
1463
- * diagnostics, and #5923's tests read them). Each is decided by two caller-side
1464
- * facts and nothing else: a member the query named, and whether that member
1465
- * resolves across a join. Neither is an internal invariant a cube where the
1466
- * member exists and a driver that could serve it are both perfectly ordinary,
1467
- * which is exactly what the "run this on a native-SQL driver" half of each
1468
- * message says. They are member-level rather than dataset-level (hence not
1469
- * `datasetInvalidError`) because the fix is always to change or drop ONE named
1470
- * member, and because they fire on `/analytics/query` where no dataset exists.
1519
+ * `generateSql()` calls this too, so the preview accepts/rejects the same set
1520
+ * — and since #10759 both callers derive `filter` from the one
1521
+ * {@link filterMemberView}, so that sentence is enforced by construction
1522
+ * instead of restated at two call sites.
1523
+ *
1524
+ * [#5716] All five refusals below are `invalidMemberError` `INVALID_FIELD` /
1525
+ * 400, naming the member and the four that predate #10861 keep their
1526
+ * 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
1528
+ * reach the engine's predicate, and whether that member resolves across a
1529
+ * join. Neither is an internal invariant a cube where the member exists and
1530
+ * a driver that could serve it are both perfectly ordinary, which is exactly
1531
+ * what the "run this on a native-SQL driver" half of every message says. They
1532
+ * are member-level rather than dataset-level (hence not `datasetInvalidError`)
1533
+ * because the fix is always to change or drop ONE named member, and because
1534
+ * four of them fire on `/analytics/query` where no dataset exists.
1535
+ *
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
1540
+ * `INVALID_FIELD` rather than becoming `DATASET_INVALID` because the verdict
1541
+ * is the same physical one as its neighbour — this engine cannot join this
1542
+ * member — and splitting the code by PROVENANCE would make a caller branch on
1543
+ * two wire shapes for one capability limit.
1471
1544
  *
1472
1545
  * Detection is on RESOLVED field names, so a dotted dimension the cube
1473
1546
  * flattens to a real column is treated as base, not cross-object.
package/dist/index.d.ts CHANGED
@@ -1308,6 +1308,11 @@ declare class NativeSQLStrategy implements AnalyticsStrategy {
1308
1308
  */
1309
1309
  private lookupMember;
1310
1310
  private resolveDimensionSql;
1311
+ /**
1312
+ * @param predicate - The measure's own scoped filter, already compiled to a
1313
+ * SQL boolean (`null` = the measure declares none, or declares one that
1314
+ * constrains nothing — `compileFilterNode`'s TRUE). #10298.
1315
+ */
1311
1316
  private resolveMeasureSql;
1312
1317
  private resolveFieldSql;
1313
1318
  /**
@@ -1443,6 +1448,59 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1443
1448
  private withReadScope;
1444
1449
  /** Is `field` a resolved cross-object (relationship-traversal) reference? */
1445
1450
  private isCrossObjectField;
1451
+ /**
1452
+ * The member view {@link planCrossObject} judges a filter by: EVERY member
1453
+ * that will end up in the engine's predicate, structure discarded, keyed by
1454
+ * RESOLVED field name (#10759), valued by WHERE THE MEMBER CAME FROM
1455
+ * (#10861).
1456
+ *
1457
+ * Both call sites — `execute()` and `generateSql()` — are handed this and
1458
+ * nothing else, which is what makes the invariant `planCrossObject` states
1459
+ * for itself ("the preview accepts/rejects the same set") structural rather
1460
+ * than a coincidence maintained by hand. They used to build the view
1461
+ * separately: the echo flattened the tree, `execute()` passed the ENGINE
1462
+ * FILTER, and a filter record answers a different question — it is a
1463
+ * predicate to evaluate, not an inventory of members. An `$or`, a `$not` or
1464
+ * an unmergeable nested `$and` travels in it as one opaque `$and` entry, so
1465
+ * the members inside were unreadable from the outside and the envelope check
1466
+ * could not reject what it could not see.
1467
+ *
1468
+ * ## Two producers, one inventory (#10861)
1469
+ *
1470
+ * The caller's `where` is not the only thing that reaches `engine.aggregate`
1471
+ * as a predicate. Since PR #10758 the compiled dataset's own definition-level
1472
+ * `filter` is lowered onto `execute()`'s `conjuncts` and rendered by
1473
+ * `generateSql()`, so a dataset declaring `filter: { 'account.region': 'West' }`
1474
+ * sent `{"$and":[{"account.region":"West"}]}` to an engine that cannot join —
1475
+ * measured on both doors, which AGREED in accepting it, so #10759's
1476
+ * preview/execution symmetry had nothing to restore. Refusing it is a
1477
+ * widening of the refusal set, ruled by the maintainer on 2026-08-22 (Option
1478
+ * A, query-time refusal): fold the scope's leaves in HERE, where driver
1479
+ * capability is known, rather than in `dataset-compiler.ts`, which cannot see
1480
+ * which driver will serve the dataset and would refuse a dataset that is
1481
+ * perfectly legal on a native-SQL deployment.
1482
+ *
1483
+ * Structure is discarded on purpose — a member is cross-object or it is not,
1484
+ * and which branch of a disjunction it sits in cannot make
1485
+ * `engine.aggregate` able to join it. PROVENANCE is not discarded, because it
1486
+ * decides what the refusal can tell the caller to go fix: `AnalyticsRequestKey`
1487
+ * is the analytics REQUEST vocabulary and a dataset's `filter` is not in it,
1488
+ * so a scope-borne member must not be reported as `param: 'where'` — see
1489
+ * `planCrossObject`. The value slot carries that and nothing else; it never
1490
+ * reaches a driver.
1491
+ *
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.
1495
+ *
1496
+ * Time-dimension WINDOWS are deliberately absent (they live in
1497
+ * `dateRangeBounds`, not in `where`). They need no arm here: a cross-object
1498
+ * time dimension is refused by `planCrossObject`'s own first loop, over
1499
+ * `query.timeDimensions`, and refused as the time dimension the author wrote
1500
+ * rather than as the lowered predicate it becomes — which is the better
1501
+ * diagnostic and the reason that loop runs first.
1502
+ */
1503
+ private filterMemberView;
1446
1504
  /**
1447
1505
  * Plan how to serve cross-object references on this join-less path (#3654).
1448
1506
  *
@@ -1453,21 +1511,36 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1453
1511
  * query (direct path), a plan for an in-envelope cross-object query.
1454
1512
  *
1455
1513
  * THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
1456
- * (needs a real join to evaluate), a MULTI-HOP dimension (`a.b.c`), or a
1514
+ * (needs a real join to evaluate), a cross-object leaf in the DATASET's own
1515
+ * 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
1457
1517
  * non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values
1458
1518
  * cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
1459
- * `generateSql()` calls this too, so the preview accepts/rejects the same set.
1460
- *
1461
- * [#5716] All four refusals below are `invalidMemberError` `INVALID_FIELD` /
1462
- * 400, naming the member and the MESSAGES are unchanged (they are good
1463
- * diagnostics, and #5923's tests read them). Each is decided by two caller-side
1464
- * facts and nothing else: a member the query named, and whether that member
1465
- * resolves across a join. Neither is an internal invariant a cube where the
1466
- * member exists and a driver that could serve it are both perfectly ordinary,
1467
- * which is exactly what the "run this on a native-SQL driver" half of each
1468
- * message says. They are member-level rather than dataset-level (hence not
1469
- * `datasetInvalidError`) because the fix is always to change or drop ONE named
1470
- * member, and because they fire on `/analytics/query` where no dataset exists.
1519
+ * `generateSql()` calls this too, so the preview accepts/rejects the same set
1520
+ * — and since #10759 both callers derive `filter` from the one
1521
+ * {@link filterMemberView}, so that sentence is enforced by construction
1522
+ * instead of restated at two call sites.
1523
+ *
1524
+ * [#5716] All five refusals below are `invalidMemberError` `INVALID_FIELD` /
1525
+ * 400, naming the member and the four that predate #10861 keep their
1526
+ * 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
1528
+ * reach the engine's predicate, and whether that member resolves across a
1529
+ * join. Neither is an internal invariant a cube where the member exists and
1530
+ * a driver that could serve it are both perfectly ordinary, which is exactly
1531
+ * what the "run this on a native-SQL driver" half of every message says. They
1532
+ * are member-level rather than dataset-level (hence not `datasetInvalidError`)
1533
+ * because the fix is always to change or drop ONE named member, and because
1534
+ * four of them fire on `/analytics/query` where no dataset exists.
1535
+ *
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
1540
+ * `INVALID_FIELD` rather than becoming `DATASET_INVALID` because the verdict
1541
+ * is the same physical one as its neighbour — this engine cannot join this
1542
+ * member — and splitting the code by PROVENANCE would make a caller branch on
1543
+ * two wire shapes for one capability limit.
1471
1544
  *
1472
1545
  * Detection is on RESOLVED field names, so a dotted dimension the cube
1473
1546
  * flattens to a real column is treated as base, not cross-object.
package/dist/index.js CHANGED
@@ -1026,14 +1026,31 @@ function invalidMemberError(message, meta) {
1026
1026
  // src/strategies/native-sql-strategy.ts
1027
1027
  import { nextUtcCalendarDay } from "@objectstack/core";
1028
1028
  var AGGREGATE_SQL = {
1029
- "count": () => "COUNT(*)",
1029
+ // [#10298] `count` takes its COLUMN when the measure declares one. The
1030
+ // wrapper used to discard `col` and always emit `COUNT(*)`, so a measure
1031
+ // written `{ aggregate: 'count', field: 'resolved_by_article' }` counted
1032
+ // ROWS instead of non-null values — and a deflection rate built as
1033
+ // `kb_resolved_count / closed_count` read 100% where the truth was 12.5%,
1034
+ // with the numerator and denominator printed beside it as 8 and 8. `*` is
1035
+ // still `COUNT(*)`: the compiler writes `sql: m.field ?? '*'`, so the star
1036
+ // IS the "no field declared" spelling and must keep counting rows.
1037
+ "count": (col) => col === "*" ? "COUNT(*)" : `COUNT(${col})`,
1030
1038
  "sum": (col) => `SUM(${col})`,
1031
1039
  "avg": (col) => `AVG(${col})`,
1032
1040
  "min": (col) => `MIN(${col})`,
1033
1041
  "max": (col) => `MAX(${col})`,
1034
1042
  "count_distinct": (col) => `COUNT(DISTINCT ${col})`
1035
1043
  };
1044
+ var CONDITIONAL_AGGREGATE_SQL = {
1045
+ "count": (col, pred) => `COUNT(CASE WHEN ${pred} THEN ${col === "*" ? "1" : col} END)`,
1046
+ "sum": (col, pred) => `SUM(CASE WHEN ${pred} THEN ${col} END)`,
1047
+ "avg": (col, pred) => `AVG(CASE WHEN ${pred} THEN ${col} END)`,
1048
+ "min": (col, pred) => `MIN(CASE WHEN ${pred} THEN ${col} END)`,
1049
+ "max": (col, pred) => `MAX(CASE WHEN ${pred} THEN ${col} END)`,
1050
+ "count_distinct": (col, pred) => `COUNT(DISTINCT CASE WHEN ${pred} THEN ${col} END)`
1051
+ };
1036
1052
  var SUPPORTED_AGGREGATE_SQL_KEYS = Object.keys(AGGREGATE_SQL);
1053
+ var CONDITIONAL_AGGREGATE_SQL_KEYS = Object.keys(CONDITIONAL_AGGREGATE_SQL);
1037
1054
  var EXPRESSION_METRIC_TYPES = /* @__PURE__ */ new Set(["number", "string", "boolean"]);
1038
1055
  var IDENTIFIER_PATH = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
1039
1056
  var NativeSQLStrategy = class {
@@ -1199,9 +1216,19 @@ var NativeSQLStrategy = class {
1199
1216
  groupByClauses.push(colExpr);
1200
1217
  }
1201
1218
  }
1219
+ const datasetScope = ctx.getDatasetScope?.(query.cube);
1202
1220
  if (query.measures && query.measures.length > 0) {
1203
1221
  for (const measure of query.measures) {
1204
- const aggExpr = this.resolveMeasureSql(cube, measure, tableName, joins);
1222
+ const measureFilter = datasetScope?.measureFilters?.[measure];
1223
+ const predicate = measureFilter ? this.compileFilterNode(
1224
+ normalizeAnalyticsFilterTree({ where: measureFilter }),
1225
+ cube,
1226
+ tableName,
1227
+ joins,
1228
+ params,
1229
+ ctx
1230
+ ) : null;
1231
+ const aggExpr = this.resolveMeasureSql(cube, measure, tableName, joins, predicate);
1205
1232
  selectClauses.push(`${aggExpr} AS "${measure}"`);
1206
1233
  }
1207
1234
  }
@@ -1215,6 +1242,17 @@ var NativeSQLStrategy = class {
1215
1242
  ctx
1216
1243
  );
1217
1244
  if (filterSql) whereClauses.push(filterSql);
1245
+ if (datasetScope?.filter) {
1246
+ const scopeSql = this.compileFilterNode(
1247
+ normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
1248
+ cube,
1249
+ tableName,
1250
+ joins,
1251
+ params,
1252
+ ctx
1253
+ );
1254
+ if (scopeSql) whereClauses.push(scopeSql);
1255
+ }
1218
1256
  if (query.timeDimensions && query.timeDimensions.length > 0) {
1219
1257
  for (const td of query.timeDimensions) {
1220
1258
  const colExpr = this.resolveFieldSql(cube, td.dimension, tableName, joins);
@@ -1388,7 +1426,12 @@ var NativeSQLStrategy = class {
1388
1426
  const raw = dim ? dim.sql : member.includes(".") ? member.split(".")[1] : member;
1389
1427
  return this.qualifyAndRegisterJoin(raw, parentTable, joins, cube);
1390
1428
  }
1391
- resolveMeasureSql(cube, member, parentTable, joins) {
1429
+ /**
1430
+ * @param predicate - The measure's own scoped filter, already compiled to a
1431
+ * SQL boolean (`null` = the measure declares none, or declares one that
1432
+ * constrains nothing — `compileFilterNode`'s TRUE). #10298.
1433
+ */
1434
+ resolveMeasureSql(cube, member, parentTable, joins, predicate = null) {
1392
1435
  const measure = this.lookupMember(cube, member, "measure");
1393
1436
  if (!measure) {
1394
1437
  const declared = Object.keys(cube.measures ?? {});
@@ -1398,6 +1441,13 @@ var NativeSQLStrategy = class {
1398
1441
  );
1399
1442
  }
1400
1443
  const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
1444
+ if (predicate !== null) {
1445
+ const wrapConditional = CONDITIONAL_AGGREGATE_SQL[measure.type];
1446
+ if (wrapConditional) return wrapConditional(col, predicate);
1447
+ throw new Error(
1448
+ `[native-sql-strategy] measure "${member}" on cube "${cube.name}" carries a scoped filter, but its type "${measure.type}" has no conditional form (conditional: ${CONDITIONAL_AGGREGATE_SQL_KEYS.join(", ")}).`
1449
+ );
1450
+ }
1401
1451
  const wrap = AGGREGATE_SQL[measure.type];
1402
1452
  if (wrap) return wrap(col);
1403
1453
  if (EXPRESSION_METRIC_TYPES.has(measure.type)) return col;
@@ -1744,10 +1794,18 @@ var ObjectQLStrategy = class {
1744
1794
  const extra = this.mergeFilterOperand(filter, field, bounds);
1745
1795
  if (extra) conjuncts.push(extra);
1746
1796
  }
1797
+ const datasetScope = ctx.getDatasetScope?.(query.cube);
1798
+ if (datasetScope?.filter) {
1799
+ const scopeCondition = this.filterNodeToCondition(
1800
+ normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
1801
+ cube
1802
+ );
1803
+ if (scopeCondition) conjuncts.push(scopeCondition);
1804
+ }
1747
1805
  if (conjuncts.length > 0) {
1748
1806
  filter.$and = [...Array.isArray(filter.$and) ? filter.$and : [], ...conjuncts];
1749
1807
  }
1750
- const plan = this.planCrossObject(cube, query, filter);
1808
+ const plan = this.planCrossObject(cube, query, this.filterMemberView(cube, query, ctx));
1751
1809
  if (plan) {
1752
1810
  return this.executeCrossObject(cube, query, aggregations, filter, plan, ctx);
1753
1811
  }
@@ -1824,9 +1882,7 @@ var ObjectQLStrategy = class {
1824
1882
  if (td.granularity) granByDim.set(td.dimension, td.granularity);
1825
1883
  }
1826
1884
  const tableName = this.extractObjectName(cube);
1827
- const plan = this.planCrossObject(cube, query, Object.fromEntries(
1828
- collectFilterLeaves(normalizeAnalyticsFilterTree(query)).map((f) => [this.resolveFieldName(cube, f.member, "any"), true])
1829
- ));
1885
+ const plan = this.planCrossObject(cube, query, this.filterMemberView(cube, query, ctx));
1830
1886
  const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd]));
1831
1887
  const joinClauses = [];
1832
1888
  const dimExpr = (dim) => {
@@ -1868,6 +1924,15 @@ var ObjectQLStrategy = class {
1868
1924
  params
1869
1925
  );
1870
1926
  if (filterClause) whereParts.push(filterClause);
1927
+ const echoedDatasetFilter = ctx.getDatasetScope?.(query.cube)?.filter;
1928
+ if (echoedDatasetFilter) {
1929
+ const scopeSql = this.renderFilterNodeSql(
1930
+ normalizeAnalyticsFilterTree({ where: echoedDatasetFilter }),
1931
+ cube,
1932
+ params
1933
+ );
1934
+ if (scopeSql) whereParts.push(scopeSql);
1935
+ }
1871
1936
  for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
1872
1937
  const nextDay = nextUtcCalendarDay2(bounds.$lte);
1873
1938
  params.push(bounds.$gte, nextDay ?? bounds.$lte);
@@ -1935,6 +2000,68 @@ var ObjectQLStrategy = class {
1935
2000
  const joinedObject = cube.joins?.[alias]?.name ?? alias;
1936
2001
  return joinedObject !== baseObject;
1937
2002
  }
2003
+ /**
2004
+ * The member view {@link planCrossObject} judges a filter by: EVERY member
2005
+ * that will end up in the engine's predicate, structure discarded, keyed by
2006
+ * RESOLVED field name (#10759), valued by WHERE THE MEMBER CAME FROM
2007
+ * (#10861).
2008
+ *
2009
+ * Both call sites — `execute()` and `generateSql()` — are handed this and
2010
+ * nothing else, which is what makes the invariant `planCrossObject` states
2011
+ * for itself ("the preview accepts/rejects the same set") structural rather
2012
+ * than a coincidence maintained by hand. They used to build the view
2013
+ * separately: the echo flattened the tree, `execute()` passed the ENGINE
2014
+ * FILTER, and a filter record answers a different question — it is a
2015
+ * predicate to evaluate, not an inventory of members. An `$or`, a `$not` or
2016
+ * an unmergeable nested `$and` travels in it as one opaque `$and` entry, so
2017
+ * the members inside were unreadable from the outside and the envelope check
2018
+ * could not reject what it could not see.
2019
+ *
2020
+ * ## Two producers, one inventory (#10861)
2021
+ *
2022
+ * The caller's `where` is not the only thing that reaches `engine.aggregate`
2023
+ * as a predicate. Since PR #10758 the compiled dataset's own definition-level
2024
+ * `filter` is lowered onto `execute()`'s `conjuncts` and rendered by
2025
+ * `generateSql()`, so a dataset declaring `filter: { 'account.region': 'West' }`
2026
+ * sent `{"$and":[{"account.region":"West"}]}` to an engine that cannot join —
2027
+ * measured on both doors, which AGREED in accepting it, so #10759's
2028
+ * preview/execution symmetry had nothing to restore. Refusing it is a
2029
+ * widening of the refusal set, ruled by the maintainer on 2026-08-22 (Option
2030
+ * A, query-time refusal): fold the scope's leaves in HERE, where driver
2031
+ * capability is known, rather than in `dataset-compiler.ts`, which cannot see
2032
+ * which driver will serve the dataset and would refuse a dataset that is
2033
+ * perfectly legal on a native-SQL deployment.
2034
+ *
2035
+ * Structure is discarded on purpose — a member is cross-object or it is not,
2036
+ * and which branch of a disjunction it sits in cannot make
2037
+ * `engine.aggregate` able to join it. PROVENANCE is not discarded, because it
2038
+ * decides what the refusal can tell the caller to go fix: `AnalyticsRequestKey`
2039
+ * is the analytics REQUEST vocabulary and a dataset's `filter` is not in it,
2040
+ * so a scope-borne member must not be reported as `param: 'where'` — see
2041
+ * `planCrossObject`. The value slot carries that and nothing else; it never
2042
+ * reaches a driver.
2043
+ *
2044
+ * Dataset leaves are inserted FIRST so a member named by BOTH producers keeps
2045
+ * the caller's provenance (last write wins on a duplicate key): if it is in
2046
+ * the request too, the request is the actionable place to fix it.
2047
+ *
2048
+ * Time-dimension WINDOWS are deliberately absent (they live in
2049
+ * `dateRangeBounds`, not in `where`). They need no arm here: a cross-object
2050
+ * time dimension is refused by `planCrossObject`'s own first loop, over
2051
+ * `query.timeDimensions`, and refused as the time dimension the author wrote
2052
+ * rather than as the lowered predicate it becomes — which is the better
2053
+ * diagnostic and the reason that loop runs first.
2054
+ */
2055
+ filterMemberView(cube, query, ctx) {
2056
+ const datasetFilter = ctx.getDatasetScope?.(query.cube)?.filter;
2057
+ const leaves = (node, origin) => collectFilterLeaves(node).map(
2058
+ (f) => [this.resolveFieldName(cube, f.member, "any"), origin]
2059
+ );
2060
+ return Object.fromEntries([
2061
+ ...datasetFilter ? leaves(normalizeAnalyticsFilterTree({ where: datasetFilter }), "dataset-filter") : [],
2062
+ ...leaves(normalizeAnalyticsFilterTree(query), "where")
2063
+ ]);
2064
+ }
1938
2065
  /**
1939
2066
  * Plan how to serve cross-object references on this join-less path (#3654).
1940
2067
  *
@@ -1945,21 +2072,36 @@ var ObjectQLStrategy = class {
1945
2072
  * query (direct path), a plan for an in-envelope cross-object query.
1946
2073
  *
1947
2074
  * THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
1948
- * (needs a real join to evaluate), a MULTI-HOP dimension (`a.b.c`), or a
2075
+ * (needs a real join to evaluate), a cross-object leaf in the DATASET's own
2076
+ * definition-level `filter` (#10861 — same join it does not have, arriving
2077
+ * from the producer PR #10758 added), a MULTI-HOP dimension (`a.b.c`), or a
1949
2078
  * non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values
1950
2079
  * cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
1951
- * `generateSql()` calls this too, so the preview accepts/rejects the same set.
2080
+ * `generateSql()` calls this too, so the preview accepts/rejects the same set
2081
+ * — and since #10759 both callers derive `filter` from the one
2082
+ * {@link filterMemberView}, so that sentence is enforced by construction
2083
+ * instead of restated at two call sites.
1952
2084
  *
1953
- * [#5716] All four refusals below are `invalidMemberError` — `INVALID_FIELD` /
1954
- * 400, naming the member — and the MESSAGES are unchanged (they are good
1955
- * diagnostics, and #5923's tests read them). Each is decided by two caller-side
1956
- * facts and nothing else: a member the query named, and whether that member
1957
- * resolves across a join. Neither is an internal invariant a cube where the
1958
- * member exists and a driver that could serve it are both perfectly ordinary,
1959
- * which is exactly what the "run this on a native-SQL driver" half of each
1960
- * message says. They are member-level rather than dataset-level (hence not
1961
- * `datasetInvalidError`) because the fix is always to change or drop ONE named
1962
- * member, and because they fire on `/analytics/query` where no dataset exists.
2085
+ * [#5716] All five refusals below are `invalidMemberError` — `INVALID_FIELD` /
2086
+ * 400, naming the member — and the four that predate #10861 keep their
2087
+ * MESSAGES unchanged (they are good diagnostics, and #5923's tests read
2088
+ * them). Each is decided by two facts and nothing else: a member that will
2089
+ * reach the engine's predicate, and whether that member resolves across a
2090
+ * join. Neither is an internal invariant a cube where the member exists and
2091
+ * a driver that could serve it are both perfectly ordinary, which is exactly
2092
+ * what the "run this on a native-SQL driver" half of every message says. They
2093
+ * are member-level rather than dataset-level (hence not `datasetInvalidError`)
2094
+ * because the fix is always to change or drop ONE named member, and because
2095
+ * four of them fire on `/analytics/query` where no dataset exists.
2096
+ *
2097
+ * [#10861] The fifth is the exception that proves the rule and is written to
2098
+ * it: it can only fire where a dataset DOES exist, and it is the one refusal
2099
+ * here whose member no request key named — so it carries `cube` and no
2100
+ * `param`, and says in its own words which document to go and edit. It stays
2101
+ * `INVALID_FIELD` rather than becoming `DATASET_INVALID` because the verdict
2102
+ * is the same physical one as its neighbour — this engine cannot join this
2103
+ * member — and splitting the code by PROVENANCE would make a caller branch on
2104
+ * two wire shapes for one capability limit.
1963
2105
  *
1964
2106
  * Detection is on RESOLVED field names, so a dotted dimension the cube
1965
2107
  * flattens to a real column is treated as base, not cross-object.
@@ -1981,7 +2123,7 @@ var ObjectQLStrategy = class {
1981
2123
  member: m,
1982
2124
  field: this.resolveMeasureAggregation(cube, m).field
1983
2125
  })),
1984
- ...Object.keys(filter).map((f) => ({ where: "filter", member: f, field: f }))
2126
+ ...Object.entries(filter).filter(([, origin]) => origin === "where").map(([f]) => ({ where: "filter", member: f, field: f }))
1985
2127
  ].filter((r) => this.isCrossObjectField(cube, r.field, baseObject));
1986
2128
  if (nonDim.length > 0) {
1987
2129
  throw invalidMemberError(
@@ -1995,6 +2137,13 @@ var ObjectQLStrategy = class {
1995
2137
  }
1996
2138
  );
1997
2139
  }
2140
+ const scopeCross = Object.entries(filter).filter(([field, origin]) => origin === "dataset-filter" && this.isCrossObjectField(cube, field, baseObject)).map(([field]) => field);
2141
+ if (scopeCross.length > 0) {
2142
+ throw invalidMemberError(
2143
+ `[Analytics] ObjectQLStrategy cannot evaluate the cross-object filter ("${scopeCross[0]}") that dataset "${cube.name}" declares at its definition level \u2014 the engine cannot join in an aggregate, so this predicate matches nothing and the answer would be neither the scoped number nor the unscoped one. Nothing in the request names it: remove the cross-object leaf from the dataset's own \`filter\`, or serve this dataset on a native-SQL driver, where the same definition is valid.`,
2144
+ { member: scopeCross[0], cube: cube.name }
2145
+ );
2146
+ }
1998
2147
  const crossDims = [];
1999
2148
  for (const dim of query.dimensions ?? []) {
2000
2149
  const field = this.resolveFieldName(cube, dim, "dimension");
@@ -3749,6 +3898,16 @@ var AnalyticsService = class {
3749
3898
  // Prefer a compiled dataset's declared relationships (D-C join allowlist);
3750
3899
  // fall back to any explicitly-configured provider for legacy cubes.
3751
3900
  getAllowedRelationships: (cubeName) => this.datasetRegistry.get(cubeName)?.allowedRelationships ?? config.getAllowedRelationships?.(cubeName),
3901
+ // [#10298] The compiled dataset's definition-level filter and its
3902
+ // per-measure filters — the half of the declaration the Cube model has
3903
+ // no room for. Same shape and same registry as `getAllowedRelationships`
3904
+ // directly above: answered for a cube that IS a compiled dataset,
3905
+ // `undefined` for every other cube.
3906
+ getDatasetScope: (cubeName) => {
3907
+ const compiled = this.datasetRegistry.get(cubeName);
3908
+ if (!compiled) return void 0;
3909
+ return { filter: compiled.filter, measureFilters: compiled.measureFilters };
3910
+ },
3752
3911
  coerceTemporalFilterValue: config.coerceTemporalFilterValue,
3753
3912
  coerceTemporalFilterColumn: config.coerceTemporalFilterColumn,
3754
3913
  isExternalObject: config.isExternalObject