Skip to content

Commit 7899f57

Browse files
claude[bot]claude
andauthored
feat(client): bind erased SDK return types to their spec contracts (#11929)
* feat(client): bind erased SDK return types to their spec contracts `packages/client/src/index.ts` dropped the precise contract types at the SDK boundary on a package that already depends on `@objectstack/spec`, so the types were reachable and the `any` was forced by nothing. Four spellings of the same erasure, all measured at head and all bound here: 32 `Promise<any>`, 5 `Promise<any[]>`, 4 `Promise<{ …any[]… }>`, and 14 fixed-shape `<T = any>` methods (8 on `ObjectStackClient`, 6 mirrored on `ScopedProjectClient`) — 55 sites, of which 51 are bound and 4 are deliberate. Each binding is the DECLARED return of the service method the route calls, verified per method against the handler's emit rather than swept: four federation methods are envelope-wrapped (`{ tables }`, not `RemoteTable[]`), `security.explain` takes the `z.input` form its contract declares because no parse runs on that path, and `search` has a same-named wrong type (`SearchResult`) sitting one import away. The fixed-shape generics become `<T extends X = X>`, not `<T = X>`: the default alone closes the erasure only for an unannotated call, because TypeScript infers `T` from the assignment's contextual type. Measured on the pin file. `automation.create` / `automation.update` / `search` / `data.clone` keep `Promise<any>` with a docblock each — they are missing CONTRACTS, not missing annotations, and authoring one lands in `packages/spec`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR * docs(api): repair the two client-sdk fences this narrowing falsifies `content/docs/api/client-sdk.mdx` carries an UNMARKED ```typescript fence (no `<!-- os:check -->`), so `check:skill-examples` never compiled it and CI could not have caught this. Measured by extracting the fence verbatim into the package's own test tsc program and diffing diagnostics with `packages/client/src/index.ts` at this branch vs at origin/main: exactly two diagnostics are INTRODUCED by the narrowing, both TS2345. 1. `run.runId` -> `resume(flowName: string, runId: string)`. `AutomationResult.runId` is optional — a completed run carries none — so the argument is `string | undefined`. Narrowed with `&& run.runId`. 2. `suggestions[0].id` -> `confirm(id: string)`. `AudienceBindingSuggestion` is `Record<string, unknown>` by contract, so the property reads as `unknown`. Wrapped in `String(...)`. Both edits carry a one-line explanation, because each is the migration an external consumer has to make and the page's job is to teach it. Re-measured after the fix: 0 introduced diagnostics. Five diagnostics remain in BOTH states (four `err is of type unknown` in the catch block, one unused local) — pre-existing artifacts of compiling a doc snippet under strict settings it was never written for, identical before and after, and not touched here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3689991 commit 7899f57

5 files changed

Lines changed: 460 additions & 80 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
'@objectstack/client': minor
3+
---
4+
5+
Bind the SDK's erased return types to the `@objectstack/spec` contracts the package already depends on
6+
7+
**This is a NARROWING of published return types.** 41 methods that resolved to `any` (or to an
8+
envelope carrying an `any[]`) now resolve to the contract type the route actually answers, and 12
9+
fixed-shape `automation.*` methods gain a constrained generic in place of `<T = any>`. Nothing
10+
changes at runtime — no request, response, unwrapping or error path is touched — but code that
11+
compiles today against these methods can stop compiling. `any` is assignable to everything and
12+
admits every property read, so the previous declaration accepted assignments, property reads and
13+
parameter forwarding that a precise type refuses.
14+
15+
What a consumer could stop compiling against, per family:
16+
17+
- **`automation`**`get`/`getFlow` are `FlowParsed`; `runs.get`/`getRun` are `ExecutionLog`;
18+
`runs.list`/`listRuns` are `{ runs: ExecutionLog[]; hasMore: boolean }`; `execute` and `resume`
19+
are `AutomationResult`; `getScreen` is `{ runId: string; screen: ScreenSpec }`; `listActions` and
20+
`listConnectors` carry `ActionDescriptor[]` / `ConnectorDescriptor[]` instead of `any[]`. ⚠️ The
21+
biggest practical break is `AutomationResult.screen`, `.runId`, `.status` and `.summary` being
22+
**optional**: a completed run carries no screen, so `result.screen.nodeId` must become
23+
`result.screen?.nodeId`. The six flat aliases and their `ScopedProjectClient` mirrors move
24+
together. ⚠️ `<T = any>` became `<T extends X = X>` on those twelve: an explicit type argument
25+
still works when it narrows the platform shape (`getFlow<FlowParsed & { name: 'onboarding' }>`),
26+
but one naming an unrelated type is now refused — including where TypeScript used to infer it
27+
from the assignment's own annotation.
28+
- **`approvals`**`recall` / `revise` / `resubmit` are `ApprovalRecallResult` /
29+
`ApprovalSendBackResult` / `ApprovalResubmitResult`; `remind` is
30+
`{ request: ApprovalRequestRow; notified: number }`; `requestInfo` and `comment` are
31+
`{ request: ApprovalRequestRow }`. These join `reassign` and `listActions`, which were already
32+
typed this way beside them.
33+
- **`shares` / `shareLinks`**`shares.list` is `RecordShare[]`, `shares.grant` is `RecordShare`;
34+
`shares.rules.list` / `save` / `get` are `SharingRuleRow`(`[]`) and `rules.evaluate` is
35+
`SharingRuleEvaluationResult`; `shareLinks.create` / `list` are `ShareLink`(`[]`).
36+
- **`reports`**`list` / `save` / `get` are `SavedReport`(`[]`), `run` is `ReportRunResult`,
37+
`schedule` / `listSchedules` are `ReportSchedule`(`[]`).
38+
- **`security`**`describeDelegableScope` is `DelegableScope`; `explain` is `ExplainDecision` (the
39+
`z.input` form `ISecurityService.explain` declares and the route relays verbatim — **not** the
40+
post-parse `ExplainDecisionParsed`, since no parse runs on that path); the three
41+
`suggestedBindings` methods carry their `{ suggestion, … }` / `{ suggestions, synced }` envelopes.
42+
The suggestion ROW stays `Record<string, unknown>` by contract, but `bindingCreated` and
43+
`synced.{created,confirmedObserved,pruned}` stop being erased.
44+
- **`email` / `datasources.external`**`email.send` is `SendEmailResult` (branch on `status`).
45+
⚠️ The four federation methods are **envelope-wrapped** and the obvious binding is the wrong one:
46+
`listTables` answers `{ tables: RemoteTable[] }`, not `RemoteTable[]`; likewise
47+
`{ draft: ObjectDraft }`, `{ object: ImportObjectResult }`, `{ catalog: ExternalCatalog }`.
48+
`validate` is a bare `SchemaValidationReport`.
49+
- **`ScopedProjectClient.packages.list`**`{ packages: InstalledPackage[]; total: number }`.
50+
51+
Four methods deliberately keep `Promise<any>` and say so in their docblocks: `automation.create` /
52+
`automation.update` echo an unvalidated request body, and `search` / `data.clone` answer shapes
53+
declared inline in the implementation rather than in `@objectstack/spec`. Those are missing
54+
*contracts*, not missing annotations, and authoring them belongs to the spec package. The
55+
caller-supplied generics on `data.*` and `actions.*` are unchanged — there the payload really is
56+
the caller's.

content/docs/api/client-sdk.mdx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,9 @@ try {
400400
// `{ status: 'paused', runId, screen }`; render the screen, then resume the run
401401
// with the collected values. A wizard pauses again for each further step.
402402
const run = await client.automation.execute('convert_lead', { params: { recordId } });
403-
if (run.status === 'paused') {
403+
// `execute` returns `AutomationResult`, on which `runId` and `screen` are
404+
// OPTIONAL — a run that COMPLETED carries neither — so narrow before resuming.
405+
if (run.status === 'paused' && run.runId) {
404406
await client.automation.resume('convert_lead', run.runId, {
405407
inputs: { account_name: 'Radium Labs' },
406408
});
@@ -438,7 +440,10 @@ await client.shareLinks.revoke(link.token);
438440

439441
// Security (admin) — resolve package audience-binding suggestions (ADR-0090)
440442
const { suggestions } = await client.security.suggestedBindings.list({ status: 'pending' });
441-
await client.security.suggestedBindings.confirm(suggestions[0].id);
443+
// A suggestion ROW is deliberately open (`Record<string, unknown>`) — its column
444+
// set belongs to the backing object, not the contract. Read the fields you know
445+
// (`id`, `status`, `package_id`) and narrow them at the point of use.
446+
await client.security.suggestedBindings.confirm(String(suggestions[0].id));
442447

443448
// Storage — File upload and management
444449
await client.storage.upload(fileData, 'user');

packages/client/src/client.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1351,7 +1351,12 @@ describe('ObjectStackClient.automation', () => {
13511351

13521352
const result = await client.automation.resume('my_flow', 'run_1', { inputs: { account_id: 'a1' } });
13531353
expect(result.status).toBe('paused');
1354-
expect(result.screen.nodeId).toBe('step2');
1354+
// [#8140] `resume` now declares `AutomationResult`, on which `screen`
1355+
// is optional — a run that COMPLETED carries none. Asserting its
1356+
// presence before reading through it is the consumer-side half of that
1357+
// narrowing, and is exactly the migration an external caller makes.
1358+
expect(result.screen).toBeDefined();
1359+
expect(result.screen?.nodeId).toBe('step2');
13551360
});
13561361

13571362
// [#8684] BREAKING: a run that resumed and then FAILED used to resolve with

0 commit comments

Comments
 (0)