@objectstack/service-analytics 17.0.0-rc.6 → 17.1.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_data4 = 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,27 +162,104 @@ 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);
177
+ }
178
+ function isFieldReference(value) {
179
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
180
+ return typeof value.$field === "string";
181
+ }
182
+ var CROSS_FIELD_COMPARISON_OPERATORS = /* @__PURE__ */ new Set([
183
+ "$eq",
184
+ "$ne",
185
+ "$gt",
186
+ "$gte",
187
+ "$lt",
188
+ "$lte"
189
+ ]);
190
+ function findCrossFieldComparand(filter) {
191
+ return findIn(filter, "");
192
+ }
193
+ function findIn(node, field) {
194
+ if (!node || typeof node !== "object") return null;
195
+ if (Array.isArray(node)) {
196
+ for (const child of node) {
197
+ const hit = findIn(child, field);
198
+ if (hit) return hit;
199
+ }
200
+ return null;
201
+ }
202
+ if (node instanceof Date || ArrayBuffer.isView(node)) return null;
203
+ for (const [key, value] of Object.entries(node)) {
204
+ if (CROSS_FIELD_COMPARISON_OPERATORS.has(key) && isFieldReference(value)) {
205
+ return { op: key, field, ref: value.$field };
206
+ }
207
+ const hit = findIn(value, key.startsWith("$") ? field : key);
208
+ if (hit) return hit;
209
+ }
210
+ return null;
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;
180
256
  }
181
257
  var TEXT_PATTERN_OPERATORS = /* @__PURE__ */ new Set([
182
258
  "$contains",
183
259
  "$notContains",
184
260
  "$startsWith",
185
- "$endsWith"
261
+ "$endsWith",
262
+ "$icontains"
186
263
  ]);
187
264
  function shapePreview(value) {
188
265
  try {
@@ -194,10 +271,16 @@ function shapePreview(value) {
194
271
  }
195
272
  }
196
273
  function unrenderableTextComparandMessage(op, field, value) {
197
- 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.`;
275
+ }
276
+ function fieldReferenceComparandMessage(op, field, ref, position) {
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).`;
278
+ }
279
+ function fieldReferenceBetweenBoundMessage(op, field, ref, index) {
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).`;
198
281
  }
199
282
  function unbindableListMemberMessage(op, field, value, index) {
200
- 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).`;
201
284
  }
202
285
 
203
286
  // src/strategies/filter-normalizer.ts
@@ -261,6 +344,15 @@ function assertCompilableComparand(opKey, field, value) {
261
344
  });
262
345
  }
263
346
  }
347
+ function assertNoFieldReferenceComparand(opKey, field, value) {
348
+ if (opKey !== "$between" || !Array.isArray(value)) return;
349
+ value.forEach((member, index) => {
350
+ if (!isFieldReference(member)) return;
351
+ throw invalidFilterError(
352
+ `[analytics] ${fieldReferenceBetweenBoundMessage(opKey, field, member.$field, index)}`
353
+ );
354
+ });
355
+ }
264
356
  function undefinedComparandError(field, path) {
265
357
  return invalidFilterError(
266
358
  `[analytics] comparand at ${path} is undefined \u2014 refusing to compile this filter. @objectstack/spec FieldOperatorsSchema declares no undefined comparand, and in JavaScript a key whose value is undefined cannot be told apart from an ABSENT key \u2014 yet the two mean OPPOSITE things (a predicate versus no constraint at all), so there is no reading of it that is not a guess. It used to compile, two ways: in a FIELD position the key was dropped outright, so a single-key where ran with no filter at all and the chart was drawn over every row (#3650's widening, which this module refuses everywhere else); in an OPERATOR or list position it became a comparison against null, which is UNKNOWN for every row and charts nothing. Write null if the null predicate was meant ({ "${field}": null } or { "${field}": { "$null": true } }), or omit the key entirely when the value is genuinely absent \u2014 an omitted key is the same "no constraint" without the ambiguity. The producer to fix is whoever BUILT this where: undefined cannot cross JSON, so it is in-process code spreading a possibly-absent value into a filter object (#6050 ruling B, pushed down to this door by #6386).`
@@ -330,6 +422,7 @@ function fieldLeaves(key, raw) {
330
422
  `[analytics] "$between" on "${key}" needs a two-element [min, max] array, got ${JSON.stringify(v2)}. Dropping the predicate would silently widen the query to every row.`
331
423
  );
332
424
  }
425
+ assertNoFieldReferenceComparand(opKey, key, v2);
333
426
  leaf("gte", [comparand(v2[0])]);
334
427
  leaf("lte", [comparand(v2[1])]);
335
428
  continue;
@@ -457,6 +550,7 @@ function nullValueSatisfiesOperator(op, value) {
457
550
  }
458
551
  }
459
552
  function operatorIsNullTotal(op, value) {
553
+ if (CROSS_FIELD_COMPARISON_OPERATORS.has(op) && isFieldReference(value)) return true;
460
554
  switch (op) {
461
555
  // Compile to `set` / `notSet` — `IS NULL` / `IS NOT NULL`, two-valued by
462
556
  // construction, on every strategy that compiles this tree.
@@ -534,7 +628,7 @@ function nullSafeNegationOperand(node) {
534
628
  }
535
629
  function filterArrayNotLowerableError(where) {
536
630
  return invalidFilterError(
537
- `[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].`
538
632
  );
539
633
  }
540
634
  function lowerAnalyticsWhere(query) {
@@ -543,8 +637,8 @@ function lowerAnalyticsWhere(query) {
543
637
  if (!where || typeof where !== "object") return null;
544
638
  if (Array.isArray(where)) {
545
639
  if (where.length === 0) return null;
546
- if (!(0, import_data.isFilterAST)(where)) throw filterArrayNotLowerableError(where);
547
- 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);
548
642
  if (!condition || typeof condition !== "object" || Array.isArray(condition)) {
549
643
  throw invalidFilterError(
550
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).`
@@ -677,6 +771,7 @@ function compileField(field, value, qAlias, params) {
677
771
  const col = `${qAlias}.${quoteIdent(field, "field")}`;
678
772
  assertDefinedComparands2(field, value);
679
773
  assertBooleanFlagComparands(field, value);
774
+ assertNoFieldReferenceComparand2(field, value);
680
775
  if (value === null) return `${col} IS NULL`;
681
776
  if (typeof value !== "object" || value instanceof Date) {
682
777
  params.push(value);
@@ -749,6 +844,23 @@ function assertBooleanFlagComparands(field, spec) {
749
844
  throw nonBooleanFlagComparandError(op, field, `"${field}".${op}`);
750
845
  }
751
846
  }
847
+ function assertNoFieldReferenceComparand2(field, spec) {
848
+ if (!isFilterNode(spec)) return;
849
+ for (const [op, opValue] of Object.entries(spec)) {
850
+ if (CROSS_FIELD_COMPARISON_OPERATORS.has(op) && isFieldReference(opValue)) {
851
+ throw readScopeCompileError(
852
+ `[read-scope-sql] ${fieldReferenceComparandMessage(op, field, opValue.$field)}`
853
+ );
854
+ }
855
+ if (op !== "$between" || !Array.isArray(opValue)) continue;
856
+ opValue.forEach((member, index) => {
857
+ if (!isFieldReference(member)) return;
858
+ throw readScopeCompileError(
859
+ `[read-scope-sql] ${fieldReferenceBetweenBoundMessage(op, field, member.$field, index)}`
860
+ );
861
+ });
862
+ }
863
+ }
752
864
  function compileOperator(col, op, val, field, params) {
753
865
  switch (op) {
754
866
  case "$eq":
@@ -949,7 +1061,7 @@ function invalidMemberError(message, meta) {
949
1061
  }
950
1062
 
951
1063
  // src/strategies/native-sql-strategy.ts
952
- var import_core = require("@objectstack/core");
1064
+ var import_core2 = require("@objectstack/core");
953
1065
  var AGGREGATE_SQL = {
954
1066
  "count": () => "COUNT(*)",
955
1067
  "sum": (col) => `SUM(${col})`,
@@ -980,9 +1092,124 @@ var NativeSQLStrategy = class {
980
1092
  }
981
1093
  }
982
1094
  }
1095
+ if (this.carriesCrossFieldComparison(query, ctx)) return false;
1096
+ if (this.carriesUninterpretableTemporalComparand(query, ctx)) return false;
983
1097
  const caps = ctx.queryCapabilities(query.cube);
984
1098
  return caps.nativeSql && typeof ctx.executeRawSql === "function";
985
1099
  }
1100
+ /**
1101
+ * [#8690] Does the query's `where` compare a declared TIME dimension against
1102
+ * a value no temporal storage rule can read? See the ruling at
1103
+ * {@link canHandle}.
1104
+ *
1105
+ * The classification comes from the CUBE, the only metadata this package has:
1106
+ * a dimension declares `type: 'time'` (compiled from the dataset dimension's
1107
+ * `type: 'date'`), and {@link lookupMember} is the same resolution every other
1108
+ * member lookup in this strategy uses, so "the member the gate classified"
1109
+ * and "the member the compiler emits" cannot drift apart.
1110
+ *
1111
+ * A `time` dimension is read with the DATETIME rule — the permissive one of
1112
+ * the three. That is the right direction because this is a routing decision,
1113
+ * not a verdict: the engine door re-judges with the field's real declared
1114
+ * type and has the final say, so under-classifying an exotic spelling merely
1115
+ * leaves today's behaviour, while over-classifying would silently move a
1116
+ * working dashboard off the fast path. The comparands this card measured
1117
+ * (`last_30_days`, `not-a-date-at-all`) are unreadable under all three rules,
1118
+ * so the decline fires for them whichever backing type the dimension has.
1119
+ *
1120
+ * `lowerAnalyticsWhere` rather than `query.where` raw, so the authored ARRAY
1121
+ * sugar is seen after `parseFilterAST` has lowered it; a THROW from that
1122
+ * lowering is not this gate's to answer — the filter is malformed either way
1123
+ * and `normalizeAnalyticsFilterTree` refuses it a moment later with the
1124
+ * message and envelope it has always had.
1125
+ */
1126
+ carriesUninterpretableTemporalComparand(query, ctx) {
1127
+ const cube = query.cube ? ctx.getCube(query.cube) : void 0;
1128
+ if (!cube) return false;
1129
+ let where = null;
1130
+ try {
1131
+ where = lowerAnalyticsWhere(query);
1132
+ } catch {
1133
+ return false;
1134
+ }
1135
+ if (!where) return false;
1136
+ return findUninterpretableTemporalMember(
1137
+ where,
1138
+ (member) => this.lookupMember(cube, member, "dimension")?.type === "time" ? "datetime" : null
1139
+ ) !== null;
1140
+ }
1141
+ /**
1142
+ * [#7598] Does serving this query require the cross-field capability this
1143
+ * strategy declines? See the ruling recorded at {@link canHandle}.
1144
+ *
1145
+ * ⚠️ This and {@link assertNoCrossFieldComparison} read the SAME inputs
1146
+ * through the SAME detector, which is what makes the decline and the
1147
+ * fail-closed backstop unable to drift: a shape one of them recognises is a
1148
+ * shape the other recognises.
1149
+ */
1150
+ carriesCrossFieldComparison(query, ctx) {
1151
+ return this.crossFieldComparisonIn(query, ctx) !== null;
1152
+ }
1153
+ crossFieldComparisonIn(query, ctx) {
1154
+ let where = null;
1155
+ try {
1156
+ where = lowerAnalyticsWhere(query);
1157
+ } catch {
1158
+ return null;
1159
+ }
1160
+ const inWhere = findCrossFieldComparand(where);
1161
+ if (inWhere) return { source: "the query's `where`", ...inWhere };
1162
+ if (typeof ctx.getReadScope !== "function") return null;
1163
+ const cube = query.cube ? ctx.getCube(query.cube) : void 0;
1164
+ if (!cube) return null;
1165
+ const objects = [this.extractObjectName(cube)];
1166
+ for (const alias of Object.keys(cube.joins ?? {})) {
1167
+ objects.push(cube.joins?.[alias]?.name ?? alias);
1168
+ }
1169
+ for (const objectName of objects) {
1170
+ const scope = ctx.getReadScope(objectName);
1171
+ if (scope === void 0 || scope === null) continue;
1172
+ const inScope = findCrossFieldComparand(scope);
1173
+ if (inScope) return { source: `the read scope of "${objectName}"`, ...inScope };
1174
+ }
1175
+ return null;
1176
+ }
1177
+ /**
1178
+ * [#7598] The fail-closed backstop at the door that BINDS.
1179
+ *
1180
+ * ⚠️ **Unreachable by construction, and kept deliberately** — saying so
1181
+ * because #7598's brief asks that a refusal arm which has become unreachable
1182
+ * be named rather than left to be re-discovered. {@link canHandle} declines
1183
+ * every query this would fire on, and it declines using
1184
+ * {@link crossFieldComparisonIn} — the same walk over the same two inputs —
1185
+ * so `resolveStrategy` cannot hand this strategy a query carrying one.
1186
+ *
1187
+ * It is kept because of what the failure mode is if that ever stops being
1188
+ * true. The defect #7598 measured was not a missing error: it was a SILENT
1189
+ * BIND — `toSqlBindValue` JSON-stringifies the reference object, so the
1190
+ * statement compiled perfectly and compared a column against the text
1191
+ * `{"$field":"budget"}`, a value no row can hold. A routing gate that misses
1192
+ * a shape therefore degrades to a wrong ANSWER rather than to an error, and
1193
+ * that is the one class this package refuses to leave to a single guard
1194
+ * (Prime Directive #12 — refuse at the door, do not tolerate at the
1195
+ * consumer). One line, no measurable cost, and it turns a routing regression
1196
+ * into a loud refusal instead of an empty chart.
1197
+ *
1198
+ * Deliberately BARE — an undeclared 500, not `INVALID_FILTER` / 400 — for the
1199
+ * reason `buildFilterClauseSql`'s #5333 exit in `objectql-strategy.ts` gives
1200
+ * for the same class: the caller's filter is legal and is served on the
1201
+ * engine path, so an arrival here is drift between our own routing gate and
1202
+ * our own emitter. Billing the caller 400 for that would hide a platform bug
1203
+ * from 5xx alerting and tell a dashboard user to fix a filter that is fine.
1204
+ * Same tier as `resolveMeasureSql`'s unrecognised-`Metric.type` throw below.
1205
+ */
1206
+ assertNoCrossFieldComparison(query, ctx) {
1207
+ const hit = this.crossFieldComparisonIn(query, ctx);
1208
+ if (!hit) return;
1209
+ throw new Error(
1210
+ `[native-sql-strategy] ${hit.source} carries a field reference { "$field": "${hit.ref}" } under "${hit.op}" on "${hit.field}", which this strategy does not compile into a column-to-column comparison \u2014 it would BIND the reference object as the comparison's value and answer a wrong row set silently (#7598). \`canHandle\` declines such a query so it routes to the ObjectQL/engine path, whose driver compiles it and enforces the #5222 rulings with metadata it owns; reaching this throw means the decline and this emitter stopped agreeing, which is our bug and must never degrade to a silent answer.`
1211
+ );
1212
+ }
986
1213
  async execute(query, ctx) {
987
1214
  const { sql, params } = await this.generateSql(query, ctx);
988
1215
  const cube = ctx.getCube(query.cube);
@@ -996,6 +1223,7 @@ var NativeSQLStrategy = class {
996
1223
  if (!cube) {
997
1224
  throw new Error(`Cube not found: ${query.cube}`);
998
1225
  }
1226
+ this.assertNoCrossFieldComparison(query, ctx);
999
1227
  const params = [];
1000
1228
  const selectClauses = [];
1001
1229
  const groupByClauses = [];
@@ -1032,7 +1260,7 @@ var NativeSQLStrategy = class {
1032
1260
  if (range.length === 2) {
1033
1261
  const td2 = this.resolveStorageTarget(cube, td.dimension, tableName);
1034
1262
  const column = this.temporalColumn(ctx, td2, colExpr);
1035
- const nextDay = (0, import_core.nextUtcCalendarDay)(range[1]);
1263
+ const nextDay = (0, import_core2.nextUtcCalendarDay)(range[1]);
1036
1264
  params.push(this.coerceTemporal(ctx, td2, range[0]));
1037
1265
  const lower = `${column} >= $${params.length}`;
1038
1266
  if (nextDay != null) {
@@ -1402,7 +1630,7 @@ var NativeSQLStrategy = class {
1402
1630
  return `${rawCol} ${sqlOp} ${patternRef} ESCAPE $${params.length}`;
1403
1631
  }
1404
1632
  if (operator === "lte") {
1405
- const nextDay = (0, import_core.nextUtcCalendarDay)(values[0]);
1633
+ const nextDay = (0, import_core2.nextUtcCalendarDay)(values[0]);
1406
1634
  if (nextDay != null) {
1407
1635
  params.push(this.coerceTemporal(ctx, target, nextDay));
1408
1636
  return `${this.temporalColumn(ctx, target, rawCol)} < $${params.length}`;
@@ -1432,7 +1660,8 @@ var NativeSQLStrategy = class {
1432
1660
  };
1433
1661
 
1434
1662
  // src/strategies/objectql-strategy.ts
1435
- var import_core2 = require("@objectstack/core");
1663
+ var import_data3 = require("@objectstack/spec/data");
1664
+ var import_core3 = require("@objectstack/core");
1436
1665
 
1437
1666
  // src/strategies/cross-object-rebucket.ts
1438
1667
  var RECOMBINABLE_METHODS = /* @__PURE__ */ new Set([
@@ -1618,6 +1847,12 @@ var ObjectQLStrategy = class {
1618
1847
  if (!cube) {
1619
1848
  throw new Error(`Cube not found: ${query.cube}`);
1620
1849
  }
1850
+ const crossField = findCrossFieldComparand(this.loweredWhere(query));
1851
+ if (crossField) {
1852
+ throw invalidFilterError(
1853
+ `[analytics] cannot render display SQL for the field reference { "$field": "${crossField.ref}" } under "${crossField.op}" on "${crossField.field}". The query itself is SERVED \u2014 \`NativeSQLStrategy.canHandle\` declines a cross-field comparison so it routes to the ObjectQL engine path, where driver-sql compiles it into a column-to-column predicate written TOTAL across NULLs and enforces the #5222 rulings (#7598, maintainer ruling 2026-08-12). This renderer has no faithful rendering of that predicate: what it can emit is a comparison against the reference object as a bound VALUE, which reproduces none of the rows the query returns. Refusing rather than half-rendering \u2014 an echo that contradicts execution is worse than no echo (#3601 / #3602 / #3650). Run the query itself (/analytics/query) to get its rows.`
1854
+ );
1855
+ }
1621
1856
  const selectParts = [];
1622
1857
  const groupByParts = [];
1623
1858
  const params = [];
@@ -1671,7 +1906,7 @@ var ObjectQLStrategy = class {
1671
1906
  );
1672
1907
  if (filterClause) whereParts.push(filterClause);
1673
1908
  for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
1674
- const nextDay = (0, import_core2.nextUtcCalendarDay)(bounds.$lte);
1909
+ const nextDay = (0, import_core3.nextUtcCalendarDay)(bounds.$lte);
1675
1910
  params.push(bounds.$gte, nextDay ?? bounds.$lte);
1676
1911
  whereParts.push(
1677
1912
  `(${field} >= $${params.length - 1} AND ${field} ${nextDay ? "<" : "<="} $${params.length})`
@@ -1722,11 +1957,11 @@ var ObjectQLStrategy = class {
1722
1957
  * predicate. `$and` makes that structurally impossible.
1723
1958
  */
1724
1959
  withReadScope(objectName, filter, ctx) {
1725
- const userFilter = Object.keys(filter).length > 0 ? filter : void 0;
1960
+ const userFilter = Object.keys(filter).length > 0 ? (0, import_data3.markFilterSubtreeProvenance)(filter, "author") : void 0;
1726
1961
  if (typeof ctx.getReadScope !== "function") return userFilter;
1727
1962
  const scope = ctx.getReadScope(objectName);
1728
1963
  if (scope === void 0 || scope === null) return userFilter;
1729
- const scopeFilter = scope;
1964
+ const scopeFilter = (0, import_data3.markFilterSubtreeProvenance)(scope, "policy");
1730
1965
  if (!userFilter) return scopeFilter;
1731
1966
  return { $and: [userFilter, scopeFilter] };
1732
1967
  }
@@ -1900,6 +2135,7 @@ var ObjectQLStrategy = class {
1900
2135
  if (fkValues.length === 0 || typeof ctx.executeAggregate !== "function") return map;
1901
2136
  const idFilter = { id: { $in: fkValues } };
1902
2137
  const scope = typeof ctx.getReadScope === "function" ? ctx.getReadScope(refObject) : null;
2138
+ if (scope != null) (0, import_data3.markFilterSubtreeProvenance)(scope, "policy");
1903
2139
  const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
1904
2140
  const rows = await ctx.executeAggregate(refObject, {
1905
2141
  groupBy: ["id", attr],
@@ -2252,8 +2488,27 @@ var ObjectQLStrategy = class {
2252
2488
  const v0 = values[0];
2253
2489
  const all = [...values];
2254
2490
  switch (operator) {
2491
+ // [#7598] IMPLICIT equality for a literal, EXPLICIT `$eq` for a field
2492
+ // reference — the branch is on the COMPARAND, not on the operator, which
2493
+ // is the same fix and the same reasoning #7597 applied to
2494
+ // `parseFilterAST`, the spec's own lowering sink.
2495
+ //
2496
+ // `{ amount: 5 }` is implicit equality and every backend reads it that
2497
+ // way. `{ amount: { $field: 'budget' } }` is NOT: it is a field-spec
2498
+ // object whose only key is `$field`, which no backend reads as an
2499
+ // equality — `driver-sql` sees an unrecognised operator key and the
2500
+ // memory evaluator sees a comparand it never resolves. So the bare return
2501
+ // was correct for four years' worth of literals and silently wrong for
2502
+ // the one comparand the 2026-08-12 ruling routes HERE on purpose: with it,
2503
+ // `{ amount: { $eq: { $field: 'budget' } } }` — the shape
2504
+ // `compileCelToFilter` emits for a field-to-field CEL rule, and the shape
2505
+ // `canHandle` now declines native SQL for — would arrive at the driver as
2506
+ // something the driver cannot read, so the capability B exists to serve
2507
+ // would fail on its single most important spelling. Its five siblings
2508
+ // (`$ne`/`$gt`/`$gte`/`$lt`/`$lte`) were never affected: they emit their
2509
+ // operator explicitly two lines down.
2255
2510
  case "equals":
2256
- return v0;
2511
+ return isFieldReference(v0) ? { $eq: v0 } : v0;
2257
2512
  case "notEquals":
2258
2513
  return { $ne: v0 };
2259
2514
  case "gt":
@@ -2313,6 +2568,24 @@ var ObjectQLStrategy = class {
2313
2568
  extractObjectName(cube) {
2314
2569
  return cube.sql.trim();
2315
2570
  }
2571
+ /**
2572
+ * [#7598] The query's `where`, lowered — the same input
2573
+ * `NativeSQLStrategy.canHandle` scans, so the strategy that DECLINED and the
2574
+ * echo that refuses read one shape rather than two.
2575
+ *
2576
+ * A throw from the lowering is swallowed for the same reason it is there: the
2577
+ * `where` is malformed either way and `normalizeAnalyticsFilterTree` below
2578
+ * refuses it with the message and envelope it has always had. This helper's
2579
+ * only job is finding a reference, and there is none to find in a filter that
2580
+ * does not lower.
2581
+ */
2582
+ loweredWhere(query) {
2583
+ try {
2584
+ return lowerAnalyticsWhere(query);
2585
+ } catch {
2586
+ return null;
2587
+ }
2588
+ }
2316
2589
  /**
2317
2590
  * The dimensions this query PROJECTS, in the order the result carries them:
2318
2591
  * every `dimensions` entry, then every granular `timeDimensions` entry that
@@ -2354,10 +2627,10 @@ var ObjectQLStrategy = class {
2354
2627
  };
2355
2628
 
2356
2629
  // src/dataset-compiler.ts
2357
- var import_data2 = require("@objectstack/spec/data");
2630
+ var import_data4 = require("@objectstack/spec/data");
2358
2631
  var import_ui = require("@objectstack/spec/ui");
2359
2632
  var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set();
2360
- var SUPPORTED_AGGREGATES = import_data2.AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
2633
+ var SUPPORTED_AGGREGATES = import_data4.AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
2361
2634
  function aggregateToMetricType(m) {
2362
2635
  if (!m.aggregate) {
2363
2636
  throw new Error(`[dataset-compiler] non-derived measure "${m.name}" has no aggregate`);
@@ -2519,11 +2792,11 @@ function compileDataset(dataset, resolver, options) {
2519
2792
  }
2520
2793
 
2521
2794
  // src/dataset-executor.ts
2522
- var import_data3 = require("@objectstack/spec/data");
2523
- var import_core3 = require("@objectstack/core");
2795
+ var import_data5 = require("@objectstack/spec/data");
2796
+ var import_core4 = require("@objectstack/core");
2524
2797
  function resolveSelectionTokens(compiled, selection, context) {
2525
- const tokenCtx = (0, import_core3.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
2526
- const resolve = (v) => (0, import_core3.resolveFilterTokens)(v, tokenCtx);
2798
+ const tokenCtx = (0, import_core4.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
2799
+ const resolve = (v) => (0, import_core4.resolveFilterTokens)(v, tokenCtx);
2527
2800
  const filter = resolve(compiled.filter);
2528
2801
  const measureFilters = resolve(compiled.measureFilters);
2529
2802
  const runtimeFilter = resolve(selection.runtimeFilter);
@@ -2559,7 +2832,7 @@ function evaluateDerivedMeasures(rows, derived) {
2559
2832
  }
2560
2833
  function fillEmptyGroups(rows, columnAggregates) {
2561
2834
  for (const [column, aggregate2] of Object.entries(columnAggregates)) {
2562
- const empty = (0, import_data3.emptyGroupValueFor)(aggregate2);
2835
+ const empty = (0, import_data5.emptyGroupValueFor)(aggregate2);
2563
2836
  if (empty === void 0) continue;
2564
2837
  for (const row of rows) if (row[column] == null) row[column] = empty;
2565
2838
  }
@@ -2759,7 +3032,7 @@ function bucketKeyAtOrdinal(ordinal, granularity) {
2759
3032
  }
2760
3033
  function alignedCompareBucketKey(key, granularity, kind, currentRange, shiftedRange) {
2761
3034
  if (typeof key !== "string" || key.length === 0) return null;
2762
- const span = (0, import_core3.bucketKeyToCalendarRange)(key, granularity);
3035
+ const span = (0, import_core4.bucketKeyToCalendarRange)(key, granularity);
2763
3036
  if (!span) return null;
2764
3037
  const targetOrdinal = kind === "previousYear" ? bucketOrdinalOfDay(shiftYear(span.start, 1), granularity) : bucketOrdinalOfDay(span.start, granularity) + (bucketOrdinalOfDay(currentRange[0], granularity) - bucketOrdinalOfDay(shiftedRange[0], granularity));
2765
3038
  const first = bucketOrdinalOfDay(currentRange[0], granularity);
@@ -3223,18 +3496,18 @@ function pickDisplayField(fields) {
3223
3496
  }
3224
3497
 
3225
3498
  // src/preview-evaluator.ts
3226
- var import_core4 = require("@objectstack/core");
3499
+ var import_core5 = require("@objectstack/core");
3227
3500
  function compare(a, b) {
3228
3501
  if (typeof a === "number" && typeof b === "number") return a - b;
3229
3502
  if (a instanceof Date || b instanceof Date) {
3230
- const ai = (0, import_core4.utcInstantMs)(a);
3231
- const bi = (0, import_core4.utcInstantMs)(b);
3503
+ const ai = (0, import_core5.utcInstantMs)(a);
3504
+ const bi = (0, import_core5.utcInstantMs)(b);
3232
3505
  if (ai !== null && bi !== null) return ai - bi;
3233
3506
  }
3234
3507
  return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0;
3235
3508
  }
3236
3509
  function lteBound(value, bound) {
3237
- const nextDay = (0, import_core4.nextUtcCalendarDay)(bound);
3510
+ const nextDay = (0, import_core5.nextUtcCalendarDay)(bound);
3238
3511
  if (nextDay != null) return compare(value, nextDay) < 0;
3239
3512
  return compare(value, bound) <= 0;
3240
3513
  }
@@ -3292,7 +3565,7 @@ function matchesWhere(row, where) {
3292
3565
  function bucketDate(value, granularity, timezone) {
3293
3566
  const d = new Date(String(value));
3294
3567
  if (Number.isNaN(d.getTime())) return null;
3295
- const { year: y, month, day: dayNum } = (0, import_core4.calendarPartsInTzOrUtc)(d, timezone);
3568
+ const { year: y, month, day: dayNum } = (0, import_core5.calendarPartsInTzOrUtc)(d, timezone);
3296
3569
  const m = `${month}`.padStart(2, "0");
3297
3570
  const day = `${dayNum}`.padStart(2, "0");
3298
3571
  switch (granularity) {
@@ -3346,7 +3619,7 @@ function evaluateAnalyticsQueryOverRows(query, cube, rows) {
3346
3619
  const [start, end] = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
3347
3620
  filtered = filtered.filter((r) => {
3348
3621
  const v = String(r[field] ?? "");
3349
- const nextDay = (0, import_core4.nextUtcCalendarDay)(end);
3622
+ const nextDay = (0, import_core5.nextUtcCalendarDay)(end);
3350
3623
  const inUpper = nextDay != null ? v < nextDay : v <= `${end}~`;
3351
3624
  return v >= String(start) && inUpper;
3352
3625
  });
@@ -3480,7 +3753,7 @@ var AnalyticsService = class {
3480
3753
  this.datasetRegistry = /* @__PURE__ */ new Map();
3481
3754
  /** [#3867] One-shot flag for the {@link assertInferableCube} stand-down warning. */
3482
3755
  this.warnedNoObjectRegistry = false;
3483
- this.logger = config.logger || (0, import_core5.createLogger)({ level: "info", format: "pretty" });
3756
+ this.logger = config.logger || (0, import_core6.createLogger)({ level: "info", format: "pretty" });
3484
3757
  this.cubeRegistry = new CubeRegistry();
3485
3758
  if (config.cubes) {
3486
3759
  this.cubeRegistry.registerAll(config.cubes);
@@ -3494,6 +3767,7 @@ var AnalyticsService = class {
3494
3767
  this.getObjectFieldNames = config.getObjectFieldNames;
3495
3768
  this.getObjectDatasource = config.getObjectDatasource;
3496
3769
  this.isExternalObject = config.isExternalObject;
3770
+ this.debugSql = config.debugSql ?? (0, import_core6.getEnv)("NODE_ENV") === "development";
3497
3771
  if (config.datasets) {
3498
3772
  for (const ds of config.datasets) {
3499
3773
  try {
@@ -3611,7 +3885,7 @@ var AnalyticsService = class {
3611
3885
  const strategy = this.resolveStrategy(query, ctx, skip);
3612
3886
  this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
3613
3887
  try {
3614
- return await strategy.execute(query, ctx);
3888
+ return this.applySqlEchoPolicy(await strategy.execute(query, ctx));
3615
3889
  } catch (e) {
3616
3890
  if (e?.code === "RAW_SQL_UNSUPPORTED") {
3617
3891
  this.logger.warn(
@@ -3624,6 +3898,30 @@ var AnalyticsService = class {
3624
3898
  }
3625
3899
  }
3626
3900
  }
3901
+ /**
3902
+ * [#8286] Withhold the executed statement unless this host enabled the echo.
3903
+ *
3904
+ * Applied at {@link query}, which is the response-assembly seam for BOTH
3905
+ * faces that serve callers: `/api/v1/analytics/query` calls it directly, and
3906
+ * `queryDataset` reaches it through `DatasetExecutor`, so a dataset response
3907
+ * inherits the same verdict without a second gate to keep in step.
3908
+ * `generateSql` — the dedicated `/api/v1/analytics/sql` dry-run route — is
3909
+ * deliberately NOT gated: asking for the statement is that route's entire
3910
+ * purpose, and it is the surface a debugging author is meant to use.
3911
+ *
3912
+ * What the echo disclosed, and why "it is only a table name" understates it:
3913
+ * the statement carries the compiled read scope, i.e. the SHAPE of the
3914
+ * isolation predicate (`"sys_user"."id" IN ($2, $3, …)` rather than an
3915
+ * `organization_id` comparison) plus its bound-parameter arity, which counts
3916
+ * the caller's own org membership. No wall was breached by it — the echo is
3917
+ * information disclosure, and this is the disclosure closing.
3918
+ */
3919
+ applySqlEchoPolicy(result) {
3920
+ if (this.debugSql || result?.sql === void 0) return result;
3921
+ const withheld = { ...result };
3922
+ delete withheld.sql;
3923
+ return withheld;
3924
+ }
3627
3925
  /**
3628
3926
  * Compile a `dataset` (ADR-0021) and register its Cube + join allowlist so it
3629
3927
  * can be queried by name. Idempotent (re-registering overwrites). Returns the
@@ -3734,11 +4032,11 @@ var AnalyticsService = class {
3734
4032
  else if (rangeTz === "UTC") rangeDims.push({ d, granularity, instant: false });
3735
4033
  }
3736
4034
  if (rangeDims.length && result.rows.length) {
3737
- const bound = (ymd, instant) => instant ? new Date((0, import_core5.zonedDateStartToUtcMs)(ymd, rangeTz)).toISOString() : ymd;
4035
+ const bound = (ymd, instant) => instant ? new Date((0, import_core6.zonedDateStartToUtcMs)(ymd, rangeTz)).toISOString() : ymd;
3738
4036
  result.drillRanges = result.rows.map((row) => {
3739
4037
  const ranges = {};
3740
4038
  for (const { d, granularity, instant } of rangeDims) {
3741
- const cal = (0, import_core5.bucketKeyToCalendarRange)(row[d.name], granularity);
4039
+ const cal = (0, import_core6.bucketKeyToCalendarRange)(row[d.name], granularity);
3742
4040
  if (cal) {
3743
4041
  ranges[d.name] = { field: d.field, gte: bound(cal.start, instant), lt: bound(cal.end, instant) };
3744
4042
  }
@@ -3789,7 +4087,7 @@ var AnalyticsService = class {
3789
4087
  }
3790
4088
  }
3791
4089
  if (f.percentScale == null) {
3792
- f.percentScale = m.derived?.op === "ratio" ? "fraction" : (0, import_data4.percentScaleOf)(meta);
4090
+ f.percentScale = m.derived?.op === "ratio" ? "fraction" : (0, import_data6.percentScaleOf)(meta);
3793
4091
  }
3794
4092
  }
3795
4093
  }
@@ -4288,11 +4586,19 @@ var AnalyticsService = class {
4288
4586
  return strategy;
4289
4587
  }
4290
4588
  }
4589
+ const crossField = findCrossFieldComparand(lowerAnalyticsWhereQuietly(query));
4291
4590
  throw new Error(
4292
- `[Analytics] No strategy can handle query for cube "${query.cube}". Checked: ${this.strategies.map((s) => s.name).join(", ")}${skip?.size ? ` (skipped at runtime: ${[...skip].map((s) => s.name).join(", ")})` : ""}. Ensure a compatible driver is configured or a fallback service is registered.`
4591
+ `[Analytics] No strategy can handle query for cube "${query.cube}". Checked: ${this.strategies.map((s) => s.name).join(", ")}${skip?.size ? ` (skipped at runtime: ${[...skip].map((s) => s.name).join(", ")})` : ""}. ` + (crossField ? `This query's filter compares against the field reference { "$field": "${crossField.ref}" } under "${crossField.op}" on "${crossField.field}", and NativeSQLStrategy DECLINES a cross-field comparison so that it routes to the ObjectQL engine path \u2014 whose driver compiles it and enforces the #5222 rulings with metadata it owns (#7598). No such path is configured here, so the capability is unavailable on this deployment: supply an \`executeAggregate\` bridge (the plugin auto-wires one from the engine), or compare against a literal value. Every other query on this cube is unaffected. ` : "") + "Ensure a compatible driver is configured or a fallback service is registered."
4293
4592
  );
4294
4593
  }
4295
4594
  };
4595
+ function lowerAnalyticsWhereQuietly(query) {
4596
+ try {
4597
+ return lowerAnalyticsWhere(query);
4598
+ } catch {
4599
+ return null;
4600
+ }
4601
+ }
4296
4602
  function mintableMeasureKey(member, cubeName) {
4297
4603
  const dot = member.indexOf(".");
4298
4604
  if (dot < 0) return member;
@@ -4588,6 +4894,11 @@ var AnalyticsServicePlugin = class {
4588
4894
  coerceTemporalFilterColumn,
4589
4895
  relationshipResolver,
4590
4896
  labelResolver,
4897
+ // [#8286] Passed through as authored — `undefined` is "this host did not
4898
+ // choose", which the service resolves to development-only. Defaulting it
4899
+ // here would be a second copy of that decision, drifting the moment one
4900
+ // of the two moves.
4901
+ debugSql: this.options.debugSql,
4591
4902
  // Source-field metadata behind the display chains on result columns:
4592
4903
  // ADR-0053 currency (`currencyConfig.defaultCurrency`) and percent scale
4593
4904
  // (`max`, which is what marks whole-percent storage — objectui#3136).