@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.cjs CHANGED
@@ -724,6 +724,53 @@ function compileScopedFilterToSql(filter, alias) {
724
724
  const sql = compileNode(filter, quotedAlias, params);
725
725
  return { sql, params };
726
726
  }
727
+ function emptyMembershipFinding(spec, negated, path) {
728
+ if (Array.isArray(spec)) {
729
+ return spec.length === 0 && negated ? { path: `${path}: []`, kind: "negatedIn" } : null;
730
+ }
731
+ if (spec === null || typeof spec !== "object") return null;
732
+ const rec = spec;
733
+ if (Array.isArray(rec.$nin) && rec.$nin.length === 0) return { path: `${path}.$nin`, kind: "nin" };
734
+ if (Array.isArray(rec.$in) && rec.$in.length === 0 && negated) {
735
+ return { path: `${path}.$in`, kind: "negatedIn" };
736
+ }
737
+ return null;
738
+ }
739
+ function findEmptyMembership(node, negated, path) {
740
+ if (node === null || typeof node !== "object" || Array.isArray(node)) return null;
741
+ const rec = node;
742
+ const bare = emptyMembershipFinding(rec, negated, path.length > 0 ? path : "<root>");
743
+ if (bare) return bare;
744
+ for (const [key, value] of Object.entries(rec)) {
745
+ const here = path.length > 0 ? `${path}.${key}` : key;
746
+ if (key === "$not") {
747
+ const found = findEmptyMembership(value, !negated, here);
748
+ if (found) return found;
749
+ } else if (key === "$and" || key === "$or") {
750
+ if (!Array.isArray(value)) continue;
751
+ for (let i = 0; i < value.length; i++) {
752
+ const found = findEmptyMembership(value[i], negated, `${here}[${i}]`);
753
+ if (found) return found;
754
+ }
755
+ } else if (!key.startsWith("$")) {
756
+ const found = emptyMembershipFinding(value, negated, here);
757
+ if (found) return found;
758
+ }
759
+ }
760
+ return null;
761
+ }
762
+ function assertReadScopeCannotVacate(scope, objectName) {
763
+ const found = findEmptyMembership(scope, false, "");
764
+ if (found === null) return;
765
+ if (found.kind === "nin") {
766
+ throw readScopeCompileError(
767
+ `[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).`
768
+ );
769
+ }
770
+ throw readScopeCompileError(
771
+ `[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).`
772
+ );
773
+ }
727
774
  function compileSub(node, qAlias) {
728
775
  const params = [];
729
776
  const sql = compileNode(node, qAlias, params);
@@ -885,7 +932,7 @@ function compileOperator(col, op, val, field, params) {
885
932
  }
886
933
  case "$nin": {
887
934
  if (!Array.isArray(val)) throw readScopeCompileError(`[read-scope-sql] $nin for "${field}" needs an array (fail-closed).`);
888
- if (val.length === 0) return "1 = 1";
935
+ 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).`);
889
936
  assertCompilableMembers(op, field, val);
890
937
  return nullSafeNegative(col, `${col} NOT IN (${val.map((v) => bind(params, v)).join(", ")})`);
891
938
  }
@@ -1063,14 +1110,31 @@ function invalidMemberError(message, meta) {
1063
1110
  // src/strategies/native-sql-strategy.ts
1064
1111
  var import_core2 = require("@objectstack/core");
1065
1112
  var AGGREGATE_SQL = {
1066
- "count": () => "COUNT(*)",
1113
+ // [#10298] `count` takes its COLUMN when the measure declares one. The
1114
+ // wrapper used to discard `col` and always emit `COUNT(*)`, so a measure
1115
+ // written `{ aggregate: 'count', field: 'resolved_by_article' }` counted
1116
+ // ROWS instead of non-null values — and a deflection rate built as
1117
+ // `kb_resolved_count / closed_count` read 100% where the truth was 12.5%,
1118
+ // with the numerator and denominator printed beside it as 8 and 8. `*` is
1119
+ // still `COUNT(*)`: the compiler writes `sql: m.field ?? '*'`, so the star
1120
+ // IS the "no field declared" spelling and must keep counting rows.
1121
+ "count": (col) => col === "*" ? "COUNT(*)" : `COUNT(${col})`,
1067
1122
  "sum": (col) => `SUM(${col})`,
1068
1123
  "avg": (col) => `AVG(${col})`,
1069
1124
  "min": (col) => `MIN(${col})`,
1070
1125
  "max": (col) => `MAX(${col})`,
1071
1126
  "count_distinct": (col) => `COUNT(DISTINCT ${col})`
1072
1127
  };
1128
+ var CONDITIONAL_AGGREGATE_SQL = {
1129
+ "count": (col, pred) => `COUNT(CASE WHEN ${pred} THEN ${col === "*" ? "1" : col} END)`,
1130
+ "sum": (col, pred) => `SUM(CASE WHEN ${pred} THEN ${col} END)`,
1131
+ "avg": (col, pred) => `AVG(CASE WHEN ${pred} THEN ${col} END)`,
1132
+ "min": (col, pred) => `MIN(CASE WHEN ${pred} THEN ${col} END)`,
1133
+ "max": (col, pred) => `MAX(CASE WHEN ${pred} THEN ${col} END)`,
1134
+ "count_distinct": (col, pred) => `COUNT(DISTINCT CASE WHEN ${pred} THEN ${col} END)`
1135
+ };
1073
1136
  var SUPPORTED_AGGREGATE_SQL_KEYS = Object.keys(AGGREGATE_SQL);
1137
+ var CONDITIONAL_AGGREGATE_SQL_KEYS = Object.keys(CONDITIONAL_AGGREGATE_SQL);
1074
1138
  var EXPRESSION_METRIC_TYPES = /* @__PURE__ */ new Set(["number", "string", "boolean"]);
1075
1139
  var IDENTIFIER_PATH = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
1076
1140
  var NativeSQLStrategy = class {
@@ -1236,9 +1300,19 @@ var NativeSQLStrategy = class {
1236
1300
  groupByClauses.push(colExpr);
1237
1301
  }
1238
1302
  }
1303
+ const datasetScope = ctx.getDatasetScope?.(query.cube);
1239
1304
  if (query.measures && query.measures.length > 0) {
1240
1305
  for (const measure of query.measures) {
1241
- const aggExpr = this.resolveMeasureSql(cube, measure, tableName, joins);
1306
+ const measureFilter = datasetScope?.measureFilters?.[measure];
1307
+ const predicate = measureFilter ? this.compileFilterNode(
1308
+ normalizeAnalyticsFilterTree({ where: measureFilter }),
1309
+ cube,
1310
+ tableName,
1311
+ joins,
1312
+ params,
1313
+ ctx
1314
+ ) : null;
1315
+ const aggExpr = this.resolveMeasureSql(cube, measure, tableName, joins, predicate);
1242
1316
  selectClauses.push(`${aggExpr} AS "${measure}"`);
1243
1317
  }
1244
1318
  }
@@ -1252,6 +1326,17 @@ var NativeSQLStrategy = class {
1252
1326
  ctx
1253
1327
  );
1254
1328
  if (filterSql) whereClauses.push(filterSql);
1329
+ if (datasetScope?.filter) {
1330
+ const scopeSql = this.compileFilterNode(
1331
+ normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
1332
+ cube,
1333
+ tableName,
1334
+ joins,
1335
+ params,
1336
+ ctx
1337
+ );
1338
+ if (scopeSql) whereClauses.push(scopeSql);
1339
+ }
1255
1340
  if (query.timeDimensions && query.timeDimensions.length > 0) {
1256
1341
  for (const td of query.timeDimensions) {
1257
1342
  const colExpr = this.resolveFieldSql(cube, td.dimension, tableName, joins);
@@ -1326,6 +1411,7 @@ var NativeSQLStrategy = class {
1326
1411
  const filter = ctx.getReadScope(objectName);
1327
1412
  if (filter === void 0 || filter === null) return;
1328
1413
  const { sql, params: scopeParams } = compileScopedFilterToSql(filter, alias);
1414
+ assertReadScopeCannotVacate(filter, objectName);
1329
1415
  if (!sql) return;
1330
1416
  let i = 0;
1331
1417
  const rendered = sql.replace(/\?/g, () => {
@@ -1425,7 +1511,12 @@ var NativeSQLStrategy = class {
1425
1511
  const raw = dim ? dim.sql : member.includes(".") ? member.split(".")[1] : member;
1426
1512
  return this.qualifyAndRegisterJoin(raw, parentTable, joins, cube);
1427
1513
  }
1428
- resolveMeasureSql(cube, member, parentTable, joins) {
1514
+ /**
1515
+ * @param predicate - The measure's own scoped filter, already compiled to a
1516
+ * SQL boolean (`null` = the measure declares none, or declares one that
1517
+ * constrains nothing — `compileFilterNode`'s TRUE). #10298.
1518
+ */
1519
+ resolveMeasureSql(cube, member, parentTable, joins, predicate = null) {
1429
1520
  const measure = this.lookupMember(cube, member, "measure");
1430
1521
  if (!measure) {
1431
1522
  const declared = Object.keys(cube.measures ?? {});
@@ -1435,6 +1526,13 @@ var NativeSQLStrategy = class {
1435
1526
  );
1436
1527
  }
1437
1528
  const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
1529
+ if (predicate !== null) {
1530
+ const wrapConditional = CONDITIONAL_AGGREGATE_SQL[measure.type];
1531
+ if (wrapConditional) return wrapConditional(col, predicate);
1532
+ throw new Error(
1533
+ `[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(", ")}).`
1534
+ );
1535
+ }
1438
1536
  const wrap = AGGREGATE_SQL[measure.type];
1439
1537
  if (wrap) return wrap(col);
1440
1538
  if (EXPRESSION_METRIC_TYPES.has(measure.type)) return col;
@@ -1767,11 +1865,16 @@ var ObjectQLStrategy = class {
1767
1865
  for (const [dim, gran] of granByDim) {
1768
1866
  groupBy.push({ field: this.resolveFieldName(cube, dim, "dimension"), dateGranularity: gran });
1769
1867
  }
1868
+ const datasetScope = ctx.getDatasetScope?.(query.cube);
1770
1869
  const aggregations = [];
1771
1870
  if (query.measures && query.measures.length > 0) {
1772
1871
  for (const measure of query.measures) {
1773
1872
  const { field, method } = this.resolveMeasureAggregation(cube, measure);
1774
- aggregations.push({ field, method, alias: measure });
1873
+ const measureFilter = datasetScope?.measureFilters?.[measure];
1874
+ const filterCondition = measureFilter ? this.filterNodeToCondition(normalizeAnalyticsFilterTree({ where: measureFilter }), cube) : null;
1875
+ aggregations.push(
1876
+ filterCondition ? { field, method, alias: measure, filter: filterCondition } : { field, method, alias: measure }
1877
+ );
1775
1878
  }
1776
1879
  }
1777
1880
  const filter = {};
@@ -1781,10 +1884,17 @@ var ObjectQLStrategy = class {
1781
1884
  const extra = this.mergeFilterOperand(filter, field, bounds);
1782
1885
  if (extra) conjuncts.push(extra);
1783
1886
  }
1887
+ if (datasetScope?.filter) {
1888
+ const scopeCondition = this.filterNodeToCondition(
1889
+ normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
1890
+ cube
1891
+ );
1892
+ if (scopeCondition) conjuncts.push(scopeCondition);
1893
+ }
1784
1894
  if (conjuncts.length > 0) {
1785
1895
  filter.$and = [...Array.isArray(filter.$and) ? filter.$and : [], ...conjuncts];
1786
1896
  }
1787
- const plan = this.planCrossObject(cube, query, filter);
1897
+ const plan = this.planCrossObject(cube, query, this.filterMemberView(cube, query, ctx));
1788
1898
  if (plan) {
1789
1899
  return this.executeCrossObject(cube, query, aggregations, filter, plan, ctx);
1790
1900
  }
@@ -1861,9 +1971,8 @@ var ObjectQLStrategy = class {
1861
1971
  if (td.granularity) granByDim.set(td.dimension, td.granularity);
1862
1972
  }
1863
1973
  const tableName = this.extractObjectName(cube);
1864
- const plan = this.planCrossObject(cube, query, Object.fromEntries(
1865
- collectFilterLeaves(normalizeAnalyticsFilterTree(query)).map((f) => [this.resolveFieldName(cube, f.member, "any"), true])
1866
- ));
1974
+ const plan = this.planCrossObject(cube, query, this.filterMemberView(cube, query, ctx));
1975
+ const datasetScope = ctx.getDatasetScope?.(query.cube);
1867
1976
  const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd]));
1868
1977
  const joinClauses = [];
1869
1978
  const dimExpr = (dim) => {
@@ -1894,7 +2003,9 @@ var ObjectQLStrategy = class {
1894
2003
  if (query.measures) {
1895
2004
  for (const m of query.measures) {
1896
2005
  const { field, method } = this.resolveMeasureAggregation(cube, m);
1897
- const aggSql = method === "count" ? "COUNT(*)" : method === "count_distinct" ? `COUNT(DISTINCT ${field})` : `${method.toUpperCase()}(${field})`;
2006
+ const measureFilter = datasetScope?.measureFilters?.[m];
2007
+ const predicate = measureFilter ? this.renderFilterNodeSql(normalizeAnalyticsFilterTree({ where: measureFilter }), cube, params) : null;
2008
+ const aggSql = predicate ? this.conditionalAggregateSql(method, field, predicate) : method === "count" ? "COUNT(*)" : method === "count_distinct" ? `COUNT(DISTINCT ${field})` : `${method.toUpperCase()}(${field})`;
1898
2009
  selectParts.push(`${aggSql} AS "${m}"`);
1899
2010
  }
1900
2011
  }
@@ -1905,6 +2016,14 @@ var ObjectQLStrategy = class {
1905
2016
  params
1906
2017
  );
1907
2018
  if (filterClause) whereParts.push(filterClause);
2019
+ if (datasetScope?.filter) {
2020
+ const scopeSql = this.renderFilterNodeSql(
2021
+ normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
2022
+ cube,
2023
+ params
2024
+ );
2025
+ if (scopeSql) whereParts.push(scopeSql);
2026
+ }
1908
2027
  for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
1909
2028
  const nextDay = (0, import_core3.nextUtcCalendarDay)(bounds.$lte);
1910
2029
  params.push(bounds.$gte, nextDay ?? bounds.$lte);
@@ -1915,6 +2034,7 @@ var ObjectQLStrategy = class {
1915
2034
  const scope = ctx.getReadScope?.(tableName);
1916
2035
  if (scope != null) {
1917
2036
  const { sql: scopeSql, params: scopeParams } = compileScopedFilterToSql(scope, tableName);
2037
+ assertReadScopeCannotVacate(scope, tableName);
1918
2038
  if (scopeSql) {
1919
2039
  let i = 0;
1920
2040
  const rendered = scopeSql.replace(/\?/g, () => {
@@ -1961,6 +2081,7 @@ var ObjectQLStrategy = class {
1961
2081
  if (typeof ctx.getReadScope !== "function") return userFilter;
1962
2082
  const scope = ctx.getReadScope(objectName);
1963
2083
  if (scope === void 0 || scope === null) return userFilter;
2084
+ assertReadScopeCannotVacate(scope, objectName);
1964
2085
  const scopeFilter = (0, import_data3.markFilterSubtreeProvenance)(scope, "policy");
1965
2086
  if (!userFilter) return scopeFilter;
1966
2087
  return { $and: [userFilter, scopeFilter] };
@@ -1972,6 +2093,109 @@ var ObjectQLStrategy = class {
1972
2093
  const joinedObject = cube.joins?.[alias]?.name ?? alias;
1973
2094
  return joinedObject !== baseObject;
1974
2095
  }
2096
+ /**
2097
+ * The member view {@link planCrossObject} judges a filter by: EVERY member
2098
+ * that will end up in the engine's predicate, structure discarded, keyed by
2099
+ * RESOLVED field name (#10759), valued by WHERE THE MEMBER CAME FROM
2100
+ * (#10861).
2101
+ *
2102
+ * Both call sites — `execute()` and `generateSql()` — are handed this and
2103
+ * nothing else, which is what makes the invariant `planCrossObject` states
2104
+ * for itself ("the preview accepts/rejects the same set") structural rather
2105
+ * than a coincidence maintained by hand. They used to build the view
2106
+ * separately: the echo flattened the tree, `execute()` passed the ENGINE
2107
+ * FILTER, and a filter record answers a different question — it is a
2108
+ * predicate to evaluate, not an inventory of members. An `$or`, a `$not` or
2109
+ * an unmergeable nested `$and` travels in it as one opaque `$and` entry, so
2110
+ * the members inside were unreadable from the outside and the envelope check
2111
+ * could not reject what it could not see.
2112
+ *
2113
+ * ## Three producers, one inventory (#10861, #11461)
2114
+ *
2115
+ * The caller's `where` is not the only thing that reaches `engine.aggregate`
2116
+ * as a predicate. Since PR #10758 the compiled dataset's own definition-level
2117
+ * `filter` is lowered onto `execute()`'s `conjuncts` and rendered by
2118
+ * `generateSql()`, so a dataset declaring `filter: { 'account.region': 'West' }`
2119
+ * sent `{"$and":[{"account.region":"West"}]}` to an engine that cannot join —
2120
+ * measured on both doors, which AGREED in accepting it, so #10759's
2121
+ * preview/execution symmetry had nothing to restore. Refusing it is a
2122
+ * widening of the refusal set, ruled by the maintainer on 2026-08-22 (Option
2123
+ * A, query-time refusal): fold the scope's leaves in HERE, where driver
2124
+ * capability is known, rather than in `dataset-compiler.ts`, which cannot see
2125
+ * which driver will serve the dataset and would refuse a dataset that is
2126
+ * perfectly legal on a native-SQL deployment.
2127
+ *
2128
+ * [#11461] #10413 phase 2 then added a THIRD producer with the same reach and
2129
+ * none of the coverage: a compiled measure's own `filter`, lowered onto that
2130
+ * measure's `aggregations[].filter` entry (#10576). This view enumerated two
2131
+ * origins, so the third was invisible to the envelope check and the arm of
2132
+ * `planCrossObject` that inspects `query.measures` reads only each measure's
2133
+ * resolved FIELD, never its filter. Measured on the unfixed tree, one fixture,
2134
+ * both doors:
2135
+ *
2136
+ * ```
2137
+ * BEFORE execute() ACCEPTED -> aggregations: [{field:"*",method:"count",
2138
+ * alias:"west_count",
2139
+ * filter:{"account.region":"West"}}]
2140
+ * -> rows [{stage:"won",total_count:3,west_count:0}]
2141
+ * (the truthful west_count is 2; total_count
2142
+ * is right, so the wrong number arrived in
2143
+ * the same response shape as the right one)
2144
+ * generateSql() ACCEPTED -> COUNT(CASE WHEN account.region = $1 THEN 1 END)
2145
+ * over a FROM with no join in it at all
2146
+ * AFTER both doors REFUSED INVALID_FIELD / 400, engine never reached
2147
+ * ```
2148
+ *
2149
+ * The same maintainer ruling covers it — same hazard, same physical verdict,
2150
+ * one more producer — so it folds in HERE for the #10861 reason and not into
2151
+ * `dataset-compiler.ts`, which still cannot see which driver will serve the
2152
+ * dataset. Only the REQUESTED measures are folded: both doors' aggregation
2153
+ * loops read `measureFilters[m]` for `m of query.measures` and nothing else,
2154
+ * so a filter declared on a measure this query never asks for reaches no
2155
+ * engine, and refusing on it would reject a query for a member that was never
2156
+ * going to be evaluated.
2157
+ *
2158
+ * Structure is discarded on purpose — a member is cross-object or it is not,
2159
+ * and which branch of a disjunction it sits in cannot make
2160
+ * `engine.aggregate` able to join it. PROVENANCE is not discarded, because it
2161
+ * decides what the refusal can tell the caller to go fix: `AnalyticsRequestKey`
2162
+ * is the analytics REQUEST vocabulary and a dataset's `filter` is not in it,
2163
+ * so a scope-borne member must not be reported as `param: 'where'` — see
2164
+ * `planCrossObject`. The value slot carries that and nothing else; it never
2165
+ * reaches a driver.
2166
+ *
2167
+ * Insertion order is measure-filter, then dataset-filter, then `where`, and
2168
+ * last write wins on a duplicate key. Two things follow, in that order of
2169
+ * importance. A member named by the request too keeps the CALLER's provenance,
2170
+ * because if it is in the request that is the actionable place to fix it. And
2171
+ * every shape that was refused before #11461 keeps the exact message it had:
2172
+ * the new origin can only ever win a key no older producer names.
2173
+ *
2174
+ * Time-dimension WINDOWS are deliberately absent (they live in
2175
+ * `dateRangeBounds`, not in `where`). They need no arm here: a cross-object
2176
+ * time dimension is refused by `planCrossObject`'s own first loop, over
2177
+ * `query.timeDimensions`, and refused as the time dimension the author wrote
2178
+ * rather than as the lowered predicate it becomes — which is the better
2179
+ * diagnostic and the reason that loop runs first.
2180
+ */
2181
+ filterMemberView(cube, query, ctx) {
2182
+ const datasetScope = ctx.getDatasetScope?.(query.cube);
2183
+ const leaves = (node, origin) => collectFilterLeaves(node).map(
2184
+ (f) => [this.resolveFieldName(cube, f.member, "any"), origin]
2185
+ );
2186
+ const measureLeaves = (query.measures ?? []).flatMap((m) => {
2187
+ const measureFilter = datasetScope?.measureFilters?.[m];
2188
+ return measureFilter ? leaves(
2189
+ normalizeAnalyticsFilterTree({ where: measureFilter }),
2190
+ { kind: "measure-filter", measure: m }
2191
+ ) : [];
2192
+ });
2193
+ return Object.fromEntries([
2194
+ ...measureLeaves,
2195
+ ...datasetScope?.filter ? leaves(normalizeAnalyticsFilterTree({ where: datasetScope.filter }), { kind: "dataset-filter" }) : [],
2196
+ ...leaves(normalizeAnalyticsFilterTree(query), { kind: "where" })
2197
+ ]);
2198
+ }
1975
2199
  /**
1976
2200
  * Plan how to serve cross-object references on this join-less path (#3654).
1977
2201
  *
@@ -1982,21 +2206,39 @@ var ObjectQLStrategy = class {
1982
2206
  * query (direct path), a plan for an in-envelope cross-object query.
1983
2207
  *
1984
2208
  * THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
1985
- * (needs a real join to evaluate), a MULTI-HOP dimension (`a.b.c`), or a
1986
- * non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values
1987
- * cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
1988
- * `generateSql()` calls this too, so the preview accepts/rejects the same set.
2209
+ * (needs a real join to evaluate), a cross-object leaf in the DATASET's own
2210
+ * definition-level `filter` (#10861 same join it does not have, arriving
2211
+ * from the producer PR #10758 added), a cross-object leaf in ONE MEASURE's own
2212
+ * `filter` (#11461 the same join again, arriving from the producer #10413
2213
+ * phase 2 added), a MULTI-HOP dimension (`a.b.c`), or a non-recombinable
2214
+ * measure (`avg`/`count_distinct`, whose sub-bucket values cannot be merged).
2215
+ * A loud error beats the silent mis-bucket #3654 kills.
2216
+ * `generateSql()` calls this too, so the preview accepts/rejects the same set
2217
+ * — and since #10759 both callers derive `filter` from the one
2218
+ * {@link filterMemberView}, so that sentence is enforced by construction
2219
+ * instead of restated at two call sites.
2220
+ *
2221
+ * [#5716] All six refusals below are `invalidMemberError` — `INVALID_FIELD` /
2222
+ * 400, naming the member — and the four that predate #10861 keep their
2223
+ * MESSAGES unchanged (they are good diagnostics, and #5923's tests read
2224
+ * 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
2225
+ * reach the engine's predicate, and whether that member resolves across a
2226
+ * join. Neither is an internal invariant — a cube where the member exists and
2227
+ * a driver that could serve it are both perfectly ordinary, which is exactly
2228
+ * what the "run this on a native-SQL driver" half of every message says. They
2229
+ * are member-level rather than dataset-level (hence not `datasetInvalidError`)
2230
+ * because the fix is always to change or drop ONE named member, and because
2231
+ * four of them fire on `/analytics/query` where no dataset exists.
1989
2232
  *
1990
- * [#5716] All four refusals below are `invalidMemberError` `INVALID_FIELD` /
1991
- * 400, naming the member and the MESSAGES are unchanged (they are good
1992
- * diagnostics, and #5923's tests read them). Each is decided by two caller-side
1993
- * facts and nothing else: a member the query named, and whether that member
1994
- * resolves across a join. Neither is an internal invariant a cube where the
1995
- * member exists and a driver that could serve it are both perfectly ordinary,
1996
- * which is exactly what the "run this on a native-SQL driver" half of each
1997
- * message says. They are member-level rather than dataset-level (hence not
1998
- * `datasetInvalidError`) because the fix is always to change or drop ONE named
1999
- * member, and because they fire on `/analytics/query` where no dataset exists.
2233
+ * [#10861, #11461] The fifth and sixth are the exceptions that prove the rule
2234
+ * and are written to it: they can only fire where a dataset DOES exist, and
2235
+ * they are the two refusals here whose member no request key named — so each
2236
+ * carries `cube` and no `param`, and says in its own words which document to
2237
+ * go and edit, the sixth naming the MEASURE inside it as well. Both stay
2238
+ * `INVALID_FIELD` rather than becoming `DATASET_INVALID` because the verdict
2239
+ * is the same physical one as their neighbours this engine cannot join this
2240
+ * member and splitting the code by PROVENANCE would make a caller branch on
2241
+ * three wire shapes for one capability limit.
2000
2242
  *
2001
2243
  * Detection is on RESOLVED field names, so a dotted dimension the cube
2002
2244
  * flattens to a real column is treated as base, not cross-object.
@@ -2018,7 +2260,7 @@ var ObjectQLStrategy = class {
2018
2260
  member: m,
2019
2261
  field: this.resolveMeasureAggregation(cube, m).field
2020
2262
  })),
2021
- ...Object.keys(filter).map((f) => ({ where: "filter", member: f, field: f }))
2263
+ ...Object.entries(filter).filter(([, origin]) => origin.kind === "where").map(([f]) => ({ where: "filter", member: f, field: f }))
2022
2264
  ].filter((r) => this.isCrossObjectField(cube, r.field, baseObject));
2023
2265
  if (nonDim.length > 0) {
2024
2266
  throw invalidMemberError(
@@ -2032,6 +2274,23 @@ var ObjectQLStrategy = class {
2032
2274
  }
2033
2275
  );
2034
2276
  }
2277
+ const scopeCross = Object.entries(filter).filter(([field, origin]) => origin.kind === "dataset-filter" && this.isCrossObjectField(cube, field, baseObject)).map(([field]) => field);
2278
+ if (scopeCross.length > 0) {
2279
+ throw invalidMemberError(
2280
+ `[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.`,
2281
+ { member: scopeCross[0], cube: cube.name }
2282
+ );
2283
+ }
2284
+ const measureCross = Object.entries(filter).flatMap(
2285
+ ([field, origin]) => origin.kind === "measure-filter" && this.isCrossObjectField(cube, field, baseObject) ? [{ field, measure: origin.measure }] : []
2286
+ );
2287
+ if (measureCross.length > 0) {
2288
+ const { field, measure } = measureCross[0];
2289
+ throw invalidMemberError(
2290
+ `[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.`,
2291
+ { member: field, cube: cube.name }
2292
+ );
2293
+ }
2035
2294
  const crossDims = [];
2036
2295
  for (const dim of query.dimensions ?? []) {
2037
2296
  const field = this.resolveFieldName(cube, dim, "dimension");
@@ -2135,6 +2394,7 @@ var ObjectQLStrategy = class {
2135
2394
  if (fkValues.length === 0 || typeof ctx.executeAggregate !== "function") return map;
2136
2395
  const idFilter = { id: { $in: fkValues } };
2137
2396
  const scope = typeof ctx.getReadScope === "function" ? ctx.getReadScope(refObject) : null;
2397
+ if (scope != null) assertReadScopeCannotVacate(scope, refObject);
2138
2398
  if (scope != null) (0, import_data3.markFilterSubtreeProvenance)(scope, "policy");
2139
2399
  const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
2140
2400
  const rows = await ctx.executeAggregate(refObject, {
@@ -2148,6 +2408,45 @@ var ObjectQLStrategy = class {
2148
2408
  }
2149
2409
  return map;
2150
2410
  }
2411
+ /**
2412
+ * A measure's aggregate, restricted to the rows its own `filter` admits
2413
+ * (#10413 phase 2) — the same six functions `generateSql`'s unconditional
2414
+ * branch renders, wrapped in a `CASE WHEN`.
2415
+ *
2416
+ * Spelled `CASE WHEN` rather than SQL-standard `FILTER (WHERE …)`, mirroring
2417
+ * `NativeSQLStrategy.CONDITIONAL_AGGREGATE_SQL`: this string is DOCUMENTATION
2418
+ * of an execution that really goes through `engine.aggregate`'s per-driver
2419
+ * `aggregations[].filter` lowering (#10576), not a statement this class runs
2420
+ * itself, so there is no reason to pick a dialect-restricted spelling over
2421
+ * the portable one the SQL-executing sibling already settled on.
2422
+ *
2423
+ * `count` over `*` counts a constant (`COUNT(CASE WHEN p THEN 1 END)`, since
2424
+ * `COUNT(CASE WHEN p THEN * END)` is not valid SQL); over a real column it
2425
+ * counts that column's non-null values among the admitted rows.
2426
+ */
2427
+ conditionalAggregateSql(method, col, pred) {
2428
+ const target = col === "*" ? "1" : col;
2429
+ switch (method) {
2430
+ case "count":
2431
+ return `COUNT(CASE WHEN ${pred} THEN ${target} END)`;
2432
+ case "count_distinct":
2433
+ return `COUNT(DISTINCT CASE WHEN ${pred} THEN ${col} END)`;
2434
+ case "sum":
2435
+ return `SUM(CASE WHEN ${pred} THEN ${col} END)`;
2436
+ case "avg":
2437
+ return `AVG(CASE WHEN ${pred} THEN ${col} END)`;
2438
+ case "min":
2439
+ return `MIN(CASE WHEN ${pred} THEN ${col} END)`;
2440
+ case "max":
2441
+ return `MAX(CASE WHEN ${pred} THEN ${col} END)`;
2442
+ // Closed vocabulary, same posture as `resolveMeasureAggregation`'s
2443
+ // callers: `method` comes only from that function, whose own aggTypes
2444
+ // list is exactly these six, so this default is unreachable rather than
2445
+ // a silent fallback for a method this table forgot.
2446
+ default:
2447
+ return `${method.toUpperCase()}(CASE WHEN ${pred} THEN ${col} END)`;
2448
+ }
2449
+ }
2151
2450
  /**
2152
2451
  * Render one normalized filter as a display SQL predicate for `generateSql`.
2153
2452
  *
@@ -2239,8 +2538,20 @@ var ObjectQLStrategy = class {
2239
2538
  resolveMeasureAggregation(cube, measureName) {
2240
2539
  const direct = this.lookupMember(cube, measureName, "measure");
2241
2540
  if (direct) {
2541
+ if (EXPRESSION_METRIC_TYPES.has(direct.type)) {
2542
+ throw invalidMemberError(
2543
+ `[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.`,
2544
+ { member: measureName, param: "measures", cube: cube.name }
2545
+ );
2546
+ }
2242
2547
  return {
2243
2548
  field: direct.sql.replace(/^\$/, ""),
2549
+ // The assertion, not a parse: for a CubeSchema-legal cube the type
2550
+ // partition above leaves exactly the six `AggregationFunction` values.
2551
+ // An enum-INVALID type (host drift, the comment above) still flows
2552
+ // through unchecked ON PURPOSE — adding a method allowlist here would
2553
+ // re-blame the caller with a 400 for OUR bug, so the cast keeps the
2554
+ // compile-time contract (#12776) without changing that posture.
2244
2555
  method: direct.type === "count_distinct" ? "count_distinct" : direct.type
2245
2556
  };
2246
2557
  }
@@ -2254,7 +2565,7 @@ var ObjectQLStrategy = class {
2254
2565
  if (candidate && candidate.type === type) {
2255
2566
  return {
2256
2567
  field: candidate.sql.replace(/^\$/, ""),
2257
- method: candidate.type === "count_distinct" ? "count_distinct" : candidate.type
2568
+ method: type
2258
2569
  };
2259
2570
  }
2260
2571
  }
@@ -3786,6 +4097,16 @@ var AnalyticsService = class {
3786
4097
  // Prefer a compiled dataset's declared relationships (D-C join allowlist);
3787
4098
  // fall back to any explicitly-configured provider for legacy cubes.
3788
4099
  getAllowedRelationships: (cubeName) => this.datasetRegistry.get(cubeName)?.allowedRelationships ?? config.getAllowedRelationships?.(cubeName),
4100
+ // [#10298] The compiled dataset's definition-level filter and its
4101
+ // per-measure filters — the half of the declaration the Cube model has
4102
+ // no room for. Same shape and same registry as `getAllowedRelationships`
4103
+ // directly above: answered for a cube that IS a compiled dataset,
4104
+ // `undefined` for every other cube.
4105
+ getDatasetScope: (cubeName) => {
4106
+ const compiled = this.datasetRegistry.get(cubeName);
4107
+ if (!compiled) return void 0;
4108
+ return { filter: compiled.filter, measureFilters: compiled.measureFilters };
4109
+ },
3789
4110
  coerceTemporalFilterValue: config.coerceTemporalFilterValue,
3790
4111
  coerceTemporalFilterColumn: config.coerceTemporalFilterColumn,
3791
4112
  isExternalObject: config.isExternalObject
@@ -3808,15 +4129,59 @@ var AnalyticsService = class {
3808
4129
  * current request's ExecutionContext (ADR-0021 D-C). The strategy then sees a
3809
4130
  * `getReadScope(objectName)` that already knows the active tenant.
3810
4131
  */
3811
- async callCtx(query, context) {
3812
- if (!this.readScopeProvider) return { ...this.baseCtx, context };
4132
+ async callCtx(query, context, tokenCtx) {
4133
+ const getDatasetScope = this.resolvedDatasetScopeGetter(tokenCtx);
4134
+ if (!this.readScopeProvider) return { ...this.baseCtx, context, getDatasetScope };
3813
4135
  const scopes = await this.resolveReadScopes(query, context);
3814
4136
  return {
3815
4137
  ...this.baseCtx,
3816
4138
  context,
4139
+ getDatasetScope,
3817
4140
  getReadScope: (objectName) => scopes.get(objectName) ?? null
3818
4141
  };
3819
4142
  }
4143
+ /**
4144
+ * [#12230] Copy-on-write expansion of filter placeholders across everything
4145
+ * a DIRECT analytics query compares on: `where` and each time dimension's
4146
+ * `dateRange` — the same positions `DatasetExecutor.resolveSelectionTokens`
4147
+ * covers for the dashboard door, minus the dataset-only channels it alone
4148
+ * carries (measure filters ride the dataset-scope getter below).
4149
+ *
4150
+ * The input is never mutated: a query object can be caller-owned metadata
4151
+ * (a saved report definition, a flow node's config) reused across requests,
4152
+ * and resolving in place would bake one request's user id into every later
4153
+ * render. Returns the SAME object when nothing resolved.
4154
+ */
4155
+ resolveQueryTokens(query, tokenCtx) {
4156
+ const where = (0, import_core6.resolveFilterTokens)(query.where, tokenCtx);
4157
+ const timeDimensions = query.timeDimensions?.map((td) => {
4158
+ if (td.dateRange == null) return td;
4159
+ const dateRange = (0, import_core6.resolveFilterTokens)(td.dateRange, tokenCtx);
4160
+ return dateRange === td.dateRange ? td : { ...td, dateRange };
4161
+ });
4162
+ const tdChanged = timeDimensions !== void 0 && timeDimensions.some((td, i) => td !== query.timeDimensions[i]);
4163
+ if (where === query.where && !tdChanged) return query;
4164
+ const out = { ...query };
4165
+ if (where !== query.where) out.where = where;
4166
+ if (tdChanged) out.timeDimensions = timeDimensions;
4167
+ return out;
4168
+ }
4169
+ /**
4170
+ * [#12230] A per-request `getDatasetScope` whose answers have their filter
4171
+ * placeholders resolved against THIS caller. See `callCtx` for why the
4172
+ * registry's copy cannot be handed out raw. Token-free scopes pass through
4173
+ * by reference — `resolveFilterTokens` returns its input unchanged when the
4174
+ * tree holds no placeholder, so the common case allocates nothing.
4175
+ */
4176
+ resolvedDatasetScopeGetter(tokenCtx) {
4177
+ return (cubeName) => {
4178
+ const scope = this.baseCtx.getDatasetScope?.(cubeName);
4179
+ if (!scope) return scope;
4180
+ const filter = (0, import_core6.resolveFilterTokens)(scope.filter, tokenCtx);
4181
+ const measureFilters = (0, import_core6.resolveFilterTokens)(scope.measureFilters, tokenCtx);
4182
+ return filter === scope.filter && measureFilters === scope.measureFilters ? scope : { filter, measureFilters };
4183
+ };
4184
+ }
3820
4185
  /**
3821
4186
  * Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
3822
4187
  * AND every joined object of the query's cube, keyed by object name. This is
@@ -3874,12 +4239,14 @@ var AnalyticsService = class {
3874
4239
  * aggregate bridge) instead of failing — or worse, fabricating empty rows.
3875
4240
  * Any other error propagates untouched.
3876
4241
  */
3877
- async query(query, context) {
3878
- if (!query.cube) {
4242
+ async query(queryInput, context) {
4243
+ if (!queryInput.cube) {
3879
4244
  throw new Error("Cube name is required in analytics query");
3880
4245
  }
4246
+ const tokenCtx = (0, import_core6.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
4247
+ const query = this.resolveQueryTokens(queryInput, tokenCtx);
3881
4248
  this.ensureCube(query);
3882
- const ctx = await this.callCtx(query, context);
4249
+ const ctx = await this.callCtx(query, context, tokenCtx);
3883
4250
  let skip;
3884
4251
  for (; ; ) {
3885
4252
  const strategy = this.resolveStrategy(query, ctx, skip);
@@ -4075,6 +4442,7 @@ var AnalyticsService = class {
4075
4442
  const label = (0, import_ui2.resolveI18nLabel)(m.label, requestLocale);
4076
4443
  if (label !== void 0) f.label = label;
4077
4444
  }
4445
+ if (f.builtinAggregate == null && m.label == null && m.aggregate) f.builtinAggregate = m.aggregate;
4078
4446
  if (f.format == null && m.format) f.format = m.format;
4079
4447
  const fc = f;
4080
4448
  const mc = m;
@@ -4133,12 +4501,14 @@ var AnalyticsService = class {
4133
4501
  /**
4134
4502
  * Generate SQL for a query without executing it (dry-run).
4135
4503
  */
4136
- async generateSql(query, context) {
4137
- if (!query.cube) {
4504
+ async generateSql(queryInput, context) {
4505
+ if (!queryInput.cube) {
4138
4506
  throw new Error("Cube name is required for SQL generation");
4139
4507
  }
4508
+ const tokenCtx = (0, import_core6.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
4509
+ const query = this.resolveQueryTokens(queryInput, tokenCtx);
4140
4510
  this.ensureCube(query);
4141
- const ctx = await this.callCtx(query, context);
4511
+ const ctx = await this.callCtx(query, context, tokenCtx);
4142
4512
  const strategy = this.resolveStrategy(query, ctx);
4143
4513
  this.logger.debug(`[Analytics] generateSql on cube "${query.cube}" \u2192 ${strategy.name}`);
4144
4514
  return strategy.generateSql(query, ctx);
@@ -4652,6 +5022,16 @@ var FallbackDelegateStrategy = class {
4652
5022
  };
4653
5023
 
4654
5024
  // src/plugin.ts
5025
+ var import_data7 = require("@objectstack/spec/data");
5026
+ function parseEngineAggregateFunction(method, alias) {
5027
+ const parsed = import_data7.AggregationFunction.safeParse(method);
5028
+ if (!parsed.success) {
5029
+ throw new Error(
5030
+ `[Analytics] The aggregate bridge cannot forward the aggregation "${alias}": "${method}" is not one of the engine's aggregate functions (${import_data7.AggregationFunction.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.`
5031
+ );
5032
+ }
5033
+ return parsed.data;
5034
+ }
4655
5035
  var AnalyticsServicePlugin = class {
4656
5036
  constructor(options = {}) {
4657
5037
  this.name = "com.objectstack.service-analytics";
@@ -4709,10 +5089,58 @@ var AnalyticsServicePlugin = class {
4709
5089
  const rows = await engine.aggregate(objectName, {
4710
5090
  where: filter,
4711
5091
  groupBy,
5092
+ // [#10413 phase 2 / #10576] `a.filter` is the per-aggregation
5093
+ // predicate `ObjectQLStrategy` lowers a measure's own scoped
5094
+ // `filter` into. This map already renames `method` → `function`
5095
+ // for the engine's own vocabulary; dropping `filter` here — as this
5096
+ // bridge did before this line existed — would have made the
5097
+ // strategy's lowering a NO-OP on every real deployment that boots
5098
+ // through this auto-bridge (the default path: `new
5099
+ // AnalyticsServicePlugin({ cubes })` with no custom
5100
+ // `executeAggregate`), passing every unit test that stubs
5101
+ // `executeAggregate` directly while silently dropping the filter in
5102
+ // production — the exact declared-≠-enforced shape Prime Directive
5103
+ // #10 calls out. Omitted (not `filter: undefined`) when the
5104
+ // aggregation carries none, matching the engine's own
5105
+ // vacuous-filter convention.
4712
5106
  aggregations: aggregations?.map((a) => ({
4713
- function: a.method,
5107
+ // [#11833] `function` is the engine contract's SIX-value
5108
+ // `AggregationFunction`. This bridge's own input declared
5109
+ // `method: string` when that history was written
5110
+ // (`StrategyContext.executeAggregate`, spec
5111
+ // `contracts/analytics-service.ts`), so the two ends of this
5112
+ // rename spoke different vocabularies: narrowing the engine side
5113
+ // to the contract turned the forward into a compile error — the
5114
+ // correct signal, and the one the deleted structural type hid by
5115
+ // declaring `function: string` on both sides.
5116
+ //
5117
+ // Since #12776 (contract) and #12940 (this plugin's own config
5118
+ // mirror above), BOTH ends declare the enum, so the rename is
5119
+ // enum-to-enum and the parse below is defence in depth behind a
5120
+ // compile-time check rather than the only check — see
5121
+ // `parseEngineAggregateFunction` for why erased types still leave
5122
+ // it load-bearing.
5123
+ //
5124
+ // It was closed by PARSING with the spec enum itself rather than
5125
+ // by widening back to `string` (what hid it) or casting past it
5126
+ // (which keeps the hole and adds a lie). `AggregationFunction` is
5127
+ // the same schema `AggregationNodeSchema.function` is built from,
5128
+ // so there is one vocabulary, and its own error map already
5129
+ // carries the `array_agg`/`string_agg` retirement prescriptions.
5130
+ //
5131
+ // TIERING, deliberately: the reachable producer of a non-aggregate
5132
+ // method — a custom-SQL measure (`AggregationMetricType`
5133
+ // `number`/`string`/`boolean`) — is already refused upstream with a
5134
+ // caller-blaming 400 by `ObjectQLStrategy.resolveMeasureAggregation`
5135
+ // (#12209). Anything still arriving here is host drift, which that
5136
+ // refusal's docblock assigns to the undeclared-500 tier — so this
5137
+ // throws rather than re-blaming the caller, and it answers loudly
5138
+ // instead of letting the engine answer `null` per bucket under the
5139
+ // author's own measure name (the #4157 class).
5140
+ function: parseEngineAggregateFunction(a.method, a.alias),
4714
5141
  field: a.field,
4715
- alias: a.alias
5142
+ alias: a.alias,
5143
+ ...a.filter ? { filter: a.filter } : {}
4716
5144
  })),
4717
5145
  // ADR-0053 Phase 2: thread the reference tz so date buckets resolve on
4718
5146
  // that zone's calendar days (engine buckets in-memory when non-UTC).
@@ -4814,6 +5242,7 @@ var AnalyticsServicePlugin = class {
4814
5242
  const map = /* @__PURE__ */ new Map();
4815
5243
  const displayField = pickDisplayField(dataEngine()?.getObject?.(targetObject)?.fields);
4816
5244
  if (!displayField || !executeAggregate || ids.length === 0) return map;
5245
+ if (scope) assertReadScopeCannotVacate(scope, targetObject);
4817
5246
  const CHUNK = 500;
4818
5247
  for (let i = 0; i < ids.length; i += CHUNK) {
4819
5248
  const idFilter = { id: { $in: ids.slice(i, i + CHUNK) } };