@objectstack/service-analytics 17.1.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.js CHANGED
@@ -1,7 +1,14 @@
1
1
  // src/analytics-service.ts
2
2
  import { percentScaleOf } from "@objectstack/spec/data";
3
3
  import { resolveI18nLabel as resolveI18nLabel2 } from "@objectstack/spec/ui";
4
- import { createLogger, getEnv, bucketKeyToCalendarRange as bucketKeyToCalendarRange2, zonedDateStartToUtcMs } from "@objectstack/core";
4
+ import {
5
+ createLogger,
6
+ getEnv,
7
+ bucketKeyToCalendarRange as bucketKeyToCalendarRange2,
8
+ zonedDateStartToUtcMs,
9
+ filterTokenContextFrom as filterTokenContextFrom2,
10
+ resolveFilterTokens as resolveFilterTokens2
11
+ } from "@objectstack/core";
5
12
  import { matchMissingColumnOfRelation } from "@objectstack/types";
6
13
 
7
14
  // src/cube-registry.ts
@@ -687,6 +694,53 @@ function compileScopedFilterToSql(filter, alias) {
687
694
  const sql = compileNode(filter, quotedAlias, params);
688
695
  return { sql, params };
689
696
  }
697
+ function emptyMembershipFinding(spec, negated, path) {
698
+ if (Array.isArray(spec)) {
699
+ return spec.length === 0 && negated ? { path: `${path}: []`, kind: "negatedIn" } : null;
700
+ }
701
+ if (spec === null || typeof spec !== "object") return null;
702
+ const rec = spec;
703
+ if (Array.isArray(rec.$nin) && rec.$nin.length === 0) return { path: `${path}.$nin`, kind: "nin" };
704
+ if (Array.isArray(rec.$in) && rec.$in.length === 0 && negated) {
705
+ return { path: `${path}.$in`, kind: "negatedIn" };
706
+ }
707
+ return null;
708
+ }
709
+ function findEmptyMembership(node, negated, path) {
710
+ if (node === null || typeof node !== "object" || Array.isArray(node)) return null;
711
+ const rec = node;
712
+ const bare = emptyMembershipFinding(rec, negated, path.length > 0 ? path : "<root>");
713
+ if (bare) return bare;
714
+ for (const [key, value] of Object.entries(rec)) {
715
+ const here = path.length > 0 ? `${path}.${key}` : key;
716
+ if (key === "$not") {
717
+ const found = findEmptyMembership(value, !negated, here);
718
+ if (found) return found;
719
+ } else if (key === "$and" || key === "$or") {
720
+ if (!Array.isArray(value)) continue;
721
+ for (let i = 0; i < value.length; i++) {
722
+ const found = findEmptyMembership(value[i], negated, `${here}[${i}]`);
723
+ if (found) return found;
724
+ }
725
+ } else if (!key.startsWith("$")) {
726
+ const found = emptyMembershipFinding(value, negated, here);
727
+ if (found) return found;
728
+ }
729
+ }
730
+ return null;
731
+ }
732
+ function assertReadScopeCannotVacate(scope, objectName) {
733
+ const found = findEmptyMembership(scope, false, "");
734
+ if (found === null) return;
735
+ if (found.kind === "nin") {
736
+ throw readScopeCompileError(
737
+ `[read-scope-sql] read scope for "${objectName}" has an empty $nin at ${found.path} \u2014 an empty exclusion excludes nothing, so the engine lowers that clause to constant TRUE and the scope does not bind as written. Refused at every polarity, matching this module's own $nin arm (fail-closed).`
738
+ );
739
+ }
740
+ throw readScopeCompileError(
741
+ `[read-scope-sql] read scope for "${objectName}" has an empty $in under negation at ${found.path} \u2014 an empty membership matches nothing, so its negation matches every row and the read scope admits the whole table (fail-closed).`
742
+ );
743
+ }
690
744
  function compileSub(node, qAlias) {
691
745
  const params = [];
692
746
  const sql = compileNode(node, qAlias, params);
@@ -848,7 +902,7 @@ function compileOperator(col, op, val, field, params) {
848
902
  }
849
903
  case "$nin": {
850
904
  if (!Array.isArray(val)) throw readScopeCompileError(`[read-scope-sql] $nin for "${field}" needs an array (fail-closed).`);
851
- if (val.length === 0) return "1 = 1";
905
+ if (val.length === 0) throw readScopeCompileError(`[read-scope-sql] $nin for "${field}" is empty \u2014 an empty exclusion excludes nothing and would compile the read scope to constant TRUE (fail-closed).`);
852
906
  assertCompilableMembers(op, field, val);
853
907
  return nullSafeNegative(col, `${col} NOT IN (${val.map((v) => bind(params, v)).join(", ")})`);
854
908
  }
@@ -1026,14 +1080,31 @@ function invalidMemberError(message, meta) {
1026
1080
  // src/strategies/native-sql-strategy.ts
1027
1081
  import { nextUtcCalendarDay } from "@objectstack/core";
1028
1082
  var AGGREGATE_SQL = {
1029
- "count": () => "COUNT(*)",
1083
+ // [#10298] `count` takes its COLUMN when the measure declares one. The
1084
+ // wrapper used to discard `col` and always emit `COUNT(*)`, so a measure
1085
+ // written `{ aggregate: 'count', field: 'resolved_by_article' }` counted
1086
+ // ROWS instead of non-null values — and a deflection rate built as
1087
+ // `kb_resolved_count / closed_count` read 100% where the truth was 12.5%,
1088
+ // with the numerator and denominator printed beside it as 8 and 8. `*` is
1089
+ // still `COUNT(*)`: the compiler writes `sql: m.field ?? '*'`, so the star
1090
+ // IS the "no field declared" spelling and must keep counting rows.
1091
+ "count": (col) => col === "*" ? "COUNT(*)" : `COUNT(${col})`,
1030
1092
  "sum": (col) => `SUM(${col})`,
1031
1093
  "avg": (col) => `AVG(${col})`,
1032
1094
  "min": (col) => `MIN(${col})`,
1033
1095
  "max": (col) => `MAX(${col})`,
1034
1096
  "count_distinct": (col) => `COUNT(DISTINCT ${col})`
1035
1097
  };
1098
+ var CONDITIONAL_AGGREGATE_SQL = {
1099
+ "count": (col, pred) => `COUNT(CASE WHEN ${pred} THEN ${col === "*" ? "1" : col} END)`,
1100
+ "sum": (col, pred) => `SUM(CASE WHEN ${pred} THEN ${col} END)`,
1101
+ "avg": (col, pred) => `AVG(CASE WHEN ${pred} THEN ${col} END)`,
1102
+ "min": (col, pred) => `MIN(CASE WHEN ${pred} THEN ${col} END)`,
1103
+ "max": (col, pred) => `MAX(CASE WHEN ${pred} THEN ${col} END)`,
1104
+ "count_distinct": (col, pred) => `COUNT(DISTINCT CASE WHEN ${pred} THEN ${col} END)`
1105
+ };
1036
1106
  var SUPPORTED_AGGREGATE_SQL_KEYS = Object.keys(AGGREGATE_SQL);
1107
+ var CONDITIONAL_AGGREGATE_SQL_KEYS = Object.keys(CONDITIONAL_AGGREGATE_SQL);
1037
1108
  var EXPRESSION_METRIC_TYPES = /* @__PURE__ */ new Set(["number", "string", "boolean"]);
1038
1109
  var IDENTIFIER_PATH = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
1039
1110
  var NativeSQLStrategy = class {
@@ -1199,9 +1270,19 @@ var NativeSQLStrategy = class {
1199
1270
  groupByClauses.push(colExpr);
1200
1271
  }
1201
1272
  }
1273
+ const datasetScope = ctx.getDatasetScope?.(query.cube);
1202
1274
  if (query.measures && query.measures.length > 0) {
1203
1275
  for (const measure of query.measures) {
1204
- const aggExpr = this.resolveMeasureSql(cube, measure, tableName, joins);
1276
+ const measureFilter = datasetScope?.measureFilters?.[measure];
1277
+ const predicate = measureFilter ? this.compileFilterNode(
1278
+ normalizeAnalyticsFilterTree({ where: measureFilter }),
1279
+ cube,
1280
+ tableName,
1281
+ joins,
1282
+ params,
1283
+ ctx
1284
+ ) : null;
1285
+ const aggExpr = this.resolveMeasureSql(cube, measure, tableName, joins, predicate);
1205
1286
  selectClauses.push(`${aggExpr} AS "${measure}"`);
1206
1287
  }
1207
1288
  }
@@ -1215,6 +1296,17 @@ var NativeSQLStrategy = class {
1215
1296
  ctx
1216
1297
  );
1217
1298
  if (filterSql) whereClauses.push(filterSql);
1299
+ if (datasetScope?.filter) {
1300
+ const scopeSql = this.compileFilterNode(
1301
+ normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
1302
+ cube,
1303
+ tableName,
1304
+ joins,
1305
+ params,
1306
+ ctx
1307
+ );
1308
+ if (scopeSql) whereClauses.push(scopeSql);
1309
+ }
1218
1310
  if (query.timeDimensions && query.timeDimensions.length > 0) {
1219
1311
  for (const td of query.timeDimensions) {
1220
1312
  const colExpr = this.resolveFieldSql(cube, td.dimension, tableName, joins);
@@ -1289,6 +1381,7 @@ var NativeSQLStrategy = class {
1289
1381
  const filter = ctx.getReadScope(objectName);
1290
1382
  if (filter === void 0 || filter === null) return;
1291
1383
  const { sql, params: scopeParams } = compileScopedFilterToSql(filter, alias);
1384
+ assertReadScopeCannotVacate(filter, objectName);
1292
1385
  if (!sql) return;
1293
1386
  let i = 0;
1294
1387
  const rendered = sql.replace(/\?/g, () => {
@@ -1388,7 +1481,12 @@ var NativeSQLStrategy = class {
1388
1481
  const raw = dim ? dim.sql : member.includes(".") ? member.split(".")[1] : member;
1389
1482
  return this.qualifyAndRegisterJoin(raw, parentTable, joins, cube);
1390
1483
  }
1391
- resolveMeasureSql(cube, member, parentTable, joins) {
1484
+ /**
1485
+ * @param predicate - The measure's own scoped filter, already compiled to a
1486
+ * SQL boolean (`null` = the measure declares none, or declares one that
1487
+ * constrains nothing — `compileFilterNode`'s TRUE). #10298.
1488
+ */
1489
+ resolveMeasureSql(cube, member, parentTable, joins, predicate = null) {
1392
1490
  const measure = this.lookupMember(cube, member, "measure");
1393
1491
  if (!measure) {
1394
1492
  const declared = Object.keys(cube.measures ?? {});
@@ -1398,6 +1496,13 @@ var NativeSQLStrategy = class {
1398
1496
  );
1399
1497
  }
1400
1498
  const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
1499
+ if (predicate !== null) {
1500
+ const wrapConditional = CONDITIONAL_AGGREGATE_SQL[measure.type];
1501
+ if (wrapConditional) return wrapConditional(col, predicate);
1502
+ throw new Error(
1503
+ `[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(", ")}).`
1504
+ );
1505
+ }
1401
1506
  const wrap = AGGREGATE_SQL[measure.type];
1402
1507
  if (wrap) return wrap(col);
1403
1508
  if (EXPRESSION_METRIC_TYPES.has(measure.type)) return col;
@@ -1730,11 +1835,16 @@ var ObjectQLStrategy = class {
1730
1835
  for (const [dim, gran] of granByDim) {
1731
1836
  groupBy.push({ field: this.resolveFieldName(cube, dim, "dimension"), dateGranularity: gran });
1732
1837
  }
1838
+ const datasetScope = ctx.getDatasetScope?.(query.cube);
1733
1839
  const aggregations = [];
1734
1840
  if (query.measures && query.measures.length > 0) {
1735
1841
  for (const measure of query.measures) {
1736
1842
  const { field, method } = this.resolveMeasureAggregation(cube, measure);
1737
- aggregations.push({ field, method, alias: measure });
1843
+ const measureFilter = datasetScope?.measureFilters?.[measure];
1844
+ const filterCondition = measureFilter ? this.filterNodeToCondition(normalizeAnalyticsFilterTree({ where: measureFilter }), cube) : null;
1845
+ aggregations.push(
1846
+ filterCondition ? { field, method, alias: measure, filter: filterCondition } : { field, method, alias: measure }
1847
+ );
1738
1848
  }
1739
1849
  }
1740
1850
  const filter = {};
@@ -1744,10 +1854,17 @@ var ObjectQLStrategy = class {
1744
1854
  const extra = this.mergeFilterOperand(filter, field, bounds);
1745
1855
  if (extra) conjuncts.push(extra);
1746
1856
  }
1857
+ if (datasetScope?.filter) {
1858
+ const scopeCondition = this.filterNodeToCondition(
1859
+ normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
1860
+ cube
1861
+ );
1862
+ if (scopeCondition) conjuncts.push(scopeCondition);
1863
+ }
1747
1864
  if (conjuncts.length > 0) {
1748
1865
  filter.$and = [...Array.isArray(filter.$and) ? filter.$and : [], ...conjuncts];
1749
1866
  }
1750
- const plan = this.planCrossObject(cube, query, filter);
1867
+ const plan = this.planCrossObject(cube, query, this.filterMemberView(cube, query, ctx));
1751
1868
  if (plan) {
1752
1869
  return this.executeCrossObject(cube, query, aggregations, filter, plan, ctx);
1753
1870
  }
@@ -1824,9 +1941,8 @@ var ObjectQLStrategy = class {
1824
1941
  if (td.granularity) granByDim.set(td.dimension, td.granularity);
1825
1942
  }
1826
1943
  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
- ));
1944
+ const plan = this.planCrossObject(cube, query, this.filterMemberView(cube, query, ctx));
1945
+ const datasetScope = ctx.getDatasetScope?.(query.cube);
1830
1946
  const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd]));
1831
1947
  const joinClauses = [];
1832
1948
  const dimExpr = (dim) => {
@@ -1857,7 +1973,9 @@ var ObjectQLStrategy = class {
1857
1973
  if (query.measures) {
1858
1974
  for (const m of query.measures) {
1859
1975
  const { field, method } = this.resolveMeasureAggregation(cube, m);
1860
- const aggSql = method === "count" ? "COUNT(*)" : method === "count_distinct" ? `COUNT(DISTINCT ${field})` : `${method.toUpperCase()}(${field})`;
1976
+ const measureFilter = datasetScope?.measureFilters?.[m];
1977
+ const predicate = measureFilter ? this.renderFilterNodeSql(normalizeAnalyticsFilterTree({ where: measureFilter }), cube, params) : null;
1978
+ const aggSql = predicate ? this.conditionalAggregateSql(method, field, predicate) : method === "count" ? "COUNT(*)" : method === "count_distinct" ? `COUNT(DISTINCT ${field})` : `${method.toUpperCase()}(${field})`;
1861
1979
  selectParts.push(`${aggSql} AS "${m}"`);
1862
1980
  }
1863
1981
  }
@@ -1868,6 +1986,14 @@ var ObjectQLStrategy = class {
1868
1986
  params
1869
1987
  );
1870
1988
  if (filterClause) whereParts.push(filterClause);
1989
+ if (datasetScope?.filter) {
1990
+ const scopeSql = this.renderFilterNodeSql(
1991
+ normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
1992
+ cube,
1993
+ params
1994
+ );
1995
+ if (scopeSql) whereParts.push(scopeSql);
1996
+ }
1871
1997
  for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
1872
1998
  const nextDay = nextUtcCalendarDay2(bounds.$lte);
1873
1999
  params.push(bounds.$gte, nextDay ?? bounds.$lte);
@@ -1878,6 +2004,7 @@ var ObjectQLStrategy = class {
1878
2004
  const scope = ctx.getReadScope?.(tableName);
1879
2005
  if (scope != null) {
1880
2006
  const { sql: scopeSql, params: scopeParams } = compileScopedFilterToSql(scope, tableName);
2007
+ assertReadScopeCannotVacate(scope, tableName);
1881
2008
  if (scopeSql) {
1882
2009
  let i = 0;
1883
2010
  const rendered = scopeSql.replace(/\?/g, () => {
@@ -1924,6 +2051,7 @@ var ObjectQLStrategy = class {
1924
2051
  if (typeof ctx.getReadScope !== "function") return userFilter;
1925
2052
  const scope = ctx.getReadScope(objectName);
1926
2053
  if (scope === void 0 || scope === null) return userFilter;
2054
+ assertReadScopeCannotVacate(scope, objectName);
1927
2055
  const scopeFilter = markFilterSubtreeProvenance(scope, "policy");
1928
2056
  if (!userFilter) return scopeFilter;
1929
2057
  return { $and: [userFilter, scopeFilter] };
@@ -1935,6 +2063,109 @@ var ObjectQLStrategy = class {
1935
2063
  const joinedObject = cube.joins?.[alias]?.name ?? alias;
1936
2064
  return joinedObject !== baseObject;
1937
2065
  }
2066
+ /**
2067
+ * The member view {@link planCrossObject} judges a filter by: EVERY member
2068
+ * that will end up in the engine's predicate, structure discarded, keyed by
2069
+ * RESOLVED field name (#10759), valued by WHERE THE MEMBER CAME FROM
2070
+ * (#10861).
2071
+ *
2072
+ * Both call sites — `execute()` and `generateSql()` — are handed this and
2073
+ * nothing else, which is what makes the invariant `planCrossObject` states
2074
+ * for itself ("the preview accepts/rejects the same set") structural rather
2075
+ * than a coincidence maintained by hand. They used to build the view
2076
+ * separately: the echo flattened the tree, `execute()` passed the ENGINE
2077
+ * FILTER, and a filter record answers a different question — it is a
2078
+ * predicate to evaluate, not an inventory of members. An `$or`, a `$not` or
2079
+ * an unmergeable nested `$and` travels in it as one opaque `$and` entry, so
2080
+ * the members inside were unreadable from the outside and the envelope check
2081
+ * could not reject what it could not see.
2082
+ *
2083
+ * ## Three producers, one inventory (#10861, #11461)
2084
+ *
2085
+ * The caller's `where` is not the only thing that reaches `engine.aggregate`
2086
+ * as a predicate. Since PR #10758 the compiled dataset's own definition-level
2087
+ * `filter` is lowered onto `execute()`'s `conjuncts` and rendered by
2088
+ * `generateSql()`, so a dataset declaring `filter: { 'account.region': 'West' }`
2089
+ * sent `{"$and":[{"account.region":"West"}]}` to an engine that cannot join —
2090
+ * measured on both doors, which AGREED in accepting it, so #10759's
2091
+ * preview/execution symmetry had nothing to restore. Refusing it is a
2092
+ * widening of the refusal set, ruled by the maintainer on 2026-08-22 (Option
2093
+ * A, query-time refusal): fold the scope's leaves in HERE, where driver
2094
+ * capability is known, rather than in `dataset-compiler.ts`, which cannot see
2095
+ * which driver will serve the dataset and would refuse a dataset that is
2096
+ * perfectly legal on a native-SQL deployment.
2097
+ *
2098
+ * [#11461] #10413 phase 2 then added a THIRD producer with the same reach and
2099
+ * none of the coverage: a compiled measure's own `filter`, lowered onto that
2100
+ * measure's `aggregations[].filter` entry (#10576). This view enumerated two
2101
+ * origins, so the third was invisible to the envelope check and the arm of
2102
+ * `planCrossObject` that inspects `query.measures` reads only each measure's
2103
+ * resolved FIELD, never its filter. Measured on the unfixed tree, one fixture,
2104
+ * both doors:
2105
+ *
2106
+ * ```
2107
+ * BEFORE execute() ACCEPTED -> aggregations: [{field:"*",method:"count",
2108
+ * alias:"west_count",
2109
+ * filter:{"account.region":"West"}}]
2110
+ * -> rows [{stage:"won",total_count:3,west_count:0}]
2111
+ * (the truthful west_count is 2; total_count
2112
+ * is right, so the wrong number arrived in
2113
+ * the same response shape as the right one)
2114
+ * generateSql() ACCEPTED -> COUNT(CASE WHEN account.region = $1 THEN 1 END)
2115
+ * over a FROM with no join in it at all
2116
+ * AFTER both doors REFUSED INVALID_FIELD / 400, engine never reached
2117
+ * ```
2118
+ *
2119
+ * The same maintainer ruling covers it — same hazard, same physical verdict,
2120
+ * one more producer — so it folds in HERE for the #10861 reason and not into
2121
+ * `dataset-compiler.ts`, which still cannot see which driver will serve the
2122
+ * dataset. Only the REQUESTED measures are folded: both doors' aggregation
2123
+ * loops read `measureFilters[m]` for `m of query.measures` and nothing else,
2124
+ * so a filter declared on a measure this query never asks for reaches no
2125
+ * engine, and refusing on it would reject a query for a member that was never
2126
+ * going to be evaluated.
2127
+ *
2128
+ * Structure is discarded on purpose — a member is cross-object or it is not,
2129
+ * and which branch of a disjunction it sits in cannot make
2130
+ * `engine.aggregate` able to join it. PROVENANCE is not discarded, because it
2131
+ * decides what the refusal can tell the caller to go fix: `AnalyticsRequestKey`
2132
+ * is the analytics REQUEST vocabulary and a dataset's `filter` is not in it,
2133
+ * so a scope-borne member must not be reported as `param: 'where'` — see
2134
+ * `planCrossObject`. The value slot carries that and nothing else; it never
2135
+ * reaches a driver.
2136
+ *
2137
+ * Insertion order is measure-filter, then dataset-filter, then `where`, and
2138
+ * last write wins on a duplicate key. Two things follow, in that order of
2139
+ * importance. A member named by the request too keeps the CALLER's provenance,
2140
+ * because if it is in the request that is the actionable place to fix it. And
2141
+ * every shape that was refused before #11461 keeps the exact message it had:
2142
+ * the new origin can only ever win a key no older producer names.
2143
+ *
2144
+ * Time-dimension WINDOWS are deliberately absent (they live in
2145
+ * `dateRangeBounds`, not in `where`). They need no arm here: a cross-object
2146
+ * time dimension is refused by `planCrossObject`'s own first loop, over
2147
+ * `query.timeDimensions`, and refused as the time dimension the author wrote
2148
+ * rather than as the lowered predicate it becomes — which is the better
2149
+ * diagnostic and the reason that loop runs first.
2150
+ */
2151
+ filterMemberView(cube, query, ctx) {
2152
+ const datasetScope = ctx.getDatasetScope?.(query.cube);
2153
+ const leaves = (node, origin) => collectFilterLeaves(node).map(
2154
+ (f) => [this.resolveFieldName(cube, f.member, "any"), origin]
2155
+ );
2156
+ const measureLeaves = (query.measures ?? []).flatMap((m) => {
2157
+ const measureFilter = datasetScope?.measureFilters?.[m];
2158
+ return measureFilter ? leaves(
2159
+ normalizeAnalyticsFilterTree({ where: measureFilter }),
2160
+ { kind: "measure-filter", measure: m }
2161
+ ) : [];
2162
+ });
2163
+ return Object.fromEntries([
2164
+ ...measureLeaves,
2165
+ ...datasetScope?.filter ? leaves(normalizeAnalyticsFilterTree({ where: datasetScope.filter }), { kind: "dataset-filter" }) : [],
2166
+ ...leaves(normalizeAnalyticsFilterTree(query), { kind: "where" })
2167
+ ]);
2168
+ }
1938
2169
  /**
1939
2170
  * Plan how to serve cross-object references on this join-less path (#3654).
1940
2171
  *
@@ -1945,21 +2176,39 @@ var ObjectQLStrategy = class {
1945
2176
  * query (direct path), a plan for an in-envelope cross-object query.
1946
2177
  *
1947
2178
  * 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
1949
- * non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values
1950
- * 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.
2179
+ * (needs a real join to evaluate), a cross-object leaf in the DATASET's own
2180
+ * definition-level `filter` (#10861 same join it does not have, arriving
2181
+ * from the producer PR #10758 added), a cross-object leaf in ONE MEASURE's own
2182
+ * `filter` (#11461 the same join again, arriving from the producer #10413
2183
+ * phase 2 added), a MULTI-HOP dimension (`a.b.c`), or a non-recombinable
2184
+ * measure (`avg`/`count_distinct`, whose sub-bucket values cannot be merged).
2185
+ * A loud error beats the silent mis-bucket #3654 kills.
2186
+ * `generateSql()` calls this too, so the preview accepts/rejects the same set
2187
+ * — and since #10759 both callers derive `filter` from the one
2188
+ * {@link filterMemberView}, so that sentence is enforced by construction
2189
+ * instead of restated at two call sites.
2190
+ *
2191
+ * [#5716] All six refusals below are `invalidMemberError` — `INVALID_FIELD` /
2192
+ * 400, naming the member — and the four that predate #10861 keep their
2193
+ * MESSAGES unchanged (they are good diagnostics, and #5923's tests read
2194
+ * 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
2195
+ * reach the engine's predicate, and whether that member resolves across a
2196
+ * join. Neither is an internal invariant — a cube where the member exists and
2197
+ * a driver that could serve it are both perfectly ordinary, which is exactly
2198
+ * what the "run this on a native-SQL driver" half of every message says. They
2199
+ * are member-level rather than dataset-level (hence not `datasetInvalidError`)
2200
+ * because the fix is always to change or drop ONE named member, and because
2201
+ * four of them fire on `/analytics/query` where no dataset exists.
1952
2202
  *
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.
2203
+ * [#10861, #11461] The fifth and sixth are the exceptions that prove the rule
2204
+ * and are written to it: they can only fire where a dataset DOES exist, and
2205
+ * they are the two refusals here whose member no request key named — so each
2206
+ * carries `cube` and no `param`, and says in its own words which document to
2207
+ * go and edit, the sixth naming the MEASURE inside it as well. Both stay
2208
+ * `INVALID_FIELD` rather than becoming `DATASET_INVALID` because the verdict
2209
+ * is the same physical one as their neighbours this engine cannot join this
2210
+ * member and splitting the code by PROVENANCE would make a caller branch on
2211
+ * three wire shapes for one capability limit.
1963
2212
  *
1964
2213
  * Detection is on RESOLVED field names, so a dotted dimension the cube
1965
2214
  * flattens to a real column is treated as base, not cross-object.
@@ -1981,7 +2230,7 @@ var ObjectQLStrategy = class {
1981
2230
  member: m,
1982
2231
  field: this.resolveMeasureAggregation(cube, m).field
1983
2232
  })),
1984
- ...Object.keys(filter).map((f) => ({ where: "filter", member: f, field: f }))
2233
+ ...Object.entries(filter).filter(([, origin]) => origin.kind === "where").map(([f]) => ({ where: "filter", member: f, field: f }))
1985
2234
  ].filter((r) => this.isCrossObjectField(cube, r.field, baseObject));
1986
2235
  if (nonDim.length > 0) {
1987
2236
  throw invalidMemberError(
@@ -1995,6 +2244,23 @@ var ObjectQLStrategy = class {
1995
2244
  }
1996
2245
  );
1997
2246
  }
2247
+ const scopeCross = Object.entries(filter).filter(([field, origin]) => origin.kind === "dataset-filter" && this.isCrossObjectField(cube, field, baseObject)).map(([field]) => field);
2248
+ if (scopeCross.length > 0) {
2249
+ throw invalidMemberError(
2250
+ `[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.`,
2251
+ { member: scopeCross[0], cube: cube.name }
2252
+ );
2253
+ }
2254
+ const measureCross = Object.entries(filter).flatMap(
2255
+ ([field, origin]) => origin.kind === "measure-filter" && this.isCrossObjectField(cube, field, baseObject) ? [{ field, measure: origin.measure }] : []
2256
+ );
2257
+ if (measureCross.length > 0) {
2258
+ const { field, measure } = measureCross[0];
2259
+ throw invalidMemberError(
2260
+ `[Analytics] ObjectQLStrategy cannot evaluate the cross-object filter ("${field}") that dataset "${cube.name}" declares on its measure "${measure}" \u2014 the engine cannot join in an aggregate, so this measure would be counted over a predicate that matches nothing and would answer 0 rather than the scoped number. Remove the cross-object leaf from that measure's own \`filter\`, or serve this dataset on a native-SQL driver, where the same definition is valid.`,
2261
+ { member: field, cube: cube.name }
2262
+ );
2263
+ }
1998
2264
  const crossDims = [];
1999
2265
  for (const dim of query.dimensions ?? []) {
2000
2266
  const field = this.resolveFieldName(cube, dim, "dimension");
@@ -2098,6 +2364,7 @@ var ObjectQLStrategy = class {
2098
2364
  if (fkValues.length === 0 || typeof ctx.executeAggregate !== "function") return map;
2099
2365
  const idFilter = { id: { $in: fkValues } };
2100
2366
  const scope = typeof ctx.getReadScope === "function" ? ctx.getReadScope(refObject) : null;
2367
+ if (scope != null) assertReadScopeCannotVacate(scope, refObject);
2101
2368
  if (scope != null) markFilterSubtreeProvenance(scope, "policy");
2102
2369
  const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
2103
2370
  const rows = await ctx.executeAggregate(refObject, {
@@ -2111,6 +2378,45 @@ var ObjectQLStrategy = class {
2111
2378
  }
2112
2379
  return map;
2113
2380
  }
2381
+ /**
2382
+ * A measure's aggregate, restricted to the rows its own `filter` admits
2383
+ * (#10413 phase 2) — the same six functions `generateSql`'s unconditional
2384
+ * branch renders, wrapped in a `CASE WHEN`.
2385
+ *
2386
+ * Spelled `CASE WHEN` rather than SQL-standard `FILTER (WHERE …)`, mirroring
2387
+ * `NativeSQLStrategy.CONDITIONAL_AGGREGATE_SQL`: this string is DOCUMENTATION
2388
+ * of an execution that really goes through `engine.aggregate`'s per-driver
2389
+ * `aggregations[].filter` lowering (#10576), not a statement this class runs
2390
+ * itself, so there is no reason to pick a dialect-restricted spelling over
2391
+ * the portable one the SQL-executing sibling already settled on.
2392
+ *
2393
+ * `count` over `*` counts a constant (`COUNT(CASE WHEN p THEN 1 END)`, since
2394
+ * `COUNT(CASE WHEN p THEN * END)` is not valid SQL); over a real column it
2395
+ * counts that column's non-null values among the admitted rows.
2396
+ */
2397
+ conditionalAggregateSql(method, col, pred) {
2398
+ const target = col === "*" ? "1" : col;
2399
+ switch (method) {
2400
+ case "count":
2401
+ return `COUNT(CASE WHEN ${pred} THEN ${target} END)`;
2402
+ case "count_distinct":
2403
+ return `COUNT(DISTINCT CASE WHEN ${pred} THEN ${col} END)`;
2404
+ case "sum":
2405
+ return `SUM(CASE WHEN ${pred} THEN ${col} END)`;
2406
+ case "avg":
2407
+ return `AVG(CASE WHEN ${pred} THEN ${col} END)`;
2408
+ case "min":
2409
+ return `MIN(CASE WHEN ${pred} THEN ${col} END)`;
2410
+ case "max":
2411
+ return `MAX(CASE WHEN ${pred} THEN ${col} END)`;
2412
+ // Closed vocabulary, same posture as `resolveMeasureAggregation`'s
2413
+ // callers: `method` comes only from that function, whose own aggTypes
2414
+ // list is exactly these six, so this default is unreachable rather than
2415
+ // a silent fallback for a method this table forgot.
2416
+ default:
2417
+ return `${method.toUpperCase()}(CASE WHEN ${pred} THEN ${col} END)`;
2418
+ }
2419
+ }
2114
2420
  /**
2115
2421
  * Render one normalized filter as a display SQL predicate for `generateSql`.
2116
2422
  *
@@ -2202,8 +2508,20 @@ var ObjectQLStrategy = class {
2202
2508
  resolveMeasureAggregation(cube, measureName) {
2203
2509
  const direct = this.lookupMember(cube, measureName, "measure");
2204
2510
  if (direct) {
2511
+ if (EXPRESSION_METRIC_TYPES.has(direct.type)) {
2512
+ throw invalidMemberError(
2513
+ `[Analytics] ObjectQLStrategy cannot evaluate the custom-SQL measure ("${measureName}") \u2014 its type "${direct.type}" declares a raw SQL expression, which the engine aggregate AST cannot carry; served anyway it would answer null for every bucket under the measure's own name. Use an aggregate measure (count/sum/avg/min/max/count_distinct), or run on a native-SQL driver.`,
2514
+ { member: measureName, param: "measures", cube: cube.name }
2515
+ );
2516
+ }
2205
2517
  return {
2206
2518
  field: direct.sql.replace(/^\$/, ""),
2519
+ // The assertion, not a parse: for a CubeSchema-legal cube the type
2520
+ // partition above leaves exactly the six `AggregationFunction` values.
2521
+ // An enum-INVALID type (host drift, the comment above) still flows
2522
+ // through unchecked ON PURPOSE — adding a method allowlist here would
2523
+ // re-blame the caller with a 400 for OUR bug, so the cast keeps the
2524
+ // compile-time contract (#12776) without changing that posture.
2207
2525
  method: direct.type === "count_distinct" ? "count_distinct" : direct.type
2208
2526
  };
2209
2527
  }
@@ -2217,7 +2535,7 @@ var ObjectQLStrategy = class {
2217
2535
  if (candidate && candidate.type === type) {
2218
2536
  return {
2219
2537
  field: candidate.sql.replace(/^\$/, ""),
2220
- method: candidate.type === "count_distinct" ? "count_distinct" : candidate.type
2538
+ method: type
2221
2539
  };
2222
2540
  }
2223
2541
  }
@@ -3749,6 +4067,16 @@ var AnalyticsService = class {
3749
4067
  // Prefer a compiled dataset's declared relationships (D-C join allowlist);
3750
4068
  // fall back to any explicitly-configured provider for legacy cubes.
3751
4069
  getAllowedRelationships: (cubeName) => this.datasetRegistry.get(cubeName)?.allowedRelationships ?? config.getAllowedRelationships?.(cubeName),
4070
+ // [#10298] The compiled dataset's definition-level filter and its
4071
+ // per-measure filters — the half of the declaration the Cube model has
4072
+ // no room for. Same shape and same registry as `getAllowedRelationships`
4073
+ // directly above: answered for a cube that IS a compiled dataset,
4074
+ // `undefined` for every other cube.
4075
+ getDatasetScope: (cubeName) => {
4076
+ const compiled = this.datasetRegistry.get(cubeName);
4077
+ if (!compiled) return void 0;
4078
+ return { filter: compiled.filter, measureFilters: compiled.measureFilters };
4079
+ },
3752
4080
  coerceTemporalFilterValue: config.coerceTemporalFilterValue,
3753
4081
  coerceTemporalFilterColumn: config.coerceTemporalFilterColumn,
3754
4082
  isExternalObject: config.isExternalObject
@@ -3771,15 +4099,59 @@ var AnalyticsService = class {
3771
4099
  * current request's ExecutionContext (ADR-0021 D-C). The strategy then sees a
3772
4100
  * `getReadScope(objectName)` that already knows the active tenant.
3773
4101
  */
3774
- async callCtx(query, context) {
3775
- if (!this.readScopeProvider) return { ...this.baseCtx, context };
4102
+ async callCtx(query, context, tokenCtx) {
4103
+ const getDatasetScope = this.resolvedDatasetScopeGetter(tokenCtx);
4104
+ if (!this.readScopeProvider) return { ...this.baseCtx, context, getDatasetScope };
3776
4105
  const scopes = await this.resolveReadScopes(query, context);
3777
4106
  return {
3778
4107
  ...this.baseCtx,
3779
4108
  context,
4109
+ getDatasetScope,
3780
4110
  getReadScope: (objectName) => scopes.get(objectName) ?? null
3781
4111
  };
3782
4112
  }
4113
+ /**
4114
+ * [#12230] Copy-on-write expansion of filter placeholders across everything
4115
+ * a DIRECT analytics query compares on: `where` and each time dimension's
4116
+ * `dateRange` — the same positions `DatasetExecutor.resolveSelectionTokens`
4117
+ * covers for the dashboard door, minus the dataset-only channels it alone
4118
+ * carries (measure filters ride the dataset-scope getter below).
4119
+ *
4120
+ * The input is never mutated: a query object can be caller-owned metadata
4121
+ * (a saved report definition, a flow node's config) reused across requests,
4122
+ * and resolving in place would bake one request's user id into every later
4123
+ * render. Returns the SAME object when nothing resolved.
4124
+ */
4125
+ resolveQueryTokens(query, tokenCtx) {
4126
+ const where = resolveFilterTokens2(query.where, tokenCtx);
4127
+ const timeDimensions = query.timeDimensions?.map((td) => {
4128
+ if (td.dateRange == null) return td;
4129
+ const dateRange = resolveFilterTokens2(td.dateRange, tokenCtx);
4130
+ return dateRange === td.dateRange ? td : { ...td, dateRange };
4131
+ });
4132
+ const tdChanged = timeDimensions !== void 0 && timeDimensions.some((td, i) => td !== query.timeDimensions[i]);
4133
+ if (where === query.where && !tdChanged) return query;
4134
+ const out = { ...query };
4135
+ if (where !== query.where) out.where = where;
4136
+ if (tdChanged) out.timeDimensions = timeDimensions;
4137
+ return out;
4138
+ }
4139
+ /**
4140
+ * [#12230] A per-request `getDatasetScope` whose answers have their filter
4141
+ * placeholders resolved against THIS caller. See `callCtx` for why the
4142
+ * registry's copy cannot be handed out raw. Token-free scopes pass through
4143
+ * by reference — `resolveFilterTokens` returns its input unchanged when the
4144
+ * tree holds no placeholder, so the common case allocates nothing.
4145
+ */
4146
+ resolvedDatasetScopeGetter(tokenCtx) {
4147
+ return (cubeName) => {
4148
+ const scope = this.baseCtx.getDatasetScope?.(cubeName);
4149
+ if (!scope) return scope;
4150
+ const filter = resolveFilterTokens2(scope.filter, tokenCtx);
4151
+ const measureFilters = resolveFilterTokens2(scope.measureFilters, tokenCtx);
4152
+ return filter === scope.filter && measureFilters === scope.measureFilters ? scope : { filter, measureFilters };
4153
+ };
4154
+ }
3783
4155
  /**
3784
4156
  * Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
3785
4157
  * AND every joined object of the query's cube, keyed by object name. This is
@@ -3837,12 +4209,14 @@ var AnalyticsService = class {
3837
4209
  * aggregate bridge) instead of failing — or worse, fabricating empty rows.
3838
4210
  * Any other error propagates untouched.
3839
4211
  */
3840
- async query(query, context) {
3841
- if (!query.cube) {
4212
+ async query(queryInput, context) {
4213
+ if (!queryInput.cube) {
3842
4214
  throw new Error("Cube name is required in analytics query");
3843
4215
  }
4216
+ const tokenCtx = filterTokenContextFrom2(context, /* @__PURE__ */ new Date());
4217
+ const query = this.resolveQueryTokens(queryInput, tokenCtx);
3844
4218
  this.ensureCube(query);
3845
- const ctx = await this.callCtx(query, context);
4219
+ const ctx = await this.callCtx(query, context, tokenCtx);
3846
4220
  let skip;
3847
4221
  for (; ; ) {
3848
4222
  const strategy = this.resolveStrategy(query, ctx, skip);
@@ -4038,6 +4412,7 @@ var AnalyticsService = class {
4038
4412
  const label = resolveI18nLabel2(m.label, requestLocale);
4039
4413
  if (label !== void 0) f.label = label;
4040
4414
  }
4415
+ if (f.builtinAggregate == null && m.label == null && m.aggregate) f.builtinAggregate = m.aggregate;
4041
4416
  if (f.format == null && m.format) f.format = m.format;
4042
4417
  const fc = f;
4043
4418
  const mc = m;
@@ -4096,12 +4471,14 @@ var AnalyticsService = class {
4096
4471
  /**
4097
4472
  * Generate SQL for a query without executing it (dry-run).
4098
4473
  */
4099
- async generateSql(query, context) {
4100
- if (!query.cube) {
4474
+ async generateSql(queryInput, context) {
4475
+ if (!queryInput.cube) {
4101
4476
  throw new Error("Cube name is required for SQL generation");
4102
4477
  }
4478
+ const tokenCtx = filterTokenContextFrom2(context, /* @__PURE__ */ new Date());
4479
+ const query = this.resolveQueryTokens(queryInput, tokenCtx);
4103
4480
  this.ensureCube(query);
4104
- const ctx = await this.callCtx(query, context);
4481
+ const ctx = await this.callCtx(query, context, tokenCtx);
4105
4482
  const strategy = this.resolveStrategy(query, ctx);
4106
4483
  this.logger.debug(`[Analytics] generateSql on cube "${query.cube}" \u2192 ${strategy.name}`);
4107
4484
  return strategy.generateSql(query, ctx);
@@ -4615,6 +4992,16 @@ var FallbackDelegateStrategy = class {
4615
4992
  };
4616
4993
 
4617
4994
  // src/plugin.ts
4995
+ import { AggregationFunction as AggregationFunction2 } from "@objectstack/spec/data";
4996
+ function parseEngineAggregateFunction(method, alias) {
4997
+ const parsed = AggregationFunction2.safeParse(method);
4998
+ if (!parsed.success) {
4999
+ throw new Error(
5000
+ `[Analytics] The aggregate bridge cannot forward the aggregation "${alias}": "${method}" is not one of the engine's aggregate functions (${AggregationFunction2.options.join(", ")}). A custom-SQL measure is refused earlier, with a caller-facing diagnostic, by ObjectQLStrategy; reaching this point means the analytics layer produced a method the engine contract does not declare.`
5001
+ );
5002
+ }
5003
+ return parsed.data;
5004
+ }
4618
5005
  var AnalyticsServicePlugin = class {
4619
5006
  constructor(options = {}) {
4620
5007
  this.name = "com.objectstack.service-analytics";
@@ -4672,10 +5059,58 @@ var AnalyticsServicePlugin = class {
4672
5059
  const rows = await engine.aggregate(objectName, {
4673
5060
  where: filter,
4674
5061
  groupBy,
5062
+ // [#10413 phase 2 / #10576] `a.filter` is the per-aggregation
5063
+ // predicate `ObjectQLStrategy` lowers a measure's own scoped
5064
+ // `filter` into. This map already renames `method` → `function`
5065
+ // for the engine's own vocabulary; dropping `filter` here — as this
5066
+ // bridge did before this line existed — would have made the
5067
+ // strategy's lowering a NO-OP on every real deployment that boots
5068
+ // through this auto-bridge (the default path: `new
5069
+ // AnalyticsServicePlugin({ cubes })` with no custom
5070
+ // `executeAggregate`), passing every unit test that stubs
5071
+ // `executeAggregate` directly while silently dropping the filter in
5072
+ // production — the exact declared-≠-enforced shape Prime Directive
5073
+ // #10 calls out. Omitted (not `filter: undefined`) when the
5074
+ // aggregation carries none, matching the engine's own
5075
+ // vacuous-filter convention.
4675
5076
  aggregations: aggregations?.map((a) => ({
4676
- function: a.method,
5077
+ // [#11833] `function` is the engine contract's SIX-value
5078
+ // `AggregationFunction`. This bridge's own input declared
5079
+ // `method: string` when that history was written
5080
+ // (`StrategyContext.executeAggregate`, spec
5081
+ // `contracts/analytics-service.ts`), so the two ends of this
5082
+ // rename spoke different vocabularies: narrowing the engine side
5083
+ // to the contract turned the forward into a compile error — the
5084
+ // correct signal, and the one the deleted structural type hid by
5085
+ // declaring `function: string` on both sides.
5086
+ //
5087
+ // Since #12776 (contract) and #12940 (this plugin's own config
5088
+ // mirror above), BOTH ends declare the enum, so the rename is
5089
+ // enum-to-enum and the parse below is defence in depth behind a
5090
+ // compile-time check rather than the only check — see
5091
+ // `parseEngineAggregateFunction` for why erased types still leave
5092
+ // it load-bearing.
5093
+ //
5094
+ // It was closed by PARSING with the spec enum itself rather than
5095
+ // by widening back to `string` (what hid it) or casting past it
5096
+ // (which keeps the hole and adds a lie). `AggregationFunction` is
5097
+ // the same schema `AggregationNodeSchema.function` is built from,
5098
+ // so there is one vocabulary, and its own error map already
5099
+ // carries the `array_agg`/`string_agg` retirement prescriptions.
5100
+ //
5101
+ // TIERING, deliberately: the reachable producer of a non-aggregate
5102
+ // method — a custom-SQL measure (`AggregationMetricType`
5103
+ // `number`/`string`/`boolean`) — is already refused upstream with a
5104
+ // caller-blaming 400 by `ObjectQLStrategy.resolveMeasureAggregation`
5105
+ // (#12209). Anything still arriving here is host drift, which that
5106
+ // refusal's docblock assigns to the undeclared-500 tier — so this
5107
+ // throws rather than re-blaming the caller, and it answers loudly
5108
+ // instead of letting the engine answer `null` per bucket under the
5109
+ // author's own measure name (the #4157 class).
5110
+ function: parseEngineAggregateFunction(a.method, a.alias),
4677
5111
  field: a.field,
4678
- alias: a.alias
5112
+ alias: a.alias,
5113
+ ...a.filter ? { filter: a.filter } : {}
4679
5114
  })),
4680
5115
  // ADR-0053 Phase 2: thread the reference tz so date buckets resolve on
4681
5116
  // that zone's calendar days (engine buckets in-memory when non-UTC).
@@ -4777,6 +5212,7 @@ var AnalyticsServicePlugin = class {
4777
5212
  const map = /* @__PURE__ */ new Map();
4778
5213
  const displayField = pickDisplayField(dataEngine()?.getObject?.(targetObject)?.fields);
4779
5214
  if (!displayField || !executeAggregate || ids.length === 0) return map;
5215
+ if (scope) assertReadScopeCannotVacate(scope, targetObject);
4780
5216
  const CHUNK = 500;
4781
5217
  for (let i = 0; i < ids.length; i += CHUNK) {
4782
5218
  const idFilter = { id: { $in: ids.slice(i, i + CHUNK) } };