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
6 changes: 6 additions & 0 deletions .changeset/issue-13078-response-schema-parity.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
"@objectstack/spec": minor
"@objectstack/client": patch
---

Widen two route response schemas to parity with the producer contracts their routes relay. `AnalyticsResultResponseSchema.data` now declares everything `AnalyticsResult` declares — `fields[].label` / `format` / `currency` / `percentScale` (the renderer chains) and `totals` (the marginal-aggregate channel) — and `TriggerFlowResponseSchema.data` now declares everything `AutomationResult` declares, including the paused screen-flow state (`status` / `runId` / `screen`), the closed `code` classification, the friendly terminal messages and the run `summary`. Both parities are pinned schema ≡ contract at compile time, so the two sources can no longer drift apart silently. `AnalyticsResultResponse` / `AnalyticsResultResponseParsed` are now exported: the schema previously had no nameable response type at all. This is an accept-set widening with zero wire change — every payload that parsed before still parses, and the served keys the schemas used to silently strip (a paused run's `runId` and `screen`, a measure's `label`) now survive a parse. The client SDK's `analytics.query` / `automation.trigger` docblocks are refreshed to record the new state; their bindings still target the producer contracts and are unchanged.
7 changes: 4 additions & 3 deletions content/docs/references/api/analytics.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,7 @@ Provides endpoints for executing analytical queries and discovering metadata.

```typescript
import { AnalyticsEndpoint, AnalyticsMetadataResponseSchema, AnalyticsQueryRequestSchema, AnalyticsResultResponseSchema, AnalyticsSqlResponseSchema, GetAnalyticsMetaRequestSchema } from '@objectstack/spec/api';
import type { AnalyticsEndpoint, AnalyticsMetadataResponse, AnalyticsQueryRequest, AnalyticsSqlResponse, GetAnalyticsMetaRequest } from '@objectstack/spec/api';
import type { AnalyticsEndpoint, AnalyticsMetadataResponse, AnalyticsQueryRequest, AnalyticsResultResponse, AnalyticsSqlResponse, GetAnalyticsMetaRequest } from '@objectstack/spec/api';

// Validate data
const result = AnalyticsEndpoint.parse(data);
Expand DownExpand Up@@ -103,7 +103,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?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ rows: Record<string, any>[]; fields: object[]; sql?: string }` | ✅ | |
| **data** | `{ rows: Record<string, any>[]; fields: object[]; sql?: string; totals?: object[] }` | ✅ | |

### Nested Shape: `AnalyticsResultResponse.error`

Expand All@@ -123,8 +123,9 @@ const result = AnalyticsEndpoint.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **rows** | `Record<string, any>[]` | ✅ | Result rows |
| **fields** | `{ name: string; type: string }[]` | ✅ | Column metadata |
| **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. |


---
Expand Down
9 changes: 8 additions & 1 deletion content/docs/references/api/automation-api.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -583,7 +583,7 @@ const result = AutomationApiErrorCode.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?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ success: boolean; output?: any; error?: string; durationMs?: number }` | ✅ | |
| **data** | `{ success: boolean; output?: any; error?: string; durationMs?: number; … }` | ✅ | |

### Nested Shape: `TriggerFlowResponse.error`

Expand All@@ -606,6 +606,13 @@ const result = AutomationApiErrorCode.parse(data);
| **output** | `any` | optional | Output data from the automation |
| **error** | `string` | optional | Error message if execution failed |
| **durationMs** | `number` | optional | Execution duration in milliseconds |
| **code** | `Enum<'PERMISSION_DENIED' \| 'INVALID_SIGNAL' \| 'RUN_NOT_FOUND' \| 'STORE_UNAVAILABLE' \| …>` | optional | Machine-readable failure classification, set alongside `error` when the caller must distinguish WHY it failed. A closed union - the members and their transport mappings are documented on the contract (`AutomationResult.code`, contracts/automation-service.ts). |
| **status** | `Enum<'completed' \| 'paused' \| 'failed'>` | optional | Lifecycle status. `paused` means the run suspended at a node and can be continued with the resume route. Absent or `completed`/`failed` means the run reached a terminal state. |
| **runId** | `string` | optional | Run id - set when `status` is `paused`, so callers can resume it |
| **screen** | `{ nodeId: string; title?: string; description?: string; fields: object[]; … }` | optional | The screen to render - set when the run paused at a `screen` node awaiting user input. The client collects values for `screen.fields` and resumes the run with them. |
| **successMessage** | `string` | optional | Friendly terminal message copied from the flow definition on terminal success, so a screen-flow runner can show a meaningful toast |
| **errorMessage** | `string` | optional | Friendly terminal message copied from the flow definition on failure |
| **summary** | `{ selected: integer; acted: integer; skipped: integer; unmeasured?: integer; … }` | optional | What the run did - records selected / acted on, gate skips, per-node status. Set on a TERMINAL result (a paused run has not finished doing it yet). |


---
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -257,7 +257,7 @@ directory rather than per file.
| Dir | Sites |
|---|---|
| `ai/` | 77 |
| `api/` | 444 |
| `api/` | 448 |
| `cloud/` | 83 |
| `identity/` | 32 |
| `integration/` | 10 |
Expand Down
57 changes: 33 additions & 24 deletions packages/client/src/analytics-automation-json-erasure.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,17 +50,21 @@
* highest-risk band (`return-type-precision.test.ts`, shape class 2). Hence one
* driven case per method rather than a family-wide assumption.
*
* ## Two spec response schemas are NARROWER than their producer — measured here
* ## Two spec response schemas WERE narrower than their producer — measured here
*
* `AnalyticsResultResponseSchema.data` and `TriggerFlowResponseSchema.data` are
* stale projections of `AnalyticsResult` / `AutomationResult`: the fixtures
* below serve keys those schemas do not declare (`fields[].label`, and a paused
* run's `status` / `runId` / `screen`). That is why the two annotations bind the
* PRODUCER's contract type rather than those two response types — binding them
* would have been a false narrowing of exactly the kind #12034 removed. The
* other two route schemas (`AnalyticsMetadataResponseSchema`,
* `AnalyticsSqlResponseSchema`) DO agree with their producer's declared return,
* and the annotations use them.
* When #12104 landed, `AnalyticsResultResponseSchema.data` and
* `TriggerFlowResponseSchema.data` were stale projections of
* `AnalyticsResult` / `AutomationResult`: the fixtures below serve keys those
* schemas did not then declare (`fields[].label`, and a paused run's
* `status` / `runId` / `screen`). That is why the two annotations bind the
* PRODUCER's contract type rather than those two response types — binding
* them would have been a false narrowing of exactly the kind #12034 removed.
* #13078 has since widened both schemas to parity (pinned schema ≡ contract
* in `spec/api/analytics.test.ts` / `spec/api/automation-api.zod.test.ts`);
* the annotations stay on the contracts, which are the source the routes
* relay. The other two route schemas (`AnalyticsMetadataResponseSchema`,
* `AnalyticsSqlResponseSchema`) agreed with their producer's declared return
* all along, and the annotations use them.
*
* ---------------------------------------------------------------------------
* Reverse verification, direction predicted BEFORE running
Expand DownExpand Up@@ -97,10 +101,12 @@ const CONTEXT = (): any => ({

/**
* One cube with a LABELLED measure and dimension. The labels are load-bearing
* rather than decorative: `AnalyticsResult.fields[].label` is a key
* `AnalyticsResultResponseSchema.data.fields` does not declare, so serving it
* is what proves that schema is a narrower projection than the producer's
* contract — the measurement the annotation choice rests on.
* rather than decorative: `AnalyticsResult.fields[].label` was a key
* `AnalyticsResultResponseSchema.data.fields` did not declare when #12104
* measured it, so serving it is what proved that schema a narrower projection
* than the producer's contract — the measurement the annotation choice rests
* on (#13078 has since widened the schema to parity; the served key is the
* evidence either way).
*/
const ACCOUNT_CUBE: Cube = {
name: 'crm_account',
Expand All@@ -119,8 +125,8 @@ const ROWS = [{ industry: 'tech', account_count: 3 }];
/**
* The ADR-0021 dataset the REST-served `queryDataset` route runs. Its dimension
* carries a `label` on purpose: the dataset executor enriches
* `AnalyticsResult.fields[].label` from it, which is the key
* `AnalyticsResultResponseSchema.data.fields` does not declare.
* `AnalyticsResult.fields[].label` from itthe key whose serving proved the
* pre-#13078 schema narrower than the contract (see the cube above).
*/
const DATASET = {
name: 'account_metrics',
Expand DownExpand Up@@ -160,7 +166,7 @@ const gate = defineActionDescriptor({
/**
* start → gate (pauses) → end. A PAUSED run is chosen deliberately: it is the
* arm whose `AutomationResult` carries `status` / `runId` / `screen`, none of
* which `TriggerFlowResponseSchema.data` declares.
* which `TriggerFlowResponseSchema.data` declared before #13078 widened it.
*/
function realAutomation(): AutomationEngine {
const engine = new AutomationEngine(
Expand DownExpand Up@@ -352,8 +358,10 @@ describe('#12104 — the four DISPATCHER-served methods resolve to the envelope,
const body = await client.automation.trigger('approve_account', {});

expect(body.success).toBe(true);
// The keys `TriggerFlowResponseSchema.data` does NOT declare, served by
// the real engine: this is why the annotation binds `AutomationResult`.
// The keys `TriggerFlowResponseSchema.data` did NOT declare before
// #13078, served by the real engine: this measurement is why the
// annotation binds `AutomationResult` (and, since #13078, why the
// schema had to move to parity with it).
expect(body.data.status).toBe('paused');
expect(typeof body.data.runId).toBe('string');
expect(body.data.screen?.title).toBe('Approve the account');
Expand All@@ -377,14 +385,15 @@ describe('#12104 — the REST-served method resolves to the BARE payload', () =>
expect(Array.isArray(body.fields)).toBe(true);
});

it('and it serves a `fields[].label` the analytics RESPONSE schema does not declare', async () => {
it('and it serves the `fields[].label` that proved the pre-#13078 response schema narrower', async () => {
// The measurement behind one of the two annotation choices. `query` and
// `queryDataset` are the SAME contract return — `IAnalyticsService`
// declares `Promise< AnalyticsResult >` for both — so a key the service
// really emits is a key `AnalyticsResult` really carries. And
// `AnalyticsResultResponseSchema.data.fields` declares only
// `{ name, type }`, so binding that schema on `analytics.query` would
// have been a FALSE narrowing of the contract the route relays.
// really emits is a key `AnalyticsResult` really carries. When #12104
// measured this, `AnalyticsResultResponseSchema.data.fields` declared
// only `{ name, type }`, so binding that schema on `analytics.query`
// would have been a FALSE narrowing of the contract the route relays;
// #13078 has since widened the schema to parity with the contract.
const { client } = producerBackedClient();

const body = await client.analytics.queryDataset({
Expand Down
45 changes: 28 additions & 17 deletions packages/client/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1492,15 +1492,19 @@ export class ObjectStackClient {
* The `data` member is `IAnalyticsService.query`'s declared return, relayed
* verbatim by the domain (`deps.success(await analyticsService.query(…))`).
*
* ⛔ NOT bound to `AnalyticsResultResponseSchema` (`@objectstack/spec/api`),
* which looks like the route's own response type: its
* `data.fields` declares `{ name, type }` only, while the contract this
* route relays also carries `label` / `format` / `currency` /
* `percentScale` and `totals` — measured on a real `AnalyticsService` in
* `analytics-automation-json-erasure.test.ts`. Binding it would have
* narrowed the declaration below what the producer serves, which is the
* bound-but-false shape #12034 paid to remove. (The schema's own drift is
* a spec-side question, filed separately.)
* Deliberately bound to the CONTRACT, not to `AnalyticsResultResponse`
* (`@objectstack/spec/api`). When #12104 wrote this annotation the schema
* was a stale projection — its `data.fields` declared `{ name, type }`
* only, while the contract this route relays also carries `label` /
* `format` / `currency` / `percentScale` and `totals` (measured on a real
* `AnalyticsService` in `analytics-automation-json-erasure.test.ts`), so
* binding it would have been the bound-but-false shape #12034 paid to
* remove. #13078 has since brought the schema to parity (pinned
* schema ≡ contract in `spec/api/analytics.test.ts`), but the binding
* stays on the producer's contract on purpose: the contract is what the
* route relays, and the schema is its transcription — annotating the
* source rather than the copy is what keeps this method immune to the
* transcription drifting again.
*/
query: async (payload: any): Promise<BaseResponse & { data: AnalyticsResult }> => {
const route = this.getRoute('analytics');
Expand DownExpand Up@@ -3806,14 +3810,21 @@ export class ObjectStackClient {
* therefore resolves to the `AutomationResult` alone. The two differ in
* the wrapper only, which is why the payload type is the same one.
*
* ⛔ NOT bound to `TriggerFlowResponse` (`@objectstack/spec/api`), which
* looks like this route's response type: its `data` declares
* `{ success, output?, error?, durationMs? }`, and the door also serves
* `status` / `runId` / `screen` (a paused run — see the row above),
* `code`, `successMessage` / `errorMessage` and `summary`. Measured
* against the real `AutomationEngine` in
* `analytics-automation-json-erasure.test.ts`. Binding the narrower
* schema would refuse the very reads this docblock tells callers to make.
* Deliberately bound to the CONTRACT, not to `TriggerFlowResponse`
* (`@objectstack/spec/api`). When #12104 wrote this annotation the
* schema was a stale projection — its `data` declared
* `{ success, output?, error?, durationMs? }` while the door also
* serves `status` / `runId` / `screen` (a paused run — see the row
* above), `code`, `successMessage` / `errorMessage` and `summary`
* (measured against the real `AutomationEngine` in
* `analytics-automation-json-erasure.test.ts`), so binding it would
* have refused the very reads this docblock tells callers to make.
* #13078 has since brought the schema to parity (pinned
* schema ≡ contract in `spec/api/automation-api.zod.test.ts`), but the
* binding stays on the producer's contract on purpose: the contract is
* what the route relays, and the schema is its transcription —
* annotating the source rather than the copy is what keeps this method
* immune to the transcription drifting again.
*/
trigger: async (triggerName: string, payload: any): Promise<BaseResponse & { data: AutomationResult }> => {
const route = this.getRoute('automation');
Expand Down
21 changes: 13 additions & 8 deletions packages/client/src/return-type-precision.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -488,14 +488,19 @@ export async function returnTypePrecisionPins12104(): Promise<void> {
// @ts-expect-error `queryDataset` is served bare by @objectstack/rest — there is no envelope
void (await client.analytics.queryDataset({ selection: { measures: ['n'] } })).data;

// The two spec response types that LOOK like the right binding and are
// narrower than the contract their route relays. Pinned at the binding so a
// future sweep cannot "tidy" either annotation onto them: the keys below are
// served by the real producers and neither schema declares them.
// @ts-expect-error `TriggerFlowResponse.data` declares no `runId` — a paused run carries one
void (undefined as unknown as TriggerFlowResponse).data.runId;
// @ts-expect-error `TriggerFlowResponse.data` declares no `screen` — a screen-flow pause carries one
void (undefined as unknown as TriggerFlowResponse).data.screen;
// [#13078] RE-JUDGED, not re-spelled (the #6442 treatment): two
// suppressions here used to pin that `TriggerFlowResponse.data` declared
// neither `runId` nor `screen` — the near-miss trap that made binding the
// schema a false narrowing. That premise is retired by design:
// #13078 widened both stale schemas to parity with the contracts their
// routes relay, so the old pins' suppressions would now be UNUSED
// (TS2578) precisely because the defect they pinned is fixed. The new
// truth is stronger and pinned as an equality: the schema's `data` IS the
// producer contract. Narrow the schema again and this goes red — the same
// guard, pointing in the direction that is now true. (The annotations
// above still bind the CONTRACT on purpose: it is the source the routes
// relay; the schema is its transcription.)
expectTypeOf<TriggerFlowResponse['data']>().toEqualTypeOf<AutomationResult>();
}

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/api-surface/api.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,8 @@
"AnalyticsProtocol (interface)",
"AnalyticsQueryRequest (type)",
"AnalyticsQueryRequestSchema (const)",
"AnalyticsResultResponse (type)",
"AnalyticsResultResponseParsed (type)",
"AnalyticsResultResponseSchema (const)",
"AnalyticsSqlResponse (type)",
"AnalyticsSqlResponseParsed (type)",
Expand Down
1 change: 0 additions & 1 deletion packages/spec/docs-import-surface.baseline.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,6 @@
"ai/AIModelConfig — no type export",
"ai/CodeContent — no type export",
"ai/ImageContent — no type export",
"api/AnalyticsResultResponse — no type export",
"api/Discovery — no type export",
"api/HttpFindQueryParams — no type export",
"api/MetadataExportRequest — no type export",
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/export-origins/api.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,8 @@
"AnalyticsProtocol": "src/api/protocol.zod.ts#AnalyticsProtocol (interface)",
"AnalyticsQueryRequest": "src/api/analytics.zod.ts#AnalyticsQueryRequest (type)",
"AnalyticsQueryRequestSchema": "src/api/analytics.zod.ts#AnalyticsQueryRequestSchema (const)",
"AnalyticsResultResponse": "src/api/analytics.zod.ts#AnalyticsResultResponse (type)",
"AnalyticsResultResponseParsed": "src/api/analytics.zod.ts#AnalyticsResultResponseParsed (type)",
"AnalyticsResultResponseSchema": "src/api/analytics.zod.ts#AnalyticsResultResponseSchema (const)",
"AnalyticsSqlResponse": "src/api/analytics.zod.ts#AnalyticsSqlResponse (type)",
"AnalyticsSqlResponseParsed": "src/api/analytics.zod.ts#AnalyticsSqlResponseParsed (type)",
Expand Down
Loading
Loading