Skip to content

Commit 4ffdc26

Browse files
committed
feat(spec): AnalyticsResult declares object, the dataset answer's base object
AnalyticsResult (contracts) and AnalyticsResultResponseSchema (api) both gain the optional member `object: string`: the base object of the dataset an answer was computed from. The contract asks every queryDataset answer to carry it, whatever dimensions are selected and whether or not rows came back; a cube query answer has no dataset behind it and carries none. Declaration only. Pins: the response schema preserves data.object on a dimension-less, zero-row answer and refuses a non-string; a queryDataset implementation returns it against the plain AnalyticsResult. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6bff748 commit 4ffdc26

4 files changed

Lines changed: 73 additions & 0 deletions

File tree

‎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)