From 4ffdc2645c184ee3359104366e7ba4bd9ca39226 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 16:02:46 +0000 Subject: [PATCH 1/3] 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 --- packages/spec/src/api/analytics.test.ts | 24 +++++++++++++++++ packages/spec/src/api/analytics.zod.ts | 7 +++++ .../src/contracts/analytics-service.test.ts | 27 +++++++++++++++++++ .../spec/src/contracts/analytics-service.ts | 15 +++++++++++ 4 files changed, 73 insertions(+) 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; } /** From 159d84d8b0b6ca83a08c24d1d53cfa7d248dfbed Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 16:18:55 +0000 Subject: [PATCH 2/3] docs(spec): regenerate the analytics API reference for data.object Generated by `pnpm --filter @objectstack/spec gen:docs`, the one artifact `check:generated` named stale after the response schema gained `object`. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- content/docs/references/api/analytics.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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. | --- From 9810aebb1391d05d70adc8b17d7a7dccdc07ffa9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 16:19:21 +0000 Subject: [PATCH 3/3] chore(changeset): spec minor for the declared dataset-answer object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clause-② is yes (widening): AnalyticsResult and its response schema gain one optional member. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .changeset/20647-analytics-result-object.md | 23 +++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .changeset/20647-analytics-result-object.md 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.