Skip to content

Commit db16b94

Browse files
os-trumpclaude
andauthored
feat(client)!: converge analytics.query / meta / explain and automation.trigger on unwrapResponse — one SDK calling convention (#13079) (#14526)
* feat(client)!: converge analytics.query/meta/explain and automation.trigger on unwrapResponse The four dispatcher-served methods ended `return res.json()` and handed their callers the `{ success, data }` envelope whole, while every other dispatcher-served method of ObjectStackClient strips it through `unwrapResponse`. All four now end `return this.unwrapResponse(res)` and declare the payload: `AnalyticsResult`, `AnalyticsMetadataResponse['data']`, `AnalyticsSqlResponse['data']`, `AutomationResult` (the value `automation.execute` already answered for the same handler). `analytics.queryDataset` is protected: served bare by @objectstack/rest, it keeps `res.json()`; its body is byte-identical (only its docblock says why). Tests: `envelope-convergence.test.ts` (new, mocked transport: the payload on all four, the unchanged rejection path, the no-`data` pass-through, and the protected method); `analytics-automation-json-erasure.test.ts` re-driven against the real producers to the payload; `return-type-precision.test.ts` pins reversed (payload compiles, `.data` is the type error); the caller census regenerated to the post-convergence ledger with the cloud census command kept and reworded. One breaking changeset with the migration table. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * test(client): let the census method slicer close on a namespace's last property `queryDataset` is the last member of `analytics = {…}` and closes with a bare `}`; the slicer required `},` and could not find it inside the block. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * test(client): slice census methods on masked source, anchored to the next sibling The previous anchor (a method's own closing brace) stopped at queryDataset's parameter type literal, and a `\s+` indentation capture absorbed newlines. Comments are masked first so docblock prose cannot satisfy a code assertion. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * test(client): assert queryDataset's route by its code spelling, not the docblock's `analytics/dataset/query` only ever appeared in prose; the method spells it `getRoute('analytics')` + `${route}/dataset/query`, which is what a comment-masked slice can see. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * docs(changeset): scope the #13079 failure-path claims to the envelope level Contract review corrections, prose only. `client.automation.trigger` is excepted from the universal compile-error claim: `r.success` and `r.error` compile before and after (both declared on `AutomationResult`), and their meaning moves from the envelope's to the run's own — a refusal the door does not classify as 400/409/422 is answered 200 through `deps.success(result)` (`respondToFlowTrigger`, classification in `flow-dispatch-status.ts`), as `execute` already does. The "not a value you will receive" sentence is scoped to the envelope level, and the `meta` sentence no longer implies the four doors ever populated it. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * feat(spec): register the client envelope convergence in the ADR-0087 ledger (#13079) The changeset for this branch carried `adr-0087: not-required (runtime-interface-only …)` and the gate refused it: `runtime-interface-only` inherits the migration-prescription refusal, and this body ships a `## Migration` table. The truthful disposition is `registered`. The ledger already carries two entries of exactly this class on this package, landed four days before this branch — `client-delete-result-success` and `client-meta-reset-result-reset`, both `@objectstack/client` return-shape moves, both "same call, same wire body", both pure `unwrapResponse` passthroughs with no `packages/spec` schema movement. This one is the stronger case of the three: those corrected declarations that were UNINHABITED, so they revealed a defect rather than breaking working code, whereas this moves reads that work today. - `entries/semantic/18.client-envelope-convergence-analytics-automation.ts` — one `SemanticMigration` for major 18, the open window. surface: the resolved value of `client.analytics.query` / `.meta` / `.explain` and `client.automation.trigger`, from the dispatcher's `{ success, data }` envelope to `data`. replacement: the payload, `r.data.X` -> `r.X`, the four rows of this branch's own `## Migration` table. reason: one SDK calling convention, and the wire is byte-identical — this branch's diff touches no `packages/spec` path at all (`git diff --name-only <merge-base> HEAD -- packages/spec` is empty). `tsc` names every site on the three analytics methods (TS2339), but NOT on `automation.trigger`: `AutomationResult` itself declares `success: boolean` and `error?: string` (`packages/spec/src/contracts/automation-service.ts`, byte-identical at the merge base and here), so `r.success` / `r.error` compile on both sides while their subject moves from the envelope to the run. An untyped JS caller has no channel at all, which is why the ledger entry is the only notification that reaches it. acceptanceCriteria: no code reads `.data` off the four, and every branch on `r.success` / `r.error` off `trigger` is re-read by hand. - `migrations/registry.ts` — regenerated by `gen:migration-registry` (144 semantic, 75 retired-key, 88 retired-def); never hand-edited. - The changeset's ADR-0087 marker moves to `registered client-envelope-convergence-analytics-automation`; the rest of the file is byte-identical (one line changed). `gen:spec-changes` and `gen:upgrade-guide` were re-run and write no diff: `PROTOCOL_VERSION` is 17.0.0, so both artifacts project majors up to 17 and neither carries any step-18 entry yet — the 2026-08-29 precedent `client-meta-reset-result-reset` appears zero times in either file too. Issue anchors live in a source comment at the top of the entry rather than in its strings: the strings are projected into `spec-changes.json` and `docs/protocol-upgrade-guide.md` when 18 becomes current, and this repo's issue numbers do not resolve for the consumers who read those. Registration requested on #14996; the landing PR is #14526, which stays the `domain:cli` seat's to land. Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i Co-authored-by: Claude <noreply@anthropic.com> * fix(spec): respell the four methods in the ADR-0087 entry so the #13079 census does not count its prose (#13079) `packages/client/src/envelope-caller-census.test.ts` (§3, this branch's own ratchet) walks the whole workspace for `analytics.query|meta|explain (` and `automation.trigger (` — over comment-masked source, so a docblock is exempt but a STRING LITERAL is not — and requires every hit to be classified in its hand ledger. The entry added in 24f2507 wrote the four methods in their call-site spelling inside its `surface` / `replacement` / `acceptanceCriteria` prose, so the census counted 9 sites in the entry file and the same 9 in the generated `registry.ts`: `sdkSites.length` 46 instead of 28, 18 unclassified, `Test Core (1/6)` red on 24f2507 while the same shard was green on d543e09. Fixed inside this registration's own surface only. Every mention of the four is respelled so no `(` follows the method name, whitespace included: - surface — `client.automation.trigger (the resolved value …)` becomes an em dash and a colon, no parenthesis and no closing one - replacement — `client.analytics.query(q)` / `.meta(cube?)` / `.explain(q)` / `client.automation.trigger(name, payload)` now name their arguments in words ("called with a query", "with or without a cube name", "given a trigger name and a payload"), and the four rewrite rows keep their `r.data.X` → `r.X` spelling exactly, which the census does not match - acceptanceCriteria — the four `client.…()` forms lose their empty parens ⛔ The census test itself is untouched: it is the `domain:cli` seat's file, and the fix belongs on the side that introduced the text. Verified with the census's own matcher (its regex, over the tree's own `maskComments`): the entry file and `registry.ts` now yield 0 hits each, down from 9 and 9. `pnpm --filter @objectstack/client exec vitest run src/envelope-caller-census.test.ts` — 14 passed, including `sdkSites.length === 28`, the mechanical-enumeration-vs-hand-ledger equality and the zero-unclassified assertion. `registry.ts` regenerated by `gen:migration-registry`; no other file touched, and the entry's meaning is unchanged. Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5c58423 commit db16b94

9 files changed

Lines changed: 900 additions & 227 deletions
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
"@objectstack/client": minor
3+
---
4+
5+
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)
6+
7+
<!-- adr-0087: registered client-envelope-convergence-analytics-automation -->
8+
9+
**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.
10+
11+
Maintainer ruling on #13079 (2026-08-31, verbatim): 「裁决:A,cloud 未测量照裁」 — 「四方法(`analytics.query` / `analytics.meta` / `analytics.explain` / `automation.trigger`)收敛 `unwrapResponse`,SDK 一套读法。」
12+
13+
## What changed
14+
15+
`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.
16+
17+
## Migration
18+
19+
| method | resolved to (before) | resolves to (now) | rewrite |
20+
|:--|:--|:--|:--|
21+
| `client.analytics.query(q)` | `{ success, data: AnalyticsResult, meta? }` | `AnalyticsResult` | `r.data.rows``r.rows` |
22+
| `client.analytics.meta(cube?)` | `AnalyticsMetadataResponse``{ success, data: CubeMeta[], meta? }` | `AnalyticsMetadataResponse['data']` — the bare cube list | `r.data[0].name``r[0].name` |
23+
| `client.analytics.explain(q)` | `AnalyticsSqlResponse``{ success, data: { sql, params }, meta? }` | `AnalyticsSqlResponse['data']``{ sql, params }` | `r.data.sql``r.sql` |
24+
| `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` |
25+
26+
Before / after, per method:
27+
28+
```ts
29+
const r1 = await client.analytics.query({ cube: 'crm_account', measures: ['account_count'] });
30+
r1.data.rows; // before
31+
r1.rows; // now
32+
33+
const r2 = await client.analytics.meta();
34+
r2.data[0].name; // before
35+
r2[0].name; // now
36+
37+
const r3 = await client.analytics.explain({ cube: 'crm_account', measures: ['account_count'] });
38+
r3.data.sql; // before
39+
r3.sql; // now
40+
41+
const r4 = await client.automation.trigger('approve_account', {});
42+
r4.data.status; // before ('paused' | 'completed' | 'failed')
43+
r4.status; // now — exactly what `client.automation.execute` already answered
44+
```
45+
46+
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.
47+
48+
### The failure path — read this before touching a `catch`
49+
50+
Nothing changes there, and it is stated per door because a convergence on `unwrapResponse` could be misread as "errors now throw":
51+
52+
- **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.
53+
- **`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.
54+
- **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.
55+
56+
### Not changed
57+
58+
- `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).
59+
- The wire. Every route answers exactly the body it answered before; a raw-HTTP caller is unaffected.
60+
- `client.automation.execute`, `client.automation.resume` and every other method that already used `unwrapResponse`.
61+
62+
### Populations measured, and the one ruled NOT MEASURED
63+
64+
`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.

packages/client/src/analytics-automation-json-erasure.test.ts

Lines changed: 66 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

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

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

300308
// ─────────────────────────────────────────────────────────────────────────────
301309

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

306-
const body = await client.analytics.query({
314+
const result = await client.analytics.query({
307315
cube: 'crm_account',
308316
measures: ['account_count'],
309317
dimensions: ['industry'],
310318
});
311319

312-
// ① The envelope is the value — NOT the payload. This is the whole
313-
// difference between `res.json()` and `unwrapResponse`.
314-
expect(Object.keys(body).sort()).toEqual(['data', 'meta', 'success']);
315-
expect(body.success).toBe(true);
316-
// ② …and `data` is verbatim what the producer's own contract method
320+
// ① The payload is the value — NOT the envelope. Before #13079 the
321+
// keys here were `['data', 'meta', 'success']`; `unwrapResponse`
322+
// strips exactly that layer and nothing else.
323+
expect('success' in result).toBe(false);
324+
expect('data' in result).toBe(false);
325+
// ② …and the value is verbatim what the producer's own contract method
317326
// returned, asserted against a second call to the service itself
318327
// rather than against a literal written here.
319-
expect(body.data).toEqual(await analytics.query({
328+
expect(result).toEqual(await analytics.query({
320329
cube: 'crm_account',
321330
measures: ['account_count'],
322331
dimensions: ['industry'],
323332
}));
324-
expect(body.data.rows).toEqual(ROWS);
333+
expect(result.rows).toEqual(ROWS);
325334
});
326335

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

330-
const body = await client.analytics.meta();
339+
const cubes = await client.analytics.meta();
331340

332-
expect(body.success).toBe(true);
333-
expect(body.data).toEqual(await analytics.getMeta());
334-
// A BARE array under `data` — there is no `cubes` wrapper (#6442).
335-
expect(Array.isArray(body.data)).toBe(true);
336-
expect(body.data[0]?.name).toBe('crm_account');
337-
expect(body.data[0]?.measures.map((m) => m.name)).toContain('crm_account.account_count');
341+
expect(cubes).toEqual(await analytics.getMeta());
342+
// A BARE array — there is no `cubes` wrapper (#6442) and, since
343+
// #13079, no `{ success, data }` around it either.
344+
expect(Array.isArray(cubes)).toBe(true);
345+
expect(cubes[0]?.name).toBe('crm_account');
346+
expect(cubes[0]?.measures.map((m) => m.name)).toContain('crm_account.account_count');
338347
});
339348

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

343-
const body = await client.analytics.explain({
352+
const dryRun = await client.analytics.explain({
344353
cube: 'crm_account',
345354
measures: ['account_count'],
346355
dimensions: ['industry'],
347356
});
348357

349-
expect(body.success).toBe(true);
350-
expect(Object.keys(body.data).sort()).toEqual(['params', 'sql']);
351-
expect(body.data.sql).toMatch(/SELECT/i);
352-
expect(Array.isArray(body.data.params)).toBe(true);
358+
expect(Object.keys(dryRun).sort()).toEqual(['params', 'sql']);
359+
expect(dryRun.sql).toMatch(/SELECT/i);
360+
expect(Array.isArray(dryRun.params)).toBe(true);
353361
});
354362

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

358-
const body = await client.automation.trigger('approve_account', {});
366+
const run = await client.automation.trigger('approve_account', {});
359367

360-
expect(body.success).toBe(true);
361368
// The keys `TriggerFlowResponseSchema.data` did NOT declare before
362-
// #13078, served by the real engine: this measurement is why the
363-
// annotation binds `AutomationResult` (and, since #13078, why the
364-
// schema had to move to parity with it).
365-
expect(body.data.status).toBe('paused');
366-
expect(typeof body.data.runId).toBe('string');
367-
expect(body.data.screen?.title).toBe('Approve the account');
369+
// #13078, served by the real engine and — since #13079 — read at the
370+
// top level, exactly where `automation.execute` has always put them.
371+
expect(run.status).toBe('paused');
372+
expect(typeof run.runId).toBe('string');
373+
expect(run.screen?.title).toBe('Approve the account');
374+
// `AutomationResult` carries its OWN `success`; the envelope's is gone.
375+
expect(run.success).toBe(true);
376+
expect('data' in run).toBe(false);
368377
});
369378
});
370379

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

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

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

423-
// The SDK hands the caller the producer's body itself — envelope included.
424-
expect(await client.analytics.meta()).toEqual(produced);
433+
// The SDK hands the caller the producer's `data` — one envelope
434+
// stripped, nothing else touched.
435+
expect(await client.analytics.meta()).toEqual(produced.data);
425436
});
426437
});

packages/client/src/client.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1546,9 +1546,10 @@ describe('ObjectStackClient.automation', () => {
15461546
// fetch layer throws on non-2xx before any unwrapping, so both surfaces
15471547
// REJECT. No SDK code changed; the contract did, and these are its pins.
15481548
//
1549-
// Both spellings are pinned, not one: `trigger()` reads `res.json()` while
1550-
// `execute()` reads `unwrapResponse()`, so a regression in either unwrap
1551-
// path would be invisible from the other's test.
1549+
// Both spellings are pinned, not one: `trigger()` and `execute()` are two
1550+
// URLs into one handler, and since #13079 both read `unwrapResponse()`;
1551+
// a regression on either door's rejection path would still be invisible
1552+
// from the other's test (the paths diverge before the shared reader).
15521553
const failedRunBody = {
15531554
success: false,
15541555
error: {

0 commit comments

Comments
 (0)