From 8c11bf4ad00fd18083c68f37d07d203c69ee6ce8 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 11 Jun 2026 17:26:05 +0500 Subject: [PATCH] =?UTF-8?q?feat(analytics):=20ADR-0037=20P3=20=E2=80=94=20?= =?UTF-8?q?draft=20data=20preview=20(seed-overlay=20dataset=20queries)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit queryDataset gains options.previewDrafts: when set AND the dataset's base object has a PENDING seed draft, the selection is evaluated over the seed's rows in memory — a query-evaluating proxy feeds the unchanged DatasetExecutor, so measure filters / compareTo / derived measures behave identically. The Live Canvas charts real numbers from drafted sample data BEFORE publish, and since publish materializes the same seed, the numbers are continuous across the gate. - preview-evaluator.ts: AnalyticsQuery over rows — Mongo-style where subset, timeDimension dateRange + granularity bucketing (day/week/month/quarter/ year), count/countDistinct/sum/avg/min/max, order/limit/offset. - AnalyticsServiceConfig.draftRowsResolver — injected hook; analytics plugin wires it to the kernel protocol (state:'draft' seed reads ONLY: a published seed's rows are already in the table and must not overlay). No pending seed → falls through to live data, so published objects keep charting real data inside a preview. - REST /analytics/dataset/query: body.previewDrafts / ?preview=draft thread through to the lookup (draft-overlaid dataset definitions) and execution. - spec: IAnalyticsService.queryDataset optional options param (additive). Reads only; same ExecutionContext principal; never touches physical tables. Co-Authored-By: Claude Fable 5 --- packages/rest/src/rest-server.ts | 15 +- .../src/__tests__/preview-evaluator.test.ts | 153 ++++++++++++++ .../src/analytics-service.ts | 46 +++++ .../services/service-analytics/src/plugin.ts | 40 ++++ .../src/preview-evaluator.ts | 187 ++++++++++++++++++ .../spec/src/contracts/analytics-service.ts | 5 + 6 files changed, 444 insertions(+), 2 deletions(-) create mode 100644 packages/services/service-analytics/src/__tests__/preview-evaluator.test.ts create mode 100644 packages/services/service-analytics/src/preview-evaluator.ts diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index fbccce74d8..e2a668cc74 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3648,12 +3648,18 @@ export class RestServer { }); } + // ADR-0037 P3 — draft data preview: the canvas / preview + // pages pass the flag so (a) the dataset lookup sees + // draft-overlaid definitions and (b) the selection runs + // over the pending seed draft's rows when one exists. + const previewDrafts = body.previewDrafts === true || req.query?.preview === 'draft'; + // Resolve the dataset definition: inline draft (Studio // preview) or a saved dataset by name. let dataset = body.dataset; if (!dataset && body.datasetName) { const p = await this.resolveProtocol(environmentId, req); - const items = await (p as any).getMetaItems?.({ type: 'dataset' }).catch(() => null); + const items = await (p as any).getMetaItems?.({ type: 'dataset', previewDrafts }).catch(() => null); const list = Array.isArray(items?.items) ? items.items : (Array.isArray(items) ? items : []); dataset = list.find((d: any) => d?.name === body.datasetName); if (!dataset) { @@ -3677,7 +3683,12 @@ export class RestServer { }); } - const result = await svc.queryDataset(dataset, selection, context ?? undefined); + const result = await svc.queryDataset( + dataset, + selection, + context ?? undefined, + previewDrafts ? { previewDrafts: true } : undefined, + ); res.json(result); } catch (error: any) { const msg = String(error?.message ?? error ?? ''); diff --git a/packages/services/service-analytics/src/__tests__/preview-evaluator.test.ts b/packages/services/service-analytics/src/__tests__/preview-evaluator.test.ts new file mode 100644 index 0000000000..33ef38fca8 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/preview-evaluator.test.ts @@ -0,0 +1,153 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0037 P3 — draft data preview: the evaluator computes a dataset +// selection over pending seed-draft rows in memory, and queryDataset routes +// through it (bypassing the engine entirely) when previewDrafts is set and a +// pending seed exists. + +import { describe, it, expect, vi } from 'vitest'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import { AnalyticsService } from '../analytics-service.js'; +import { evaluateAnalyticsQueryOverRows, bucketDate, matchesWhere } from '../preview-evaluator.js'; +import type { Cube } from '@objectstack/spec/data'; + +const SEED_ROWS = [ + { title: 'Flight', amount: 1200, category: 'travel', spent_on: '2026-05-03' }, + { title: 'Hotel', amount: 800, category: 'travel', spent_on: '2026-05-12' }, + { title: 'Lunch', amount: 60, category: 'meals', spent_on: '2026-06-01' }, + { title: 'Dinner', amount: 140, category: 'meals', spent_on: '2026-06-02' }, +]; + +const DATASET = DatasetSchema.parse({ + name: 'expense_ds', + label: 'Expense', + object: 'expense', + dimensions: [ + { name: 'category', field: 'category', type: 'string' }, + { name: 'spent_on', field: 'spent_on', type: 'date', dateGranularity: 'month' }, + ], + measures: [ + { name: 'count', aggregate: 'count' }, + { name: 'total_amount', aggregate: 'sum', field: 'amount' }, + ], +}); + +function previewService(rows: Record[] | null) { + // No strategies, no fallback: if the engine path is ever reached the call + // throws — which is exactly how we prove preview bypasses it. + return new AnalyticsService({ + draftRowsResolver: vi.fn(async () => rows), + }); +} + +describe('queryDataset({ previewDrafts }) — seed-overlay preview', () => { + it('charts the drafted seed rows without touching the engine', async () => { + const result = await previewService(SEED_ROWS).queryDataset( + DATASET, + { dimensions: ['category'], measures: ['count', 'total_amount'] }, + undefined, + { previewDrafts: true }, + ); + const byCat = Object.fromEntries(result.rows.map((r) => [r.category, r])); + expect(byCat.travel).toMatchObject({ count: 2, total_amount: 2000 }); + expect(byCat.meals).toMatchObject({ count: 2, total_amount: 200 }); + }); + + it('buckets date dimensions by the dataset granularity (month)', async () => { + const result = await previewService(SEED_ROWS).queryDataset( + DATASET, + { dimensions: ['spent_on'], measures: ['total_amount'] }, + undefined, + { previewDrafts: true }, + ); + const byMonth = Object.fromEntries(result.rows.map((r) => [r.spent_on, r.total_amount])); + expect(byMonth['2026-05']).toBe(2000); + expect(byMonth['2026-06']).toBe(200); + }); + + it('falls through to the real engine when the object has NO pending seed', async () => { + // resolver → null ⇒ live-data path ⇒ the bare service (no strategies, + // no fallback) rejects — proof the preview branch stepped aside. + await expect( + previewService(null).queryDataset( + DATASET, + { dimensions: ['category'], measures: ['count'] }, + undefined, + { previewDrafts: true }, + ), + ).rejects.toThrow(); + }); + + it('ignores the flag entirely when no resolver is configured', async () => { + const svc = new AnalyticsService({}); + await expect( + svc.queryDataset(DATASET, { dimensions: [], measures: ['count'] }, undefined, { previewDrafts: true }), + ).rejects.toThrow(); // straight to the (absent) engine — no preview detour + }); +}); + +describe('evaluateAnalyticsQueryOverRows', () => { + const CUBE: Cube = { + name: 'expense_ds', + sql: 'expense', + dimensions: { + category: { name: 'category', type: 'string', sql: 'category' }, + spent_on: { name: 'spent_on', type: 'time', sql: 'spent_on', granularities: ['month'] }, + }, + measures: { + count: { name: 'count', type: 'count', sql: '*' }, + total_amount: { name: 'total_amount', type: 'sum', sql: 'amount' }, + }, + } as unknown as Cube; + + it('applies Mongo-style where filters', () => { + const r = evaluateAnalyticsQueryOverRows( + { measures: ['count'], dimensions: [], where: { category: 'travel' } }, + CUBE, + SEED_ROWS, + ); + expect(r.rows[0].count).toBe(2); + expect( + evaluateAnalyticsQueryOverRows( + { measures: ['count'], dimensions: [], where: { amount: { $gte: 800 } } }, + CUBE, + SEED_ROWS, + ).rows[0].count, + ).toBe(2); + }); + + it('filters by timeDimension dateRange and buckets by granularity', () => { + const r = evaluateAnalyticsQueryOverRows( + { + measures: ['total_amount'], + dimensions: ['spent_on'], + timeDimensions: [{ dimension: 'spent_on', granularity: 'month', dateRange: ['2026-05-01', '2026-05-31'] }], + }, + CUBE, + SEED_ROWS, + ); + expect(r.rows).toEqual([{ spent_on: '2026-05', total_amount: 2000 }]); + }); + + it('orders and limits', () => { + const r = evaluateAnalyticsQueryOverRows( + { measures: ['total_amount'], dimensions: ['category'], order: { total_amount: 'desc' }, limit: 1 }, + CUBE, + SEED_ROWS, + ); + expect(r.rows).toEqual([{ category: 'travel', total_amount: 2000 }]); + }); + + it('a zero-dimension query over zero rows still yields one row (count 0)', () => { + const r = evaluateAnalyticsQueryOverRows({ measures: ['count'], dimensions: [] }, CUBE, []); + expect(r.rows).toEqual([{ count: 0 }]); + }); + + it('helpers: bucketDate + matchesWhere edge ops', () => { + expect(bucketDate('2026-06-11', 'quarter')).toBe('2026-Q2'); + expect(bucketDate('not-a-date', 'month')).toBeNull(); + expect(matchesWhere({ a: 'x' }, { $or: [{ a: 'y' }, { a: 'x' }] })).toBe(true); + expect(matchesWhere({ a: 5 }, { a: { $in: [1, 5] } })).toBe(true); + expect(matchesWhere({ a: 'Hello World' }, { a: { $contains: 'world' } })).toBe(true); + }); +}); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index a4774a32ca..02388ff33e 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -19,6 +19,7 @@ import { ObjectQLStrategy } from './strategies/objectql-strategy.js'; import { compileDataset, type CompiledDataset, type RelationshipResolver } from './dataset-compiler.js'; import { DatasetExecutor } from './dataset-executor.js'; import { resolveDimensionLabels, type DimensionLabelDeps } from './dimension-labels.js'; +import { evaluateAnalyticsQueryOverRows } from './preview-evaluator.js'; /** * Configuration for AnalyticsService. @@ -101,6 +102,20 @@ export interface AnalyticsServiceConfig { * by the plugin from the `data` engine; omit to keep raw values. */ labelResolver?: DimensionLabelDeps; + + /** + * ADR-0037 Phase 3 — draft data preview. Resolve the PENDING `seed` draft + * rows for an object (returns null when the object has no pending seed). + * When provided and `queryDataset` is called with `previewDrafts`, the + * selection is evaluated over these rows in memory instead of the engine — + * the Live Canvas charts real numbers from the drafted sample data, and + * because publish materializes the SAME seed, the numbers are continuous + * across the publish boundary. Reads only; never touches physical tables. + */ + draftRowsResolver?: ( + objectName: string, + context?: ExecutionContext, + ) => Promise[] | null>; } /** @@ -142,6 +157,8 @@ export class AnalyticsService implements IAnalyticsService { private readonly relationshipResolver?: RelationshipResolver; /** Optional dimension display-label resolver (select options / lookup names). */ private readonly labelResolver?: DimensionLabelDeps; + /** ADR-0037 P3: pending-seed row resolver for draft data preview. */ + private readonly draftRowsResolver?: AnalyticsServiceConfig['draftRowsResolver']; readonly cubeRegistry: CubeRegistry; private readonly logger: Logger; @@ -157,6 +174,7 @@ export class AnalyticsService implements IAnalyticsService { this.readScopeProvider = config.getReadScope; this.relationshipResolver = config.relationshipResolver; this.labelResolver = config.labelResolver; + this.draftRowsResolver = config.draftRowsResolver; // Compile + register pre-defined datasets (ADR-0021). if (config.datasets) { @@ -341,9 +359,37 @@ export class AnalyticsService implements IAnalyticsService { dataset: Dataset, selection: DatasetSelection, context?: ExecutionContext, + options?: { previewDrafts?: boolean }, ): Promise { const compiled = this.registerDataset(dataset); this.logger.debug(`[Analytics] queryDataset "${dataset.name}" (object=${dataset.object}, include=${(dataset.include ?? []).join(',') || '—'})`); + + // ── ADR-0037 P3 — draft data preview ──────────────────────────────────── + // When the request renders the as-if-published world AND the base object + // has a PENDING seed draft, evaluate the selection over the seed's rows in + // memory (a query-evaluating proxy feeds the unchanged DatasetExecutor, so + // measure filters / compareTo / derived measures all behave identically). + // No pending seed → fall through to the real engine: published objects + // keep charting live data even inside a preview. + if (options?.previewDrafts && this.draftRowsResolver) { + let seedRows: Record[] | null = null; + try { + seedRows = await this.draftRowsResolver(dataset.object, context); + } catch (e) { + this.logger.warn(`[Analytics] draft preview resolver failed for "${dataset.object}" — falling back to live data: ${String((e as Error)?.message ?? e)}`); + } + if (seedRows) { + this.logger.debug(`[Analytics] queryDataset "${dataset.name}" → preview over ${seedRows.length} drafted seed row(s)`); + const previewService = { + query: async (q: AnalyticsQuery) => evaluateAnalyticsQueryOverRows(q, compiled.cube, seedRows!), + } as IAnalyticsService; + const previewResult = await new DatasetExecutor(previewService).execute(compiled, selection, context); + // Label resolution is skipped on purpose: drafted seed rows reference + // lookups by NAME (the seed convention), which already reads well. + return previewResult; + } + } + const result = await new DatasetExecutor(this).execute(compiled, selection, context); // ADR-0021 — resolve grouped dimension values to human display labels diff --git a/packages/services/service-analytics/src/plugin.ts b/packages/services/service-analytics/src/plugin.ts index 659869039c..a08bbf0992 100644 --- a/packages/services/service-analytics/src/plugin.ts +++ b/packages/services/service-analytics/src/plugin.ts @@ -335,6 +335,45 @@ export class AnalyticsServicePlugin implements Plugin { }, }; + // ADR-0037 P3 — draft data preview: resolve the PENDING seed draft's rows + // for an object via the kernel protocol (state:'draft' read — a published + // seed's rows are already in the real table and must NOT overlay). Lazy + // service lookup so plugin order doesn't matter; null ⇒ no pending seed ⇒ + // queryDataset falls through to live data. + const draftRowsResolver = async (objectName: string): Promise[] | null> => { + type ProtocolLike = { + getMetaItems?(req: { type: string; previewDrafts?: boolean }): Promise; + getMetaItem?(req: { type: string; name: string; state?: string }): Promise; + }; + let protocol: ProtocolLike | undefined; + try { + protocol = ctx.getService('protocol'); + } catch { return null; } + if (!protocol?.getMetaItems || !protocol.getMetaItem) return null; + const res = await protocol.getMetaItems({ type: 'seed', previewDrafts: true }).catch(() => null); + const list = Array.isArray(res) + ? res + : (res && typeof res === 'object' && Array.isArray((res as { items?: unknown[] }).items) + ? (res as { items: unknown[] }).items + : []); + const rows: Record[] = []; + let pending = false; + for (const entry of list) { + const body = ((entry as { item?: unknown })?.item ?? entry) as { name?: string; object?: string } | null; + if (!body?.name || body.object !== objectName) continue; + // Only a PENDING draft row qualifies; getMetaItem({state:'draft'}) + // throws no_draft when the seed is already published. + const draft = await protocol.getMetaItem({ type: 'seed', name: body.name, state: 'draft' }).catch(() => null); + const draftBody = (draft as { item?: { records?: unknown[] } } | null)?.item; + if (!draftBody) continue; + pending = true; + for (const r of Array.isArray(draftBody.records) ? draftBody.records : []) { + if (r && typeof r === 'object') rows.push(r as Record); + } + } + return pending ? rows : null; + }; + const config: AnalyticsServiceConfig = { cubes: this.options.cubes, logger: ctx.logger, @@ -346,6 +385,7 @@ export class AnalyticsServicePlugin implements Plugin { getAllowedRelationships: this.options.getAllowedRelationships, relationshipResolver, labelResolver, + draftRowsResolver, }; if (autoBridgedReadScope) { diff --git a/packages/services/service-analytics/src/preview-evaluator.ts b/packages/services/service-analytics/src/preview-evaluator.ts new file mode 100644 index 0000000000..cfeb123825 --- /dev/null +++ b/packages/services/service-analytics/src/preview-evaluator.ts @@ -0,0 +1,187 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0037 Phase 3 — draft data preview: evaluate an AnalyticsQuery over an +// in-memory row set (the pending `seed` draft's records) instead of the real +// data engine. This is what lets a Live Canvas dashboard chart REAL numbers +// from the DRAFTED sample data before anything is published — and because +// publish materializes the *same* seed, the numbers are continuous across +// the publish boundary. +// +// Scope (deliberately the dataset-query subset, not a general engine): +// • Mongo-style `where` filters ($eq implicit, $ne/$gt/$gte/$lt/$lte/ +// $in/$nin/$contains, $and/$or/$not) +// • timeDimensions date-range filtering + granularity bucketing +// (day/week/month/quarter/year) +// • group-by dimensions; count / countDistinct / sum / avg / min / max +// • order + limit/offset +// Anything beyond (joins via `include`, raw SQL) falls back to the caller's +// normal execution path — the preview simply doesn't claim it. + +import type { AnalyticsQuery, AnalyticsResult } from '@objectstack/spec/contracts'; +import type { Cube } from '@objectstack/spec/data'; + +type Row = Record; + +// ── Filters (the unified Query DSL subset) ────────────────────────────────── + +function compare(a: unknown, b: unknown): number { + if (typeof a === 'number' && typeof b === 'number') return a - b; + return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0; +} + +function matchOp(value: unknown, op: string, expected: unknown): boolean { + switch (op) { + case '$eq': return value === expected || String(value) === String(expected); + case '$ne': return !(value === expected || String(value) === String(expected)); + case '$gt': return value != null && compare(value, expected) > 0; + case '$gte': return value != null && compare(value, expected) >= 0; + case '$lt': return value != null && compare(value, expected) < 0; + case '$lte': return value != null && compare(value, expected) <= 0; + case '$in': return Array.isArray(expected) && expected.some((e) => value === e || String(value) === String(e)); + case '$nin': return Array.isArray(expected) && !expected.some((e) => value === e || String(value) === String(e)); + case '$contains': return String(value ?? '').toLowerCase().includes(String(expected ?? '').toLowerCase()); + default: return true; // unknown operator — permissive (preview, reads only) + } +} + +export function matchesWhere(row: Row, where: Record | undefined): boolean { + if (!where) return true; + for (const [key, cond] of Object.entries(where)) { + if (key === '$and') { + if (!(cond as Row[]).every((c) => matchesWhere(row, c as Row))) return false; + } else if (key === '$or') { + if (!(cond as Row[]).some((c) => matchesWhere(row, c as Row))) return false; + } else if (key === '$not') { + if (matchesWhere(row, cond as Row)) return false; + } else if (cond !== null && typeof cond === 'object' && !Array.isArray(cond)) { + for (const [op, expected] of Object.entries(cond as Row)) { + if (!matchOp(row[key], op, expected)) return false; + } + } else if (!(row[key] === cond || String(row[key]) === String(cond))) { + return false; // implicit equality + } + } + return true; +} + +// ── Time bucketing ────────────────────────────────────────────────────────── + +export function bucketDate(value: unknown, granularity: string): string | null { + const d = new Date(String(value)); + if (Number.isNaN(d.getTime())) return null; + const y = d.getUTCFullYear(); + const m = `${d.getUTCMonth() + 1}`.padStart(2, '0'); + const day = `${d.getUTCDate()}`.padStart(2, '0'); + switch (granularity) { + case 'year': return `${y}`; + case 'quarter': return `${y}-Q${Math.floor(d.getUTCMonth() / 3) + 1}`; + case 'month': return `${y}-${m}`; + case 'week': { + const monday = new Date(d); + const dow = (d.getUTCDay() + 6) % 7; // Monday=0 + monday.setUTCDate(d.getUTCDate() - dow); + return monday.toISOString().slice(0, 10); + } + case 'day': + default: + return `${y}-${m}-${day}`; + } +} + +// ── Aggregation ───────────────────────────────────────────────────────────── + +function aggregate(rows: Row[], metricType: string, field: string): number { + if (metricType === 'count' || field === '*') { + if (metricType === 'countDistinct') { + return new Set(rows.map((r) => r[field]).filter((v) => v != null)).size; + } + return rows.length; + } + const nums = rows.map((r) => Number(r[field])).filter((n) => Number.isFinite(n)); + switch (metricType) { + case 'countDistinct': return new Set(rows.map((r) => r[field]).filter((v) => v != null)).size; + case 'sum': return nums.reduce((a, b) => a + b, 0); + case 'avg': return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : 0; + case 'min': return nums.length ? Math.min(...nums) : 0; + case 'max': return nums.length ? Math.max(...nums) : 0; + default: return nums.length ? nums.reduce((a, b) => a + b, 0) : rows.length; + } +} + +/** + * Evaluate `query` over `rows` using the cube's measure/dimension specs. + * Mirrors the engine strategies' output contract: rows keyed by bare + * measure/dimension names, `fields` describing each output column. + */ +export function evaluateAnalyticsQueryOverRows( + query: AnalyticsQuery, + cube: Cube, + rows: Row[], +): AnalyticsResult { + // 1. Row-level filters: `where`, then timeDimension dateRanges. + let filtered = rows.filter((r) => matchesWhere(r, query.where)); + const timeDims = query.timeDimensions ?? []; + for (const td of timeDims) { + const dim = cube.dimensions?.[td.dimension]; + const field = String(dim?.sql ?? td.dimension); + if (!td.dateRange) continue; + const [start, end] = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange]; + filtered = filtered.filter((r) => { + const v = String(r[field] ?? ''); + return v >= String(start) && v <= `${end}~`; // '~' > any date char: inclusive end-day + }); + } + + // 2. Grouping keys: each selected dimension (time dims bucketed). + const dimensions = query.dimensions ?? []; + const granByDim = new Map(timeDims.filter((t) => t.granularity).map((t) => [t.dimension, t.granularity!])); + const keyOf = (r: Row): { key: string; values: Row } => { + const values: Row = {}; + for (const name of dimensions) { + const dim = cube.dimensions?.[name]; + const field = String(dim?.sql ?? name); + const raw = r[field]; + const gran = granByDim.get(name) ?? (dim?.type === 'time' && dim.granularities?.length === 1 ? String(dim.granularities[0]) : undefined); + values[name] = gran ? bucketDate(raw, gran) : (raw ?? null); + } + return { key: JSON.stringify(values), values }; + }; + + const groups = new Map(); + for (const r of filtered) { + const { key, values } = keyOf(r); + const g = groups.get(key) ?? { values, rows: [] }; + g.rows.push(r); + groups.set(key, g); + } + // No dimensions → a single overall group (even over zero rows: count = 0). + if (dimensions.length === 0 && groups.size === 0) { + groups.set('{}', { values: {}, rows: [] }); + } + + // 3. Aggregate each measure per group. + const out: Row[] = []; + for (const g of groups.values()) { + const row: Row = { ...g.values }; + for (const m of query.measures) { + const metric = cube.measures?.[m]; + row[m] = aggregate(g.rows, String(metric?.type ?? 'count'), String(metric?.sql ?? '*')); + } + out.push(row); + } + + // 4. Order + paging. + for (const [col, dir] of Object.entries(query.order ?? {}).reverse()) { + out.sort((a, b) => (dir === 'desc' ? -1 : 1) * compare(a[col], b[col])); + } + const offset = query.offset ?? 0; + const limited = out.slice(offset, query.limit != null ? offset + query.limit : undefined); + + return { + rows: limited, + fields: [ + ...dimensions.map((d) => ({ name: d, type: 'string' })), + ...query.measures.map((m) => ({ name: m, type: 'number' })), + ], + }; +} diff --git a/packages/spec/src/contracts/analytics-service.ts b/packages/spec/src/contracts/analytics-service.ts index ccb5a6b2f3..cda693001b 100644 --- a/packages/spec/src/contracts/analytics-service.ts +++ b/packages/spec/src/contracts/analytics-service.ts @@ -163,11 +163,16 @@ export interface IAnalyticsService { * @param dataset - The dataset definition (saved or inline draft). * @param selection - Dimensions/measures to project + runtime directives. * @param context - The request's ExecutionContext (tenant/RLS, see {@link query}). + * @param options - ADR-0037 P3: `previewDrafts` evaluates the selection over + * the base object's PENDING seed-draft rows (when one exists) so a draft + * preview charts real numbers before publish. Same principal, reads only; + * implementations without draft support ignore it. */ queryDataset?( dataset: Dataset, selection: DatasetSelection, context?: ExecutionContext, + options?: { previewDrafts?: boolean }, ): Promise; }