@objectstack/service-analytics 17.2.0 → 17.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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
  }
@@ -1327,6 +1381,7 @@ var NativeSQLStrategy = class {
1327
1381
  const filter = ctx.getReadScope(objectName);
1328
1382
  if (filter === void 0 || filter === null) return;
1329
1383
  const { sql, params: scopeParams } = compileScopedFilterToSql(filter, alias);
1384
+ assertReadScopeCannotVacate(filter, objectName);
1330
1385
  if (!sql) return;
1331
1386
  let i = 0;
1332
1387
  const rendered = sql.replace(/\?/g, () => {
@@ -1780,11 +1835,16 @@ var ObjectQLStrategy = class {
1780
1835
  for (const [dim, gran] of granByDim) {
1781
1836
  groupBy.push({ field: this.resolveFieldName(cube, dim, "dimension"), dateGranularity: gran });
1782
1837
  }
1838
+ const datasetScope = ctx.getDatasetScope?.(query.cube);
1783
1839
  const aggregations = [];
1784
1840
  if (query.measures && query.measures.length > 0) {
1785
1841
  for (const measure of query.measures) {
1786
1842
  const { field, method } = this.resolveMeasureAggregation(cube, measure);
1787
- 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
+ );
1788
1848
  }
1789
1849
  }
1790
1850
  const filter = {};
@@ -1794,7 +1854,6 @@ var ObjectQLStrategy = class {
1794
1854
  const extra = this.mergeFilterOperand(filter, field, bounds);
1795
1855
  if (extra) conjuncts.push(extra);
1796
1856
  }
1797
- const datasetScope = ctx.getDatasetScope?.(query.cube);
1798
1857
  if (datasetScope?.filter) {
1799
1858
  const scopeCondition = this.filterNodeToCondition(
1800
1859
  normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
@@ -1883,6 +1942,7 @@ var ObjectQLStrategy = class {
1883
1942
  }
1884
1943
  const tableName = this.extractObjectName(cube);
1885
1944
  const plan = this.planCrossObject(cube, query, this.filterMemberView(cube, query, ctx));
1945
+ const datasetScope = ctx.getDatasetScope?.(query.cube);
1886
1946
  const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd]));
1887
1947
  const joinClauses = [];
1888
1948
  const dimExpr = (dim) => {
@@ -1913,7 +1973,9 @@ var ObjectQLStrategy = class {
1913
1973
  if (query.measures) {
1914
1974
  for (const m of query.measures) {
1915
1975
  const { field, method } = this.resolveMeasureAggregation(cube, m);
1916
- 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})`;
1917
1979
  selectParts.push(`${aggSql} AS "${m}"`);
1918
1980
  }
1919
1981
  }
@@ -1924,10 +1986,9 @@ var ObjectQLStrategy = class {
1924
1986
  params
1925
1987
  );
1926
1988
  if (filterClause) whereParts.push(filterClause);
1927
- const echoedDatasetFilter = ctx.getDatasetScope?.(query.cube)?.filter;
1928
- if (echoedDatasetFilter) {
1989
+ if (datasetScope?.filter) {
1929
1990
  const scopeSql = this.renderFilterNodeSql(
1930
- normalizeAnalyticsFilterTree({ where: echoedDatasetFilter }),
1991
+ normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
1931
1992
  cube,
1932
1993
  params
1933
1994
  );
@@ -1943,6 +2004,7 @@ var ObjectQLStrategy = class {
1943
2004
  const scope = ctx.getReadScope?.(tableName);
1944
2005
  if (scope != null) {
1945
2006
  const { sql: scopeSql, params: scopeParams } = compileScopedFilterToSql(scope, tableName);
2007
+ assertReadScopeCannotVacate(scope, tableName);
1946
2008
  if (scopeSql) {
1947
2009
  let i = 0;
1948
2010
  const rendered = scopeSql.replace(/\?/g, () => {
@@ -1989,6 +2051,7 @@ var ObjectQLStrategy = class {
1989
2051
  if (typeof ctx.getReadScope !== "function") return userFilter;
1990
2052
  const scope = ctx.getReadScope(objectName);
1991
2053
  if (scope === void 0 || scope === null) return userFilter;
2054
+ assertReadScopeCannotVacate(scope, objectName);
1992
2055
  const scopeFilter = markFilterSubtreeProvenance(scope, "policy");
1993
2056
  if (!userFilter) return scopeFilter;
1994
2057
  return { $and: [userFilter, scopeFilter] };
@@ -2017,7 +2080,7 @@ var ObjectQLStrategy = class {
2017
2080
  * the members inside were unreadable from the outside and the envelope check
2018
2081
  * could not reject what it could not see.
2019
2082
  *
2020
- * ## Two producers, one inventory (#10861)
2083
+ * ## Three producers, one inventory (#10861, #11461)
2021
2084
  *
2022
2085
  * The caller's `where` is not the only thing that reaches `engine.aggregate`
2023
2086
  * as a predicate. Since PR #10758 the compiled dataset's own definition-level
@@ -2032,6 +2095,36 @@ var ObjectQLStrategy = class {
2032
2095
  * which driver will serve the dataset and would refuse a dataset that is
2033
2096
  * perfectly legal on a native-SQL deployment.
2034
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
+ *
2035
2128
  * Structure is discarded on purpose — a member is cross-object or it is not,
2036
2129
  * and which branch of a disjunction it sits in cannot make
2037
2130
  * `engine.aggregate` able to join it. PROVENANCE is not discarded, because it
@@ -2041,9 +2134,12 @@ var ObjectQLStrategy = class {
2041
2134
  * `planCrossObject`. The value slot carries that and nothing else; it never
2042
2135
  * reaches a driver.
2043
2136
  *
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.
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.
2047
2143
  *
2048
2144
  * Time-dimension WINDOWS are deliberately absent (they live in
2049
2145
  * `dateRangeBounds`, not in `where`). They need no arm here: a cross-object
@@ -2053,13 +2149,21 @@ var ObjectQLStrategy = class {
2053
2149
  * diagnostic and the reason that loop runs first.
2054
2150
  */
2055
2151
  filterMemberView(cube, query, ctx) {
2056
- const datasetFilter = ctx.getDatasetScope?.(query.cube)?.filter;
2152
+ const datasetScope = ctx.getDatasetScope?.(query.cube);
2057
2153
  const leaves = (node, origin) => collectFilterLeaves(node).map(
2058
2154
  (f) => [this.resolveFieldName(cube, f.member, "any"), origin]
2059
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
+ });
2060
2163
  return Object.fromEntries([
2061
- ...datasetFilter ? leaves(normalizeAnalyticsFilterTree({ where: datasetFilter }), "dataset-filter") : [],
2062
- ...leaves(normalizeAnalyticsFilterTree(query), "where")
2164
+ ...measureLeaves,
2165
+ ...datasetScope?.filter ? leaves(normalizeAnalyticsFilterTree({ where: datasetScope.filter }), { kind: "dataset-filter" }) : [],
2166
+ ...leaves(normalizeAnalyticsFilterTree(query), { kind: "where" })
2063
2167
  ]);
2064
2168
  }
2065
2169
  /**
@@ -2074,18 +2178,20 @@ var ObjectQLStrategy = class {
2074
2178
  * THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
2075
2179
  * (needs a real join to evaluate), a cross-object leaf in the DATASET's own
2076
2180
  * 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
2078
- * non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values
2079
- * cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
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.
2080
2186
  * `generateSql()` calls this too, so the preview accepts/rejects the same set
2081
2187
  * — and since #10759 both callers derive `filter` from the one
2082
2188
  * {@link filterMemberView}, so that sentence is enforced by construction
2083
2189
  * instead of restated at two call sites.
2084
2190
  *
2085
- * [#5716] All five refusals below are `invalidMemberError` — `INVALID_FIELD` /
2191
+ * [#5716] All six refusals below are `invalidMemberError` — `INVALID_FIELD` /
2086
2192
  * 400, naming the member — and the four that predate #10861 keep their
2087
2193
  * 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
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
2089
2195
  * reach the engine's predicate, and whether that member resolves across a
2090
2196
  * join. Neither is an internal invariant — a cube where the member exists and
2091
2197
  * a driver that could serve it are both perfectly ordinary, which is exactly
@@ -2094,14 +2200,15 @@ var ObjectQLStrategy = class {
2094
2200
  * because the fix is always to change or drop ONE named member, and because
2095
2201
  * four of them fire on `/analytics/query` where no dataset exists.
2096
2202
  *
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
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
2101
2208
  * `INVALID_FIELD` rather than becoming `DATASET_INVALID` because the verdict
2102
- * is the same physical one as its neighbour — this engine cannot join this
2209
+ * is the same physical one as their neighbours — this engine cannot join this
2103
2210
  * member — and splitting the code by PROVENANCE would make a caller branch on
2104
- * two wire shapes for one capability limit.
2211
+ * three wire shapes for one capability limit.
2105
2212
  *
2106
2213
  * Detection is on RESOLVED field names, so a dotted dimension the cube
2107
2214
  * flattens to a real column is treated as base, not cross-object.
@@ -2123,7 +2230,7 @@ var ObjectQLStrategy = class {
2123
2230
  member: m,
2124
2231
  field: this.resolveMeasureAggregation(cube, m).field
2125
2232
  })),
2126
- ...Object.entries(filter).filter(([, origin]) => origin === "where").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 }))
2127
2234
  ].filter((r) => this.isCrossObjectField(cube, r.field, baseObject));
2128
2235
  if (nonDim.length > 0) {
2129
2236
  throw invalidMemberError(
@@ -2137,13 +2244,23 @@ var ObjectQLStrategy = class {
2137
2244
  }
2138
2245
  );
2139
2246
  }
2140
- const scopeCross = Object.entries(filter).filter(([field, origin]) => origin === "dataset-filter" && this.isCrossObjectField(cube, field, baseObject)).map(([field]) => field);
2247
+ const scopeCross = Object.entries(filter).filter(([field, origin]) => origin.kind === "dataset-filter" && this.isCrossObjectField(cube, field, baseObject)).map(([field]) => field);
2141
2248
  if (scopeCross.length > 0) {
2142
2249
  throw invalidMemberError(
2143
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.`,
2144
2251
  { member: scopeCross[0], cube: cube.name }
2145
2252
  );
2146
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
+ }
2147
2264
  const crossDims = [];
2148
2265
  for (const dim of query.dimensions ?? []) {
2149
2266
  const field = this.resolveFieldName(cube, dim, "dimension");
@@ -2247,6 +2364,7 @@ var ObjectQLStrategy = class {
2247
2364
  if (fkValues.length === 0 || typeof ctx.executeAggregate !== "function") return map;
2248
2365
  const idFilter = { id: { $in: fkValues } };
2249
2366
  const scope = typeof ctx.getReadScope === "function" ? ctx.getReadScope(refObject) : null;
2367
+ if (scope != null) assertReadScopeCannotVacate(scope, refObject);
2250
2368
  if (scope != null) markFilterSubtreeProvenance(scope, "policy");
2251
2369
  const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
2252
2370
  const rows = await ctx.executeAggregate(refObject, {
@@ -2260,6 +2378,45 @@ var ObjectQLStrategy = class {
2260
2378
  }
2261
2379
  return map;
2262
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
+ }
2263
2420
  /**
2264
2421
  * Render one normalized filter as a display SQL predicate for `generateSql`.
2265
2422
  *
@@ -2351,8 +2508,20 @@ var ObjectQLStrategy = class {
2351
2508
  resolveMeasureAggregation(cube, measureName) {
2352
2509
  const direct = this.lookupMember(cube, measureName, "measure");
2353
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
+ }
2354
2517
  return {
2355
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.
2356
2525
  method: direct.type === "count_distinct" ? "count_distinct" : direct.type
2357
2526
  };
2358
2527
  }
@@ -2366,7 +2535,7 @@ var ObjectQLStrategy = class {
2366
2535
  if (candidate && candidate.type === type) {
2367
2536
  return {
2368
2537
  field: candidate.sql.replace(/^\$/, ""),
2369
- method: candidate.type === "count_distinct" ? "count_distinct" : candidate.type
2538
+ method: type
2370
2539
  };
2371
2540
  }
2372
2541
  }
@@ -3930,15 +4099,59 @@ var AnalyticsService = class {
3930
4099
  * current request's ExecutionContext (ADR-0021 D-C). The strategy then sees a
3931
4100
  * `getReadScope(objectName)` that already knows the active tenant.
3932
4101
  */
3933
- async callCtx(query, context) {
3934
- 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 };
3935
4105
  const scopes = await this.resolveReadScopes(query, context);
3936
4106
  return {
3937
4107
  ...this.baseCtx,
3938
4108
  context,
4109
+ getDatasetScope,
3939
4110
  getReadScope: (objectName) => scopes.get(objectName) ?? null
3940
4111
  };
3941
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
+ }
3942
4155
  /**
3943
4156
  * Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
3944
4157
  * AND every joined object of the query's cube, keyed by object name. This is
@@ -3996,12 +4209,14 @@ var AnalyticsService = class {
3996
4209
  * aggregate bridge) instead of failing — or worse, fabricating empty rows.
3997
4210
  * Any other error propagates untouched.
3998
4211
  */
3999
- async query(query, context) {
4000
- if (!query.cube) {
4212
+ async query(queryInput, context) {
4213
+ if (!queryInput.cube) {
4001
4214
  throw new Error("Cube name is required in analytics query");
4002
4215
  }
4216
+ const tokenCtx = filterTokenContextFrom2(context, /* @__PURE__ */ new Date());
4217
+ const query = this.resolveQueryTokens(queryInput, tokenCtx);
4003
4218
  this.ensureCube(query);
4004
- const ctx = await this.callCtx(query, context);
4219
+ const ctx = await this.callCtx(query, context, tokenCtx);
4005
4220
  let skip;
4006
4221
  for (; ; ) {
4007
4222
  const strategy = this.resolveStrategy(query, ctx, skip);
@@ -4197,6 +4412,7 @@ var AnalyticsService = class {
4197
4412
  const label = resolveI18nLabel2(m.label, requestLocale);
4198
4413
  if (label !== void 0) f.label = label;
4199
4414
  }
4415
+ if (f.builtinAggregate == null && m.label == null && m.aggregate) f.builtinAggregate = m.aggregate;
4200
4416
  if (f.format == null && m.format) f.format = m.format;
4201
4417
  const fc = f;
4202
4418
  const mc = m;
@@ -4255,12 +4471,14 @@ var AnalyticsService = class {
4255
4471
  /**
4256
4472
  * Generate SQL for a query without executing it (dry-run).
4257
4473
  */
4258
- async generateSql(query, context) {
4259
- if (!query.cube) {
4474
+ async generateSql(queryInput, context) {
4475
+ if (!queryInput.cube) {
4260
4476
  throw new Error("Cube name is required for SQL generation");
4261
4477
  }
4478
+ const tokenCtx = filterTokenContextFrom2(context, /* @__PURE__ */ new Date());
4479
+ const query = this.resolveQueryTokens(queryInput, tokenCtx);
4262
4480
  this.ensureCube(query);
4263
- const ctx = await this.callCtx(query, context);
4481
+ const ctx = await this.callCtx(query, context, tokenCtx);
4264
4482
  const strategy = this.resolveStrategy(query, ctx);
4265
4483
  this.logger.debug(`[Analytics] generateSql on cube "${query.cube}" \u2192 ${strategy.name}`);
4266
4484
  return strategy.generateSql(query, ctx);
@@ -4774,6 +4992,16 @@ var FallbackDelegateStrategy = class {
4774
4992
  };
4775
4993
 
4776
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
+ }
4777
5005
  var AnalyticsServicePlugin = class {
4778
5006
  constructor(options = {}) {
4779
5007
  this.name = "com.objectstack.service-analytics";
@@ -4831,10 +5059,58 @@ var AnalyticsServicePlugin = class {
4831
5059
  const rows = await engine.aggregate(objectName, {
4832
5060
  where: filter,
4833
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.
4834
5076
  aggregations: aggregations?.map((a) => ({
4835
- 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),
4836
5111
  field: a.field,
4837
- alias: a.alias
5112
+ alias: a.alias,
5113
+ ...a.filter ? { filter: a.filter } : {}
4838
5114
  })),
4839
5115
  // ADR-0053 Phase 2: thread the reference tz so date buckets resolve on
4840
5116
  // that zone's calendar days (engine buckets in-memory when non-UTC).
@@ -4936,6 +5212,7 @@ var AnalyticsServicePlugin = class {
4936
5212
  const map = /* @__PURE__ */ new Map();
4937
5213
  const displayField = pickDisplayField(dataEngine()?.getObject?.(targetObject)?.fields);
4938
5214
  if (!displayField || !executeAggregate || ids.length === 0) return map;
5215
+ if (scope) assertReadScopeCannotVacate(scope, targetObject);
4939
5216
  const CHUNK = 500;
4940
5217
  for (let i = 0; i < ids.length; i += CHUNK) {
4941
5218
  const idFilter = { id: { $in: ids.slice(i, i + CHUNK) } };