@objectstack/service-analytics 16.0.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.js CHANGED
@@ -668,7 +668,70 @@ var NativeSQLStrategy = class {
668
668
  }
669
669
  };
670
670
 
671
+ // src/strategies/cross-object-rebucket.ts
672
+ var RECOMBINABLE_METHODS = /* @__PURE__ */ new Set([
673
+ "sum",
674
+ "count",
675
+ "min",
676
+ "max"
677
+ ]);
678
+ var RESTRICTED_BUCKET = "(restricted)";
679
+ function orderableValue(v) {
680
+ if (v == null) return NaN;
681
+ if (typeof v === "number") return v;
682
+ if (v instanceof Date) return v.getTime();
683
+ const n = Number(v);
684
+ if (Number.isFinite(n)) return n;
685
+ return Date.parse(String(v));
686
+ }
687
+ function recombine(method, acc, next) {
688
+ if (method === "min" || method === "max") {
689
+ if (acc === void 0) return next ?? 0;
690
+ const a = orderableValue(acc);
691
+ const n2 = orderableValue(next);
692
+ if (Number.isNaN(n2)) return acc;
693
+ if (Number.isNaN(a)) return next;
694
+ const nextWins = method === "min" ? n2 < a : n2 > a;
695
+ return nextWins ? next : acc;
696
+ }
697
+ const n = Number(next ?? 0);
698
+ return acc === void 0 ? n : Number(acc) + n;
699
+ }
700
+ function rebucketCrossObject(baseRows, baseDimFields, crossDims, measures) {
701
+ const buckets = /* @__PURE__ */ new Map();
702
+ for (const row of baseRows) {
703
+ const resolved = {};
704
+ for (const cd of crossDims) {
705
+ const fk = row[cd.fkField];
706
+ resolved[cd.outputName] = cd.fkToAttr.has(fk) ? cd.fkToAttr.get(fk) : RESTRICTED_BUCKET;
707
+ }
708
+ const keyParts = [];
709
+ for (const f of baseDimFields) keyParts.push(`${f}=${JSON.stringify(row[f] ?? null)}`);
710
+ for (const cd of crossDims) keyParts.push(`${cd.outputName}=${String(resolved[cd.outputName])}`);
711
+ const key = keyParts.join("");
712
+ let bucket = buckets.get(key);
713
+ if (!bucket) {
714
+ bucket = {};
715
+ for (const f of baseDimFields) bucket[f] = row[f];
716
+ for (const cd of crossDims) bucket[cd.outputName] = resolved[cd.outputName];
717
+ buckets.set(key, bucket);
718
+ }
719
+ for (const m of measures) {
720
+ bucket[m.alias] = recombine(m.method, bucket[m.alias], row[m.alias]);
721
+ }
722
+ }
723
+ return [...buckets.values()];
724
+ }
725
+
671
726
  // src/strategies/objectql-strategy.ts
727
+ var SCALAR_SQL_OPS = {
728
+ equals: "=",
729
+ notEquals: "!=",
730
+ gt: ">",
731
+ gte: ">=",
732
+ lt: "<",
733
+ lte: "<="
734
+ };
672
735
  var ObjectQLStrategy = class {
673
736
  constructor() {
674
737
  this.name = "ObjectQLStrategy";
@@ -706,15 +769,22 @@ var ObjectQLStrategy = class {
706
769
  }
707
770
  }
708
771
  const filter = {};
709
- const normalizedFilters = normalizeAnalyticsFilters(query);
710
- if (normalizedFilters.length > 0) {
711
- for (const f of normalizedFilters) {
712
- const fieldName = this.resolveFieldName(cube, f.member, "any");
713
- const converted = this.convertFilter(f.operator, f.values);
714
- const existing = filter[fieldName];
715
- const mergeable = (v) => !!v && typeof v === "object" && !Array.isArray(v);
716
- filter[fieldName] = mergeable(existing) && mergeable(converted) ? { ...existing, ...converted } : converted;
717
- }
772
+ 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
+ }
778
+ for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
779
+ const extra = this.mergeFilterOperand(filter, field, bounds);
780
+ if (extra) conjuncts.push(extra);
781
+ }
782
+ if (conjuncts.length > 0) {
783
+ filter.$and = [...Array.isArray(filter.$and) ? filter.$and : [], ...conjuncts];
784
+ }
785
+ const plan = this.planCrossObject(cube, query, filter);
786
+ if (plan) {
787
+ return this.executeCrossObject(cube, query, aggregations, filter, plan, ctx);
718
788
  }
719
789
  const rows = await ctx.executeAggregate(objectName, {
720
790
  // Structured groupBy items ({field, dateGranularity}) pass through the
@@ -722,11 +792,17 @@ var ObjectQLStrategy = class {
722
792
  // contract types groupBy as string[]; the cast carries the richer shape.
723
793
  groupBy: groupBy.length > 0 ? groupBy : void 0,
724
794
  aggregations: aggregations.length > 0 ? aggregations : void 0,
725
- filter: Object.keys(filter).length > 0 ? filter : void 0,
795
+ filter: this.withReadScope(objectName, filter, ctx),
726
796
  // ADR-0053 Phase 2 (D2): forward the reference tz so date buckets resolve
727
797
  // on that zone's calendar days. A non-UTC zone makes the engine bucket
728
798
  // in-memory (uniform across drivers); UTC/unset keeps the DB fast path.
729
- timezone: query.timezone
799
+ timezone: query.timezone,
800
+ // ADR-0021 D-C (#3602): the second belt. `withReadScope` above is this
801
+ // layer's own scoping; handing the engine the context makes ITS middleware
802
+ // inject RLS too, so a future strategy that forgets `withReadScope` still
803
+ // cannot read across tenants. Without it the operation reaches the engine
804
+ // principal-less and plugin-security falls open — the #3597 shape.
805
+ context: ctx.context
730
806
  });
731
807
  const mappedRows = rows.map((row) => {
732
808
  const mapped = {};
@@ -744,8 +820,28 @@ var ObjectQLStrategy = class {
744
820
  return mapped;
745
821
  });
746
822
  const fields = this.buildFieldMeta(query, cube);
747
- return { rows: mappedRows, fields };
823
+ let sql;
824
+ try {
825
+ sql = (await this.generateSql(query, ctx)).sql;
826
+ } catch {
827
+ sql = void 0;
828
+ }
829
+ return sql ? { rows: mappedRows, fields, sql } : { rows: mappedRows, fields };
748
830
  }
831
+ /**
832
+ * Render a REPRESENTATIVE SQL string for an ObjectQL aggregate query.
833
+ *
834
+ * This path executes through `engine.aggregate()`, not raw SQL, so the string
835
+ * is documentation rather than the literal statement — but it must be an
836
+ * honest account of what the query does, because dataset responses echo it
837
+ * and authors read it to verify their widget options landed (#3588). It
838
+ * therefore renders date bucketing (`date_trunc`), the WHERE predicate,
839
+ * ordering, and the row window.
840
+ *
841
+ * Filter VALUES are rendered as `$n` placeholders and returned in `params`,
842
+ * never inlined: the echoed statement travels to the browser, and a filter
843
+ * comparand can carry tenant data.
844
+ */
749
845
  async generateSql(query, ctx) {
750
846
  const cube = ctx.getCube(query.cube);
751
847
  if (!cube) {
@@ -753,28 +849,307 @@ var ObjectQLStrategy = class {
753
849
  }
754
850
  const selectParts = [];
755
851
  const groupByParts = [];
852
+ const params = [];
853
+ const granByDim = /* @__PURE__ */ new Map();
854
+ for (const td of query.timeDimensions ?? []) {
855
+ if (td.granularity) granByDim.set(td.dimension, td.granularity);
856
+ }
857
+ const tableName = this.extractObjectName(cube);
858
+ const plan = this.planCrossObject(cube, query, Object.fromEntries(
859
+ normalizeAnalyticsFilters(query).map((f) => [this.resolveFieldName(cube, f.member, "any"), true])
860
+ ));
861
+ const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd]));
862
+ const joinClauses = [];
863
+ const dimExpr = (dim) => {
864
+ const cd = crossByDim.get(dim);
865
+ if (cd) {
866
+ joinClauses.push(
867
+ `LEFT JOIN "${cd.refObject}" ON "${tableName}"."${cd.fkField}" = "${cd.refObject}"."id"`
868
+ );
869
+ return `"${cd.refObject}"."${cd.attr}"`;
870
+ }
871
+ const col = this.resolveFieldName(cube, dim, "dimension");
872
+ const gran = granByDim.get(dim);
873
+ return gran ? `date_trunc('${gran}', ${col})` : col;
874
+ };
756
875
  if (query.dimensions) {
757
876
  for (const dim of query.dimensions) {
758
- const col = this.resolveFieldName(cube, dim, "dimension");
759
- selectParts.push(`${col} AS "${dim}"`);
760
- groupByParts.push(col);
877
+ const expr = dimExpr(dim);
878
+ selectParts.push(`${expr} AS "${dim}"`);
879
+ groupByParts.push(expr);
761
880
  }
762
881
  }
882
+ for (const [dim] of granByDim) {
883
+ if (query.dimensions?.includes(dim)) continue;
884
+ const expr = dimExpr(dim);
885
+ selectParts.push(`${expr} AS "${dim}"`);
886
+ groupByParts.push(expr);
887
+ }
763
888
  if (query.measures) {
764
889
  for (const m of query.measures) {
765
890
  const { field, method } = this.resolveMeasureAggregation(cube, m);
766
- const aggSql = method === "count" ? "COUNT(*)" : `${method.toUpperCase()}(${field})`;
891
+ const aggSql = method === "count" ? "COUNT(*)" : method === "count_distinct" ? `COUNT(DISTINCT ${field})` : `${method.toUpperCase()}(${field})`;
767
892
  selectParts.push(`${aggSql} AS "${m}"`);
768
893
  }
769
894
  }
770
- const tableName = this.extractObjectName(cube);
895
+ 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
+ }
905
+ 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}`);
908
+ }
909
+ const scope = ctx.getReadScope?.(tableName);
910
+ if (scope != null) {
911
+ const { sql: scopeSql, params: scopeParams } = compileScopedFilterToSql(scope, tableName);
912
+ if (scopeSql) {
913
+ let i = 0;
914
+ const rendered = scopeSql.replace(/\?/g, () => {
915
+ params.push(scopeParams[i++]);
916
+ return `$${params.length}`;
917
+ });
918
+ whereParts.push(`(${rendered})`);
919
+ }
920
+ }
771
921
  let sql = `SELECT ${selectParts.join(", ")} FROM "${tableName}"`;
922
+ if (joinClauses.length > 0) sql += " " + joinClauses.join(" ");
923
+ if (whereParts.length > 0) {
924
+ sql += ` WHERE ${whereParts.join(" AND ")}`;
925
+ }
772
926
  if (groupByParts.length > 0) {
773
927
  sql += ` GROUP BY ${groupByParts.join(", ")}`;
774
928
  }
775
- return { sql, params: [] };
929
+ if (query.order && Object.keys(query.order).length > 0) {
930
+ const orderClauses = Object.entries(query.order).map(([f, d]) => `"${f}" ${d.toUpperCase()}`);
931
+ sql += ` ORDER BY ${orderClauses.join(", ")}`;
932
+ }
933
+ if (query.limit != null) sql += ` LIMIT ${query.limit}`;
934
+ if (query.offset != null) sql += ` OFFSET ${query.offset}`;
935
+ return { sql, params };
776
936
  }
777
937
  // ── Helpers ──────────────────────────────────────────────────────
938
+ /**
939
+ * ADR-0021 D-C (#3597) — AND the object's read scope (tenant + RLS) into the
940
+ * filter handed to `engine.aggregate`.
941
+ *
942
+ * This path used to drop the scope entirely, and the engine could not make up
943
+ * for it: the aggregate bridge passes no `ExecutionContext`, so the security
944
+ * middleware's principal-less fall-open skipped its own RLS injection. Both
945
+ * belts were off at once — an authenticated caller received aggregates
946
+ * computed over EVERY tenant's rows.
947
+ *
948
+ * Composed with `$and`, never by key merge: the query's own filter and the
949
+ * scope can name the SAME field (e.g. a dashboard filtering `organization_id`),
950
+ * and a spread would let caller input silently overwrite the security
951
+ * predicate. `$and` makes that structurally impossible.
952
+ */
953
+ withReadScope(objectName, filter, ctx) {
954
+ const userFilter = Object.keys(filter).length > 0 ? filter : void 0;
955
+ if (typeof ctx.getReadScope !== "function") return userFilter;
956
+ const scope = ctx.getReadScope(objectName);
957
+ if (scope === void 0 || scope === null) return userFilter;
958
+ const scopeFilter = scope;
959
+ if (!userFilter) return scopeFilter;
960
+ return { $and: [userFilter, scopeFilter] };
961
+ }
962
+ /** Is `field` a resolved cross-object (relationship-traversal) reference? */
963
+ isCrossObjectField(cube, field, baseObject) {
964
+ if (!field.includes(".")) return false;
965
+ const alias = field.split(".")[0];
966
+ const joinedObject = cube.joins?.[alias]?.name ?? alias;
967
+ return joinedObject !== baseObject;
968
+ }
969
+ /**
970
+ * Plan how to serve cross-object references on this join-less path (#3654).
971
+ *
972
+ * `engine.aggregate()` cannot join. A cross-object DIMENSION within a
973
+ * supported envelope is served by an FK-expand (`executeCrossObject`): group
974
+ * the base aggregate on the lookup FK, resolve the FK to the related attribute
975
+ * with a SCOPED read, re-bucket in memory. Returns `null` for a base-only
976
+ * query (direct path), a plan for an in-envelope cross-object query.
977
+ *
978
+ * THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
979
+ * (needs a real join to evaluate), a MULTI-HOP dimension (`a.b.c`), or a
980
+ * non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values
981
+ * cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
982
+ * `generateSql()` calls this too, so the preview accepts/rejects the same set.
983
+ *
984
+ * Detection is on RESOLVED field names, so a dotted dimension the cube
985
+ * flattens to a real column is treated as base, not cross-object.
986
+ */
987
+ planCrossObject(cube, query, filter) {
988
+ const baseObject = this.extractObjectName(cube);
989
+ for (const td of query.timeDimensions ?? []) {
990
+ const field = this.resolveFieldName(cube, td.dimension, "dimension");
991
+ if (this.isCrossObjectField(cube, field, baseObject)) {
992
+ throw new Error(
993
+ `[Analytics] ObjectQLStrategy cannot bucket a cross-object time dimension ("${field}").`
994
+ );
995
+ }
996
+ }
997
+ const nonDim = [
998
+ ...(query.measures ?? []).map((m) => ({ where: "measure", field: this.resolveMeasureAggregation(cube, m).field })),
999
+ ...Object.keys(filter).map((f) => ({ where: "filter", field: f }))
1000
+ ].filter((r) => this.isCrossObjectField(cube, r.field, baseObject));
1001
+ if (nonDim.length > 0) {
1002
+ throw new Error(
1003
+ `[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}.`
1004
+ );
1005
+ }
1006
+ const crossDims = [];
1007
+ for (const dim of query.dimensions ?? []) {
1008
+ const field = this.resolveFieldName(cube, dim, "dimension");
1009
+ if (!this.isCrossObjectField(cube, field, baseObject)) continue;
1010
+ const [alias, ...rest] = field.split(".");
1011
+ const attr = rest.join(".");
1012
+ if (attr.includes(".")) {
1013
+ throw new Error(
1014
+ `[Analytics] ObjectQLStrategy supports only single-hop cross-object dimensions; "${field}" traverses more than one relationship.`
1015
+ );
1016
+ }
1017
+ crossDims.push({ outputName: dim, fkField: alias, attr, refObject: cube.joins?.[alias]?.name ?? alias });
1018
+ }
1019
+ if (crossDims.length === 0) return null;
1020
+ for (const m of query.measures ?? []) {
1021
+ const { method } = this.resolveMeasureAggregation(cube, m);
1022
+ if (!RECOMBINABLE_METHODS.has(method)) {
1023
+ throw new Error(
1024
+ `[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.`
1025
+ );
1026
+ }
1027
+ }
1028
+ return { crossDims };
1029
+ }
1030
+ /**
1031
+ * Serve a cross-object-dimension query by FK-expand (#3654). The pure
1032
+ * re-bucketing step lives in `cross-object-rebucket.ts`.
1033
+ */
1034
+ async executeCrossObject(cube, query, aggregations, filter, plan, ctx) {
1035
+ const baseObject = this.extractObjectName(cube);
1036
+ const crossByDim = new Map(plan.crossDims.map((cd) => [cd.outputName, cd]));
1037
+ const granByDim = /* @__PURE__ */ new Map();
1038
+ for (const td of query.timeDimensions ?? []) {
1039
+ if (td.granularity) granByDim.set(td.dimension, td.granularity);
1040
+ }
1041
+ const groupBy = [];
1042
+ const baseDimFields = [];
1043
+ for (const dim of query.dimensions ?? []) {
1044
+ const cd = crossByDim.get(dim);
1045
+ if (cd) {
1046
+ groupBy.push(cd.fkField);
1047
+ continue;
1048
+ }
1049
+ const field = this.resolveFieldName(cube, dim, "dimension");
1050
+ const gran = granByDim.get(dim);
1051
+ groupBy.push(gran ? { field, dateGranularity: gran } : field);
1052
+ baseDimFields.push(field);
1053
+ granByDim.delete(dim);
1054
+ }
1055
+ for (const [dim, gran] of granByDim) {
1056
+ const field = this.resolveFieldName(cube, dim, "dimension");
1057
+ groupBy.push({ field, dateGranularity: gran });
1058
+ baseDimFields.push(field);
1059
+ }
1060
+ const baseRows = await ctx.executeAggregate(baseObject, {
1061
+ groupBy: groupBy.length > 0 ? groupBy : void 0,
1062
+ aggregations: aggregations.length > 0 ? aggregations : void 0,
1063
+ filter: this.withReadScope(baseObject, filter, ctx),
1064
+ timezone: query.timezone,
1065
+ context: ctx.context
1066
+ });
1067
+ const resolvedDims = [];
1068
+ for (const cd of plan.crossDims) {
1069
+ const fkValues = [...new Set(baseRows.map((r) => r[cd.fkField]).filter((v) => v != null))];
1070
+ const fkToAttr = await this.resolveFkAttr(cd.refObject, cd.attr, fkValues, ctx);
1071
+ resolvedDims.push({ outputName: cd.outputName, fkField: cd.fkField, fkToAttr });
1072
+ }
1073
+ const measures = (query.measures ?? []).map((m) => ({
1074
+ alias: m,
1075
+ // planCrossObject already asserted every measure is recombinable.
1076
+ method: this.resolveMeasureAggregation(cube, m).method
1077
+ }));
1078
+ const merged = rebucketCrossObject(baseRows, baseDimFields, resolvedDims, measures);
1079
+ const mappedRows = merged.map((row) => {
1080
+ const out = {};
1081
+ for (const dim of query.dimensions ?? []) {
1082
+ if (crossByDim.has(dim)) {
1083
+ if (dim in row) out[dim] = row[dim];
1084
+ } else {
1085
+ const field = this.resolveFieldName(cube, dim, "dimension");
1086
+ if (field in row) out[dim] = row[field];
1087
+ }
1088
+ }
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
+ for (const m of query.measures ?? []) {
1095
+ if (m in row) out[m] = row[m];
1096
+ }
1097
+ return out;
1098
+ });
1099
+ return { rows: mappedRows, fields: this.buildFieldMeta(query, cube) };
1100
+ }
1101
+ /**
1102
+ * Resolve `fkValues` (ids of `refObject`) to their `attr` values, applying the
1103
+ * referenced object's OWN read scope (#3654 / #3602). Reuses the aggregate
1104
+ * bridge — `group by (id, attr)` is one row per record. Ids the scope hides
1105
+ * are simply absent from the map (⇒ RESTRICTED bucket downstream).
1106
+ */
1107
+ async resolveFkAttr(refObject, attr, fkValues, ctx) {
1108
+ const map = /* @__PURE__ */ new Map();
1109
+ if (fkValues.length === 0 || typeof ctx.executeAggregate !== "function") return map;
1110
+ const idFilter = { id: { $in: fkValues } };
1111
+ const scope = typeof ctx.getReadScope === "function" ? ctx.getReadScope(refObject) : null;
1112
+ const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
1113
+ const rows = await ctx.executeAggregate(refObject, {
1114
+ groupBy: ["id", attr],
1115
+ aggregations: [{ field: "id", method: "count", alias: "_c" }],
1116
+ filter,
1117
+ context: ctx.context
1118
+ });
1119
+ for (const r of rows) {
1120
+ if (r.id != null) map.set(r.id, r[attr]);
1121
+ }
1122
+ return map;
1123
+ }
1124
+ /**
1125
+ * Render one normalized filter as a display SQL predicate for `generateSql`.
1126
+ *
1127
+ * Mirrors `NativeSQLStrategy.buildFilterClause`'s operator vocabulary so the
1128
+ * two previews read alike, but binds through `coerceFilterValueForObjectQL`:
1129
+ * the comparand shown is the one THIS path actually hands the engine (a real
1130
+ * boolean, not SQL's 1/0). Returns null for an operator/value combination
1131
+ * that carries no predicate, matching `execute()`, which drops it too.
1132
+ */
1133
+ buildFilterClauseSql(col, operator, values, params) {
1134
+ if (operator === "set") return `${col} IS NOT NULL`;
1135
+ if (operator === "notSet") return `${col} IS NULL`;
1136
+ if (!values || values.length === 0) return null;
1137
+ if (operator === "in" || operator === "notIn") {
1138
+ const placeholders = values.map((v) => {
1139
+ params.push(coerceFilterValueForObjectQL(v));
1140
+ return `$${params.length}`;
1141
+ }).join(", ");
1142
+ return `${col} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
1143
+ }
1144
+ if (operator === "contains" || operator === "notContains") {
1145
+ params.push(`%${values[0]}%`);
1146
+ return `${col} ${operator === "contains" ? "LIKE" : "NOT LIKE"} $${params.length}`;
1147
+ }
1148
+ const op = SCALAR_SQL_OPS[operator];
1149
+ if (!op) return null;
1150
+ params.push(coerceFilterValueForObjectQL(values[0]));
1151
+ return `${col} ${op} $${params.length}`;
1152
+ }
778
1153
  /**
779
1154
  * Resolve a member ref to a `{ sql, type? }` definition.
780
1155
  *
@@ -839,6 +1214,89 @@ var ObjectQLStrategy = class {
839
1214
  }
840
1215
  return { field: "*", method: "count" };
841
1216
  }
1217
+ /**
1218
+ * AND one more operand onto `filter[field]`, merging operator objects rather
1219
+ * than overwriting them. Returns a standalone conjunct when the two cannot
1220
+ * share one entry, or `null` when the merge absorbed the operand.
1221
+ *
1222
+ * Every predicate this strategy contributes goes through here — the caller's
1223
+ * `where` and the time-dimension `dateRange` alike. Two operands on one field
1224
+ * are the normal case (`{$gte}` from a `where` plus `{$gte,$lte}` from a
1225
+ * window on `close_date`), and a plain assignment would keep only the last:
1226
+ * that is how a range used to lose a bound.
1227
+ *
1228
+ * Spreading is sound only while the operands name DIFFERENT operators. Where
1229
+ * they collide — two `$gte` bounds on one field, which a window makes routine
1230
+ * and which a `where` can already produce on its own through `$and` — the
1231
+ * spread keeps whichever came last and WIDENS the query. Same for a bare
1232
+ * equality meeting an operator object: neither can absorb the other. Those
1233
+ * are handed back for the caller to AND in separately, so the engine
1234
+ * intersects them instead of the strategy picking a winner.
1235
+ */
1236
+ mergeFilterOperand(filter, field, operand) {
1237
+ const existing = filter[field];
1238
+ if (existing === void 0) {
1239
+ filter[field] = operand;
1240
+ return null;
1241
+ }
1242
+ const mergeable = (v) => !!v && typeof v === "object" && !Array.isArray(v);
1243
+ if (!mergeable(existing) || !mergeable(operand)) return { [field]: operand };
1244
+ if (Object.keys(operand).some((op) => op in existing)) return { [field]: operand };
1245
+ filter[field] = { ...existing, ...operand };
1246
+ return null;
1247
+ }
1248
+ /**
1249
+ * Lower `timeDimensions[].dateRange` into resolved-field bounds (#3650).
1250
+ *
1251
+ * `dateRange` states a WINDOW on a time dimension; it is a SIBLING of `where`,
1252
+ * never folded into it. `normalizeAnalyticsFilters` reads only `where`, so
1253
+ * this path used to drop the window on the floor — no error, just every row
1254
+ * ever recorded. Nor is that a corner case: `NativeSQLStrategy.canHandle`
1255
+ * declines any query carrying a `granularity`, so a date-bucketed trend lands
1256
+ * HERE on every driver — and "bucketed trend" is precisely the shape that also
1257
+ * carries a range ("last 12 months", "this quarter").
1258
+ *
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.
1262
+ *
1263
+ * Comparands are coerced by the SAME helper the `where` path uses, so an
1264
+ * epoch-ms bound recovers as a number and an ISO string stays a string. No
1265
+ * STORAGE coercion happens here, deliberately: `NativeSQLStrategy` needs
1266
+ * `coerceTemporal` because it binds into raw SQL and had to learn that a
1267
+ * SQLite `Field.datetime` is an INTEGER epoch (#2034); this path goes through
1268
+ * `engine.aggregate()`, where the driver's own CRUD filter coercion applies —
1269
+ * the very coercion that already makes a `where` bound on that same column
1270
+ * work today.
1271
+ *
1272
+ * A bare-string `dateRange` degenerates to the single point `[s, s]`, matching
1273
+ * `NativeSQLStrategy`. Relative phrases ("Last 7 days") are NOT resolved here;
1274
+ * neither SQL path resolves them, and inventing a second interpretation on the
1275
+ * driver-independent path is how the two would drift apart again.
1276
+ *
1277
+ * An oddly-sized array (the schema types `dateRange` as a plain `string[]`)
1278
+ * takes its first two entries, a one-entry array degenerating to a point.
1279
+ * `NativeSQLStrategy` drops such a window entirely — but "drop the window"
1280
+ * means "plot all of history", which is the very failure this fixes, so the
1281
+ * fallback here errs toward the narrower query instead.
1282
+ */
1283
+ dateRangeBounds(cube, query) {
1284
+ const out = [];
1285
+ for (const td of query.timeDimensions ?? []) {
1286
+ if (!td.dateRange) continue;
1287
+ const range = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
1288
+ const [start, end = start] = range;
1289
+ if (start == null) continue;
1290
+ out.push({
1291
+ field: this.resolveFieldName(cube, td.dimension, "dimension"),
1292
+ bounds: {
1293
+ $gte: coerceFilterValueForObjectQL(String(start)),
1294
+ $lte: coerceFilterValueForObjectQL(String(end))
1295
+ }
1296
+ });
1297
+ }
1298
+ return out;
1299
+ }
842
1300
  convertFilter(operator, values) {
843
1301
  if (operator === "set") return { $ne: null };
844
1302
  if (operator === "notSet") return null;
@@ -1023,6 +1481,23 @@ function compileDataset(dataset, resolver) {
1023
1481
  }
1024
1482
 
1025
1483
  // src/dataset-executor.ts
1484
+ import { filterTokenContextFrom, resolveFilterTokens } from "@objectstack/core";
1485
+ function resolveSelectionTokens(compiled, selection, context) {
1486
+ const tokenCtx = filterTokenContextFrom(context, /* @__PURE__ */ new Date());
1487
+ const resolve = (v) => resolveFilterTokens(v, tokenCtx);
1488
+ const filter = resolve(compiled.filter);
1489
+ const measureFilters = resolve(compiled.measureFilters);
1490
+ const runtimeFilter = resolve(selection.runtimeFilter);
1491
+ const timeDimensions = selection.timeDimensions?.map(
1492
+ (td) => td.dateRange == null ? td : { ...td, dateRange: resolve(td.dateRange) }
1493
+ );
1494
+ const compiledChanged = filter !== compiled.filter || measureFilters !== compiled.measureFilters;
1495
+ const selectionChanged = runtimeFilter !== selection.runtimeFilter || timeDimensions !== void 0 && timeDimensions.some((td, i) => td !== selection.timeDimensions[i]);
1496
+ return {
1497
+ compiled: compiledChanged ? { ...compiled, filter, measureFilters } : compiled,
1498
+ selection: selectionChanged ? { ...selection, runtimeFilter, timeDimensions } : selection
1499
+ };
1500
+ }
1026
1501
  function combineFilters(a, b) {
1027
1502
  if (a && b) return { $and: [a, b] };
1028
1503
  return a ?? b;
@@ -1061,6 +1536,72 @@ function computeDerived(d, row) {
1061
1536
  return null;
1062
1537
  }
1063
1538
  }
1539
+ function resolveDimensionGranularity(selection, dimension, datasetDefault) {
1540
+ const stated = (selection.timeDimensions ?? []).find((t) => t.dimension === dimension)?.granularity;
1541
+ if (stated) return stated;
1542
+ return selection.dateGranularity ?? datasetDefault;
1543
+ }
1544
+ function compareValues(a, b) {
1545
+ const aNull = a == null || a === "";
1546
+ const bNull = b == null || b === "";
1547
+ if (aNull || bNull) return aNull && bNull ? 0 : aNull ? 1 : -1;
1548
+ if (a instanceof Date || b instanceof Date) {
1549
+ return Number(a instanceof Date ? a.getTime() : a) - Number(b instanceof Date ? b.getTime() : b);
1550
+ }
1551
+ if (typeof a === "boolean" || typeof b === "boolean") {
1552
+ return Number(a) - Number(b);
1553
+ }
1554
+ const an = typeof a === "number" ? a : Number(a);
1555
+ const bn = typeof b === "number" ? b : Number(b);
1556
+ if (Number.isFinite(an) && Number.isFinite(bn)) return an - bn;
1557
+ return String(a).localeCompare(String(b));
1558
+ }
1559
+ function applyOrdering(rows, order, sortKeys) {
1560
+ const keys = Object.entries(order ?? {});
1561
+ if (keys.length === 0 || rows.length < 2) return rows;
1562
+ return [...rows].sort((ra, rb) => {
1563
+ for (const [key, dir] of keys) {
1564
+ const map = sortKeys?.[key];
1565
+ const av = map?.get(ra[key]) ?? ra[key];
1566
+ const bv = map?.get(rb[key]) ?? rb[key];
1567
+ const aNull = av == null || av === "";
1568
+ const bNull = bv == null || bv === "";
1569
+ if (aNull || bNull) {
1570
+ if (aNull && bNull) continue;
1571
+ return aNull ? 1 : -1;
1572
+ }
1573
+ const c = compareValues(av, bv);
1574
+ if (c !== 0) return dir === "desc" ? -c : c;
1575
+ }
1576
+ return 0;
1577
+ });
1578
+ }
1579
+ function applyWindow(rows, limit, offset) {
1580
+ const start = offset != null && offset > 0 ? offset : 0;
1581
+ if (start === 0 && limit == null) return rows;
1582
+ return rows.slice(start, limit != null ? start + limit : void 0);
1583
+ }
1584
+ function resolveOrdering(selection, dimensions) {
1585
+ const order = selection.order;
1586
+ if (order && Object.keys(order).length > 0) {
1587
+ const selectable = /* @__PURE__ */ new Set([
1588
+ ...dimensions,
1589
+ ...selection.measures,
1590
+ ...selection.measures.map((m) => `${m}__compare`)
1591
+ ]);
1592
+ const unknown = Object.keys(order).filter((k) => !selectable.has(k));
1593
+ if (unknown.length) {
1594
+ throw new Error(
1595
+ `[dataset-executor] order key(s) ${unknown.map((k) => `"${k}"`).join(", ")} \u2014 not a selected dimension or measure. Selectable here: ${[...selectable].join(", ") || "(none)"}.`
1596
+ );
1597
+ }
1598
+ return order;
1599
+ }
1600
+ if ((selection.limit != null || selection.offset != null) && dimensions.length > 0) {
1601
+ return Object.fromEntries(dimensions.map((d) => [d, "asc"]));
1602
+ }
1603
+ return void 0;
1604
+ }
1064
1605
  function parseUTC(date) {
1065
1606
  const ms = Date.parse(date.length === 10 ? `${date}T00:00:00Z` : date);
1066
1607
  if (Number.isNaN(ms)) throw new Error(`[dataset-executor] invalid date in dateRange: "${date}"`);
@@ -1088,8 +1629,17 @@ function shiftRange(range, kind) {
1088
1629
  return [toISODate(prevStartMs), toISODate(prevEndMs)];
1089
1630
  }
1090
1631
  var DatasetExecutor = class {
1091
- constructor(service) {
1632
+ /**
1633
+ * @param service - The analytics service the executor issues its queries to.
1634
+ * @param orderLabels - Optional sort-key label hook (#3680). When provided,
1635
+ * an order key naming a label-bearing (`select`/`lookup`) dimension sorts
1636
+ * by its display label instead of the stored value. Omit to sort by stored
1637
+ * values everywhere (e.g. the draft-preview path, whose seed rows already
1638
+ * carry display names).
1639
+ */
1640
+ constructor(service, orderLabels) {
1092
1641
  this.service = service;
1642
+ this.orderLabels = orderLabels;
1093
1643
  }
1094
1644
  /**
1095
1645
  * Execute a dataset selection and return the shaped rows (+ field metadata).
@@ -1098,7 +1648,8 @@ var DatasetExecutor = class {
1098
1648
  * underlying `IAnalyticsService.query` so the tenant/RLS read scope is
1099
1649
  * applied per request (ADR-0021 D-C).
1100
1650
  */
1101
- async execute(compiled, selection, context) {
1651
+ async execute(compiledInput, selectionInput, context) {
1652
+ const { compiled, selection } = resolveSelectionTokens(compiledInput, selectionInput, context);
1102
1653
  const result = await this.executeSelection(compiled, selection, context);
1103
1654
  const groupings = selection.totals?.groupings;
1104
1655
  if (groupings?.length) {
@@ -1142,6 +1693,14 @@ var DatasetExecutor = class {
1142
1693
  }
1143
1694
  const baseFilter = combineFilters(compiled.filter, selection.runtimeFilter);
1144
1695
  const dimensions = selection.dimensions ?? [];
1696
+ const order = resolveOrdering(selection, dimensions);
1697
+ const labelOrderKeys = this.orderLabels ? Object.keys(order ?? {}).filter(
1698
+ (k) => dimensions.includes(k) && this.orderLabels.isLabelBearing(k)
1699
+ ) : [];
1700
+ const singleQuery = filtered.length === 0 && !selection.compareTo && selectedDerived.length === 0;
1701
+ const pushDownKeys = /* @__PURE__ */ new Set([...dimensions, ...unfiltered]);
1702
+ const canPushDownWindow = singleQuery && labelOrderKeys.length === 0 && Object.keys(order ?? {}).every((k) => pushDownKeys.has(k));
1703
+ const windowQuery = canPushDownWindow ? { order, limit: selection.limit, offset: selection.offset } : void 0;
1145
1704
  let result;
1146
1705
  if (unfiltered.length > 0 || filtered.length === 0) {
1147
1706
  result = await this.service.query(this.buildQuery(compiled, {
@@ -1149,7 +1708,8 @@ var DatasetExecutor = class {
1149
1708
  dimensions,
1150
1709
  where: baseFilter,
1151
1710
  selection,
1152
- contextTimezone: context?.timezone
1711
+ contextTimezone: context?.timezone,
1712
+ window: windowQuery
1153
1713
  }), context);
1154
1714
  } else {
1155
1715
  result = { rows: [], fields: [] };
@@ -1178,6 +1738,15 @@ var DatasetExecutor = class {
1178
1738
  }
1179
1739
  result.rows = evaluateDerivedMeasures(result.rows, selectedDerived);
1180
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);
1181
1750
  return result;
1182
1751
  }
1183
1752
  buildQuery(compiled, opts) {
@@ -1192,18 +1761,28 @@ var DatasetExecutor = class {
1192
1761
  if (opts.where) q.where = opts.where;
1193
1762
  const selTimeDims = opts.selection.timeDimensions ?? [];
1194
1763
  const selDims = new Set(selTimeDims.map((t) => t.dimension));
1764
+ const granularityFor = (name) => {
1765
+ const cd = compiled.cube.dimensions[name];
1766
+ if (cd?.type !== "time") return void 0;
1767
+ const datasetDefault = cd.granularities?.length === 1 ? String(cd.granularities[0]) : void 0;
1768
+ return resolveDimensionGranularity(opts.selection, name, datasetDefault);
1769
+ };
1770
+ const resolvedTimeDims = selTimeDims.map((t) => {
1771
+ if (t.granularity) return t;
1772
+ const granularity = granularityFor(t.dimension);
1773
+ return granularity ? { ...t, granularity } : t;
1774
+ });
1195
1775
  const explicitTimeDims = [];
1196
1776
  for (const name of opts.dimensions) {
1197
- const cd = compiled.cube.dimensions[name];
1198
- if (cd?.type === "time" && cd.granularities?.length === 1 && !selDims.has(name)) {
1199
- explicitTimeDims.push({ dimension: name, granularity: String(cd.granularities[0]) });
1200
- }
1777
+ if (selDims.has(name)) continue;
1778
+ const granularity = granularityFor(name);
1779
+ if (granularity) explicitTimeDims.push({ dimension: name, granularity });
1201
1780
  }
1202
- const mergedTimeDims = [...selTimeDims, ...explicitTimeDims];
1781
+ const mergedTimeDims = [...resolvedTimeDims, ...explicitTimeDims];
1203
1782
  if (mergedTimeDims.length > 0) q.timeDimensions = mergedTimeDims;
1204
- if (opts.selection.order) q.order = opts.selection.order;
1205
- if (opts.selection.limit != null) q.limit = opts.selection.limit;
1206
- if (opts.selection.offset != null) q.offset = opts.selection.offset;
1783
+ if (opts.window?.order && Object.keys(opts.window.order).length > 0) q.order = opts.window.order;
1784
+ if (opts.window?.limit != null) q.limit = opts.window.limit;
1785
+ if (opts.window?.offset != null) q.offset = opts.window.offset;
1207
1786
  return q;
1208
1787
  }
1209
1788
  async runCompare(compiled, selection, measures, dimensions, baseFilter, context) {
@@ -1219,14 +1798,13 @@ var DatasetExecutor = class {
1219
1798
  const shiftedTd = (selection.timeDimensions ?? []).map(
1220
1799
  (t) => t.dimension === cmp.dimension ? { ...t, dateRange: shifted } : t
1221
1800
  );
1222
- const sub = await this.service.query({
1223
- cube: compiled.cube.name,
1801
+ const sub = await this.service.query(this.buildQuery(compiled, {
1224
1802
  measures,
1225
1803
  dimensions,
1226
1804
  where: baseFilter,
1227
- timeDimensions: shiftedTd,
1228
- timezone: selection.timezone ?? context?.timezone ?? "UTC"
1229
- }, context);
1805
+ selection: { ...selection, timeDimensions: shiftedTd },
1806
+ contextTimezone: context?.timezone
1807
+ }), context);
1230
1808
  return sub.rows.map((row) => {
1231
1809
  const out = {};
1232
1810
  for (const dim of dimensions) out[dim] = row[dim];
@@ -1257,11 +1835,77 @@ function mergeByDimensions(base, extra, dimensions, valueColumns) {
1257
1835
 
1258
1836
  // src/dimension-labels.ts
1259
1837
  var LOOKUP_TYPES = /* @__PURE__ */ new Set(["lookup", "master_detail"]);
1838
+ function createOrderLabelResolver(baseObject, dims, deps, resolveScope, context) {
1839
+ const dimByName = new Map(dims.map((d) => [d.name, d]));
1840
+ const metaFor = (dimension) => {
1841
+ const dim = dimByName.get(dimension);
1842
+ return dim ? deps.getObjectFields(baseObject)?.[dim.field] : void 0;
1843
+ };
1844
+ return {
1845
+ isLabelBearing(dimension) {
1846
+ const meta = metaFor(dimension);
1847
+ if (!meta) return false;
1848
+ if (Array.isArray(meta.options) && meta.options.length > 0) return true;
1849
+ return !!(meta.type && LOOKUP_TYPES.has(meta.type) && meta.reference);
1850
+ },
1851
+ async resolveLabels(dimension, values) {
1852
+ const meta = metaFor(dimension);
1853
+ if (!meta) return void 0;
1854
+ if (Array.isArray(meta.options) && meta.options.length > 0) {
1855
+ const labelByValue = /* @__PURE__ */ new Map();
1856
+ for (const opt of meta.options) {
1857
+ if (opt && opt.label != null) labelByValue.set(opt.value, String(opt.label));
1858
+ }
1859
+ return labelByValue;
1860
+ }
1861
+ if (meta.type && LOOKUP_TYPES.has(meta.type) && meta.reference) {
1862
+ let scope;
1863
+ if (resolveScope) {
1864
+ try {
1865
+ scope = await resolveScope(meta.reference);
1866
+ } catch {
1867
+ return void 0;
1868
+ }
1869
+ }
1870
+ return deps.fetchRecordLabels(meta.reference, values, scope ?? void 0, context);
1871
+ }
1872
+ return void 0;
1873
+ }
1874
+ };
1875
+ }
1876
+ function withLabelFetchCache(deps) {
1877
+ const cache = /* @__PURE__ */ new Map();
1878
+ return {
1879
+ getObjectFields: (objectName) => deps.getObjectFields(objectName),
1880
+ async fetchRecordLabels(targetObject, ids, scope, context) {
1881
+ let known = cache.get(targetObject);
1882
+ if (!known) {
1883
+ known = /* @__PURE__ */ new Map();
1884
+ cache.set(targetObject, known);
1885
+ }
1886
+ const missing = ids.filter((id) => !known.has(id));
1887
+ if (missing.length > 0) {
1888
+ const fetched = await deps.fetchRecordLabels(targetObject, missing, scope, context);
1889
+ for (const id of missing) known.set(id, fetched.get(id) ?? null);
1890
+ }
1891
+ const out = /* @__PURE__ */ new Map();
1892
+ for (const id of ids) {
1893
+ const label = known.get(id);
1894
+ if (label != null) out.set(id, label);
1895
+ }
1896
+ return out;
1897
+ }
1898
+ };
1899
+ }
1260
1900
  var pad = (n) => String(n).padStart(2, "0");
1261
1901
  function formatDateBucket(value, granularity) {
1262
1902
  if (value == null || value instanceof Date === false) {
1263
1903
  if (typeof value !== "number" && typeof value !== "string") return value;
1264
1904
  }
1905
+ if (granularity === "year") {
1906
+ const y2 = typeof value === "number" ? value : Number(String(value).trim());
1907
+ if (Number.isInteger(y2) && y2 >= 1e3 && y2 <= 9999) return String(y2);
1908
+ }
1265
1909
  let d;
1266
1910
  if (value instanceof Date) d = value;
1267
1911
  else if (typeof value === "number") d = new Date(value);
@@ -1285,7 +1929,7 @@ function formatDateBucket(value, granularity) {
1285
1929
  return `${y}-${pad(m + 1)}-${pad(d.getUTCDate())}`;
1286
1930
  }
1287
1931
  }
1288
- async function resolveDimensionLabels(baseObject, dims, rows, deps) {
1932
+ async function resolveDimensionLabels(baseObject, dims, rows, deps, resolveScope, context) {
1289
1933
  if (!rows.length || !dims.length) return;
1290
1934
  const fields = deps.getObjectFields(baseObject);
1291
1935
  if (!fields) return;
@@ -1317,7 +1961,15 @@ async function resolveDimensionLabels(baseObject, dims, rows, deps) {
1317
1961
  new Set(rows.map((r) => r[dim.name]).filter((v) => v != null))
1318
1962
  );
1319
1963
  if (ids.length === 0) continue;
1320
- const labelById = await deps.fetchRecordLabels(meta.reference, ids);
1964
+ let scope;
1965
+ if (resolveScope) {
1966
+ try {
1967
+ scope = await resolveScope(meta.reference);
1968
+ } catch {
1969
+ continue;
1970
+ }
1971
+ }
1972
+ const labelById = await deps.fetchRecordLabels(meta.reference, ids, scope ?? void 0, context);
1321
1973
  if (!labelById || labelById.size === 0) continue;
1322
1974
  for (const row of rows) {
1323
1975
  const label = labelById.get(row[dim.name]);
@@ -1511,6 +2163,8 @@ var AnalyticsService = class {
1511
2163
  constructor(config = {}) {
1512
2164
  /** Compiled datasets by name — feeds the join allowlist (D-C) and queryDataset. */
1513
2165
  this.datasetRegistry = /* @__PURE__ */ new Map();
2166
+ /** [#3867] One-shot flag for the {@link assertInferableCube} stand-down warning. */
2167
+ this.warnedNoObjectRegistry = false;
1514
2168
  this.logger = config.logger || createLogger({ level: "info", format: "pretty" });
1515
2169
  this.cubeRegistry = new CubeRegistry();
1516
2170
  if (config.cubes) {
@@ -1521,6 +2175,7 @@ var AnalyticsService = class {
1521
2175
  this.measureCurrency = config.measureCurrency;
1522
2176
  this.labelResolver = config.labelResolver;
1523
2177
  this.draftRowsResolver = config.draftRowsResolver;
2178
+ this.isRegisteredObject = config.isRegisteredObject;
1524
2179
  if (config.datasets) {
1525
2180
  for (const ds of config.datasets) {
1526
2181
  try {
@@ -1561,10 +2216,11 @@ var AnalyticsService = class {
1561
2216
  * `getReadScope(objectName)` that already knows the active tenant.
1562
2217
  */
1563
2218
  async callCtx(query, context) {
1564
- if (!this.readScopeProvider) return this.baseCtx;
2219
+ if (!this.readScopeProvider) return { ...this.baseCtx, context };
1565
2220
  const scopes = await this.resolveReadScopes(query, context);
1566
2221
  return {
1567
2222
  ...this.baseCtx,
2223
+ context,
1568
2224
  getReadScope: (objectName) => scopes.get(objectName) ?? null
1569
2225
  };
1570
2226
  }
@@ -1685,9 +2341,19 @@ var AnalyticsService = class {
1685
2341
  return previewResult;
1686
2342
  }
1687
2343
  }
2344
+ const provider = this.readScopeProvider;
2345
+ const resolveScope = provider ? (targetObject) => provider(targetObject, context) : void 0;
2346
+ const labelDeps = this.labelResolver ? withLabelFetchCache(this.labelResolver) : void 0;
2347
+ const orderLabels = labelDeps && dataset.dimensions?.length ? createOrderLabelResolver(
2348
+ dataset.object,
2349
+ dataset.dimensions.filter((d) => !!d.field).map((d) => ({ name: d.name, field: d.field })),
2350
+ labelDeps,
2351
+ resolveScope,
2352
+ context
2353
+ ) : void 0;
1688
2354
  let result;
1689
2355
  try {
1690
- result = await new DatasetExecutor(this).execute(compiled, selection, context);
2356
+ result = await new DatasetExecutor(this, orderLabels).execute(compiled, selection, context);
1691
2357
  } catch (err) {
1692
2358
  if (isMissingSourceError(err)) {
1693
2359
  this.logger.warn(
@@ -1723,18 +2389,20 @@ var AnalyticsService = class {
1723
2389
  const rangeTz = selection.timezone ?? context?.timezone ?? "UTC";
1724
2390
  const rangeDims = [];
1725
2391
  for (const d of selectedDims) {
1726
- if (!d.field || d.type !== "date" || !d.dateGranularity) continue;
2392
+ if (!d.field || d.type !== "date") continue;
2393
+ const granularity = resolveDimensionGranularity(selection, d.name, d.dateGranularity);
2394
+ if (!granularity) continue;
1727
2395
  const ftype = this.measureCurrency?.(dataset.object, d.field)?.type;
1728
- if (ftype === "datetime") rangeDims.push({ d, instant: true });
1729
- else if (ftype === "date") rangeDims.push({ d, instant: false });
1730
- else if (rangeTz === "UTC") rangeDims.push({ d, instant: false });
2396
+ if (ftype === "datetime") rangeDims.push({ d, granularity, instant: true });
2397
+ else if (ftype === "date") rangeDims.push({ d, granularity, instant: false });
2398
+ else if (rangeTz === "UTC") rangeDims.push({ d, granularity, instant: false });
1731
2399
  }
1732
2400
  if (rangeDims.length && result.rows.length) {
1733
2401
  const bound = (ymd, instant) => instant ? new Date(zonedDateStartToUtcMs(ymd, rangeTz)).toISOString() : ymd;
1734
2402
  result.drillRanges = result.rows.map((row) => {
1735
2403
  const ranges = {};
1736
- for (const { d, instant } of rangeDims) {
1737
- const cal = bucketKeyToCalendarRange(row[d.name], d.dateGranularity);
2404
+ for (const { d, granularity, instant } of rangeDims) {
2405
+ const cal = bucketKeyToCalendarRange(row[d.name], granularity);
1738
2406
  if (cal) {
1739
2407
  ranges[d.name] = { field: d.field, gte: bound(cal.start, instant), lt: bound(cal.end, instant) };
1740
2408
  }
@@ -1743,15 +2411,20 @@ var AnalyticsService = class {
1743
2411
  });
1744
2412
  result.object = dataset.object;
1745
2413
  }
1746
- if (this.labelResolver && selectedDims.length) {
1747
- const dims = selectedDims.filter((d) => !!d.field).map((d) => ({ name: d.name, field: d.field, type: d.type, dateGranularity: d.dateGranularity }));
2414
+ if (labelDeps && selectedDims.length) {
2415
+ const dims = selectedDims.filter((d) => !!d.field).map((d) => ({
2416
+ name: d.name,
2417
+ field: d.field,
2418
+ type: d.type,
2419
+ dateGranularity: resolveDimensionGranularity(selection, d.name, d.dateGranularity)
2420
+ }));
1748
2421
  if (dims.length) {
1749
2422
  try {
1750
- await resolveDimensionLabels(dataset.object, dims, result.rows, this.labelResolver);
2423
+ await resolveDimensionLabels(dataset.object, dims, result.rows, labelDeps, resolveScope, context);
1751
2424
  for (const total of result.totals ?? []) {
1752
2425
  const subset = dims.filter((d) => total.dimensions.includes(d.name));
1753
2426
  if (subset.length) {
1754
- await resolveDimensionLabels(dataset.object, subset, total.rows, this.labelResolver);
2427
+ await resolveDimensionLabels(dataset.object, subset, total.rows, labelDeps, resolveScope, context);
1755
2428
  }
1756
2429
  }
1757
2430
  } catch (e) {
@@ -1839,6 +2512,7 @@ var AnalyticsService = class {
1839
2512
  const name = query.cube;
1840
2513
  let cube = this.cubeRegistry.get(name);
1841
2514
  if (!cube) {
2515
+ this.assertInferableCube(name);
1842
2516
  cube = this.inferCubeFromQuery(query);
1843
2517
  this.cubeRegistry.register(cube);
1844
2518
  const isScalarMetric = (query.dimensions?.length ?? 0) === 0 && (query.timeDimensions?.length ?? 0) === 0;
@@ -1865,6 +2539,39 @@ var AnalyticsService = class {
1865
2539
  );
1866
2540
  }
1867
2541
  }
2542
+ /**
2543
+ * [#3867] Gate on the cube auto-inference path: a name with no registered
2544
+ * Cube may only be inferred into one if it is a registered object.
2545
+ *
2546
+ * Rejects with `status: 404` / `code: 'CUBE_NOT_FOUND'` so the HTTP boundary
2547
+ * answers "no such cube" instead of letting the name reach the driver as a
2548
+ * table and surfacing whatever the driver says about it. The message names
2549
+ * both ways the request could be made valid, because from here the two are
2550
+ * genuinely indistinguishable: register a Cube, or register the object.
2551
+ *
2552
+ * Skips when `isRegisteredObject` was not supplied — see the config field's
2553
+ * doc for why that tier is a deliberate stand-down and not a hole.
2554
+ */
2555
+ assertInferableCube(name) {
2556
+ const isRegisteredObject = this.isRegisteredObject;
2557
+ if (!isRegisteredObject) {
2558
+ if (!this.warnedNoObjectRegistry) {
2559
+ this.warnedNoObjectRegistry = true;
2560
+ this.logger.warn(
2561
+ "[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."
2562
+ );
2563
+ }
2564
+ return;
2565
+ }
2566
+ if (isRegisteredObject(name)) return;
2567
+ const err = new Error(
2568
+ `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.`
2569
+ );
2570
+ err.code = "CUBE_NOT_FOUND";
2571
+ err.status = 404;
2572
+ err.cube = name;
2573
+ throw err;
2574
+ }
1868
2575
  /** Build a minimal Cube from the fields referenced by an AnalyticsQuery. */
1869
2576
  inferCubeFromQuery(query) {
1870
2577
  const cubeName = query.cube;
@@ -2006,7 +2713,7 @@ var AnalyticsServicePlugin = class {
2006
2713
  '[Analytics] No "data" service registered yet at init; will retry per-query. Register ObjectQLPlugin or pass executeAggregate.'
2007
2714
  );
2008
2715
  }
2009
- executeAggregate = async (objectName, { groupBy, aggregations, filter, timezone }) => {
2716
+ executeAggregate = async (objectName, { groupBy, aggregations, filter, timezone, context }) => {
2010
2717
  const engine = tryGetDataEngine();
2011
2718
  if (!engine) {
2012
2719
  throw new Error(
@@ -2023,7 +2730,13 @@ var AnalyticsServicePlugin = class {
2023
2730
  })),
2024
2731
  // ADR-0053 Phase 2: thread the reference tz so date buckets resolve on
2025
2732
  // that zone's calendar days (engine buckets in-memory when non-UTC).
2026
- timezone
2733
+ timezone,
2734
+ // ADR-0021 D-C (#3602): thread the caller's identity so the engine's
2735
+ // middleware chain scopes the read itself. `BaseEngineOptions.context`
2736
+ // is `.optional()`, so nothing ever forced this bridge to pass it —
2737
+ // and it did not, which is how an authenticated aggregate reached the
2738
+ // engine with no principal and plugin-security fell open (#3597).
2739
+ context
2027
2740
  });
2028
2741
  return rows;
2029
2742
  };
@@ -2071,6 +2784,7 @@ var AnalyticsServicePlugin = class {
2071
2784
  }));
2072
2785
  let getReadScope = this.options.getReadScope;
2073
2786
  let autoBridgedReadScope = false;
2787
+ let securityPresentAtInit = false;
2074
2788
  if (!getReadScope) {
2075
2789
  const trySecurity = () => {
2076
2790
  try {
@@ -2080,10 +2794,9 @@ var AnalyticsServicePlugin = class {
2080
2794
  return void 0;
2081
2795
  }
2082
2796
  };
2083
- if (trySecurity()) {
2084
- getReadScope = (object, context) => trySecurity()?.getReadFilter(object, context);
2085
- autoBridgedReadScope = true;
2086
- }
2797
+ securityPresentAtInit = !!trySecurity();
2798
+ getReadScope = (object, context) => trySecurity()?.getReadFilter(object, context);
2799
+ autoBridgedReadScope = true;
2087
2800
  }
2088
2801
  const relationshipResolver = (baseObject, relationshipName) => {
2089
2802
  const engine = (() => {
@@ -2111,17 +2824,26 @@ var AnalyticsServicePlugin = class {
2111
2824
  };
2112
2825
  const labelResolver = {
2113
2826
  getObjectFields: (objectName) => dataEngine()?.getObject?.(objectName)?.fields,
2114
- fetchRecordLabels: async (targetObject, ids) => {
2827
+ fetchRecordLabels: async (targetObject, ids, scope, context) => {
2115
2828
  const map = /* @__PURE__ */ new Map();
2116
2829
  const displayField = pickDisplayField(dataEngine()?.getObject?.(targetObject)?.fields);
2117
2830
  if (!displayField || !executeAggregate || ids.length === 0) return map;
2118
- const rows = await executeAggregate(targetObject, {
2119
- groupBy: ["id", displayField],
2120
- aggregations: [{ field: "id", method: "count", alias: "_c" }],
2121
- filter: { id: { $in: ids } }
2122
- });
2123
- for (const r of rows) {
2124
- if (r.id != null && r[displayField] != null) map.set(r.id, String(r[displayField]));
2831
+ const CHUNK = 500;
2832
+ for (let i = 0; i < ids.length; i += CHUNK) {
2833
+ const idFilter = { id: { $in: ids.slice(i, i + CHUNK) } };
2834
+ const filter = scope ? { $and: [idFilter, scope] } : idFilter;
2835
+ const rows = await executeAggregate(targetObject, {
2836
+ groupBy: ["id", displayField],
2837
+ aggregations: [{ field: "id", method: "count", alias: "_c" }],
2838
+ filter,
2839
+ // #3602 second belt — `scope` above is the analytics layer's own
2840
+ // predicate on this per-record read; the context makes the engine's
2841
+ // middleware scope it as well.
2842
+ context
2843
+ });
2844
+ for (const r of rows) {
2845
+ if (r.id != null && r[displayField] != null) map.set(r.id, String(r[displayField]));
2846
+ }
2125
2847
  }
2126
2848
  return map;
2127
2849
  }
@@ -2186,13 +2908,31 @@ var AnalyticsServicePlugin = class {
2186
2908
  const obj = dataEngine()?.getObject?.(objectName);
2187
2909
  return !!(obj && obj.external != null);
2188
2910
  },
2911
+ // [#3867] Existence probe for the cube auto-inference gate. Reads the
2912
+ // same schema registry the data path's #3770 gate consults, through the
2913
+ // engine accessor this bridge already uses above — so "which objects
2914
+ // exist" has one answer across /data and /analytics.
2915
+ //
2916
+ // `dataEngine()` resolves lazily and may be absent entirely (analytics
2917
+ // installed without a data engine). Reporting `false` there would 404
2918
+ // every cube, so an unresolvable engine reports `true` — "cannot answer,
2919
+ // do not block" — mirroring the tiering #3770 took on the data path.
2920
+ isRegisteredObject: (name) => {
2921
+ const engine = dataEngine();
2922
+ if (!engine) return true;
2923
+ return engine.getObject?.(name) != null;
2924
+ },
2189
2925
  draftRowsResolver
2190
2926
  };
2191
- if (autoBridgedReadScope) {
2927
+ if (autoBridgedReadScope && securityPresentAtInit) {
2192
2928
  ctx.logger.info('[Analytics] Auto-bridged getReadScope \u2192 "security" service (getReadFilter)');
2929
+ } else if (autoBridgedReadScope) {
2930
+ ctx.logger.info(
2931
+ '[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).'
2932
+ );
2193
2933
  } else if (!getReadScope) {
2194
2934
  ctx.logger.warn(
2195
- '[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.'
2935
+ '[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.'
2196
2936
  );
2197
2937
  }
2198
2938
  if (autoBridged) {
@@ -2235,10 +2975,12 @@ export {
2235
2975
  combineFilters,
2236
2976
  compileDataset,
2237
2977
  compileScopedFilterToSql,
2978
+ createOrderLabelResolver,
2238
2979
  evaluateDerivedMeasures,
2239
2980
  mergeByDimensions,
2240
2981
  pickDisplayField,
2241
2982
  resolveDimensionLabels,
2242
- shiftRange
2983
+ shiftRange,
2984
+ withLabelFetchCache
2243
2985
  };
2244
2986
  //# sourceMappingURL=index.js.map