Skip to content

Commit c5887e7

Browse files
committed
test(dogfood), chore(changeset): pin the authoring refusal of a formula dimension end to end
Claude-Session: https://claude.ai/code/session_016WV4d1SZmabyrNd3YLCq3J Co-authored-by: Claude <noreply@anthropic.com>
1 parent b100c40 commit c5887e7

2 files changed

Lines changed: 117 additions & 0 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/service-analytics': minor
3+
'@objectstack/lint': minor
4+
---
5+
6+
fix(service-analytics, lint)!: a dataset or cube dimension over a formula field is refused where it is authored — the dataset compile and `os validate` / `os build` / `os lint` / the runtime dataset save — instead of on the first query that groups by it
7+
8+
Clause-②: no (narrowing) — the dataset compile (`AnalyticsService.registerDataset`, `queryDataset` and every door that compiles through them) refuses a dataset declaring a dimension over a virtual (`formula`) field of its base object, and the authoring rules refuse a dataset or `analyticsCubes` dimension over one with the new id `dimension-virtual-field-refused`, bodies they accepted before. Nothing is widened: the one new export (`DIMENSION_VIRTUAL_FIELD_REFUSED`, the rule id's constant beside its two siblings in `@objectstack/lint`) adds no accepted input, and no error code, status or answer member is added.
9+
Metadata-protocol: narrowed
10+
Skills-docs: no move — `skills/**` teaches no dataset dimension verdict (`objectstack-data/rules/field-types.md` says a formula is virtual and a dataset measure needs a stored field, and does not mention dimensions), and `content/docs/deployment/validating-metadata.mdx` does not yet list this id beside `dimension-json-stored-field-refused`; both are reported for the owning seat. The prescription ships in the refusal text and below.
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped: DatasetDimension.field and a cube dimension's sql keep their shape and meaning. A dimension over a formula never served a group (since the query-time refusal every query selecting it answers 400 INVALID_FIELD, and before it a 500, a driver backstop or one null group), so there is no rewrite that keeps what the author meant: the author stores the value on a field of its own, or removes the dimension, and the refusal names the dimension and the field. -->
13+
14+
**BREAKING** accept-set narrowing at dataset authoring (`@objectstack/service-analytics`, `@objectstack/lint`), for a dimension over a `formula` field only.
15+
16+
- **Before.** Measured on `main` `2d04cd2b`. A dataset whose dimension's `field` is a `formula` (any `returnType`) compiled and registered (`registerDataset`), and a query naming only its other, stored dimensions was served; a query grouping by the formula dimension was refused at query time (`INVALID_FIELD` / 400 at `groupBy.N`), in front of whoever opened the dashboard tile. `os validate`, `os build`, `os lint` and the runtime dataset save (`PUT /api/v1/meta/dataset/NAME`, the gate Studio, REST and MCP authors share) reported nothing for it, on a dataset or an `analyticsCubes` cube. The measure side already refused a total over a formula at both doors.
17+
- **Now.** The dataset compile refuses such a dataset with `DATASET_INVALID` / 400 naming the dataset, the dimension, its position (`dimensions.N`), the field and its type, and the remedy, whichever members a query names: `registerDataset`, `queryDataset` (native, ObjectQL and the draft preview alike) and the build probes that query through it. The authoring rules refuse the dimension with `dimension-virtual-field-refused` (error) at `datasets[N].dimensions[M].field` or `analyticsCubes[N].dimensions.KEY.sql`, judged on the leaf's own object for a dotted path, on all three CLI commands and at the runtime dataset save (`422`). The verdict is the spec's `isVirtualSearchField` (`SEARCH_VIRTUAL_TYPES`), the predicate the query-time doors use; `os explain dimension-virtual-field-refused` gives the reasoning.
18+
- **Unchanged.** A dimension over a stored field, a date or datetime dimension with or without `dateGranularity`, and every measure verdict, a `count` over a formula and the stored-number remedy for a total included. The compile does not judge a dotted dimension (its hook answers for the base object only); the lint leg and the query-time refusal still do.
19+
- **Who moves.** An author whose dataset or cube declares a dimension over a formula field: the dataset stops compiling until the dimension moves, and its stored dimensions stop being served with it.
20+
21+
**The remedy.** Denormalise the value: store it in a field of its own on the object (written by a hook or a flow when its source changes) and point the dimension at that field, or remove the dimension.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// END-TO-END gate for #30773: a dataset whose DIMENSION groups by a formula
4+
// field is refused where it is authored — the runtime save (`PUT /meta/dataset`,
5+
// the gate Studio, REST and MCP share) answers `422` naming
6+
// `dimension-virtual-field-refused`, and the dataset compile behind
7+
// `POST /analytics/dataset/query` answers `400 DATASET_INVALID` naming the
8+
// dimension, whatever the selection — proven on the stock showcase, through the
9+
// real stack.
10+
//
11+
// `showcase_project.budget_remaining` is a `formula` (`returnType: 'currency'`):
12+
// computed on read, no column. Measured on `main` `2d04cd2b` before this card,
13+
// such a dataset compiled and registered, a query selecting only its stored
14+
// dimension was served, and only a query grouping by the formula was refused
15+
// (#29304, `400 INVALID_FIELD` at `groupBy.0`) — in front of whoever opened
16+
// the tile, not the author.
17+
//
18+
// Controls: the same dataset without the formula dimension saves and queries.
19+
20+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
21+
import { type VerifyStack } from '@objectstack/verify';
22+
import { bootShowcase } from './showcase-boot.js';
23+
24+
const OBJECT = 'showcase_project';
25+
const SAVED = 'os30773_project_by_formula';
26+
const SAVED_CONTROL = 'os30773_project_by_status';
27+
28+
const dataset = (name: string, withFormula: boolean) => ({
29+
name,
30+
label: 'Projects',
31+
object: OBJECT,
32+
dimensions: [
33+
{ name: 'status', label: 'Status', field: 'status', type: 'string' },
34+
...(withFormula ? [{ name: 'remaining', label: 'Remaining', field: 'budget_remaining', type: 'number' }] : []),
35+
],
36+
measures: [{ name: 'cnt', label: 'Count', aggregate: 'count' }],
37+
});
38+
39+
describe('dogfood: a dataset dimension over a formula field is refused at authoring (#30773)', () => {
40+
let stack: VerifyStack;
41+
let token: string;
42+
43+
beforeAll(async () => {
44+
stack = await bootShowcase();
45+
token = await stack.signIn();
46+
}, 180_000);
47+
48+
afterAll(async () => {
49+
await stack?.stop?.();
50+
});
51+
52+
async function call(method: 'PUT' | 'POST', path: string, body: unknown) {
53+
const res = await stack.apiAs(token, method, path, body);
54+
return { status: res.status, body: (await res.json()) as Record<string, any> };
55+
}
56+
57+
it('PUT /meta/dataset: the save is refused 422, naming dimension-virtual-field-refused at the dimension', async () => {
58+
const res = await call('PUT', `/meta/dataset/${SAVED}`, dataset(SAVED, true));
59+
expect(res.status, JSON.stringify(res.body)).toBe(422);
60+
const text = JSON.stringify(res.body);
61+
expect(text).toContain('dimension-virtual-field-refused');
62+
expect(text).toContain('dimensions[1].field');
63+
expect(text).toContain('budget_remaining');
64+
});
65+
66+
it('CONTROL PUT /meta/dataset: the same dataset over a stored dimension saves', async () => {
67+
const res = await call('PUT', `/meta/dataset/${SAVED_CONTROL}`, dataset(SAVED_CONTROL, false));
68+
expect(res.status, JSON.stringify(res.body)).toBe(200);
69+
});
70+
71+
for (const dimensions of [['remaining'], ['status']]) {
72+
it(`POST /analytics/dataset/query (inline, selecting ${dimensions.join()}): 400 DATASET_INVALID naming the dimension and the remedy`, async () => {
73+
const res = await call('POST', '/analytics/dataset/query', {
74+
dataset: dataset('os30773_inline', true),
75+
selection: { dimensions, measures: ['cnt'] },
76+
});
77+
expect(res.status, JSON.stringify(res.body)).toBe(400);
78+
const error = res.body.error ?? res.body;
79+
expect(error.code, JSON.stringify(res.body)).toBe('DATASET_INVALID');
80+
const message = String(error.message);
81+
expect(message).toContain(
82+
'dataset "os30773_inline" dimension "remaining" (dimensions.1) groups by field "budget_remaining", ' +
83+
`which object "${OBJECT}" declares as \`formula\``,
84+
);
85+
expect(message).toContain(`Denormalise the value onto "${OBJECT}" (a stored field, written when the source changes)`);
86+
});
87+
}
88+
89+
it('CONTROL POST /analytics/dataset/query: the dataset without the formula dimension is served', async () => {
90+
const res = await call('POST', '/analytics/dataset/query', {
91+
dataset: dataset('os30773_inline_control', false),
92+
selection: { dimensions: ['status'], measures: ['cnt'] },
93+
});
94+
expect(res.status, JSON.stringify(res.body)).toBe(200);
95+
});
96+
});

0 commit comments

Comments
 (0)