Skip to content
Draft
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
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: not-required (runtime-interface-only packages/client/src/index.ts#ObjectStackClient) The four changed members are methods of a published runtime TypeScript class. What moves is the VALUE each promise resolves to — the dispatcher's `{ success, data }` envelope before, its `data` member after — and the matching return annotations. No Zod schema changes, no `packages/spec` declaration moves, no authorable key and no stored representation is involved: the wire body every route answers is byte-identical before and after, only the SDK's reader changed. So `objectstack migrate meta` has nothing to visit and there is no tombstone to mint; the channel that reaches every affected consumer is the COMPILER at the call site (`error TS2339: Property 'data' does not exist on type 'AnalyticsResult'`), which is strictly more precise than a ledger line. Recorded rather than worked around: this body carries a migration table, and the gate refuses `runtime-interface-only` on any body that prescribes a rewrite; `type-surface-only` (#13080) is scoped by its predicate 4 to symbols that were `any` at the merge base, and these four were concrete envelope types there, so no verifiable category fits a concrete-to-concrete runtime interface move that ships its prescription. The disposition is left stated rather than swapped for one that would pass through a detector blind spot. -->

**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);
});
});
Loading
Loading