@objectstack/service-analytics 7.9.0 → 8.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.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,52 @@ 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
+ callCtx(context) {
1143
+ if (!this.readScopeProvider) return this.baseCtx;
1144
+ return {
1145
+ ...this.baseCtx,
1146
+ getReadScope: (objectName) => this.readScopeProvider(objectName, context)
1147
+ };
1148
+ }
670
1149
  /**
671
1150
  * Execute an analytical query by delegating to the first capable strategy.
672
1151
  */
673
- async query(query) {
1152
+ async query(query, context) {
674
1153
  if (!query.cube) {
675
1154
  throw new Error("Cube name is required in analytics query");
676
1155
  }
677
1156
  this.ensureCube(query);
678
- const strategy = this.resolveStrategy(query);
1157
+ const ctx = this.callCtx(context);
1158
+ const strategy = this.resolveStrategy(query, ctx);
679
1159
  this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
680
- return strategy.execute(query, this.strategyCtx);
1160
+ return strategy.execute(query, ctx);
1161
+ }
1162
+ /**
1163
+ * Compile a `dataset` (ADR-0021) and register its Cube + join allowlist so it
1164
+ * can be queried by name. Idempotent (re-registering overwrites). Returns the
1165
+ * compiled dataset.
1166
+ */
1167
+ registerDataset(dataset) {
1168
+ const compiled = compileDataset(dataset, this.relationshipResolver);
1169
+ this.cubeRegistry.register(compiled.cube);
1170
+ this.datasetRegistry.set(dataset.name, compiled);
1171
+ return compiled;
1172
+ }
1173
+ /**
1174
+ * Execute a semantic-layer dataset (ADR-0021). Compiles the dataset (saved or
1175
+ * inline draft — Studio preview), registers its Cube + join allowlist, then
1176
+ * runs the selection through the `DatasetExecutor` with the request context so
1177
+ * tenant/RLS scoping (D-C) is applied. See {@link IAnalyticsService.queryDataset}.
1178
+ */
1179
+ async queryDataset(dataset, selection, context) {
1180
+ const compiled = this.registerDataset(dataset);
1181
+ this.logger.debug(`[Analytics] queryDataset "${dataset.name}" (object=${dataset.object}, include=${(dataset.include ?? []).join(",") || "\u2014"})`);
1182
+ return new DatasetExecutor(this).execute(compiled, selection, context);
681
1183
  }
682
1184
  /**
683
1185
  * Get cube metadata for discovery.
@@ -702,14 +1204,15 @@ var AnalyticsService = class {
702
1204
  /**
703
1205
  * Generate SQL for a query without executing it (dry-run).
704
1206
  */
705
- async generateSql(query) {
1207
+ async generateSql(query, context) {
706
1208
  if (!query.cube) {
707
1209
  throw new Error("Cube name is required for SQL generation");
708
1210
  }
709
1211
  this.ensureCube(query);
710
- const strategy = this.resolveStrategy(query);
1212
+ const ctx = this.callCtx(context);
1213
+ const strategy = this.resolveStrategy(query, ctx);
711
1214
  this.logger.debug(`[Analytics] generateSql on cube "${query.cube}" \u2192 ${strategy.name}`);
712
- return strategy.generateSql(query, this.strategyCtx);
1215
+ return strategy.generateSql(query, ctx);
713
1216
  }
714
1217
  // ── Internal ─────────────────────────────────────────────────────
715
1218
  /**
@@ -802,9 +1305,9 @@ var AnalyticsService = class {
802
1305
  /**
803
1306
  * Walk the strategy chain and return the first strategy that can handle the query.
804
1307
  */
805
- resolveStrategy(query) {
1308
+ resolveStrategy(query, ctx) {
806
1309
  for (const strategy of this.strategies) {
807
- if (strategy.canHandle(query, this.strategyCtx)) {
1310
+ if (strategy.canHandle(query, ctx)) {
808
1311
  return strategy;
809
1312
  }
810
1313
  }
@@ -944,14 +1447,56 @@ var AnalyticsServicePlugin = class {
944
1447
  objectqlAggregate: !!executeAggregate,
945
1448
  inMemory: false
946
1449
  }));
1450
+ let getReadScope = this.options.getReadScope;
1451
+ let autoBridgedReadScope = false;
1452
+ if (!getReadScope) {
1453
+ const trySecurity = () => {
1454
+ try {
1455
+ const svc = ctx.getService("security");
1456
+ return svc && typeof svc.getReadFilter === "function" ? svc : void 0;
1457
+ } catch {
1458
+ return void 0;
1459
+ }
1460
+ };
1461
+ if (trySecurity()) {
1462
+ getReadScope = (object, context) => trySecurity()?.getReadFilter(object, context);
1463
+ autoBridgedReadScope = true;
1464
+ }
1465
+ }
1466
+ const relationshipResolver = (baseObject, relationshipName) => {
1467
+ const engine = (() => {
1468
+ try {
1469
+ const svc = ctx.getService("data");
1470
+ return svc && typeof svc.getObject === "function" ? svc : void 0;
1471
+ } catch {
1472
+ return void 0;
1473
+ }
1474
+ })();
1475
+ const obj = engine?.getObject?.(baseObject);
1476
+ const field = obj?.fields?.[relationshipName];
1477
+ if (field && (field.type === "lookup" || field.type === "master_detail") && field.reference) {
1478
+ return field.reference;
1479
+ }
1480
+ return engine ? void 0 : relationshipName;
1481
+ };
947
1482
  const config = {
948
1483
  cubes: this.options.cubes,
949
1484
  logger: ctx.logger,
950
1485
  queryCapabilities,
951
1486
  executeRawSql,
952
1487
  executeAggregate,
953
- fallbackService
1488
+ fallbackService,
1489
+ getReadScope,
1490
+ getAllowedRelationships: this.options.getAllowedRelationships,
1491
+ relationshipResolver
954
1492
  };
1493
+ if (autoBridgedReadScope) {
1494
+ ctx.logger.info('[Analytics] Auto-bridged getReadScope \u2192 "security" service (getReadFilter)');
1495
+ } else if (!getReadScope) {
1496
+ ctx.logger.warn(
1497
+ '[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.'
1498
+ );
1499
+ }
955
1500
  if (autoBridged) {
956
1501
  ctx.logger.info('[Analytics] Auto-bridged executeAggregate \u2192 "data" service (IDataEngine)');
957
1502
  }
@@ -986,7 +1531,14 @@ export {
986
1531
  AnalyticsService,
987
1532
  AnalyticsServicePlugin,
988
1533
  CubeRegistry,
1534
+ DatasetExecutor,
989
1535
  NativeSQLStrategy,
990
- ObjectQLStrategy
1536
+ ObjectQLStrategy,
1537
+ combineFilters,
1538
+ compileDataset,
1539
+ compileScopedFilterToSql,
1540
+ evaluateDerivedMeasures,
1541
+ mergeByDimensions,
1542
+ shiftRange
991
1543
  };
992
1544
  //# sourceMappingURL=index.js.map