Skip to content
64 changes: 64 additions & 0 deletions .changeset/client-envelope-convergence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
---
"@objectstack/client": minor
---

feat(client)!: `analytics.query` / `analytics.meta` / `analytics.explain` and `automation.trigger` resolve to the payload — the dispatcher envelope is unwrapped, as on every other SDK method (#13079)

<!-- adr-0087: registered client-envelope-convergence-analytics-automation -->

**BREAKING** — a runtime change to what four published SDK methods resolve to. It ships as `minor` under the lockstep launch-window convention (`scripts/check-changeset-no-major.mjs`): the version number is not the migration signal here, this entry is.

Maintainer ruling on #13079 (2026-08-31, verbatim): 「裁决:A,cloud 未测量照裁」 — 「四方法(`analytics.query` / `analytics.meta` / `analytics.explain` / `automation.trigger`)收敛 `unwrapResponse`,SDK 一套读法。」

## What changed

`ObjectStackClient` had two response readers. `unwrapResponse` strips the runtime dispatcher's `{ success, data }` envelope and hands back `data` — every other dispatcher-served method uses it, and every return type bound since #8140 is that post-unwrap payload. These four ended `return res.json()`, which strips nothing, so their callers alone had to read `.data`; the sharpest case was `automation.trigger` and `automation.execute` answering two shapes for one handler. All four now end `return this.unwrapResponse(res)`, and their return declarations are the payload types, derived from the route's declared `data` member where the spec already transcribes it.

## Migration

| method | resolved to (before) | resolves to (now) | rewrite |
|:--|:--|:--|:--|
| `client.analytics.query(q)` | `{ success, data: AnalyticsResult, meta? }` | `AnalyticsResult` | `r.data.rows` → `r.rows` |
| `client.analytics.meta(cube?)` | `AnalyticsMetadataResponse` — `{ success, data: CubeMeta[], meta? }` | `AnalyticsMetadataResponse['data']` — the bare cube list | `r.data[0].name` → `r[0].name` |
| `client.analytics.explain(q)` | `AnalyticsSqlResponse` — `{ success, data: { sql, params }, meta? }` | `AnalyticsSqlResponse['data']` — `{ sql, params }` | `r.data.sql` → `r.sql` |
| `client.automation.trigger(name, payload)` | `{ success, data: AutomationResult, meta? }` | `AutomationResult` — the same value `client.automation.execute` resolves to | `r.data.status` → `r.status`, `r.data.runId` → `r.runId` |

Before / after, per method:

```ts
const r1 = await client.analytics.query({ cube: 'crm_account', measures: ['account_count'] });
r1.data.rows; // before
r1.rows; // now

const r2 = await client.analytics.meta();
r2.data[0].name; // before
r2[0].name; // now

const r3 = await client.analytics.explain({ cube: 'crm_account', measures: ['account_count'] });
r3.data.sql; // before
r3.sql; // now

const r4 = await client.automation.trigger('approve_account', {});
r4.data.status; // before ('paused' | 'completed' | 'failed')
r4.status; // now — exactly what `client.automation.execute` already answered
```

For the three analytics methods every old read is a compile error under the new declarations (`Property 'data' does not exist on type …`, TS2339), so a TypeScript consumer finds each site at build time. `client.automation.trigger` is the exception: `r.data.…` is a compile error there too, but `r.success` and `r.error` compile before AND after, because `AutomationResult` itself declares `success: boolean` and `error?: string` (`packages/spec/src/contracts/automation-service.ts`). Their MEANING moves: before, `r.success` was the envelope's flag — always `true` on a resolved call — and `r.error` was never set on a 2xx; now they are the run's own — `success: false` / `error: string` on a refusal the door does not classify as 400/409/422 and answers 200. A consumer branching on `r.success` or `r.error` off `trigger` must re-read that branch by hand; the compiler will not point at it. A JavaScript consumer reads `undefined` from `.data` and has to search for the four spellings.

### The failure path — read this before touching a `catch`

Nothing changes there, and it is stated per door because a convergence on `unwrapResponse` could be misread as "errors now throw":

- **Non-2xx answers threw before and throw now.** `ObjectStackClient.fetch` rejects on every non-2xx status BEFORE either reader runs, carrying the ADR-0112 envelope on the error (`err.code`, `err.httpStatus`, `err.message`, `err.details`). A failed `trigger` run has been a thrown `400 FLOW_FAILED` since #9378 (`409 FLOW_DISABLED` / `422 FLOW_NO_START_NODE` since #9415); a query the analytics service refuses is a thrown 4xx. Your `catch` blocks are unchanged.
- **`unwrapResponse` never throws.** A 2xx body with a boolean `success` and a `data` key resolves to `data`. A 2xx body with no `data` key resolves unchanged (pass-through) — and no dispatcher door behind these four routes sends a 2xx without `data`, so at the ENVELOPE level a resolved `{ success: false, error }` is not a value you will receive from them. ⚠️ The PAYLOAD level differs on one door: `client.automation.trigger` can resolve to an `AutomationResult` whose own `success` is `false` (with `error` set) — a run the door does not classify as 400 `FLOW_FAILED` / 409 `FLOW_DISABLED` / 422 `FLOW_NO_START_NODE` is answered 200 through `deps.success(result)` (`respondToFlowTrigger` in `packages/runtime/src/domains/automation.ts`; the classification table is `classifyFlowRefusal` in `packages/runtime/src/flow-dispatch-status.ts`), exactly as `client.automation.execute` already does for the same handler. Before this change that run reached you as `{ success: true, data: { success: false, error } }`; now it reaches you as the inner object.
- **What you lose.** The envelope's `success` flag — always `true` on a resolved call — is no longer on the resolved value. Its `meta` slot is gone too, but these four doors never populated it: each answers `deps.success(result)` with no meta argument and JSON serialisation drops the `undefined`, so there was never a `meta.requestId` to read here. Neither key was ever on any other SDK method's value.

### Not changed

- `client.analytics.queryDataset(...)` — served by `@objectstack/rest` with no envelope at all; it resolved to the bare `AnalyticsResult` before and still does (ruling item 1: protected, not converted).
- The wire. Every route answers exactly the body it answered before; a raw-HTTP caller is unaffected.
- `client.automation.execute`, `client.automation.resume` and every other method that already used `unwrapResponse`.

### Populations measured, and the one ruled NOT MEASURED

`packages/client/src/envelope-caller-census.test.ts` (PR #13647) measured the callers: in this repo, zero production call sites and 13 loud test pins — this change's own diff — and in objectui one production site whose row-extraction chain accepts both spellings today (objectui#7028 tightens it to the post-unwrap spelling after this lands). `objectstack-ai/cloud` was not measured (ruling item 4); the census file carries `CLOUD_CENSUS_COMMAND`, and a `.data` read on any of these four there is a runtime break after this change.
121 changes: 66 additions & 55 deletions packages/client/src/analytics-automation-json-erasure.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#12104 — the in-repo half] What the five `return res.json()` methods of the
* `analytics.*` / `automation.*` families actually resolve to, measured against
* their REAL producers.
* [#12104 — the in-repo half; #13079 — the convergence] What the five
* `analytics.*` / `automation.trigger` methods actually resolve to, measured
* against their REAL producers.
*
* ## The erasure these five carried
*
Expand Down Expand Up @@ -35,20 +35,26 @@
* ## The load-bearing fact these five share, and the one that splits them
*
* `unwrapResponse` strips the `{ success, data }` envelope; `res.json()` does
* NOT. So a `res.json()` method resolves to the WHOLE body, and the shape of
* that body is decided by which surface serves the route:
* NOT. The shape of the body is decided by which surface serves the route,
* and the reader has to match it:
*
* - `query` / `meta` / `explain` and `automation.trigger` are DISPATCHER
* routes, and every dispatcher domain answers through `deps.success(v)` —
* `{ success: true, data: v }`. Their true type is the envelope, not `v`.
* `{ success: true, data: v }`. Since #13079 (maintainer ruling
* 2026-08-31, option A) all four end `unwrapResponse`, so they resolve to
* `v`; until then they ended `res.json()` and resolved to the envelope,
* which #12104 had stated in their declarations.
* - `queryDataset` is a REST route (`@objectstack/rest` mounts it; the
* dispatcher mounts no twin) and it answers `res.json(result)` — BARE. Its
* true type is `v` itself.
* dispatcher mounts no twin) and it answers `res.json(result)` — BARE. It
* keeps `res.json()`, which there IS the payload read; ⛔ PROTECTED by the
* ruling from being "converged" into the others' shape.
*
* Binding the payload where the envelope is served (or the reverse) would
* typecheck against `any` and ship a false declaration, which is the census's
* highest-risk band (`return-type-precision.test.ts`, shape class 2). Hence one
* driven case per method rather than a family-wide assumption.
* So all five now resolve to `v`, by two different readers, and each
* (reader, surface) pair is driven here against the real producer: converting
* the bare route to `unwrapResponse`, or sliding a dispatcher route back to
* `res.json()`, would leave the declarations false with no type error — this
* file's cases are what go red. Hence one driven case per method rather than
* a family-wide assumption.
*
* ## Two spec response schemas WERE narrower than their producer — measured here
*
Expand All @@ -72,8 +78,10 @@
* Removing an annotation from any of the five leaves THIS file green — the wire
* value does not change — and turns `return-type-precision.test.ts` RED under
* `tsc`, plus `check:exported-any-returns` red on the un-deleted ledger entry.
* That asymmetry is the whole reason both files exist; the ablation is recorded
* on the PR against the halves a declaration change can move.
* Reverting one of the four #13079 conversions (`unwrapResponse` back to
* `res.json()`) turns THIS file red on that method's case — the resolved value
* regains the envelope — and `return-type-precision.test.ts` red on its
* reversed pin. Both asymmetries are why the files exist as a pair.
*/

import { describe, it, expect, vi } from 'vitest';
Expand Down Expand Up @@ -299,72 +307,73 @@ function producerBackedClient() {

// ─────────────────────────────────────────────────────────────────────────────

describe('#12104 — the four DISPATCHER-served methods resolve to the envelope, not the payload', () => {
it('analytics.query answers `{ success, data: AnalyticsResult }`', async () => {
describe('#13079 — the four DISPATCHER-served methods resolve to the PAYLOAD, measured against the real producers', () => {
it('analytics.query answers the AnalyticsResult itself — the producer\'s own return, no envelope', async () => {
const { client, analytics } = producerBackedClient();

const body = await client.analytics.query({
const result = await client.analytics.query({
cube: 'crm_account',
measures: ['account_count'],
dimensions: ['industry'],
});

// ① The envelope is the value — NOT the payload. This is the whole
// difference between `res.json()` and `unwrapResponse`.
expect(Object.keys(body).sort()).toEqual(['data', 'meta', 'success']);
expect(body.success).toBe(true);
// ② …and `data` is verbatim what the producer's own contract method
// ① The payload is the value — NOT the envelope. Before #13079 the
// keys here were `['data', 'meta', 'success']`; `unwrapResponse`
// strips exactly that layer and nothing else.
expect('success' in result).toBe(false);
expect('data' in result).toBe(false);
// ② …and the value is verbatim what the producer's own contract method
// returned, asserted against a second call to the service itself
// rather than against a literal written here.
expect(body.data).toEqual(await analytics.query({
expect(result).toEqual(await analytics.query({
cube: 'crm_account',
measures: ['account_count'],
dimensions: ['industry'],
}));
expect(body.data.rows).toEqual(ROWS);
expect(result.rows).toEqual(ROWS);
});

it('analytics.meta answers `{ success, data: CubeMeta[] }`', async () => {
it('analytics.meta answers the bare CubeMeta[] — no envelope, no `cubes` wrapper', async () => {
const { client, analytics } = producerBackedClient();

const body = await client.analytics.meta();
const cubes = await client.analytics.meta();

expect(body.success).toBe(true);
expect(body.data).toEqual(await analytics.getMeta());
// A BARE array under `data` — there is no `cubes` wrapper (#6442).
expect(Array.isArray(body.data)).toBe(true);
expect(body.data[0]?.name).toBe('crm_account');
expect(body.data[0]?.measures.map((m) => m.name)).toContain('crm_account.account_count');
expect(cubes).toEqual(await analytics.getMeta());
// A BARE array — there is no `cubes` wrapper (#6442) and, since
// #13079, no `{ success, data }` around it either.
expect(Array.isArray(cubes)).toBe(true);
expect(cubes[0]?.name).toBe('crm_account');
expect(cubes[0]?.measures.map((m) => m.name)).toContain('crm_account.account_count');
});

it('analytics.explain answers `{ success, data: { sql, params } }`', async () => {
it('analytics.explain answers `{ sql, params }`', async () => {
const { client } = producerBackedClient();

const body = await client.analytics.explain({
const dryRun = await client.analytics.explain({
cube: 'crm_account',
measures: ['account_count'],
dimensions: ['industry'],
});

expect(body.success).toBe(true);
expect(Object.keys(body.data).sort()).toEqual(['params', 'sql']);
expect(body.data.sql).toMatch(/SELECT/i);
expect(Array.isArray(body.data.params)).toBe(true);
expect(Object.keys(dryRun).sort()).toEqual(['params', 'sql']);
expect(dryRun.sql).toMatch(/SELECT/i);
expect(Array.isArray(dryRun.params)).toBe(true);
});

it('automation.trigger answers `{ success, data: AutomationResult }` — the whole result', async () => {
it('automation.trigger answers the AutomationResult — the whole run, the same value `execute` answers', async () => {
const { client } = producerBackedClient();

const body = await client.automation.trigger('approve_account', {});
const run = await client.automation.trigger('approve_account', {});

expect(body.success).toBe(true);
// 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');
// #13078, served by the real engine and — since #13079 — read at the
// top level, exactly where `automation.execute` has always put them.
expect(run.status).toBe('paused');
expect(typeof run.runId).toBe('string');
expect(run.screen?.title).toBe('Approve the account');
// `AutomationResult` carries its OWN `success`; the envelope's is gone.
expect(run.success).toBe(true);
expect('data' in run).toBe(false);
});
});

Expand Down Expand Up @@ -406,12 +415,13 @@ describe('#12104 — the REST-served method resolves to the BARE payload', () =>
});
});

describe('#12104 — the premise the four envelope annotations rest on', () => {
it('the dispatcher wraps exactly once, and `res.json()` strips nothing', async () => {
// Runtime-observable and deliberately so: every envelope annotation this
// card adds describes the PRE-unwrap value, so if a domain stopped
// wrapping (or the SDK started unwrapping here) the declarations would
// become false without a single type error.
describe('#13079 — the premise the four payload annotations rest on', () => {
it('the dispatcher wraps exactly once, and `unwrapResponse` strips exactly once', async () => {
// Runtime-observable and deliberately so: every payload annotation
// describes the POST-unwrap value, so if a domain stopped wrapping (the
// SDK would then hand back `data`'s `data`, or the pass-through) or a
// method slid back to `res.json()` (the envelope would return) the
// declarations would become false without a single type error.
const { client, dispatcher } = producerBackedClient();

const raw = await dispatcher.handleAnalytics('/meta', 'GET', undefined, CONTEXT(), {});
Expand All @@ -420,7 +430,8 @@ describe('#12104 — the premise the four envelope annotations rest on', () => {
expect(produced.success).toBe(true);
expect(Array.isArray(produced.data)).toBe(true);

// The SDK hands the caller the producer's body itself — envelope included.
expect(await client.analytics.meta()).toEqual(produced);
// The SDK hands the caller the producer's `data` — one envelope
// stripped, nothing else touched.
expect(await client.analytics.meta()).toEqual(produced.data);
});
});
7 changes: 4 additions & 3 deletions packages/client/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1546,9 +1546,10 @@ describe('ObjectStackClient.automation', () => {
// fetch layer throws on non-2xx before any unwrapping, so both surfaces
// REJECT. No SDK code changed; the contract did, and these are its pins.
//
// Both spellings are pinned, not one: `trigger()` reads `res.json()` while
// `execute()` reads `unwrapResponse()`, so a regression in either unwrap
// path would be invisible from the other's test.
// Both spellings are pinned, not one: `trigger()` and `execute()` are two
// URLs into one handler, and since #13079 both read `unwrapResponse()`;
// a regression on either door's rejection path would still be invisible
// from the other's test (the paths diverge before the shared reader).
const failedRunBody = {
success: false,
error: {
Expand Down
Loading
Loading