docs: design spec for binding a second contract to a TypedClient - #353
docs: design spec for binding a second contract to a TypedClient#353btravers wants to merge 3 commits into
Conversation
Records the investigation and decision from millenium!56319 note 2210871: add a synchronous, memoized TypedClient#for(contract) rather than moving the contract to the call site or re-introducing a NestJS integration package. Spec lives in .agents/specs/ rather than docs/ because the VitePress config sets no srcExclude, so any .md under docs/ becomes a published page. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds an internal design spec documenting the investigation and the chosen API shape for binding an additional contract to an already-connected TypedClient (a synchronous, memoized TypedClient#for(contract)), while explicitly recording rejected alternatives (NestJS integration, contract-at-callsite).
Changes:
- Introduces a design document describing the rationale, semantics, implementation sketch, and test plan for
TypedClient#for(contract). - Captures related findings discovered during investigation (notably a
startChildWorkflow/executeChildWorkflowTSDoc mismatch) for separate follow-up.
| The map erases the type parameter, so storing and reading each cross a cast — | ||
| contained to the two lines inside `for()`. |
There was a problem hiding this comment.
Fixed in 3b414ba. Now reads "the store and the read each require a cast, contained to the two lines inside for()."
| const client = (await TypedClient.create({ contract: leaseContract, client: temporal })).get(); | ||
|
|
||
| await client.startWorkflow("computeOneLessorTaxDeclarationWorkflow", { workflowId, args }); | ||
| await client.for(platoContract).startWorkflow("someOtherWorkflow", { workflowId, args }); |
There was a problem hiding this comment.
it s not a second contract, the client should not depend on a contract
| const client = (await TypedClient.create({ contract: leaseContract, client: temporal })).get(); | |
| await client.startWorkflow("computeOneLessorTaxDeclarationWorkflow", { workflowId, args }); | |
| await client.for(platoContract).startWorkflow("someOtherWorkflow", { workflowId, args }); | |
| const client = await TypedClient.create({ client: temporal }).get(); | |
| await client.for(leaseContract).startWorkflow("computeOneLessorTaxDeclarationWorkflow", { workflowId, args }); | |
| await client.for(platoContract).startWorkflow("someOtherWorkflow", { workflowId, args }); |
There was a problem hiding this comment.
Done in 3b414ba — the client no longer knows about contracts at all.
TypedClient is now connection-scoped with no type parameter; the contract-bound surface moved to ContractClient<TContract>, reached only via for(). No contract is privileged, which was the wart the previous draft accepted and you correctly rejected.
Two consequences worth confirming, both now in the spec:
- The
@temporalio/client >= 1.16check (missingclient.schedule) moves from the old constructor intocreate. It is a property of the connection, not of any contract — and moving it is what keepsfor()infallible, so it returnsContractClient<TContract>directly with noAsyncResultwrapper. create({ contract, client })is dropped outright rather than deprecated, since retaining it would preserve the coupling being removed.createOrThrowgoes with it (already@deprecated, takes a contract positionally). That makes this a breaking change — amajorchangeset, folded into the beta line since 8.0 has not shipped. About 6TypedClient<typeof X>annotations in tests/examples becomeContractClient<typeof X>, andupgrade-to-v8.mdgains a migration section.
Also adopted your await TypedClient.create({...}).get() spelling throughout — the fluent form is what docs/index.md already uses, and my draft had the clunkier (await create(...)).get().
Review feedback: the client should not depend on a contract at all, so the
privileged bootstrap contract in the previous draft is gone.
TypedClient becomes connection-scoped with no type parameter; the contract-bound
surface moves to ContractClient<TContract>, reached via TypedClient#for(contract).
The >= 1.16 schedule check moves to create, where it is a property of the
connection rather than of any contract, which keeps for() infallible.
create({ contract, client }) is dropped rather than deprecated — retaining it
would preserve the coupling being removed — and createOrThrow goes with it.
Records the resulting breaking changes, the ~6 type annotations to retype, and
the migration section owed to upgrade-to-v8.md.
Also adopts the repo's fluent `await create({...}).get()` idiom and fixes an
ungrammatical sentence flagged in review.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nt spec Adds a Non-goals entry explaining the client/worker asymmetry: the client's contract coupling was accidental (connection vs schema) while the worker's is essential (a Worker instance polls exactly one task queue and the contract is its job description), with the NativeConnection-sharing story already covered by createWorker's option spread. Also records the deliberate choice to keep the TypedClient name, the memoization trade-off and its constraint on a future for(contract, options) overload, adds a type-level migration assertion (TypedClient takes no type argument), and reflows one inline code span that was broken across lines. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Superseded — the spec (with the review revisions) merged to main as part of #354. |
Summary
Design doc only — no source changes. Records the investigation and decision behind millenium!56319 note 2210871, which asked whether the contract belongs on
TypedClient.createand whether a NestJS provider should exist.Decision: add a synchronous, memoized
TypedClient#for(contract).Existing call sites are untouched, nothing is renamed, no new type is exported.
Why this shape
createis async and fallible only because ofensureConnected(). Contract binding is sync and free —contractfeeds justtaskQueue, workflow lookup, schema validation andTypedScheduleClient. Those two concerns being fused is what pushes construction into a request handler in the originating MR.for()separates them: it reuses aClientthat already passed theschedulecheck atcreate()time, so it cannot fail and needs noAsyncResultwrapper.Ruled out (reasons recorded in the spec)
client-nestjs/worker-nestjsdeliberately. The consumer also has its own in-house@emeria/nestjs-temporal, and the removed provider was one-module-per-contract, so it would not have addressed the complaint.client.schedulewould need the contract threaded throughTypedScheduleClienttoo.Honest caveat
At the current one-contract-per-app,
for()buys a sync binding step, not fewer clients. The payoff arrives when one process binds two contracts. Recommended anyway because 8.0-beta is the cheap window for it and the surface is one method — but "do nothing yet" stays defensible, and the spec says so.Also noted
Two findings unrelated to the decision, both recorded:
mapErr+tag("@temporal-contract/TechnicalError")will not compile on 8.0 —createreturnsAsyncResult<…, never>, so it collapses to.get().startChildWorkflow/executeChildWorkflowTSDoc documents a same-contract overload that does not exist;contractis required in both signatures. To be fixed separately.Why
.agents/specs/and notdocs/The VitePress config sets no
srcExclude, so every.mdunderdocs/becomes a published page. An internal design doc does not belong on the public site.🤖 Generated with Claude Code