@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.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // src/analytics-service.ts
2
+ import { percentScaleOf } from "@objectstack/spec/data";
2
3
  import { createLogger, bucketKeyToCalendarRange, zonedDateStartToUtcMs } from "@objectstack/core";
3
4
 
4
5
  // src/cube-registry.ts
@@ -128,7 +129,8 @@ var MONGO_TO_CUBE_OP = {
128
129
  $nin: "notIn",
129
130
  $contains: "contains",
130
131
  $notContains: "notContains",
131
- $exists: "set"
132
+ $startsWith: "startsWith",
133
+ $endsWith: "endsWith"
132
134
  };
133
135
  function stringifyForCube(v) {
134
136
  if (v == null) return "";
@@ -137,55 +139,102 @@ function stringifyForCube(v) {
137
139
  if (typeof v === "object") return JSON.stringify(v);
138
140
  return String(v);
139
141
  }
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);
142
+ function andOf(children) {
143
+ if (children.length === 0) return null;
144
+ if (children.length === 1) return children[0];
145
+ return { kind: "and", children };
146
+ }
147
+ function fieldLeaves(key, raw) {
148
+ const out = [];
149
+ const leaf = (operator, values) => {
150
+ out.push({ kind: "leaf", member: key, operator, values });
151
+ };
152
+ if (raw === null) {
153
+ leaf("notSet", []);
154
+ return out;
155
+ }
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
+ if (opKey === "$between") {
162
+ const v2 = wrapper[opKey];
163
+ if (!Array.isArray(v2) || v2.length !== 2) {
164
+ throw new Error(
165
+ `[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.`
166
+ );
167
+ }
168
+ leaf("gte", [stringifyForCube(v2[0])]);
169
+ leaf("lte", [stringifyForCube(v2[1])]);
170
+ continue;
147
171
  }
172
+ if (opKey === "$null" || opKey === "$exists") {
173
+ const isNull = opKey === "$null" ? wrapper[opKey] === true : wrapper[opKey] === false;
174
+ leaf(isNull ? "notSet" : "set", []);
175
+ continue;
176
+ }
177
+ const cubeOp = MONGO_TO_CUBE_OP[opKey];
178
+ if (!cubeOp) {
179
+ throw new Error(
180
+ `[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.`
181
+ );
182
+ }
183
+ const v = wrapper[opKey];
184
+ leaf(cubeOp, Array.isArray(v) ? v.map(stringifyForCube) : [stringifyForCube(v)]);
148
185
  }
149
- continue;
186
+ return out;
150
187
  }
151
- if (key === "$or" || key === "$not") continue;
152
- if (raw === null) {
153
- out.push({ member: key, operator: "notSet", values: [] });
154
- continue;
188
+ for (const [nestedKey, nestedVal] of Object.entries(wrapper)) {
189
+ out.push(...fieldLeaves(`${key}.${nestedKey}`, nestedVal));
155
190
  }
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);
191
+ return out;
192
+ }
193
+ if (Array.isArray(raw)) leaf("in", raw.map(stringifyForCube));
194
+ else leaf("equals", [stringifyForCube(raw)]);
195
+ return out;
196
+ }
197
+ function buildNode(cond) {
198
+ const children = [];
199
+ for (const [key, raw] of Object.entries(cond)) {
200
+ if (raw === void 0) continue;
201
+ if (key === "$and" || key === "$or") {
202
+ if (!Array.isArray(raw) || raw.length === 0) {
203
+ throw new Error(
204
+ `[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.`
205
+ );
171
206
  }
207
+ const branches = raw.map((sub) => sub && typeof sub === "object" ? buildNode(sub) : null).filter((n) => n !== null);
208
+ if (branches.length === 0) continue;
209
+ if (key === "$and") children.push(...branches);
210
+ else children.push(branches.length === 1 ? branches[0] : { kind: "or", children: branches });
172
211
  continue;
173
212
  }
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)] });
213
+ if (key === "$not") {
214
+ const inner = raw && typeof raw === "object" ? buildNode(raw) : null;
215
+ if (inner) children.push({ kind: "not", child: inner });
216
+ continue;
217
+ }
218
+ if (key.startsWith("$")) {
219
+ throw new Error(
220
+ `[analytics] Unsupported top-level filter operator "${key}". Dropping it would silently widen the query to rows the filter excludes.`
221
+ );
178
222
  }
223
+ children.push(...fieldLeaves(key, raw));
179
224
  }
225
+ return andOf(children);
180
226
  }
181
- function normalizeAnalyticsFilters(query) {
182
- if (!query || typeof query !== "object") return [];
183
- const out = [];
227
+ function normalizeAnalyticsFilterTree(query) {
228
+ if (!query || typeof query !== "object") return null;
184
229
  const where = query.where;
185
- if (where && typeof where === "object" && !Array.isArray(where)) {
186
- flattenCondition(where, out);
187
- }
188
- return out;
230
+ if (!where || typeof where !== "object" || Array.isArray(where)) return null;
231
+ return buildNode(where);
232
+ }
233
+ function collectFilterLeaves(node) {
234
+ if (!node) return [];
235
+ if (node.kind === "leaf") return [{ member: node.member, operator: node.operator, values: node.values }];
236
+ if (node.kind === "not") return collectFilterLeaves(node.child);
237
+ return node.children.flatMap(collectFilterLeaves);
189
238
  }
190
239
  function recoverNumber(s) {
191
240
  if (/^-?\d+(\.\d+)?$/.test(s)) {
@@ -317,6 +366,18 @@ function compileOperator(col, op, val, field, params) {
317
366
  }
318
367
 
319
368
  // src/strategies/native-sql-strategy.ts
369
+ import { nextUtcCalendarDay } from "@objectstack/core";
370
+ var AGGREGATE_SQL = {
371
+ "count": () => "COUNT(*)",
372
+ "sum": (col) => `SUM(${col})`,
373
+ "avg": (col) => `AVG(${col})`,
374
+ "min": (col) => `MIN(${col})`,
375
+ "max": (col) => `MAX(${col})`,
376
+ "count_distinct": (col) => `COUNT(DISTINCT ${col})`
377
+ };
378
+ var SUPPORTED_AGGREGATE_SQL_KEYS = Object.keys(AGGREGATE_SQL);
379
+ var EXPRESSION_METRIC_TYPES = /* @__PURE__ */ new Set(["number", "string", "boolean"]);
380
+ var IDENTIFIER_PATH = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
320
381
  var NativeSQLStrategy = class {
321
382
  constructor() {
322
383
  this.name = "NativeSQLStrategy";
@@ -371,15 +432,15 @@ var NativeSQLStrategy = class {
371
432
  }
372
433
  }
373
434
  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
- }
435
+ const filterSql = this.compileFilterNode(
436
+ normalizeAnalyticsFilterTree(query),
437
+ cube,
438
+ tableName,
439
+ joins,
440
+ params,
441
+ ctx
442
+ );
443
+ if (filterSql) whereClauses.push(filterSql);
383
444
  if (query.timeDimensions && query.timeDimensions.length > 0) {
384
445
  for (const td of query.timeDimensions) {
385
446
  const colExpr = this.resolveFieldSql(cube, td.dimension, tableName, joins);
@@ -387,11 +448,17 @@ var NativeSQLStrategy = class {
387
448
  const range = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
388
449
  if (range.length === 2) {
389
450
  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}`);
451
+ const column = this.temporalColumn(ctx, td2, colExpr);
452
+ const nextDay = nextUtcCalendarDay(range[1]);
453
+ params.push(this.coerceTemporal(ctx, td2, range[0]));
454
+ const lower = `${column} >= $${params.length}`;
455
+ if (nextDay != null) {
456
+ params.push(this.coerceTemporal(ctx, td2, nextDay));
457
+ whereClauses.push(`(${lower} AND ${column} < $${params.length})`);
458
+ } else {
459
+ params.push(this.coerceTemporal(ctx, td2, range[1]));
460
+ whereClauses.push(`(${lower} AND ${column} <= $${params.length})`);
461
+ }
395
462
  }
396
463
  }
397
464
  }
@@ -489,6 +556,7 @@ var NativeSQLStrategy = class {
489
556
  }
490
557
  return rawSql;
491
558
  }
559
+ if (!IDENTIFIER_PATH.test(rawSql)) return rawSql;
492
560
  const segments = rawSql.split(".");
493
561
  const column = segments[segments.length - 1];
494
562
  const hops = segments.slice(0, -1);
@@ -548,24 +616,19 @@ var NativeSQLStrategy = class {
548
616
  }
549
617
  resolveMeasureSql(cube, member, parentTable, joins) {
550
618
  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(*)`;
619
+ if (!measure) {
620
+ const declared = Object.keys(cube.measures ?? {});
621
+ throw new Error(
622
+ `[native-sql-strategy] cube "${cube.name}" declares no measure "${member}"` + (declared.length ? ` (declared: ${declared.join(", ")})` : " (it declares none)")
623
+ );
568
624
  }
625
+ const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
626
+ const wrap = AGGREGATE_SQL[measure.type];
627
+ if (wrap) return wrap(col);
628
+ if (EXPRESSION_METRIC_TYPES.has(measure.type)) return col;
629
+ throw new Error(
630
+ `[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(", ")}).`
631
+ );
569
632
  }
570
633
  resolveFieldSql(cube, member, parentTable, joins) {
571
634
  const dim = this.lookupMember(cube, member, "dimension");
@@ -618,7 +681,53 @@ var NativeSQLStrategy = class {
618
681
  }
619
682
  return coerceFilterValueForSql(value);
620
683
  }
621
- buildFilterClause(col, operator, values, params, ctx, target) {
684
+ /**
685
+ * The column side of {@link coerceTemporal}: normalise the reference so it
686
+ * reads in the storage form the comparand was coerced into.
687
+ *
688
+ * A SQLite `Field.datetime` column carries an INTEGER epoch (a `Date` write)
689
+ * and ISO TEXT (a REST/JSON write, a `NOW()` default — including the platform's
690
+ * own `created_at`) at the SAME time, so coercing the value alone fixes one half
691
+ * and empties the other. That is #3912: a `dateRange: last_30_days` on
692
+ * `created_date` read 0 with 29 rows in range. Every other column and dialect
693
+ * gets its reference back verbatim.
694
+ */
695
+ temporalColumn(ctx, target, col) {
696
+ if (typeof ctx.coerceTemporalFilterColumn !== "function") return col;
697
+ return ctx.coerceTemporalFilterColumn(target.object, target.field, col) || col;
698
+ }
699
+ /**
700
+ * Compile a normalized filter node into a boolean SQL expression, recursing
701
+ * through the combinators. `null` = no constraint.
702
+ *
703
+ * Leaves go through {@link buildFilterClause} exactly as they did when this
704
+ * was a flat loop, so the storage-form coercion and the calendar-day
705
+ * upper-bound rule (#3777) apply at every depth — including inside an `$or`,
706
+ * where a second, combinator-aware implementation would have been free to
707
+ * drift from the first.
708
+ *
709
+ * Parenthesisation is explicit rather than left to SQL's precedence: `AND`
710
+ * does bind tighter than `OR`, so `a AND b OR c` happens to be right, but
711
+ * being right by construction is what keeps a future edit from making it
712
+ * wrong.
713
+ */
714
+ compileFilterNode(node, cube, parentTable, joins, params, ctx) {
715
+ if (!node) return null;
716
+ if (node.kind === "leaf") {
717
+ const colExpr = this.resolveFieldSql(cube, node.member, parentTable, joins);
718
+ const target = this.resolveStorageTarget(cube, node.member, parentTable);
719
+ return this.buildFilterClause(colExpr, node.operator, node.values, params, ctx, target);
720
+ }
721
+ if (node.kind === "not") {
722
+ const inner = this.compileFilterNode(node.child, cube, parentTable, joins, params, ctx);
723
+ return inner ? `NOT (${inner})` : null;
724
+ }
725
+ const parts = node.children.map((child) => this.compileFilterNode(child, cube, parentTable, joins, params, ctx)).filter((s) => !!s);
726
+ if (parts.length === 0) return null;
727
+ if (parts.length === 1) return parts[0];
728
+ return `(${parts.join(node.kind === "or" ? " OR " : " AND ")})`;
729
+ }
730
+ buildFilterClause(rawCol, operator, values, params, ctx, target) {
622
731
  const opMap = {
623
732
  equals: "=",
624
733
  notEquals: "!=",
@@ -627,26 +736,42 @@ var NativeSQLStrategy = class {
627
736
  lt: "<",
628
737
  lte: "<=",
629
738
  contains: "LIKE",
630
- notContains: "NOT LIKE"
739
+ notContains: "NOT LIKE",
740
+ startsWith: "LIKE",
741
+ endsWith: "LIKE"
631
742
  };
632
- if (operator === "set") return `${col} IS NOT NULL`;
633
- if (operator === "notSet") return `${col} IS NULL`;
743
+ const likePattern = {
744
+ contains: (v) => `%${v}%`,
745
+ notContains: (v) => `%${v}%`,
746
+ startsWith: (v) => `${v}%`,
747
+ endsWith: (v) => `%${v}`
748
+ };
749
+ if (operator === "set") return `${rawCol} IS NOT NULL`;
750
+ if (operator === "notSet") return `${rawCol} IS NULL`;
634
751
  if (operator === "in" || operator === "notIn") {
635
752
  if (!values || values.length === 0) return null;
636
753
  const placeholders = values.map((v) => {
637
754
  params.push(this.coerceTemporal(ctx, target, v));
638
755
  return `$${params.length}`;
639
756
  }).join(", ");
640
- return `${col} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
757
+ return `${this.temporalColumn(ctx, target, rawCol)} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
641
758
  }
642
759
  const sqlOp = opMap[operator];
643
760
  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]));
761
+ const pattern = likePattern[operator];
762
+ if (pattern) {
763
+ params.push(pattern(values[0]));
764
+ return `${rawCol} ${sqlOp} $${params.length}`;
648
765
  }
649
- return `${col} ${sqlOp} $${params.length}`;
766
+ if (operator === "lte") {
767
+ const nextDay = nextUtcCalendarDay(values[0]);
768
+ if (nextDay != null) {
769
+ params.push(this.coerceTemporal(ctx, target, nextDay));
770
+ return `${this.temporalColumn(ctx, target, rawCol)} < $${params.length}`;
771
+ }
772
+ }
773
+ params.push(this.coerceTemporal(ctx, target, values[0]));
774
+ return `${this.temporalColumn(ctx, target, rawCol)} ${sqlOp} $${params.length}`;
650
775
  }
651
776
  extractObjectName(cube) {
652
777
  return cube.sql.trim();
@@ -668,6 +793,9 @@ var NativeSQLStrategy = class {
668
793
  }
669
794
  };
670
795
 
796
+ // src/strategies/objectql-strategy.ts
797
+ import { nextUtcCalendarDay as nextUtcCalendarDay2 } from "@objectstack/core";
798
+
671
799
  // src/strategies/cross-object-rebucket.ts
672
800
  var RECOMBINABLE_METHODS = /* @__PURE__ */ new Set([
673
801
  "sum",
@@ -770,11 +898,7 @@ var ObjectQLStrategy = class {
770
898
  }
771
899
  const filter = {};
772
900
  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
- }
901
+ this.applyFilterNode(normalizeAnalyticsFilterTree(query), cube, filter, conjuncts);
778
902
  for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
779
903
  const extra = this.mergeFilterOperand(filter, field, bounds);
780
904
  if (extra) conjuncts.push(extra);
@@ -806,11 +930,9 @@ var ObjectQLStrategy = class {
806
930
  });
807
931
  const mappedRows = rows.map((row) => {
808
932
  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
- }
933
+ for (const dim of this.projectedDimensions(query)) {
934
+ const shortName = this.resolveFieldName(cube, dim, "dimension");
935
+ if (shortName in row) mapped[dim] = row[shortName];
814
936
  }
815
937
  if (query.measures) {
816
938
  for (const m of query.measures) {
@@ -856,7 +978,7 @@ var ObjectQLStrategy = class {
856
978
  }
857
979
  const tableName = this.extractObjectName(cube);
858
980
  const plan = this.planCrossObject(cube, query, Object.fromEntries(
859
- normalizeAnalyticsFilters(query).map((f) => [this.resolveFieldName(cube, f.member, "any"), true])
981
+ collectFilterLeaves(normalizeAnalyticsFilterTree(query)).map((f) => [this.resolveFieldName(cube, f.member, "any"), true])
860
982
  ));
861
983
  const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd]));
862
984
  const joinClauses = [];
@@ -893,18 +1015,18 @@ var ObjectQLStrategy = class {
893
1015
  }
894
1016
  }
895
1017
  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
- }
1018
+ const filterClause = this.renderFilterNodeSql(
1019
+ normalizeAnalyticsFilterTree(query),
1020
+ cube,
1021
+ params
1022
+ );
1023
+ if (filterClause) whereParts.push(filterClause);
905
1024
  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}`);
1025
+ const nextDay = nextUtcCalendarDay2(bounds.$lte);
1026
+ params.push(bounds.$gte, nextDay ?? bounds.$lte);
1027
+ whereParts.push(
1028
+ `(${field} >= $${params.length - 1} AND ${field} ${nextDay ? "<" : "<="} $${params.length})`
1029
+ );
908
1030
  }
909
1031
  const scope = ctx.getReadScope?.(tableName);
910
1032
  if (scope != null) {
@@ -1078,7 +1200,7 @@ var ObjectQLStrategy = class {
1078
1200
  const merged = rebucketCrossObject(baseRows, baseDimFields, resolvedDims, measures);
1079
1201
  const mappedRows = merged.map((row) => {
1080
1202
  const out = {};
1081
- for (const dim of query.dimensions ?? []) {
1203
+ for (const dim of this.projectedDimensions(query)) {
1082
1204
  if (crossByDim.has(dim)) {
1083
1205
  if (dim in row) out[dim] = row[dim];
1084
1206
  } else {
@@ -1086,11 +1208,6 @@ var ObjectQLStrategy = class {
1086
1208
  if (field in row) out[dim] = row[field];
1087
1209
  }
1088
1210
  }
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
1211
  for (const m of query.measures ?? []) {
1095
1212
  if (m in row) out[m] = row[m];
1096
1213
  }
@@ -1233,6 +1350,75 @@ var ObjectQLStrategy = class {
1233
1350
  * are handed back for the caller to AND in separately, so the engine
1234
1351
  * intersects them instead of the strategy picking a winner.
1235
1352
  */
1353
+ /**
1354
+ * Fold a normalized filter node into the engine filter being built.
1355
+ *
1356
+ * AND-ed LEAVES merge per field through {@link mergeFilterOperand}, exactly
1357
+ * as the flat loop this replaced did — so a query without combinators still
1358
+ * produces byte-identical engine input. Anything structural (`$or`, `$not`,
1359
+ * a nested `$and` that cannot merge) becomes its own conjunct, which the
1360
+ * caller ANDs in. The engine speaks these combinators natively
1361
+ * (`FilterCondition` declares them and every driver compiles them), so this
1362
+ * path hands them over rather than lowering them.
1363
+ */
1364
+ applyFilterNode(node, cube, filter, conjuncts) {
1365
+ if (!node) return;
1366
+ if (node.kind === "leaf") {
1367
+ const fieldName = this.resolveFieldName(cube, node.member, "any");
1368
+ const extra = this.mergeFilterOperand(filter, fieldName, this.convertFilter(node.operator, node.values));
1369
+ if (extra) conjuncts.push(extra);
1370
+ return;
1371
+ }
1372
+ if (node.kind === "and") {
1373
+ for (const child of node.children) this.applyFilterNode(child, cube, filter, conjuncts);
1374
+ return;
1375
+ }
1376
+ const rendered = this.filterNodeToCondition(node, cube);
1377
+ if (rendered) conjuncts.push(rendered);
1378
+ }
1379
+ /** A node as a standalone `FilterCondition` the engine can consume. */
1380
+ filterNodeToCondition(node, cube) {
1381
+ if (!node) return null;
1382
+ if (node.kind === "not") {
1383
+ const inner = this.filterNodeToCondition(node.child, cube);
1384
+ return inner ? { $not: inner } : null;
1385
+ }
1386
+ if (node.kind === "or") {
1387
+ const branches = node.children.map((child) => this.filterNodeToCondition(child, cube)).filter((c) => !!c);
1388
+ return branches.length > 0 ? { $or: branches } : null;
1389
+ }
1390
+ const filter = {};
1391
+ const conjuncts = [];
1392
+ this.applyFilterNode(node, cube, filter, conjuncts);
1393
+ if (conjuncts.length > 0) {
1394
+ filter.$and = [...Array.isArray(filter.$and) ? filter.$and : [], ...conjuncts];
1395
+ }
1396
+ return Object.keys(filter).length > 0 ? filter : null;
1397
+ }
1398
+ /**
1399
+ * Render a normalized filter node as the display SQL `/analytics/sql`
1400
+ * echoes. Values still bind as `$n` placeholders — the echo travels to the
1401
+ * browser, so a comparand is never inlined.
1402
+ */
1403
+ renderFilterNodeSql(node, cube, params) {
1404
+ if (!node) return null;
1405
+ if (node.kind === "leaf") {
1406
+ return this.buildFilterClauseSql(
1407
+ this.resolveFieldName(cube, node.member, "any"),
1408
+ node.operator,
1409
+ node.values,
1410
+ params
1411
+ );
1412
+ }
1413
+ if (node.kind === "not") {
1414
+ const inner = this.renderFilterNodeSql(node.child, cube, params);
1415
+ return inner ? `NOT (${inner})` : null;
1416
+ }
1417
+ const parts = node.children.map((child) => this.renderFilterNodeSql(child, cube, params)).filter((s) => !!s);
1418
+ if (parts.length === 0) return null;
1419
+ if (parts.length === 1) return parts[0];
1420
+ return `(${parts.join(node.kind === "or" ? " OR " : " AND ")})`;
1421
+ }
1236
1422
  mergeFilterOperand(filter, field, operand) {
1237
1423
  const existing = filter[field];
1238
1424
  if (existing === void 0) {
@@ -1256,9 +1442,13 @@ var ObjectQLStrategy = class {
1256
1442
  * HERE on every driver — and "bucketed trend" is precisely the shape that also
1257
1443
  * carries a range ("last 12 months", "this quarter").
1258
1444
  *
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.
1445
+ * Bounds are inclusive on both ends — logically "from day X through day Y".
1446
+ * The `$lte` end is left as the bare calendar day on purpose: the driver's
1447
+ * filter compiler owns the calendar-day instant translation, compiling a
1448
+ * bare-day `$lte` on a `datetime` column into the half-open `< nextDay`
1449
+ * (#3777) while a `date` column keeps the plain `<=`. `NativeSQLStrategy`
1450
+ * performs the same half-open translation itself because it binds into raw
1451
+ * SQL, so one dashboard reads the same on every driver.
1262
1452
  *
1263
1453
  * Comparands are coerced by the SAME helper the `where` path uses, so an
1264
1454
  * epoch-ms bound recovers as a number and an ISO string stays a string. No
@@ -1318,24 +1508,60 @@ var ObjectQLStrategy = class {
1318
1508
  return { $lte: v0 };
1319
1509
  case "contains":
1320
1510
  return { $regex: values[0] };
1511
+ // `notContains` had no arm and fell to the `default` below, which returns
1512
+ // a BARE VALUE — i.e. `{field: 'x'}`, an equality. "does not contain x"
1513
+ // was compiled as "equals x". These three pass through as the canonical
1514
+ // spec operators every driver implements directly, so an anchored match
1515
+ // stays anchored rather than depending on regex dialect (#4128).
1516
+ case "notContains":
1517
+ return { $notContains: values[0] };
1518
+ case "startsWith":
1519
+ return { $startsWith: values[0] };
1520
+ case "endsWith":
1521
+ return { $endsWith: values[0] };
1321
1522
  case "in":
1322
1523
  return { $in: all };
1323
1524
  case "notIn":
1324
1525
  return { $nin: all };
1325
1526
  default:
1326
- return v0;
1527
+ throw new Error(
1528
+ `[analytics] ObjectQL strategy cannot express filter operator "${operator}". Treating it as an equality would silently query something the author did not ask for.`
1529
+ );
1327
1530
  }
1328
1531
  }
1329
1532
  extractObjectName(cube) {
1330
1533
  return cube.sql.trim();
1331
1534
  }
1535
+ /**
1536
+ * The dimensions this query PROJECTS, in the order the result carries them:
1537
+ * every `dimensions` entry, then every granular `timeDimensions` entry that
1538
+ * is not already one of them.
1539
+ *
1540
+ * `timeDimensions` is not merely a filter carrier. An entry with a
1541
+ * `granularity` is GROUPED BY — see the `td.granularity` sites that build
1542
+ * groupBy here, in `generateSql` and in the cross-object path — so its
1543
+ * bucket is a COLUMN of the result; an entry without one only contributes a
1544
+ * `dateRange` predicate and must NOT be projected.
1545
+ *
1546
+ * Grouping, row mapping and {@link buildFieldMeta} have to agree on exactly
1547
+ * that set. When they did not, a bucketed query returned rows carrying only
1548
+ * the measures and a `fields` list that never mentioned the bucket — a trend
1549
+ * chart got N values and no x-axis (#4033) — even though the SQL had
1550
+ * selected `date_trunc(…) AS "<dim>"` all along. One definition, every
1551
+ * consumer.
1552
+ */
1553
+ projectedDimensions(query) {
1554
+ const out = [...query.dimensions ?? []];
1555
+ for (const td of query.timeDimensions ?? []) {
1556
+ if (td.granularity && !out.includes(td.dimension)) out.push(td.dimension);
1557
+ }
1558
+ return out;
1559
+ }
1332
1560
  buildFieldMeta(query, cube) {
1333
1561
  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
- }
1562
+ for (const dim of this.projectedDimensions(query)) {
1563
+ const d = this.lookupMember(cube, dim, "dimension");
1564
+ fields.push({ name: dim, type: d?.type || "string" });
1339
1565
  }
1340
1566
  if (query.measures) {
1341
1567
  for (const m of query.measures) {
@@ -1347,14 +1573,16 @@ var ObjectQLStrategy = class {
1347
1573
  };
1348
1574
 
1349
1575
  // src/dataset-compiler.ts
1576
+ import { AggregationFunction } from "@objectstack/spec/data";
1350
1577
  var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set(["array_agg", "string_agg"]);
1578
+ var SUPPORTED_AGGREGATES = AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
1351
1579
  function aggregateToMetricType(m) {
1352
1580
  if (!m.aggregate) {
1353
1581
  throw new Error(`[dataset-compiler] non-derived measure "${m.name}" has no aggregate`);
1354
1582
  }
1355
1583
  if (UNSUPPORTED_AGGREGATES.has(m.aggregate)) {
1356
1584
  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).`
1585
+ `[dataset-compiler] measure "${m.name}" uses aggregate "${m.aggregate}" which is not supported by the v1 dataset runtime (supported: ${SUPPORTED_AGGREGATES.join(", ")}).`
1358
1586
  );
1359
1587
  }
1360
1588
  return m.aggregate;
@@ -1481,6 +1709,7 @@ function compileDataset(dataset, resolver) {
1481
1709
  }
1482
1710
 
1483
1711
  // src/dataset-executor.ts
1712
+ import { emptyGroupValueFor } from "@objectstack/spec/data";
1484
1713
  import { filterTokenContextFrom, resolveFilterTokens } from "@objectstack/core";
1485
1714
  function resolveSelectionTokens(compiled, selection, context) {
1486
1715
  const tokenCtx = filterTokenContextFrom(context, /* @__PURE__ */ new Date());
@@ -1502,6 +1731,12 @@ function combineFilters(a, b) {
1502
1731
  if (a && b) return { $and: [a, b] };
1503
1732
  return a ?? b;
1504
1733
  }
1734
+ function splitMeasuresByFilter(measures, measureFilters) {
1735
+ const unfiltered = [];
1736
+ const filtered = [];
1737
+ for (const m of measures) (measureFilters[m] ? filtered : unfiltered).push(m);
1738
+ return { unfiltered, filtered };
1739
+ }
1505
1740
  function evaluateDerivedMeasures(rows, derived) {
1506
1741
  if (derived.length === 0) return rows;
1507
1742
  return rows.map((row) => {
@@ -1512,6 +1747,14 @@ function evaluateDerivedMeasures(rows, derived) {
1512
1747
  return out;
1513
1748
  });
1514
1749
  }
1750
+ function fillEmptyGroups(rows, columnAggregates) {
1751
+ for (const [column, aggregate2] of Object.entries(columnAggregates)) {
1752
+ const empty = emptyGroupValueFor(aggregate2);
1753
+ if (empty === void 0) continue;
1754
+ for (const row of rows) if (row[column] == null) row[column] = empty;
1755
+ }
1756
+ return rows;
1757
+ }
1515
1758
  function num(v) {
1516
1759
  if (v == null) return null;
1517
1760
  const n = typeof v === "number" ? v : Number(v);
@@ -1581,7 +1824,7 @@ function applyWindow(rows, limit, offset) {
1581
1824
  if (start === 0 && limit == null) return rows;
1582
1825
  return rows.slice(start, limit != null ? start + limit : void 0);
1583
1826
  }
1584
- function resolveOrdering(selection, dimensions) {
1827
+ function resolveOrdering(selection, dimensions, timeDimensions = []) {
1585
1828
  const order = selection.order;
1586
1829
  if (order && Object.keys(order).length > 0) {
1587
1830
  const selectable = /* @__PURE__ */ new Set([
@@ -1600,6 +1843,10 @@ function resolveOrdering(selection, dimensions) {
1600
1843
  if ((selection.limit != null || selection.offset != null) && dimensions.length > 0) {
1601
1844
  return Object.fromEntries(dimensions.map((d) => [d, "asc"]));
1602
1845
  }
1846
+ const timeKeys = timeDimensions.filter((d) => dimensions.includes(d));
1847
+ if (timeKeys.length > 0) {
1848
+ return Object.fromEntries(timeKeys.map((d) => [d, "asc"]));
1849
+ }
1603
1850
  return void 0;
1604
1851
  }
1605
1852
  function parseUTC(date) {
@@ -1686,14 +1933,10 @@ var DatasetExecutor = class {
1686
1933
  for (const d of selectedDerived) {
1687
1934
  for (const dep of d.of) baseMeasures.add(dep);
1688
1935
  }
1689
- const unfiltered = [];
1690
- const filtered = [];
1691
- for (const m of baseMeasures) {
1692
- (compiled.measureFilters[m] ? filtered : unfiltered).push(m);
1693
- }
1936
+ const { unfiltered, filtered } = splitMeasuresByFilter(baseMeasures, compiled.measureFilters);
1694
1937
  const baseFilter = combineFilters(compiled.filter, selection.runtimeFilter);
1695
1938
  const dimensions = selection.dimensions ?? [];
1696
- const order = resolveOrdering(selection, dimensions);
1939
+ const order = resolveOrdering(selection, dimensions, this.timeDimensionsOf(compiled, dimensions));
1697
1940
  const labelOrderKeys = this.orderLabels ? Object.keys(order ?? {}).filter(
1698
1941
  (k) => dimensions.includes(k) && this.orderLabels.isLabelBearing(k)
1699
1942
  ) : [];
@@ -1701,6 +1944,76 @@ var DatasetExecutor = class {
1701
1944
  const pushDownKeys = /* @__PURE__ */ new Set([...dimensions, ...unfiltered]);
1702
1945
  const canPushDownWindow = singleQuery && labelOrderKeys.length === 0 && Object.keys(order ?? {}).every((k) => pushDownKeys.has(k));
1703
1946
  const windowQuery = canPushDownWindow ? { order, limit: selection.limit, offset: selection.offset } : void 0;
1947
+ const result = await this.runMeasurePass(compiled, selection, {
1948
+ measures: [...baseMeasures],
1949
+ dimensions,
1950
+ baseFilter,
1951
+ window: windowQuery,
1952
+ context
1953
+ });
1954
+ if (selection.compareTo) {
1955
+ const compareRows = await this.runCompare(compiled, selection, [...baseMeasures], dimensions, baseFilter, context);
1956
+ result.rows = mergeByDimensions(
1957
+ result.rows,
1958
+ compareRows,
1959
+ dimensions,
1960
+ [...baseMeasures].map((m) => `${m}__compare`)
1961
+ );
1962
+ for (const m of baseMeasures) result.fields.push({ name: `${m}__compare`, type: "number" });
1963
+ }
1964
+ const fillColumns = {};
1965
+ for (const m of baseMeasures) {
1966
+ const aggregate2 = compiled.cube.measures?.[m]?.type;
1967
+ fillColumns[m] = aggregate2;
1968
+ if (selection.compareTo) fillColumns[`${m}__compare`] = aggregate2;
1969
+ }
1970
+ fillEmptyGroups(result.rows, fillColumns);
1971
+ result.rows = evaluateDerivedMeasures(result.rows, selectedDerived);
1972
+ for (const d of selectedDerived) result.fields.push({ name: d.name, type: "number" });
1973
+ let sortKeys;
1974
+ for (const key of labelOrderKeys) {
1975
+ const values = [...new Set(result.rows.map((r) => r[key]).filter((v) => v != null))];
1976
+ if (values.length === 0) continue;
1977
+ const labels = await this.orderLabels.resolveLabels(key, values);
1978
+ if (labels && labels.size > 0) (sortKeys ?? (sortKeys = {}))[key] = labels;
1979
+ }
1980
+ result.rows = applyOrdering(result.rows, order, sortKeys);
1981
+ result.rows = applyWindow(result.rows, selection.limit, selection.offset);
1982
+ return result;
1983
+ }
1984
+ /**
1985
+ * Run ONE grouped pass over a set of base measures, honouring each measure's
1986
+ * own scoped `filter`: the unfiltered measures in a single query, plus one
1987
+ * supplementary query per filter-scoped measure, merged back by dimension key.
1988
+ *
1989
+ * **This is the executor's only implementation of "how a measure filter is
1990
+ * applied", and every window goes through it** — the current period, each
1991
+ * `totals` subset (which re-enters via `executeSelection`), and the
1992
+ * `compareTo` window. Before #4820 the comparison window had its own,
1993
+ * simpler answer: one shifted query over all base measures with only the
1994
+ * base filter, so `compiled.measureFilters` was never read on that path.
1995
+ * `won_count` counted won deals and `won_count__compare` counted every deal,
1996
+ * under one label, in adjacent columns. Only measures carrying a filter were
1997
+ * wrong — which is what made it survive: the unfiltered ones next to them
1998
+ * compared correctly.
1999
+ *
2000
+ * The caller supplies the `selection` this pass queries under, which is how
2001
+ * the comparison window differs at all: same measures, same dimensions, same
2002
+ * filters — a `timeDimensions` shifted by {@link shiftRange}. Nothing else
2003
+ * about the two passes may drift, because anything that does becomes a
2004
+ * discrepancy between two columns the reader is invited to subtract.
2005
+ *
2006
+ * Cost: one extra query per filter-scoped measure when `compareTo` is set.
2007
+ * The alternative — declaring the discrepancy in the response — is not one,
2008
+ * since the two columns exist to be directly comparable.
2009
+ *
2010
+ * @param window - Ordering/window to push into the SQL. Only ever set for a
2011
+ * selection the caller proved is a single self-sufficient query; a pass
2012
+ * that fans out must return its whole grid for the merge.
2013
+ */
2014
+ async runMeasurePass(compiled, selection, opts) {
2015
+ const { measures, dimensions, baseFilter, window, context } = opts;
2016
+ const { unfiltered, filtered } = splitMeasuresByFilter(measures, compiled.measureFilters);
1704
2017
  let result;
1705
2018
  if (unfiltered.length > 0 || filtered.length === 0) {
1706
2019
  result = await this.service.query(this.buildQuery(compiled, {
@@ -1709,7 +2022,7 @@ var DatasetExecutor = class {
1709
2022
  where: baseFilter,
1710
2023
  selection,
1711
2024
  contextTimezone: context?.timezone,
1712
- window: windowQuery
2025
+ window
1713
2026
  }), context);
1714
2027
  } else {
1715
2028
  result = { rows: [], fields: [] };
@@ -1726,29 +2039,21 @@ var DatasetExecutor = class {
1726
2039
  result.rows = mergeByDimensions(result.rows, sub.rows, dimensions, [m]);
1727
2040
  result.fields.push({ name: m, type: "number" });
1728
2041
  }
1729
- if (selection.compareTo) {
1730
- const compareRows = await this.runCompare(compiled, selection, [...baseMeasures], dimensions, baseFilter, context);
1731
- result.rows = mergeByDimensions(
1732
- result.rows,
1733
- compareRows,
1734
- dimensions,
1735
- [...baseMeasures].map((m) => `${m}__compare`)
1736
- );
1737
- for (const m of baseMeasures) result.fields.push({ name: `${m}__compare`, type: "number" });
1738
- }
1739
- result.rows = evaluateDerivedMeasures(result.rows, selectedDerived);
1740
- for (const d of selectedDerived) result.fields.push({ name: d.name, type: "number" });
1741
- let sortKeys;
1742
- for (const key of labelOrderKeys) {
1743
- const values = [...new Set(result.rows.map((r) => r[key]).filter((v) => v != null))];
1744
- if (values.length === 0) continue;
1745
- const labels = await this.orderLabels.resolveLabels(key, values);
1746
- if (labels && labels.size > 0) (sortKeys ?? (sortKeys = {}))[key] = labels;
1747
- }
1748
- result.rows = applyOrdering(result.rows, order, sortKeys);
1749
- result.rows = applyWindow(result.rows, selection.limit, selection.offset);
1750
2042
  return result;
1751
2043
  }
2044
+ /**
2045
+ * The selected dimensions the compiled cube types as `time`, in selection
2046
+ * order (#3916) — the axis {@link resolveOrdering} defaults to ascending.
2047
+ *
2048
+ * Membership is decided by the DIMENSION's declared type, not by whether the
2049
+ * selection happens to bucket it: a `date` dimension left ungranulated groups
2050
+ * raw timestamps, and those want chronological order every bit as much as
2051
+ * month buckets do. (Both sort correctly — `compareValues` compares Dates and
2052
+ * ISO strings chronologically, and bucket keys are minted sort-stable.)
2053
+ */
2054
+ timeDimensionsOf(compiled, dimensions) {
2055
+ return dimensions.filter((d) => compiled.cube.dimensions[d]?.type === "time");
2056
+ }
1752
2057
  buildQuery(compiled, opts) {
1753
2058
  const q = {
1754
2059
  cube: compiled.cube.name,
@@ -1798,13 +2103,11 @@ var DatasetExecutor = class {
1798
2103
  const shiftedTd = (selection.timeDimensions ?? []).map(
1799
2104
  (t) => t.dimension === cmp.dimension ? { ...t, dateRange: shifted } : t
1800
2105
  );
1801
- const sub = await this.service.query(this.buildQuery(compiled, {
1802
- measures,
1803
- dimensions,
1804
- where: baseFilter,
1805
- selection: { ...selection, timeDimensions: shiftedTd },
1806
- contextTimezone: context?.timezone
1807
- }), context);
2106
+ const sub = await this.runMeasurePass(
2107
+ compiled,
2108
+ { ...selection, timeDimensions: shiftedTd },
2109
+ { measures, dimensions, baseFilter, context }
2110
+ );
1808
2111
  return sub.rows.map((row) => {
1809
2112
  const out = {};
1810
2113
  for (const dim of dimensions) out[dim] = row[dim];
@@ -1990,11 +2293,21 @@ function pickDisplayField(fields) {
1990
2293
  }
1991
2294
 
1992
2295
  // src/preview-evaluator.ts
1993
- import { calendarPartsInTzOrUtc } from "@objectstack/core";
2296
+ import { calendarPartsInTzOrUtc, nextUtcCalendarDay as nextUtcCalendarDay3, utcInstantMs } from "@objectstack/core";
1994
2297
  function compare(a, b) {
1995
2298
  if (typeof a === "number" && typeof b === "number") return a - b;
2299
+ if (a instanceof Date || b instanceof Date) {
2300
+ const ai = utcInstantMs(a);
2301
+ const bi = utcInstantMs(b);
2302
+ if (ai !== null && bi !== null) return ai - bi;
2303
+ }
1996
2304
  return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0;
1997
2305
  }
2306
+ function lteBound(value, bound) {
2307
+ const nextDay = nextUtcCalendarDay3(bound);
2308
+ if (nextDay != null) return compare(value, nextDay) < 0;
2309
+ return compare(value, bound) <= 0;
2310
+ }
1998
2311
  function matchOp(value, op, expected) {
1999
2312
  switch (op) {
2000
2313
  case "$eq":
@@ -2007,8 +2320,16 @@ function matchOp(value, op, expected) {
2007
2320
  return value != null && compare(value, expected) >= 0;
2008
2321
  case "$lt":
2009
2322
  return value != null && compare(value, expected) < 0;
2010
- case "$lte":
2011
- return value != null && compare(value, expected) <= 0;
2323
+ case "$lte": {
2324
+ if (value == null) return false;
2325
+ return lteBound(value, expected);
2326
+ }
2327
+ case "$between": {
2328
+ if (value == null || !Array.isArray(expected) || expected.length !== 2) return false;
2329
+ const [min, max] = expected;
2330
+ if (min == null || max == null) return false;
2331
+ return compare(value, min) >= 0 && lteBound(value, max);
2332
+ }
2012
2333
  case "$in":
2013
2334
  return Array.isArray(expected) && expected.some((e) => value === e || String(value) === String(e));
2014
2335
  case "$nin":
@@ -2095,7 +2416,9 @@ function evaluateAnalyticsQueryOverRows(query, cube, rows) {
2095
2416
  const [start, end] = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
2096
2417
  filtered = filtered.filter((r) => {
2097
2418
  const v = String(r[field] ?? "");
2098
- return v >= String(start) && v <= `${end}~`;
2419
+ const nextDay = nextUtcCalendarDay3(end);
2420
+ const inUpper = nextDay != null ? v < nextDay : v <= `${end}~`;
2421
+ return v >= String(start) && inUpper;
2099
2422
  });
2100
2423
  }
2101
2424
  const dimensions = query.dimensions ?? [];
@@ -2154,6 +2477,7 @@ function isMissingSourceError(err) {
2154
2477
  msg.includes("not registered") || // framework: object not in registry
2155
2478
  msg.includes("unknown object") || msg.includes("is not a registered object");
2156
2479
  }
2480
+ var BARE_IDENTIFIER = /^[a-z_][a-z0-9_]*$/i;
2157
2481
  var DEFAULT_CAPABILITIES = {
2158
2482
  nativeSql: false,
2159
2483
  objectqlAggregate: false,
@@ -2172,10 +2496,11 @@ var AnalyticsService = class {
2172
2496
  }
2173
2497
  this.readScopeProvider = config.getReadScope;
2174
2498
  this.relationshipResolver = config.relationshipResolver;
2175
- this.measureCurrency = config.measureCurrency;
2499
+ this.sourceFieldMeta = config.sourceFieldMeta;
2176
2500
  this.labelResolver = config.labelResolver;
2177
2501
  this.draftRowsResolver = config.draftRowsResolver;
2178
2502
  this.isRegisteredObject = config.isRegisteredObject;
2503
+ this.getObjectFieldNames = config.getObjectFieldNames;
2179
2504
  if (config.datasets) {
2180
2505
  for (const ds of config.datasets) {
2181
2506
  try {
@@ -2195,6 +2520,7 @@ var AnalyticsService = class {
2195
2520
  // fall back to any explicitly-configured provider for legacy cubes.
2196
2521
  getAllowedRelationships: (cubeName) => this.datasetRegistry.get(cubeName)?.allowedRelationships ?? config.getAllowedRelationships?.(cubeName),
2197
2522
  coerceTemporalFilterValue: config.coerceTemporalFilterValue,
2523
+ coerceTemporalFilterColumn: config.coerceTemporalFilterColumn,
2198
2524
  isExternalObject: config.isExternalObject
2199
2525
  };
2200
2526
  const builtIn = [
@@ -2392,7 +2718,7 @@ var AnalyticsService = class {
2392
2718
  if (!d.field || d.type !== "date") continue;
2393
2719
  const granularity = resolveDimensionGranularity(selection, d.name, d.dateGranularity);
2394
2720
  if (!granularity) continue;
2395
- const ftype = this.measureCurrency?.(dataset.object, d.field)?.type;
2721
+ const ftype = this.sourceFieldMeta?.(dataset.object, d.field)?.type;
2396
2722
  if (ftype === "datetime") rangeDims.push({ d, granularity, instant: true });
2397
2723
  else if (ftype === "date") rangeDims.push({ d, granularity, instant: false });
2398
2724
  else if (rangeTz === "UTC") rangeDims.push({ d, granularity, instant: false });
@@ -2441,14 +2767,17 @@ var AnalyticsService = class {
2441
2767
  if (f.format == null && m.format) f.format = m.format;
2442
2768
  const fc = f;
2443
2769
  const mc = m;
2770
+ const meta = m.field ? this.sourceFieldMeta?.(dataset.object, m.field) : void 0;
2444
2771
  if (fc.currency == null) {
2445
- const meta = m.field ? this.measureCurrency?.(dataset.object, m.field) : void 0;
2446
2772
  const monetary = !!mc.currency || meta?.type === "currency";
2447
2773
  if (monetary) {
2448
2774
  const resolved = mc.currency ?? meta?.defaultCurrency ?? context?.currency;
2449
2775
  if (resolved) fc.currency = resolved;
2450
2776
  }
2451
2777
  }
2778
+ if (f.percentScale == null) {
2779
+ f.percentScale = m.derived?.op === "ratio" ? "fraction" : percentScaleOf(meta);
2780
+ }
2452
2781
  }
2453
2782
  }
2454
2783
  if (result.fields?.length && selectedDims.length) {
@@ -2514,6 +2843,7 @@ var AnalyticsService = class {
2514
2843
  if (!cube) {
2515
2844
  this.assertInferableCube(name);
2516
2845
  cube = this.inferCubeFromQuery(query);
2846
+ this.assertMeasureFields(query, cube, Object.keys(cube.measures));
2517
2847
  this.cubeRegistry.register(cube);
2518
2848
  const isScalarMetric = (query.dimensions?.length ?? 0) === 0 && (query.timeDimensions?.length ?? 0) === 0;
2519
2849
  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.`;
@@ -2533,10 +2863,82 @@ var AnalyticsService = class {
2533
2863
  ...cube,
2534
2864
  measures: { ...cube.measures, ...extraMeasures }
2535
2865
  };
2866
+ this.assertMeasureFields(query, augmented, Object.keys(cube.measures));
2536
2867
  this.cubeRegistry.register(augmented);
2537
2868
  this.logger.debug(
2538
2869
  `[Analytics] Augmented cube "${name}" with inferred measures: ${Object.keys(extraMeasures).join(",")}`
2539
2870
  );
2871
+ } else {
2872
+ this.assertMeasureFields(query, cube, Object.keys(cube.measures));
2873
+ }
2874
+ }
2875
+ /**
2876
+ * [#4437] Reject a measure whose SOURCE FIELD the backing object does not
2877
+ * have, BEFORE the strategy compiles it into SQL.
2878
+ *
2879
+ * `inferMeasure` maps a suffix convention onto a field name and has no way to
2880
+ * know whether that field exists: `ghost_sum` happily became `SUM(ghost)`, the
2881
+ * driver threw `no such column`, and the caller got
2882
+ * `500 {"code":"SQLITE_ERROR","message":"Internal server error"}` — a driver
2883
+ * error class on the wire, and nothing actionable, for what is a plain typo.
2884
+ * The DATA route has refused the same mistake with a `400 INVALID_FIELD`
2885
+ * naming the field since #4315/#4254; this is the analytics half of that
2886
+ * answer, and it is deliberately the SAME envelope (`code`/`field`/`object`/
2887
+ * `param`) so one mistake has one shape across both routes.
2888
+ *
2889
+ * What it checks, and what it deliberately does not:
2890
+ *
2891
+ * - Only when the cube's `sql` is a bare OBJECT NAME. An authored cube whose
2892
+ * `sql` is a real SQL expression has no field list to check against.
2893
+ * - Only when {@link AnalyticsServiceConfig.getObjectFieldNames} answers.
2894
+ * Absent hook / unknown object → stand down (see the config field's doc).
2895
+ * - Only measures whose source is a BARE COLUMN. `count(*)` has no source
2896
+ * field, and a dotted reference (`account.industry`) resolves through a
2897
+ * join whose target this check cannot see — both pass through untouched.
2898
+ * - `id` / `created_at` / `updated_at` are admitted unconditionally, matching
2899
+ * the data path's `resolveQueryFields`: they are engine-assigned rather than
2900
+ * declared, and a gate stricter than the engine it guards would reject
2901
+ * queries that used to work.
2902
+ */
2903
+ assertMeasureFields(query, cube, declaredMeasures) {
2904
+ const probe = this.getObjectFieldNames;
2905
+ if (!probe) return;
2906
+ const measures = query.measures ?? [];
2907
+ if (measures.length === 0) return;
2908
+ const object = typeof cube.sql === "string" ? cube.sql.trim() : "";
2909
+ if (!object || !BARE_IDENTIFIER.test(object)) return;
2910
+ const fieldNames = probe(object);
2911
+ if (!fieldNames || fieldNames.length === 0) return;
2912
+ const known = /* @__PURE__ */ new Set([...fieldNames, "id", "created_at", "updated_at"]);
2913
+ const stripPrefix = (m) => m.includes(".") ? m.split(".").slice(1).join(".") : m;
2914
+ const sourceFieldOf = (measure) => {
2915
+ const metric = cube.measures[stripPrefix(measure)];
2916
+ if (!metric) return null;
2917
+ if (metric.type === "count" && (metric.sql === "*" || metric.sql == null)) return null;
2918
+ const source = typeof metric.sql === "string" ? metric.sql.trim() : "";
2919
+ if (!source || source === "*" || !BARE_IDENTIFIER.test(source)) return null;
2920
+ return source;
2921
+ };
2922
+ const invalid = /* @__PURE__ */ new Set();
2923
+ for (const measure of measures) {
2924
+ const source = sourceFieldOf(measure);
2925
+ if (source && !known.has(source)) invalid.add(stripPrefix(measure));
2926
+ }
2927
+ if (invalid.size === 0) return;
2928
+ const usable = declaredMeasures.filter((m) => !invalid.has(m));
2929
+ for (const measure of measures) {
2930
+ const source = sourceFieldOf(measure);
2931
+ if (!source || known.has(source)) continue;
2932
+ const err = new Error(
2933
+ `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(", ")}.`
2934
+ );
2935
+ err.code = "INVALID_FIELD";
2936
+ err.status = 400;
2937
+ err.field = source;
2938
+ err.object = object;
2939
+ err.param = "measures";
2940
+ err.measure = measure;
2941
+ throw err;
2540
2942
  }
2541
2943
  }
2542
2944
  /**
@@ -2682,9 +3084,22 @@ var FallbackDelegateStrategy = class {
2682
3084
  var AnalyticsServicePlugin = class {
2683
3085
  constructor(options = {}) {
2684
3086
  this.name = "com.objectstack.service-analytics";
3087
+ /**
3088
+ * Services init() registers on every path (ADR-0116, #4131) — lets the
3089
+ * kernel name this plugin when a consumer requires one before it inits.
3090
+ */
3091
+ this.providesServices = ["analytics"];
2685
3092
  this.version = "1.0.0";
2686
3093
  this.type = "standard";
2687
3094
  this.dependencies = [];
3095
+ /**
3096
+ * init() probes the `data` engine ObjectQLPlugin provides for the
3097
+ * auto-bridge — order-if-present so the probe verdict is deterministic
3098
+ * (ADR-0116, #4471). Soft, not hard: without an engine the plugin
3099
+ * degrades on purpose (per-query lazy resolution / explicit
3100
+ * `executeAggregate`).
3101
+ */
3102
+ this.optionalDependencies = ["com.objectstack.engine.objectql"];
2688
3103
  this.options = options;
2689
3104
  }
2690
3105
  async init(ctx) {
@@ -2884,6 +3299,17 @@ var AnalyticsServicePlugin = class {
2884
3299
  }
2885
3300
  return value;
2886
3301
  };
3302
+ const coerceTemporalFilterColumn = (objectName, fieldName, columnSql) => {
3303
+ try {
3304
+ const svc = ctx.getService("data");
3305
+ const driver = svc?.getDriverForObject?.(objectName);
3306
+ if (driver && typeof driver.temporalFilterColumnSql === "function") {
3307
+ return driver.temporalFilterColumnSql(objectName, fieldName, columnSql);
3308
+ }
3309
+ } catch {
3310
+ }
3311
+ return columnSql;
3312
+ };
2887
3313
  const config = {
2888
3314
  cubes: this.options.cubes,
2889
3315
  logger: ctx.logger,
@@ -2894,12 +3320,15 @@ var AnalyticsServicePlugin = class {
2894
3320
  getReadScope,
2895
3321
  getAllowedRelationships: this.options.getAllowedRelationships,
2896
3322
  coerceTemporalFilterValue,
3323
+ coerceTemporalFilterColumn,
2897
3324
  relationshipResolver,
2898
3325
  labelResolver,
2899
- // ADR-0053 — source-field currency metadata for the measure currency chain.
2900
- measureCurrency: (object, field) => {
3326
+ // Source-field metadata behind the display chains on result columns:
3327
+ // ADR-0053 currency (`currencyConfig.defaultCurrency`) and percent scale
3328
+ // (`max`, which is what marks whole-percent storage — objectui#3136).
3329
+ sourceFieldMeta: (object, field) => {
2901
3330
  const f = dataEngine()?.getObject?.(object)?.fields?.[field];
2902
- return f ? { type: f.type, defaultCurrency: f.currencyConfig?.defaultCurrency } : void 0;
3331
+ return f ? { type: f.type, max: f.max, defaultCurrency: f.currencyConfig?.defaultCurrency } : void 0;
2903
3332
  },
2904
3333
  // ADR-0062 D6 — a federated object carries an `external` block (ADR-0015).
2905
3334
  // Reported so NativeSQLStrategy declines it (its hand-compiled FROM would
@@ -2922,6 +3351,18 @@ var AnalyticsServicePlugin = class {
2922
3351
  if (!engine) return true;
2923
3352
  return engine.getObject?.(name) != null;
2924
3353
  },
3354
+ // [#4437] Field names for the measure source-field gate. Read from the
3355
+ // SAME schema registry `isRegisteredObject` above consults (and the data
3356
+ // path's #4315 gate reads), so "which fields exist" has one answer across
3357
+ // /data and /analytics. `undefined` — no engine, unknown object, or an
3358
+ // object with no field map (an external datasource whose columns are not
3359
+ // mirrored locally) — means "cannot answer", and the gate stands down.
3360
+ getObjectFieldNames: (objectName) => {
3361
+ const fields = dataEngine()?.getObject?.(objectName)?.fields;
3362
+ if (!fields || typeof fields !== "object") return void 0;
3363
+ const names = Object.keys(fields);
3364
+ return names.length > 0 ? names : void 0;
3365
+ },
2925
3366
  draftRowsResolver
2926
3367
  };
2927
3368
  if (autoBridgedReadScope && securityPresentAtInit) {
@@ -2977,6 +3418,7 @@ export {
2977
3418
  compileScopedFilterToSql,
2978
3419
  createOrderLabelResolver,
2979
3420
  evaluateDerivedMeasures,
3421
+ fillEmptyGroups,
2980
3422
  mergeByDimensions,
2981
3423
  pickDisplayField,
2982
3424
  resolveDimensionLabels,