Skip to content

Commit 8c11bf4

Browse files
os-zhuangclaude
andcommitted
feat(analytics): ADR-0037 P3 — draft data preview (seed-overlay dataset queries)
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 <noreply@anthropic.com>
1 parent 119ff49 commit 8c11bf4

6 files changed

Lines changed: 444 additions & 2 deletions

File tree

packages/rest/src/rest-server.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3648,12 +3648,18 @@ export class RestServer {
36483648
});
36493649
}
36503650

3651+
// ADR-0037 P3 — draft data preview: the canvas / preview
3652+
// pages pass the flag so (a) the dataset lookup sees
3653+
// draft-overlaid definitions and (b) the selection runs
3654+
// over the pending seed draft's rows when one exists.
3655+
const previewDrafts = body.previewDrafts === true || req.query?.preview === 'draft';
3656+
36513657
// Resolve the dataset definition: inline draft (Studio
36523658
// preview) or a saved dataset by name.
36533659
let dataset = body.dataset;
36543660
if (!dataset && body.datasetName) {
36553661
const p = await this.resolveProtocol(environmentId, req);
3656-
const items = await (p as any).getMetaItems?.({ type: 'dataset' }).catch(() => null);
3662+
const items = await (p as any).getMetaItems?.({ type: 'dataset', previewDrafts }).catch(() => null);
36573663
const list = Array.isArray(items?.items) ? items.items : (Array.isArray(items) ? items : []);
36583664
dataset = list.find((d: any) => d?.name === body.datasetName);
36593665
if (!dataset) {
@@ -3677,7 +3683,12 @@ export class RestServer {
36773683
});
36783684
}
36793685

3680-
const result = await svc.queryDataset(dataset, selection, context ?? undefined);
3686+
const result = await svc.queryDataset(
3687+
dataset,
3688+
selection,
3689+
context ?? undefined,
3690+
previewDrafts ? { previewDrafts: true } : undefined,
3691+
);
36813692
res.json(result);
36823693
} catch (error: any) {
36833694
const msg = String(error?.message ?? error ?? '');
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// ADR-0037 P3 — draft data preview: the evaluator computes a dataset
4+
// selection over pending seed-draft rows in memory, and queryDataset routes
5+
// through it (bypassing the engine entirely) when previewDrafts is set and a
6+
// pending seed exists.
7+
8+
import { describe, it, expect, vi } from 'vitest';
9+
import { DatasetSchema } from '@objectstack/spec/ui';
10+
import { AnalyticsService } from '../analytics-service.js';
11+
import { evaluateAnalyticsQueryOverRows, bucketDate, matchesWhere } from '../preview-evaluator.js';
12+
import type { Cube } from '@objectstack/spec/data';
13+
14+
const SEED_ROWS = [
15+
{ title: 'Flight', amount: 1200, category: 'travel', spent_on: '2026-05-03' },
16+
{ title: 'Hotel', amount: 800, category: 'travel', spent_on: '2026-05-12' },
17+
{ title: 'Lunch', amount: 60, category: 'meals', spent_on: '2026-06-01' },
18+
{ title: 'Dinner', amount: 140, category: 'meals', spent_on: '2026-06-02' },
19+
];
20+
21+
const DATASET = DatasetSchema.parse({
22+
name: 'expense_ds',
23+
label: 'Expense',
24+
object: 'expense',
25+
dimensions: [
26+
{ name: 'category', field: 'category', type: 'string' },
27+
{ name: 'spent_on', field: 'spent_on', type: 'date', dateGranularity: 'month' },
28+
],
29+
measures: [
30+
{ name: 'count', aggregate: 'count' },
31+
{ name: 'total_amount', aggregate: 'sum', field: 'amount' },
32+
],
33+
});
34+
35+
function previewService(rows: Record<string, unknown>[] | null) {
36+
// No strategies, no fallback: if the engine path is ever reached the call
37+
// throws — which is exactly how we prove preview bypasses it.
38+
return new AnalyticsService({
39+
draftRowsResolver: vi.fn(async () => rows),
40+
});
41+
}
42+
43+
describe('queryDataset({ previewDrafts }) — seed-overlay preview', () => {
44+
it('charts the drafted seed rows without touching the engine', async () => {
45+
const result = await previewService(SEED_ROWS).queryDataset(
46+
DATASET,
47+
{ dimensions: ['category'], measures: ['count', 'total_amount'] },
48+
undefined,
49+
{ previewDrafts: true },
50+
);
51+
const byCat = Object.fromEntries(result.rows.map((r) => [r.category, r]));
52+
expect(byCat.travel).toMatchObject({ count: 2, total_amount: 2000 });
53+
expect(byCat.meals).toMatchObject({ count: 2, total_amount: 200 });
54+
});
55+
56+
it('buckets date dimensions by the dataset granularity (month)', async () => {
57+
const result = await previewService(SEED_ROWS).queryDataset(
58+
DATASET,
59+
{ dimensions: ['spent_on'], measures: ['total_amount'] },
60+
undefined,
61+
{ previewDrafts: true },
62+
);
63+
const byMonth = Object.fromEntries(result.rows.map((r) => [r.spent_on, r.total_amount]));
64+
expect(byMonth['2026-05']).toBe(2000);
65+
expect(byMonth['2026-06']).toBe(200);
66+
});
67+
68+
it('falls through to the real engine when the object has NO pending seed', async () => {
69+
// resolver → null ⇒ live-data path ⇒ the bare service (no strategies,
70+
// no fallback) rejects — proof the preview branch stepped aside.
71+
await expect(
72+
previewService(null).queryDataset(
73+
DATASET,
74+
{ dimensions: ['category'], measures: ['count'] },
75+
undefined,
76+
{ previewDrafts: true },
77+
),
78+
).rejects.toThrow();
79+
});
80+
81+
it('ignores the flag entirely when no resolver is configured', async () => {
82+
const svc = new AnalyticsService({});
83+
await expect(
84+
svc.queryDataset(DATASET, { dimensions: [], measures: ['count'] }, undefined, { previewDrafts: true }),
85+
).rejects.toThrow(); // straight to the (absent) engine — no preview detour
86+
});
87+
});
88+
89+
describe('evaluateAnalyticsQueryOverRows', () => {
90+
const CUBE: Cube = {
91+
name: 'expense_ds',
92+
sql: 'expense',
93+
dimensions: {
94+
category: { name: 'category', type: 'string', sql: 'category' },
95+
spent_on: { name: 'spent_on', type: 'time', sql: 'spent_on', granularities: ['month'] },
96+
},
97+
measures: {
98+
count: { name: 'count', type: 'count', sql: '*' },
99+
total_amount: { name: 'total_amount', type: 'sum', sql: 'amount' },
100+
},
101+
} as unknown as Cube;
102+
103+
it('applies Mongo-style where filters', () => {
104+
const r = evaluateAnalyticsQueryOverRows(
105+
{ measures: ['count'], dimensions: [], where: { category: 'travel' } },
106+
CUBE,
107+
SEED_ROWS,
108+
);
109+
expect(r.rows[0].count).toBe(2);
110+
expect(
111+
evaluateAnalyticsQueryOverRows(
112+
{ measures: ['count'], dimensions: [], where: { amount: { $gte: 800 } } },
113+
CUBE,
114+
SEED_ROWS,
115+
).rows[0].count,
116+
).toBe(2);
117+
});
118+
119+
it('filters by timeDimension dateRange and buckets by granularity', () => {
120+
const r = evaluateAnalyticsQueryOverRows(
121+
{
122+
measures: ['total_amount'],
123+
dimensions: ['spent_on'],
124+
timeDimensions: [{ dimension: 'spent_on', granularity: 'month', dateRange: ['2026-05-01', '2026-05-31'] }],
125+
},
126+
CUBE,
127+
SEED_ROWS,
128+
);
129+
expect(r.rows).toEqual([{ spent_on: '2026-05', total_amount: 2000 }]);
130+
});
131+
132+
it('orders and limits', () => {
133+
const r = evaluateAnalyticsQueryOverRows(
134+
{ measures: ['total_amount'], dimensions: ['category'], order: { total_amount: 'desc' }, limit: 1 },
135+
CUBE,
136+
SEED_ROWS,
137+
);
138+
expect(r.rows).toEqual([{ category: 'travel', total_amount: 2000 }]);
139+
});
140+
141+
it('a zero-dimension query over zero rows still yields one row (count 0)', () => {
142+
const r = evaluateAnalyticsQueryOverRows({ measures: ['count'], dimensions: [] }, CUBE, []);
143+
expect(r.rows).toEqual([{ count: 0 }]);
144+
});
145+
146+
it('helpers: bucketDate + matchesWhere edge ops', () => {
147+
expect(bucketDate('2026-06-11', 'quarter')).toBe('2026-Q2');
148+
expect(bucketDate('not-a-date', 'month')).toBeNull();
149+
expect(matchesWhere({ a: 'x' }, { $or: [{ a: 'y' }, { a: 'x' }] })).toBe(true);
150+
expect(matchesWhere({ a: 5 }, { a: { $in: [1, 5] } })).toBe(true);
151+
expect(matchesWhere({ a: 'Hello World' }, { a: { $contains: 'world' } })).toBe(true);
152+
});
153+
});

packages/services/service-analytics/src/analytics-service.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { ObjectQLStrategy } from './strategies/objectql-strategy.js';
1919
import { compileDataset, type CompiledDataset, type RelationshipResolver } from './dataset-compiler.js';
2020
import { DatasetExecutor } from './dataset-executor.js';
2121
import { resolveDimensionLabels, type DimensionLabelDeps } from './dimension-labels.js';
22+
import { evaluateAnalyticsQueryOverRows } from './preview-evaluator.js';
2223

2324
/**
2425
* Configuration for AnalyticsService.
@@ -101,6 +102,20 @@ export interface AnalyticsServiceConfig {
101102
* by the plugin from the `data` engine; omit to keep raw values.
102103
*/
103104
labelResolver?: DimensionLabelDeps;
105+
106+
/**
107+
* ADR-0037 Phase 3 — draft data preview. Resolve the PENDING `seed` draft
108+
* rows for an object (returns null when the object has no pending seed).
109+
* When provided and `queryDataset` is called with `previewDrafts`, the
110+
* selection is evaluated over these rows in memory instead of the engine —
111+
* the Live Canvas charts real numbers from the drafted sample data, and
112+
* because publish materializes the SAME seed, the numbers are continuous
113+
* across the publish boundary. Reads only; never touches physical tables.
114+
*/
115+
draftRowsResolver?: (
116+
objectName: string,
117+
context?: ExecutionContext,
118+
) => Promise<Record<string, unknown>[] | null>;
104119
}
105120

106121
/**
@@ -142,6 +157,8 @@ export class AnalyticsService implements IAnalyticsService {
142157
private readonly relationshipResolver?: RelationshipResolver;
143158
/** Optional dimension display-label resolver (select options / lookup names). */
144159
private readonly labelResolver?: DimensionLabelDeps;
160+
/** ADR-0037 P3: pending-seed row resolver for draft data preview. */
161+
private readonly draftRowsResolver?: AnalyticsServiceConfig['draftRowsResolver'];
145162
readonly cubeRegistry: CubeRegistry;
146163
private readonly logger: Logger;
147164

@@ -157,6 +174,7 @@ export class AnalyticsService implements IAnalyticsService {
157174
this.readScopeProvider = config.getReadScope;
158175
this.relationshipResolver = config.relationshipResolver;
159176
this.labelResolver = config.labelResolver;
177+
this.draftRowsResolver = config.draftRowsResolver;
160178

161179
// Compile + register pre-defined datasets (ADR-0021).
162180
if (config.datasets) {
@@ -341,9 +359,37 @@ export class AnalyticsService implements IAnalyticsService {
341359
dataset: Dataset,
342360
selection: DatasetSelection,
343361
context?: ExecutionContext,
362+
options?: { previewDrafts?: boolean },
344363
): Promise<AnalyticsResult> {
345364
const compiled = this.registerDataset(dataset);
346365
this.logger.debug(`[Analytics] queryDataset "${dataset.name}" (object=${dataset.object}, include=${(dataset.include ?? []).join(',') || '—'})`);
366+
367+
// ── ADR-0037 P3 — draft data preview ────────────────────────────────────
368+
// When the request renders the as-if-published world AND the base object
369+
// has a PENDING seed draft, evaluate the selection over the seed's rows in
370+
// memory (a query-evaluating proxy feeds the unchanged DatasetExecutor, so
371+
// measure filters / compareTo / derived measures all behave identically).
372+
// No pending seed → fall through to the real engine: published objects
373+
// keep charting live data even inside a preview.
374+
if (options?.previewDrafts && this.draftRowsResolver) {
375+
let seedRows: Record<string, unknown>[] | null = null;
376+
try {
377+
seedRows = await this.draftRowsResolver(dataset.object, context);
378+
} catch (e) {
379+
this.logger.warn(`[Analytics] draft preview resolver failed for "${dataset.object}" — falling back to live data: ${String((e as Error)?.message ?? e)}`);
380+
}
381+
if (seedRows) {
382+
this.logger.debug(`[Analytics] queryDataset "${dataset.name}" → preview over ${seedRows.length} drafted seed row(s)`);
383+
const previewService = {
384+
query: async (q: AnalyticsQuery) => evaluateAnalyticsQueryOverRows(q, compiled.cube, seedRows!),
385+
} as IAnalyticsService;
386+
const previewResult = await new DatasetExecutor(previewService).execute(compiled, selection, context);
387+
// Label resolution is skipped on purpose: drafted seed rows reference
388+
// lookups by NAME (the seed convention), which already reads well.
389+
return previewResult;
390+
}
391+
}
392+
347393
const result = await new DatasetExecutor(this).execute(compiled, selection, context);
348394

349395
// ADR-0021 — resolve grouped dimension values to human display labels

packages/services/service-analytics/src/plugin.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,45 @@ export class AnalyticsServicePlugin implements Plugin {
335335
},
336336
};
337337

338+
// ADR-0037 P3 — draft data preview: resolve the PENDING seed draft's rows
339+
// for an object via the kernel protocol (state:'draft' read — a published
340+
// seed's rows are already in the real table and must NOT overlay). Lazy
341+
// service lookup so plugin order doesn't matter; null ⇒ no pending seed ⇒
342+
// queryDataset falls through to live data.
343+
const draftRowsResolver = async (objectName: string): Promise<Record<string, unknown>[] | null> => {
344+
type ProtocolLike = {
345+
getMetaItems?(req: { type: string; previewDrafts?: boolean }): Promise<unknown>;
346+
getMetaItem?(req: { type: string; name: string; state?: string }): Promise<unknown>;
347+
};
348+
let protocol: ProtocolLike | undefined;
349+
try {
350+
protocol = ctx.getService<ProtocolLike>('protocol');
351+
} catch { return null; }
352+
if (!protocol?.getMetaItems || !protocol.getMetaItem) return null;
353+
const res = await protocol.getMetaItems({ type: 'seed', previewDrafts: true }).catch(() => null);
354+
const list = Array.isArray(res)
355+
? res
356+
: (res && typeof res === 'object' && Array.isArray((res as { items?: unknown[] }).items)
357+
? (res as { items: unknown[] }).items
358+
: []);
359+
const rows: Record<string, unknown>[] = [];
360+
let pending = false;
361+
for (const entry of list) {
362+
const body = ((entry as { item?: unknown })?.item ?? entry) as { name?: string; object?: string } | null;
363+
if (!body?.name || body.object !== objectName) continue;
364+
// Only a PENDING draft row qualifies; getMetaItem({state:'draft'})
365+
// throws no_draft when the seed is already published.
366+
const draft = await protocol.getMetaItem({ type: 'seed', name: body.name, state: 'draft' }).catch(() => null);
367+
const draftBody = (draft as { item?: { records?: unknown[] } } | null)?.item;
368+
if (!draftBody) continue;
369+
pending = true;
370+
for (const r of Array.isArray(draftBody.records) ? draftBody.records : []) {
371+
if (r && typeof r === 'object') rows.push(r as Record<string, unknown>);
372+
}
373+
}
374+
return pending ? rows : null;
375+
};
376+
338377
const config: AnalyticsServiceConfig = {
339378
cubes: this.options.cubes,
340379
logger: ctx.logger,
@@ -346,6 +385,7 @@ export class AnalyticsServicePlugin implements Plugin {
346385
getAllowedRelationships: this.options.getAllowedRelationships,
347386
relationshipResolver,
348387
labelResolver,
388+
draftRowsResolver,
349389
};
350390

351391
if (autoBridgedReadScope) {

0 commit comments

Comments
 (0)