diff --git a/.changeset/20700-analytics-result-drill-sidecars.md b/.changeset/20700-analytics-result-drill-sidecars.md new file mode 100644 index 00000000000..f5f35b7581a --- /dev/null +++ b/.changeset/20700-analytics-result-drill-sidecars.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): a dataset answer declares its four drill-through sidecars (#20700) + +Clause-②: yes (widening) + +`AnalyticsResult` (`@objectstack/spec/contracts`) gains four optional members, and +`AnalyticsResultResponseSchema` (`@objectstack/spec/api`) mirrors them on `data`: +`dimensionFields` (drillable dimension name to its field), `drillRawRows` (each +row's stored grouped values, aligned to `rows`), `drillRawTotals` (the same for +`totals`) and `drillRanges` (each row's date-bucket range, `[gte, lt)`). Nothing is +removed or renamed, and no existing member changes meaning. + +`@objectstack/service-analytics` already sets them on a drillable `queryDataset` +answer. Code typed against `AnalyticsResult` can now read them without a cast, and +a parse with `AnalyticsResultResponseSchema` keeps them where it used to strip them. diff --git a/content/docs/references/api/analytics.mdx b/content/docs/references/api/analytics.mdx index 670af459206..9c581188bbe 100644 --- a/content/docs/references/api/analytics.mdx +++ b/content/docs/references/api/analytics.mdx @@ -156,6 +156,10 @@ const result = AnalyticsEndpoint.parse(data); | **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. | +| **dimensionFields** | `Record` | optional | Drill-through sidecar: each equality-drillable dimension the answer is grouped by, dimension name to that dataset dimension's own `field` (a base-object field or a `relationship.field` path). Set only on a dataset answer (`POST /analytics/dataset/query`) that has rows and groups by at least one dimension with a `field` that is not `type: 'date'`; a date dimension is never listed (see `drillRanges`). Absent on a cube query answer. | +| **drillRawRows** | `Record[]` | optional | Drill-through sidecar, aligned to `rows` by index: each `dimensionFields` dimension name to the stored value that row was grouped by (a select option's value, a lookup's record id), captured before label resolution rewrites the row to its display label. An exact-match drill filter is built from these, not from the labels in `rows`. Set exactly when `dimensionFields` is. | +| **drillRawTotals** | `Record[][]` | optional | Drill-through sidecar for `totals`: entry `[i][j]` aligns to `totals[i].rows[j]` and holds the stored value of the `dimensionFields` dimensions that grouping groups by, so the grand-total grouping yields an empty map per row. Set only when `dimensionFields` is and the answer carries at least one `totals` grouping. | +| **drillRanges** | `Record[]` | optional | Drill-through sidecar for time buckets, aligned to `rows` by index: date dimension name to the half-open range `[gte, lt)` that row's bucket covers. Bounds are `YYYY-MM-DD` calendar days, or ISO-8601 instants at midnight in the reference timezone when the source field is `datetime`; a row whose bucket value is null gets no entry for that dimension. Set only on a dataset answer that has rows and groups by at least one `type: 'date'` dimension with a `field` and a granularity; independent of `dimensionFields`. Absent on a cube query answer. | --- diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md index 06c7e267624..acaf9f2c8ec 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `api/` | 431 | +| `api/` | 432 | diff --git a/packages/spec/src/api/analytics.test.ts b/packages/spec/src/api/analytics.test.ts index 643b5c50df9..d5a064fccad 100644 --- a/packages/spec/src/api/analytics.test.ts +++ b/packages/spec/src/api/analytics.test.ts @@ -278,6 +278,63 @@ describe('AnalyticsResultResponseSchema', () => { expect(bad.success ? [] : bad.error.issues.map((i) => i.path.join('.'))).toContain('data.object'); }); + // The four drill-through sidecars (ADR-0021 D2), on the answer that carries all + // four at once: a dataset grouped by a lookup (equality drill) and by a + // quarter-bucketed date (range drill), with a grand total and a per-account + // subtotal. Preservation, not just acceptance: this schema strips an undeclared + // key, so an undeclared sidecar would parse green and vanish. + const drillable = { + rows: [ + { account: 'Acme', close_date: '2026-Q2', revenue: 100 }, + { account: 'Globex', close_date: '2026-Q3', revenue: 40 }, + ], + fields: [ + { name: 'account', type: 'string' }, + { name: 'close_date', type: 'time' }, + { name: 'revenue', type: 'number' }, + ], + totals: [ + { dimensions: [], rows: [{ revenue: 140 }] }, + { dimensions: ['account'], rows: [{ account: 'Acme', revenue: 100 }, { account: 'Globex', revenue: 40 }] }, + ], + object: 'opportunity', + dimensionFields: { account: 'account' }, + drillRawRows: [{ account: 'acc_1' }, { account: 'acc_2' }], + drillRawTotals: [[{}], [{ account: 'acc_1' }, { account: 'acc_2' }]], + drillRanges: [ + { close_date: { field: 'close_date', gte: '2026-04-01', lt: '2026-07-01' } }, + { close_date: { field: 'close_date', gte: '2026-07-01', lt: '2026-10-01' } }, + ], + }; + + it('should preserve all four drill-through sidecars on a drillable dataset answer', () => { + const resp = AnalyticsResultResponseSchema.parse({ success: true, data: drillable }); + expect(resp.data.dimensionFields).toEqual({ account: 'account' }); + expect(resp.data.drillRawRows).toEqual([{ account: 'acc_1' }, { account: 'acc_2' }]); + expect(resp.data.drillRawTotals).toEqual([[{}], [{ account: 'acc_1' }, { account: 'acc_2' }]]); + expect(resp.data.drillRanges).toEqual(drillable.drillRanges); + + // Optional: a cube query answer, and a non-drillable dataset answer, carry none. + const bare = AnalyticsResultResponseSchema.parse({ success: true, data: { rows: [], fields: [] } }); + for (const key of ['dimensionFields', 'drillRawRows', 'drillRawTotals', 'drillRanges']) { + expect(key in bare.data).toBe(false); + } + }); + + it.each([ + ['dimensionFields', { account: 42 }, 'data.dimensionFields.account'], + ['drillRawRows', { account: 'acc_1' }, 'data.drillRawRows'], + ['drillRawTotals', [{ account: 'acc_1' }], 'data.drillRawTotals.0'], + ['drillRanges', [{ close_date: { field: 'close_date', gte: '2026-04-01' } }], 'data.drillRanges.0.close_date.lt'], + ])('should refuse a malformed %s at its own path', (key, value, path) => { + const bad = AnalyticsResultResponseSchema.safeParse({ + success: true, + data: { ...drillable, [key]: value }, + }); + expect(bad.success).toBe(false); + expect(bad.success ? [] : bad.error.issues.map((i) => i.path.join('.'))).toEqual([path]); + }); + 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 802e3c3e79d..f96531cb30f 100644 --- a/packages/spec/src/api/analytics.zod.ts +++ b/packages/spec/src/api/analytics.zod.ts @@ -149,6 +149,43 @@ export const AnalyticsResultResponseSchema = lazySchema(() => BaseResponseSchema + '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.', ), + // Drill-through sidecars (ADR-0021 D2) — mirrored member for member from + // `AnalyticsResult`, where each one's conditions are stated in full. + dimensionFields: z.record(z.string(), z.string()).optional().describe( + 'Drill-through sidecar: each equality-drillable dimension the answer is grouped ' + + 'by, dimension name to that dataset dimension\'s own `field` (a base-object ' + + 'field or a `relationship.field` path). Set only on a dataset answer ' + + '(`POST /analytics/dataset/query`) that has rows and groups by at least one ' + + 'dimension with a `field` that is not `type: \'date\'`; a date dimension is ' + + 'never listed (see `drillRanges`). Absent on a cube query answer.', + ), + drillRawRows: z.array(z.record(z.string(), z.unknown())).optional().describe( + 'Drill-through sidecar, aligned to `rows` by index: each `dimensionFields` ' + + 'dimension name to the stored value that row was grouped by (a select ' + + 'option\'s value, a lookup\'s record id), captured before label resolution ' + + 'rewrites the row to its display label. An exact-match drill filter is built ' + + 'from these, not from the labels in `rows`. Set exactly when `dimensionFields` is.', + ), + drillRawTotals: z.array(z.array(z.record(z.string(), z.unknown()))).optional().describe( + 'Drill-through sidecar for `totals`: entry `[i][j]` aligns to `totals[i].rows[j]` ' + + 'and holds the stored value of the `dimensionFields` dimensions that grouping ' + + 'groups by, so the grand-total grouping yields an empty map per row. Set only ' + + 'when `dimensionFields` is and the answer carries at least one `totals` grouping.', + ), + drillRanges: z.array(z.record(z.string(), z.object({ + field: z.string().describe('The date dimension\'s own `field`'), + gte: z.string().describe('Inclusive lower bound of the bucket'), + lt: z.string().describe('Exclusive upper bound of the bucket'), + }))).optional().describe( + 'Drill-through sidecar for time buckets, aligned to `rows` by index: date ' + + 'dimension name to the half-open range `[gte, lt)` that row\'s bucket covers. ' + + 'Bounds are `YYYY-MM-DD` calendar days, or ISO-8601 instants at midnight in the ' + + 'reference timezone when the source field is `datetime`; a row whose bucket ' + + 'value is null gets no entry for that dimension. Set only on a dataset answer ' + + 'that has rows and groups by at least one `type: \'date\'` dimension with a ' + + '`field` and a granularity; independent of `dimensionFields`. Absent on a cube ' + + 'query answer.', + ), }), })); diff --git a/packages/spec/src/contracts/analytics-service.test.ts b/packages/spec/src/contracts/analytics-service.test.ts index 65becfcfa53..4abc11d74a6 100644 --- a/packages/spec/src/contracts/analytics-service.test.ts +++ b/packages/spec/src/contracts/analytics-service.test.ts @@ -115,6 +115,51 @@ describe('Analytics Service Contract', () => { expect(offType.rows).toEqual([]); }); + // The four drill-through sidecars (ADR-0021 D2), declared on the answer + // itself: a `queryDataset` implementation returns them against the plain + // `AnalyticsResult`, and a caller reads each one without a cast (a read of an + // undeclared member is a compile error, so these reads are the existence pin). + // The `@ts-expect-error` lines pin each member's TYPE. + it('carries the drill-through sidecars on a drillable dataset answer', async () => { + const service: IAnalyticsService = { + query: async () => ({ rows: [], fields: [] }), + getMeta: async () => [], + queryDataset: async (dataset) => ({ + rows: [{ account: 'Acme', close_date: '2026-Q2', revenue: 100 }], + fields: [{ name: 'revenue', type: 'number' }], + totals: [{ dimensions: [], rows: [{ revenue: 100 }] }], + object: dataset.object, + dimensionFields: { account: 'account' }, + drillRawRows: [{ account: 'acc_1' }], + drillRawTotals: [[{}]], + drillRanges: [{ close_date: { field: 'close_date', gte: '2026-04-01', lt: '2026-07-01' } }], + }), + }; + + const answer = await service.queryDataset!( + { name: 'pipeline', label: 'Pipeline', object: 'opportunity', dimensions: [], measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }] }, + { measures: ['revenue'], dimensions: ['account', 'close_date'] }, + ); + const field: string | undefined = answer.dimensionFields?.account; + expect(field).toBe('account'); + expect(answer.drillRawRows?.[0]).toEqual({ account: 'acc_1' }); + expect(answer.drillRawTotals?.[0]?.[0]).toEqual({}); + const range: { field: string; gte: string; lt: string } | undefined = answer.drillRanges?.[0]?.close_date; + expect(range).toEqual({ field: 'close_date', gte: '2026-04-01', lt: '2026-07-01' }); + + const offType: AnalyticsResult[] = [ + // @ts-expect-error — `dimensionFields` maps a dimension name to a field NAME, a string + { rows: [], fields: [], dimensionFields: { account: 42 } }, + // @ts-expect-error — `drillRawRows` is an array aligned to `rows`, not one map + { rows: [], fields: [], drillRawRows: { account: 'acc_1' } }, + // @ts-expect-error — `drillRawTotals` is one array of maps PER totals grouping + { rows: [], fields: [], drillRawTotals: [{ account: 'acc_1' }] }, + // @ts-expect-error — a `drillRanges` entry carries both bounds + { rows: [], fields: [], drillRanges: [{ close_date: { field: 'close_date', gte: '2026-04-01' } }] }, + ]; + expect(offType).toHaveLength(4); + }); + 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 7a65f2bb099..0e0d4c9ff9a 100644 --- a/packages/spec/src/contracts/analytics-service.ts +++ b/packages/spec/src/contracts/analytics-service.ts @@ -147,6 +147,69 @@ export interface AnalyticsResult { * Absent on a `query` (cube) answer, which has no dataset behind it. */ object?: string; + + // ── Drill-through sidecars (ADR-0021 D2) ──────────────────────────────── + // Four keys a `queryDataset` answer carries so a host can drill a clicked + // bucket back to the records behind it. Each is set only on a drillable + // answer, by the conditions stated per member; none is set on a `query` + // (cube) answer, on an answer with no rows, or on a draft-preview answer + // computed over pending seed rows (`previewDrafts`). + + /** + * Each equality-drillable dimension the answer is grouped by: dimension + * NAME → that dataset dimension's own `field` (a base-object field, or a + * `relationship.field` path, as the dataset declares it). A host builds an + * exact-match drill filter on these field names; the rows carry only + * dimension names. + * + * Set only on a `queryDataset` answer that has rows and whose selection + * groups by at least one dimension with a `field` that is not + * `type: 'date'`. A date bucket cannot be exact-matched, so a date + * dimension is never listed here; its drill scope is `drillRanges`. + */ + dimensionFields?: Record; + /** + * The RAW grouped value behind each row, aligned to `rows` by index: + * `drillRawRows[i]` maps each `dimensionFields` dimension NAME → the stored + * value `rows[i]` was grouped by (a select option's value, a lookup's + * record id), captured before dimension label resolution rewrites + * `rows[i][dim]` to its display label. An exact-match drill filter is + * built from these values, not from the labels in `rows`. + * + * Set exactly when `dimensionFields` is. + */ + drillRawRows?: Array>; + /** + * The totals-side companion to `drillRawRows`: `drillRawTotals[i]` aligns + * to `totals[i]` and `drillRawTotals[i][j]` to `totals[i].rows[j]`. Each + * map holds the raw grouped value of the `dimensionFields` dimensions that + * grouping groups by, captured in the same pass, so the grand-total + * grouping (`[]`) yields an empty map per row. + * + * Set only when `dimensionFields` is and the answer carries at least one + * `totals` grouping. + */ + drillRawTotals?: Array>>; + /** + * The half-open range `[gte, lt)` each row's time bucket covers, aligned + * to `rows` by index: `drillRanges[i]` maps date dimension NAME → + * `{ field, gte, lt }`, `field` being that dimension's `field`. A bucket + * ("2026-Q2") groups a span of records, so it drills by range rather than + * by the equality `drillRawRows` carries. `gte` and `lt` are `YYYY-MM-DD` + * calendar days, except for a dimension whose source field is `datetime`: + * those are ISO-8601 instants at midnight in the reference timezone (the + * selection's `timezone`, else the request's, else UTC). A row whose + * bucket value is `null` gets no entry for that dimension. + * + * Set only on a `queryDataset` answer that has rows and whose selection + * groups by at least one `type: 'date'` dimension with a `field` and a + * granularity (the selection's, else the dimension's `dateGranularity`). + * Such a dimension is left out when its source field is neither `date` + * nor `datetime` (or its type is unknown) and the reference timezone is + * not UTC. Independent of `dimensionFields`: a date-only grouping carries + * `drillRanges` and none of the equality sidecars. + */ + drillRanges?: Array>; } /**