@objectstack/service-analytics 17.0.0-rc.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.js CHANGED
@@ -128,7 +128,8 @@ var MONGO_TO_CUBE_OP = {
128
128
  $nin: "notIn",
129
129
  $contains: "contains",
130
130
  $notContains: "notContains",
131
- $exists: "set"
131
+ $startsWith: "startsWith",
132
+ $endsWith: "endsWith"
132
133
  };
133
134
  function stringifyForCube(v) {
134
135
  if (v == null) return "";
@@ -137,55 +138,102 @@ function stringifyForCube(v) {
137
138
  if (typeof v === "object") return JSON.stringify(v);
138
139
  return String(v);
139
140
  }
140
- function flattenCondition(cond, out) {
141
- for (const [key, raw] of Object.entries(cond)) {
142
- if (raw === void 0) continue;
143
- if (key === "$and" && Array.isArray(raw)) {
144
- for (const sub of raw) {
145
- if (sub && typeof sub === "object") {
146
- flattenCondition(sub, out);
141
+ function andOf(children) {
142
+ if (children.length === 0) return null;
143
+ if (children.length === 1) return children[0];
144
+ return { kind: "and", children };
145
+ }
146
+ function fieldLeaves(key, raw) {
147
+ const out = [];
148
+ const leaf = (operator, values) => {
149
+ out.push({ kind: "leaf", member: key, operator, values });
150
+ };
151
+ if (raw === null) {
152
+ leaf("notSet", []);
153
+ return out;
154
+ }
155
+ if (typeof raw === "object" && !Array.isArray(raw) && !(raw instanceof Date)) {
156
+ const wrapper = raw;
157
+ const opKeys = Object.keys(wrapper).filter((k) => k.startsWith("$"));
158
+ if (opKeys.length > 0) {
159
+ for (const opKey of opKeys) {
160
+ if (opKey === "$between") {
161
+ const v2 = wrapper[opKey];
162
+ if (!Array.isArray(v2) || v2.length !== 2) {
163
+ throw new Error(
164
+ `[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.`
165
+ );
166
+ }
167
+ leaf("gte", [stringifyForCube(v2[0])]);
168
+ leaf("lte", [stringifyForCube(v2[1])]);
169
+ continue;
170
+ }
171
+ if (opKey === "$null" || opKey === "$exists") {
172
+ const isNull = opKey === "$null" ? wrapper[opKey] === true : wrapper[opKey] === false;
173
+ leaf(isNull ? "notSet" : "set", []);
174
+ continue;
147
175
  }
176
+ const cubeOp = MONGO_TO_CUBE_OP[opKey];
177
+ if (!cubeOp) {
178
+ throw new Error(
179
+ `[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.`
180
+ );
181
+ }
182
+ const v = wrapper[opKey];
183
+ leaf(cubeOp, Array.isArray(v) ? v.map(stringifyForCube) : [stringifyForCube(v)]);
148
184
  }
149
- continue;
185
+ return out;
150
186
  }
151
- if (key === "$or" || key === "$not") continue;
152
- if (raw === null) {
153
- out.push({ member: key, operator: "notSet", values: [] });
154
- continue;
187
+ for (const [nestedKey, nestedVal] of Object.entries(wrapper)) {
188
+ out.push(...fieldLeaves(`${key}.${nestedKey}`, nestedVal));
155
189
  }
156
- if (typeof raw === "object" && !Array.isArray(raw) && !(raw instanceof Date)) {
157
- const wrapper = raw;
158
- const opKeys = Object.keys(wrapper).filter((k) => k.startsWith("$"));
159
- if (opKeys.length > 0) {
160
- for (const opKey of opKeys) {
161
- const cubeOp = MONGO_TO_CUBE_OP[opKey];
162
- if (!cubeOp) continue;
163
- const v = wrapper[opKey];
164
- const values = Array.isArray(v) ? v.map(stringifyForCube) : [stringifyForCube(v)];
165
- out.push({ member: key, operator: cubeOp, values });
166
- }
167
- continue;
168
- }
169
- for (const [nestedKey, nestedVal] of Object.entries(wrapper)) {
170
- flattenCondition({ [`${key}.${nestedKey}`]: nestedVal }, out);
190
+ return out;
191
+ }
192
+ if (Array.isArray(raw)) leaf("in", raw.map(stringifyForCube));
193
+ else leaf("equals", [stringifyForCube(raw)]);
194
+ return out;
195
+ }
196
+ function buildNode(cond) {
197
+ const children = [];
198
+ for (const [key, raw] of Object.entries(cond)) {
199
+ if (raw === void 0) continue;
200
+ if (key === "$and" || key === "$or") {
201
+ if (!Array.isArray(raw) || raw.length === 0) {
202
+ throw new Error(
203
+ `[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.`
204
+ );
171
205
  }
206
+ const branches = raw.map((sub) => sub && typeof sub === "object" ? buildNode(sub) : null).filter((n) => n !== null);
207
+ if (branches.length === 0) continue;
208
+ if (key === "$and") children.push(...branches);
209
+ else children.push(branches.length === 1 ? branches[0] : { kind: "or", children: branches });
172
210
  continue;
173
211
  }
174
- if (Array.isArray(raw)) {
175
- out.push({ member: key, operator: "in", values: raw.map(stringifyForCube) });
176
- } else {
177
- out.push({ member: key, operator: "equals", values: [stringifyForCube(raw)] });
212
+ if (key === "$not") {
213
+ const inner = raw && typeof raw === "object" ? buildNode(raw) : null;
214
+ if (inner) children.push({ kind: "not", child: inner });
215
+ continue;
216
+ }
217
+ if (key.startsWith("$")) {
218
+ throw new Error(
219
+ `[analytics] Unsupported top-level filter operator "${key}". Dropping it would silently widen the query to rows the filter excludes.`
220
+ );
178
221
  }
222
+ children.push(...fieldLeaves(key, raw));
179
223
  }
224
+ return andOf(children);
180
225
  }
181
- function normalizeAnalyticsFilters(query) {
182
- if (!query || typeof query !== "object") return [];
183
- const out = [];
226
+ function normalizeAnalyticsFilterTree(query) {
227
+ if (!query || typeof query !== "object") return null;
184
228
  const where = query.where;
185
- if (where && typeof where === "object" && !Array.isArray(where)) {
186
- flattenCondition(where, out);
187
- }
188
- return out;
229
+ if (!where || typeof where !== "object" || Array.isArray(where)) return null;
230
+ return buildNode(where);
231
+ }
232
+ function collectFilterLeaves(node) {
233
+ if (!node) return [];
234
+ if (node.kind === "leaf") return [{ member: node.member, operator: node.operator, values: node.values }];
235
+ if (node.kind === "not") return collectFilterLeaves(node.child);
236
+ return node.children.flatMap(collectFilterLeaves);
189
237
  }
190
238
  function recoverNumber(s) {
191
239
  if (/^-?\d+(\.\d+)?$/.test(s)) {
@@ -317,6 +365,18 @@ function compileOperator(col, op, val, field, params) {
317
365
  }
318
366
 
319
367
  // src/strategies/native-sql-strategy.ts
368
+ import { nextUtcCalendarDay } from "@objectstack/core";
369
+ var AGGREGATE_SQL = {
370
+ "count": () => "COUNT(*)",
371
+ "sum": (col) => `SUM(${col})`,
372
+ "avg": (col) => `AVG(${col})`,
373
+ "min": (col) => `MIN(${col})`,
374
+ "max": (col) => `MAX(${col})`,
375
+ "count_distinct": (col) => `COUNT(DISTINCT ${col})`
376
+ };
377
+ var SUPPORTED_AGGREGATE_SQL_KEYS = Object.keys(AGGREGATE_SQL);
378
+ var EXPRESSION_METRIC_TYPES = /* @__PURE__ */ new Set(["number", "string", "boolean"]);
379
+ var IDENTIFIER_PATH = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
320
380
  var NativeSQLStrategy = class {
321
381
  constructor() {
322
382
  this.name = "NativeSQLStrategy";
@@ -371,15 +431,15 @@ var NativeSQLStrategy = class {
371
431
  }
372
432
  }
373
433
  const whereClauses = [];
374
- const normalizedFilters = normalizeAnalyticsFilters(query);
375
- if (normalizedFilters.length > 0) {
376
- for (const filter of normalizedFilters) {
377
- const colExpr = this.resolveFieldSql(cube, filter.member, tableName, joins);
378
- const target = this.resolveStorageTarget(cube, filter.member, tableName);
379
- const clause = this.buildFilterClause(colExpr, filter.operator, filter.values, params, ctx, target);
380
- if (clause) whereClauses.push(clause);
381
- }
382
- }
434
+ const filterSql = this.compileFilterNode(
435
+ normalizeAnalyticsFilterTree(query),
436
+ cube,
437
+ tableName,
438
+ joins,
439
+ params,
440
+ ctx
441
+ );
442
+ if (filterSql) whereClauses.push(filterSql);
383
443
  if (query.timeDimensions && query.timeDimensions.length > 0) {
384
444
  for (const td of query.timeDimensions) {
385
445
  const colExpr = this.resolveFieldSql(cube, td.dimension, tableName, joins);
@@ -387,11 +447,17 @@ var NativeSQLStrategy = class {
387
447
  const range = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
388
448
  if (range.length === 2) {
389
449
  const td2 = this.resolveStorageTarget(cube, td.dimension, tableName);
390
- params.push(
391
- this.coerceTemporal(ctx, td2, range[0]),
392
- this.coerceTemporal(ctx, td2, range[1])
393
- );
394
- whereClauses.push(`${colExpr} BETWEEN $${params.length - 1} AND $${params.length}`);
450
+ const column = this.temporalColumn(ctx, td2, colExpr);
451
+ const nextDay = nextUtcCalendarDay(range[1]);
452
+ params.push(this.coerceTemporal(ctx, td2, range[0]));
453
+ const lower = `${column} >= $${params.length}`;
454
+ if (nextDay != null) {
455
+ params.push(this.coerceTemporal(ctx, td2, nextDay));
456
+ whereClauses.push(`(${lower} AND ${column} < $${params.length})`);
457
+ } else {
458
+ params.push(this.coerceTemporal(ctx, td2, range[1]));
459
+ whereClauses.push(`(${lower} AND ${column} <= $${params.length})`);
460
+ }
395
461
  }
396
462
  }
397
463
  }
@@ -489,6 +555,7 @@ var NativeSQLStrategy = class {
489
555
  }
490
556
  return rawSql;
491
557
  }
558
+ if (!IDENTIFIER_PATH.test(rawSql)) return rawSql;
492
559
  const segments = rawSql.split(".");
493
560
  const column = segments[segments.length - 1];
494
561
  const hops = segments.slice(0, -1);
@@ -548,24 +615,19 @@ var NativeSQLStrategy = class {
548
615
  }
549
616
  resolveMeasureSql(cube, member, parentTable, joins) {
550
617
  const measure = this.lookupMember(cube, member, "measure");
551
- if (!measure) return `COUNT(*)`;
552
- const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
553
- switch (measure.type) {
554
- case "count":
555
- return "COUNT(*)";
556
- case "sum":
557
- return `SUM(${col})`;
558
- case "avg":
559
- return `AVG(${col})`;
560
- case "min":
561
- return `MIN(${col})`;
562
- case "max":
563
- return `MAX(${col})`;
564
- case "count_distinct":
565
- return `COUNT(DISTINCT ${col})`;
566
- default:
567
- return `COUNT(*)`;
618
+ if (!measure) {
619
+ const declared = Object.keys(cube.measures ?? {});
620
+ throw new Error(
621
+ `[native-sql-strategy] cube "${cube.name}" declares no measure "${member}"` + (declared.length ? ` (declared: ${declared.join(", ")})` : " (it declares none)")
622
+ );
568
623
  }
624
+ const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
625
+ const wrap = AGGREGATE_SQL[measure.type];
626
+ if (wrap) return wrap(col);
627
+ if (EXPRESSION_METRIC_TYPES.has(measure.type)) return col;
628
+ throw new Error(
629
+ `[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(", ")}).`
630
+ );
569
631
  }
570
632
  resolveFieldSql(cube, member, parentTable, joins) {
571
633
  const dim = this.lookupMember(cube, member, "dimension");
@@ -618,7 +680,53 @@ var NativeSQLStrategy = class {
618
680
  }
619
681
  return coerceFilterValueForSql(value);
620
682
  }
621
- buildFilterClause(col, operator, values, params, ctx, target) {
683
+ /**
684
+ * The column side of {@link coerceTemporal}: normalise the reference so it
685
+ * reads in the storage form the comparand was coerced into.
686
+ *
687
+ * A SQLite `Field.datetime` column carries an INTEGER epoch (a `Date` write)
688
+ * and ISO TEXT (a REST/JSON write, a `NOW()` default — including the platform's
689
+ * own `created_at`) at the SAME time, so coercing the value alone fixes one half
690
+ * and empties the other. That is #3912: a `dateRange: last_30_days` on
691
+ * `created_date` read 0 with 29 rows in range. Every other column and dialect
692
+ * gets its reference back verbatim.
693
+ */
694
+ temporalColumn(ctx, target, col) {
695
+ if (typeof ctx.coerceTemporalFilterColumn !== "function") return col;
696
+ return ctx.coerceTemporalFilterColumn(target.object, target.field, col) || col;
697
+ }
698
+ /**
699
+ * Compile a normalized filter node into a boolean SQL expression, recursing
700
+ * through the combinators. `null` = no constraint.
701
+ *
702
+ * Leaves go through {@link buildFilterClause} exactly as they did when this
703
+ * was a flat loop, so the storage-form coercion and the calendar-day
704
+ * upper-bound rule (#3777) apply at every depth — including inside an `$or`,
705
+ * where a second, combinator-aware implementation would have been free to
706
+ * drift from the first.
707
+ *
708
+ * Parenthesisation is explicit rather than left to SQL's precedence: `AND`
709
+ * does bind tighter than `OR`, so `a AND b OR c` happens to be right, but
710
+ * being right by construction is what keeps a future edit from making it
711
+ * wrong.
712
+ */
713
+ compileFilterNode(node, cube, parentTable, joins, params, ctx) {
714
+ if (!node) return null;
715
+ if (node.kind === "leaf") {
716
+ const colExpr = this.resolveFieldSql(cube, node.member, parentTable, joins);
717
+ const target = this.resolveStorageTarget(cube, node.member, parentTable);
718
+ return this.buildFilterClause(colExpr, node.operator, node.values, params, ctx, target);
719
+ }
720
+ if (node.kind === "not") {
721
+ const inner = this.compileFilterNode(node.child, cube, parentTable, joins, params, ctx);
722
+ return inner ? `NOT (${inner})` : null;
723
+ }
724
+ const parts = node.children.map((child) => this.compileFilterNode(child, cube, parentTable, joins, params, ctx)).filter((s) => !!s);
725
+ if (parts.length === 0) return null;
726
+ if (parts.length === 1) return parts[0];
727
+ return `(${parts.join(node.kind === "or" ? " OR " : " AND ")})`;
728
+ }
729
+ buildFilterClause(rawCol, operator, values, params, ctx, target) {
622
730
  const opMap = {
623
731
  equals: "=",
624
732
  notEquals: "!=",
@@ -627,26 +735,42 @@ var NativeSQLStrategy = class {
627
735
  lt: "<",
628
736
  lte: "<=",
629
737
  contains: "LIKE",
630
- notContains: "NOT LIKE"
738
+ notContains: "NOT LIKE",
739
+ startsWith: "LIKE",
740
+ endsWith: "LIKE"
631
741
  };
632
- if (operator === "set") return `${col} IS NOT NULL`;
633
- if (operator === "notSet") return `${col} IS NULL`;
742
+ const likePattern = {
743
+ contains: (v) => `%${v}%`,
744
+ notContains: (v) => `%${v}%`,
745
+ startsWith: (v) => `${v}%`,
746
+ endsWith: (v) => `%${v}`
747
+ };
748
+ if (operator === "set") return `${rawCol} IS NOT NULL`;
749
+ if (operator === "notSet") return `${rawCol} IS NULL`;
634
750
  if (operator === "in" || operator === "notIn") {
635
751
  if (!values || values.length === 0) return null;
636
752
  const placeholders = values.map((v) => {
637
753
  params.push(this.coerceTemporal(ctx, target, v));
638
754
  return `$${params.length}`;
639
755
  }).join(", ");
640
- return `${col} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
756
+ return `${this.temporalColumn(ctx, target, rawCol)} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
641
757
  }
642
758
  const sqlOp = opMap[operator];
643
759
  if (!sqlOp || !values || values.length === 0) return null;
644
- if (operator === "contains" || operator === "notContains") {
645
- params.push(`%${values[0]}%`);
646
- } else {
647
- params.push(this.coerceTemporal(ctx, target, values[0]));
760
+ const pattern = likePattern[operator];
761
+ if (pattern) {
762
+ params.push(pattern(values[0]));
763
+ return `${rawCol} ${sqlOp} $${params.length}`;
764
+ }
765
+ if (operator === "lte") {
766
+ const nextDay = nextUtcCalendarDay(values[0]);
767
+ if (nextDay != null) {
768
+ params.push(this.coerceTemporal(ctx, target, nextDay));
769
+ return `${this.temporalColumn(ctx, target, rawCol)} < $${params.length}`;
770
+ }
648
771
  }
649
- return `${col} ${sqlOp} $${params.length}`;
772
+ params.push(this.coerceTemporal(ctx, target, values[0]));
773
+ return `${this.temporalColumn(ctx, target, rawCol)} ${sqlOp} $${params.length}`;
650
774
  }
651
775
  extractObjectName(cube) {
652
776
  return cube.sql.trim();
@@ -668,6 +792,9 @@ var NativeSQLStrategy = class {
668
792
  }
669
793
  };
670
794
 
795
+ // src/strategies/objectql-strategy.ts
796
+ import { nextUtcCalendarDay as nextUtcCalendarDay2 } from "@objectstack/core";
797
+
671
798
  // src/strategies/cross-object-rebucket.ts
672
799
  var RECOMBINABLE_METHODS = /* @__PURE__ */ new Set([
673
800
  "sum",
@@ -770,11 +897,7 @@ var ObjectQLStrategy = class {
770
897
  }
771
898
  const filter = {};
772
899
  const conjuncts = [];
773
- for (const f of normalizeAnalyticsFilters(query)) {
774
- const fieldName = this.resolveFieldName(cube, f.member, "any");
775
- const extra = this.mergeFilterOperand(filter, fieldName, this.convertFilter(f.operator, f.values));
776
- if (extra) conjuncts.push(extra);
777
- }
900
+ this.applyFilterNode(normalizeAnalyticsFilterTree(query), cube, filter, conjuncts);
778
901
  for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
779
902
  const extra = this.mergeFilterOperand(filter, field, bounds);
780
903
  if (extra) conjuncts.push(extra);
@@ -806,11 +929,9 @@ var ObjectQLStrategy = class {
806
929
  });
807
930
  const mappedRows = rows.map((row) => {
808
931
  const mapped = {};
809
- if (query.dimensions) {
810
- for (const dim of query.dimensions) {
811
- const shortName = this.resolveFieldName(cube, dim, "dimension");
812
- if (shortName in row) mapped[dim] = row[shortName];
813
- }
932
+ for (const dim of this.projectedDimensions(query)) {
933
+ const shortName = this.resolveFieldName(cube, dim, "dimension");
934
+ if (shortName in row) mapped[dim] = row[shortName];
814
935
  }
815
936
  if (query.measures) {
816
937
  for (const m of query.measures) {
@@ -856,7 +977,7 @@ var ObjectQLStrategy = class {
856
977
  }
857
978
  const tableName = this.extractObjectName(cube);
858
979
  const plan = this.planCrossObject(cube, query, Object.fromEntries(
859
- normalizeAnalyticsFilters(query).map((f) => [this.resolveFieldName(cube, f.member, "any"), true])
980
+ collectFilterLeaves(normalizeAnalyticsFilterTree(query)).map((f) => [this.resolveFieldName(cube, f.member, "any"), true])
860
981
  ));
861
982
  const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd]));
862
983
  const joinClauses = [];
@@ -893,18 +1014,18 @@ var ObjectQLStrategy = class {
893
1014
  }
894
1015
  }
895
1016
  const whereParts = [];
896
- for (const f of normalizeAnalyticsFilters(query)) {
897
- const clause = this.buildFilterClauseSql(
898
- this.resolveFieldName(cube, f.member, "any"),
899
- f.operator,
900
- f.values,
901
- params
902
- );
903
- if (clause) whereParts.push(clause);
904
- }
1017
+ const filterClause = this.renderFilterNodeSql(
1018
+ normalizeAnalyticsFilterTree(query),
1019
+ cube,
1020
+ params
1021
+ );
1022
+ if (filterClause) whereParts.push(filterClause);
905
1023
  for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
906
- params.push(bounds.$gte, bounds.$lte);
907
- whereParts.push(`${field} BETWEEN $${params.length - 1} AND $${params.length}`);
1024
+ const nextDay = nextUtcCalendarDay2(bounds.$lte);
1025
+ params.push(bounds.$gte, nextDay ?? bounds.$lte);
1026
+ whereParts.push(
1027
+ `(${field} >= $${params.length - 1} AND ${field} ${nextDay ? "<" : "<="} $${params.length})`
1028
+ );
908
1029
  }
909
1030
  const scope = ctx.getReadScope?.(tableName);
910
1031
  if (scope != null) {
@@ -1078,7 +1199,7 @@ var ObjectQLStrategy = class {
1078
1199
  const merged = rebucketCrossObject(baseRows, baseDimFields, resolvedDims, measures);
1079
1200
  const mappedRows = merged.map((row) => {
1080
1201
  const out = {};
1081
- for (const dim of query.dimensions ?? []) {
1202
+ for (const dim of this.projectedDimensions(query)) {
1082
1203
  if (crossByDim.has(dim)) {
1083
1204
  if (dim in row) out[dim] = row[dim];
1084
1205
  } else {
@@ -1086,11 +1207,6 @@ var ObjectQLStrategy = class {
1086
1207
  if (field in row) out[dim] = row[field];
1087
1208
  }
1088
1209
  }
1089
- for (const td of query.timeDimensions ?? []) {
1090
- if (query.dimensions?.includes(td.dimension)) continue;
1091
- const field = this.resolveFieldName(cube, td.dimension, "dimension");
1092
- if (field in row) out[td.dimension] = row[field];
1093
- }
1094
1210
  for (const m of query.measures ?? []) {
1095
1211
  if (m in row) out[m] = row[m];
1096
1212
  }
@@ -1233,6 +1349,75 @@ var ObjectQLStrategy = class {
1233
1349
  * are handed back for the caller to AND in separately, so the engine
1234
1350
  * intersects them instead of the strategy picking a winner.
1235
1351
  */
1352
+ /**
1353
+ * Fold a normalized filter node into the engine filter being built.
1354
+ *
1355
+ * AND-ed LEAVES merge per field through {@link mergeFilterOperand}, exactly
1356
+ * as the flat loop this replaced did — so a query without combinators still
1357
+ * produces byte-identical engine input. Anything structural (`$or`, `$not`,
1358
+ * a nested `$and` that cannot merge) becomes its own conjunct, which the
1359
+ * caller ANDs in. The engine speaks these combinators natively
1360
+ * (`FilterCondition` declares them and every driver compiles them), so this
1361
+ * path hands them over rather than lowering them.
1362
+ */
1363
+ applyFilterNode(node, cube, filter, conjuncts) {
1364
+ if (!node) return;
1365
+ if (node.kind === "leaf") {
1366
+ const fieldName = this.resolveFieldName(cube, node.member, "any");
1367
+ const extra = this.mergeFilterOperand(filter, fieldName, this.convertFilter(node.operator, node.values));
1368
+ if (extra) conjuncts.push(extra);
1369
+ return;
1370
+ }
1371
+ if (node.kind === "and") {
1372
+ for (const child of node.children) this.applyFilterNode(child, cube, filter, conjuncts);
1373
+ return;
1374
+ }
1375
+ const rendered = this.filterNodeToCondition(node, cube);
1376
+ if (rendered) conjuncts.push(rendered);
1377
+ }
1378
+ /** A node as a standalone `FilterCondition` the engine can consume. */
1379
+ filterNodeToCondition(node, cube) {
1380
+ if (!node) return null;
1381
+ if (node.kind === "not") {
1382
+ const inner = this.filterNodeToCondition(node.child, cube);
1383
+ return inner ? { $not: inner } : null;
1384
+ }
1385
+ if (node.kind === "or") {
1386
+ const branches = node.children.map((child) => this.filterNodeToCondition(child, cube)).filter((c) => !!c);
1387
+ return branches.length > 0 ? { $or: branches } : null;
1388
+ }
1389
+ const filter = {};
1390
+ const conjuncts = [];
1391
+ this.applyFilterNode(node, cube, filter, conjuncts);
1392
+ if (conjuncts.length > 0) {
1393
+ filter.$and = [...Array.isArray(filter.$and) ? filter.$and : [], ...conjuncts];
1394
+ }
1395
+ return Object.keys(filter).length > 0 ? filter : null;
1396
+ }
1397
+ /**
1398
+ * Render a normalized filter node as the display SQL `/analytics/sql`
1399
+ * echoes. Values still bind as `$n` placeholders — the echo travels to the
1400
+ * browser, so a comparand is never inlined.
1401
+ */
1402
+ renderFilterNodeSql(node, cube, params) {
1403
+ if (!node) return null;
1404
+ if (node.kind === "leaf") {
1405
+ return this.buildFilterClauseSql(
1406
+ this.resolveFieldName(cube, node.member, "any"),
1407
+ node.operator,
1408
+ node.values,
1409
+ params
1410
+ );
1411
+ }
1412
+ if (node.kind === "not") {
1413
+ const inner = this.renderFilterNodeSql(node.child, cube, params);
1414
+ return inner ? `NOT (${inner})` : null;
1415
+ }
1416
+ const parts = node.children.map((child) => this.renderFilterNodeSql(child, cube, params)).filter((s) => !!s);
1417
+ if (parts.length === 0) return null;
1418
+ if (parts.length === 1) return parts[0];
1419
+ return `(${parts.join(node.kind === "or" ? " OR " : " AND ")})`;
1420
+ }
1236
1421
  mergeFilterOperand(filter, field, operand) {
1237
1422
  const existing = filter[field];
1238
1423
  if (existing === void 0) {
@@ -1256,9 +1441,13 @@ var ObjectQLStrategy = class {
1256
1441
  * HERE on every driver — and "bucketed trend" is precisely the shape that also
1257
1442
  * carries a range ("last 12 months", "this quarter").
1258
1443
  *
1259
- * Bounds are inclusive on both ends — the same `$gte`/`$lte` pair
1260
- * `NativeSQLStrategy` binds as `BETWEEN` and the memory driver builds as a
1261
- * `$match`, so one dashboard reads the same on every driver.
1444
+ * Bounds are inclusive on both ends — logically "from day X through day Y".
1445
+ * The `$lte` end is left as the bare calendar day on purpose: the driver's
1446
+ * filter compiler owns the calendar-day instant translation, compiling a
1447
+ * bare-day `$lte` on a `datetime` column into the half-open `< nextDay`
1448
+ * (#3777) while a `date` column keeps the plain `<=`. `NativeSQLStrategy`
1449
+ * performs the same half-open translation itself because it binds into raw
1450
+ * SQL, so one dashboard reads the same on every driver.
1262
1451
  *
1263
1452
  * Comparands are coerced by the SAME helper the `where` path uses, so an
1264
1453
  * epoch-ms bound recovers as a number and an ISO string stays a string. No
@@ -1318,24 +1507,60 @@ var ObjectQLStrategy = class {
1318
1507
  return { $lte: v0 };
1319
1508
  case "contains":
1320
1509
  return { $regex: values[0] };
1510
+ // `notContains` had no arm and fell to the `default` below, which returns
1511
+ // a BARE VALUE — i.e. `{field: 'x'}`, an equality. "does not contain x"
1512
+ // was compiled as "equals x". These three pass through as the canonical
1513
+ // spec operators every driver implements directly, so an anchored match
1514
+ // stays anchored rather than depending on regex dialect (#4128).
1515
+ case "notContains":
1516
+ return { $notContains: values[0] };
1517
+ case "startsWith":
1518
+ return { $startsWith: values[0] };
1519
+ case "endsWith":
1520
+ return { $endsWith: values[0] };
1321
1521
  case "in":
1322
1522
  return { $in: all };
1323
1523
  case "notIn":
1324
1524
  return { $nin: all };
1325
1525
  default:
1326
- return v0;
1526
+ throw new Error(
1527
+ `[analytics] ObjectQL strategy cannot express filter operator "${operator}". Treating it as an equality would silently query something the author did not ask for.`
1528
+ );
1327
1529
  }
1328
1530
  }
1329
1531
  extractObjectName(cube) {
1330
1532
  return cube.sql.trim();
1331
1533
  }
1534
+ /**
1535
+ * The dimensions this query PROJECTS, in the order the result carries them:
1536
+ * every `dimensions` entry, then every granular `timeDimensions` entry that
1537
+ * is not already one of them.
1538
+ *
1539
+ * `timeDimensions` is not merely a filter carrier. An entry with a
1540
+ * `granularity` is GROUPED BY — see the `td.granularity` sites that build
1541
+ * groupBy here, in `generateSql` and in the cross-object path — so its
1542
+ * bucket is a COLUMN of the result; an entry without one only contributes a
1543
+ * `dateRange` predicate and must NOT be projected.
1544
+ *
1545
+ * Grouping, row mapping and {@link buildFieldMeta} have to agree on exactly
1546
+ * that set. When they did not, a bucketed query returned rows carrying only
1547
+ * the measures and a `fields` list that never mentioned the bucket — a trend
1548
+ * chart got N values and no x-axis (#4033) — even though the SQL had
1549
+ * selected `date_trunc(…) AS "<dim>"` all along. One definition, every
1550
+ * consumer.
1551
+ */
1552
+ projectedDimensions(query) {
1553
+ const out = [...query.dimensions ?? []];
1554
+ for (const td of query.timeDimensions ?? []) {
1555
+ if (td.granularity && !out.includes(td.dimension)) out.push(td.dimension);
1556
+ }
1557
+ return out;
1558
+ }
1332
1559
  buildFieldMeta(query, cube) {
1333
1560
  const fields = [];
1334
- if (query.dimensions) {
1335
- for (const dim of query.dimensions) {
1336
- const d = this.lookupMember(cube, dim, "dimension");
1337
- fields.push({ name: dim, type: d?.type || "string" });
1338
- }
1561
+ for (const dim of this.projectedDimensions(query)) {
1562
+ const d = this.lookupMember(cube, dim, "dimension");
1563
+ fields.push({ name: dim, type: d?.type || "string" });
1339
1564
  }
1340
1565
  if (query.measures) {
1341
1566
  for (const m of query.measures) {
@@ -1347,14 +1572,16 @@ var ObjectQLStrategy = class {
1347
1572
  };
1348
1573
 
1349
1574
  // src/dataset-compiler.ts
1575
+ import { AggregationFunction } from "@objectstack/spec/data";
1350
1576
  var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set(["array_agg", "string_agg"]);
1577
+ var SUPPORTED_AGGREGATES = AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
1351
1578
  function aggregateToMetricType(m) {
1352
1579
  if (!m.aggregate) {
1353
1580
  throw new Error(`[dataset-compiler] non-derived measure "${m.name}" has no aggregate`);
1354
1581
  }
1355
1582
  if (UNSUPPORTED_AGGREGATES.has(m.aggregate)) {
1356
1583
  throw new Error(
1357
- `[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).`
1584
+ `[dataset-compiler] measure "${m.name}" uses aggregate "${m.aggregate}" which is not supported by the v1 dataset runtime (supported: ${SUPPORTED_AGGREGATES.join(", ")}).`
1358
1585
  );
1359
1586
  }
1360
1587
  return m.aggregate;
@@ -1581,7 +1808,7 @@ function applyWindow(rows, limit, offset) {
1581
1808
  if (start === 0 && limit == null) return rows;
1582
1809
  return rows.slice(start, limit != null ? start + limit : void 0);
1583
1810
  }
1584
- function resolveOrdering(selection, dimensions) {
1811
+ function resolveOrdering(selection, dimensions, timeDimensions = []) {
1585
1812
  const order = selection.order;
1586
1813
  if (order && Object.keys(order).length > 0) {
1587
1814
  const selectable = /* @__PURE__ */ new Set([
@@ -1600,6 +1827,10 @@ function resolveOrdering(selection, dimensions) {
1600
1827
  if ((selection.limit != null || selection.offset != null) && dimensions.length > 0) {
1601
1828
  return Object.fromEntries(dimensions.map((d) => [d, "asc"]));
1602
1829
  }
1830
+ const timeKeys = timeDimensions.filter((d) => dimensions.includes(d));
1831
+ if (timeKeys.length > 0) {
1832
+ return Object.fromEntries(timeKeys.map((d) => [d, "asc"]));
1833
+ }
1603
1834
  return void 0;
1604
1835
  }
1605
1836
  function parseUTC(date) {
@@ -1693,7 +1924,7 @@ var DatasetExecutor = class {
1693
1924
  }
1694
1925
  const baseFilter = combineFilters(compiled.filter, selection.runtimeFilter);
1695
1926
  const dimensions = selection.dimensions ?? [];
1696
- const order = resolveOrdering(selection, dimensions);
1927
+ const order = resolveOrdering(selection, dimensions, this.timeDimensionsOf(compiled, dimensions));
1697
1928
  const labelOrderKeys = this.orderLabels ? Object.keys(order ?? {}).filter(
1698
1929
  (k) => dimensions.includes(k) && this.orderLabels.isLabelBearing(k)
1699
1930
  ) : [];
@@ -1749,6 +1980,19 @@ var DatasetExecutor = class {
1749
1980
  result.rows = applyWindow(result.rows, selection.limit, selection.offset);
1750
1981
  return result;
1751
1982
  }
1983
+ /**
1984
+ * The selected dimensions the compiled cube types as `time`, in selection
1985
+ * order (#3916) — the axis {@link resolveOrdering} defaults to ascending.
1986
+ *
1987
+ * Membership is decided by the DIMENSION's declared type, not by whether the
1988
+ * selection happens to bucket it: a `date` dimension left ungranulated groups
1989
+ * raw timestamps, and those want chronological order every bit as much as
1990
+ * month buckets do. (Both sort correctly — `compareValues` compares Dates and
1991
+ * ISO strings chronologically, and bucket keys are minted sort-stable.)
1992
+ */
1993
+ timeDimensionsOf(compiled, dimensions) {
1994
+ return dimensions.filter((d) => compiled.cube.dimensions[d]?.type === "time");
1995
+ }
1752
1996
  buildQuery(compiled, opts) {
1753
1997
  const q = {
1754
1998
  cube: compiled.cube.name,
@@ -1990,11 +2234,21 @@ function pickDisplayField(fields) {
1990
2234
  }
1991
2235
 
1992
2236
  // src/preview-evaluator.ts
1993
- import { calendarPartsInTzOrUtc } from "@objectstack/core";
2237
+ import { calendarPartsInTzOrUtc, nextUtcCalendarDay as nextUtcCalendarDay3, utcInstantMs } from "@objectstack/core";
1994
2238
  function compare(a, b) {
1995
2239
  if (typeof a === "number" && typeof b === "number") return a - b;
2240
+ if (a instanceof Date || b instanceof Date) {
2241
+ const ai = utcInstantMs(a);
2242
+ const bi = utcInstantMs(b);
2243
+ if (ai !== null && bi !== null) return ai - bi;
2244
+ }
1996
2245
  return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0;
1997
2246
  }
2247
+ function lteBound(value, bound) {
2248
+ const nextDay = nextUtcCalendarDay3(bound);
2249
+ if (nextDay != null) return compare(value, nextDay) < 0;
2250
+ return compare(value, bound) <= 0;
2251
+ }
1998
2252
  function matchOp(value, op, expected) {
1999
2253
  switch (op) {
2000
2254
  case "$eq":
@@ -2007,8 +2261,16 @@ function matchOp(value, op, expected) {
2007
2261
  return value != null && compare(value, expected) >= 0;
2008
2262
  case "$lt":
2009
2263
  return value != null && compare(value, expected) < 0;
2010
- case "$lte":
2011
- return value != null && compare(value, expected) <= 0;
2264
+ case "$lte": {
2265
+ if (value == null) return false;
2266
+ return lteBound(value, expected);
2267
+ }
2268
+ case "$between": {
2269
+ if (value == null || !Array.isArray(expected) || expected.length !== 2) return false;
2270
+ const [min, max] = expected;
2271
+ if (min == null || max == null) return false;
2272
+ return compare(value, min) >= 0 && lteBound(value, max);
2273
+ }
2012
2274
  case "$in":
2013
2275
  return Array.isArray(expected) && expected.some((e) => value === e || String(value) === String(e));
2014
2276
  case "$nin":
@@ -2095,7 +2357,9 @@ function evaluateAnalyticsQueryOverRows(query, cube, rows) {
2095
2357
  const [start, end] = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
2096
2358
  filtered = filtered.filter((r) => {
2097
2359
  const v = String(r[field] ?? "");
2098
- return v >= String(start) && v <= `${end}~`;
2360
+ const nextDay = nextUtcCalendarDay3(end);
2361
+ const inUpper = nextDay != null ? v < nextDay : v <= `${end}~`;
2362
+ return v >= String(start) && inUpper;
2099
2363
  });
2100
2364
  }
2101
2365
  const dimensions = query.dimensions ?? [];
@@ -2195,6 +2459,7 @@ var AnalyticsService = class {
2195
2459
  // fall back to any explicitly-configured provider for legacy cubes.
2196
2460
  getAllowedRelationships: (cubeName) => this.datasetRegistry.get(cubeName)?.allowedRelationships ?? config.getAllowedRelationships?.(cubeName),
2197
2461
  coerceTemporalFilterValue: config.coerceTemporalFilterValue,
2462
+ coerceTemporalFilterColumn: config.coerceTemporalFilterColumn,
2198
2463
  isExternalObject: config.isExternalObject
2199
2464
  };
2200
2465
  const builtIn = [
@@ -2682,6 +2947,11 @@ var FallbackDelegateStrategy = class {
2682
2947
  var AnalyticsServicePlugin = class {
2683
2948
  constructor(options = {}) {
2684
2949
  this.name = "com.objectstack.service-analytics";
2950
+ /**
2951
+ * Services init() registers on every path (ADR-0116, #4131) — lets the
2952
+ * kernel name this plugin when a consumer requires one before it inits.
2953
+ */
2954
+ this.providesServices = ["analytics"];
2685
2955
  this.version = "1.0.0";
2686
2956
  this.type = "standard";
2687
2957
  this.dependencies = [];
@@ -2884,6 +3154,17 @@ var AnalyticsServicePlugin = class {
2884
3154
  }
2885
3155
  return value;
2886
3156
  };
3157
+ const coerceTemporalFilterColumn = (objectName, fieldName, columnSql) => {
3158
+ try {
3159
+ const svc = ctx.getService("data");
3160
+ const driver = svc?.getDriverForObject?.(objectName);
3161
+ if (driver && typeof driver.temporalFilterColumnSql === "function") {
3162
+ return driver.temporalFilterColumnSql(objectName, fieldName, columnSql);
3163
+ }
3164
+ } catch {
3165
+ }
3166
+ return columnSql;
3167
+ };
2887
3168
  const config = {
2888
3169
  cubes: this.options.cubes,
2889
3170
  logger: ctx.logger,
@@ -2894,6 +3175,7 @@ var AnalyticsServicePlugin = class {
2894
3175
  getReadScope,
2895
3176
  getAllowedRelationships: this.options.getAllowedRelationships,
2896
3177
  coerceTemporalFilterValue,
3178
+ coerceTemporalFilterColumn,
2897
3179
  relationshipResolver,
2898
3180
  labelResolver,
2899
3181
  // ADR-0053 — source-field currency metadata for the measure currency chain.