Skip to content

Commit 35587f7

Browse files
feat(spec): AnalyticsResult declares object, the dataset answer's base object (#20687)
Closes #20647 `AnalyticsResult` and `AnalyticsResultResponseSchema.data` declare an optional `object: string`, the base object of the dataset an answer was computed from. This is the declaration only; setting it on every dataset answer is #20644's service change. Clause-②: yes (widening) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 31ed067 commit 35587f7

6 files changed

Lines changed: 98 additions & 1 deletion

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): a dataset answer declares its base object as `object` (#20647)
6+
7+
Clause-②: yes (widening)
8+
9+
`AnalyticsResult` (`@objectstack/spec/contracts`) gains one optional member,
10+
`object?: string`, and `AnalyticsResultResponseSchema` (`@objectstack/spec/api`)
11+
mirrors it on `data`. It is the base object of the dataset the answer was
12+
computed from, by machine name. Nothing is removed or renamed, and no existing
13+
member changes meaning.
14+
15+
**For a consumer.** Code typed against `AnalyticsResult` can read `object` from a
16+
`queryDataset` answer without a cast, and a parse with
17+
`AnalyticsResultResponseSchema` keeps `data.object` where it used to strip it. The
18+
contract asks every dataset answer to carry it, whatever dimensions are selected
19+
and whether or not rows came back. A cube query answer has no dataset behind it
20+
and carries none.
21+
22+
**Producers.** This release declares the member. `@objectstack/service-analytics`
23+
sets it on every dataset answer once #20644 lands.

‎content/docs/references/api/analytics.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ const result = AnalyticsEndpoint.parse(data);
122122
| **success** | `boolean` | ✅ | Operation success status |
123123
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false |
124124
| **meta** | `{ timestamp: string; duration?: integer; requestId?: string; traceId?: string }` | optional | Response metadata |
125-
| **data** | `{ rows: Record<string, any>[]; fields: object[]; sql?: string; totals?: object[] }` | ✅ | |
125+
| **data** | `{ rows: Record<string, any>[]; fields: object[]; sql?: string; totals?: object[]; … }` | ✅ | |
126126

127127
### Nested Shape: `AnalyticsResultResponse.error`
128128

@@ -155,6 +155,7 @@ const result = AnalyticsEndpoint.parse(data);
155155
| **fields** | `{ name: string; type: string; label?: string; format?: string; … }[]` | ✅ | Column metadata |
156156
| **sql** | `string` | optional | Executed SQL (if debug enabled) |
157157
| **totals** | `{ dimensions: string[]; rows: Record<string, any>[] }[]` | optional | Marginal aggregates - one entry per requested totals grouping, in request order, each computed with the measure's true aggregate over the underlying data (never re-derived from bucketed values). The grand-total grouping yields a single dimensionless row. |
158+
| **object** | `string` | optional | The base object of the dataset the answer was computed from: the dataset's `object`, by machine name. Every dataset answer (`POST /analytics/dataset/query`) must carry it, whatever dimensions are selected and whether or not rows came back, so a consumer can refresh on that object's record changes and drill into its records. Absent on a cube query answer, which has no dataset behind it. |
158159

159160

160161
---

‎packages/spec/src/api/analytics.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,30 @@ describe('AnalyticsResultResponseSchema', () => {
254254
expect(resp.data.totals?.[1].rows[0].revenue).toBe(250);
255255
});
256256

257+
// `data.object` — the dataset's base object, which a consumer keys its
258+
// record-change refresh on. The answer that most needs it is the one with no
259+
// dimensions and no rows (a KPI tile before its first record), so that is the
260+
// payload pinned. Preservation, not just acceptance: this schema strips an
261+
// undeclared key, so an undeclared `object` would parse green and vanish.
262+
it('should preserve data.object — the dataset base object — on a dimension-less, zero-row answer', () => {
263+
const resp = AnalyticsResultResponseSchema.parse({
264+
success: true,
265+
data: { rows: [], fields: [{ name: 'count', type: 'number' }], object: 'showcase_project' },
266+
});
267+
expect(resp.data.object).toBe('showcase_project');
268+
269+
// Optional: a cube query answer carries none, and still parses.
270+
const cube = AnalyticsResultResponseSchema.parse({ success: true, data: { rows: [], fields: [] } });
271+
expect('object' in cube.data).toBe(false);
272+
273+
const bad = AnalyticsResultResponseSchema.safeParse({
274+
success: true,
275+
data: { rows: [], fields: [], object: 42 },
276+
});
277+
expect(bad.success).toBe(false);
278+
expect(bad.success ? [] : bad.error.issues.map((i) => i.path.join('.'))).toContain('data.object');
279+
});
280+
257281
it('should reject a percentScale outside the closed vocabulary, and a totals entry without dimensions', () => {
258282
expect(() =>
259283
AnalyticsResultResponseSchema.parse({

‎packages/spec/src/api/analytics.zod.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,13 @@ export const AnalyticsResultResponseSchema = lazySchema(() => BaseResponseSchema
142142
+ 'the underlying data (never re-derived from bucketed values). The '
143143
+ 'grand-total grouping yields a single dimensionless row.',
144144
),
145+
object: z.string().optional().describe(
146+
'The base object of the dataset the answer was computed from: the dataset\'s '
147+
+ '`object`, by machine name. Every dataset answer (`POST /analytics/dataset/query`) '
148+
+ 'must carry it, whatever dimensions are selected and whether or not rows came '
149+
+ 'back, so a consumer can refresh on that object\'s record changes and drill '
150+
+ 'into its records. Absent on a cube query answer, which has no dataset behind it.',
151+
),
145152
}),
146153
}));
147154

‎packages/spec/src/contracts/analytics-service.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,33 @@ describe('Analytics Service Contract', () => {
8888
expect(offEnum.name).toBe('count');
8989
});
9090

91+
// `object` — the dataset's base object, declared on the answer itself and not
92+
// on a drill-through side type: a `queryDataset` implementation returns it on
93+
// a dimension-less, zero-row answer against the plain `AnalyticsResult`, and
94+
// the member is a string (a non-string is refused at compile time).
95+
it('carries the dataset base object as `object` on a dimension-less, zero-row dataset answer', async () => {
96+
const service: IAnalyticsService = {
97+
query: async () => ({ rows: [], fields: [] }),
98+
getMeta: async () => [],
99+
queryDataset: async (dataset) => ({ rows: [], fields: [{ name: 'count', type: 'number' }], object: dataset.object }),
100+
};
101+
102+
const answer = await service.queryDataset!(
103+
{ name: 'projects', label: 'Projects', object: 'project', dimensions: [], measures: [{ name: 'count', aggregate: 'count' }] },
104+
{ measures: ['count'] },
105+
);
106+
expect(answer.object).toBe('project');
107+
expect(answer.rows).toEqual([]);
108+
109+
const offType: AnalyticsResult = {
110+
rows: [],
111+
fields: [],
112+
// @ts-expect-error — `object` is the base object's machine name, a string
113+
object: 42,
114+
};
115+
expect(offType.rows).toEqual([]);
116+
});
117+
91118
it('should generate SQL without executing', async () => {
92119
const service: IAnalyticsService = {
93120
query: async () => ({ rows: [], fields: [] }),

‎packages/spec/src/contracts/analytics-service.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,21 @@ export interface AnalyticsResult {
132132
dimensions: string[];
133133
rows: Record<string, unknown>[];
134134
}>;
135+
/**
136+
* The base object of the dataset the answer was computed from: the
137+
* dataset's own `object` (`DatasetSchema.object`, its FROM), by machine
138+
* name. A consumer keys two things on it — refreshing when that object's
139+
* records change, and drilling a clicked value into those records.
140+
*
141+
* The contract, for `queryDataset`: EVERY dataset answer must carry it,
142+
* whatever dimensions are selected and whether or not rows came back — a
143+
* dimension-less KPI answer and a zero-row answer included. It names the
144+
* answer's subject, so it does not depend on the selection having a
145+
* drillable dimension.
146+
*
147+
* Absent on a `query` (cube) answer, which has no dataset behind it.
148+
*/
149+
object?: string;
135150
}
136151

137152
/**

0 commit comments

Comments
 (0)