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
23 changes: 23 additions & 0 deletions .changeset/20647-analytics-result-object.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion content/docs/references/api/analytics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>[]; fields: object[]; sql?: string; totals?: object[] }` | ✅ | |
| **data** | `{ rows: Record<string, any>[]; fields: object[]; sql?: string; totals?: object[]; … }` | ✅ | |

### Nested Shape: `AnalyticsResultResponse.error`

Expand Down Expand Up @@ -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<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. |


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

Expand Down
27 changes: 27 additions & 0 deletions packages/spec/src/contracts/analytics-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] }),
Expand Down
15 changes: 15 additions & 0 deletions packages/spec/src/contracts/analytics-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,21 @@ export interface AnalyticsResult {
dimensions: string[];
rows: Record<string, unknown>[];
}>;
/**
* 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;
}

/**
Expand Down
Loading