feat(client)!: converge analytics.query / meta / explain and automation.trigger on unwrapResponse — one SDK calling convention (#13079) - #14526
Conversation
…rigger 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
…t 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
…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
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 7609c5d35f13503df34b3fc0fbe639950bef330e && git checkout 7609c5d35f13503df34b3fc0fbe639950bef330e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3bca6a4affb756031becce6ff7d908dd959eb8ea d543e098859cf2f101af7040777e421ec0994aee && git checkout -B drift-repro 3bca6a4affb756031becce6ff7d908dd959eb8ea && git merge --no-ff d543e098859cf2f101af7040777e421ec0994aee
node scripts/docs-audit/affected-docs.mjs --json 3bca6a4affb756031becce6ff7d908dd959eb8ea
|
…he 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
… 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
Fixes #13079
Executes the maintainer's ruling on the card (2026-08-31, comment
5479237368, verbatim and untranslated): 「裁决:A,cloud 未测量照裁」 — 「四方法(analytics.query/analytics.meta/analytics.explain/automation.trigger)收敛unwrapResponse,SDK 一套读法。」 Ruling items honoured: (1)analytics.queryDatasetprotected — its method body is byte-identical to base (sha12e6ec790…, 467 bytes; only its docblock says why it stays onres.json()); (2) migration note + breaking changeset; the 13 loud in-repo pins PR #13647 measured are this diff; (3) objectui's tolerant chain is objectui#7028's, time-gated behind this landing — not touched here; (4)cloudstays NOT MEASURED:CLOUD_CENSUS_COMMANDis kept in the census file, reworded to post-convergence semantics; (5) clause ② YES —needs:contract-reviewapplied at creation. Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipzaContract change — per method, before and after
unwrapResponsenever throws. Every non-2xx answer is thrown byObjectStackClient.fetchBEFORE either reader runs, with the ADR-0112 envelope on the error (err.code,err.httpStatus,err.message,err.details) — that was true before this PR and is true after it. A 2xx body with a booleansuccessand adatakey now resolves todata; a 2xx body with nodatakey resolves unchanged (pass-through). No dispatcher door behind these four routes sends a 2xx withoutdata(a failed run has been a thrown 400 since #9378), so at the ENVELOPE level a resolved{ success: false, error }is not a value a caller receives from them. At the PAYLOAD level one door differs:client.automation.triggercan resolve to anAutomationResultwhose ownsuccessisfalse(witherrorset) — a run thatclassifyFlowRefusal(packages/runtime/src/flow-dispatch-status.ts) does not classify as 400FLOW_FAILED/ 409FLOW_DISABLED/ 422FLOW_NO_START_NODEis answered 200 throughdeps.success(result)inrespondToFlowTrigger(packages/runtime/src/domains/automation.ts), exactly asclient.automation.executealready does for the same handler; before this PR that run reached the caller as{ success: true, data: { success: false, error } }. Consequence for a caller: ontrigger,r.successandr.errorcompile before AND after (both declared onAutomationResult,packages/spec/src/contracts/automation-service.ts), but their meaning moves from the envelope's (successalwaystrueon a resolved call,errornever set on a 2xx) to the run's own — a branch on either must be re-read by hand, the compiler will not point at it. The three analytics methods have no such overlap:.datais TS2339 and nothing else compiles on both sides.analytics.query(q)POST /analytics/query{ success: true, data: AnalyticsResult, meta? }— declaredBaseResponse & { data: AnalyticsResult }AnalyticsResult(rows,fields,sql?,totals?)VALIDATION_ERRORon a refused queryanalytics.meta(cube?)GET /analytics/metaAnalyticsMetadataResponse—{ success, data: CubeMeta[], meta? }AnalyticsMetadataResponse['data']— the bare cube listanalytics.explain(q)POST /analytics/sqlAnalyticsSqlResponse—{ success, data: { sql, params }, meta? }AnalyticsSqlResponse['data']—{ sql, params }automation.trigger(name, payload)POST /automation/trigger/:name{ success: true, data: AutomationResult, meta? }— declaredBaseResponse & { data: AutomationResult }AutomationResult— the same valueautomation.executeanswers for the same handler (its ownsuccessmay befalseon an unclassified 200 refusal)FLOW_FAILED(#9378), 409FLOW_DISABLED/ 422FLOW_NO_START_NODE(#9415), 404 unknown flow — unchangedanalytics.queryDataset(...)— PROTECTEDPOST /analytics/dataset/query(@objectstack/rest, bare)AnalyticsResultAnalyticsResult— unchanged, stillres.json()What a caller loses: the envelope's
successflag — alwaystrueon a resolved call — is no longer on the resolved value of these four; itsmetaslot is gone too, but these doors never populated it (each answersdeps.success(result)with no meta argument, and JSON drops theundefined). Neither key was ever on any other SDK method's value. What a caller gains: one reading rule;triggerandexecutestop answering two shapes for one handler. The wire is untouched: every route answers exactly the body it answered before, so raw-HTTP callers are unaffected.Diff
packages/client/src/index.ts— the four methods endreturn this.unwrapResponse(res); return declarations move to the payload types, derived from the existing envelope types where the spec transcribes them (AnalyticsMetadataResponse['data'],AnalyticsSqlResponse['data']) and to the producer contracts otherwise (AnalyticsResult,AutomationResult) — nopackages/specedit. TheBaseResponseimport goes (unused). Docblocks rewritten so nothing documents the envelope read;queryDataset's docblock says why it stays.packages/client/src/envelope-convergence.test.ts(new) — mocked transport: each of the four resolves todatawith the envelope keys absent; exactly-once strip on a payload carrying its ownsuccess; the rejection path per door (400FLOW_FAILED, 409FLOW_DISABLED, 400VALIDATION_ERROR) unchanged; the 2xx no-datapass-through pinned;queryDatasetresolves to the bare body. RED on the base in exactly its five payload cases (the five failure-path/protected cases pass there, by design).packages/client/src/return-type-precision.test.ts— the A sixth client-SDK erasure spelling, larger than the other five combined: 43 exported methodsreturn res.json()directly, whose lib.dom type isPromise< any >#12104 pins REVERSED, not deleted:expectTypeOfbinds the payload types; the four@ts-expect-errorsuppressions move from the payload read to the.dataread. RED on the base as 4×TS2344 + 4×TS2578.packages/client/src/analytics-automation-json-erasure.test.ts— the four cases re-driven against the realAnalyticsService/AutomationEngine/HttpDispatcherto the payload; the premise case now pins "dispatcher wraps once, SDK strips once" (meta()equals the dispatcher body'sdata).packages/client/src/envelope-caller-census.test.ts— the derived ledger regenerated to the post-convergence world:ENVELOPE_DEPENDENTratcheted at ZERO, newPAYLOAD_DEPENDENTverdict (18),RESULT_INSENSITIVE10,NOT_SDK1, 28 SDK sites; section 4 reads off the SDK source (comment-masked, namespace-anchored, sliced to the next sibling property) that the four end inunwrapResponseandqueryDatasetstill dialsgetRoute('analytics')+${route}/dataset/queryand readsres.json(); the split-call positive control moves from 2 to 5;CLOUD_CENSUS_COMMANDkept, reworded (a.dataread there is now a runtime break); objectui block names objectui#7028 as the tightening owner.packages/client/src/client.test.ts— one stale comment (the six RESULT_INSENSITIVE sites there are untouched)..changeset/client-envelope-convergence.md—@objectstack/client: minorunder the lockstep launch-window convention (check-changeset-no-major.mjs: the repo never writesmajor;**BREAKING**+feat(client)!:are the carriers), with the per-method migration table, the failure-path statement, and the ADR-0087 marker. Patch round after the contract review: the failure-path claims are scoped to the envelope level,trigger's ownsuccess/errorare excepted from the compile-error claim, and themetasentence no longer implies these doors populated it — prose only, no code change.Out of scope and untouched: the ~40 other
return res.json()sites inindex.ts(the better-auth-backedauth.*/organizations.*/oauth.*families, a different class: REST-served bare bodies, not dispatcher envelopes).content/docs/api/client-sdk.mdx:312–329,433anddata-api.mdx:443mention the four; none shows a.dataread on the SDK value, so no devx edit is strictly required — reported for the PM, not edited.packages/client/README.md:273–277likewise.⚠ Known red, recorded rather than worked around:
check:adr-0087-registrationThe changeset states
not-required (runtime-interface-only packages/client/src/index.ts#ObjectStackClient)and the gate refuses it (node scripts/check-adr-0087-registration.mjs --base 1dcb995f23→ exit 1):runtime-interface-onlyinherits the prescription refusal (#8299) and the body carries the## Migrationtable the ruling requires;type-surface-only(#13080) is scoped by its predicate 4 to symbols that wereanyat the merge base, and these four were concrete envelope types there;registeredwould put an SDK reader change into a ledger that servesobjectstack migrate meta. No verifiable category fits a concrete-to-concrete published runtime-interface move that ships its prescription. Filed as #14502 (spec-tooling); precedent for landing with the refusal recorded in-body:.changeset/adr0006-d2-client-environments-namespace.md. Thechangeset-checkjob (pr-automation.yml) is advisory, not one of the six required contexts.Verification
Readings are quoted at the sha they were taken; exit codes were captured before any pipe; every build/test that acquired the shared verify lock (
scripts/pm/os-verify-lock.sh) ended with VERDICTcommand-exit 0on the wrapper. The patch-round commit changes only.changeset/client-envelope-convergence.md(prose), so every reading below stands for the code at the new head.1dcb995f23(untouched tree, dependency closure freshly built — 103 packages): census + producer-backed suite →Test Files 2 passed (2) · Tests 20 passed (20). The Census the caller population of the four envelope-returning SDK methods (#13079) #13647 ledger (13 ENVELOPE_DEPENDENT / 6 / 1, 19 SDK sites) holds on this base.1dcb995f23, only the new suite and the reversed pins present,index.tsunconverted:envelope-convergence.test.ts→Tests 5 failed | 5 passed (10)— exactly the five payload cases red;tsc --noEmit -p tsconfig.test.json→ 17 errors: 4×TS2344 + 4×TS2578 inreturn-type-precision.test.ts, 9 payload reads in the new suite.897585a74b:pnpm --filter @objectstack/client buildexit 0;pnpm --filter @objectstack/client typecheckexit 0 (check:test-typecheck: OK — 0 file(s) / 0 error(s));check:exported-any-returns→no NEW exported callable of @objectstack/client resolves to any: 317 callables reached; full client suite →Tests 1 failed | 430 passed (431), the one red being the census's ownqueryDatasetsource-slicer (a test-file defect, fixed over5ce08b0f0c/8f9001c665/9e5a10ee5c;packages/client/src/index.tsis blob3575b137…in all four commits).8f9001c665(lock acquired): the four client suites →Tests 1 failed | 32 passed (33), the one red being the census assertion that matched the URL by its docblock spelling; tsc over the test program → 0 errors.9e5a10ee5cchanges only that assertion (getRoute('analytics')+${route}/dataset/query, the code spelling); its exact regexes were evaluated by node against the final head'sindex.ts(all four predicates true;unwrapResponseabsent) and the slicer's five slices are byte-exact (queryDataset= 467 bytes). The final-head vitest re-run of the census file is declared NOT RE-RUN locally — eight lock attempts over ~65 minutes never acquired (holders:issue-14336runB.shat 1000+ s,issue-13504,issue-13440); the node evaluation is the same predicate the test evaluates, andTest Core(required) runs the file on the PR.9e5a10ee5c, gate union (dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, re-derived at this sha; 36 families, identical to the lists derived at5ce08b0f0cand8f9001c665): 31 families exit 0 — includingcheck:query-options-erasure(ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new) andcheck:type-check-coverage;check:adr-0087-registrationexit 1 = the recorded refusal above (check-adr-0087-registration has no verifiable disposition for a published runtime-interface change that was concrete at base and ships a code prescription — every category refuses #13079's changeset #14502);check-test-completenessexit 3 (PREREQUISITE NOT MET — this gate grades a saved turbo run test log) NOT MEASURED;pm/check-half-statesexit 3 (PREREQUISITE NOT MET — repo-scoped reads are refused) NOT MEASURED;check:dual-build-cjs-loadsexit 3 (This is NOT a pass: nothing was measured, 30 packages unbuilt here) NOT MEASURED. Whole-repopnpm lint(eslint . --no-inline-config) exit 0 at9e5a10ee5c;pnpm check:nul-bytesexit 0. At the patch-round head both changeset gates re-run:check-changeset-no-majorgreen (This diff introduces no major bump),check-adr-0087-registrationthe same recorded refusal.check:skill-examples(needs@objectstack/client-reactbuilt — verdict linepackages/client-react/dist holds no .d.ts declarations — build first) andcheck:type-check-debt(a tsc re-measure over an unbuilt closure); and the DOWNSTREAM consumer typecheck (turbo run typecheck --filter='...@objectstack/client', prefix dots = consumers,^buildfirst). The narrowing's evidence: population = the six consumers pnpm names (cli,client-react,example-todo,example-crm,example-showcase,dogfood); a comment-masked, newline-tolerant, receiver-aware scan of their 723 source files finds 0 call-shaped occurrences of the four methods (positive control: the same scanner finds 29 inpackages/client/src), the census §3 asserts zero production sites workspace-wide at this head, and the only declarations this diff moves are the four methods' return types plus an unused type import — so no consumer tsc program's verdict can move.TypeScript Type Check(required) runs it on the PR.Ablation (reverse verification)
At
5ce08b0f0c(index.tsblob3575b137…, identical at the final head).automation.trigger's conversion reverted on disk — annotation back toPromise< { success: boolean; data: AutomationResult } >,return this.unwrapResponse< AutomationResult >(res)back toreturn res.json()— by exact-string replacement asserting one occurrence each. Mutation proven on disk: probe counts1 → 0(the trigger unwrap line) and44 → 45(return res.json();lines), blob3575b137…→add6b550…(the leg aborts if the blob had not moved). Direction predicted before running: RED, more diagnostics. Observed: convergence suite 2 failed (trigger payload; exactly-once strip), producer-backed suite 1 failed (trigger), census 1 failed (each of the four methods ends unwrapResponse) —Tests 5 failed | 26 passed (31)including the then-unfixed slicer case; tsc 8 errors: TS2344 (return-type-precision.test.ts:484), TS2578 (:503, the trigger.datasuppression now unused), 3×TS2339 in the producer-backed suite, 3×TS2339 in the convergence suite. Restore bygit checkout HEAD -- ABSOLUTE_PATHinside anEXIT INT TERMtrap, proven by blob back to3575b137…equal toHEAD:packages/client/src/index.ts,git diff HEAD --statempty,git status --porcelainempty; the post-restore re-run at the same sha: convergence + producer-backed + precision green, tsc 0 errors. No rebuild leg was needed: vitest resolves./indextosrc/index.tsandtsconfig.test.jsonreads src, so the mutation reached both suites without a build — the red itself is the evidence;dist/(built once at897585a74b) is read only bycheck:exported-any-returns, which was not part of the ablation.