@objectstack/service-analytics 17.0.0-rc.5 → 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,8 +41,10 @@ __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
+ var import_ui2 = require("@objectstack/spec/ui");
45
46
  var import_core5 = require("@objectstack/core");
47
+ var import_types = require("@objectstack/types");
46
48
 
47
49
  // src/cube-registry.ts
48
50
  var CubeRegistry = class {
@@ -162,6 +164,84 @@ var CubeRegistry = class {
162
164
  // src/strategies/filter-normalizer.ts
163
165
  var import_data = require("@objectstack/spec/data");
164
166
  var import_api = require("@objectstack/spec/api");
167
+
168
+ // src/comparand-shape.ts
169
+ 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);
174
+ }
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;
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
+ }
215
+ var TEXT_PATTERN_OPERATORS = /* @__PURE__ */ new Set([
216
+ "$contains",
217
+ "$notContains",
218
+ "$startsWith",
219
+ "$endsWith",
220
+ "$icontains"
221
+ ]);
222
+ function shapePreview(value) {
223
+ try {
224
+ const json = JSON.stringify(value);
225
+ if (typeof json !== "string") return typeof value;
226
+ return json.length > 80 ? `${json.slice(0, 77)}...` : json;
227
+ } catch {
228
+ return typeof value;
229
+ }
230
+ }
231
+ 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.`;
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
+ }
240
+ 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).`;
242
+ }
243
+
244
+ // src/strategies/filter-normalizer.ts
165
245
  function invalidFilterError(message) {
166
246
  const err = new Error(message);
167
247
  err.code = import_api.StandardErrorCode.enum.INVALID_FILTER;
@@ -180,7 +260,11 @@ var MONGO_TO_CUBE_OP = {
180
260
  $contains: "contains",
181
261
  $notContains: "notContains",
182
262
  $startsWith: "startsWith",
183
- $endsWith: "endsWith"
263
+ $endsWith: "endsWith",
264
+ // [#6520] The case-INSENSITIVE twin, ASCII fold only. A separate cube operator
265
+ // rather than a flag on `contains`, because the two compile to different SQL
266
+ // and one name would make the renderers guess which was meant.
267
+ $icontains: "icontains"
184
268
  };
185
269
  function comparand(v) {
186
270
  return v === void 0 ? null : v;
@@ -203,7 +287,73 @@ function andOf(children) {
203
287
  if (children.length === 1) return children[0];
204
288
  return { kind: "and", children };
205
289
  }
290
+ function assertCompilableComparand(opKey, field, value) {
291
+ if (TEXT_PATTERN_OPERATORS.has(opKey)) {
292
+ if (!isRenderableTextComparand(value)) {
293
+ throw invalidFilterError(`[analytics] ${unrenderableTextComparandMessage(opKey, field, value)}`);
294
+ }
295
+ return;
296
+ }
297
+ if ((opKey === "$in" || opKey === "$nin") && Array.isArray(value)) {
298
+ value.forEach((member, index) => {
299
+ if (!isBindableComparand(member)) {
300
+ throw invalidFilterError(`[analytics] ${unbindableListMemberMessage(opKey, field, member, index)}`);
301
+ }
302
+ });
303
+ }
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
+ }
314
+ function undefinedComparandError(field, path) {
315
+ return invalidFilterError(
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).`
317
+ );
318
+ }
319
+ function assertDefinedComparands(field, spec) {
320
+ const root = `"${field}"`;
321
+ if (spec === void 0) throw undefinedComparandError(field, root);
322
+ if (Array.isArray(spec)) {
323
+ spec.forEach((member, index) => {
324
+ if (member === void 0) throw undefinedComparandError(field, `${root}[${index}]`);
325
+ });
326
+ return;
327
+ }
328
+ if (!isFilterObject(spec)) return;
329
+ for (const [op, opValue] of Object.entries(spec)) {
330
+ if (!op.startsWith("$") || op === "$null" || op === "$exists") continue;
331
+ const opPath = `${root}.${op}`;
332
+ if (opValue === void 0) throw undefinedComparandError(field, opPath);
333
+ if (!Array.isArray(opValue)) continue;
334
+ opValue.forEach((member, index) => {
335
+ if (member === void 0) throw undefinedComparandError(field, `${opPath}[${index}]`);
336
+ });
337
+ }
338
+ }
339
+ function mixedFieldWrapperError(field, opKeys, nonOpKeys) {
340
+ const offending = nonOpKeys.map((k) => `"${k}"`).join(", ");
341
+ const rewrites = nonOpKeys.map((k) => `"${k}" \u2192 "$${k}"`).join(", ");
342
+ const example = nonOpKeys[0];
343
+ return invalidFilterError(
344
+ `[analytics] "${field}" mixes $-operator keys (${opKeys.join(", ")}) with non-$ sibling key(s) ${offending} in ONE field constraint \u2014 refusing to compile this filter. A $-prefixed key is an OPERATOR and a bare key is a NESTED-RELATION member; the two readings of ${offending} lead to different predicates and this module cannot tell which was meant, so any silent choice is a guess. If an operator missing its "$" was meant \u2014 the usual authoring slip \u2014 spell it with the prefix: ${rewrites}, as in { "${field}": { "$${example}": ... } }. If a nested-relation member was meant, give it a wrapper of its OWN with no $ siblings \u2014 { "${field}": { "${example}": ... } } compiles to the member "${field}.${example}" \u2014 and AND it with the operator constraint explicitly: { "$and": [{ "${field}": { "$op": ... } }, { "${field}": { "${example}": ... } }] }. This shape used to compile by silently DROPPING every non-$ sibling, and a dropped conjunct does not narrow the query, it WIDENS it: the chart included rows the author excluded, with nothing to read (#3650's failure mode, which this module refuses everywhere else). The sibling door in this package (read-scope-sql.ts) already fails closed on this exact shape \u2014 one shape, one answer (#6444).`
345
+ );
346
+ }
347
+ function assertUnmixedFieldWrapper(field, wrapper) {
348
+ const keys = Object.keys(wrapper);
349
+ const opKeys = keys.filter((k) => k.startsWith("$"));
350
+ if (opKeys.length === 0) return;
351
+ const nonOpKeys = keys.filter((k) => !k.startsWith("$"));
352
+ if (nonOpKeys.length === 0) return;
353
+ throw mixedFieldWrapperError(field, opKeys, nonOpKeys);
354
+ }
206
355
  function fieldLeaves(key, raw) {
356
+ assertDefinedComparands(key, raw);
207
357
  const out = [];
208
358
  const leaf = (operator, values) => {
209
359
  out.push({ kind: "leaf", member: key, operator, values });
@@ -219,6 +369,7 @@ function fieldLeaves(key, raw) {
219
369
  `[analytics] "${key}" carries a field constraint with zero operators ({}). Refusing rather than reading it as "every row" or "no row" \u2014 #5240 ruled this shape refused on every backend.`
220
370
  );
221
371
  }
372
+ assertUnmixedFieldWrapper(key, wrapper);
222
373
  const opKeys = Object.keys(wrapper).filter((k) => k.startsWith("$"));
223
374
  if (opKeys.length > 0) {
224
375
  for (const opKey of opKeys) {
@@ -229,6 +380,7 @@ function fieldLeaves(key, raw) {
229
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.`
230
381
  );
231
382
  }
383
+ assertNoFieldReferenceComparand(opKey, key, v2);
232
384
  leaf("gte", [comparand(v2[0])]);
233
385
  leaf("lte", [comparand(v2[1])]);
234
386
  continue;
@@ -253,6 +405,7 @@ function fieldLeaves(key, raw) {
253
405
  );
254
406
  }
255
407
  const v = wrapper[opKey];
408
+ assertCompilableComparand(opKey, key, v);
256
409
  const values = Array.isArray(v) ? v.map(comparand) : [comparand(v)];
257
410
  if (nullValueSatisfiesOperator(opKey, v) && !operatorIsNullTotal(opKey, v)) {
258
411
  out.push({
@@ -282,7 +435,6 @@ function fieldLeaves(key, raw) {
282
435
  function buildNode(cond) {
283
436
  const children = [];
284
437
  for (const [key, raw] of Object.entries(cond)) {
285
- if (raw === void 0) continue;
286
438
  if (key === "$and" || key === "$or") {
287
439
  if (!Array.isArray(raw)) {
288
440
  throw invalidFilterError(
@@ -356,6 +508,7 @@ function nullValueSatisfiesOperator(op, value) {
356
508
  }
357
509
  }
358
510
  function operatorIsNullTotal(op, value) {
511
+ if (CROSS_FIELD_COMPARISON_OPERATORS.has(op) && isFieldReference(value)) return true;
359
512
  switch (op) {
360
513
  // Compile to `set` / `notSet` — `IS NULL` / `IS NOT NULL`, two-valued by
361
514
  // construction, on every strategy that compiles this tree.
@@ -498,6 +651,11 @@ function likePattern(shape, value) {
498
651
  const escaped = escapeLikePattern(value);
499
652
  return shape === "starts" ? `${escaped}%` : shape === "ends" ? `%${escaped}` : `%${escaped}%`;
500
653
  }
654
+ var ASCII_UPPER_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
655
+ var ASCII_LOWER_LETTERS = "abcdefghijklmnopqrstuvwxyz";
656
+ function asciiLowerSqlExpr(expr) {
657
+ return `translate(${expr}, '${ASCII_UPPER_LETTERS}', '${ASCII_LOWER_LETTERS}')`;
658
+ }
501
659
 
502
660
  // src/read-scope-sql.ts
503
661
  var IDENT = /^[a-z_][a-z0-9_]*$/i;
@@ -569,6 +727,9 @@ function compileNode(node, qAlias, params) {
569
727
  }
570
728
  function compileField(field, value, qAlias, params) {
571
729
  const col = `${qAlias}.${quoteIdent(field, "field")}`;
730
+ assertDefinedComparands2(field, value);
731
+ assertBooleanFlagComparands(field, value);
732
+ assertNoFieldReferenceComparand2(field, value);
572
733
  if (value === null) return `${col} IS NULL`;
573
734
  if (typeof value !== "object" || value instanceof Date) {
574
735
  params.push(value);
@@ -598,6 +759,66 @@ function bindLike(params, pattern) {
598
759
  function nullSafeNegative(col, test) {
599
760
  return `(${col} IS NULL OR ${test})`;
600
761
  }
762
+ function assertCompilableMembers(op, field, members) {
763
+ members.forEach((member, index) => {
764
+ if (!isBindableComparand(member)) {
765
+ throw readScopeCompileError(`[read-scope-sql] ${unbindableListMemberMessage(op, field, member, index)}`);
766
+ }
767
+ });
768
+ }
769
+ function assertRenderableText(op, field, val) {
770
+ if (isRenderableTextComparand(val)) return;
771
+ throw readScopeCompileError(`[read-scope-sql] ${unrenderableTextComparandMessage(op, field, val)}`);
772
+ }
773
+ function undefinedComparandError2(field, path) {
774
+ return readScopeCompileError(
775
+ `[read-scope-sql] comparand at ${path} is undefined \u2014 refusing to build read scope (fail-closed). @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: undefined went into the bind list, the driver read it as SQL NULL, every comparison against NULL is UNKNOWN, and the scope matched ZERO rows in silence. Write null if the null predicate was meant ({ "${field}": null } or { "${field}": { "$null": true } }), or omit the key when the value is genuinely absent. The producer to fix is whoever BUILT this read scope \u2014 an admin-authored sharing rule / permission set, its CEL lowering, or the in-process code that assembled the FilterCondition \u2014 never the caller of this query, who cannot author it (#6050 ruling B, pushed down to this compiler by #6125).`
776
+ );
777
+ }
778
+ function assertDefinedComparands2(field, spec) {
779
+ const root = `"${field}"`;
780
+ if (spec === void 0) throw undefinedComparandError2(field, root);
781
+ if (!isFilterNode(spec)) return;
782
+ for (const [op, opValue] of Object.entries(spec)) {
783
+ if (!op.startsWith("$") || op === "$null" || op === "$exists") continue;
784
+ const opPath = `${root}.${op}`;
785
+ if (opValue === void 0) throw undefinedComparandError2(field, opPath);
786
+ if (!Array.isArray(opValue)) continue;
787
+ opValue.forEach((member, index) => {
788
+ if (member === void 0) throw undefinedComparandError2(field, `${opPath}[${index}]`);
789
+ });
790
+ }
791
+ }
792
+ function nonBooleanFlagComparandError(op, field, path) {
793
+ return readScopeCompileError(
794
+ `[read-scope-sql] comparand for "${op}" at ${path} is not a boolean \u2014 refusing to build read scope (fail-closed). @objectstack/spec FieldOperatorsSchema declares both $null and $exists as z.boolean(), and this compiler used to read the comparand by TRUTHINESS instead \u2014 so a non-boolean was silently sorted into one of the two declared answers rather than refused. The string "false" is TRUTHY, which is the case that matters: it landed on the side OPPOSITE the false it was written to mean, turning "rows with no ${field}" into "rows that have one" \u2014 a read scope that ADMITS the rows the policy excludes. Write the boolean itself (true or false), not a string, a number, null or undefined. The producer to fix is whoever BUILT this read scope \u2014 an admin-authored sharing rule / permission set, its CEL lowering, or the in-process code (a getReadScope option) that assembled the FilterCondition \u2014 never the caller of this query, who cannot author it (#5347 / #5369, pushed down to this compiler by #6387).`
795
+ );
796
+ }
797
+ function assertBooleanFlagComparands(field, spec) {
798
+ if (!isFilterNode(spec)) return;
799
+ for (const op of ["$null", "$exists"]) {
800
+ if (!Object.prototype.hasOwnProperty.call(spec, op)) continue;
801
+ if (typeof spec[op] === "boolean") continue;
802
+ throw nonBooleanFlagComparandError(op, field, `"${field}".${op}`);
803
+ }
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
+ }
601
822
  function compileOperator(col, op, val, field, params) {
602
823
  switch (op) {
603
824
  case "$eq":
@@ -617,33 +838,70 @@ function compileOperator(col, op, val, field, params) {
617
838
  case "$in": {
618
839
  if (!Array.isArray(val)) throw readScopeCompileError(`[read-scope-sql] $in for "${field}" needs an array (fail-closed).`);
619
840
  if (val.length === 0) return FALSE_CLAUSE;
841
+ assertCompilableMembers(op, field, val);
620
842
  return `${col} IN (${val.map((v) => bind(params, v)).join(", ")})`;
621
843
  }
622
844
  case "$nin": {
623
845
  if (!Array.isArray(val)) throw readScopeCompileError(`[read-scope-sql] $nin for "${field}" needs an array (fail-closed).`);
624
846
  if (val.length === 0) return "1 = 1";
847
+ assertCompilableMembers(op, field, val);
625
848
  return nullSafeNegative(col, `${col} NOT IN (${val.map((v) => bind(params, v)).join(", ")})`);
626
849
  }
627
850
  case "$between": {
628
851
  if (!Array.isArray(val) || val.length !== 2) throw readScopeCompileError(`[read-scope-sql] $between for "${field}" needs [min,max] (fail-closed).`);
852
+ assertCompilableMembers(op, field, val);
629
853
  return `${col} BETWEEN ${bind(params, val[0])} AND ${bind(params, val[1])}`;
630
854
  }
631
855
  // [#5567] The comparand is a LITERAL, so it is escaped and the escape
632
856
  // character is bound with it. See {@link bindLike}.
857
+ // [#5234] …and it must be a value `String()` can render, which is asserted
858
+ // BEFORE `likePattern` sees it — see {@link assertRenderableText}.
633
859
  case "$contains":
860
+ assertRenderableText(op, field, val);
634
861
  return `${col} LIKE ${bindLike(params, likePattern("contains", val))}`;
862
+ /**
863
+ * [#6520] `$icontains` on the READ-SCOPE lowering — the one compiler in this
864
+ * package where a wrong answer is an ADR-0021 scope over-reach rather than a
865
+ * loose chart filter, which is why the fold is the spec's ruled one and not
866
+ * `LOWER()`.
867
+ *
868
+ * `assertRenderableText` first, exactly as its case-exact twin above: the
869
+ * comparand has to be something `String()` renders faithfully before a
870
+ * pattern is built from it (#5234).
871
+ *
872
+ * The fold wraps BOTH the column and the bound pattern. Folding one side
873
+ * only would compare a folded needle against a raw column — matching just
874
+ * the rows already lower-case — and on a read scope that is a row set the
875
+ * policy author never wrote, in the narrowing direction here but in the
876
+ * WIDENING direction under a `$not`.
877
+ */
878
+ case "$icontains": {
879
+ assertRenderableText(op, field, val);
880
+ const patternRef = asciiLowerSqlExpr(bind(params, likePattern("contains", val)));
881
+ return `${asciiLowerSqlExpr(col)} LIKE ${patternRef} ESCAPE ${bind(params, LIKE_ESCAPE_CHAR)}`;
882
+ }
635
883
  // [#5298] NULL-safe: `NOT LIKE` is UNKNOWN for a NULL column, and "does not
636
884
  // contain" is true of a value that is not there.
637
885
  case "$notContains":
886
+ assertRenderableText(op, field, val);
638
887
  return nullSafeNegative(col, `${col} NOT LIKE ${bindLike(params, likePattern("contains", val))}`);
639
888
  case "$startsWith":
889
+ assertRenderableText(op, field, val);
640
890
  return `${col} LIKE ${bindLike(params, likePattern("starts", val))}`;
641
891
  case "$endsWith":
892
+ assertRenderableText(op, field, val);
642
893
  return `${col} LIKE ${bindLike(params, likePattern("ends", val))}`;
894
+ // [#6387] `val` is a boolean here — {@link assertBooleanFlagComparands}
895
+ // refused anything else at {@link compileField}, before this emitter runs.
896
+ // So `=== true` is an exhaustive TWO-WAY choice over the declared domain,
897
+ // not the "anything truthy is IS NULL" rule it used to be. That old rule is
898
+ // what put the STRING `"false"` on the side opposite the `false` it was
899
+ // written to mean; the identity spelling cannot, and it is the spelling
900
+ // {@link nullValueSatisfiesOperator} now mirrors (#5146 / #5298).
643
901
  case "$null":
644
- return val ? `${col} IS NULL` : `${col} IS NOT NULL`;
902
+ return val === true ? `${col} IS NULL` : `${col} IS NOT NULL`;
645
903
  case "$exists":
646
- return val ? `${col} IS NOT NULL` : `${col} IS NULL`;
904
+ return val === true ? `${col} IS NOT NULL` : `${col} IS NULL`;
647
905
  default:
648
906
  throw readScopeCompileError(`[read-scope-sql] unsupported operator "${op}" on "${field}" (fail-closed).`);
649
907
  }
@@ -656,11 +914,22 @@ function nullValueSatisfiesOperator2(op, value) {
656
914
  // Mirror image: `$ne: null` compiles to `IS NOT NULL`, which a NULL fails.
657
915
  case "$ne":
658
916
  return value !== null;
659
- // Truthiness, matching this file's emitter (see the note above).
917
+ // [#6387] Identity, matching this file's emitter (see the note above).
918
+ // `assertBooleanFlagComparands` refuses anything but `true` / `false` before
919
+ // this table is consulted, so each arm is an exhaustive TWO-WAY choice over
920
+ // the declared domain — and the strict spelling is chosen over the lenient
921
+ // one it replaces for the reason #5347 gave: `Boolean(value)` and
922
+ // `value === true` are equivalent only while the gate upstream holds, and
923
+ // the lenient spelling would quietly resume answering for shapes nobody
924
+ // ruled on if that gate were ever moved. A NULL column satisfies `$null`
925
+ // exactly when the author asked for null…
660
926
  case "$null":
661
- return Boolean(value);
927
+ return value === true;
928
+ // …and satisfies `$exists` exactly when the author asked for "no value".
929
+ // `$null: true` and `$exists: false` are the same question, so these two
930
+ // arms are correctly each other's MIRROR, not each other's copy (#5369).
662
931
  case "$exists":
663
- return !value;
932
+ return value === false;
664
933
  // Negative-polarity set / substring tests hold vacuously for an absent value.
665
934
  case "$nin":
666
935
  return true;
@@ -781,9 +1050,82 @@ var NativeSQLStrategy = class {
781
1050
  }
782
1051
  }
783
1052
  }
1053
+ if (this.carriesCrossFieldComparison(query, ctx)) return false;
784
1054
  const caps = ctx.queryCapabilities(query.cube);
785
1055
  return caps.nativeSql && typeof ctx.executeRawSql === "function";
786
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
+ }
787
1129
  async execute(query, ctx) {
788
1130
  const { sql, params } = await this.generateSql(query, ctx);
789
1131
  const cube = ctx.getCube(query.cube);
@@ -797,6 +1139,7 @@ var NativeSQLStrategy = class {
797
1139
  if (!cube) {
798
1140
  throw new Error(`Cube not found: ${query.cube}`);
799
1141
  }
1142
+ this.assertNoCrossFieldComparison(query, ctx);
800
1143
  const params = [];
801
1144
  const selectClauses = [];
802
1145
  const groupByClauses = [];
@@ -1166,13 +1509,19 @@ var NativeSQLStrategy = class {
1166
1509
  contains: "LIKE",
1167
1510
  notContains: "NOT LIKE",
1168
1511
  startsWith: "LIKE",
1169
- endsWith: "LIKE"
1512
+ endsWith: "LIKE",
1513
+ // [#6520] `$icontains` — `LIKE` like its neighbours; what separates it is
1514
+ // the ASCII fold applied below, not the keyword.
1515
+ icontains: "LIKE"
1170
1516
  };
1171
1517
  const likeShape = {
1172
1518
  contains: "contains",
1173
1519
  notContains: "contains",
1174
1520
  startsWith: "starts",
1175
- endsWith: "ends"
1521
+ endsWith: "ends",
1522
+ // [#6520] Same wildcard placement as `contains`; the case fold is what
1523
+ // differs, and it is applied to both sides of the comparison below.
1524
+ icontains: "contains"
1176
1525
  };
1177
1526
  if (operator === "set") return `${rawCol} IS NOT NULL`;
1178
1527
  if (operator === "notSet") return `${rawCol} IS NULL`;
@@ -1191,6 +1540,9 @@ var NativeSQLStrategy = class {
1191
1540
  params.push(likePattern(shape, values[0]));
1192
1541
  const patternRef = `$${params.length}`;
1193
1542
  params.push(LIKE_ESCAPE_CHAR);
1543
+ if (operator === "icontains") {
1544
+ return `${asciiLowerSqlExpr(rawCol)} ${sqlOp} ${asciiLowerSqlExpr(patternRef)} ESCAPE $${params.length}`;
1545
+ }
1194
1546
  return `${rawCol} ${sqlOp} ${patternRef} ESCAPE $${params.length}`;
1195
1547
  }
1196
1548
  if (operator === "lte") {
@@ -1224,6 +1576,7 @@ var NativeSQLStrategy = class {
1224
1576
  };
1225
1577
 
1226
1578
  // src/strategies/objectql-strategy.ts
1579
+ var import_data2 = require("@objectstack/spec/data");
1227
1580
  var import_core2 = require("@objectstack/core");
1228
1581
 
1229
1582
  // src/strategies/cross-object-rebucket.ts
@@ -1294,7 +1647,12 @@ var LIKE_SQL_OPS = {
1294
1647
  contains: { sql: "LIKE", shape: "contains" },
1295
1648
  notContains: { sql: "NOT LIKE", shape: "contains" },
1296
1649
  startsWith: { sql: "LIKE", shape: "starts" },
1297
- endsWith: { sql: "LIKE", shape: "ends" }
1650
+ endsWith: { sql: "LIKE", shape: "ends" },
1651
+ // [#6520] `$icontains`: the same escaped pattern and bound `ESCAPE` as its
1652
+ // four case-EXACT neighbours, with `fold` adding the ASCII-only case fold to
1653
+ // both sides of the comparison. The flag is on this row alone — the family
1654
+ // above it is case-sensitive by ruling (#4706 Q2 = A).
1655
+ icontains: { sql: "LIKE", shape: "contains", fold: true }
1298
1656
  };
1299
1657
  var ObjectQLStrategy = class {
1300
1658
  constructor() {
@@ -1405,6 +1763,12 @@ var ObjectQLStrategy = class {
1405
1763
  if (!cube) {
1406
1764
  throw new Error(`Cube not found: ${query.cube}`);
1407
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
+ }
1408
1772
  const selectParts = [];
1409
1773
  const groupByParts = [];
1410
1774
  const params = [];
@@ -1509,11 +1873,11 @@ var ObjectQLStrategy = class {
1509
1873
  * predicate. `$and` makes that structurally impossible.
1510
1874
  */
1511
1875
  withReadScope(objectName, filter, ctx) {
1512
- 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;
1513
1877
  if (typeof ctx.getReadScope !== "function") return userFilter;
1514
1878
  const scope = ctx.getReadScope(objectName);
1515
1879
  if (scope === void 0 || scope === null) return userFilter;
1516
- const scopeFilter = scope;
1880
+ const scopeFilter = (0, import_data2.markFilterSubtreeProvenance)(scope, "policy");
1517
1881
  if (!userFilter) return scopeFilter;
1518
1882
  return { $and: [userFilter, scopeFilter] };
1519
1883
  }
@@ -1687,6 +2051,7 @@ var ObjectQLStrategy = class {
1687
2051
  if (fkValues.length === 0 || typeof ctx.executeAggregate !== "function") return map;
1688
2052
  const idFilter = { id: { $in: fkValues } };
1689
2053
  const scope = typeof ctx.getReadScope === "function" ? ctx.getReadScope(refObject) : null;
2054
+ if (scope != null) (0, import_data2.markFilterSubtreeProvenance)(scope, "policy");
1690
2055
  const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
1691
2056
  const rows = await ctx.executeAggregate(refObject, {
1692
2057
  groupBy: ["id", attr],
@@ -1735,7 +2100,9 @@ var ObjectQLStrategy = class {
1735
2100
  params.push(likePattern(like.shape, values[0]));
1736
2101
  const patternRef = `$${params.length}`;
1737
2102
  params.push(LIKE_ESCAPE_CHAR);
1738
- return `${col} ${like.sql} ${patternRef} ESCAPE $${params.length}`;
2103
+ const lhs = like.fold ? asciiLowerSqlExpr(col) : col;
2104
+ const rhs = like.fold ? asciiLowerSqlExpr(patternRef) : patternRef;
2105
+ return `${lhs} ${like.sql} ${rhs} ESCAPE $${params.length}`;
1739
2106
  }
1740
2107
  const op = SCALAR_SQL_OPS[operator];
1741
2108
  if (!op) {
@@ -2021,6 +2388,14 @@ var ObjectQLStrategy = class {
2021
2388
  * string — `String(…)`, the same normalisation `like-pattern.ts` applies at the
2022
2389
  * two SQL emitters and `driver-sql`'s `applyLike` applies at the driver, so one
2023
2390
  * `$contains` means one thing on every face (#5567's invariant).
2391
+ *
2392
+ * [#5234] Those four `String(…)` calls now only ever see a value that renders
2393
+ * faithfully: `fieldLeaves` refuses an object comparand on this family before a
2394
+ * leaf exists. That ordering is load-bearing rather than incidental — this arm
2395
+ * is a PRODUCER for the engine, so stringifying an object here would have
2396
+ * laundered it into `'[object Object]'` and handed a driver a perfectly
2397
+ * well-typed string. A strict driver downstream could never have seen the shape
2398
+ * it was strict about, which is why the guard sits at the door and not here.
2024
2399
  */
2025
2400
  convertFilter(operator, values) {
2026
2401
  if (operator === "set") return { $ne: null };
@@ -2029,8 +2404,27 @@ var ObjectQLStrategy = class {
2029
2404
  const v0 = values[0];
2030
2405
  const all = [...values];
2031
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.
2032
2426
  case "equals":
2033
- return v0;
2427
+ return isFieldReference(v0) ? { $eq: v0 } : v0;
2034
2428
  case "notEquals":
2035
2429
  return { $ne: v0 };
2036
2430
  case "gt":
@@ -2090,6 +2484,24 @@ var ObjectQLStrategy = class {
2090
2484
  extractObjectName(cube) {
2091
2485
  return cube.sql.trim();
2092
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
+ }
2093
2505
  /**
2094
2506
  * The dimensions this query PROJECTS, in the order the result carries them:
2095
2507
  * every `dimensions` entry, then every granular `timeDimensions` entry that
@@ -2131,9 +2543,10 @@ var ObjectQLStrategy = class {
2131
2543
  };
2132
2544
 
2133
2545
  // src/dataset-compiler.ts
2134
- var import_data2 = require("@objectstack/spec/data");
2135
- var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set(["array_agg", "string_agg"]);
2136
- var SUPPORTED_AGGREGATES = import_data2.AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
2546
+ var import_data3 = require("@objectstack/spec/data");
2547
+ var import_ui = require("@objectstack/spec/ui");
2548
+ var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set();
2549
+ var SUPPORTED_AGGREGATES = import_data3.AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
2137
2550
  function aggregateToMetricType(m) {
2138
2551
  if (!m.aggregate) {
2139
2552
  throw new Error(`[dataset-compiler] non-derived measure "${m.name}" has no aggregate`);
@@ -2167,6 +2580,7 @@ function fieldRelationshipPath(field) {
2167
2580
  }
2168
2581
  var MAX_JOIN_HOPS = 3;
2169
2582
  var joinAlias = (path) => path.replace(/\./g, "__");
2583
+ var REGISTRY_LOCALE = void 0;
2170
2584
  function compileDataset(dataset, resolver, options) {
2171
2585
  const include = dataset.include ?? [];
2172
2586
  const declaredDatasource = (objectName) => {
@@ -2238,7 +2652,11 @@ function compileDataset(dataset, resolver, options) {
2238
2652
  assertDeclared(d.field, "dimension", d.name);
2239
2653
  const dim = {
2240
2654
  name: d.name,
2241
- label: typeof d.label === "string" ? d.label : d.name,
2655
+ // [#6761] An inline locale map is a label, not a missing one. Before this,
2656
+ // the `typeof === 'string'` test dropped the map and substituted the
2657
+ // machine name, which `/analytics/meta` then published as a display title
2658
+ // (`title: 'owner'` for a dimension labelled `{ en: 'Owner', … }`).
2659
+ label: (0, import_ui.resolveI18nLabel)(d.label, REGISTRY_LOCALE) ?? d.name,
2242
2660
  type: dimensionType(d),
2243
2661
  sql: d.field
2244
2662
  };
@@ -2258,7 +2676,8 @@ function compileDataset(dataset, resolver, options) {
2258
2676
  if (m.field) assertDeclared(m.field, "measure", m.name);
2259
2677
  const metric = {
2260
2678
  name: m.name,
2261
- label: typeof m.label === "string" ? m.label : m.name,
2679
+ // [#6761] Same as the dimension label above — see {@link REGISTRY_LOCALE}.
2680
+ label: (0, import_ui.resolveI18nLabel)(m.label, REGISTRY_LOCALE) ?? m.name,
2262
2681
  type: aggregateToMetricType(m),
2263
2682
  // `count` with no field aggregates over rows (*).
2264
2683
  sql: m.field ?? "*"
@@ -2269,7 +2688,10 @@ function compileDataset(dataset, resolver, options) {
2269
2688
  }
2270
2689
  const cube = {
2271
2690
  name: dataset.name,
2272
- title: typeof dataset.label === "string" ? dataset.label : dataset.name,
2691
+ // [#6761] The cube's own display title, same rule. `Cube.title` is optional
2692
+ // in the schema, but an absent dataset label already produced the machine
2693
+ // name here and that is not what this card changes — only the map case moves.
2694
+ title: (0, import_ui.resolveI18nLabel)(dataset.label, REGISTRY_LOCALE) ?? dataset.name,
2273
2695
  sql: dataset.object,
2274
2696
  measures,
2275
2697
  dimensions,
@@ -2286,7 +2708,7 @@ function compileDataset(dataset, resolver, options) {
2286
2708
  }
2287
2709
 
2288
2710
  // src/dataset-executor.ts
2289
- var import_data3 = require("@objectstack/spec/data");
2711
+ var import_data4 = require("@objectstack/spec/data");
2290
2712
  var import_core3 = require("@objectstack/core");
2291
2713
  function resolveSelectionTokens(compiled, selection, context) {
2292
2714
  const tokenCtx = (0, import_core3.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
@@ -2326,7 +2748,7 @@ function evaluateDerivedMeasures(rows, derived) {
2326
2748
  }
2327
2749
  function fillEmptyGroups(rows, columnAggregates) {
2328
2750
  for (const [column, aggregate2] of Object.entries(columnAggregates)) {
2329
- const empty = (0, import_data3.emptyGroupValueFor)(aggregate2);
2751
+ const empty = (0, import_data4.emptyGroupValueFor)(aggregate2);
2330
2752
  if (empty === void 0) continue;
2331
2753
  for (const row of rows) if (row[column] == null) row[column] = empty;
2332
2754
  }
@@ -2478,6 +2900,62 @@ function shiftRange(range, kind) {
2478
2900
  const prevStartMs = prevEndMs - (lengthDays - 1) * DAY_MS;
2479
2901
  return [toISODate(prevStartMs), toISODate(prevEndMs)];
2480
2902
  }
2903
+ function isoWeekKeyOfUtcMs(ms) {
2904
+ const target = new Date(ms);
2905
+ const dayNum = (target.getUTCDay() + 6) % 7;
2906
+ target.setUTCDate(target.getUTCDate() - dayNum + 3);
2907
+ const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
2908
+ const weekNo = 1 + Math.round(
2909
+ ((target.getTime() - firstThursday.getTime()) / DAY_MS - 3 + (firstThursday.getUTCDay() + 6) % 7) / 7
2910
+ );
2911
+ return `${target.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`;
2912
+ }
2913
+ function bucketOrdinalOfDay(ymd, granularity) {
2914
+ const ms = parseUTC(ymd);
2915
+ const d = new Date(ms);
2916
+ const y = d.getUTCFullYear();
2917
+ const m = d.getUTCMonth();
2918
+ switch (granularity) {
2919
+ case "year":
2920
+ return y;
2921
+ case "quarter":
2922
+ return y * 4 + Math.floor(m / 3);
2923
+ case "month":
2924
+ return y * 12 + m;
2925
+ // 1970-01-01 was a Thursday, so shifting by 3 days puts the Monday boundary
2926
+ // on a multiple of 7 and the ordinal advances exactly at each ISO week start.
2927
+ case "week":
2928
+ return Math.floor((ms + 3 * DAY_MS) / (7 * DAY_MS));
2929
+ case "day":
2930
+ default:
2931
+ return Math.floor(ms / DAY_MS);
2932
+ }
2933
+ }
2934
+ function bucketKeyAtOrdinal(ordinal, granularity) {
2935
+ switch (granularity) {
2936
+ case "year":
2937
+ return String(ordinal);
2938
+ case "quarter":
2939
+ return `${Math.floor(ordinal / 4)}-Q${ordinal % 4 + 1}`;
2940
+ case "month":
2941
+ return `${Math.floor(ordinal / 12)}-${String(ordinal % 12 + 1).padStart(2, "0")}`;
2942
+ case "week":
2943
+ return isoWeekKeyOfUtcMs(ordinal * 7 * DAY_MS - 3 * DAY_MS);
2944
+ case "day":
2945
+ default:
2946
+ return toISODate(ordinal * DAY_MS);
2947
+ }
2948
+ }
2949
+ function alignedCompareBucketKey(key, granularity, kind, currentRange, shiftedRange) {
2950
+ if (typeof key !== "string" || key.length === 0) return null;
2951
+ const span = (0, import_core3.bucketKeyToCalendarRange)(key, granularity);
2952
+ if (!span) return null;
2953
+ const targetOrdinal = kind === "previousYear" ? bucketOrdinalOfDay(shiftYear(span.start, 1), granularity) : bucketOrdinalOfDay(span.start, granularity) + (bucketOrdinalOfDay(currentRange[0], granularity) - bucketOrdinalOfDay(shiftedRange[0], granularity));
2954
+ const first = bucketOrdinalOfDay(currentRange[0], granularity);
2955
+ const last = bucketOrdinalOfDay(currentRange[1], granularity);
2956
+ if (targetOrdinal < first || targetOrdinal > last) return null;
2957
+ return bucketKeyAtOrdinal(targetOrdinal, granularity);
2958
+ }
2481
2959
  var DatasetExecutor = class {
2482
2960
  /**
2483
2961
  * @param service - The analytics service the executor issues its queries to.
@@ -2664,6 +3142,24 @@ var DatasetExecutor = class {
2664
3142
  timeDimensionsOf(compiled, dimensions) {
2665
3143
  return dimensions.filter((d) => compiled.cube.dimensions[d]?.type === "time");
2666
3144
  }
3145
+ /**
3146
+ * The EFFECTIVE bucket size one dimension is grouped at for this selection,
3147
+ * or `undefined` when it is not a date dimension or nothing states a size (in
3148
+ * which case the runtime groups the raw column).
3149
+ *
3150
+ * One definition, two readers, deliberately: {@link buildQuery} uses it to
3151
+ * decide the `GROUP BY`, and {@link runCompare} uses it to realign the
3152
+ * comparison pass's bucket keys (#6007). Those two MUST agree — realigning
3153
+ * `month` keys a query grouped by `quarter` would move every comparison value
3154
+ * onto a bucket that does not exist — and the way to make them agree is to
3155
+ * have one of them, not two that look alike.
3156
+ */
3157
+ granularityOf(compiled, selection, name) {
3158
+ const cd = compiled.cube.dimensions[name];
3159
+ if (cd?.type !== "time") return void 0;
3160
+ const datasetDefault = cd.granularities?.length === 1 ? String(cd.granularities[0]) : void 0;
3161
+ return resolveDimensionGranularity(selection, name, datasetDefault);
3162
+ }
2667
3163
  buildQuery(compiled, opts) {
2668
3164
  const q = {
2669
3165
  cube: compiled.cube.name,
@@ -2677,12 +3173,7 @@ var DatasetExecutor = class {
2677
3173
  const selTimeDims = opts.selection.timeDimensions ?? [];
2678
3174
  const selDims = new Set(selTimeDims.map((t) => t.dimension));
2679
3175
  const groupedDims = new Set(opts.dimensions);
2680
- const granularityFor = (name) => {
2681
- const cd = compiled.cube.dimensions[name];
2682
- if (cd?.type !== "time") return void 0;
2683
- const datasetDefault = cd.granularities?.length === 1 ? String(cd.granularities[0]) : void 0;
2684
- return resolveDimensionGranularity(opts.selection, name, datasetDefault);
2685
- };
3176
+ const granularityFor = (name) => this.granularityOf(compiled, opts.selection, name);
2686
3177
  const bucketsUnstatedEntry = (dimension) => groupedDims.has(dimension) || opts.selection.dateGranularity != null;
2687
3178
  const resolvedTimeDims = selTimeDims.map((t) => {
2688
3179
  if (t.granularity) return t;
@@ -2717,9 +3208,14 @@ var DatasetExecutor = class {
2717
3208
  { ...selection, timeDimensions: shiftedTd },
2718
3209
  { measures, dimensions, baseFilter, context }
2719
3210
  );
3211
+ const granularity = dimensions.includes(dimension) ? this.granularityOf(compiled, selection, dimension) : void 0;
2720
3212
  return sub.rows.map((row) => {
2721
3213
  const out = {};
2722
3214
  for (const dim of dimensions) out[dim] = row[dim];
3215
+ if (granularity) {
3216
+ const aligned = alignedCompareBucketKey(row[dimension], granularity, cmp.kind, range, shifted);
3217
+ if (aligned != null) out[dimension] = aligned;
3218
+ }
2723
3219
  for (const m of measures) out[`${m}__compare`] = row[m];
2724
3220
  return out;
2725
3221
  });
@@ -3096,8 +3592,12 @@ function hasDeclaredErrorEnvelope(err) {
3096
3592
  const e = err;
3097
3593
  return typeof e?.status === "number" && typeof e?.code === "string" && e.code.length > 0;
3098
3594
  }
3595
+ function isMissingColumnOfRelation(message) {
3596
+ return (0, import_types.matchMissingColumnOfRelation)(message) !== void 0;
3597
+ }
3099
3598
  function isMissingSourceError(err) {
3100
3599
  const raw = String(err?.message ?? err ?? "");
3600
+ if (isMissingColumnOfRelation(raw)) return false;
3101
3601
  const msg = raw.toLowerCase();
3102
3602
  return msg.includes("no such table") || // sqlite / libsql
3103
3603
  /relation\s+[`"']?[A-Za-z0-9_$.]+[`"']?\s+does not exist/i.test(raw) || // postgres
@@ -3107,6 +3607,7 @@ function isMissingSourceError(err) {
3107
3607
  }
3108
3608
  function missingSourceRelation(err) {
3109
3609
  const msg = String(err?.message ?? err ?? "");
3610
+ if (isMissingColumnOfRelation(msg)) return void 0;
3110
3611
  const patterns = [
3111
3612
  /no such table:\s*[`"'[]?([A-Za-z0-9_$.]+)/i,
3112
3613
  // sqlite / libsql
@@ -3182,6 +3683,7 @@ var AnalyticsService = class {
3182
3683
  this.getObjectFieldNames = config.getObjectFieldNames;
3183
3684
  this.getObjectDatasource = config.getObjectDatasource;
3184
3685
  this.isExternalObject = config.isExternalObject;
3686
+ this.debugSql = config.debugSql ?? (0, import_core5.getEnv)("NODE_ENV") === "development";
3185
3687
  if (config.datasets) {
3186
3688
  for (const ds of config.datasets) {
3187
3689
  try {
@@ -3299,7 +3801,7 @@ var AnalyticsService = class {
3299
3801
  const strategy = this.resolveStrategy(query, ctx, skip);
3300
3802
  this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
3301
3803
  try {
3302
- return await strategy.execute(query, ctx);
3804
+ return this.applySqlEchoPolicy(await strategy.execute(query, ctx));
3303
3805
  } catch (e) {
3304
3806
  if (e?.code === "RAW_SQL_UNSUPPORTED") {
3305
3807
  this.logger.warn(
@@ -3312,6 +3814,30 @@ var AnalyticsService = class {
3312
3814
  }
3313
3815
  }
3314
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
+ }
3315
3841
  /**
3316
3842
  * Compile a `dataset` (ADR-0021) and register its Cube + join allowlist so it
3317
3843
  * can be queried by name. Idempotent (re-registering overwrites). Returns the
@@ -3351,6 +3877,7 @@ var AnalyticsService = class {
3351
3877
  return previewResult;
3352
3878
  }
3353
3879
  }
3880
+ const requestLocale = context?.locale;
3354
3881
  const provider = this.readScopeProvider;
3355
3882
  const resolveScope = provider ? (targetObject) => provider(targetObject, context) : void 0;
3356
3883
  const labelDeps = this.labelResolver ? withLabelFetchCache(this.labelResolver) : void 0;
@@ -3460,7 +3987,10 @@ var AnalyticsService = class {
3460
3987
  for (const f of result.fields) {
3461
3988
  const m = measureByName.get(f.name) ?? measureByName.get(f.name.replace(/__compare$/, ""));
3462
3989
  if (!m) continue;
3463
- if (f.label == null && typeof m.label === "string") f.label = m.label;
3990
+ if (f.label == null) {
3991
+ const label = (0, import_ui2.resolveI18nLabel)(m.label, requestLocale);
3992
+ if (label !== void 0) f.label = label;
3993
+ }
3464
3994
  if (f.format == null && m.format) f.format = m.format;
3465
3995
  const fc = f;
3466
3996
  const mc = m;
@@ -3473,7 +4003,7 @@ var AnalyticsService = class {
3473
4003
  }
3474
4004
  }
3475
4005
  if (f.percentScale == null) {
3476
- 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);
3477
4007
  }
3478
4008
  }
3479
4009
  }
@@ -3489,7 +4019,9 @@ var AnalyticsService = class {
3489
4019
  for (const f of result.fields) {
3490
4020
  if (f.label != null) continue;
3491
4021
  const d = dimByName.get(f.name) ?? dimByField.get(f.name);
3492
- if (d && typeof d.label === "string") f.label = d.label;
4022
+ if (!d) continue;
4023
+ const label = (0, import_ui2.resolveI18nLabel)(d.label, requestLocale);
4024
+ if (label !== void 0) f.label = label;
3493
4025
  }
3494
4026
  }
3495
4027
  return result;
@@ -3569,10 +4101,10 @@ var AnalyticsService = class {
3569
4101
  else this.logger.warn(message);
3570
4102
  return;
3571
4103
  }
3572
- const stripPrefix = (m) => m.includes(".") ? m.split(".").slice(1).join(".") : m;
3573
4104
  const extraMeasures = {};
3574
4105
  for (const m of query.measures || []) {
3575
- const key = stripPrefix(m);
4106
+ if (cube.measures[m] || extraMeasures[m]) continue;
4107
+ const key = mintableMeasureKey(m, name);
3576
4108
  if (cube.measures[key] || extraMeasures[key]) continue;
3577
4109
  extraMeasures[key] = inferMeasure(key);
3578
4110
  }
@@ -3621,6 +4153,13 @@ var AnalyticsService = class {
3621
4153
  * the data path's `resolveQueryFields`: they are engine-assigned rather than
3622
4154
  * declared, and a gate stricter than the engine it guards would reject
3623
4155
  * queries that used to work.
4156
+ *
4157
+ * [#5918] Its `stripPrefix` below is deliberately NOT narrowed the way the two
4158
+ * MINTS were. This is a RESOLVER — it mirrors `lookupMember`'s tiers to answer
4159
+ * "which Metric will the strategy read", and that tier order did not change.
4160
+ * What changed is what can reach it: a dotted measure is now either a
4161
+ * `<cube>.` qualifier or a key the cube itself declares, because every other
4162
+ * dotted spelling is refused at the mint before this gate runs.
3624
4163
  */
3625
4164
  assertMeasureFields(query, cube, declaredMeasures) {
3626
4165
  const probe = this.getObjectFieldNames;
@@ -3909,7 +4448,7 @@ var AnalyticsService = class {
3909
4448
  };
3910
4449
  measures.count = { name: "count", label: "Count", type: "count", sql: "*" };
3911
4450
  for (const m of query.measures || []) {
3912
- const key = m.includes(".") ? m.split(".").slice(1).join(".") : m;
4451
+ const key = mintableMeasureKey(m, cubeName);
3913
4452
  if (measures[key]) continue;
3914
4453
  const inferred = inferMeasure(key);
3915
4454
  measures[key] = inferred;
@@ -3963,11 +4502,28 @@ var AnalyticsService = class {
3963
4502
  return strategy;
3964
4503
  }
3965
4504
  }
4505
+ const crossField = findCrossFieldComparand(lowerAnalyticsWhereQuietly(query));
3966
4506
  throw new Error(
3967
- `[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."
3968
4508
  );
3969
4509
  }
3970
4510
  };
4511
+ function lowerAnalyticsWhereQuietly(query) {
4512
+ try {
4513
+ return lowerAnalyticsWhere(query);
4514
+ } catch {
4515
+ return null;
4516
+ }
4517
+ }
4518
+ function mintableMeasureKey(member, cubeName) {
4519
+ const dot = member.indexOf(".");
4520
+ if (dot < 0) return member;
4521
+ if (member.slice(0, dot) === cubeName) return member.slice(dot + 1);
4522
+ throw invalidMemberError(
4523
+ `[Analytics] Measure '${member}' on cube '${cubeName}' is a DOTTED member, and measures do not traverse relationships \u2014 only dimensions do \u2014 so there is no related column for this to aggregate. Until #5918 the prefix was silently dropped, so the aggregate ran against '${cubeName}' itself while the result column kept the label '${member}'. Aggregate one of the object's OWN fields instead ('<field>_sum' / '_avg' / '_min' / '_max' / '_count_distinct'), or declare a Cube whose measure names the related column in its own 'sql'. The only dot a measure may carry is the '${cubeName}.' qualifier.`,
4524
+ { member, param: "measures", cube: cubeName }
4525
+ );
4526
+ }
3971
4527
  function inferMeasure(key) {
3972
4528
  if (key === "count") {
3973
4529
  return { name: "count", label: "Count", type: "count", sql: "*" };
@@ -4254,6 +4810,11 @@ var AnalyticsServicePlugin = class {
4254
4810
  coerceTemporalFilterColumn,
4255
4811
  relationshipResolver,
4256
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,
4257
4818
  // Source-field metadata behind the display chains on result columns:
4258
4819
  // ADR-0053 currency (`currencyConfig.defaultCurrency`) and percent scale
4259
4820
  // (`max`, which is what marks whole-percent storage — objectui#3136).
@@ -4266,7 +4827,17 @@ var AnalyticsServicePlugin = class {
4266
4827
  // datasource the query was routed to. Undefined ⇒ the object rides the
4267
4828
  // default datasource (or the engine cannot answer), and the diagnostic
4268
4829
  // says so rather than inventing a name.
4269
- getObjectDatasource: (objectName) => dataEngine()?.getObject?.(objectName)?.datasource,
4830
+ //
4831
+ // [#5288] Asked of the ENGINE's resolver, not of the object's declaration.
4832
+ // `getObject(name).datasource` is the declared value — step 1 of the five
4833
+ // `getDriver` routes by — so an object placed by a `datasourceMapping`
4834
+ // rule, by the ADR-0057 §3.6 lifecycle split, or by its package's
4835
+ // `defaultDatasource` answered `'default'`, and the diagnostic named a
4836
+ // database the rows are not in. Recomputing those rules here instead would
4837
+ // be the second implementation `resolveMappedDatasource` (#4462) exists to
4838
+ // prevent: it drifts by one step, silently, and the drift only surfaces as
4839
+ // an error message pointing at the wrong database.
4840
+ getObjectDatasource: (objectName) => dataEngine()?.resolveEffectiveDatasource?.(objectName),
4270
4841
  // ADR-0062 D6 — a federated object carries an `external` block (ADR-0015).
4271
4842
  // Reported so NativeSQLStrategy declines it (its hand-compiled FROM would
4272
4843
  // hit the wrong physical table) and the driver-correct ObjectQL path runs.