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.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) => ( + + ))} + + + + {state.rows.map((row, i) => ( + + {columns.map((c) => ( + + ))} + + ))} + +
{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/); + }); +});