diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.test.tsx
new file mode 100644
index 000000000..d7dfb6756
--- /dev/null
+++ b/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.test.tsx
@@ -0,0 +1,69 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, screen, fireEvent, cleanup } from '@testing-library/react';
+import { DatasetDefaultInspector } from './DatasetDefaultInspector';
+
+afterEach(cleanup);
+
+const baseProps = { type: 'dataset', name: 'sales', locale: 'en-US' as const };
+
+const draft = {
+ name: 'sales',
+ label: 'Sales',
+ object: 'opportunity',
+ include: ['account'],
+ dimensions: [{ name: 'region', field: 'account.region', type: 'string' }],
+ measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount', certified: true }],
+};
+
+describe('DatasetDefaultInspector', () => {
+ it('renders the structured designer (object / dimension / measure rows)', () => {
+ render();
+ expect(screen.getByDisplayValue('opportunity')).toBeInTheDocument();
+ expect(screen.getByDisplayValue('account')).toBeInTheDocument(); // include relationship row
+ expect(screen.getByText('Dimension 1')).toBeInTheDocument();
+ expect(screen.getByText('Measure 1')).toBeInTheDocument();
+ expect(screen.getByDisplayValue('region')).toBeInTheDocument();
+ expect(screen.getByDisplayValue('revenue')).toBeInTheDocument();
+ });
+
+ it('adds a measure via onPatch', () => {
+ const onPatch = vi.fn();
+ render();
+ fireEvent.click(screen.getByText('Add measure'));
+ expect(onPatch).toHaveBeenCalledTimes(1);
+ const patch = onPatch.mock.calls[0][0];
+ expect(patch.measures).toHaveLength(2);
+ expect(patch.measures[1]).toMatchObject({ aggregate: 'sum', certified: false });
+ });
+
+ it('adds a dimension via onPatch', () => {
+ const onPatch = vi.fn();
+ render();
+ fireEvent.click(screen.getByText('Add dimension'));
+ expect(onPatch.mock.calls[0][0].dimensions).toHaveLength(2);
+ });
+
+ it('edits a measure name through onPatch', () => {
+ const onPatch = vi.fn();
+ render();
+ fireEvent.change(screen.getByDisplayValue('revenue'), { target: { value: 'total_revenue' } });
+ expect(onPatch).toHaveBeenCalledWith({ measures: [expect.objectContaining({ name: 'total_revenue' })] });
+ });
+
+ it('removes the measure row', () => {
+ const onPatch = vi.fn();
+ render();
+ // The measure block's remove button is the second "Remove" (dimension is first).
+ const removes = screen.getAllByText('Remove');
+ fireEvent.click(removes[removes.length - 1]);
+ expect(onPatch).toHaveBeenCalledWith({ measures: [] });
+ });
+
+ it('hides add/remove affordances when readOnly', () => {
+ render();
+ expect(screen.queryByText('Add measure')).not.toBeInTheDocument();
+ expect(screen.queryByText('Remove')).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.tsx
new file mode 100644
index 000000000..8cb626393
--- /dev/null
+++ b/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.tsx
@@ -0,0 +1,166 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * DatasetDefaultInspector — the curated designer for an analytics `dataset`
+ * (ADR-0021). Replaces the generic whole-draft JSON SchemaForm with structured,
+ * fool-proof editors for the dataset's parts:
+ *
+ * - base `object`,
+ * - `include` relationships (the join allowlist — D-C),
+ * - `dimensions` (name + field/`relationship.field` + type), and
+ * - `measures` (name + aggregate + field + certified).
+ *
+ * The aggregate is a closed dropdown (count/sum/avg/min/max/count_distinct) so
+ * an author can't type an unsupported function — the dataset compiler rejects
+ * `array_agg`/`string_agg` in v1, and surfacing only the valid set avoids that
+ * round-trip. Edits flow through `onPatch`; the DatasetPreview on the canvas
+ * re-runs live as the draft changes.
+ */
+
+import * as React from 'react';
+import { Plus, X } from 'lucide-react';
+import { Badge, Button, Input, Label } from '@object-ui/components';
+import {
+ InspectorShell,
+ InspectorTextField,
+ InspectorSelectField,
+ InspectorCheckboxField,
+ InspectorRemoveButton,
+ appendArray,
+ spliceArray,
+} from './_shared';
+import type { MetadataDefaultInspectorProps } from '../default-inspector-registry';
+
+// Closed to what the dataset compiler supports (no array_agg/string_agg in v1).
+const AGGREGATE_OPTIONS = [
+ { value: 'count', label: 'count' },
+ { value: 'sum', label: 'sum' },
+ { value: 'avg', label: 'avg' },
+ { value: 'min', label: 'min' },
+ { value: 'max', label: 'max' },
+ { value: 'count_distinct', label: 'count distinct' },
+];
+
+const DIMENSION_TYPE_OPTIONS = [
+ { value: 'string', label: 'string' },
+ { value: 'number', label: 'number' },
+ { value: 'date', label: 'date' },
+ { value: 'boolean', label: 'boolean' },
+ { value: 'lookup', label: 'lookup' },
+];
+
+type Dimension = { name?: string; field?: string; type?: string };
+type Measure = { name?: string; aggregate?: string; field?: string; certified?: boolean };
+
+function SectionHeader({ title, count, onAdd, addLabel }: { title: string; count: number; onAdd?: () => void; addLabel: string }) {
+ return (
+
+
+
+ {count}
+
+ {onAdd && (
+
+ )}
+
+ );
+}
+
+export function DatasetDefaultInspector({ draft, onPatch, readOnly }: MetadataDefaultInspectorProps) {
+ const label = typeof draft.label === 'string' ? draft.label : '';
+ const description = typeof draft.description === 'string' ? draft.description : '';
+ const object = typeof draft.object === 'string' ? draft.object : '';
+ const include: string[] = Array.isArray(draft.include) ? (draft.include as string[]) : [];
+ const dimensions: Dimension[] = Array.isArray(draft.dimensions) ? (draft.dimensions as Dimension[]) : [];
+ const measures: Measure[] = Array.isArray(draft.measures) ? (draft.measures as Measure[]) : [];
+
+ const patchDimension = (i: number, patch: Partial) =>
+ onPatch({ dimensions: dimensions.map((d, idx) => (idx === i ? { ...d, ...patch } : d)) });
+ const patchMeasure = (i: number, patch: Partial) =>
+ onPatch({ measures: measures.map((m, idx) => (idx === i ? { ...m, ...patch } : m)) });
+
+ return (
+ {}} hideClose>
+ onPatch({ label: v })} disabled={readOnly} />
+ onPatch({ description: v })} disabled={readOnly} />
+ onPatch({ object: v })} placeholder="e.g. opportunity" disabled={readOnly} mono />
+
+ {/* Included relationships (the join allowlist) */}
+
+
onPatch({ include: appendArray(include, '') })}
+ />
+ {include.length === 0 ? (
+
+ No joins. Add a relationship name (e.g. account) to use account.field dimensions/measures.
+
+ ) : (
+ include.map((rel, i) => (
+
+ onPatch({ include: include.map((r, idx) => (idx === i ? e.target.value : r)) })}
+ placeholder="relationship name (lookup field)"
+ disabled={readOnly}
+ className="h-8 text-sm font-mono"
+ />
+ {!readOnly && (
+
+ )}
+
+ ))
+ )}
+
+
+ {/* Dimensions */}
+
+
onPatch({ dimensions: appendArray(dimensions, { name: '', field: '', type: 'string' }) })}
+ />
+ {dimensions.map((d, i) => (
+
+
+ Dimension {i + 1}
+ {!readOnly && onPatch({ dimensions: spliceArray(dimensions, i, null) })} />}
+
+
patchDimension(i, { name: v })} placeholder="e.g. region" disabled={readOnly} mono />
+ patchDimension(i, { field: v })} placeholder="field or relationship.field" disabled={readOnly} mono />
+ patchDimension(i, { type: v })} disabled={readOnly} />
+
+ ))}
+
+
+ {/* Measures */}
+
+
onPatch({ measures: appendArray(measures, { name: '', aggregate: 'sum', field: '', certified: false }) })}
+ />
+ {measures.map((m, i) => (
+
+
+ Measure {i + 1}
+ {!readOnly && onPatch({ measures: spliceArray(measures, i, null) })} />}
+
+
patchMeasure(i, { name: v })} placeholder="e.g. revenue" disabled={readOnly} mono />
+ patchMeasure(i, { aggregate: v })} disabled={readOnly} />
+ patchMeasure(i, { field: v })} placeholder="field (optional for count)" disabled={readOnly} mono />
+ patchMeasure(i, { certified: v })} disabled={readOnly} />
+
+ ))}
+
+
+ );
+}
diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/index.ts b/packages/app-shell/src/views/metadata-admin/inspectors/index.ts
index 3f0b481f6..2c06e805a 100644
--- a/packages/app-shell/src/views/metadata-admin/inspectors/index.ts
+++ b/packages/app-shell/src/views/metadata-admin/inspectors/index.ts
@@ -17,6 +17,7 @@ import { ReportColumnInspector } from './ReportColumnInspector';
import { ReportDefaultInspector } from './ReportDefaultInspector';
import { ObjectFieldInspector } from './ObjectFieldInspector';
import { ObjectDefaultInspector } from './ObjectDefaultInspector';
+import { DatasetDefaultInspector } from './DatasetDefaultInspector';
export function registerBuiltinInspectors(): void {
registerMetadataInspector('dashboard', DashboardWidgetInspector);
@@ -36,4 +37,6 @@ export function registerBuiltinInspectors(): void {
registerMetadataDefaultInspector('report', ReportDefaultInspector);
registerMetadataInspector('object', ObjectFieldInspector);
registerMetadataDefaultInspector('object', ObjectDefaultInspector);
+ // ADR-0021: structured dataset designer (object/include/dimensions/measures).
+ registerMetadataDefaultInspector('dataset', DatasetDefaultInspector);
}
diff --git a/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.test.tsx
new file mode 100644
index 000000000..b9ff1f393
--- /dev/null
+++ b/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.test.tsx
@@ -0,0 +1,60 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, screen, cleanup, waitFor } from '@testing-library/react';
+import { DatasetPreview } from './DatasetPreview';
+
+// Mock the data adapter the preview pulls from AdapterProvider.
+const { queryDataset } = vi.hoisted(() => ({ queryDataset: vi.fn() }));
+vi.mock('../../../providers/AdapterProvider', () => ({
+ useAdapter: () => ({ queryDataset }),
+}));
+
+afterEach(() => {
+ cleanup();
+ queryDataset.mockReset();
+});
+
+const baseProps = { type: 'dataset', name: 'sales', locale: 'en-US' as const };
+
+const draft = {
+ name: 'sales',
+ label: 'Sales',
+ object: 'opportunity',
+ include: ['account'],
+ dimensions: [{ name: 'region', field: 'account.region' }],
+ measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }],
+};
+
+describe('DatasetPreview', () => {
+ it('auto-runs the draft and renders the result table', async () => {
+ queryDataset.mockResolvedValue({ rows: [{ region: 'NA', revenue: 100 }, { region: 'EU', revenue: 50 }], fields: [] });
+ render();
+
+ // Posted the inline draft + derived selection.
+ await waitFor(() => expect(queryDataset).toHaveBeenCalledWith(draft, { dimensions: ['region'], measures: ['revenue'] }));
+ // Rows render.
+ expect(await screen.findByText('NA')).toBeInTheDocument();
+ expect(screen.getByText('100')).toBeInTheDocument();
+ expect(screen.getByText('EU')).toBeInTheDocument();
+ });
+
+ it('surfaces a server/compile error as an alert (no silent fallback)', async () => {
+ queryDataset.mockRejectedValue(new Error('relationship "account" is not declared in the dataset\'s `include`'));
+ render();
+ const alert = await screen.findByRole('alert');
+ expect(alert.textContent).toMatch(/not declared/);
+ });
+
+ it('prompts to add a measure when none are defined', () => {
+ render();
+ expect(screen.getByText(/Add a measure/i)).toBeInTheDocument();
+ expect(queryDataset).not.toHaveBeenCalled();
+ });
+
+ it('prompts to pick a base object when object is missing', () => {
+ render();
+ expect(screen.getByText(/Pick a base object/i)).toBeInTheDocument();
+ expect(queryDataset).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.tsx
new file mode 100644
index 000000000..5499416f1
--- /dev/null
+++ b/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.tsx
@@ -0,0 +1,189 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * DatasetPreview — runs the live `dataset` draft (ADR-0021) against the server
+ * and shows the resulting rows, so authors can see their semantic layer work
+ * before saving.
+ *
+ * It posts the (possibly unsaved) draft inline to
+ * `POST /api/v1/analytics/dataset/query` via the AdapterProvider data source
+ * (`adapter.queryDataset`). The server compiles the dataset → Cube, applies the
+ * tenant/RLS read scope (ADR-0021 D-C), and returns chart-ready rows.
+ *
+ * Unlike the legacy single-object aggregation, dataset queries are cross-object
+ * and only run server-side — so a failure is surfaced as an error banner (the
+ * compile error, e.g. "relationship not declared in include") rather than
+ * silently falling back to wrong numbers.
+ */
+
+import * as React from 'react';
+import { Loader2, BarChart3, AlertTriangle } from 'lucide-react';
+import { useAdapter } from '../../../providers/AdapterProvider';
+import type { MetadataPreviewProps } from '../preview-registry';
+import { PreviewShell, PreviewEmptyState, PreviewErrorBoundary } from './PreviewShell';
+
+// Lazy-loaded so the (recharts-backed) chart bundle only loads when a dataset
+// preview actually renders a chart — keeps the metadata-admin bundle small.
+const ChartRenderer = React.lazy(() =>
+ import('@object-ui/plugin-charts').then((m) => ({ default: m.ChartRenderer })),
+);
+
+type Row = Record;
+type PreviewState =
+ | { status: 'idle' | 'loading'; rows: Row[]; error?: undefined }
+ | { status: 'ok'; rows: Row[]; error?: undefined }
+ | { status: 'error'; rows: Row[]; error: string };
+
+function formatCell(v: unknown): string {
+ if (v == null) return '—';
+ if (typeof v === 'number') {
+ return Number.isInteger(v) ? String(v) : v.toLocaleString(undefined, { maximumFractionDigits: 2 });
+ }
+ if (typeof v === 'boolean') return v ? 'true' : 'false';
+ return String(v);
+}
+
+export function DatasetPreview({ draft }: MetadataPreviewProps) {
+ const adapter = useAdapter();
+
+ const objectName = (draft as Record).object as string | undefined;
+
+ const measureNames = React.useMemo(() => {
+ const m = Array.isArray((draft as any).measures) ? ((draft as any).measures as Array>) : [];
+ return m.map((x) => String(x?.name ?? '')).filter(Boolean);
+ }, [draft]);
+
+ const dimensionNames = React.useMemo(() => {
+ const d = Array.isArray((draft as any).dimensions) ? ((draft as any).dimensions as Array>) : [];
+ return d.map((x) => String(x?.name ?? '')).filter(Boolean);
+ }, [draft]);
+
+ const canRun = !!objectName && measureNames.length > 0;
+
+ const [state, setState] = React.useState({ status: 'idle', rows: [] });
+
+ const run = React.useCallback(async () => {
+ if (!canRun) return;
+ setState({ status: 'loading', rows: [] });
+ try {
+ const result = await (adapter as unknown as {
+ queryDataset: (d: unknown, s: unknown) => Promise<{ rows: Row[] }>;
+ }).queryDataset(draft, { dimensions: dimensionNames, measures: measureNames });
+ setState({ status: 'ok', rows: Array.isArray(result?.rows) ? result.rows : [] });
+ } catch (e) {
+ setState({ status: 'error', rows: [], error: String((e as Error)?.message ?? e) });
+ }
+ }, [adapter, draft, dimensionNames, measureNames, canRun]);
+
+ // Auto-run a live preview whenever the meaningful selection signature changes.
+ const signature = `${objectName ?? ''}|${dimensionNames.join(',')}|${measureNames.join(',')}`;
+ React.useEffect(() => {
+ if (canRun) void run();
+ // Intentionally keyed on `signature` only — re-run when the dataset's
+ // object/dimensions/measures change, not on every unrelated draft edit.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [signature]);
+
+ if (!objectName) {
+ return (
+
+ }
+ title="Pick a base object"
+ description="Set the dataset's `object` to preview it against live data."
+ />
+
+ );
+ }
+
+ if (measureNames.length === 0) {
+ return (
+
+ }
+ title="Add a measure"
+ description="A dataset needs at least one measure (e.g. revenue = sum(amount)) to preview."
+ />
+
+ );
+ }
+
+ const columns = [...dimensionNames, ...measureNames];
+
+ return (
+
+
+
+
+
+ {measureNames.length} measure{measureNames.length === 1 ? '' : 's'} · {dimensionNames.length} dimension{dimensionNames.length === 1 ? '' : 's'}
+
+
+
+ {state.status === 'error' && (
+
+ )}
+
+ {state.status === 'ok' && state.rows.length === 0 && (
+
}
+ title="No rows"
+ description="The dataset returned no rows for the current scope."
+ />
+ )}
+
+ {state.rows.length > 0 && dimensionNames.length >= 1 && (
+
+ }>
+
+ >,
+ xAxisKey: dimensionNames[0],
+ series: measureNames.map((m) => ({ dataKey: m, label: m, chartType: 'bar' as const })),
+ } as any}
+ />
+
+
+
+ )}
+
+ {state.rows.length > 0 && (
+
+
+
+
+ {columns.map((c) => (
+ | {c} |
+ ))}
+
+
+
+ {state.rows.map((row, i) => (
+
+ {columns.map((c) => (
+ | {formatCell(row[c])} |
+ ))}
+
+ ))}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/packages/app-shell/src/views/metadata-admin/previews/index.ts b/packages/app-shell/src/views/metadata-admin/previews/index.ts
index 7e55d7308..3a9df2aac 100644
--- a/packages/app-shell/src/views/metadata-admin/previews/index.ts
+++ b/packages/app-shell/src/views/metadata-admin/previews/index.ts
@@ -28,6 +28,7 @@ import { RolePreview } from './RolePreview';
import { SkillPreview } from './SkillPreview';
import { DatasourcePreview } from './DatasourcePreview';
import { ValidationPreview } from './ValidationPreview';
+import { DatasetPreview } from './DatasetPreview';
export function registerBuiltinPreviews(): void {
// UI surfaces
@@ -41,6 +42,8 @@ export function registerBuiltinPreviews(): void {
registerMetadataPreview('object', ObjectPreview);
registerMetadataPreview('datasource', DatasourcePreview);
registerMetadataPreview('validation', ValidationPreview);
+ // Analytics (ADR-0021): live cross-object dataset preview.
+ registerMetadataPreview('dataset', DatasetPreview);
// System
registerMetadataPreview('email_template', EmailTemplatePreview);
registerMetadataPreview('translation', TranslationPreview);
diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts
index 8a0175d92..209432a73 100644
--- a/packages/data-objectstack/src/index.ts
+++ b/packages/data-objectstack/src/index.ts
@@ -1793,6 +1793,74 @@ export class ObjectStackAdapter implements DataSource {
}
}
+ /**
+ * Run a semantic-layer `dataset` (ADR-0021) and return chart-ready rows.
+ *
+ * Posts to `POST /api/v1/analytics/dataset/query` (see `@objectstack/rest`
+ * `registerAnalyticsEndpoints`). Accepts either a saved dataset name or an
+ * inline draft definition — the inline form is what the Studio dataset
+ * editor sends to preview an unsaved draft. The adapter's bearer token is
+ * forwarded so tenant/RLS scoping (ADR-0021 D-C) is enforced server-side.
+ *
+ * Unlike {@link aggregate}, this does NOT fall back to client-side
+ * aggregation: cross-object joins can only run on the server, so a failure
+ * is surfaced to the caller (the preview panel shows the error) rather than
+ * silently returning wrong numbers.
+ *
+ * @param dataset - An inline dataset definition (draft) OR a saved dataset name.
+ * @param selection - Dimension/measure names to project + runtime directives.
+ */
+ async queryDataset(
+ dataset: Record | string,
+ selection: {
+ dimensions?: string[];
+ measures: string[];
+ runtimeFilter?: Record;
+ timeDimensions?: unknown[];
+ compareTo?: { kind: 'previousPeriod' | 'previousYear'; dimension: string };
+ order?: Record;
+ limit?: number;
+ offset?: number;
+ timezone?: string;
+ },
+ ): Promise<{ rows: Array>; fields: Array<{ name: string; type: string }> }> {
+ await this.connect();
+ const base = (this.baseUrl || '').replace(/\/$/, '');
+ const url = `${base}/api/v1/analytics/dataset/query`;
+ const requestBody = typeof dataset === 'string'
+ ? { datasetName: dataset, selection }
+ : { dataset, selection };
+
+ const res = await this.fetchImpl(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
+ },
+ body: JSON.stringify(requestBody),
+ });
+
+ if (!res.ok) {
+ let detail = '';
+ try {
+ const errBody = await res.json();
+ detail = errBody?.message || errBody?.error || JSON.stringify(errBody);
+ } catch { /* non-JSON error body */ }
+ throw new Error(`Dataset query failed: ${res.status} ${res.statusText}${detail ? ` — ${detail}` : ''}`);
+ }
+
+ const payload = await res.json();
+ // Unwrap the standard `{ success, data }` envelope when present.
+ const data = payload && typeof payload === 'object' && 'success' in payload && 'data' in payload
+ ? (payload as { data: unknown }).data
+ : payload;
+ const rows = Array.isArray((data as any)?.rows)
+ ? (data as any).rows
+ : (Array.isArray(data) ? (data as any) : []);
+ const fields = Array.isArray((data as any)?.fields) ? (data as any).fields : [];
+ return { rows, fields };
+ }
+
/** Client-side aggregation fallback */
private aggregateClientSide(records: any[], params: { field: string; function: string; groupBy: string }): any[] {
const { field, function: aggFn, groupBy } = params;
diff --git a/packages/data-objectstack/src/queryDataset.test.ts b/packages/data-objectstack/src/queryDataset.test.ts
new file mode 100644
index 000000000..ad07bada2
--- /dev/null
+++ b/packages/data-objectstack/src/queryDataset.test.ts
@@ -0,0 +1,83 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { ObjectStackAdapter, clearSharedDiscoveryCache } from './index';
+
+/** A fetch mock that answers discovery + records the dataset-query POST. */
+function makeFetch(datasetResponse: { ok: boolean; status?: number; body: unknown }) {
+ const calls: Array<{ url: string; init?: any }> = [];
+ const fetchImpl = vi.fn(async (url: any, init?: any) => {
+ const u = String(url);
+ calls.push({ url: u, init });
+ if (u.includes('/api/v1/discovery')) {
+ return { ok: true, status: 200, statusText: 'OK', json: async () => ({ success: true, data: { version: 'v1', routes: {} } }) } as any;
+ }
+ if (u.includes('/api/v1/analytics/dataset/query')) {
+ return {
+ ok: datasetResponse.ok,
+ status: datasetResponse.status ?? (datasetResponse.ok ? 200 : 400),
+ statusText: datasetResponse.ok ? 'OK' : 'Bad Request',
+ json: async () => datasetResponse.body,
+ } as any;
+ }
+ return { ok: true, status: 200, statusText: 'OK', json: async () => ({}) } as any;
+ });
+ return { fetchImpl, calls };
+}
+
+const inlineDataset = {
+ name: 'sales', label: 'Sales', object: 'opportunity', include: ['account'],
+ dimensions: [{ name: 'region', field: 'account.region', type: 'string' }],
+ measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }],
+};
+const selection = { dimensions: ['region'], measures: ['revenue'] };
+
+describe('ObjectStackAdapter.queryDataset', () => {
+ beforeEach(() => clearSharedDiscoveryCache());
+
+ it('POSTs the inline dataset + selection and returns rows/fields', async () => {
+ const { fetchImpl, calls } = makeFetch({
+ ok: true,
+ body: { rows: [{ region: 'NA', revenue: 100 }], fields: [{ name: 'revenue', type: 'number' }] },
+ });
+ const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', token: 'tok_123', autoReconnect: false, fetch: fetchImpl as any });
+
+ const result = await adapter.queryDataset(inlineDataset as any, selection);
+
+ expect(result.rows).toEqual([{ region: 'NA', revenue: 100 }]);
+ expect(result.fields).toEqual([{ name: 'revenue', type: 'number' }]);
+
+ const post = calls.find((c) => c.url.includes('/analytics/dataset/query'))!;
+ expect(post.url).toBe('http://localhost:3000/api/v1/analytics/dataset/query');
+ expect(post.init.method).toBe('POST');
+ expect(post.init.headers.Authorization).toBe('Bearer tok_123');
+ expect(JSON.parse(post.init.body)).toEqual({ dataset: inlineDataset, selection });
+ });
+
+ it('sends datasetName when given a string', async () => {
+ const { fetchImpl, calls } = makeFetch({ ok: true, body: { rows: [], fields: [] } });
+ const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
+ await adapter.queryDataset('sales', selection);
+ const post = calls.find((c) => c.url.includes('/analytics/dataset/query'))!;
+ expect(JSON.parse(post.init.body)).toEqual({ datasetName: 'sales', selection });
+ });
+
+ it('unwraps a { success, data } envelope', async () => {
+ const { fetchImpl } = makeFetch({ ok: true, body: { success: true, data: { rows: [{ x: 1 }], fields: [] } } });
+ const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
+ const result = await adapter.queryDataset(inlineDataset as any, selection);
+ expect(result.rows).toEqual([{ x: 1 }]);
+ });
+
+ it('throws (no silent fallback) with the server message on a 4xx', async () => {
+ const { fetchImpl } = makeFetch({ ok: false, status: 400, body: { code: 'DATASET_INVALID', message: 'relationship not declared' } });
+ const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
+ await expect(adapter.queryDataset(inlineDataset as any, selection)).rejects.toThrow(/relationship not declared/);
+ });
+});