@objectstack/service-analytics 9.0.0 → 9.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1232,6 +1232,160 @@ function pickDisplayField(fields) {
1232
1232
  return void 0;
1233
1233
  }
1234
1234
 
1235
+ // src/preview-evaluator.ts
1236
+ function compare(a, b) {
1237
+ if (typeof a === "number" && typeof b === "number") return a - b;
1238
+ return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0;
1239
+ }
1240
+ function matchOp(value, op, expected) {
1241
+ switch (op) {
1242
+ case "$eq":
1243
+ return value === expected || String(value) === String(expected);
1244
+ case "$ne":
1245
+ return !(value === expected || String(value) === String(expected));
1246
+ case "$gt":
1247
+ return value != null && compare(value, expected) > 0;
1248
+ case "$gte":
1249
+ return value != null && compare(value, expected) >= 0;
1250
+ case "$lt":
1251
+ return value != null && compare(value, expected) < 0;
1252
+ case "$lte":
1253
+ return value != null && compare(value, expected) <= 0;
1254
+ case "$in":
1255
+ return Array.isArray(expected) && expected.some((e) => value === e || String(value) === String(e));
1256
+ case "$nin":
1257
+ return Array.isArray(expected) && !expected.some((e) => value === e || String(value) === String(e));
1258
+ case "$contains":
1259
+ return String(value ?? "").toLowerCase().includes(String(expected ?? "").toLowerCase());
1260
+ default:
1261
+ return true;
1262
+ }
1263
+ }
1264
+ function matchesWhere(row, where) {
1265
+ if (!where) return true;
1266
+ for (const [key, cond] of Object.entries(where)) {
1267
+ if (key === "$and") {
1268
+ if (!cond.every((c) => matchesWhere(row, c))) return false;
1269
+ } else if (key === "$or") {
1270
+ if (!cond.some((c) => matchesWhere(row, c))) return false;
1271
+ } else if (key === "$not") {
1272
+ if (matchesWhere(row, cond)) return false;
1273
+ } else if (cond !== null && typeof cond === "object" && !Array.isArray(cond)) {
1274
+ for (const [op, expected] of Object.entries(cond)) {
1275
+ if (!matchOp(row[key], op, expected)) return false;
1276
+ }
1277
+ } else if (!(row[key] === cond || String(row[key]) === String(cond))) {
1278
+ return false;
1279
+ }
1280
+ }
1281
+ return true;
1282
+ }
1283
+ function bucketDate(value, granularity) {
1284
+ const d = new Date(String(value));
1285
+ if (Number.isNaN(d.getTime())) return null;
1286
+ const y = d.getUTCFullYear();
1287
+ const m = `${d.getUTCMonth() + 1}`.padStart(2, "0");
1288
+ const day = `${d.getUTCDate()}`.padStart(2, "0");
1289
+ switch (granularity) {
1290
+ case "year":
1291
+ return `${y}`;
1292
+ case "quarter":
1293
+ return `${y}-Q${Math.floor(d.getUTCMonth() / 3) + 1}`;
1294
+ case "month":
1295
+ return `${y}-${m}`;
1296
+ case "week": {
1297
+ const monday = new Date(d);
1298
+ const dow = (d.getUTCDay() + 6) % 7;
1299
+ monday.setUTCDate(d.getUTCDate() - dow);
1300
+ return monday.toISOString().slice(0, 10);
1301
+ }
1302
+ case "day":
1303
+ default:
1304
+ return `${y}-${m}-${day}`;
1305
+ }
1306
+ }
1307
+ function aggregate(rows, metricType, field) {
1308
+ if (metricType === "count" || field === "*") {
1309
+ if (metricType === "countDistinct") {
1310
+ return new Set(rows.map((r) => r[field]).filter((v) => v != null)).size;
1311
+ }
1312
+ return rows.length;
1313
+ }
1314
+ const nums = rows.map((r) => Number(r[field])).filter((n) => Number.isFinite(n));
1315
+ switch (metricType) {
1316
+ case "countDistinct":
1317
+ return new Set(rows.map((r) => r[field]).filter((v) => v != null)).size;
1318
+ case "sum":
1319
+ return nums.reduce((a, b) => a + b, 0);
1320
+ case "avg":
1321
+ return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : 0;
1322
+ case "min":
1323
+ return nums.length ? Math.min(...nums) : 0;
1324
+ case "max":
1325
+ return nums.length ? Math.max(...nums) : 0;
1326
+ default:
1327
+ return nums.length ? nums.reduce((a, b) => a + b, 0) : rows.length;
1328
+ }
1329
+ }
1330
+ function evaluateAnalyticsQueryOverRows(query, cube, rows) {
1331
+ let filtered = rows.filter((r) => matchesWhere(r, query.where));
1332
+ const timeDims = query.timeDimensions ?? [];
1333
+ for (const td of timeDims) {
1334
+ const dim = cube.dimensions?.[td.dimension];
1335
+ const field = String(dim?.sql ?? td.dimension);
1336
+ if (!td.dateRange) continue;
1337
+ const [start, end] = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
1338
+ filtered = filtered.filter((r) => {
1339
+ const v = String(r[field] ?? "");
1340
+ return v >= String(start) && v <= `${end}~`;
1341
+ });
1342
+ }
1343
+ const dimensions = query.dimensions ?? [];
1344
+ const granByDim = new Map(timeDims.filter((t) => t.granularity).map((t) => [t.dimension, t.granularity]));
1345
+ const keyOf = (r) => {
1346
+ const values = {};
1347
+ for (const name of dimensions) {
1348
+ const dim = cube.dimensions?.[name];
1349
+ const field = String(dim?.sql ?? name);
1350
+ const raw = r[field];
1351
+ const gran = granByDim.get(name) ?? (dim?.type === "time" && dim.granularities?.length === 1 ? String(dim.granularities[0]) : void 0);
1352
+ values[name] = gran ? bucketDate(raw, gran) : raw ?? null;
1353
+ }
1354
+ return { key: JSON.stringify(values), values };
1355
+ };
1356
+ const groups = /* @__PURE__ */ new Map();
1357
+ for (const r of filtered) {
1358
+ const { key, values } = keyOf(r);
1359
+ const g = groups.get(key) ?? { values, rows: [] };
1360
+ g.rows.push(r);
1361
+ groups.set(key, g);
1362
+ }
1363
+ if (dimensions.length === 0 && groups.size === 0) {
1364
+ groups.set("{}", { values: {}, rows: [] });
1365
+ }
1366
+ const out = [];
1367
+ for (const g of groups.values()) {
1368
+ const row = { ...g.values };
1369
+ for (const m of query.measures) {
1370
+ const metric = cube.measures?.[m];
1371
+ row[m] = aggregate(g.rows, String(metric?.type ?? "count"), String(metric?.sql ?? "*"));
1372
+ }
1373
+ out.push(row);
1374
+ }
1375
+ for (const [col, dir] of Object.entries(query.order ?? {}).reverse()) {
1376
+ out.sort((a, b) => (dir === "desc" ? -1 : 1) * compare(a[col], b[col]));
1377
+ }
1378
+ const offset = query.offset ?? 0;
1379
+ const limited = out.slice(offset, query.limit != null ? offset + query.limit : void 0);
1380
+ return {
1381
+ rows: limited,
1382
+ fields: [
1383
+ ...dimensions.map((d) => ({ name: d, type: "string" })),
1384
+ ...query.measures.map((m) => ({ name: m, type: "number" }))
1385
+ ]
1386
+ };
1387
+ }
1388
+
1235
1389
  // src/analytics-service.ts
1236
1390
  var DEFAULT_CAPABILITIES = {
1237
1391
  nativeSql: false,
@@ -1250,6 +1404,7 @@ var AnalyticsService = class {
1250
1404
  this.readScopeProvider = config.getReadScope;
1251
1405
  this.relationshipResolver = config.relationshipResolver;
1252
1406
  this.labelResolver = config.labelResolver;
1407
+ this.draftRowsResolver = config.draftRowsResolver;
1253
1408
  if (config.datasets) {
1254
1409
  for (const ds of config.datasets) {
1255
1410
  try {
@@ -1343,6 +1498,14 @@ var AnalyticsService = class {
1343
1498
  }
1344
1499
  /**
1345
1500
  * Execute an analytical query by delegating to the first capable strategy.
1501
+ *
1502
+ * A strategy can discover only AT EXECUTION TIME that the underlying driver
1503
+ * cannot serve it — the canonical case is NativeSQLStrategy on an in-memory
1504
+ * driver, whose `execute()` returns null for raw SQL (the auto-bridge throws
1505
+ * `RAW_SQL_UNSUPPORTED`). That is a capability miss, not a query error: fall
1506
+ * back to the next capable strategy (e.g. ObjectQLStrategy over the
1507
+ * aggregate bridge) instead of failing — or worse, fabricating empty rows.
1508
+ * Any other error propagates untouched.
1346
1509
  */
1347
1510
  async query(query, context) {
1348
1511
  if (!query.cube) {
@@ -1350,9 +1513,23 @@ var AnalyticsService = class {
1350
1513
  }
1351
1514
  this.ensureCube(query);
1352
1515
  const ctx = await this.callCtx(query, context);
1353
- const strategy = this.resolveStrategy(query, ctx);
1354
- this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
1355
- return strategy.execute(query, ctx);
1516
+ let skip;
1517
+ for (; ; ) {
1518
+ const strategy = this.resolveStrategy(query, ctx, skip);
1519
+ this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
1520
+ try {
1521
+ return await strategy.execute(query, ctx);
1522
+ } catch (e) {
1523
+ if (e?.code === "RAW_SQL_UNSUPPORTED") {
1524
+ this.logger.warn(
1525
+ `[Analytics] ${strategy.name} cannot run on this driver (raw SQL unsupported) \u2014 falling back to the next strategy.`
1526
+ );
1527
+ (skip ?? (skip = /* @__PURE__ */ new Set())).add(strategy);
1528
+ continue;
1529
+ }
1530
+ throw e;
1531
+ }
1532
+ }
1356
1533
  }
1357
1534
  /**
1358
1535
  * Compile a `dataset` (ADR-0021) and register its Cube + join allowlist so it
@@ -1371,9 +1548,25 @@ var AnalyticsService = class {
1371
1548
  * runs the selection through the `DatasetExecutor` with the request context so
1372
1549
  * tenant/RLS scoping (D-C) is applied. See {@link IAnalyticsService.queryDataset}.
1373
1550
  */
1374
- async queryDataset(dataset, selection, context) {
1551
+ async queryDataset(dataset, selection, context, options) {
1375
1552
  const compiled = this.registerDataset(dataset);
1376
1553
  this.logger.debug(`[Analytics] queryDataset "${dataset.name}" (object=${dataset.object}, include=${(dataset.include ?? []).join(",") || "\u2014"})`);
1554
+ if (options?.previewDrafts && this.draftRowsResolver) {
1555
+ let seedRows = null;
1556
+ try {
1557
+ seedRows = await this.draftRowsResolver(dataset.object, context);
1558
+ } catch (e) {
1559
+ this.logger.warn(`[Analytics] draft preview resolver failed for "${dataset.object}" \u2014 falling back to live data: ${String(e?.message ?? e)}`);
1560
+ }
1561
+ if (seedRows) {
1562
+ this.logger.debug(`[Analytics] queryDataset "${dataset.name}" \u2192 preview over ${seedRows.length} drafted seed row(s)`);
1563
+ const previewService = {
1564
+ query: async (q) => evaluateAnalyticsQueryOverRows(q, compiled.cube, seedRows)
1565
+ };
1566
+ const previewResult = await new DatasetExecutor(previewService).execute(compiled, selection, context);
1567
+ return previewResult;
1568
+ }
1569
+ }
1377
1570
  const result = await new DatasetExecutor(this).execute(compiled, selection, context);
1378
1571
  if (this.labelResolver && selection.dimensions?.length) {
1379
1572
  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 }));
@@ -1518,16 +1711,19 @@ var AnalyticsService = class {
1518
1711
  };
1519
1712
  }
1520
1713
  /**
1521
- * Walk the strategy chain and return the first strategy that can handle the query.
1714
+ * Walk the strategy chain and return the first strategy that can handle the
1715
+ * query. `skip` excludes strategies that already proved incapable at
1716
+ * execution time (see {@link query}'s RAW_SQL_UNSUPPORTED fallback).
1522
1717
  */
1523
- resolveStrategy(query, ctx) {
1718
+ resolveStrategy(query, ctx, skip) {
1524
1719
  for (const strategy of this.strategies) {
1720
+ if (skip?.has(strategy)) continue;
1525
1721
  if (strategy.canHandle(query, ctx)) {
1526
1722
  return strategy;
1527
1723
  }
1528
1724
  }
1529
1725
  throw new Error(
1530
- `[Analytics] No strategy can handle query for cube "${query.cube}". Checked: ${this.strategies.map((s) => s.name).join(", ")}. Ensure a compatible driver is configured or a fallback service is registered.`
1726
+ `[Analytics] No strategy can handle query for cube "${query.cube}". Checked: ${this.strategies.map((s) => s.name).join(", ")}${skip?.size ? ` (skipped at runtime: ${[...skip].map((s) => s.name).join(", ")})` : ""}. Ensure a compatible driver is configured or a fallback service is registered.`
1531
1727
  );
1532
1728
  }
1533
1729
  };
@@ -1649,8 +1845,15 @@ var AnalyticsServicePlugin = class {
1649
1845
  }
1650
1846
  const knexSql = sql.replace(/\$(\d+)/g, "?");
1651
1847
  const result = await engine.execute(knexSql, { args: params });
1848
+ if (result === null || result === void 0) {
1849
+ const err = new Error(
1850
+ `[Analytics] The "data" engine's driver returned null for raw SQL \u2014 this driver does not support SQL execution. The query will fall back to an aggregate-based strategy when one is available.`
1851
+ );
1852
+ err.code = "RAW_SQL_UNSUPPORTED";
1853
+ throw err;
1854
+ }
1652
1855
  if (Array.isArray(result)) return result;
1653
- if (result && typeof result === "object" && "rows" in result) {
1856
+ if (typeof result === "object" && "rows" in result) {
1654
1857
  return result.rows;
1655
1858
  }
1656
1859
  return [];
@@ -1719,6 +1922,31 @@ var AnalyticsServicePlugin = class {
1719
1922
  return map;
1720
1923
  }
1721
1924
  };
1925
+ const draftRowsResolver = async (objectName) => {
1926
+ let protocol;
1927
+ try {
1928
+ protocol = ctx.getService("protocol");
1929
+ } catch {
1930
+ return null;
1931
+ }
1932
+ if (!protocol?.getMetaItems || !protocol.getMetaItem) return null;
1933
+ const res = await protocol.getMetaItems({ type: "seed", previewDrafts: true }).catch(() => null);
1934
+ const list = Array.isArray(res) ? res : res && typeof res === "object" && Array.isArray(res.items) ? res.items : [];
1935
+ const rows = [];
1936
+ let pending = false;
1937
+ for (const entry of list) {
1938
+ const body = entry?.item ?? entry;
1939
+ if (!body?.name || body.object !== objectName) continue;
1940
+ const draft = await protocol.getMetaItem({ type: "seed", name: body.name, state: "draft" }).catch(() => null);
1941
+ const draftBody = draft?.item;
1942
+ if (!draftBody) continue;
1943
+ pending = true;
1944
+ for (const r of Array.isArray(draftBody.records) ? draftBody.records : []) {
1945
+ if (r && typeof r === "object") rows.push(r);
1946
+ }
1947
+ }
1948
+ return pending ? rows : null;
1949
+ };
1722
1950
  const config = {
1723
1951
  cubes: this.options.cubes,
1724
1952
  logger: ctx.logger,
@@ -1729,7 +1957,8 @@ var AnalyticsServicePlugin = class {
1729
1957
  getReadScope,
1730
1958
  getAllowedRelationships: this.options.getAllowedRelationships,
1731
1959
  relationshipResolver,
1732
- labelResolver
1960
+ labelResolver,
1961
+ draftRowsResolver
1733
1962
  };
1734
1963
  if (autoBridgedReadScope) {
1735
1964
  ctx.logger.info('[Analytics] Auto-bridged getReadScope \u2192 "security" service (getReadFilter)');