From d4c11914fa3f59ee4c5aade1c12fb4be9431ff7e Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Wed, 29 Jul 2026 20:05:48 +0200 Subject: [PATCH 1/3] docs: add design spec for binding a second contract to a TypedClient 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) --- ...29-typed-client-contract-binding-design.md | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 .agents/specs/2026-07-29-typed-client-contract-binding-design.md diff --git a/.agents/specs/2026-07-29-typed-client-contract-binding-design.md b/.agents/specs/2026-07-29-typed-client-contract-binding-design.md new file mode 100644 index 00000000..e4c5ca50 --- /dev/null +++ b/.agents/specs/2026-07-29-typed-client-contract-binding-design.md @@ -0,0 +1,181 @@ +# Design: bind a second contract to an existing `TypedClient` + +- **Date:** 2026-07-29 +- **Status:** approved, not yet implemented +- **Target:** `@temporal-contract/client`, 8.0 beta line +- **Origin:** [millenium!56319 note 2210871](https://gitlab.factory.fonciamillenium.net/FonciaStark/millenium/-/merge_requests/56319#note_2210871) + +> Design docs live in `.agents/specs/`, not `docs/`. The VitePress config sets no +> `srcExclude`, so every `.md` under `docs/` becomes a published page. + +## Context + +The originating comment asks two things: + +> maybe we could create a nestjs provider for the TypedClient +> +> does it make sense to give the contract when calling `TypedClient.create`? It +> means we must have one TypedClient per contract. Instead we could have only one +> TypedClient and give the contract when calling `startWorkflow` or +> `executeWorkflow`? Or maybe we could find an other better DX + +It sits on `lessor-tax-declaration.service.ts` in a draft MR titled _"POC: evaluate +replacing the in-house Temporal integration with temporal-contract (compute +slice)"_, where a `TypedClient` is constructed **inside a request-scoped method**: + +```ts +async refreshDeclaration(declarationId: string): Promise { + // ... + const typedClient = await TypedClient.create({ + contract: leaseAccountingComputeContract, + client: this.client, + }) + .mapErr((error) => match(error).with(tag("@temporal-contract/TechnicalError"), /* ... */).exhaustive()) + .getOrThrow(); + + await typedClient.startWorkflow("computeOneLessorTaxDeclarationWorkflow", { /* ... */ }); +} +``` + +### What the investigation found + +1. **Construction is fallible and async only because of the connection.** `create` + awaits `ensureConnected()` and routes setup faults to the defect channel. + Contract binding itself is synchronous and free — `contract` feeds only + `taskQueue`, workflow lookup, schema validation, and `TypedScheduleClient`. + The two concerns are fused, which is what pushes construction into a request + handler. +2. **Part of the pain is a version artifact.** The consumer pins + `catalog:temporalContract7`. On 8.0 `create` returns + `AsyncResult, never>`, so the `mapErr` / + `tag("@temporal-contract/TechnicalError")` ceremony above does not compile and + collapses to `.get()`. +3. **The multi-contract burden is currently theoretical.** Across the millenium + monorepo, temporal-contract is consumed by `applications/plato` (one contract) + and, in this POC, `service-lease-accounting` (`leaseAccountingComputeContract`). + One contract per application; no process binds two today. +4. **Precedent exists for contract-per-call.** The worker's + `context.startChildWorkflow(contract, name, options)` takes the contract as a + required first parameter. + +## Non-goals + +- **A NestJS integration package.** `@temporal-contract/client-nestjs` and + `@temporal-contract/worker-nestjs` were deliberately removed in PR #116 + (`53dfb80`, 2026-03-01) to "simplify the project scope and reduce maintenance + burden". The consumer additionally has its own in-house `@emeria/nestjs-temporal` + (`@ContractActivitiesHandler()`, activity explorer), so the need is already met + outside this repo. Re-adding it would reverse a deliberate decision to solve a + problem the consumer has already solved. The removed provider was also + one-module-per-contract, so it would not have addressed this complaint anyway. +- **Moving the contract to the call site** (`client.startWorkflow(contract, name, +options)`). Rejected: it taxes every call site to serve a case that does not + exist yet, and `client.schedule` would need the contract threaded through + `TypedScheduleClient` as well. + +## Decision + +Add one synchronous instance method to `TypedClient`. + +```ts +class TypedClient { + /** + * Bind another contract, reusing this client's connection and interceptors. + * Synchronous, infallible, memoized per contract identity. + */ + for(contract: TOther): TypedClient; +} +``` + +```ts +const client = (await TypedClient.create({ contract: leaseContract, client: temporal })).get(); + +await client.startWorkflow("computeOneLessorTaxDeclarationWorkflow", { workflowId, args }); +await client.for(platoContract).startWorkflow("someOtherWorkflow", { workflowId, args }); +``` + +Existing call sites are untouched, nothing is renamed, and no new type is exported. + +### Semantics + +- **Infallible.** The private constructor's only `throw` is the missing + `client.schedule` check (`@temporalio/client` < 1.16). `for()` reuses the same + `Client` that already passed that check during `create()`, so it cannot fail. It + returns `TypedClient` directly — no `AsyncResult` wrapper. This is the + ergonomic win: binding is a plain expression, valid in a field initializer. +- **Memoized and identity-stable.** A + `WeakMap>` seeded with + `[this.contract, this]`, so `client.for(ownContract) === client` and repeated + `for(c)` returns the same instance instead of rebuilding `TypedScheduleClient`. + The map erases the type parameter, so storing and reading each cross a cast — + contained to the two lines inside `for()`. +- **Inherits `client` and `interceptors`.** No per-call interceptor override — + YAGNI, and addable later without a break. +- **Never reconnects.** `ensureConnected()` stays a `create()`-time concern. + +### Known wart + +The bootstrap contract is privileged: contract B is reached _through_ contract A's +client. Accepted for now — see Follow-up. + +## Implementation + +Single file: `packages/client/src/client.ts`. + +1. Add a private `bound` field: + `WeakMap>`. +2. Seed it with `this.contract -> this` at the end of the private constructor. +3. Add the public `for(contract: TOther): TypedClient` method: + look up the memo, otherwise construct via the private constructor with + `(contract, this.client, this.interceptors)`, store, return. +4. Add a TSDoc `@example` including `import { P } from "unthrown";` where the + example matches on errors — per the convention that every standalone example + block shows where its symbols come from. + +The private constructor is reachable from `for()` because both live on the same +class, so no visibility change is needed. + +## Testing + +| Level | File | Cases | +| ----------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Type | `packages/client/src/types-inference.spec.ts` | `for(other).startWorkflow("nameFromOther")` compiles; a workflow name from the bootstrap contract is rejected on the derived client. This is where the real risk lives. | +| Unit | `packages/client/src/client.spec.ts` | `for(own) === client`; `for(c)` twice returns the same instance; the derived client uses the derived contract's `taskQueue`; input/output validation runs against the derived contract's schemas; interceptors are inherited; `ensureConnected()` is not called again. | +| Integration | `packages/client/src/__tests__/` | New `second.contract.ts` + `second.workflows.ts` fixtures and a second `Worker` on the second task queue; one case proving `client.for(secondContract).executeWorkflow(...)` is routed to that queue against a live server. | + +The integration fixture mirrors the existing `worker` vitest fixture in +`__tests__/client.spec.ts` (`Worker.create` with `taskQueue`, `workflowsPath`, +`{ auto: true }`, shutdown in teardown). + +## Documentation + +- `docs/reference/client-surface.md` — add the `for()` entry. +- TSDoc `@example` on the method, which renders into the generated API docs. +- No new how-to page: a page for a single method is thin, and the reference entry + plus the example carries it. + +## Release + +A `minor` changeset on `@temporal-contract/client`. The repo is in changesets pre +mode on the `beta` tag, so it folds into the next `8.0.0-beta.N`; the fixed group +bumps all four packages together. + +## Follow-up (not in scope) + +If a process ever binds three or more contracts, or the privileged bootstrap +contract causes a real ordering problem, introduce an unbound connection-scoped +root that mints contract-bound clients (`root.for(contract)`), keeping +`TypedClient.create({ contract, client })` as sugar. `for()` semantics are +identical, so call sites do not change. + +## Unrelated defect found while investigating + +`packages/worker/src/workflow.ts` — the TSDoc for `startChildWorkflow` and +`executeChildWorkflow` claims: + +> - Same contract: Pass workflowName from current contract +> - Cross-contract: Pass contract and workflowName to invoke workflows from other workers + +Both signatures take `contract` as a **required** first parameter; there is no +same-contract overload. The documentation describes an API that does not exist. +Fix separately. From 3b414ba4bef7a5dc0a628151aa18ad67f9f653d9 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Wed, 29 Jul 2026 21:12:46 +0200 Subject: [PATCH 2/3] docs: revise client spec to decouple the client from the contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, 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) --- ...29-typed-client-contract-binding-design.md | 209 +++++++++++------- 1 file changed, 132 insertions(+), 77 deletions(-) diff --git a/.agents/specs/2026-07-29-typed-client-contract-binding-design.md b/.agents/specs/2026-07-29-typed-client-contract-binding-design.md index e4c5ca50..5bcaa192 100644 --- a/.agents/specs/2026-07-29-typed-client-contract-binding-design.md +++ b/.agents/specs/2026-07-29-typed-client-contract-binding-design.md @@ -1,4 +1,4 @@ -# Design: bind a second contract to an existing `TypedClient` +# Design: decouple the client from the contract - **Date:** 2026-07-29 - **Status:** approved, not yet implemented @@ -47,17 +47,33 @@ async refreshDeclaration(declarationId: string): Promise { handler. 2. **Part of the pain is a version artifact.** The consumer pins `catalog:temporalContract7`. On 8.0 `create` returns - `AsyncResult, never>`, so the `mapErr` / + `AsyncResult<..., never>`, so the `mapErr` / `tag("@temporal-contract/TechnicalError")` ceremony above does not compile and collapses to `.get()`. -3. **The multi-contract burden is currently theoretical.** Across the millenium - monorepo, temporal-contract is consumed by `applications/plato` (one contract) - and, in this POC, `service-lease-accounting` (`leaseAccountingComputeContract`). - One contract per application; no process binds two today. +3. **The multi-contract case is not yet present.** Across the millenium monorepo, + temporal-contract is consumed by `applications/plato` (one contract) and, in + this POC, `service-lease-accounting` (`leaseAccountingComputeContract`). One + contract per application; no process binds two today. The driver for this + change is therefore separation of concerns, not contract count — see Rationale. 4. **Precedent exists for contract-per-call.** The worker's `context.startChildWorkflow(contract, name, options)` takes the contract as a required first parameter. +## Rationale + +A client is a **connection**. A contract is a **schema**. Coupling them means a +connection cannot be established without first choosing a schema, which is +backwards: the connection is the scarce, fallible, process-lifetime resource, and +the schema is a free compile-time artifact. + +The practical consequences of the current coupling: + +- Establishing a connection requires naming a contract, so an application with + two contracts opens two clients and runs `ensureConnected()` twice. +- The fallible, async step cannot be hoisted to process start without also fixing + the contract there, which is what pushed construction into a request handler in + the originating MR. + ## Non-goals - **A NestJS integration package.** `@temporal-contract/client-nestjs` and @@ -65,108 +81,147 @@ async refreshDeclaration(declarationId: string): Promise { (`53dfb80`, 2026-03-01) to "simplify the project scope and reduce maintenance burden". The consumer additionally has its own in-house `@emeria/nestjs-temporal` (`@ContractActivitiesHandler()`, activity explorer), so the need is already met - outside this repo. Re-adding it would reverse a deliberate decision to solve a - problem the consumer has already solved. The removed provider was also - one-module-per-contract, so it would not have addressed this complaint anyway. -- **Moving the contract to the call site** (`client.startWorkflow(contract, name, -options)`). Rejected: it taxes every call site to serve a case that does not - exist yet, and `client.schedule` would need the contract threaded through - `TypedScheduleClient` as well. + outside this repo. The removed provider was also one-module-per-contract, so it + would not have addressed this complaint anyway. +- **Moving the contract onto every call** (`client.startWorkflow(contract, name, +options)`). Rejected: it taxes every call site, and `client.schedule` would need + the contract threaded through `TypedScheduleClient` as well. Binding once via + `for()` gives the same decoupling without the per-call cost. ## Decision -Add one synchronous instance method to `TypedClient`. +Split the class in two. ```ts -class TypedClient { - /** - * Bind another contract, reusing this client's connection and interceptors. - * Synchronous, infallible, memoized per contract identity. - */ - for(contract: TOther): TypedClient; +/** Connection-scoped. No contract, no type parameter. */ +export class TypedClient { + static create(options: { + client: Client; + interceptors?: readonly ClientInterceptor[]; + }): AsyncResult; + + /** Bind a contract. Synchronous, infallible, memoized per contract identity. */ + for(contract: TContract): ContractClient; +} + +/** Contract-scoped. Everything TypedClient exposes today. */ +export class ContractClient { + startWorkflow(...): ...; + executeWorkflow(...): ...; + signalWithStart(...): ...; + getHandle(...): ...; + readonly schedule: TypedScheduleClient; } ``` ```ts -const client = (await TypedClient.create({ contract: leaseContract, client: temporal })).get(); +const client = await TypedClient.create({ client: temporal }).get(); // once, at startup -await client.startWorkflow("computeOneLessorTaxDeclarationWorkflow", { workflowId, args }); +await client.for(leaseContract).startWorkflow("computeOneLessorTaxDeclarationWorkflow", { + workflowId, + args, +}); await client.for(platoContract).startWorkflow("someOtherWorkflow", { workflowId, args }); ``` -Existing call sites are untouched, nothing is renamed, and no new type is exported. +No contract is privileged; every contract is reached the same way. ### Semantics -- **Infallible.** The private constructor's only `throw` is the missing - `client.schedule` check (`@temporalio/client` < 1.16). `for()` reuses the same - `Client` that already passed that check during `create()`, so it cannot fail. It - returns `TypedClient` directly — no `AsyncResult` wrapper. This is the +- **`for()` is infallible.** The `@temporalio/client >= 1.16` check (a missing + `client.schedule`) moves to `TypedClient.create`, where it belongs — it is a + property of the connection, not of any contract. `for()` therefore returns + `ContractClient` directly, with no `AsyncResult` wrapper. This is the ergonomic win: binding is a plain expression, valid in a field initializer. - **Memoized and identity-stable.** A - `WeakMap>` seeded with - `[this.contract, this]`, so `client.for(ownContract) === client` and repeated - `for(c)` returns the same instance instead of rebuilding `TypedScheduleClient`. - The map erases the type parameter, so storing and reading each cross a cast — - contained to the two lines inside `for()`. -- **Inherits `client` and `interceptors`.** No per-call interceptor override — - YAGNI, and addable later without a break. -- **Never reconnects.** `ensureConnected()` stays a `create()`-time concern. - -### Known wart - -The bootstrap contract is privileged: contract B is reached _through_ contract A's -client. Accepted for now — see Follow-up. + `WeakMap>` on the root, so + repeated `for(c)` returns the same instance rather than rebuilding + `TypedScheduleClient`. The map erases the type parameter, so the store and the + read each require a cast, contained to the two lines inside `for()`. +- **`ContractClient` inherits `client` and `interceptors` from the root.** No + per-call interceptor override — YAGNI, and addable later without a break. +- **`create` still awaits `ensureConnected()`**, once per process rather than once + per contract. + +## Breaking changes + +8.0 has not shipped, so these land inside the existing beta line rather than +forcing a new major. + +| Before | After | +| -------------------------------------------------- | ---------------------------------------------- | +| `TypedClient.create({ contract, client })` | `TypedClient.create({ client }).for(contract)` | +| `TypedClient` (type annotation) | `ContractClient` | +| `TypedClient.createOrThrow(contract, client, ...)` | removed | + +`create({ contract, client })` is **not** retained as a deprecated alias: keeping +it would preserve exactly the contract dependency this change removes. +`createOrThrow` is already `@deprecated` and takes a contract positionally, so it +goes at the same time. + +Roughly 15 references to `TypedClient<...>` exist in-repo (tests, examples, +`docs/reference/client-surface.md`); about 6 are `TypedClient` +annotations that become `ContractClient`. ## Implementation -Single file: `packages/client/src/client.ts`. - -1. Add a private `bound` field: - `WeakMap>`. -2. Seed it with `this.contract -> this` at the end of the private constructor. -3. Add the public `for(contract: TOther): TypedClient` method: - look up the memo, otherwise construct via the private constructor with - `(contract, this.client, this.interceptors)`, store, return. -4. Add a TSDoc `@example` including `import { P } from "unthrown";` where the - example matches on errors — per the convention that every standalone example - block shows where its symbols come from. - -The private constructor is reachable from `for()` because both live on the same -class, so no visibility change is needed. +`packages/client/src/client.ts`. + +1. Rename `class TypedClient` to `ContractClient`, keeping + every member as-is. Its constructor stays private. +2. Move the `client.schedule` presence check out of that constructor into the new + root's `create`. +3. Add `class TypedClient` holding `client`, `interceptors`, and the memo + `WeakMap`; `static create({ client, interceptors })` performs the schedule check + and `ensureConnected()`, returning `AsyncResult`. +4. Add `TypedClient#for(contract)`: read the memo, otherwise construct a + `ContractClient` with `(contract, this.client, this.interceptors)`, store, + return. +5. Delete `createOrThrow` and `CreateTypedClientOptions`'s `contract` field. +6. Export `ContractClient` from the package entry point. +7. Add a TSDoc `@example` to both `create` and `for`, each showing its imports — + per the convention that a standalone example block names where its symbols come + from. + +`ContractClient` must be constructible from `TypedClient#for`. Since they are +separate classes, the constructor cannot stay `private`; make it `internal` by +convention (a documented `@internal` TSDoc tag plus omission from the public +surface docs) rather than exporting a factory. + +> `client.ts` is already ~1100 lines. Splitting `ContractClient` into its own +> module is tempting but would balloon the diff and obscure the rename in review. +> Keep both classes in `client.ts` for this change; split separately if it grows. ## Testing -| Level | File | Cases | -| ----------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Type | `packages/client/src/types-inference.spec.ts` | `for(other).startWorkflow("nameFromOther")` compiles; a workflow name from the bootstrap contract is rejected on the derived client. This is where the real risk lives. | -| Unit | `packages/client/src/client.spec.ts` | `for(own) === client`; `for(c)` twice returns the same instance; the derived client uses the derived contract's `taskQueue`; input/output validation runs against the derived contract's schemas; interceptors are inherited; `ensureConnected()` is not called again. | -| Integration | `packages/client/src/__tests__/` | New `second.contract.ts` + `second.workflows.ts` fixtures and a second `Worker` on the second task queue; one case proving `client.for(secondContract).executeWorkflow(...)` is routed to that queue against a live server. | +| Level | File | Cases | +| ----------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Type | `packages/client/src/types-inference.spec.ts` | `for(c).startWorkflow(name)` accepts only `c`'s workflow names; two different contracts yield mutually incompatible `ContractClient` types. This is where the real risk lives. | +| Unit | `packages/client/src/client.spec.ts` | `for(c)` twice returns the same instance; two contracts yield distinct instances; each uses its own `taskQueue` and validates against its own schemas; interceptors are inherited; `ensureConnected()` runs once per root; `create` rejects a `Client` without `schedule`. | +| Integration | `packages/client/src/__tests__/` | New `second.contract.ts` + `second.workflows.ts` fixtures and a second `Worker` on the second task queue; one case proving one root drives both contracts and that `for(secondContract).executeWorkflow(...)` is routed to the second queue against a live server. | -The integration fixture mirrors the existing `worker` vitest fixture in -`__tests__/client.spec.ts` (`Worker.create` with `taskQueue`, `workflowsPath`, -`{ auto: true }`, shutdown in teardown). +Existing suites need their `TypedClient` fixtures retyped to +`ContractClient` and their construction updated to +`create({ client }).for(contract)`. The integration fixture mirrors the existing +`worker` vitest fixture in `__tests__/client.spec.ts` (`Worker.create` with +`taskQueue`, `workflowsPath`, `{ auto: true }`, shutdown in teardown). ## Documentation -- `docs/reference/client-surface.md` — add the `for()` entry. -- TSDoc `@example` on the method, which renders into the generated API docs. -- No new how-to page: a page for a single method is thin, and the reference entry - plus the example carries it. +- `docs/reference/client-surface.md` — document both classes and `for()`. +- `docs/how-to/upgrade-to-v8.md` — a migration section for the three breaking + changes in the table above. +- Every `TypedClient.create({ contract, client })` occurrence across `docs/`, + `README.md`, `packages/*/README.md` and `examples/` updated to the two-step + form. +- TSDoc `@example` on `create` and `for`, which render into the generated API docs. ## Release -A `minor` changeset on `@temporal-contract/client`. The repo is in changesets pre -mode on the `beta` tag, so it folds into the next `8.0.0-beta.N`; the fixed group -bumps all four packages together. - -## Follow-up (not in scope) - -If a process ever binds three or more contracts, or the privileged bootstrap -contract causes a real ordering problem, introduce an unbound connection-scoped -root that mints contract-bound clients (`root.for(contract)`), keeping -`TypedClient.create({ contract, client })` as sugar. `for()` semantics are -identical, so call sites do not change. +A `major` changeset on `@temporal-contract/client` — the change is breaking. The +repo is in changesets pre mode on the `beta` tag, so it folds into the next +`8.0.0-beta.N` rather than opening a 9.0 line; the fixed group bumps all four +packages together. ## Unrelated defect found while investigating From 0274e9b0dd55c9bdf6ea04fccfb28d0906525d91 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 29 Jul 2026 23:47:36 +0200 Subject: [PATCH 3/3] docs: record why the worker stays contract-bound and tighten the client 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 --- ...29-typed-client-contract-binding-design.md | 55 +++++++++++++++---- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/.agents/specs/2026-07-29-typed-client-contract-binding-design.md b/.agents/specs/2026-07-29-typed-client-contract-binding-design.md index 5bcaa192..fd78af06 100644 --- a/.agents/specs/2026-07-29-typed-client-contract-binding-design.md +++ b/.agents/specs/2026-07-29-typed-client-contract-binding-design.md @@ -83,10 +83,28 @@ The practical consequences of the current coupling: (`@ContractActivitiesHandler()`, activity explorer), so the need is already met outside this repo. The removed provider was also one-module-per-contract, so it would not have addressed this complaint anyway. -- **Moving the contract onto every call** (`client.startWorkflow(contract, name, -options)`). Rejected: it taxes every call site, and `client.schedule` would need - the contract threaded through `TypedScheduleClient` as well. Binding once via - `for()` gives the same decoupling without the per-call cost. +- **Moving the contract onto every call** + (`client.startWorkflow(contract, name, options)`). Rejected: it taxes every + call site, and `client.schedule` would need the contract threaded through + `TypedScheduleClient` as well. Binding once via `for()` gives the same + decoupling without the per-call cost. +- **Applying the same decoupling to the worker.** The client's contract coupling + was accidental — a connection and a schema have nothing to do with each other. + The worker's is essential: a Temporal `Worker` instance polls exactly one task + queue, and the contract is the worker's job description — it supplies the task + queue (`packages/worker/src/worker.ts` passes `taskQueue: contract.taskQueue`), + the workflows and activities to register, and the schemas to validate against. + An unbound worker would have nothing to poll and nothing to run, so + `createWorker({ contract, ... })` stays as-is. (The invariant is one `Worker` + _instance_ per task queue, not one worker process — Temporal scales + horizontally by running many processes on the same queue.) The client-side + principle — share the scarce connection — already holds on the worker side + without an API change: `createWorker` spreads its remaining options through to + `Worker.create`, so a process hosting two contracts runs two `Worker` instances + sharing one `NativeConnection`. The only gap is two contracts declaring the + _same_ `taskQueue` string, which would need one worker registering both + contracts' handlers; that is a design smell (two schema universes interleaved + on one queue) and stays unsupported until a real need appears. ## Decision @@ -126,6 +144,16 @@ await client.for(platoContract).startWorkflow("someOtherWorkflow", { workflowId, No contract is privileged; every contract is reached the same way. +### Naming + +`TypedClient` keeps its name even though the type parameter moves to +`ContractClient`. Renaming the root (`TemporalClient`, `ClientRoot`, …) was +considered — the unshipped beta is the only cheap window — and rejected: +`TypedClient` is the package's documented entry point, every consumer and doc +starts from it, and the change is easier to teach as "`TypedClient` now hands +out contract-bound clients" than as two renames at once. The name stays honest +enough: the root is what makes the client typed, via `for()`. + ### Semantics - **`for()` is infallible.** The `@temporalio/client >= 1.16` check (a missing @@ -137,7 +165,14 @@ No contract is privileged; every contract is reached the same way. `WeakMap>` on the root, so repeated `for(c)` returns the same instance rather than rebuilding `TypedScheduleClient`. The map erases the type parameter, so the store and the - read each require a cast, contained to the two lines inside `for()`. + read each require a cast, contained to the two lines inside `for()`. Dropping + memoization was considered (construction is cheap, and the casts would go with + it) but identity stability keeps `for()` free to call in hot paths — the + natural consumer shape is `this.client.for(contract).startWorkflow(...)` per + request — without allocating a `TypedScheduleClient` per call. Note the + constraint this places on a future `for(contract, options)` overload: only the + option-less call could serve from the memo, so the `for(c) === for(c)` + guarantee is documented for the option-less form only. - **`ContractClient` inherits `client` and `interceptors` from the root.** No per-call interceptor override — YAGNI, and addable later without a break. - **`create` still awaits `ensureConnected()`**, once per process rather than once @@ -194,11 +229,11 @@ surface docs) rather than exporting a factory. ## Testing -| Level | File | Cases | -| ----------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Type | `packages/client/src/types-inference.spec.ts` | `for(c).startWorkflow(name)` accepts only `c`'s workflow names; two different contracts yield mutually incompatible `ContractClient` types. This is where the real risk lives. | -| Unit | `packages/client/src/client.spec.ts` | `for(c)` twice returns the same instance; two contracts yield distinct instances; each uses its own `taskQueue` and validates against its own schemas; interceptors are inherited; `ensureConnected()` runs once per root; `create` rejects a `Client` without `schedule`. | -| Integration | `packages/client/src/__tests__/` | New `second.contract.ts` + `second.workflows.ts` fixtures and a second `Worker` on the second task queue; one case proving one root drives both contracts and that `for(secondContract).executeWorkflow(...)` is routed to the second queue against a live server. | +| Level | File | Cases | +| ----------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Type | `packages/client/src/types-inference.spec.ts` | `for(c).startWorkflow(name)` accepts only `c`'s workflow names; two different contracts yield mutually incompatible `ContractClient` types; `TypedClient` accepts no type argument, so a stray 7.x-style `TypedClient` fails loudly during migration rather than resolving silently. This is where the real risk lives. | +| Unit | `packages/client/src/client.spec.ts` | `for(c)` twice returns the same instance; two contracts yield distinct instances; each uses its own `taskQueue` and validates against its own schemas; interceptors are inherited; `ensureConnected()` runs once per root; `create` rejects a `Client` without `schedule`. | +| Integration | `packages/client/src/__tests__/` | New `second.contract.ts` + `second.workflows.ts` fixtures and a second `Worker` on the second task queue; one case proving one root drives both contracts and that `for(secondContract).executeWorkflow(...)` is routed to the second queue against a live server. | Existing suites need their `TypedClient` fixtures retyped to `ContractClient` and their construction updated to