@objectstack/service-analytics 8.0.0 → 9.0.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.d.cts CHANGED
@@ -83,6 +83,73 @@ interface CompiledDataset {
83
83
  type RelationshipResolver = (baseObject: string, relationshipName: string) => string | undefined;
84
84
  declare function compileDataset(dataset: Dataset, resolver?: RelationshipResolver): CompiledDataset;
85
85
 
86
+ /**
87
+ * Dimension display-label resolution (ADR-0021).
88
+ *
89
+ * Analytics groups by the raw stored value of a dimension field. For two field
90
+ * kinds that value is NOT human-readable:
91
+ *
92
+ * - **select** — grouped by the stored option `value` (e.g. `backlog`), but the
93
+ * user-facing text is the option `label` (e.g. `Backlog`).
94
+ * - **lookup / master_detail** — grouped by the foreign-key `id` (e.g.
95
+ * `8eqtuKI4G9IhUsPS`), but the user-facing text is the related record's
96
+ * display field (its name/title).
97
+ *
98
+ * `resolveDimensionLabels` post-processes the result rows IN PLACE, replacing the
99
+ * raw value at `row[dimension.name]` with its display label when one is found.
100
+ * Unresolved values are left untouched so an orphaned id still renders as itself
101
+ * rather than blanking out. Date / number / plain-string dimensions are no-ops.
102
+ *
103
+ * The resolution LOGIC lives here (and is unit-tested); the low-level capabilities
104
+ * — reading an object's field map and fetching id→label pairs — are injected via
105
+ * {@link DimensionLabelDeps} so this module stays free of any engine dependency.
106
+ */
107
+ /** The minimal field shape this resolver needs. */
108
+ interface FieldMetaLite {
109
+ type?: string;
110
+ /** Lookup / master_detail target object name. */
111
+ reference?: string;
112
+ /** Select options — the value→label source. */
113
+ options?: Array<{
114
+ value: unknown;
115
+ label?: string;
116
+ }>;
117
+ }
118
+ /** Capabilities the resolver needs from the runtime (injected by the plugin). */
119
+ interface DimensionLabelDeps {
120
+ /** Return the field map for an object, or `undefined` if unknown. */
121
+ getObjectFields(objectName: string): Record<string, FieldMetaLite> | undefined;
122
+ /**
123
+ * Fetch a map of `id → display label` for the given ids of a target object.
124
+ * The implementation chooses the target's display field. Returning an empty
125
+ * map (e.g. no display field, no data access) leaves the ids unresolved.
126
+ */
127
+ fetchRecordLabels(targetObject: string, ids: unknown[]): Promise<Map<unknown, string>>;
128
+ }
129
+ /** Date-dimension granularity (mirrors the dataset `dateGranularity` enum). */
130
+ type DateGranularity = 'day' | 'week' | 'month' | 'quarter' | 'year';
131
+ /**
132
+ * Replace raw dimension values with display labels, in place.
133
+ *
134
+ * @param baseObject - the dataset's base object (where the dimension fields live)
135
+ * @param dims - selected dimensions as `{ name, field, type?, dateGranularity? }`
136
+ * (row key = `name`)
137
+ * @param rows - result rows, mutated in place
138
+ * @param deps - injected runtime capabilities
139
+ */
140
+ declare function resolveDimensionLabels(baseObject: string, dims: Array<{
141
+ name: string;
142
+ field: string;
143
+ type?: string;
144
+ dateGranularity?: DateGranularity | string;
145
+ }>, rows: Record<string, unknown>[], deps: DimensionLabelDeps): Promise<void>;
146
+ /**
147
+ * Pick the display field for an object from its field map, by convention:
148
+ * an explicit `name`/`title`/`label` field, else the first text-like field.
149
+ * Returns `undefined` when nothing suitable exists.
150
+ */
151
+ declare function pickDisplayField(fields: Record<string, FieldMetaLite> | undefined): string | undefined;
152
+
86
153
  /**
87
154
  * Configuration for AnalyticsService.
88
155
  */
@@ -131,8 +198,13 @@ interface AnalyticsServiceConfig {
131
198
  * object (exactly what `RLSCompiler` emits). The service binds the active
132
199
  * context per query and the strategy compiles the filter into alias-qualified
133
200
  * SQL injected into every base and joined table.
201
+ *
202
+ * MAY be async: the production bridge resolves RLS from the `security`
203
+ * service's `getReadFilter`, which can hit the database. The service
204
+ * pre-resolves the scope for every base + joined object of a query (before
205
+ * the synchronous SQL builder runs), so a sync return still works unchanged.
134
206
  */
135
- getReadScope?: (objectName: string, context?: ExecutionContext) => FilterCondition | null | undefined;
207
+ getReadScope?: (objectName: string, context?: ExecutionContext) => FilterCondition | null | undefined | Promise<FilterCondition | null | undefined>;
136
208
  /**
137
209
  * ADR-0021 D-C — join allowlist per cube (the dataset's declared `include`).
138
210
  * Joins outside this set are rejected by the strategy. Compiled datasets
@@ -148,6 +220,14 @@ interface AnalyticsServiceConfig {
148
220
  relationshipResolver?: RelationshipResolver;
149
221
  /** Pre-defined datasets to compile + register at construction (ADR-0021). */
150
222
  datasets?: Dataset[];
223
+ /**
224
+ * ADR-0021 — resolve raw dimension values to human display labels. When
225
+ * provided, `queryDataset` post-processes result rows so a `select` dimension
226
+ * shows its option label (not the stored value) and a `lookup`/`master_detail`
227
+ * dimension shows the related record's display name (not the FK id). Injected
228
+ * by the plugin from the `data` engine; omit to keep raw values.
229
+ */
230
+ labelResolver?: DimensionLabelDeps;
151
231
  }
152
232
  /**
153
233
  * AnalyticsService — Multi-driver analytics orchestrator.
@@ -177,6 +257,8 @@ declare class AnalyticsService implements IAnalyticsService {
177
257
  private readonly datasetRegistry;
178
258
  /** Optional object-graph resolver used when compiling datasets. */
179
259
  private readonly relationshipResolver?;
260
+ /** Optional dimension display-label resolver (select options / lookup names). */
261
+ private readonly labelResolver?;
180
262
  readonly cubeRegistry: CubeRegistry;
181
263
  private readonly logger;
182
264
  constructor(config?: AnalyticsServiceConfig);
@@ -186,6 +268,20 @@ declare class AnalyticsService implements IAnalyticsService {
186
268
  * `getReadScope(objectName)` that already knows the active tenant.
187
269
  */
188
270
  private callCtx;
271
+ /**
272
+ * Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
273
+ * AND every joined object of the query's cube, keyed by object name. This is
274
+ * the async pre-pass that lets the synchronous strategy enforce scoping even
275
+ * when the provider (security `getReadFilter`) resolves asynchronously.
276
+ *
277
+ * The object set is `cube.sql` (base) plus every `cube.joins[*].name` — a
278
+ * SUPERSET of what the strategy actually scans (the strategy only joins along
279
+ * declared relationships), so no scanned object is ever left unscoped.
280
+ *
281
+ * Fail-closed: if the provider throws for an object, the whole query is
282
+ * rejected rather than emitting SQL with that object unscoped.
283
+ */
284
+ private resolveReadScopes;
189
285
  /**
190
286
  * Execute an analytical query by delegating to the first capable strategy.
191
287
  */
@@ -270,7 +366,7 @@ interface AnalyticsServicePluginOptions {
270
366
  * emits). When omitted, the plugin auto-bridges to a registered `'security'`
271
367
  * service exposing `getReadFilter(object, context)` if one is present.
272
368
  */
273
- getReadScope?: (objectName: string, context?: ExecutionContext) => FilterCondition | null | undefined;
369
+ getReadScope?: (objectName: string, context?: ExecutionContext) => FilterCondition | null | undefined | Promise<FilterCondition | null | undefined>;
274
370
  /**
275
371
  * ADR-0021 D-C — join allowlist per cube (the dataset's declared `include`).
276
372
  * Typically wired from the dataset registry's compiled `allowedRelationships`.
@@ -476,4 +572,4 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
476
572
  private buildFieldMeta;
477
573
  }
478
574
 
479
- export { AnalyticsService, type AnalyticsServiceConfig, AnalyticsServicePlugin, type AnalyticsServicePluginOptions, type CompareTo, type CompiledDataset, CubeRegistry, DatasetExecutor, type DerivedMeasureSpec, NativeSQLStrategy, ObjectQLStrategy, type RelationshipResolver, combineFilters, compileDataset, compileScopedFilterToSql, evaluateDerivedMeasures, mergeByDimensions, shiftRange };
575
+ export { AnalyticsService, type AnalyticsServiceConfig, AnalyticsServicePlugin, type AnalyticsServicePluginOptions, type CompareTo, type CompiledDataset, CubeRegistry, DatasetExecutor, type DerivedMeasureSpec, type DimensionLabelDeps, type FieldMetaLite, NativeSQLStrategy, ObjectQLStrategy, type RelationshipResolver, combineFilters, compileDataset, compileScopedFilterToSql, evaluateDerivedMeasures, mergeByDimensions, pickDisplayField, resolveDimensionLabels, shiftRange };
package/dist/index.d.ts CHANGED
@@ -83,6 +83,73 @@ interface CompiledDataset {
83
83
  type RelationshipResolver = (baseObject: string, relationshipName: string) => string | undefined;
84
84
  declare function compileDataset(dataset: Dataset, resolver?: RelationshipResolver): CompiledDataset;
85
85
 
86
+ /**
87
+ * Dimension display-label resolution (ADR-0021).
88
+ *
89
+ * Analytics groups by the raw stored value of a dimension field. For two field
90
+ * kinds that value is NOT human-readable:
91
+ *
92
+ * - **select** — grouped by the stored option `value` (e.g. `backlog`), but the
93
+ * user-facing text is the option `label` (e.g. `Backlog`).
94
+ * - **lookup / master_detail** — grouped by the foreign-key `id` (e.g.
95
+ * `8eqtuKI4G9IhUsPS`), but the user-facing text is the related record's
96
+ * display field (its name/title).
97
+ *
98
+ * `resolveDimensionLabels` post-processes the result rows IN PLACE, replacing the
99
+ * raw value at `row[dimension.name]` with its display label when one is found.
100
+ * Unresolved values are left untouched so an orphaned id still renders as itself
101
+ * rather than blanking out. Date / number / plain-string dimensions are no-ops.
102
+ *
103
+ * The resolution LOGIC lives here (and is unit-tested); the low-level capabilities
104
+ * — reading an object's field map and fetching id→label pairs — are injected via
105
+ * {@link DimensionLabelDeps} so this module stays free of any engine dependency.
106
+ */
107
+ /** The minimal field shape this resolver needs. */
108
+ interface FieldMetaLite {
109
+ type?: string;
110
+ /** Lookup / master_detail target object name. */
111
+ reference?: string;
112
+ /** Select options — the value→label source. */
113
+ options?: Array<{
114
+ value: unknown;
115
+ label?: string;
116
+ }>;
117
+ }
118
+ /** Capabilities the resolver needs from the runtime (injected by the plugin). */
119
+ interface DimensionLabelDeps {
120
+ /** Return the field map for an object, or `undefined` if unknown. */
121
+ getObjectFields(objectName: string): Record<string, FieldMetaLite> | undefined;
122
+ /**
123
+ * Fetch a map of `id → display label` for the given ids of a target object.
124
+ * The implementation chooses the target's display field. Returning an empty
125
+ * map (e.g. no display field, no data access) leaves the ids unresolved.
126
+ */
127
+ fetchRecordLabels(targetObject: string, ids: unknown[]): Promise<Map<unknown, string>>;
128
+ }
129
+ /** Date-dimension granularity (mirrors the dataset `dateGranularity` enum). */
130
+ type DateGranularity = 'day' | 'week' | 'month' | 'quarter' | 'year';
131
+ /**
132
+ * Replace raw dimension values with display labels, in place.
133
+ *
134
+ * @param baseObject - the dataset's base object (where the dimension fields live)
135
+ * @param dims - selected dimensions as `{ name, field, type?, dateGranularity? }`
136
+ * (row key = `name`)
137
+ * @param rows - result rows, mutated in place
138
+ * @param deps - injected runtime capabilities
139
+ */
140
+ declare function resolveDimensionLabels(baseObject: string, dims: Array<{
141
+ name: string;
142
+ field: string;
143
+ type?: string;
144
+ dateGranularity?: DateGranularity | string;
145
+ }>, rows: Record<string, unknown>[], deps: DimensionLabelDeps): Promise<void>;
146
+ /**
147
+ * Pick the display field for an object from its field map, by convention:
148
+ * an explicit `name`/`title`/`label` field, else the first text-like field.
149
+ * Returns `undefined` when nothing suitable exists.
150
+ */
151
+ declare function pickDisplayField(fields: Record<string, FieldMetaLite> | undefined): string | undefined;
152
+
86
153
  /**
87
154
  * Configuration for AnalyticsService.
88
155
  */
@@ -131,8 +198,13 @@ interface AnalyticsServiceConfig {
131
198
  * object (exactly what `RLSCompiler` emits). The service binds the active
132
199
  * context per query and the strategy compiles the filter into alias-qualified
133
200
  * SQL injected into every base and joined table.
201
+ *
202
+ * MAY be async: the production bridge resolves RLS from the `security`
203
+ * service's `getReadFilter`, which can hit the database. The service
204
+ * pre-resolves the scope for every base + joined object of a query (before
205
+ * the synchronous SQL builder runs), so a sync return still works unchanged.
134
206
  */
135
- getReadScope?: (objectName: string, context?: ExecutionContext) => FilterCondition | null | undefined;
207
+ getReadScope?: (objectName: string, context?: ExecutionContext) => FilterCondition | null | undefined | Promise<FilterCondition | null | undefined>;
136
208
  /**
137
209
  * ADR-0021 D-C — join allowlist per cube (the dataset's declared `include`).
138
210
  * Joins outside this set are rejected by the strategy. Compiled datasets
@@ -148,6 +220,14 @@ interface AnalyticsServiceConfig {
148
220
  relationshipResolver?: RelationshipResolver;
149
221
  /** Pre-defined datasets to compile + register at construction (ADR-0021). */
150
222
  datasets?: Dataset[];
223
+ /**
224
+ * ADR-0021 — resolve raw dimension values to human display labels. When
225
+ * provided, `queryDataset` post-processes result rows so a `select` dimension
226
+ * shows its option label (not the stored value) and a `lookup`/`master_detail`
227
+ * dimension shows the related record's display name (not the FK id). Injected
228
+ * by the plugin from the `data` engine; omit to keep raw values.
229
+ */
230
+ labelResolver?: DimensionLabelDeps;
151
231
  }
152
232
  /**
153
233
  * AnalyticsService — Multi-driver analytics orchestrator.
@@ -177,6 +257,8 @@ declare class AnalyticsService implements IAnalyticsService {
177
257
  private readonly datasetRegistry;
178
258
  /** Optional object-graph resolver used when compiling datasets. */
179
259
  private readonly relationshipResolver?;
260
+ /** Optional dimension display-label resolver (select options / lookup names). */
261
+ private readonly labelResolver?;
180
262
  readonly cubeRegistry: CubeRegistry;
181
263
  private readonly logger;
182
264
  constructor(config?: AnalyticsServiceConfig);
@@ -186,6 +268,20 @@ declare class AnalyticsService implements IAnalyticsService {
186
268
  * `getReadScope(objectName)` that already knows the active tenant.
187
269
  */
188
270
  private callCtx;
271
+ /**
272
+ * Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
273
+ * AND every joined object of the query's cube, keyed by object name. This is
274
+ * the async pre-pass that lets the synchronous strategy enforce scoping even
275
+ * when the provider (security `getReadFilter`) resolves asynchronously.
276
+ *
277
+ * The object set is `cube.sql` (base) plus every `cube.joins[*].name` — a
278
+ * SUPERSET of what the strategy actually scans (the strategy only joins along
279
+ * declared relationships), so no scanned object is ever left unscoped.
280
+ *
281
+ * Fail-closed: if the provider throws for an object, the whole query is
282
+ * rejected rather than emitting SQL with that object unscoped.
283
+ */
284
+ private resolveReadScopes;
189
285
  /**
190
286
  * Execute an analytical query by delegating to the first capable strategy.
191
287
  */
@@ -270,7 +366,7 @@ interface AnalyticsServicePluginOptions {
270
366
  * emits). When omitted, the plugin auto-bridges to a registered `'security'`
271
367
  * service exposing `getReadFilter(object, context)` if one is present.
272
368
  */
273
- getReadScope?: (objectName: string, context?: ExecutionContext) => FilterCondition | null | undefined;
369
+ getReadScope?: (objectName: string, context?: ExecutionContext) => FilterCondition | null | undefined | Promise<FilterCondition | null | undefined>;
274
370
  /**
275
371
  * ADR-0021 D-C — join allowlist per cube (the dataset's declared `include`).
276
372
  * Typically wired from the dataset registry's compiled `allowedRelationships`.
@@ -476,4 +572,4 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
476
572
  private buildFieldMeta;
477
573
  }
478
574
 
479
- export { AnalyticsService, type AnalyticsServiceConfig, AnalyticsServicePlugin, type AnalyticsServicePluginOptions, type CompareTo, type CompiledDataset, CubeRegistry, DatasetExecutor, type DerivedMeasureSpec, NativeSQLStrategy, ObjectQLStrategy, type RelationshipResolver, combineFilters, compileDataset, compileScopedFilterToSql, evaluateDerivedMeasures, mergeByDimensions, shiftRange };
575
+ export { AnalyticsService, type AnalyticsServiceConfig, AnalyticsServicePlugin, type AnalyticsServicePluginOptions, type CompareTo, type CompiledDataset, CubeRegistry, DatasetExecutor, type DerivedMeasureSpec, type DimensionLabelDeps, type FieldMetaLite, NativeSQLStrategy, ObjectQLStrategy, type RelationshipResolver, combineFilters, compileDataset, compileScopedFilterToSql, evaluateDerivedMeasures, mergeByDimensions, pickDisplayField, resolveDimensionLabels, shiftRange };
package/dist/index.js CHANGED
@@ -596,12 +596,22 @@ var ObjectQLStrategy = class {
596
596
  async execute(query, ctx) {
597
597
  const cube = ctx.getCube(query.cube);
598
598
  const objectName = this.extractObjectName(cube);
599
+ const granByDim = /* @__PURE__ */ new Map();
600
+ for (const td of query.timeDimensions ?? []) {
601
+ if (td.granularity) granByDim.set(td.dimension, td.granularity);
602
+ }
599
603
  const groupBy = [];
600
604
  if (query.dimensions && query.dimensions.length > 0) {
601
605
  for (const dim of query.dimensions) {
602
- groupBy.push(this.resolveFieldName(cube, dim, "dimension"));
606
+ const field = this.resolveFieldName(cube, dim, "dimension");
607
+ const gran = granByDim.get(dim);
608
+ groupBy.push(gran ? { field, dateGranularity: gran } : field);
609
+ granByDim.delete(dim);
603
610
  }
604
611
  }
612
+ for (const [dim, gran] of granByDim) {
613
+ groupBy.push({ field: this.resolveFieldName(cube, dim, "dimension"), dateGranularity: gran });
614
+ }
605
615
  const aggregations = [];
606
616
  if (query.measures && query.measures.length > 0) {
607
617
  for (const measure of query.measures) {
@@ -614,10 +624,16 @@ var ObjectQLStrategy = class {
614
624
  if (normalizedFilters.length > 0) {
615
625
  for (const f of normalizedFilters) {
616
626
  const fieldName = this.resolveFieldName(cube, f.member, "any");
617
- filter[fieldName] = this.convertFilter(f.operator, f.values);
627
+ const converted = this.convertFilter(f.operator, f.values);
628
+ const existing = filter[fieldName];
629
+ const mergeable = (v) => !!v && typeof v === "object" && !Array.isArray(v);
630
+ filter[fieldName] = mergeable(existing) && mergeable(converted) ? { ...existing, ...converted } : converted;
618
631
  }
619
632
  }
620
633
  const rows = await ctx.executeAggregate(objectName, {
634
+ // Structured groupBy items ({field, dateGranularity}) pass through the
635
+ // executeAggregate bridge to engine.aggregate, which buckets them. The
636
+ // contract types groupBy as string[]; the cast carries the richer shape.
621
637
  groupBy: groupBy.length > 0 ? groupBy : void 0,
622
638
  aggregations: aggregations.length > 0 ? aggregations : void 0,
623
639
  filter: Object.keys(filter).length > 0 ? filter : void 0
@@ -1030,7 +1046,17 @@ var DatasetExecutor = class {
1030
1046
  timezone: opts.selection.timezone ?? "UTC"
1031
1047
  };
1032
1048
  if (opts.where) q.where = opts.where;
1033
- if (opts.selection.timeDimensions) q.timeDimensions = opts.selection.timeDimensions;
1049
+ const selTimeDims = opts.selection.timeDimensions ?? [];
1050
+ const selDims = new Set(selTimeDims.map((t) => t.dimension));
1051
+ const explicitTimeDims = [];
1052
+ for (const name of opts.dimensions) {
1053
+ const cd = compiled.cube.dimensions[name];
1054
+ if (cd?.type === "time" && cd.granularities?.length === 1 && !selDims.has(name)) {
1055
+ explicitTimeDims.push({ dimension: name, granularity: String(cd.granularities[0]) });
1056
+ }
1057
+ }
1058
+ const mergedTimeDims = [...selTimeDims, ...explicitTimeDims];
1059
+ if (mergedTimeDims.length > 0) q.timeDimensions = mergedTimeDims;
1034
1060
  if (opts.selection.order) q.order = opts.selection.order;
1035
1061
  if (opts.selection.limit != null) q.limit = opts.selection.limit;
1036
1062
  if (opts.selection.offset != null) q.offset = opts.selection.offset;
@@ -1085,6 +1111,88 @@ function mergeByDimensions(base, extra, dimensions, valueColumns) {
1085
1111
  return base;
1086
1112
  }
1087
1113
 
1114
+ // src/dimension-labels.ts
1115
+ var LOOKUP_TYPES = /* @__PURE__ */ new Set(["lookup", "master_detail"]);
1116
+ var pad = (n) => String(n).padStart(2, "0");
1117
+ function formatDateBucket(value, granularity) {
1118
+ if (value == null || value instanceof Date === false) {
1119
+ if (typeof value !== "number" && typeof value !== "string") return value;
1120
+ }
1121
+ let d;
1122
+ if (value instanceof Date) d = value;
1123
+ else if (typeof value === "number") d = new Date(value);
1124
+ else {
1125
+ const s = String(value).trim();
1126
+ d = /^\d+$/.test(s) ? new Date(Number(s) < 1e12 ? Number(s) * 1e3 : Number(s)) : new Date(s);
1127
+ }
1128
+ if (Number.isNaN(d.getTime())) return value;
1129
+ const y = d.getUTCFullYear();
1130
+ const m = d.getUTCMonth();
1131
+ switch (granularity) {
1132
+ case "year":
1133
+ return String(y);
1134
+ case "quarter":
1135
+ return `${y}-Q${Math.floor(m / 3) + 1}`;
1136
+ case "month":
1137
+ return `${y}-${pad(m + 1)}`;
1138
+ case "week":
1139
+ case "day":
1140
+ default:
1141
+ return `${y}-${pad(m + 1)}-${pad(d.getUTCDate())}`;
1142
+ }
1143
+ }
1144
+ async function resolveDimensionLabels(baseObject, dims, rows, deps) {
1145
+ if (!rows.length || !dims.length) return;
1146
+ const fields = deps.getObjectFields(baseObject);
1147
+ if (!fields) return;
1148
+ for (const dim of dims) {
1149
+ const meta = fields[dim.field];
1150
+ if (dim.type === "date" || meta && meta.type === "date") {
1151
+ for (const row of rows) {
1152
+ const formatted = formatDateBucket(row[dim.name], dim.dateGranularity);
1153
+ if (formatted != null) row[dim.name] = formatted;
1154
+ }
1155
+ continue;
1156
+ }
1157
+ if (!meta) continue;
1158
+ if (Array.isArray(meta.options) && meta.options.length > 0) {
1159
+ const labelByValue = /* @__PURE__ */ new Map();
1160
+ for (const opt of meta.options) {
1161
+ if (opt && opt.label != null) labelByValue.set(opt.value, String(opt.label));
1162
+ }
1163
+ if (labelByValue.size === 0) continue;
1164
+ for (const row of rows) {
1165
+ const raw = row[dim.name];
1166
+ const label = labelByValue.get(raw);
1167
+ if (label != null) row[dim.name] = label;
1168
+ }
1169
+ continue;
1170
+ }
1171
+ if (meta.type && LOOKUP_TYPES.has(meta.type) && meta.reference) {
1172
+ const ids = Array.from(
1173
+ new Set(rows.map((r) => r[dim.name]).filter((v) => v != null))
1174
+ );
1175
+ if (ids.length === 0) continue;
1176
+ const labelById = await deps.fetchRecordLabels(meta.reference, ids);
1177
+ if (!labelById || labelById.size === 0) continue;
1178
+ for (const row of rows) {
1179
+ const label = labelById.get(row[dim.name]);
1180
+ if (label != null) row[dim.name] = label;
1181
+ }
1182
+ }
1183
+ }
1184
+ }
1185
+ function pickDisplayField(fields) {
1186
+ if (!fields) return void 0;
1187
+ for (const preferred of ["name", "title", "label"]) {
1188
+ if (fields[preferred]) return preferred;
1189
+ }
1190
+ for (const [name, meta] of Object.entries(fields)) {
1191
+ if (meta.type === "text" || meta.type === "string") return name;
1192
+ }
1193
+ return void 0;
1194
+ }
1195
+
1088
1196
  // src/analytics-service.ts
1089
1197
  var DEFAULT_CAPABILITIES = {
1090
1198
  nativeSql: false,
@@ -1102,6 +1210,7 @@ var AnalyticsService = class {
1102
1210
  }
1103
1211
  this.readScopeProvider = config.getReadScope;
1104
1212
  this.relationshipResolver = config.relationshipResolver;
1213
+ this.labelResolver = config.labelResolver;
1105
1214
  if (config.datasets) {
1106
1215
  for (const ds of config.datasets) {
1107
1216
  try {
@@ -1139,13 +1248,60 @@ var AnalyticsService = class {
1139
1248
  * current request's ExecutionContext (ADR-0021 D-C). The strategy then sees a
1140
1249
  * `getReadScope(objectName)` that already knows the active tenant.
1141
1250
  */
1142
- callCtx(context) {
1251
+ async callCtx(query, context) {
1143
1252
  if (!this.readScopeProvider) return this.baseCtx;
1253
+ const scopes = await this.resolveReadScopes(query, context);
1144
1254
  return {
1145
1255
  ...this.baseCtx,
1146
- getReadScope: (objectName) => this.readScopeProvider(objectName, context)
1256
+ getReadScope: (objectName) => scopes.get(objectName) ?? null
1147
1257
  };
1148
1258
  }
1259
+ /**
1260
+ * Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
1261
+ * AND every joined object of the query's cube, keyed by object name. This is
1262
+ * the async pre-pass that lets the synchronous strategy enforce scoping even
1263
+ * when the provider (security `getReadFilter`) resolves asynchronously.
1264
+ *
1265
+ * The object set is `cube.sql` (base) plus every `cube.joins[*].name` — a
1266
+ * SUPERSET of what the strategy actually scans (the strategy only joins along
1267
+ * declared relationships), so no scanned object is ever left unscoped.
1268
+ *
1269
+ * Fail-closed: if the provider throws for an object, the whole query is
1270
+ * rejected rather than emitting SQL with that object unscoped.
1271
+ */
1272
+ async resolveReadScopes(query, context) {
1273
+ const map = /* @__PURE__ */ new Map();
1274
+ const provider = this.readScopeProvider;
1275
+ if (!provider || !query.cube) return map;
1276
+ const cube = this.cubeRegistry.get(query.cube);
1277
+ if (!cube) return map;
1278
+ const objects = /* @__PURE__ */ new Set();
1279
+ if (typeof cube.sql === "string" && cube.sql.trim()) {
1280
+ objects.add(cube.sql.trim());
1281
+ }
1282
+ const joins = cube.joins;
1283
+ if (joins) {
1284
+ for (const [alias, j] of Object.entries(joins)) {
1285
+ objects.add(j?.name ?? alias);
1286
+ }
1287
+ }
1288
+ for (const object of objects) {
1289
+ let filter;
1290
+ try {
1291
+ filter = await provider(object, context);
1292
+ } catch (e) {
1293
+ this.logger.error?.(
1294
+ `[Analytics] read-scope resolution failed for object "${object}" \u2014 rejecting query (fail-closed, ADR-0021 D-C)`,
1295
+ e instanceof Error ? e : new Error(String(e))
1296
+ );
1297
+ throw new Error(
1298
+ `[Analytics] read-scope resolution failed for "${object}"; query denied (fail-closed).`
1299
+ );
1300
+ }
1301
+ if (filter != null) map.set(object, filter);
1302
+ }
1303
+ return map;
1304
+ }
1149
1305
  /**
1150
1306
  * Execute an analytical query by delegating to the first capable strategy.
1151
1307
  */
@@ -1154,7 +1310,7 @@ var AnalyticsService = class {
1154
1310
  throw new Error("Cube name is required in analytics query");
1155
1311
  }
1156
1312
  this.ensureCube(query);
1157
- const ctx = this.callCtx(context);
1313
+ const ctx = await this.callCtx(query, context);
1158
1314
  const strategy = this.resolveStrategy(query, ctx);
1159
1315
  this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
1160
1316
  return strategy.execute(query, ctx);
@@ -1179,7 +1335,27 @@ var AnalyticsService = class {
1179
1335
  async queryDataset(dataset, selection, context) {
1180
1336
  const compiled = this.registerDataset(dataset);
1181
1337
  this.logger.debug(`[Analytics] queryDataset "${dataset.name}" (object=${dataset.object}, include=${(dataset.include ?? []).join(",") || "\u2014"})`);
1182
- return new DatasetExecutor(this).execute(compiled, selection, context);
1338
+ const result = await new DatasetExecutor(this).execute(compiled, selection, context);
1339
+ if (this.labelResolver && selection.dimensions?.length) {
1340
+ const dims = selection.dimensions.map((name) => dataset.dimensions?.find((d) => d.name === name)).filter((d) => !!d?.field).map((d) => ({ name: d.name, field: d.field, type: d.type, dateGranularity: d.dateGranularity }));
1341
+ if (dims.length) {
1342
+ try {
1343
+ await resolveDimensionLabels(dataset.object, dims, result.rows, this.labelResolver);
1344
+ } catch (e) {
1345
+ this.logger?.warn?.(`[Analytics] dimension label resolution failed for "${dataset.name}": ${String(e?.message ?? e)}`);
1346
+ }
1347
+ }
1348
+ }
1349
+ if (result.fields?.length && dataset.measures?.length) {
1350
+ const measureByName = new Map(dataset.measures.map((m) => [m.name, m]));
1351
+ for (const f of result.fields) {
1352
+ const m = measureByName.get(f.name) ?? measureByName.get(f.name.replace(/__compare$/, ""));
1353
+ if (!m) continue;
1354
+ if (f.label == null && typeof m.label === "string") f.label = m.label;
1355
+ if (f.format == null && m.format) f.format = m.format;
1356
+ }
1357
+ }
1358
+ return result;
1183
1359
  }
1184
1360
  /**
1185
1361
  * Get cube metadata for discovery.
@@ -1209,7 +1385,7 @@ var AnalyticsService = class {
1209
1385
  throw new Error("Cube name is required for SQL generation");
1210
1386
  }
1211
1387
  this.ensureCube(query);
1212
- const ctx = this.callCtx(context);
1388
+ const ctx = await this.callCtx(query, context);
1213
1389
  const strategy = this.resolveStrategy(query, ctx);
1214
1390
  this.logger.debug(`[Analytics] generateSql on cube "${query.cube}" \u2192 ${strategy.name}`);
1215
1391
  return strategy.generateSql(query, ctx);
@@ -1479,6 +1655,31 @@ var AnalyticsServicePlugin = class {
1479
1655
  }
1480
1656
  return engine ? void 0 : relationshipName;
1481
1657
  };
1658
+ const dataEngine = () => {
1659
+ try {
1660
+ const svc = ctx.getService("data");
1661
+ return svc && typeof svc.getObject === "function" ? svc : void 0;
1662
+ } catch {
1663
+ return void 0;
1664
+ }
1665
+ };
1666
+ const labelResolver = {
1667
+ getObjectFields: (objectName) => dataEngine()?.getObject?.(objectName)?.fields,
1668
+ fetchRecordLabels: async (targetObject, ids) => {
1669
+ const map = /* @__PURE__ */ new Map();
1670
+ const displayField = pickDisplayField(dataEngine()?.getObject?.(targetObject)?.fields);
1671
+ if (!displayField || !executeAggregate || ids.length === 0) return map;
1672
+ const rows = await executeAggregate(targetObject, {
1673
+ groupBy: ["id", displayField],
1674
+ aggregations: [{ field: "id", method: "count", alias: "_c" }],
1675
+ filter: { id: { $in: ids } }
1676
+ });
1677
+ for (const r of rows) {
1678
+ if (r.id != null && r[displayField] != null) map.set(r.id, String(r[displayField]));
1679
+ }
1680
+ return map;
1681
+ }
1682
+ };
1482
1683
  const config = {
1483
1684
  cubes: this.options.cubes,
1484
1685
  logger: ctx.logger,
@@ -1488,7 +1689,8 @@ var AnalyticsServicePlugin = class {
1488
1689
  fallbackService,
1489
1690
  getReadScope,
1490
1691
  getAllowedRelationships: this.options.getAllowedRelationships,
1491
- relationshipResolver
1692
+ relationshipResolver,
1693
+ labelResolver
1492
1694
  };
1493
1695
  if (autoBridgedReadScope) {
1494
1696
  ctx.logger.info('[Analytics] Auto-bridged getReadScope \u2192 "security" service (getReadFilter)');
@@ -1539,6 +1741,8 @@ export {
1539
1741
  compileScopedFilterToSql,
1540
1742
  evaluateDerivedMeasures,
1541
1743
  mergeByDimensions,
1744
+ pickDisplayField,
1745
+ resolveDimensionLabels,
1542
1746
  shiftRange
1543
1747
  };
1544
1748
  //# sourceMappingURL=index.js.map