@objectstack/service-analytics 16.1.0 → 17.0.0-rc.1

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
@@ -29,16 +29,18 @@ __export(index_exports, {
29
29
  combineFilters: () => combineFilters,
30
30
  compileDataset: () => compileDataset,
31
31
  compileScopedFilterToSql: () => compileScopedFilterToSql,
32
+ createOrderLabelResolver: () => createOrderLabelResolver,
32
33
  evaluateDerivedMeasures: () => evaluateDerivedMeasures,
33
34
  mergeByDimensions: () => mergeByDimensions,
34
35
  pickDisplayField: () => pickDisplayField,
35
36
  resolveDimensionLabels: () => resolveDimensionLabels,
36
- shiftRange: () => shiftRange
37
+ shiftRange: () => shiftRange,
38
+ withLabelFetchCache: () => withLabelFetchCache
37
39
  });
38
40
  module.exports = __toCommonJS(index_exports);
39
41
 
40
42
  // src/analytics-service.ts
41
- var import_core2 = require("@objectstack/core");
43
+ var import_core5 = require("@objectstack/core");
42
44
 
43
45
  // src/cube-registry.ts
44
46
  var CubeRegistry = class {
@@ -167,7 +169,8 @@ var MONGO_TO_CUBE_OP = {
167
169
  $nin: "notIn",
168
170
  $contains: "contains",
169
171
  $notContains: "notContains",
170
- $exists: "set"
172
+ $startsWith: "startsWith",
173
+ $endsWith: "endsWith"
171
174
  };
172
175
  function stringifyForCube(v) {
173
176
  if (v == null) return "";
@@ -176,55 +179,102 @@ function stringifyForCube(v) {
176
179
  if (typeof v === "object") return JSON.stringify(v);
177
180
  return String(v);
178
181
  }
179
- function flattenCondition(cond, out) {
180
- for (const [key, raw] of Object.entries(cond)) {
181
- if (raw === void 0) continue;
182
- if (key === "$and" && Array.isArray(raw)) {
183
- for (const sub of raw) {
184
- if (sub && typeof sub === "object") {
185
- flattenCondition(sub, out);
182
+ function andOf(children) {
183
+ if (children.length === 0) return null;
184
+ if (children.length === 1) return children[0];
185
+ return { kind: "and", children };
186
+ }
187
+ function fieldLeaves(key, raw) {
188
+ const out = [];
189
+ const leaf = (operator, values) => {
190
+ out.push({ kind: "leaf", member: key, operator, values });
191
+ };
192
+ if (raw === null) {
193
+ leaf("notSet", []);
194
+ return out;
195
+ }
196
+ if (typeof raw === "object" && !Array.isArray(raw) && !(raw instanceof Date)) {
197
+ const wrapper = raw;
198
+ const opKeys = Object.keys(wrapper).filter((k) => k.startsWith("$"));
199
+ if (opKeys.length > 0) {
200
+ for (const opKey of opKeys) {
201
+ if (opKey === "$between") {
202
+ const v2 = wrapper[opKey];
203
+ if (!Array.isArray(v2) || v2.length !== 2) {
204
+ throw new Error(
205
+ `[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.`
206
+ );
207
+ }
208
+ leaf("gte", [stringifyForCube(v2[0])]);
209
+ leaf("lte", [stringifyForCube(v2[1])]);
210
+ continue;
186
211
  }
212
+ if (opKey === "$null" || opKey === "$exists") {
213
+ const isNull = opKey === "$null" ? wrapper[opKey] === true : wrapper[opKey] === false;
214
+ leaf(isNull ? "notSet" : "set", []);
215
+ continue;
216
+ }
217
+ const cubeOp = MONGO_TO_CUBE_OP[opKey];
218
+ if (!cubeOp) {
219
+ throw new Error(
220
+ `[analytics] Unsupported filter operator "${opKey}" on "${key}". Supported: ${Object.keys(MONGO_TO_CUBE_OP).join(", ")}, $between, $null, $exists, and the $and/$or/$not combinators. Dropping it would silently widen the query to rows the filter excludes.`
221
+ );
222
+ }
223
+ const v = wrapper[opKey];
224
+ leaf(cubeOp, Array.isArray(v) ? v.map(stringifyForCube) : [stringifyForCube(v)]);
187
225
  }
188
- continue;
226
+ return out;
189
227
  }
190
- if (key === "$or" || key === "$not") continue;
191
- if (raw === null) {
192
- out.push({ member: key, operator: "notSet", values: [] });
193
- continue;
228
+ for (const [nestedKey, nestedVal] of Object.entries(wrapper)) {
229
+ out.push(...fieldLeaves(`${key}.${nestedKey}`, nestedVal));
194
230
  }
195
- if (typeof raw === "object" && !Array.isArray(raw) && !(raw instanceof Date)) {
196
- const wrapper = raw;
197
- const opKeys = Object.keys(wrapper).filter((k) => k.startsWith("$"));
198
- if (opKeys.length > 0) {
199
- for (const opKey of opKeys) {
200
- const cubeOp = MONGO_TO_CUBE_OP[opKey];
201
- if (!cubeOp) continue;
202
- const v = wrapper[opKey];
203
- const values = Array.isArray(v) ? v.map(stringifyForCube) : [stringifyForCube(v)];
204
- out.push({ member: key, operator: cubeOp, values });
205
- }
206
- continue;
207
- }
208
- for (const [nestedKey, nestedVal] of Object.entries(wrapper)) {
209
- flattenCondition({ [`${key}.${nestedKey}`]: nestedVal }, out);
231
+ return out;
232
+ }
233
+ if (Array.isArray(raw)) leaf("in", raw.map(stringifyForCube));
234
+ else leaf("equals", [stringifyForCube(raw)]);
235
+ return out;
236
+ }
237
+ function buildNode(cond) {
238
+ const children = [];
239
+ for (const [key, raw] of Object.entries(cond)) {
240
+ if (raw === void 0) continue;
241
+ if (key === "$and" || key === "$or") {
242
+ if (!Array.isArray(raw) || raw.length === 0) {
243
+ throw new Error(
244
+ `[analytics] "${key}" requires a non-empty array. An empty combinator has no defensible reading \u2014 dropping it widens the query, and treating it as "match nothing" silently empties a chart.`
245
+ );
210
246
  }
247
+ const branches = raw.map((sub) => sub && typeof sub === "object" ? buildNode(sub) : null).filter((n) => n !== null);
248
+ if (branches.length === 0) continue;
249
+ if (key === "$and") children.push(...branches);
250
+ else children.push(branches.length === 1 ? branches[0] : { kind: "or", children: branches });
211
251
  continue;
212
252
  }
213
- if (Array.isArray(raw)) {
214
- out.push({ member: key, operator: "in", values: raw.map(stringifyForCube) });
215
- } else {
216
- out.push({ member: key, operator: "equals", values: [stringifyForCube(raw)] });
253
+ if (key === "$not") {
254
+ const inner = raw && typeof raw === "object" ? buildNode(raw) : null;
255
+ if (inner) children.push({ kind: "not", child: inner });
256
+ continue;
257
+ }
258
+ if (key.startsWith("$")) {
259
+ throw new Error(
260
+ `[analytics] Unsupported top-level filter operator "${key}". Dropping it would silently widen the query to rows the filter excludes.`
261
+ );
217
262
  }
263
+ children.push(...fieldLeaves(key, raw));
218
264
  }
265
+ return andOf(children);
219
266
  }
220
- function normalizeAnalyticsFilters(query) {
221
- if (!query || typeof query !== "object") return [];
222
- const out = [];
267
+ function normalizeAnalyticsFilterTree(query) {
268
+ if (!query || typeof query !== "object") return null;
223
269
  const where = query.where;
224
- if (where && typeof where === "object" && !Array.isArray(where)) {
225
- flattenCondition(where, out);
226
- }
227
- return out;
270
+ if (!where || typeof where !== "object" || Array.isArray(where)) return null;
271
+ return buildNode(where);
272
+ }
273
+ function collectFilterLeaves(node) {
274
+ if (!node) return [];
275
+ if (node.kind === "leaf") return [{ member: node.member, operator: node.operator, values: node.values }];
276
+ if (node.kind === "not") return collectFilterLeaves(node.child);
277
+ return node.children.flatMap(collectFilterLeaves);
228
278
  }
229
279
  function recoverNumber(s) {
230
280
  if (/^-?\d+(\.\d+)?$/.test(s)) {
@@ -356,6 +406,18 @@ function compileOperator(col, op, val, field, params) {
356
406
  }
357
407
 
358
408
  // src/strategies/native-sql-strategy.ts
409
+ var import_core = require("@objectstack/core");
410
+ var AGGREGATE_SQL = {
411
+ "count": () => "COUNT(*)",
412
+ "sum": (col) => `SUM(${col})`,
413
+ "avg": (col) => `AVG(${col})`,
414
+ "min": (col) => `MIN(${col})`,
415
+ "max": (col) => `MAX(${col})`,
416
+ "count_distinct": (col) => `COUNT(DISTINCT ${col})`
417
+ };
418
+ var SUPPORTED_AGGREGATE_SQL_KEYS = Object.keys(AGGREGATE_SQL);
419
+ var EXPRESSION_METRIC_TYPES = /* @__PURE__ */ new Set(["number", "string", "boolean"]);
420
+ var IDENTIFIER_PATH = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
359
421
  var NativeSQLStrategy = class {
360
422
  constructor() {
361
423
  this.name = "NativeSQLStrategy";
@@ -410,15 +472,15 @@ var NativeSQLStrategy = class {
410
472
  }
411
473
  }
412
474
  const whereClauses = [];
413
- const normalizedFilters = normalizeAnalyticsFilters(query);
414
- if (normalizedFilters.length > 0) {
415
- for (const filter of normalizedFilters) {
416
- const colExpr = this.resolveFieldSql(cube, filter.member, tableName, joins);
417
- const target = this.resolveStorageTarget(cube, filter.member, tableName);
418
- const clause = this.buildFilterClause(colExpr, filter.operator, filter.values, params, ctx, target);
419
- if (clause) whereClauses.push(clause);
420
- }
421
- }
475
+ const filterSql = this.compileFilterNode(
476
+ normalizeAnalyticsFilterTree(query),
477
+ cube,
478
+ tableName,
479
+ joins,
480
+ params,
481
+ ctx
482
+ );
483
+ if (filterSql) whereClauses.push(filterSql);
422
484
  if (query.timeDimensions && query.timeDimensions.length > 0) {
423
485
  for (const td of query.timeDimensions) {
424
486
  const colExpr = this.resolveFieldSql(cube, td.dimension, tableName, joins);
@@ -426,11 +488,17 @@ var NativeSQLStrategy = class {
426
488
  const range = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
427
489
  if (range.length === 2) {
428
490
  const td2 = this.resolveStorageTarget(cube, td.dimension, tableName);
429
- params.push(
430
- this.coerceTemporal(ctx, td2, range[0]),
431
- this.coerceTemporal(ctx, td2, range[1])
432
- );
433
- whereClauses.push(`${colExpr} BETWEEN $${params.length - 1} AND $${params.length}`);
491
+ const column = this.temporalColumn(ctx, td2, colExpr);
492
+ const nextDay = (0, import_core.nextUtcCalendarDay)(range[1]);
493
+ params.push(this.coerceTemporal(ctx, td2, range[0]));
494
+ const lower = `${column} >= $${params.length}`;
495
+ if (nextDay != null) {
496
+ params.push(this.coerceTemporal(ctx, td2, nextDay));
497
+ whereClauses.push(`(${lower} AND ${column} < $${params.length})`);
498
+ } else {
499
+ params.push(this.coerceTemporal(ctx, td2, range[1]));
500
+ whereClauses.push(`(${lower} AND ${column} <= $${params.length})`);
501
+ }
434
502
  }
435
503
  }
436
504
  }
@@ -528,6 +596,7 @@ var NativeSQLStrategy = class {
528
596
  }
529
597
  return rawSql;
530
598
  }
599
+ if (!IDENTIFIER_PATH.test(rawSql)) return rawSql;
531
600
  const segments = rawSql.split(".");
532
601
  const column = segments[segments.length - 1];
533
602
  const hops = segments.slice(0, -1);
@@ -587,24 +656,19 @@ var NativeSQLStrategy = class {
587
656
  }
588
657
  resolveMeasureSql(cube, member, parentTable, joins) {
589
658
  const measure = this.lookupMember(cube, member, "measure");
590
- if (!measure) return `COUNT(*)`;
591
- const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
592
- switch (measure.type) {
593
- case "count":
594
- return "COUNT(*)";
595
- case "sum":
596
- return `SUM(${col})`;
597
- case "avg":
598
- return `AVG(${col})`;
599
- case "min":
600
- return `MIN(${col})`;
601
- case "max":
602
- return `MAX(${col})`;
603
- case "count_distinct":
604
- return `COUNT(DISTINCT ${col})`;
605
- default:
606
- return `COUNT(*)`;
659
+ if (!measure) {
660
+ const declared = Object.keys(cube.measures ?? {});
661
+ throw new Error(
662
+ `[native-sql-strategy] cube "${cube.name}" declares no measure "${member}"` + (declared.length ? ` (declared: ${declared.join(", ")})` : " (it declares none)")
663
+ );
607
664
  }
665
+ const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
666
+ const wrap = AGGREGATE_SQL[measure.type];
667
+ if (wrap) return wrap(col);
668
+ if (EXPRESSION_METRIC_TYPES.has(measure.type)) return col;
669
+ throw new Error(
670
+ `[native-sql-strategy] measure "${member}" on cube "${cube.name}" has unrecognised type "${measure.type}" \u2014 expected an aggregate (${SUPPORTED_AGGREGATE_SQL_KEYS.join(", ")}) or a custom-expression type (${[...EXPRESSION_METRIC_TYPES].join(", ")}).`
671
+ );
608
672
  }
609
673
  resolveFieldSql(cube, member, parentTable, joins) {
610
674
  const dim = this.lookupMember(cube, member, "dimension");
@@ -657,7 +721,53 @@ var NativeSQLStrategy = class {
657
721
  }
658
722
  return coerceFilterValueForSql(value);
659
723
  }
660
- buildFilterClause(col, operator, values, params, ctx, target) {
724
+ /**
725
+ * The column side of {@link coerceTemporal}: normalise the reference so it
726
+ * reads in the storage form the comparand was coerced into.
727
+ *
728
+ * A SQLite `Field.datetime` column carries an INTEGER epoch (a `Date` write)
729
+ * and ISO TEXT (a REST/JSON write, a `NOW()` default — including the platform's
730
+ * own `created_at`) at the SAME time, so coercing the value alone fixes one half
731
+ * and empties the other. That is #3912: a `dateRange: last_30_days` on
732
+ * `created_date` read 0 with 29 rows in range. Every other column and dialect
733
+ * gets its reference back verbatim.
734
+ */
735
+ temporalColumn(ctx, target, col) {
736
+ if (typeof ctx.coerceTemporalFilterColumn !== "function") return col;
737
+ return ctx.coerceTemporalFilterColumn(target.object, target.field, col) || col;
738
+ }
739
+ /**
740
+ * Compile a normalized filter node into a boolean SQL expression, recursing
741
+ * through the combinators. `null` = no constraint.
742
+ *
743
+ * Leaves go through {@link buildFilterClause} exactly as they did when this
744
+ * was a flat loop, so the storage-form coercion and the calendar-day
745
+ * upper-bound rule (#3777) apply at every depth — including inside an `$or`,
746
+ * where a second, combinator-aware implementation would have been free to
747
+ * drift from the first.
748
+ *
749
+ * Parenthesisation is explicit rather than left to SQL's precedence: `AND`
750
+ * does bind tighter than `OR`, so `a AND b OR c` happens to be right, but
751
+ * being right by construction is what keeps a future edit from making it
752
+ * wrong.
753
+ */
754
+ compileFilterNode(node, cube, parentTable, joins, params, ctx) {
755
+ if (!node) return null;
756
+ if (node.kind === "leaf") {
757
+ const colExpr = this.resolveFieldSql(cube, node.member, parentTable, joins);
758
+ const target = this.resolveStorageTarget(cube, node.member, parentTable);
759
+ return this.buildFilterClause(colExpr, node.operator, node.values, params, ctx, target);
760
+ }
761
+ if (node.kind === "not") {
762
+ const inner = this.compileFilterNode(node.child, cube, parentTable, joins, params, ctx);
763
+ return inner ? `NOT (${inner})` : null;
764
+ }
765
+ const parts = node.children.map((child) => this.compileFilterNode(child, cube, parentTable, joins, params, ctx)).filter((s) => !!s);
766
+ if (parts.length === 0) return null;
767
+ if (parts.length === 1) return parts[0];
768
+ return `(${parts.join(node.kind === "or" ? " OR " : " AND ")})`;
769
+ }
770
+ buildFilterClause(rawCol, operator, values, params, ctx, target) {
661
771
  const opMap = {
662
772
  equals: "=",
663
773
  notEquals: "!=",
@@ -666,26 +776,42 @@ var NativeSQLStrategy = class {
666
776
  lt: "<",
667
777
  lte: "<=",
668
778
  contains: "LIKE",
669
- notContains: "NOT LIKE"
779
+ notContains: "NOT LIKE",
780
+ startsWith: "LIKE",
781
+ endsWith: "LIKE"
670
782
  };
671
- if (operator === "set") return `${col} IS NOT NULL`;
672
- if (operator === "notSet") return `${col} IS NULL`;
783
+ const likePattern = {
784
+ contains: (v) => `%${v}%`,
785
+ notContains: (v) => `%${v}%`,
786
+ startsWith: (v) => `${v}%`,
787
+ endsWith: (v) => `%${v}`
788
+ };
789
+ if (operator === "set") return `${rawCol} IS NOT NULL`;
790
+ if (operator === "notSet") return `${rawCol} IS NULL`;
673
791
  if (operator === "in" || operator === "notIn") {
674
792
  if (!values || values.length === 0) return null;
675
793
  const placeholders = values.map((v) => {
676
794
  params.push(this.coerceTemporal(ctx, target, v));
677
795
  return `$${params.length}`;
678
796
  }).join(", ");
679
- return `${col} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
797
+ return `${this.temporalColumn(ctx, target, rawCol)} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
680
798
  }
681
799
  const sqlOp = opMap[operator];
682
800
  if (!sqlOp || !values || values.length === 0) return null;
683
- if (operator === "contains" || operator === "notContains") {
684
- params.push(`%${values[0]}%`);
685
- } else {
686
- params.push(this.coerceTemporal(ctx, target, values[0]));
801
+ const pattern = likePattern[operator];
802
+ if (pattern) {
803
+ params.push(pattern(values[0]));
804
+ return `${rawCol} ${sqlOp} $${params.length}`;
805
+ }
806
+ if (operator === "lte") {
807
+ const nextDay = (0, import_core.nextUtcCalendarDay)(values[0]);
808
+ if (nextDay != null) {
809
+ params.push(this.coerceTemporal(ctx, target, nextDay));
810
+ return `${this.temporalColumn(ctx, target, rawCol)} < $${params.length}`;
811
+ }
687
812
  }
688
- return `${col} ${sqlOp} $${params.length}`;
813
+ params.push(this.coerceTemporal(ctx, target, values[0]));
814
+ return `${this.temporalColumn(ctx, target, rawCol)} ${sqlOp} $${params.length}`;
689
815
  }
690
816
  extractObjectName(cube) {
691
817
  return cube.sql.trim();
@@ -708,6 +834,72 @@ var NativeSQLStrategy = class {
708
834
  };
709
835
 
710
836
  // src/strategies/objectql-strategy.ts
837
+ var import_core2 = require("@objectstack/core");
838
+
839
+ // src/strategies/cross-object-rebucket.ts
840
+ var RECOMBINABLE_METHODS = /* @__PURE__ */ new Set([
841
+ "sum",
842
+ "count",
843
+ "min",
844
+ "max"
845
+ ]);
846
+ var RESTRICTED_BUCKET = "(restricted)";
847
+ function orderableValue(v) {
848
+ if (v == null) return NaN;
849
+ if (typeof v === "number") return v;
850
+ if (v instanceof Date) return v.getTime();
851
+ const n = Number(v);
852
+ if (Number.isFinite(n)) return n;
853
+ return Date.parse(String(v));
854
+ }
855
+ function recombine(method, acc, next) {
856
+ if (method === "min" || method === "max") {
857
+ if (acc === void 0) return next ?? 0;
858
+ const a = orderableValue(acc);
859
+ const n2 = orderableValue(next);
860
+ if (Number.isNaN(n2)) return acc;
861
+ if (Number.isNaN(a)) return next;
862
+ const nextWins = method === "min" ? n2 < a : n2 > a;
863
+ return nextWins ? next : acc;
864
+ }
865
+ const n = Number(next ?? 0);
866
+ return acc === void 0 ? n : Number(acc) + n;
867
+ }
868
+ function rebucketCrossObject(baseRows, baseDimFields, crossDims, measures) {
869
+ const buckets = /* @__PURE__ */ new Map();
870
+ for (const row of baseRows) {
871
+ const resolved = {};
872
+ for (const cd of crossDims) {
873
+ const fk = row[cd.fkField];
874
+ resolved[cd.outputName] = cd.fkToAttr.has(fk) ? cd.fkToAttr.get(fk) : RESTRICTED_BUCKET;
875
+ }
876
+ const keyParts = [];
877
+ for (const f of baseDimFields) keyParts.push(`${f}=${JSON.stringify(row[f] ?? null)}`);
878
+ for (const cd of crossDims) keyParts.push(`${cd.outputName}=${String(resolved[cd.outputName])}`);
879
+ const key = keyParts.join("");
880
+ let bucket = buckets.get(key);
881
+ if (!bucket) {
882
+ bucket = {};
883
+ for (const f of baseDimFields) bucket[f] = row[f];
884
+ for (const cd of crossDims) bucket[cd.outputName] = resolved[cd.outputName];
885
+ buckets.set(key, bucket);
886
+ }
887
+ for (const m of measures) {
888
+ bucket[m.alias] = recombine(m.method, bucket[m.alias], row[m.alias]);
889
+ }
890
+ }
891
+ return [...buckets.values()];
892
+ }
893
+
894
+ // src/strategies/objectql-strategy.ts
895
+ var SCALAR_SQL_OPS = {
896
+ equals: "=",
897
+ notEquals: "!=",
898
+ gt: ">",
899
+ gte: ">=",
900
+ lt: "<",
901
+ lte: "<="
902
+ };
711
903
  var ObjectQLStrategy = class {
712
904
  constructor() {
713
905
  this.name = "ObjectQLStrategy";
@@ -745,15 +937,18 @@ var ObjectQLStrategy = class {
745
937
  }
746
938
  }
747
939
  const filter = {};
748
- const normalizedFilters = normalizeAnalyticsFilters(query);
749
- if (normalizedFilters.length > 0) {
750
- for (const f of normalizedFilters) {
751
- const fieldName = this.resolveFieldName(cube, f.member, "any");
752
- const converted = this.convertFilter(f.operator, f.values);
753
- const existing = filter[fieldName];
754
- const mergeable = (v) => !!v && typeof v === "object" && !Array.isArray(v);
755
- filter[fieldName] = mergeable(existing) && mergeable(converted) ? { ...existing, ...converted } : converted;
756
- }
940
+ const conjuncts = [];
941
+ this.applyFilterNode(normalizeAnalyticsFilterTree(query), cube, filter, conjuncts);
942
+ for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
943
+ const extra = this.mergeFilterOperand(filter, field, bounds);
944
+ if (extra) conjuncts.push(extra);
945
+ }
946
+ if (conjuncts.length > 0) {
947
+ filter.$and = [...Array.isArray(filter.$and) ? filter.$and : [], ...conjuncts];
948
+ }
949
+ const plan = this.planCrossObject(cube, query, filter);
950
+ if (plan) {
951
+ return this.executeCrossObject(cube, query, aggregations, filter, plan, ctx);
757
952
  }
758
953
  const rows = await ctx.executeAggregate(objectName, {
759
954
  // Structured groupBy items ({field, dateGranularity}) pass through the
@@ -761,19 +956,23 @@ var ObjectQLStrategy = class {
761
956
  // contract types groupBy as string[]; the cast carries the richer shape.
762
957
  groupBy: groupBy.length > 0 ? groupBy : void 0,
763
958
  aggregations: aggregations.length > 0 ? aggregations : void 0,
764
- filter: Object.keys(filter).length > 0 ? filter : void 0,
959
+ filter: this.withReadScope(objectName, filter, ctx),
765
960
  // ADR-0053 Phase 2 (D2): forward the reference tz so date buckets resolve
766
961
  // on that zone's calendar days. A non-UTC zone makes the engine bucket
767
962
  // in-memory (uniform across drivers); UTC/unset keeps the DB fast path.
768
- timezone: query.timezone
963
+ timezone: query.timezone,
964
+ // ADR-0021 D-C (#3602): the second belt. `withReadScope` above is this
965
+ // layer's own scoping; handing the engine the context makes ITS middleware
966
+ // inject RLS too, so a future strategy that forgets `withReadScope` still
967
+ // cannot read across tenants. Without it the operation reaches the engine
968
+ // principal-less and plugin-security falls open — the #3597 shape.
969
+ context: ctx.context
769
970
  });
770
971
  const mappedRows = rows.map((row) => {
771
972
  const mapped = {};
772
- if (query.dimensions) {
773
- for (const dim of query.dimensions) {
774
- const shortName = this.resolveFieldName(cube, dim, "dimension");
775
- if (shortName in row) mapped[dim] = row[shortName];
776
- }
973
+ for (const dim of this.projectedDimensions(query)) {
974
+ const shortName = this.resolveFieldName(cube, dim, "dimension");
975
+ if (shortName in row) mapped[dim] = row[shortName];
777
976
  }
778
977
  if (query.measures) {
779
978
  for (const m of query.measures) {
@@ -783,8 +982,28 @@ var ObjectQLStrategy = class {
783
982
  return mapped;
784
983
  });
785
984
  const fields = this.buildFieldMeta(query, cube);
786
- return { rows: mappedRows, fields };
985
+ let sql;
986
+ try {
987
+ sql = (await this.generateSql(query, ctx)).sql;
988
+ } catch {
989
+ sql = void 0;
990
+ }
991
+ return sql ? { rows: mappedRows, fields, sql } : { rows: mappedRows, fields };
787
992
  }
993
+ /**
994
+ * Render a REPRESENTATIVE SQL string for an ObjectQL aggregate query.
995
+ *
996
+ * This path executes through `engine.aggregate()`, not raw SQL, so the string
997
+ * is documentation rather than the literal statement — but it must be an
998
+ * honest account of what the query does, because dataset responses echo it
999
+ * and authors read it to verify their widget options landed (#3588). It
1000
+ * therefore renders date bucketing (`date_trunc`), the WHERE predicate,
1001
+ * ordering, and the row window.
1002
+ *
1003
+ * Filter VALUES are rendered as `$n` placeholders and returned in `params`,
1004
+ * never inlined: the echoed statement travels to the browser, and a filter
1005
+ * comparand can carry tenant data.
1006
+ */
788
1007
  async generateSql(query, ctx) {
789
1008
  const cube = ctx.getCube(query.cube);
790
1009
  if (!cube) {
@@ -792,28 +1011,302 @@ var ObjectQLStrategy = class {
792
1011
  }
793
1012
  const selectParts = [];
794
1013
  const groupByParts = [];
1014
+ const params = [];
1015
+ const granByDim = /* @__PURE__ */ new Map();
1016
+ for (const td of query.timeDimensions ?? []) {
1017
+ if (td.granularity) granByDim.set(td.dimension, td.granularity);
1018
+ }
1019
+ const tableName = this.extractObjectName(cube);
1020
+ const plan = this.planCrossObject(cube, query, Object.fromEntries(
1021
+ collectFilterLeaves(normalizeAnalyticsFilterTree(query)).map((f) => [this.resolveFieldName(cube, f.member, "any"), true])
1022
+ ));
1023
+ const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd]));
1024
+ const joinClauses = [];
1025
+ const dimExpr = (dim) => {
1026
+ const cd = crossByDim.get(dim);
1027
+ if (cd) {
1028
+ joinClauses.push(
1029
+ `LEFT JOIN "${cd.refObject}" ON "${tableName}"."${cd.fkField}" = "${cd.refObject}"."id"`
1030
+ );
1031
+ return `"${cd.refObject}"."${cd.attr}"`;
1032
+ }
1033
+ const col = this.resolveFieldName(cube, dim, "dimension");
1034
+ const gran = granByDim.get(dim);
1035
+ return gran ? `date_trunc('${gran}', ${col})` : col;
1036
+ };
795
1037
  if (query.dimensions) {
796
1038
  for (const dim of query.dimensions) {
797
- const col = this.resolveFieldName(cube, dim, "dimension");
798
- selectParts.push(`${col} AS "${dim}"`);
799
- groupByParts.push(col);
1039
+ const expr = dimExpr(dim);
1040
+ selectParts.push(`${expr} AS "${dim}"`);
1041
+ groupByParts.push(expr);
800
1042
  }
801
1043
  }
1044
+ for (const [dim] of granByDim) {
1045
+ if (query.dimensions?.includes(dim)) continue;
1046
+ const expr = dimExpr(dim);
1047
+ selectParts.push(`${expr} AS "${dim}"`);
1048
+ groupByParts.push(expr);
1049
+ }
802
1050
  if (query.measures) {
803
1051
  for (const m of query.measures) {
804
1052
  const { field, method } = this.resolveMeasureAggregation(cube, m);
805
- const aggSql = method === "count" ? "COUNT(*)" : `${method.toUpperCase()}(${field})`;
1053
+ const aggSql = method === "count" ? "COUNT(*)" : method === "count_distinct" ? `COUNT(DISTINCT ${field})` : `${method.toUpperCase()}(${field})`;
806
1054
  selectParts.push(`${aggSql} AS "${m}"`);
807
1055
  }
808
1056
  }
809
- const tableName = this.extractObjectName(cube);
1057
+ const whereParts = [];
1058
+ const filterClause = this.renderFilterNodeSql(
1059
+ normalizeAnalyticsFilterTree(query),
1060
+ cube,
1061
+ params
1062
+ );
1063
+ if (filterClause) whereParts.push(filterClause);
1064
+ for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
1065
+ const nextDay = (0, import_core2.nextUtcCalendarDay)(bounds.$lte);
1066
+ params.push(bounds.$gte, nextDay ?? bounds.$lte);
1067
+ whereParts.push(
1068
+ `(${field} >= $${params.length - 1} AND ${field} ${nextDay ? "<" : "<="} $${params.length})`
1069
+ );
1070
+ }
1071
+ const scope = ctx.getReadScope?.(tableName);
1072
+ if (scope != null) {
1073
+ const { sql: scopeSql, params: scopeParams } = compileScopedFilterToSql(scope, tableName);
1074
+ if (scopeSql) {
1075
+ let i = 0;
1076
+ const rendered = scopeSql.replace(/\?/g, () => {
1077
+ params.push(scopeParams[i++]);
1078
+ return `$${params.length}`;
1079
+ });
1080
+ whereParts.push(`(${rendered})`);
1081
+ }
1082
+ }
810
1083
  let sql = `SELECT ${selectParts.join(", ")} FROM "${tableName}"`;
1084
+ if (joinClauses.length > 0) sql += " " + joinClauses.join(" ");
1085
+ if (whereParts.length > 0) {
1086
+ sql += ` WHERE ${whereParts.join(" AND ")}`;
1087
+ }
811
1088
  if (groupByParts.length > 0) {
812
1089
  sql += ` GROUP BY ${groupByParts.join(", ")}`;
813
1090
  }
814
- return { sql, params: [] };
1091
+ if (query.order && Object.keys(query.order).length > 0) {
1092
+ const orderClauses = Object.entries(query.order).map(([f, d]) => `"${f}" ${d.toUpperCase()}`);
1093
+ sql += ` ORDER BY ${orderClauses.join(", ")}`;
1094
+ }
1095
+ if (query.limit != null) sql += ` LIMIT ${query.limit}`;
1096
+ if (query.offset != null) sql += ` OFFSET ${query.offset}`;
1097
+ return { sql, params };
815
1098
  }
816
1099
  // ── Helpers ──────────────────────────────────────────────────────
1100
+ /**
1101
+ * ADR-0021 D-C (#3597) — AND the object's read scope (tenant + RLS) into the
1102
+ * filter handed to `engine.aggregate`.
1103
+ *
1104
+ * This path used to drop the scope entirely, and the engine could not make up
1105
+ * for it: the aggregate bridge passes no `ExecutionContext`, so the security
1106
+ * middleware's principal-less fall-open skipped its own RLS injection. Both
1107
+ * belts were off at once — an authenticated caller received aggregates
1108
+ * computed over EVERY tenant's rows.
1109
+ *
1110
+ * Composed with `$and`, never by key merge: the query's own filter and the
1111
+ * scope can name the SAME field (e.g. a dashboard filtering `organization_id`),
1112
+ * and a spread would let caller input silently overwrite the security
1113
+ * predicate. `$and` makes that structurally impossible.
1114
+ */
1115
+ withReadScope(objectName, filter, ctx) {
1116
+ const userFilter = Object.keys(filter).length > 0 ? filter : void 0;
1117
+ if (typeof ctx.getReadScope !== "function") return userFilter;
1118
+ const scope = ctx.getReadScope(objectName);
1119
+ if (scope === void 0 || scope === null) return userFilter;
1120
+ const scopeFilter = scope;
1121
+ if (!userFilter) return scopeFilter;
1122
+ return { $and: [userFilter, scopeFilter] };
1123
+ }
1124
+ /** Is `field` a resolved cross-object (relationship-traversal) reference? */
1125
+ isCrossObjectField(cube, field, baseObject) {
1126
+ if (!field.includes(".")) return false;
1127
+ const alias = field.split(".")[0];
1128
+ const joinedObject = cube.joins?.[alias]?.name ?? alias;
1129
+ return joinedObject !== baseObject;
1130
+ }
1131
+ /**
1132
+ * Plan how to serve cross-object references on this join-less path (#3654).
1133
+ *
1134
+ * `engine.aggregate()` cannot join. A cross-object DIMENSION within a
1135
+ * supported envelope is served by an FK-expand (`executeCrossObject`): group
1136
+ * the base aggregate on the lookup FK, resolve the FK to the related attribute
1137
+ * with a SCOPED read, re-bucket in memory. Returns `null` for a base-only
1138
+ * query (direct path), a plan for an in-envelope cross-object query.
1139
+ *
1140
+ * THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
1141
+ * (needs a real join to evaluate), a MULTI-HOP dimension (`a.b.c`), or a
1142
+ * non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values
1143
+ * cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
1144
+ * `generateSql()` calls this too, so the preview accepts/rejects the same set.
1145
+ *
1146
+ * Detection is on RESOLVED field names, so a dotted dimension the cube
1147
+ * flattens to a real column is treated as base, not cross-object.
1148
+ */
1149
+ planCrossObject(cube, query, filter) {
1150
+ const baseObject = this.extractObjectName(cube);
1151
+ for (const td of query.timeDimensions ?? []) {
1152
+ const field = this.resolveFieldName(cube, td.dimension, "dimension");
1153
+ if (this.isCrossObjectField(cube, field, baseObject)) {
1154
+ throw new Error(
1155
+ `[Analytics] ObjectQLStrategy cannot bucket a cross-object time dimension ("${field}").`
1156
+ );
1157
+ }
1158
+ }
1159
+ const nonDim = [
1160
+ ...(query.measures ?? []).map((m) => ({ where: "measure", field: this.resolveMeasureAggregation(cube, m).field })),
1161
+ ...Object.keys(filter).map((f) => ({ where: "filter", field: f }))
1162
+ ].filter((r) => this.isCrossObjectField(cube, r.field, baseObject));
1163
+ if (nonDim.length > 0) {
1164
+ throw new Error(
1165
+ `[Analytics] ObjectQLStrategy cannot evaluate a cross-object ${nonDim[0].where} ("${nonDim[0].field}") \u2014 the engine cannot join in an aggregate. Run this query on a native-SQL driver, or remove the cross-object ${nonDim[0].where}.`
1166
+ );
1167
+ }
1168
+ const crossDims = [];
1169
+ for (const dim of query.dimensions ?? []) {
1170
+ const field = this.resolveFieldName(cube, dim, "dimension");
1171
+ if (!this.isCrossObjectField(cube, field, baseObject)) continue;
1172
+ const [alias, ...rest] = field.split(".");
1173
+ const attr = rest.join(".");
1174
+ if (attr.includes(".")) {
1175
+ throw new Error(
1176
+ `[Analytics] ObjectQLStrategy supports only single-hop cross-object dimensions; "${field}" traverses more than one relationship.`
1177
+ );
1178
+ }
1179
+ crossDims.push({ outputName: dim, fkField: alias, attr, refObject: cube.joins?.[alias]?.name ?? alias });
1180
+ }
1181
+ if (crossDims.length === 0) return null;
1182
+ for (const m of query.measures ?? []) {
1183
+ const { method } = this.resolveMeasureAggregation(cube, m);
1184
+ if (!RECOMBINABLE_METHODS.has(method)) {
1185
+ throw new Error(
1186
+ `[Analytics] ObjectQLStrategy cannot group by a cross-object dimension with a "${method}" measure ("${m}") \u2014 its value cannot be recombined across the intermediate FK grouping. Use sum/count/min/max, or run on a native-SQL driver.`
1187
+ );
1188
+ }
1189
+ }
1190
+ return { crossDims };
1191
+ }
1192
+ /**
1193
+ * Serve a cross-object-dimension query by FK-expand (#3654). The pure
1194
+ * re-bucketing step lives in `cross-object-rebucket.ts`.
1195
+ */
1196
+ async executeCrossObject(cube, query, aggregations, filter, plan, ctx) {
1197
+ const baseObject = this.extractObjectName(cube);
1198
+ const crossByDim = new Map(plan.crossDims.map((cd) => [cd.outputName, cd]));
1199
+ const granByDim = /* @__PURE__ */ new Map();
1200
+ for (const td of query.timeDimensions ?? []) {
1201
+ if (td.granularity) granByDim.set(td.dimension, td.granularity);
1202
+ }
1203
+ const groupBy = [];
1204
+ const baseDimFields = [];
1205
+ for (const dim of query.dimensions ?? []) {
1206
+ const cd = crossByDim.get(dim);
1207
+ if (cd) {
1208
+ groupBy.push(cd.fkField);
1209
+ continue;
1210
+ }
1211
+ const field = this.resolveFieldName(cube, dim, "dimension");
1212
+ const gran = granByDim.get(dim);
1213
+ groupBy.push(gran ? { field, dateGranularity: gran } : field);
1214
+ baseDimFields.push(field);
1215
+ granByDim.delete(dim);
1216
+ }
1217
+ for (const [dim, gran] of granByDim) {
1218
+ const field = this.resolveFieldName(cube, dim, "dimension");
1219
+ groupBy.push({ field, dateGranularity: gran });
1220
+ baseDimFields.push(field);
1221
+ }
1222
+ const baseRows = await ctx.executeAggregate(baseObject, {
1223
+ groupBy: groupBy.length > 0 ? groupBy : void 0,
1224
+ aggregations: aggregations.length > 0 ? aggregations : void 0,
1225
+ filter: this.withReadScope(baseObject, filter, ctx),
1226
+ timezone: query.timezone,
1227
+ context: ctx.context
1228
+ });
1229
+ const resolvedDims = [];
1230
+ for (const cd of plan.crossDims) {
1231
+ const fkValues = [...new Set(baseRows.map((r) => r[cd.fkField]).filter((v) => v != null))];
1232
+ const fkToAttr = await this.resolveFkAttr(cd.refObject, cd.attr, fkValues, ctx);
1233
+ resolvedDims.push({ outputName: cd.outputName, fkField: cd.fkField, fkToAttr });
1234
+ }
1235
+ const measures = (query.measures ?? []).map((m) => ({
1236
+ alias: m,
1237
+ // planCrossObject already asserted every measure is recombinable.
1238
+ method: this.resolveMeasureAggregation(cube, m).method
1239
+ }));
1240
+ const merged = rebucketCrossObject(baseRows, baseDimFields, resolvedDims, measures);
1241
+ const mappedRows = merged.map((row) => {
1242
+ const out = {};
1243
+ for (const dim of this.projectedDimensions(query)) {
1244
+ if (crossByDim.has(dim)) {
1245
+ if (dim in row) out[dim] = row[dim];
1246
+ } else {
1247
+ const field = this.resolveFieldName(cube, dim, "dimension");
1248
+ if (field in row) out[dim] = row[field];
1249
+ }
1250
+ }
1251
+ for (const m of query.measures ?? []) {
1252
+ if (m in row) out[m] = row[m];
1253
+ }
1254
+ return out;
1255
+ });
1256
+ return { rows: mappedRows, fields: this.buildFieldMeta(query, cube) };
1257
+ }
1258
+ /**
1259
+ * Resolve `fkValues` (ids of `refObject`) to their `attr` values, applying the
1260
+ * referenced object's OWN read scope (#3654 / #3602). Reuses the aggregate
1261
+ * bridge — `group by (id, attr)` is one row per record. Ids the scope hides
1262
+ * are simply absent from the map (⇒ RESTRICTED bucket downstream).
1263
+ */
1264
+ async resolveFkAttr(refObject, attr, fkValues, ctx) {
1265
+ const map = /* @__PURE__ */ new Map();
1266
+ if (fkValues.length === 0 || typeof ctx.executeAggregate !== "function") return map;
1267
+ const idFilter = { id: { $in: fkValues } };
1268
+ const scope = typeof ctx.getReadScope === "function" ? ctx.getReadScope(refObject) : null;
1269
+ const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
1270
+ const rows = await ctx.executeAggregate(refObject, {
1271
+ groupBy: ["id", attr],
1272
+ aggregations: [{ field: "id", method: "count", alias: "_c" }],
1273
+ filter,
1274
+ context: ctx.context
1275
+ });
1276
+ for (const r of rows) {
1277
+ if (r.id != null) map.set(r.id, r[attr]);
1278
+ }
1279
+ return map;
1280
+ }
1281
+ /**
1282
+ * Render one normalized filter as a display SQL predicate for `generateSql`.
1283
+ *
1284
+ * Mirrors `NativeSQLStrategy.buildFilterClause`'s operator vocabulary so the
1285
+ * two previews read alike, but binds through `coerceFilterValueForObjectQL`:
1286
+ * the comparand shown is the one THIS path actually hands the engine (a real
1287
+ * boolean, not SQL's 1/0). Returns null for an operator/value combination
1288
+ * that carries no predicate, matching `execute()`, which drops it too.
1289
+ */
1290
+ buildFilterClauseSql(col, operator, values, params) {
1291
+ if (operator === "set") return `${col} IS NOT NULL`;
1292
+ if (operator === "notSet") return `${col} IS NULL`;
1293
+ if (!values || values.length === 0) return null;
1294
+ if (operator === "in" || operator === "notIn") {
1295
+ const placeholders = values.map((v) => {
1296
+ params.push(coerceFilterValueForObjectQL(v));
1297
+ return `$${params.length}`;
1298
+ }).join(", ");
1299
+ return `${col} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
1300
+ }
1301
+ if (operator === "contains" || operator === "notContains") {
1302
+ params.push(`%${values[0]}%`);
1303
+ return `${col} ${operator === "contains" ? "LIKE" : "NOT LIKE"} $${params.length}`;
1304
+ }
1305
+ const op = SCALAR_SQL_OPS[operator];
1306
+ if (!op) return null;
1307
+ params.push(coerceFilterValueForObjectQL(values[0]));
1308
+ return `${col} ${op} $${params.length}`;
1309
+ }
817
1310
  /**
818
1311
  * Resolve a member ref to a `{ sql, type? }` definition.
819
1312
  *
@@ -878,6 +1371,162 @@ var ObjectQLStrategy = class {
878
1371
  }
879
1372
  return { field: "*", method: "count" };
880
1373
  }
1374
+ /**
1375
+ * AND one more operand onto `filter[field]`, merging operator objects rather
1376
+ * than overwriting them. Returns a standalone conjunct when the two cannot
1377
+ * share one entry, or `null` when the merge absorbed the operand.
1378
+ *
1379
+ * Every predicate this strategy contributes goes through here — the caller's
1380
+ * `where` and the time-dimension `dateRange` alike. Two operands on one field
1381
+ * are the normal case (`{$gte}` from a `where` plus `{$gte,$lte}` from a
1382
+ * window on `close_date`), and a plain assignment would keep only the last:
1383
+ * that is how a range used to lose a bound.
1384
+ *
1385
+ * Spreading is sound only while the operands name DIFFERENT operators. Where
1386
+ * they collide — two `$gte` bounds on one field, which a window makes routine
1387
+ * and which a `where` can already produce on its own through `$and` — the
1388
+ * spread keeps whichever came last and WIDENS the query. Same for a bare
1389
+ * equality meeting an operator object: neither can absorb the other. Those
1390
+ * are handed back for the caller to AND in separately, so the engine
1391
+ * intersects them instead of the strategy picking a winner.
1392
+ */
1393
+ /**
1394
+ * Fold a normalized filter node into the engine filter being built.
1395
+ *
1396
+ * AND-ed LEAVES merge per field through {@link mergeFilterOperand}, exactly
1397
+ * as the flat loop this replaced did — so a query without combinators still
1398
+ * produces byte-identical engine input. Anything structural (`$or`, `$not`,
1399
+ * a nested `$and` that cannot merge) becomes its own conjunct, which the
1400
+ * caller ANDs in. The engine speaks these combinators natively
1401
+ * (`FilterCondition` declares them and every driver compiles them), so this
1402
+ * path hands them over rather than lowering them.
1403
+ */
1404
+ applyFilterNode(node, cube, filter, conjuncts) {
1405
+ if (!node) return;
1406
+ if (node.kind === "leaf") {
1407
+ const fieldName = this.resolveFieldName(cube, node.member, "any");
1408
+ const extra = this.mergeFilterOperand(filter, fieldName, this.convertFilter(node.operator, node.values));
1409
+ if (extra) conjuncts.push(extra);
1410
+ return;
1411
+ }
1412
+ if (node.kind === "and") {
1413
+ for (const child of node.children) this.applyFilterNode(child, cube, filter, conjuncts);
1414
+ return;
1415
+ }
1416
+ const rendered = this.filterNodeToCondition(node, cube);
1417
+ if (rendered) conjuncts.push(rendered);
1418
+ }
1419
+ /** A node as a standalone `FilterCondition` the engine can consume. */
1420
+ filterNodeToCondition(node, cube) {
1421
+ if (!node) return null;
1422
+ if (node.kind === "not") {
1423
+ const inner = this.filterNodeToCondition(node.child, cube);
1424
+ return inner ? { $not: inner } : null;
1425
+ }
1426
+ if (node.kind === "or") {
1427
+ const branches = node.children.map((child) => this.filterNodeToCondition(child, cube)).filter((c) => !!c);
1428
+ return branches.length > 0 ? { $or: branches } : null;
1429
+ }
1430
+ const filter = {};
1431
+ const conjuncts = [];
1432
+ this.applyFilterNode(node, cube, filter, conjuncts);
1433
+ if (conjuncts.length > 0) {
1434
+ filter.$and = [...Array.isArray(filter.$and) ? filter.$and : [], ...conjuncts];
1435
+ }
1436
+ return Object.keys(filter).length > 0 ? filter : null;
1437
+ }
1438
+ /**
1439
+ * Render a normalized filter node as the display SQL `/analytics/sql`
1440
+ * echoes. Values still bind as `$n` placeholders — the echo travels to the
1441
+ * browser, so a comparand is never inlined.
1442
+ */
1443
+ renderFilterNodeSql(node, cube, params) {
1444
+ if (!node) return null;
1445
+ if (node.kind === "leaf") {
1446
+ return this.buildFilterClauseSql(
1447
+ this.resolveFieldName(cube, node.member, "any"),
1448
+ node.operator,
1449
+ node.values,
1450
+ params
1451
+ );
1452
+ }
1453
+ if (node.kind === "not") {
1454
+ const inner = this.renderFilterNodeSql(node.child, cube, params);
1455
+ return inner ? `NOT (${inner})` : null;
1456
+ }
1457
+ const parts = node.children.map((child) => this.renderFilterNodeSql(child, cube, params)).filter((s) => !!s);
1458
+ if (parts.length === 0) return null;
1459
+ if (parts.length === 1) return parts[0];
1460
+ return `(${parts.join(node.kind === "or" ? " OR " : " AND ")})`;
1461
+ }
1462
+ mergeFilterOperand(filter, field, operand) {
1463
+ const existing = filter[field];
1464
+ if (existing === void 0) {
1465
+ filter[field] = operand;
1466
+ return null;
1467
+ }
1468
+ const mergeable = (v) => !!v && typeof v === "object" && !Array.isArray(v);
1469
+ if (!mergeable(existing) || !mergeable(operand)) return { [field]: operand };
1470
+ if (Object.keys(operand).some((op) => op in existing)) return { [field]: operand };
1471
+ filter[field] = { ...existing, ...operand };
1472
+ return null;
1473
+ }
1474
+ /**
1475
+ * Lower `timeDimensions[].dateRange` into resolved-field bounds (#3650).
1476
+ *
1477
+ * `dateRange` states a WINDOW on a time dimension; it is a SIBLING of `where`,
1478
+ * never folded into it. `normalizeAnalyticsFilters` reads only `where`, so
1479
+ * this path used to drop the window on the floor — no error, just every row
1480
+ * ever recorded. Nor is that a corner case: `NativeSQLStrategy.canHandle`
1481
+ * declines any query carrying a `granularity`, so a date-bucketed trend lands
1482
+ * HERE on every driver — and "bucketed trend" is precisely the shape that also
1483
+ * carries a range ("last 12 months", "this quarter").
1484
+ *
1485
+ * Bounds are inclusive on both ends — logically "from day X through day Y".
1486
+ * The `$lte` end is left as the bare calendar day on purpose: the driver's
1487
+ * filter compiler owns the calendar-day → instant translation, compiling a
1488
+ * bare-day `$lte` on a `datetime` column into the half-open `< nextDay`
1489
+ * (#3777) while a `date` column keeps the plain `<=`. `NativeSQLStrategy`
1490
+ * performs the same half-open translation itself because it binds into raw
1491
+ * SQL, so one dashboard reads the same on every driver.
1492
+ *
1493
+ * Comparands are coerced by the SAME helper the `where` path uses, so an
1494
+ * epoch-ms bound recovers as a number and an ISO string stays a string. No
1495
+ * STORAGE coercion happens here, deliberately: `NativeSQLStrategy` needs
1496
+ * `coerceTemporal` because it binds into raw SQL and had to learn that a
1497
+ * SQLite `Field.datetime` is an INTEGER epoch (#2034); this path goes through
1498
+ * `engine.aggregate()`, where the driver's own CRUD filter coercion applies —
1499
+ * the very coercion that already makes a `where` bound on that same column
1500
+ * work today.
1501
+ *
1502
+ * A bare-string `dateRange` degenerates to the single point `[s, s]`, matching
1503
+ * `NativeSQLStrategy`. Relative phrases ("Last 7 days") are NOT resolved here;
1504
+ * neither SQL path resolves them, and inventing a second interpretation on the
1505
+ * driver-independent path is how the two would drift apart again.
1506
+ *
1507
+ * An oddly-sized array (the schema types `dateRange` as a plain `string[]`)
1508
+ * takes its first two entries, a one-entry array degenerating to a point.
1509
+ * `NativeSQLStrategy` drops such a window entirely — but "drop the window"
1510
+ * means "plot all of history", which is the very failure this fixes, so the
1511
+ * fallback here errs toward the narrower query instead.
1512
+ */
1513
+ dateRangeBounds(cube, query) {
1514
+ const out = [];
1515
+ for (const td of query.timeDimensions ?? []) {
1516
+ if (!td.dateRange) continue;
1517
+ const range = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
1518
+ const [start, end = start] = range;
1519
+ if (start == null) continue;
1520
+ out.push({
1521
+ field: this.resolveFieldName(cube, td.dimension, "dimension"),
1522
+ bounds: {
1523
+ $gte: coerceFilterValueForObjectQL(String(start)),
1524
+ $lte: coerceFilterValueForObjectQL(String(end))
1525
+ }
1526
+ });
1527
+ }
1528
+ return out;
1529
+ }
881
1530
  convertFilter(operator, values) {
882
1531
  if (operator === "set") return { $ne: null };
883
1532
  if (operator === "notSet") return null;
@@ -899,24 +1548,60 @@ var ObjectQLStrategy = class {
899
1548
  return { $lte: v0 };
900
1549
  case "contains":
901
1550
  return { $regex: values[0] };
1551
+ // `notContains` had no arm and fell to the `default` below, which returns
1552
+ // a BARE VALUE — i.e. `{field: 'x'}`, an equality. "does not contain x"
1553
+ // was compiled as "equals x". These three pass through as the canonical
1554
+ // spec operators every driver implements directly, so an anchored match
1555
+ // stays anchored rather than depending on regex dialect (#4128).
1556
+ case "notContains":
1557
+ return { $notContains: values[0] };
1558
+ case "startsWith":
1559
+ return { $startsWith: values[0] };
1560
+ case "endsWith":
1561
+ return { $endsWith: values[0] };
902
1562
  case "in":
903
1563
  return { $in: all };
904
1564
  case "notIn":
905
1565
  return { $nin: all };
906
1566
  default:
907
- return v0;
1567
+ throw new Error(
1568
+ `[analytics] ObjectQL strategy cannot express filter operator "${operator}". Treating it as an equality would silently query something the author did not ask for.`
1569
+ );
908
1570
  }
909
1571
  }
910
1572
  extractObjectName(cube) {
911
1573
  return cube.sql.trim();
912
1574
  }
1575
+ /**
1576
+ * The dimensions this query PROJECTS, in the order the result carries them:
1577
+ * every `dimensions` entry, then every granular `timeDimensions` entry that
1578
+ * is not already one of them.
1579
+ *
1580
+ * `timeDimensions` is not merely a filter carrier. An entry with a
1581
+ * `granularity` is GROUPED BY — see the `td.granularity` sites that build
1582
+ * groupBy here, in `generateSql` and in the cross-object path — so its
1583
+ * bucket is a COLUMN of the result; an entry without one only contributes a
1584
+ * `dateRange` predicate and must NOT be projected.
1585
+ *
1586
+ * Grouping, row mapping and {@link buildFieldMeta} have to agree on exactly
1587
+ * that set. When they did not, a bucketed query returned rows carrying only
1588
+ * the measures and a `fields` list that never mentioned the bucket — a trend
1589
+ * chart got N values and no x-axis (#4033) — even though the SQL had
1590
+ * selected `date_trunc(…) AS "<dim>"` all along. One definition, every
1591
+ * consumer.
1592
+ */
1593
+ projectedDimensions(query) {
1594
+ const out = [...query.dimensions ?? []];
1595
+ for (const td of query.timeDimensions ?? []) {
1596
+ if (td.granularity && !out.includes(td.dimension)) out.push(td.dimension);
1597
+ }
1598
+ return out;
1599
+ }
913
1600
  buildFieldMeta(query, cube) {
914
1601
  const fields = [];
915
- if (query.dimensions) {
916
- for (const dim of query.dimensions) {
917
- const d = this.lookupMember(cube, dim, "dimension");
918
- fields.push({ name: dim, type: d?.type || "string" });
919
- }
1602
+ for (const dim of this.projectedDimensions(query)) {
1603
+ const d = this.lookupMember(cube, dim, "dimension");
1604
+ fields.push({ name: dim, type: d?.type || "string" });
920
1605
  }
921
1606
  if (query.measures) {
922
1607
  for (const m of query.measures) {
@@ -928,14 +1613,16 @@ var ObjectQLStrategy = class {
928
1613
  };
929
1614
 
930
1615
  // src/dataset-compiler.ts
1616
+ var import_data = require("@objectstack/spec/data");
931
1617
  var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set(["array_agg", "string_agg"]);
1618
+ var SUPPORTED_AGGREGATES = import_data.AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
932
1619
  function aggregateToMetricType(m) {
933
1620
  if (!m.aggregate) {
934
1621
  throw new Error(`[dataset-compiler] non-derived measure "${m.name}" has no aggregate`);
935
1622
  }
936
1623
  if (UNSUPPORTED_AGGREGATES.has(m.aggregate)) {
937
1624
  throw new Error(
938
- `[dataset-compiler] measure "${m.name}" uses aggregate "${m.aggregate}" which is not supported by the v1 dataset runtime (supported: count, sum, avg, min, max, count_distinct).`
1625
+ `[dataset-compiler] measure "${m.name}" uses aggregate "${m.aggregate}" which is not supported by the v1 dataset runtime (supported: ${SUPPORTED_AGGREGATES.join(", ")}).`
939
1626
  );
940
1627
  }
941
1628
  return m.aggregate;
@@ -1062,6 +1749,23 @@ function compileDataset(dataset, resolver) {
1062
1749
  }
1063
1750
 
1064
1751
  // src/dataset-executor.ts
1752
+ var import_core3 = require("@objectstack/core");
1753
+ function resolveSelectionTokens(compiled, selection, context) {
1754
+ const tokenCtx = (0, import_core3.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
1755
+ const resolve = (v) => (0, import_core3.resolveFilterTokens)(v, tokenCtx);
1756
+ const filter = resolve(compiled.filter);
1757
+ const measureFilters = resolve(compiled.measureFilters);
1758
+ const runtimeFilter = resolve(selection.runtimeFilter);
1759
+ const timeDimensions = selection.timeDimensions?.map(
1760
+ (td) => td.dateRange == null ? td : { ...td, dateRange: resolve(td.dateRange) }
1761
+ );
1762
+ const compiledChanged = filter !== compiled.filter || measureFilters !== compiled.measureFilters;
1763
+ const selectionChanged = runtimeFilter !== selection.runtimeFilter || timeDimensions !== void 0 && timeDimensions.some((td, i) => td !== selection.timeDimensions[i]);
1764
+ return {
1765
+ compiled: compiledChanged ? { ...compiled, filter, measureFilters } : compiled,
1766
+ selection: selectionChanged ? { ...selection, runtimeFilter, timeDimensions } : selection
1767
+ };
1768
+ }
1065
1769
  function combineFilters(a, b) {
1066
1770
  if (a && b) return { $and: [a, b] };
1067
1771
  return a ?? b;
@@ -1100,6 +1804,76 @@ function computeDerived(d, row) {
1100
1804
  return null;
1101
1805
  }
1102
1806
  }
1807
+ function resolveDimensionGranularity(selection, dimension, datasetDefault) {
1808
+ const stated = (selection.timeDimensions ?? []).find((t) => t.dimension === dimension)?.granularity;
1809
+ if (stated) return stated;
1810
+ return selection.dateGranularity ?? datasetDefault;
1811
+ }
1812
+ function compareValues(a, b) {
1813
+ const aNull = a == null || a === "";
1814
+ const bNull = b == null || b === "";
1815
+ if (aNull || bNull) return aNull && bNull ? 0 : aNull ? 1 : -1;
1816
+ if (a instanceof Date || b instanceof Date) {
1817
+ return Number(a instanceof Date ? a.getTime() : a) - Number(b instanceof Date ? b.getTime() : b);
1818
+ }
1819
+ if (typeof a === "boolean" || typeof b === "boolean") {
1820
+ return Number(a) - Number(b);
1821
+ }
1822
+ const an = typeof a === "number" ? a : Number(a);
1823
+ const bn = typeof b === "number" ? b : Number(b);
1824
+ if (Number.isFinite(an) && Number.isFinite(bn)) return an - bn;
1825
+ return String(a).localeCompare(String(b));
1826
+ }
1827
+ function applyOrdering(rows, order, sortKeys) {
1828
+ const keys = Object.entries(order ?? {});
1829
+ if (keys.length === 0 || rows.length < 2) return rows;
1830
+ return [...rows].sort((ra, rb) => {
1831
+ for (const [key, dir] of keys) {
1832
+ const map = sortKeys?.[key];
1833
+ const av = map?.get(ra[key]) ?? ra[key];
1834
+ const bv = map?.get(rb[key]) ?? rb[key];
1835
+ const aNull = av == null || av === "";
1836
+ const bNull = bv == null || bv === "";
1837
+ if (aNull || bNull) {
1838
+ if (aNull && bNull) continue;
1839
+ return aNull ? 1 : -1;
1840
+ }
1841
+ const c = compareValues(av, bv);
1842
+ if (c !== 0) return dir === "desc" ? -c : c;
1843
+ }
1844
+ return 0;
1845
+ });
1846
+ }
1847
+ function applyWindow(rows, limit, offset) {
1848
+ const start = offset != null && offset > 0 ? offset : 0;
1849
+ if (start === 0 && limit == null) return rows;
1850
+ return rows.slice(start, limit != null ? start + limit : void 0);
1851
+ }
1852
+ function resolveOrdering(selection, dimensions, timeDimensions = []) {
1853
+ const order = selection.order;
1854
+ if (order && Object.keys(order).length > 0) {
1855
+ const selectable = /* @__PURE__ */ new Set([
1856
+ ...dimensions,
1857
+ ...selection.measures,
1858
+ ...selection.measures.map((m) => `${m}__compare`)
1859
+ ]);
1860
+ const unknown = Object.keys(order).filter((k) => !selectable.has(k));
1861
+ if (unknown.length) {
1862
+ throw new Error(
1863
+ `[dataset-executor] order key(s) ${unknown.map((k) => `"${k}"`).join(", ")} \u2014 not a selected dimension or measure. Selectable here: ${[...selectable].join(", ") || "(none)"}.`
1864
+ );
1865
+ }
1866
+ return order;
1867
+ }
1868
+ if ((selection.limit != null || selection.offset != null) && dimensions.length > 0) {
1869
+ return Object.fromEntries(dimensions.map((d) => [d, "asc"]));
1870
+ }
1871
+ const timeKeys = timeDimensions.filter((d) => dimensions.includes(d));
1872
+ if (timeKeys.length > 0) {
1873
+ return Object.fromEntries(timeKeys.map((d) => [d, "asc"]));
1874
+ }
1875
+ return void 0;
1876
+ }
1103
1877
  function parseUTC(date) {
1104
1878
  const ms = Date.parse(date.length === 10 ? `${date}T00:00:00Z` : date);
1105
1879
  if (Number.isNaN(ms)) throw new Error(`[dataset-executor] invalid date in dateRange: "${date}"`);
@@ -1127,8 +1901,17 @@ function shiftRange(range, kind) {
1127
1901
  return [toISODate(prevStartMs), toISODate(prevEndMs)];
1128
1902
  }
1129
1903
  var DatasetExecutor = class {
1130
- constructor(service) {
1904
+ /**
1905
+ * @param service - The analytics service the executor issues its queries to.
1906
+ * @param orderLabels - Optional sort-key label hook (#3680). When provided,
1907
+ * an order key naming a label-bearing (`select`/`lookup`) dimension sorts
1908
+ * by its display label instead of the stored value. Omit to sort by stored
1909
+ * values everywhere (e.g. the draft-preview path, whose seed rows already
1910
+ * carry display names).
1911
+ */
1912
+ constructor(service, orderLabels) {
1131
1913
  this.service = service;
1914
+ this.orderLabels = orderLabels;
1132
1915
  }
1133
1916
  /**
1134
1917
  * Execute a dataset selection and return the shaped rows (+ field metadata).
@@ -1137,7 +1920,8 @@ var DatasetExecutor = class {
1137
1920
  * underlying `IAnalyticsService.query` so the tenant/RLS read scope is
1138
1921
  * applied per request (ADR-0021 D-C).
1139
1922
  */
1140
- async execute(compiled, selection, context) {
1923
+ async execute(compiledInput, selectionInput, context) {
1924
+ const { compiled, selection } = resolveSelectionTokens(compiledInput, selectionInput, context);
1141
1925
  const result = await this.executeSelection(compiled, selection, context);
1142
1926
  const groupings = selection.totals?.groupings;
1143
1927
  if (groupings?.length) {
@@ -1181,6 +1965,14 @@ var DatasetExecutor = class {
1181
1965
  }
1182
1966
  const baseFilter = combineFilters(compiled.filter, selection.runtimeFilter);
1183
1967
  const dimensions = selection.dimensions ?? [];
1968
+ const order = resolveOrdering(selection, dimensions, this.timeDimensionsOf(compiled, dimensions));
1969
+ const labelOrderKeys = this.orderLabels ? Object.keys(order ?? {}).filter(
1970
+ (k) => dimensions.includes(k) && this.orderLabels.isLabelBearing(k)
1971
+ ) : [];
1972
+ const singleQuery = filtered.length === 0 && !selection.compareTo && selectedDerived.length === 0;
1973
+ const pushDownKeys = /* @__PURE__ */ new Set([...dimensions, ...unfiltered]);
1974
+ const canPushDownWindow = singleQuery && labelOrderKeys.length === 0 && Object.keys(order ?? {}).every((k) => pushDownKeys.has(k));
1975
+ const windowQuery = canPushDownWindow ? { order, limit: selection.limit, offset: selection.offset } : void 0;
1184
1976
  let result;
1185
1977
  if (unfiltered.length > 0 || filtered.length === 0) {
1186
1978
  result = await this.service.query(this.buildQuery(compiled, {
@@ -1188,7 +1980,8 @@ var DatasetExecutor = class {
1188
1980
  dimensions,
1189
1981
  where: baseFilter,
1190
1982
  selection,
1191
- contextTimezone: context?.timezone
1983
+ contextTimezone: context?.timezone,
1984
+ window: windowQuery
1192
1985
  }), context);
1193
1986
  } else {
1194
1987
  result = { rows: [], fields: [] };
@@ -1217,8 +2010,30 @@ var DatasetExecutor = class {
1217
2010
  }
1218
2011
  result.rows = evaluateDerivedMeasures(result.rows, selectedDerived);
1219
2012
  for (const d of selectedDerived) result.fields.push({ name: d.name, type: "number" });
2013
+ let sortKeys;
2014
+ for (const key of labelOrderKeys) {
2015
+ const values = [...new Set(result.rows.map((r) => r[key]).filter((v) => v != null))];
2016
+ if (values.length === 0) continue;
2017
+ const labels = await this.orderLabels.resolveLabels(key, values);
2018
+ if (labels && labels.size > 0) (sortKeys ?? (sortKeys = {}))[key] = labels;
2019
+ }
2020
+ result.rows = applyOrdering(result.rows, order, sortKeys);
2021
+ result.rows = applyWindow(result.rows, selection.limit, selection.offset);
1220
2022
  return result;
1221
2023
  }
2024
+ /**
2025
+ * The selected dimensions the compiled cube types as `time`, in selection
2026
+ * order (#3916) — the axis {@link resolveOrdering} defaults to ascending.
2027
+ *
2028
+ * Membership is decided by the DIMENSION's declared type, not by whether the
2029
+ * selection happens to bucket it: a `date` dimension left ungranulated groups
2030
+ * raw timestamps, and those want chronological order every bit as much as
2031
+ * month buckets do. (Both sort correctly — `compareValues` compares Dates and
2032
+ * ISO strings chronologically, and bucket keys are minted sort-stable.)
2033
+ */
2034
+ timeDimensionsOf(compiled, dimensions) {
2035
+ return dimensions.filter((d) => compiled.cube.dimensions[d]?.type === "time");
2036
+ }
1222
2037
  buildQuery(compiled, opts) {
1223
2038
  const q = {
1224
2039
  cube: compiled.cube.name,
@@ -1231,18 +2046,28 @@ var DatasetExecutor = class {
1231
2046
  if (opts.where) q.where = opts.where;
1232
2047
  const selTimeDims = opts.selection.timeDimensions ?? [];
1233
2048
  const selDims = new Set(selTimeDims.map((t) => t.dimension));
2049
+ const granularityFor = (name) => {
2050
+ const cd = compiled.cube.dimensions[name];
2051
+ if (cd?.type !== "time") return void 0;
2052
+ const datasetDefault = cd.granularities?.length === 1 ? String(cd.granularities[0]) : void 0;
2053
+ return resolveDimensionGranularity(opts.selection, name, datasetDefault);
2054
+ };
2055
+ const resolvedTimeDims = selTimeDims.map((t) => {
2056
+ if (t.granularity) return t;
2057
+ const granularity = granularityFor(t.dimension);
2058
+ return granularity ? { ...t, granularity } : t;
2059
+ });
1234
2060
  const explicitTimeDims = [];
1235
2061
  for (const name of opts.dimensions) {
1236
- const cd = compiled.cube.dimensions[name];
1237
- if (cd?.type === "time" && cd.granularities?.length === 1 && !selDims.has(name)) {
1238
- explicitTimeDims.push({ dimension: name, granularity: String(cd.granularities[0]) });
1239
- }
2062
+ if (selDims.has(name)) continue;
2063
+ const granularity = granularityFor(name);
2064
+ if (granularity) explicitTimeDims.push({ dimension: name, granularity });
1240
2065
  }
1241
- const mergedTimeDims = [...selTimeDims, ...explicitTimeDims];
2066
+ const mergedTimeDims = [...resolvedTimeDims, ...explicitTimeDims];
1242
2067
  if (mergedTimeDims.length > 0) q.timeDimensions = mergedTimeDims;
1243
- if (opts.selection.order) q.order = opts.selection.order;
1244
- if (opts.selection.limit != null) q.limit = opts.selection.limit;
1245
- if (opts.selection.offset != null) q.offset = opts.selection.offset;
2068
+ if (opts.window?.order && Object.keys(opts.window.order).length > 0) q.order = opts.window.order;
2069
+ if (opts.window?.limit != null) q.limit = opts.window.limit;
2070
+ if (opts.window?.offset != null) q.offset = opts.window.offset;
1246
2071
  return q;
1247
2072
  }
1248
2073
  async runCompare(compiled, selection, measures, dimensions, baseFilter, context) {
@@ -1258,14 +2083,13 @@ var DatasetExecutor = class {
1258
2083
  const shiftedTd = (selection.timeDimensions ?? []).map(
1259
2084
  (t) => t.dimension === cmp.dimension ? { ...t, dateRange: shifted } : t
1260
2085
  );
1261
- const sub = await this.service.query({
1262
- cube: compiled.cube.name,
2086
+ const sub = await this.service.query(this.buildQuery(compiled, {
1263
2087
  measures,
1264
2088
  dimensions,
1265
2089
  where: baseFilter,
1266
- timeDimensions: shiftedTd,
1267
- timezone: selection.timezone ?? context?.timezone ?? "UTC"
1268
- }, context);
2090
+ selection: { ...selection, timeDimensions: shiftedTd },
2091
+ contextTimezone: context?.timezone
2092
+ }), context);
1269
2093
  return sub.rows.map((row) => {
1270
2094
  const out = {};
1271
2095
  for (const dim of dimensions) out[dim] = row[dim];
@@ -1296,11 +2120,77 @@ function mergeByDimensions(base, extra, dimensions, valueColumns) {
1296
2120
 
1297
2121
  // src/dimension-labels.ts
1298
2122
  var LOOKUP_TYPES = /* @__PURE__ */ new Set(["lookup", "master_detail"]);
2123
+ function createOrderLabelResolver(baseObject, dims, deps, resolveScope, context) {
2124
+ const dimByName = new Map(dims.map((d) => [d.name, d]));
2125
+ const metaFor = (dimension) => {
2126
+ const dim = dimByName.get(dimension);
2127
+ return dim ? deps.getObjectFields(baseObject)?.[dim.field] : void 0;
2128
+ };
2129
+ return {
2130
+ isLabelBearing(dimension) {
2131
+ const meta = metaFor(dimension);
2132
+ if (!meta) return false;
2133
+ if (Array.isArray(meta.options) && meta.options.length > 0) return true;
2134
+ return !!(meta.type && LOOKUP_TYPES.has(meta.type) && meta.reference);
2135
+ },
2136
+ async resolveLabels(dimension, values) {
2137
+ const meta = metaFor(dimension);
2138
+ if (!meta) return void 0;
2139
+ if (Array.isArray(meta.options) && meta.options.length > 0) {
2140
+ const labelByValue = /* @__PURE__ */ new Map();
2141
+ for (const opt of meta.options) {
2142
+ if (opt && opt.label != null) labelByValue.set(opt.value, String(opt.label));
2143
+ }
2144
+ return labelByValue;
2145
+ }
2146
+ if (meta.type && LOOKUP_TYPES.has(meta.type) && meta.reference) {
2147
+ let scope;
2148
+ if (resolveScope) {
2149
+ try {
2150
+ scope = await resolveScope(meta.reference);
2151
+ } catch {
2152
+ return void 0;
2153
+ }
2154
+ }
2155
+ return deps.fetchRecordLabels(meta.reference, values, scope ?? void 0, context);
2156
+ }
2157
+ return void 0;
2158
+ }
2159
+ };
2160
+ }
2161
+ function withLabelFetchCache(deps) {
2162
+ const cache = /* @__PURE__ */ new Map();
2163
+ return {
2164
+ getObjectFields: (objectName) => deps.getObjectFields(objectName),
2165
+ async fetchRecordLabels(targetObject, ids, scope, context) {
2166
+ let known = cache.get(targetObject);
2167
+ if (!known) {
2168
+ known = /* @__PURE__ */ new Map();
2169
+ cache.set(targetObject, known);
2170
+ }
2171
+ const missing = ids.filter((id) => !known.has(id));
2172
+ if (missing.length > 0) {
2173
+ const fetched = await deps.fetchRecordLabels(targetObject, missing, scope, context);
2174
+ for (const id of missing) known.set(id, fetched.get(id) ?? null);
2175
+ }
2176
+ const out = /* @__PURE__ */ new Map();
2177
+ for (const id of ids) {
2178
+ const label = known.get(id);
2179
+ if (label != null) out.set(id, label);
2180
+ }
2181
+ return out;
2182
+ }
2183
+ };
2184
+ }
1299
2185
  var pad = (n) => String(n).padStart(2, "0");
1300
2186
  function formatDateBucket(value, granularity) {
1301
2187
  if (value == null || value instanceof Date === false) {
1302
2188
  if (typeof value !== "number" && typeof value !== "string") return value;
1303
2189
  }
2190
+ if (granularity === "year") {
2191
+ const y2 = typeof value === "number" ? value : Number(String(value).trim());
2192
+ if (Number.isInteger(y2) && y2 >= 1e3 && y2 <= 9999) return String(y2);
2193
+ }
1304
2194
  let d;
1305
2195
  if (value instanceof Date) d = value;
1306
2196
  else if (typeof value === "number") d = new Date(value);
@@ -1324,7 +2214,7 @@ function formatDateBucket(value, granularity) {
1324
2214
  return `${y}-${pad(m + 1)}-${pad(d.getUTCDate())}`;
1325
2215
  }
1326
2216
  }
1327
- async function resolveDimensionLabels(baseObject, dims, rows, deps) {
2217
+ async function resolveDimensionLabels(baseObject, dims, rows, deps, resolveScope, context) {
1328
2218
  if (!rows.length || !dims.length) return;
1329
2219
  const fields = deps.getObjectFields(baseObject);
1330
2220
  if (!fields) return;
@@ -1356,7 +2246,15 @@ async function resolveDimensionLabels(baseObject, dims, rows, deps) {
1356
2246
  new Set(rows.map((r) => r[dim.name]).filter((v) => v != null))
1357
2247
  );
1358
2248
  if (ids.length === 0) continue;
1359
- const labelById = await deps.fetchRecordLabels(meta.reference, ids);
2249
+ let scope;
2250
+ if (resolveScope) {
2251
+ try {
2252
+ scope = await resolveScope(meta.reference);
2253
+ } catch {
2254
+ continue;
2255
+ }
2256
+ }
2257
+ const labelById = await deps.fetchRecordLabels(meta.reference, ids, scope ?? void 0, context);
1360
2258
  if (!labelById || labelById.size === 0) continue;
1361
2259
  for (const row of rows) {
1362
2260
  const label = labelById.get(row[dim.name]);
@@ -1377,11 +2275,21 @@ function pickDisplayField(fields) {
1377
2275
  }
1378
2276
 
1379
2277
  // src/preview-evaluator.ts
1380
- var import_core = require("@objectstack/core");
2278
+ var import_core4 = require("@objectstack/core");
1381
2279
  function compare(a, b) {
1382
2280
  if (typeof a === "number" && typeof b === "number") return a - b;
2281
+ if (a instanceof Date || b instanceof Date) {
2282
+ const ai = (0, import_core4.utcInstantMs)(a);
2283
+ const bi = (0, import_core4.utcInstantMs)(b);
2284
+ if (ai !== null && bi !== null) return ai - bi;
2285
+ }
1383
2286
  return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0;
1384
2287
  }
2288
+ function lteBound(value, bound) {
2289
+ const nextDay = (0, import_core4.nextUtcCalendarDay)(bound);
2290
+ if (nextDay != null) return compare(value, nextDay) < 0;
2291
+ return compare(value, bound) <= 0;
2292
+ }
1385
2293
  function matchOp(value, op, expected) {
1386
2294
  switch (op) {
1387
2295
  case "$eq":
@@ -1394,8 +2302,16 @@ function matchOp(value, op, expected) {
1394
2302
  return value != null && compare(value, expected) >= 0;
1395
2303
  case "$lt":
1396
2304
  return value != null && compare(value, expected) < 0;
1397
- case "$lte":
1398
- return value != null && compare(value, expected) <= 0;
2305
+ case "$lte": {
2306
+ if (value == null) return false;
2307
+ return lteBound(value, expected);
2308
+ }
2309
+ case "$between": {
2310
+ if (value == null || !Array.isArray(expected) || expected.length !== 2) return false;
2311
+ const [min, max] = expected;
2312
+ if (min == null || max == null) return false;
2313
+ return compare(value, min) >= 0 && lteBound(value, max);
2314
+ }
1399
2315
  case "$in":
1400
2316
  return Array.isArray(expected) && expected.some((e) => value === e || String(value) === String(e));
1401
2317
  case "$nin":
@@ -1428,7 +2344,7 @@ function matchesWhere(row, where) {
1428
2344
  function bucketDate(value, granularity, timezone) {
1429
2345
  const d = new Date(String(value));
1430
2346
  if (Number.isNaN(d.getTime())) return null;
1431
- const { year: y, month, day: dayNum } = (0, import_core.calendarPartsInTzOrUtc)(d, timezone);
2347
+ const { year: y, month, day: dayNum } = (0, import_core4.calendarPartsInTzOrUtc)(d, timezone);
1432
2348
  const m = `${month}`.padStart(2, "0");
1433
2349
  const day = `${dayNum}`.padStart(2, "0");
1434
2350
  switch (granularity) {
@@ -1482,7 +2398,9 @@ function evaluateAnalyticsQueryOverRows(query, cube, rows) {
1482
2398
  const [start, end] = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
1483
2399
  filtered = filtered.filter((r) => {
1484
2400
  const v = String(r[field] ?? "");
1485
- return v >= String(start) && v <= `${end}~`;
2401
+ const nextDay = (0, import_core4.nextUtcCalendarDay)(end);
2402
+ const inUpper = nextDay != null ? v < nextDay : v <= `${end}~`;
2403
+ return v >= String(start) && inUpper;
1486
2404
  });
1487
2405
  }
1488
2406
  const dimensions = query.dimensions ?? [];
@@ -1550,7 +2468,9 @@ var AnalyticsService = class {
1550
2468
  constructor(config = {}) {
1551
2469
  /** Compiled datasets by name — feeds the join allowlist (D-C) and queryDataset. */
1552
2470
  this.datasetRegistry = /* @__PURE__ */ new Map();
1553
- this.logger = config.logger || (0, import_core2.createLogger)({ level: "info", format: "pretty" });
2471
+ /** [#3867] One-shot flag for the {@link assertInferableCube} stand-down warning. */
2472
+ this.warnedNoObjectRegistry = false;
2473
+ this.logger = config.logger || (0, import_core5.createLogger)({ level: "info", format: "pretty" });
1554
2474
  this.cubeRegistry = new CubeRegistry();
1555
2475
  if (config.cubes) {
1556
2476
  this.cubeRegistry.registerAll(config.cubes);
@@ -1560,6 +2480,7 @@ var AnalyticsService = class {
1560
2480
  this.measureCurrency = config.measureCurrency;
1561
2481
  this.labelResolver = config.labelResolver;
1562
2482
  this.draftRowsResolver = config.draftRowsResolver;
2483
+ this.isRegisteredObject = config.isRegisteredObject;
1563
2484
  if (config.datasets) {
1564
2485
  for (const ds of config.datasets) {
1565
2486
  try {
@@ -1579,6 +2500,7 @@ var AnalyticsService = class {
1579
2500
  // fall back to any explicitly-configured provider for legacy cubes.
1580
2501
  getAllowedRelationships: (cubeName) => this.datasetRegistry.get(cubeName)?.allowedRelationships ?? config.getAllowedRelationships?.(cubeName),
1581
2502
  coerceTemporalFilterValue: config.coerceTemporalFilterValue,
2503
+ coerceTemporalFilterColumn: config.coerceTemporalFilterColumn,
1582
2504
  isExternalObject: config.isExternalObject
1583
2505
  };
1584
2506
  const builtIn = [
@@ -1600,10 +2522,11 @@ var AnalyticsService = class {
1600
2522
  * `getReadScope(objectName)` that already knows the active tenant.
1601
2523
  */
1602
2524
  async callCtx(query, context) {
1603
- if (!this.readScopeProvider) return this.baseCtx;
2525
+ if (!this.readScopeProvider) return { ...this.baseCtx, context };
1604
2526
  const scopes = await this.resolveReadScopes(query, context);
1605
2527
  return {
1606
2528
  ...this.baseCtx,
2529
+ context,
1607
2530
  getReadScope: (objectName) => scopes.get(objectName) ?? null
1608
2531
  };
1609
2532
  }
@@ -1724,9 +2647,19 @@ var AnalyticsService = class {
1724
2647
  return previewResult;
1725
2648
  }
1726
2649
  }
2650
+ const provider = this.readScopeProvider;
2651
+ const resolveScope = provider ? (targetObject) => provider(targetObject, context) : void 0;
2652
+ const labelDeps = this.labelResolver ? withLabelFetchCache(this.labelResolver) : void 0;
2653
+ const orderLabels = labelDeps && dataset.dimensions?.length ? createOrderLabelResolver(
2654
+ dataset.object,
2655
+ dataset.dimensions.filter((d) => !!d.field).map((d) => ({ name: d.name, field: d.field })),
2656
+ labelDeps,
2657
+ resolveScope,
2658
+ context
2659
+ ) : void 0;
1727
2660
  let result;
1728
2661
  try {
1729
- result = await new DatasetExecutor(this).execute(compiled, selection, context);
2662
+ result = await new DatasetExecutor(this, orderLabels).execute(compiled, selection, context);
1730
2663
  } catch (err) {
1731
2664
  if (isMissingSourceError(err)) {
1732
2665
  this.logger.warn(
@@ -1762,18 +2695,20 @@ var AnalyticsService = class {
1762
2695
  const rangeTz = selection.timezone ?? context?.timezone ?? "UTC";
1763
2696
  const rangeDims = [];
1764
2697
  for (const d of selectedDims) {
1765
- if (!d.field || d.type !== "date" || !d.dateGranularity) continue;
2698
+ if (!d.field || d.type !== "date") continue;
2699
+ const granularity = resolveDimensionGranularity(selection, d.name, d.dateGranularity);
2700
+ if (!granularity) continue;
1766
2701
  const ftype = this.measureCurrency?.(dataset.object, d.field)?.type;
1767
- if (ftype === "datetime") rangeDims.push({ d, instant: true });
1768
- else if (ftype === "date") rangeDims.push({ d, instant: false });
1769
- else if (rangeTz === "UTC") rangeDims.push({ d, instant: false });
2702
+ if (ftype === "datetime") rangeDims.push({ d, granularity, instant: true });
2703
+ else if (ftype === "date") rangeDims.push({ d, granularity, instant: false });
2704
+ else if (rangeTz === "UTC") rangeDims.push({ d, granularity, instant: false });
1770
2705
  }
1771
2706
  if (rangeDims.length && result.rows.length) {
1772
- const bound = (ymd, instant) => instant ? new Date((0, import_core2.zonedDateStartToUtcMs)(ymd, rangeTz)).toISOString() : ymd;
2707
+ const bound = (ymd, instant) => instant ? new Date((0, import_core5.zonedDateStartToUtcMs)(ymd, rangeTz)).toISOString() : ymd;
1773
2708
  result.drillRanges = result.rows.map((row) => {
1774
2709
  const ranges = {};
1775
- for (const { d, instant } of rangeDims) {
1776
- const cal = (0, import_core2.bucketKeyToCalendarRange)(row[d.name], d.dateGranularity);
2710
+ for (const { d, granularity, instant } of rangeDims) {
2711
+ const cal = (0, import_core5.bucketKeyToCalendarRange)(row[d.name], granularity);
1777
2712
  if (cal) {
1778
2713
  ranges[d.name] = { field: d.field, gte: bound(cal.start, instant), lt: bound(cal.end, instant) };
1779
2714
  }
@@ -1782,15 +2717,20 @@ var AnalyticsService = class {
1782
2717
  });
1783
2718
  result.object = dataset.object;
1784
2719
  }
1785
- if (this.labelResolver && selectedDims.length) {
1786
- const dims = selectedDims.filter((d) => !!d.field).map((d) => ({ name: d.name, field: d.field, type: d.type, dateGranularity: d.dateGranularity }));
2720
+ if (labelDeps && selectedDims.length) {
2721
+ const dims = selectedDims.filter((d) => !!d.field).map((d) => ({
2722
+ name: d.name,
2723
+ field: d.field,
2724
+ type: d.type,
2725
+ dateGranularity: resolveDimensionGranularity(selection, d.name, d.dateGranularity)
2726
+ }));
1787
2727
  if (dims.length) {
1788
2728
  try {
1789
- await resolveDimensionLabels(dataset.object, dims, result.rows, this.labelResolver);
2729
+ await resolveDimensionLabels(dataset.object, dims, result.rows, labelDeps, resolveScope, context);
1790
2730
  for (const total of result.totals ?? []) {
1791
2731
  const subset = dims.filter((d) => total.dimensions.includes(d.name));
1792
2732
  if (subset.length) {
1793
- await resolveDimensionLabels(dataset.object, subset, total.rows, this.labelResolver);
2733
+ await resolveDimensionLabels(dataset.object, subset, total.rows, labelDeps, resolveScope, context);
1794
2734
  }
1795
2735
  }
1796
2736
  } catch (e) {
@@ -1878,6 +2818,7 @@ var AnalyticsService = class {
1878
2818
  const name = query.cube;
1879
2819
  let cube = this.cubeRegistry.get(name);
1880
2820
  if (!cube) {
2821
+ this.assertInferableCube(name);
1881
2822
  cube = this.inferCubeFromQuery(query);
1882
2823
  this.cubeRegistry.register(cube);
1883
2824
  const isScalarMetric = (query.dimensions?.length ?? 0) === 0 && (query.timeDimensions?.length ?? 0) === 0;
@@ -1904,6 +2845,39 @@ var AnalyticsService = class {
1904
2845
  );
1905
2846
  }
1906
2847
  }
2848
+ /**
2849
+ * [#3867] Gate on the cube auto-inference path: a name with no registered
2850
+ * Cube may only be inferred into one if it is a registered object.
2851
+ *
2852
+ * Rejects with `status: 404` / `code: 'CUBE_NOT_FOUND'` so the HTTP boundary
2853
+ * answers "no such cube" instead of letting the name reach the driver as a
2854
+ * table and surfacing whatever the driver says about it. The message names
2855
+ * both ways the request could be made valid, because from here the two are
2856
+ * genuinely indistinguishable: register a Cube, or register the object.
2857
+ *
2858
+ * Skips when `isRegisteredObject` was not supplied — see the config field's
2859
+ * doc for why that tier is a deliberate stand-down and not a hole.
2860
+ */
2861
+ assertInferableCube(name) {
2862
+ const isRegisteredObject = this.isRegisteredObject;
2863
+ if (!isRegisteredObject) {
2864
+ if (!this.warnedNoObjectRegistry) {
2865
+ this.warnedNoObjectRegistry = true;
2866
+ this.logger.warn(
2867
+ "[Analytics] no object-registry hook configured \u2014 the cube-inference existence gate (#3867) is INACTIVE for this service; an unregistered cube name reaches the driver as a raw table name."
2868
+ );
2869
+ }
2870
+ return;
2871
+ }
2872
+ if (isRegisteredObject(name)) return;
2873
+ const err = new Error(
2874
+ `Cube '${name}' not found: no cube is registered under that name, and it is not a registered object either (a cube can only be auto-inferred from a registered object). Define a Cube in your stack, or check the object name.`
2875
+ );
2876
+ err.code = "CUBE_NOT_FOUND";
2877
+ err.status = 404;
2878
+ err.cube = name;
2879
+ throw err;
2880
+ }
1907
2881
  /** Build a minimal Cube from the fields referenced by an AnalyticsQuery. */
1908
2882
  inferCubeFromQuery(query) {
1909
2883
  const cubeName = query.cube;
@@ -2014,6 +2988,11 @@ var FallbackDelegateStrategy = class {
2014
2988
  var AnalyticsServicePlugin = class {
2015
2989
  constructor(options = {}) {
2016
2990
  this.name = "com.objectstack.service-analytics";
2991
+ /**
2992
+ * Services init() registers on every path (ADR-0116, #4131) — lets the
2993
+ * kernel name this plugin when a consumer requires one before it inits.
2994
+ */
2995
+ this.providesServices = ["analytics"];
2017
2996
  this.version = "1.0.0";
2018
2997
  this.type = "standard";
2019
2998
  this.dependencies = [];
@@ -2045,7 +3024,7 @@ var AnalyticsServicePlugin = class {
2045
3024
  '[Analytics] No "data" service registered yet at init; will retry per-query. Register ObjectQLPlugin or pass executeAggregate.'
2046
3025
  );
2047
3026
  }
2048
- executeAggregate = async (objectName, { groupBy, aggregations, filter, timezone }) => {
3027
+ executeAggregate = async (objectName, { groupBy, aggregations, filter, timezone, context }) => {
2049
3028
  const engine = tryGetDataEngine();
2050
3029
  if (!engine) {
2051
3030
  throw new Error(
@@ -2062,7 +3041,13 @@ var AnalyticsServicePlugin = class {
2062
3041
  })),
2063
3042
  // ADR-0053 Phase 2: thread the reference tz so date buckets resolve on
2064
3043
  // that zone's calendar days (engine buckets in-memory when non-UTC).
2065
- timezone
3044
+ timezone,
3045
+ // ADR-0021 D-C (#3602): thread the caller's identity so the engine's
3046
+ // middleware chain scopes the read itself. `BaseEngineOptions.context`
3047
+ // is `.optional()`, so nothing ever forced this bridge to pass it —
3048
+ // and it did not, which is how an authenticated aggregate reached the
3049
+ // engine with no principal and plugin-security fell open (#3597).
3050
+ context
2066
3051
  });
2067
3052
  return rows;
2068
3053
  };
@@ -2110,6 +3095,7 @@ var AnalyticsServicePlugin = class {
2110
3095
  }));
2111
3096
  let getReadScope = this.options.getReadScope;
2112
3097
  let autoBridgedReadScope = false;
3098
+ let securityPresentAtInit = false;
2113
3099
  if (!getReadScope) {
2114
3100
  const trySecurity = () => {
2115
3101
  try {
@@ -2119,10 +3105,9 @@ var AnalyticsServicePlugin = class {
2119
3105
  return void 0;
2120
3106
  }
2121
3107
  };
2122
- if (trySecurity()) {
2123
- getReadScope = (object, context) => trySecurity()?.getReadFilter(object, context);
2124
- autoBridgedReadScope = true;
2125
- }
3108
+ securityPresentAtInit = !!trySecurity();
3109
+ getReadScope = (object, context) => trySecurity()?.getReadFilter(object, context);
3110
+ autoBridgedReadScope = true;
2126
3111
  }
2127
3112
  const relationshipResolver = (baseObject, relationshipName) => {
2128
3113
  const engine = (() => {
@@ -2150,17 +3135,26 @@ var AnalyticsServicePlugin = class {
2150
3135
  };
2151
3136
  const labelResolver = {
2152
3137
  getObjectFields: (objectName) => dataEngine()?.getObject?.(objectName)?.fields,
2153
- fetchRecordLabels: async (targetObject, ids) => {
3138
+ fetchRecordLabels: async (targetObject, ids, scope, context) => {
2154
3139
  const map = /* @__PURE__ */ new Map();
2155
3140
  const displayField = pickDisplayField(dataEngine()?.getObject?.(targetObject)?.fields);
2156
3141
  if (!displayField || !executeAggregate || ids.length === 0) return map;
2157
- const rows = await executeAggregate(targetObject, {
2158
- groupBy: ["id", displayField],
2159
- aggregations: [{ field: "id", method: "count", alias: "_c" }],
2160
- filter: { id: { $in: ids } }
2161
- });
2162
- for (const r of rows) {
2163
- if (r.id != null && r[displayField] != null) map.set(r.id, String(r[displayField]));
3142
+ const CHUNK = 500;
3143
+ for (let i = 0; i < ids.length; i += CHUNK) {
3144
+ const idFilter = { id: { $in: ids.slice(i, i + CHUNK) } };
3145
+ const filter = scope ? { $and: [idFilter, scope] } : idFilter;
3146
+ const rows = await executeAggregate(targetObject, {
3147
+ groupBy: ["id", displayField],
3148
+ aggregations: [{ field: "id", method: "count", alias: "_c" }],
3149
+ filter,
3150
+ // #3602 second belt — `scope` above is the analytics layer's own
3151
+ // predicate on this per-record read; the context makes the engine's
3152
+ // middleware scope it as well.
3153
+ context
3154
+ });
3155
+ for (const r of rows) {
3156
+ if (r.id != null && r[displayField] != null) map.set(r.id, String(r[displayField]));
3157
+ }
2164
3158
  }
2165
3159
  return map;
2166
3160
  }
@@ -2201,6 +3195,17 @@ var AnalyticsServicePlugin = class {
2201
3195
  }
2202
3196
  return value;
2203
3197
  };
3198
+ const coerceTemporalFilterColumn = (objectName, fieldName, columnSql) => {
3199
+ try {
3200
+ const svc = ctx.getService("data");
3201
+ const driver = svc?.getDriverForObject?.(objectName);
3202
+ if (driver && typeof driver.temporalFilterColumnSql === "function") {
3203
+ return driver.temporalFilterColumnSql(objectName, fieldName, columnSql);
3204
+ }
3205
+ } catch {
3206
+ }
3207
+ return columnSql;
3208
+ };
2204
3209
  const config = {
2205
3210
  cubes: this.options.cubes,
2206
3211
  logger: ctx.logger,
@@ -2211,6 +3216,7 @@ var AnalyticsServicePlugin = class {
2211
3216
  getReadScope,
2212
3217
  getAllowedRelationships: this.options.getAllowedRelationships,
2213
3218
  coerceTemporalFilterValue,
3219
+ coerceTemporalFilterColumn,
2214
3220
  relationshipResolver,
2215
3221
  labelResolver,
2216
3222
  // ADR-0053 — source-field currency metadata for the measure currency chain.
@@ -2225,13 +3231,31 @@ var AnalyticsServicePlugin = class {
2225
3231
  const obj = dataEngine()?.getObject?.(objectName);
2226
3232
  return !!(obj && obj.external != null);
2227
3233
  },
3234
+ // [#3867] Existence probe for the cube auto-inference gate. Reads the
3235
+ // same schema registry the data path's #3770 gate consults, through the
3236
+ // engine accessor this bridge already uses above — so "which objects
3237
+ // exist" has one answer across /data and /analytics.
3238
+ //
3239
+ // `dataEngine()` resolves lazily and may be absent entirely (analytics
3240
+ // installed without a data engine). Reporting `false` there would 404
3241
+ // every cube, so an unresolvable engine reports `true` — "cannot answer,
3242
+ // do not block" — mirroring the tiering #3770 took on the data path.
3243
+ isRegisteredObject: (name) => {
3244
+ const engine = dataEngine();
3245
+ if (!engine) return true;
3246
+ return engine.getObject?.(name) != null;
3247
+ },
2228
3248
  draftRowsResolver
2229
3249
  };
2230
- if (autoBridgedReadScope) {
3250
+ if (autoBridgedReadScope && securityPresentAtInit) {
2231
3251
  ctx.logger.info('[Analytics] Auto-bridged getReadScope \u2192 "security" service (getReadFilter)');
3252
+ } else if (autoBridgedReadScope) {
3253
+ ctx.logger.info(
3254
+ '[Analytics] getReadScope bridged to the "security" service; that service is not registered yet at init and will be resolved per query (plugin order is not significant).'
3255
+ );
2232
3256
  } else if (!getReadScope) {
2233
3257
  ctx.logger.warn(
2234
- '[Analytics] No getReadScope configured and no "security" service with getReadFilter found \u2014 the raw-SQL analytics path will NOT enforce tenant/RLS scoping on joined objects (ADR-0021 D-C). Supply getReadScope or register a security service in multi-tenant deployments.'
3258
+ '[Analytics] No getReadScope configured and no "security" service with getReadFilter found \u2014 analytics queries will NOT enforce tenant/RLS scoping (ADR-0021 D-C). Supply getReadScope or register a security service in multi-tenant deployments.'
2235
3259
  );
2236
3260
  }
2237
3261
  if (autoBridged) {
@@ -2275,10 +3299,12 @@ var AnalyticsServicePlugin = class {
2275
3299
  combineFilters,
2276
3300
  compileDataset,
2277
3301
  compileScopedFilterToSql,
3302
+ createOrderLabelResolver,
2278
3303
  evaluateDerivedMeasures,
2279
3304
  mergeByDimensions,
2280
3305
  pickDisplayField,
2281
3306
  resolveDimensionLabels,
2282
- shiftRange
3307
+ shiftRange,
3308
+ withLabelFetchCache
2283
3309
  });
2284
3310
  //# sourceMappingURL=index.cjs.map