diff --git a/.changeset/20647-analytics-result-object.md b/.changeset/20647-analytics-result-object.md new file mode 100644 index 00000000000..204951b6086 --- /dev/null +++ b/.changeset/20647-analytics-result-object.md @@ -0,0 +1,23 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): a dataset answer declares its base object as `object` (#20647) + +Clause-②: yes (widening) + +`AnalyticsResult` (`@objectstack/spec/contracts`) gains one optional member, +`object?: string`, and `AnalyticsResultResponseSchema` (`@objectstack/spec/api`) +mirrors it on `data`. It is the base object of the dataset the answer was +computed from, by machine name. Nothing is removed or renamed, and no existing +member changes meaning. + +**For a consumer.** Code typed against `AnalyticsResult` can read `object` from a +`queryDataset` answer without a cast, and a parse with +`AnalyticsResultResponseSchema` keeps `data.object` where it used to strip it. The +contract asks every dataset 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. + +**Producers.** This release declares the member. `@objectstack/service-analytics` +sets it on every dataset answer once #20644 lands. diff --git a/content/docs/references/api/analytics.mdx b/content/docs/references/api/analytics.mdx index 04247636123..670af459206 100644 --- a/content/docs/references/api/analytics.mdx +++ b/content/docs/references/api/analytics.mdx @@ -122,7 +122,7 @@ const result = AnalyticsEndpoint.parse(data); | **success** | `boolean` | ✅ | Operation success status | | **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: integer; requestId?: string; traceId?: string }` | optional | Response metadata | -| **data** | `{ rows: Record[]; fields: object[]; sql?: string; totals?: object[] }` | ✅ | | +| **data** | `{ rows: Record[]; fields: object[]; sql?: string; totals?: object[]; … }` | ✅ | | ### Nested Shape: `AnalyticsResultResponse.error` @@ -155,6 +155,7 @@ const result = AnalyticsEndpoint.parse(data); | **fields** | `{ name: string; type: string; label?: string; format?: string; … }[]` | ✅ | Column metadata | | **sql** | `string` | optional | Executed SQL (if debug enabled) | | **totals** | `{ dimensions: string[]; rows: Record[] }[]` | 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. | +| **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. | --- diff --git a/packages/spec/src/api/analytics.test.ts b/packages/spec/src/api/analytics.test.ts index ce7add708a7..643b5c50df9 100644 --- a/packages/spec/src/api/analytics.test.ts +++ b/packages/spec/src/api/analytics.test.ts @@ -254,6 +254,30 @@ describe('AnalyticsResultResponseSchema', () => { expect(resp.data.totals?.[1].rows[0].revenue).toBe(250); }); + // `data.object` — the dataset's base object, which a consumer keys its + // record-change refresh on. The answer that most needs it is the one with no + // dimensions and no rows (a KPI tile before its first record), so that is the + // payload pinned. Preservation, not just acceptance: this schema strips an + // undeclared key, so an undeclared `object` would parse green and vanish. + it('should preserve data.object — the dataset base object — on a dimension-less, zero-row answer', () => { + const resp = AnalyticsResultResponseSchema.parse({ + success: true, + data: { rows: [], fields: [{ name: 'count', type: 'number' }], object: 'showcase_project' }, + }); + expect(resp.data.object).toBe('showcase_project'); + + // Optional: a cube query answer carries none, and still parses. + const cube = AnalyticsResultResponseSchema.parse({ success: true, data: { rows: [], fields: [] } }); + expect('object' in cube.data).toBe(false); + + const bad = AnalyticsResultResponseSchema.safeParse({ + success: true, + data: { rows: [], fields: [], object: 42 }, + }); + expect(bad.success).toBe(false); + expect(bad.success ? [] : bad.error.issues.map((i) => i.path.join('.'))).toContain('data.object'); + }); + it('should reject a percentScale outside the closed vocabulary, and a totals entry without dimensions', () => { expect(() => AnalyticsResultResponseSchema.parse({ diff --git a/packages/spec/src/api/analytics.zod.ts b/packages/spec/src/api/analytics.zod.ts index 72361f58b1a..802e3c3e79d 100644 --- a/packages/spec/src/api/analytics.zod.ts +++ b/packages/spec/src/api/analytics.zod.ts @@ -142,6 +142,13 @@ export const AnalyticsResultResponseSchema = lazySchema(() => BaseResponseSchema + 'the underlying data (never re-derived from bucketed values). The ' + 'grand-total grouping yields a single dimensionless row.', ), + object: z.string().optional().describe( + '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.', + ), }), })); diff --git a/packages/spec/src/contracts/analytics-service.test.ts b/packages/spec/src/contracts/analytics-service.test.ts index de4c01c0920..65becfcfa53 100644 --- a/packages/spec/src/contracts/analytics-service.test.ts +++ b/packages/spec/src/contracts/analytics-service.test.ts @@ -88,6 +88,33 @@ describe('Analytics Service Contract', () => { expect(offEnum.name).toBe('count'); }); + // `object` — the dataset's base object, declared on the answer itself and not + // on a drill-through side type: a `queryDataset` implementation returns it on + // a dimension-less, zero-row answer against the plain `AnalyticsResult`, and + // the member is a string (a non-string is refused at compile time). + it('carries the dataset base object as `object` on a dimension-less, zero-row dataset answer', async () => { + const service: IAnalyticsService = { + query: async () => ({ rows: [], fields: [] }), + getMeta: async () => [], + queryDataset: async (dataset) => ({ rows: [], fields: [{ name: 'count', type: 'number' }], object: dataset.object }), + }; + + const answer = await service.queryDataset!( + { name: 'projects', label: 'Projects', object: 'project', dimensions: [], measures: [{ name: 'count', aggregate: 'count' }] }, + { measures: ['count'] }, + ); + expect(answer.object).toBe('project'); + expect(answer.rows).toEqual([]); + + const offType: AnalyticsResult = { + rows: [], + fields: [], + // @ts-expect-error — `object` is the base object's machine name, a string + object: 42, + }; + expect(offType.rows).toEqual([]); + }); + it('should generate SQL without executing', async () => { const service: IAnalyticsService = { query: async () => ({ rows: [], fields: [] }), diff --git a/packages/spec/src/contracts/analytics-service.ts b/packages/spec/src/contracts/analytics-service.ts index 7df0e29ca6d..7a65f2bb099 100644 --- a/packages/spec/src/contracts/analytics-service.ts +++ b/packages/spec/src/contracts/analytics-service.ts @@ -132,6 +132,21 @@ export interface AnalyticsResult { dimensions: string[]; rows: Record[]; }>; + /** + * The base object of the dataset the answer was computed from: the + * dataset's own `object` (`DatasetSchema.object`, its FROM), by machine + * name. A consumer keys two things on it — refreshing when that object's + * records change, and drilling a clicked value into those records. + * + * The contract, for `queryDataset`: EVERY dataset answer must carry it, + * whatever dimensions are selected and whether or not rows came back — a + * dimension-less KPI answer and a zero-row answer included. It names the + * answer's subject, so it does not depend on the selection having a + * drillable dimension. + * + * Absent on a `query` (cube) answer, which has no dataset behind it. + */ + object?: string; } /**