@objectstack/service-analytics 17.0.0-rc.0 → 17.0.0-rc.2

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
@@ -31,6 +31,7 @@ __export(index_exports, {
31
31
  compileScopedFilterToSql: () => compileScopedFilterToSql,
32
32
  createOrderLabelResolver: () => createOrderLabelResolver,
33
33
  evaluateDerivedMeasures: () => evaluateDerivedMeasures,
34
+ fillEmptyGroups: () => fillEmptyGroups,
34
35
  mergeByDimensions: () => mergeByDimensions,
35
36
  pickDisplayField: () => pickDisplayField,
36
37
  resolveDimensionLabels: () => resolveDimensionLabels,
@@ -40,7 +41,8 @@ __export(index_exports, {
40
41
  module.exports = __toCommonJS(index_exports);
41
42
 
42
43
  // src/analytics-service.ts
43
- var import_core3 = require("@objectstack/core");
44
+ var import_data3 = require("@objectstack/spec/data");
45
+ var import_core5 = require("@objectstack/core");
44
46
 
45
47
  // src/cube-registry.ts
46
48
  var CubeRegistry = class {
@@ -169,7 +171,8 @@ var MONGO_TO_CUBE_OP = {
169
171
  $nin: "notIn",
170
172
  $contains: "contains",
171
173
  $notContains: "notContains",
172
- $exists: "set"
174
+ $startsWith: "startsWith",
175
+ $endsWith: "endsWith"
173
176
  };
174
177
  function stringifyForCube(v) {
175
178
  if (v == null) return "";
@@ -178,55 +181,102 @@ function stringifyForCube(v) {
178
181
  if (typeof v === "object") return JSON.stringify(v);
179
182
  return String(v);
180
183
  }
181
- function flattenCondition(cond, out) {
182
- for (const [key, raw] of Object.entries(cond)) {
183
- if (raw === void 0) continue;
184
- if (key === "$and" && Array.isArray(raw)) {
185
- for (const sub of raw) {
186
- if (sub && typeof sub === "object") {
187
- flattenCondition(sub, out);
184
+ function andOf(children) {
185
+ if (children.length === 0) return null;
186
+ if (children.length === 1) return children[0];
187
+ return { kind: "and", children };
188
+ }
189
+ function fieldLeaves(key, raw) {
190
+ const out = [];
191
+ const leaf = (operator, values) => {
192
+ out.push({ kind: "leaf", member: key, operator, values });
193
+ };
194
+ if (raw === null) {
195
+ leaf("notSet", []);
196
+ return out;
197
+ }
198
+ if (typeof raw === "object" && !Array.isArray(raw) && !(raw instanceof Date)) {
199
+ const wrapper = raw;
200
+ const opKeys = Object.keys(wrapper).filter((k) => k.startsWith("$"));
201
+ if (opKeys.length > 0) {
202
+ for (const opKey of opKeys) {
203
+ if (opKey === "$between") {
204
+ const v2 = wrapper[opKey];
205
+ if (!Array.isArray(v2) || v2.length !== 2) {
206
+ throw new Error(
207
+ `[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.`
208
+ );
209
+ }
210
+ leaf("gte", [stringifyForCube(v2[0])]);
211
+ leaf("lte", [stringifyForCube(v2[1])]);
212
+ continue;
213
+ }
214
+ if (opKey === "$null" || opKey === "$exists") {
215
+ const isNull = opKey === "$null" ? wrapper[opKey] === true : wrapper[opKey] === false;
216
+ leaf(isNull ? "notSet" : "set", []);
217
+ continue;
218
+ }
219
+ const cubeOp = MONGO_TO_CUBE_OP[opKey];
220
+ if (!cubeOp) {
221
+ throw new Error(
222
+ `[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.`
223
+ );
188
224
  }
225
+ const v = wrapper[opKey];
226
+ leaf(cubeOp, Array.isArray(v) ? v.map(stringifyForCube) : [stringifyForCube(v)]);
189
227
  }
190
- continue;
228
+ return out;
191
229
  }
192
- if (key === "$or" || key === "$not") continue;
193
- if (raw === null) {
194
- out.push({ member: key, operator: "notSet", values: [] });
195
- continue;
230
+ for (const [nestedKey, nestedVal] of Object.entries(wrapper)) {
231
+ out.push(...fieldLeaves(`${key}.${nestedKey}`, nestedVal));
196
232
  }
197
- if (typeof raw === "object" && !Array.isArray(raw) && !(raw instanceof Date)) {
198
- const wrapper = raw;
199
- const opKeys = Object.keys(wrapper).filter((k) => k.startsWith("$"));
200
- if (opKeys.length > 0) {
201
- for (const opKey of opKeys) {
202
- const cubeOp = MONGO_TO_CUBE_OP[opKey];
203
- if (!cubeOp) continue;
204
- const v = wrapper[opKey];
205
- const values = Array.isArray(v) ? v.map(stringifyForCube) : [stringifyForCube(v)];
206
- out.push({ member: key, operator: cubeOp, values });
207
- }
208
- continue;
209
- }
210
- for (const [nestedKey, nestedVal] of Object.entries(wrapper)) {
211
- flattenCondition({ [`${key}.${nestedKey}`]: nestedVal }, out);
233
+ return out;
234
+ }
235
+ if (Array.isArray(raw)) leaf("in", raw.map(stringifyForCube));
236
+ else leaf("equals", [stringifyForCube(raw)]);
237
+ return out;
238
+ }
239
+ function buildNode(cond) {
240
+ const children = [];
241
+ for (const [key, raw] of Object.entries(cond)) {
242
+ if (raw === void 0) continue;
243
+ if (key === "$and" || key === "$or") {
244
+ if (!Array.isArray(raw) || raw.length === 0) {
245
+ throw new Error(
246
+ `[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.`
247
+ );
212
248
  }
249
+ const branches = raw.map((sub) => sub && typeof sub === "object" ? buildNode(sub) : null).filter((n) => n !== null);
250
+ if (branches.length === 0) continue;
251
+ if (key === "$and") children.push(...branches);
252
+ else children.push(branches.length === 1 ? branches[0] : { kind: "or", children: branches });
213
253
  continue;
214
254
  }
215
- if (Array.isArray(raw)) {
216
- out.push({ member: key, operator: "in", values: raw.map(stringifyForCube) });
217
- } else {
218
- out.push({ member: key, operator: "equals", values: [stringifyForCube(raw)] });
255
+ if (key === "$not") {
256
+ const inner = raw && typeof raw === "object" ? buildNode(raw) : null;
257
+ if (inner) children.push({ kind: "not", child: inner });
258
+ continue;
259
+ }
260
+ if (key.startsWith("$")) {
261
+ throw new Error(
262
+ `[analytics] Unsupported top-level filter operator "${key}". Dropping it would silently widen the query to rows the filter excludes.`
263
+ );
219
264
  }
265
+ children.push(...fieldLeaves(key, raw));
220
266
  }
267
+ return andOf(children);
221
268
  }
222
- function normalizeAnalyticsFilters(query) {
223
- if (!query || typeof query !== "object") return [];
224
- const out = [];
269
+ function normalizeAnalyticsFilterTree(query) {
270
+ if (!query || typeof query !== "object") return null;
225
271
  const where = query.where;
226
- if (where && typeof where === "object" && !Array.isArray(where)) {
227
- flattenCondition(where, out);
228
- }
229
- return out;
272
+ if (!where || typeof where !== "object" || Array.isArray(where)) return null;
273
+ return buildNode(where);
274
+ }
275
+ function collectFilterLeaves(node) {
276
+ if (!node) return [];
277
+ if (node.kind === "leaf") return [{ member: node.member, operator: node.operator, values: node.values }];
278
+ if (node.kind === "not") return collectFilterLeaves(node.child);
279
+ return node.children.flatMap(collectFilterLeaves);
230
280
  }
231
281
  function recoverNumber(s) {
232
282
  if (/^-?\d+(\.\d+)?$/.test(s)) {
@@ -358,6 +408,18 @@ function compileOperator(col, op, val, field, params) {
358
408
  }
359
409
 
360
410
  // src/strategies/native-sql-strategy.ts
411
+ var import_core = require("@objectstack/core");
412
+ var AGGREGATE_SQL = {
413
+ "count": () => "COUNT(*)",
414
+ "sum": (col) => `SUM(${col})`,
415
+ "avg": (col) => `AVG(${col})`,
416
+ "min": (col) => `MIN(${col})`,
417
+ "max": (col) => `MAX(${col})`,
418
+ "count_distinct": (col) => `COUNT(DISTINCT ${col})`
419
+ };
420
+ var SUPPORTED_AGGREGATE_SQL_KEYS = Object.keys(AGGREGATE_SQL);
421
+ var EXPRESSION_METRIC_TYPES = /* @__PURE__ */ new Set(["number", "string", "boolean"]);
422
+ var IDENTIFIER_PATH = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
361
423
  var NativeSQLStrategy = class {
362
424
  constructor() {
363
425
  this.name = "NativeSQLStrategy";
@@ -412,15 +474,15 @@ var NativeSQLStrategy = class {
412
474
  }
413
475
  }
414
476
  const whereClauses = [];
415
- const normalizedFilters = normalizeAnalyticsFilters(query);
416
- if (normalizedFilters.length > 0) {
417
- for (const filter of normalizedFilters) {
418
- const colExpr = this.resolveFieldSql(cube, filter.member, tableName, joins);
419
- const target = this.resolveStorageTarget(cube, filter.member, tableName);
420
- const clause = this.buildFilterClause(colExpr, filter.operator, filter.values, params, ctx, target);
421
- if (clause) whereClauses.push(clause);
422
- }
423
- }
477
+ const filterSql = this.compileFilterNode(
478
+ normalizeAnalyticsFilterTree(query),
479
+ cube,
480
+ tableName,
481
+ joins,
482
+ params,
483
+ ctx
484
+ );
485
+ if (filterSql) whereClauses.push(filterSql);
424
486
  if (query.timeDimensions && query.timeDimensions.length > 0) {
425
487
  for (const td of query.timeDimensions) {
426
488
  const colExpr = this.resolveFieldSql(cube, td.dimension, tableName, joins);
@@ -428,11 +490,17 @@ var NativeSQLStrategy = class {
428
490
  const range = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
429
491
  if (range.length === 2) {
430
492
  const td2 = this.resolveStorageTarget(cube, td.dimension, tableName);
431
- params.push(
432
- this.coerceTemporal(ctx, td2, range[0]),
433
- this.coerceTemporal(ctx, td2, range[1])
434
- );
435
- whereClauses.push(`${colExpr} BETWEEN $${params.length - 1} AND $${params.length}`);
493
+ const column = this.temporalColumn(ctx, td2, colExpr);
494
+ const nextDay = (0, import_core.nextUtcCalendarDay)(range[1]);
495
+ params.push(this.coerceTemporal(ctx, td2, range[0]));
496
+ const lower = `${column} >= $${params.length}`;
497
+ if (nextDay != null) {
498
+ params.push(this.coerceTemporal(ctx, td2, nextDay));
499
+ whereClauses.push(`(${lower} AND ${column} < $${params.length})`);
500
+ } else {
501
+ params.push(this.coerceTemporal(ctx, td2, range[1]));
502
+ whereClauses.push(`(${lower} AND ${column} <= $${params.length})`);
503
+ }
436
504
  }
437
505
  }
438
506
  }
@@ -530,6 +598,7 @@ var NativeSQLStrategy = class {
530
598
  }
531
599
  return rawSql;
532
600
  }
601
+ if (!IDENTIFIER_PATH.test(rawSql)) return rawSql;
533
602
  const segments = rawSql.split(".");
534
603
  const column = segments[segments.length - 1];
535
604
  const hops = segments.slice(0, -1);
@@ -589,24 +658,19 @@ var NativeSQLStrategy = class {
589
658
  }
590
659
  resolveMeasureSql(cube, member, parentTable, joins) {
591
660
  const measure = this.lookupMember(cube, member, "measure");
592
- if (!measure) return `COUNT(*)`;
593
- const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
594
- switch (measure.type) {
595
- case "count":
596
- return "COUNT(*)";
597
- case "sum":
598
- return `SUM(${col})`;
599
- case "avg":
600
- return `AVG(${col})`;
601
- case "min":
602
- return `MIN(${col})`;
603
- case "max":
604
- return `MAX(${col})`;
605
- case "count_distinct":
606
- return `COUNT(DISTINCT ${col})`;
607
- default:
608
- return `COUNT(*)`;
661
+ if (!measure) {
662
+ const declared = Object.keys(cube.measures ?? {});
663
+ throw new Error(
664
+ `[native-sql-strategy] cube "${cube.name}" declares no measure "${member}"` + (declared.length ? ` (declared: ${declared.join(", ")})` : " (it declares none)")
665
+ );
609
666
  }
667
+ const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
668
+ const wrap = AGGREGATE_SQL[measure.type];
669
+ if (wrap) return wrap(col);
670
+ if (EXPRESSION_METRIC_TYPES.has(measure.type)) return col;
671
+ throw new Error(
672
+ `[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(", ")}).`
673
+ );
610
674
  }
611
675
  resolveFieldSql(cube, member, parentTable, joins) {
612
676
  const dim = this.lookupMember(cube, member, "dimension");
@@ -659,7 +723,53 @@ var NativeSQLStrategy = class {
659
723
  }
660
724
  return coerceFilterValueForSql(value);
661
725
  }
662
- buildFilterClause(col, operator, values, params, ctx, target) {
726
+ /**
727
+ * The column side of {@link coerceTemporal}: normalise the reference so it
728
+ * reads in the storage form the comparand was coerced into.
729
+ *
730
+ * A SQLite `Field.datetime` column carries an INTEGER epoch (a `Date` write)
731
+ * and ISO TEXT (a REST/JSON write, a `NOW()` default — including the platform's
732
+ * own `created_at`) at the SAME time, so coercing the value alone fixes one half
733
+ * and empties the other. That is #3912: a `dateRange: last_30_days` on
734
+ * `created_date` read 0 with 29 rows in range. Every other column and dialect
735
+ * gets its reference back verbatim.
736
+ */
737
+ temporalColumn(ctx, target, col) {
738
+ if (typeof ctx.coerceTemporalFilterColumn !== "function") return col;
739
+ return ctx.coerceTemporalFilterColumn(target.object, target.field, col) || col;
740
+ }
741
+ /**
742
+ * Compile a normalized filter node into a boolean SQL expression, recursing
743
+ * through the combinators. `null` = no constraint.
744
+ *
745
+ * Leaves go through {@link buildFilterClause} exactly as they did when this
746
+ * was a flat loop, so the storage-form coercion and the calendar-day
747
+ * upper-bound rule (#3777) apply at every depth — including inside an `$or`,
748
+ * where a second, combinator-aware implementation would have been free to
749
+ * drift from the first.
750
+ *
751
+ * Parenthesisation is explicit rather than left to SQL's precedence: `AND`
752
+ * does bind tighter than `OR`, so `a AND b OR c` happens to be right, but
753
+ * being right by construction is what keeps a future edit from making it
754
+ * wrong.
755
+ */
756
+ compileFilterNode(node, cube, parentTable, joins, params, ctx) {
757
+ if (!node) return null;
758
+ if (node.kind === "leaf") {
759
+ const colExpr = this.resolveFieldSql(cube, node.member, parentTable, joins);
760
+ const target = this.resolveStorageTarget(cube, node.member, parentTable);
761
+ return this.buildFilterClause(colExpr, node.operator, node.values, params, ctx, target);
762
+ }
763
+ if (node.kind === "not") {
764
+ const inner = this.compileFilterNode(node.child, cube, parentTable, joins, params, ctx);
765
+ return inner ? `NOT (${inner})` : null;
766
+ }
767
+ const parts = node.children.map((child) => this.compileFilterNode(child, cube, parentTable, joins, params, ctx)).filter((s) => !!s);
768
+ if (parts.length === 0) return null;
769
+ if (parts.length === 1) return parts[0];
770
+ return `(${parts.join(node.kind === "or" ? " OR " : " AND ")})`;
771
+ }
772
+ buildFilterClause(rawCol, operator, values, params, ctx, target) {
663
773
  const opMap = {
664
774
  equals: "=",
665
775
  notEquals: "!=",
@@ -668,26 +778,42 @@ var NativeSQLStrategy = class {
668
778
  lt: "<",
669
779
  lte: "<=",
670
780
  contains: "LIKE",
671
- notContains: "NOT LIKE"
781
+ notContains: "NOT LIKE",
782
+ startsWith: "LIKE",
783
+ endsWith: "LIKE"
672
784
  };
673
- if (operator === "set") return `${col} IS NOT NULL`;
674
- if (operator === "notSet") return `${col} IS NULL`;
785
+ const likePattern = {
786
+ contains: (v) => `%${v}%`,
787
+ notContains: (v) => `%${v}%`,
788
+ startsWith: (v) => `${v}%`,
789
+ endsWith: (v) => `%${v}`
790
+ };
791
+ if (operator === "set") return `${rawCol} IS NOT NULL`;
792
+ if (operator === "notSet") return `${rawCol} IS NULL`;
675
793
  if (operator === "in" || operator === "notIn") {
676
794
  if (!values || values.length === 0) return null;
677
795
  const placeholders = values.map((v) => {
678
796
  params.push(this.coerceTemporal(ctx, target, v));
679
797
  return `$${params.length}`;
680
798
  }).join(", ");
681
- return `${col} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
799
+ return `${this.temporalColumn(ctx, target, rawCol)} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
682
800
  }
683
801
  const sqlOp = opMap[operator];
684
802
  if (!sqlOp || !values || values.length === 0) return null;
685
- if (operator === "contains" || operator === "notContains") {
686
- params.push(`%${values[0]}%`);
687
- } else {
688
- params.push(this.coerceTemporal(ctx, target, values[0]));
803
+ const pattern = likePattern[operator];
804
+ if (pattern) {
805
+ params.push(pattern(values[0]));
806
+ return `${rawCol} ${sqlOp} $${params.length}`;
807
+ }
808
+ if (operator === "lte") {
809
+ const nextDay = (0, import_core.nextUtcCalendarDay)(values[0]);
810
+ if (nextDay != null) {
811
+ params.push(this.coerceTemporal(ctx, target, nextDay));
812
+ return `${this.temporalColumn(ctx, target, rawCol)} < $${params.length}`;
813
+ }
689
814
  }
690
- return `${col} ${sqlOp} $${params.length}`;
815
+ params.push(this.coerceTemporal(ctx, target, values[0]));
816
+ return `${this.temporalColumn(ctx, target, rawCol)} ${sqlOp} $${params.length}`;
691
817
  }
692
818
  extractObjectName(cube) {
693
819
  return cube.sql.trim();
@@ -709,6 +835,9 @@ var NativeSQLStrategy = class {
709
835
  }
710
836
  };
711
837
 
838
+ // src/strategies/objectql-strategy.ts
839
+ var import_core2 = require("@objectstack/core");
840
+
712
841
  // src/strategies/cross-object-rebucket.ts
713
842
  var RECOMBINABLE_METHODS = /* @__PURE__ */ new Set([
714
843
  "sum",
@@ -811,11 +940,7 @@ var ObjectQLStrategy = class {
811
940
  }
812
941
  const filter = {};
813
942
  const conjuncts = [];
814
- for (const f of normalizeAnalyticsFilters(query)) {
815
- const fieldName = this.resolveFieldName(cube, f.member, "any");
816
- const extra = this.mergeFilterOperand(filter, fieldName, this.convertFilter(f.operator, f.values));
817
- if (extra) conjuncts.push(extra);
818
- }
943
+ this.applyFilterNode(normalizeAnalyticsFilterTree(query), cube, filter, conjuncts);
819
944
  for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
820
945
  const extra = this.mergeFilterOperand(filter, field, bounds);
821
946
  if (extra) conjuncts.push(extra);
@@ -847,11 +972,9 @@ var ObjectQLStrategy = class {
847
972
  });
848
973
  const mappedRows = rows.map((row) => {
849
974
  const mapped = {};
850
- if (query.dimensions) {
851
- for (const dim of query.dimensions) {
852
- const shortName = this.resolveFieldName(cube, dim, "dimension");
853
- if (shortName in row) mapped[dim] = row[shortName];
854
- }
975
+ for (const dim of this.projectedDimensions(query)) {
976
+ const shortName = this.resolveFieldName(cube, dim, "dimension");
977
+ if (shortName in row) mapped[dim] = row[shortName];
855
978
  }
856
979
  if (query.measures) {
857
980
  for (const m of query.measures) {
@@ -897,7 +1020,7 @@ var ObjectQLStrategy = class {
897
1020
  }
898
1021
  const tableName = this.extractObjectName(cube);
899
1022
  const plan = this.planCrossObject(cube, query, Object.fromEntries(
900
- normalizeAnalyticsFilters(query).map((f) => [this.resolveFieldName(cube, f.member, "any"), true])
1023
+ collectFilterLeaves(normalizeAnalyticsFilterTree(query)).map((f) => [this.resolveFieldName(cube, f.member, "any"), true])
901
1024
  ));
902
1025
  const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd]));
903
1026
  const joinClauses = [];
@@ -934,18 +1057,18 @@ var ObjectQLStrategy = class {
934
1057
  }
935
1058
  }
936
1059
  const whereParts = [];
937
- for (const f of normalizeAnalyticsFilters(query)) {
938
- const clause = this.buildFilterClauseSql(
939
- this.resolveFieldName(cube, f.member, "any"),
940
- f.operator,
941
- f.values,
942
- params
943
- );
944
- if (clause) whereParts.push(clause);
945
- }
1060
+ const filterClause = this.renderFilterNodeSql(
1061
+ normalizeAnalyticsFilterTree(query),
1062
+ cube,
1063
+ params
1064
+ );
1065
+ if (filterClause) whereParts.push(filterClause);
946
1066
  for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
947
- params.push(bounds.$gte, bounds.$lte);
948
- whereParts.push(`${field} BETWEEN $${params.length - 1} AND $${params.length}`);
1067
+ const nextDay = (0, import_core2.nextUtcCalendarDay)(bounds.$lte);
1068
+ params.push(bounds.$gte, nextDay ?? bounds.$lte);
1069
+ whereParts.push(
1070
+ `(${field} >= $${params.length - 1} AND ${field} ${nextDay ? "<" : "<="} $${params.length})`
1071
+ );
949
1072
  }
950
1073
  const scope = ctx.getReadScope?.(tableName);
951
1074
  if (scope != null) {
@@ -1119,7 +1242,7 @@ var ObjectQLStrategy = class {
1119
1242
  const merged = rebucketCrossObject(baseRows, baseDimFields, resolvedDims, measures);
1120
1243
  const mappedRows = merged.map((row) => {
1121
1244
  const out = {};
1122
- for (const dim of query.dimensions ?? []) {
1245
+ for (const dim of this.projectedDimensions(query)) {
1123
1246
  if (crossByDim.has(dim)) {
1124
1247
  if (dim in row) out[dim] = row[dim];
1125
1248
  } else {
@@ -1127,11 +1250,6 @@ var ObjectQLStrategy = class {
1127
1250
  if (field in row) out[dim] = row[field];
1128
1251
  }
1129
1252
  }
1130
- for (const td of query.timeDimensions ?? []) {
1131
- if (query.dimensions?.includes(td.dimension)) continue;
1132
- const field = this.resolveFieldName(cube, td.dimension, "dimension");
1133
- if (field in row) out[td.dimension] = row[field];
1134
- }
1135
1253
  for (const m of query.measures ?? []) {
1136
1254
  if (m in row) out[m] = row[m];
1137
1255
  }
@@ -1274,6 +1392,75 @@ var ObjectQLStrategy = class {
1274
1392
  * are handed back for the caller to AND in separately, so the engine
1275
1393
  * intersects them instead of the strategy picking a winner.
1276
1394
  */
1395
+ /**
1396
+ * Fold a normalized filter node into the engine filter being built.
1397
+ *
1398
+ * AND-ed LEAVES merge per field through {@link mergeFilterOperand}, exactly
1399
+ * as the flat loop this replaced did — so a query without combinators still
1400
+ * produces byte-identical engine input. Anything structural (`$or`, `$not`,
1401
+ * a nested `$and` that cannot merge) becomes its own conjunct, which the
1402
+ * caller ANDs in. The engine speaks these combinators natively
1403
+ * (`FilterCondition` declares them and every driver compiles them), so this
1404
+ * path hands them over rather than lowering them.
1405
+ */
1406
+ applyFilterNode(node, cube, filter, conjuncts) {
1407
+ if (!node) return;
1408
+ if (node.kind === "leaf") {
1409
+ const fieldName = this.resolveFieldName(cube, node.member, "any");
1410
+ const extra = this.mergeFilterOperand(filter, fieldName, this.convertFilter(node.operator, node.values));
1411
+ if (extra) conjuncts.push(extra);
1412
+ return;
1413
+ }
1414
+ if (node.kind === "and") {
1415
+ for (const child of node.children) this.applyFilterNode(child, cube, filter, conjuncts);
1416
+ return;
1417
+ }
1418
+ const rendered = this.filterNodeToCondition(node, cube);
1419
+ if (rendered) conjuncts.push(rendered);
1420
+ }
1421
+ /** A node as a standalone `FilterCondition` the engine can consume. */
1422
+ filterNodeToCondition(node, cube) {
1423
+ if (!node) return null;
1424
+ if (node.kind === "not") {
1425
+ const inner = this.filterNodeToCondition(node.child, cube);
1426
+ return inner ? { $not: inner } : null;
1427
+ }
1428
+ if (node.kind === "or") {
1429
+ const branches = node.children.map((child) => this.filterNodeToCondition(child, cube)).filter((c) => !!c);
1430
+ return branches.length > 0 ? { $or: branches } : null;
1431
+ }
1432
+ const filter = {};
1433
+ const conjuncts = [];
1434
+ this.applyFilterNode(node, cube, filter, conjuncts);
1435
+ if (conjuncts.length > 0) {
1436
+ filter.$and = [...Array.isArray(filter.$and) ? filter.$and : [], ...conjuncts];
1437
+ }
1438
+ return Object.keys(filter).length > 0 ? filter : null;
1439
+ }
1440
+ /**
1441
+ * Render a normalized filter node as the display SQL `/analytics/sql`
1442
+ * echoes. Values still bind as `$n` placeholders — the echo travels to the
1443
+ * browser, so a comparand is never inlined.
1444
+ */
1445
+ renderFilterNodeSql(node, cube, params) {
1446
+ if (!node) return null;
1447
+ if (node.kind === "leaf") {
1448
+ return this.buildFilterClauseSql(
1449
+ this.resolveFieldName(cube, node.member, "any"),
1450
+ node.operator,
1451
+ node.values,
1452
+ params
1453
+ );
1454
+ }
1455
+ if (node.kind === "not") {
1456
+ const inner = this.renderFilterNodeSql(node.child, cube, params);
1457
+ return inner ? `NOT (${inner})` : null;
1458
+ }
1459
+ const parts = node.children.map((child) => this.renderFilterNodeSql(child, cube, params)).filter((s) => !!s);
1460
+ if (parts.length === 0) return null;
1461
+ if (parts.length === 1) return parts[0];
1462
+ return `(${parts.join(node.kind === "or" ? " OR " : " AND ")})`;
1463
+ }
1277
1464
  mergeFilterOperand(filter, field, operand) {
1278
1465
  const existing = filter[field];
1279
1466
  if (existing === void 0) {
@@ -1297,9 +1484,13 @@ var ObjectQLStrategy = class {
1297
1484
  * HERE on every driver — and "bucketed trend" is precisely the shape that also
1298
1485
  * carries a range ("last 12 months", "this quarter").
1299
1486
  *
1300
- * Bounds are inclusive on both ends — the same `$gte`/`$lte` pair
1301
- * `NativeSQLStrategy` binds as `BETWEEN` and the memory driver builds as a
1302
- * `$match`, so one dashboard reads the same on every driver.
1487
+ * Bounds are inclusive on both ends — logically "from day X through day Y".
1488
+ * The `$lte` end is left as the bare calendar day on purpose: the driver's
1489
+ * filter compiler owns the calendar-day instant translation, compiling a
1490
+ * bare-day `$lte` on a `datetime` column into the half-open `< nextDay`
1491
+ * (#3777) while a `date` column keeps the plain `<=`. `NativeSQLStrategy`
1492
+ * performs the same half-open translation itself because it binds into raw
1493
+ * SQL, so one dashboard reads the same on every driver.
1303
1494
  *
1304
1495
  * Comparands are coerced by the SAME helper the `where` path uses, so an
1305
1496
  * epoch-ms bound recovers as a number and an ISO string stays a string. No
@@ -1359,24 +1550,60 @@ var ObjectQLStrategy = class {
1359
1550
  return { $lte: v0 };
1360
1551
  case "contains":
1361
1552
  return { $regex: values[0] };
1553
+ // `notContains` had no arm and fell to the `default` below, which returns
1554
+ // a BARE VALUE — i.e. `{field: 'x'}`, an equality. "does not contain x"
1555
+ // was compiled as "equals x". These three pass through as the canonical
1556
+ // spec operators every driver implements directly, so an anchored match
1557
+ // stays anchored rather than depending on regex dialect (#4128).
1558
+ case "notContains":
1559
+ return { $notContains: values[0] };
1560
+ case "startsWith":
1561
+ return { $startsWith: values[0] };
1562
+ case "endsWith":
1563
+ return { $endsWith: values[0] };
1362
1564
  case "in":
1363
1565
  return { $in: all };
1364
1566
  case "notIn":
1365
1567
  return { $nin: all };
1366
1568
  default:
1367
- return v0;
1569
+ throw new Error(
1570
+ `[analytics] ObjectQL strategy cannot express filter operator "${operator}". Treating it as an equality would silently query something the author did not ask for.`
1571
+ );
1368
1572
  }
1369
1573
  }
1370
1574
  extractObjectName(cube) {
1371
1575
  return cube.sql.trim();
1372
1576
  }
1577
+ /**
1578
+ * The dimensions this query PROJECTS, in the order the result carries them:
1579
+ * every `dimensions` entry, then every granular `timeDimensions` entry that
1580
+ * is not already one of them.
1581
+ *
1582
+ * `timeDimensions` is not merely a filter carrier. An entry with a
1583
+ * `granularity` is GROUPED BY — see the `td.granularity` sites that build
1584
+ * groupBy here, in `generateSql` and in the cross-object path — so its
1585
+ * bucket is a COLUMN of the result; an entry without one only contributes a
1586
+ * `dateRange` predicate and must NOT be projected.
1587
+ *
1588
+ * Grouping, row mapping and {@link buildFieldMeta} have to agree on exactly
1589
+ * that set. When they did not, a bucketed query returned rows carrying only
1590
+ * the measures and a `fields` list that never mentioned the bucket — a trend
1591
+ * chart got N values and no x-axis (#4033) — even though the SQL had
1592
+ * selected `date_trunc(…) AS "<dim>"` all along. One definition, every
1593
+ * consumer.
1594
+ */
1595
+ projectedDimensions(query) {
1596
+ const out = [...query.dimensions ?? []];
1597
+ for (const td of query.timeDimensions ?? []) {
1598
+ if (td.granularity && !out.includes(td.dimension)) out.push(td.dimension);
1599
+ }
1600
+ return out;
1601
+ }
1373
1602
  buildFieldMeta(query, cube) {
1374
1603
  const fields = [];
1375
- if (query.dimensions) {
1376
- for (const dim of query.dimensions) {
1377
- const d = this.lookupMember(cube, dim, "dimension");
1378
- fields.push({ name: dim, type: d?.type || "string" });
1379
- }
1604
+ for (const dim of this.projectedDimensions(query)) {
1605
+ const d = this.lookupMember(cube, dim, "dimension");
1606
+ fields.push({ name: dim, type: d?.type || "string" });
1380
1607
  }
1381
1608
  if (query.measures) {
1382
1609
  for (const m of query.measures) {
@@ -1388,14 +1615,16 @@ var ObjectQLStrategy = class {
1388
1615
  };
1389
1616
 
1390
1617
  // src/dataset-compiler.ts
1618
+ var import_data = require("@objectstack/spec/data");
1391
1619
  var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set(["array_agg", "string_agg"]);
1620
+ var SUPPORTED_AGGREGATES = import_data.AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
1392
1621
  function aggregateToMetricType(m) {
1393
1622
  if (!m.aggregate) {
1394
1623
  throw new Error(`[dataset-compiler] non-derived measure "${m.name}" has no aggregate`);
1395
1624
  }
1396
1625
  if (UNSUPPORTED_AGGREGATES.has(m.aggregate)) {
1397
1626
  throw new Error(
1398
- `[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).`
1627
+ `[dataset-compiler] measure "${m.name}" uses aggregate "${m.aggregate}" which is not supported by the v1 dataset runtime (supported: ${SUPPORTED_AGGREGATES.join(", ")}).`
1399
1628
  );
1400
1629
  }
1401
1630
  return m.aggregate;
@@ -1522,10 +1751,11 @@ function compileDataset(dataset, resolver) {
1522
1751
  }
1523
1752
 
1524
1753
  // src/dataset-executor.ts
1525
- var import_core = require("@objectstack/core");
1754
+ var import_data2 = require("@objectstack/spec/data");
1755
+ var import_core3 = require("@objectstack/core");
1526
1756
  function resolveSelectionTokens(compiled, selection, context) {
1527
- const tokenCtx = (0, import_core.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
1528
- const resolve = (v) => (0, import_core.resolveFilterTokens)(v, tokenCtx);
1757
+ const tokenCtx = (0, import_core3.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
1758
+ const resolve = (v) => (0, import_core3.resolveFilterTokens)(v, tokenCtx);
1529
1759
  const filter = resolve(compiled.filter);
1530
1760
  const measureFilters = resolve(compiled.measureFilters);
1531
1761
  const runtimeFilter = resolve(selection.runtimeFilter);
@@ -1543,6 +1773,12 @@ function combineFilters(a, b) {
1543
1773
  if (a && b) return { $and: [a, b] };
1544
1774
  return a ?? b;
1545
1775
  }
1776
+ function splitMeasuresByFilter(measures, measureFilters) {
1777
+ const unfiltered = [];
1778
+ const filtered = [];
1779
+ for (const m of measures) (measureFilters[m] ? filtered : unfiltered).push(m);
1780
+ return { unfiltered, filtered };
1781
+ }
1546
1782
  function evaluateDerivedMeasures(rows, derived) {
1547
1783
  if (derived.length === 0) return rows;
1548
1784
  return rows.map((row) => {
@@ -1553,6 +1789,14 @@ function evaluateDerivedMeasures(rows, derived) {
1553
1789
  return out;
1554
1790
  });
1555
1791
  }
1792
+ function fillEmptyGroups(rows, columnAggregates) {
1793
+ for (const [column, aggregate2] of Object.entries(columnAggregates)) {
1794
+ const empty = (0, import_data2.emptyGroupValueFor)(aggregate2);
1795
+ if (empty === void 0) continue;
1796
+ for (const row of rows) if (row[column] == null) row[column] = empty;
1797
+ }
1798
+ return rows;
1799
+ }
1556
1800
  function num(v) {
1557
1801
  if (v == null) return null;
1558
1802
  const n = typeof v === "number" ? v : Number(v);
@@ -1622,7 +1866,7 @@ function applyWindow(rows, limit, offset) {
1622
1866
  if (start === 0 && limit == null) return rows;
1623
1867
  return rows.slice(start, limit != null ? start + limit : void 0);
1624
1868
  }
1625
- function resolveOrdering(selection, dimensions) {
1869
+ function resolveOrdering(selection, dimensions, timeDimensions = []) {
1626
1870
  const order = selection.order;
1627
1871
  if (order && Object.keys(order).length > 0) {
1628
1872
  const selectable = /* @__PURE__ */ new Set([
@@ -1641,6 +1885,10 @@ function resolveOrdering(selection, dimensions) {
1641
1885
  if ((selection.limit != null || selection.offset != null) && dimensions.length > 0) {
1642
1886
  return Object.fromEntries(dimensions.map((d) => [d, "asc"]));
1643
1887
  }
1888
+ const timeKeys = timeDimensions.filter((d) => dimensions.includes(d));
1889
+ if (timeKeys.length > 0) {
1890
+ return Object.fromEntries(timeKeys.map((d) => [d, "asc"]));
1891
+ }
1644
1892
  return void 0;
1645
1893
  }
1646
1894
  function parseUTC(date) {
@@ -1727,14 +1975,10 @@ var DatasetExecutor = class {
1727
1975
  for (const d of selectedDerived) {
1728
1976
  for (const dep of d.of) baseMeasures.add(dep);
1729
1977
  }
1730
- const unfiltered = [];
1731
- const filtered = [];
1732
- for (const m of baseMeasures) {
1733
- (compiled.measureFilters[m] ? filtered : unfiltered).push(m);
1734
- }
1978
+ const { unfiltered, filtered } = splitMeasuresByFilter(baseMeasures, compiled.measureFilters);
1735
1979
  const baseFilter = combineFilters(compiled.filter, selection.runtimeFilter);
1736
1980
  const dimensions = selection.dimensions ?? [];
1737
- const order = resolveOrdering(selection, dimensions);
1981
+ const order = resolveOrdering(selection, dimensions, this.timeDimensionsOf(compiled, dimensions));
1738
1982
  const labelOrderKeys = this.orderLabels ? Object.keys(order ?? {}).filter(
1739
1983
  (k) => dimensions.includes(k) && this.orderLabels.isLabelBearing(k)
1740
1984
  ) : [];
@@ -1742,6 +1986,76 @@ var DatasetExecutor = class {
1742
1986
  const pushDownKeys = /* @__PURE__ */ new Set([...dimensions, ...unfiltered]);
1743
1987
  const canPushDownWindow = singleQuery && labelOrderKeys.length === 0 && Object.keys(order ?? {}).every((k) => pushDownKeys.has(k));
1744
1988
  const windowQuery = canPushDownWindow ? { order, limit: selection.limit, offset: selection.offset } : void 0;
1989
+ const result = await this.runMeasurePass(compiled, selection, {
1990
+ measures: [...baseMeasures],
1991
+ dimensions,
1992
+ baseFilter,
1993
+ window: windowQuery,
1994
+ context
1995
+ });
1996
+ if (selection.compareTo) {
1997
+ const compareRows = await this.runCompare(compiled, selection, [...baseMeasures], dimensions, baseFilter, context);
1998
+ result.rows = mergeByDimensions(
1999
+ result.rows,
2000
+ compareRows,
2001
+ dimensions,
2002
+ [...baseMeasures].map((m) => `${m}__compare`)
2003
+ );
2004
+ for (const m of baseMeasures) result.fields.push({ name: `${m}__compare`, type: "number" });
2005
+ }
2006
+ const fillColumns = {};
2007
+ for (const m of baseMeasures) {
2008
+ const aggregate2 = compiled.cube.measures?.[m]?.type;
2009
+ fillColumns[m] = aggregate2;
2010
+ if (selection.compareTo) fillColumns[`${m}__compare`] = aggregate2;
2011
+ }
2012
+ fillEmptyGroups(result.rows, fillColumns);
2013
+ result.rows = evaluateDerivedMeasures(result.rows, selectedDerived);
2014
+ for (const d of selectedDerived) result.fields.push({ name: d.name, type: "number" });
2015
+ let sortKeys;
2016
+ for (const key of labelOrderKeys) {
2017
+ const values = [...new Set(result.rows.map((r) => r[key]).filter((v) => v != null))];
2018
+ if (values.length === 0) continue;
2019
+ const labels = await this.orderLabels.resolveLabels(key, values);
2020
+ if (labels && labels.size > 0) (sortKeys ?? (sortKeys = {}))[key] = labels;
2021
+ }
2022
+ result.rows = applyOrdering(result.rows, order, sortKeys);
2023
+ result.rows = applyWindow(result.rows, selection.limit, selection.offset);
2024
+ return result;
2025
+ }
2026
+ /**
2027
+ * Run ONE grouped pass over a set of base measures, honouring each measure's
2028
+ * own scoped `filter`: the unfiltered measures in a single query, plus one
2029
+ * supplementary query per filter-scoped measure, merged back by dimension key.
2030
+ *
2031
+ * **This is the executor's only implementation of "how a measure filter is
2032
+ * applied", and every window goes through it** — the current period, each
2033
+ * `totals` subset (which re-enters via `executeSelection`), and the
2034
+ * `compareTo` window. Before #4820 the comparison window had its own,
2035
+ * simpler answer: one shifted query over all base measures with only the
2036
+ * base filter, so `compiled.measureFilters` was never read on that path.
2037
+ * `won_count` counted won deals and `won_count__compare` counted every deal,
2038
+ * under one label, in adjacent columns. Only measures carrying a filter were
2039
+ * wrong — which is what made it survive: the unfiltered ones next to them
2040
+ * compared correctly.
2041
+ *
2042
+ * The caller supplies the `selection` this pass queries under, which is how
2043
+ * the comparison window differs at all: same measures, same dimensions, same
2044
+ * filters — a `timeDimensions` shifted by {@link shiftRange}. Nothing else
2045
+ * about the two passes may drift, because anything that does becomes a
2046
+ * discrepancy between two columns the reader is invited to subtract.
2047
+ *
2048
+ * Cost: one extra query per filter-scoped measure when `compareTo` is set.
2049
+ * The alternative — declaring the discrepancy in the response — is not one,
2050
+ * since the two columns exist to be directly comparable.
2051
+ *
2052
+ * @param window - Ordering/window to push into the SQL. Only ever set for a
2053
+ * selection the caller proved is a single self-sufficient query; a pass
2054
+ * that fans out must return its whole grid for the merge.
2055
+ */
2056
+ async runMeasurePass(compiled, selection, opts) {
2057
+ const { measures, dimensions, baseFilter, window, context } = opts;
2058
+ const { unfiltered, filtered } = splitMeasuresByFilter(measures, compiled.measureFilters);
1745
2059
  let result;
1746
2060
  if (unfiltered.length > 0 || filtered.length === 0) {
1747
2061
  result = await this.service.query(this.buildQuery(compiled, {
@@ -1750,7 +2064,7 @@ var DatasetExecutor = class {
1750
2064
  where: baseFilter,
1751
2065
  selection,
1752
2066
  contextTimezone: context?.timezone,
1753
- window: windowQuery
2067
+ window
1754
2068
  }), context);
1755
2069
  } else {
1756
2070
  result = { rows: [], fields: [] };
@@ -1767,29 +2081,21 @@ var DatasetExecutor = class {
1767
2081
  result.rows = mergeByDimensions(result.rows, sub.rows, dimensions, [m]);
1768
2082
  result.fields.push({ name: m, type: "number" });
1769
2083
  }
1770
- if (selection.compareTo) {
1771
- const compareRows = await this.runCompare(compiled, selection, [...baseMeasures], dimensions, baseFilter, context);
1772
- result.rows = mergeByDimensions(
1773
- result.rows,
1774
- compareRows,
1775
- dimensions,
1776
- [...baseMeasures].map((m) => `${m}__compare`)
1777
- );
1778
- for (const m of baseMeasures) result.fields.push({ name: `${m}__compare`, type: "number" });
1779
- }
1780
- result.rows = evaluateDerivedMeasures(result.rows, selectedDerived);
1781
- for (const d of selectedDerived) result.fields.push({ name: d.name, type: "number" });
1782
- let sortKeys;
1783
- for (const key of labelOrderKeys) {
1784
- const values = [...new Set(result.rows.map((r) => r[key]).filter((v) => v != null))];
1785
- if (values.length === 0) continue;
1786
- const labels = await this.orderLabels.resolveLabels(key, values);
1787
- if (labels && labels.size > 0) (sortKeys ?? (sortKeys = {}))[key] = labels;
1788
- }
1789
- result.rows = applyOrdering(result.rows, order, sortKeys);
1790
- result.rows = applyWindow(result.rows, selection.limit, selection.offset);
1791
2084
  return result;
1792
2085
  }
2086
+ /**
2087
+ * The selected dimensions the compiled cube types as `time`, in selection
2088
+ * order (#3916) — the axis {@link resolveOrdering} defaults to ascending.
2089
+ *
2090
+ * Membership is decided by the DIMENSION's declared type, not by whether the
2091
+ * selection happens to bucket it: a `date` dimension left ungranulated groups
2092
+ * raw timestamps, and those want chronological order every bit as much as
2093
+ * month buckets do. (Both sort correctly — `compareValues` compares Dates and
2094
+ * ISO strings chronologically, and bucket keys are minted sort-stable.)
2095
+ */
2096
+ timeDimensionsOf(compiled, dimensions) {
2097
+ return dimensions.filter((d) => compiled.cube.dimensions[d]?.type === "time");
2098
+ }
1793
2099
  buildQuery(compiled, opts) {
1794
2100
  const q = {
1795
2101
  cube: compiled.cube.name,
@@ -1839,13 +2145,11 @@ var DatasetExecutor = class {
1839
2145
  const shiftedTd = (selection.timeDimensions ?? []).map(
1840
2146
  (t) => t.dimension === cmp.dimension ? { ...t, dateRange: shifted } : t
1841
2147
  );
1842
- const sub = await this.service.query(this.buildQuery(compiled, {
1843
- measures,
1844
- dimensions,
1845
- where: baseFilter,
1846
- selection: { ...selection, timeDimensions: shiftedTd },
1847
- contextTimezone: context?.timezone
1848
- }), context);
2148
+ const sub = await this.runMeasurePass(
2149
+ compiled,
2150
+ { ...selection, timeDimensions: shiftedTd },
2151
+ { measures, dimensions, baseFilter, context }
2152
+ );
1849
2153
  return sub.rows.map((row) => {
1850
2154
  const out = {};
1851
2155
  for (const dim of dimensions) out[dim] = row[dim];
@@ -2031,11 +2335,21 @@ function pickDisplayField(fields) {
2031
2335
  }
2032
2336
 
2033
2337
  // src/preview-evaluator.ts
2034
- var import_core2 = require("@objectstack/core");
2338
+ var import_core4 = require("@objectstack/core");
2035
2339
  function compare(a, b) {
2036
2340
  if (typeof a === "number" && typeof b === "number") return a - b;
2341
+ if (a instanceof Date || b instanceof Date) {
2342
+ const ai = (0, import_core4.utcInstantMs)(a);
2343
+ const bi = (0, import_core4.utcInstantMs)(b);
2344
+ if (ai !== null && bi !== null) return ai - bi;
2345
+ }
2037
2346
  return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0;
2038
2347
  }
2348
+ function lteBound(value, bound) {
2349
+ const nextDay = (0, import_core4.nextUtcCalendarDay)(bound);
2350
+ if (nextDay != null) return compare(value, nextDay) < 0;
2351
+ return compare(value, bound) <= 0;
2352
+ }
2039
2353
  function matchOp(value, op, expected) {
2040
2354
  switch (op) {
2041
2355
  case "$eq":
@@ -2048,8 +2362,16 @@ function matchOp(value, op, expected) {
2048
2362
  return value != null && compare(value, expected) >= 0;
2049
2363
  case "$lt":
2050
2364
  return value != null && compare(value, expected) < 0;
2051
- case "$lte":
2052
- return value != null && compare(value, expected) <= 0;
2365
+ case "$lte": {
2366
+ if (value == null) return false;
2367
+ return lteBound(value, expected);
2368
+ }
2369
+ case "$between": {
2370
+ if (value == null || !Array.isArray(expected) || expected.length !== 2) return false;
2371
+ const [min, max] = expected;
2372
+ if (min == null || max == null) return false;
2373
+ return compare(value, min) >= 0 && lteBound(value, max);
2374
+ }
2053
2375
  case "$in":
2054
2376
  return Array.isArray(expected) && expected.some((e) => value === e || String(value) === String(e));
2055
2377
  case "$nin":
@@ -2082,7 +2404,7 @@ function matchesWhere(row, where) {
2082
2404
  function bucketDate(value, granularity, timezone) {
2083
2405
  const d = new Date(String(value));
2084
2406
  if (Number.isNaN(d.getTime())) return null;
2085
- const { year: y, month, day: dayNum } = (0, import_core2.calendarPartsInTzOrUtc)(d, timezone);
2407
+ const { year: y, month, day: dayNum } = (0, import_core4.calendarPartsInTzOrUtc)(d, timezone);
2086
2408
  const m = `${month}`.padStart(2, "0");
2087
2409
  const day = `${dayNum}`.padStart(2, "0");
2088
2410
  switch (granularity) {
@@ -2136,7 +2458,9 @@ function evaluateAnalyticsQueryOverRows(query, cube, rows) {
2136
2458
  const [start, end] = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
2137
2459
  filtered = filtered.filter((r) => {
2138
2460
  const v = String(r[field] ?? "");
2139
- return v >= String(start) && v <= `${end}~`;
2461
+ const nextDay = (0, import_core4.nextUtcCalendarDay)(end);
2462
+ const inUpper = nextDay != null ? v < nextDay : v <= `${end}~`;
2463
+ return v >= String(start) && inUpper;
2140
2464
  });
2141
2465
  }
2142
2466
  const dimensions = query.dimensions ?? [];
@@ -2195,6 +2519,7 @@ function isMissingSourceError(err) {
2195
2519
  msg.includes("not registered") || // framework: object not in registry
2196
2520
  msg.includes("unknown object") || msg.includes("is not a registered object");
2197
2521
  }
2522
+ var BARE_IDENTIFIER = /^[a-z_][a-z0-9_]*$/i;
2198
2523
  var DEFAULT_CAPABILITIES = {
2199
2524
  nativeSql: false,
2200
2525
  objectqlAggregate: false,
@@ -2206,17 +2531,18 @@ var AnalyticsService = class {
2206
2531
  this.datasetRegistry = /* @__PURE__ */ new Map();
2207
2532
  /** [#3867] One-shot flag for the {@link assertInferableCube} stand-down warning. */
2208
2533
  this.warnedNoObjectRegistry = false;
2209
- this.logger = config.logger || (0, import_core3.createLogger)({ level: "info", format: "pretty" });
2534
+ this.logger = config.logger || (0, import_core5.createLogger)({ level: "info", format: "pretty" });
2210
2535
  this.cubeRegistry = new CubeRegistry();
2211
2536
  if (config.cubes) {
2212
2537
  this.cubeRegistry.registerAll(config.cubes);
2213
2538
  }
2214
2539
  this.readScopeProvider = config.getReadScope;
2215
2540
  this.relationshipResolver = config.relationshipResolver;
2216
- this.measureCurrency = config.measureCurrency;
2541
+ this.sourceFieldMeta = config.sourceFieldMeta;
2217
2542
  this.labelResolver = config.labelResolver;
2218
2543
  this.draftRowsResolver = config.draftRowsResolver;
2219
2544
  this.isRegisteredObject = config.isRegisteredObject;
2545
+ this.getObjectFieldNames = config.getObjectFieldNames;
2220
2546
  if (config.datasets) {
2221
2547
  for (const ds of config.datasets) {
2222
2548
  try {
@@ -2236,6 +2562,7 @@ var AnalyticsService = class {
2236
2562
  // fall back to any explicitly-configured provider for legacy cubes.
2237
2563
  getAllowedRelationships: (cubeName) => this.datasetRegistry.get(cubeName)?.allowedRelationships ?? config.getAllowedRelationships?.(cubeName),
2238
2564
  coerceTemporalFilterValue: config.coerceTemporalFilterValue,
2565
+ coerceTemporalFilterColumn: config.coerceTemporalFilterColumn,
2239
2566
  isExternalObject: config.isExternalObject
2240
2567
  };
2241
2568
  const builtIn = [
@@ -2433,17 +2760,17 @@ var AnalyticsService = class {
2433
2760
  if (!d.field || d.type !== "date") continue;
2434
2761
  const granularity = resolveDimensionGranularity(selection, d.name, d.dateGranularity);
2435
2762
  if (!granularity) continue;
2436
- const ftype = this.measureCurrency?.(dataset.object, d.field)?.type;
2763
+ const ftype = this.sourceFieldMeta?.(dataset.object, d.field)?.type;
2437
2764
  if (ftype === "datetime") rangeDims.push({ d, granularity, instant: true });
2438
2765
  else if (ftype === "date") rangeDims.push({ d, granularity, instant: false });
2439
2766
  else if (rangeTz === "UTC") rangeDims.push({ d, granularity, instant: false });
2440
2767
  }
2441
2768
  if (rangeDims.length && result.rows.length) {
2442
- const bound = (ymd, instant) => instant ? new Date((0, import_core3.zonedDateStartToUtcMs)(ymd, rangeTz)).toISOString() : ymd;
2769
+ const bound = (ymd, instant) => instant ? new Date((0, import_core5.zonedDateStartToUtcMs)(ymd, rangeTz)).toISOString() : ymd;
2443
2770
  result.drillRanges = result.rows.map((row) => {
2444
2771
  const ranges = {};
2445
2772
  for (const { d, granularity, instant } of rangeDims) {
2446
- const cal = (0, import_core3.bucketKeyToCalendarRange)(row[d.name], granularity);
2773
+ const cal = (0, import_core5.bucketKeyToCalendarRange)(row[d.name], granularity);
2447
2774
  if (cal) {
2448
2775
  ranges[d.name] = { field: d.field, gte: bound(cal.start, instant), lt: bound(cal.end, instant) };
2449
2776
  }
@@ -2482,14 +2809,17 @@ var AnalyticsService = class {
2482
2809
  if (f.format == null && m.format) f.format = m.format;
2483
2810
  const fc = f;
2484
2811
  const mc = m;
2812
+ const meta = m.field ? this.sourceFieldMeta?.(dataset.object, m.field) : void 0;
2485
2813
  if (fc.currency == null) {
2486
- const meta = m.field ? this.measureCurrency?.(dataset.object, m.field) : void 0;
2487
2814
  const monetary = !!mc.currency || meta?.type === "currency";
2488
2815
  if (monetary) {
2489
2816
  const resolved = mc.currency ?? meta?.defaultCurrency ?? context?.currency;
2490
2817
  if (resolved) fc.currency = resolved;
2491
2818
  }
2492
2819
  }
2820
+ if (f.percentScale == null) {
2821
+ f.percentScale = m.derived?.op === "ratio" ? "fraction" : (0, import_data3.percentScaleOf)(meta);
2822
+ }
2493
2823
  }
2494
2824
  }
2495
2825
  if (result.fields?.length && selectedDims.length) {
@@ -2555,6 +2885,7 @@ var AnalyticsService = class {
2555
2885
  if (!cube) {
2556
2886
  this.assertInferableCube(name);
2557
2887
  cube = this.inferCubeFromQuery(query);
2888
+ this.assertMeasureFields(query, cube, Object.keys(cube.measures));
2558
2889
  this.cubeRegistry.register(cube);
2559
2890
  const isScalarMetric = (query.dimensions?.length ?? 0) === 0 && (query.timeDimensions?.length ?? 0) === 0;
2560
2891
  const message = `[Analytics] No cube registered for "${name}"; auto-inferred a minimal cube (sql="${name}", measures=${Object.keys(cube.measures).join(",") || "(none)"}, dimensions=${Object.keys(cube.dimensions).join(",") || "(none)"}). Define an explicit Cube in your stack for full control.`;
@@ -2574,10 +2905,82 @@ var AnalyticsService = class {
2574
2905
  ...cube,
2575
2906
  measures: { ...cube.measures, ...extraMeasures }
2576
2907
  };
2908
+ this.assertMeasureFields(query, augmented, Object.keys(cube.measures));
2577
2909
  this.cubeRegistry.register(augmented);
2578
2910
  this.logger.debug(
2579
2911
  `[Analytics] Augmented cube "${name}" with inferred measures: ${Object.keys(extraMeasures).join(",")}`
2580
2912
  );
2913
+ } else {
2914
+ this.assertMeasureFields(query, cube, Object.keys(cube.measures));
2915
+ }
2916
+ }
2917
+ /**
2918
+ * [#4437] Reject a measure whose SOURCE FIELD the backing object does not
2919
+ * have, BEFORE the strategy compiles it into SQL.
2920
+ *
2921
+ * `inferMeasure` maps a suffix convention onto a field name and has no way to
2922
+ * know whether that field exists: `ghost_sum` happily became `SUM(ghost)`, the
2923
+ * driver threw `no such column`, and the caller got
2924
+ * `500 {"code":"SQLITE_ERROR","message":"Internal server error"}` — a driver
2925
+ * error class on the wire, and nothing actionable, for what is a plain typo.
2926
+ * The DATA route has refused the same mistake with a `400 INVALID_FIELD`
2927
+ * naming the field since #4315/#4254; this is the analytics half of that
2928
+ * answer, and it is deliberately the SAME envelope (`code`/`field`/`object`/
2929
+ * `param`) so one mistake has one shape across both routes.
2930
+ *
2931
+ * What it checks, and what it deliberately does not:
2932
+ *
2933
+ * - Only when the cube's `sql` is a bare OBJECT NAME. An authored cube whose
2934
+ * `sql` is a real SQL expression has no field list to check against.
2935
+ * - Only when {@link AnalyticsServiceConfig.getObjectFieldNames} answers.
2936
+ * Absent hook / unknown object → stand down (see the config field's doc).
2937
+ * - Only measures whose source is a BARE COLUMN. `count(*)` has no source
2938
+ * field, and a dotted reference (`account.industry`) resolves through a
2939
+ * join whose target this check cannot see — both pass through untouched.
2940
+ * - `id` / `created_at` / `updated_at` are admitted unconditionally, matching
2941
+ * the data path's `resolveQueryFields`: they are engine-assigned rather than
2942
+ * declared, and a gate stricter than the engine it guards would reject
2943
+ * queries that used to work.
2944
+ */
2945
+ assertMeasureFields(query, cube, declaredMeasures) {
2946
+ const probe = this.getObjectFieldNames;
2947
+ if (!probe) return;
2948
+ const measures = query.measures ?? [];
2949
+ if (measures.length === 0) return;
2950
+ const object = typeof cube.sql === "string" ? cube.sql.trim() : "";
2951
+ if (!object || !BARE_IDENTIFIER.test(object)) return;
2952
+ const fieldNames = probe(object);
2953
+ if (!fieldNames || fieldNames.length === 0) return;
2954
+ const known = /* @__PURE__ */ new Set([...fieldNames, "id", "created_at", "updated_at"]);
2955
+ const stripPrefix = (m) => m.includes(".") ? m.split(".").slice(1).join(".") : m;
2956
+ const sourceFieldOf = (measure) => {
2957
+ const metric = cube.measures[stripPrefix(measure)];
2958
+ if (!metric) return null;
2959
+ if (metric.type === "count" && (metric.sql === "*" || metric.sql == null)) return null;
2960
+ const source = typeof metric.sql === "string" ? metric.sql.trim() : "";
2961
+ if (!source || source === "*" || !BARE_IDENTIFIER.test(source)) return null;
2962
+ return source;
2963
+ };
2964
+ const invalid = /* @__PURE__ */ new Set();
2965
+ for (const measure of measures) {
2966
+ const source = sourceFieldOf(measure);
2967
+ if (source && !known.has(source)) invalid.add(stripPrefix(measure));
2968
+ }
2969
+ if (invalid.size === 0) return;
2970
+ const usable = declaredMeasures.filter((m) => !invalid.has(m));
2971
+ for (const measure of measures) {
2972
+ const source = sourceFieldOf(measure);
2973
+ if (!source || known.has(source)) continue;
2974
+ const err = new Error(
2975
+ `Measure '${measure}' on cube '${cube.name}' aggregates field '${source}', which object '${object}' does not have. Valid measures: ${usable.join(", ") || "(none)"}. Other measures are inferred from the object's OWN fields as '<field>_sum' / '_avg' / '_min' / '_max' / '_count_distinct', so check the spelling of '${source}' \u2014 known fields: ${[...fieldNames].sort().join(", ")}.`
2976
+ );
2977
+ err.code = "INVALID_FIELD";
2978
+ err.status = 400;
2979
+ err.field = source;
2980
+ err.object = object;
2981
+ err.param = "measures";
2982
+ err.measure = measure;
2983
+ throw err;
2581
2984
  }
2582
2985
  }
2583
2986
  /**
@@ -2723,9 +3126,22 @@ var FallbackDelegateStrategy = class {
2723
3126
  var AnalyticsServicePlugin = class {
2724
3127
  constructor(options = {}) {
2725
3128
  this.name = "com.objectstack.service-analytics";
3129
+ /**
3130
+ * Services init() registers on every path (ADR-0116, #4131) — lets the
3131
+ * kernel name this plugin when a consumer requires one before it inits.
3132
+ */
3133
+ this.providesServices = ["analytics"];
2726
3134
  this.version = "1.0.0";
2727
3135
  this.type = "standard";
2728
3136
  this.dependencies = [];
3137
+ /**
3138
+ * init() probes the `data` engine ObjectQLPlugin provides for the
3139
+ * auto-bridge — order-if-present so the probe verdict is deterministic
3140
+ * (ADR-0116, #4471). Soft, not hard: without an engine the plugin
3141
+ * degrades on purpose (per-query lazy resolution / explicit
3142
+ * `executeAggregate`).
3143
+ */
3144
+ this.optionalDependencies = ["com.objectstack.engine.objectql"];
2729
3145
  this.options = options;
2730
3146
  }
2731
3147
  async init(ctx) {
@@ -2925,6 +3341,17 @@ var AnalyticsServicePlugin = class {
2925
3341
  }
2926
3342
  return value;
2927
3343
  };
3344
+ const coerceTemporalFilterColumn = (objectName, fieldName, columnSql) => {
3345
+ try {
3346
+ const svc = ctx.getService("data");
3347
+ const driver = svc?.getDriverForObject?.(objectName);
3348
+ if (driver && typeof driver.temporalFilterColumnSql === "function") {
3349
+ return driver.temporalFilterColumnSql(objectName, fieldName, columnSql);
3350
+ }
3351
+ } catch {
3352
+ }
3353
+ return columnSql;
3354
+ };
2928
3355
  const config = {
2929
3356
  cubes: this.options.cubes,
2930
3357
  logger: ctx.logger,
@@ -2935,12 +3362,15 @@ var AnalyticsServicePlugin = class {
2935
3362
  getReadScope,
2936
3363
  getAllowedRelationships: this.options.getAllowedRelationships,
2937
3364
  coerceTemporalFilterValue,
3365
+ coerceTemporalFilterColumn,
2938
3366
  relationshipResolver,
2939
3367
  labelResolver,
2940
- // ADR-0053 — source-field currency metadata for the measure currency chain.
2941
- measureCurrency: (object, field) => {
3368
+ // Source-field metadata behind the display chains on result columns:
3369
+ // ADR-0053 currency (`currencyConfig.defaultCurrency`) and percent scale
3370
+ // (`max`, which is what marks whole-percent storage — objectui#3136).
3371
+ sourceFieldMeta: (object, field) => {
2942
3372
  const f = dataEngine()?.getObject?.(object)?.fields?.[field];
2943
- return f ? { type: f.type, defaultCurrency: f.currencyConfig?.defaultCurrency } : void 0;
3373
+ return f ? { type: f.type, max: f.max, defaultCurrency: f.currencyConfig?.defaultCurrency } : void 0;
2944
3374
  },
2945
3375
  // ADR-0062 D6 — a federated object carries an `external` block (ADR-0015).
2946
3376
  // Reported so NativeSQLStrategy declines it (its hand-compiled FROM would
@@ -2963,6 +3393,18 @@ var AnalyticsServicePlugin = class {
2963
3393
  if (!engine) return true;
2964
3394
  return engine.getObject?.(name) != null;
2965
3395
  },
3396
+ // [#4437] Field names for the measure source-field gate. Read from the
3397
+ // SAME schema registry `isRegisteredObject` above consults (and the data
3398
+ // path's #4315 gate reads), so "which fields exist" has one answer across
3399
+ // /data and /analytics. `undefined` — no engine, unknown object, or an
3400
+ // object with no field map (an external datasource whose columns are not
3401
+ // mirrored locally) — means "cannot answer", and the gate stands down.
3402
+ getObjectFieldNames: (objectName) => {
3403
+ const fields = dataEngine()?.getObject?.(objectName)?.fields;
3404
+ if (!fields || typeof fields !== "object") return void 0;
3405
+ const names = Object.keys(fields);
3406
+ return names.length > 0 ? names : void 0;
3407
+ },
2966
3408
  draftRowsResolver
2967
3409
  };
2968
3410
  if (autoBridgedReadScope && securityPresentAtInit) {
@@ -3019,6 +3461,7 @@ var AnalyticsServicePlugin = class {
3019
3461
  compileScopedFilterToSql,
3020
3462
  createOrderLabelResolver,
3021
3463
  evaluateDerivedMeasures,
3464
+ fillEmptyGroups,
3022
3465
  mergeByDimensions,
3023
3466
  pickDisplayField,
3024
3467
  resolveDimensionLabels,