Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 ?? '');
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>[] | 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);
});
});
46 changes: 46 additions & 0 deletions packages/services/service-analytics/src/analytics-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<Record<string, unknown>[] | null>;
}

/**
Expand Down Expand Up @@ -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;

Expand All @@ -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) {
Expand Down Expand Up @@ -341,9 +359,37 @@ export class AnalyticsService implements IAnalyticsService {
dataset: Dataset,
selection: DatasetSelection,
context?: ExecutionContext,
options?: { previewDrafts?: boolean },
): Promise<AnalyticsResult> {
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<string, unknown>[] | 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
Expand Down
40 changes: 40 additions & 0 deletions packages/services/service-analytics/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>[] | null> => {
type ProtocolLike = {
getMetaItems?(req: { type: string; previewDrafts?: boolean }): Promise<unknown>;
getMetaItem?(req: { type: string; name: string; state?: string }): Promise<unknown>;
};
let protocol: ProtocolLike | undefined;
try {
protocol = ctx.getService<ProtocolLike>('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<string, unknown>[] = [];
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<string, unknown>);
}
}
return pending ? rows : null;
};

const config: AnalyticsServiceConfig = {
cubes: this.options.cubes,
logger: ctx.logger,
Expand All @@ -346,6 +385,7 @@ export class AnalyticsServicePlugin implements Plugin {
getAllowedRelationships: this.options.getAllowedRelationships,
relationshipResolver,
labelResolver,
draftRowsResolver,
};

if (autoBridgedReadScope) {
Expand Down
Loading
Loading