@objectstack/service-analytics 7.9.0 → 8.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -202,6 +202,115 @@ function coerceFilterValueForSql(s) {
202
202
  return s;
203
203
  }
204
204
 
205
+ // src/read-scope-sql.ts
206
+ var IDENT = /^[a-z_][a-z0-9_]*$/i;
207
+ function quoteIdent(name, kind) {
208
+ if (typeof name !== "string" || !IDENT.test(name)) {
209
+ throw new Error(`[read-scope-sql] unsafe ${kind} identifier "${String(name)}" \u2014 refusing to build read scope (fail-closed).`);
210
+ }
211
+ return `"${name}"`;
212
+ }
213
+ function compileScopedFilterToSql(filter, alias) {
214
+ const quotedAlias = quoteIdent(alias, "alias");
215
+ const params = [];
216
+ const sql = compileNode(filter, quotedAlias, params);
217
+ return { sql, params };
218
+ }
219
+ function compileNode(node, qAlias, params) {
220
+ if (node === null || typeof node !== "object" || Array.isArray(node)) {
221
+ throw new Error("[read-scope-sql] read scope must be a filter object (fail-closed).");
222
+ }
223
+ const clauses = [];
224
+ for (const [key, value] of Object.entries(node)) {
225
+ if (key === "$and" || key === "$or") {
226
+ if (!Array.isArray(value) || value.length === 0) {
227
+ throw new Error(`[read-scope-sql] "${key}" requires a non-empty array (fail-closed).`);
228
+ }
229
+ const parts = value.map((child) => compileNode(child, qAlias, params)).filter((s) => s.length > 0);
230
+ if (parts.length === 0) continue;
231
+ const joiner = key === "$and" ? " AND " : " OR ";
232
+ clauses.push(`(${parts.join(joiner)})`);
233
+ } else if (key === "$not") {
234
+ const inner = compileNode(value, qAlias, params);
235
+ if (inner) clauses.push(`NOT (${inner})`);
236
+ } else if (key.startsWith("$")) {
237
+ throw new Error(`[read-scope-sql] unsupported top-level operator "${key}" (fail-closed).`);
238
+ } else {
239
+ clauses.push(compileField(key, value, qAlias, params));
240
+ }
241
+ }
242
+ return clauses.join(" AND ");
243
+ }
244
+ function compileField(field, value, qAlias, params) {
245
+ const col = `${qAlias}.${quoteIdent(field, "field")}`;
246
+ if (value === null) return `${col} IS NULL`;
247
+ if (typeof value !== "object" || value instanceof Date) {
248
+ params.push(value);
249
+ return `${col} = ?`;
250
+ }
251
+ if (Array.isArray(value)) {
252
+ throw new Error(`[read-scope-sql] bare array value for "${field}" \u2014 use { $in: [...] } (fail-closed).`);
253
+ }
254
+ const ops = value;
255
+ const keys = Object.keys(ops);
256
+ if (keys.length === 0 || keys.some((k) => !k.startsWith("$"))) {
257
+ throw new Error(`[read-scope-sql] "${field}" has a nested/relation value which is not supported in a read scope (fail-closed).`);
258
+ }
259
+ const parts = [];
260
+ for (const op of keys) {
261
+ parts.push(compileOperator(col, op, ops[op], field, params));
262
+ }
263
+ return parts.length === 1 ? parts[0] : `(${parts.join(" AND ")})`;
264
+ }
265
+ function bind(params, v) {
266
+ params.push(v);
267
+ return "?";
268
+ }
269
+ function compileOperator(col, op, val, field, params) {
270
+ switch (op) {
271
+ case "$eq":
272
+ return val === null ? `${col} IS NULL` : `${col} = ${bind(params, val)}`;
273
+ case "$ne":
274
+ return val === null ? `${col} IS NOT NULL` : `${col} <> ${bind(params, val)}`;
275
+ case "$gt":
276
+ return `${col} > ${bind(params, val)}`;
277
+ case "$gte":
278
+ return `${col} >= ${bind(params, val)}`;
279
+ case "$lt":
280
+ return `${col} < ${bind(params, val)}`;
281
+ case "$lte":
282
+ return `${col} <= ${bind(params, val)}`;
283
+ case "$in": {
284
+ if (!Array.isArray(val)) throw new Error(`[read-scope-sql] $in for "${field}" needs an array (fail-closed).`);
285
+ if (val.length === 0) return "1 = 0";
286
+ return `${col} IN (${val.map((v) => bind(params, v)).join(", ")})`;
287
+ }
288
+ case "$nin": {
289
+ if (!Array.isArray(val)) throw new Error(`[read-scope-sql] $nin for "${field}" needs an array (fail-closed).`);
290
+ if (val.length === 0) return "1 = 1";
291
+ return `${col} NOT IN (${val.map((v) => bind(params, v)).join(", ")})`;
292
+ }
293
+ case "$between": {
294
+ if (!Array.isArray(val) || val.length !== 2) throw new Error(`[read-scope-sql] $between for "${field}" needs [min,max] (fail-closed).`);
295
+ return `${col} BETWEEN ${bind(params, val[0])} AND ${bind(params, val[1])}`;
296
+ }
297
+ case "$contains":
298
+ return `${col} LIKE ${bind(params, `%${String(val)}%`)}`;
299
+ case "$notContains":
300
+ return `${col} NOT LIKE ${bind(params, `%${String(val)}%`)}`;
301
+ case "$startsWith":
302
+ return `${col} LIKE ${bind(params, `${String(val)}%`)}`;
303
+ case "$endsWith":
304
+ return `${col} LIKE ${bind(params, `%${String(val)}`)}`;
305
+ case "$null":
306
+ return val ? `${col} IS NULL` : `${col} IS NOT NULL`;
307
+ case "$exists":
308
+ return val ? `${col} IS NOT NULL` : `${col} IS NULL`;
309
+ default:
310
+ throw new Error(`[read-scope-sql] unsupported operator "${op}" on "${field}" (fail-closed).`);
311
+ }
312
+ }
313
+
205
314
  // src/strategies/native-sql-strategy.ts
206
315
  var NativeSQLStrategy = class {
207
316
  constructor() {
@@ -265,6 +374,21 @@ var NativeSQLStrategy = class {
265
374
  }
266
375
  }
267
376
  }
377
+ const allowed = ctx.getAllowedRelationships?.(query.cube);
378
+ if (allowed) {
379
+ for (const alias of joins.keys()) {
380
+ if (!allowed.has(alias)) {
381
+ throw new Error(
382
+ `[NativeSQLStrategy] join "${alias}" is not backed by a declared relationship on cube "${query.cube}". v1 only joins along relationships listed in the dataset's \`include\`.`
383
+ );
384
+ }
385
+ }
386
+ }
387
+ this.applyReadScope(this.extractObjectName(cube), tableName, ctx, whereClauses, params);
388
+ for (const alias of joins.keys()) {
389
+ const joinedObject = cube.joins?.[alias]?.name ?? alias;
390
+ this.applyReadScope(joinedObject, alias, ctx, whereClauses, params);
391
+ }
268
392
  let sql = `SELECT ${selectClauses.join(", ")} FROM "${tableName}"`;
269
393
  if (joins.size > 0) {
270
394
  sql += " " + Array.from(joins.values()).join(" ");
@@ -288,6 +412,28 @@ var NativeSQLStrategy = class {
288
412
  return { sql, params };
289
413
  }
290
414
  // ── Helpers ──────────────────────────────────────────────────────
415
+ /**
416
+ * ADR-0021 D-C — inject an object's read scope (tenant + RLS predicate) into
417
+ * the WHERE clause. The scope is a canonical `FilterCondition` (what the
418
+ * RLSCompiler emits); `compileScopedFilterToSql` turns it into alias-qualified,
419
+ * parameterized SQL (fail-closed — it throws rather than drop a predicate).
420
+ * The `?` placeholders are then renumbered into the strategy's `$N` scheme.
421
+ * No-op when the runtime provides no scope hook (the caller is then
422
+ * responsible for isolation — see contract note).
423
+ */
424
+ applyReadScope(objectName, alias, ctx, whereClauses, params) {
425
+ if (typeof ctx.getReadScope !== "function") return;
426
+ const filter = ctx.getReadScope(objectName);
427
+ if (filter === void 0 || filter === null) return;
428
+ const { sql, params: scopeParams } = compileScopedFilterToSql(filter, alias);
429
+ if (!sql) return;
430
+ let i = 0;
431
+ const rendered = sql.replace(/\?/g, () => {
432
+ params.push(scopeParams[i++]);
433
+ return `$${params.length}`;
434
+ });
435
+ whereClauses.push(`(${rendered})`);
436
+ }
291
437
  /**
292
438
  * Resolve a dimension/measure/filter SQL expression that may reference a
293
439
  * related table via dot notation (e.g. `account.industry`).
@@ -306,15 +452,17 @@ var NativeSQLStrategy = class {
306
452
  * Returns the qualified SQL reference (e.g. `"account"."industry"`).
307
453
  * Pure column references (no dot) are returned as-is.
308
454
  */
309
- qualifyAndRegisterJoin(rawSql, parentTable, joins) {
455
+ qualifyAndRegisterJoin(rawSql, parentTable, joins, cube) {
310
456
  if (!rawSql.includes(".")) return rawSql;
311
457
  const [alias, ...rest] = rawSql.split(".");
312
458
  if (!alias || rest.length === 0) return rawSql;
313
459
  const column = rest.join(".");
314
460
  if (!joins.has(alias)) {
461
+ const joinTable = cube?.joins?.[alias]?.name ?? alias;
462
+ const tableRef = joinTable === alias ? `"${alias}"` : `"${joinTable}" "${alias}"`;
315
463
  joins.set(
316
464
  alias,
317
- `LEFT JOIN "${alias}" ON "${parentTable}"."${alias}" = "${alias}"."id"`
465
+ `LEFT JOIN ${tableRef} ON "${parentTable}"."${alias}" = "${alias}"."id"`
318
466
  );
319
467
  }
320
468
  return `"${alias}"."${column}"`;
@@ -353,12 +501,12 @@ var NativeSQLStrategy = class {
353
501
  resolveDimensionSql(cube, member, parentTable, joins) {
354
502
  const dim = this.lookupMember(cube, member, "dimension");
355
503
  const raw = dim ? dim.sql : member.includes(".") ? member.split(".")[1] : member;
356
- return this.qualifyAndRegisterJoin(raw, parentTable, joins);
504
+ return this.qualifyAndRegisterJoin(raw, parentTable, joins, cube);
357
505
  }
358
506
  resolveMeasureSql(cube, member, parentTable, joins) {
359
507
  const measure = this.lookupMember(cube, member, "measure");
360
508
  if (!measure) return `COUNT(*)`;
361
- const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins);
509
+ const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
362
510
  switch (measure.type) {
363
511
  case "count":
364
512
  return "COUNT(*)";
@@ -378,9 +526,9 @@ var NativeSQLStrategy = class {
378
526
  }
379
527
  resolveFieldSql(cube, member, parentTable, joins) {
380
528
  const dim = this.lookupMember(cube, member, "dimension");
381
- if (dim) return this.qualifyAndRegisterJoin(dim.sql, parentTable, joins);
529
+ if (dim) return this.qualifyAndRegisterJoin(dim.sql, parentTable, joins, cube);
382
530
  const measure = this.lookupMember(cube, member, "measure");
383
- if (measure) return this.qualifyAndRegisterJoin(measure.sql, parentTable, joins);
531
+ if (measure) return this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
384
532
  const fieldName = member.includes(".") ? member.split(".")[1] : member;
385
533
  return fieldName;
386
534
  }
@@ -634,6 +782,309 @@ var ObjectQLStrategy = class {
634
782
  }
635
783
  };
636
784
 
785
+ // src/dataset-compiler.ts
786
+ var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set(["array_agg", "string_agg"]);
787
+ function aggregateToMetricType(m) {
788
+ if (UNSUPPORTED_AGGREGATES.has(m.aggregate)) {
789
+ throw new Error(
790
+ `[dataset-compiler] measure "${m.name}" uses aggregate "${m.aggregate}" which is not supported by the v1 dataset runtime (supported: count, sum, avg, min, max, count_distinct).`
791
+ );
792
+ }
793
+ return m.aggregate;
794
+ }
795
+ function dimensionType(d) {
796
+ switch (d.type) {
797
+ case "date":
798
+ return "time";
799
+ case "number":
800
+ return "number";
801
+ case "boolean":
802
+ return "boolean";
803
+ case "lookup":
804
+ return "string";
805
+ case "string":
806
+ return "string";
807
+ default:
808
+ return "string";
809
+ }
810
+ }
811
+ function relationshipPrefix(field) {
812
+ const idx = field.indexOf(".");
813
+ return idx > 0 ? field.slice(0, idx) : null;
814
+ }
815
+ function compileDataset(dataset, resolver) {
816
+ const include = dataset.include ?? [];
817
+ const allowedRelationships = new Set(include);
818
+ const joins = {};
819
+ for (const rel of include) {
820
+ let targetTable = rel;
821
+ if (resolver) {
822
+ const resolved = resolver(dataset.object, rel);
823
+ if (!resolved) {
824
+ throw new Error(
825
+ `[dataset-compiler] dataset "${dataset.name}" includes relationship "${rel}" which does not exist on object "${dataset.object}".`
826
+ );
827
+ }
828
+ targetTable = resolved;
829
+ }
830
+ joins[rel] = {
831
+ name: targetTable,
832
+ relationship: "many_to_one",
833
+ sql: `${dataset.object}.${rel} = ${rel}.id`
834
+ };
835
+ }
836
+ const assertDeclared = (field, ownerKind, ownerName) => {
837
+ const prefix = relationshipPrefix(field);
838
+ if (prefix && !allowedRelationships.has(prefix)) {
839
+ throw new Error(
840
+ `[dataset-compiler] ${ownerKind} "${ownerName}" references relationship "${prefix}" via "${field}", but "${prefix}" is not declared in the dataset's \`include\`. v1 only joins along declared relationships.`
841
+ );
842
+ }
843
+ };
844
+ const dimensions = {};
845
+ for (const d of dataset.dimensions) {
846
+ assertDeclared(d.field, "dimension", d.name);
847
+ const dim = {
848
+ name: d.name,
849
+ label: typeof d.label === "string" ? d.label : d.name,
850
+ type: dimensionType(d),
851
+ sql: d.field
852
+ };
853
+ if (dim.type === "time") {
854
+ dim.granularities = d.dateGranularity ? [d.dateGranularity] : ["day", "week", "month", "quarter", "year"];
855
+ }
856
+ dimensions[d.name] = dim;
857
+ }
858
+ const measures = {};
859
+ const derived = [];
860
+ const measureFilters = {};
861
+ for (const m of dataset.measures) {
862
+ if (m.derived) {
863
+ derived.push({ name: m.name, op: m.derived.op, of: m.derived.of });
864
+ continue;
865
+ }
866
+ if (m.field) assertDeclared(m.field, "measure", m.name);
867
+ const metric = {
868
+ name: m.name,
869
+ label: typeof m.label === "string" ? m.label : m.name,
870
+ type: aggregateToMetricType(m),
871
+ // `count` with no field aggregates over rows (*).
872
+ sql: m.field ?? "*"
873
+ };
874
+ if (typeof m.format === "string") metric.format = m.format;
875
+ measures[m.name] = metric;
876
+ if (m.filter) measureFilters[m.name] = m.filter;
877
+ }
878
+ const cube = {
879
+ name: dataset.name,
880
+ title: typeof dataset.label === "string" ? dataset.label : dataset.name,
881
+ sql: dataset.object,
882
+ measures,
883
+ dimensions,
884
+ public: false
885
+ };
886
+ if (Object.keys(joins).length > 0) cube.joins = joins;
887
+ return {
888
+ cube,
889
+ allowedRelationships,
890
+ derived,
891
+ filter: dataset.filter,
892
+ measureFilters
893
+ };
894
+ }
895
+
896
+ // src/dataset-executor.ts
897
+ function combineFilters(a, b) {
898
+ if (a && b) return { $and: [a, b] };
899
+ return a ?? b;
900
+ }
901
+ function evaluateDerivedMeasures(rows, derived) {
902
+ if (derived.length === 0) return rows;
903
+ return rows.map((row) => {
904
+ const out = { ...row };
905
+ for (const d of derived) {
906
+ out[d.name] = computeDerived(d, out);
907
+ }
908
+ return out;
909
+ });
910
+ }
911
+ function num(v) {
912
+ if (v == null) return null;
913
+ const n = typeof v === "number" ? v : Number(v);
914
+ return Number.isFinite(n) ? n : null;
915
+ }
916
+ function computeDerived(d, row) {
917
+ const vals = d.of.map((name) => num(row[name]));
918
+ if (vals.some((v) => v === null)) return null;
919
+ const nums = vals;
920
+ switch (d.op) {
921
+ case "ratio": {
922
+ if (nums.length < 2 || nums[1] === 0) return null;
923
+ return nums[0] / nums[1];
924
+ }
925
+ case "difference":
926
+ return nums.slice(1).reduce((acc, v) => acc - v, nums[0]);
927
+ case "sum":
928
+ return nums.reduce((acc, v) => acc + v, 0);
929
+ case "product":
930
+ return nums.reduce((acc, v) => acc * v, 1);
931
+ default:
932
+ return null;
933
+ }
934
+ }
935
+ function parseUTC(date) {
936
+ const ms = Date.parse(date.length === 10 ? `${date}T00:00:00Z` : date);
937
+ if (Number.isNaN(ms)) throw new Error(`[dataset-executor] invalid date in dateRange: "${date}"`);
938
+ return ms;
939
+ }
940
+ var DAY_MS = 864e5;
941
+ function toISODate(ms) {
942
+ return new Date(ms).toISOString().slice(0, 10);
943
+ }
944
+ function shiftYear(date, years) {
945
+ const d = new Date(parseUTC(date));
946
+ d.setUTCFullYear(d.getUTCFullYear() + years);
947
+ return toISODate(d.getTime());
948
+ }
949
+ function shiftRange(range, kind) {
950
+ const [start, end] = range;
951
+ if (kind === "previousYear") {
952
+ return [shiftYear(start, -1), shiftYear(end, -1)];
953
+ }
954
+ const startMs = parseUTC(start);
955
+ const endMs = parseUTC(end);
956
+ const lengthDays = Math.round((endMs - startMs) / DAY_MS) + 1;
957
+ const prevEndMs = startMs - DAY_MS;
958
+ const prevStartMs = prevEndMs - (lengthDays - 1) * DAY_MS;
959
+ return [toISODate(prevStartMs), toISODate(prevEndMs)];
960
+ }
961
+ var DatasetExecutor = class {
962
+ constructor(service) {
963
+ this.service = service;
964
+ }
965
+ /**
966
+ * Execute a dataset selection and return the shaped rows (+ field metadata).
967
+ *
968
+ * @param context - The request's ExecutionContext, threaded into every
969
+ * underlying `IAnalyticsService.query` so the tenant/RLS read scope is
970
+ * applied per request (ADR-0021 D-C).
971
+ */
972
+ async execute(compiled, selection, context) {
973
+ const derivedByName = new Map(compiled.derived.map((d) => [d.name, d]));
974
+ const selectedDerived = selection.measures.map((m) => derivedByName.get(m)).filter((d) => !!d);
975
+ const baseMeasures = /* @__PURE__ */ new Set();
976
+ for (const m of selection.measures) {
977
+ if (!derivedByName.has(m)) baseMeasures.add(m);
978
+ }
979
+ for (const d of selectedDerived) {
980
+ for (const dep of d.of) baseMeasures.add(dep);
981
+ }
982
+ const unfiltered = [];
983
+ const filtered = [];
984
+ for (const m of baseMeasures) {
985
+ (compiled.measureFilters[m] ? filtered : unfiltered).push(m);
986
+ }
987
+ const baseFilter = combineFilters(compiled.filter, selection.runtimeFilter);
988
+ const dimensions = selection.dimensions ?? [];
989
+ let result;
990
+ if (unfiltered.length > 0 || filtered.length === 0) {
991
+ result = await this.service.query(this.buildQuery(compiled, {
992
+ measures: unfiltered,
993
+ dimensions,
994
+ where: baseFilter,
995
+ selection
996
+ }), context);
997
+ } else {
998
+ result = { rows: [], fields: [] };
999
+ }
1000
+ for (const m of filtered) {
1001
+ const mFilter = combineFilters(baseFilter, compiled.measureFilters[m]);
1002
+ const sub = await this.service.query(this.buildQuery(compiled, {
1003
+ measures: [m],
1004
+ dimensions,
1005
+ where: mFilter,
1006
+ selection
1007
+ }), context);
1008
+ result.rows = mergeByDimensions(result.rows, sub.rows, dimensions, [m]);
1009
+ result.fields.push({ name: m, type: "number" });
1010
+ }
1011
+ if (selection.compareTo) {
1012
+ const compareRows = await this.runCompare(compiled, selection, [...baseMeasures], dimensions, baseFilter, context);
1013
+ result.rows = mergeByDimensions(
1014
+ result.rows,
1015
+ compareRows,
1016
+ dimensions,
1017
+ [...baseMeasures].map((m) => `${m}__compare`)
1018
+ );
1019
+ for (const m of baseMeasures) result.fields.push({ name: `${m}__compare`, type: "number" });
1020
+ }
1021
+ result.rows = evaluateDerivedMeasures(result.rows, selectedDerived);
1022
+ for (const d of selectedDerived) result.fields.push({ name: d.name, type: "number" });
1023
+ return result;
1024
+ }
1025
+ buildQuery(compiled, opts) {
1026
+ const q = {
1027
+ cube: compiled.cube.name,
1028
+ measures: opts.measures,
1029
+ dimensions: opts.dimensions,
1030
+ timezone: opts.selection.timezone ?? "UTC"
1031
+ };
1032
+ if (opts.where) q.where = opts.where;
1033
+ if (opts.selection.timeDimensions) q.timeDimensions = opts.selection.timeDimensions;
1034
+ if (opts.selection.order) q.order = opts.selection.order;
1035
+ if (opts.selection.limit != null) q.limit = opts.selection.limit;
1036
+ if (opts.selection.offset != null) q.offset = opts.selection.offset;
1037
+ return q;
1038
+ }
1039
+ async runCompare(compiled, selection, measures, dimensions, baseFilter, context) {
1040
+ const cmp = selection.compareTo;
1041
+ const td = (selection.timeDimensions ?? []).find((t) => t.dimension === cmp.dimension);
1042
+ if (!td || !td.dateRange) {
1043
+ throw new Error(
1044
+ `[dataset-executor] compareTo requires a timeDimension "${cmp.dimension}" with a dateRange.`
1045
+ );
1046
+ }
1047
+ const range = Array.isArray(td.dateRange) ? [td.dateRange[0], td.dateRange[1] ?? td.dateRange[0]] : [td.dateRange, td.dateRange];
1048
+ const shifted = shiftRange(range, cmp.kind);
1049
+ const shiftedTd = (selection.timeDimensions ?? []).map(
1050
+ (t) => t.dimension === cmp.dimension ? { ...t, dateRange: shifted } : t
1051
+ );
1052
+ const sub = await this.service.query({
1053
+ cube: compiled.cube.name,
1054
+ measures,
1055
+ dimensions,
1056
+ where: baseFilter,
1057
+ timeDimensions: shiftedTd,
1058
+ timezone: selection.timezone ?? "UTC"
1059
+ }, context);
1060
+ return sub.rows.map((row) => {
1061
+ const out = {};
1062
+ for (const dim of dimensions) out[dim] = row[dim];
1063
+ for (const m of measures) out[`${m}__compare`] = row[m];
1064
+ return out;
1065
+ });
1066
+ }
1067
+ };
1068
+ function mergeByDimensions(base, extra, dimensions, valueColumns) {
1069
+ const keyOf = (row) => dimensions.map((d) => String(row[d] ?? "")).join("");
1070
+ const index = /* @__PURE__ */ new Map();
1071
+ for (const row of base) index.set(keyOf(row), row);
1072
+ for (const row of extra) {
1073
+ const key = keyOf(row);
1074
+ const target = index.get(key);
1075
+ if (target) {
1076
+ for (const c of valueColumns) target[c] = row[c];
1077
+ } else {
1078
+ const fresh = {};
1079
+ for (const d of dimensions) fresh[d] = row[d];
1080
+ for (const c of valueColumns) fresh[c] = row[c];
1081
+ index.set(key, fresh);
1082
+ base.push(fresh);
1083
+ }
1084
+ }
1085
+ return base;
1086
+ }
1087
+
637
1088
  // src/analytics-service.ts
638
1089
  var DEFAULT_CAPABILITIES = {
639
1090
  nativeSql: false,
@@ -642,17 +1093,33 @@ var DEFAULT_CAPABILITIES = {
642
1093
  };
643
1094
  var AnalyticsService = class {
644
1095
  constructor(config = {}) {
1096
+ /** Compiled datasets by name — feeds the join allowlist (D-C) and queryDataset. */
1097
+ this.datasetRegistry = /* @__PURE__ */ new Map();
645
1098
  this.logger = config.logger || createLogger({ level: "info", format: "pretty" });
646
1099
  this.cubeRegistry = new CubeRegistry();
647
1100
  if (config.cubes) {
648
1101
  this.cubeRegistry.registerAll(config.cubes);
649
1102
  }
650
- this.strategyCtx = {
1103
+ this.readScopeProvider = config.getReadScope;
1104
+ this.relationshipResolver = config.relationshipResolver;
1105
+ if (config.datasets) {
1106
+ for (const ds of config.datasets) {
1107
+ try {
1108
+ this.registerDataset(ds);
1109
+ } catch (e) {
1110
+ this.logger?.warn?.(`[Analytics] Failed to register dataset "${ds?.name}": ${String(e?.message ?? e)}`);
1111
+ }
1112
+ }
1113
+ }
1114
+ this.baseCtx = {
651
1115
  getCube: (name) => this.cubeRegistry.get(name),
652
1116
  queryCapabilities: config.queryCapabilities || (() => DEFAULT_CAPABILITIES),
653
1117
  executeRawSql: config.executeRawSql,
654
1118
  executeAggregate: config.executeAggregate,
655
- fallbackService: config.fallbackService
1119
+ fallbackService: config.fallbackService,
1120
+ // Prefer a compiled dataset's declared relationships (D-C join allowlist);
1121
+ // fall back to any explicitly-configured provider for legacy cubes.
1122
+ getAllowedRelationships: (cubeName) => this.datasetRegistry.get(cubeName)?.allowedRelationships ?? config.getAllowedRelationships?.(cubeName)
656
1123
  };
657
1124
  const builtIn = [
658
1125
  new NativeSQLStrategy(),
@@ -667,17 +1134,99 @@ var AnalyticsService = class {
667
1134
  `[Analytics] Initialized with ${this.cubeRegistry.size} cubes, ${this.strategies.length} strategies: ${this.strategies.map((s) => s.name).join(" \u2192 ")}`
668
1135
  );
669
1136
  }
1137
+ /**
1138
+ * Build a per-call StrategyContext that binds the read-scope provider to the
1139
+ * current request's ExecutionContext (ADR-0021 D-C). The strategy then sees a
1140
+ * `getReadScope(objectName)` that already knows the active tenant.
1141
+ */
1142
+ async callCtx(query, context) {
1143
+ if (!this.readScopeProvider) return this.baseCtx;
1144
+ const scopes = await this.resolveReadScopes(query, context);
1145
+ return {
1146
+ ...this.baseCtx,
1147
+ getReadScope: (objectName) => scopes.get(objectName) ?? null
1148
+ };
1149
+ }
1150
+ /**
1151
+ * Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
1152
+ * AND every joined object of the query's cube, keyed by object name. This is
1153
+ * the async pre-pass that lets the synchronous strategy enforce scoping even
1154
+ * when the provider (security `getReadFilter`) resolves asynchronously.
1155
+ *
1156
+ * The object set is `cube.sql` (base) plus every `cube.joins[*].name` — a
1157
+ * SUPERSET of what the strategy actually scans (the strategy only joins along
1158
+ * declared relationships), so no scanned object is ever left unscoped.
1159
+ *
1160
+ * Fail-closed: if the provider throws for an object, the whole query is
1161
+ * rejected rather than emitting SQL with that object unscoped.
1162
+ */
1163
+ async resolveReadScopes(query, context) {
1164
+ const map = /* @__PURE__ */ new Map();
1165
+ const provider = this.readScopeProvider;
1166
+ if (!provider || !query.cube) return map;
1167
+ const cube = this.cubeRegistry.get(query.cube);
1168
+ if (!cube) return map;
1169
+ const objects = /* @__PURE__ */ new Set();
1170
+ if (typeof cube.sql === "string" && cube.sql.trim()) {
1171
+ objects.add(cube.sql.trim());
1172
+ }
1173
+ const joins = cube.joins;
1174
+ if (joins) {
1175
+ for (const [alias, j] of Object.entries(joins)) {
1176
+ objects.add(j?.name ?? alias);
1177
+ }
1178
+ }
1179
+ for (const object of objects) {
1180
+ let filter;
1181
+ try {
1182
+ filter = await provider(object, context);
1183
+ } catch (e) {
1184
+ this.logger.error?.(
1185
+ `[Analytics] read-scope resolution failed for object "${object}" \u2014 rejecting query (fail-closed, ADR-0021 D-C)`,
1186
+ e instanceof Error ? e : new Error(String(e))
1187
+ );
1188
+ throw new Error(
1189
+ `[Analytics] read-scope resolution failed for "${object}"; query denied (fail-closed).`
1190
+ );
1191
+ }
1192
+ if (filter != null) map.set(object, filter);
1193
+ }
1194
+ return map;
1195
+ }
670
1196
  /**
671
1197
  * Execute an analytical query by delegating to the first capable strategy.
672
1198
  */
673
- async query(query) {
1199
+ async query(query, context) {
674
1200
  if (!query.cube) {
675
1201
  throw new Error("Cube name is required in analytics query");
676
1202
  }
677
1203
  this.ensureCube(query);
678
- const strategy = this.resolveStrategy(query);
1204
+ const ctx = await this.callCtx(query, context);
1205
+ const strategy = this.resolveStrategy(query, ctx);
679
1206
  this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
680
- return strategy.execute(query, this.strategyCtx);
1207
+ return strategy.execute(query, ctx);
1208
+ }
1209
+ /**
1210
+ * Compile a `dataset` (ADR-0021) and register its Cube + join allowlist so it
1211
+ * can be queried by name. Idempotent (re-registering overwrites). Returns the
1212
+ * compiled dataset.
1213
+ */
1214
+ registerDataset(dataset) {
1215
+ const compiled = compileDataset(dataset, this.relationshipResolver);
1216
+ this.cubeRegistry.register(compiled.cube);
1217
+ this.datasetRegistry.set(dataset.name, compiled);
1218
+ return compiled;
1219
+ }
1220
+ /**
1221
+ * Execute a semantic-layer dataset (ADR-0021). Compiles the dataset (saved or
1222
+ * inline draft — Studio preview), registers its Cube + join allowlist, then
1223
+ * runs the selection through the `DatasetExecutor` with the request context so
1224
+ * tenant/RLS scoping (D-C) is applied. See {@link IAnalyticsService.queryDataset}.
1225
+ */
1226
+ async queryDataset(dataset, selection, context) {
1227
+ const compiled = this.registerDataset(dataset);
1228
+ this.logger.debug(`[Analytics] queryDataset "${dataset.name}" (object=${dataset.object}, include=${(dataset.include ?? []).join(",") || "\u2014"})`);
1229
+ return new DatasetExecutor(this).execute(compiled, selection, context);
681
1230
  }
682
1231
  /**
683
1232
  * Get cube metadata for discovery.
@@ -702,14 +1251,15 @@ var AnalyticsService = class {
702
1251
  /**
703
1252
  * Generate SQL for a query without executing it (dry-run).
704
1253
  */
705
- async generateSql(query) {
1254
+ async generateSql(query, context) {
706
1255
  if (!query.cube) {
707
1256
  throw new Error("Cube name is required for SQL generation");
708
1257
  }
709
1258
  this.ensureCube(query);
710
- const strategy = this.resolveStrategy(query);
1259
+ const ctx = await this.callCtx(query, context);
1260
+ const strategy = this.resolveStrategy(query, ctx);
711
1261
  this.logger.debug(`[Analytics] generateSql on cube "${query.cube}" \u2192 ${strategy.name}`);
712
- return strategy.generateSql(query, this.strategyCtx);
1262
+ return strategy.generateSql(query, ctx);
713
1263
  }
714
1264
  // ── Internal ─────────────────────────────────────────────────────
715
1265
  /**
@@ -802,9 +1352,9 @@ var AnalyticsService = class {
802
1352
  /**
803
1353
  * Walk the strategy chain and return the first strategy that can handle the query.
804
1354
  */
805
- resolveStrategy(query) {
1355
+ resolveStrategy(query, ctx) {
806
1356
  for (const strategy of this.strategies) {
807
- if (strategy.canHandle(query, this.strategyCtx)) {
1357
+ if (strategy.canHandle(query, ctx)) {
808
1358
  return strategy;
809
1359
  }
810
1360
  }
@@ -944,14 +1494,56 @@ var AnalyticsServicePlugin = class {
944
1494
  objectqlAggregate: !!executeAggregate,
945
1495
  inMemory: false
946
1496
  }));
1497
+ let getReadScope = this.options.getReadScope;
1498
+ let autoBridgedReadScope = false;
1499
+ if (!getReadScope) {
1500
+ const trySecurity = () => {
1501
+ try {
1502
+ const svc = ctx.getService("security");
1503
+ return svc && typeof svc.getReadFilter === "function" ? svc : void 0;
1504
+ } catch {
1505
+ return void 0;
1506
+ }
1507
+ };
1508
+ if (trySecurity()) {
1509
+ getReadScope = (object, context) => trySecurity()?.getReadFilter(object, context);
1510
+ autoBridgedReadScope = true;
1511
+ }
1512
+ }
1513
+ const relationshipResolver = (baseObject, relationshipName) => {
1514
+ const engine = (() => {
1515
+ try {
1516
+ const svc = ctx.getService("data");
1517
+ return svc && typeof svc.getObject === "function" ? svc : void 0;
1518
+ } catch {
1519
+ return void 0;
1520
+ }
1521
+ })();
1522
+ const obj = engine?.getObject?.(baseObject);
1523
+ const field = obj?.fields?.[relationshipName];
1524
+ if (field && (field.type === "lookup" || field.type === "master_detail") && field.reference) {
1525
+ return field.reference;
1526
+ }
1527
+ return engine ? void 0 : relationshipName;
1528
+ };
947
1529
  const config = {
948
1530
  cubes: this.options.cubes,
949
1531
  logger: ctx.logger,
950
1532
  queryCapabilities,
951
1533
  executeRawSql,
952
1534
  executeAggregate,
953
- fallbackService
1535
+ fallbackService,
1536
+ getReadScope,
1537
+ getAllowedRelationships: this.options.getAllowedRelationships,
1538
+ relationshipResolver
954
1539
  };
1540
+ if (autoBridgedReadScope) {
1541
+ ctx.logger.info('[Analytics] Auto-bridged getReadScope \u2192 "security" service (getReadFilter)');
1542
+ } else if (!getReadScope) {
1543
+ ctx.logger.warn(
1544
+ '[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.'
1545
+ );
1546
+ }
955
1547
  if (autoBridged) {
956
1548
  ctx.logger.info('[Analytics] Auto-bridged executeAggregate \u2192 "data" service (IDataEngine)');
957
1549
  }
@@ -986,7 +1578,14 @@ export {
986
1578
  AnalyticsService,
987
1579
  AnalyticsServicePlugin,
988
1580
  CubeRegistry,
1581
+ DatasetExecutor,
989
1582
  NativeSQLStrategy,
990
- ObjectQLStrategy
1583
+ ObjectQLStrategy,
1584
+ combineFilters,
1585
+ compileDataset,
1586
+ compileScopedFilterToSql,
1587
+ evaluateDerivedMeasures,
1588
+ mergeByDimensions,
1589
+ shiftRange
991
1590
  };
992
1591
  //# sourceMappingURL=index.js.map