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
27 changes: 27 additions & 0 deletions .changeset/21044-cube-measure-field-type-table.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"@objectstack/service-analytics": minor
---

fix(service-analytics)!: the cube door asks the aggregate × field-type table for every measure, so a configured or suffix-inferred cube measure whose aggregate the table refuses for its column's declared type answers `INVALID_FIELD` / 400 on every driver and both strategies, and a `min` / `max` over a temporal column is described `time` in `fields[]`

Clause-②: no (narrowing)

<!-- adr-0087: not-required (already-registered dataset-measure-selecting-aggregate-field-type-refused, dataset-measure-aggregate-field-type-refused) the pairs this change refuses are exactly the pairs AGGREGATE_FIELD_TYPE_COMPATIBILITY already refuses, and the table is not edited: every refused min / max pair is registered under protocol major 18 by the first id and every refused sum / avg pair by the second, each with its routes (count, a sort for a first or last record, or a numeric / temporal field for a quantity stored as text). This change adds a query-time reader of the same table at the analytics cube door; it refuses a query shape, not a stored one, and no authorable key, export or stored row moves: CubeSchema and the analytics query body keep parsing every member. -->

**BREAKING**: this narrows what `POST /api/v1/analytics/query` and its dry run `POST /api/v1/analytics/sql` accept, on every driver and on both strategies. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.

FROM → TO, for a `measures` entry that resolves to a cube measure over a column of the cube's own object (an authored cube measure, or a suffix-inferred one such as `note_max`):

- `min` / `max` over a type outside the numeric, temporal and boolean classes (the string family such as `text`, `email` and `url`; `select`, `radio`, `lookup`, `user`; `autonumber`; the JSON-stored, file and `formula` types): FROM, on the native-SQL strategy, `200` with the column's own value (a string such as `"y"`) under `fields[] { type: 'number' }`, on SQLite and PostgreSQL alike; on the ObjectQL strategy the engine's door already answered `400 INVALID_FIELD` after the strategy began. TO `400 INVALID_FIELD` before either strategy reads anything.
- `sum` / `avg` over a type outside the numeric and boolean classes (`sum` also refuses `percent`): FROM `200` with a plausible `0` on SQLite and `500 DATABASE_ERROR` on PostgreSQL (the ObjectQL strategy refused `avg` at the engine and passed `sum` to the driver, which answered the same `0` / `500`). TO `400 INVALID_FIELD`.
- `min` / `max` over a `date`, `datetime` or `time` column: FROM `fields[] { type: 'number' }` beside the instant. TO `fields[] { type: 'time' }`, the `DimensionType` word a temporal dimension column already carries, by the same rule the dataset door applies (`measureResultType`).

**What an author sees now.** `400 INVALID_FIELD`, naming the measure as the request wrote it, the cube, the column, the object and its declared type, saying the query was not run, and naming the types the aggregate accepts, read off `AGGREGATE_FIELD_TYPE_COMPATIBILITY`. The thrown error carries `member`, `param` (`measures`), `cube`, `field` and `object`.

**Why a refusal.** The dataset door (`POST /api/v1/analytics/dataset/query`) refuses every one of these pairs at compile by the same table (`DATASET_INVALID`), and the engine's aggregate door refuses most of them on the ObjectQL strategy; the native-SQL strategy compiled its own statement and asked nothing. Measured through the real dispatcher route on SQLite and PostgreSQL 16: a configured cube's `max` over a `text` column answered `"y"` under a column described `number` on the native strategy and `400` on the ObjectQL strategy, and `sum` over the same column answered `0` on SQLite and `500` on PostgreSQL. One cube, one query, an answer chosen by the driver.

**What to write instead.** Aggregate a field of a type the aggregate accepts. A question that was counting in disguise is `count` (or `count_distinct` over a scalar-stored field). A first or last record by a text value is a sort on a list, not an aggregate. A quantity stored as text belongs in a numeric field of its own, aggregated there.

**Who is affected.** A dashboard, report or caller that asked `min` / `max` / `sum` / `avg` of such a column through `/analytics/query` on the native-SQL strategy and read the answer as a real one. No example app and no shipped cube authors such a pair. A reader that branched on `fields[].type === 'number'` for a temporal `min` / `max` column now sees `time`.

**Unchanged.** Every pair the table accepts, a `max` over a `boolean` column included (its column keeps `number`: the rule declines the boolean class); `count` over any column; `count_distinct`, which keeps its own door and words; a measure over a relationship path (`account.name`), which this door does not judge; a column the host's field metadata cannot resolve, or a type outside `FieldType`; a measure whose `sql` is `*`; an expression metric type (`number` / `string` / `boolean`); and a host that wires no `sourceFieldMeta`, where the declaration cannot be read. The dataset door keeps its own `DATASET_INVALID` answer at compile.
216 changes: 216 additions & 0 deletions packages/runtime/src/analytics-cube-measure-field-type-door.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#21044] Over the wire: `POST /api/v1/analytics/query`, served by the real
* dispatcher route, refuses a configured cube's `max` over a `text` column with
* the cube door's `INVALID_FIELD` / 400 — on the native-SQL face, which served
* the column's text under `fields[] { type: 'number' }`, and on the ObjectQL
* face, which the engine's aggregate door refused only after the strategy had
* begun — and serves the controls.
*
* The service door is pinned beside the implementation (`service-analytics`
* `cube-measure-field-type-door.test.ts`, both faces, with read counters).
* This file asks the question that pin cannot: does the route relay the
* refusal's envelope as a 400 with its code, and does `fields[]` reach the wire
* as the service describes it.
*
* The analytics service is the one `AnalyticsServicePlugin` composes over a
* real `ObjectQL` engine and `SqlDriver`: `sourceFieldMeta` wired from the
* engine's registry, both auto-bridges live, the cube handed in as
* `AnalyticsServiceConfig.cubes` (the config key the CLI threads an app's
* `analyticsCubes` into). The SQLite cell always runs; the PostgreSQL cell runs
* where `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise. No CI step
* provisions that variable for this package, so the live cell is red-capable
* and un-run in CI; the PR that landed this file carries its local PostgreSQL
* 16 run. The live cell owns its table, dropped before and after.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';
import { AnalyticsServicePlugin } from '@objectstack/service-analytics';

import { createDispatcherPlugin } from './dispatcher-plugin.js';

const OBJECT = 'os21044_route_ledger';

const LEDGER = {
name: OBJECT,
label: 'Cube measure route ledger',
fields: {
note: { name: 'note', type: 'text' as const },
opened_at: { name: 'opened_at', type: 'datetime' as const },
amount: { name: 'amount', type: 'number' as const },
},
};

const ROWS = [
{ id: 'r1', note: 'x', opened_at: '2026-01-02T03:04:05.000Z', amount: 10 },
{ id: 'r2', note: 'y', opened_at: '2026-03-04T05:06:07.000Z', amount: 32 },
] as const;

const CUBE = {
name: 'os21044_route_cube',
title: 'Cube measure route cube',
sql: OBJECT,
public: true,
measures: {
max_note: { type: 'max', sql: 'note', label: 'Largest note (text)' },
max_opened: { type: 'max', sql: 'opened_at', label: 'Latest opening (datetime)' },
max_amount: { type: 'max', sql: 'amount', label: 'Largest amount (number)' },
},
dimensions: {},
};

interface Cell {
id: 'sqlite' | 'pg';
label: string;
env: string | null;
config: () => Record<string, unknown> | null;
}

const CELLS: readonly Cell[] = [
{ id: 'sqlite', label: 'sqlite', env: null, config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) },
{
id: 'pg',
label: 'live postgres',
env: 'OS_TEST_POSTGRES_URL',
config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null),
},
];

const FACES = ['native', 'objectql'] as const;
type Face = (typeof FACES)[number];

const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } };

// ── harness (the shape `analytics-authored-cube-format-granularity.test.ts` uses) ──

// The analytics domain refuses an anonymous caller first (ADR-0056 D2), so this
// harness signs its caller in the way that file does: an `auth` slot in the
// shape `resolveExecutionContext` reads answers a session for every request.
// Only identity is stubbed; the route, the service and the engine are real.
const SIGNED_IN_AUTH = { api: { getSession: async () => ({ user: { id: 'usr_analytics_caller' } }) } };

type Handler = (req: unknown, res: unknown) => unknown;

function makeFakeServer() {
const handlers: Record<string, Handler> = {};
const rec = (verb: string) => (path: string, handler: Handler) => {
handlers[`${verb} ${path}`] = handler;
};
return {
handlers,
server: { get: rec('GET'), post: rec('POST'), put: rec('PUT'), delete: rec('DELETE'), patch: rec('PATCH') },
};
}

function makeCtx(fakeServer: unknown, analytics: unknown) {
const kernel = {
getService: (name: string) => (name === 'analytics' ? analytics : name === 'auth' ? SIGNED_IN_AUTH : undefined),
getServiceAsync: async (name: string) => (name === 'analytics' ? analytics : name === 'auth' ? SIGNED_IN_AUTH : undefined),
};
return {
getKernel: () => kernel,
getService: (name: string) => (name === 'http.server' ? fakeServer : undefined),
environmentId: undefined,
logger: quiet,
hook: () => {},
on: () => {},
} as any;
}

function makeRes() {
const res: any = {
statusCode: undefined as number | undefined,
body: undefined as any,
status(c: number) { res.statusCode = c; return res; },
header() { return res; },
json(b: unknown) { res.body = b; return res; },
};
return res;
}

for (const cell of CELLS) {
const config = cell.config();
describe.skipIf(!config)(
`[#21044] POST /api/v1/analytics/query — a cube measure the aggregate × field-type table refuses is a 400 — ${cell.label}${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`,
() => {
let driver: any;
let engine: ObjectQL;
const post: Partial<Record<Face, (body: unknown) => Promise<{ status: number; body: any }>>> = {};

const dropTable = async () => {
if (cell.id === 'pg') await driver?.execute(`drop table if exists ${OBJECT}`).catch(() => {});
};

beforeAll(async () => {
driver = new SqlDriver(config as any);
await dropTable();
engine = new ObjectQL({ logger: quiet } as any);
engine.registerDriver(driver, true);
await engine.init();
engine.registry.registerObject(LEDGER as any);
await engine.syncSchemas();
for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any);

for (const [face, caps] of [
['native', undefined],
['objectql', () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false })],
] as const) {
const registered: Record<string, unknown> = {};
await new AnalyticsServicePlugin({ cubes: [CUBE], ...(caps ? { queryCapabilities: caps } : {}) } as any).init({
getService: (name: string) => (name === 'data' ? engine : registered[name]),
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
hook: () => {},
logger: quiet,
} as never);
const { server, handlers } = makeFakeServer();
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
await plugin.start?.(makeCtx(server, registered.analytics));
const handler = handlers['POST /api/v1/analytics/query'];
expect(handler, 'POST /api/v1/analytics/query must be mounted').toBeTypeOf('function');
post[face] = async (body) => {
const res = makeRes();
// What the wire carries: JSON, both ways.
await handler({ body: JSON.parse(JSON.stringify(body)), query: {} }, res);
return { status: res.statusCode ?? 200, body: JSON.parse(JSON.stringify(res.body ?? null)) };
};
}
});

afterAll(async () => {
await dropTable();
try { await engine?.destroy(); } catch { /* noop */ }
});

for (const face of FACES) {
it(`${face}: max over a text column answers 400 INVALID_FIELD, and nothing is served`, async () => {
const res = await post[face]!({ cube: CUBE.name, measures: ['max_note'] });
expect(res.status, JSON.stringify(res.body)).toBe(400);
expect(res.body.success).toBe(false);
expect(res.body.error.code).toBe('INVALID_FIELD');
expect(res.body.error.httpStatus).toBe(400);
expect(res.body.data).toBeUndefined();
});

it(`${face}: the control — max over a number column is served, a number typed number`, async () => {
const res = await post[face]!({ cube: CUBE.name, measures: ['max_amount'] });
expect(res.status, JSON.stringify(res.body)).toBe(200);
const fields = res.body.data.fields as Array<{ name: string; type: string }>;
expect(res.body.data.rows[0].max_amount).toBe(32);
expect(fields.find((f) => f.name === 'max_amount')?.type).toBe('number');
});

it(`${face}: an accepted temporal pair is served and reaches the wire typed time`, async () => {
const res = await post[face]!({ cube: CUBE.name, measures: ['max_opened'] });
expect(res.status, JSON.stringify(res.body)).toBe(200);
const fields = res.body.data.fields as Array<{ name: string; type: string }>;
expect(res.body.data.rows[0].max_opened).not.toBeNull();
expect(fields.find((f) => f.name === 'max_opened')?.type).toBe('time');
});
}
},
);
}
Loading
Loading