@objectstack/service-analytics 17.0.0-rc.6 → 17.0.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,7 +41,7 @@ __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_data5 = require("@objectstack/spec/data");
45
45
  var import_ui2 = require("@objectstack/spec/ui");
46
46
  var import_core5 = require("@objectstack/core");
47
47
  var import_types = require("@objectstack/types");
@@ -178,11 +178,46 @@ function isRenderableTextComparand(value) {
178
178
  if (kind === "string" || kind === "number" || kind === "bigint" || kind === "boolean") return true;
179
179
  return value instanceof Date;
180
180
  }
181
+ function isFieldReference(value) {
182
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
183
+ return typeof value.$field === "string";
184
+ }
185
+ var CROSS_FIELD_COMPARISON_OPERATORS = /* @__PURE__ */ new Set([
186
+ "$eq",
187
+ "$ne",
188
+ "$gt",
189
+ "$gte",
190
+ "$lt",
191
+ "$lte"
192
+ ]);
193
+ function findCrossFieldComparand(filter) {
194
+ return findIn(filter, "");
195
+ }
196
+ function findIn(node, field) {
197
+ if (!node || typeof node !== "object") return null;
198
+ if (Array.isArray(node)) {
199
+ for (const child of node) {
200
+ const hit = findIn(child, field);
201
+ if (hit) return hit;
202
+ }
203
+ return null;
204
+ }
205
+ if (node instanceof Date || ArrayBuffer.isView(node)) return null;
206
+ for (const [key, value] of Object.entries(node)) {
207
+ if (CROSS_FIELD_COMPARISON_OPERATORS.has(key) && isFieldReference(value)) {
208
+ return { op: key, field, ref: value.$field };
209
+ }
210
+ const hit = findIn(value, key.startsWith("$") ? field : key);
211
+ if (hit) return hit;
212
+ }
213
+ return null;
214
+ }
181
215
  var TEXT_PATTERN_OPERATORS = /* @__PURE__ */ new Set([
182
216
  "$contains",
183
217
  "$notContains",
184
218
  "$startsWith",
185
- "$endsWith"
219
+ "$endsWith",
220
+ "$icontains"
186
221
  ]);
187
222
  function shapePreview(value) {
188
223
  try {
@@ -196,6 +231,12 @@ function shapePreview(value) {
196
231
  function unrenderableTextComparandMessage(op, field, value) {
197
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.`;
198
233
  }
234
+ function fieldReferenceComparandMessage(op, field, ref, position) {
235
+ 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).`;
236
+ }
237
+ function fieldReferenceBetweenBoundMessage(op, field, ref, index) {
238
+ 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
+ }
199
240
  function unbindableListMemberMessage(op, field, value, index) {
200
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).`;
201
242
  }
@@ -261,6 +302,15 @@ function assertCompilableComparand(opKey, field, value) {
261
302
  });
262
303
  }
263
304
  }
305
+ function assertNoFieldReferenceComparand(opKey, field, value) {
306
+ if (opKey !== "$between" || !Array.isArray(value)) return;
307
+ value.forEach((member, index) => {
308
+ if (!isFieldReference(member)) return;
309
+ throw invalidFilterError(
310
+ `[analytics] ${fieldReferenceBetweenBoundMessage(opKey, field, member.$field, index)}`
311
+ );
312
+ });
313
+ }
264
314
  function undefinedComparandError(field, path) {
265
315
  return invalidFilterError(
266
316
  `[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 +380,7 @@ function fieldLeaves(key, raw) {
330
380
  `[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
381
  );
332
382
  }
383
+ assertNoFieldReferenceComparand(opKey, key, v2);
333
384
  leaf("gte", [comparand(v2[0])]);
334
385
  leaf("lte", [comparand(v2[1])]);
335
386
  continue;
@@ -457,6 +508,7 @@ function nullValueSatisfiesOperator(op, value) {
457
508
  }
458
509
  }
459
510
  function operatorIsNullTotal(op, value) {
511
+ if (CROSS_FIELD_COMPARISON_OPERATORS.has(op) && isFieldReference(value)) return true;
460
512
  switch (op) {
461
513
  // Compile to `set` / `notSet` — `IS NULL` / `IS NOT NULL`, two-valued by
462
514
  // construction, on every strategy that compiles this tree.
@@ -677,6 +729,7 @@ function compileField(field, value, qAlias, params) {
677
729
  const col = `${qAlias}.${quoteIdent(field, "field")}`;
678
730
  assertDefinedComparands2(field, value);
679
731
  assertBooleanFlagComparands(field, value);
732
+ assertNoFieldReferenceComparand2(field, value);
680
733
  if (value === null) return `${col} IS NULL`;
681
734
  if (typeof value !== "object" || value instanceof Date) {
682
735
  params.push(value);
@@ -749,6 +802,23 @@ function assertBooleanFlagComparands(field, spec) {
749
802
  throw nonBooleanFlagComparandError(op, field, `"${field}".${op}`);
750
803
  }
751
804
  }
805
+ function assertNoFieldReferenceComparand2(field, spec) {
806
+ if (!isFilterNode(spec)) return;
807
+ for (const [op, opValue] of Object.entries(spec)) {
808
+ if (CROSS_FIELD_COMPARISON_OPERATORS.has(op) && isFieldReference(opValue)) {
809
+ throw readScopeCompileError(
810
+ `[read-scope-sql] ${fieldReferenceComparandMessage(op, field, opValue.$field)}`
811
+ );
812
+ }
813
+ if (op !== "$between" || !Array.isArray(opValue)) continue;
814
+ opValue.forEach((member, index) => {
815
+ if (!isFieldReference(member)) return;
816
+ throw readScopeCompileError(
817
+ `[read-scope-sql] ${fieldReferenceBetweenBoundMessage(op, field, member.$field, index)}`
818
+ );
819
+ });
820
+ }
821
+ }
752
822
  function compileOperator(col, op, val, field, params) {
753
823
  switch (op) {
754
824
  case "$eq":
@@ -980,9 +1050,82 @@ var NativeSQLStrategy = class {
980
1050
  }
981
1051
  }
982
1052
  }
1053
+ if (this.carriesCrossFieldComparison(query, ctx)) return false;
983
1054
  const caps = ctx.queryCapabilities(query.cube);
984
1055
  return caps.nativeSql && typeof ctx.executeRawSql === "function";
985
1056
  }
1057
+ /**
1058
+ * [#7598] Does serving this query require the cross-field capability this
1059
+ * strategy declines? See the ruling recorded at {@link canHandle}.
1060
+ *
1061
+ * ⚠️ This and {@link assertNoCrossFieldComparison} read the SAME inputs
1062
+ * through the SAME detector, which is what makes the decline and the
1063
+ * fail-closed backstop unable to drift: a shape one of them recognises is a
1064
+ * shape the other recognises.
1065
+ */
1066
+ carriesCrossFieldComparison(query, ctx) {
1067
+ return this.crossFieldComparisonIn(query, ctx) !== null;
1068
+ }
1069
+ crossFieldComparisonIn(query, ctx) {
1070
+ let where = null;
1071
+ try {
1072
+ where = lowerAnalyticsWhere(query);
1073
+ } catch {
1074
+ return null;
1075
+ }
1076
+ const inWhere = findCrossFieldComparand(where);
1077
+ if (inWhere) return { source: "the query's `where`", ...inWhere };
1078
+ if (typeof ctx.getReadScope !== "function") return null;
1079
+ const cube = query.cube ? ctx.getCube(query.cube) : void 0;
1080
+ if (!cube) return null;
1081
+ const objects = [this.extractObjectName(cube)];
1082
+ for (const alias of Object.keys(cube.joins ?? {})) {
1083
+ objects.push(cube.joins?.[alias]?.name ?? alias);
1084
+ }
1085
+ for (const objectName of objects) {
1086
+ const scope = ctx.getReadScope(objectName);
1087
+ if (scope === void 0 || scope === null) continue;
1088
+ const inScope = findCrossFieldComparand(scope);
1089
+ if (inScope) return { source: `the read scope of "${objectName}"`, ...inScope };
1090
+ }
1091
+ return null;
1092
+ }
1093
+ /**
1094
+ * [#7598] The fail-closed backstop at the door that BINDS.
1095
+ *
1096
+ * ⚠️ **Unreachable by construction, and kept deliberately** — saying so
1097
+ * because #7598's brief asks that a refusal arm which has become unreachable
1098
+ * be named rather than left to be re-discovered. {@link canHandle} declines
1099
+ * every query this would fire on, and it declines using
1100
+ * {@link crossFieldComparisonIn} — the same walk over the same two inputs —
1101
+ * so `resolveStrategy` cannot hand this strategy a query carrying one.
1102
+ *
1103
+ * It is kept because of what the failure mode is if that ever stops being
1104
+ * true. The defect #7598 measured was not a missing error: it was a SILENT
1105
+ * BIND — `toSqlBindValue` JSON-stringifies the reference object, so the
1106
+ * statement compiled perfectly and compared a column against the text
1107
+ * `{"$field":"budget"}`, a value no row can hold. A routing gate that misses
1108
+ * a shape therefore degrades to a wrong ANSWER rather than to an error, and
1109
+ * that is the one class this package refuses to leave to a single guard
1110
+ * (Prime Directive #12 — refuse at the door, do not tolerate at the
1111
+ * consumer). One line, no measurable cost, and it turns a routing regression
1112
+ * into a loud refusal instead of an empty chart.
1113
+ *
1114
+ * Deliberately BARE — an undeclared 500, not `INVALID_FILTER` / 400 — for the
1115
+ * reason `buildFilterClauseSql`'s #5333 exit in `objectql-strategy.ts` gives
1116
+ * for the same class: the caller's filter is legal and is served on the
1117
+ * engine path, so an arrival here is drift between our own routing gate and
1118
+ * our own emitter. Billing the caller 400 for that would hide a platform bug
1119
+ * from 5xx alerting and tell a dashboard user to fix a filter that is fine.
1120
+ * Same tier as `resolveMeasureSql`'s unrecognised-`Metric.type` throw below.
1121
+ */
1122
+ assertNoCrossFieldComparison(query, ctx) {
1123
+ const hit = this.crossFieldComparisonIn(query, ctx);
1124
+ if (!hit) return;
1125
+ throw new Error(
1126
+ `[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.`
1127
+ );
1128
+ }
986
1129
  async execute(query, ctx) {
987
1130
  const { sql, params } = await this.generateSql(query, ctx);
988
1131
  const cube = ctx.getCube(query.cube);
@@ -996,6 +1139,7 @@ var NativeSQLStrategy = class {
996
1139
  if (!cube) {
997
1140
  throw new Error(`Cube not found: ${query.cube}`);
998
1141
  }
1142
+ this.assertNoCrossFieldComparison(query, ctx);
999
1143
  const params = [];
1000
1144
  const selectClauses = [];
1001
1145
  const groupByClauses = [];
@@ -1432,6 +1576,7 @@ var NativeSQLStrategy = class {
1432
1576
  };
1433
1577
 
1434
1578
  // src/strategies/objectql-strategy.ts
1579
+ var import_data2 = require("@objectstack/spec/data");
1435
1580
  var import_core2 = require("@objectstack/core");
1436
1581
 
1437
1582
  // src/strategies/cross-object-rebucket.ts
@@ -1618,6 +1763,12 @@ var ObjectQLStrategy = class {
1618
1763
  if (!cube) {
1619
1764
  throw new Error(`Cube not found: ${query.cube}`);
1620
1765
  }
1766
+ const crossField = findCrossFieldComparand(this.loweredWhere(query));
1767
+ if (crossField) {
1768
+ throw invalidFilterError(
1769
+ `[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.`
1770
+ );
1771
+ }
1621
1772
  const selectParts = [];
1622
1773
  const groupByParts = [];
1623
1774
  const params = [];
@@ -1722,11 +1873,11 @@ var ObjectQLStrategy = class {
1722
1873
  * predicate. `$and` makes that structurally impossible.
1723
1874
  */
1724
1875
  withReadScope(objectName, filter, ctx) {
1725
- const userFilter = Object.keys(filter).length > 0 ? filter : void 0;
1876
+ const userFilter = Object.keys(filter).length > 0 ? (0, import_data2.markFilterSubtreeProvenance)(filter, "author") : void 0;
1726
1877
  if (typeof ctx.getReadScope !== "function") return userFilter;
1727
1878
  const scope = ctx.getReadScope(objectName);
1728
1879
  if (scope === void 0 || scope === null) return userFilter;
1729
- const scopeFilter = scope;
1880
+ const scopeFilter = (0, import_data2.markFilterSubtreeProvenance)(scope, "policy");
1730
1881
  if (!userFilter) return scopeFilter;
1731
1882
  return { $and: [userFilter, scopeFilter] };
1732
1883
  }
@@ -1900,6 +2051,7 @@ var ObjectQLStrategy = class {
1900
2051
  if (fkValues.length === 0 || typeof ctx.executeAggregate !== "function") return map;
1901
2052
  const idFilter = { id: { $in: fkValues } };
1902
2053
  const scope = typeof ctx.getReadScope === "function" ? ctx.getReadScope(refObject) : null;
2054
+ if (scope != null) (0, import_data2.markFilterSubtreeProvenance)(scope, "policy");
1903
2055
  const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
1904
2056
  const rows = await ctx.executeAggregate(refObject, {
1905
2057
  groupBy: ["id", attr],
@@ -2252,8 +2404,27 @@ var ObjectQLStrategy = class {
2252
2404
  const v0 = values[0];
2253
2405
  const all = [...values];
2254
2406
  switch (operator) {
2407
+ // [#7598] IMPLICIT equality for a literal, EXPLICIT `$eq` for a field
2408
+ // reference — the branch is on the COMPARAND, not on the operator, which
2409
+ // is the same fix and the same reasoning #7597 applied to
2410
+ // `parseFilterAST`, the spec's own lowering sink.
2411
+ //
2412
+ // `{ amount: 5 }` is implicit equality and every backend reads it that
2413
+ // way. `{ amount: { $field: 'budget' } }` is NOT: it is a field-spec
2414
+ // object whose only key is `$field`, which no backend reads as an
2415
+ // equality — `driver-sql` sees an unrecognised operator key and the
2416
+ // memory evaluator sees a comparand it never resolves. So the bare return
2417
+ // was correct for four years' worth of literals and silently wrong for
2418
+ // the one comparand the 2026-08-12 ruling routes HERE on purpose: with it,
2419
+ // `{ amount: { $eq: { $field: 'budget' } } }` — the shape
2420
+ // `compileCelToFilter` emits for a field-to-field CEL rule, and the shape
2421
+ // `canHandle` now declines native SQL for — would arrive at the driver as
2422
+ // something the driver cannot read, so the capability B exists to serve
2423
+ // would fail on its single most important spelling. Its five siblings
2424
+ // (`$ne`/`$gt`/`$gte`/`$lt`/`$lte`) were never affected: they emit their
2425
+ // operator explicitly two lines down.
2255
2426
  case "equals":
2256
- return v0;
2427
+ return isFieldReference(v0) ? { $eq: v0 } : v0;
2257
2428
  case "notEquals":
2258
2429
  return { $ne: v0 };
2259
2430
  case "gt":
@@ -2313,6 +2484,24 @@ var ObjectQLStrategy = class {
2313
2484
  extractObjectName(cube) {
2314
2485
  return cube.sql.trim();
2315
2486
  }
2487
+ /**
2488
+ * [#7598] The query's `where`, lowered — the same input
2489
+ * `NativeSQLStrategy.canHandle` scans, so the strategy that DECLINED and the
2490
+ * echo that refuses read one shape rather than two.
2491
+ *
2492
+ * A throw from the lowering is swallowed for the same reason it is there: the
2493
+ * `where` is malformed either way and `normalizeAnalyticsFilterTree` below
2494
+ * refuses it with the message and envelope it has always had. This helper's
2495
+ * only job is finding a reference, and there is none to find in a filter that
2496
+ * does not lower.
2497
+ */
2498
+ loweredWhere(query) {
2499
+ try {
2500
+ return lowerAnalyticsWhere(query);
2501
+ } catch {
2502
+ return null;
2503
+ }
2504
+ }
2316
2505
  /**
2317
2506
  * The dimensions this query PROJECTS, in the order the result carries them:
2318
2507
  * every `dimensions` entry, then every granular `timeDimensions` entry that
@@ -2354,10 +2543,10 @@ var ObjectQLStrategy = class {
2354
2543
  };
2355
2544
 
2356
2545
  // src/dataset-compiler.ts
2357
- var import_data2 = require("@objectstack/spec/data");
2546
+ var import_data3 = require("@objectstack/spec/data");
2358
2547
  var import_ui = require("@objectstack/spec/ui");
2359
2548
  var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set();
2360
- var SUPPORTED_AGGREGATES = import_data2.AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
2549
+ var SUPPORTED_AGGREGATES = import_data3.AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
2361
2550
  function aggregateToMetricType(m) {
2362
2551
  if (!m.aggregate) {
2363
2552
  throw new Error(`[dataset-compiler] non-derived measure "${m.name}" has no aggregate`);
@@ -2519,7 +2708,7 @@ function compileDataset(dataset, resolver, options) {
2519
2708
  }
2520
2709
 
2521
2710
  // src/dataset-executor.ts
2522
- var import_data3 = require("@objectstack/spec/data");
2711
+ var import_data4 = require("@objectstack/spec/data");
2523
2712
  var import_core3 = require("@objectstack/core");
2524
2713
  function resolveSelectionTokens(compiled, selection, context) {
2525
2714
  const tokenCtx = (0, import_core3.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
@@ -2559,7 +2748,7 @@ function evaluateDerivedMeasures(rows, derived) {
2559
2748
  }
2560
2749
  function fillEmptyGroups(rows, columnAggregates) {
2561
2750
  for (const [column, aggregate2] of Object.entries(columnAggregates)) {
2562
- const empty = (0, import_data3.emptyGroupValueFor)(aggregate2);
2751
+ const empty = (0, import_data4.emptyGroupValueFor)(aggregate2);
2563
2752
  if (empty === void 0) continue;
2564
2753
  for (const row of rows) if (row[column] == null) row[column] = empty;
2565
2754
  }
@@ -3494,6 +3683,7 @@ var AnalyticsService = class {
3494
3683
  this.getObjectFieldNames = config.getObjectFieldNames;
3495
3684
  this.getObjectDatasource = config.getObjectDatasource;
3496
3685
  this.isExternalObject = config.isExternalObject;
3686
+ this.debugSql = config.debugSql ?? (0, import_core5.getEnv)("NODE_ENV") === "development";
3497
3687
  if (config.datasets) {
3498
3688
  for (const ds of config.datasets) {
3499
3689
  try {
@@ -3611,7 +3801,7 @@ var AnalyticsService = class {
3611
3801
  const strategy = this.resolveStrategy(query, ctx, skip);
3612
3802
  this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
3613
3803
  try {
3614
- return await strategy.execute(query, ctx);
3804
+ return this.applySqlEchoPolicy(await strategy.execute(query, ctx));
3615
3805
  } catch (e) {
3616
3806
  if (e?.code === "RAW_SQL_UNSUPPORTED") {
3617
3807
  this.logger.warn(
@@ -3624,6 +3814,30 @@ var AnalyticsService = class {
3624
3814
  }
3625
3815
  }
3626
3816
  }
3817
+ /**
3818
+ * [#8286] Withhold the executed statement unless this host enabled the echo.
3819
+ *
3820
+ * Applied at {@link query}, which is the response-assembly seam for BOTH
3821
+ * faces that serve callers: `/api/v1/analytics/query` calls it directly, and
3822
+ * `queryDataset` reaches it through `DatasetExecutor`, so a dataset response
3823
+ * inherits the same verdict without a second gate to keep in step.
3824
+ * `generateSql` — the dedicated `/api/v1/analytics/sql` dry-run route — is
3825
+ * deliberately NOT gated: asking for the statement is that route's entire
3826
+ * purpose, and it is the surface a debugging author is meant to use.
3827
+ *
3828
+ * What the echo disclosed, and why "it is only a table name" understates it:
3829
+ * the statement carries the compiled read scope, i.e. the SHAPE of the
3830
+ * isolation predicate (`"sys_user"."id" IN ($2, $3, …)` rather than an
3831
+ * `organization_id` comparison) plus its bound-parameter arity, which counts
3832
+ * the caller's own org membership. No wall was breached by it — the echo is
3833
+ * information disclosure, and this is the disclosure closing.
3834
+ */
3835
+ applySqlEchoPolicy(result) {
3836
+ if (this.debugSql || result?.sql === void 0) return result;
3837
+ const withheld = { ...result };
3838
+ delete withheld.sql;
3839
+ return withheld;
3840
+ }
3627
3841
  /**
3628
3842
  * Compile a `dataset` (ADR-0021) and register its Cube + join allowlist so it
3629
3843
  * can be queried by name. Idempotent (re-registering overwrites). Returns the
@@ -3789,7 +4003,7 @@ var AnalyticsService = class {
3789
4003
  }
3790
4004
  }
3791
4005
  if (f.percentScale == null) {
3792
- f.percentScale = m.derived?.op === "ratio" ? "fraction" : (0, import_data4.percentScaleOf)(meta);
4006
+ f.percentScale = m.derived?.op === "ratio" ? "fraction" : (0, import_data5.percentScaleOf)(meta);
3793
4007
  }
3794
4008
  }
3795
4009
  }
@@ -4288,11 +4502,19 @@ var AnalyticsService = class {
4288
4502
  return strategy;
4289
4503
  }
4290
4504
  }
4505
+ const crossField = findCrossFieldComparand(lowerAnalyticsWhereQuietly(query));
4291
4506
  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.`
4507
+ `[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
4508
  );
4294
4509
  }
4295
4510
  };
4511
+ function lowerAnalyticsWhereQuietly(query) {
4512
+ try {
4513
+ return lowerAnalyticsWhere(query);
4514
+ } catch {
4515
+ return null;
4516
+ }
4517
+ }
4296
4518
  function mintableMeasureKey(member, cubeName) {
4297
4519
  const dot = member.indexOf(".");
4298
4520
  if (dot < 0) return member;
@@ -4588,6 +4810,11 @@ var AnalyticsServicePlugin = class {
4588
4810
  coerceTemporalFilterColumn,
4589
4811
  relationshipResolver,
4590
4812
  labelResolver,
4813
+ // [#8286] Passed through as authored — `undefined` is "this host did not
4814
+ // choose", which the service resolves to development-only. Defaulting it
4815
+ // here would be a second copy of that decision, drifting the moment one
4816
+ // of the two moves.
4817
+ debugSql: this.options.debugSql,
4591
4818
  // Source-field metadata behind the display chains on result columns:
4592
4819
  // ADR-0053 currency (`currencyConfig.defaultCurrency`) and percent scale
4593
4820
  // (`max`, which is what marks whole-percent storage — objectui#3136).