@objectstack/service-analytics 9.0.0 → 9.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.d.cts CHANGED
@@ -228,6 +228,16 @@ interface AnalyticsServiceConfig {
228
228
  * by the plugin from the `data` engine; omit to keep raw values.
229
229
  */
230
230
  labelResolver?: DimensionLabelDeps;
231
+ /**
232
+ * ADR-0037 Phase 3 — draft data preview. Resolve the PENDING `seed` draft
233
+ * rows for an object (returns null when the object has no pending seed).
234
+ * When provided and `queryDataset` is called with `previewDrafts`, the
235
+ * selection is evaluated over these rows in memory instead of the engine —
236
+ * the Live Canvas charts real numbers from the drafted sample data, and
237
+ * because publish materializes the SAME seed, the numbers are continuous
238
+ * across the publish boundary. Reads only; never touches physical tables.
239
+ */
240
+ draftRowsResolver?: (objectName: string, context?: ExecutionContext) => Promise<Record<string, unknown>[] | null>;
231
241
  }
232
242
  /**
233
243
  * AnalyticsService — Multi-driver analytics orchestrator.
@@ -259,6 +269,8 @@ declare class AnalyticsService implements IAnalyticsService {
259
269
  private readonly relationshipResolver?;
260
270
  /** Optional dimension display-label resolver (select options / lookup names). */
261
271
  private readonly labelResolver?;
272
+ /** ADR-0037 P3: pending-seed row resolver for draft data preview. */
273
+ private readonly draftRowsResolver?;
262
274
  readonly cubeRegistry: CubeRegistry;
263
275
  private readonly logger;
264
276
  constructor(config?: AnalyticsServiceConfig);
@@ -284,6 +296,14 @@ declare class AnalyticsService implements IAnalyticsService {
284
296
  private resolveReadScopes;
285
297
  /**
286
298
  * Execute an analytical query by delegating to the first capable strategy.
299
+ *
300
+ * A strategy can discover only AT EXECUTION TIME that the underlying driver
301
+ * cannot serve it — the canonical case is NativeSQLStrategy on an in-memory
302
+ * driver, whose `execute()` returns null for raw SQL (the auto-bridge throws
303
+ * `RAW_SQL_UNSUPPORTED`). That is a capability miss, not a query error: fall
304
+ * back to the next capable strategy (e.g. ObjectQLStrategy over the
305
+ * aggregate bridge) instead of failing — or worse, fabricating empty rows.
306
+ * Any other error propagates untouched.
287
307
  */
288
308
  query(query: AnalyticsQuery, context?: ExecutionContext): Promise<AnalyticsResult>;
289
309
  /**
@@ -298,7 +318,9 @@ declare class AnalyticsService implements IAnalyticsService {
298
318
  * runs the selection through the `DatasetExecutor` with the request context so
299
319
  * tenant/RLS scoping (D-C) is applied. See {@link IAnalyticsService.queryDataset}.
300
320
  */
301
- queryDataset(dataset: Dataset, selection: DatasetSelection, context?: ExecutionContext): Promise<AnalyticsResult>;
321
+ queryDataset(dataset: Dataset, selection: DatasetSelection, context?: ExecutionContext, options?: {
322
+ previewDrafts?: boolean;
323
+ }): Promise<AnalyticsResult>;
302
324
  /**
303
325
  * Get cube metadata for discovery.
304
326
  */
@@ -326,7 +348,9 @@ declare class AnalyticsService implements IAnalyticsService {
326
348
  /** Build a minimal Cube from the fields referenced by an AnalyticsQuery. */
327
349
  private inferCubeFromQuery;
328
350
  /**
329
- * Walk the strategy chain and return the first strategy that can handle the query.
351
+ * Walk the strategy chain and return the first strategy that can handle the
352
+ * query. `skip` excludes strategies that already proved incapable at
353
+ * execution time (see {@link query}'s RAW_SQL_UNSUPPORTED fallback).
330
354
  */
331
355
  private resolveStrategy;
332
356
  }
package/dist/index.d.ts CHANGED
@@ -228,6 +228,16 @@ interface AnalyticsServiceConfig {
228
228
  * by the plugin from the `data` engine; omit to keep raw values.
229
229
  */
230
230
  labelResolver?: DimensionLabelDeps;
231
+ /**
232
+ * ADR-0037 Phase 3 — draft data preview. Resolve the PENDING `seed` draft
233
+ * rows for an object (returns null when the object has no pending seed).
234
+ * When provided and `queryDataset` is called with `previewDrafts`, the
235
+ * selection is evaluated over these rows in memory instead of the engine —
236
+ * the Live Canvas charts real numbers from the drafted sample data, and
237
+ * because publish materializes the SAME seed, the numbers are continuous
238
+ * across the publish boundary. Reads only; never touches physical tables.
239
+ */
240
+ draftRowsResolver?: (objectName: string, context?: ExecutionContext) => Promise<Record<string, unknown>[] | null>;
231
241
  }
232
242
  /**
233
243
  * AnalyticsService — Multi-driver analytics orchestrator.
@@ -259,6 +269,8 @@ declare class AnalyticsService implements IAnalyticsService {
259
269
  private readonly relationshipResolver?;
260
270
  /** Optional dimension display-label resolver (select options / lookup names). */
261
271
  private readonly labelResolver?;
272
+ /** ADR-0037 P3: pending-seed row resolver for draft data preview. */
273
+ private readonly draftRowsResolver?;
262
274
  readonly cubeRegistry: CubeRegistry;
263
275
  private readonly logger;
264
276
  constructor(config?: AnalyticsServiceConfig);
@@ -284,6 +296,14 @@ declare class AnalyticsService implements IAnalyticsService {
284
296
  private resolveReadScopes;
285
297
  /**
286
298
  * Execute an analytical query by delegating to the first capable strategy.
299
+ *
300
+ * A strategy can discover only AT EXECUTION TIME that the underlying driver
301
+ * cannot serve it — the canonical case is NativeSQLStrategy on an in-memory
302
+ * driver, whose `execute()` returns null for raw SQL (the auto-bridge throws
303
+ * `RAW_SQL_UNSUPPORTED`). That is a capability miss, not a query error: fall
304
+ * back to the next capable strategy (e.g. ObjectQLStrategy over the
305
+ * aggregate bridge) instead of failing — or worse, fabricating empty rows.
306
+ * Any other error propagates untouched.
287
307
  */
288
308
  query(query: AnalyticsQuery, context?: ExecutionContext): Promise<AnalyticsResult>;
289
309
  /**
@@ -298,7 +318,9 @@ declare class AnalyticsService implements IAnalyticsService {
298
318
  * runs the selection through the `DatasetExecutor` with the request context so
299
319
  * tenant/RLS scoping (D-C) is applied. See {@link IAnalyticsService.queryDataset}.
300
320
  */
301
- queryDataset(dataset: Dataset, selection: DatasetSelection, context?: ExecutionContext): Promise<AnalyticsResult>;
321
+ queryDataset(dataset: Dataset, selection: DatasetSelection, context?: ExecutionContext, options?: {
322
+ previewDrafts?: boolean;
323
+ }): Promise<AnalyticsResult>;
302
324
  /**
303
325
  * Get cube metadata for discovery.
304
326
  */
@@ -326,7 +348,9 @@ declare class AnalyticsService implements IAnalyticsService {
326
348
  /** Build a minimal Cube from the fields referenced by an AnalyticsQuery. */
327
349
  private inferCubeFromQuery;
328
350
  /**
329
- * Walk the strategy chain and return the first strategy that can handle the query.
351
+ * Walk the strategy chain and return the first strategy that can handle the
352
+ * query. `skip` excludes strategies that already proved incapable at
353
+ * execution time (see {@link query}'s RAW_SQL_UNSUPPORTED fallback).
330
354
  */
331
355
  private resolveStrategy;
332
356
  }
package/dist/index.js CHANGED
@@ -1193,6 +1193,160 @@ function pickDisplayField(fields) {
1193
1193
  return void 0;
1194
1194
  }
1195
1195
 
1196
+ // src/preview-evaluator.ts
1197
+ function compare(a, b) {
1198
+ if (typeof a === "number" && typeof b === "number") return a - b;
1199
+ return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0;
1200
+ }
1201
+ function matchOp(value, op, expected) {
1202
+ switch (op) {
1203
+ case "$eq":
1204
+ return value === expected || String(value) === String(expected);
1205
+ case "$ne":
1206
+ return !(value === expected || String(value) === String(expected));
1207
+ case "$gt":
1208
+ return value != null && compare(value, expected) > 0;
1209
+ case "$gte":
1210
+ return value != null && compare(value, expected) >= 0;
1211
+ case "$lt":
1212
+ return value != null && compare(value, expected) < 0;
1213
+ case "$lte":
1214
+ return value != null && compare(value, expected) <= 0;
1215
+ case "$in":
1216
+ return Array.isArray(expected) && expected.some((e) => value === e || String(value) === String(e));
1217
+ case "$nin":
1218
+ return Array.isArray(expected) && !expected.some((e) => value === e || String(value) === String(e));
1219
+ case "$contains":
1220
+ return String(value ?? "").toLowerCase().includes(String(expected ?? "").toLowerCase());
1221
+ default:
1222
+ return true;
1223
+ }
1224
+ }
1225
+ function matchesWhere(row, where) {
1226
+ if (!where) return true;
1227
+ for (const [key, cond] of Object.entries(where)) {
1228
+ if (key === "$and") {
1229
+ if (!cond.every((c) => matchesWhere(row, c))) return false;
1230
+ } else if (key === "$or") {
1231
+ if (!cond.some((c) => matchesWhere(row, c))) return false;
1232
+ } else if (key === "$not") {
1233
+ if (matchesWhere(row, cond)) return false;
1234
+ } else if (cond !== null && typeof cond === "object" && !Array.isArray(cond)) {
1235
+ for (const [op, expected] of Object.entries(cond)) {
1236
+ if (!matchOp(row[key], op, expected)) return false;
1237
+ }
1238
+ } else if (!(row[key] === cond || String(row[key]) === String(cond))) {
1239
+ return false;
1240
+ }
1241
+ }
1242
+ return true;
1243
+ }
1244
+ function bucketDate(value, granularity) {
1245
+ const d = new Date(String(value));
1246
+ if (Number.isNaN(d.getTime())) return null;
1247
+ const y = d.getUTCFullYear();
1248
+ const m = `${d.getUTCMonth() + 1}`.padStart(2, "0");
1249
+ const day = `${d.getUTCDate()}`.padStart(2, "0");
1250
+ switch (granularity) {
1251
+ case "year":
1252
+ return `${y}`;
1253
+ case "quarter":
1254
+ return `${y}-Q${Math.floor(d.getUTCMonth() / 3) + 1}`;
1255
+ case "month":
1256
+ return `${y}-${m}`;
1257
+ case "week": {
1258
+ const monday = new Date(d);
1259
+ const dow = (d.getUTCDay() + 6) % 7;
1260
+ monday.setUTCDate(d.getUTCDate() - dow);
1261
+ return monday.toISOString().slice(0, 10);
1262
+ }
1263
+ case "day":
1264
+ default:
1265
+ return `${y}-${m}-${day}`;
1266
+ }
1267
+ }
1268
+ function aggregate(rows, metricType, field) {
1269
+ if (metricType === "count" || field === "*") {
1270
+ if (metricType === "countDistinct") {
1271
+ return new Set(rows.map((r) => r[field]).filter((v) => v != null)).size;
1272
+ }
1273
+ return rows.length;
1274
+ }
1275
+ const nums = rows.map((r) => Number(r[field])).filter((n) => Number.isFinite(n));
1276
+ switch (metricType) {
1277
+ case "countDistinct":
1278
+ return new Set(rows.map((r) => r[field]).filter((v) => v != null)).size;
1279
+ case "sum":
1280
+ return nums.reduce((a, b) => a + b, 0);
1281
+ case "avg":
1282
+ return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : 0;
1283
+ case "min":
1284
+ return nums.length ? Math.min(...nums) : 0;
1285
+ case "max":
1286
+ return nums.length ? Math.max(...nums) : 0;
1287
+ default:
1288
+ return nums.length ? nums.reduce((a, b) => a + b, 0) : rows.length;
1289
+ }
1290
+ }
1291
+ function evaluateAnalyticsQueryOverRows(query, cube, rows) {
1292
+ let filtered = rows.filter((r) => matchesWhere(r, query.where));
1293
+ const timeDims = query.timeDimensions ?? [];
1294
+ for (const td of timeDims) {
1295
+ const dim = cube.dimensions?.[td.dimension];
1296
+ const field = String(dim?.sql ?? td.dimension);
1297
+ if (!td.dateRange) continue;
1298
+ const [start, end] = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
1299
+ filtered = filtered.filter((r) => {
1300
+ const v = String(r[field] ?? "");
1301
+ return v >= String(start) && v <= `${end}~`;
1302
+ });
1303
+ }
1304
+ const dimensions = query.dimensions ?? [];
1305
+ const granByDim = new Map(timeDims.filter((t) => t.granularity).map((t) => [t.dimension, t.granularity]));
1306
+ const keyOf = (r) => {
1307
+ const values = {};
1308
+ for (const name of dimensions) {
1309
+ const dim = cube.dimensions?.[name];
1310
+ const field = String(dim?.sql ?? name);
1311
+ const raw = r[field];
1312
+ const gran = granByDim.get(name) ?? (dim?.type === "time" && dim.granularities?.length === 1 ? String(dim.granularities[0]) : void 0);
1313
+ values[name] = gran ? bucketDate(raw, gran) : raw ?? null;
1314
+ }
1315
+ return { key: JSON.stringify(values), values };
1316
+ };
1317
+ const groups = /* @__PURE__ */ new Map();
1318
+ for (const r of filtered) {
1319
+ const { key, values } = keyOf(r);
1320
+ const g = groups.get(key) ?? { values, rows: [] };
1321
+ g.rows.push(r);
1322
+ groups.set(key, g);
1323
+ }
1324
+ if (dimensions.length === 0 && groups.size === 0) {
1325
+ groups.set("{}", { values: {}, rows: [] });
1326
+ }
1327
+ const out = [];
1328
+ for (const g of groups.values()) {
1329
+ const row = { ...g.values };
1330
+ for (const m of query.measures) {
1331
+ const metric = cube.measures?.[m];
1332
+ row[m] = aggregate(g.rows, String(metric?.type ?? "count"), String(metric?.sql ?? "*"));
1333
+ }
1334
+ out.push(row);
1335
+ }
1336
+ for (const [col, dir] of Object.entries(query.order ?? {}).reverse()) {
1337
+ out.sort((a, b) => (dir === "desc" ? -1 : 1) * compare(a[col], b[col]));
1338
+ }
1339
+ const offset = query.offset ?? 0;
1340
+ const limited = out.slice(offset, query.limit != null ? offset + query.limit : void 0);
1341
+ return {
1342
+ rows: limited,
1343
+ fields: [
1344
+ ...dimensions.map((d) => ({ name: d, type: "string" })),
1345
+ ...query.measures.map((m) => ({ name: m, type: "number" }))
1346
+ ]
1347
+ };
1348
+ }
1349
+
1196
1350
  // src/analytics-service.ts
1197
1351
  var DEFAULT_CAPABILITIES = {
1198
1352
  nativeSql: false,
@@ -1211,6 +1365,7 @@ var AnalyticsService = class {
1211
1365
  this.readScopeProvider = config.getReadScope;
1212
1366
  this.relationshipResolver = config.relationshipResolver;
1213
1367
  this.labelResolver = config.labelResolver;
1368
+ this.draftRowsResolver = config.draftRowsResolver;
1214
1369
  if (config.datasets) {
1215
1370
  for (const ds of config.datasets) {
1216
1371
  try {
@@ -1304,6 +1459,14 @@ var AnalyticsService = class {
1304
1459
  }
1305
1460
  /**
1306
1461
  * Execute an analytical query by delegating to the first capable strategy.
1462
+ *
1463
+ * A strategy can discover only AT EXECUTION TIME that the underlying driver
1464
+ * cannot serve it — the canonical case is NativeSQLStrategy on an in-memory
1465
+ * driver, whose `execute()` returns null for raw SQL (the auto-bridge throws
1466
+ * `RAW_SQL_UNSUPPORTED`). That is a capability miss, not a query error: fall
1467
+ * back to the next capable strategy (e.g. ObjectQLStrategy over the
1468
+ * aggregate bridge) instead of failing — or worse, fabricating empty rows.
1469
+ * Any other error propagates untouched.
1307
1470
  */
1308
1471
  async query(query, context) {
1309
1472
  if (!query.cube) {
@@ -1311,9 +1474,23 @@ var AnalyticsService = class {
1311
1474
  }
1312
1475
  this.ensureCube(query);
1313
1476
  const ctx = await this.callCtx(query, context);
1314
- const strategy = this.resolveStrategy(query, ctx);
1315
- this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
1316
- return strategy.execute(query, ctx);
1477
+ let skip;
1478
+ for (; ; ) {
1479
+ const strategy = this.resolveStrategy(query, ctx, skip);
1480
+ this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
1481
+ try {
1482
+ return await strategy.execute(query, ctx);
1483
+ } catch (e) {
1484
+ if (e?.code === "RAW_SQL_UNSUPPORTED") {
1485
+ this.logger.warn(
1486
+ `[Analytics] ${strategy.name} cannot run on this driver (raw SQL unsupported) \u2014 falling back to the next strategy.`
1487
+ );
1488
+ (skip ?? (skip = /* @__PURE__ */ new Set())).add(strategy);
1489
+ continue;
1490
+ }
1491
+ throw e;
1492
+ }
1493
+ }
1317
1494
  }
1318
1495
  /**
1319
1496
  * Compile a `dataset` (ADR-0021) and register its Cube + join allowlist so it
@@ -1332,9 +1509,25 @@ var AnalyticsService = class {
1332
1509
  * runs the selection through the `DatasetExecutor` with the request context so
1333
1510
  * tenant/RLS scoping (D-C) is applied. See {@link IAnalyticsService.queryDataset}.
1334
1511
  */
1335
- async queryDataset(dataset, selection, context) {
1512
+ async queryDataset(dataset, selection, context, options) {
1336
1513
  const compiled = this.registerDataset(dataset);
1337
1514
  this.logger.debug(`[Analytics] queryDataset "${dataset.name}" (object=${dataset.object}, include=${(dataset.include ?? []).join(",") || "\u2014"})`);
1515
+ if (options?.previewDrafts && this.draftRowsResolver) {
1516
+ let seedRows = null;
1517
+ try {
1518
+ seedRows = await this.draftRowsResolver(dataset.object, context);
1519
+ } catch (e) {
1520
+ this.logger.warn(`[Analytics] draft preview resolver failed for "${dataset.object}" \u2014 falling back to live data: ${String(e?.message ?? e)}`);
1521
+ }
1522
+ if (seedRows) {
1523
+ this.logger.debug(`[Analytics] queryDataset "${dataset.name}" \u2192 preview over ${seedRows.length} drafted seed row(s)`);
1524
+ const previewService = {
1525
+ query: async (q) => evaluateAnalyticsQueryOverRows(q, compiled.cube, seedRows)
1526
+ };
1527
+ const previewResult = await new DatasetExecutor(previewService).execute(compiled, selection, context);
1528
+ return previewResult;
1529
+ }
1530
+ }
1338
1531
  const result = await new DatasetExecutor(this).execute(compiled, selection, context);
1339
1532
  if (this.labelResolver && selection.dimensions?.length) {
1340
1533
  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 }));
@@ -1479,16 +1672,19 @@ var AnalyticsService = class {
1479
1672
  };
1480
1673
  }
1481
1674
  /**
1482
- * Walk the strategy chain and return the first strategy that can handle the query.
1675
+ * Walk the strategy chain and return the first strategy that can handle the
1676
+ * query. `skip` excludes strategies that already proved incapable at
1677
+ * execution time (see {@link query}'s RAW_SQL_UNSUPPORTED fallback).
1483
1678
  */
1484
- resolveStrategy(query, ctx) {
1679
+ resolveStrategy(query, ctx, skip) {
1485
1680
  for (const strategy of this.strategies) {
1681
+ if (skip?.has(strategy)) continue;
1486
1682
  if (strategy.canHandle(query, ctx)) {
1487
1683
  return strategy;
1488
1684
  }
1489
1685
  }
1490
1686
  throw new Error(
1491
- `[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.`
1687
+ `[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.`
1492
1688
  );
1493
1689
  }
1494
1690
  };
@@ -1610,8 +1806,15 @@ var AnalyticsServicePlugin = class {
1610
1806
  }
1611
1807
  const knexSql = sql.replace(/\$(\d+)/g, "?");
1612
1808
  const result = await engine.execute(knexSql, { args: params });
1809
+ if (result === null || result === void 0) {
1810
+ const err = new Error(
1811
+ `[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.`
1812
+ );
1813
+ err.code = "RAW_SQL_UNSUPPORTED";
1814
+ throw err;
1815
+ }
1613
1816
  if (Array.isArray(result)) return result;
1614
- if (result && typeof result === "object" && "rows" in result) {
1817
+ if (typeof result === "object" && "rows" in result) {
1615
1818
  return result.rows;
1616
1819
  }
1617
1820
  return [];
@@ -1680,6 +1883,31 @@ var AnalyticsServicePlugin = class {
1680
1883
  return map;
1681
1884
  }
1682
1885
  };
1886
+ const draftRowsResolver = async (objectName) => {
1887
+ let protocol;
1888
+ try {
1889
+ protocol = ctx.getService("protocol");
1890
+ } catch {
1891
+ return null;
1892
+ }
1893
+ if (!protocol?.getMetaItems || !protocol.getMetaItem) return null;
1894
+ const res = await protocol.getMetaItems({ type: "seed", previewDrafts: true }).catch(() => null);
1895
+ const list = Array.isArray(res) ? res : res && typeof res === "object" && Array.isArray(res.items) ? res.items : [];
1896
+ const rows = [];
1897
+ let pending = false;
1898
+ for (const entry of list) {
1899
+ const body = entry?.item ?? entry;
1900
+ if (!body?.name || body.object !== objectName) continue;
1901
+ const draft = await protocol.getMetaItem({ type: "seed", name: body.name, state: "draft" }).catch(() => null);
1902
+ const draftBody = draft?.item;
1903
+ if (!draftBody) continue;
1904
+ pending = true;
1905
+ for (const r of Array.isArray(draftBody.records) ? draftBody.records : []) {
1906
+ if (r && typeof r === "object") rows.push(r);
1907
+ }
1908
+ }
1909
+ return pending ? rows : null;
1910
+ };
1683
1911
  const config = {
1684
1912
  cubes: this.options.cubes,
1685
1913
  logger: ctx.logger,
@@ -1690,7 +1918,8 @@ var AnalyticsServicePlugin = class {
1690
1918
  getReadScope,
1691
1919
  getAllowedRelationships: this.options.getAllowedRelationships,
1692
1920
  relationshipResolver,
1693
- labelResolver
1921
+ labelResolver,
1922
+ draftRowsResolver
1694
1923
  };
1695
1924
  if (autoBridgedReadScope) {
1696
1925
  ctx.logger.info('[Analytics] Auto-bridged getReadScope \u2192 "security" service (getReadFilter)');