@objectstack/service-analytics 17.0.0 → 17.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -41,9 +41,9 @@ __export(index_exports, {
41
41
  module.exports = __toCommonJS(index_exports);
42
42
 
43
43
  // src/analytics-service.ts
44
- var import_data5 = require("@objectstack/spec/data");
44
+ var import_data6 = require("@objectstack/spec/data");
45
45
  var import_ui2 = require("@objectstack/spec/ui");
46
- var import_core5 = require("@objectstack/core");
46
+ var import_core6 = require("@objectstack/core");
47
47
  var import_types = require("@objectstack/types");
48
48
 
49
49
  // src/cube-registry.ts
@@ -162,21 +162,18 @@ var CubeRegistry = class {
162
162
  };
163
163
 
164
164
  // src/strategies/filter-normalizer.ts
165
- var import_data = require("@objectstack/spec/data");
165
+ var import_data2 = require("@objectstack/spec/data");
166
166
  var import_api = require("@objectstack/spec/api");
167
167
 
168
168
  // src/comparand-shape.ts
169
+ var import_core = require("@objectstack/core");
170
+ var import_data = require("@objectstack/spec/data");
169
171
  function isBindableComparand(value) {
170
- if (value === null || value === void 0) return true;
171
- const kind = typeof value;
172
- if (kind === "string" || kind === "number" || kind === "bigint" || kind === "boolean") return true;
173
- return value instanceof Date || ArrayBuffer.isView(value);
172
+ if (value === void 0) return true;
173
+ return (0, import_data.isAcceptedFilterComparand)(value) || ArrayBuffer.isView(value);
174
174
  }
175
175
  function isRenderableTextComparand(value) {
176
- if (value === null || value === void 0) return true;
177
- const kind = typeof value;
178
- if (kind === "string" || kind === "number" || kind === "bigint" || kind === "boolean") return true;
179
- return value instanceof Date;
176
+ return value === void 0 || (0, import_data.isAcceptedFilterComparand)(value);
180
177
  }
181
178
  function isFieldReference(value) {
182
179
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
@@ -212,6 +209,51 @@ function findIn(node, field) {
212
209
  }
213
210
  return null;
214
211
  }
212
+ function findUninterpretableTemporalMember(filter, kindOf) {
213
+ return findUninterpretableIn(filter, "", kindOf);
214
+ }
215
+ function findUninterpretableIn(node, field, kindOf) {
216
+ if (!node || typeof node !== "object") return null;
217
+ if (Array.isArray(node)) {
218
+ for (const child of node) {
219
+ const hit = findUninterpretableIn(child, field, kindOf);
220
+ if (hit) return hit;
221
+ }
222
+ return null;
223
+ }
224
+ if (node instanceof Date || ArrayBuffer.isView(node)) return null;
225
+ if (isFieldReference(node)) return null;
226
+ for (const [key, value] of Object.entries(node)) {
227
+ const scope = key.startsWith("$") ? field : key;
228
+ const kind = scope ? kindOf(scope) : null;
229
+ if (kind) {
230
+ const hit2 = judgeTemporalLiterals(value, scope, kind);
231
+ if (hit2) return hit2;
232
+ continue;
233
+ }
234
+ const hit = findUninterpretableIn(value, scope, kindOf);
235
+ if (hit) return hit;
236
+ }
237
+ return null;
238
+ }
239
+ function judgeTemporalLiterals(value, field, kind) {
240
+ if (Array.isArray(value)) {
241
+ for (const member of value) {
242
+ const hit = judgeTemporalLiterals(member, field, kind);
243
+ if (hit) return hit;
244
+ }
245
+ return null;
246
+ }
247
+ if (value && typeof value === "object") {
248
+ if (value instanceof Date || ArrayBuffer.isView(value) || isFieldReference(value)) return null;
249
+ for (const nested of Object.values(value)) {
250
+ const hit = judgeTemporalLiterals(nested, field, kind);
251
+ if (hit) return hit;
252
+ }
253
+ return null;
254
+ }
255
+ return (0, import_core.isUninterpretableTemporalComparand)(kind, value) ? { field, kind, value } : null;
256
+ }
215
257
  var TEXT_PATTERN_OPERATORS = /* @__PURE__ */ new Set([
216
258
  "$contains",
217
259
  "$notContains",
@@ -229,7 +271,7 @@ function shapePreview(value) {
229
271
  }
230
272
  }
231
273
  function unrenderableTextComparandMessage(op, field, value) {
232
- return `"${op}" on "${field}" matches against the TEXT of a pattern, but its comparand is ${Array.isArray(value) ? "an array" : "an object"} (${shapePreview(value)}). filter.zod.ts declares it a string (StringOperatorSchema); a string, number, boolean, null or Date is accepted. Refusing rather than stringifying it: String({}) is "[object Object]", so the pattern that ran would be one nobody wrote \u2014 and a row storing that literal text matches it.`;
274
+ return `"${op}" on "${field}" matches against the TEXT of a pattern, but its comparand is ${Array.isArray(value) ? "an array" : "an object"} (${shapePreview(value)}). filter.zod.ts declares it a string (StringOperatorSchema); ${import_data.ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} is accepted. Refusing rather than stringifying it: String({}) is "[object Object]", so the pattern that ran would be one nobody wrote \u2014 and a row storing that literal text matches it.`;
233
275
  }
234
276
  function fieldReferenceComparandMessage(op, field, ref, position) {
235
277
  return `"${op}" on "${field}"${position ? ` (${position})` : ""} compares against the field reference { "$field": "${ref}" }, which this compiler does not lower into a column-to-column comparison. Refusing rather than binding it: the reference object used to become the BOUND VALUE of the comparison, so the emitted predicate compared "${field}" against the reference itself \u2014 a value no row can hold \u2014 and a read scope built from it answered the wrong row set with nothing to read. \u26A0\uFE0F This is NOT the platform declining the rule. @objectstack/spec declares this shape (FieldReferenceSchema), @objectstack/formula resolves it per record in memory, driver-sql / driver-sqlite-wasm compile it to a same-table column comparison for the six scalar operators since #5222, and since the 2026-08-12 ruling on #7598 the analytics native-SQL strategy DECLINES such a query so it routes to the ObjectQL engine path and runs there \u2014 the driver enforcing declared-only enumeration, the tenant-isolation ban and the comparison class with metadata it owns. What refuses here is this SQL lowering, whose only remaining caller is the /analytics/sql display echo; it has no faithful rendering of the predicate the engine path actually runs, and half-rendering one would describe a query that returns different rows. Run the query itself (/analytics/query) to get its rows (#7598).`;
@@ -238,7 +280,7 @@ function fieldReferenceBetweenBoundMessage(op, field, ref, index) {
238
280
  return `"${op}" on "${field}" has the field reference { "$field": "${ref}" } at index ${index} of its [min, max] bounds. A range BOUND may not be a field reference on any backend: driver-sql and driver-sqlite-wasm refuse both endpoints (#5222), @objectstack/formula does not resolve a reference inside a list either \u2014 it orders the bounds against the raw reference object, which no value compares meaningfully to \u2014 and @objectstack/spec no longer declares the position at all (#7596 removed FieldReferenceSchema from the $between endpoint union, ADR-0049 declared = enforced). Refusing rather than lowering it: this compiler splits $between into its two bounds, so the reference would arrive at the driver under a "$gte" / "$lte" the author never wrote \u2014 a position the SQL drivers DO compile \u2014 and the range would quietly succeed here while the identical filter is refused everywhere else. Use a literal bound, or spell the comparison you meant as a scalar one ({ "${field}": { "$gte": { "$field": "${ref}" } } }), which IS served \u2014 on the ObjectQL engine path, where the driver enforces the #5222 rulings (#7598).`;
239
281
  }
240
282
  function unbindableListMemberMessage(op, field, value, index) {
241
- return `"${op}" on "${field}" has a value at index ${index} of its list that cannot be bound as a SQL parameter: ${shapePreview(value)}. Every member of an $in/$nin/$between list is a comparand in its own right \u2014 use a string, number, boolean, null, Date or binary value. Refusing rather than binding it: the member can equal no stored value, so the list silently loses that entry (and a $nin loses the exclusion the caller wrote).`;
283
+ return `"${op}" on "${field}" has a value at index ${index} of its list that cannot be bound as a SQL parameter: ${shapePreview(value)}. Every member of an $in/$nin/$between list is a comparand in its own right \u2014 use ${import_data.ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} (or a binary value). Refusing rather than binding it: the member can equal no stored value, so the list silently loses that entry (and a $nin loses the exclusion the caller wrote).`;
242
284
  }
243
285
 
244
286
  // src/strategies/filter-normalizer.ts
@@ -586,7 +628,7 @@ function nullSafeNegationOperand(node) {
586
628
  }
587
629
  function filterArrayNotLowerableError(where) {
588
630
  return invalidFilterError(
589
- `[analytics] received a 'where' array that is not a filter: ${JSON.stringify(where)}. A filter array is a comparison [field, operator, value], a logical node ["and"|"or", ...conditions], or a list of those \u2014 it is INPUT-ONLY sugar (spec 'FilterArray'), lowered to a FilterCondition by @objectstack/spec parseFilterAST() at every door, this one included (#5158/#5334). This value cannot be lowered, and an unapplied filter would have charted the UNFILTERED dataset. Recognised operators: ${[...import_data.VALID_AST_OPERATORS].sort().join(", ")}. Infix joins ([condA, "or", condB]) are NOT one of the shapes \u2014 write the prefix form ["or", condA, condB].`
631
+ `[analytics] received a 'where' array that is not a filter: ${JSON.stringify(where)}. A filter array is a comparison [field, operator, value], a logical node ["and"|"or", ...conditions], or a list of those \u2014 it is INPUT-ONLY sugar (spec 'FilterArray'), lowered to a FilterCondition by @objectstack/spec parseFilterAST() at every door, this one included (#5158/#5334). This value cannot be lowered, and an unapplied filter would have charted the UNFILTERED dataset. Recognised operators: ${[...import_data2.VALID_AST_OPERATORS].sort().join(", ")}. Infix joins ([condA, "or", condB]) are NOT one of the shapes \u2014 write the prefix form ["or", condA, condB].`
590
632
  );
591
633
  }
592
634
  function lowerAnalyticsWhere(query) {
@@ -595,8 +637,8 @@ function lowerAnalyticsWhere(query) {
595
637
  if (!where || typeof where !== "object") return null;
596
638
  if (Array.isArray(where)) {
597
639
  if (where.length === 0) return null;
598
- if (!(0, import_data.isFilterAST)(where)) throw filterArrayNotLowerableError(where);
599
- const condition = (0, import_data.parseFilterAST)(where);
640
+ if (!(0, import_data2.isFilterAST)(where)) throw filterArrayNotLowerableError(where);
641
+ const condition = (0, import_data2.parseFilterAST)(where);
600
642
  if (!condition || typeof condition !== "object" || Array.isArray(condition)) {
601
643
  throw invalidFilterError(
602
644
  `[analytics] filter array ${JSON.stringify(where)} passed isFilterAST() but parseFilterAST() lowered it to ${JSON.stringify(condition)}. Refusing rather than charting the dataset unfiltered (#5158/#5334).`
@@ -1019,16 +1061,33 @@ function invalidMemberError(message, meta) {
1019
1061
  }
1020
1062
 
1021
1063
  // src/strategies/native-sql-strategy.ts
1022
- var import_core = require("@objectstack/core");
1064
+ var import_core2 = require("@objectstack/core");
1023
1065
  var AGGREGATE_SQL = {
1024
- "count": () => "COUNT(*)",
1066
+ // [#10298] `count` takes its COLUMN when the measure declares one. The
1067
+ // wrapper used to discard `col` and always emit `COUNT(*)`, so a measure
1068
+ // written `{ aggregate: 'count', field: 'resolved_by_article' }` counted
1069
+ // ROWS instead of non-null values — and a deflection rate built as
1070
+ // `kb_resolved_count / closed_count` read 100% where the truth was 12.5%,
1071
+ // with the numerator and denominator printed beside it as 8 and 8. `*` is
1072
+ // still `COUNT(*)`: the compiler writes `sql: m.field ?? '*'`, so the star
1073
+ // IS the "no field declared" spelling and must keep counting rows.
1074
+ "count": (col) => col === "*" ? "COUNT(*)" : `COUNT(${col})`,
1025
1075
  "sum": (col) => `SUM(${col})`,
1026
1076
  "avg": (col) => `AVG(${col})`,
1027
1077
  "min": (col) => `MIN(${col})`,
1028
1078
  "max": (col) => `MAX(${col})`,
1029
1079
  "count_distinct": (col) => `COUNT(DISTINCT ${col})`
1030
1080
  };
1081
+ var CONDITIONAL_AGGREGATE_SQL = {
1082
+ "count": (col, pred) => `COUNT(CASE WHEN ${pred} THEN ${col === "*" ? "1" : col} END)`,
1083
+ "sum": (col, pred) => `SUM(CASE WHEN ${pred} THEN ${col} END)`,
1084
+ "avg": (col, pred) => `AVG(CASE WHEN ${pred} THEN ${col} END)`,
1085
+ "min": (col, pred) => `MIN(CASE WHEN ${pred} THEN ${col} END)`,
1086
+ "max": (col, pred) => `MAX(CASE WHEN ${pred} THEN ${col} END)`,
1087
+ "count_distinct": (col, pred) => `COUNT(DISTINCT CASE WHEN ${pred} THEN ${col} END)`
1088
+ };
1031
1089
  var SUPPORTED_AGGREGATE_SQL_KEYS = Object.keys(AGGREGATE_SQL);
1090
+ var CONDITIONAL_AGGREGATE_SQL_KEYS = Object.keys(CONDITIONAL_AGGREGATE_SQL);
1032
1091
  var EXPRESSION_METRIC_TYPES = /* @__PURE__ */ new Set(["number", "string", "boolean"]);
1033
1092
  var IDENTIFIER_PATH = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
1034
1093
  var NativeSQLStrategy = class {
@@ -1051,9 +1110,51 @@ var NativeSQLStrategy = class {
1051
1110
  }
1052
1111
  }
1053
1112
  if (this.carriesCrossFieldComparison(query, ctx)) return false;
1113
+ if (this.carriesUninterpretableTemporalComparand(query, ctx)) return false;
1054
1114
  const caps = ctx.queryCapabilities(query.cube);
1055
1115
  return caps.nativeSql && typeof ctx.executeRawSql === "function";
1056
1116
  }
1117
+ /**
1118
+ * [#8690] Does the query's `where` compare a declared TIME dimension against
1119
+ * a value no temporal storage rule can read? See the ruling at
1120
+ * {@link canHandle}.
1121
+ *
1122
+ * The classification comes from the CUBE, the only metadata this package has:
1123
+ * a dimension declares `type: 'time'` (compiled from the dataset dimension's
1124
+ * `type: 'date'`), and {@link lookupMember} is the same resolution every other
1125
+ * member lookup in this strategy uses, so "the member the gate classified"
1126
+ * and "the member the compiler emits" cannot drift apart.
1127
+ *
1128
+ * A `time` dimension is read with the DATETIME rule — the permissive one of
1129
+ * the three. That is the right direction because this is a routing decision,
1130
+ * not a verdict: the engine door re-judges with the field's real declared
1131
+ * type and has the final say, so under-classifying an exotic spelling merely
1132
+ * leaves today's behaviour, while over-classifying would silently move a
1133
+ * working dashboard off the fast path. The comparands this card measured
1134
+ * (`last_30_days`, `not-a-date-at-all`) are unreadable under all three rules,
1135
+ * so the decline fires for them whichever backing type the dimension has.
1136
+ *
1137
+ * `lowerAnalyticsWhere` rather than `query.where` raw, so the authored ARRAY
1138
+ * sugar is seen after `parseFilterAST` has lowered it; a THROW from that
1139
+ * lowering is not this gate's to answer — the filter is malformed either way
1140
+ * and `normalizeAnalyticsFilterTree` refuses it a moment later with the
1141
+ * message and envelope it has always had.
1142
+ */
1143
+ carriesUninterpretableTemporalComparand(query, ctx) {
1144
+ const cube = query.cube ? ctx.getCube(query.cube) : void 0;
1145
+ if (!cube) return false;
1146
+ let where = null;
1147
+ try {
1148
+ where = lowerAnalyticsWhere(query);
1149
+ } catch {
1150
+ return false;
1151
+ }
1152
+ if (!where) return false;
1153
+ return findUninterpretableTemporalMember(
1154
+ where,
1155
+ (member) => this.lookupMember(cube, member, "dimension")?.type === "time" ? "datetime" : null
1156
+ ) !== null;
1157
+ }
1057
1158
  /**
1058
1159
  * [#7598] Does serving this query require the cross-field capability this
1059
1160
  * strategy declines? See the ruling recorded at {@link canHandle}.
@@ -1152,9 +1253,19 @@ var NativeSQLStrategy = class {
1152
1253
  groupByClauses.push(colExpr);
1153
1254
  }
1154
1255
  }
1256
+ const datasetScope = ctx.getDatasetScope?.(query.cube);
1155
1257
  if (query.measures && query.measures.length > 0) {
1156
1258
  for (const measure of query.measures) {
1157
- const aggExpr = this.resolveMeasureSql(cube, measure, tableName, joins);
1259
+ const measureFilter = datasetScope?.measureFilters?.[measure];
1260
+ const predicate = measureFilter ? this.compileFilterNode(
1261
+ normalizeAnalyticsFilterTree({ where: measureFilter }),
1262
+ cube,
1263
+ tableName,
1264
+ joins,
1265
+ params,
1266
+ ctx
1267
+ ) : null;
1268
+ const aggExpr = this.resolveMeasureSql(cube, measure, tableName, joins, predicate);
1158
1269
  selectClauses.push(`${aggExpr} AS "${measure}"`);
1159
1270
  }
1160
1271
  }
@@ -1168,6 +1279,17 @@ var NativeSQLStrategy = class {
1168
1279
  ctx
1169
1280
  );
1170
1281
  if (filterSql) whereClauses.push(filterSql);
1282
+ if (datasetScope?.filter) {
1283
+ const scopeSql = this.compileFilterNode(
1284
+ normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
1285
+ cube,
1286
+ tableName,
1287
+ joins,
1288
+ params,
1289
+ ctx
1290
+ );
1291
+ if (scopeSql) whereClauses.push(scopeSql);
1292
+ }
1171
1293
  if (query.timeDimensions && query.timeDimensions.length > 0) {
1172
1294
  for (const td of query.timeDimensions) {
1173
1295
  const colExpr = this.resolveFieldSql(cube, td.dimension, tableName, joins);
@@ -1176,7 +1298,7 @@ var NativeSQLStrategy = class {
1176
1298
  if (range.length === 2) {
1177
1299
  const td2 = this.resolveStorageTarget(cube, td.dimension, tableName);
1178
1300
  const column = this.temporalColumn(ctx, td2, colExpr);
1179
- const nextDay = (0, import_core.nextUtcCalendarDay)(range[1]);
1301
+ const nextDay = (0, import_core2.nextUtcCalendarDay)(range[1]);
1180
1302
  params.push(this.coerceTemporal(ctx, td2, range[0]));
1181
1303
  const lower = `${column} >= $${params.length}`;
1182
1304
  if (nextDay != null) {
@@ -1341,7 +1463,12 @@ var NativeSQLStrategy = class {
1341
1463
  const raw = dim ? dim.sql : member.includes(".") ? member.split(".")[1] : member;
1342
1464
  return this.qualifyAndRegisterJoin(raw, parentTable, joins, cube);
1343
1465
  }
1344
- resolveMeasureSql(cube, member, parentTable, joins) {
1466
+ /**
1467
+ * @param predicate - The measure's own scoped filter, already compiled to a
1468
+ * SQL boolean (`null` = the measure declares none, or declares one that
1469
+ * constrains nothing — `compileFilterNode`'s TRUE). #10298.
1470
+ */
1471
+ resolveMeasureSql(cube, member, parentTable, joins, predicate = null) {
1345
1472
  const measure = this.lookupMember(cube, member, "measure");
1346
1473
  if (!measure) {
1347
1474
  const declared = Object.keys(cube.measures ?? {});
@@ -1351,6 +1478,13 @@ var NativeSQLStrategy = class {
1351
1478
  );
1352
1479
  }
1353
1480
  const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
1481
+ if (predicate !== null) {
1482
+ const wrapConditional = CONDITIONAL_AGGREGATE_SQL[measure.type];
1483
+ if (wrapConditional) return wrapConditional(col, predicate);
1484
+ throw new Error(
1485
+ `[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(", ")}).`
1486
+ );
1487
+ }
1354
1488
  const wrap = AGGREGATE_SQL[measure.type];
1355
1489
  if (wrap) return wrap(col);
1356
1490
  if (EXPRESSION_METRIC_TYPES.has(measure.type)) return col;
@@ -1546,7 +1680,7 @@ var NativeSQLStrategy = class {
1546
1680
  return `${rawCol} ${sqlOp} ${patternRef} ESCAPE $${params.length}`;
1547
1681
  }
1548
1682
  if (operator === "lte") {
1549
- const nextDay = (0, import_core.nextUtcCalendarDay)(values[0]);
1683
+ const nextDay = (0, import_core2.nextUtcCalendarDay)(values[0]);
1550
1684
  if (nextDay != null) {
1551
1685
  params.push(this.coerceTemporal(ctx, target, nextDay));
1552
1686
  return `${this.temporalColumn(ctx, target, rawCol)} < $${params.length}`;
@@ -1576,8 +1710,8 @@ var NativeSQLStrategy = class {
1576
1710
  };
1577
1711
 
1578
1712
  // src/strategies/objectql-strategy.ts
1579
- var import_data2 = require("@objectstack/spec/data");
1580
- var import_core2 = require("@objectstack/core");
1713
+ var import_data3 = require("@objectstack/spec/data");
1714
+ var import_core3 = require("@objectstack/core");
1581
1715
 
1582
1716
  // src/strategies/cross-object-rebucket.ts
1583
1717
  var RECOMBINABLE_METHODS = /* @__PURE__ */ new Set([
@@ -1697,10 +1831,18 @@ var ObjectQLStrategy = class {
1697
1831
  const extra = this.mergeFilterOperand(filter, field, bounds);
1698
1832
  if (extra) conjuncts.push(extra);
1699
1833
  }
1834
+ const datasetScope = ctx.getDatasetScope?.(query.cube);
1835
+ if (datasetScope?.filter) {
1836
+ const scopeCondition = this.filterNodeToCondition(
1837
+ normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
1838
+ cube
1839
+ );
1840
+ if (scopeCondition) conjuncts.push(scopeCondition);
1841
+ }
1700
1842
  if (conjuncts.length > 0) {
1701
1843
  filter.$and = [...Array.isArray(filter.$and) ? filter.$and : [], ...conjuncts];
1702
1844
  }
1703
- const plan = this.planCrossObject(cube, query, filter);
1845
+ const plan = this.planCrossObject(cube, query, this.filterMemberView(cube, query, ctx));
1704
1846
  if (plan) {
1705
1847
  return this.executeCrossObject(cube, query, aggregations, filter, plan, ctx);
1706
1848
  }
@@ -1777,9 +1919,7 @@ var ObjectQLStrategy = class {
1777
1919
  if (td.granularity) granByDim.set(td.dimension, td.granularity);
1778
1920
  }
1779
1921
  const tableName = this.extractObjectName(cube);
1780
- const plan = this.planCrossObject(cube, query, Object.fromEntries(
1781
- collectFilterLeaves(normalizeAnalyticsFilterTree(query)).map((f) => [this.resolveFieldName(cube, f.member, "any"), true])
1782
- ));
1922
+ const plan = this.planCrossObject(cube, query, this.filterMemberView(cube, query, ctx));
1783
1923
  const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd]));
1784
1924
  const joinClauses = [];
1785
1925
  const dimExpr = (dim) => {
@@ -1821,8 +1961,17 @@ var ObjectQLStrategy = class {
1821
1961
  params
1822
1962
  );
1823
1963
  if (filterClause) whereParts.push(filterClause);
1964
+ const echoedDatasetFilter = ctx.getDatasetScope?.(query.cube)?.filter;
1965
+ if (echoedDatasetFilter) {
1966
+ const scopeSql = this.renderFilterNodeSql(
1967
+ normalizeAnalyticsFilterTree({ where: echoedDatasetFilter }),
1968
+ cube,
1969
+ params
1970
+ );
1971
+ if (scopeSql) whereParts.push(scopeSql);
1972
+ }
1824
1973
  for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
1825
- const nextDay = (0, import_core2.nextUtcCalendarDay)(bounds.$lte);
1974
+ const nextDay = (0, import_core3.nextUtcCalendarDay)(bounds.$lte);
1826
1975
  params.push(bounds.$gte, nextDay ?? bounds.$lte);
1827
1976
  whereParts.push(
1828
1977
  `(${field} >= $${params.length - 1} AND ${field} ${nextDay ? "<" : "<="} $${params.length})`
@@ -1873,11 +2022,11 @@ var ObjectQLStrategy = class {
1873
2022
  * predicate. `$and` makes that structurally impossible.
1874
2023
  */
1875
2024
  withReadScope(objectName, filter, ctx) {
1876
- const userFilter = Object.keys(filter).length > 0 ? (0, import_data2.markFilterSubtreeProvenance)(filter, "author") : void 0;
2025
+ const userFilter = Object.keys(filter).length > 0 ? (0, import_data3.markFilterSubtreeProvenance)(filter, "author") : void 0;
1877
2026
  if (typeof ctx.getReadScope !== "function") return userFilter;
1878
2027
  const scope = ctx.getReadScope(objectName);
1879
2028
  if (scope === void 0 || scope === null) return userFilter;
1880
- const scopeFilter = (0, import_data2.markFilterSubtreeProvenance)(scope, "policy");
2029
+ const scopeFilter = (0, import_data3.markFilterSubtreeProvenance)(scope, "policy");
1881
2030
  if (!userFilter) return scopeFilter;
1882
2031
  return { $and: [userFilter, scopeFilter] };
1883
2032
  }
@@ -1888,6 +2037,68 @@ var ObjectQLStrategy = class {
1888
2037
  const joinedObject = cube.joins?.[alias]?.name ?? alias;
1889
2038
  return joinedObject !== baseObject;
1890
2039
  }
2040
+ /**
2041
+ * The member view {@link planCrossObject} judges a filter by: EVERY member
2042
+ * that will end up in the engine's predicate, structure discarded, keyed by
2043
+ * RESOLVED field name (#10759), valued by WHERE THE MEMBER CAME FROM
2044
+ * (#10861).
2045
+ *
2046
+ * Both call sites — `execute()` and `generateSql()` — are handed this and
2047
+ * nothing else, which is what makes the invariant `planCrossObject` states
2048
+ * for itself ("the preview accepts/rejects the same set") structural rather
2049
+ * than a coincidence maintained by hand. They used to build the view
2050
+ * separately: the echo flattened the tree, `execute()` passed the ENGINE
2051
+ * FILTER, and a filter record answers a different question — it is a
2052
+ * predicate to evaluate, not an inventory of members. An `$or`, a `$not` or
2053
+ * an unmergeable nested `$and` travels in it as one opaque `$and` entry, so
2054
+ * the members inside were unreadable from the outside and the envelope check
2055
+ * could not reject what it could not see.
2056
+ *
2057
+ * ## Two producers, one inventory (#10861)
2058
+ *
2059
+ * The caller's `where` is not the only thing that reaches `engine.aggregate`
2060
+ * as a predicate. Since PR #10758 the compiled dataset's own definition-level
2061
+ * `filter` is lowered onto `execute()`'s `conjuncts` and rendered by
2062
+ * `generateSql()`, so a dataset declaring `filter: { 'account.region': 'West' }`
2063
+ * sent `{"$and":[{"account.region":"West"}]}` to an engine that cannot join —
2064
+ * measured on both doors, which AGREED in accepting it, so #10759's
2065
+ * preview/execution symmetry had nothing to restore. Refusing it is a
2066
+ * widening of the refusal set, ruled by the maintainer on 2026-08-22 (Option
2067
+ * A, query-time refusal): fold the scope's leaves in HERE, where driver
2068
+ * capability is known, rather than in `dataset-compiler.ts`, which cannot see
2069
+ * which driver will serve the dataset and would refuse a dataset that is
2070
+ * perfectly legal on a native-SQL deployment.
2071
+ *
2072
+ * Structure is discarded on purpose — a member is cross-object or it is not,
2073
+ * and which branch of a disjunction it sits in cannot make
2074
+ * `engine.aggregate` able to join it. PROVENANCE is not discarded, because it
2075
+ * decides what the refusal can tell the caller to go fix: `AnalyticsRequestKey`
2076
+ * is the analytics REQUEST vocabulary and a dataset's `filter` is not in it,
2077
+ * so a scope-borne member must not be reported as `param: 'where'` — see
2078
+ * `planCrossObject`. The value slot carries that and nothing else; it never
2079
+ * reaches a driver.
2080
+ *
2081
+ * Dataset leaves are inserted FIRST so a member named by BOTH producers keeps
2082
+ * the caller's provenance (last write wins on a duplicate key): if it is in
2083
+ * the request too, the request is the actionable place to fix it.
2084
+ *
2085
+ * Time-dimension WINDOWS are deliberately absent (they live in
2086
+ * `dateRangeBounds`, not in `where`). They need no arm here: a cross-object
2087
+ * time dimension is refused by `planCrossObject`'s own first loop, over
2088
+ * `query.timeDimensions`, and refused as the time dimension the author wrote
2089
+ * rather than as the lowered predicate it becomes — which is the better
2090
+ * diagnostic and the reason that loop runs first.
2091
+ */
2092
+ filterMemberView(cube, query, ctx) {
2093
+ const datasetFilter = ctx.getDatasetScope?.(query.cube)?.filter;
2094
+ const leaves = (node, origin) => collectFilterLeaves(node).map(
2095
+ (f) => [this.resolveFieldName(cube, f.member, "any"), origin]
2096
+ );
2097
+ return Object.fromEntries([
2098
+ ...datasetFilter ? leaves(normalizeAnalyticsFilterTree({ where: datasetFilter }), "dataset-filter") : [],
2099
+ ...leaves(normalizeAnalyticsFilterTree(query), "where")
2100
+ ]);
2101
+ }
1891
2102
  /**
1892
2103
  * Plan how to serve cross-object references on this join-less path (#3654).
1893
2104
  *
@@ -1898,21 +2109,36 @@ var ObjectQLStrategy = class {
1898
2109
  * query (direct path), a plan for an in-envelope cross-object query.
1899
2110
  *
1900
2111
  * THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
1901
- * (needs a real join to evaluate), a MULTI-HOP dimension (`a.b.c`), or a
2112
+ * (needs a real join to evaluate), a cross-object leaf in the DATASET's own
2113
+ * definition-level `filter` (#10861 — same join it does not have, arriving
2114
+ * from the producer PR #10758 added), a MULTI-HOP dimension (`a.b.c`), or a
1902
2115
  * non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values
1903
2116
  * cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
1904
- * `generateSql()` calls this too, so the preview accepts/rejects the same set.
2117
+ * `generateSql()` calls this too, so the preview accepts/rejects the same set
2118
+ * — and since #10759 both callers derive `filter` from the one
2119
+ * {@link filterMemberView}, so that sentence is enforced by construction
2120
+ * instead of restated at two call sites.
1905
2121
  *
1906
- * [#5716] All four refusals below are `invalidMemberError` — `INVALID_FIELD` /
1907
- * 400, naming the member — and the MESSAGES are unchanged (they are good
1908
- * diagnostics, and #5923's tests read them). Each is decided by two caller-side
1909
- * facts and nothing else: a member the query named, and whether that member
1910
- * resolves across a join. Neither is an internal invariant a cube where the
1911
- * member exists and a driver that could serve it are both perfectly ordinary,
1912
- * which is exactly what the "run this on a native-SQL driver" half of each
1913
- * message says. They are member-level rather than dataset-level (hence not
1914
- * `datasetInvalidError`) because the fix is always to change or drop ONE named
1915
- * member, and because they fire on `/analytics/query` where no dataset exists.
2122
+ * [#5716] All five refusals below are `invalidMemberError` — `INVALID_FIELD` /
2123
+ * 400, naming the member — and the four that predate #10861 keep their
2124
+ * MESSAGES unchanged (they are good diagnostics, and #5923's tests read
2125
+ * them). Each is decided by two facts and nothing else: a member that will
2126
+ * reach the engine's predicate, and whether that member resolves across a
2127
+ * join. Neither is an internal invariant a cube where the member exists and
2128
+ * a driver that could serve it are both perfectly ordinary, which is exactly
2129
+ * what the "run this on a native-SQL driver" half of every message says. They
2130
+ * are member-level rather than dataset-level (hence not `datasetInvalidError`)
2131
+ * because the fix is always to change or drop ONE named member, and because
2132
+ * four of them fire on `/analytics/query` where no dataset exists.
2133
+ *
2134
+ * [#10861] The fifth is the exception that proves the rule and is written to
2135
+ * it: it can only fire where a dataset DOES exist, and it is the one refusal
2136
+ * here whose member no request key named — so it carries `cube` and no
2137
+ * `param`, and says in its own words which document to go and edit. It stays
2138
+ * `INVALID_FIELD` rather than becoming `DATASET_INVALID` because the verdict
2139
+ * is the same physical one as its neighbour — this engine cannot join this
2140
+ * member — and splitting the code by PROVENANCE would make a caller branch on
2141
+ * two wire shapes for one capability limit.
1916
2142
  *
1917
2143
  * Detection is on RESOLVED field names, so a dotted dimension the cube
1918
2144
  * flattens to a real column is treated as base, not cross-object.
@@ -1934,7 +2160,7 @@ var ObjectQLStrategy = class {
1934
2160
  member: m,
1935
2161
  field: this.resolveMeasureAggregation(cube, m).field
1936
2162
  })),
1937
- ...Object.keys(filter).map((f) => ({ where: "filter", member: f, field: f }))
2163
+ ...Object.entries(filter).filter(([, origin]) => origin === "where").map(([f]) => ({ where: "filter", member: f, field: f }))
1938
2164
  ].filter((r) => this.isCrossObjectField(cube, r.field, baseObject));
1939
2165
  if (nonDim.length > 0) {
1940
2166
  throw invalidMemberError(
@@ -1948,6 +2174,13 @@ var ObjectQLStrategy = class {
1948
2174
  }
1949
2175
  );
1950
2176
  }
2177
+ const scopeCross = Object.entries(filter).filter(([field, origin]) => origin === "dataset-filter" && this.isCrossObjectField(cube, field, baseObject)).map(([field]) => field);
2178
+ if (scopeCross.length > 0) {
2179
+ throw invalidMemberError(
2180
+ `[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.`,
2181
+ { member: scopeCross[0], cube: cube.name }
2182
+ );
2183
+ }
1951
2184
  const crossDims = [];
1952
2185
  for (const dim of query.dimensions ?? []) {
1953
2186
  const field = this.resolveFieldName(cube, dim, "dimension");
@@ -2051,7 +2284,7 @@ var ObjectQLStrategy = class {
2051
2284
  if (fkValues.length === 0 || typeof ctx.executeAggregate !== "function") return map;
2052
2285
  const idFilter = { id: { $in: fkValues } };
2053
2286
  const scope = typeof ctx.getReadScope === "function" ? ctx.getReadScope(refObject) : null;
2054
- if (scope != null) (0, import_data2.markFilterSubtreeProvenance)(scope, "policy");
2287
+ if (scope != null) (0, import_data3.markFilterSubtreeProvenance)(scope, "policy");
2055
2288
  const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
2056
2289
  const rows = await ctx.executeAggregate(refObject, {
2057
2290
  groupBy: ["id", attr],
@@ -2543,10 +2776,10 @@ var ObjectQLStrategy = class {
2543
2776
  };
2544
2777
 
2545
2778
  // src/dataset-compiler.ts
2546
- var import_data3 = require("@objectstack/spec/data");
2779
+ var import_data4 = require("@objectstack/spec/data");
2547
2780
  var import_ui = require("@objectstack/spec/ui");
2548
2781
  var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set();
2549
- var SUPPORTED_AGGREGATES = import_data3.AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
2782
+ var SUPPORTED_AGGREGATES = import_data4.AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
2550
2783
  function aggregateToMetricType(m) {
2551
2784
  if (!m.aggregate) {
2552
2785
  throw new Error(`[dataset-compiler] non-derived measure "${m.name}" has no aggregate`);
@@ -2708,11 +2941,11 @@ function compileDataset(dataset, resolver, options) {
2708
2941
  }
2709
2942
 
2710
2943
  // src/dataset-executor.ts
2711
- var import_data4 = require("@objectstack/spec/data");
2712
- var import_core3 = require("@objectstack/core");
2944
+ var import_data5 = require("@objectstack/spec/data");
2945
+ var import_core4 = require("@objectstack/core");
2713
2946
  function resolveSelectionTokens(compiled, selection, context) {
2714
- const tokenCtx = (0, import_core3.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
2715
- const resolve = (v) => (0, import_core3.resolveFilterTokens)(v, tokenCtx);
2947
+ const tokenCtx = (0, import_core4.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
2948
+ const resolve = (v) => (0, import_core4.resolveFilterTokens)(v, tokenCtx);
2716
2949
  const filter = resolve(compiled.filter);
2717
2950
  const measureFilters = resolve(compiled.measureFilters);
2718
2951
  const runtimeFilter = resolve(selection.runtimeFilter);
@@ -2748,7 +2981,7 @@ function evaluateDerivedMeasures(rows, derived) {
2748
2981
  }
2749
2982
  function fillEmptyGroups(rows, columnAggregates) {
2750
2983
  for (const [column, aggregate2] of Object.entries(columnAggregates)) {
2751
- const empty = (0, import_data4.emptyGroupValueFor)(aggregate2);
2984
+ const empty = (0, import_data5.emptyGroupValueFor)(aggregate2);
2752
2985
  if (empty === void 0) continue;
2753
2986
  for (const row of rows) if (row[column] == null) row[column] = empty;
2754
2987
  }
@@ -2948,7 +3181,7 @@ function bucketKeyAtOrdinal(ordinal, granularity) {
2948
3181
  }
2949
3182
  function alignedCompareBucketKey(key, granularity, kind, currentRange, shiftedRange) {
2950
3183
  if (typeof key !== "string" || key.length === 0) return null;
2951
- const span = (0, import_core3.bucketKeyToCalendarRange)(key, granularity);
3184
+ const span = (0, import_core4.bucketKeyToCalendarRange)(key, granularity);
2952
3185
  if (!span) return null;
2953
3186
  const targetOrdinal = kind === "previousYear" ? bucketOrdinalOfDay(shiftYear(span.start, 1), granularity) : bucketOrdinalOfDay(span.start, granularity) + (bucketOrdinalOfDay(currentRange[0], granularity) - bucketOrdinalOfDay(shiftedRange[0], granularity));
2954
3187
  const first = bucketOrdinalOfDay(currentRange[0], granularity);
@@ -3412,18 +3645,18 @@ function pickDisplayField(fields) {
3412
3645
  }
3413
3646
 
3414
3647
  // src/preview-evaluator.ts
3415
- var import_core4 = require("@objectstack/core");
3648
+ var import_core5 = require("@objectstack/core");
3416
3649
  function compare(a, b) {
3417
3650
  if (typeof a === "number" && typeof b === "number") return a - b;
3418
3651
  if (a instanceof Date || b instanceof Date) {
3419
- const ai = (0, import_core4.utcInstantMs)(a);
3420
- const bi = (0, import_core4.utcInstantMs)(b);
3652
+ const ai = (0, import_core5.utcInstantMs)(a);
3653
+ const bi = (0, import_core5.utcInstantMs)(b);
3421
3654
  if (ai !== null && bi !== null) return ai - bi;
3422
3655
  }
3423
3656
  return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0;
3424
3657
  }
3425
3658
  function lteBound(value, bound) {
3426
- const nextDay = (0, import_core4.nextUtcCalendarDay)(bound);
3659
+ const nextDay = (0, import_core5.nextUtcCalendarDay)(bound);
3427
3660
  if (nextDay != null) return compare(value, nextDay) < 0;
3428
3661
  return compare(value, bound) <= 0;
3429
3662
  }
@@ -3481,7 +3714,7 @@ function matchesWhere(row, where) {
3481
3714
  function bucketDate(value, granularity, timezone) {
3482
3715
  const d = new Date(String(value));
3483
3716
  if (Number.isNaN(d.getTime())) return null;
3484
- const { year: y, month, day: dayNum } = (0, import_core4.calendarPartsInTzOrUtc)(d, timezone);
3717
+ const { year: y, month, day: dayNum } = (0, import_core5.calendarPartsInTzOrUtc)(d, timezone);
3485
3718
  const m = `${month}`.padStart(2, "0");
3486
3719
  const day = `${dayNum}`.padStart(2, "0");
3487
3720
  switch (granularity) {
@@ -3535,7 +3768,7 @@ function evaluateAnalyticsQueryOverRows(query, cube, rows) {
3535
3768
  const [start, end] = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
3536
3769
  filtered = filtered.filter((r) => {
3537
3770
  const v = String(r[field] ?? "");
3538
- const nextDay = (0, import_core4.nextUtcCalendarDay)(end);
3771
+ const nextDay = (0, import_core5.nextUtcCalendarDay)(end);
3539
3772
  const inUpper = nextDay != null ? v < nextDay : v <= `${end}~`;
3540
3773
  return v >= String(start) && inUpper;
3541
3774
  });
@@ -3669,7 +3902,7 @@ var AnalyticsService = class {
3669
3902
  this.datasetRegistry = /* @__PURE__ */ new Map();
3670
3903
  /** [#3867] One-shot flag for the {@link assertInferableCube} stand-down warning. */
3671
3904
  this.warnedNoObjectRegistry = false;
3672
- this.logger = config.logger || (0, import_core5.createLogger)({ level: "info", format: "pretty" });
3905
+ this.logger = config.logger || (0, import_core6.createLogger)({ level: "info", format: "pretty" });
3673
3906
  this.cubeRegistry = new CubeRegistry();
3674
3907
  if (config.cubes) {
3675
3908
  this.cubeRegistry.registerAll(config.cubes);
@@ -3683,7 +3916,7 @@ var AnalyticsService = class {
3683
3916
  this.getObjectFieldNames = config.getObjectFieldNames;
3684
3917
  this.getObjectDatasource = config.getObjectDatasource;
3685
3918
  this.isExternalObject = config.isExternalObject;
3686
- this.debugSql = config.debugSql ?? (0, import_core5.getEnv)("NODE_ENV") === "development";
3919
+ this.debugSql = config.debugSql ?? (0, import_core6.getEnv)("NODE_ENV") === "development";
3687
3920
  if (config.datasets) {
3688
3921
  for (const ds of config.datasets) {
3689
3922
  try {
@@ -3702,6 +3935,16 @@ var AnalyticsService = class {
3702
3935
  // Prefer a compiled dataset's declared relationships (D-C join allowlist);
3703
3936
  // fall back to any explicitly-configured provider for legacy cubes.
3704
3937
  getAllowedRelationships: (cubeName) => this.datasetRegistry.get(cubeName)?.allowedRelationships ?? config.getAllowedRelationships?.(cubeName),
3938
+ // [#10298] The compiled dataset's definition-level filter and its
3939
+ // per-measure filters — the half of the declaration the Cube model has
3940
+ // no room for. Same shape and same registry as `getAllowedRelationships`
3941
+ // directly above: answered for a cube that IS a compiled dataset,
3942
+ // `undefined` for every other cube.
3943
+ getDatasetScope: (cubeName) => {
3944
+ const compiled = this.datasetRegistry.get(cubeName);
3945
+ if (!compiled) return void 0;
3946
+ return { filter: compiled.filter, measureFilters: compiled.measureFilters };
3947
+ },
3705
3948
  coerceTemporalFilterValue: config.coerceTemporalFilterValue,
3706
3949
  coerceTemporalFilterColumn: config.coerceTemporalFilterColumn,
3707
3950
  isExternalObject: config.isExternalObject
@@ -3948,11 +4191,11 @@ var AnalyticsService = class {
3948
4191
  else if (rangeTz === "UTC") rangeDims.push({ d, granularity, instant: false });
3949
4192
  }
3950
4193
  if (rangeDims.length && result.rows.length) {
3951
- const bound = (ymd, instant) => instant ? new Date((0, import_core5.zonedDateStartToUtcMs)(ymd, rangeTz)).toISOString() : ymd;
4194
+ const bound = (ymd, instant) => instant ? new Date((0, import_core6.zonedDateStartToUtcMs)(ymd, rangeTz)).toISOString() : ymd;
3952
4195
  result.drillRanges = result.rows.map((row) => {
3953
4196
  const ranges = {};
3954
4197
  for (const { d, granularity, instant } of rangeDims) {
3955
- const cal = (0, import_core5.bucketKeyToCalendarRange)(row[d.name], granularity);
4198
+ const cal = (0, import_core6.bucketKeyToCalendarRange)(row[d.name], granularity);
3956
4199
  if (cal) {
3957
4200
  ranges[d.name] = { field: d.field, gte: bound(cal.start, instant), lt: bound(cal.end, instant) };
3958
4201
  }
@@ -4003,7 +4246,7 @@ var AnalyticsService = class {
4003
4246
  }
4004
4247
  }
4005
4248
  if (f.percentScale == null) {
4006
- f.percentScale = m.derived?.op === "ratio" ? "fraction" : (0, import_data5.percentScaleOf)(meta);
4249
+ f.percentScale = m.derived?.op === "ratio" ? "fraction" : (0, import_data6.percentScaleOf)(meta);
4007
4250
  }
4008
4251
  }
4009
4252
  }