@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.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // src/analytics-service.ts
2
2
  import { percentScaleOf } from "@objectstack/spec/data";
3
- import { createLogger, bucketKeyToCalendarRange, zonedDateStartToUtcMs } from "@objectstack/core";
3
+ import { resolveI18nLabel as resolveI18nLabel2 } from "@objectstack/spec/ui";
4
+ import { createLogger, getEnv, bucketKeyToCalendarRange as bucketKeyToCalendarRange2, zonedDateStartToUtcMs } from "@objectstack/core";
5
+ import { matchMissingColumnOfRelation } from "@objectstack/types";
4
6
 
5
7
  // src/cube-registry.ts
6
8
  var CubeRegistry = class {
@@ -120,6 +122,84 @@ var CubeRegistry = class {
120
122
  // src/strategies/filter-normalizer.ts
121
123
  import { isFilterAST, parseFilterAST, VALID_AST_OPERATORS } from "@objectstack/spec/data";
122
124
  import { StandardErrorCode } from "@objectstack/spec/api";
125
+
126
+ // src/comparand-shape.ts
127
+ function isBindableComparand(value) {
128
+ if (value === null || value === void 0) return true;
129
+ const kind = typeof value;
130
+ if (kind === "string" || kind === "number" || kind === "bigint" || kind === "boolean") return true;
131
+ return value instanceof Date || ArrayBuffer.isView(value);
132
+ }
133
+ function isRenderableTextComparand(value) {
134
+ if (value === null || value === void 0) return true;
135
+ const kind = typeof value;
136
+ if (kind === "string" || kind === "number" || kind === "bigint" || kind === "boolean") return true;
137
+ return value instanceof Date;
138
+ }
139
+ function isFieldReference(value) {
140
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
141
+ return typeof value.$field === "string";
142
+ }
143
+ var CROSS_FIELD_COMPARISON_OPERATORS = /* @__PURE__ */ new Set([
144
+ "$eq",
145
+ "$ne",
146
+ "$gt",
147
+ "$gte",
148
+ "$lt",
149
+ "$lte"
150
+ ]);
151
+ function findCrossFieldComparand(filter) {
152
+ return findIn(filter, "");
153
+ }
154
+ function findIn(node, field) {
155
+ if (!node || typeof node !== "object") return null;
156
+ if (Array.isArray(node)) {
157
+ for (const child of node) {
158
+ const hit = findIn(child, field);
159
+ if (hit) return hit;
160
+ }
161
+ return null;
162
+ }
163
+ if (node instanceof Date || ArrayBuffer.isView(node)) return null;
164
+ for (const [key, value] of Object.entries(node)) {
165
+ if (CROSS_FIELD_COMPARISON_OPERATORS.has(key) && isFieldReference(value)) {
166
+ return { op: key, field, ref: value.$field };
167
+ }
168
+ const hit = findIn(value, key.startsWith("$") ? field : key);
169
+ if (hit) return hit;
170
+ }
171
+ return null;
172
+ }
173
+ var TEXT_PATTERN_OPERATORS = /* @__PURE__ */ new Set([
174
+ "$contains",
175
+ "$notContains",
176
+ "$startsWith",
177
+ "$endsWith",
178
+ "$icontains"
179
+ ]);
180
+ function shapePreview(value) {
181
+ try {
182
+ const json = JSON.stringify(value);
183
+ if (typeof json !== "string") return typeof value;
184
+ return json.length > 80 ? `${json.slice(0, 77)}...` : json;
185
+ } catch {
186
+ return typeof value;
187
+ }
188
+ }
189
+ function unrenderableTextComparandMessage(op, field, value) {
190
+ 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.`;
191
+ }
192
+ function fieldReferenceComparandMessage(op, field, ref, position) {
193
+ 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).`;
194
+ }
195
+ function fieldReferenceBetweenBoundMessage(op, field, ref, index) {
196
+ 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).`;
197
+ }
198
+ function unbindableListMemberMessage(op, field, value, index) {
199
+ 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).`;
200
+ }
201
+
202
+ // src/strategies/filter-normalizer.ts
123
203
  function invalidFilterError(message) {
124
204
  const err = new Error(message);
125
205
  err.code = StandardErrorCode.enum.INVALID_FILTER;
@@ -138,7 +218,11 @@ var MONGO_TO_CUBE_OP = {
138
218
  $contains: "contains",
139
219
  $notContains: "notContains",
140
220
  $startsWith: "startsWith",
141
- $endsWith: "endsWith"
221
+ $endsWith: "endsWith",
222
+ // [#6520] The case-INSENSITIVE twin, ASCII fold only. A separate cube operator
223
+ // rather than a flag on `contains`, because the two compile to different SQL
224
+ // and one name would make the renderers guess which was meant.
225
+ $icontains: "icontains"
142
226
  };
143
227
  function comparand(v) {
144
228
  return v === void 0 ? null : v;
@@ -161,7 +245,73 @@ function andOf(children) {
161
245
  if (children.length === 1) return children[0];
162
246
  return { kind: "and", children };
163
247
  }
248
+ function assertCompilableComparand(opKey, field, value) {
249
+ if (TEXT_PATTERN_OPERATORS.has(opKey)) {
250
+ if (!isRenderableTextComparand(value)) {
251
+ throw invalidFilterError(`[analytics] ${unrenderableTextComparandMessage(opKey, field, value)}`);
252
+ }
253
+ return;
254
+ }
255
+ if ((opKey === "$in" || opKey === "$nin") && Array.isArray(value)) {
256
+ value.forEach((member, index) => {
257
+ if (!isBindableComparand(member)) {
258
+ throw invalidFilterError(`[analytics] ${unbindableListMemberMessage(opKey, field, member, index)}`);
259
+ }
260
+ });
261
+ }
262
+ }
263
+ function assertNoFieldReferenceComparand(opKey, field, value) {
264
+ if (opKey !== "$between" || !Array.isArray(value)) return;
265
+ value.forEach((member, index) => {
266
+ if (!isFieldReference(member)) return;
267
+ throw invalidFilterError(
268
+ `[analytics] ${fieldReferenceBetweenBoundMessage(opKey, field, member.$field, index)}`
269
+ );
270
+ });
271
+ }
272
+ function undefinedComparandError(field, path) {
273
+ return invalidFilterError(
274
+ `[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).`
275
+ );
276
+ }
277
+ function assertDefinedComparands(field, spec) {
278
+ const root = `"${field}"`;
279
+ if (spec === void 0) throw undefinedComparandError(field, root);
280
+ if (Array.isArray(spec)) {
281
+ spec.forEach((member, index) => {
282
+ if (member === void 0) throw undefinedComparandError(field, `${root}[${index}]`);
283
+ });
284
+ return;
285
+ }
286
+ if (!isFilterObject(spec)) return;
287
+ for (const [op, opValue] of Object.entries(spec)) {
288
+ if (!op.startsWith("$") || op === "$null" || op === "$exists") continue;
289
+ const opPath = `${root}.${op}`;
290
+ if (opValue === void 0) throw undefinedComparandError(field, opPath);
291
+ if (!Array.isArray(opValue)) continue;
292
+ opValue.forEach((member, index) => {
293
+ if (member === void 0) throw undefinedComparandError(field, `${opPath}[${index}]`);
294
+ });
295
+ }
296
+ }
297
+ function mixedFieldWrapperError(field, opKeys, nonOpKeys) {
298
+ const offending = nonOpKeys.map((k) => `"${k}"`).join(", ");
299
+ const rewrites = nonOpKeys.map((k) => `"${k}" \u2192 "$${k}"`).join(", ");
300
+ const example = nonOpKeys[0];
301
+ return invalidFilterError(
302
+ `[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).`
303
+ );
304
+ }
305
+ function assertUnmixedFieldWrapper(field, wrapper) {
306
+ const keys = Object.keys(wrapper);
307
+ const opKeys = keys.filter((k) => k.startsWith("$"));
308
+ if (opKeys.length === 0) return;
309
+ const nonOpKeys = keys.filter((k) => !k.startsWith("$"));
310
+ if (nonOpKeys.length === 0) return;
311
+ throw mixedFieldWrapperError(field, opKeys, nonOpKeys);
312
+ }
164
313
  function fieldLeaves(key, raw) {
314
+ assertDefinedComparands(key, raw);
165
315
  const out = [];
166
316
  const leaf = (operator, values) => {
167
317
  out.push({ kind: "leaf", member: key, operator, values });
@@ -177,6 +327,7 @@ function fieldLeaves(key, raw) {
177
327
  `[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.`
178
328
  );
179
329
  }
330
+ assertUnmixedFieldWrapper(key, wrapper);
180
331
  const opKeys = Object.keys(wrapper).filter((k) => k.startsWith("$"));
181
332
  if (opKeys.length > 0) {
182
333
  for (const opKey of opKeys) {
@@ -187,6 +338,7 @@ function fieldLeaves(key, raw) {
187
338
  `[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.`
188
339
  );
189
340
  }
341
+ assertNoFieldReferenceComparand(opKey, key, v2);
190
342
  leaf("gte", [comparand(v2[0])]);
191
343
  leaf("lte", [comparand(v2[1])]);
192
344
  continue;
@@ -211,6 +363,7 @@ function fieldLeaves(key, raw) {
211
363
  );
212
364
  }
213
365
  const v = wrapper[opKey];
366
+ assertCompilableComparand(opKey, key, v);
214
367
  const values = Array.isArray(v) ? v.map(comparand) : [comparand(v)];
215
368
  if (nullValueSatisfiesOperator(opKey, v) && !operatorIsNullTotal(opKey, v)) {
216
369
  out.push({
@@ -240,7 +393,6 @@ function fieldLeaves(key, raw) {
240
393
  function buildNode(cond) {
241
394
  const children = [];
242
395
  for (const [key, raw] of Object.entries(cond)) {
243
- if (raw === void 0) continue;
244
396
  if (key === "$and" || key === "$or") {
245
397
  if (!Array.isArray(raw)) {
246
398
  throw invalidFilterError(
@@ -314,6 +466,7 @@ function nullValueSatisfiesOperator(op, value) {
314
466
  }
315
467
  }
316
468
  function operatorIsNullTotal(op, value) {
469
+ if (CROSS_FIELD_COMPARISON_OPERATORS.has(op) && isFieldReference(value)) return true;
317
470
  switch (op) {
318
471
  // Compile to `set` / `notSet` — `IS NULL` / `IS NOT NULL`, two-valued by
319
472
  // construction, on every strategy that compiles this tree.
@@ -456,6 +609,11 @@ function likePattern(shape, value) {
456
609
  const escaped = escapeLikePattern(value);
457
610
  return shape === "starts" ? `${escaped}%` : shape === "ends" ? `%${escaped}` : `%${escaped}%`;
458
611
  }
612
+ var ASCII_UPPER_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
613
+ var ASCII_LOWER_LETTERS = "abcdefghijklmnopqrstuvwxyz";
614
+ function asciiLowerSqlExpr(expr) {
615
+ return `translate(${expr}, '${ASCII_UPPER_LETTERS}', '${ASCII_LOWER_LETTERS}')`;
616
+ }
459
617
 
460
618
  // src/read-scope-sql.ts
461
619
  var IDENT = /^[a-z_][a-z0-9_]*$/i;
@@ -527,6 +685,9 @@ function compileNode(node, qAlias, params) {
527
685
  }
528
686
  function compileField(field, value, qAlias, params) {
529
687
  const col = `${qAlias}.${quoteIdent(field, "field")}`;
688
+ assertDefinedComparands2(field, value);
689
+ assertBooleanFlagComparands(field, value);
690
+ assertNoFieldReferenceComparand2(field, value);
530
691
  if (value === null) return `${col} IS NULL`;
531
692
  if (typeof value !== "object" || value instanceof Date) {
532
693
  params.push(value);
@@ -556,6 +717,66 @@ function bindLike(params, pattern) {
556
717
  function nullSafeNegative(col, test) {
557
718
  return `(${col} IS NULL OR ${test})`;
558
719
  }
720
+ function assertCompilableMembers(op, field, members) {
721
+ members.forEach((member, index) => {
722
+ if (!isBindableComparand(member)) {
723
+ throw readScopeCompileError(`[read-scope-sql] ${unbindableListMemberMessage(op, field, member, index)}`);
724
+ }
725
+ });
726
+ }
727
+ function assertRenderableText(op, field, val) {
728
+ if (isRenderableTextComparand(val)) return;
729
+ throw readScopeCompileError(`[read-scope-sql] ${unrenderableTextComparandMessage(op, field, val)}`);
730
+ }
731
+ function undefinedComparandError2(field, path) {
732
+ return readScopeCompileError(
733
+ `[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).`
734
+ );
735
+ }
736
+ function assertDefinedComparands2(field, spec) {
737
+ const root = `"${field}"`;
738
+ if (spec === void 0) throw undefinedComparandError2(field, root);
739
+ if (!isFilterNode(spec)) return;
740
+ for (const [op, opValue] of Object.entries(spec)) {
741
+ if (!op.startsWith("$") || op === "$null" || op === "$exists") continue;
742
+ const opPath = `${root}.${op}`;
743
+ if (opValue === void 0) throw undefinedComparandError2(field, opPath);
744
+ if (!Array.isArray(opValue)) continue;
745
+ opValue.forEach((member, index) => {
746
+ if (member === void 0) throw undefinedComparandError2(field, `${opPath}[${index}]`);
747
+ });
748
+ }
749
+ }
750
+ function nonBooleanFlagComparandError(op, field, path) {
751
+ return readScopeCompileError(
752
+ `[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).`
753
+ );
754
+ }
755
+ function assertBooleanFlagComparands(field, spec) {
756
+ if (!isFilterNode(spec)) return;
757
+ for (const op of ["$null", "$exists"]) {
758
+ if (!Object.prototype.hasOwnProperty.call(spec, op)) continue;
759
+ if (typeof spec[op] === "boolean") continue;
760
+ throw nonBooleanFlagComparandError(op, field, `"${field}".${op}`);
761
+ }
762
+ }
763
+ function assertNoFieldReferenceComparand2(field, spec) {
764
+ if (!isFilterNode(spec)) return;
765
+ for (const [op, opValue] of Object.entries(spec)) {
766
+ if (CROSS_FIELD_COMPARISON_OPERATORS.has(op) && isFieldReference(opValue)) {
767
+ throw readScopeCompileError(
768
+ `[read-scope-sql] ${fieldReferenceComparandMessage(op, field, opValue.$field)}`
769
+ );
770
+ }
771
+ if (op !== "$between" || !Array.isArray(opValue)) continue;
772
+ opValue.forEach((member, index) => {
773
+ if (!isFieldReference(member)) return;
774
+ throw readScopeCompileError(
775
+ `[read-scope-sql] ${fieldReferenceBetweenBoundMessage(op, field, member.$field, index)}`
776
+ );
777
+ });
778
+ }
779
+ }
559
780
  function compileOperator(col, op, val, field, params) {
560
781
  switch (op) {
561
782
  case "$eq":
@@ -575,33 +796,70 @@ function compileOperator(col, op, val, field, params) {
575
796
  case "$in": {
576
797
  if (!Array.isArray(val)) throw readScopeCompileError(`[read-scope-sql] $in for "${field}" needs an array (fail-closed).`);
577
798
  if (val.length === 0) return FALSE_CLAUSE;
799
+ assertCompilableMembers(op, field, val);
578
800
  return `${col} IN (${val.map((v) => bind(params, v)).join(", ")})`;
579
801
  }
580
802
  case "$nin": {
581
803
  if (!Array.isArray(val)) throw readScopeCompileError(`[read-scope-sql] $nin for "${field}" needs an array (fail-closed).`);
582
804
  if (val.length === 0) return "1 = 1";
805
+ assertCompilableMembers(op, field, val);
583
806
  return nullSafeNegative(col, `${col} NOT IN (${val.map((v) => bind(params, v)).join(", ")})`);
584
807
  }
585
808
  case "$between": {
586
809
  if (!Array.isArray(val) || val.length !== 2) throw readScopeCompileError(`[read-scope-sql] $between for "${field}" needs [min,max] (fail-closed).`);
810
+ assertCompilableMembers(op, field, val);
587
811
  return `${col} BETWEEN ${bind(params, val[0])} AND ${bind(params, val[1])}`;
588
812
  }
589
813
  // [#5567] The comparand is a LITERAL, so it is escaped and the escape
590
814
  // character is bound with it. See {@link bindLike}.
815
+ // [#5234] …and it must be a value `String()` can render, which is asserted
816
+ // BEFORE `likePattern` sees it — see {@link assertRenderableText}.
591
817
  case "$contains":
818
+ assertRenderableText(op, field, val);
592
819
  return `${col} LIKE ${bindLike(params, likePattern("contains", val))}`;
820
+ /**
821
+ * [#6520] `$icontains` on the READ-SCOPE lowering — the one compiler in this
822
+ * package where a wrong answer is an ADR-0021 scope over-reach rather than a
823
+ * loose chart filter, which is why the fold is the spec's ruled one and not
824
+ * `LOWER()`.
825
+ *
826
+ * `assertRenderableText` first, exactly as its case-exact twin above: the
827
+ * comparand has to be something `String()` renders faithfully before a
828
+ * pattern is built from it (#5234).
829
+ *
830
+ * The fold wraps BOTH the column and the bound pattern. Folding one side
831
+ * only would compare a folded needle against a raw column — matching just
832
+ * the rows already lower-case — and on a read scope that is a row set the
833
+ * policy author never wrote, in the narrowing direction here but in the
834
+ * WIDENING direction under a `$not`.
835
+ */
836
+ case "$icontains": {
837
+ assertRenderableText(op, field, val);
838
+ const patternRef = asciiLowerSqlExpr(bind(params, likePattern("contains", val)));
839
+ return `${asciiLowerSqlExpr(col)} LIKE ${patternRef} ESCAPE ${bind(params, LIKE_ESCAPE_CHAR)}`;
840
+ }
593
841
  // [#5298] NULL-safe: `NOT LIKE` is UNKNOWN for a NULL column, and "does not
594
842
  // contain" is true of a value that is not there.
595
843
  case "$notContains":
844
+ assertRenderableText(op, field, val);
596
845
  return nullSafeNegative(col, `${col} NOT LIKE ${bindLike(params, likePattern("contains", val))}`);
597
846
  case "$startsWith":
847
+ assertRenderableText(op, field, val);
598
848
  return `${col} LIKE ${bindLike(params, likePattern("starts", val))}`;
599
849
  case "$endsWith":
850
+ assertRenderableText(op, field, val);
600
851
  return `${col} LIKE ${bindLike(params, likePattern("ends", val))}`;
852
+ // [#6387] `val` is a boolean here — {@link assertBooleanFlagComparands}
853
+ // refused anything else at {@link compileField}, before this emitter runs.
854
+ // So `=== true` is an exhaustive TWO-WAY choice over the declared domain,
855
+ // not the "anything truthy is IS NULL" rule it used to be. That old rule is
856
+ // what put the STRING `"false"` on the side opposite the `false` it was
857
+ // written to mean; the identity spelling cannot, and it is the spelling
858
+ // {@link nullValueSatisfiesOperator} now mirrors (#5146 / #5298).
601
859
  case "$null":
602
- return val ? `${col} IS NULL` : `${col} IS NOT NULL`;
860
+ return val === true ? `${col} IS NULL` : `${col} IS NOT NULL`;
603
861
  case "$exists":
604
- return val ? `${col} IS NOT NULL` : `${col} IS NULL`;
862
+ return val === true ? `${col} IS NOT NULL` : `${col} IS NULL`;
605
863
  default:
606
864
  throw readScopeCompileError(`[read-scope-sql] unsupported operator "${op}" on "${field}" (fail-closed).`);
607
865
  }
@@ -614,11 +872,22 @@ function nullValueSatisfiesOperator2(op, value) {
614
872
  // Mirror image: `$ne: null` compiles to `IS NOT NULL`, which a NULL fails.
615
873
  case "$ne":
616
874
  return value !== null;
617
- // Truthiness, matching this file's emitter (see the note above).
875
+ // [#6387] Identity, matching this file's emitter (see the note above).
876
+ // `assertBooleanFlagComparands` refuses anything but `true` / `false` before
877
+ // this table is consulted, so each arm is an exhaustive TWO-WAY choice over
878
+ // the declared domain — and the strict spelling is chosen over the lenient
879
+ // one it replaces for the reason #5347 gave: `Boolean(value)` and
880
+ // `value === true` are equivalent only while the gate upstream holds, and
881
+ // the lenient spelling would quietly resume answering for shapes nobody
882
+ // ruled on if that gate were ever moved. A NULL column satisfies `$null`
883
+ // exactly when the author asked for null…
618
884
  case "$null":
619
- return Boolean(value);
885
+ return value === true;
886
+ // …and satisfies `$exists` exactly when the author asked for "no value".
887
+ // `$null: true` and `$exists: false` are the same question, so these two
888
+ // arms are correctly each other's MIRROR, not each other's copy (#5369).
620
889
  case "$exists":
621
- return !value;
890
+ return value === false;
622
891
  // Negative-polarity set / substring tests hold vacuously for an absent value.
623
892
  case "$nin":
624
893
  return true;
@@ -739,9 +1008,82 @@ var NativeSQLStrategy = class {
739
1008
  }
740
1009
  }
741
1010
  }
1011
+ if (this.carriesCrossFieldComparison(query, ctx)) return false;
742
1012
  const caps = ctx.queryCapabilities(query.cube);
743
1013
  return caps.nativeSql && typeof ctx.executeRawSql === "function";
744
1014
  }
1015
+ /**
1016
+ * [#7598] Does serving this query require the cross-field capability this
1017
+ * strategy declines? See the ruling recorded at {@link canHandle}.
1018
+ *
1019
+ * ⚠️ This and {@link assertNoCrossFieldComparison} read the SAME inputs
1020
+ * through the SAME detector, which is what makes the decline and the
1021
+ * fail-closed backstop unable to drift: a shape one of them recognises is a
1022
+ * shape the other recognises.
1023
+ */
1024
+ carriesCrossFieldComparison(query, ctx) {
1025
+ return this.crossFieldComparisonIn(query, ctx) !== null;
1026
+ }
1027
+ crossFieldComparisonIn(query, ctx) {
1028
+ let where = null;
1029
+ try {
1030
+ where = lowerAnalyticsWhere(query);
1031
+ } catch {
1032
+ return null;
1033
+ }
1034
+ const inWhere = findCrossFieldComparand(where);
1035
+ if (inWhere) return { source: "the query's `where`", ...inWhere };
1036
+ if (typeof ctx.getReadScope !== "function") return null;
1037
+ const cube = query.cube ? ctx.getCube(query.cube) : void 0;
1038
+ if (!cube) return null;
1039
+ const objects = [this.extractObjectName(cube)];
1040
+ for (const alias of Object.keys(cube.joins ?? {})) {
1041
+ objects.push(cube.joins?.[alias]?.name ?? alias);
1042
+ }
1043
+ for (const objectName of objects) {
1044
+ const scope = ctx.getReadScope(objectName);
1045
+ if (scope === void 0 || scope === null) continue;
1046
+ const inScope = findCrossFieldComparand(scope);
1047
+ if (inScope) return { source: `the read scope of "${objectName}"`, ...inScope };
1048
+ }
1049
+ return null;
1050
+ }
1051
+ /**
1052
+ * [#7598] The fail-closed backstop at the door that BINDS.
1053
+ *
1054
+ * ⚠️ **Unreachable by construction, and kept deliberately** — saying so
1055
+ * because #7598's brief asks that a refusal arm which has become unreachable
1056
+ * be named rather than left to be re-discovered. {@link canHandle} declines
1057
+ * every query this would fire on, and it declines using
1058
+ * {@link crossFieldComparisonIn} — the same walk over the same two inputs —
1059
+ * so `resolveStrategy` cannot hand this strategy a query carrying one.
1060
+ *
1061
+ * It is kept because of what the failure mode is if that ever stops being
1062
+ * true. The defect #7598 measured was not a missing error: it was a SILENT
1063
+ * BIND — `toSqlBindValue` JSON-stringifies the reference object, so the
1064
+ * statement compiled perfectly and compared a column against the text
1065
+ * `{"$field":"budget"}`, a value no row can hold. A routing gate that misses
1066
+ * a shape therefore degrades to a wrong ANSWER rather than to an error, and
1067
+ * that is the one class this package refuses to leave to a single guard
1068
+ * (Prime Directive #12 — refuse at the door, do not tolerate at the
1069
+ * consumer). One line, no measurable cost, and it turns a routing regression
1070
+ * into a loud refusal instead of an empty chart.
1071
+ *
1072
+ * Deliberately BARE — an undeclared 500, not `INVALID_FILTER` / 400 — for the
1073
+ * reason `buildFilterClauseSql`'s #5333 exit in `objectql-strategy.ts` gives
1074
+ * for the same class: the caller's filter is legal and is served on the
1075
+ * engine path, so an arrival here is drift between our own routing gate and
1076
+ * our own emitter. Billing the caller 400 for that would hide a platform bug
1077
+ * from 5xx alerting and tell a dashboard user to fix a filter that is fine.
1078
+ * Same tier as `resolveMeasureSql`'s unrecognised-`Metric.type` throw below.
1079
+ */
1080
+ assertNoCrossFieldComparison(query, ctx) {
1081
+ const hit = this.crossFieldComparisonIn(query, ctx);
1082
+ if (!hit) return;
1083
+ throw new Error(
1084
+ `[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.`
1085
+ );
1086
+ }
745
1087
  async execute(query, ctx) {
746
1088
  const { sql, params } = await this.generateSql(query, ctx);
747
1089
  const cube = ctx.getCube(query.cube);
@@ -755,6 +1097,7 @@ var NativeSQLStrategy = class {
755
1097
  if (!cube) {
756
1098
  throw new Error(`Cube not found: ${query.cube}`);
757
1099
  }
1100
+ this.assertNoCrossFieldComparison(query, ctx);
758
1101
  const params = [];
759
1102
  const selectClauses = [];
760
1103
  const groupByClauses = [];
@@ -1124,13 +1467,19 @@ var NativeSQLStrategy = class {
1124
1467
  contains: "LIKE",
1125
1468
  notContains: "NOT LIKE",
1126
1469
  startsWith: "LIKE",
1127
- endsWith: "LIKE"
1470
+ endsWith: "LIKE",
1471
+ // [#6520] `$icontains` — `LIKE` like its neighbours; what separates it is
1472
+ // the ASCII fold applied below, not the keyword.
1473
+ icontains: "LIKE"
1128
1474
  };
1129
1475
  const likeShape = {
1130
1476
  contains: "contains",
1131
1477
  notContains: "contains",
1132
1478
  startsWith: "starts",
1133
- endsWith: "ends"
1479
+ endsWith: "ends",
1480
+ // [#6520] Same wildcard placement as `contains`; the case fold is what
1481
+ // differs, and it is applied to both sides of the comparison below.
1482
+ icontains: "contains"
1134
1483
  };
1135
1484
  if (operator === "set") return `${rawCol} IS NOT NULL`;
1136
1485
  if (operator === "notSet") return `${rawCol} IS NULL`;
@@ -1149,6 +1498,9 @@ var NativeSQLStrategy = class {
1149
1498
  params.push(likePattern(shape, values[0]));
1150
1499
  const patternRef = `$${params.length}`;
1151
1500
  params.push(LIKE_ESCAPE_CHAR);
1501
+ if (operator === "icontains") {
1502
+ return `${asciiLowerSqlExpr(rawCol)} ${sqlOp} ${asciiLowerSqlExpr(patternRef)} ESCAPE $${params.length}`;
1503
+ }
1152
1504
  return `${rawCol} ${sqlOp} ${patternRef} ESCAPE $${params.length}`;
1153
1505
  }
1154
1506
  if (operator === "lte") {
@@ -1182,6 +1534,7 @@ var NativeSQLStrategy = class {
1182
1534
  };
1183
1535
 
1184
1536
  // src/strategies/objectql-strategy.ts
1537
+ import { markFilterSubtreeProvenance } from "@objectstack/spec/data";
1185
1538
  import { nextUtcCalendarDay as nextUtcCalendarDay2 } from "@objectstack/core";
1186
1539
 
1187
1540
  // src/strategies/cross-object-rebucket.ts
@@ -1252,7 +1605,12 @@ var LIKE_SQL_OPS = {
1252
1605
  contains: { sql: "LIKE", shape: "contains" },
1253
1606
  notContains: { sql: "NOT LIKE", shape: "contains" },
1254
1607
  startsWith: { sql: "LIKE", shape: "starts" },
1255
- endsWith: { sql: "LIKE", shape: "ends" }
1608
+ endsWith: { sql: "LIKE", shape: "ends" },
1609
+ // [#6520] `$icontains`: the same escaped pattern and bound `ESCAPE` as its
1610
+ // four case-EXACT neighbours, with `fold` adding the ASCII-only case fold to
1611
+ // both sides of the comparison. The flag is on this row alone — the family
1612
+ // above it is case-sensitive by ruling (#4706 Q2 = A).
1613
+ icontains: { sql: "LIKE", shape: "contains", fold: true }
1256
1614
  };
1257
1615
  var ObjectQLStrategy = class {
1258
1616
  constructor() {
@@ -1363,6 +1721,12 @@ var ObjectQLStrategy = class {
1363
1721
  if (!cube) {
1364
1722
  throw new Error(`Cube not found: ${query.cube}`);
1365
1723
  }
1724
+ const crossField = findCrossFieldComparand(this.loweredWhere(query));
1725
+ if (crossField) {
1726
+ throw invalidFilterError(
1727
+ `[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.`
1728
+ );
1729
+ }
1366
1730
  const selectParts = [];
1367
1731
  const groupByParts = [];
1368
1732
  const params = [];
@@ -1467,11 +1831,11 @@ var ObjectQLStrategy = class {
1467
1831
  * predicate. `$and` makes that structurally impossible.
1468
1832
  */
1469
1833
  withReadScope(objectName, filter, ctx) {
1470
- const userFilter = Object.keys(filter).length > 0 ? filter : void 0;
1834
+ const userFilter = Object.keys(filter).length > 0 ? markFilterSubtreeProvenance(filter, "author") : void 0;
1471
1835
  if (typeof ctx.getReadScope !== "function") return userFilter;
1472
1836
  const scope = ctx.getReadScope(objectName);
1473
1837
  if (scope === void 0 || scope === null) return userFilter;
1474
- const scopeFilter = scope;
1838
+ const scopeFilter = markFilterSubtreeProvenance(scope, "policy");
1475
1839
  if (!userFilter) return scopeFilter;
1476
1840
  return { $and: [userFilter, scopeFilter] };
1477
1841
  }
@@ -1645,6 +2009,7 @@ var ObjectQLStrategy = class {
1645
2009
  if (fkValues.length === 0 || typeof ctx.executeAggregate !== "function") return map;
1646
2010
  const idFilter = { id: { $in: fkValues } };
1647
2011
  const scope = typeof ctx.getReadScope === "function" ? ctx.getReadScope(refObject) : null;
2012
+ if (scope != null) markFilterSubtreeProvenance(scope, "policy");
1648
2013
  const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
1649
2014
  const rows = await ctx.executeAggregate(refObject, {
1650
2015
  groupBy: ["id", attr],
@@ -1693,7 +2058,9 @@ var ObjectQLStrategy = class {
1693
2058
  params.push(likePattern(like.shape, values[0]));
1694
2059
  const patternRef = `$${params.length}`;
1695
2060
  params.push(LIKE_ESCAPE_CHAR);
1696
- return `${col} ${like.sql} ${patternRef} ESCAPE $${params.length}`;
2061
+ const lhs = like.fold ? asciiLowerSqlExpr(col) : col;
2062
+ const rhs = like.fold ? asciiLowerSqlExpr(patternRef) : patternRef;
2063
+ return `${lhs} ${like.sql} ${rhs} ESCAPE $${params.length}`;
1697
2064
  }
1698
2065
  const op = SCALAR_SQL_OPS[operator];
1699
2066
  if (!op) {
@@ -1979,6 +2346,14 @@ var ObjectQLStrategy = class {
1979
2346
  * string — `String(…)`, the same normalisation `like-pattern.ts` applies at the
1980
2347
  * two SQL emitters and `driver-sql`'s `applyLike` applies at the driver, so one
1981
2348
  * `$contains` means one thing on every face (#5567's invariant).
2349
+ *
2350
+ * [#5234] Those four `String(…)` calls now only ever see a value that renders
2351
+ * faithfully: `fieldLeaves` refuses an object comparand on this family before a
2352
+ * leaf exists. That ordering is load-bearing rather than incidental — this arm
2353
+ * is a PRODUCER for the engine, so stringifying an object here would have
2354
+ * laundered it into `'[object Object]'` and handed a driver a perfectly
2355
+ * well-typed string. A strict driver downstream could never have seen the shape
2356
+ * it was strict about, which is why the guard sits at the door and not here.
1982
2357
  */
1983
2358
  convertFilter(operator, values) {
1984
2359
  if (operator === "set") return { $ne: null };
@@ -1987,8 +2362,27 @@ var ObjectQLStrategy = class {
1987
2362
  const v0 = values[0];
1988
2363
  const all = [...values];
1989
2364
  switch (operator) {
2365
+ // [#7598] IMPLICIT equality for a literal, EXPLICIT `$eq` for a field
2366
+ // reference — the branch is on the COMPARAND, not on the operator, which
2367
+ // is the same fix and the same reasoning #7597 applied to
2368
+ // `parseFilterAST`, the spec's own lowering sink.
2369
+ //
2370
+ // `{ amount: 5 }` is implicit equality and every backend reads it that
2371
+ // way. `{ amount: { $field: 'budget' } }` is NOT: it is a field-spec
2372
+ // object whose only key is `$field`, which no backend reads as an
2373
+ // equality — `driver-sql` sees an unrecognised operator key and the
2374
+ // memory evaluator sees a comparand it never resolves. So the bare return
2375
+ // was correct for four years' worth of literals and silently wrong for
2376
+ // the one comparand the 2026-08-12 ruling routes HERE on purpose: with it,
2377
+ // `{ amount: { $eq: { $field: 'budget' } } }` — the shape
2378
+ // `compileCelToFilter` emits for a field-to-field CEL rule, and the shape
2379
+ // `canHandle` now declines native SQL for — would arrive at the driver as
2380
+ // something the driver cannot read, so the capability B exists to serve
2381
+ // would fail on its single most important spelling. Its five siblings
2382
+ // (`$ne`/`$gt`/`$gte`/`$lt`/`$lte`) were never affected: they emit their
2383
+ // operator explicitly two lines down.
1990
2384
  case "equals":
1991
- return v0;
2385
+ return isFieldReference(v0) ? { $eq: v0 } : v0;
1992
2386
  case "notEquals":
1993
2387
  return { $ne: v0 };
1994
2388
  case "gt":
@@ -2048,6 +2442,24 @@ var ObjectQLStrategy = class {
2048
2442
  extractObjectName(cube) {
2049
2443
  return cube.sql.trim();
2050
2444
  }
2445
+ /**
2446
+ * [#7598] The query's `where`, lowered — the same input
2447
+ * `NativeSQLStrategy.canHandle` scans, so the strategy that DECLINED and the
2448
+ * echo that refuses read one shape rather than two.
2449
+ *
2450
+ * A throw from the lowering is swallowed for the same reason it is there: the
2451
+ * `where` is malformed either way and `normalizeAnalyticsFilterTree` below
2452
+ * refuses it with the message and envelope it has always had. This helper's
2453
+ * only job is finding a reference, and there is none to find in a filter that
2454
+ * does not lower.
2455
+ */
2456
+ loweredWhere(query) {
2457
+ try {
2458
+ return lowerAnalyticsWhere(query);
2459
+ } catch {
2460
+ return null;
2461
+ }
2462
+ }
2051
2463
  /**
2052
2464
  * The dimensions this query PROJECTS, in the order the result carries them:
2053
2465
  * every `dimensions` entry, then every granular `timeDimensions` entry that
@@ -2090,7 +2502,8 @@ var ObjectQLStrategy = class {
2090
2502
 
2091
2503
  // src/dataset-compiler.ts
2092
2504
  import { AggregationFunction } from "@objectstack/spec/data";
2093
- var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set(["array_agg", "string_agg"]);
2505
+ import { resolveI18nLabel } from "@objectstack/spec/ui";
2506
+ var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set();
2094
2507
  var SUPPORTED_AGGREGATES = AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
2095
2508
  function aggregateToMetricType(m) {
2096
2509
  if (!m.aggregate) {
@@ -2125,6 +2538,7 @@ function fieldRelationshipPath(field) {
2125
2538
  }
2126
2539
  var MAX_JOIN_HOPS = 3;
2127
2540
  var joinAlias = (path) => path.replace(/\./g, "__");
2541
+ var REGISTRY_LOCALE = void 0;
2128
2542
  function compileDataset(dataset, resolver, options) {
2129
2543
  const include = dataset.include ?? [];
2130
2544
  const declaredDatasource = (objectName) => {
@@ -2196,7 +2610,11 @@ function compileDataset(dataset, resolver, options) {
2196
2610
  assertDeclared(d.field, "dimension", d.name);
2197
2611
  const dim = {
2198
2612
  name: d.name,
2199
- label: typeof d.label === "string" ? d.label : d.name,
2613
+ // [#6761] An inline locale map is a label, not a missing one. Before this,
2614
+ // the `typeof === 'string'` test dropped the map and substituted the
2615
+ // machine name, which `/analytics/meta` then published as a display title
2616
+ // (`title: 'owner'` for a dimension labelled `{ en: 'Owner', … }`).
2617
+ label: resolveI18nLabel(d.label, REGISTRY_LOCALE) ?? d.name,
2200
2618
  type: dimensionType(d),
2201
2619
  sql: d.field
2202
2620
  };
@@ -2216,7 +2634,8 @@ function compileDataset(dataset, resolver, options) {
2216
2634
  if (m.field) assertDeclared(m.field, "measure", m.name);
2217
2635
  const metric = {
2218
2636
  name: m.name,
2219
- label: typeof m.label === "string" ? m.label : m.name,
2637
+ // [#6761] Same as the dimension label above — see {@link REGISTRY_LOCALE}.
2638
+ label: resolveI18nLabel(m.label, REGISTRY_LOCALE) ?? m.name,
2220
2639
  type: aggregateToMetricType(m),
2221
2640
  // `count` with no field aggregates over rows (*).
2222
2641
  sql: m.field ?? "*"
@@ -2227,7 +2646,10 @@ function compileDataset(dataset, resolver, options) {
2227
2646
  }
2228
2647
  const cube = {
2229
2648
  name: dataset.name,
2230
- title: typeof dataset.label === "string" ? dataset.label : dataset.name,
2649
+ // [#6761] The cube's own display title, same rule. `Cube.title` is optional
2650
+ // in the schema, but an absent dataset label already produced the machine
2651
+ // name here and that is not what this card changes — only the map case moves.
2652
+ title: resolveI18nLabel(dataset.label, REGISTRY_LOCALE) ?? dataset.name,
2231
2653
  sql: dataset.object,
2232
2654
  measures,
2233
2655
  dimensions,
@@ -2245,7 +2667,7 @@ function compileDataset(dataset, resolver, options) {
2245
2667
 
2246
2668
  // src/dataset-executor.ts
2247
2669
  import { emptyGroupValueFor } from "@objectstack/spec/data";
2248
- import { filterTokenContextFrom, resolveFilterTokens } from "@objectstack/core";
2670
+ import { bucketKeyToCalendarRange, filterTokenContextFrom, resolveFilterTokens } from "@objectstack/core";
2249
2671
  function resolveSelectionTokens(compiled, selection, context) {
2250
2672
  const tokenCtx = filterTokenContextFrom(context, /* @__PURE__ */ new Date());
2251
2673
  const resolve = (v) => resolveFilterTokens(v, tokenCtx);
@@ -2436,6 +2858,62 @@ function shiftRange(range, kind) {
2436
2858
  const prevStartMs = prevEndMs - (lengthDays - 1) * DAY_MS;
2437
2859
  return [toISODate(prevStartMs), toISODate(prevEndMs)];
2438
2860
  }
2861
+ function isoWeekKeyOfUtcMs(ms) {
2862
+ const target = new Date(ms);
2863
+ const dayNum = (target.getUTCDay() + 6) % 7;
2864
+ target.setUTCDate(target.getUTCDate() - dayNum + 3);
2865
+ const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
2866
+ const weekNo = 1 + Math.round(
2867
+ ((target.getTime() - firstThursday.getTime()) / DAY_MS - 3 + (firstThursday.getUTCDay() + 6) % 7) / 7
2868
+ );
2869
+ return `${target.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`;
2870
+ }
2871
+ function bucketOrdinalOfDay(ymd, granularity) {
2872
+ const ms = parseUTC(ymd);
2873
+ const d = new Date(ms);
2874
+ const y = d.getUTCFullYear();
2875
+ const m = d.getUTCMonth();
2876
+ switch (granularity) {
2877
+ case "year":
2878
+ return y;
2879
+ case "quarter":
2880
+ return y * 4 + Math.floor(m / 3);
2881
+ case "month":
2882
+ return y * 12 + m;
2883
+ // 1970-01-01 was a Thursday, so shifting by 3 days puts the Monday boundary
2884
+ // on a multiple of 7 and the ordinal advances exactly at each ISO week start.
2885
+ case "week":
2886
+ return Math.floor((ms + 3 * DAY_MS) / (7 * DAY_MS));
2887
+ case "day":
2888
+ default:
2889
+ return Math.floor(ms / DAY_MS);
2890
+ }
2891
+ }
2892
+ function bucketKeyAtOrdinal(ordinal, granularity) {
2893
+ switch (granularity) {
2894
+ case "year":
2895
+ return String(ordinal);
2896
+ case "quarter":
2897
+ return `${Math.floor(ordinal / 4)}-Q${ordinal % 4 + 1}`;
2898
+ case "month":
2899
+ return `${Math.floor(ordinal / 12)}-${String(ordinal % 12 + 1).padStart(2, "0")}`;
2900
+ case "week":
2901
+ return isoWeekKeyOfUtcMs(ordinal * 7 * DAY_MS - 3 * DAY_MS);
2902
+ case "day":
2903
+ default:
2904
+ return toISODate(ordinal * DAY_MS);
2905
+ }
2906
+ }
2907
+ function alignedCompareBucketKey(key, granularity, kind, currentRange, shiftedRange) {
2908
+ if (typeof key !== "string" || key.length === 0) return null;
2909
+ const span = bucketKeyToCalendarRange(key, granularity);
2910
+ if (!span) return null;
2911
+ const targetOrdinal = kind === "previousYear" ? bucketOrdinalOfDay(shiftYear(span.start, 1), granularity) : bucketOrdinalOfDay(span.start, granularity) + (bucketOrdinalOfDay(currentRange[0], granularity) - bucketOrdinalOfDay(shiftedRange[0], granularity));
2912
+ const first = bucketOrdinalOfDay(currentRange[0], granularity);
2913
+ const last = bucketOrdinalOfDay(currentRange[1], granularity);
2914
+ if (targetOrdinal < first || targetOrdinal > last) return null;
2915
+ return bucketKeyAtOrdinal(targetOrdinal, granularity);
2916
+ }
2439
2917
  var DatasetExecutor = class {
2440
2918
  /**
2441
2919
  * @param service - The analytics service the executor issues its queries to.
@@ -2622,6 +3100,24 @@ var DatasetExecutor = class {
2622
3100
  timeDimensionsOf(compiled, dimensions) {
2623
3101
  return dimensions.filter((d) => compiled.cube.dimensions[d]?.type === "time");
2624
3102
  }
3103
+ /**
3104
+ * The EFFECTIVE bucket size one dimension is grouped at for this selection,
3105
+ * or `undefined` when it is not a date dimension or nothing states a size (in
3106
+ * which case the runtime groups the raw column).
3107
+ *
3108
+ * One definition, two readers, deliberately: {@link buildQuery} uses it to
3109
+ * decide the `GROUP BY`, and {@link runCompare} uses it to realign the
3110
+ * comparison pass's bucket keys (#6007). Those two MUST agree — realigning
3111
+ * `month` keys a query grouped by `quarter` would move every comparison value
3112
+ * onto a bucket that does not exist — and the way to make them agree is to
3113
+ * have one of them, not two that look alike.
3114
+ */
3115
+ granularityOf(compiled, selection, name) {
3116
+ const cd = compiled.cube.dimensions[name];
3117
+ if (cd?.type !== "time") return void 0;
3118
+ const datasetDefault = cd.granularities?.length === 1 ? String(cd.granularities[0]) : void 0;
3119
+ return resolveDimensionGranularity(selection, name, datasetDefault);
3120
+ }
2625
3121
  buildQuery(compiled, opts) {
2626
3122
  const q = {
2627
3123
  cube: compiled.cube.name,
@@ -2635,12 +3131,7 @@ var DatasetExecutor = class {
2635
3131
  const selTimeDims = opts.selection.timeDimensions ?? [];
2636
3132
  const selDims = new Set(selTimeDims.map((t) => t.dimension));
2637
3133
  const groupedDims = new Set(opts.dimensions);
2638
- const granularityFor = (name) => {
2639
- const cd = compiled.cube.dimensions[name];
2640
- if (cd?.type !== "time") return void 0;
2641
- const datasetDefault = cd.granularities?.length === 1 ? String(cd.granularities[0]) : void 0;
2642
- return resolveDimensionGranularity(opts.selection, name, datasetDefault);
2643
- };
3134
+ const granularityFor = (name) => this.granularityOf(compiled, opts.selection, name);
2644
3135
  const bucketsUnstatedEntry = (dimension) => groupedDims.has(dimension) || opts.selection.dateGranularity != null;
2645
3136
  const resolvedTimeDims = selTimeDims.map((t) => {
2646
3137
  if (t.granularity) return t;
@@ -2675,9 +3166,14 @@ var DatasetExecutor = class {
2675
3166
  { ...selection, timeDimensions: shiftedTd },
2676
3167
  { measures, dimensions, baseFilter, context }
2677
3168
  );
3169
+ const granularity = dimensions.includes(dimension) ? this.granularityOf(compiled, selection, dimension) : void 0;
2678
3170
  return sub.rows.map((row) => {
2679
3171
  const out = {};
2680
3172
  for (const dim of dimensions) out[dim] = row[dim];
3173
+ if (granularity) {
3174
+ const aligned = alignedCompareBucketKey(row[dimension], granularity, cmp.kind, range, shifted);
3175
+ if (aligned != null) out[dimension] = aligned;
3176
+ }
2681
3177
  for (const m of measures) out[`${m}__compare`] = row[m];
2682
3178
  return out;
2683
3179
  });
@@ -3054,8 +3550,12 @@ function hasDeclaredErrorEnvelope(err) {
3054
3550
  const e = err;
3055
3551
  return typeof e?.status === "number" && typeof e?.code === "string" && e.code.length > 0;
3056
3552
  }
3553
+ function isMissingColumnOfRelation(message) {
3554
+ return matchMissingColumnOfRelation(message) !== void 0;
3555
+ }
3057
3556
  function isMissingSourceError(err) {
3058
3557
  const raw = String(err?.message ?? err ?? "");
3558
+ if (isMissingColumnOfRelation(raw)) return false;
3059
3559
  const msg = raw.toLowerCase();
3060
3560
  return msg.includes("no such table") || // sqlite / libsql
3061
3561
  /relation\s+[`"']?[A-Za-z0-9_$.]+[`"']?\s+does not exist/i.test(raw) || // postgres
@@ -3065,6 +3565,7 @@ function isMissingSourceError(err) {
3065
3565
  }
3066
3566
  function missingSourceRelation(err) {
3067
3567
  const msg = String(err?.message ?? err ?? "");
3568
+ if (isMissingColumnOfRelation(msg)) return void 0;
3068
3569
  const patterns = [
3069
3570
  /no such table:\s*[`"'[]?([A-Za-z0-9_$.]+)/i,
3070
3571
  // sqlite / libsql
@@ -3140,6 +3641,7 @@ var AnalyticsService = class {
3140
3641
  this.getObjectFieldNames = config.getObjectFieldNames;
3141
3642
  this.getObjectDatasource = config.getObjectDatasource;
3142
3643
  this.isExternalObject = config.isExternalObject;
3644
+ this.debugSql = config.debugSql ?? getEnv("NODE_ENV") === "development";
3143
3645
  if (config.datasets) {
3144
3646
  for (const ds of config.datasets) {
3145
3647
  try {
@@ -3257,7 +3759,7 @@ var AnalyticsService = class {
3257
3759
  const strategy = this.resolveStrategy(query, ctx, skip);
3258
3760
  this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
3259
3761
  try {
3260
- return await strategy.execute(query, ctx);
3762
+ return this.applySqlEchoPolicy(await strategy.execute(query, ctx));
3261
3763
  } catch (e) {
3262
3764
  if (e?.code === "RAW_SQL_UNSUPPORTED") {
3263
3765
  this.logger.warn(
@@ -3270,6 +3772,30 @@ var AnalyticsService = class {
3270
3772
  }
3271
3773
  }
3272
3774
  }
3775
+ /**
3776
+ * [#8286] Withhold the executed statement unless this host enabled the echo.
3777
+ *
3778
+ * Applied at {@link query}, which is the response-assembly seam for BOTH
3779
+ * faces that serve callers: `/api/v1/analytics/query` calls it directly, and
3780
+ * `queryDataset` reaches it through `DatasetExecutor`, so a dataset response
3781
+ * inherits the same verdict without a second gate to keep in step.
3782
+ * `generateSql` — the dedicated `/api/v1/analytics/sql` dry-run route — is
3783
+ * deliberately NOT gated: asking for the statement is that route's entire
3784
+ * purpose, and it is the surface a debugging author is meant to use.
3785
+ *
3786
+ * What the echo disclosed, and why "it is only a table name" understates it:
3787
+ * the statement carries the compiled read scope, i.e. the SHAPE of the
3788
+ * isolation predicate (`"sys_user"."id" IN ($2, $3, …)` rather than an
3789
+ * `organization_id` comparison) plus its bound-parameter arity, which counts
3790
+ * the caller's own org membership. No wall was breached by it — the echo is
3791
+ * information disclosure, and this is the disclosure closing.
3792
+ */
3793
+ applySqlEchoPolicy(result) {
3794
+ if (this.debugSql || result?.sql === void 0) return result;
3795
+ const withheld = { ...result };
3796
+ delete withheld.sql;
3797
+ return withheld;
3798
+ }
3273
3799
  /**
3274
3800
  * Compile a `dataset` (ADR-0021) and register its Cube + join allowlist so it
3275
3801
  * can be queried by name. Idempotent (re-registering overwrites). Returns the
@@ -3309,6 +3835,7 @@ var AnalyticsService = class {
3309
3835
  return previewResult;
3310
3836
  }
3311
3837
  }
3838
+ const requestLocale = context?.locale;
3312
3839
  const provider = this.readScopeProvider;
3313
3840
  const resolveScope = provider ? (targetObject) => provider(targetObject, context) : void 0;
3314
3841
  const labelDeps = this.labelResolver ? withLabelFetchCache(this.labelResolver) : void 0;
@@ -3383,7 +3910,7 @@ var AnalyticsService = class {
3383
3910
  result.drillRanges = result.rows.map((row) => {
3384
3911
  const ranges = {};
3385
3912
  for (const { d, granularity, instant } of rangeDims) {
3386
- const cal = bucketKeyToCalendarRange(row[d.name], granularity);
3913
+ const cal = bucketKeyToCalendarRange2(row[d.name], granularity);
3387
3914
  if (cal) {
3388
3915
  ranges[d.name] = { field: d.field, gte: bound(cal.start, instant), lt: bound(cal.end, instant) };
3389
3916
  }
@@ -3418,7 +3945,10 @@ var AnalyticsService = class {
3418
3945
  for (const f of result.fields) {
3419
3946
  const m = measureByName.get(f.name) ?? measureByName.get(f.name.replace(/__compare$/, ""));
3420
3947
  if (!m) continue;
3421
- if (f.label == null && typeof m.label === "string") f.label = m.label;
3948
+ if (f.label == null) {
3949
+ const label = resolveI18nLabel2(m.label, requestLocale);
3950
+ if (label !== void 0) f.label = label;
3951
+ }
3422
3952
  if (f.format == null && m.format) f.format = m.format;
3423
3953
  const fc = f;
3424
3954
  const mc = m;
@@ -3447,7 +3977,9 @@ var AnalyticsService = class {
3447
3977
  for (const f of result.fields) {
3448
3978
  if (f.label != null) continue;
3449
3979
  const d = dimByName.get(f.name) ?? dimByField.get(f.name);
3450
- if (d && typeof d.label === "string") f.label = d.label;
3980
+ if (!d) continue;
3981
+ const label = resolveI18nLabel2(d.label, requestLocale);
3982
+ if (label !== void 0) f.label = label;
3451
3983
  }
3452
3984
  }
3453
3985
  return result;
@@ -3527,10 +4059,10 @@ var AnalyticsService = class {
3527
4059
  else this.logger.warn(message);
3528
4060
  return;
3529
4061
  }
3530
- const stripPrefix = (m) => m.includes(".") ? m.split(".").slice(1).join(".") : m;
3531
4062
  const extraMeasures = {};
3532
4063
  for (const m of query.measures || []) {
3533
- const key = stripPrefix(m);
4064
+ if (cube.measures[m] || extraMeasures[m]) continue;
4065
+ const key = mintableMeasureKey(m, name);
3534
4066
  if (cube.measures[key] || extraMeasures[key]) continue;
3535
4067
  extraMeasures[key] = inferMeasure(key);
3536
4068
  }
@@ -3579,6 +4111,13 @@ var AnalyticsService = class {
3579
4111
  * the data path's `resolveQueryFields`: they are engine-assigned rather than
3580
4112
  * declared, and a gate stricter than the engine it guards would reject
3581
4113
  * queries that used to work.
4114
+ *
4115
+ * [#5918] Its `stripPrefix` below is deliberately NOT narrowed the way the two
4116
+ * MINTS were. This is a RESOLVER — it mirrors `lookupMember`'s tiers to answer
4117
+ * "which Metric will the strategy read", and that tier order did not change.
4118
+ * What changed is what can reach it: a dotted measure is now either a
4119
+ * `<cube>.` qualifier or a key the cube itself declares, because every other
4120
+ * dotted spelling is refused at the mint before this gate runs.
3582
4121
  */
3583
4122
  assertMeasureFields(query, cube, declaredMeasures) {
3584
4123
  const probe = this.getObjectFieldNames;
@@ -3867,7 +4406,7 @@ var AnalyticsService = class {
3867
4406
  };
3868
4407
  measures.count = { name: "count", label: "Count", type: "count", sql: "*" };
3869
4408
  for (const m of query.measures || []) {
3870
- const key = m.includes(".") ? m.split(".").slice(1).join(".") : m;
4409
+ const key = mintableMeasureKey(m, cubeName);
3871
4410
  if (measures[key]) continue;
3872
4411
  const inferred = inferMeasure(key);
3873
4412
  measures[key] = inferred;
@@ -3921,11 +4460,28 @@ var AnalyticsService = class {
3921
4460
  return strategy;
3922
4461
  }
3923
4462
  }
4463
+ const crossField = findCrossFieldComparand(lowerAnalyticsWhereQuietly(query));
3924
4464
  throw new Error(
3925
- `[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.`
4465
+ `[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."
3926
4466
  );
3927
4467
  }
3928
4468
  };
4469
+ function lowerAnalyticsWhereQuietly(query) {
4470
+ try {
4471
+ return lowerAnalyticsWhere(query);
4472
+ } catch {
4473
+ return null;
4474
+ }
4475
+ }
4476
+ function mintableMeasureKey(member, cubeName) {
4477
+ const dot = member.indexOf(".");
4478
+ if (dot < 0) return member;
4479
+ if (member.slice(0, dot) === cubeName) return member.slice(dot + 1);
4480
+ throw invalidMemberError(
4481
+ `[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.`,
4482
+ { member, param: "measures", cube: cubeName }
4483
+ );
4484
+ }
3929
4485
  function inferMeasure(key) {
3930
4486
  if (key === "count") {
3931
4487
  return { name: "count", label: "Count", type: "count", sql: "*" };
@@ -4212,6 +4768,11 @@ var AnalyticsServicePlugin = class {
4212
4768
  coerceTemporalFilterColumn,
4213
4769
  relationshipResolver,
4214
4770
  labelResolver,
4771
+ // [#8286] Passed through as authored — `undefined` is "this host did not
4772
+ // choose", which the service resolves to development-only. Defaulting it
4773
+ // here would be a second copy of that decision, drifting the moment one
4774
+ // of the two moves.
4775
+ debugSql: this.options.debugSql,
4215
4776
  // Source-field metadata behind the display chains on result columns:
4216
4777
  // ADR-0053 currency (`currencyConfig.defaultCurrency`) and percent scale
4217
4778
  // (`max`, which is what marks whole-percent storage — objectui#3136).
@@ -4224,7 +4785,17 @@ var AnalyticsServicePlugin = class {
4224
4785
  // datasource the query was routed to. Undefined ⇒ the object rides the
4225
4786
  // default datasource (or the engine cannot answer), and the diagnostic
4226
4787
  // says so rather than inventing a name.
4227
- getObjectDatasource: (objectName) => dataEngine()?.getObject?.(objectName)?.datasource,
4788
+ //
4789
+ // [#5288] Asked of the ENGINE's resolver, not of the object's declaration.
4790
+ // `getObject(name).datasource` is the declared value — step 1 of the five
4791
+ // `getDriver` routes by — so an object placed by a `datasourceMapping`
4792
+ // rule, by the ADR-0057 §3.6 lifecycle split, or by its package's
4793
+ // `defaultDatasource` answered `'default'`, and the diagnostic named a
4794
+ // database the rows are not in. Recomputing those rules here instead would
4795
+ // be the second implementation `resolveMappedDatasource` (#4462) exists to
4796
+ // prevent: it drifts by one step, silently, and the drift only surfaces as
4797
+ // an error message pointing at the wrong database.
4798
+ getObjectDatasource: (objectName) => dataEngine()?.resolveEffectiveDatasource?.(objectName),
4228
4799
  // ADR-0062 D6 — a federated object carries an `external` block (ADR-0015).
4229
4800
  // Reported so NativeSQLStrategy declines it (its hand-compiled FROM would
4230
4801
  // hit the wrong physical table) and the driver-correct ObjectQL path runs.