Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/20700-analytics-result-drill-sidecars.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions content/docs/references/api/analytics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,10 @@ const result = AnalyticsEndpoint.parse(data);
| **sql** | `string` | optional | Executed SQL (if debug enabled) |
| **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. |
| **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<string, string>` | 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<string, any>[]` | 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<string, any>[][]` | 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<string, { field: string; gte: string; lt: string }>[]` | 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. |


---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ hand-patch a number here** β€” fix the code or the verdict and regenerate.

| Dir | Sites |
|---|---|
| `api/` | 431 |
| `api/` | 432 |
57 changes: 57 additions & 0 deletions packages/spec/src/api/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
37 changes: 37 additions & 0 deletions packages/spec/src/api/analytics.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
),
}),
}));

Expand Down
45 changes: 45 additions & 0 deletions packages/spec/src/contracts/analytics-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] }),
Expand Down
63 changes: 63 additions & 0 deletions packages/spec/src/contracts/analytics-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
/**
* 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<Record<string, unknown>>;
/**
* 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<Array<Record<string, unknown>>>;
/**
* 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<Record<string, { field: string; gte: string; lt: string }>>;
}

/**
Expand Down
Loading