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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -29,16 +29,18 @@ __export(index_exports, {
29
29
  combineFilters: () => combineFilters,
30
30
  compileDataset: () => compileDataset,
31
31
  compileScopedFilterToSql: () => compileScopedFilterToSql,
32
+ createOrderLabelResolver: () => createOrderLabelResolver,
32
33
  evaluateDerivedMeasures: () => evaluateDerivedMeasures,
33
34
  mergeByDimensions: () => mergeByDimensions,
34
35
  pickDisplayField: () => pickDisplayField,
35
36
  resolveDimensionLabels: () => resolveDimensionLabels,
36
- shiftRange: () => shiftRange
37
+ shiftRange: () => shiftRange,
38
+ withLabelFetchCache: () => withLabelFetchCache
37
39
  });
38
40
  module.exports = __toCommonJS(index_exports);
39
41
 
40
42
  // src/analytics-service.ts
41
- var import_core2 = require("@objectstack/core");
43
+ var import_core3 = require("@objectstack/core");
42
44
 
43
45
  // src/cube-registry.ts
44
46
  var CubeRegistry = class {
@@ -707,7 +709,70 @@ var NativeSQLStrategy = class {
707
709
  }
708
710
  };
709
711
 
712
+ // src/strategies/cross-object-rebucket.ts
713
+ var RECOMBINABLE_METHODS = /* @__PURE__ */ new Set([
714
+ "sum",
715
+ "count",
716
+ "min",
717
+ "max"
718
+ ]);
719
+ var RESTRICTED_BUCKET = "(restricted)";
720
+ function orderableValue(v) {
721
+ if (v == null) return NaN;
722
+ if (typeof v === "number") return v;
723
+ if (v instanceof Date) return v.getTime();
724
+ const n = Number(v);
725
+ if (Number.isFinite(n)) return n;
726
+ return Date.parse(String(v));
727
+ }
728
+ function recombine(method, acc, next) {
729
+ if (method === "min" || method === "max") {
730
+ if (acc === void 0) return next ?? 0;
731
+ const a = orderableValue(acc);
732
+ const n2 = orderableValue(next);
733
+ if (Number.isNaN(n2)) return acc;
734
+ if (Number.isNaN(a)) return next;
735
+ const nextWins = method === "min" ? n2 < a : n2 > a;
736
+ return nextWins ? next : acc;
737
+ }
738
+ const n = Number(next ?? 0);
739
+ return acc === void 0 ? n : Number(acc) + n;
740
+ }
741
+ function rebucketCrossObject(baseRows, baseDimFields, crossDims, measures) {
742
+ const buckets = /* @__PURE__ */ new Map();
743
+ for (const row of baseRows) {
744
+ const resolved = {};
745
+ for (const cd of crossDims) {
746
+ const fk = row[cd.fkField];
747
+ resolved[cd.outputName] = cd.fkToAttr.has(fk) ? cd.fkToAttr.get(fk) : RESTRICTED_BUCKET;
748
+ }
749
+ const keyParts = [];
750
+ for (const f of baseDimFields) keyParts.push(`${f}=${JSON.stringify(row[f] ?? null)}`);
751
+ for (const cd of crossDims) keyParts.push(`${cd.outputName}=${String(resolved[cd.outputName])}`);
752
+ const key = keyParts.join("");
753
+ let bucket = buckets.get(key);
754
+ if (!bucket) {
755
+ bucket = {};
756
+ for (const f of baseDimFields) bucket[f] = row[f];
757
+ for (const cd of crossDims) bucket[cd.outputName] = resolved[cd.outputName];
758
+ buckets.set(key, bucket);
759
+ }
760
+ for (const m of measures) {
761
+ bucket[m.alias] = recombine(m.method, bucket[m.alias], row[m.alias]);
762
+ }
763
+ }
764
+ return [...buckets.values()];
765
+ }
766
+
710
767
  // src/strategies/objectql-strategy.ts
768
+ var SCALAR_SQL_OPS = {
769
+ equals: "=",
770
+ notEquals: "!=",
771
+ gt: ">",
772
+ gte: ">=",
773
+ lt: "<",
774
+ lte: "<="
775
+ };
711
776
  var ObjectQLStrategy = class {
712
777
  constructor() {
713
778
  this.name = "ObjectQLStrategy";
@@ -745,15 +810,22 @@ var ObjectQLStrategy = class {
745
810
  }
746
811
  }
747
812
  const filter = {};
748
- const normalizedFilters = normalizeAnalyticsFilters(query);
749
- if (normalizedFilters.length > 0) {
750
- for (const f of normalizedFilters) {
751
- const fieldName = this.resolveFieldName(cube, f.member, "any");
752
- const converted = this.convertFilter(f.operator, f.values);
753
- const existing = filter[fieldName];
754
- const mergeable = (v) => !!v && typeof v === "object" && !Array.isArray(v);
755
- filter[fieldName] = mergeable(existing) && mergeable(converted) ? { ...existing, ...converted } : converted;
756
- }
813
+ const conjuncts = [];
814
+ for (const f of normalizeAnalyticsFilters(query)) {
815
+ const fieldName = this.resolveFieldName(cube, f.member, "any");
816
+ const extra = this.mergeFilterOperand(filter, fieldName, this.convertFilter(f.operator, f.values));
817
+ if (extra) conjuncts.push(extra);
818
+ }
819
+ for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
820
+ const extra = this.mergeFilterOperand(filter, field, bounds);
821
+ if (extra) conjuncts.push(extra);
822
+ }
823
+ if (conjuncts.length > 0) {
824
+ filter.$and = [...Array.isArray(filter.$and) ? filter.$and : [], ...conjuncts];
825
+ }
826
+ const plan = this.planCrossObject(cube, query, filter);
827
+ if (plan) {
828
+ return this.executeCrossObject(cube, query, aggregations, filter, plan, ctx);
757
829
  }
758
830
  const rows = await ctx.executeAggregate(objectName, {
759
831
  // Structured groupBy items ({field, dateGranularity}) pass through the
@@ -761,11 +833,17 @@ var ObjectQLStrategy = class {
761
833
  // contract types groupBy as string[]; the cast carries the richer shape.
762
834
  groupBy: groupBy.length > 0 ? groupBy : void 0,
763
835
  aggregations: aggregations.length > 0 ? aggregations : void 0,
764
- filter: Object.keys(filter).length > 0 ? filter : void 0,
836
+ filter: this.withReadScope(objectName, filter, ctx),
765
837
  // ADR-0053 Phase 2 (D2): forward the reference tz so date buckets resolve
766
838
  // on that zone's calendar days. A non-UTC zone makes the engine bucket
767
839
  // in-memory (uniform across drivers); UTC/unset keeps the DB fast path.
768
- timezone: query.timezone
840
+ timezone: query.timezone,
841
+ // ADR-0021 D-C (#3602): the second belt. `withReadScope` above is this
842
+ // layer's own scoping; handing the engine the context makes ITS middleware
843
+ // inject RLS too, so a future strategy that forgets `withReadScope` still
844
+ // cannot read across tenants. Without it the operation reaches the engine
845
+ // principal-less and plugin-security falls open — the #3597 shape.
846
+ context: ctx.context
769
847
  });
770
848
  const mappedRows = rows.map((row) => {
771
849
  const mapped = {};
@@ -783,8 +861,28 @@ var ObjectQLStrategy = class {
783
861
  return mapped;
784
862
  });
785
863
  const fields = this.buildFieldMeta(query, cube);
786
- return { rows: mappedRows, fields };
864
+ let sql;
865
+ try {
866
+ sql = (await this.generateSql(query, ctx)).sql;
867
+ } catch {
868
+ sql = void 0;
869
+ }
870
+ return sql ? { rows: mappedRows, fields, sql } : { rows: mappedRows, fields };
787
871
  }
872
+ /**
873
+ * Render a REPRESENTATIVE SQL string for an ObjectQL aggregate query.
874
+ *
875
+ * This path executes through `engine.aggregate()`, not raw SQL, so the string
876
+ * is documentation rather than the literal statement — but it must be an
877
+ * honest account of what the query does, because dataset responses echo it
878
+ * and authors read it to verify their widget options landed (#3588). It
879
+ * therefore renders date bucketing (`date_trunc`), the WHERE predicate,
880
+ * ordering, and the row window.
881
+ *
882
+ * Filter VALUES are rendered as `$n` placeholders and returned in `params`,
883
+ * never inlined: the echoed statement travels to the browser, and a filter
884
+ * comparand can carry tenant data.
885
+ */
788
886
  async generateSql(query, ctx) {
789
887
  const cube = ctx.getCube(query.cube);
790
888
  if (!cube) {
@@ -792,28 +890,307 @@ var ObjectQLStrategy = class {
792
890
  }
793
891
  const selectParts = [];
794
892
  const groupByParts = [];
893
+ const params = [];
894
+ const granByDim = /* @__PURE__ */ new Map();
895
+ for (const td of query.timeDimensions ?? []) {
896
+ if (td.granularity) granByDim.set(td.dimension, td.granularity);
897
+ }
898
+ const tableName = this.extractObjectName(cube);
899
+ const plan = this.planCrossObject(cube, query, Object.fromEntries(
900
+ normalizeAnalyticsFilters(query).map((f) => [this.resolveFieldName(cube, f.member, "any"), true])
901
+ ));
902
+ const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd]));
903
+ const joinClauses = [];
904
+ const dimExpr = (dim) => {
905
+ const cd = crossByDim.get(dim);
906
+ if (cd) {
907
+ joinClauses.push(
908
+ `LEFT JOIN "${cd.refObject}" ON "${tableName}"."${cd.fkField}" = "${cd.refObject}"."id"`
909
+ );
910
+ return `"${cd.refObject}"."${cd.attr}"`;
911
+ }
912
+ const col = this.resolveFieldName(cube, dim, "dimension");
913
+ const gran = granByDim.get(dim);
914
+ return gran ? `date_trunc('${gran}', ${col})` : col;
915
+ };
795
916
  if (query.dimensions) {
796
917
  for (const dim of query.dimensions) {
797
- const col = this.resolveFieldName(cube, dim, "dimension");
798
- selectParts.push(`${col} AS "${dim}"`);
799
- groupByParts.push(col);
918
+ const expr = dimExpr(dim);
919
+ selectParts.push(`${expr} AS "${dim}"`);
920
+ groupByParts.push(expr);
800
921
  }
801
922
  }
923
+ for (const [dim] of granByDim) {
924
+ if (query.dimensions?.includes(dim)) continue;
925
+ const expr = dimExpr(dim);
926
+ selectParts.push(`${expr} AS "${dim}"`);
927
+ groupByParts.push(expr);
928
+ }
802
929
  if (query.measures) {
803
930
  for (const m of query.measures) {
804
931
  const { field, method } = this.resolveMeasureAggregation(cube, m);
805
- const aggSql = method === "count" ? "COUNT(*)" : `${method.toUpperCase()}(${field})`;
932
+ const aggSql = method === "count" ? "COUNT(*)" : method === "count_distinct" ? `COUNT(DISTINCT ${field})` : `${method.toUpperCase()}(${field})`;
806
933
  selectParts.push(`${aggSql} AS "${m}"`);
807
934
  }
808
935
  }
809
- const tableName = this.extractObjectName(cube);
936
+ const whereParts = [];
937
+ for (const f of normalizeAnalyticsFilters(query)) {
938
+ const clause = this.buildFilterClauseSql(
939
+ this.resolveFieldName(cube, f.member, "any"),
940
+ f.operator,
941
+ f.values,
942
+ params
943
+ );
944
+ if (clause) whereParts.push(clause);
945
+ }
946
+ for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
947
+ params.push(bounds.$gte, bounds.$lte);
948
+ whereParts.push(`${field} BETWEEN $${params.length - 1} AND $${params.length}`);
949
+ }
950
+ const scope = ctx.getReadScope?.(tableName);
951
+ if (scope != null) {
952
+ const { sql: scopeSql, params: scopeParams } = compileScopedFilterToSql(scope, tableName);
953
+ if (scopeSql) {
954
+ let i = 0;
955
+ const rendered = scopeSql.replace(/\?/g, () => {
956
+ params.push(scopeParams[i++]);
957
+ return `$${params.length}`;
958
+ });
959
+ whereParts.push(`(${rendered})`);
960
+ }
961
+ }
810
962
  let sql = `SELECT ${selectParts.join(", ")} FROM "${tableName}"`;
963
+ if (joinClauses.length > 0) sql += " " + joinClauses.join(" ");
964
+ if (whereParts.length > 0) {
965
+ sql += ` WHERE ${whereParts.join(" AND ")}`;
966
+ }
811
967
  if (groupByParts.length > 0) {
812
968
  sql += ` GROUP BY ${groupByParts.join(", ")}`;
813
969
  }
814
- return { sql, params: [] };
970
+ if (query.order && Object.keys(query.order).length > 0) {
971
+ const orderClauses = Object.entries(query.order).map(([f, d]) => `"${f}" ${d.toUpperCase()}`);
972
+ sql += ` ORDER BY ${orderClauses.join(", ")}`;
973
+ }
974
+ if (query.limit != null) sql += ` LIMIT ${query.limit}`;
975
+ if (query.offset != null) sql += ` OFFSET ${query.offset}`;
976
+ return { sql, params };
815
977
  }
816
978
  // ── Helpers ──────────────────────────────────────────────────────
979
+ /**
980
+ * ADR-0021 D-C (#3597) — AND the object's read scope (tenant + RLS) into the
981
+ * filter handed to `engine.aggregate`.
982
+ *
983
+ * This path used to drop the scope entirely, and the engine could not make up
984
+ * for it: the aggregate bridge passes no `ExecutionContext`, so the security
985
+ * middleware's principal-less fall-open skipped its own RLS injection. Both
986
+ * belts were off at once — an authenticated caller received aggregates
987
+ * computed over EVERY tenant's rows.
988
+ *
989
+ * Composed with `$and`, never by key merge: the query's own filter and the
990
+ * scope can name the SAME field (e.g. a dashboard filtering `organization_id`),
991
+ * and a spread would let caller input silently overwrite the security
992
+ * predicate. `$and` makes that structurally impossible.
993
+ */
994
+ withReadScope(objectName, filter, ctx) {
995
+ const userFilter = Object.keys(filter).length > 0 ? filter : void 0;
996
+ if (typeof ctx.getReadScope !== "function") return userFilter;
997
+ const scope = ctx.getReadScope(objectName);
998
+ if (scope === void 0 || scope === null) return userFilter;
999
+ const scopeFilter = scope;
1000
+ if (!userFilter) return scopeFilter;
1001
+ return { $and: [userFilter, scopeFilter] };
1002
+ }
1003
+ /** Is `field` a resolved cross-object (relationship-traversal) reference? */
1004
+ isCrossObjectField(cube, field, baseObject) {
1005
+ if (!field.includes(".")) return false;
1006
+ const alias = field.split(".")[0];
1007
+ const joinedObject = cube.joins?.[alias]?.name ?? alias;
1008
+ return joinedObject !== baseObject;
1009
+ }
1010
+ /**
1011
+ * Plan how to serve cross-object references on this join-less path (#3654).
1012
+ *
1013
+ * `engine.aggregate()` cannot join. A cross-object DIMENSION within a
1014
+ * supported envelope is served by an FK-expand (`executeCrossObject`): group
1015
+ * the base aggregate on the lookup FK, resolve the FK to the related attribute
1016
+ * with a SCOPED read, re-bucket in memory. Returns `null` for a base-only
1017
+ * query (direct path), a plan for an in-envelope cross-object query.
1018
+ *
1019
+ * THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
1020
+ * (needs a real join to evaluate), a MULTI-HOP dimension (`a.b.c`), or a
1021
+ * non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values
1022
+ * cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
1023
+ * `generateSql()` calls this too, so the preview accepts/rejects the same set.
1024
+ *
1025
+ * Detection is on RESOLVED field names, so a dotted dimension the cube
1026
+ * flattens to a real column is treated as base, not cross-object.
1027
+ */
1028
+ planCrossObject(cube, query, filter) {
1029
+ const baseObject = this.extractObjectName(cube);
1030
+ for (const td of query.timeDimensions ?? []) {
1031
+ const field = this.resolveFieldName(cube, td.dimension, "dimension");
1032
+ if (this.isCrossObjectField(cube, field, baseObject)) {
1033
+ throw new Error(
1034
+ `[Analytics] ObjectQLStrategy cannot bucket a cross-object time dimension ("${field}").`
1035
+ );
1036
+ }
1037
+ }
1038
+ const nonDim = [
1039
+ ...(query.measures ?? []).map((m) => ({ where: "measure", field: this.resolveMeasureAggregation(cube, m).field })),
1040
+ ...Object.keys(filter).map((f) => ({ where: "filter", field: f }))
1041
+ ].filter((r) => this.isCrossObjectField(cube, r.field, baseObject));
1042
+ if (nonDim.length > 0) {
1043
+ throw new Error(
1044
+ `[Analytics] ObjectQLStrategy cannot evaluate a cross-object ${nonDim[0].where} ("${nonDim[0].field}") \u2014 the engine cannot join in an aggregate. Run this query on a native-SQL driver, or remove the cross-object ${nonDim[0].where}.`
1045
+ );
1046
+ }
1047
+ const crossDims = [];
1048
+ for (const dim of query.dimensions ?? []) {
1049
+ const field = this.resolveFieldName(cube, dim, "dimension");
1050
+ if (!this.isCrossObjectField(cube, field, baseObject)) continue;
1051
+ const [alias, ...rest] = field.split(".");
1052
+ const attr = rest.join(".");
1053
+ if (attr.includes(".")) {
1054
+ throw new Error(
1055
+ `[Analytics] ObjectQLStrategy supports only single-hop cross-object dimensions; "${field}" traverses more than one relationship.`
1056
+ );
1057
+ }
1058
+ crossDims.push({ outputName: dim, fkField: alias, attr, refObject: cube.joins?.[alias]?.name ?? alias });
1059
+ }
1060
+ if (crossDims.length === 0) return null;
1061
+ for (const m of query.measures ?? []) {
1062
+ const { method } = this.resolveMeasureAggregation(cube, m);
1063
+ if (!RECOMBINABLE_METHODS.has(method)) {
1064
+ throw new Error(
1065
+ `[Analytics] ObjectQLStrategy cannot group by a cross-object dimension with a "${method}" measure ("${m}") \u2014 its value cannot be recombined across the intermediate FK grouping. Use sum/count/min/max, or run on a native-SQL driver.`
1066
+ );
1067
+ }
1068
+ }
1069
+ return { crossDims };
1070
+ }
1071
+ /**
1072
+ * Serve a cross-object-dimension query by FK-expand (#3654). The pure
1073
+ * re-bucketing step lives in `cross-object-rebucket.ts`.
1074
+ */
1075
+ async executeCrossObject(cube, query, aggregations, filter, plan, ctx) {
1076
+ const baseObject = this.extractObjectName(cube);
1077
+ const crossByDim = new Map(plan.crossDims.map((cd) => [cd.outputName, cd]));
1078
+ const granByDim = /* @__PURE__ */ new Map();
1079
+ for (const td of query.timeDimensions ?? []) {
1080
+ if (td.granularity) granByDim.set(td.dimension, td.granularity);
1081
+ }
1082
+ const groupBy = [];
1083
+ const baseDimFields = [];
1084
+ for (const dim of query.dimensions ?? []) {
1085
+ const cd = crossByDim.get(dim);
1086
+ if (cd) {
1087
+ groupBy.push(cd.fkField);
1088
+ continue;
1089
+ }
1090
+ const field = this.resolveFieldName(cube, dim, "dimension");
1091
+ const gran = granByDim.get(dim);
1092
+ groupBy.push(gran ? { field, dateGranularity: gran } : field);
1093
+ baseDimFields.push(field);
1094
+ granByDim.delete(dim);
1095
+ }
1096
+ for (const [dim, gran] of granByDim) {
1097
+ const field = this.resolveFieldName(cube, dim, "dimension");
1098
+ groupBy.push({ field, dateGranularity: gran });
1099
+ baseDimFields.push(field);
1100
+ }
1101
+ const baseRows = await ctx.executeAggregate(baseObject, {
1102
+ groupBy: groupBy.length > 0 ? groupBy : void 0,
1103
+ aggregations: aggregations.length > 0 ? aggregations : void 0,
1104
+ filter: this.withReadScope(baseObject, filter, ctx),
1105
+ timezone: query.timezone,
1106
+ context: ctx.context
1107
+ });
1108
+ const resolvedDims = [];
1109
+ for (const cd of plan.crossDims) {
1110
+ const fkValues = [...new Set(baseRows.map((r) => r[cd.fkField]).filter((v) => v != null))];
1111
+ const fkToAttr = await this.resolveFkAttr(cd.refObject, cd.attr, fkValues, ctx);
1112
+ resolvedDims.push({ outputName: cd.outputName, fkField: cd.fkField, fkToAttr });
1113
+ }
1114
+ const measures = (query.measures ?? []).map((m) => ({
1115
+ alias: m,
1116
+ // planCrossObject already asserted every measure is recombinable.
1117
+ method: this.resolveMeasureAggregation(cube, m).method
1118
+ }));
1119
+ const merged = rebucketCrossObject(baseRows, baseDimFields, resolvedDims, measures);
1120
+ const mappedRows = merged.map((row) => {
1121
+ const out = {};
1122
+ for (const dim of query.dimensions ?? []) {
1123
+ if (crossByDim.has(dim)) {
1124
+ if (dim in row) out[dim] = row[dim];
1125
+ } else {
1126
+ const field = this.resolveFieldName(cube, dim, "dimension");
1127
+ if (field in row) out[dim] = row[field];
1128
+ }
1129
+ }
1130
+ for (const td of query.timeDimensions ?? []) {
1131
+ if (query.dimensions?.includes(td.dimension)) continue;
1132
+ const field = this.resolveFieldName(cube, td.dimension, "dimension");
1133
+ if (field in row) out[td.dimension] = row[field];
1134
+ }
1135
+ for (const m of query.measures ?? []) {
1136
+ if (m in row) out[m] = row[m];
1137
+ }
1138
+ return out;
1139
+ });
1140
+ return { rows: mappedRows, fields: this.buildFieldMeta(query, cube) };
1141
+ }
1142
+ /**
1143
+ * Resolve `fkValues` (ids of `refObject`) to their `attr` values, applying the
1144
+ * referenced object's OWN read scope (#3654 / #3602). Reuses the aggregate
1145
+ * bridge — `group by (id, attr)` is one row per record. Ids the scope hides
1146
+ * are simply absent from the map (⇒ RESTRICTED bucket downstream).
1147
+ */
1148
+ async resolveFkAttr(refObject, attr, fkValues, ctx) {
1149
+ const map = /* @__PURE__ */ new Map();
1150
+ if (fkValues.length === 0 || typeof ctx.executeAggregate !== "function") return map;
1151
+ const idFilter = { id: { $in: fkValues } };
1152
+ const scope = typeof ctx.getReadScope === "function" ? ctx.getReadScope(refObject) : null;
1153
+ const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
1154
+ const rows = await ctx.executeAggregate(refObject, {
1155
+ groupBy: ["id", attr],
1156
+ aggregations: [{ field: "id", method: "count", alias: "_c" }],
1157
+ filter,
1158
+ context: ctx.context
1159
+ });
1160
+ for (const r of rows) {
1161
+ if (r.id != null) map.set(r.id, r[attr]);
1162
+ }
1163
+ return map;
1164
+ }
1165
+ /**
1166
+ * Render one normalized filter as a display SQL predicate for `generateSql`.
1167
+ *
1168
+ * Mirrors `NativeSQLStrategy.buildFilterClause`'s operator vocabulary so the
1169
+ * two previews read alike, but binds through `coerceFilterValueForObjectQL`:
1170
+ * the comparand shown is the one THIS path actually hands the engine (a real
1171
+ * boolean, not SQL's 1/0). Returns null for an operator/value combination
1172
+ * that carries no predicate, matching `execute()`, which drops it too.
1173
+ */
1174
+ buildFilterClauseSql(col, operator, values, params) {
1175
+ if (operator === "set") return `${col} IS NOT NULL`;
1176
+ if (operator === "notSet") return `${col} IS NULL`;
1177
+ if (!values || values.length === 0) return null;
1178
+ if (operator === "in" || operator === "notIn") {
1179
+ const placeholders = values.map((v) => {
1180
+ params.push(coerceFilterValueForObjectQL(v));
1181
+ return `$${params.length}`;
1182
+ }).join(", ");
1183
+ return `${col} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
1184
+ }
1185
+ if (operator === "contains" || operator === "notContains") {
1186
+ params.push(`%${values[0]}%`);
1187
+ return `${col} ${operator === "contains" ? "LIKE" : "NOT LIKE"} $${params.length}`;
1188
+ }
1189
+ const op = SCALAR_SQL_OPS[operator];
1190
+ if (!op) return null;
1191
+ params.push(coerceFilterValueForObjectQL(values[0]));
1192
+ return `${col} ${op} $${params.length}`;
1193
+ }
817
1194
  /**
818
1195
  * Resolve a member ref to a `{ sql, type? }` definition.
819
1196
  *
@@ -878,6 +1255,89 @@ var ObjectQLStrategy = class {
878
1255
  }
879
1256
  return { field: "*", method: "count" };
880
1257
  }
1258
+ /**
1259
+ * AND one more operand onto `filter[field]`, merging operator objects rather
1260
+ * than overwriting them. Returns a standalone conjunct when the two cannot
1261
+ * share one entry, or `null` when the merge absorbed the operand.
1262
+ *
1263
+ * Every predicate this strategy contributes goes through here — the caller's
1264
+ * `where` and the time-dimension `dateRange` alike. Two operands on one field
1265
+ * are the normal case (`{$gte}` from a `where` plus `{$gte,$lte}` from a
1266
+ * window on `close_date`), and a plain assignment would keep only the last:
1267
+ * that is how a range used to lose a bound.
1268
+ *
1269
+ * Spreading is sound only while the operands name DIFFERENT operators. Where
1270
+ * they collide — two `$gte` bounds on one field, which a window makes routine
1271
+ * and which a `where` can already produce on its own through `$and` — the
1272
+ * spread keeps whichever came last and WIDENS the query. Same for a bare
1273
+ * equality meeting an operator object: neither can absorb the other. Those
1274
+ * are handed back for the caller to AND in separately, so the engine
1275
+ * intersects them instead of the strategy picking a winner.
1276
+ */
1277
+ mergeFilterOperand(filter, field, operand) {
1278
+ const existing = filter[field];
1279
+ if (existing === void 0) {
1280
+ filter[field] = operand;
1281
+ return null;
1282
+ }
1283
+ const mergeable = (v) => !!v && typeof v === "object" && !Array.isArray(v);
1284
+ if (!mergeable(existing) || !mergeable(operand)) return { [field]: operand };
1285
+ if (Object.keys(operand).some((op) => op in existing)) return { [field]: operand };
1286
+ filter[field] = { ...existing, ...operand };
1287
+ return null;
1288
+ }
1289
+ /**
1290
+ * Lower `timeDimensions[].dateRange` into resolved-field bounds (#3650).
1291
+ *
1292
+ * `dateRange` states a WINDOW on a time dimension; it is a SIBLING of `where`,
1293
+ * never folded into it. `normalizeAnalyticsFilters` reads only `where`, so
1294
+ * this path used to drop the window on the floor — no error, just every row
1295
+ * ever recorded. Nor is that a corner case: `NativeSQLStrategy.canHandle`
1296
+ * declines any query carrying a `granularity`, so a date-bucketed trend lands
1297
+ * HERE on every driver — and "bucketed trend" is precisely the shape that also
1298
+ * carries a range ("last 12 months", "this quarter").
1299
+ *
1300
+ * Bounds are inclusive on both ends — the same `$gte`/`$lte` pair
1301
+ * `NativeSQLStrategy` binds as `BETWEEN` and the memory driver builds as a
1302
+ * `$match`, so one dashboard reads the same on every driver.
1303
+ *
1304
+ * Comparands are coerced by the SAME helper the `where` path uses, so an
1305
+ * epoch-ms bound recovers as a number and an ISO string stays a string. No
1306
+ * STORAGE coercion happens here, deliberately: `NativeSQLStrategy` needs
1307
+ * `coerceTemporal` because it binds into raw SQL and had to learn that a
1308
+ * SQLite `Field.datetime` is an INTEGER epoch (#2034); this path goes through
1309
+ * `engine.aggregate()`, where the driver's own CRUD filter coercion applies —
1310
+ * the very coercion that already makes a `where` bound on that same column
1311
+ * work today.
1312
+ *
1313
+ * A bare-string `dateRange` degenerates to the single point `[s, s]`, matching
1314
+ * `NativeSQLStrategy`. Relative phrases ("Last 7 days") are NOT resolved here;
1315
+ * neither SQL path resolves them, and inventing a second interpretation on the
1316
+ * driver-independent path is how the two would drift apart again.
1317
+ *
1318
+ * An oddly-sized array (the schema types `dateRange` as a plain `string[]`)
1319
+ * takes its first two entries, a one-entry array degenerating to a point.
1320
+ * `NativeSQLStrategy` drops such a window entirely — but "drop the window"
1321
+ * means "plot all of history", which is the very failure this fixes, so the
1322
+ * fallback here errs toward the narrower query instead.
1323
+ */
1324
+ dateRangeBounds(cube, query) {
1325
+ const out = [];
1326
+ for (const td of query.timeDimensions ?? []) {
1327
+ if (!td.dateRange) continue;
1328
+ const range = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
1329
+ const [start, end = start] = range;
1330
+ if (start == null) continue;
1331
+ out.push({
1332
+ field: this.resolveFieldName(cube, td.dimension, "dimension"),
1333
+ bounds: {
1334
+ $gte: coerceFilterValueForObjectQL(String(start)),
1335
+ $lte: coerceFilterValueForObjectQL(String(end))
1336
+ }
1337
+ });
1338
+ }
1339
+ return out;
1340
+ }
881
1341
  convertFilter(operator, values) {
882
1342
  if (operator === "set") return { $ne: null };
883
1343
  if (operator === "notSet") return null;
@@ -1062,6 +1522,23 @@ function compileDataset(dataset, resolver) {
1062
1522
  }
1063
1523
 
1064
1524
  // src/dataset-executor.ts
1525
+ var import_core = require("@objectstack/core");
1526
+ function resolveSelectionTokens(compiled, selection, context) {
1527
+ const tokenCtx = (0, import_core.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
1528
+ const resolve = (v) => (0, import_core.resolveFilterTokens)(v, tokenCtx);
1529
+ const filter = resolve(compiled.filter);
1530
+ const measureFilters = resolve(compiled.measureFilters);
1531
+ const runtimeFilter = resolve(selection.runtimeFilter);
1532
+ const timeDimensions = selection.timeDimensions?.map(
1533
+ (td) => td.dateRange == null ? td : { ...td, dateRange: resolve(td.dateRange) }
1534
+ );
1535
+ const compiledChanged = filter !== compiled.filter || measureFilters !== compiled.measureFilters;
1536
+ const selectionChanged = runtimeFilter !== selection.runtimeFilter || timeDimensions !== void 0 && timeDimensions.some((td, i) => td !== selection.timeDimensions[i]);
1537
+ return {
1538
+ compiled: compiledChanged ? { ...compiled, filter, measureFilters } : compiled,
1539
+ selection: selectionChanged ? { ...selection, runtimeFilter, timeDimensions } : selection
1540
+ };
1541
+ }
1065
1542
  function combineFilters(a, b) {
1066
1543
  if (a && b) return { $and: [a, b] };
1067
1544
  return a ?? b;
@@ -1100,6 +1577,72 @@ function computeDerived(d, row) {
1100
1577
  return null;
1101
1578
  }
1102
1579
  }
1580
+ function resolveDimensionGranularity(selection, dimension, datasetDefault) {
1581
+ const stated = (selection.timeDimensions ?? []).find((t) => t.dimension === dimension)?.granularity;
1582
+ if (stated) return stated;
1583
+ return selection.dateGranularity ?? datasetDefault;
1584
+ }
1585
+ function compareValues(a, b) {
1586
+ const aNull = a == null || a === "";
1587
+ const bNull = b == null || b === "";
1588
+ if (aNull || bNull) return aNull && bNull ? 0 : aNull ? 1 : -1;
1589
+ if (a instanceof Date || b instanceof Date) {
1590
+ return Number(a instanceof Date ? a.getTime() : a) - Number(b instanceof Date ? b.getTime() : b);
1591
+ }
1592
+ if (typeof a === "boolean" || typeof b === "boolean") {
1593
+ return Number(a) - Number(b);
1594
+ }
1595
+ const an = typeof a === "number" ? a : Number(a);
1596
+ const bn = typeof b === "number" ? b : Number(b);
1597
+ if (Number.isFinite(an) && Number.isFinite(bn)) return an - bn;
1598
+ return String(a).localeCompare(String(b));
1599
+ }
1600
+ function applyOrdering(rows, order, sortKeys) {
1601
+ const keys = Object.entries(order ?? {});
1602
+ if (keys.length === 0 || rows.length < 2) return rows;
1603
+ return [...rows].sort((ra, rb) => {
1604
+ for (const [key, dir] of keys) {
1605
+ const map = sortKeys?.[key];
1606
+ const av = map?.get(ra[key]) ?? ra[key];
1607
+ const bv = map?.get(rb[key]) ?? rb[key];
1608
+ const aNull = av == null || av === "";
1609
+ const bNull = bv == null || bv === "";
1610
+ if (aNull || bNull) {
1611
+ if (aNull && bNull) continue;
1612
+ return aNull ? 1 : -1;
1613
+ }
1614
+ const c = compareValues(av, bv);
1615
+ if (c !== 0) return dir === "desc" ? -c : c;
1616
+ }
1617
+ return 0;
1618
+ });
1619
+ }
1620
+ function applyWindow(rows, limit, offset) {
1621
+ const start = offset != null && offset > 0 ? offset : 0;
1622
+ if (start === 0 && limit == null) return rows;
1623
+ return rows.slice(start, limit != null ? start + limit : void 0);
1624
+ }
1625
+ function resolveOrdering(selection, dimensions) {
1626
+ const order = selection.order;
1627
+ if (order && Object.keys(order).length > 0) {
1628
+ const selectable = /* @__PURE__ */ new Set([
1629
+ ...dimensions,
1630
+ ...selection.measures,
1631
+ ...selection.measures.map((m) => `${m}__compare`)
1632
+ ]);
1633
+ const unknown = Object.keys(order).filter((k) => !selectable.has(k));
1634
+ if (unknown.length) {
1635
+ throw new Error(
1636
+ `[dataset-executor] order key(s) ${unknown.map((k) => `"${k}"`).join(", ")} \u2014 not a selected dimension or measure. Selectable here: ${[...selectable].join(", ") || "(none)"}.`
1637
+ );
1638
+ }
1639
+ return order;
1640
+ }
1641
+ if ((selection.limit != null || selection.offset != null) && dimensions.length > 0) {
1642
+ return Object.fromEntries(dimensions.map((d) => [d, "asc"]));
1643
+ }
1644
+ return void 0;
1645
+ }
1103
1646
  function parseUTC(date) {
1104
1647
  const ms = Date.parse(date.length === 10 ? `${date}T00:00:00Z` : date);
1105
1648
  if (Number.isNaN(ms)) throw new Error(`[dataset-executor] invalid date in dateRange: "${date}"`);
@@ -1127,8 +1670,17 @@ function shiftRange(range, kind) {
1127
1670
  return [toISODate(prevStartMs), toISODate(prevEndMs)];
1128
1671
  }
1129
1672
  var DatasetExecutor = class {
1130
- constructor(service) {
1673
+ /**
1674
+ * @param service - The analytics service the executor issues its queries to.
1675
+ * @param orderLabels - Optional sort-key label hook (#3680). When provided,
1676
+ * an order key naming a label-bearing (`select`/`lookup`) dimension sorts
1677
+ * by its display label instead of the stored value. Omit to sort by stored
1678
+ * values everywhere (e.g. the draft-preview path, whose seed rows already
1679
+ * carry display names).
1680
+ */
1681
+ constructor(service, orderLabels) {
1131
1682
  this.service = service;
1683
+ this.orderLabels = orderLabels;
1132
1684
  }
1133
1685
  /**
1134
1686
  * Execute a dataset selection and return the shaped rows (+ field metadata).
@@ -1137,7 +1689,8 @@ var DatasetExecutor = class {
1137
1689
  * underlying `IAnalyticsService.query` so the tenant/RLS read scope is
1138
1690
  * applied per request (ADR-0021 D-C).
1139
1691
  */
1140
- async execute(compiled, selection, context) {
1692
+ async execute(compiledInput, selectionInput, context) {
1693
+ const { compiled, selection } = resolveSelectionTokens(compiledInput, selectionInput, context);
1141
1694
  const result = await this.executeSelection(compiled, selection, context);
1142
1695
  const groupings = selection.totals?.groupings;
1143
1696
  if (groupings?.length) {
@@ -1181,6 +1734,14 @@ var DatasetExecutor = class {
1181
1734
  }
1182
1735
  const baseFilter = combineFilters(compiled.filter, selection.runtimeFilter);
1183
1736
  const dimensions = selection.dimensions ?? [];
1737
+ const order = resolveOrdering(selection, dimensions);
1738
+ const labelOrderKeys = this.orderLabels ? Object.keys(order ?? {}).filter(
1739
+ (k) => dimensions.includes(k) && this.orderLabels.isLabelBearing(k)
1740
+ ) : [];
1741
+ const singleQuery = filtered.length === 0 && !selection.compareTo && selectedDerived.length === 0;
1742
+ const pushDownKeys = /* @__PURE__ */ new Set([...dimensions, ...unfiltered]);
1743
+ const canPushDownWindow = singleQuery && labelOrderKeys.length === 0 && Object.keys(order ?? {}).every((k) => pushDownKeys.has(k));
1744
+ const windowQuery = canPushDownWindow ? { order, limit: selection.limit, offset: selection.offset } : void 0;
1184
1745
  let result;
1185
1746
  if (unfiltered.length > 0 || filtered.length === 0) {
1186
1747
  result = await this.service.query(this.buildQuery(compiled, {
@@ -1188,7 +1749,8 @@ var DatasetExecutor = class {
1188
1749
  dimensions,
1189
1750
  where: baseFilter,
1190
1751
  selection,
1191
- contextTimezone: context?.timezone
1752
+ contextTimezone: context?.timezone,
1753
+ window: windowQuery
1192
1754
  }), context);
1193
1755
  } else {
1194
1756
  result = { rows: [], fields: [] };
@@ -1217,6 +1779,15 @@ var DatasetExecutor = class {
1217
1779
  }
1218
1780
  result.rows = evaluateDerivedMeasures(result.rows, selectedDerived);
1219
1781
  for (const d of selectedDerived) result.fields.push({ name: d.name, type: "number" });
1782
+ let sortKeys;
1783
+ for (const key of labelOrderKeys) {
1784
+ const values = [...new Set(result.rows.map((r) => r[key]).filter((v) => v != null))];
1785
+ if (values.length === 0) continue;
1786
+ const labels = await this.orderLabels.resolveLabels(key, values);
1787
+ if (labels && labels.size > 0) (sortKeys ?? (sortKeys = {}))[key] = labels;
1788
+ }
1789
+ result.rows = applyOrdering(result.rows, order, sortKeys);
1790
+ result.rows = applyWindow(result.rows, selection.limit, selection.offset);
1220
1791
  return result;
1221
1792
  }
1222
1793
  buildQuery(compiled, opts) {
@@ -1231,18 +1802,28 @@ var DatasetExecutor = class {
1231
1802
  if (opts.where) q.where = opts.where;
1232
1803
  const selTimeDims = opts.selection.timeDimensions ?? [];
1233
1804
  const selDims = new Set(selTimeDims.map((t) => t.dimension));
1805
+ const granularityFor = (name) => {
1806
+ const cd = compiled.cube.dimensions[name];
1807
+ if (cd?.type !== "time") return void 0;
1808
+ const datasetDefault = cd.granularities?.length === 1 ? String(cd.granularities[0]) : void 0;
1809
+ return resolveDimensionGranularity(opts.selection, name, datasetDefault);
1810
+ };
1811
+ const resolvedTimeDims = selTimeDims.map((t) => {
1812
+ if (t.granularity) return t;
1813
+ const granularity = granularityFor(t.dimension);
1814
+ return granularity ? { ...t, granularity } : t;
1815
+ });
1234
1816
  const explicitTimeDims = [];
1235
1817
  for (const name of opts.dimensions) {
1236
- const cd = compiled.cube.dimensions[name];
1237
- if (cd?.type === "time" && cd.granularities?.length === 1 && !selDims.has(name)) {
1238
- explicitTimeDims.push({ dimension: name, granularity: String(cd.granularities[0]) });
1239
- }
1818
+ if (selDims.has(name)) continue;
1819
+ const granularity = granularityFor(name);
1820
+ if (granularity) explicitTimeDims.push({ dimension: name, granularity });
1240
1821
  }
1241
- const mergedTimeDims = [...selTimeDims, ...explicitTimeDims];
1822
+ const mergedTimeDims = [...resolvedTimeDims, ...explicitTimeDims];
1242
1823
  if (mergedTimeDims.length > 0) q.timeDimensions = mergedTimeDims;
1243
- if (opts.selection.order) q.order = opts.selection.order;
1244
- if (opts.selection.limit != null) q.limit = opts.selection.limit;
1245
- if (opts.selection.offset != null) q.offset = opts.selection.offset;
1824
+ if (opts.window?.order && Object.keys(opts.window.order).length > 0) q.order = opts.window.order;
1825
+ if (opts.window?.limit != null) q.limit = opts.window.limit;
1826
+ if (opts.window?.offset != null) q.offset = opts.window.offset;
1246
1827
  return q;
1247
1828
  }
1248
1829
  async runCompare(compiled, selection, measures, dimensions, baseFilter, context) {
@@ -1258,14 +1839,13 @@ var DatasetExecutor = class {
1258
1839
  const shiftedTd = (selection.timeDimensions ?? []).map(
1259
1840
  (t) => t.dimension === cmp.dimension ? { ...t, dateRange: shifted } : t
1260
1841
  );
1261
- const sub = await this.service.query({
1262
- cube: compiled.cube.name,
1842
+ const sub = await this.service.query(this.buildQuery(compiled, {
1263
1843
  measures,
1264
1844
  dimensions,
1265
1845
  where: baseFilter,
1266
- timeDimensions: shiftedTd,
1267
- timezone: selection.timezone ?? context?.timezone ?? "UTC"
1268
- }, context);
1846
+ selection: { ...selection, timeDimensions: shiftedTd },
1847
+ contextTimezone: context?.timezone
1848
+ }), context);
1269
1849
  return sub.rows.map((row) => {
1270
1850
  const out = {};
1271
1851
  for (const dim of dimensions) out[dim] = row[dim];
@@ -1296,11 +1876,77 @@ function mergeByDimensions(base, extra, dimensions, valueColumns) {
1296
1876
 
1297
1877
  // src/dimension-labels.ts
1298
1878
  var LOOKUP_TYPES = /* @__PURE__ */ new Set(["lookup", "master_detail"]);
1879
+ function createOrderLabelResolver(baseObject, dims, deps, resolveScope, context) {
1880
+ const dimByName = new Map(dims.map((d) => [d.name, d]));
1881
+ const metaFor = (dimension) => {
1882
+ const dim = dimByName.get(dimension);
1883
+ return dim ? deps.getObjectFields(baseObject)?.[dim.field] : void 0;
1884
+ };
1885
+ return {
1886
+ isLabelBearing(dimension) {
1887
+ const meta = metaFor(dimension);
1888
+ if (!meta) return false;
1889
+ if (Array.isArray(meta.options) && meta.options.length > 0) return true;
1890
+ return !!(meta.type && LOOKUP_TYPES.has(meta.type) && meta.reference);
1891
+ },
1892
+ async resolveLabels(dimension, values) {
1893
+ const meta = metaFor(dimension);
1894
+ if (!meta) return void 0;
1895
+ if (Array.isArray(meta.options) && meta.options.length > 0) {
1896
+ const labelByValue = /* @__PURE__ */ new Map();
1897
+ for (const opt of meta.options) {
1898
+ if (opt && opt.label != null) labelByValue.set(opt.value, String(opt.label));
1899
+ }
1900
+ return labelByValue;
1901
+ }
1902
+ if (meta.type && LOOKUP_TYPES.has(meta.type) && meta.reference) {
1903
+ let scope;
1904
+ if (resolveScope) {
1905
+ try {
1906
+ scope = await resolveScope(meta.reference);
1907
+ } catch {
1908
+ return void 0;
1909
+ }
1910
+ }
1911
+ return deps.fetchRecordLabels(meta.reference, values, scope ?? void 0, context);
1912
+ }
1913
+ return void 0;
1914
+ }
1915
+ };
1916
+ }
1917
+ function withLabelFetchCache(deps) {
1918
+ const cache = /* @__PURE__ */ new Map();
1919
+ return {
1920
+ getObjectFields: (objectName) => deps.getObjectFields(objectName),
1921
+ async fetchRecordLabels(targetObject, ids, scope, context) {
1922
+ let known = cache.get(targetObject);
1923
+ if (!known) {
1924
+ known = /* @__PURE__ */ new Map();
1925
+ cache.set(targetObject, known);
1926
+ }
1927
+ const missing = ids.filter((id) => !known.has(id));
1928
+ if (missing.length > 0) {
1929
+ const fetched = await deps.fetchRecordLabels(targetObject, missing, scope, context);
1930
+ for (const id of missing) known.set(id, fetched.get(id) ?? null);
1931
+ }
1932
+ const out = /* @__PURE__ */ new Map();
1933
+ for (const id of ids) {
1934
+ const label = known.get(id);
1935
+ if (label != null) out.set(id, label);
1936
+ }
1937
+ return out;
1938
+ }
1939
+ };
1940
+ }
1299
1941
  var pad = (n) => String(n).padStart(2, "0");
1300
1942
  function formatDateBucket(value, granularity) {
1301
1943
  if (value == null || value instanceof Date === false) {
1302
1944
  if (typeof value !== "number" && typeof value !== "string") return value;
1303
1945
  }
1946
+ if (granularity === "year") {
1947
+ const y2 = typeof value === "number" ? value : Number(String(value).trim());
1948
+ if (Number.isInteger(y2) && y2 >= 1e3 && y2 <= 9999) return String(y2);
1949
+ }
1304
1950
  let d;
1305
1951
  if (value instanceof Date) d = value;
1306
1952
  else if (typeof value === "number") d = new Date(value);
@@ -1324,7 +1970,7 @@ function formatDateBucket(value, granularity) {
1324
1970
  return `${y}-${pad(m + 1)}-${pad(d.getUTCDate())}`;
1325
1971
  }
1326
1972
  }
1327
- async function resolveDimensionLabels(baseObject, dims, rows, deps) {
1973
+ async function resolveDimensionLabels(baseObject, dims, rows, deps, resolveScope, context) {
1328
1974
  if (!rows.length || !dims.length) return;
1329
1975
  const fields = deps.getObjectFields(baseObject);
1330
1976
  if (!fields) return;
@@ -1356,7 +2002,15 @@ async function resolveDimensionLabels(baseObject, dims, rows, deps) {
1356
2002
  new Set(rows.map((r) => r[dim.name]).filter((v) => v != null))
1357
2003
  );
1358
2004
  if (ids.length === 0) continue;
1359
- const labelById = await deps.fetchRecordLabels(meta.reference, ids);
2005
+ let scope;
2006
+ if (resolveScope) {
2007
+ try {
2008
+ scope = await resolveScope(meta.reference);
2009
+ } catch {
2010
+ continue;
2011
+ }
2012
+ }
2013
+ const labelById = await deps.fetchRecordLabels(meta.reference, ids, scope ?? void 0, context);
1360
2014
  if (!labelById || labelById.size === 0) continue;
1361
2015
  for (const row of rows) {
1362
2016
  const label = labelById.get(row[dim.name]);
@@ -1377,7 +2031,7 @@ function pickDisplayField(fields) {
1377
2031
  }
1378
2032
 
1379
2033
  // src/preview-evaluator.ts
1380
- var import_core = require("@objectstack/core");
2034
+ var import_core2 = require("@objectstack/core");
1381
2035
  function compare(a, b) {
1382
2036
  if (typeof a === "number" && typeof b === "number") return a - b;
1383
2037
  return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0;
@@ -1428,7 +2082,7 @@ function matchesWhere(row, where) {
1428
2082
  function bucketDate(value, granularity, timezone) {
1429
2083
  const d = new Date(String(value));
1430
2084
  if (Number.isNaN(d.getTime())) return null;
1431
- const { year: y, month, day: dayNum } = (0, import_core.calendarPartsInTzOrUtc)(d, timezone);
2085
+ const { year: y, month, day: dayNum } = (0, import_core2.calendarPartsInTzOrUtc)(d, timezone);
1432
2086
  const m = `${month}`.padStart(2, "0");
1433
2087
  const day = `${dayNum}`.padStart(2, "0");
1434
2088
  switch (granularity) {
@@ -1550,7 +2204,9 @@ var AnalyticsService = class {
1550
2204
  constructor(config = {}) {
1551
2205
  /** Compiled datasets by name — feeds the join allowlist (D-C) and queryDataset. */
1552
2206
  this.datasetRegistry = /* @__PURE__ */ new Map();
1553
- this.logger = config.logger || (0, import_core2.createLogger)({ level: "info", format: "pretty" });
2207
+ /** [#3867] One-shot flag for the {@link assertInferableCube} stand-down warning. */
2208
+ this.warnedNoObjectRegistry = false;
2209
+ this.logger = config.logger || (0, import_core3.createLogger)({ level: "info", format: "pretty" });
1554
2210
  this.cubeRegistry = new CubeRegistry();
1555
2211
  if (config.cubes) {
1556
2212
  this.cubeRegistry.registerAll(config.cubes);
@@ -1560,6 +2216,7 @@ var AnalyticsService = class {
1560
2216
  this.measureCurrency = config.measureCurrency;
1561
2217
  this.labelResolver = config.labelResolver;
1562
2218
  this.draftRowsResolver = config.draftRowsResolver;
2219
+ this.isRegisteredObject = config.isRegisteredObject;
1563
2220
  if (config.datasets) {
1564
2221
  for (const ds of config.datasets) {
1565
2222
  try {
@@ -1600,10 +2257,11 @@ var AnalyticsService = class {
1600
2257
  * `getReadScope(objectName)` that already knows the active tenant.
1601
2258
  */
1602
2259
  async callCtx(query, context) {
1603
- if (!this.readScopeProvider) return this.baseCtx;
2260
+ if (!this.readScopeProvider) return { ...this.baseCtx, context };
1604
2261
  const scopes = await this.resolveReadScopes(query, context);
1605
2262
  return {
1606
2263
  ...this.baseCtx,
2264
+ context,
1607
2265
  getReadScope: (objectName) => scopes.get(objectName) ?? null
1608
2266
  };
1609
2267
  }
@@ -1724,9 +2382,19 @@ var AnalyticsService = class {
1724
2382
  return previewResult;
1725
2383
  }
1726
2384
  }
2385
+ const provider = this.readScopeProvider;
2386
+ const resolveScope = provider ? (targetObject) => provider(targetObject, context) : void 0;
2387
+ const labelDeps = this.labelResolver ? withLabelFetchCache(this.labelResolver) : void 0;
2388
+ const orderLabels = labelDeps && dataset.dimensions?.length ? createOrderLabelResolver(
2389
+ dataset.object,
2390
+ dataset.dimensions.filter((d) => !!d.field).map((d) => ({ name: d.name, field: d.field })),
2391
+ labelDeps,
2392
+ resolveScope,
2393
+ context
2394
+ ) : void 0;
1727
2395
  let result;
1728
2396
  try {
1729
- result = await new DatasetExecutor(this).execute(compiled, selection, context);
2397
+ result = await new DatasetExecutor(this, orderLabels).execute(compiled, selection, context);
1730
2398
  } catch (err) {
1731
2399
  if (isMissingSourceError(err)) {
1732
2400
  this.logger.warn(
@@ -1762,18 +2430,20 @@ var AnalyticsService = class {
1762
2430
  const rangeTz = selection.timezone ?? context?.timezone ?? "UTC";
1763
2431
  const rangeDims = [];
1764
2432
  for (const d of selectedDims) {
1765
- if (!d.field || d.type !== "date" || !d.dateGranularity) continue;
2433
+ if (!d.field || d.type !== "date") continue;
2434
+ const granularity = resolveDimensionGranularity(selection, d.name, d.dateGranularity);
2435
+ if (!granularity) continue;
1766
2436
  const ftype = this.measureCurrency?.(dataset.object, d.field)?.type;
1767
- if (ftype === "datetime") rangeDims.push({ d, instant: true });
1768
- else if (ftype === "date") rangeDims.push({ d, instant: false });
1769
- else if (rangeTz === "UTC") rangeDims.push({ d, instant: false });
2437
+ if (ftype === "datetime") rangeDims.push({ d, granularity, instant: true });
2438
+ else if (ftype === "date") rangeDims.push({ d, granularity, instant: false });
2439
+ else if (rangeTz === "UTC") rangeDims.push({ d, granularity, instant: false });
1770
2440
  }
1771
2441
  if (rangeDims.length && result.rows.length) {
1772
- const bound = (ymd, instant) => instant ? new Date((0, import_core2.zonedDateStartToUtcMs)(ymd, rangeTz)).toISOString() : ymd;
2442
+ const bound = (ymd, instant) => instant ? new Date((0, import_core3.zonedDateStartToUtcMs)(ymd, rangeTz)).toISOString() : ymd;
1773
2443
  result.drillRanges = result.rows.map((row) => {
1774
2444
  const ranges = {};
1775
- for (const { d, instant } of rangeDims) {
1776
- const cal = (0, import_core2.bucketKeyToCalendarRange)(row[d.name], d.dateGranularity);
2445
+ for (const { d, granularity, instant } of rangeDims) {
2446
+ const cal = (0, import_core3.bucketKeyToCalendarRange)(row[d.name], granularity);
1777
2447
  if (cal) {
1778
2448
  ranges[d.name] = { field: d.field, gte: bound(cal.start, instant), lt: bound(cal.end, instant) };
1779
2449
  }
@@ -1782,15 +2452,20 @@ var AnalyticsService = class {
1782
2452
  });
1783
2453
  result.object = dataset.object;
1784
2454
  }
1785
- if (this.labelResolver && selectedDims.length) {
1786
- const dims = selectedDims.filter((d) => !!d.field).map((d) => ({ name: d.name, field: d.field, type: d.type, dateGranularity: d.dateGranularity }));
2455
+ if (labelDeps && selectedDims.length) {
2456
+ const dims = selectedDims.filter((d) => !!d.field).map((d) => ({
2457
+ name: d.name,
2458
+ field: d.field,
2459
+ type: d.type,
2460
+ dateGranularity: resolveDimensionGranularity(selection, d.name, d.dateGranularity)
2461
+ }));
1787
2462
  if (dims.length) {
1788
2463
  try {
1789
- await resolveDimensionLabels(dataset.object, dims, result.rows, this.labelResolver);
2464
+ await resolveDimensionLabels(dataset.object, dims, result.rows, labelDeps, resolveScope, context);
1790
2465
  for (const total of result.totals ?? []) {
1791
2466
  const subset = dims.filter((d) => total.dimensions.includes(d.name));
1792
2467
  if (subset.length) {
1793
- await resolveDimensionLabels(dataset.object, subset, total.rows, this.labelResolver);
2468
+ await resolveDimensionLabels(dataset.object, subset, total.rows, labelDeps, resolveScope, context);
1794
2469
  }
1795
2470
  }
1796
2471
  } catch (e) {
@@ -1878,6 +2553,7 @@ var AnalyticsService = class {
1878
2553
  const name = query.cube;
1879
2554
  let cube = this.cubeRegistry.get(name);
1880
2555
  if (!cube) {
2556
+ this.assertInferableCube(name);
1881
2557
  cube = this.inferCubeFromQuery(query);
1882
2558
  this.cubeRegistry.register(cube);
1883
2559
  const isScalarMetric = (query.dimensions?.length ?? 0) === 0 && (query.timeDimensions?.length ?? 0) === 0;
@@ -1904,6 +2580,39 @@ var AnalyticsService = class {
1904
2580
  );
1905
2581
  }
1906
2582
  }
2583
+ /**
2584
+ * [#3867] Gate on the cube auto-inference path: a name with no registered
2585
+ * Cube may only be inferred into one if it is a registered object.
2586
+ *
2587
+ * Rejects with `status: 404` / `code: 'CUBE_NOT_FOUND'` so the HTTP boundary
2588
+ * answers "no such cube" instead of letting the name reach the driver as a
2589
+ * table and surfacing whatever the driver says about it. The message names
2590
+ * both ways the request could be made valid, because from here the two are
2591
+ * genuinely indistinguishable: register a Cube, or register the object.
2592
+ *
2593
+ * Skips when `isRegisteredObject` was not supplied — see the config field's
2594
+ * doc for why that tier is a deliberate stand-down and not a hole.
2595
+ */
2596
+ assertInferableCube(name) {
2597
+ const isRegisteredObject = this.isRegisteredObject;
2598
+ if (!isRegisteredObject) {
2599
+ if (!this.warnedNoObjectRegistry) {
2600
+ this.warnedNoObjectRegistry = true;
2601
+ this.logger.warn(
2602
+ "[Analytics] no object-registry hook configured \u2014 the cube-inference existence gate (#3867) is INACTIVE for this service; an unregistered cube name reaches the driver as a raw table name."
2603
+ );
2604
+ }
2605
+ return;
2606
+ }
2607
+ if (isRegisteredObject(name)) return;
2608
+ const err = new Error(
2609
+ `Cube '${name}' not found: no cube is registered under that name, and it is not a registered object either (a cube can only be auto-inferred from a registered object). Define a Cube in your stack, or check the object name.`
2610
+ );
2611
+ err.code = "CUBE_NOT_FOUND";
2612
+ err.status = 404;
2613
+ err.cube = name;
2614
+ throw err;
2615
+ }
1907
2616
  /** Build a minimal Cube from the fields referenced by an AnalyticsQuery. */
1908
2617
  inferCubeFromQuery(query) {
1909
2618
  const cubeName = query.cube;
@@ -2045,7 +2754,7 @@ var AnalyticsServicePlugin = class {
2045
2754
  '[Analytics] No "data" service registered yet at init; will retry per-query. Register ObjectQLPlugin or pass executeAggregate.'
2046
2755
  );
2047
2756
  }
2048
- executeAggregate = async (objectName, { groupBy, aggregations, filter, timezone }) => {
2757
+ executeAggregate = async (objectName, { groupBy, aggregations, filter, timezone, context }) => {
2049
2758
  const engine = tryGetDataEngine();
2050
2759
  if (!engine) {
2051
2760
  throw new Error(
@@ -2062,7 +2771,13 @@ var AnalyticsServicePlugin = class {
2062
2771
  })),
2063
2772
  // ADR-0053 Phase 2: thread the reference tz so date buckets resolve on
2064
2773
  // that zone's calendar days (engine buckets in-memory when non-UTC).
2065
- timezone
2774
+ timezone,
2775
+ // ADR-0021 D-C (#3602): thread the caller's identity so the engine's
2776
+ // middleware chain scopes the read itself. `BaseEngineOptions.context`
2777
+ // is `.optional()`, so nothing ever forced this bridge to pass it —
2778
+ // and it did not, which is how an authenticated aggregate reached the
2779
+ // engine with no principal and plugin-security fell open (#3597).
2780
+ context
2066
2781
  });
2067
2782
  return rows;
2068
2783
  };
@@ -2110,6 +2825,7 @@ var AnalyticsServicePlugin = class {
2110
2825
  }));
2111
2826
  let getReadScope = this.options.getReadScope;
2112
2827
  let autoBridgedReadScope = false;
2828
+ let securityPresentAtInit = false;
2113
2829
  if (!getReadScope) {
2114
2830
  const trySecurity = () => {
2115
2831
  try {
@@ -2119,10 +2835,9 @@ var AnalyticsServicePlugin = class {
2119
2835
  return void 0;
2120
2836
  }
2121
2837
  };
2122
- if (trySecurity()) {
2123
- getReadScope = (object, context) => trySecurity()?.getReadFilter(object, context);
2124
- autoBridgedReadScope = true;
2125
- }
2838
+ securityPresentAtInit = !!trySecurity();
2839
+ getReadScope = (object, context) => trySecurity()?.getReadFilter(object, context);
2840
+ autoBridgedReadScope = true;
2126
2841
  }
2127
2842
  const relationshipResolver = (baseObject, relationshipName) => {
2128
2843
  const engine = (() => {
@@ -2150,17 +2865,26 @@ var AnalyticsServicePlugin = class {
2150
2865
  };
2151
2866
  const labelResolver = {
2152
2867
  getObjectFields: (objectName) => dataEngine()?.getObject?.(objectName)?.fields,
2153
- fetchRecordLabels: async (targetObject, ids) => {
2868
+ fetchRecordLabels: async (targetObject, ids, scope, context) => {
2154
2869
  const map = /* @__PURE__ */ new Map();
2155
2870
  const displayField = pickDisplayField(dataEngine()?.getObject?.(targetObject)?.fields);
2156
2871
  if (!displayField || !executeAggregate || ids.length === 0) return map;
2157
- const rows = await executeAggregate(targetObject, {
2158
- groupBy: ["id", displayField],
2159
- aggregations: [{ field: "id", method: "count", alias: "_c" }],
2160
- filter: { id: { $in: ids } }
2161
- });
2162
- for (const r of rows) {
2163
- if (r.id != null && r[displayField] != null) map.set(r.id, String(r[displayField]));
2872
+ const CHUNK = 500;
2873
+ for (let i = 0; i < ids.length; i += CHUNK) {
2874
+ const idFilter = { id: { $in: ids.slice(i, i + CHUNK) } };
2875
+ const filter = scope ? { $and: [idFilter, scope] } : idFilter;
2876
+ const rows = await executeAggregate(targetObject, {
2877
+ groupBy: ["id", displayField],
2878
+ aggregations: [{ field: "id", method: "count", alias: "_c" }],
2879
+ filter,
2880
+ // #3602 second belt — `scope` above is the analytics layer's own
2881
+ // predicate on this per-record read; the context makes the engine's
2882
+ // middleware scope it as well.
2883
+ context
2884
+ });
2885
+ for (const r of rows) {
2886
+ if (r.id != null && r[displayField] != null) map.set(r.id, String(r[displayField]));
2887
+ }
2164
2888
  }
2165
2889
  return map;
2166
2890
  }
@@ -2225,13 +2949,31 @@ var AnalyticsServicePlugin = class {
2225
2949
  const obj = dataEngine()?.getObject?.(objectName);
2226
2950
  return !!(obj && obj.external != null);
2227
2951
  },
2952
+ // [#3867] Existence probe for the cube auto-inference gate. Reads the
2953
+ // same schema registry the data path's #3770 gate consults, through the
2954
+ // engine accessor this bridge already uses above — so "which objects
2955
+ // exist" has one answer across /data and /analytics.
2956
+ //
2957
+ // `dataEngine()` resolves lazily and may be absent entirely (analytics
2958
+ // installed without a data engine). Reporting `false` there would 404
2959
+ // every cube, so an unresolvable engine reports `true` — "cannot answer,
2960
+ // do not block" — mirroring the tiering #3770 took on the data path.
2961
+ isRegisteredObject: (name) => {
2962
+ const engine = dataEngine();
2963
+ if (!engine) return true;
2964
+ return engine.getObject?.(name) != null;
2965
+ },
2228
2966
  draftRowsResolver
2229
2967
  };
2230
- if (autoBridgedReadScope) {
2968
+ if (autoBridgedReadScope && securityPresentAtInit) {
2231
2969
  ctx.logger.info('[Analytics] Auto-bridged getReadScope \u2192 "security" service (getReadFilter)');
2970
+ } else if (autoBridgedReadScope) {
2971
+ ctx.logger.info(
2972
+ '[Analytics] getReadScope bridged to the "security" service; that service is not registered yet at init and will be resolved per query (plugin order is not significant).'
2973
+ );
2232
2974
  } else if (!getReadScope) {
2233
2975
  ctx.logger.warn(
2234
- '[Analytics] No getReadScope configured and no "security" service with getReadFilter found \u2014 the raw-SQL analytics path will NOT enforce tenant/RLS scoping on joined objects (ADR-0021 D-C). Supply getReadScope or register a security service in multi-tenant deployments.'
2976
+ '[Analytics] No getReadScope configured and no "security" service with getReadFilter found \u2014 analytics queries will NOT enforce tenant/RLS scoping (ADR-0021 D-C). Supply getReadScope or register a security service in multi-tenant deployments.'
2235
2977
  );
2236
2978
  }
2237
2979
  if (autoBridged) {
@@ -2275,10 +3017,12 @@ var AnalyticsServicePlugin = class {
2275
3017
  combineFilters,
2276
3018
  compileDataset,
2277
3019
  compileScopedFilterToSql,
3020
+ createOrderLabelResolver,
2278
3021
  evaluateDerivedMeasures,
2279
3022
  mergeByDimensions,
2280
3023
  pickDisplayField,
2281
3024
  resolveDimensionLabels,
2282
- shiftRange
3025
+ shiftRange,
3026
+ withLabelFetchCache
2283
3027
  });
2284
3028
  //# sourceMappingURL=index.cjs.map