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
2 changes: 1 addition & 1 deletion .objectui-sha
Original file line number Diff line number Diff line change
@@ -1 +1 @@
c657e9b180a8c7aa56938d22d7baaee5dc8fc765
5ab1e0e630a5db14defba535cc69f67cb71746c7
6 changes: 6 additions & 0 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,12 @@ export default class Serve extends Command {
*/
static readonly ALWAYS_ON_CAPABILITIES: readonly string[] = Object.freeze([
'queue', 'job', 'cache', 'settings', 'email', 'storage', 'sharing', 'messaging',
// `analytics` is foundational post-ADR-0021: the AnalyticsService backs the
// dataset/cube query endpoints (`/api/v1/analytics/*`). It must exist even
// when an app declares no `analyticsCubes`, because a `dataset` can be
// authored/previewed inline (Studio) and compiled on the fly. Without it the
// dataset preview + dashboard/report analytics widgets silently no-op.
'analytics',
]);

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -922,7 +922,7 @@ export class ObjectQL implements IDataEngine {
// 5. Register all other metadata types generically
const metadataArrayKeys = [
// UI Protocol
'actions', 'views', 'pages', 'dashboards', 'reports', 'themes',
'actions', 'views', 'pages', 'dashboards', 'reports', 'datasets', 'themes',
// Automation Protocol
'flows', 'workflows', 'approvals', 'webhooks',
'jobs',
Expand Down Expand Up @@ -1076,7 +1076,7 @@ export class ObjectQL implements IDataEngine {

// Register metadata arrays (actions, views, triggers, etc.)
const metadataArrayKeys = [
'actions', 'views', 'pages', 'dashboards', 'reports', 'themes',
'actions', 'views', 'pages', 'dashboards', 'reports', 'datasets', 'themes',
'flows', 'workflows', 'approvals', 'webhooks',
'roles', 'permissions', 'profiles', 'sharingRules', 'policies',
'agents', 'ragPipelines', 'apis',
Expand Down
100 changes: 100 additions & 0 deletions packages/rest/src/analytics-routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, vi } from 'vitest';
import { RestServer } from './rest-server';

// ── helpers ──────────────────────────────────────────────────────────────────

function mockServer() {
return {
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(),
use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
};
}
function mockProtocol() {
return { getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', endpoints: {} }), getMetaTypes: vi.fn().mockResolvedValue([]), getMetaItems: vi.fn().mockResolvedValue([]) };
}
function mockRes() {
const res: any = { statusCode: 200, body: undefined };
res.status = vi.fn((c: number) => { res.statusCode = c; return res; });
res.json = vi.fn((b: any) => { res.body = b; return res; });
res.end = vi.fn(() => res);
return res;
}

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'] };

/** Build a RestServer with an optional analytics provider (positional arg #15). */
function buildServer(analyticsProvider?: any) {
const server = mockServer();
const rest = new RestServer(
server as any, mockProtocol() as any, {} as any,
undefined, undefined, undefined, undefined, undefined, undefined, undefined,
undefined, undefined, undefined, undefined,
analyticsProvider,
);
rest.registerRoutes();
const route = rest.getRoutes().find((r) => r.method === 'POST' && r.path.endsWith('/analytics/dataset/query'));
return { route };
}

describe('POST /analytics/dataset/query', () => {
it('registers the route', () => {
const { route } = buildServer(async () => ({ queryDataset: vi.fn() }));
expect(route).toBeTruthy();
expect(route!.metadata?.tags).toContain('analytics');
});

it('runs an inline dataset through the analytics service and returns rows', async () => {
const queryDataset = vi.fn().mockResolvedValue({ rows: [{ region: 'NA', revenue: 100 }], fields: [] });
const { route } = buildServer(async () => ({ queryDataset }));
const res = mockRes();
await route!.handler({ method: 'POST', params: {}, headers: {}, body: { dataset: inlineDataset, selection } } as any, res);

expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ rows: [{ region: 'NA', revenue: 100 }], fields: [] });
// dataset was schema-validated before reaching the service (certified default applied)
const passedDataset = queryDataset.mock.calls[0][0];
expect(passedDataset.measures[0].certified).toBe(false);
expect(queryDataset.mock.calls[0][1]).toEqual(selection);
});

it('returns 501 when no analytics service is configured', async () => {
const { route } = buildServer(undefined);
const res = mockRes();
await route!.handler({ method: 'POST', params: {}, headers: {}, body: { dataset: inlineDataset, selection } } as any, res);
expect(res.statusCode).toBe(501);
expect(res.body.code).toBe('NOT_IMPLEMENTED');
});

it('returns 400 when selection.measures is missing/empty', async () => {
const { route } = buildServer(async () => ({ queryDataset: vi.fn() }));
const res = mockRes();
await route!.handler({ method: 'POST', params: {}, headers: {}, body: { dataset: inlineDataset, selection: { dimensions: ['region'] } } } as any, res);
expect(res.statusCode).toBe(400);
expect(res.body.code).toBe('VALIDATION_FAILED');
});

it('returns 400 for an invalid dataset definition', async () => {
const { route } = buildServer(async () => ({ queryDataset: vi.fn() }));
const res = mockRes();
const bad = { ...inlineDataset, measures: [{ name: 'x', aggregate: 'not_a_real_agg' }] };
await route!.handler({ method: 'POST', params: {}, headers: {}, body: { dataset: bad, selection } } as any, res);
expect(res.statusCode).toBe(400);
expect(res.body.code).toBe('VALIDATION_FAILED');
});

it('maps a dataset D-C compile error to 400 (undeclared relationship)', async () => {
const queryDataset = vi.fn().mockRejectedValue(new Error("dimension \"region\" references relationship \"account\" via \"account.region\", but \"account\" is not declared in the dataset's `include`."));
const { route } = buildServer(async () => ({ queryDataset }));
const res = mockRes();
await route!.handler({ method: 'POST', params: {}, headers: {}, body: { dataset: inlineDataset, selection } } as any, res);
expect(res.statusCode).toBe(400);
expect(res.body.code).toBe('DATASET_INVALID');
});
});
11 changes: 10 additions & 1 deletion packages/rest/src/rest-api-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,15 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin {
} catch { return undefined; }
};

// Analytics service resolver — used by /analytics/dataset/query
// (ADR-0021 dataset preview/query). Returns undefined when no
// analytics service is registered so the route fails cleanly (501).
const analyticsServiceProvider = async (_environmentId?: string): Promise<any | undefined> => {
try {
return ctx.getService<any>('analytics');
} catch { return undefined; }
};

if (!server) {
ctx.logger.warn(`RestApiPlugin: HTTP Server service '${serverService}' not found. REST routes skipped.`);
return;
Expand All @@ -167,7 +176,7 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin {
ctx.logger.info('Hydrating REST API from Protocol...');

try {
const restServer = new RestServer(server, protocol, config.api as any, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider);
const restServer = new RestServer(server, protocol, config.api as any, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider);
restServer.registerRoutes();

ctx.logger.info('REST API successfully registered');
Expand Down
97 changes: 97 additions & 0 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,7 @@ export class RestServer {
private approvalsServiceProvider?: (environmentId?: string) => Promise<any | undefined>;
private sharingRulesServiceProvider?: (environmentId?: string) => Promise<any | undefined>;
private i18nServiceProvider?: (environmentId?: string) => Promise<any | undefined>;
private analyticsServiceProvider?: (environmentId?: string) => Promise<any | undefined>;

constructor(
server: IHttpServer,
Expand All @@ -524,6 +525,7 @@ export class RestServer {
approvalsServiceProvider?: (environmentId?: string) => Promise<any | undefined>,
sharingRulesServiceProvider?: (environmentId?: string) => Promise<any | undefined>,
i18nServiceProvider?: (environmentId?: string) => Promise<any | undefined>,
analyticsServiceProvider?: (environmentId?: string) => Promise<any | undefined>,
) {
this.protocol = protocol;
this.config = this.normalizeConfig(config);
Expand All @@ -539,6 +541,7 @@ export class RestServer {
this.approvalsServiceProvider = approvalsServiceProvider;
this.sharingRulesServiceProvider = sharingRulesServiceProvider;
this.i18nServiceProvider = i18nServiceProvider;
this.analyticsServiceProvider = analyticsServiceProvider;
}

/**
Expand Down Expand Up @@ -1268,6 +1271,7 @@ export class RestServer {
this.registerSharingRuleEndpoints(bp);
this.registerReportsEndpoints(bp);
this.registerApprovalsEndpoints(bp);
this.registerAnalyticsEndpoints(bp);
if (this.config.api.enableCrud) {
this.registerCrudEndpoints(bp);
}
Expand Down Expand Up @@ -3531,6 +3535,99 @@ export class RestServer {
* when no sharing service is configured so a deployment without the
* `@objectstack/plugin-sharing` plugin fails cleanly.
*/
/**
* ADR-0021 — analytics dataset preview/query endpoint.
*
* POST {basePath}/analytics/dataset/query
* body: { dataset?: <inline Dataset>, datasetName?: string, selection: DatasetSelection }
*
* Compiles the dataset (an inline draft for Studio preview, or a saved one
* by name) and runs the selection through the analytics service's
* `queryDataset`, threading the request ExecutionContext so tenant/RLS
* scoping (ADR-0021 D-C) applies. Returns 501 when no analytics service
* (or one without `queryDataset`) is configured, so a deployment without
* `@objectstack/service-analytics` fails cleanly.
*/
private registerAnalyticsEndpoints(basePath: string): void {
const isScoped = basePath.includes('/environments/:environmentId');
const resolveService = async (environmentId?: string) => {
if (!this.analyticsServiceProvider) return undefined;
try { return await this.analyticsServiceProvider(environmentId); }
catch { return undefined; }
};

this.routeManager.register({
method: 'POST',
path: `${basePath}/analytics/dataset/query`,
handler: async (req: any, res: any) => {
try {
const environmentId = isScoped ? req.params?.environmentId : undefined;
const context = await this.resolveExecCtx(environmentId, req);
if (this.enforceAuth(req, res, context)) return;

const svc = await resolveService(environmentId);
if (!svc || typeof svc.queryDataset !== 'function') {
return res.status(501).json({
code: 'NOT_IMPLEMENTED',
message: 'Analytics dataset query is not available on this deployment (no analytics service with queryDataset).',
});
}

const body = req.body ?? {};
const selection = body.selection;
if (!selection || !Array.isArray(selection.measures) || selection.measures.length === 0) {
return res.status(400).json({
code: 'VALIDATION_FAILED',
message: 'body.selection.measures must be a non-empty array of measure names.',
});
}

// 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 list = Array.isArray(items?.items) ? items.items : (Array.isArray(items) ? items : []);
dataset = list.find((d: any) => d?.name === body.datasetName);
if (!dataset) {
return res.status(404).json({ code: 'NOT_FOUND', message: `Dataset "${body.datasetName}" not found.` });
}
}
if (!dataset) {
return res.status(400).json({ code: 'VALIDATION_FAILED', message: 'Provide body.dataset (inline) or body.datasetName.' });
}

// Validate against the spec schema so a malformed draft
// yields a clean 400 instead of a runtime throw.
try {
const { DatasetSchema } = await import('@objectstack/spec/ui');
dataset = (DatasetSchema as any).parse(dataset);
} catch (verr: any) {
return res.status(400).json({
code: 'VALIDATION_FAILED',
message: 'Invalid dataset definition.',
detail: String(verr?.message ?? verr).slice(0, 1000),
});
}

const result = await svc.queryDataset(dataset, selection, context ?? undefined);
res.json(result);
} catch (error: any) {
const msg = String(error?.message ?? error ?? '');
// Dataset-compiler D-C / unsupported-aggregate / read-scope
// errors are client-side mistakes — surface as 400.
if (/not declared in the dataset|not backed by a declared relationship|not supported by the v1 dataset runtime|read-scope-sql/.test(msg)) {
return res.status(400).json({ code: 'DATASET_INVALID', message: msg.slice(0, 1000) });
}
logError('[REST] Analytics dataset query error:', error);
res.status(500).json({ code: 'ANALYTICS_QUERY_FAILED', error: msg.slice(0, 500) });
}
},
metadata: { summary: 'Run a semantic-layer dataset (preview/query)', tags: ['analytics'] },
});
}

private registerSharingEndpoints(basePath: string): void {
const { crud } = this.config;
const dataPath = `${basePath}${crud.dataPrefix}`;
Expand Down
Loading