From 0fd4efe85bb161ddf0511ed4f13f8f5b3d0d81fd Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 30 Jul 2026 22:47:07 +0200 Subject: [PATCH 01/15] feat!: replace createWorker with TypedWorker.create and adopt OkAsync/ErrAsync Family-consistency audit against amqp-contract/unthrown conventions: - New TypedWorker class with a static create(options) factory (the org's Typed*.create() shape, sibling of TypedClient.create) returning AsyncResult; infrastructure failures stay TechnicalError-caused defects. run() returns AsyncResult (never-rejecting, mid-run crash = defect), shutdown() delegates, and the raw Temporal Worker is exposed via .raw (runUntil/getState). The free createWorker is removed (beta window). - createContractTest's worker fixture now exposes the TypedWorker and drops the unhandled-rejection guard (run()'s promise never rejects). - Pre-lifted async results are built with OkAsync(v)/ErrAsync(e) (canonical since unthrown 4.1) instead of Ok(v).toAsync()/Err(e).toAsync() across source, specs, TSDoc, docs, examples, and agent rules. - Docs/README/upgrade-guide sweep + changeset (fixed group, major). --- .agents/rules/handlers.md | 44 ++-- .changeset/family-consistency-typed-worker.md | 20 ++ AGENTS.md | 2 +- docs/explanation/architecture.md | 4 +- docs/explanation/the-result-model.md | 2 +- docs/how-to/add-activity-middleware.md | 12 +- docs/how-to/configure-a-worker.md | 46 ++-- docs/how-to/implement-activities.md | 6 +- docs/how-to/intercept-client-calls.md | 11 +- docs/how-to/test-workflows.md | 19 +- docs/how-to/troubleshoot.md | 4 +- docs/how-to/upgrade-to-v8.md | 49 ++++- docs/reference/errors.md | 4 +- docs/reference/worker-surface.md | 26 ++- docs/tutorial/your-first-workflow.md | 6 +- .../src/application/activities.ts | 8 +- .../src/application/worker.ts | 26 +-- packages/contract/src/errors.ts | 2 +- .../src/__tests__/contract-test.spec.ts | 6 +- packages/testing/src/activity.spec.ts | 8 +- packages/testing/src/contract.ts | 33 +-- packages/testing/src/time-skipping.ts | 6 +- packages/worker/README.md | 14 +- .../__tests__/time-skipping.inprocess.spec.ts | 19 +- packages/worker/src/__tests__/worker.spec.ts | 49 ++--- .../src/activity-contract-errors.spec.ts | 61 +++--- packages/worker/src/activity.spec.ts | 42 ++-- packages/worker/src/activity.ts | 8 +- packages/worker/src/worker.spec.ts | 112 +++++++++- packages/worker/src/worker.ts | 197 ++++++++++++------ packages/worker/src/workflow.ts | 11 +- 31 files changed, 544 insertions(+), 313 deletions(-) create mode 100644 .changeset/family-consistency-typed-worker.md diff --git a/.agents/rules/handlers.md b/.agents/rules/handlers.md index f420c7ed..6b5ba580 100644 --- a/.agents/rules/handlers.md +++ b/.agents/rules/handlers.md @@ -29,9 +29,11 @@ case, the worker package exports a `qualifyFailure(type, options?)` helper that that function — `fromPromise(inventoryService.check(orderId), qualifyFailure("INVENTORY_CHECK_FAILED"))` preserves an `Error` rejection's message and `cause`, with `options.nonRetryable` / `options.details` / `options.message` (non-`Error` fallback) available. For a -value you already have, use `OkAsync(value)` / `ErrAsync(failure)`, or lift an -existing sync `Result` with `Ok(value).toAsync()` / `Err(failure).toAsync()` — -unthrown has no lowercase `okAsync`/`errAsync`. +value you already have, use the canonical pre-lifted constructors +`OkAsync(value)` / `ErrAsync(failure)` (prefer `OkAsync()` zero-arg over +`OkAsync(undefined)`); `.toAsync()` is for lifting a sync `Result` you already +hold, not for direct construction — unthrown has no lowercase +`okAsync`/`errAsync`. Canonical example: `examples/order-processing-worker/src/application/activities.ts`. @@ -100,21 +102,29 @@ Typed-error semantics inside the workflow context: ## Worker Setup -`createWorker` returns `AsyncResult` — bundling / connection -failures are _technical_ infrastructure faults, so they ride the **defect** -channel (a `TechnicalError` instance as the cause), not the modeled Err -channel. `activities` is optional — omit it for a workflow-only worker. Same -shape on the client: the connection-scoped `TypedClient.create({ client })` -returns `AsyncResult` (a `TechnicalError`-caused defect on -setup failure); bind a contract with the synchronous, infallible -`typedClient.for(contract)`, which returns a `ContractClient` (the -type to use in annotations). There is no `TypedClient.createOrThrow` and no -`createWorkerOrThrow` — use `.get()` on the returned `AsyncResult`. +`TypedWorker.create(options)` — the org's `Typed*.create()` factory shape, +mirroring `TypedClient.create` — returns `AsyncResult`: +bundling / connection failures are _technical_ infrastructure faults, so they +ride the **defect** channel (a `TechnicalError` instance as the cause), not +the modeled Err channel. `activities` is optional — omit it for a +workflow-only worker. Same shape on the client: the connection-scoped +`TypedClient.create({ client })` returns `AsyncResult` (a +`TechnicalError`-caused defect on setup failure); bind a contract with the +synchronous, infallible `typedClient.for(contract)`, which returns a +`ContractClient` (the type to use in annotations). There is no +`TypedClient.createOrThrow`, no `createWorker`, and no `createWorkerOrThrow` +— use `.get()` on the returned `AsyncResult`. + +`TypedWorker` owns the unthrown-disciplined lifecycle: `run()` returns +`AsyncResult` (a mid-run crash is a `TechnicalError`-caused +defect; the internal promise never rejects) and `shutdown()` initiates a +graceful drain. Everything else Temporal offers (`runUntil`, `getState`) +lives on the raw escape hatch `worker.raw`. ```typescript -import { createWorker, workflowsPathFromURL } from "@temporal-contract/worker/worker"; +import { TypedWorker, workflowsPathFromURL } from "@temporal-contract/worker/worker"; -const workerResult = await createWorker({ +const workerResult = await TypedWorker.create({ contract: myContract, connection, workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"), @@ -125,7 +135,7 @@ if (workerResult.isDefect()) { process.exit(1); } -await workerResult.value.run(); +await workerResult.get().run().get(); ``` ## Cancellation @@ -184,7 +194,7 @@ ApplicationFailure.create({ ## Anti-patterns -- **Never throw** from activities — Temporal sees thrown errors as `ApplicationFailure(type: "Error", retryable: true)` by default, which masks the real failure type and triggers unwanted retries. Use `Err(ApplicationFailure.create({ type, message, nonRetryable })).toAsync()` (or a `fromPromise(promise, qualifyFailure)` chain whose `qualifyFailure` returns the `ApplicationFailure`) instead. +- **Never throw** from activities — Temporal sees thrown errors as `ApplicationFailure(type: "Error", retryable: true)` by default, which masks the real failure type and triggers unwanted retries. Use `ErrAsync(ApplicationFailure.create({ type, message, nonRetryable }))` (or a `fromPromise(promise, qualifyFailure)` chain whose `qualifyFailure` returns the `ApplicationFailure`) instead. - **Never use `any`** — use `unknown` and validate with schemas. Enforced by oxlint. - **Always use `.js` extensions** in imports (even for TypeScript files) — required by ESM module resolution. - **Don't `try/catch` `CancelledFailure` in workflows** — use `cancellableScope` so cancellation flows through the same `AsyncResult` discipline as everything else. diff --git a/.changeset/family-consistency-typed-worker.md b/.changeset/family-consistency-typed-worker.md new file mode 100644 index 00000000..309e397d --- /dev/null +++ b/.changeset/family-consistency-typed-worker.md @@ -0,0 +1,20 @@ +--- +"@temporal-contract/worker": major +"@temporal-contract/testing": major +"@temporal-contract/contract": major +"@temporal-contract/client": major +--- + +Family-consistency audit: `TypedWorker.create` replaces `createWorker`, and `OkAsync`/`ErrAsync` become the canonical pre-lifted constructors. + +**Breaking (worker).** The free `createWorker` function is removed in favour of a static factory on a new `TypedWorker` class — the worker-side sibling of `TypedClient.create` and the org's shared `Typed*.create()` shape (matching amqp-contract's `TypedAmqpClient.create` / `TypedAmqpWorker.create`): + +- `TypedWorker.create(options)` takes the same `CreateWorkerOptions` and returns `AsyncResult` — bundling/connection failures stay technical defects with a `TechnicalError` cause; unwrap with `.get()`. +- `worker.run()` returns `AsyncResult`: a worker that fails while running surfaces as a `TechnicalError`-caused defect and the underlying promise never rejects (safe to hold across a test without unhandled-rejection guards). `await worker.run().get()` rethrows at the edge. +- `worker.shutdown()` delegates to the raw worker; everything else Temporal's runtime owns (`runUntil`, `getState`, …) lives on the `worker.raw` escape hatch. + +Migration: `createWorker(opts).get()` → `TypedWorker.create(opts).get()`, `await worker.run()` → `await worker.run().get()`, `worker.runUntil(...)`/`worker.getState()` → `worker.raw.runUntil(...)`/`worker.raw.getState()`. + +**Breaking (testing).** `createContractTest`'s `worker` fixture now exposes the `TypedWorker` (the raw Temporal `Worker` is at `worker.raw`). + +**Docs/idiom sweep (all packages).** Pre-lifted async results are now built with `OkAsync(value)` / `ErrAsync(error)` (canonical since unthrown 4.1) instead of `Ok(value).toAsync()` / `Err(error).toAsync()` throughout the docs, examples, and TSDoc; `.toAsync()` remains for lifting an existing sync `Result`. diff --git a/AGENTS.md b/AGENTS.md index e8a1df44..990e91a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ This file is the source of truth for agent guidance in this repo. `CLAUDE.md` an ## The 6 rules that prevent broken PRs 1. **Workflow code is deterministic.** No `Date.now()`, `Math.random()`, `setTimeout`, `crypto.randomUUID()`, native I/O, or `process.env` reads inside `declareWorkflow`'s `implementation`. Use `@temporalio/workflow` primitives (`sleep`, `uuid4`, the patched `Date`) or push the side effect into an activity. See [.agents/rules/workflow-determinism.md](.agents/rules/workflow-determinism.md). This is the #1 cause of broken Temporal workflows — read that file before touching workflow code. -2. **Activities and the typed client return `AsyncResult` from `unthrown`.** Never throw — wrap technical errors in `ApplicationFailure` and surface them via `Err(...).toAsync()` (or `fromPromise(promise, qualify)`, where `qualify` returns the modeled error `E`). unthrown has no lowercase `okAsync`/`errAsync`: use `OkAsync(value)`/`ErrAsync(error)` to construct an `AsyncResult` directly, or lift an existing sync `Result` with `Ok(value).toAsync()`/`Err(error).toAsync()`. The client uses unthrown's `Result` for sync returns. unthrown adds a third **`defect`** channel for _unanticipated_ failures — a thrown exception the code didn't model surfaces as a defect (inspectable via `result.isDefect()` / `result.cause`, re-thrown at the edge), not a typed `err`. Narrow before reaching `.value`/`.error`/`.cause` — both the `r.isOk()` method and the `isOk(r)` free function are type guards (same for `isErr`/`isDefect`); the codebase uses the methods. Error classes are built with `TaggedError("@temporal-contract/Name", { name: "Name" })<{ ...payload }>` — the `_tag` is package-namespaced to avoid collisions, while `options.name` keeps `Error.name` the bare class name for readable logs. The worker's `ValidationError` subclasses are the exception — they must stay `ApplicationFailure` for Temporal's terminal-failure semantics. There is no `neverthrow`, no `@swan-io/boxed`, and no `@temporal-contract/boxed` package — those were removed. +2. **Activities and the typed client return `AsyncResult` from `unthrown`.** Never throw — wrap technical errors in `ApplicationFailure` and surface them via `ErrAsync(...)` (or `fromPromise(promise, qualify)`, where `qualify` returns the modeled error `E`). `OkAsync(value)`/`ErrAsync(error)` are the canonical pre-lifted constructors (there is no lowercase `okAsync`/`errAsync`); lifting an existing sync `Result` with `.toAsync()` stays valid but is not used for direct construction in this codebase — prefer `OkAsync()` zero-arg over `OkAsync(undefined)`. The client uses unthrown's `Result` for sync returns. unthrown adds a third **`defect`** channel for _unanticipated_ failures — a thrown exception the code didn't model surfaces as a defect (inspectable via `result.isDefect()` / `result.cause`, re-thrown at the edge), not a typed `err`. Narrow before reaching `.value`/`.error`/`.cause` — both the `r.isOk()` method and the `isOk(r)` free function are type guards (same for `isErr`/`isDefect`); the codebase uses the methods. Error classes are built with `TaggedError("@temporal-contract/Name", { name: "Name" })<{ ...payload }>` — the `_tag` is package-namespaced to avoid collisions, while `options.name` keeps `Error.name` the bare class name for readable logs. The worker's `ValidationError` subclasses are the exception — they must stay `ApplicationFailure` for Temporal's terminal-failure semantics. There is no `neverthrow`, no `@swan-io/boxed`, and no `@temporal-contract/boxed` package — those were removed. 3. **No `any`.** Use `unknown` and narrow. Enforced by oxlint. 4. **`.js` extensions in every import.** TypeScript files import each other as `./foo.js`, never `./foo` or `./foo.ts`. Required by ESM module resolution. 5. **ESM only.** All packages are `"type": "module"`. No CommonJS in source. diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index b936432b..ac1beb6e 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -27,7 +27,7 @@ compatible set. ```typescript import { declareActivitiesHandler } from "@temporal-contract/worker/activity"; import { declareWorkflow } from "@temporal-contract/worker/workflow"; -import { createWorker } from "@temporal-contract/worker/worker"; +import { TypedWorker } from "@temporal-contract/worker/worker"; ``` This is not organizational tidiness. Workflow code is compiled into an isolated @@ -43,7 +43,7 @@ can legally use. Everywhere else you pass values. For workflows you pass a **path**: ```typescript -const worker = await createWorker({ +const worker = await TypedWorker.create({ contract: orderContract, connection, workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"), diff --git a/docs/explanation/the-result-model.md b/docs/explanation/the-result-model.md index d36f9db9..02af5f20 100644 --- a/docs/explanation/the-result-model.md +++ b/docs/explanation/the-result-model.md @@ -51,7 +51,7 @@ Nobody writes domain logic branching on "the gRPC transport hiccupped". Keeping them in `E` forced every caller to write an arm for a case they would only ever log. -So `TypedClient.create` and `createWorker` now return `AsyncResult<_, never>`. +So `TypedClient.create` and `TypedWorker.create` now return `AsyncResult<_, never>`. An empty error channel is a precise statement: _this operation has no anticipated failure modes_. Everything that can go wrong is a defect. diff --git a/docs/how-to/add-activity-middleware.md b/docs/how-to/add-activity-middleware.md index ae25e229..322968ae 100644 --- a/docs/how-to/add-activity-middleware.md +++ b/docs/how-to/add-activity-middleware.md @@ -68,7 +68,7 @@ implementations. Use `declareActivityMiddleware` to pin the in and out types: ```typescript import { declareActivityMiddleware, type EmptyContext } from "@temporal-contract/worker/activity"; -import { Err } from "unthrown"; +import { ErrAsync } from "unthrown"; const withTenant = declareActivityMiddleware( (invocation, next) => { @@ -76,9 +76,7 @@ const withTenant = declareActivityMiddleware if (!tenantId) { // Short-circuit: never calls next(). - return Err( - ApplicationFailure.create({ type: "Unauthenticated", nonRetryable: true }), - ).toAsync(); + return ErrAsync(ApplicationFailure.create({ type: "Unauthenticated", nonRetryable: true })); } return next({ context: { tenantId } }); @@ -177,17 +175,17 @@ Because `next` can be called more than once, a retry is just a branch on the error channel: ```typescript -import { Err, P } from "unthrown"; +import { ErrAsync, P } from "unthrown"; const retryOnce: ActivityMiddleware = (invocation, next) => next().flatMapErrCases((matcher) => matcher .with(P.instanceOf(ApplicationFailure), (error) => - error.type === "GATEWAY_TIMEOUT" ? next() : Err(error).toAsync(), + error.type === "GATEWAY_TIMEOUT" ? next() : ErrAsync(error), ) // The middleware error union is `ApplicationFailure | AnyContractError`, // and the matcher must cover all of it — pass declared errors through. - .with(P._, (error) => Err(error).toAsync()), + .with(P._, (error) => ErrAsync(error)), ); ``` diff --git a/docs/how-to/configure-a-worker.md b/docs/how-to/configure-a-worker.md index a4534de3..da289399 100644 --- a/docs/how-to/configure-a-worker.md +++ b/docs/how-to/configure-a-worker.md @@ -6,7 +6,7 @@ the task queue named on the contract. ## Minimal worker ```typescript -import { createWorker, workflowsPathFromURL } from "@temporal-contract/worker/worker"; +import { TypedWorker, workflowsPathFromURL } from "@temporal-contract/worker/worker"; import { NativeConnection } from "@temporalio/worker"; import { activities } from "./activities.js"; @@ -14,14 +14,14 @@ import { orderContract } from "./contract.js"; const connection = await NativeConnection.connect({ address: "localhost:7233" }); -const worker = await createWorker({ +const worker = await TypedWorker.create({ contract: orderContract, connection, workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"), activities, }).get(); -await worker.run(); +await worker.run().get(); ``` `taskQueue` comes from the contract, so you never repeat it. Everything else on @@ -29,12 +29,12 @@ Temporal's `WorkerOptions` is accepted and passed through. ## Handle a failed start -`createWorker` returns `AsyncResult` — there is no modeled error. +`TypedWorker.create` returns `AsyncResult` — there is no modeled error. Bundling failures, bad connections, and invalid options are _technical_ faults, so they ride the **defect** channel with a `TechnicalError` cause: ```typescript -const result = await createWorker({ +const result = await TypedWorker.create({ contract: orderContract, connection, workflowsPath, @@ -46,12 +46,19 @@ if (result.isDefect()) { process.exit(1); } -await result.value.run(); +await result.value.run().get(); ``` `.get()` is the terse form — on a defect it rethrows the original cause with its stack intact, which is usually what you want at process startup. +`run()` has the same shape: it returns `AsyncResult`, so a worker +that fails while running surfaces as a defect (a `TechnicalError` cause) rather +than a rejected promise — `await worker.run().get()` rethrows it at the edge. +The underlying Temporal `Worker` stays available as `worker.raw` for anything +the typed surface doesn't cover (`worker.raw.getState()`, +`worker.raw.runUntil(...)`). + ## Resolve the workflows path Temporal bundles workflow code into an isolated sandbox, so it needs a _path_, @@ -90,32 +97,32 @@ effects.** See [Architecture](/explanation/architecture). polls exclusively for Workflow Tasks: ```typescript -import { createWorker, workflowsPathFromURL } from "@temporal-contract/worker/worker"; +import { TypedWorker, workflowsPathFromURL } from "@temporal-contract/worker/worker"; import { NativeConnection } from "@temporalio/worker"; import { orderContract } from "./contract.js"; const connection = await NativeConnection.connect({ address: "localhost:7233" }); -const worker = await createWorker({ +const worker = await TypedWorker.create({ contract: orderContract, connection, workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"), // no `activities` }).get(); -await worker.run(); +await worker.run().get(); ``` This is the split-deployment pattern: workflows are deterministic and CPU-light, activities do the heavy I/O, and the two often deserve different scaling profiles. Run one workflow-only worker process and a separate -activity worker process (a `createWorker` call _with_ `activities`) on the +activity worker process (a `TypedWorker.create` call _with_ `activities`) on the same task queue — Temporal routes each task kind to whichever worker polls for it. ```typescript -const worker = await createWorker({ +const worker = await TypedWorker.create({ contract: orderContract, connection, workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"), @@ -136,14 +143,14 @@ it low for CPU-bound or memory-hungry activities. ## Shut down gracefully ```typescript -const worker = await createWorker({/* ... */}).get(); +const worker = await TypedWorker.create({/* ... */}).get(); process.on("SIGTERM", () => { console.log("draining..."); worker.shutdown(); }); -await worker.run(); // resolves once in-flight tasks finish +await worker.run().get(); // resolves once in-flight tasks finish await connection.close(); ``` @@ -151,10 +158,11 @@ await connection.close(); Kubernetes, set `terminationGracePeriodSeconds` above your longest `startToCloseTimeout` so activities are not killed mid-flight. -Run the worker for a bounded scope instead when you want automatic cleanup: +Run the worker for a bounded scope instead when you want automatic cleanup — +`runUntil` lives on the raw Temporal worker: ```typescript -await worker.runUntil(async () => { +await worker.raw.runUntil(async () => { // worker is running for this block only }); ``` @@ -167,13 +175,13 @@ Each contract needs its own worker, because each has its own task queue: ```typescript const [orderWorker, shipmentWorker] = await Promise.all([ - createWorker({ + TypedWorker.create({ contract: orderContract, connection, workflowsPath: workflowsPathFromURL(import.meta.url, "./order.workflows.js"), activities: orderActivities, }).get(), - createWorker({ + TypedWorker.create({ contract: shipmentContract, connection, workflowsPath: workflowsPathFromURL(import.meta.url, "./shipment.workflows.js"), @@ -181,7 +189,7 @@ const [orderWorker, shipmentWorker] = await Promise.all([ }).get(), ]); -await Promise.all([orderWorker.run(), shipmentWorker.run()]); +await Promise.all([orderWorker.run().get(), shipmentWorker.run().get()]); ``` They share the connection. Split them into separate processes when their @@ -202,7 +210,7 @@ const connection = await NativeConnection.connect({ }, }); -const worker = await createWorker({ +const worker = await TypedWorker.create({ contract: orderContract, connection, namespace: "my-namespace.a1b2c", diff --git a/docs/how-to/implement-activities.md b/docs/how-to/implement-activities.md index 965b3476..c03a0c0e 100644 --- a/docs/how-to/implement-activities.md +++ b/docs/how-to/implement-activities.md @@ -100,7 +100,7 @@ processOrder: { fromPromise(riskEngine.score(customerId), qualifyFailure("RISK_CHECK_FAILED")) .flatMap((score) => score > 0.9 - ? Err(ApplicationFailure.create({ type: "HIGH_RISK", nonRetryable: true })).toAsync() + ? ErrAsync(ApplicationFailure.create({ type: "HIGH_RISK", nonRetryable: true })) : fromPromise(gateway.charge(customerId, amount), qualifyFailure("CHARGE_FAILED")), ) .map((charge) => ({ transactionId: charge.id })), @@ -109,8 +109,8 @@ processOrder: { - `.map` transforms a success value. - `.flatMap` runs another result-returning step and flattens. -- `Err(...).toAsync()` lifts a synchronous failure into an `AsyncResult` - (`ErrAsync(...)` is the shorthand). +- `ErrAsync(...)` builds an already-failed `AsyncResult` (the canonical + shorthand for `Err(...).toAsync()`); `OkAsync(...)` is its success twin. ## Inject dependencies diff --git a/docs/how-to/intercept-client-calls.md b/docs/how-to/intercept-client-calls.md index f678a373..a83f7ede 100644 --- a/docs/how-to/intercept-client-calls.md +++ b/docs/how-to/intercept-client-calls.md @@ -97,15 +97,15 @@ Modeled domain errors (`WorkflowNotInContractError`, a `ContractError`, a valida failure) stay on the `err` channel. Branch on those with `flatMapErrCases`: ```typescript -import { Err, Ok, P } from "unthrown"; +import { ErrAsync, OkAsync, P } from "unthrown"; const fallback: ClientInterceptor = (args, next) => next().flatMapErrCases((matcher) => matcher // Idempotent start: treat "already running" as success. - .with(P.tag("@temporal-contract/WorkflowAlreadyStartedError"), () => Ok(undefined).toAsync()) + .with(P.tag("@temporal-contract/WorkflowAlreadyStartedError"), () => OkAsync()) // The matcher must cover the whole union — `P._` passes the rest through. - .with(P._, (error) => Err(error).toAsync()), + .with(P._, (error) => ErrAsync(error)), ); ``` @@ -155,11 +155,12 @@ next().mapErrCases( ::: -To short-circuit with a _modeled_ outcome instead, return `Ok`: +To short-circuit with a _modeled_ outcome instead, return a success +(`OkAsync`): ```typescript const skipInReadOnly: ClientInterceptor = (args, next) => - maintenanceMode && args.operation === "signal" ? Ok(undefined).toAsync() : next(); + maintenanceMode && args.operation === "signal" ? OkAsync() : next(); ``` ## Measure latency diff --git a/docs/how-to/test-workflows.md b/docs/how-to/test-workflows.md index 26c74e02..f0e9ab66 100644 --- a/docs/how-to/test-workflows.md +++ b/docs/how-to/test-workflows.md @@ -177,14 +177,14 @@ pnpm add -D @temporal-contract/testing @temporalio/testing ```typescript import { TypedClient } from "@temporal-contract/client"; import { it } from "@temporal-contract/testing/time-skipping"; -import { createWorker, workflowsPathFromURL } from "@temporal-contract/worker/worker"; +import { TypedWorker, workflowsPathFromURL } from "@temporal-contract/worker/worker"; import { expect } from "vitest"; import { activities } from "./activities.js"; import { orderContract } from "./contract.js"; it("processes an order", async ({ testEnv }) => { - const worker = await createWorker({ + const worker = await TypedWorker.create({ contract: orderContract, connection: testEnv.nativeConnection, workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"), @@ -195,7 +195,7 @@ it("processes an order", async ({ testEnv }) => { client: testEnv.client, }).get(); - await worker.runUntil(async () => { + await worker.raw.runUntil(async () => { const result = await client.for(orderContract).executeWorkflow("processOrder", { workflowId: "order-test-1", args: { orderId: "ORD-1", customerId: "CUST-1", amount: 42 }, @@ -206,7 +206,8 @@ it("processes an order", async ({ testEnv }) => { }); ``` -`worker.runUntil` starts the worker, runs the callback, and shuts down cleanly. +`worker.raw.runUntil` (on the underlying Temporal worker) starts the worker, +runs the callback, and shuts down cleanly. The environment is created once per Vitest worker process and torn down when it exits — spawning one per test would dominate the suite's runtime. @@ -267,7 +268,7 @@ afterAll(async () => { it("expires an unapproved order after 24 hours", async ({ testEnv }) => { // ... worker + client setup ... - await worker.runUntil(async () => { + await worker.raw.runUntil(async () => { const started = await client.for(orderContract).startWorkflow("processOrder", { workflowId: "order-expiry", args: { orderId: "ORD-1", customerId: "CUST-1", amount: 42 }, @@ -335,7 +336,7 @@ import { orderContract } from "./contract.js"; const it = createContractTest(orderContract, { workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"), activities, // omit for a workflow-only worker - // workerOptions: forwarded to createWorker (namespace, interceptors, tuning) + // workerOptions: forwarded to TypedWorker.create (namespace, interceptors, tuning) }); describe("order processing", () => { @@ -358,12 +359,12 @@ container: ```typescript import { TypedClient } from "@temporal-contract/client"; import { it } from "@temporal-contract/testing/extension"; -import { createWorker, workflowsPathFromURL } from "@temporal-contract/worker/worker"; +import { TypedWorker, workflowsPathFromURL } from "@temporal-contract/worker/worker"; import { Client } from "@temporalio/client"; import { expect } from "vitest"; it("indexes the order by customer", async ({ clientConnection, workerConnection }) => { - const worker = await createWorker({ + const worker = await TypedWorker.create({ contract: orderContract, connection: workerConnection, workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"), @@ -374,7 +375,7 @@ it("indexes the order by customer", async ({ clientConnection, workerConnection client: new Client({ connection: clientConnection }), }).get(); - await worker.runUntil(async () => { + await worker.raw.runUntil(async () => { const result = await client.for(orderContract).executeWorkflow("processOrder", { workflowId: "order-search-1", args: { orderId: "ORD-1", customerId: "CUST-1", amount: 42 }, diff --git a/docs/how-to/troubleshoot.md b/docs/how-to/troubleshoot.md index be6ee128..fd48af1b 100644 --- a/docs/how-to/troubleshoot.md +++ b/docs/how-to/troubleshoot.md @@ -31,7 +31,7 @@ The worker package has no root export — import from a subpath: ```typescript import { declareActivitiesHandler } from "@temporal-contract/worker/activity"; import { declareWorkflow } from "@temporal-contract/worker/workflow"; -import { createWorker } from "@temporal-contract/worker/worker"; +import { TypedWorker } from "@temporal-contract/worker/worker"; ``` ## Type errors @@ -162,7 +162,7 @@ A `TechnicalError` on the defect channel — bundling failed, or the connection did not open. Inspect the cause: ```typescript -const result = await createWorker({ ... }); +const result = await TypedWorker.create({ ... }); if (result.isDefect()) { console.error(result.cause); // TechnicalError console.error(result.cause.cause); // the underlying failure diff --git a/docs/how-to/upgrade-to-v8.md b/docs/how-to/upgrade-to-v8.md index ef6d4783..28e6e944 100644 --- a/docs/how-to/upgrade-to-v8.md +++ b/docs/how-to/upgrade-to-v8.md @@ -142,7 +142,7 @@ for logging. ### Creation factories -`TypedClient.create` and `createWorker` now return `AsyncResult<_, never>`: +`TypedClient.create` and `TypedWorker.create` now return `AsyncResult<_, never>`: ```typescript // 7.x @@ -169,8 +169,8 @@ Or, more concisely — `.get()` rethrows a defect's original cause: const typedClient = await TypedClient.create({ client }).get(); ``` -The same applies to `createWorker`. The deprecated `createWorkerOrThrow` -migration alias is removed in 8.0 — use `createWorker(...).get()`. +The same applies to the worker factory. The deprecated `createWorkerOrThrow` +migration alias is removed in 8.0 — use `TypedWorker.create(...).get()`. ### Every other operation @@ -414,15 +414,40 @@ A mechanical rename, no alias kept: + fromPromise(gateway.charge(customerId, amount), qualifyFailure("CHARGE_FAILED")) ``` +### `createWorker` → `TypedWorker.create` + +The free `createWorker` function is replaced by a static factory on a +`TypedWorker` class — the worker-side sibling of `TypedClient.create` (the +same `Typed*.create()` shape used across the family). It takes the same +options and returns `AsyncResult`; the underlying +Temporal `Worker` stays reachable as `worker.raw`. + +```diff +- import { createWorker, workflowsPathFromURL } from "@temporal-contract/worker/worker"; ++ import { TypedWorker, workflowsPathFromURL } from "@temporal-contract/worker/worker"; + +- const worker = await createWorker({ contract, connection, workflowsPath, activities }).get(); ++ const worker = await TypedWorker.create({ contract, connection, workflowsPath, activities }).get(); + +- await worker.run(); ++ await worker.run().get(); +``` + +`TypedWorker.run()` returns `AsyncResult` — a worker that fails +while running is a defect (a `TechnicalError` cause), and the underlying +promise never rejects, so it is safe to hold onto across a test. +`worker.shutdown()` delegates to the raw worker; anything else Temporal +offers (`runUntil`, `getState`) lives on `worker.raw`. + ### `createWorkerOrThrow` is removed The deprecated throwing alias goes the same way as the client's -`createOrThrow`: `createWorker` returns `AsyncResult`, so -`.get()` gives the same throw-on-defect behavior with the original cause. +`createOrThrow`: `TypedWorker.create` returns `AsyncResult`, +so `.get()` gives the same throw-on-defect behavior with the original cause. ```diff - const worker = await createWorkerOrThrow({ contract, connection, workflowsPath, activities }); -+ const worker = await createWorker({ contract, connection, workflowsPath, activities }).get(); ++ const worker = await TypedWorker.create({ contract, connection, workflowsPath, activities }).get(); ``` ### Contract misuse fails the execution instead of hanging it @@ -440,14 +465,14 @@ bugs, they now surface as failed executions instead. ### Workflow-only workers -`activities` is now optional on `createWorker`. Omit it and the worker only +`activities` is now optional on `TypedWorker.create`. Omit it and the worker only polls for Workflow Tasks — the split-deployment pattern where workflow and activity workers scale independently on the same task queue: ```typescript -import { createWorker, workflowsPathFromURL } from "@temporal-contract/worker/worker"; +import { TypedWorker, workflowsPathFromURL } from "@temporal-contract/worker/worker"; -const worker = await createWorker({ +const worker = await TypedWorker.create({ contract: orderContract, connection, workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"), @@ -595,7 +620,7 @@ New on the surface: - [ ] `tag(...)` → `P.tag(...)` if you tracked an earlier 8.0 beta - [ ] `mapErr` / `flatMapErr` / `tapErr` / `recoverErr` → `*Cases` - [ ] `match({ err })` → `match({ errCases })` -- [ ] `TypedClient.create` / `createWorker` use `isDefect()` or `.get()` +- [ ] `TypedClient.create` / `TypedWorker.create` use `isDefect()` or `.get()` - [ ] No `P.tag("@temporal-contract/RuntimeClientError")` or `P.tag("@temporal-contract/TechnicalError")` arms remain - [ ] `TypedClient.create({ contract, client })` → @@ -605,7 +630,9 @@ New on the surface: (imports and `P.tag` arms) - [ ] `getHandle` calls drop their `await` (it returns a sync `Result`) - [ ] `qualify` → `qualifyFailure` in activity implementations -- [ ] `createWorkerOrThrow(...)` → `createWorker(...).get()` +- [ ] `createWorker(...)` / `createWorkerOrThrow(...)` → + `TypedWorker.create(...).get()`; `worker.run()` → `worker.run().get()`; + `runUntil` / `getState` via `worker.raw` - [ ] No `SignalInputValidationError` imports remain; alerting expects invalid signals to be dropped and logged, not to fail executions - [ ] `schedule.create` matchers handle `ScheduleAlreadyExistsError`; diff --git a/docs/reference/errors.md b/docs/reference/errors.md index 5ebe8f1f..5a1f6a36 100644 --- a/docs/reference/errors.md +++ b/docs/reference/errors.md @@ -89,7 +89,7 @@ compile. Never appears in a modeled `E` channel; it is only ever a defect's | `cause` | the underlying failure | ```typescript -const result = await createWorker({ ... }); +const result = await TypedWorker.create({ ... }); if (result.isDefect() && result.cause instanceof TechnicalError) { console.error(result.cause.message, result.cause.cause); } @@ -367,7 +367,7 @@ the defect channel instead. | Operation | `err` channel | | ------------------------------------------ | --------------------------------------------------------------------------------- | -| `createWorker` | `never` | +| `TypedWorker.create` / `TypedWorker.run` | `never` | | activity call, no declared errors | n/a — `Promise`, throws on failure | | activity call, declared errors | `ContractErrorUnion \| ActivityError \| ActivityCancelledError` | | `startChildWorkflow` | `ChildWorkflowError \| ChildWorkflowCancelledError \| ChildWorkflowNotFoundError` | diff --git a/docs/reference/worker-surface.md b/docs/reference/worker-surface.md index e926bc30..6ced9bb2 100644 --- a/docs/reference/worker-surface.md +++ b/docs/reference/worker-surface.md @@ -329,16 +329,24 @@ than the anything-goes `{}`. ## `@temporal-contract/worker/worker` -### `createWorker(options)` +### `TypedWorker.create(options)` ```typescript -function createWorker( - options: CreateWorkerOptions, -): AsyncResult; +class TypedWorker { + static create( + options: CreateWorkerOptions, + ): AsyncResult; + + readonly raw: Worker; + run(): AsyncResult; + shutdown(): void; +} ``` -`CreateWorkerOptions` is Temporal's `WorkerOptions` without `taskQueue` (taken -from the contract), plus `contract` and an optional `activities`. +The worker-side sibling of `TypedClient.create` — the org's `Typed*.create()` +factory shape. `CreateWorkerOptions` is Temporal's `WorkerOptions` without +`taskQueue` (taken from the contract), plus `contract` and an optional +`activities`. **`activities` is optional.** Omit it for a workflow-only worker — one that polls exclusively for Workflow Tasks, leaving activities to a separate worker @@ -350,6 +358,12 @@ technical faults on the **defect** channel with a `TechnicalError` cause. Inspect with `isDefect()` / `match({ defect })` / `recoverDefect`, or use `.get()` to rethrow the original cause. +**Lifecycle.** `run()` starts the worker loop and resolves `Ok` after a clean +shutdown; a worker that fails while running surfaces as a defect (a +`TechnicalError` cause), and the underlying promise never rejects. `shutdown()` +initiates a graceful drain. Everything else Temporal offers — `runUntil`, +`getState`, tuning introspection — lives on the `raw` escape hatch. + ### `workflowsPathFromURL(baseURL, relativePath)` ```typescript diff --git a/docs/tutorial/your-first-workflow.md b/docs/tutorial/your-first-workflow.md index c06fb2b9..65e1935d 100644 --- a/docs/tutorial/your-first-workflow.md +++ b/docs/tutorial/your-first-workflow.md @@ -255,7 +255,7 @@ the task queue named in the contract. Create `src/worker.ts`: ```typescript -import { createWorker, workflowsPathFromURL } from "@temporal-contract/worker/worker"; +import { TypedWorker, workflowsPathFromURL } from "@temporal-contract/worker/worker"; import { NativeConnection } from "@temporalio/worker"; import { activities } from "./activities.js"; @@ -263,7 +263,7 @@ import { orderContract } from "./contract.js"; const connection = await NativeConnection.connect({ address: "localhost:7233" }); -const worker = await createWorker({ +const worker = await TypedWorker.create({ contract: orderContract, connection, // Workflows are bundled separately, so they are referenced by path. @@ -272,7 +272,7 @@ const worker = await createWorker({ }).get(); console.log("worker listening on task queue 'orders'"); -await worker.run(); +await worker.run().get(); ``` Start it: diff --git a/examples/order-processing-worker/src/application/activities.ts b/examples/order-processing-worker/src/application/activities.ts index 880d8e70..829e45da 100644 --- a/examples/order-processing-worker/src/application/activities.ts +++ b/examples/order-processing-worker/src/application/activities.ts @@ -16,13 +16,15 @@ import { * Activity implementations using unthrown's `AsyncResult` pattern. * * Instead of throwing exceptions, activities return: - * - Ok(value).toAsync() for success - * - Err(ApplicationFailure).toAsync() for technical failures (or a + * - OkAsync(value) for success + * - ErrAsync(ApplicationFailure) for technical failures (or a * `fromPromise` chain whose `qualifyFailure` wraps a rejection into an * `ApplicationFailure`) - * - Err(errors.X(data)).toAsync() for *declared* contract errors — the + * - ErrAsync(errors.X(data)) for *declared* contract errors — the * typed constructors arrive in the implementation's second (helpers) * argument and surface to the calling workflow as typed `ContractError`s. + * (Sync `Ok(...)` / `Err(...)` stay valid inside combinator callbacks + * like `flatMap`, as below.) * * All technical exceptions MUST be caught and wrapped in `ApplicationFailure` * (Temporal's first-class failure shape, re-exported from diff --git a/examples/order-processing-worker/src/application/worker.ts b/examples/order-processing-worker/src/application/worker.ts index 6a8dfb19..cd345b7e 100644 --- a/examples/order-processing-worker/src/application/worker.ts +++ b/examples/order-processing-worker/src/application/worker.ts @@ -2,7 +2,7 @@ import { extname } from "node:path"; import { fileURLToPath } from "node:url"; import { orderProcessingContract } from "@temporal-contract/sample-order-processing-contract"; -import { createWorker } from "@temporal-contract/worker/worker"; +import { TypedWorker } from "@temporal-contract/worker/worker"; import { NativeConnection } from "@temporalio/worker"; import { logger } from "../logger.js"; @@ -28,29 +28,29 @@ async function run() { address: "localhost:7233", }); - // Create and run the worker using createWorker — creation failures are - // technical faults that ride the defect channel (a TechnicalError cause), - // not the Err channel and not thrown. - const workerResult = await createWorker({ + // Create and run the worker via the TypedWorker.create factory — creation + // failures are technical faults that ride the defect channel (a + // TechnicalError cause), not the Err channel and not thrown. + const workerResult = await TypedWorker.create({ contract: orderProcessingContract, connection, namespace: "default", workflowsPath: workflowPath("workflows"), activities, }); - if (!workerResult.isOk()) { - logger.error( - { err: workerResult.isErr() ? workerResult.error : workerResult.cause }, - "❌ Worker creation failed", - ); + if (workerResult.isDefect()) { + logger.error({ err: workerResult.cause }, "❌ Worker creation failed"); process.exit(1); } - const worker = workerResult.value; + // The Err channel is empty (never) and the defect case exited above, so + // `.get()` unwraps directly. + const worker = workerResult.get(); logger.info("✅ Worker registered successfully"); - // Run the worker - await worker.run(); + // Run the worker loop. `run()` returns AsyncResult — a + // runtime failure is a defect whose cause `.get()` rethrows below. + await worker.run().get(); } run().catch((err) => { diff --git a/packages/contract/src/errors.ts b/packages/contract/src/errors.ts index 4f1f046a..110b8885 100644 --- a/packages/contract/src/errors.ts +++ b/packages/contract/src/errors.ts @@ -27,7 +27,7 @@ import type { AnySchema, ErrorDefinition, InferErrorData, InferErrorDataInput } * TypeScript — connection failures, missing runtime capabilities, worker * bundling errors. These are *unmodeled* infrastructure faults, never * anticipated domain failures, so they ride the `Defect` channel: the - * creation factories (`TypedClient.create`, `createWorker`) surface them as a + * creation factories (`TypedClient.create`, `TypedWorker.create`) surface them as a * `Defect` whose `cause` is a `TechnicalError` instance (inspect via `match`'s * `defect` handler, `recoverDefect`, or `tapDefect`) — this class never * appears in a `Result`'s modeled `E` channel. diff --git a/packages/testing/src/__tests__/contract-test.spec.ts b/packages/testing/src/__tests__/contract-test.spec.ts index 69e42070..81bcab8f 100644 --- a/packages/testing/src/__tests__/contract-test.spec.ts +++ b/packages/testing/src/__tests__/contract-test.spec.ts @@ -9,7 +9,7 @@ import { extname } from "node:path"; import { fileURLToPath } from "node:url"; import { declareActivitiesHandler } from "@temporal-contract/worker/activity"; -import { Ok } from "unthrown"; +import { OkAsync } from "unthrown"; import { describe, expect } from "vitest"; import { createContractTest } from "../contract.js"; @@ -22,7 +22,7 @@ const activities = declareActivitiesHandler({ contract: testContract, activities: { greet: { - decorate: ({ name }) => Ok({ decorated: name.toUpperCase() }).toAsync(), + decorate: ({ name }) => OkAsync({ decorated: name.toUpperCase() }), }, }, }); @@ -52,7 +52,7 @@ describe("createContractTest", () => { typedClient, client, }) => { - expect(worker.getState()).toBe("RUNNING"); + expect(worker.raw.getState()).toBe("RUNNING"); // The root memoizes contract bindings — the fixture's client is the // same instance `for()` hands out. expect(typedClient.for(testContract)).toBe(client); diff --git a/packages/testing/src/activity.spec.ts b/packages/testing/src/activity.spec.ts index b87ea1d7..a840358f 100644 --- a/packages/testing/src/activity.spec.ts +++ b/packages/testing/src/activity.spec.ts @@ -8,7 +8,7 @@ import { defineActivity } from "@temporal-contract/contract"; import { ContractError } from "@temporal-contract/contract/errors"; import { MockActivityEnvironment } from "@temporalio/testing"; -import { Err, Ok, fromSafePromise } from "unthrown"; +import { ErrAsync, OkAsync, fromSafePromise } from "unthrown"; import { describe, expect, it } from "vitest"; import { z } from "zod"; @@ -30,7 +30,7 @@ describe("runActivity", () => { it("returns the implementation's Ok result", async () => { const result = await runActivity( charge, - ({ amount }) => Ok({ transactionId: `TXN-${amount}` }).toAsync(), + ({ amount }) => OkAsync({ transactionId: `TXN-${amount}` }), { amount: 42 }, ); @@ -44,7 +44,7 @@ describe("runActivity", () => { const result = await runActivity( charge, ({ amount }, { errors }) => - Err(errors.PaymentDeclined({ reason: `insufficient funds for ${amount}` })).toAsync(), + ErrAsync(errors.PaymentDeclined({ reason: `insufficient funds for ${amount}` })), { amount: 9000 }, ); @@ -83,7 +83,7 @@ describe("runActivity", () => { // Inside `env.run`, `Context.current()` is this environment's // context — the mock env instance exposes it as `env.context`. env.context.heartbeat("halfway"); - return Ok({ transactionId: `TXN-${amount}` }).toAsync(); + return OkAsync({ transactionId: `TXN-${amount}` }); }, { amount: 7 }, { env }, diff --git a/packages/testing/src/contract.ts b/packages/testing/src/contract.ts index c40c183a..dbd5b2f8 100644 --- a/packages/testing/src/contract.ts +++ b/packages/testing/src/contract.ts @@ -39,9 +39,8 @@ import { TypedClient, type ContractClient } from "@temporal-contract/client"; import type { ContractDefinition } from "@temporal-contract/contract"; import type { ActivitiesHandler } from "@temporal-contract/worker/activity"; -import { createWorker, type CreateWorkerOptions } from "@temporal-contract/worker/worker"; +import { TypedWorker, type CreateWorkerOptions } from "@temporal-contract/worker/worker"; import { Client } from "@temporalio/client"; -import type { Worker } from "@temporalio/worker"; import { vi } from "vitest"; import { it as baseIt } from "./extension.js"; @@ -62,7 +61,7 @@ export type CreateContractTestOptions = { */ activities?: ActivitiesHandler; /** - * Extra options forwarded to `createWorker` (e.g. `namespace`, + * Extra options forwarded to `TypedWorker.create` (e.g. `namespace`, * interceptors, tuning knobs). The `namespace`, when given, is also used * by the client fixtures. */ @@ -86,9 +85,10 @@ export type ContractTestContext = { typedClient: TypedClient; /** * The running worker for the contract's task queue — created and started - * before the test (`auto`), shut down after it. + * before the test (`auto`), shut down after it. The underlying Temporal + * `Worker` stays reachable via `worker.raw`. */ - worker: Worker; + worker: TypedWorker; }; /** @@ -111,7 +111,7 @@ export function createContractTest( async ({ workerConnection }, use) => { // Technical creation failures ride the defect channel (E = never); // `get()` unwraps directly and rethrows a defect's cause. - const worker = await createWorker({ + const worker = await TypedWorker.create({ contract, connection: workerConnection, workflowsPath: options.workflowsPath, @@ -119,22 +119,23 @@ export function createContractTest( ...options.workerOptions, }).get(); - const runPromise = worker.run(); - // Handled here so a mid-test worker crash doesn't trip Node's - // unhandled-rejection reporting; the real failure resurfaces at the - // `await runPromise` below. - runPromise.catch(() => {}); + // `run()` returns `AsyncResult` whose internal promise + // never rejects, so holding onto it across the test cannot trip + // Node's unhandled-rejection reporting; a mid-test worker crash + // resurfaces at the `.get()` below. + const running = worker.run(); - await vi.waitFor(() => worker.getState() === "RUNNING", { interval: 100 }); + await vi.waitFor(() => worker.raw.getState() === "RUNNING", { interval: 100 }); await use(worker); - if (worker.getState() === "RUNNING") { + if (worker.raw.getState() === "RUNNING") { worker.shutdown(); } - // Resolves once shutdown completes — or rejects with the original - // error when the worker failed, surfacing it as a teardown failure. - await runPromise; + // Resolves once shutdown completes — or rethrows the original + // failure's cause when the worker crashed, surfacing it as a + // teardown failure. + await running.get(); }, { auto: true }, ], diff --git a/packages/testing/src/time-skipping.ts b/packages/testing/src/time-skipping.ts index 15792836..ce047e17 100644 --- a/packages/testing/src/time-skipping.ts +++ b/packages/testing/src/time-skipping.ts @@ -17,11 +17,11 @@ * @example * ```ts * import { it } from "@temporal-contract/testing/time-skipping"; - * import { createWorker, workflowsPathFromURL } from "@temporal-contract/worker/worker"; + * import { TypedWorker, workflowsPathFromURL } from "@temporal-contract/worker/worker"; * import { TypedClient } from "@temporal-contract/client"; * * it("processes the order", async ({ testEnv }) => { - * const worker = await createWorker({ + * const worker = await TypedWorker.create({ * contract: myContract, * connection: testEnv.nativeConnection, * workflowsPath: workflowsPathFromURL(import.meta.url, "./test.workflows.js"), @@ -30,7 +30,7 @@ * const typedClient = await TypedClient.create({ client: testEnv.client }).get(); * const client = typedClient.for(myContract); * - * await worker.runUntil(async () => { + * await worker.raw.runUntil(async () => { * const result = await client.executeWorkflow("processOrder", { * workflowId: "order-1", * args: { orderId: "ORD-1" }, diff --git a/packages/worker/README.md b/packages/worker/README.md index 4d641cf0..614b1d1e 100644 --- a/packages/worker/README.md +++ b/packages/worker/README.md @@ -56,7 +56,7 @@ export const processOrder = declareWorkflow({ ```typescript // worker.ts import { NativeConnection } from "@temporalio/worker"; -import { createWorker, workflowsPathFromURL } from "@temporal-contract/worker/worker"; +import { TypedWorker, workflowsPathFromURL } from "@temporal-contract/worker/worker"; import { activities } from "./activities.js"; import { myContract } from "./contract.js"; @@ -65,7 +65,7 @@ const connection = await NativeConnection.connect({ address: "localhost:7233" }) // The task queue comes from the contract; the workflows path is resolved // from this module's URL (ESM — include the extension explicitly). -const workerResult = await createWorker({ +const workerResult = await TypedWorker.create({ contract: myContract, connection, workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"), @@ -77,17 +77,21 @@ if (workerResult.isDefect()) { process.exit(1); } -await workerResult.value.run(); +// `run()` returns AsyncResult — a runtime failure is a defect +// whose cause `.get()` rethrows. The raw Temporal Worker stays reachable +// via `.raw` (e.g. `worker.raw.runUntil(...)` in tests). +const worker = workerResult.get(); +await worker.run().get(); ``` ### Workflow-only workers -`activities` is optional on `createWorker`. Omit it to run a worker that only +`activities` is optional on `TypedWorker.create`. Omit it to run a worker that only executes workflows — useful when workflow code and activity code are deployed and scaled as separate processes on the same task queue: ```typescript -const workerResult = await createWorker({ +const workerResult = await TypedWorker.create({ contract: myContract, connection, workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"), diff --git a/packages/worker/src/__tests__/time-skipping.inprocess.spec.ts b/packages/worker/src/__tests__/time-skipping.inprocess.spec.ts index 7704c5eb..500a6784 100644 --- a/packages/worker/src/__tests__/time-skipping.inprocess.spec.ts +++ b/packages/worker/src/__tests__/time-skipping.inprocess.spec.ts @@ -3,13 +3,13 @@ import { fileURLToPath } from "node:url"; import { ContractError, TypedClient, type ClientInterceptor } from "@temporal-contract/client"; import { it } from "@temporal-contract/testing/time-skipping"; -import { Ok, Err, type AsyncResult } from "unthrown"; +import { OkAsync, ErrAsync } from "unthrown"; /** * Full contract-pipeline coverage against the time-skipping * `TestWorkflowEnvironment` (`@temporal-contract/testing/time-skipping`) — * no Docker required. Exercises, in-process: * - * - `createWorker` / `TypedClient.create` AsyncResult factories, + * - `TypedWorker.create` / `TypedClient.create` AsyncResult factories, * - the activity-boundary wire format (sender validates and transmits the * original value; receiver parses), * - `createContext` seed + accumulating middleware context, @@ -26,12 +26,9 @@ import { declareActivitiesHandler, declareActivityMiddleware, } from "../activity.js"; -import { createWorker, TechnicalError } from "../worker.js"; +import { TypedWorker, TechnicalError } from "../worker.js"; import { inprocessContract } from "./inprocess.contract.js"; -const okAsync = (value: T): AsyncResult => Ok(value).toAsync(); -const errAsync = (error: E): AsyncResult => Err(error).toAsync(); - const seenContexts: Record[] = []; const tracing = composeActivityMiddleware( @@ -49,9 +46,9 @@ const activities = declareActivitiesHandler({ charge: ({ amount }, { errors, context }) => { seenContexts.push(context); if (amount < 0) { - return errAsync(errors.PaymentDeclined({ reason: "negative-amount" })); + return ErrAsync(errors.PaymentDeclined({ reason: "negative-amount" })); } - return okAsync({ transactionId: `tx-${amount}-${context.traceId}` }); + return OkAsync({ transactionId: `tx-${amount}-${context.traceId}` }); }, }, }, @@ -69,7 +66,7 @@ function workflowPath(filename: string): string { describe("time-skipping TestWorkflowEnvironment", () => { it("runs the full contract pipeline in-process", async ({ testEnv }) => { - const workerResult = await createWorker({ + const workerResult = await TypedWorker.create({ contract: inprocessContract, connection: testEnv.nativeConnection, workflowsPath: workflowPath("inprocess.workflows"), @@ -87,7 +84,7 @@ describe("time-skipping TestWorkflowEnvironment", () => { if (!clientResult.isOk()) return; const client = clientResult.value.for(inprocessContract); - await worker.runUntil(async () => { + await worker.raw.runUntil(async () => { // Happy path — the hour-long sleep is skipped, the accumulated // middleware context reaches the implementation. const charged = await client.executeWorkflow("placeOrder", { @@ -139,7 +136,7 @@ describe("time-skipping TestWorkflowEnvironment", () => { }); it("surfaces worker bundling failures on the defect channel", async ({ testEnv }) => { - const workerResult = await createWorker({ + const workerResult = await TypedWorker.create({ contract: inprocessContract, connection: testEnv.nativeConnection, workflowsPath: workflowPath("does-not-exist"), diff --git a/packages/worker/src/__tests__/worker.spec.ts b/packages/worker/src/__tests__/worker.spec.ts index 11d8ba0f..d902239d 100644 --- a/packages/worker/src/__tests__/worker.spec.ts +++ b/packages/worker/src/__tests__/worker.spec.ts @@ -8,53 +8,48 @@ import { } from "@temporal-contract/client"; import { it as baseIt } from "@temporal-contract/testing/extension"; import { Client, WorkflowFailedError } from "@temporalio/client"; -import { type Worker } from "@temporalio/worker"; -import { Ok, Err, type AsyncResult } from "unthrown"; +import { OkAsync, ErrAsync } from "unthrown"; import { describe, expect, vi, beforeEach } from "vitest"; import { ApplicationFailure, declareActivitiesHandler } from "../activity.js"; -import { createWorker } from "../worker.js"; +import { TypedWorker } from "../worker.js"; import { testContract } from "./test.contract.js"; -// unthrown has no `okAsync`/`errAsync`; lift a sync `Result` with `.toAsync()`. -const okAsync = (value: T): AsyncResult => Ok(value).toAsync(); -const errAsync = (error: E): AsyncResult => Err(error).toAsync(); - // ============================================================================ // Test Setup // ============================================================================ const it = baseIt.extend<{ - worker: Worker; + worker: TypedWorker; client: ContractClient; }>({ worker: [ async ({ workerConnection }, use) => { - // Create and start worker using createWorker - const workerResult = await createWorker({ + // Technical creation failures ride the defect channel (E = never); + // `.get()` unwraps directly and rethrows a defect's cause. + const worker = await TypedWorker.create({ contract: testContract, connection: workerConnection, namespace: "default", workflowsPath: workflowPath("test.workflows"), activities, - }); - if (!workerResult.isOk()) { - throw workerResult.isErr() ? workerResult.error : workerResult.cause; - } - const worker = workerResult.value; + }).get(); - // Start worker in background - worker.run().catch((err) => { - console.error("Worker failed:", err); - }); + // Start the worker loop in the background. `run()` returns + // `AsyncResult` whose internal promise never rejects, so + // holding onto it cannot trip unhandled-rejection reporting. + const running = worker.run(); - await vi.waitFor(() => worker.getState() === "RUNNING", { interval: 100 }); + await vi.waitFor(() => worker.raw.getState() === "RUNNING", { interval: 100 }); await use(worker); - await worker.shutdown(); + worker.shutdown(); + + await vi.waitFor(() => worker.raw.getState() === "STOPPED", { interval: 100 }); - await vi.waitFor(() => worker.getState() === "STOPPED", { interval: 100 }); + // Rethrows the original failure's cause if the worker crashed mid-test. + await running.get(); }, { auto: true }, ], @@ -83,14 +78,14 @@ const activities = declareActivitiesHandler({ // interactiveWorkflow, parentWorkflow, ...) no longer need `{}` entries. workflowWithActivities: { processPayment: ({ amount }) => { - return okAsync({ + return OkAsync({ transactionId: `TXN-${amount}-${Date.now()}`, success: amount > 0, }); }, validateOrder: ({ orderId }) => { - return okAsync({ + return OkAsync({ valid: orderId.startsWith("ORD-"), }); }, @@ -98,12 +93,12 @@ const activities = declareActivitiesHandler({ logMessage: ({ message }) => { logMessages.push(message); - return okAsync({}); + return OkAsync({}); }, failableActivity: ({ shouldFail }) => { if (shouldFail) { - return errAsync( + return ErrAsync( ApplicationFailure.create({ type: "ACTIVITY_FAILED", message: "Activity was configured to fail", @@ -111,7 +106,7 @@ const activities = declareActivitiesHandler({ }), ); } - return okAsync({ success: true }); + return OkAsync({ success: true }); }, }, }); diff --git a/packages/worker/src/activity-contract-errors.spec.ts b/packages/worker/src/activity-contract-errors.spec.ts index 8e3510aa..63080954 100644 --- a/packages/worker/src/activity-contract-errors.spec.ts +++ b/packages/worker/src/activity-contract-errors.spec.ts @@ -1,6 +1,6 @@ import { defineContract } from "@temporal-contract/contract"; import { ContractError } from "@temporal-contract/contract/errors"; -import { Ok, Err, P, type AsyncResult } from "unthrown"; +import { OkAsync, ErrAsync, P } from "unthrown"; /** * Runtime coverage for `declareActivitiesHandler`'s contract-declared typed * errors, middleware chain, and dependency context — the boundary where an @@ -20,9 +20,6 @@ import { } from "./activity.js"; import { ContractErrorDataValidationError } from "./errors.js"; -const okAsync = (value: T): AsyncResult => Ok(value).toAsync(); -const errAsync = (error: E): AsyncResult => Err(error).toAsync(); - const contract = defineContract({ taskQueue: "test-queue", workflows: { @@ -58,10 +55,10 @@ describe("declareActivitiesHandler — contract errors", () => { const activities = declareActivitiesHandler({ contract, activities: { - sendEmail: () => okAsync({ sent: true }), + sendEmail: () => OkAsync({ sent: true }), processOrder: { chargePayment: (_args, { errors }) => - errAsync(errors.PaymentDeclined({ reason: "insufficient_funds" })), + ErrAsync(errors.PaymentDeclined({ reason: "insufficient_funds" })), }, }, }); @@ -80,9 +77,9 @@ describe("declareActivitiesHandler — contract errors", () => { const activities = declareActivitiesHandler({ contract, activities: { - sendEmail: () => okAsync({ sent: true }), + sendEmail: () => OkAsync({ sent: true }), processOrder: { - chargePayment: (_args, { errors }) => errAsync(errors.GatewayUnavailable()), + chargePayment: (_args, { errors }) => ErrAsync(errors.GatewayUnavailable()), }, }, }); @@ -116,7 +113,7 @@ describe("declareActivitiesHandler — contract errors", () => { const activities = declareActivitiesHandler({ contract: transformingContract, activities: { - flaky: (_args, { errors }) => errAsync(errors.Nope({ reason: "declined" })), + flaky: (_args, { errors }) => ErrAsync(errors.Nope({ reason: "declined" })), }, }); @@ -130,11 +127,11 @@ describe("declareActivitiesHandler — contract errors", () => { const activities = declareActivitiesHandler({ contract, activities: { - sendEmail: () => okAsync({ sent: true }), + sendEmail: () => OkAsync({ sent: true }), processOrder: { chargePayment: (_args, { errors }) => // @ts-expect-error — deliberately invalid payload - errAsync(errors.PaymentDeclined({ reason: 42 })), + ErrAsync(errors.PaymentDeclined({ reason: 42 })), }, }, }); @@ -153,9 +150,9 @@ describe("declareActivitiesHandler — contract errors", () => { const activities = declareActivitiesHandler({ contract, activities: { - sendEmail: () => okAsync({ sent: true }), + sendEmail: () => OkAsync({ sent: true }), processOrder: { - chargePayment: () => errAsync(foreign) as never, + chargePayment: () => ErrAsync(foreign) as never, }, }, }); @@ -172,9 +169,9 @@ describe("declareActivitiesHandler — contract errors", () => { const activities = declareActivitiesHandler({ contract, activities: { - sendEmail: () => okAsync({ sent: true }), + sendEmail: () => OkAsync({ sent: true }), processOrder: { - chargePayment: () => errAsync(failure), + chargePayment: () => ErrAsync(failure), }, }, }); @@ -190,10 +187,10 @@ describe("declareActivitiesHandler — createContext", () => { contract, createContext: () => ({ paymentGateway }), activities: { - sendEmail: () => okAsync({ sent: true }), + sendEmail: () => OkAsync({ sent: true }), processOrder: { chargePayment: (args, { context }) => - okAsync({ + OkAsync({ transactionId: `${context.paymentGateway === paymentGateway}-${args.amount}`, }), }, @@ -211,9 +208,9 @@ describe("declareActivitiesHandler — createContext", () => { contract, createContext, activities: { - sendEmail: () => okAsync({ sent: true }), + sendEmail: () => OkAsync({ sent: true }), processOrder: { - chargePayment: () => okAsync({ transactionId: "tx" }), + chargePayment: () => OkAsync({ transactionId: "tx" }), }, }, }); @@ -234,9 +231,9 @@ describe("declareActivitiesHandler — createContext", () => { describe("declareActivitiesHandler — middleware", () => { const passthroughImplementations = { - sendEmail: () => okAsync({ sent: true }), + sendEmail: () => OkAsync({ sent: true }), processOrder: { - chargePayment: () => okAsync({ transactionId: "tx" }), + chargePayment: () => OkAsync({ transactionId: "tx" }), }, }; @@ -281,9 +278,9 @@ describe("declareActivitiesHandler — middleware", () => { contract, middleware: (_invocation, next) => next({ input: { amount: 999 } }), activities: { - sendEmail: () => okAsync({ sent: true }), + sendEmail: () => OkAsync({ sent: true }), processOrder: { - chargePayment: (args) => okAsync({ transactionId: `tx-${args.amount}` }), + chargePayment: (args) => OkAsync({ transactionId: `tx-${args.amount}` }), }, }, }); @@ -294,12 +291,12 @@ describe("declareActivitiesHandler — middleware", () => { }); it("re-validates a substituted input — an invalid substitution fails terminally", async () => { - const implementation = vi.fn(() => okAsync({ transactionId: "tx" })); + const implementation = vi.fn(() => OkAsync({ transactionId: "tx" })); const activities = declareActivitiesHandler({ contract, middleware: (_invocation, next) => next({ input: { amount: "not-a-number" } }), activities: { - sendEmail: () => okAsync({ sent: true }), + sendEmail: () => OkAsync({ sent: true }), processOrder: { chargePayment: implementation }, }, }); @@ -311,12 +308,12 @@ describe("declareActivitiesHandler — middleware", () => { }); it("can short-circuit with its own result — output still validated", async () => { - const implementation = vi.fn(() => okAsync({ transactionId: "tx" })); + const implementation = vi.fn(() => OkAsync({ transactionId: "tx" })); const activities = declareActivitiesHandler({ contract, - middleware: () => okAsync({ transactionId: "cached" }), + middleware: () => OkAsync({ transactionId: "cached" }), activities: { - sendEmail: () => okAsync({ sent: true }), + sendEmail: () => OkAsync({ sent: true }), processOrder: { chargePayment: implementation }, }, }); @@ -344,10 +341,10 @@ describe("declareActivitiesHandler — middleware", () => { contract, middleware: observing, activities: { - sendEmail: () => okAsync({ sent: true }), + sendEmail: () => OkAsync({ sent: true }), processOrder: { chargePayment: (_args, { errors }) => - errAsync(errors.PaymentDeclined({ reason: "expired_card" })), + ErrAsync(errors.PaymentDeclined({ reason: "expired_card" })), }, }, }); @@ -398,10 +395,10 @@ describe("declareActivitiesHandler — middleware", () => { activities: { sendEmail: (_args, { context }) => { seenByImplementation.push(context); - return okAsync({ sent: true }); + return OkAsync({ sent: true }); }, processOrder: { - chargePayment: () => okAsync({ transactionId: "tx" }), + chargePayment: () => OkAsync({ transactionId: "tx" }), }, }, }); diff --git a/packages/worker/src/activity.spec.ts b/packages/worker/src/activity.spec.ts index f329c45f..926b8a02 100644 --- a/packages/worker/src/activity.spec.ts +++ b/packages/worker/src/activity.spec.ts @@ -1,5 +1,5 @@ import type { ContractDefinition } from "@temporal-contract/contract"; -import { Ok, Err, fromSafePromise, type AsyncResult } from "unthrown"; +import { OkAsync, ErrAsync, fromSafePromise, type AsyncResult } from "unthrown"; import { describe, expect, it } from "vitest"; import { z } from "zod"; @@ -10,10 +10,6 @@ import { ActivityOutputValidationError, } from "./errors.js"; -// unthrown has no `okAsync`/`errAsync`; lift a sync `Result` with `.toAsync()`. -const okAsync = (value: T): AsyncResult => Ok(value).toAsync(); -const errAsync = (error: E): AsyncResult => Err(error).toAsync(); - describe("Worker unthrown Package", () => { describe("declareActivitiesHandler", () => { it("should create an activities handler with Result pattern", () => { @@ -39,7 +35,7 @@ describe("Worker unthrown Package", () => { const activities = declareActivitiesHandler({ contract, activities: { - sendEmail: () => okAsync({ sent: true }), + sendEmail: () => OkAsync({ sent: true }), }, }); @@ -67,7 +63,7 @@ describe("Worker unthrown Package", () => { const activities = declareActivitiesHandler({ contract, activities: { - processPayment: (args) => okAsync({ transactionId: `tx-${args.amount}` }), + processPayment: (args) => OkAsync({ transactionId: `tx-${args.amount}` }), }, }); @@ -101,7 +97,7 @@ describe("Worker unthrown Package", () => { const activities = declareActivitiesHandler({ contract, activities: { - fetchData: (args) => okAsync({ data: `data-${args.id}`, timestamp: 123 }), + fetchData: (args) => OkAsync({ data: `data-${args.id}`, timestamp: 123 }), }, }); @@ -119,7 +115,7 @@ describe("Worker unthrown Package", () => { _args, ): AsyncResult<{ data: string; timestamp: number }, ApplicationFailure> => // @ts-expect-error - intentionally returning invalid output - okAsync({ data: "test" }), // Missing timestamp + OkAsync({ data: "test" }), // Missing timestamp }, }); @@ -143,7 +139,7 @@ describe("Worker unthrown Package", () => { const activities = declareActivitiesHandler({ contract, activities: { - successActivity: (args) => okAsync({ result: `success-${args.value}` }), + successActivity: (args) => OkAsync({ result: `success-${args.value}` }), }, }); @@ -171,7 +167,7 @@ describe("Worker unthrown Package", () => { contract, activities: { failingActivity: (_args) => - errAsync( + ErrAsync( ApplicationFailure.create({ type: "ACTIVITY_FAILED", message: "Something went wrong", @@ -211,7 +207,7 @@ describe("Worker unthrown Package", () => { contract, activities: { permanentlyFailingActivity: (_args) => - errAsync( + ErrAsync( ApplicationFailure.create({ type: "PERMANENT", message: "do not retry", @@ -285,7 +281,7 @@ describe("Worker unthrown Package", () => { contract, activities: { orderWorkflow: { - validateOrder: (args) => okAsync({ valid: args.orderId.length > 0 }), + validateOrder: (args) => OkAsync({ valid: args.orderId.length > 0 }), }, }, }); @@ -315,9 +311,9 @@ describe("Worker unthrown Package", () => { declareActivitiesHandler({ contract, activities: { - validActivity: (_args: unknown) => okAsync({ result: "test" }), + validActivity: (_args: unknown) => OkAsync({ result: "test" }), // @ts-expect-error - intentionally missing activity definition - unknownActivity: (_args: unknown) => okAsync({ result: "test" }), + unknownActivity: (_args: unknown) => OkAsync({ result: "test" }), }, }); }).toThrowError(new ActivityDefinitionNotFoundError("unknownActivity", ["validActivity"])); @@ -344,7 +340,7 @@ describe("Worker unthrown Package", () => { // workflow activities), which TypeScript can't flag excess keys // against — the runtime check is the only guard. activities: { - strayActivity: (_args: unknown) => okAsync({}), + strayActivity: (_args: unknown) => OkAsync({}), }, }); }).toThrowError(new ActivityDefinitionNotFoundError("strayActivity", [])); @@ -371,7 +367,7 @@ describe("Worker unthrown Package", () => { contract, // @ts-expect-error - intentionally omitting a declared implementation activities: { - implemented: (_args: unknown) => okAsync({}), + implemented: (_args: unknown) => OkAsync({}), }, }); }).toThrow(/missing implementation for declared activity: forgotten/); @@ -406,7 +402,7 @@ describe("Worker unthrown Package", () => { activities: { // @ts-expect-error - intentionally omitting a declared implementation orderWorkflow: { - validateOrder: (_args: unknown) => okAsync({}), + validateOrder: (_args: unknown) => OkAsync({}), }, }, }); @@ -447,7 +443,7 @@ describe("Worker unthrown Package", () => { declareActivitiesHandler({ contract, activities: { - conflicted: (_args: unknown) => okAsync({}), + conflicted: (_args: unknown) => OkAsync({}), }, }); }).toThrow(/global activity "conflicted" has the same name as a workflow/); @@ -478,7 +474,7 @@ describe("Worker unthrown Package", () => { activities: { transformer: (args) => { seen.push(args); - return okAsync({ n: 21 }); + return OkAsync({ n: 21 }); }, }, }); @@ -492,7 +488,7 @@ describe("Worker unthrown Package", () => { const activities = declareActivitiesHandler({ contract: transformContract, activities: { - transformer: () => okAsync({ n: 21 }), + transformer: () => OkAsync({ n: 21 }), }, }); @@ -521,7 +517,7 @@ describe("Worker unthrown Package", () => { const activities = declareActivitiesHandler({ contract, activities: { - strictActivity: (_args) => okAsync({ success: true }), + strictActivity: (_args) => OkAsync({ success: true }), }, }); @@ -556,7 +552,7 @@ describe("Worker unthrown Package", () => { contract, activities: { // @ts-expect-error - intentionally returning invalid output - strictOutputActivity: (_args) => okAsync({ value: "not-a-number", status: "active" }), + strictOutputActivity: (_args) => OkAsync({ value: "not-a-number", status: "active" }), }, }); diff --git a/packages/worker/src/activity.ts b/packages/worker/src/activity.ts index 8c130447..b884c403 100644 --- a/packages/worker/src/activity.ts +++ b/packages/worker/src/activity.ts @@ -338,7 +338,7 @@ export type ActivityMiddlewareNext< * (invocation, next) => { * const tenantId = readTenant(invocation.input); * if (!tenantId) { - * return Err(ApplicationFailure.create({ type: "Unauthenticated", nonRetryable: true })).toAsync(); + * return ErrAsync(ApplicationFailure.create({ type: "Unauthenticated", nonRetryable: true })); * } * return next({ context: { tenantId } }); * }, @@ -654,15 +654,15 @@ export type ActivitiesHandler = * // Wire into a worker with this package's typed factory — the task queue * // comes from the contract. * import { NativeConnection } from '@temporalio/worker'; - * import { createWorker, workflowsPathFromURL } from '@temporal-contract/worker/worker'; + * import { TypedWorker, workflowsPathFromURL } from '@temporal-contract/worker/worker'; * * const connection = await NativeConnection.connect({ address: 'localhost:7233' }); - * const workerResult = await createWorker({ + * const worker = await TypedWorker.create({ * contract: myContract, * connection, * workflowsPath: workflowsPathFromURL(import.meta.url, './workflows.js'), * activities, - * }); + * }).get(); * ``` * * @remarks diff --git a/packages/worker/src/worker.spec.ts b/packages/worker/src/worker.spec.ts index 7dc19f95..24a1f9e3 100644 --- a/packages/worker/src/worker.spec.ts +++ b/packages/worker/src/worker.spec.ts @@ -3,7 +3,7 @@ import { type NativeConnection, Worker } from "@temporalio/worker"; import { describe, expect, it, vi, beforeEach } from "vitest"; import { z } from "zod"; -import { createWorker, TechnicalError, workflowsPathFromURL } from "./worker.js"; +import { TypedWorker, TechnicalError, workflowsPathFromURL } from "./worker.js"; // Mock @temporalio/worker vi.mock("@temporalio/worker", () => ({ @@ -20,7 +20,7 @@ describe("Worker Entry Point", () => { vi.clearAllMocks(); }); - describe("createWorker", () => { + describe("TypedWorker.create", () => { it("should create a worker with contract task queue", async () => { // GIVEN const contract = { @@ -39,7 +39,7 @@ describe("Worker Entry Point", () => { vi.mocked(Worker.create).mockResolvedValue(mockWorker); // WHEN - const workerResult = await createWorker({ + const workerResult = await TypedWorker.create({ contract, connection: mockConnection, workflowsPath: "/path/to/workflows", @@ -53,7 +53,12 @@ describe("Worker Entry Point", () => { workflowsPath: "/path/to/workflows", activities: {}, }); - expect(workerResult).toBeOkWith(mockWorker); + expect(workerResult).toBeOk(); + if (workerResult.isOk()) { + expect(workerResult.value).toBeInstanceOf(TypedWorker); + // The underlying Temporal Worker stays reachable via the escape hatch. + expect(workerResult.value.raw).toBe(mockWorker); + } }); it("should surface Worker.create rejections as a Defect with a TechnicalError cause", async () => { @@ -71,7 +76,7 @@ describe("Worker Entry Point", () => { vi.mocked(Worker.create).mockRejectedValue(bundleError); // WHEN - const workerResult = await createWorker({ + const workerResult = await TypedWorker.create({ contract, connection: { close: vi.fn() } as unknown as NativeConnection, workflowsPath: "/path/to/workflows", @@ -108,7 +113,7 @@ describe("Worker Entry Point", () => { vi.mocked(Worker.create).mockResolvedValue(mockWorker); // WHEN — no `activities` in the options - const workerResult = await createWorker({ + const workerResult = await TypedWorker.create({ contract, connection: mockConnection, workflowsPath: "/path/to/workflows", @@ -123,7 +128,7 @@ describe("Worker Entry Point", () => { }); const callArg = vi.mocked(Worker.create).mock.calls[0]![0]; expect(Object.keys(callArg)).not.toContain("activities"); - expect(workerResult).toBeOkWith(mockWorker); + expect(workerResult).toBeOk(); }); it("should use provided connection", async () => { @@ -144,7 +149,7 @@ describe("Worker Entry Point", () => { vi.mocked(Worker.create).mockResolvedValue(mockWorker); // WHEN - const workerResult = await createWorker({ + const workerResult = await TypedWorker.create({ contract, connection: existingConnection, workflowsPath: "/path/to/workflows", @@ -158,7 +163,7 @@ describe("Worker Entry Point", () => { workflowsPath: "/path/to/workflows", activities: {}, }); - expect(workerResult).toBeOkWith(mockWorker); + expect(workerResult).toBeOk(); }); it("should pass through other worker options", async () => { @@ -179,7 +184,7 @@ describe("Worker Entry Point", () => { vi.mocked(Worker.create).mockResolvedValue(mockWorker); // WHEN - await createWorker({ + await TypedWorker.create({ contract, connection: mockConnection, workflowsPath: "/path/to/workflows", @@ -198,6 +203,93 @@ describe("Worker Entry Point", () => { }); }); + describe("TypedWorker lifecycle", () => { + const contract = { + taskQueue: "lifecycle-queue", + workflows: { + testWorkflow: { + input: z.object({ value: z.string() }), + output: z.object({ result: z.string() }), + }, + }, + } satisfies ContractDefinition; + + async function createTypedWorker(rawWorker: Worker): Promise { + vi.mocked(Worker.create).mockResolvedValue(rawWorker); + return await TypedWorker.create({ + contract, + connection: { close: vi.fn() } as unknown as NativeConnection, + workflowsPath: "/path/to/workflows", + activities: {}, + }).get(); + } + + it("run() resolves Ok(void) when the underlying run completes", async () => { + // GIVEN + const rawWorker = { run: vi.fn().mockResolvedValue(undefined) } as unknown as Worker; + const worker = await createTypedWorker(rawWorker); + + // WHEN + const runResult = await worker.run(); + + // THEN + expect(rawWorker.run).toHaveBeenCalledTimes(1); + expect(runResult).toBeOk(); + }); + + it("run() surfaces a runtime failure as a Defect with a TechnicalError cause", async () => { + // GIVEN + const runError = new Error("poller crashed"); + const rawWorker = { run: vi.fn().mockRejectedValue(runError) } as unknown as Worker; + const worker = await createTypedWorker(rawWorker); + + // WHEN + const runResult = await worker.run(); + + // THEN — a running-worker failure is a technical fault on the defect + // channel, never a modeled Err + expect(runResult).toBeDefect(); + if (runResult.isDefect()) { + const cause = runResult.cause; + expect(cause).toBeInstanceOf(TechnicalError); + expect((cause as TechnicalError).message).toContain('task queue "lifecycle-queue"'); + expect((cause as TechnicalError).cause).toBe(runError); + } + }); + + it("run() folds a synchronous throw from the underlying run into the defect channel", async () => { + // GIVEN — Temporal throws IllegalStateError synchronously on a double run + const stateError = new Error("Poller was already started"); + const rawWorker = { + run: vi.fn(() => { + throw stateError; + }), + } as unknown as Worker; + const worker = await createTypedWorker(rawWorker); + + // WHEN — calling run() must not throw + const runResult = await worker.run(); + + // THEN + expect(runResult).toBeDefect(); + if (runResult.isDefect()) { + expect((runResult.cause as TechnicalError).cause).toBe(stateError); + } + }); + + it("shutdown() delegates to the underlying worker", async () => { + // GIVEN + const rawWorker = { run: vi.fn(), shutdown: vi.fn() } as unknown as Worker; + const worker = await createTypedWorker(rawWorker); + + // WHEN + worker.shutdown(); + + // THEN + expect(rawWorker.shutdown).toHaveBeenCalledTimes(1); + }); + }); + describe("workflowsPathFromURL", () => { it("should resolve a relative .js path against the base URL", () => { // GIVEN diff --git a/packages/worker/src/worker.ts b/packages/worker/src/worker.ts index 50817125..0903cf91 100644 --- a/packages/worker/src/worker.ts +++ b/packages/worker/src/worker.ts @@ -14,7 +14,8 @@ import type { ActivitiesHandler } from "./activity.js"; export { TechnicalError } from "@temporal-contract/contract/errors"; /** - * Options for creating a Temporal worker + * Options for {@link TypedWorker.create} — the single options-object shape + * shared by the org's `Typed*.create()` factories. */ export type CreateWorkerOptions = Omit< WorkerOptions, @@ -40,70 +41,138 @@ export type CreateWorkerOptions = Omit< }; /** - * Create a typed Temporal worker with contract-based configuration. + * Contract-scoped root of the typed worker surface — the worker-side sibling + * of `TypedClient`. * - * This helper simplifies worker creation by: - * - Using the contract's task queue automatically - * - Providing type-safe configuration - * - * Returns `AsyncResult` — worker bundling and connection - * failures are *technical* infrastructure faults, not anticipated domain - * errors, so they surface on the `Defect` channel (a {@link TechnicalError} - * instance as the defect's cause) rather than the modeled `Err` channel. - * Inspect them via `match`'s `defect` handler or `recoverDefect` / `tapDefect`. - * - * @example - * ```ts - * import { NativeConnection } from '@temporalio/worker'; - * import { createWorker, workflowsPathFromURL } from '@temporal-contract/worker/worker'; - * import { activities } from './activities.js'; - * import myContract from './contract.js'; - * - * const connection = await NativeConnection.connect({ - * address: 'localhost:7233', - * }); - * - * const workerResult = await createWorker({ - * contract: myContract, - * connection, - * workflowsPath: workflowsPathFromURL(import.meta.url, './workflows.js'), - * activities, - * }); - * if (workerResult.isDefect()) { - * console.error('worker setup failed', workerResult.cause); - * process.exit(1); - * } - * - * await workerResult.value.run(); - * ``` + * Created with the static {@link TypedWorker.create} factory (the org's + * `Typed*.create()` shape), it owns the unthrown-disciplined lifecycle — + * {@link TypedWorker.run | run} and {@link TypedWorker.shutdown | shutdown} — + * while everything else Temporal's runtime offers (`runUntil`, `getState`, + * tuning introspection) stays reachable through the + * {@link TypedWorker.raw | raw} escape hatch. */ -export function createWorker( - options: CreateWorkerOptions, -): AsyncResult { - const { contract, activities, ...workerOptions } = options; +export class TypedWorker { + /** + * The underlying `@temporalio/worker` `Worker` — the escape hatch for + * anything the typed surface doesn't cover (e.g. `raw.runUntil(...)` in + * tests, `raw.getState()` for monitoring). Temporal's runtime owns the + * worker loop; this accessor is always available. + */ + readonly raw: Worker; + + /** Task queue the worker polls — kept for diagnostics in {@link run}. */ + private readonly taskQueue: string; - // Create the worker with contract's task queue. `Worker.create` rejects on - // workflow-bundle compilation errors, bad connections, and invalid - // options — all *technical* faults, routed to the defect channel with a - // `TechnicalError` cause (never a modeled `Err`). - // - // `activities` is spread conditionally: a workflow-only worker must not - // pass the key at all (exactOptionalPropertyTypes discipline — and Temporal - // treats an absent map as "don't poll for Activity Tasks"). - return fromPromise( - Worker.create({ - ...workerOptions, - ...(activities !== undefined ? { activities } : {}), - taskQueue: contract.taskQueue, - }), - (cause, defect) => - defect( - new TechnicalError( - `Failed to create Temporal worker for task queue "${contract.taskQueue}"`, - cause, + private constructor(worker: Worker, taskQueue: string) { + this.raw = worker; + this.taskQueue = taskQueue; + } + + /** + * Create a typed Temporal worker with contract-based configuration. + * + * This factory simplifies worker creation by: + * - Using the contract's task queue automatically + * - Providing type-safe configuration + * + * Returns `AsyncResult` — worker bundling and + * connection failures are *technical* infrastructure faults, not + * anticipated domain errors, so they surface on the `Defect` channel (a + * {@link TechnicalError} instance as the defect's cause) rather than the + * modeled `Err` channel. The `Err` channel is empty (`never`), so `.get()` + * unwraps directly — a setup defect rethrows its cause. Alternatively, + * inspect defects via `match`'s `defect` handler or `recoverDefect` / + * `tapDefect`. + * + * @example + * ```ts + * import { NativeConnection } from '@temporalio/worker'; + * import { TypedWorker, workflowsPathFromURL } from '@temporal-contract/worker/worker'; + * import { activities } from './activities.js'; + * import myContract from './contract.js'; + * + * const connection = await NativeConnection.connect({ + * address: 'localhost:7233', + * }); + * + * const worker = await TypedWorker.create({ + * contract: myContract, + * connection, + * workflowsPath: workflowsPathFromURL(import.meta.url, './workflows.js'), + * activities, + * }).get(); + * + * await worker.run().get(); + * ``` + */ + static create( + options: CreateWorkerOptions, + ): AsyncResult { + const { contract, activities, ...workerOptions } = options; + + // Create the worker with contract's task queue. `Worker.create` rejects on + // workflow-bundle compilation errors, bad connections, and invalid + // options — all *technical* faults, routed to the defect channel with a + // `TechnicalError` cause (never a modeled `Err`). + // + // `activities` is spread conditionally: a workflow-only worker must not + // pass the key at all (exactOptionalPropertyTypes discipline — and Temporal + // treats an absent map as "don't poll for Activity Tasks"). + return fromPromise( + Worker.create({ + ...workerOptions, + ...(activities !== undefined ? { activities } : {}), + taskQueue: contract.taskQueue, + }), + (cause, defect) => + defect( + new TechnicalError( + `Failed to create Temporal worker for task queue "${contract.taskQueue}"`, + cause, + ), ), - ), - ); + ).map((worker) => new TypedWorker(worker, contract.taskQueue)); + } + + /** + * Start the worker loop — delegates to the underlying `Worker.run()`. + * + * Returns `AsyncResult` that resolves `Ok` once the worker has + * drained and shut down (after {@link shutdown} or a shutdown signal). A + * worker that fails while running is a *technical* infrastructure fault, so + * it surfaces on the `Defect` channel (a {@link TechnicalError} instance as + * the defect's cause) — the returned `AsyncResult` never rejects, so it is + * safe to hold onto and inspect later. `await worker.run().get()` at the + * edge rethrows a defect's cause. + */ + run(): AsyncResult { + return fromPromise( + // The async wrapper folds a synchronous throw from `run()` (e.g. + // Temporal's IllegalStateError on a double `run`) into the rejection + // path, so it is triaged like any other technical fault. + (async () => { + await this.raw.run(); + })(), + (cause, defect) => + defect( + new TechnicalError( + `Temporal worker for task queue "${this.taskQueue}" failed while running`, + cause, + ), + ), + ); + } + + /** + * Initiate a graceful shutdown — delegates to the underlying + * `Worker.shutdown()`. The worker stops polling, finishes in-flight tasks, + * and the {@link run} result resolves once draining completes. Calling it + * on a worker that is not running throws Temporal's `IllegalStateError` — + * a programming defect, not a modeled error. + */ + shutdown(): void { + this.raw.shutdown(); + } } /** @@ -118,15 +187,15 @@ export function createWorker( * * @example * ```ts - * import { workflowsPathFromURL } from '@temporal-contract/worker/worker'; + * import { TypedWorker, workflowsPathFromURL } from '@temporal-contract/worker/worker'; * - * const worker = await createWorker({ + * const worker = await TypedWorker.create({ * contract: myContract, * connection, * // Include the extension explicitly to work in both source (.ts) and build (.js) contexts * workflowsPath: workflowsPathFromURL(import.meta.url, './workflows.js'), * activities, - * }); + * }).get(); * ``` */ export function workflowsPathFromURL(baseURL: string, relativePath: string): string { diff --git a/packages/worker/src/workflow.ts b/packages/worker/src/workflow.ts index 562ff82c..b1590390 100644 --- a/packages/worker/src/workflow.ts +++ b/packages/worker/src/workflow.ts @@ -166,19 +166,18 @@ export { * Then in your worker setup: * ```ts * // worker.ts - * import { createWorker, workflowsPathFromURL } from '@temporal-contract/worker/worker'; + * import { TypedWorker, workflowsPathFromURL } from '@temporal-contract/worker/worker'; * import { activities } from './activities.js'; * import myContract from './contract.js'; * - * const workerResult = await createWorker({ + * // `TypedWorker.create` returns AsyncResult — setup + * // failures ride the defect channel, so `.get()` unwraps directly. + * const worker = await TypedWorker.create({ * contract: myContract, * connection, * workflowsPath: workflowsPathFromURL(import.meta.url, './workflows.js'), * activities, - * }); - * // `createWorker` returns AsyncResult — setup failures ride - * // the defect channel, so `.get()` narrows the ok channel directly. - * const worker = workerResult.get(); + * }).get(); * ``` */ export function declareWorkflow< From 64dd6a0b551dc82a3152a689a4a3d36661e9b96f Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 30 Jul 2026 23:24:41 +0200 Subject: [PATCH 02/15] chore: sync tooling configs with the family and codify the naming/signature policy --- .agents/rules/contract-patterns.md | 14 ++++++++++++++ docs/package.json | 6 +++--- knip.json | 2 +- pnpm-lock.yaml | 15 ++++++++++++--- pnpm-workspace.yaml | 3 +++ turbo.json | 4 ++++ 6 files changed, 37 insertions(+), 7 deletions(-) diff --git a/.agents/rules/contract-patterns.md b/.agents/rules/contract-patterns.md index d468c248..3189bf0d 100644 --- a/.agents/rules/contract-patterns.md +++ b/.agents/rules/contract-patterns.md @@ -1,5 +1,19 @@ # Contract Patterns +## Verb policy (btravstack-wide naming convention) + +API names follow a three-tier verb convention, shared with amqp-contract: + +| Verb | Meaning | Examples | +| ---------------------------- | -------------------------------------------- | -------------------------------------------------------------------------- | +| `define*` | Contract/schema authoring (pure, no runtime) | `defineContract`, `defineWorkflow`, `defineActivity`, `defineSignal`, … | +| `declare*` | Binding an implementation to a contract | `declareWorkflow`, `declareActivitiesHandler`, `declareActivityMiddleware` | +| `create*` / `Typed*` classes | Runtime object factories | `createWorker`, `TypedClient.create`, `createContractTest` | + +Additionally, **in-workflow** handler binding uses `handle*` on the workflow context: `context.handleSignal`, `context.handleQuery`, `context.handleUpdate`. When naming a new export, pick the tier by what it does — never a `define*` that touches runtime, never a `declare*` that authors schema. + +**Signature shape (Deno rule, btravstack-wide): exported functions take at most 2 positional arguments; everything else goes into a trailing options object.** Prefer a single options bag for factories (`defineContract({...})`, `declareWorkflow({...})`), and `fn(subject, options)` where one positional subject is natural (e.g. `startUpdate(updateName, options)`). Never add a third positional parameter to a public function. + ## Defining a Contract **Composition-first (org rule, shared with amqp-contract): define resources diff --git a/docs/package.json b/docs/package.json index dd662fa2..80054728 100644 --- a/docs/package.json +++ b/docs/package.json @@ -21,9 +21,9 @@ "@btravstack/theme": "catalog:", "@btravstack/tsconfig": "catalog:", "@types/node": "catalog:", - "mermaid": "11.16.0", + "mermaid": "catalog:", "tsx": "catalog:", - "vitepress": "1.6.4", - "vitepress-plugin-mermaid": "2.0.17" + "vitepress": "catalog:", + "vitepress-plugin-mermaid": "catalog:" } } diff --git a/knip.json b/knip.json index 67de14d9..a76f2475 100644 --- a/knip.json +++ b/knip.json @@ -1,5 +1,5 @@ { - "$schema": "https://unpkg.com/knip@5/schema.json", + "$schema": "https://unpkg.com/knip@6/schema.json", "ignoreExportsUsedInFile": true, "ignoreDependencies": [ "@btravstack/typedoc", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31c039ac..e46b17c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ catalogs: lefthook: specifier: 2.1.10 version: 2.1.10 + mermaid: + specifier: 11.16.0 + version: 11.16.0 oxfmt: specifier: 0.59.0 version: 0.59.0 @@ -114,6 +117,12 @@ catalogs: valibot: specifier: 1.4.2 version: 1.4.2 + vitepress: + specifier: 1.6.4 + version: 1.6.4 + vitepress-plugin-mermaid: + specifier: 2.0.17 + version: 2.0.17 vitest: specifier: 4.1.10 version: 4.1.10 @@ -192,16 +201,16 @@ importers: specifier: 'catalog:' version: 26.1.1 mermaid: - specifier: 11.16.0 + specifier: 'catalog:' version: 11.16.0 tsx: specifier: 'catalog:' version: 4.23.1 vitepress: - specifier: 1.6.4 + specifier: 'catalog:' version: 1.6.4(@algolia/client-search@5.53.0)(@types/node@26.1.1)(jiti@2.7.0)(postcss@8.5.20)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0) vitepress-plugin-mermaid: - specifier: 2.0.17 + specifier: 'catalog:' version: 2.0.17(mermaid@11.16.0)(vitepress@1.6.4(@algolia/client-search@5.53.0)(@types/node@26.1.1)(jiti@2.7.0)(postcss@8.5.20)(terser@5.48.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0)) examples/order-processing-client: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 20a8325d..ba5c5255 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -32,6 +32,7 @@ catalog: arktype: 2.2.3 knip: 6.27.0 lefthook: 2.1.10 + mermaid: 11.16.0 oxfmt: 0.59.0 oxlint: 1.74.0 pino: 10.3.1 @@ -46,6 +47,8 @@ catalog: typescript: 6.0.3 unthrown: 5.0.0 valibot: 1.4.2 + vitepress: 1.6.4 + vitepress-plugin-mermaid: 2.0.17 vitest: 4.1.10 zod: 4.4.3 diff --git a/turbo.json b/turbo.json index bb76fcdb..1b91499a 100644 --- a/turbo.json +++ b/turbo.json @@ -2,6 +2,10 @@ "$schema": "https://turbo.build/schema.json", "globalDependencies": ["**/.env.*local"], "tasks": { + "lint": { + "dependsOn": ["^build"] + }, + "format": {}, "typecheck": { "dependsOn": ["^build"] }, From 71accd607c97417cb2f59e3615a18fbd46e1b737 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 30 Jul 2026 23:24:51 +0200 Subject: [PATCH 03/15] feat(contract)!: rename Infer* helpers and activityOptions, harden the contract boundary - SignalNamesOf/QueryNamesOf/UpdateNamesOf/DeclaredErrorsOf renamed to the Infer* prefix family - defineActivity defaultOptions renamed to activityOptions - typed-error wire envelope marker (details[1]); data-less rehydration now requires the marker - onRehydrationMiss diagnostic hook for degrade-to-generic rehydration - strict ms-grammar duration validation at defineContract time - Temporal-reserved name rejection (__temporal_* prefix, __stack_trace, __enhanced_stack_trace) - activity-only contracts allowed (workflows may be empty when global activities exist) - exported error tag constants (CONTRACT_ERROR_TAG, TECHNICAL_ERROR_TAG) --- packages/client/src/client.ts | 10 +- packages/client/src/types-inference.spec.ts | 2 +- packages/contract/src/builder.spec.ts | 178 +++++++++++++++++- packages/contract/src/builder.ts | 128 ++++++++++--- packages/contract/src/error-tags.ts | 20 ++ packages/contract/src/errors.spec.ts | 133 ++++++++++++- packages/contract/src/errors.ts | 114 ++++++++++- packages/contract/src/index.ts | 10 +- packages/contract/src/types-inference.spec.ts | 60 +++--- packages/contract/src/types.ts | 19 +- .../testing/src/__tests__/test.contract.ts | 2 +- packages/testing/src/activity.spec.ts | 2 +- .../src/__tests__/inprocess.contract.ts | 2 +- .../src/__tests__/inprocess.workflows.ts | 2 +- .../__tests__/time-skipping.inprocess.spec.ts | 2 +- .../src/activity-contract-errors.spec.ts | 14 +- packages/worker/src/child-workflow.ts | 4 +- packages/worker/src/contract-errors.ts | 16 +- packages/worker/src/internal.ts | 16 +- packages/worker/src/workflow-errors.spec.ts | 7 +- packages/worker/src/workflow-proxy.spec.ts | 16 +- packages/worker/src/workflow.ts | 16 +- 22 files changed, 639 insertions(+), 134 deletions(-) create mode 100644 packages/contract/src/error-tags.ts diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 4e091657..090e3c0b 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -3,12 +3,12 @@ import type { AnyWorkflowDefinition, ContractDefinition, ErrorDefinition, + InferSignalNames, + InferUpdateNames, SearchAttributeDefinition, SearchAttributeKindToType, SignalDefinition, - SignalNamesOf, UpdateDefinition, - UpdateNamesOf, } from "@temporal-contract/contract"; import { TechnicalError, type ContractErrorUnion } from "@temporal-contract/contract/errors"; import { type Client, type WorkflowHandle, type WorkflowUpdateHandle } from "@temporalio/client"; @@ -184,7 +184,7 @@ type SignalArgsField = TSignalDef extends SignalDefinition export type TypedSignalWithStartOptions< TContract extends ContractDefinition, TWorkflowName extends keyof TContract["workflows"] & string, - TSignalName extends SignalNamesOf, + TSignalName extends InferSignalNames, > = Omit< WorkflowSignalWithStartOptions, "taskQueue" | "args" | "signal" | "signalArgs" | "searchAttributes" | "typedSearchAttributes" @@ -331,7 +331,7 @@ export type TypedWorkflowHandle = { * parameter is omittable when the update's input schema accepts * `undefined` (e.g. an argument-less `defineUpdate({ output })`). */ - startUpdate: >( + startUpdate: >( updateName: TUpdateName, ...options: TWorkflow["updates"][TUpdateName] extends UpdateDefinition ? undefined extends ClientInferInput @@ -838,7 +838,7 @@ export class ContractClient { */ signalWithStart< TWorkflowName extends keyof TContract["workflows"] & string, - TSignalName extends SignalNamesOf, + TSignalName extends InferSignalNames, >( workflowName: TWorkflowName, options: TypedSignalWithStartOptions, diff --git a/packages/client/src/types-inference.spec.ts b/packages/client/src/types-inference.spec.ts index 69e02889..56e07693 100644 --- a/packages/client/src/types-inference.spec.ts +++ b/packages/client/src/types-inference.spec.ts @@ -116,7 +116,7 @@ describe("signalWithStart name narrowing (audit fix #3)", () => { // We materialise that by asking for `TypedSignalWithStartOptions` with // the literal "anything" as the signal name; the resulting `signalName` // field collapses to `never` because the generic constraint - // `SignalNamesOf<…>` resolves to `never` for a no-signals workflow. + // `InferSignalNames<…>` resolves to `never` for a no-signals workflow. type Options = TypedSignalWithStartOptions< typeof contractNoSignals, "bare", diff --git a/packages/contract/src/builder.spec.ts b/packages/contract/src/builder.spec.ts index e1d57930..a55f99d1 100644 --- a/packages/contract/src/builder.spec.ts +++ b/packages/contract/src/builder.spec.ts @@ -344,13 +344,29 @@ describe("Contract Builder", () => { ).toThrow("Contract validation failed"); }); - it("should throw when no workflows are defined", () => { + it("should throw when neither workflows nor global activities are defined", () => { expect(() => defineContract({ taskQueue: "test", workflows: {}, }), - ).toThrow("at least one workflow is required"); + ).toThrow("at least one workflow or global activity is required"); + }); + + it("should accept an activity-only contract (zero workflows)", () => { + const contract = defineContract({ + taskQueue: "activity-pool", + workflows: {}, + activities: { + sendEmail: { + input: z.object({ to: z.string() }), + output: z.object({ sent: z.boolean() }), + }, + }, + }); + + expect(contract.workflows).toEqual({}); + expect(contract.activities.sendEmail).toBeDefined(); }); it("should throw when workflow name is invalid", () => { @@ -1126,7 +1142,7 @@ describe("Contract Builder — typed errors and default options", () => { ).toThrow(/data must be a Standard Schema compatible schema/); }); - it("accepts contract-level activity defaultOptions", () => { + it("accepts contract-level activityOptions", () => { const contract = defineContract({ taskQueue: "test-queue", workflows: { @@ -1139,7 +1155,7 @@ describe("Contract Builder — typed errors and default options", () => { sendEmail: { input: z.object({ to: z.string() }), output: z.void(), - defaultOptions: { + activityOptions: { startToCloseTimeout: "30 seconds", heartbeatTimeout: 5_000, retry: { maximumAttempts: 5, nonRetryableErrorTypes: ["RecipientRejected"] }, @@ -1148,10 +1164,10 @@ describe("Contract Builder — typed errors and default options", () => { }, }); - expect(contract.activities.sendEmail.defaultOptions?.startToCloseTimeout).toBe("30 seconds"); + expect(contract.activities.sendEmail.activityOptions?.startToCloseTimeout).toBe("30 seconds"); }); - it("rejects a typo'd defaultOptions key (strict object)", () => { + it("rejects a typo'd activityOptions key (strict object)", () => { expect(() => defineContract({ taskQueue: "test-queue", @@ -1163,14 +1179,14 @@ describe("Contract Builder — typed errors and default options", () => { input: z.object({}), output: z.void(), // @ts-expect-error — deliberate typo - defaultOptions: { startToCloseTimeOut: "30 seconds" }, + activityOptions: { startToCloseTimeOut: "30 seconds" }, }, }, }), - ).toThrow(/Contract validation failed/); + ).toThrow('global activity "sendEmail" activityOptions has unknown key "startToCloseTimeOut"'); }); - it("rejects a typo'd retry key inside defaultOptions (strict object)", () => { + it("rejects a typo'd retry key inside activityOptions (strict object)", () => { expect(() => defineContract({ taskQueue: "test-queue", @@ -1181,7 +1197,7 @@ describe("Contract Builder — typed errors and default options", () => { sendEmail: { input: z.object({}), output: z.void(), - defaultOptions: { + activityOptions: { // @ts-expect-error — deliberate typo retry: { maxAttempts: 3 }, }, @@ -1191,3 +1207,145 @@ describe("Contract Builder — typed errors and default options", () => { ).toThrow(/Contract validation failed/); }); }); + +describe("Contract Builder — duration validation", () => { + const withTimeout = (startToCloseTimeout: string | number) => + defineContract({ + taskQueue: "test-queue", + workflows: {}, + activities: { + sendEmail: { + input: z.object({}), + output: z.void(), + activityOptions: { startToCloseTimeout }, + }, + }, + }); + + it.each(["5 minutes", "30s", "1.5h", "100", "2 days", "1 y", ".5m", "500ms"])( + "accepts the ms-formatted duration %j", + (value) => { + expect(() => withTimeout(value)).not.toThrow(); + }, + ); + + it("accepts a number of milliseconds", () => { + expect(() => withTimeout(30_000)).not.toThrow(); + }); + + it.each(["5 minutos", "", "abc", "30 s econds", "1..5h", "-30s"])( + "rejects the invalid duration string %j with the offending path and value", + (value) => { + expect(() => withTimeout(value)).toThrow( + `global activity "sendEmail" activityOptions: startToCloseTimeout has invalid duration "${value}"`, + ); + }, + ); + + it("rejects negative and non-finite numeric durations", () => { + expect(() => withTimeout(-1)).toThrow(/startToCloseTimeout has invalid duration -1/); + expect(() => withTimeout(Number.POSITIVE_INFINITY)).toThrow(/invalid duration Infinity/); + }); + + it("validates retry intervals with the same grammar", () => { + expect(() => + defineContract({ + taskQueue: "test-queue", + workflows: {}, + activities: { + sendEmail: { + input: z.object({}), + output: z.void(), + activityOptions: { retry: { initialInterval: "quick" } }, + }, + }, + }), + ).toThrow( + 'global activity "sendEmail" activityOptions.retry: initialInterval has invalid duration "quick"', + ); + }); +}); + +describe("Contract Builder — Temporal-reserved names", () => { + const RESERVED_MESSAGE = /is reserved by Temporal/; + + it("rejects a workflow name starting with __temporal_", () => { + expect(() => + defineContract({ + taskQueue: "test", + workflows: { + __temporal_cleanup: { input: z.object({}), output: z.object({}) }, + }, + }), + ).toThrow(RESERVED_MESSAGE); + }); + + it("rejects the exact reserved query names", () => { + for (const reserved of ["__stack_trace", "__enhanced_stack_trace"]) { + expect(() => + defineContract({ + taskQueue: "test", + workflows: { + wf: { + input: z.object({}), + output: z.object({}), + queries: { + [reserved]: { input: z.object({}), output: z.object({}) }, + }, + }, + }, + }), + ).toThrow(RESERVED_MESSAGE); + } + }); + + it("rejects reserved global activity, workflow activity, signal, and update names", () => { + const base = { input: z.object({}), output: z.object({}) }; + + expect(() => + defineContract({ + taskQueue: "test", + workflows: {}, + activities: { __temporal_probe: base }, + }), + ).toThrow(RESERVED_MESSAGE); + + expect(() => + defineContract({ + taskQueue: "test", + workflows: { + wf: { ...base, activities: { __temporal_probe: base } }, + }, + }), + ).toThrow(RESERVED_MESSAGE); + + expect(() => + defineContract({ + taskQueue: "test", + workflows: { + wf: { ...base, signals: { __temporal_ping: { input: z.object({}) } } }, + }, + }), + ).toThrow(RESERVED_MESSAGE); + + expect(() => + defineContract({ + taskQueue: "test", + workflows: { + wf: { ...base, updates: { __stack_trace: base } }, + }, + }), + ).toThrow(RESERVED_MESSAGE); + }); + + it("still allows ordinary double-underscore names", () => { + expect(() => + defineContract({ + taskQueue: "test", + workflows: { + __internal_wf: { input: z.object({}), output: z.object({}) }, + }, + }), + ).not.toThrow(); + }); +}); diff --git a/packages/contract/src/builder.ts b/packages/contract/src/builder.ts index 84e0fcc4..467a78e0 100644 --- a/packages/contract/src/builder.ts +++ b/packages/contract/src/builder.ts @@ -52,8 +52,8 @@ import type { * }, * // Contract-level ActivityOptions defaults shared by every worker. * // Merge precedence: declareWorkflow's activityOptions - * // < defaultOptions < activityOptionsByName. - * defaultOptions: { + * // < this contract-level activityOptions < activityOptionsByName. + * activityOptions: { * startToCloseTimeout: "30 seconds", * retry: { maximumAttempts: 5 }, * }, @@ -303,9 +303,12 @@ export function defineWorkflow( * * The contract validates the structure and ensures: * - Task queue is specified - * - At least one workflow is defined - * - No unknown top-level keys (typo protection, like `defaultOptions`) - * - Valid JavaScript identifiers are used + * - At least one workflow or global activity is defined (a contract with + * only global `activities` and zero workflows is valid — e.g. a dedicated + * activity-pool task queue) + * - No unknown top-level keys (typo protection, like `activityOptions`) + * - Valid JavaScript identifiers that don't collide with Temporal-reserved + * names are used * - No ambiguous name collisions between workflows, global activities, and * workflow-specific activities (referencing the *same* activity definition * object from several scopes is allowed) @@ -424,6 +427,27 @@ const undefinedInputSchema: UndefinedInputSchema = { */ const IDENTIFIER_PATTERN = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; +/** + * Temporal reserves handler names for its own SDK internals: everything + * starting with `__temporal_` plus the exact query names `__stack_trace` + * and `__enhanced_stack_trace`. A contract resource shadowing one of these + * would clash with the SDK's built-in handlers at runtime, so they are + * rejected for workflows, activities, signals, queries, and updates (error + * and search-attribute names never become Temporal handler names). + */ +const TEMPORAL_RESERVED_PREFIX = "__temporal_"; +const TEMPORAL_RESERVED_NAMES: readonly string[] = ["__stack_trace", "__enhanced_stack_trace"]; + +/** The identifier kinds whose names surface as Temporal handler/type names. */ +const TEMPORAL_NAMED_KINDS: readonly string[] = [ + "workflow", + "activity", + "global activity", + "signal", + "query", + "update", +]; + /** The seven Temporal search attribute kinds (see {@link SearchAttributeKind}). */ const SEARCH_ATTRIBUTE_KINDS: readonly string[] = [ "TEXT", @@ -435,7 +459,7 @@ const SEARCH_ATTRIBUTE_KINDS: readonly string[] = [ "KEYWORD_LIST", ]; -const DEFAULT_OPTIONS_KEYS = [ +const ACTIVITY_OPTIONS_KEYS = [ "startToCloseTimeout", "scheduleToCloseTimeout", "scheduleToStartTimeout", @@ -443,7 +467,7 @@ const DEFAULT_OPTIONS_KEYS = [ "retry", ] as const; -const DEFAULT_OPTIONS_DURATION_KEYS = [ +const ACTIVITY_OPTIONS_DURATION_KEYS = [ "startToCloseTimeout", "scheduleToCloseTimeout", "scheduleToStartTimeout", @@ -473,6 +497,14 @@ function assertIdentifier(kind: string, name: string): void { if (!IDENTIFIER_PATTERN.test(name)) { fail(`${kind} name "${name}" must be a valid JavaScript identifier`); } + if ( + TEMPORAL_NAMED_KINDS.includes(kind) && + (name.startsWith(TEMPORAL_RESERVED_PREFIX) || TEMPORAL_RESERVED_NAMES.includes(name)) + ) { + fail( + `${kind} name "${name}" is reserved by Temporal — names starting with "__temporal_" and the names "__stack_trace" / "__enhanced_stack_trace" are used internally by the Temporal SDK. Rename it.`, + ); + } } /** @@ -503,14 +535,46 @@ function assertSchema(context: string, slot: string, value: unknown): void { } /** - * A Temporal duration value: an `ms`-formatted string or a number of - * milliseconds. `undefined` (absent) is allowed — every duration slot on - * `defaultOptions` is optional. + * Strict grammar of the `ms` npm package (which Temporal uses to parse + * duration strings): a decimal number followed by an optional unit — + * `ms`/`s`/`m`/`h`/`d`/`w`/`y`, their long forms (`msecs`, `seconds`, + * `mins`, `hours`, `days`, `weeks`, `yrs`, …), with optional spaces before + * the unit. A bare number string ("1500") means milliseconds, exactly as + * `ms` treats it. The `ms` grammar technically accepts a leading `-`, but a + * negative duration is never a valid Temporal timeout/interval, so the sign + * is deliberately rejected here. + */ +const MS_DURATION_PATTERN = + /^(?:\d+)?\.?\d+ *(?:milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)?$/i; + +/** + * A Temporal duration value: an `ms`-formatted string or a non-negative + * finite number of milliseconds. `undefined` (absent) is allowed — every + * duration slot on the contract-level `activityOptions` is optional. + * + * Strings are validated against the `ms` grammar at `defineContract` time so + * a malformed duration ("5 minutos") fails when the contract is defined — + * with a message naming the offending path — instead of surfacing later as + * an opaque worker-side Temporal error. */ function assertDuration(context: string, key: string, value: unknown): void { - if (value !== undefined && typeof value !== "string" && typeof value !== "number") { + if (value === undefined) return; + if (typeof value === "number") { + if (!Number.isFinite(value) || value < 0) { + fail( + `${context}: ${key} has invalid duration ${String(value)} — a numeric duration must be a non-negative, finite number of milliseconds`, + ); + } + return; + } + if (typeof value !== "string") { fail(`${context}: ${key} must be an ms-formatted string or a number of milliseconds`); } + if (!MS_DURATION_PATTERN.test(value) || value.length > 100) { + fail( + `${context}: ${key} has invalid duration "${value}" — expected an ms-formatted string (a number followed by an optional unit ms/s/m/h/d/w/y or its long form, e.g. "30s", "5 minutes", "1.5h") or a number of milliseconds`, + ); + } } /** @@ -544,30 +608,30 @@ function validateErrorsMap(context: string, errors: unknown): void { } /** - * Validate contract-level activity `defaultOptions`. Strict keys so a typo + * Validate contract-level `activityOptions`. Strict keys so a typo * (`startToCloseTimeOut`) fails at `defineContract` time instead of being * silently ignored when the worker merges options. */ -function validateDefaultOptions(context: string, options: unknown): void { +function validateActivityOptions(context: string, options: unknown): void { if (!isRecord(options)) { - fail(`${context}: defaultOptions must be an object`); + fail(`${context}: activityOptions must be an object`); } - assertKnownKeys(`${context} defaultOptions`, options, DEFAULT_OPTIONS_KEYS); - for (const key of DEFAULT_OPTIONS_DURATION_KEYS) { - assertDuration(`${context} defaultOptions`, key, options[key]); + assertKnownKeys(`${context} activityOptions`, options, ACTIVITY_OPTIONS_KEYS); + for (const key of ACTIVITY_OPTIONS_DURATION_KEYS) { + assertDuration(`${context} activityOptions`, key, options[key]); } const retry = options["retry"]; if (retry === undefined) return; if (!isRecord(retry)) { - fail(`${context}: defaultOptions.retry must be an object`); + fail(`${context}: activityOptions.retry must be an object`); } - assertKnownKeys(`${context} defaultOptions.retry`, retry, RETRY_KEYS); - assertDuration(`${context} defaultOptions.retry`, "initialInterval", retry["initialInterval"]); - assertDuration(`${context} defaultOptions.retry`, "maximumInterval", retry["maximumInterval"]); + assertKnownKeys(`${context} activityOptions.retry`, retry, RETRY_KEYS); + assertDuration(`${context} activityOptions.retry`, "initialInterval", retry["initialInterval"]); + assertDuration(`${context} activityOptions.retry`, "maximumInterval", retry["maximumInterval"]); for (const key of ["backoffCoefficient", "maximumAttempts"] as const) { if (retry[key] !== undefined && typeof retry[key] !== "number") { - fail(`${context}: defaultOptions.retry.${key} must be a number`); + fail(`${context}: activityOptions.retry.${key} must be a number`); } } const nonRetryableErrorTypes = retry["nonRetryableErrorTypes"]; @@ -576,13 +640,13 @@ function validateDefaultOptions(context: string, options: unknown): void { (!Array.isArray(nonRetryableErrorTypes) || nonRetryableErrorTypes.some((entry) => typeof entry !== "string")) ) { - fail(`${context}: defaultOptions.retry.nonRetryableErrorTypes must be an array of strings`); + fail(`${context}: activityOptions.retry.nonRetryableErrorTypes must be an array of strings`); } } /** * Validate an activity definition: Standard Schema `input`/`output`, plus - * optional `errors` and `defaultOptions`. + * optional `errors` and `activityOptions`. */ function validateActivityDefinition(context: string, definition: unknown): void { if (!isRecord(definition)) { @@ -593,8 +657,8 @@ function validateActivityDefinition(context: string, definition: unknown): void if (definition["errors"] !== undefined) { validateErrorsMap(context, definition["errors"]); } - if (definition["defaultOptions"] !== undefined) { - validateDefaultOptions(context, definition["defaultOptions"]); + if (definition["activityOptions"] !== undefined) { + validateActivityOptions(context, definition["activityOptions"]); } } @@ -767,7 +831,7 @@ function validateNameCollisions( /** * Validate a contract definition's structure. The root is strict — an * unknown top-level key (e.g. a misspelled `workflow`) fails instead of - * being silently ignored, matching the strict `defaultOptions` behavior. + * being silently ignored, matching the strict `activityOptions` behavior. */ function validateContractDefinition(definition: unknown): void { if (!isRecord(definition)) { @@ -787,9 +851,6 @@ function validateContractDefinition(definition: unknown): void { if (!isRecord(workflows)) { fail("workflows must be an object"); } - if (Object.keys(workflows).length === 0) { - fail("at least one workflow is required"); - } for (const [workflowName, workflow] of Object.entries(workflows)) { assertIdentifier("workflow", workflowName); validateWorkflowDefinition(`workflow "${workflowName}"`, workflow); @@ -806,5 +867,12 @@ function validateContractDefinition(definition: unknown): void { } } + // Activity-only contracts (zero workflows, ≥1 global activity) are valid — + // they model dedicated activity-pool task queues. A contract with neither + // workflows nor activities declares nothing and is still rejected. + if (Object.keys(workflows).length === 0 && Object.keys(activities ?? {}).length === 0) { + fail("at least one workflow or global activity is required"); + } + validateNameCollisions(workflows, activities); } diff --git a/packages/contract/src/error-tags.ts b/packages/contract/src/error-tags.ts new file mode 100644 index 00000000..290ef83f --- /dev/null +++ b/packages/contract/src/error-tags.ts @@ -0,0 +1,20 @@ +/** + * Named constants for the unthrown `_tag` literals of this package's tagged + * errors, so consumers can match without hand-writing the namespaced strings: + * + * ```ts + * result.mapErrCases((matcher) => + * matcher.with(P.tag(CONTRACT_ERROR_TAG), (e) => e.errorName), + * ); + * ``` + * + * Kept in a standalone, dependency-free module (no `unthrown` import) so the + * constants are re-exportable from the package root, which must stay + * importable without the optional `unthrown` peer installed. + */ + +/** `_tag` of `ContractError` — a contract-declared typed domain error. */ +export const CONTRACT_ERROR_TAG = "@temporal-contract/ContractError"; + +/** `_tag` of `TechnicalError` — an infrastructure fault carried as a defect's `cause`. */ +export const TECHNICAL_ERROR_TAG = "@temporal-contract/TechnicalError"; diff --git a/packages/contract/src/errors.spec.ts b/packages/contract/src/errors.spec.ts index c244fb53..7449eff7 100644 --- a/packages/contract/src/errors.spec.ts +++ b/packages/contract/src/errors.spec.ts @@ -1,10 +1,15 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; import { _internal_buildErrorConstructors, _internal_rehydrateContractError, + CONTRACT_ERROR_TAG, + CONTRACT_ERROR_WIRE_MARKER, ContractError, + onRehydrationMiss, + TECHNICAL_ERROR_TAG, + TechnicalError, } from "./errors.js"; describe("_internal_buildErrorConstructors", () => { @@ -66,6 +71,17 @@ describe("_internal_buildErrorConstructors", () => { }); }); +describe("error tag constants", () => { + it("CONTRACT_ERROR_TAG matches ContractError's _tag", () => { + const error = new ContractError({ errorName: "X", data: undefined, message: "x" }); + expect(error._tag).toBe(CONTRACT_ERROR_TAG); + }); + + it("TECHNICAL_ERROR_TAG matches TechnicalError's _tag", () => { + expect(new TechnicalError("boom")._tag).toBe(TECHNICAL_ERROR_TAG); + }); +}); + describe("_internal_rehydrateContractError", () => { const declaredErrors = { PaymentDeclined: { @@ -75,11 +91,15 @@ describe("_internal_rehydrateContractError", () => { OutOfStock: { message: "No stock left" }, }; + afterEach(() => { + onRehydrationMiss(undefined); + }); + it("rehydrates a matching failure with schema-validated data", async () => { const error = await _internal_rehydrateContractError(declaredErrors, { type: "PaymentDeclined", message: "Card declined", - details: [{ reason: "insufficient_funds" }], + details: [{ reason: "insufficient_funds" }, CONTRACT_ERROR_WIRE_MARKER], }); expect(error).toBeInstanceOf(ContractError); @@ -88,8 +108,20 @@ describe("_internal_rehydrateContractError", () => { expect(error?.message).toBe("Card declined"); }); - it("rehydrates a data-less error and keeps the original failure as cause", async () => { - const failure = { type: "OutOfStock", details: [] }; + it("rehydrates a data-carrying failure without the marker (schema is the gate)", async () => { + // Pre-marker producers (or hand-built failures) stay rehydratable when + // the declared data schema validates — the marker corroborates but is + // not required once a schema gates the payload. + const error = await _internal_rehydrateContractError(declaredErrors, { + type: "PaymentDeclined", + details: [{ reason: "insufficient_funds" }], + }); + + expect(error?.errorName).toBe("PaymentDeclined"); + }); + + it("rehydrates a marked data-less error and keeps the original failure as cause", async () => { + const failure = { type: "OutOfStock", details: [undefined, CONTRACT_ERROR_WIRE_MARKER] }; const error = await _internal_rehydrateContractError(declaredErrors, failure); @@ -100,6 +132,29 @@ describe("_internal_rehydrateContractError", () => { expect(error?.cause).toBe(failure); }); + it("accepts a marker that lost its identity to serialization (plain object)", async () => { + const error = await _internal_rehydrateContractError(declaredErrors, { + type: "OutOfStock", + details: [null, { $tc: 1 }], + }); + + expect(error?.errorName).toBe("OutOfStock"); + }); + + it("does NOT rehydrate a data-less name without the marker (false-positive regression)", async () => { + // A foreign ApplicationFailure — e.g. produced by qualifyFailure("OutOfStock") + // or any handwritten `ApplicationFailure.create({ type: "OutOfStock" })` — + // must not surface as the typed domain error just because the `type` + // string matches a declared data-less error. + const error = await _internal_rehydrateContractError(declaredErrors, { + type: "OutOfStock", + message: "same name, different provenance", + details: [], + }); + + expect(error).toBeUndefined(); + }); + it("returns undefined for an undeclared failure type", async () => { const error = await _internal_rehydrateContractError(declaredErrors, { type: "SOMETHING_ELSE", @@ -112,7 +167,7 @@ describe("_internal_rehydrateContractError", () => { it("returns undefined when the payload no longer validates", async () => { const error = await _internal_rehydrateContractError(declaredErrors, { type: "PaymentDeclined", - details: [{ reason: 42 }], + details: [{ reason: 42 }, CONTRACT_ERROR_WIRE_MARKER], }); expect(error).toBeUndefined(); @@ -124,4 +179,72 @@ describe("_internal_rehydrateContractError", () => { ).resolves.toBeUndefined(); await expect(_internal_rehydrateContractError(declaredErrors, {})).resolves.toBeUndefined(); }); + + describe("onRehydrationMiss diagnostics", () => { + it("reports a data validation miss with the issues", async () => { + const handler = vi.fn(); + onRehydrationMiss(handler); + const failure = { + type: "PaymentDeclined", + details: [{ reason: 42 }, CONTRACT_ERROR_WIRE_MARKER], + }; + + await _internal_rehydrateContractError(declaredErrors, failure); + + expect(handler).toHaveBeenCalledExactlyOnceWith({ + errorName: "PaymentDeclined", + reason: "data-validation-failed", + issues: expect.arrayContaining([expect.objectContaining({ message: expect.any(String) })]), + failure, + }); + }); + + it("reports a missing-marker miss for data-less declared names", async () => { + const handler = vi.fn(); + onRehydrationMiss(handler); + const failure = { type: "OutOfStock", details: [] }; + + await _internal_rehydrateContractError(declaredErrors, failure); + + expect(handler).toHaveBeenCalledExactlyOnceWith({ + errorName: "OutOfStock", + reason: "missing-wire-marker", + failure, + }); + }); + + it("does not report undeclared types or successful rehydrations", async () => { + const handler = vi.fn(); + onRehydrationMiss(handler); + + await _internal_rehydrateContractError(declaredErrors, { type: "SOMETHING_ELSE" }); + await _internal_rehydrateContractError(declaredErrors, { + type: "PaymentDeclined", + details: [{ reason: "ok" }, CONTRACT_ERROR_WIRE_MARKER], + }); + + expect(handler).not.toHaveBeenCalled(); + }); + + it("swallows a throwing handler — classification still degrades cleanly", async () => { + onRehydrationMiss(() => { + // oxlint-disable-next-line unthrown/no-throw -- deliberately hostile diagnostic hook for the swallow test + throw new Error("hostile logger"); + }); + + await expect( + _internal_rehydrateContractError(declaredErrors, { type: "OutOfStock", details: [] }), + ).resolves.toBeUndefined(); + }); + + it("can be unregistered by passing undefined", async () => { + const handler = vi.fn(); + onRehydrationMiss(handler); + onRehydrationMiss(undefined); + + await _internal_rehydrateContractError(declaredErrors, { type: "OutOfStock", details: [] }); + + expect(handler).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/contract/src/errors.ts b/packages/contract/src/errors.ts index 110b8885..a9bbf10e 100644 --- a/packages/contract/src/errors.ts +++ b/packages/contract/src/errors.ts @@ -11,17 +11,22 @@ * - the worker hands implementations typed **constructors** for the errors * declared on their activity/workflow, and converts a returned/thrown * {@link ContractError} into a Temporal `ApplicationFailure` - * (`type` = error name, `details[0]` = validated data, `nonRetryable` + * (`type` = error name, `details[0]` = validated data, `details[1]` = + * the {@link CONTRACT_ERROR_WIRE_MARKER} envelope marker, `nonRetryable` * from the contract) at the boundary; * - the workflow-side activities proxy and the client **rehydrate** a * matching `ApplicationFailure` back into a {@link ContractError}, so * consumers branch on a typed, schema-validated error union instead of * string-matching failure types. */ +import type { StandardSchemaV1 } from "@standard-schema/spec"; import { TaggedError } from "unthrown"; +import { CONTRACT_ERROR_TAG, TECHNICAL_ERROR_TAG } from "./error-tags.js"; import type { AnySchema, ErrorDefinition, InferErrorData, InferErrorDataInput } from "./types.js"; +export { CONTRACT_ERROR_TAG, TECHNICAL_ERROR_TAG } from "./error-tags.js"; + /** * Error for technical/runtime failures that cannot be prevented by * TypeScript — connection failures, missing runtime capabilities, worker @@ -35,7 +40,7 @@ import type { AnySchema, ErrorDefinition, InferErrorData, InferErrorDataInput } * The class is retained (and still exported) so the descriptive message and * `cause` survive for logging; it is only ever used as a defect's `cause`. */ -export class TechnicalError extends TaggedError("@temporal-contract/TechnicalError", { +export class TechnicalError extends TaggedError(TECHNICAL_ERROR_TAG, { name: "TechnicalError", })<{ cause?: unknown; @@ -68,7 +73,7 @@ export class TechnicalError extends TaggedError("@temporal-contract/TechnicalErr * `errorName` then narrows to the concrete declared error. */ export class ContractError extends TaggedError( - "@temporal-contract/ContractError", + CONTRACT_ERROR_TAG, { name: "ContractError" }, )<{ /** Declared error name — the `ApplicationFailure.type` discriminator. */ @@ -190,16 +195,96 @@ export type ApplicationFailureLike = { readonly details?: readonly unknown[] | null | undefined; }; +/** + * Wire-envelope marker carried at `details[1]` of every `ApplicationFailure` + * produced from a {@link ContractError} (`details[0]` stays the data + * payload). It marks the failure as temporal-contract provenance, versioned + * for future envelope evolution, so the rehydrator can tell a genuine + * contract error from an unrelated `ApplicationFailure` that merely reuses a + * declared error name as its `type` string. + */ +export const CONTRACT_ERROR_WIRE_MARKER = { $tc: 1 } as const; + +/** Does the failure's `details` carry the {@link CONTRACT_ERROR_WIRE_MARKER}? */ +function hasWireMarker(details: readonly unknown[] | null | undefined): boolean { + const candidate = details?.[1]; + return ( + typeof candidate === "object" && + candidate !== null && + (candidate as Record)["$tc"] === CONTRACT_ERROR_WIRE_MARKER.$tc + ); +} + +/** + * Diagnostic payload describing a rehydration miss: a failure whose `type` + * matched a declared error name but that could not be rehydrated as the + * typed {@link ContractError} and degraded to the caller's generic failure + * classification. + */ +export type RehydrationMiss = { + /** The declared error name that `failure.type` matched. */ + readonly errorName: string; + /** + * Why rehydration degraded: + * - `"data-validation-failed"` — the declared `data` schema rejected + * `details[0]` (schema drift or a foreign failure with a payload); + * - `"missing-wire-marker"` — a data-less declared error without the + * {@link CONTRACT_ERROR_WIRE_MARKER}, i.e. most likely an unrelated + * `ApplicationFailure` reusing the declared name as its `type`. + */ + readonly reason: "data-validation-failed" | "missing-wire-marker"; + /** Validation issues when `reason` is `"data-validation-failed"`. */ + readonly issues?: ReadonlyArray; + /** The failure that was being rehydrated. */ + readonly failure: ApplicationFailureLike; +}; + +let rehydrationMissHandler: ((miss: RehydrationMiss) => void) | undefined; + +/** + * Register a module-level diagnostic hook invoked whenever a failure whose + * `type` matches a declared error name fails to rehydrate as a typed + * {@link ContractError} (see {@link RehydrationMiss}). The degrade-to-generic + * behavior is unchanged — this only makes it observable. The worker and + * client packages wire this into their loggers; pass `undefined` to + * unregister. A throwing handler is swallowed: diagnostics must never break + * error classification. + */ +export function onRehydrationMiss(handler: ((miss: RehydrationMiss) => void) | undefined): void { + rehydrationMissHandler = handler; +} + +function reportRehydrationMiss(miss: RehydrationMiss): void { + if (!rehydrationMissHandler) return; + try { + rehydrationMissHandler(miss); + } catch { + // Deliberately swallowed — a throwing diagnostic hook must not turn a + // degrade-to-generic path into a hard failure. + } +} + /** * Attempt to rehydrate an `ApplicationFailure` back into a typed * {@link ContractError}, by matching `failure.type` against the declared * error names and validating `failure.details[0]` against the declared * `data` schema. * + * Provenance rules: + * - errors **with** a `data` schema: schema validation is the gate — the + * {@link CONTRACT_ERROR_WIRE_MARKER} at `details[1]` is preferred but not + * required (a validating payload is strong-enough evidence); + * - errors **without** a `data` schema: the marker is **required** — + * otherwise any unrelated `ApplicationFailure` whose `type` happens to + * equal a declared data-less error name would be surfaced as the typed + * domain error. + * * Returns `undefined` when the failure doesn't correspond to a declared - * error (unknown `type`, or payload that no longer validates) — callers fall - * through to their generic failure classification, so a mismatch degrades to - * today's untyped behavior instead of producing a wrong typed error. + * error (unknown `type`, payload that no longer validates, or a data-less + * name without the marker) — callers fall through to their generic failure + * classification, so a mismatch degrades to today's untyped behavior instead + * of producing a wrong typed error. Degrades are reported through the + * {@link onRehydrationMiss} hook so they are observable. * * @internal — exported under a deliberately-internal-looking name for the * sibling worker and client packages. Not part of the public API; no semver @@ -217,8 +302,23 @@ export async function _internal_rehydrateContractError( let data: unknown = undefined; if (definition.data) { const validated = await definition.data["~standard"].validate(failure.details?.[0]); - if (validated.issues) return undefined; + if (validated.issues) { + reportRehydrationMiss({ + errorName: failure.type, + reason: "data-validation-failed", + issues: validated.issues, + failure, + }); + return undefined; + } data = validated.value; + } else if (!hasWireMarker(failure.details)) { + reportRehydrationMiss({ + errorName: failure.type, + reason: "missing-wire-marker", + failure, + }); + return undefined; } return new ContractError({ diff --git a/packages/contract/src/index.ts b/packages/contract/src/index.ts index f52f9876..afb58efd 100644 --- a/packages/contract/src/index.ts +++ b/packages/contract/src/index.ts @@ -10,6 +10,8 @@ export { export { formatIssue, summarizeIssues } from "./format.js"; +export { CONTRACT_ERROR_TAG, TECHNICAL_ERROR_TAG } from "./error-tags.js"; + export type { AnySchema, UndefinedInputSchema, @@ -22,7 +24,7 @@ export type { ContractDefinition, // Typed domain errors ErrorDefinition, - DeclaredErrorsOf, + InferDeclaredErrors, InferErrorData, InferErrorDataInput, // Contract-level activity option defaults @@ -41,7 +43,7 @@ export type { WorkerInferOutput, ClientInferInput, ClientInferOutput, - SignalNamesOf, - QueryNamesOf, - UpdateNamesOf, + InferSignalNames, + InferQueryNames, + InferUpdateNames, } from "./types.js"; diff --git a/packages/contract/src/types-inference.spec.ts b/packages/contract/src/types-inference.spec.ts index b6b4d14c..2cdeb3cb 100644 --- a/packages/contract/src/types-inference.spec.ts +++ b/packages/contract/src/types-inference.spec.ts @@ -21,11 +21,11 @@ import type { ClientInferInput, ClientInferOutput, InferActivityNames, + InferQueryNames, + InferSignalNames, + InferUpdateNames, InferWorkflowNames, - QueryNamesOf, SearchAttributeKindToType, - SignalNamesOf, - UpdateNamesOf, WorkerInferInput, } from "./types.js"; @@ -77,6 +77,24 @@ describe("contract inference utilities", () => { expectTypeOf>().toEqualTypeOf(); }); + it("activity-only contracts (empty workflows map) keep their inference intact", () => { + const activityOnly = defineContract({ + taskQueue: "activity-pool", + workflows: {}, + activities: { + log: defineActivity({ + input: z.object({ message: z.string() }), + output: z.void(), + }), + }, + }); + + // Empty workflow map → `never`, not `string` — so typos in workflow + // names on client/worker call sites still fail to compile. + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf<"log">(); + }); + it("defineActivity preserves the literal schema types", () => { const charge = defineActivity({ input: z.object({ amount: z.number() }), @@ -149,23 +167,23 @@ describe("Signal/query/update name helpers (audit fix #3)", () => { }, }); - it("SignalNamesOf yields the declared signal-name union", () => { - type Names = SignalNamesOf<(typeof contractWithSignal)["workflows"]["hasSignal"]>; + it("InferSignalNames yields the declared signal-name union", () => { + type Names = InferSignalNames<(typeof contractWithSignal)["workflows"]["hasSignal"]>; expectTypeOf().toEqualTypeOf<"cancel">(); }); - it("SignalNamesOf is `never` when the workflow declares no signals", () => { - type Names = SignalNamesOf<(typeof contractNoInteractions)["workflows"]["bare"]>; + it("InferSignalNames is `never` when the workflow declares no signals", () => { + type Names = InferSignalNames<(typeof contractNoInteractions)["workflows"]["bare"]>; expectTypeOf().toEqualTypeOf(); }); - it("QueryNamesOf is `never` when the workflow declares no queries", () => { - type Names = QueryNamesOf<(typeof contractNoInteractions)["workflows"]["bare"]>; + it("InferQueryNames is `never` when the workflow declares no queries", () => { + type Names = InferQueryNames<(typeof contractNoInteractions)["workflows"]["bare"]>; expectTypeOf().toEqualTypeOf(); }); - it("UpdateNamesOf is `never` when the workflow declares no updates", () => { - type Names = UpdateNamesOf<(typeof contractNoInteractions)["workflows"]["bare"]>; + it("InferUpdateNames is `never` when the workflow declares no updates", () => { + type Names = InferUpdateNames<(typeof contractNoInteractions)["workflows"]["bare"]>; expectTypeOf().toEqualTypeOf(); }); @@ -186,7 +204,7 @@ describe("Signal/query/update name helpers (audit fix #3)", () => { }); type Union = typeof wfA | typeof wfB; - type Names = SignalNamesOf; + type Names = InferSignalNames; // Distributive: union of each variant's signal names, not the // intersection (which `keyof (A | B)` would otherwise produce). @@ -217,11 +235,11 @@ describe("Signal/query/update name helpers (audit fix #3)", () => { type Union = typeof wfA | typeof wfB; - expectTypeOf>().toEqualTypeOf<"getStatus" | "getCount">(); - expectTypeOf>().toEqualTypeOf<"bump" | "reset">(); + expectTypeOf>().toEqualTypeOf<"getStatus" | "getCount">(); + expectTypeOf>().toEqualTypeOf<"bump" | "reset">(); }); - it("QueryNamesOf yields the declared query-name union", () => { + it("InferQueryNames yields the declared query-name union", () => { const wf = defineWorkflow({ input: z.object({}), output: z.object({}), @@ -229,10 +247,10 @@ describe("Signal/query/update name helpers (audit fix #3)", () => { getStatus: defineQuery({ input: z.object({}), output: z.string() }), }, }); - expectTypeOf>().toEqualTypeOf<"getStatus">(); + expectTypeOf>().toEqualTypeOf<"getStatus">(); }); - it("UpdateNamesOf yields the declared update-name union", () => { + it("InferUpdateNames yields the declared update-name union", () => { const wf = defineWorkflow({ input: z.object({}), output: z.object({}), @@ -240,7 +258,7 @@ describe("Signal/query/update name helpers (audit fix #3)", () => { bump: defineUpdate({ input: z.object({}), output: z.number() }), }, }); - expectTypeOf>().toEqualTypeOf<"bump">(); + expectTypeOf>().toEqualTypeOf<"bump">(); }); }); @@ -277,8 +295,8 @@ describe("input-less signal/query/update definitions", () => { queries: { getStatus: defineQuery({ output: z.string() }) }, updates: { bump: defineUpdate({ output: z.number() }) }, }); - expectTypeOf>().toEqualTypeOf<"shutdown">(); - expectTypeOf>().toEqualTypeOf<"getStatus">(); - expectTypeOf>().toEqualTypeOf<"bump">(); + expectTypeOf>().toEqualTypeOf<"shutdown">(); + expectTypeOf>().toEqualTypeOf<"getStatus">(); + expectTypeOf>().toEqualTypeOf<"bump">(); }); }); diff --git a/packages/contract/src/types.ts b/packages/contract/src/types.ts index 7eb2ff0c..9e66ad58 100644 --- a/packages/contract/src/types.ts +++ b/packages/contract/src/types.ts @@ -72,7 +72,8 @@ export type ActivityRetryPolicy = { * * Merge precedence at the worker (least → most specific): * `declareWorkflow`'s `activityOptions` (workflow-wide default) - * → this `defaultOptions` (activity-specific, from the contract author) + * → this contract-level `activityOptions` (activity-specific, from the + * contract author) * → `activityOptionsByName` (explicit per-workflow, per-activity override). * * Deployment-specific concerns (`taskQueue` routing, cancellation type) are @@ -98,7 +99,7 @@ export type ActivityDefinition< readonly input: TInput; readonly output: TOutput; readonly errors?: TErrors; - readonly defaultOptions?: ActivityDefaultOptions; + readonly activityOptions?: ActivityDefaultOptions; }; /** @@ -230,11 +231,11 @@ export type AnyWorkflowDefinition = WorkflowDefinition< * or `never` when the definition declares none. * * The conditional is distributive and `infer`-based (rather than indexing - * `TDef["errors"]` directly) for the same reasons as {@link SignalNamesOf}: + * `TDef["errors"]` directly) for the same reasons as {@link InferSignalNames}: * union definitions yield the union of their error maps, and the optional * property is tolerated under `exactOptionalPropertyTypes`. */ -export type DeclaredErrorsOf = TDef extends { +export type InferDeclaredErrors = TDef extends { errors: infer TErrors; } ? TErrors extends Record @@ -279,7 +280,7 @@ export type InferErrorDataInput = TDef extends { * `infer S` also tolerates the property being absent or `undefined` under * `exactOptionalPropertyTypes`. */ -export type SignalNamesOf = W extends { +export type InferSignalNames = W extends { signals?: infer S; } ? S extends Record @@ -289,10 +290,10 @@ export type SignalNamesOf = W extends { /** * Extract query names declared on a workflow as a string union, or `never` - * if the workflow declares no queries. See {@link SignalNamesOf} for the + * if the workflow declares no queries. See {@link InferSignalNames} for the * rationale behind the distributive `infer`-based shape. */ -export type QueryNamesOf = W extends { +export type InferQueryNames = W extends { queries?: infer Q; } ? Q extends Record @@ -302,10 +303,10 @@ export type QueryNamesOf = W extends { /** * Extract update names declared on a workflow as a string union, or `never` - * if the workflow declares no updates. See {@link SignalNamesOf} for the + * if the workflow declares no updates. See {@link InferSignalNames} for the * rationale behind the distributive `infer`-based shape. */ -export type UpdateNamesOf = W extends { +export type InferUpdateNames = W extends { updates?: infer U; } ? U extends Record diff --git a/packages/testing/src/__tests__/test.contract.ts b/packages/testing/src/__tests__/test.contract.ts index ef3e1634..400b5268 100644 --- a/packages/testing/src/__tests__/test.contract.ts +++ b/packages/testing/src/__tests__/test.contract.ts @@ -7,7 +7,7 @@ import { z } from "zod"; const decorate = defineActivity({ input: z.object({ name: z.string() }), output: z.object({ decorated: z.string() }), - defaultOptions: { startToCloseTimeout: "10 seconds" }, + activityOptions: { startToCloseTimeout: "10 seconds" }, }); const greet = defineWorkflow({ diff --git a/packages/testing/src/activity.spec.ts b/packages/testing/src/activity.spec.ts index a840358f..0b95d14c 100644 --- a/packages/testing/src/activity.spec.ts +++ b/packages/testing/src/activity.spec.ts @@ -23,7 +23,7 @@ const charge = defineActivity({ nonRetryable: true, }, }, - defaultOptions: { startToCloseTimeout: "10 seconds" }, + activityOptions: { startToCloseTimeout: "10 seconds" }, }); describe("runActivity", () => { diff --git a/packages/worker/src/__tests__/inprocess.contract.ts b/packages/worker/src/__tests__/inprocess.contract.ts index 29b07ad3..cd37f80a 100644 --- a/packages/worker/src/__tests__/inprocess.contract.ts +++ b/packages/worker/src/__tests__/inprocess.contract.ts @@ -14,7 +14,7 @@ const charge = defineActivity({ }, }, // Contract-level defaults — `declareWorkflow` omits `activityOptions`. - defaultOptions: { startToCloseTimeout: "10 seconds" }, + activityOptions: { startToCloseTimeout: "10 seconds" }, }); const placeOrder = defineWorkflow({ diff --git a/packages/worker/src/__tests__/inprocess.workflows.ts b/packages/worker/src/__tests__/inprocess.workflows.ts index 33daedf2..28cd0d47 100644 --- a/packages/worker/src/__tests__/inprocess.workflows.ts +++ b/packages/worker/src/__tests__/inprocess.workflows.ts @@ -7,7 +7,7 @@ export const placeOrder = declareWorkflow({ workflowName: "placeOrder", contract: inprocessContract, // No `activityOptions`: the only reachable activity (`charge`) carries - // contract-level `defaultOptions`. + // contract-level `activityOptions`. implementation: async (context, args) => { if (args.amount === 0) { // Typed workflow error — rehydrated as a ContractError by the client. diff --git a/packages/worker/src/__tests__/time-skipping.inprocess.spec.ts b/packages/worker/src/__tests__/time-skipping.inprocess.spec.ts index 500a6784..9ec6ce6c 100644 --- a/packages/worker/src/__tests__/time-skipping.inprocess.spec.ts +++ b/packages/worker/src/__tests__/time-skipping.inprocess.spec.ts @@ -16,7 +16,7 @@ import { OkAsync, ErrAsync } from "unthrown"; * - typed contract errors (activity-side rehydration in the workflow, * workflow-side rehydration at the client), * - client interceptors observing every operation, - * - contract-level `defaultOptions` standing in for `activityOptions`, + * - contract-level `activityOptions` standing in for `activityOptions`, * - time skipping (an hour-long `sleep` resolving immediately). */ import { describe, expect } from "vitest"; diff --git a/packages/worker/src/activity-contract-errors.spec.ts b/packages/worker/src/activity-contract-errors.spec.ts index 63080954..36e2a790 100644 --- a/packages/worker/src/activity-contract-errors.spec.ts +++ b/packages/worker/src/activity-contract-errors.spec.ts @@ -6,7 +6,8 @@ import { OkAsync, ErrAsync, P } from "unthrown"; * errors, middleware chain, and dependency context — the boundary where an * implementation's `Err(ContractError)` becomes a Temporal * `ApplicationFailure` (`type` = error name, `details[0]` = validated data, - * `nonRetryable` from the contract). + * `details[1]` = the wire-envelope marker, `nonRetryable` from the + * contract). */ import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; @@ -69,11 +70,11 @@ describe("declareActivitiesHandler — contract errors", () => { type: "PaymentDeclined", message: "The payment was declined", nonRetryable: true, - details: [{ reason: "insufficient_funds" }], + details: [{ reason: "insufficient_funds" }, { $tc: 1 }], }); }); - it("serializes data-less errors with empty details and contract retryability", async () => { + it("serializes data-less errors with a marker-only envelope and contract retryability", async () => { const activities = declareActivitiesHandler({ contract, activities: { @@ -87,7 +88,10 @@ describe("declareActivitiesHandler — contract errors", () => { await expect(activities.chargePayment({ amount: 100 })).rejects.toMatchObject({ type: "GatewayUnavailable", nonRetryable: false, - details: [], + // `details[0]` stays the (absent) data slot so slot positions are + // stable; `details[1]` is the provenance marker the rehydrating side + // requires for data-less errors. + details: [undefined, { $tc: 1 }], }); }); @@ -119,7 +123,7 @@ describe("declareActivitiesHandler — contract errors", () => { await expect(activities.flaky({})).rejects.toMatchObject({ type: "Nope", - details: [{ reason: "declined" }], // not [{ reason: "declined!" }] + details: [{ reason: "declined" }, { $tc: 1 }], // not [{ reason: "declined!" }, …] }); }); diff --git a/packages/worker/src/child-workflow.ts b/packages/worker/src/child-workflow.ts index 7a8ba1e5..e66ea088 100644 --- a/packages/worker/src/child-workflow.ts +++ b/packages/worker/src/child-workflow.ts @@ -6,8 +6,8 @@ import type { AnyWorkflowDefinition, ContractDefinition, + InferSignalNames, SignalDefinition, - SignalNamesOf, } from "@temporal-contract/contract"; import { summarizeIssues } from "@temporal-contract/contract"; import { @@ -56,7 +56,7 @@ export type TypedChildWorkflowOptions< * parses it on receive, so a transforming schema applies exactly once. */ export type TypedChildWorkflowSignals = { - [K in SignalNamesOf]: ( + [K in InferSignalNames]: ( args: ClientInferInput>, ) => AsyncResult; }; diff --git a/packages/worker/src/contract-errors.ts b/packages/worker/src/contract-errors.ts index 353150c2..d293d68b 100644 --- a/packages/worker/src/contract-errors.ts +++ b/packages/worker/src/contract-errors.ts @@ -1,5 +1,8 @@ import type { ErrorDefinition } from "@temporal-contract/contract"; -import type { AnyContractError } from "@temporal-contract/contract/errors"; +import { + CONTRACT_ERROR_WIRE_MARKER, + type AnyContractError, +} from "@temporal-contract/contract/errors"; /** * Worker-side bridge between contract-declared typed errors and Temporal's * `ApplicationFailure`. @@ -22,6 +25,11 @@ import { ContractErrorDataValidationError } from "./errors.js"; * declared schema (fail early on contract misuse), but the parsed value is * discarded — the consuming side (client / workflow-proxy rehydration) * parses it, so a transforming `data` schema applies exactly once, + * - `details[1]` = the `CONTRACT_ERROR_WIRE_MARKER` provenance/version + * envelope, so the rehydrating side can tell a genuine contract error from + * an unrelated `ApplicationFailure` that reuses a declared name as its + * `type` (required for data-less errors, corroborating for data-carrying + * ones), * - `nonRetryable` = the contract's declaration (default retryable), * - `cause` = the constructor-supplied cause, so stack traces survive. * @@ -47,7 +55,9 @@ export async function contractErrorToApplicationFailure( ]); } - let details: unknown[] = []; + // `details[0]` is always the data slot (undefined for data-less errors) + // and `details[1]` the wire marker, so slot positions stay stable. + let details: unknown[] = [undefined, CONTRACT_ERROR_WIRE_MARKER]; if (definition.data) { const validated = await definition.data["~standard"].validate(error.data); if (validated.issues) { @@ -55,7 +65,7 @@ export async function contractErrorToApplicationFailure( throw new ContractErrorDataValidationError(error.errorName, validated.issues); } // Transmit the ORIGINAL payload — the rehydrating side parses it (D1). - details = [error.data]; + details = [error.data, CONTRACT_ERROR_WIRE_MARKER]; } return ApplicationFailure.create({ diff --git a/packages/worker/src/internal.ts b/packages/worker/src/internal.ts index bed30b55..c488e903 100644 --- a/packages/worker/src/internal.ts +++ b/packages/worker/src/internal.ts @@ -76,7 +76,7 @@ type ActivityFn = (...args: unknown[]) => Promise; /** * Build the raw `Record` proxy of activities for a workflow, - * applying contract-level `defaultOptions` and per-activity + * applying contract-level `activityOptions` and per-activity * `ActivityOptions` overrides where present. * * **Fast path (no contract defaults, no overrides):** a single @@ -87,7 +87,7 @@ type ActivityFn = (...args: unknown[]) => Promise; * * **Merged path:** one extra `proxyActivities(merged)` call is made *only* * for each activity whose effective options differ from the workflow-wide - * default — i.e. it declares `defaultOptions` on the contract and/or has an + * default — i.e. it declares `activityOptions` on the contract and/or has an * `activityOptionsByName` override. Activities without either keep using the * single default proxy. The result is a `Proxy` that returns the bound * function for named keys and falls back to the default proxy for everything @@ -100,7 +100,7 @@ type ActivityFn = (...args: unknown[]) => Promise; * "one ActivityOptions per `proxyActivities` call" semantics: * * 1. `declareWorkflow`'s `activityOptions` (workflow-wide default) - * 2. the contract's `defineActivity({ defaultOptions })` (activity-specific, + * 2. the contract's `defineActivity({ activityOptions })` (activity-specific, * shared by every worker) * 3. `activityOptionsByName` (explicit per-workflow, per-activity override) */ @@ -116,7 +116,7 @@ export function buildRawActivitiesProxy( }; // `activityOptions` is optional on `declareWorkflow` — an activity covered - // by contract-level `defaultOptions` (or an explicit override) doesn't need + // by contract-level `activityOptions` (or an explicit override) doesn't need // the workflow-wide default. It is still required as soon as any reachable // activity has no per-activity options of its own; fail at declaration time // with the offending names rather than letting Temporal's generic @@ -124,7 +124,7 @@ export function buildRawActivitiesProxy( if (!defaultOptions) { const uncovered = Object.entries(allDefinitions) .filter(([name, definition]) => { - const contractDefaults = definition.defaultOptions; + const contractDefaults = definition.activityOptions; const override = overrides?.[name]; const hasContractDefaults = contractDefaults && Object.keys(contractDefaults).length > 0; const hasOverride = override && Object.keys(override).length > 0; @@ -138,7 +138,7 @@ export function buildRawActivitiesProxy( // oxlint-disable-next-line unthrown/no-throw -- sanctioned ContractMisuseError model: declaration-time fail-fast as a non-retryable ApplicationFailure (CLAUDE.md rule 2 exception) throw new ContractMisuseError( `declareWorkflow: \`activityOptions\` was omitted but the following activities declare ` + - `no contract-level \`defaultOptions\` and have no \`activityOptionsByName\` entry: ` + + `no contract-level \`activityOptions\` and have no \`activityOptionsByName\` entry: ` + `${uncovered.join(", ")}. Provide \`activityOptions\` or per-activity options.`, ); } @@ -174,11 +174,11 @@ export function buildRawActivitiesProxy( // options. const customizedFns: Record = {}; for (const [name, definition] of Object.entries(allDefinitions)) { - const contractDefaults = definition.defaultOptions; + const contractDefaults = definition.activityOptions; const override = overrideByName[name]; // An empty options bag can't change the effective options — treat it as // absent so the "one extra proxy only when options differ" optimization - // holds for `defaultOptions: {}` / `activityOptionsByName: { x: {} }`. + // holds for `activityOptions: {}` / `activityOptionsByName: { x: {} }`. const hasContractDefaults = contractDefaults && Object.keys(contractDefaults).length > 0; const hasOverride = override && Object.keys(override).length > 0; if (!hasContractDefaults && !hasOverride) { diff --git a/packages/worker/src/workflow-errors.spec.ts b/packages/worker/src/workflow-errors.spec.ts index 7335975a..587fd003 100644 --- a/packages/worker/src/workflow-errors.spec.ts +++ b/packages/worker/src/workflow-errors.spec.ts @@ -4,8 +4,9 @@ import { ApplicationFailure } from "@temporalio/common"; * Runtime coverage for workflow-level contract errors: a * `throw context.errors.X(...)` from the implementation must leave the * workflow function as an `ApplicationFailure` (`type` = error name, - * `details[0]` = validated data) — a plain thrown `Error` would be retried - * as a Workflow Task failure forever. + * `details[0]` = validated data, `details[1]` = the wire-envelope marker) — + * a plain thrown `Error` would be retried as a Workflow Task failure + * forever. * * Mocks `@temporalio/workflow` so the declared workflow function is callable * outside a real workflow sandbox. @@ -66,7 +67,7 @@ describe("declareWorkflow — contract errors", () => { type: "EmptyOrder", message: "Order has no items", nonRetryable: true, - details: [{ orderId: "ORD-1" }], + details: [{ orderId: "ORD-1" }, { $tc: 1 }], }); }); diff --git a/packages/worker/src/workflow-proxy.spec.ts b/packages/worker/src/workflow-proxy.spec.ts index ef20da6e..b492b3f7 100644 --- a/packages/worker/src/workflow-proxy.spec.ts +++ b/packages/worker/src/workflow-proxy.spec.ts @@ -272,19 +272,19 @@ describe("declareWorkflow hoists proxyActivities to declaration time", () => { }); }); -describe("buildRawActivitiesProxy — contract-level defaultOptions", () => { +describe("buildRawActivitiesProxy — contract-level activityOptions", () => { afterEach(() => { proxyCalls.length = 0; }); - const tunedActivityDef = (defaultOptions: Record): ActivityDefinition => + const tunedActivityDef = (activityOptions: Record): ActivityDefinition => ({ input: z.object({}), output: z.object({}), - defaultOptions, + activityOptions, }) as unknown as ActivityDefinition; - it("merges contract defaultOptions over the workflow-wide default", () => { + it("merges contract activityOptions over the workflow-wide default", () => { const def: Record = { plain: activityDef(z.object({}), z.object({})), tuned: tunedActivityDef({ @@ -310,7 +310,7 @@ describe("buildRawActivitiesProxy — contract-level defaultOptions", () => { ]); }); - it("gives activityOptionsByName precedence over contract defaultOptions", () => { + it("gives activityOptionsByName precedence over contract activityOptions", () => { const def: Record = { tuned: tunedActivityDef({ startToCloseTimeout: "10 minutes", @@ -334,7 +334,7 @@ describe("buildRawActivitiesProxy — contract-level defaultOptions", () => { ]); }); - it("applies defaultOptions declared on global contract activities", () => { + it("applies activityOptions declared on global contract activities", () => { const globalDefs: Record = { notify: tunedActivityDef({ startToCloseTimeout: "5 seconds" }), }; @@ -351,12 +351,12 @@ describe("buildRawActivitiesProxy — empty option bags stay on the fast path", proxyCalls.length = 0; }); - it("treats an empty contract defaultOptions object as absent", () => { + it("treats an empty contract activityOptions object as absent", () => { const def: Record = { a: { input: z.object({}), output: z.object({}), - defaultOptions: {}, + activityOptions: {}, } as unknown as ActivityDefinition, }; const defaults: ActivityOptions = { startToCloseTimeout: "1 minute" }; diff --git a/packages/worker/src/workflow.ts b/packages/worker/src/workflow.ts index b1590390..d2713c46 100644 --- a/packages/worker/src/workflow.ts +++ b/packages/worker/src/workflow.ts @@ -9,12 +9,12 @@ import type { ActivityDefinition, ContractDefinition, ErrorDefinition, + InferQueryNames, + InferSignalNames, + InferUpdateNames, QueryDefinition, - QueryNamesOf, SignalDefinition, - SignalNamesOf, UpdateDefinition, - UpdateNamesOf, } from "@temporal-contract/contract"; import { _internal_buildErrorConstructors, @@ -419,7 +419,7 @@ type DeclareWorkflowOptions< * Default activity options applied to every activity reachable from this * workflow (workflow-local + global) unless overridden. Merge precedence * per activity (least → most specific): this workflow-wide default → the - * activity's contract-level `defineActivity({ defaultOptions })` → the + * activity's contract-level `defineActivity({ activityOptions })` → the * explicit {@link activityOptionsByName} entry. See Temporal's * `ActivityOptions` for the full set of fields: * - `startToCloseTimeout`: Maximum time for a single attempt to run @@ -437,7 +437,7 @@ type DeclareWorkflowOptions< * ``` * * Optional when every reachable activity carries its own options — via - * contract-level `defaultOptions` or an {@link activityOptionsByName} + * contract-level `activityOptions` or an {@link activityOptionsByName} * entry. If it is omitted while some activity has neither, * `declareWorkflow` throws at declaration time listing the uncovered * activities. @@ -562,7 +562,7 @@ type WorkflowContext< * } * ``` */ - defineSignal: >( + defineSignal: >( signalName: K, handler: SignalHandlerImplementation>, ) => void; @@ -584,7 +584,7 @@ type WorkflowContext< * } * ``` */ - defineQuery: >( + defineQuery: >( queryName: K, handler: QueryHandlerImplementation>, ) => void; @@ -607,7 +607,7 @@ type WorkflowContext< * } * ``` */ - defineUpdate: >( + defineUpdate: >( updateName: K, handler: UpdateHandlerImplementation>, ) => void; From bfae920854bd6ba0679e1c76a0f017345b281ed5 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 30 Jul 2026 23:25:59 +0200 Subject: [PATCH 04/15] docs: update verb-policy example to TypedWorker.create --- .agents/rules/contract-patterns.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/rules/contract-patterns.md b/.agents/rules/contract-patterns.md index 3189bf0d..5661ba2c 100644 --- a/.agents/rules/contract-patterns.md +++ b/.agents/rules/contract-patterns.md @@ -8,7 +8,7 @@ API names follow a three-tier verb convention, shared with amqp-contract: | ---------------------------- | -------------------------------------------- | -------------------------------------------------------------------------- | | `define*` | Contract/schema authoring (pure, no runtime) | `defineContract`, `defineWorkflow`, `defineActivity`, `defineSignal`, … | | `declare*` | Binding an implementation to a contract | `declareWorkflow`, `declareActivitiesHandler`, `declareActivityMiddleware` | -| `create*` / `Typed*` classes | Runtime object factories | `createWorker`, `TypedClient.create`, `createContractTest` | +| `create*` / `Typed*` classes | Runtime object factories | `TypedWorker.create`, `TypedClient.create`, `createContractTest` | Additionally, **in-workflow** handler binding uses `handle*` on the workflow context: `context.handleSignal`, `context.handleQuery`, `context.handleUpdate`. When naming a new export, pick the tier by what it does — never a `define*` that touches runtime, never a `declare*` that authors schema. From 1635e0bb41790746d97d1e7ea0a64815ea2971ad Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 30 Jul 2026 23:52:12 +0200 Subject: [PATCH 05/15] feat(worker)!: fix shared-activity clobber, rename context handlers, close the qualify defect hole - shared activity implemented twice with different functions now throws at declaration time - context.defineSignal/defineQuery/defineUpdate renamed to handleSignal/handleQuery/handleUpdate - export WorkflowContext, DeclareWorkflowOptions, child handle types, ActivityImplementationFor - qualifyFailure requires an expected-cause discriminator; unexpected causes ride the defect channel; inner nonRetryable inherited by default; expected: "any" is the explicit escape hatch - rethrowCancellation helper; swallowed-cancellation warnings on cancellation error classes - async query/update schemas rejected at bind time with ContractMisuseError - continueAsNew can no longer override workflowType/taskQueue via options spread - ChildWorkflowError carries workflowName; validation errors carry a direction field - worker error tag constants; TypedWorker.create verifies workflow registration by default --- .agents/rules/handlers.md | 19 +- .../src/application/activities.ts | 28 +- .../src/application/workflows.ts | 10 +- packages/contract/src/errors.spec.ts | 2 +- .../src/__tests__/inprocess.contract.ts | 13 +- .../src/__tests__/inprocess.workflows.ts | 31 +- .../registration-complete.workflows.ts | 16 + .../registration-mismatch.workflows.ts | 20 + .../registration-missing.workflows.ts | 10 + .../__tests__/registration-raw.workflows.ts | 19 + .../src/__tests__/registration.contract.ts | 23 ++ .../worker/src/__tests__/test.workflows.ts | 10 +- .../__tests__/time-skipping.inprocess.spec.ts | 40 ++ packages/worker/src/activity.spec.ts | 248 ++++++++++-- packages/worker/src/activity.ts | 353 ++++++++++++++---- packages/worker/src/child-workflow.spec.ts | 3 + packages/worker/src/child-workflow.ts | 3 + packages/worker/src/continue-as-new.spec.ts | 20 +- packages/worker/src/error-tags.ts | 40 ++ packages/worker/src/errors.spec.ts | 101 +++++ packages/worker/src/errors.ts | 144 +++++-- packages/worker/src/handlers.spec.ts | 133 ++++++- packages/worker/src/handlers.ts | 96 +++++ packages/worker/src/internal.ts | 15 +- packages/worker/src/types-inference.spec.ts | 222 ++++++++++- packages/worker/src/types.ts | 2 +- packages/worker/src/worker.spec.ts | 124 ++++++ packages/worker/src/worker.ts | 154 +++++++- packages/worker/src/workflow-brand.ts | 31 ++ packages/worker/src/workflow.ts | 117 ++++-- 30 files changed, 1837 insertions(+), 210 deletions(-) create mode 100644 packages/worker/src/__tests__/registration-complete.workflows.ts create mode 100644 packages/worker/src/__tests__/registration-mismatch.workflows.ts create mode 100644 packages/worker/src/__tests__/registration-missing.workflows.ts create mode 100644 packages/worker/src/__tests__/registration-raw.workflows.ts create mode 100644 packages/worker/src/__tests__/registration.contract.ts create mode 100644 packages/worker/src/error-tags.ts create mode 100644 packages/worker/src/workflow-brand.ts diff --git a/.agents/rules/handlers.md b/.agents/rules/handlers.md index 6b5ba580..9a341817 100644 --- a/.agents/rules/handlers.md +++ b/.agents/rules/handlers.md @@ -23,12 +23,17 @@ export const activities = declareActivitiesHandler({ }); ``` -`fromPromise(promise, qualifyFailure)` forces every rejection through `qualifyFailure`, which -returns the modeled error `E` (here an `ApplicationFailure`). For the common -case, the worker package exports a `qualifyFailure(type, options?)` helper that builds -that function — `fromPromise(inventoryService.check(orderId), qualifyFailure("INVENTORY_CHECK_FAILED"))` -preserves an `Error` rejection's message and `cause`, with `options.nonRetryable` -/ `options.details` / `options.message` (non-`Error` fallback) available. For a +`fromPromise(promise, qualify)` forces every rejection through `qualify`, which +returns the modeled error `E` (here an `ApplicationFailure`) or routes the cause +to the defect channel. For the common case, the worker package exports a +`qualifyFailure(errorType, options)` helper that builds that function — +`options.expected` is **required** (an error-class constructor, an array of +them, a predicate, or the literal `"any"` escape hatch): matching causes are +wrapped (preserving an `Error` rejection's message and `cause`, and inheriting +`nonRetryable: true` from a matched non-retryable `ApplicationFailure` unless +`options.nonRetryable` overrides), everything else becomes a defect. E.g. +`fromPromise(inventoryService.check(orderId), qualifyFailure("INVENTORY_CHECK_FAILED", { expected: InventoryServiceError }))`. +`options.details` / `options.message` (non-`Error` fallback) remain available. For a value you already have, use the canonical pre-lifted constructors `OkAsync(value)` / `ErrAsync(failure)` (prefer `OkAsync()` zero-arg over `OkAsync(undefined)`); `.toAsync()` is for lifting a sync `Result` you already @@ -73,7 +78,7 @@ export const processOrder = declareWorkflow({ implementation: async (context, args) => { // context.activities — typed, validated activities // context.info — WorkflowInfo - // context.defineSignal/defineQuery/defineUpdate — handler registration + // context.handleSignal/handleQuery/handleUpdate — handler binding // context.executeChildWorkflow / context.startChildWorkflow // context.cancellableScope / context.nonCancellableScope — see below diff --git a/examples/order-processing-worker/src/application/activities.ts b/examples/order-processing-worker/src/application/activities.ts index 829e45da..1d1258c7 100644 --- a/examples/order-processing-worker/src/application/activities.ts +++ b/examples/order-processing-worker/src/application/activities.ts @@ -52,13 +52,19 @@ export const activities = declareActivitiesHandler({ sendNotification: ({ customerId, subject, message }) => fromPromise( sendNotificationUseCase.execute(customerId, subject, message), - qualifyFailure("NOTIFICATION_FAILED", { message: "Failed to send notification" }), + qualifyFailure("NOTIFICATION_FAILED", { + // Anticipated failure shape: the domain layer signals technical + // faults by throwing plain `Error`s. Anything else (a TypeError + // from a bug, ...) stays a defect and re-throws at the edge. + expected: Error, + message: "Failed to send notification", + }), ), purgeExpiredOrders: ({ olderThanDays }) => fromPromise( purgeExpiredOrdersUseCase.execute(olderThanDays), - qualifyFailure("ORDER_PURGE_FAILED", { message: "Order purge failed" }), + qualifyFailure("ORDER_PURGE_FAILED", { expected: Error, message: "Order purge failed" }), ).map((purgedCount) => ({ purgedCount })), processOrder: { @@ -72,7 +78,10 @@ export const activities = declareActivitiesHandler({ processPayment: ({ customerId, amount }, { errors }) => fromPromise( processPaymentUseCase.execute(customerId, amount), - qualifyFailure("PAYMENT_GATEWAY_ERROR", { message: "Payment gateway call failed" }), + qualifyFailure("PAYMENT_GATEWAY_ERROR", { + expected: Error, + message: "Payment gateway call failed", + }), ).flatMap((outcome) => { if (outcome.status === "declined") { return Err(errors.PaymentDeclined({ reason: outcome.reason })); @@ -84,6 +93,7 @@ export const activities = declareActivitiesHandler({ fromPromise( reserveInventoryUseCase.execute(items), qualifyFailure("INVENTORY_RESERVATION_FAILED", { + expected: Error, message: "Inventory reservation failed", }), ), @@ -91,19 +101,25 @@ export const activities = declareActivitiesHandler({ releaseInventory: (reservationId) => fromPromise( releaseInventoryUseCase.execute(reservationId), - qualifyFailure("INVENTORY_RELEASE_FAILED", { message: "Inventory release failed" }), + qualifyFailure("INVENTORY_RELEASE_FAILED", { + expected: Error, + message: "Inventory release failed", + }), ), createShipment: ({ orderId, customerId }) => fromPromise( createShipmentUseCase.execute(orderId, customerId), - qualifyFailure("SHIPMENT_CREATION_FAILED", { message: "Shipment creation failed" }), + qualifyFailure("SHIPMENT_CREATION_FAILED", { + expected: Error, + message: "Shipment creation failed", + }), ), refundPayment: (transactionId) => fromPromise( refundPaymentUseCase.execute(transactionId), - qualifyFailure("REFUND_FAILED", { message: "Refund failed" }), + qualifyFailure("REFUND_FAILED", { expected: Error, message: "Refund failed" }), ), }, }, diff --git a/examples/order-processing-worker/src/application/workflows.ts b/examples/order-processing-worker/src/application/workflows.ts index 699c1ce8..a07a9121 100644 --- a/examples/order-processing-worker/src/application/workflows.ts +++ b/examples/order-processing-worker/src/application/workflows.ts @@ -22,8 +22,8 @@ const APPROVAL_TIMEOUT = "5 minutes"; /** * Process Order Workflow Implementation * - * - Signal/query handlers are registered up front via `context.defineSignal` - * / `context.defineQuery`, closing over plain workflow-local state — the + * - Signal/query handlers are registered up front via `context.handleSignal` + * / `context.handleQuery`, closing over plain workflow-local state — the * deterministic way to expose interactive state. * - Activities use unthrown's `AsyncResult` in their implementation * (domain + infrastructure). `processPayment` declares a contract error, @@ -70,7 +70,7 @@ export const processOrder = declareWorkflow({ let cancelRequested = false; // Payload-carrying signal — the handler receives the schema-parsed input. - context.defineSignal("approveOrder", (approval) => { + context.handleSignal("approveOrder", (approval) => { approvedBy = approval.approvedBy; log.info( `Order ${order.orderId} approved by ${approval.approvedBy}` + @@ -79,7 +79,7 @@ export const processOrder = declareWorkflow({ }); // Payload-less signal (`defineSignal()` in the contract) — no arguments. - context.defineSignal("cancelRequested", () => { + context.handleSignal("cancelRequested", () => { cancelRequested = true; log.info(`Cancellation requested for order ${order.orderId}`); }); @@ -87,7 +87,7 @@ export const processOrder = declareWorkflow({ // Argument-less query — must stay synchronous and side-effect free. // The optional field is spread in (never set to `undefined`) to satisfy // exactOptionalPropertyTypes. - context.defineQuery("getOrderStatus", () => ({ + context.handleQuery("getOrderStatus", () => ({ status, ...(approvedBy !== undefined ? { approvedBy } : {}), })); diff --git a/packages/contract/src/errors.spec.ts b/packages/contract/src/errors.spec.ts index 7449eff7..33575a07 100644 --- a/packages/contract/src/errors.spec.ts +++ b/packages/contract/src/errors.spec.ts @@ -142,7 +142,7 @@ describe("_internal_rehydrateContractError", () => { }); it("does NOT rehydrate a data-less name without the marker (false-positive regression)", async () => { - // A foreign ApplicationFailure — e.g. produced by qualifyFailure("OutOfStock") + // A foreign ApplicationFailure — e.g. produced by qualifyFailure("OutOfStock", { expected: Error }) // or any handwritten `ApplicationFailure.create({ type: "OutOfStock" })` — // must not surface as the typed domain error just because the `type` // string matches a declared data-less error. diff --git a/packages/worker/src/__tests__/inprocess.contract.ts b/packages/worker/src/__tests__/inprocess.contract.ts index cd37f80a..4ab79737 100644 --- a/packages/worker/src/__tests__/inprocess.contract.ts +++ b/packages/worker/src/__tests__/inprocess.contract.ts @@ -29,7 +29,18 @@ const placeOrder = defineWorkflow({ activities: { charge }, }); +/** + * Workflow used by the cancellation-outcome integration spec: it blocks + * inside a cancellable scope and re-raises cancellation via + * `rethrowCancellation`, so the execution must end `Cancelled` (not + * `Completed`). + */ +const waitForever = defineWorkflow({ + input: z.object({}), + output: z.object({ status: z.string() }), +}); + export const inprocessContract = defineContract({ taskQueue: "inprocess-tests", - workflows: { placeOrder }, + workflows: { placeOrder, waitForever }, }); diff --git a/packages/worker/src/__tests__/inprocess.workflows.ts b/packages/worker/src/__tests__/inprocess.workflows.ts index 28cd0d47..903d6429 100644 --- a/packages/worker/src/__tests__/inprocess.workflows.ts +++ b/packages/worker/src/__tests__/inprocess.workflows.ts @@ -1,6 +1,6 @@ -import { sleep } from "@temporalio/workflow"; +import { condition, sleep } from "@temporalio/workflow"; -import { ContractError, declareWorkflow } from "../workflow.js"; +import { ContractError, declareWorkflow, rethrowCancellation } from "../workflow.js"; import { inprocessContract } from "./inprocess.contract.js"; export const placeOrder = declareWorkflow({ @@ -34,3 +34,30 @@ export const placeOrder = declareWorkflow({ return { status: `charged:${charged.value.transactionId}` }; }, }); + +export const waitForever = declareWorkflow({ + workflowName: "waitForever", + contract: inprocessContract, + implementation: async (context, _args) => { + // Block until cancelled: `condition(() => false)` never resolves on its + // own (even under time skipping), so the only way out of the scope is a + // cancellation request. + const result = await context.cancellableScope(async () => { + await condition(() => false); + return "unreachable"; + }); + + if (result.isDefect()) { + // An unmodeled bug — rethrow the original cause at the edge. + // oxlint-disable-next-line unthrown/no-throw -- defect-cause rethrow at the edge: an unmodeled failure must surface as a Workflow Task failure + throw result.cause; + } + if (result.isErr()) { + // Honor the cancellation: without this re-raise the workflow would + // return normally and end `Completed` instead of `Cancelled`. + rethrowCancellation(result.error); + } + + return { status: "completed" }; + }, +}); diff --git a/packages/worker/src/__tests__/registration-complete.workflows.ts b/packages/worker/src/__tests__/registration-complete.workflows.ts new file mode 100644 index 00000000..ad2759df --- /dev/null +++ b/packages/worker/src/__tests__/registration-complete.workflows.ts @@ -0,0 +1,16 @@ +import { declareWorkflow } from "../workflow.js"; +import { registrationContract } from "./registration.contract.js"; + +/** Every contract workflow exported under its declared name — the happy path. */ + +export const alpha = declareWorkflow({ + workflowName: "alpha", + contract: registrationContract, + implementation: async (_context, args) => ({ result: args.value }), +}); + +export const beta = declareWorkflow({ + workflowName: "beta", + contract: registrationContract, + implementation: async (_context, args) => ({ doubled: args.n * 2 }), +}); diff --git a/packages/worker/src/__tests__/registration-mismatch.workflows.ts b/packages/worker/src/__tests__/registration-mismatch.workflows.ts new file mode 100644 index 00000000..b1b4ad8d --- /dev/null +++ b/packages/worker/src/__tests__/registration-mismatch.workflows.ts @@ -0,0 +1,20 @@ +import { declareWorkflow } from "../workflow.js"; +import { registrationContract } from "./registration.contract.js"; + +/** + * `alpha` is declared correctly but exported under a different name — + * Temporal registers workflows by EXPORT name, so this would register the + * workflow as type "renamedAlpha" and leave "alpha" unregistered. + */ + +export const renamedAlpha = declareWorkflow({ + workflowName: "alpha", + contract: registrationContract, + implementation: async (_context, args) => ({ result: args.value }), +}); + +export const beta = declareWorkflow({ + workflowName: "beta", + contract: registrationContract, + implementation: async (_context, args) => ({ doubled: args.n * 2 }), +}); diff --git a/packages/worker/src/__tests__/registration-missing.workflows.ts b/packages/worker/src/__tests__/registration-missing.workflows.ts new file mode 100644 index 00000000..56fdfe5c --- /dev/null +++ b/packages/worker/src/__tests__/registration-missing.workflows.ts @@ -0,0 +1,10 @@ +import { declareWorkflow } from "../workflow.js"; +import { registrationContract } from "./registration.contract.js"; + +/** `beta` is declared on the contract but never `declareWorkflow`-exported. */ + +export const alpha = declareWorkflow({ + workflowName: "alpha", + contract: registrationContract, + implementation: async (_context, args) => ({ result: args.value }), +}); diff --git a/packages/worker/src/__tests__/registration-raw.workflows.ts b/packages/worker/src/__tests__/registration-raw.workflows.ts new file mode 100644 index 00000000..c02724a7 --- /dev/null +++ b/packages/worker/src/__tests__/registration-raw.workflows.ts @@ -0,0 +1,19 @@ +import { declareWorkflow } from "../workflow.js"; +import { registrationContract } from "./registration.contract.js"; + +/** + * `alpha` uses `declareWorkflow`; `beta` is a raw + * `@temporalio/workflow`-style function exported under its contract name — + * a supported pattern the registration check must accept (Temporal + * registers by export name, so it registers correctly). + */ + +export const alpha = declareWorkflow({ + workflowName: "alpha", + contract: registrationContract, + implementation: async (_context, args) => ({ result: args.value }), +}); + +export async function beta(args: { n: number }): Promise<{ doubled: number }> { + return { doubled: args.n * 2 }; +} diff --git a/packages/worker/src/__tests__/registration.contract.ts b/packages/worker/src/__tests__/registration.contract.ts new file mode 100644 index 00000000..8a6b9969 --- /dev/null +++ b/packages/worker/src/__tests__/registration.contract.ts @@ -0,0 +1,23 @@ +import { defineContract, defineWorkflow } from "@temporal-contract/contract"; +import { z } from "zod"; + +/** + * Activity-less contract used by the workflow-registration-check unit specs + * (`../worker.spec.ts`). Two workflows so the specs can cover "one missing" + * and "one mismatched" independently. + */ + +const alpha = defineWorkflow({ + input: z.object({ value: z.string() }), + output: z.object({ result: z.string() }), +}); + +const beta = defineWorkflow({ + input: z.object({ n: z.number() }), + output: z.object({ doubled: z.number() }), +}); + +export const registrationContract = defineContract({ + taskQueue: "registration-check-queue", + workflows: { alpha, beta }, +}); diff --git a/packages/worker/src/__tests__/test.workflows.ts b/packages/worker/src/__tests__/test.workflows.ts index 9153b776..7e452c84 100644 --- a/packages/worker/src/__tests__/test.workflows.ts +++ b/packages/worker/src/__tests__/test.workflows.ts @@ -71,21 +71,21 @@ export const workflowWithActivities = declareWorkflow({ export const interactiveWorkflow = declareWorkflow({ workflowName: "interactiveWorkflow", contract: testContract, - implementation: async ({ defineSignal, defineQuery, defineUpdate }, args) => { + implementation: async ({ handleSignal, handleQuery, handleUpdate }, args) => { let currentValue = 0; currentValue = args.initialValue; - // Define signal, query, and update handlers with access to workflow state - defineSignal("increment", async (signalArgs) => { + // Bind signal, query, and update handlers with access to workflow state + handleSignal("increment", async (signalArgs) => { currentValue += signalArgs.amount; }); - defineQuery("getCurrentValue", () => { + handleQuery("getCurrentValue", () => { return { value: currentValue }; }); - defineUpdate("multiply", async (updateArgs) => { + handleUpdate("multiply", async (updateArgs) => { currentValue *= updateArgs.factor; return { newValue: currentValue }; }); diff --git a/packages/worker/src/__tests__/time-skipping.inprocess.spec.ts b/packages/worker/src/__tests__/time-skipping.inprocess.spec.ts index 9ec6ce6c..f24751e7 100644 --- a/packages/worker/src/__tests__/time-skipping.inprocess.spec.ts +++ b/packages/worker/src/__tests__/time-skipping.inprocess.spec.ts @@ -135,6 +135,46 @@ describe("time-skipping TestWorkflowEnvironment", () => { ]); }); + it("a workflow that re-raises cancellation via rethrowCancellation ends Cancelled", async ({ + testEnv, + }) => { + const workerResult = await TypedWorker.create({ + contract: inprocessContract, + connection: testEnv.nativeConnection, + workflowsPath: workflowPath("inprocess.workflows"), + activities, + }); + expect(workerResult.isOk()).toBe(true); + if (!workerResult.isOk()) return; + const worker = workerResult.value; + + const clientResult = await TypedClient.create({ client: testEnv.client }); + expect(clientResult.isOk()).toBe(true); + if (!clientResult.isOk()) return; + const client = clientResult.value.for(inprocessContract); + + await worker.raw.runUntil(async () => { + const workflowId = "inprocess-cancelled-outcome"; + const started = await client.startWorkflow("waitForever", { + workflowId, + args: {}, + }); + expect(started.isOk()).toBe(true); + if (!started.isOk()) return; + + const cancelResult = await started.value.cancel(); + expect(cancelResult.isOk()).toBe(true); + + // Await completion via the raw handle (the result rejects with the + // cancellation failure — that rejection is the point), then assert the + // terminal status Temporal recorded. + const rawHandle = testEnv.client.workflow.getHandle(workflowId); + await rawHandle.result().catch(() => undefined); + const description = await rawHandle.describe(); + expect(description.status.name).toBe("CANCELLED"); + }); + }); + it("surfaces worker bundling failures on the defect channel", async ({ testEnv }) => { const workerResult = await TypedWorker.create({ contract: inprocessContract, diff --git a/packages/worker/src/activity.spec.ts b/packages/worker/src/activity.spec.ts index 926b8a02..da64c454 100644 --- a/packages/worker/src/activity.spec.ts +++ b/packages/worker/src/activity.spec.ts @@ -420,6 +420,109 @@ describe("Worker unthrown Package", () => { ); }); + describe("shared activity definitions (flat-namespace duplicate handling)", () => { + // `defineContract` permits one `defineActivity` object to be referenced + // from several scopes — it's ONE activity in Temporal's flat runtime + // namespace. The handler must not let one scope's implementation + // silently clobber another's (last-wins), and must dedupe when both + // scopes supply the exact same function reference. + const sharedDef = { + input: z.object({ id: z.string() }), + output: z.object({ ok: z.boolean() }), + }; + const sharedContract = { + taskQueue: "test-queue", + workflows: { + alpha: { + input: z.object({}), + output: z.object({}), + activities: { sharedActivity: sharedDef }, + }, + beta: { + input: z.object({}), + output: z.object({}), + activities: { sharedActivity: sharedDef }, + }, + }, + } satisfies ContractDefinition; + + it("throws at declaration time when two scopes supply different implementations", () => { + expect(() => { + declareActivitiesHandler({ + contract: sharedContract, + activities: { + alpha: { sharedActivity: (_args) => OkAsync({ ok: true }) }, + beta: { sharedActivity: (_args) => OkAsync({ ok: false }) }, + }, + }); + }).toThrow( + /activity "sharedActivity" received two different implementations — one from workflow "alpha" and one from workflow "beta"/, + ); + }); + + it("the conflict message explains both resolutions (hoist to global, or share the function)", () => { + expect(() => { + declareActivitiesHandler({ + contract: sharedContract, + activities: { + alpha: { sharedActivity: (_args) => OkAsync({ ok: true }) }, + beta: { sharedActivity: (_args) => OkAsync({ ok: false }) }, + }, + }); + }).toThrow( + /hoist the shared activity to the contract's global `activities` block|same implementation function reference/, + ); + }); + + it("dedupes silently when both scopes pass the exact same function reference", async () => { + const calls: unknown[] = []; + const shared = (args: { id: string }) => { + calls.push(args); + return OkAsync({ ok: true }); + }; + + const activities = declareActivitiesHandler({ + contract: sharedContract, + activities: { + alpha: { sharedActivity: shared }, + beta: { sharedActivity: shared }, + }, + }); + + // Exactly one flat registration, and it works. + expect(Object.keys(activities)).toEqual(["sharedActivity"]); + const result = await activities.sharedActivity({ id: "x" }); + expect(result).toEqual({ ok: true }); + expect(calls).toEqual([{ id: "x" }]); + }); + + it("conflicts between the global scope and a workflow scope are reported with both scope names", () => { + const globalSharedContract = { + taskQueue: "test-queue", + workflows: { + alpha: { + input: z.object({}), + output: z.object({}), + activities: { sharedActivity: sharedDef }, + }, + }, + activities: { sharedActivity: sharedDef }, + } satisfies ContractDefinition; + + expect(() => { + declareActivitiesHandler({ + contract: globalSharedContract, + activities: { + sharedActivity: (_args: { id: string }) => OkAsync({ ok: true }), + alpha: { sharedActivity: (_args: { id: string }) => OkAsync({ ok: false }) }, + }, + }); + }).toThrow( + /activity "sharedActivity" received two different implementations — one from the global scope and one from workflow "alpha"/, + ); + }); + }); + it("throws on a workflow-name/global-activity-name collision (defense-in-depth)", () => { // `defineContract` rejects this shape too; the handler re-checks for // contracts built as raw literals that never went through it. @@ -572,76 +675,167 @@ describe("Worker unthrown Package", () => { }); describe("qualifyFailure", () => { - it("wraps an Error rejection in an ApplicationFailure with the given type", () => { + // Stand-in for unthrown's injected `defect` helper: the tests only need + // to observe that the qualifier routed the cause to the defect channel. + const defectMarker = (cause: unknown) => ({ __defect: cause }); + class GatewayError extends Error {} + class OtherGatewayError extends Error {} + + it("wraps a rejection matching an expected error class in an ApplicationFailure of the given type", () => { // GIVEN - const cause = new Error("connection refused"); + const cause = new GatewayError("connection refused"); // WHEN - const failure = qualifyFailure("EMAIL_SEND_FAILED")(cause); + const outcome = qualifyFailure("EMAIL_SEND_FAILED", { expected: GatewayError })( + cause, + defectMarker, + ); // THEN - expect(failure).toBeInstanceOf(ApplicationFailure); + expect(outcome).toBeInstanceOf(ApplicationFailure); + const failure = outcome as ApplicationFailure; expect(failure.type).toBe("EMAIL_SEND_FAILED"); expect(failure.message).toBe("connection refused"); expect(failure.cause).toBe(cause); expect(failure.nonRetryable).toBeFalsy(); }); - it("uses the fallback message for a non-Error rejection", () => { + it("routes an unexpected cause (e.g. a TypeError from a bug) to the defect channel", () => { + // GIVEN — a TypeError is not in the modeled failure set. + const bug = new TypeError("undefined is not a function"); + // WHEN - const failure = qualifyFailure("PAYMENT_FAILED", { message: "Failed to charge card" })( - "boom", + const outcome = qualifyFailure("EMAIL_SEND_FAILED", { expected: GatewayError })( + bug, + defectMarker, ); - // THEN - expect(failure.type).toBe("PAYMENT_FAILED"); - expect(failure.message).toBe("Failed to charge card"); - expect(failure.cause).toBeUndefined(); + // THEN — the qualifier returned the injected defect, cause untouched. + expect(outcome).toEqual({ __defect: bug }); }); - it("stringifies a non-Error rejection when no fallback message is given", () => { - // WHEN - const failure = qualifyFailure("PAYMENT_FAILED")({ code: 42 }); + it("accepts an array of expected classes (any match wraps)", () => { + const qualify = qualifyFailure("GATEWAY_FAILED", { + expected: [GatewayError, OtherGatewayError], + }); - // THEN - expect(failure.message).toBe("[object Object]"); - expect(failure.cause).toBeUndefined(); + expect(qualify(new OtherGatewayError("down"), defectMarker)).toBeInstanceOf( + ApplicationFailure, + ); + expect(qualify(new RangeError("nope"), defectMarker)).toEqual({ + __defect: expect.any(RangeError), + }); + }); + + it("accepts a predicate for non-class causes", () => { + const qualify = qualifyFailure("PAYMENT_FAILED", { + expected: (cause) => typeof cause === "object" && cause !== null && "code" in cause, + message: "Failed to charge card", + }); + + const wrapped = qualify({ code: 42 }, defectMarker); + expect(wrapped).toBeInstanceOf(ApplicationFailure); + expect((wrapped as ApplicationFailure).message).toBe("Failed to charge card"); + expect((wrapped as ApplicationFailure).cause).toBeUndefined(); + + expect(qualify("boom", defectMarker)).toEqual({ __defect: "boom" }); + }); + + it("expected: 'any' wraps every rejection (the explicit escape hatch)", () => { + const qualify = qualifyFailure("PAYMENT_FAILED", { expected: "any" }); + + const fromError = qualify(new TypeError("bug"), defectMarker); + expect(fromError).toBeInstanceOf(ApplicationFailure); + expect((fromError as ApplicationFailure).message).toBe("bug"); + + const fromValue = qualify({ code: 42 }, defectMarker); + expect(fromValue).toBeInstanceOf(ApplicationFailure); + // No fallback message given — non-Error causes stringify. + expect((fromValue as ApplicationFailure).message).toBe("[object Object]"); }); it("prefers the rejection's own message over the fallback for Error rejections", () => { - // WHEN - const failure = qualifyFailure("PAYMENT_FAILED", { message: "fallback" })( + const outcome = qualifyFailure("PAYMENT_FAILED", { expected: Error, message: "fallback" })( new Error("declined"), + defectMarker, ); - // THEN - expect(failure.message).toBe("declined"); + expect((outcome as ApplicationFailure).message).toBe("declined"); }); it("forwards nonRetryable and details to the failure", () => { - // WHEN - const failure = qualifyFailure("INSUFFICIENT_FUNDS", { + const outcome = qualifyFailure("INSUFFICIENT_FUNDS", { + expected: Error, nonRetryable: true, details: [{ balance: 0 }], - })(new Error("declined")); + })(new Error("declined"), defectMarker); - // THEN + const failure = outcome as ApplicationFailure; expect(failure.nonRetryable).toBe(true); expect(failure.details).toEqual([{ balance: 0 }]); }); - it("always wraps — even an ApplicationFailure rejection — so the declared type is guaranteed", () => { + it("always wraps a matched ApplicationFailure so the declared type is guaranteed", () => { // GIVEN const inner = ApplicationFailure.create({ type: "INNER", message: "already modeled" }); // WHEN - const failure = qualifyFailure("OUTER")(inner); + const outcome = qualifyFailure("OUTER", { expected: ApplicationFailure })( + inner, + defectMarker, + ); // THEN — callers can rely on `type` for retry policies; the original // failure is preserved as `cause`. + const failure = outcome as ApplicationFailure; expect(failure.type).toBe("OUTER"); expect(failure.message).toBe("already modeled"); expect(failure.cause).toBe(inner); }); + + it("inherits nonRetryable: true from a matched non-retryable ApplicationFailure by default", () => { + // GIVEN — an inner permanent failure. Pre-v8, re-typing it silently + // made it retryable again; now the wrapper inherits non-retryability. + const inner = ApplicationFailure.create({ + type: "INNER", + message: "permanent", + nonRetryable: true, + }); + + // WHEN — no explicit `nonRetryable` option. + const outcome = qualifyFailure("OUTER", { expected: ApplicationFailure })( + inner, + defectMarker, + ); + + // THEN + expect((outcome as ApplicationFailure).nonRetryable).toBe(true); + }); + + it("an explicit nonRetryable option overrides the inherited value", () => { + const inner = ApplicationFailure.create({ + type: "INNER", + message: "permanent", + nonRetryable: true, + }); + + const outcome = qualifyFailure("OUTER", { + expected: ApplicationFailure, + nonRetryable: false, + })(inner, defectMarker); + + expect((outcome as ApplicationFailure).nonRetryable).toBe(false); + }); + + it("a matched retryable ApplicationFailure stays retryable", () => { + const inner = ApplicationFailure.create({ type: "INNER", message: "transient" }); + + const outcome = qualifyFailure("OUTER", { expected: ApplicationFailure })( + inner, + defectMarker, + ); + + expect((outcome as ApplicationFailure).nonRetryable).toBeFalsy(); + }); }); }); diff --git a/packages/worker/src/activity.ts b/packages/worker/src/activity.ts index b884c403..d40c40c7 100644 --- a/packages/worker/src/activity.ts +++ b/packages/worker/src/activity.ts @@ -23,7 +23,7 @@ import { } from "@temporal-contract/contract"; import { _internal_buildErrorConstructors, - ContractError, + CONTRACT_ERROR_TAG, type AnyContractError, type ContractErrorConstructors, type ContractErrorInputUnion, @@ -52,6 +52,19 @@ export { // a separate `@temporalio/common` import to construct one. export { ApplicationFailure } from "@temporalio/common"; +// Literal-typed `_tag` constants for this package's tagged errors, so +// consumers can `P.tag(ACTIVITY_ERROR_TAG)` without hand-writing the +// namespaced strings (mirrors the contract package's error-tags module). +export { + ACTIVITY_CANCELLED_ERROR_TAG, + ACTIVITY_DEFINITION_NOT_FOUND_ERROR_TAG, + ACTIVITY_ERROR_TAG, + CHILD_WORKFLOW_CANCELLED_ERROR_TAG, + CHILD_WORKFLOW_ERROR_TAG, + CHILD_WORKFLOW_NOT_FOUND_ERROR_TAG, + WORKFLOW_CANCELLED_ERROR_TAG, +} from "./error-tags.js"; + // Re-export the typed contract-error surface so implementations can // `instanceof`-check and type against it without a separate // `@temporal-contract/contract/errors` import. @@ -63,13 +76,94 @@ export { } from "@temporal-contract/contract/errors"; /** - * Build a qualifier for `fromPromise` that wraps a rejection in an - * {@link ApplicationFailure} of the given `type`. + * An error-class constructor accepted by {@link QualifyFailureOptions.expected}. + * `never[]` parameters make every concrete constructor assignable; the + * `Error` return keeps the slot honest (only error classes belong here). + */ +type ErrorClass = abstract new (...args: never[]) => Error; + +/** + * Options for {@link qualifyFailure}. `expected` is **required** — it is the + * triage decision that separates modeled failures from defects. + */ +export type QualifyFailureOptions = { + /** + * Which rejection causes are *anticipated* and should be wrapped into the + * modeled {@link ApplicationFailure}: + * + * - an error-class constructor (matched with `instanceof`), + * - an array of error-class constructors (any match wraps), + * - a predicate `(cause: unknown) => boolean`, + * - the literal `"any"` — a deliberate, greppable escape hatch that wraps + * every rejection (the pre-v8 blanket behavior). + * + * Anything that doesn't match rides unthrown's **defect** channel instead: + * an unanticipated throw (a `TypeError` from a bug, an assertion failure) + * is not a domain outcome, and blanket-wrapping it would disguise the bug + * as the declared failure `type` and subject it to that type's retry + * semantics. + */ + expected: ErrorClass | readonly ErrorClass[] | ((cause: unknown) => boolean) | "any"; + /** Fallback message when the rejection is not an `Error` (default: `String(error)`). */ + message?: string; + /** + * Mark the failure non-retryable — Temporal stops retrying immediately. + * When omitted, a matched cause that is itself an `ApplicationFailure` + * with `nonRetryable: true` propagates its non-retryability (see + * remarks); set `false` explicitly to force the wrapped failure retryable. + */ + nonRetryable?: boolean; + /** Structured payload forwarded to the workflow (avoids parsing `message`). */ + details?: unknown[]; +}; + +/** + * `true` when `expected` is an error-class constructor rather than a + * predicate. Both are functions at runtime; classes are recognized by their + * `Error`-derived prototype (predicates — arrow functions or plain functions + * — never have one). + */ +function isErrorClass( + expected: ErrorClass | ((cause: unknown) => boolean), +): expected is ErrorClass { + if ((expected as unknown) === Error) return true; + const proto: unknown = (expected as { prototype?: unknown }).prototype; + return typeof proto === "object" && proto !== null && proto instanceof Error; +} + +function matchesExpected(cause: unknown, expected: QualifyFailureOptions["expected"]): boolean { + if (expected === "any") return true; + if (Array.isArray(expected)) { + return (expected as readonly ErrorClass[]).some((cls) => cause instanceof cls); + } + const single = expected as ErrorClass | ((cause: unknown) => boolean); + if (isErrorClass(single)) { + return cause instanceof single; + } + return single(cause); +} + +/** + * Build a qualifier for `fromPromise` that **triages** each rejection: causes + * matching `options.expected` are wrapped in a modeled + * {@link ApplicationFailure} of the given `errorType`; everything else goes + * to unthrown's **defect** channel. * - * Replaces the hand-written wrapping every activity otherwise repeats: - * an `Error` rejection keeps its own message and is preserved as `cause` - * (so stack traces survive the activity → workflow boundary); anything - * else falls back to `options.message` (or `String(error)`). + * Triage philosophy: `fromPromise`'s qualify step exists to force a per-cause + * decision — *is this failure part of the activity's model, or a bug?* A + * qualifier that wraps everything erases that decision: a `TypeError` from a + * typo would surface as, say, `EMAIL_SEND_FAILED` and inherit its retry + * semantics, hiding the defect from operators and from the defect channel's + * fail-loud handling. `expected` is therefore **required**: name the failure + * classes (or predicate) you anticipate; let the rest stay defects that + * re-throw at the activity edge with their original cause. The literal + * `expected: "any"` remains as an explicit, greppable escape hatch for the + * old blanket behavior. + * + * For a matched `Error` cause, the wrapper keeps the cause's own message and + * preserves it as `cause` (so stack traces survive the activity → workflow + * boundary); a matched non-`Error` cause falls back to `options.message` (or + * `String(cause)`). * * @example * ```ts @@ -80,47 +174,58 @@ export { * contract: myContract, * activities: { * sendEmail: (args) => - * fromPromise(emailService.send(args), qualifyFailure('EMAIL_SEND_FAILED')) - * .map(() => ({ sent: true })), + * fromPromise( + * emailService.send(args), + * // Anticipated: the SDK's typed error. Anything else (TypeError, + * // assertion failure, ...) is a defect and re-throws at the edge. + * qualifyFailure('EMAIL_SEND_FAILED', { expected: EmailServiceError }), + * ).map(() => ({ sent: true })), * chargeCard: (args) => * fromPromise( * paymentGateway.charge(args), - * // Permanent failure: opt out of the configured retry policy. - * qualifyFailure('CARD_DECLINED', { nonRetryable: true }), + * qualifyFailure('CARD_DECLINED', { + * // Several anticipated classes; a predicate works too. + * expected: [CardDeclinedError, GatewayTimeoutError], + * // Permanent failure: opt out of the configured retry policy. + * nonRetryable: true, + * }), * ), * }, * }); * ``` * * @remarks - * The qualifier **always** wraps — even when the rejection is already an - * `ApplicationFailure` — so the resulting failure's `type` is guaranteed - * to be the declared one (retry policies keyed on - * `retry.nonRetryableErrorTypes` can rely on it). The original failure is - * preserved as `cause`. Note the flip side: because the wrapper's `type` and - * `nonRetryable` take precedence, an inner `ApplicationFailure`'s own - * `type`/`nonRetryable: true` is masked — pass `{ nonRetryable: true }` here (or - * write a custom qualifier) if that inner failure should stay non-retryable. + * A matched cause is **always** wrapped — even when it is already an + * `ApplicationFailure` — so the resulting failure's `type` is guaranteed to + * be the declared one (retry policies keyed on + * `retry.nonRetryableErrorTypes` can rely on it), with the original failure + * preserved as `cause`. Retryability of the wrapper: when + * `options.nonRetryable` is set it wins unconditionally; when it is omitted + * and the matched cause is an `ApplicationFailure` with `nonRetryable: true`, + * the wrapper inherits `nonRetryable: true` (a permanent inner failure no + * longer silently becomes retryable just because it was re-typed). */ export function qualifyFailure( - type: string, - options?: { - /** Fallback message when the rejection is not an `Error` (default: `String(error)`). */ - message?: string; - /** Mark the failure non-retryable — Temporal stops retrying immediately. */ - nonRetryable?: boolean; - /** Structured payload forwarded to the workflow (avoids parsing `message`). */ - details?: unknown[]; - }, -): (error: unknown) => ApplicationFailure { - return (error) => - ApplicationFailure.create({ - type, - message: error instanceof Error ? error.message : (options?.message ?? String(error)), - ...(error instanceof Error ? { cause: error } : {}), - ...(options?.nonRetryable !== undefined ? { nonRetryable: options.nonRetryable } : {}), - ...(options?.details !== undefined ? { details: options.details } : {}), + errorType: string, + options: QualifyFailureOptions, +): (cause: unknown, defect: (cause: unknown) => TDefect) => ApplicationFailure | TDefect { + return (cause, defect) => { + if (!matchesExpected(cause, options.expected)) { + return defect(cause); + } + // `nonRetryable` precedence: explicit option > inherited from a matched + // non-retryable ApplicationFailure cause > Temporal default (retryable). + const inheritedNonRetryable = + cause instanceof ApplicationFailure && cause.nonRetryable === true ? true : undefined; + const nonRetryable = options.nonRetryable ?? inheritedNonRetryable; + return ApplicationFailure.create({ + type: errorType, + message: cause instanceof Error ? cause.message : (options.message ?? String(cause)), + ...(cause instanceof Error ? { cause } : {}), + ...(nonRetryable !== undefined ? { nonRetryable } : {}), + ...(options.details !== undefined ? { details: options.details } : {}), }); + }; } /** @@ -259,13 +364,89 @@ type ResultActivitiesImplementations< [K in keyof TActivities]: ResultActivityImplementation; }; +/** + * The correctly-typed implementation function for one **workflow-local** + * activity of a contract. Lets a standalone implementation typecheck outside + * the `declareActivitiesHandler` call so it can live in its own module (with + * precise `args`/`helpers` inference) and be assigned into the nested + * implementations map later: + * + * @example + * ```ts + * const validateOrder: ActivityImplementationFor< + * typeof myContract, + * "orderWorkflow", + * "validateOrder" + * > = (args, { errors }) => + * args.orderId ? OkAsync({ valid: true }) : ErrAsync(errors.EmptyOrder({})); + * + * declareActivitiesHandler({ + * contract: myContract, + * activities: { orderWorkflow: { validateOrder } }, + * }); + * ``` + * + * The optional `TContext` parameter mirrors the handler's injected context + * (`createContext` seed + middleware accumulation); leave it defaulted when + * the implementation doesn't read `helpers.context`. + * + * See {@link GlobalActivityImplementationFor} for the contract-global + * variant. + */ +export type ActivityImplementationFor< + TContract extends ContractDefinition, + TWorkflowName extends keyof TContract["workflows"] & string, + TActivityName extends keyof TContract["workflows"][TWorkflowName]["activities"] & string, + TContext extends Record | EmptyContext = EmptyContext, +> = + TContract["workflows"][TWorkflowName]["activities"] extends Record + ? ResultActivityImplementation< + TContract["workflows"][TWorkflowName]["activities"][TActivityName], + TContext + > + : never; + +/** + * The correctly-typed implementation function for one **global** activity of + * a contract — the `contract.activities`-scoped sibling of + * {@link ActivityImplementationFor}. + * + * @example + * ```ts + * const sendEmail: GlobalActivityImplementationFor = + * (args) => OkAsync({ sent: true }); + * ``` + */ +export type GlobalActivityImplementationFor< + TContract extends ContractDefinition, + TActivityName extends keyof TContract["activities"] & string, + TContext extends Record | EmptyContext = EmptyContext, +> = + TContract["activities"] extends Record + ? ResultActivityImplementation + : never; + /** * Per-invocation description handed to middleware and `createContext`. */ export type ActivityInvocationInfo = { /** Flat runtime name of the activity (as Temporal sees it). */ readonly activityName: string; - /** Owning workflow for workflow-local activities; `undefined` for global ones. */ + /** + * Owning workflow for workflow-local activities; `undefined` for global + * ones. + * + * **Shared-definition caveat:** `workflowName` identifies the scope the + * implementation was *registered under*, not the workflow that is calling + * right now (Temporal's flat activity namespace erases the caller). When + * one `defineActivity` object is referenced from several scopes and + * implemented with the same function reference, the activity registers + * once under the first scope encountered — global first, then the + * contract's workflow declaration order — and every invocation reports + * that scope's `workflowName`. To know the actual calling workflow inside + * an activity, read `Context.current().info.workflowType` from + * `@temporalio/activity`. + */ readonly workflowName: string | undefined; }; @@ -516,9 +697,9 @@ export function composeActivityMiddleware( } /** - * Options for creating activities handler + * Options for {@link declareActivitiesHandler}. */ -type DeclareActivitiesHandlerOptions< +export type DeclareActivitiesHandlerOptions< TContract extends ContractDefinition, TContext extends Record | EmptyContext = EmptyContext, TInjected extends TContext = TContext, @@ -798,22 +979,19 @@ export function declareActivitiesHandler< // validated against their declared data schema and serialized as // ApplicationFailure(type = error name, details = [data]). errCases: (matcher) => - matcher.with( - P.instanceOf(ApplicationFailure), - P.tag("@temporal-contract/ContractError"), - async (error) => { - if (error instanceof ContractError) { - // oxlint-disable-next-line unthrown/no-throw -- sanctioned ApplicationFailure model: the Err payload is thrown at the activity boundary so Temporal applies its retry policy (CLAUDE.md rule 2 exception) - throw await contractErrorToApplicationFailure( - error, - activityDef.errors, - `activity "${label}"`, - ); - } + matcher + .with(P.tag(CONTRACT_ERROR_TAG), async (error) => { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ApplicationFailure model: the Err payload is thrown at the activity boundary so Temporal applies its retry policy (CLAUDE.md rule 2 exception) + throw await contractErrorToApplicationFailure( + error, + activityDef.errors, + `activity "${label}"`, + ); + }) + .with(P.instanceOf(ApplicationFailure), (error) => { // oxlint-disable-next-line unthrown/no-throw -- sanctioned ApplicationFailure model: the Err payload is thrown at the activity boundary so Temporal applies its retry policy (CLAUDE.md rule 2 exception) throw error; - }, - ), + }), // A defect is an *unanticipated* throw inside the activity. Re-throw the // original cause unwrapped: Temporal wraps a non-`ApplicationFailure` // error as `ApplicationFailure(type: "Error")` and applies the default @@ -862,6 +1040,43 @@ export function declareActivitiesHandler< // registered" failure the first time Temporal dispatches a task for it. const missingImplementations: string[] = []; + // Flat-namespace bookkeeping: `defineContract` allows one `defineActivity` + // object to be referenced from several scopes (it's one activity), so the + // same flat name can legitimately appear here more than once. What must + // NOT happen is a silent last-wins overwrite of a *different* function — + // one scope's implementation would clobber the other's. Track who + // registered each flat name (and with which raw implementation) so a + // duplicate either dedupes (same function reference — first registration + // wins, see the `ActivityInvocationInfo.workflowName` caveat) or throws. + const registrations = new Map(); + + /** + * Returns `true` when the caller should register `impl` under + * `activityName`, `false` when an identical registration already exists + * (silent dedupe). Throws on a conflicting duplicate. + */ + function shouldRegister(activityName: string, scopeLabel: string, impl: unknown): boolean { + const existing = registrations.get(activityName); + if (!existing) { + registrations.set(activityName, { scopeLabel, impl }); + return true; + } + if (existing.impl === impl) { + // Same function reference from another scope — one activity, one + // implementation. Keep the first registration. + return false; + } + // oxlint-disable-next-line unthrown/no-throw -- declaration-time fail-fast config error: worker startup must abort instead of silently clobbering a shared activity implementation + throw new Error( + `declareActivitiesHandler: activity "${activityName}" received two different implementations — ` + + `one from ${existing.scopeLabel} and one from ${scopeLabel}. Activities share a single flat ` + + `namespace at runtime, so the second implementation would silently replace the first. ` + + `Either hoist the shared activity to the contract's global \`activities\` block and implement ` + + `it once at the root level, or pass the exact same implementation function reference from ` + + `every scope that declares it.`, + ); + } + // 1) Global activities declared under contract.activities. if (contract.activities) { for (const [activityName, activityDef] of Object.entries(contract.activities)) { @@ -872,12 +1087,14 @@ export function declareActivitiesHandler< } // Assign wrapped global activity - (wrappedActivities as Record)[activityName] = makeWrapped( - activityName, - { activityName, workflowName: undefined }, - activityDef, - impl as ErasedImplementation, - ); + if (shouldRegister(activityName, "the global scope", impl)) { + (wrappedActivities as Record)[activityName] = makeWrapped( + activityName, + { activityName, workflowName: undefined }, + activityDef, + impl as ErasedImplementation, + ); + } } } @@ -893,13 +1110,17 @@ export function declareActivitiesHandler< continue; } - // Assign workflow activity directly at root level (flat structure) - (wrappedActivities as Record)[activityName] = makeWrapped( - `${workflowName}.${activityName}`, - { activityName, workflowName }, - activityDef, - impl as ErasedImplementation, - ); + // Assign workflow activity directly at root level (flat structure). + // `shouldRegister` dedupes a shared-definition re-registration (same + // function reference) and throws on a conflicting one. + if (shouldRegister(activityName, `workflow "${workflowName}"`, impl)) { + (wrappedActivities as Record)[activityName] = makeWrapped( + `${workflowName}.${activityName}`, + { activityName, workflowName }, + activityDef, + impl as ErasedImplementation, + ); + } } // Stray keys inside a workflow namespace: implementations for activities diff --git a/packages/worker/src/child-workflow.spec.ts b/packages/worker/src/child-workflow.spec.ts index ad1b7cb7..4612fcf1 100644 --- a/packages/worker/src/child-workflow.spec.ts +++ b/packages/worker/src/child-workflow.spec.ts @@ -57,6 +57,8 @@ describe("classifyChildWorkflowError", () => { // not have to peel `ChildWorkflowFailure → ApplicationFailure` themselves. expect(surfaced.cause).toBe(inner); expect(surfaced.cause).not.toBe(wrapper); + // Structured field (v8): no message parsing needed for the child name. + expect(surfaced.workflowName).toBe("processPayment"); expect(surfaced.message).toContain(`"processPayment"`); expect(surfaced.message).toContain("card declined"); }); @@ -148,6 +150,7 @@ describe("classifyChildWorkflowError", () => { expect(result).not.toBeInstanceOf(ChildWorkflowCancelledError); expect(result).not.toBeInstanceOf(ChildWorkflowNotFoundError); expect((result as ChildWorkflowError).cause).toBe(raw); + expect((result as ChildWorkflowError).workflowName).toBe("anyChild"); expect((result as ChildWorkflowError).message).toContain("network hiccup"); }); diff --git a/packages/worker/src/child-workflow.ts b/packages/worker/src/child-workflow.ts index e66ea088..0c1806c0 100644 --- a/packages/worker/src/child-workflow.ts +++ b/packages/worker/src/child-workflow.ts @@ -108,6 +108,7 @@ function validateChildWorkflowOutput( if (inputResult.issues) { return Err( new ChildWorkflowError( + childWorkflowName, `Child workflow "${childWorkflowName}" signal "${signalName}" input validation failed: ${summarizeIssues(inputResult.issues)}`, ), ); diff --git a/packages/worker/src/continue-as-new.spec.ts b/packages/worker/src/continue-as-new.spec.ts index 3bfd536b..2a6a1974 100644 --- a/packages/worker/src/continue-as-new.spec.ts +++ b/packages/worker/src/continue-as-new.spec.ts @@ -187,20 +187,24 @@ describe("context.continueAsNew", () => { }); }); - it("user options can override workflowType at runtime (boundary noted)", async () => { - // `WorkflowContext.continueAsNew` Omits `workflowType` and `taskQueue` from - // its options type, so typed call sites can't get here. The runtime is - // permissive — user options spread last — which lets power users override - // if they bypass the type. This test documents that boundary. + it("ignores a smuggled workflowType/taskQueue override (validated target wins)", async () => { + // `WorkflowContext.continueAsNew` Omits `workflowType` and `taskQueue` + // from its options type, so typed call sites can't pass them. The + // runtime backs the type up: user options are spread FIRST and the + // validated destination is set last, so even an `as never` bypass can't + // reroute the (already-validated) args to a different workflow type or + // task queue. const continueAsNew = createContinueAsNew(contract, "counter"); await expect( continueAsNew({ n: 1 }, { workflowType: "evil", - } as unknown as Parameters[1]), - ).rejects.toThrow(); + taskQueue: "evil-queue", + } as never), + ).rejects.toThrow("__STUB_CONTINUE_AS_NEW__"); - expect(captured[0]?.options.workflowType).toBe("evil"); + expect(captured[0]?.options.workflowType).toBe("counter"); + expect(captured[0]?.options.taskQueue).toBe("tq-current"); }); describe("cross-contract dispatch heuristic", () => { diff --git a/packages/worker/src/error-tags.ts b/packages/worker/src/error-tags.ts new file mode 100644 index 00000000..bb75cb50 --- /dev/null +++ b/packages/worker/src/error-tags.ts @@ -0,0 +1,40 @@ +/** + * Named constants for the unthrown `_tag` literals of this package's tagged + * errors, so consumers can match without hand-writing the namespaced strings + * (mirrors `@temporal-contract/contract`'s `error-tags.ts`): + * + * ```ts + * result.mapErrCases((matcher) => + * matcher.with(P.tag(ACTIVITY_ERROR_TAG), (e) => e.activityName), + * ); + * ``` + * + * Kept in a standalone, dependency-free module (no `unthrown` import) so the + * constants stay importable without pulling in any runtime machinery. + * + * Note: the worker's `ValidationError` subclasses (input/output validation, + * `ContractMisuseError`) are `ApplicationFailure`s, not tagged errors — they + * are discriminated by `failure.type`, not `_tag`, and have no constant here. + */ + +/** `_tag` of `ActivityError` — an errors-declaring activity failed for an undeclared reason. */ +export const ACTIVITY_ERROR_TAG = "@temporal-contract/ActivityError"; + +/** `_tag` of `ActivityCancelledError` — a call to an errors-declaring activity was cancelled. */ +export const ACTIVITY_CANCELLED_ERROR_TAG = "@temporal-contract/ActivityCancelledError"; + +/** `_tag` of `ActivityDefinitionNotFoundError` — an implementation was supplied for an undeclared activity. */ +export const ACTIVITY_DEFINITION_NOT_FOUND_ERROR_TAG = + "@temporal-contract/ActivityDefinitionNotFoundError"; + +/** `_tag` of `ChildWorkflowError` — a child-workflow operation failed. */ +export const CHILD_WORKFLOW_ERROR_TAG = "@temporal-contract/ChildWorkflowError"; + +/** `_tag` of `ChildWorkflowCancelledError` — a child-workflow operation was cancelled. */ +export const CHILD_WORKFLOW_CANCELLED_ERROR_TAG = "@temporal-contract/ChildWorkflowCancelledError"; + +/** `_tag` of `ChildWorkflowNotFoundError` — the child workflow isn't declared on the contract. */ +export const CHILD_WORKFLOW_NOT_FOUND_ERROR_TAG = "@temporal-contract/ChildWorkflowNotFoundError"; + +/** `_tag` of `WorkflowCancelledError` — a typed cancellation scope was cancelled. */ +export const WORKFLOW_CANCELLED_ERROR_TAG = "@temporal-contract/WorkflowCancelledError"; diff --git a/packages/worker/src/errors.spec.ts b/packages/worker/src/errors.spec.ts index b67cdc4b..c2cd7b6e 100644 --- a/packages/worker/src/errors.spec.ts +++ b/packages/worker/src/errors.spec.ts @@ -1,5 +1,6 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; import { ApplicationFailure } from "@temporalio/common"; +import { CancelledFailure } from "@temporalio/common"; /** * Coverage for the path-aware issue formatting in worker validation errors * and in `formatChildWorkflowValidationMessage` (the workflow.ts call site @@ -14,13 +15,31 @@ import { ApplicationFailure } from "@temporalio/common"; import { describe, expect, it } from "vitest"; import { + ACTIVITY_CANCELLED_ERROR_TAG, + ACTIVITY_ERROR_TAG, + CHILD_WORKFLOW_CANCELLED_ERROR_TAG, + CHILD_WORKFLOW_ERROR_TAG, + CHILD_WORKFLOW_NOT_FOUND_ERROR_TAG, + WORKFLOW_CANCELLED_ERROR_TAG, +} from "./error-tags.js"; +import { + ActivityCancelledError, + ActivityError, ActivityInputValidationError, ActivityOutputValidationError, + ChildWorkflowCancelledError, + ChildWorkflowError, + ChildWorkflowNotFoundError, ContractMisuseError, + QueryInputValidationError, + QueryOutputValidationError, + UpdateInputValidationError, UpdateOutputValidationError, ValidationError, + WorkflowCancelledError, WorkflowInputValidationError, WorkflowOutputValidationError, + rethrowCancellation, } from "./errors.js"; import { formatChildWorkflowValidationMessage } from "./internal.js"; @@ -269,4 +288,86 @@ describe("validation errors are terminal Temporal failures (#251)", () => { expect(error).toBeInstanceOf(ActivityInputValidationError); expect(error).not.toBeInstanceOf(ActivityOutputValidationError); }); + + it("carry a `direction` field aligning with the client's direction-as-field error family", () => { + // Class names still split direction (WorkflowInputValidationError etc.), + // but the structured field lets shared tooling branch without parsing + // class names. + expect(new WorkflowInputValidationError("wf", [issue("bad")]).direction).toBe("input"); + expect(new WorkflowOutputValidationError("wf", [issue("bad")]).direction).toBe("output"); + expect(new ActivityInputValidationError("act", [issue("bad")]).direction).toBe("input"); + expect(new ActivityOutputValidationError("act", [issue("bad")]).direction).toBe("output"); + expect(new QueryInputValidationError("q", [issue("bad")]).direction).toBe("input"); + expect(new QueryOutputValidationError("q", [issue("bad")]).direction).toBe("output"); + expect(new UpdateInputValidationError("u", [issue("bad")]).direction).toBe("input"); + expect(new UpdateOutputValidationError("u", [issue("bad")]).direction).toBe("output"); + }); +}); + +describe("error tag constants", () => { + it("match the corresponding classes' _tag literals", () => { + expect(new ActivityError("act", "boom")._tag).toBe(ACTIVITY_ERROR_TAG); + expect(new ActivityCancelledError("act")._tag).toBe(ACTIVITY_CANCELLED_ERROR_TAG); + expect(new ChildWorkflowError("wf", "boom")._tag).toBe(CHILD_WORKFLOW_ERROR_TAG); + expect(new ChildWorkflowCancelledError("wf")._tag).toBe(CHILD_WORKFLOW_CANCELLED_ERROR_TAG); + expect(new ChildWorkflowNotFoundError("wf")._tag).toBe(CHILD_WORKFLOW_NOT_FOUND_ERROR_TAG); + expect(new WorkflowCancelledError()._tag).toBe(WORKFLOW_CANCELLED_ERROR_TAG); + }); +}); + +describe("ChildWorkflowError structured fields", () => { + it("carries the child workflowName as a structured field (no message parsing)", () => { + const error = new ChildWorkflowError("processPayment", "it failed", new Error("inner")); + expect(error.workflowName).toBe("processPayment"); + expect(error.message).toBe("it failed"); + expect((error.cause as Error).message).toBe("inner"); + }); +}); + +describe("rethrowCancellation", () => { + it("rethrows the underlying CancelledFailure carried in error.cause", () => { + const cancelled = new CancelledFailure("workflow cancelled"); + const wrapped = new WorkflowCancelledError(cancelled); + + // The original CancelledFailure must reach Temporal so the execution + // ends `Cancelled` — not the wrapper, which Temporal wouldn't recognize. + let thrown: unknown; + try { + rethrowCancellation(wrapped); + } catch (error) { + thrown = error; + } + expect(thrown).toBe(cancelled); + }); + + it("works for the activity and child-workflow cancellation variants too", () => { + const cancelled = new CancelledFailure("cancelled"); + + expect(() => rethrowCancellation(new ActivityCancelledError("act", cancelled))).toThrow( + cancelled, + ); + expect(() => rethrowCancellation(new ChildWorkflowCancelledError("child", cancelled))).toThrow( + cancelled, + ); + }); + + it("falls back to throwing the error itself when no cause was attached", () => { + const wrapped = new WorkflowCancelledError(); + + let thrown: unknown; + try { + rethrowCancellation(wrapped); + } catch (error) { + thrown = error; + } + expect(thrown).toBe(wrapped); + }); + + it("is typed `never` — usable as a terminal statement", () => { + // Type-level: the return type must be `never` so control-flow analysis + // treats a call as terminal (no unreachable-code or missing-return + // complaints in workflow branches). + const fn: (e: WorkflowCancelledError) => never = rethrowCancellation; + expect(typeof fn).toBe("function"); + }); }); diff --git a/packages/worker/src/errors.ts b/packages/worker/src/errors.ts index ff0965c0..94f4b4a3 100644 --- a/packages/worker/src/errors.ts +++ b/packages/worker/src/errors.ts @@ -3,6 +3,27 @@ import { summarizeIssues } from "@temporal-contract/contract"; import { ApplicationFailure } from "@temporalio/common"; import { TaggedError } from "unthrown"; +import { + ACTIVITY_CANCELLED_ERROR_TAG, + ACTIVITY_DEFINITION_NOT_FOUND_ERROR_TAG, + ACTIVITY_ERROR_TAG, + CHILD_WORKFLOW_CANCELLED_ERROR_TAG, + CHILD_WORKFLOW_ERROR_TAG, + CHILD_WORKFLOW_NOT_FOUND_ERROR_TAG, + WORKFLOW_CANCELLED_ERROR_TAG, +} from "./error-tags.js"; + +// Cancellation caveat (module-wide): the cancellation error classes below +// ({@link ActivityCancelledError}, {@link ChildWorkflowCancelledError}, +// {@link WorkflowCancelledError}) surface Temporal cancellation on the +// modeled `Err(...)` channel of an `AsyncResult`. That makes cancellation +// explicit — but it also means *generic* error handling can swallow it: a +// workflow that maps every `Err` to a "failed" result and returns normally +// completes as `Completed`, not `Cancelled`, even though the server asked +// for cancellation. When a workflow does not intend to absorb cancellation, +// re-raise it with {@link rethrowCancellation} so Temporal records the +// execution as `Cancelled`. + /** * Base class for the contract's runtime validation failures — workflow and * activity input/output, plus query/update payloads. (Invalid *signal* @@ -66,7 +87,7 @@ export abstract class ValidationError extends ApplicationFailure { * Error thrown when an activity definition is not found in the contract */ export class ActivityDefinitionNotFoundError extends TaggedError( - "@temporal-contract/ActivityDefinitionNotFoundError", + ACTIVITY_DEFINITION_NOT_FOUND_ERROR_TAG, { name: "ActivityDefinitionNotFoundError" }, )<{ activityName: string; @@ -83,6 +104,9 @@ export class ActivityDefinitionNotFoundError extends TaggedError( * Error thrown when activity input validation fails */ export class ActivityInputValidationError extends ValidationError { + /** Which side of the payload boundary failed — aligns with the client's direction-as-field error family. */ + public readonly direction = "input" as const; + constructor( public readonly activityName: string, issues: ReadonlyArray, @@ -100,6 +124,9 @@ export class ActivityInputValidationError extends ValidationError { * Error thrown when activity output validation fails */ export class ActivityOutputValidationError extends ValidationError { + /** Which side of the payload boundary failed — aligns with the client's direction-as-field error family. */ + public readonly direction = "output" as const; + constructor( public readonly activityName: string, issues: ReadonlyArray, @@ -117,6 +144,9 @@ export class ActivityOutputValidationError extends ValidationError { * Error thrown when workflow input validation fails */ export class WorkflowInputValidationError extends ValidationError { + /** Which side of the payload boundary failed — aligns with the client's direction-as-field error family. */ + public readonly direction = "input" as const; + constructor( public readonly workflowName: string, issues: ReadonlyArray, @@ -134,6 +164,9 @@ export class WorkflowInputValidationError extends ValidationError { * Error thrown when workflow output validation fails */ export class WorkflowOutputValidationError extends ValidationError { + /** Which side of the payload boundary failed — aligns with the client's direction-as-field error family. */ + public readonly direction = "output" as const; + constructor( public readonly workflowName: string, issues: ReadonlyArray, @@ -151,6 +184,9 @@ export class WorkflowOutputValidationError extends ValidationError { * Error thrown when query input validation fails */ export class QueryInputValidationError extends ValidationError { + /** Which side of the payload boundary failed — aligns with the client's direction-as-field error family. */ + public readonly direction = "input" as const; + constructor( public readonly queryName: string, issues: ReadonlyArray, @@ -168,6 +204,9 @@ export class QueryInputValidationError extends ValidationError { * Error thrown when query output validation fails */ export class QueryOutputValidationError extends ValidationError { + /** Which side of the payload boundary failed — aligns with the client's direction-as-field error family. */ + public readonly direction = "output" as const; + constructor( public readonly queryName: string, issues: ReadonlyArray, @@ -185,6 +224,9 @@ export class QueryOutputValidationError extends ValidationError { * Error thrown when update input validation fails */ export class UpdateInputValidationError extends ValidationError { + /** Which side of the payload boundary failed — aligns with the client's direction-as-field error family. */ + public readonly direction = "input" as const; + constructor( public readonly updateName: string, issues: ReadonlyArray, @@ -202,6 +244,9 @@ export class UpdateInputValidationError extends ValidationError { * Error thrown when update output validation fails */ export class UpdateOutputValidationError extends ValidationError { + /** Which side of the payload boundary failed — aligns with the client's direction-as-field error family. */ + public readonly direction = "output" as const; + constructor( public readonly updateName: string, issues: ReadonlyArray, @@ -274,7 +319,7 @@ export class ContractMisuseError extends ValidationError { * Only activities that declare an `errors` map surface this — activities * without declared errors keep Temporal's native throwing behavior. */ -export class ActivityError extends TaggedError("@temporal-contract/ActivityError", { +export class ActivityError extends TaggedError(ACTIVITY_ERROR_TAG, { name: "ActivityError", })<{ activityName: string; @@ -294,11 +339,17 @@ export class ActivityError extends TaggedError("@temporal-contract/ActivityError * A sibling of {@link ActivityError} rather than a subclass, for the same * reason {@link ChildWorkflowCancelledError} is a sibling of * {@link ChildWorkflowError}: call sites discriminate on the `_tag`. + * + * **Swallowing this error changes the workflow outcome.** Cancellation rides + * the modeled `Err(...)` channel here, so generic error handling (e.g. + * mapping every `Err` to a "failed" result and returning normally) makes the + * workflow complete as `Completed` instead of `Cancelled`. When the workflow + * should honor the cancellation request, re-raise it with + * {@link rethrowCancellation}. */ -export class ActivityCancelledError extends TaggedError( - "@temporal-contract/ActivityCancelledError", - { name: "ActivityCancelledError" }, -)<{ +export class ActivityCancelledError extends TaggedError(ACTIVITY_CANCELLED_ERROR_TAG, { + name: "ActivityCancelledError", +})<{ activityName: string; cause?: unknown; }> { @@ -311,10 +362,9 @@ export class ActivityCancelledError extends TaggedError( /** * Error thrown when a child workflow is not found in the contract */ -export class ChildWorkflowNotFoundError extends TaggedError( - "@temporal-contract/ChildWorkflowNotFoundError", - { name: "ChildWorkflowNotFoundError" }, -)<{ +export class ChildWorkflowNotFoundError extends TaggedError(CHILD_WORKFLOW_NOT_FOUND_ERROR_TAG, { + name: "ChildWorkflowNotFoundError", +})<{ workflowName: string; availableWorkflows: readonly string[]; }> { @@ -333,14 +383,19 @@ export class ChildWorkflowNotFoundError extends TaggedError( * `TimeoutFailure`, `TerminatedFailure`, etc.) lifted from Temporal's wrapper — * mirroring the client-side `WorkflowFailedError.cause` behavior, so callers * can branch on the failure category in one step instead of unwrapping twice. + * + * Carries the child's `workflowName` as a structured field (matching its + * sibling {@link ChildWorkflowCancelledError}) so callers don't have to parse + * it out of `message`. */ -export class ChildWorkflowError extends TaggedError("@temporal-contract/ChildWorkflowError", { +export class ChildWorkflowError extends TaggedError(CHILD_WORKFLOW_ERROR_TAG, { name: "ChildWorkflowError", })<{ + workflowName: string; cause?: unknown; }> { - constructor(message: string, cause?: unknown) { - super({ cause }); + constructor(workflowName: string, message: string, cause?: unknown) { + super({ workflowName, cause }); this.message = message; } } @@ -359,11 +414,17 @@ export class ChildWorkflowError extends TaggedError("@temporal-contract/ChildWor * `instanceof ChildWorkflowError` that also matches cancellation. A * `result.match` with the exhaustive `errCases` matcher folds the * `ChildWorkflowError | ChildWorkflowCancelledError` union exhaustively. + * + * **Swallowing this error changes the workflow outcome.** Cancellation rides + * the modeled `Err(...)` channel here, so generic error handling (e.g. + * mapping every `Err` to a "failed" result and returning normally) makes the + * parent workflow complete as `Completed` instead of `Cancelled`. When the + * workflow should honor the cancellation request, re-raise it with + * {@link rethrowCancellation}. */ -export class ChildWorkflowCancelledError extends TaggedError( - "@temporal-contract/ChildWorkflowCancelledError", - { name: "ChildWorkflowCancelledError" }, -)<{ +export class ChildWorkflowCancelledError extends TaggedError(CHILD_WORKFLOW_CANCELLED_ERROR_TAG, { + name: "ChildWorkflowCancelledError", +})<{ workflowName: string; cause?: unknown; }> { @@ -384,11 +445,17 @@ export class ChildWorkflowCancelledError extends TaggedError( * Non-cancellation errors thrown inside a scope are *unmodeled* failures: they * surface on the scope's `defect` channel (re-thrown at the edge / inspectable * via `result.isDefect()` and `result.cause`), not as a typed `Err(...)`. + * + * **Swallowing this error changes the workflow outcome.** Cancellation rides + * the modeled `Err(...)` channel here, so generic error handling (e.g. + * mapping every `Err` to a "failed" result and returning normally) makes the + * workflow complete as `Completed` instead of `Cancelled`. When the workflow + * should honor the cancellation request after cleanup, re-raise it with + * {@link rethrowCancellation}. */ -export class WorkflowCancelledError extends TaggedError( - "@temporal-contract/WorkflowCancelledError", - { name: "WorkflowCancelledError" }, -)<{ +export class WorkflowCancelledError extends TaggedError(WORKFLOW_CANCELLED_ERROR_TAG, { + name: "WorkflowCancelledError", +})<{ cause?: unknown; }> { constructor(cause?: unknown) { @@ -396,3 +463,38 @@ export class WorkflowCancelledError extends TaggedError( this.message = "Workflow cancellation scope was cancelled"; } } + +/** + * Re-raise a cancellation error surfaced on the modeled `Err(...)` channel so + * Temporal records the workflow execution as `Cancelled`. + * + * The typed surfaces fold Temporal cancellation into + * `Err(ActivityCancelledError | WorkflowCancelledError | + * ChildWorkflowCancelledError)`. That is deliberate — cancellation becomes an + * explicit branch — but it has a footgun: generic error handling that maps + * every `Err` to a domain "failed" outcome and returns normally makes the + * workflow complete as `Completed`, silently overriding the server's + * cancellation request. When the workflow should *honor* the cancellation + * (typically after `nonCancellableScope` cleanup), call this helper: it + * throws the original `CancelledFailure` carried in `error.cause` (or the + * error itself when no cause was attached), which Temporal recognizes and + * turns into a `Cancelled` workflow outcome. + * + * Workflow-sandbox safe: no I/O, no wall clock — it only rethrows. + * + * @example + * ```ts + * const result = await context.cancellableScope(() => context.activities.processStep(args)); + * if (result.isErr()) { + * await context.nonCancellableScope(() => context.activities.releaseResources(args)); + * // Honor the cancellation instead of completing normally: + * rethrowCancellation(result.error); + * } + * ``` + */ +export function rethrowCancellation( + error: ActivityCancelledError | ChildWorkflowCancelledError | WorkflowCancelledError, +): never { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned cancellation re-raise: the original CancelledFailure must reach Temporal so the execution ends Cancelled (CLAUDE.md rule 2 exception) + throw error.cause ?? error; +} diff --git a/packages/worker/src/handlers.spec.ts b/packages/worker/src/handlers.spec.ts index f6303e9f..8ebff0a4 100644 --- a/packages/worker/src/handlers.spec.ts +++ b/packages/worker/src/handlers.spec.ts @@ -196,7 +196,11 @@ describe("bindQueryHandler", () => { expect(() => entry.impl([])).toThrow(QueryOutputValidationError); }); - it("throws ContractMisuseError when input validation is async (Temporal queries must be sync)", () => { + it("throws ContractMisuseError AT BIND TIME when the input schema validates asynchronously", () => { + // An async schema (e.g. zod async refine) used to pass declaration and + // fail only when the first live query arrived; the bind-time probe moves + // the failure to handler binding with a message naming the workflow, + // handler kind, name, and direction. captured.length = 0; const wfWithAsyncQuery = defineWorkflow({ input: z.object({}), @@ -205,13 +209,17 @@ describe("bindQueryHandler", () => { progress: { input: asyncStringSchema, output: z.number() }, }, }); - bindQueryHandler(wfWithAsyncQuery, "probe", "progress", vi.fn().mockReturnValue(1) as never); - const entry = captured.find((c) => c.kind === "query" && c.name === "progress")!; - expect(() => entry.impl(["x"])).toThrow(ContractMisuseError); - expect(() => entry.impl(["x"])).toThrow(/validation must be synchronous/); + const bind = () => + bindQueryHandler(wfWithAsyncQuery, "probe", "progress", vi.fn().mockReturnValue(1) as never); + expect(bind).toThrow(ContractMisuseError); + expect(bind).toThrow( + /Query "progress" of workflow "probe": the input schema validates asynchronously/, + ); + // Nothing was registered — the bind failed before setHandler. + expect(captured.find((c) => c.kind === "query" && c.name === "progress")).toBeUndefined(); }); - it("throws ContractMisuseError when output validation is async", () => { + it("throws ContractMisuseError AT BIND TIME when the output schema validates asynchronously", () => { captured.length = 0; const wfWithAsyncOutput = defineWorkflow({ input: z.object({}), @@ -220,10 +228,78 @@ describe("bindQueryHandler", () => { progress: { input: z.tuple([]), output: asyncStringSchema }, }, }); - bindQueryHandler(wfWithAsyncOutput, "probe", "progress", vi.fn().mockReturnValue("x") as never); + const bind = () => + bindQueryHandler( + wfWithAsyncOutput, + "probe", + "progress", + vi.fn().mockReturnValue("x") as never, + ); + expect(bind).toThrow(ContractMisuseError); + expect(bind).toThrow( + /Query "progress" of workflow "probe": the output schema validates asynchronously/, + ); + }); + + it("a synchronously-throwing schema passes the bind-time probe (a sync throw proves synchronicity)", () => { + // The probe feeds a sentinel to validate(); some libraries throw + // synchronously on garbage input. That throw must be treated as "fine, + // it's synchronous" — not as a probe failure. + captured.length = 0; + const throwingSchema = { + "~standard": { + version: 1 as const, + vendor: "test-throwing", + validate: (input: unknown) => { + if (typeof input === "symbol") { + // oxlint-disable-next-line unthrown/no-throw -- test double: simulates a schema library that throws synchronously on garbage input + throw new TypeError("cannot validate a symbol"); + } + return { value: input, issues: undefined }; + }, + }, + }; + const wf = defineWorkflow({ + input: z.object({}), + output: z.object({}), + queries: { + progress: { input: throwingSchema, output: z.number() }, + }, + }); + expect(() => + bindQueryHandler(wf, "probe", "progress", vi.fn().mockReturnValue(2) as never), + ).not.toThrow(); + const entry = captured.find((c) => c.kind === "query" && c.name === "progress")!; + expect(entry.impl("anything")).toBe(2); + }); + + it("keeps the per-call sync guard as defense-in-depth for schemas that defeat the probe", () => { + // A pathological schema that answers the probe synchronously but goes + // async for real payloads: the bind succeeds, and the per-call guard + // still trips a ContractMisuseError instead of corrupting query + // semantics. + captured.length = 0; + const probeDodgingSchema = { + "~standard": { + version: 1 as const, + vendor: "test-dodging", + validate: (input: unknown) => + typeof input === "symbol" + ? { value: input, issues: undefined } + : Promise.resolve({ value: input, issues: undefined }), + }, + }; + const wf = defineWorkflow({ + input: z.object({}), + output: z.object({}), + queries: { + progress: { input: probeDodgingSchema, output: z.number() }, + }, + }); + bindQueryHandler(wf, "probe", "progress", vi.fn().mockReturnValue(1) as never); const entry = captured.find((c) => c.kind === "query" && c.name === "progress")!; - expect(() => entry.impl([])).toThrow(ContractMisuseError); - expect(() => entry.impl([])).toThrow(/output validation must be synchronous/); + expect(() => entry.impl(["x"])).toThrow(ContractMisuseError); + expect(() => entry.impl(["x"])).toThrow(/validation must be synchronous/); }); it("throws ContractMisuseError when the workflow has no queries block", () => { @@ -286,13 +362,13 @@ describe("bindUpdateHandler", () => { expect(() => entry.validator!([7])).not.toThrow(); }); - it("validator throws ContractMisuseError when the input schema is async (Temporal validators must be sync)", () => { + it("throws ContractMisuseError AT BIND TIME when the update input schema validates asynchronously", () => { // Temporal's update validator slot is documented as synchronous — // returning a Promise from the validator silently breaks admission // semantics. Standard Schema permits async validate(), so the typical - // offender is Zod with `.refine(async)` on an update input. We surface - // that as a clear error at validator-call time, mirroring how - // bindQueryHandler handles the same situation. + // offender is Zod with `.refine(async)` on an update input. The + // bind-time probe surfaces that when the handler is bound (worker + // startup / first workflow task) instead of on the first live update. captured.length = 0; const wfWithAsyncUpdate = defineWorkflow({ input: z.object({}), @@ -301,12 +377,31 @@ describe("bindUpdateHandler", () => { bumpAttempt: { input: asyncStringSchema, output: z.object({ attempt: z.number() }) }, }, }); - bindUpdateHandler(wfWithAsyncUpdate, "probe", "bumpAttempt", (async () => ({ - attempt: 1, - })) as never); - const entry = captured.find((c) => c.kind === "update" && c.name === "bumpAttempt")!; - expect(() => entry.validator!(["x"])).toThrow(ContractMisuseError); - expect(() => entry.validator!(["x"])).toThrow(/input validation must be synchronous/); + const bind = () => + bindUpdateHandler(wfWithAsyncUpdate, "probe", "bumpAttempt", (async () => ({ + attempt: 1, + })) as never); + expect(bind).toThrow(ContractMisuseError); + expect(bind).toThrow( + /Update "bumpAttempt" of workflow "probe": the input schema validates asynchronously/, + ); + expect(captured.find((c) => c.kind === "update" && c.name === "bumpAttempt")).toBeUndefined(); + }); + + it("an async update OUTPUT schema is allowed (output validation runs in the async handler body)", () => { + captured.length = 0; + const wfWithAsyncOutput = defineWorkflow({ + input: z.object({}), + output: z.object({}), + updates: { + bumpAttempt: { input: z.tuple([z.number()]), output: asyncStringSchema }, + }, + }); + expect(() => + bindUpdateHandler(wfWithAsyncOutput, "probe", "bumpAttempt", (async () => ({ + attempt: 1, + })) as never), + ).not.toThrow(); }); it("handler runs and returns parsed output when input is valid", async () => { diff --git a/packages/worker/src/handlers.ts b/packages/worker/src/handlers.ts index 485d7f65..e4d47d95 100644 --- a/packages/worker/src/handlers.ts +++ b/packages/worker/src/handlers.ts @@ -71,6 +71,75 @@ function updateInputMustBeSynchronousMessage(updateName: string): string { ); } +/** + * Sentinel fed to a schema's `validate` when probing for synchronicity. Its + * validity is irrelevant — only whether `validate` returns a thenable. + */ +const SYNC_PROBE_SENTINEL = Symbol("temporal-contract.sync-schema-probe"); + +function isThenable(value: unknown): value is PromiseLike { + return ( + (typeof value === "object" || typeof value === "function") && + value !== null && + typeof (value as { then?: unknown }).then === "function" + ); +} + +/** + * Bind-time probe for the sync-only schema slots (query input/output, update + * input). Standard Schema permits `validate` to return a Promise (e.g. Zod + * with an async `.refine`), but Temporal runs query handlers and the update + * validator slot synchronously — an async schema would pass declaration and + * only blow up when the first live request arrives. Probing at + * `handleQuery`/`handleUpdate` bind time (i.e. on the first workflow task + * that registers the handler) moves that failure to worker startup/binding, + * where it is a clear {@link ContractMisuseError} instead of a mid-traffic + * surprise. + * + * The probe invokes `validate` on an opaque sentinel and only inspects + * whether the result is a thenable. Any synchronous **throw** counts as + * "fine, it's synchronous": a sync validation error on the sentinel is + * expected and proves synchronicity. A returned thenable is the async + * signature — its eventual settlement is silenced (so a rejecting probe + * can't trip unhandled-rejection reporting) and the bind fails immediately. + */ +function assertSyncSchema( + schema: { "~standard": { validate: (value: unknown) => unknown } }, + location: { + workflowName: string; + handlerKind: "Query" | "Update"; + handlerName: string; + direction: "input" | "output"; + }, +): void { + let probeResult: unknown; + try { + probeResult = schema["~standard"].validate(SYNC_PROBE_SENTINEL); + } catch { + // A synchronous throw on the sentinel proves the schema validates + // synchronously — exactly what this probe is checking for. + return; + } + if (isThenable(probeResult)) { + // Detach the probe's eventual settlement — we only cared about the shape. + probeResult.then( + () => undefined, + () => undefined, + ); + const requirement = + location.handlerKind === "Query" + ? "Temporal query handlers run synchronously" + : "Temporal's update validator slot is synchronous"; + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ContractMisuseError model: bind-time fail-fast as a non-retryable ApplicationFailure (CLAUDE.md rule 2 exception) + throw new ContractMisuseError( + `${location.handlerKind} "${location.handlerName}" of workflow "${location.workflowName}": ` + + `the ${location.direction} schema validates asynchronously (its validate() returned a Promise, ` + + `e.g. an async refine), but ${requirement}. ` + + `Use a synchronously-validating schema for this ${location.direction}.`, + ); + } +} + /** * Bind a typed signal handler to the running workflow. Validates the * signal payload against the contract's input schema before invoking the @@ -163,6 +232,22 @@ export function bindQueryHandler( ); } + // Bind-time probe: both query schema slots must validate synchronously. + // Failing here (worker startup / first workflow task) beats failing on the + // first live query. The per-call guards below stay as defense-in-depth. + assertSyncSchema(queryDef.input, { + workflowName, + handlerKind: "Query", + handlerName: queryName, + direction: "input", + }); + assertSyncSchema(queryDef.output, { + workflowName, + handlerKind: "Query", + handlerName: queryName, + direction: "output", + }); + const query = defineQuery(queryName); setHandler(query, (...args: unknown[]) => { const input = extractHandlerInput(args); @@ -249,6 +334,17 @@ export function bindUpdateHandler( ); } + // Bind-time probe: the update *input* schema feeds Temporal's synchronous + // validator slot, so it must validate synchronously. (The output schema is + // exempt — it runs inside the async handler body.) The per-call guards + // below stay as defense-in-depth. + assertSyncSchema(updateDef.input, { + workflowName, + handlerKind: "Update", + handlerName: updateName, + direction: "input", + }); + const update = defineUpdate(updateName); setHandler( update, diff --git a/packages/worker/src/internal.ts b/packages/worker/src/internal.ts index c488e903..920ce443 100644 --- a/packages/worker/src/internal.ts +++ b/packages/worker/src/internal.ts @@ -301,14 +301,17 @@ export function createContinueAsNew( throw new WorkflowInputValidationError(targetName, inputResult.issues); } - // workflowType/taskQueue come from the destination contract; user - // options are spread last so power users can override (e.g. retry, - // memo). The public TypedContinueAsNewOptions type Omits workflowType - // and taskQueue so this isn't a footgun on the typed call path. + // workflowType/taskQueue come from the destination contract and are set + // LAST, after the user-options spread, so callers cannot override the + // validated target — the args were just validated against `targetName`'s + // input schema, and routing them to a different workflow type or task + // queue would bypass that validation. The public TypedContinueAsNewOptions + // type already Omits `workflowType`/`taskQueue`; this ordering closes the + // `as never` / plain-JavaScript escape hatch too. const fn = makeContinueAsNewFunc({ + ...options, workflowType: targetName, taskQueue: targetContract.taskQueue, - ...options, }); // Transmit the ORIGINAL args — validated above, parsed by the new run's @@ -399,6 +402,7 @@ export function classifyChildWorkflowError( const inner = error.cause ?? error; const innerMessage = inner instanceof Error ? inner.message : String(inner); return new ChildWorkflowError( + childWorkflowName, `${describeChildWorkflowOperation(operation, childWorkflowName)}: ${innerMessage}`, inner, ); @@ -406,6 +410,7 @@ export function classifyChildWorkflowError( const message = error instanceof Error ? error.message : String(error); return new ChildWorkflowError( + childWorkflowName, `${describeChildWorkflowOperation(operation, childWorkflowName)}: ${message}`, error, ); diff --git a/packages/worker/src/types-inference.spec.ts b/packages/worker/src/types-inference.spec.ts index 5ead22d0..960e5f8c 100644 --- a/packages/worker/src/types-inference.spec.ts +++ b/packages/worker/src/types-inference.spec.ts @@ -1,20 +1,42 @@ -import { defineActivity } from "@temporal-contract/contract"; +import { + defineActivity, + defineContract, + defineSignal, + defineWorkflow, +} from "@temporal-contract/contract"; +import { OkAsync } from "unthrown"; /** * Type-level tests for the worker- and client-side inference helpers. * * The library's headline guarantee is that input/output direction is inverted * between worker and client perspectives — getting this wrong silently * mistypes every call site, so it's worth pinning at the type level. + * + * The second half pins the public type surface exported for standalone use: + * `ActivityImplementationFor`, `WorkflowContext`-typed helpers, stored + * `TypedChildWorkflowHandle`s, middleware context accumulation, + * `activityOptionsByName` key constraints, and `continueAsNew` typing. */ -import { describe, expectTypeOf, it } from "vitest"; +import { describe, expect, expectTypeOf, it } from "vitest"; import { z } from "zod"; +import { + composeActivityMiddleware, + declareActivitiesHandler, + declareActivityMiddleware, + type ActivityImplementationFor, + type ActivityMiddleware, + type EmptyContext, + type GlobalActivityImplementationFor, +} from "./activity.js"; +import type { TypedChildWorkflowHandle } from "./child-workflow.js"; import type { ClientInferInput, ClientInferOutput, WorkerInferInput, WorkerInferOutput, } from "./types.js"; +import type { DeclareWorkflowOptions, WorkflowContext } from "./workflow.js"; describe("worker/client inference duality", () => { it("on a transforming schema, client sends InferInput, worker receives InferOutput", () => { @@ -57,3 +79,199 @@ describe("worker/client inference duality", () => { >(); }); }); + +// --------------------------------------------------------------------------- +// Public type-surface pins (v8): standalone declarations outside the +// declare* factories must typecheck with full inference. +// --------------------------------------------------------------------------- + +const validateOrderDef = defineActivity({ + input: z.object({ orderId: z.string() }), + output: z.object({ valid: z.boolean() }), +}); + +const logDef = defineActivity({ + input: z.object({ msg: z.string() }), + output: z.object({}), +}); + +const orderWorkflowDef = defineWorkflow({ + input: z.object({ id: z.string() }), + output: z.object({ status: z.string() }), + activities: { validateOrder: validateOrderDef }, + signals: { + cancel: defineSignal({ input: z.object({ reason: z.string() }) }), + }, +}); + +const otherWorkflowDef = defineWorkflow({ + input: z.object({ batchId: z.string() }), + output: z.object({ archived: z.boolean() }), +}); + +const inferenceContract = defineContract({ + taskQueue: "types-inference", + workflows: { orderWorkflow: orderWorkflowDef, otherWorkflow: otherWorkflowDef }, + activities: { log: logDef }, +}); + +describe("standalone activity implementations (ActivityImplementationFor)", () => { + it("resolves the correctly-typed implementation and is accepted by declareActivitiesHandler", async () => { + // Standalone, workflow-scoped implementation declared OUTSIDE the + // handler call — args and return channel fully inferred from the + // contract entry. + const validateOrder: ActivityImplementationFor< + typeof inferenceContract, + "orderWorkflow", + "validateOrder" + > = (args) => { + expectTypeOf(args).toEqualTypeOf<{ orderId: string }>(); + return OkAsync({ valid: args.orderId.length > 0 }); + }; + + // Global-scope variant. + const log: GlobalActivityImplementationFor = (args) => { + expectTypeOf(args).toEqualTypeOf<{ msg: string }>(); + return OkAsync({}); + }; + + const handler = declareActivitiesHandler({ + contract: inferenceContract, + activities: { + log, + orderWorkflow: { validateOrder }, + }, + }); + + // The standalone implementations really registered (runtime smoke). + expect(await handler.validateOrder({ orderId: "ORD-1" })).toEqual({ valid: true }); + expect(await handler.log({ msg: "hello" })).toEqual({}); + }); + + it("rejects an implementation with the wrong output shape", () => { + const wrong: ActivityImplementationFor< + typeof inferenceContract, + "orderWorkflow", + "validateOrder" + // @ts-expect-error — output must be { valid: boolean } + > = () => OkAsync({ nope: true }); + void wrong; + }); +}); + +describe("WorkflowContext as a standalone helper parameter type", () => { + it("types activities, handleSignal, and errors on a helper function", () => { + // Never executed — a pure type-level exercise of the exported context + // shape (executing would require the workflow sandbox). + const helper = (context: WorkflowContext) => { + // Typed activities: workflow-local + global merged. + expectTypeOf(context.activities.validateOrder) + .parameter(0) + .toEqualTypeOf<{ orderId: string }>(); + expectTypeOf(context.activities.log).parameter(0).toEqualTypeOf<{ msg: string }>(); + + // handleSignal is keyed by the contract's signal names, and the + // handler's args are the signal's worker-side input type. + context.handleSignal("cancel", (args) => { + expectTypeOf(args).toEqualTypeOf<{ reason: string }>(); + }); + // @ts-expect-error — "nope" is not a declared signal + context.handleSignal("nope", () => {}); + }; + void helper; + expect(typeof helper).toBe("function"); + }); +}); + +describe("TypedChildWorkflowHandle storage", () => { + it("a started child handle can be stored under an explicit type annotation", () => { + const useHandle = async ( + context: WorkflowContext, + ) => { + let stored: + | TypedChildWorkflowHandle<(typeof inferenceContract)["workflows"]["otherWorkflow"]> + | undefined; + + const started = await context.startChildWorkflow(inferenceContract, "otherWorkflow", { + workflowId: "child-1", + args: { batchId: "B-1" }, + }); + if (started.isOk()) { + stored = started.value; + // The stored handle keeps the child's typed result channel. + expectTypeOf(stored.workflowId).toEqualTypeOf(); + const result = stored.result(); + expectTypeOf(await result).toMatchTypeOf<{ isOk: () => boolean }>(); + } + return stored; + }; + void useHandle; + expect(typeof useHandle).toBe("function"); + }); +}); + +describe("composeActivityMiddleware context accumulation", () => { + it("threads the accumulated context type across the chain", () => { + const chain = composeActivityMiddleware( + declareActivityMiddleware((_invocation, next) => + next({ context: { tenantId: "t-1" } }), + ), + declareActivityMiddleware<{ tenantId: string }, { tenantId: string; traceId: string }>( + (invocation, next) => { + // The upstream stage's out-context is visible here. + expectTypeOf(invocation.context.tenantId).toEqualTypeOf(); + return next({ context: { ...invocation.context, traceId: "tr-1" } }); + }, + ), + ); + + // The composed chain's out-context is the LAST middleware's. + expectTypeOf(chain).toEqualTypeOf< + ActivityMiddleware + >(); + }); +}); + +describe("activityOptionsByName key constraint", () => { + it("only reachable activity names (workflow-local + global) are accepted", () => { + type Options = DeclareWorkflowOptions; + + const good: Options["activityOptionsByName"] = { + validateOrder: { startToCloseTimeout: "1 minute" }, + log: { startToCloseTimeout: "30 seconds" }, + }; + const bad: Options["activityOptionsByName"] = { + // @ts-expect-error — "nope" is not an activity reachable from orderWorkflow + nope: { startToCloseTimeout: "1 minute" }, + }; + void good; + void bad; + expect(true).toBe(true); + }); +}); + +describe("continueAsNew typing", () => { + it("types same-workflow and cross-contract dispatch, returning Promise", () => { + const roll = (context: WorkflowContext) => { + // Same-workflow: args typed against THIS workflow's input. + const same = context.continueAsNew({ id: "next-run" }); + expectTypeOf(same).toEqualTypeOf>(); + + // Cross-contract: args typed against the DESTINATION workflow's input. + const cross = context.continueAsNew(inferenceContract, "otherWorkflow", { + batchId: "B-2", + }); + expectTypeOf(cross).toEqualTypeOf>(); + + // @ts-expect-error — wrong args shape for the same-workflow overload + void context.continueAsNew({ wrong: true }); + + // @ts-expect-error — cross-contract args must match the destination's input + void context.continueAsNew(inferenceContract, "otherWorkflow", { id: "x" }); + + return [same, cross]; + }; + void roll; + expect(typeof roll).toBe("function"); + }); +}); diff --git a/packages/worker/src/types.ts b/packages/worker/src/types.ts index 242614ba..6cc3c966 100644 --- a/packages/worker/src/types.ts +++ b/packages/worker/src/types.ts @@ -23,7 +23,7 @@ export type { * The double conditional (map shape first, then the indexed entry) tolerates * the optional `signals` slot under `exactOptionalPropertyTypes` and keeps * unknown names collapsing to `never` so handler payloads stay sound. - * Shared by `WorkflowContext.defineSignal` and the typed child-workflow + * Shared by `WorkflowContext.handleSignal` and the typed child-workflow * handle's `signals` map. */ export type SignalDefOf = diff --git a/packages/worker/src/worker.spec.ts b/packages/worker/src/worker.spec.ts index 24a1f9e3..67b6e5c5 100644 --- a/packages/worker/src/worker.spec.ts +++ b/packages/worker/src/worker.spec.ts @@ -1,10 +1,21 @@ +import { extname } from "node:path"; +import { fileURLToPath } from "node:url"; + import type { ContractDefinition } from "@temporal-contract/contract"; import { type NativeConnection, Worker } from "@temporalio/worker"; import { describe, expect, it, vi, beforeEach } from "vitest"; import { z } from "zod"; +import { registrationContract } from "./__tests__/registration.contract.js"; import { TypedWorker, TechnicalError, workflowsPathFromURL } from "./worker.js"; +/** Resolve a registration-check fixture module next to this spec. */ +function fixturePath(basename: string): string { + return fileURLToPath( + new URL(`./__tests__/${basename}${extname(import.meta.url)}`, import.meta.url), + ); +} + // Mock @temporalio/worker vi.mock("@temporalio/worker", () => ({ NativeConnection: { @@ -203,6 +214,119 @@ describe("Worker Entry Point", () => { }); }); + describe("workflow-registration completeness check", () => { + // Best-effort startup check (default ON): `TypedWorker.create` imports + // the `workflowsPath` module in the main thread, identifies + // `declareWorkflow`-produced exports via their brand, and fails creation + // when a contract workflow is missing or exported under the wrong name. + // The existing suites above pass a non-existent "/path/to/workflows" — + // an unimportable module skips the check silently, which those suites + // implicitly cover. + const mockConnection = { close: vi.fn() } as unknown as NativeConnection; + const mockWorker = { run: vi.fn() } as unknown as Worker; + + it("errors (TechnicalError defect) when a contract workflow has no declareWorkflow export, naming it", async () => { + vi.mocked(Worker.create).mockResolvedValue(mockWorker); + + const workerResult = await TypedWorker.create({ + contract: registrationContract, + connection: mockConnection, + workflowsPath: fixturePath("registration-missing.workflows"), + }); + + expect(workerResult).toBeDefect(); + if (workerResult.isDefect()) { + const cause = workerResult.cause; + expect(cause).toBeInstanceOf(TechnicalError); + const message = (cause as TechnicalError).message; + expect(message).toContain("Workflow registration check failed"); + expect(message).toContain("no workflow export"); + expect(message).toContain("beta"); + expect(message).toContain("verifyWorkflowRegistration: false"); + } + // Creation aborted before reaching Temporal's Worker.create. + expect(Worker.create).not.toHaveBeenCalled(); + }); + + it("errors when a declared workflow is exported under a different name (registration-name mismatch)", async () => { + vi.mocked(Worker.create).mockResolvedValue(mockWorker); + + const workerResult = await TypedWorker.create({ + contract: registrationContract, + connection: mockConnection, + workflowsPath: fixturePath("registration-mismatch.workflows"), + }); + + expect(workerResult).toBeDefect(); + if (workerResult.isDefect()) { + const message = (workerResult.cause as TechnicalError).message; + expect(message).toContain("export-name mismatch"); + expect(message).toContain('"alpha" is exported as "renamedAlpha"'); + expect(message).toContain("registers workflows by export name"); + } + }); + + it("passes when every contract workflow is exported under its declared name", async () => { + vi.mocked(Worker.create).mockResolvedValue(mockWorker); + + const workerResult = await TypedWorker.create({ + contract: registrationContract, + connection: mockConnection, + workflowsPath: fixturePath("registration-complete.workflows"), + }); + + expect(workerResult).toBeOk(); + expect(Worker.create).toHaveBeenCalledTimes(1); + }); + + it("accepts a raw workflow function exported under the contract name (no declareWorkflow brand)", async () => { + // Workflows written against the raw `@temporalio/workflow` API are a + // supported pattern — Temporal registers by export name, so a plain + // function export under the workflow's name registers correctly. + vi.mocked(Worker.create).mockResolvedValue(mockWorker); + + const workerResult = await TypedWorker.create({ + contract: registrationContract, + connection: mockConnection, + workflowsPath: fixturePath("registration-raw.workflows"), + }); + + expect(workerResult).toBeOk(); + expect(Worker.create).toHaveBeenCalledTimes(1); + }); + + it("verifyWorkflowRegistration: false opts out — an incomplete module creates the worker anyway", async () => { + vi.mocked(Worker.create).mockResolvedValue(mockWorker); + + const workerResult = await TypedWorker.create({ + contract: registrationContract, + connection: mockConnection, + workflowsPath: fixturePath("registration-missing.workflows"), + verifyWorkflowRegistration: false, + }); + + expect(workerResult).toBeOk(); + expect(Worker.create).toHaveBeenCalledTimes(1); + // The option is consumed by the typed layer, not forwarded to Temporal. + const callArg = vi.mocked(Worker.create).mock.calls[0]![0]; + expect(Object.keys(callArg)).not.toContain("verifyWorkflowRegistration"); + }); + + it("skips the check when workflowsPath cannot be imported (best-effort; bundler is the authority)", async () => { + vi.mocked(Worker.create).mockResolvedValue(mockWorker); + + const workerResult = await TypedWorker.create({ + contract: registrationContract, + connection: mockConnection, + workflowsPath: "/definitely/not/a/real/module.js", + }); + + // The (mocked) Worker.create succeeds — the check stayed silent. + expect(workerResult).toBeOk(); + expect(Worker.create).toHaveBeenCalledTimes(1); + }); + }); + describe("TypedWorker lifecycle", () => { const contract = { taskQueue: "lifecycle-queue", diff --git a/packages/worker/src/worker.ts b/packages/worker/src/worker.ts index 0903cf91..f50ace83 100644 --- a/packages/worker/src/worker.ts +++ b/packages/worker/src/worker.ts @@ -1,4 +1,4 @@ -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; // Entry point for worker creation utilities import { type ContractDefinition } from "@temporal-contract/contract"; @@ -7,6 +7,7 @@ import { Worker, type WorkerOptions } from "@temporalio/worker"; import { fromPromise, type AsyncResult } from "unthrown"; import type { ActivitiesHandler } from "./activity.js"; +import { _internal_declaredWorkflowName } from "./workflow-brand.js"; // Technical creation failure — worker bundling / connection errors are // unmodeled infrastructure defects, surfaced on the `Defect` channel with a @@ -38,8 +39,124 @@ export type CreateWorkerOptions = Omit< * separate worker process on the same task queue registers the activities. */ activities?: ActivitiesHandler; + + /** + * Best-effort startup check that the `workflowsPath` module registers + * every contract workflow under its declared name. **Defaults to `true`.** + * + * Activities already fail fast at `declareActivitiesHandler` time when an + * implementation is missing; workflows historically did not — a forgotten + * `declareWorkflow` export surfaced only when the first task for it was + * dispatched, and an export whose *name* differs from its `workflowName` + * registered under the wrong workflow type. With this check enabled, + * `TypedWorker.create` imports the `workflowsPath` module in the main + * thread, identifies `declareWorkflow`-produced exports via their brand + * marker, and fails creation (a `TechnicalError`-caused defect) when + * + * - a contract workflow has neither a `declareWorkflow`-produced export + * nor a plain function export under its name (raw + * `@temporalio/workflow`-style workflow functions exported under the + * correct name are accepted), or + * - a declared workflow is exported under a name that differs from its + * `workflowName` (Temporal registers workflows by *export name*, so the + * mismatch would register it as the wrong workflow type). + * + * Best-effort semantics: the check only runs when `workflowsPath` is + * provided (prebuilt `workflowBundle`s are skipped), and a module that + * cannot be imported in the main thread is skipped silently — the + * subsequent `Worker.create` bundling step is the authority on whether the + * module loads at all. Note the module *is* evaluated in the main thread, + * so workflow modules should stay side-effect-free at module scope (they + * should be anyway — the sandbox re-evaluates them constantly). + * + * Set to `false` to opt out (e.g. when the workflows module intentionally + * exports helpers whose names shadow contract workflows, or module-scope + * evaluation outside the sandbox is undesirable). + */ + verifyWorkflowRegistration?: boolean; }; +/** + * Import the workflows module (best-effort) and verify every contract + * workflow is exported under its declared name. Returns silently when the + * module cannot be imported in the main thread — `Worker.create`'s bundler + * is the authority on load failures. + * + * @internal + */ +async function verifyWorkflowRegistration( + contract: ContractDefinition, + workflowsPath: string, +): Promise { + let moduleExports: Record; + try { + moduleExports = (await import(pathToFileURL(workflowsPath).href)) as Record; + } catch { + // Best-effort: the module may be main-thread hostile (sandbox-only + // imports, workflow-bundle-relative paths) or simply not resolvable + // outside the bundler. Skip — a genuinely broken module fails + // `Worker.create`'s bundling step with the bundler's own diagnostics. + return; + } + + // Map each declared workflowName to the export names it appears under. + const exportNamesByWorkflow = new Map(); + for (const [exportName, candidate] of Object.entries(moduleExports)) { + const declaredName = _internal_declaredWorkflowName(candidate); + if (declaredName === undefined) continue; + const names = exportNamesByWorkflow.get(declaredName) ?? []; + names.push(exportName); + exportNamesByWorkflow.set(declaredName, names); + } + + const missing: string[] = []; + const mismatched: string[] = []; + for (const workflowName of Object.keys(contract.workflows)) { + const exportNames = exportNamesByWorkflow.get(workflowName); + if (!exportNames) { + // No declareWorkflow-produced export declares this workflow. A plain + // function exported under the workflow's name still registers + // correctly (Temporal registers by export name) — workflows written + // against the raw `@temporalio/workflow` API are a supported pattern, + // so only flag when neither shape is present. + if (typeof moduleExports[workflowName] !== "function") { + missing.push(workflowName); + } + continue; + } + // Temporal registers workflows by EXPORT name: the declared name must be + // among the export names, otherwise the workflow registers under the + // wrong type. (Extra aliases alongside the correct export are tolerated.) + if (!exportNames.includes(workflowName)) { + mismatched.push( + `"${workflowName}" is exported as ${exportNames.map((n) => `"${n}"`).join(", ")}`, + ); + } + } + + const problems: string[] = []; + if (missing.length > 0) { + problems.push( + `no workflow export${missing.length > 1 ? "s" : ""} (declareWorkflow-produced or plain ` + + `function) for contract workflow${missing.length > 1 ? "s" : ""}: ${missing.join(", ")}`, + ); + } + if (mismatched.length > 0) { + problems.push( + `export-name mismatch (Temporal registers workflows by export name, so these would ` + + `register under the wrong workflow type): ${mismatched.join("; ")}. ` + + `Export each workflow under its declared workflowName`, + ); + } + if (problems.length > 0) { + // oxlint-disable-next-line unthrown/no-throw -- declaration-time fail-fast config error inside the fromPromise boundary: surfaces as a TechnicalError-caused defect + throw new TechnicalError( + `Workflow registration check failed for "${workflowsPath}": ${problems.join(". Also: ")}. ` + + `(Disable with \`verifyWorkflowRegistration: false\` if this is intentional.)`, + ); + } +} + /** * Contract-scoped root of the typed worker surface — the worker-side sibling * of `TypedClient`. @@ -108,7 +225,12 @@ export class TypedWorker { static create( options: CreateWorkerOptions, ): AsyncResult { - const { contract, activities, ...workerOptions } = options; + const { + contract, + activities, + verifyWorkflowRegistration: verifyRegistration, + ...workerOptions + } = options; // Create the worker with contract's task queue. `Worker.create` rejects on // workflow-bundle compilation errors, bad connections, and invalid @@ -119,17 +241,27 @@ export class TypedWorker { // pass the key at all (exactOptionalPropertyTypes discipline — and Temporal // treats an absent map as "don't poll for Activity Tasks"). return fromPromise( - Worker.create({ - ...workerOptions, - ...(activities !== undefined ? { activities } : {}), - taskQueue: contract.taskQueue, - }), + (async () => { + // Registration completeness check (default on) — only meaningful + // when a `workflowsPath` module is supplied; prebuilt + // `workflowBundle`s are skipped. See the option's JSDoc. + if ((verifyRegistration ?? true) && typeof workerOptions.workflowsPath === "string") { + await verifyWorkflowRegistration(contract, workerOptions.workflowsPath); + } + return Worker.create({ + ...workerOptions, + ...(activities !== undefined ? { activities } : {}), + taskQueue: contract.taskQueue, + }); + })(), (cause, defect) => defect( - new TechnicalError( - `Failed to create Temporal worker for task queue "${contract.taskQueue}"`, - cause, - ), + cause instanceof TechnicalError + ? cause + : new TechnicalError( + `Failed to create Temporal worker for task queue "${contract.taskQueue}"`, + cause, + ), ), ).map((worker) => new TypedWorker(worker, contract.taskQueue)); } diff --git a/packages/worker/src/workflow-brand.ts b/packages/worker/src/workflow-brand.ts new file mode 100644 index 00000000..1d8c3ade --- /dev/null +++ b/packages/worker/src/workflow-brand.ts @@ -0,0 +1,31 @@ +/** + * Brand marker shared between `declareWorkflow` (which stamps it) and + * `TypedWorker.create`'s workflow-registration check (which reads it). Kept + * in a leaf module so the `worker` entry point doesn't have to import the + * whole workflow surface (and its `@temporalio/workflow` dependency) just to + * read the brand. + */ + +/** + * Brand key marking functions produced by `declareWorkflow`. + * `Symbol.for` (not a private symbol) so the marker survives duplicated + * module instances — the worker's registration check may import the + * workflows module in the main thread while the workflow bundle carries its + * own copy of this package. + * + * @internal + */ +export const DECLARED_WORKFLOW_BRAND = Symbol.for("temporal-contract.declareWorkflow"); + +/** + * Read the `workflowName` a `declareWorkflow`-produced function was declared + * with, or `undefined` for anything else. Used by `TypedWorker`'s + * workflow-registration completeness check. + * + * @internal + */ +export function _internal_declaredWorkflowName(candidate: unknown): string | undefined { + if (typeof candidate !== "function") return undefined; + const brand = (candidate as { [DECLARED_WORKFLOW_BRAND]?: unknown })[DECLARED_WORKFLOW_BRAND]; + return typeof brand === "string" ? brand : undefined; +} diff --git a/packages/worker/src/workflow.ts b/packages/worker/src/workflow.ts index d2713c46..205190fe 100644 --- a/packages/worker/src/workflow.ts +++ b/packages/worker/src/workflow.ts @@ -68,6 +68,7 @@ import { type WorkerInferInput, type WorkerInferOutput, } from "./types.js"; +import { DECLARED_WORKFLOW_BRAND } from "./workflow-brand.js"; export { ActivityCancelledError, @@ -101,6 +102,52 @@ export { type ContractErrorUnion, } from "@temporal-contract/contract/errors"; +// Cancellation re-raise helper: cancellation surfaces on the modeled +// `Err(...)` channel, which generic error handling can swallow (turning a +// server-side cancel into a `Completed` outcome). `rethrowCancellation` +// re-raises the original CancelledFailure so the execution ends `Cancelled`. +export { rethrowCancellation } from "./errors.js"; + +// Literal-typed `_tag` constants for this package's tagged errors, so +// consumers can `P.tag(ACTIVITY_ERROR_TAG)` without hand-writing the +// namespaced strings (mirrors the contract package's error-tags module). +export { + ACTIVITY_CANCELLED_ERROR_TAG, + ACTIVITY_DEFINITION_NOT_FOUND_ERROR_TAG, + ACTIVITY_ERROR_TAG, + CHILD_WORKFLOW_CANCELLED_ERROR_TAG, + CHILD_WORKFLOW_ERROR_TAG, + CHILD_WORKFLOW_NOT_FOUND_ERROR_TAG, + WORKFLOW_CANCELLED_ERROR_TAG, +} from "./error-tags.js"; + +// Public child-workflow types: the handle returned by +// `context.startChildWorkflow`, its options bag, and the typed signal-sender +// map — exported so user code can annotate stored handles and helpers. +export type { + TypedChildWorkflowHandle, + TypedChildWorkflowOptions, + TypedChildWorkflowSignals, +} from "./child-workflow.js"; + +// Public handler-implementation types for `context.handleSignal` / +// `handleQuery` / `handleUpdate`, so handlers can be declared standalone. +export type { + QueryHandlerImplementation, + SignalHandlerImplementation, + UpdateHandlerImplementation, +} from "./handlers.js"; + +// Public activity-inference types: the workflow-side shape of a single +// activity and of the full `context.activities` map. +export type { + WorkflowInferActivity, + WorkflowInferWorkflowContextActivities, +} from "./activities-proxy.js"; + +// Continue-as-new options type referenced by `WorkflowContext.continueAsNew`. +export type { TypedContinueAsNewOptions } from "./internal.js"; + /** * Create a typed workflow implementation with automatic validation * @@ -294,7 +341,7 @@ export function declareWorkflow< // Create workflow context. // - // The defineSignal / defineQuery / defineUpdate arrows forward to the + // The handleSignal / handleQuery / handleUpdate arrows forward to the // hoisted helpers in `./handlers.ts`. The arrows themselves are thin // closures that close over `definition` and `workflowName`; the heavy // logic — runtime guards, validation, Temporal `defineSignal/Query/ @@ -303,7 +350,7 @@ export function declareWorkflow< // // The cast at each assignment preserves the typed call-site surface // (the `K extends keyof ...` constraints declared on - // `WorkflowContext.defineSignal/Query/Update`), while the helpers + // `WorkflowContext.handleSignal/Query/Update`), while the helpers // themselves take loosely-typed arguments at the runtime boundary. const context: WorkflowContext = { activities: contextActivities as WorkflowInferWorkflowContextActivities< @@ -315,27 +362,27 @@ export function declareWorkflow< executeChildWorkflow: createExecuteChildWorkflow, cancellableScope, nonCancellableScope, - defineSignal: ((signalName, handler) => + handleSignal: ((signalName, handler) => bindSignalHandler( definition, workflowName, signalName, handler as unknown as SignalHandlerImplementation, - )) as WorkflowContext["defineSignal"], - defineQuery: ((queryName, handler) => + )) as WorkflowContext["handleSignal"], + handleQuery: ((queryName, handler) => bindQueryHandler( definition, workflowName, queryName, handler as unknown as QueryHandlerImplementation, - )) as WorkflowContext["defineQuery"], - defineUpdate: ((updateName, handler) => + )) as WorkflowContext["handleQuery"], + handleUpdate: ((updateName, handler) => bindUpdateHandler( definition, workflowName, updateName, handler as unknown as UpdateHandlerImplementation, - )) as WorkflowContext["defineUpdate"], + )) as WorkflowContext["handleUpdate"], continueAsNew: createContinueAsNew(contract, workflowName) as WorkflowContext< TContract, TWorkflowName @@ -387,6 +434,18 @@ export function declareWorkflow< // workflow type; without this the anonymous arrow above would surface as "". Object.defineProperty(workflowFn, "name", { value: workflowName, configurable: true }); + // Non-enumerable brand carrying the declared workflowName, so + // `TypedWorker.create`'s registration check can identify + // declareWorkflow-produced exports (and their intended registration name) + // when it imports the workflows module. Non-enumerable keeps it invisible + // to spreads / Object.keys; `Symbol.for` keeps it recognizable across + // duplicated module instances. + Object.defineProperty(workflowFn, DECLARED_WORKFLOW_BRAND, { + value: workflowName, + enumerable: false, + configurable: true, + }); + return workflowFn; } @@ -408,7 +467,7 @@ type ActivityNamesFor< /** * Options for declaring a workflow implementation */ -type DeclareWorkflowOptions< +export type DeclareWorkflowOptions< TContract extends ContractDefinition, TWorkflowName extends keyof TContract["workflows"] & string, > = { @@ -493,7 +552,7 @@ type DeclareWorkflowOptions< * Receives a workflow context (with typed activities and utilities) and validated input arguments. * Returns the workflow output which will be validated against the contract schema. */ -type WorkflowImplementation< +export type WorkflowImplementation< TContract extends ContractDefinition, TWorkflowName extends keyof TContract["workflows"] & string, > = ( @@ -520,7 +579,7 @@ type WorkflowErrorConstructorsOf = TWorkflow extends { ? ContractErrorConstructors : Record; -type WorkflowContext< +export type WorkflowContext< TContract extends ContractDefinition, TWorkflowName extends keyof TContract["workflows"] & string, > = { @@ -546,15 +605,16 @@ type WorkflowContext< errors: WorkflowErrorConstructorsOf; /** - * Define a signal handler within the workflow implementation - * Allows the signal handler to access workflow state + * Bind a signal handler within the workflow implementation (`handle*` — + * the in-workflow binding tier of the verb convention). Allows the signal + * handler to access workflow state. * * @example * ```ts * implementation: async (context, args) => { * let currentValue = args.initialValue; * - * context.defineSignal('increment', async (signalArgs) => { + * context.handleSignal('increment', async (signalArgs) => { * currentValue += signalArgs.amount; * }); * @@ -562,21 +622,26 @@ type WorkflowContext< * } * ``` */ - defineSignal: >( + handleSignal: >( signalName: K, handler: SignalHandlerImplementation>, ) => void; /** - * Define a query handler within the workflow implementation - * Allows the query handler to access workflow state + * Bind a query handler within the workflow implementation (`handle*` — + * the in-workflow binding tier of the verb convention). Allows the query + * handler to access workflow state. + * + * Both query schemas (input and output) must validate synchronously — + * Temporal runs query handlers synchronously. An async-validating schema + * (e.g. zod async refine) trips a `ContractMisuseError` at bind time. * * @example * ```ts * implementation: async (context, args) => { * let currentValue = args.initialValue; * - * context.defineQuery('getCurrentValue', () => { + * context.handleQuery('getCurrentValue', () => { * return { value: currentValue }; * }); * @@ -584,21 +649,27 @@ type WorkflowContext< * } * ``` */ - defineQuery: >( + handleQuery: >( queryName: K, handler: QueryHandlerImplementation>, ) => void; /** - * Define an update handler within the workflow implementation - * Allows the update handler to access and modify workflow state + * Bind an update handler within the workflow implementation (`handle*` — + * the in-workflow binding tier of the verb convention). Allows the update + * handler to access and modify workflow state. + * + * The update's *input* schema must validate synchronously — it feeds + * Temporal's synchronous update validator slot. An async-validating schema + * trips a `ContractMisuseError` at bind time (the output schema may be + * async; it runs inside the handler body). * * @example * ```ts * implementation: async (context, args) => { * let currentValue = args.initialValue; * - * context.defineUpdate('multiply', async (updateArgs) => { + * context.handleUpdate('multiply', async (updateArgs) => { * currentValue *= updateArgs.factor; * return { newValue: currentValue }; * }); @@ -607,7 +678,7 @@ type WorkflowContext< * } * ``` */ - defineUpdate: >( + handleUpdate: >( updateName: K, handler: UpdateHandlerImplementation>, ) => void; From 7cf22231ae64d68139d6bc615191dce0ace282d0 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 31 Jul 2026 09:38:34 +0200 Subject: [PATCH 06/15] feat(client)!: first-class outcome/update/query errors, tag bundles, combinator refactor - WorkflowCancelledError/WorkflowTerminatedError/WorkflowTimeoutError classified from result paths - UpdateFailedError/UpdateRejectedError/QueryFailedError replace defect-channel leaks - error tag constants + P-composable tag bundles (tagPatterns helper) - handle.raw escape hatch; ContractClient/TypedScheduleClient constructors privatized - ContractClient exposes contract and taskQueue; DATETIME rejects invalid Date - interceptors can patch only input/signalInput, never identity fields - imperative assertNoDefect thunks replaced with AsyncResult combinator chains - schedule update() validates args against the contract when workflowType is declared --- .../order-processing-client/src/client.ts | 48 +- packages/client/src/__tests__/client.spec.ts | 13 +- packages/client/src/client.spec.ts | 413 +++++++- packages/client/src/client.ts | 908 ++++++++++-------- packages/client/src/error-tags.ts | 131 +++ packages/client/src/errors.ts | 268 +++++- packages/client/src/index.ts | 40 +- packages/client/src/interceptors.ts | 58 +- packages/client/src/internal.ts | 214 ++++- packages/client/src/schedule.spec.ts | 105 +- packages/client/src/schedule.ts | 119 ++- packages/client/src/types-inference.spec.ts | 126 ++- packages/client/src/types.ts | 18 +- 13 files changed, 1922 insertions(+), 539 deletions(-) create mode 100644 packages/client/src/error-tags.ts diff --git a/examples/order-processing-client/src/client.ts b/examples/order-processing-client/src/client.ts index 89f868d2..71d4b4c5 100644 --- a/examples/order-processing-client/src/client.ts +++ b/examples/order-processing-client/src/client.ts @@ -1,4 +1,10 @@ -import { TypedClient } from "@temporal-contract/client"; +import { + tagPatterns, + TypedClient, + WORKFLOW_OUTCOME_ERROR_TAGS, + WORKFLOW_RESULT_ERROR_TAGS, + WORKFLOW_START_ERROR_TAGS, +} from "@temporal-contract/client"; import { orderProcessingContract, type OrderSchema, @@ -126,14 +132,11 @@ async function run() { `❌ Payment declined: ${err.data.reason}`, ), ) - .with(P.tag("@temporal-contract/WorkflowValidationError"), (err) => - logger.error({ error: err }, "❌ Workflow output validation failed"), - ) - .with(P.tag("@temporal-contract/WorkflowFailedError"), (err) => - logger.error({ error: err, cause: err.cause }, "❌ Workflow completed with failure"), - ) - .with(P.tag("@temporal-contract/WorkflowExecutionNotFoundError"), (err) => - logger.error({ error: err }, "❌ Workflow execution not found in namespace"), + // Everything else the result phase can surface — validation, generic + // failure, the first-class outcome trio (cancelled/terminated/timed + // out), and a missing execution — grouped via the exported bundle. + .with(...tagPatterns(WORKFLOW_RESULT_ERROR_TAGS), (err) => + logger.error({ error: err }, "❌ Workflow did not complete successfully"), ), defect: (cause) => logger.error({ cause }, "❌ Unexpected failure awaiting result"), }); @@ -192,6 +195,12 @@ async function run() { .with(P.tag("@temporal-contract/ContractError"), (err) => logger.error({ errorName: err.errorName }, "❌ Payment declined"), ) + // The first-class outcome errors get their own arm here: a + // server-side cancel / terminate / timeout is a distinct outcome, + // not a generic "failure" — no `err.cause instanceof ...` digging. + .with(...tagPatterns(WORKFLOW_OUTCOME_ERROR_TAGS), (err) => + logger.warn({ error: err }, `🛑 Workflow ${err.name}: execution was stopped`), + ) .with(P.tag("@temporal-contract/WorkflowValidationError"), (err) => logger.error({ error: err }, "❌ Workflow output validation failed"), ) @@ -246,23 +255,20 @@ async function run() { `❌ Payment declined: ${err.data.reason}`, ), ) - .with(P.tag("@temporal-contract/WorkflowNotInContractError"), (err) => - logger.error({ error: err }, "❌ Workflow not declared in the contract"), - ) - .with(P.tag("@temporal-contract/WorkflowValidationError"), (err) => - logger.error({ error: err }, "❌ Validation failed"), - ) // Idempotent fast-path: a workflow with this ID is already running // (or in retention). Production callers can re-fetch the existing - // handle; here we just log and move on. + // handle; here we just log and move on. (Handled before the grouped + // bundles so it keeps its dedicated branch.) .with(P.tag("@temporal-contract/WorkflowAlreadyStartedError"), (err) => logger.warn({ error: err }, "⏭️ Workflow already started — skipping"), ) - .with(P.tag("@temporal-contract/WorkflowFailedError"), (err) => - logger.error({ error: err, cause: err.cause }, "❌ Workflow completed with failure"), - ) - .with(P.tag("@temporal-contract/WorkflowExecutionNotFoundError"), (err) => - logger.error({ error: err }, "❌ Workflow execution not found in namespace"), + // Everything else executeWorkflow can err with — the start-phase and + // result-phase bundles together cover the full union, so a widened + // union in a future release fails compilation right here. + .with( + ...tagPatterns(WORKFLOW_START_ERROR_TAGS), + ...tagPatterns(WORKFLOW_RESULT_ERROR_TAGS), + (err) => logger.error({ error: err }, "❌ Order processing failed"), ), // A defect is an unmodeled failure (a bug) — including technical/ // infrastructure faults like a dropped connection (a `RuntimeClientError` diff --git a/packages/client/src/__tests__/client.spec.ts b/packages/client/src/__tests__/client.spec.ts index cdf34f05..dc20b0e0 100644 --- a/packages/client/src/__tests__/client.spec.ts +++ b/packages/client/src/__tests__/client.spec.ts @@ -4,11 +4,11 @@ import { fileURLToPath } from "node:url"; import { it as baseIt } from "@temporal-contract/testing/extension"; import { Client } from "@temporalio/client"; import { Worker } from "@temporalio/worker"; -import { P } from "unthrown"; import { describe, expect, vi, beforeEach } from "vitest"; import { type ContractClient, TypedClient } from "../client.js"; -import { WorkflowValidationError } from "../errors.js"; +import { WORKFLOW_RESULT_ERROR_TAGS, WORKFLOW_START_ERROR_TAGS } from "../error-tags.js"; +import { tagPatterns, WorkflowValidationError } from "../errors.js"; import { secondContract } from "./second.contract.js"; import { testContract } from "./test.contract.js"; @@ -547,12 +547,11 @@ describe("Client Package - Integration Tests", () => { expect(value).toEqual({ result: "Processed: test" }); }, errCases: (matcher) => + // The exported tag bundles cover executeWorkflow's full error + // union (start phase + result phase, outcome trio included). matcher.with( - P.tag("@temporal-contract/WorkflowNotInContractError"), - P.tag("@temporal-contract/WorkflowValidationError"), - P.tag("@temporal-contract/WorkflowAlreadyStartedError"), - P.tag("@temporal-contract/WorkflowFailedError"), - P.tag("@temporal-contract/WorkflowExecutionNotFoundError"), + ...tagPatterns(WORKFLOW_START_ERROR_TAGS), + ...tagPatterns(WORKFLOW_RESULT_ERROR_TAGS), () => { throw new Error("Should not be called"); }, diff --git a/packages/client/src/client.spec.ts b/packages/client/src/client.spec.ts index bc09cc32..c1a115e9 100644 --- a/packages/client/src/client.spec.ts +++ b/packages/client/src/client.spec.ts @@ -9,29 +9,42 @@ import { import { ContractError, TechnicalError } from "@temporal-contract/contract/errors"; import { type Client, + QueryNotRegisteredError as TemporalQueryNotRegisteredError, WorkflowExecutionAlreadyStartedError, WorkflowFailedError as TemporalWorkflowFailedError, + WorkflowUpdateFailedError as TemporalWorkflowUpdateFailedError, } from "@temporalio/client"; import { ApplicationFailure, + CancelledFailure, defineSearchAttributeKey, + TerminatedFailure, + TimeoutFailure, + TimeoutType, TypedSearchAttributes, WorkflowNotFoundError as TemporalWorkflowNotFoundError, } from "@temporalio/common"; -import { P } from "unthrown"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { z } from "zod"; import { ContractClient, readTypedSearchAttributes, TypedClient } from "./client.js"; +import { WORKFLOW_RESULT_ERROR_TAGS, WORKFLOW_START_ERROR_TAGS } from "./error-tags.js"; import { + QueryFailedError, QueryValidationError, RuntimeClientError, SignalValidationError, + tagPatterns, + UpdateFailedError, + UpdateRejectedError, UpdateValidationError, WorkflowAlreadyStartedError, + WorkflowCancelledError, WorkflowExecutionNotFoundError, WorkflowFailedError, WorkflowNotInContractError, + WorkflowTerminatedError, + WorkflowTimeoutError, WorkflowValidationError, } from "./errors.js"; import type { ClientInterceptor } from "./interceptors.js"; @@ -122,10 +135,33 @@ vi.mock("@temporalio/client", () => { super(message); } } + // Mirrors Temporal's `WorkflowUpdateFailedError` shape (message + cause) — + // thrown while waiting on an update outcome, for both admission + // rejections and failed handlers. + class WorkflowUpdateFailedError extends Error { + constructor( + message: string, + public override readonly cause: Error | undefined, + ) { + super(message); + } + } + // Mirrors Temporal's `QueryNotRegisteredError` (message + gRPC code) — + // thrown for unregistered queries AND throwing query handlers. + class QueryNotRegisteredError extends Error { + constructor( + message: string, + public readonly code: number, + ) { + super(message); + } + } return { WorkflowHandle: vi.fn(), WorkflowExecutionAlreadyStartedError, WorkflowFailedError, + WorkflowUpdateFailedError, + QueryNotRegisteredError, ScheduleAlreadyRunning, ScheduleNotFoundError, }; @@ -941,12 +977,11 @@ describe("TypedClient", () => { expect(value).toEqual({ result: "success" }); }, errCases: (matcher) => + // The two exported tag bundles cover executeWorkflow's full error + // union (start phase + result phase, outcome trio included). matcher.with( - P.tag("@temporal-contract/WorkflowNotInContractError"), - P.tag("@temporal-contract/WorkflowValidationError"), - P.tag("@temporal-contract/WorkflowAlreadyStartedError"), - P.tag("@temporal-contract/WorkflowFailedError"), - P.tag("@temporal-contract/WorkflowExecutionNotFoundError"), + ...tagPatterns(WORKFLOW_START_ERROR_TAGS), + ...tagPatterns(WORKFLOW_RESULT_ERROR_TAGS), () => { throw new Error("Should not be called"); }, @@ -1350,6 +1385,84 @@ describe("TypedClient", () => { } }); + it("executeWorkflow classifies a CancelledFailure cause into WorkflowCancelledError", async () => { + const cancelled = new CancelledFailure("cancel requested"); + mockWorkflow.execute.mockRejectedValue( + new TemporalWorkflowFailedError("failed", cancelled, "NON_RETRYABLE_FAILURE"), + ); + + const result = await typedClient.executeWorkflow("testWorkflow", { + workflowId: "test-123", + args: { name: "hello", value: 42 }, + }); + + expect(result).toBeErr(); + if (result.isErr()) { + expect(result.error).toBeInstanceOf(WorkflowCancelledError); + const err = result.error as WorkflowCancelledError; + expect(err.workflowId).toBe("test-123"); + // The original CancelledFailure is kept as the cause. + expect(err.cause).toBe(cancelled); + // The generic wrapper is NOT used for the outcome trio. + expect(result.error).not.toBeInstanceOf(WorkflowFailedError); + } + }); + + it("executeWorkflow classifies a TimeoutFailure cause into WorkflowTimeoutError", async () => { + const timedOut = new TimeoutFailure( + "deadline exceeded", + undefined, + TimeoutType.START_TO_CLOSE, + ); + mockWorkflow.execute.mockRejectedValue( + new TemporalWorkflowFailedError("failed", timedOut, "TIMEOUT"), + ); + + const result = await typedClient.executeWorkflow("testWorkflow", { + workflowId: "test-123", + args: { name: "hello", value: 42 }, + }); + + expect(result).toBeErr(); + if (result.isErr()) { + expect(result.error).toBeInstanceOf(WorkflowTimeoutError); + expect((result.error as WorkflowTimeoutError).cause).toBe(timedOut); + } + }); + + it("handle.result() classifies a TerminatedFailure cause into WorkflowTerminatedError", async () => { + const terminated = new TerminatedFailure("operator said so"); + const handle = { + workflowId: "test-123", + result: vi + .fn() + .mockRejectedValue( + new TemporalWorkflowFailedError("failed", terminated, "NON_RETRYABLE_FAILURE"), + ), + query: vi.fn(), + signal: vi.fn(), + executeUpdate: vi.fn(), + terminate: vi.fn(), + cancel: vi.fn(), + describe: vi.fn(), + fetchHistory: vi.fn(), + }; + mockWorkflow.getHandle.mockReturnValue(handle); + + const handleResult = typedClient.getHandle("testWorkflow", "test-123"); + if (!handleResult.isOk()) throw new Error("getHandle should succeed"); + const result = await handleResult.value.result(); + + expect(result).toBeErr(); + if (result.isErr()) { + expect(result.error).toBeInstanceOf(WorkflowTerminatedError); + const err = result.error as WorkflowTerminatedError; + expect(err.workflowId).toBe("test-123"); + expect(err.cause).toBe(terminated); + expect(err.message).toContain("operator said so"); + } + }); + it("falls back to handle.workflowId when Temporal's WorkflowNotFoundError carries an empty workflowId", async () => { // Temporal's runtime sometimes constructs WorkflowNotFoundError with // workflowId = "" (when the upstream error doesn't include the id). @@ -1878,6 +1991,40 @@ describe("TypedClient — interceptors", () => { expect(mockWorkflow.execute).not.toHaveBeenCalled(); }); + it("a patch merges ONLY the payload keys — identity fields cannot be rewritten", async () => { + mockWorkflow.execute.mockResolvedValue({ result: "ok" }); + const seen: string[] = []; + // A hostile/buggy interceptor smuggles identity fields into the patch + // beside a legitimate input patch. Only the payload keys may merge. + const smuggling: ClientInterceptor = (_args, next) => + next({ + operation: "signal", + workflowName: "evil", + workflowId: "evil-id", + input: { name: "patched", value: 7 }, + } as never); + const observing: ClientInterceptor = (args, next) => { + seen.push(`${args.operation}:${args.workflowName}:${args.workflowId}`); + return next(); + }; + + const result = await ( + await clientWith([smuggling, observing]) + ).executeWorkflow("testWorkflow", { + workflowId: "wf-identity", + args: { name: "original", value: 1 }, + }); + + expect(result).toBeOk(); + // The downstream interceptor still sees the call's true identity... + expect(seen).toEqual(["executeWorkflow:testWorkflow:wf-identity"]); + // ...while the payload patch went through the normal validation pipeline. + expect(mockWorkflow.execute).toHaveBeenCalledWith( + "testWorkflow", + expect.objectContaining({ args: [{ name: "patched", value: 7 }] }), + ); + }); + it("can retry by calling next again", async () => { mockWorkflow.execute .mockRejectedValueOnce(new Error("transient")) @@ -2167,6 +2314,237 @@ describe("ContractClient — startUpdate", () => { }); }); +describe("ContractClient — update/query operational errors", () => { + // Closes the defect-channel hole for routine operational outcomes: a + // failed/rejected update and an unserveable query are modeled Errs now, + // not defects. + const opContract = defineContract({ + taskQueue: "op-q", + workflows: { + opWorkflow: defineWorkflow({ + input: z.object({ id: z.string() }), + output: z.object({ ok: z.boolean() }), + queries: { + peek: { input: z.tuple([]), output: z.string() }, + }, + updates: { + adjust: { input: z.object({ delta: z.number() }), output: z.number() }, + }, + }), + }, + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const getOpHandle = async (rawHandle: Record) => { + mockWorkflow.getHandle.mockReturnValue(rawHandle); + const client = await bindContract(opContract, { + workflow: mockWorkflow, + schedule: mockSchedule, + } as unknown as Client); + const handleResult = client.getHandle("opWorkflow", "wf-op"); + if (!handleResult.isOk()) throw new Error("expected Ok"); + return handleResult.value; + }; + + // The wire shape of a worker-side admission rejection: the worker's + // update-input validator throws an ApplicationFailure whose `type` is + // pinned to "UpdateInputValidationError". + const rejectionFailure = () => + ApplicationFailure.create({ + type: "UpdateInputValidationError", + message: 'Update "adjust" input validation failed: at delta: expected number', + nonRetryable: true, + }); + + it("updates.* classifies a worker-side admission rejection into UpdateRejectedError", async () => { + const inner = rejectionFailure(); + const executeUpdate = vi + .fn() + .mockRejectedValue(new TemporalWorkflowUpdateFailedError("Workflow Update failed", inner)); + const handle = await getOpHandle({ workflowId: "wf-op", executeUpdate }); + + const result = await handle.updates.adjust({ delta: 1 }); + + expect(result).toBeErr(); + if (result.isErr()) { + expect(result.error).toBeInstanceOf(UpdateRejectedError); + const err = result.error as UpdateRejectedError; + expect(err.updateName).toBe("adjust"); + // The original ApplicationFailure is kept as cause (wrapper unwrapped). + expect(err.cause).toBe(inner); + } + }); + + it("updates.* classifies a failed admitted handler into UpdateFailedError with the inner cause unwrapped", async () => { + const inner = ApplicationFailure.create({ type: "PaymentDeclined", message: "no funds" }); + const executeUpdate = vi + .fn() + .mockRejectedValue(new TemporalWorkflowUpdateFailedError("Workflow Update failed", inner)); + const handle = await getOpHandle({ workflowId: "wf-op", executeUpdate }); + + const result = await handle.updates.adjust({ delta: 1 }); + + expect(result).toBeErr(); + if (result.isErr()) { + expect(result.error).toBeInstanceOf(UpdateFailedError); + expect(result.error).not.toBeInstanceOf(UpdateRejectedError); + const err = result.error as UpdateFailedError; + expect(err.updateName).toBe("adjust"); + expect(err.cause).toBe(inner); + expect(err.cause).not.toBeInstanceOf(TemporalWorkflowUpdateFailedError); + } + }); + + it("updates.* still routes unrecognized rejections to the defect channel", async () => { + const executeUpdate = vi.fn().mockRejectedValue(new Error("grpc blew up")); + const handle = await getOpHandle({ workflowId: "wf-op", executeUpdate }); + + const result = await handle.updates.adjust({ delta: 1 }); + + expect(result).toBeDefect(); + if (result.isDefect()) { + expect(result.cause).toBeInstanceOf(RuntimeClientError); + expect((result.cause as RuntimeClientError).operation).toBe("update"); + } + }); + + it("startUpdate classifies WorkflowUpdateFailedError (rejection at admission)", async () => { + const inner = rejectionFailure(); + const startUpdate = vi + .fn() + .mockRejectedValue(new TemporalWorkflowUpdateFailedError("Workflow Update failed", inner)); + const handle = await getOpHandle({ workflowId: "wf-op", startUpdate }); + + const result = await handle.startUpdate("adjust", { args: { delta: 1 } }); + + expect(result).toBeErr(); + if (result.isErr()) { + expect(result.error).toBeInstanceOf(UpdateRejectedError); + } + }); + + it("updateHandle.result() classifies update outcomes too", async () => { + const inner = ApplicationFailure.create({ type: "SomethingBusiness", message: "boom" }); + const startUpdate = vi.fn().mockResolvedValue({ + updateId: "upd-1", + workflowId: "wf-op", + workflowRunId: "run-1", + result: vi + .fn() + .mockRejectedValue(new TemporalWorkflowUpdateFailedError("Workflow Update failed", inner)), + }); + const handle = await getOpHandle({ workflowId: "wf-op", startUpdate }); + + const updateHandleResult = await handle.startUpdate("adjust", { args: { delta: 1 } }); + expect(updateHandleResult).toBeOk(); + if (!updateHandleResult.isOk()) return; + + const result = await updateHandleResult.value.result(); + expect(result).toBeErr(); + if (result.isErr()) { + expect(result.error).toBeInstanceOf(UpdateFailedError); + expect((result.error as UpdateFailedError).cause).toBe(inner); + } + }); + + it("queries.* classifies QueryNotRegisteredError into QueryFailedError", async () => { + // Temporal reports BOTH "no handler registered" and "the handler threw" + // as QueryNotRegisteredError (INVALID_ARGUMENT), so both surface as the + // modeled QueryFailedError. + const inner = new TemporalQueryNotRegisteredError( + "Workflow did not register a handler for peek", + 3, + ); + const query = vi.fn().mockRejectedValue(inner); + const handle = await getOpHandle({ workflowId: "wf-op", query }); + + const result = await handle.queries.peek([]); + + expect(result).toBeErr(); + if (result.isErr()) { + expect(result.error).toBeInstanceOf(QueryFailedError); + const err = result.error as QueryFailedError; + expect(err.queryName).toBe("peek"); + expect(err.cause).toBe(inner); + } + }); + + it("queries.* still routes unrecognized rejections to the defect channel", async () => { + const query = vi.fn().mockRejectedValue(new Error("network down")); + const handle = await getOpHandle({ workflowId: "wf-op", query }); + + const result = await handle.queries.peek([]); + + expect(result).toBeDefect(); + if (result.isDefect()) { + expect(result.cause).toBeInstanceOf(RuntimeClientError); + expect((result.cause as RuntimeClientError).operation).toBe("query"); + } + }); +}); + +describe("ContractClient — raw escape hatch and accessors", () => { + const accessorContract = defineContract({ + taskQueue: "accessor-q", + workflows: { + plain: defineWorkflow({ + input: z.object({ id: z.string() }), + output: z.object({}), + }), + }, + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("handle.raw exposes the underlying Temporal WorkflowHandle", async () => { + const rawHandle = { workflowId: "wf-raw", result: vi.fn() }; + mockWorkflow.getHandle.mockReturnValue(rawHandle); + const client = await bindContract(accessorContract, { + workflow: mockWorkflow, + schedule: mockSchedule, + } as unknown as Client); + + const handleResult = client.getHandle("plain", "wf-raw"); + expect(handleResult).toBeOk(); + if (handleResult.isOk()) { + expect(handleResult.value.raw).toBe(rawHandle); + } + }); + + it("startWorkflow handles carry raw too", async () => { + const rawHandle = { workflowId: "wf-raw-2", firstExecutionRunId: "run-1", result: vi.fn() }; + mockWorkflow.start.mockResolvedValue(rawHandle); + const client = await bindContract(accessorContract, { + workflow: mockWorkflow, + schedule: mockSchedule, + } as unknown as Client); + + const handleResult = await client.startWorkflow("plain", { + workflowId: "wf-raw-2", + args: { id: "a" }, + }); + expect(handleResult).toBeOk(); + if (handleResult.isOk()) { + expect(handleResult.value.raw).toBe(rawHandle); + } + }); + + it("exposes the bound contract and its taskQueue for logging/plumbing", async () => { + const client = await bindContract(accessorContract, { + workflow: mockWorkflow, + schedule: mockSchedule, + } as unknown as Client); + + expect(client.contract).toBe(accessorContract); + expect(client.taskQueue).toBe("accessor-q"); + }); +}); + describe("ContractClient — omittable input-less payloads (runtime)", () => { // Wave 1 made `defineSignal()` / `defineQuery({output})` / // `defineUpdate({output})` materialize an UndefinedInputSchema. The @@ -2349,6 +2727,29 @@ describe("ContractClient — search attribute VALUE validation (runtime)", () => expect(result).toBeOk(); }); + it("rejects an invalid Date (new Date(NaN)) for DATETIME attributes", async () => { + const client = await bindContract(kindContract, { + workflow: mockWorkflow, + schedule: mockSchedule, + } as unknown as Client); + + const result = await client.startWorkflow("kinds", { + workflowId: "k-invalid-date", + args: { id: "a" }, + searchAttributes: { placedAt: new Date(Number.NaN) }, + }); + + expect(result).toBeDefect(); + if (result.isDefect()) { + expect(result.cause).toBeInstanceOf(RuntimeClientError); + const message = (result.cause as RuntimeClientError).message; + expect(message).toContain("placedAt"); + expect(message).toContain("must be a valid Date"); + expect(message).toContain("received an invalid Date."); + } + expect(mockWorkflow.start).not.toHaveBeenCalled(); + }); + it("rejects non-string entries inside a KEYWORD_LIST", async () => { const client = await bindContract(kindContract, { workflow: mockWorkflow, diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 090e3c0b..ad68e8f6 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -18,12 +18,35 @@ import type { WorkflowStartOptions, } from "@temporalio/client"; import { defineSearchAttributeKey, type TypedSearchAttributes } from "@temporalio/common"; -import { type AsyncResult, type Result, Ok, Err, fromPromise } from "unthrown"; +import { + type AsyncResult, + type Result, + Ok, + Err, + OkAsync, + ErrAsync, + fromPromise, + P, +} from "unthrown"; import { + WORKFLOW_ALREADY_STARTED_ERROR_TAG, + WORKFLOW_CANCELLED_ERROR_TAG, + WORKFLOW_EXECUTION_NOT_FOUND_ERROR_TAG, + WORKFLOW_FAILED_ERROR_TAG, + WORKFLOW_TERMINATED_ERROR_TAG, + WORKFLOW_TIMEOUT_ERROR_TAG, +} from "./error-tags.js"; +import { + type QueryFailedError, + type UpdateFailedError, + type UpdateRejectedError, type WorkflowAlreadyStartedError, + type WorkflowCancelledError, type WorkflowExecutionNotFoundError, type WorkflowFailedError, + type WorkflowTerminatedError, + type WorkflowTimeoutError, WorkflowNotInContractError, WorkflowValidationError, QueryValidationError, @@ -38,12 +61,15 @@ import { type ClientInterceptorArgs, } from "./interceptors.js"; import { - assertNoDefect, - classifyExecutionResultError, classifyHandleError, + classifyQueryError, + classifyResultError, classifyStartError, + classifyUpdateError, makeAsyncResult, + rehydrateFailedResult, toTypedSearchAttributes, + validateStandardSchema, } from "./internal.js"; import { TypedScheduleClient } from "./schedule.js"; import type { @@ -69,6 +95,23 @@ export type WorkflowContractErrorsOf = ? ContractErrorUnion : never; +/** + * Union of the modeled errors a result-awaiting call can surface for a + * workflow — the shared tail of {@link ContractClient.executeWorkflow} and + * {@link TypedWorkflowHandle.result}: any contract error declared on the + * workflow, plus output validation, the generic completion failure, the + * three first-class workflow outcomes (cancelled / terminated / timed out), + * and a missing execution. + */ +export type WorkflowResultErrorsOf = + | WorkflowContractErrorsOf + | WorkflowValidationError + | WorkflowFailedError + | WorkflowCancelledError + | WorkflowTerminatedError + | WorkflowTimeoutError + | WorkflowExecutionNotFoundError; + /** * Typed `searchAttributes` map for a workflow, derived from the workflow's * declared `searchAttributes`. Each key is constrained to a declared @@ -179,7 +222,10 @@ type SignalArgsField = TSignalDef extends SignalDefinition /** * Options for {@link ContractClient.signalWithStart} — typed against both - * the workflow's input schema and the named signal's input schema. + * the workflow's input schema and the named signal's input schema. The + * signal is addressed by the `signalName` field of this options bag (there + * is no positional signal parameter), keeping the method at two positional + * arguments like the rest of the surface. */ export type TypedSignalWithStartOptions< TContract extends ContractDefinition, @@ -218,7 +264,8 @@ export type TypedGetHandleOptions = GetWorkflowHandleOptions & { /** * Options for {@link TypedWorkflowHandle.startUpdate} — the update payload - * plus the passthrough subset of Temporal's `WorkflowUpdateOptions`. + * plus the passthrough subset of Temporal's `WorkflowUpdateOptions`. Passed + * as the second (positional) argument after the update name. */ export type TypedStartUpdateOptions = { /** @@ -236,6 +283,17 @@ export type TypedStartUpdateOptions = { ? { args?: ClientInferInput } : { args: ClientInferInput }); +/** + * Union of the modeled errors an update interaction can surface once the + * request reaches Temporal: client-side payload validation, a worker-side + * admission rejection, a failed (admitted) handler, or a missing execution. + */ +type UpdateCallError = + | UpdateValidationError + | UpdateRejectedError + | UpdateFailedError + | WorkflowExecutionNotFoundError; + /** * Typed handle to an in-flight update, returned by * {@link TypedWorkflowHandle.startUpdate}. `result()` parses the update's @@ -251,12 +309,11 @@ export type TypedWorkflowUpdateHandle = { readonly workflowRunId: string | undefined; /** * Wait for and return the update's result, parsed against the contract's - * output schema. + * output schema. A worker-side admission rejection surfaces as + * `UpdateRejectedError`; a failed (admitted) handler as + * `UpdateFailedError` — both on the Err channel, never as defects. */ - result: () => AsyncResult< - ClientInferOutput, - UpdateValidationError | WorkflowExecutionNotFoundError - >; + result: () => AsyncResult, UpdateCallError>; }; /** @@ -295,12 +352,22 @@ export type TypedWorkflowHandle = { */ readonly firstExecutionRunId: string | undefined; + /** + * The underlying `@temporalio/client` `WorkflowHandle` — the escape hatch + * for anything the typed surface doesn't cover yet (e.g. + * `raw.getUpdateHandle(...)`, `raw.cancel()` with SDK-specific options). + * Calls made through `raw` bypass contract validation and the interceptor + * chain. Mirrors {@link TypedClient.raw} at the handle level. + */ + readonly raw: WorkflowHandle; + /** * Type-safe queries based on workflow definition with Result pattern. * Each query returns an `AsyncResult` — erring with `QueryValidationError` - * or `WorkflowExecutionNotFoundError` — instead of a throwing `Promise`; - * the error union is carried by {@link ClientInferWorkflowQueries} - * directly. + * (payload/result schema mismatch), `QueryFailedError` (no handler + * registered on the execution, or the handler threw), or + * `WorkflowExecutionNotFoundError` — instead of a throwing `Promise`; the + * error union is carried by {@link ClientInferWorkflowQueries} directly. */ queries: ClientInferWorkflowQueries; @@ -317,15 +384,18 @@ export type TypedWorkflowHandle = { * Type-safe updates based on workflow definition with Result pattern. * Each update starts the update AND waits for its result (Temporal's * `executeUpdate`), returning an `AsyncResult` that errs with - * `UpdateValidationError` or `WorkflowExecutionNotFoundError`; use - * {@link startUpdate} to obtain an update handle without waiting for - * completion. + * `UpdateValidationError`, `UpdateRejectedError` (worker-side admission + * rejection), `UpdateFailedError` (the admitted handler failed), or + * `WorkflowExecutionNotFoundError`; use {@link startUpdate} to obtain an + * update handle without waiting for completion. */ updates: ClientInferWorkflowUpdates; /** * Start an update without waiting for its completion — Temporal's - * `startUpdate` beside the `updates` map's execute-and-wait shape. + * `startUpdate` beside the `updates` map's execute-and-wait shape. The + * update is addressed positionally (`startUpdate(updateName, options)`); + * everything else rides the {@link TypedStartUpdateOptions} bag. * Returns a {@link TypedWorkflowUpdateHandle} whose `result()` parses the * outcome against the contract's output schema on receive. The `options` * parameter is omittable when the update's input schema accepts @@ -344,22 +414,22 @@ export type TypedWorkflowHandle = { ? TWorkflow["updates"][TUpdateName] : never >, - UpdateValidationError | WorkflowExecutionNotFoundError + UpdateCallError >; /** * Get workflow result with Result pattern. When the workflow declares * contract errors, a failed execution whose failure matches a declared * error surfaces as that typed error instead of the generic - * {@link WorkflowFailedError}. + * {@link WorkflowFailedError}. A cancelled / terminated / timed-out + * execution surfaces as the first-class `WorkflowCancelledError` / + * `WorkflowTerminatedError` / `WorkflowTimeoutError` — no `instanceof` + * digging through `WorkflowFailedError.cause` required. Cancellation is a + * modeled `Err(...)`: give it its own matcher arm rather than folding it + * into a blanket "failed" branch, so a deliberate cancel isn't reported + * as a breakage. */ - result: () => AsyncResult< - ClientInferOutput, - | WorkflowContractErrorsOf - | WorkflowValidationError - | WorkflowFailedError - | WorkflowExecutionNotFoundError - >; + result: () => AsyncResult, WorkflowResultErrorsOf>; /** * Terminate workflow with Result pattern @@ -417,6 +487,12 @@ type ResolvedWorkflow = { * worker parses them on receive, so a transforming schema is applied exactly * once per boundary (never here on the sending side). * + * Step 5's `toTypedSearchAttributes` throws a `RuntimeClientError` on an + * undeclared key or a value that doesn't match the declared kind — a + * technical misconfiguration. The throw happens inside the `flatMap` + * callback, whose throw→defect net turns it into a defect (never a modeled + * Err), which then flows through the composed pipeline untouched. + * * `getHandle` deliberately keeps its own three-line lookup — it doesn't * accept `args` or `searchAttributes`, so it can't share this helper. The * call-specific extras (signal validation, post-call output parsing, @@ -436,32 +512,22 @@ function resolveDefinitionAndValidateInput< ResolvedWorkflow, WorkflowNotInContractError | WorkflowValidationError > { - return makeAsyncResult< - ResolvedWorkflow, - WorkflowNotInContractError | WorkflowValidationError - >(async () => { - const definition = contract.workflows[workflowName]; - if (!definition) { - return Err(createWorkflowNotInContractError(workflowName, contract)); - } + const definition = contract.workflows[workflowName]; + if (!definition) { + return ErrAsync(createWorkflowNotInContractError(workflowName, contract)); + } - const inputResult = await definition.input["~standard"].validate(args); + return validateStandardSchema(definition.input, args).flatMap((inputResult) => { if (inputResult.issues) { return Err( new WorkflowValidationError(workflowName, "input", inputResult.issues, workflowId), ); } - - // `toTypedSearchAttributes` throws a `RuntimeClientError` on an undeclared - // key or a value that doesn't match the declared kind — a technical - // misconfiguration captured as a defect (never a modeled Err) and - // re-thrown into the caller's throw→defect net by `assertNoDefect`. const typedSearchAttributes = toTypedSearchAttributes( definition, workflowName, searchAttributes, ); - return Ok({ definition: definition as TContract["workflows"][TWorkflowName], typedSearchAttributes, @@ -600,7 +666,11 @@ export class TypedClient { * * @example * ```ts - * import { P } from "unthrown"; + * import { + * tagPatterns, + * WORKFLOW_RESULT_ERROR_TAGS, + * WORKFLOW_START_ERROR_TAGS, + * } from "@temporal-contract/client"; * * import { orderContract } from "./contracts/order.contract.js"; * @@ -615,11 +685,8 @@ export class TypedClient { * ok: (output) => console.log("processed", output), * errCases: (matcher) => * matcher.with( - * P.tag("@temporal-contract/WorkflowNotInContractError"), - * P.tag("@temporal-contract/WorkflowValidationError"), - * P.tag("@temporal-contract/WorkflowAlreadyStartedError"), - * P.tag("@temporal-contract/WorkflowFailedError"), - * P.tag("@temporal-contract/WorkflowExecutionNotFoundError"), + * ...tagPatterns(WORKFLOW_START_ERROR_TAGS), + * ...tagPatterns(WORKFLOW_RESULT_ERROR_TAGS), * (error) => console.error("processing failed", error), * ), * defect: (cause) => console.error("unexpected failure", cause), @@ -631,7 +698,7 @@ export class TypedClient { // restore/erase it at the memo boundary (see the field's doc). const memoized = this.contractClients.get(contract); if (memoized) return memoized as unknown as ContractClient; - const bound = new ContractClient(contract, this.raw, this.interceptors); + const bound = ContractClient._internal_create(contract, this.raw, this.interceptors); this.contractClients.set(contract, bound as unknown as ContractClient); return bound; } @@ -644,9 +711,17 @@ export class TypedClient { * Provides type-safe methods to start and execute workflows defined in the * bound contract, with explicit error handling using the Result pattern. * Obtained from {@link TypedClient.for} — the connection-scoped root — and - * inherits its underlying `Client` and interceptors. + * inherits its underlying `Client` and interceptors. Not constructible + * directly: the class is exported for type annotations only. */ export class ContractClient { + /** + * The contract this client is bound to — handy for logging, metrics + * labels, and plumbing the same contract into workers/tests without + * threading a second reference around. + */ + readonly contract: TContract; + /** * Typed wrapper around Temporal's `client.schedule.create(...)` and * related lifecycle methods. Fires the underlying `startWorkflow` action @@ -681,18 +756,41 @@ export class ContractClient { */ readonly schedule: TypedScheduleClient; + private readonly client: Client; + private readonly interceptors: readonly ClientInterceptor[]; + + private constructor( + contract: TContract, + client: Client, + interceptors: readonly ClientInterceptor[], + ) { + this.contract = contract; + this.client = client; + this.interceptors = interceptors; + this.schedule = TypedScheduleClient._internal_create(contract, client.schedule); + } + /** * Constructed exclusively by {@link TypedClient.for}. Not part of the * public API — obtain instances via `typedClient.for(contract)`. * * @internal */ - constructor( - private readonly contract: TContract, - private readonly client: Client, - private readonly interceptors: readonly ClientInterceptor[], - ) { - this.schedule = new TypedScheduleClient(contract, client.schedule); + static _internal_create( + contract: TContract, + client: Client, + interceptors: readonly ClientInterceptor[], + ): ContractClient { + return new ContractClient(contract, client, interceptors); + } + + /** + * The task queue this client dispatches to — the bound contract's + * `taskQueue`. Exposed for logging/observability so callers don't need to + * reach through {@link contract}. + */ + get taskQueue(): TContract["taskQueue"] { + return this.contract.taskQueue; } /** @@ -700,7 +798,7 @@ export class ContractClient { * * @example * ```ts - * import { P } from "unthrown"; + * import { tagPatterns, WORKFLOW_START_ERROR_TAGS } from "@temporal-contract/client"; * * const handleResult = await contractClient.startWorkflow('processOrder', { * workflowId: 'order-123', @@ -715,11 +813,8 @@ export class ContractClient { * // ... handle result * }, * errCases: (matcher) => - * matcher.with( - * P.tag('@temporal-contract/WorkflowNotInContractError'), - * P.tag('@temporal-contract/WorkflowValidationError'), - * P.tag('@temporal-contract/WorkflowAlreadyStartedError'), - * (error) => console.error('Failed to start:', error), + * matcher.with(...tagPatterns(WORKFLOW_START_ERROR_TAGS), (error) => + * console.error('Failed to start:', error), * ), * defect: (cause) => console.error('Unexpected failure:', cause), * }); @@ -739,50 +834,43 @@ export class ContractClient { WorkflowStartOptions, "taskQueue" | "args" | "searchAttributes" | "typedSearchAttributes" > & { args?: unknown; searchAttributes?: Record }; - type Ok = TypedWorkflowHandle; - type Err = WorkflowNotInContractError | WorkflowValidationError | WorkflowAlreadyStartedError; - const runPipeline = (currentInput: unknown): AsyncResult => { - const work = async () => { - const resolved = await resolveDefinitionAndValidateInput( - this.contract, - workflowName, - temporalOptions.workflowId, - currentInput, - searchAttributes as Record | undefined, - ); - // A technical throw inside the resolver is captured as a defect; - // re-throw it here so it rides this thunk's throw→defect net. - assertNoDefect(resolved); - if (resolved.isErr()) return Err(resolved.error); - const { definition, typedSearchAttributes } = resolved.value; + type StartOk = TypedWorkflowHandle; + type StartErr = + | WorkflowNotInContractError + | WorkflowValidationError + | WorkflowAlreadyStartedError; - try { - // Transmit the caller's ORIGINAL args — the input was validated - // above (fail early), but the worker parses on receive, so the - // parsed value must not cross the wire (D1). An omitted payload - // travels as empty args, not `[undefined]`. - const handle = await this.client.workflow.start(workflowName, { + const runPipeline = (currentInput: unknown): AsyncResult => + resolveDefinitionAndValidateInput( + this.contract, + workflowName, + temporalOptions.workflowId, + currentInput, + searchAttributes as Record | undefined, + ).flatMap(({ definition, typedSearchAttributes }) => + // Transmit the caller's ORIGINAL args — the input was validated + // above (fail early), but the worker parses on receive, so the + // parsed value must not cross the wire (D1). An omitted payload + // travels as empty args, not `[undefined]`. + fromPromise( + this.client.workflow.start(workflowName, { ...temporalOptions, taskQueue: this.contract.taskQueue, args: currentInput === undefined ? [] : [currentInput], ...(typedSearchAttributes ? { typedSearchAttributes } : {}), - }); - return Ok( + }), + // A start collision is the modeled Err; any other rejection is an + // unrecognized technical failure routed to the defect channel. + (error, defect) => + classifyStartError(error) ?? defect(new RuntimeClientError("startWorkflow", error)), + ).map( + (handle) => this.createTypedHandle(handle, workflowName, definition, { runId: handle.firstExecutionRunId, firstExecutionRunId: handle.firstExecutionRunId, - }) as Ok, - ); - } catch (error) { - const alreadyStarted = classifyStartError(error); - if (alreadyStarted) return Err(alreadyStarted); - // Unrecognized, technical failure — route to the defect channel. - // oxlint-disable-next-line unthrown/no-throw -- defect-channel routing: this throw inside the makeAsyncResult work thunk IS how a technical fault becomes a defect, never a modeled Err - throw new RuntimeClientError("startWorkflow", error); - } - }; - return makeAsyncResult(work); - }; + }) as StartOk, + ), + ); // Interceptors wrap the whole pipeline (outside validation), so a // patched input is validated exactly like the caller's original. Types @@ -797,7 +885,7 @@ export class ContractClient { input: args, } satisfies ClientInterceptorArgs, (current) => runPipeline(current.input) as AsyncResult, - ) as AsyncResult; + ) as AsyncResult; } /** @@ -805,7 +893,8 @@ export class ContractClient { * * Validates both halves of the call against the contract: * - `args` against the workflow's input schema - * - `signalArgs` against the named signal's input schema + * - `signalArgs` against the input schema of the signal named by the + * options bag's `signalName` field * * Returns a `TypedWorkflowHandleWithSignaledRunId` — the same shape as * `startWorkflow`'s handle, plus a `signaledRunId` field for correlating @@ -814,6 +903,11 @@ export class ContractClient { * @example * ```ts * import { P } from "unthrown"; + * import { + * SIGNAL_VALIDATION_ERROR_TAG, + * tagPatterns, + * WORKFLOW_START_ERROR_TAGS, + * } from "@temporal-contract/client"; * * const result = await contractClient.signalWithStart('processOrder', { * workflowId: 'order-123', @@ -826,10 +920,8 @@ export class ContractClient { * ok: (handle) => console.log('signaled run', handle.signaledRunId), * errCases: (matcher) => * matcher.with( - * P.tag('@temporal-contract/WorkflowNotInContractError'), - * P.tag('@temporal-contract/WorkflowValidationError'), - * P.tag('@temporal-contract/SignalValidationError'), - * P.tag('@temporal-contract/WorkflowAlreadyStartedError'), + * ...tagPatterns(WORKFLOW_START_ERROR_TAGS), + * P.tag(SIGNAL_VALIDATION_ERROR_TAG), * (error) => console.error('signalWithStart failed', error), * ), * defect: (cause) => console.error('unexpected failure', cause), @@ -861,8 +953,10 @@ export class ContractClient { signalArgs?: unknown; searchAttributes?: Record; }; - type Ok = TypedWorkflowHandleWithSignaledRunId; - type Err = + type SignalStartOk = TypedWorkflowHandleWithSignaledRunId< + TContract["workflows"][TWorkflowName] + >; + type SignalStartErr = | WorkflowNotInContractError | WorkflowValidationError | SignalValidationError @@ -871,71 +965,65 @@ export class ContractClient { const runPipeline = ( currentInput: unknown, currentSignalInput: unknown, - ): AsyncResult => { - const work = async () => { - const resolved = await resolveDefinitionAndValidateInput( - this.contract, - workflowName, - temporalOptions.workflowId, - currentInput, - searchAttributes as Record | undefined, - ); - // A technical throw inside the resolver is captured as a defect; - // re-throw it here so it rides this thunk's throw→defect net. - assertNoDefect(resolved); - if (resolved.isErr()) return Err(resolved.error); - const { definition, typedSearchAttributes } = resolved.value; - - // Validate signal input — call-site-specific, kept inline. Like the - // workflow input, the parsed value is discarded: the signal handler - // parses on receive, so the original signal args go over the wire. - const signalDef = (definition.signals as Record | undefined)?.[ - signalName - ]; - if (!signalDef) { - // Type-level constraint should already prevent this; defensive for - // raw-call / union-typed-name corner cases. - return Err( - new SignalValidationError(signalName, [ - { - message: `Signal "${signalName}" is not declared on workflow "${workflowName}".`, - }, - ]), + ): AsyncResult => + resolveDefinitionAndValidateInput( + this.contract, + workflowName, + temporalOptions.workflowId, + currentInput, + searchAttributes as Record | undefined, + ) + .flatMap((resolved) => { + // Validate signal input — call-site-specific, kept inline. Like the + // workflow input, the parsed value is discarded: the signal handler + // parses on receive, so the original signal args go over the wire. + const signalDef = ( + resolved.definition.signals as Record | undefined + )?.[signalName]; + if (!signalDef) { + // Type-level constraint should already prevent this; defensive for + // raw-call / union-typed-name corner cases. + return ErrAsync( + new SignalValidationError(signalName, [ + { + message: `Signal "${signalName}" is not declared on workflow "${workflowName}".`, + }, + ]), + ); + } + return validateStandardSchema(signalDef.input, currentSignalInput).flatMap( + (signalInputResult) => + signalInputResult.issues + ? Err(new SignalValidationError(signalName, signalInputResult.issues)) + : Ok(resolved), ); - } - const signalInputResult = await signalDef.input["~standard"].validate(currentSignalInput); - if (signalInputResult.issues) { - return Err(new SignalValidationError(signalName, signalInputResult.issues)); - } - - try { - const handle = await this.client.workflow.signalWithStart(workflowName, { - ...temporalOptions, - taskQueue: this.contract.taskQueue, - args: currentInput === undefined ? [] : [currentInput], - signal: signalName, - // An omitted signal payload travels as empty signalArgs. The - // cast collapses the `[] | [unknown]` union the SDK's overload - // inference can't split. - signalArgs: (currentSignalInput === undefined ? [] : [currentSignalInput]) as unknown[], - ...(typedSearchAttributes ? { typedSearchAttributes } : {}), - }); - const typed = this.createTypedHandle( - handle, - workflowName, - definition, - ) as TypedWorkflowHandle; - return Ok({ ...typed, signaledRunId: handle.signaledRunId } as Ok); - } catch (error) { - const alreadyStarted = classifyStartError(error); - if (alreadyStarted) return Err(alreadyStarted); - // Unrecognized, technical failure — route to the defect channel. - // oxlint-disable-next-line unthrown/no-throw -- defect-channel routing: this throw inside the makeAsyncResult work thunk IS how a technical fault becomes a defect, never a modeled Err - throw new RuntimeClientError("signalWithStart", error); - } - }; - return makeAsyncResult(work); - }; + }) + .flatMap(({ definition, typedSearchAttributes }) => + fromPromise( + this.client.workflow.signalWithStart(workflowName, { + ...temporalOptions, + taskQueue: this.contract.taskQueue, + args: currentInput === undefined ? [] : [currentInput], + signal: signalName, + // An omitted signal payload travels as empty signalArgs. The + // cast collapses the `[] | [unknown]` union the SDK's overload + // inference can't split. + signalArgs: (currentSignalInput === undefined + ? [] + : [currentSignalInput]) as unknown[], + ...(typedSearchAttributes ? { typedSearchAttributes } : {}), + }), + (error, defect) => + classifyStartError(error) ?? defect(new RuntimeClientError("signalWithStart", error)), + ).map((handle) => { + const typed = this.createTypedHandle( + handle, + workflowName, + definition, + ) as TypedWorkflowHandle; + return { ...typed, signaledRunId: handle.signaledRunId } as SignalStartOk; + }), + ); if (this.interceptors.length === 0) return runPipeline(args, signalArgs); return chainInterceptors( @@ -953,15 +1041,26 @@ export class ContractClient { current.input, (current as { signalInput: unknown }).signalInput, ) as AsyncResult, - ) as AsyncResult; + ) as AsyncResult; } /** - * Execute a workflow (start and wait for result) with AsyncResult pattern + * Execute a workflow (start and wait for result) with AsyncResult pattern. + * + * Beside the start-phase errors, the result phase surfaces the workflow's + * declared contract errors and the first-class outcome errors + * (`WorkflowCancelledError` / `WorkflowTerminatedError` / + * `WorkflowTimeoutError`) — see {@link TypedWorkflowHandle.result} for the + * cancellation-handling caveat. * * @example * ```ts * import { P } from "unthrown"; + * import { + * tagPatterns, + * WORKFLOW_RESULT_ERROR_TAGS, + * WORKFLOW_START_ERROR_TAGS, + * } from "@temporal-contract/client"; * * const result = await contractClient.executeWorkflow('processOrder', { * workflowId: 'order-123', @@ -973,15 +1072,15 @@ export class ContractClient { * await result.match({ * ok: (output) => console.log('Order processed:', output.status), * errCases: (matcher) => - * matcher.with( - * P.tag('@temporal-contract/ContractError'), - * P.tag('@temporal-contract/WorkflowNotInContractError'), - * P.tag('@temporal-contract/WorkflowValidationError'), - * P.tag('@temporal-contract/WorkflowAlreadyStartedError'), - * P.tag('@temporal-contract/WorkflowFailedError'), - * P.tag('@temporal-contract/WorkflowExecutionNotFoundError'), - * (error) => console.error('Processing failed:', error), - * ), + * matcher + * .with(P.tag('@temporal-contract/ContractError'), (error) => + * console.error('Domain failure:', error.errorName), + * ) + * .with( + * ...tagPatterns(WORKFLOW_START_ERROR_TAGS), + * ...tagPatterns(WORKFLOW_RESULT_ERROR_TAGS), + * (error) => console.error('Processing failed:', error), + * ), * defect: (cause) => console.error('Unexpected failure:', cause), * }); * ``` @@ -991,89 +1090,93 @@ export class ContractClient { options: TypedWorkflowStartOptions, ): AsyncResult< ClientInferOutput, - | WorkflowContractErrorsOf + | WorkflowResultErrorsOf | WorkflowNotInContractError - | WorkflowValidationError | WorkflowAlreadyStartedError - | WorkflowFailedError - | WorkflowExecutionNotFoundError > { // Widen once at the boundary — same rationale as `startWorkflow`. const { args, searchAttributes, ...temporalOptions } = options as Omit< WorkflowStartOptions, "taskQueue" | "args" | "searchAttributes" | "typedSearchAttributes" > & { args?: unknown; searchAttributes?: Record }; - type Ok = ClientInferOutput; - type Err = - | WorkflowContractErrorsOf + type ExecuteOk = ClientInferOutput; + type ExecuteErr = + | WorkflowResultErrorsOf | WorkflowNotInContractError - | WorkflowValidationError - | WorkflowAlreadyStartedError - | WorkflowFailedError - | WorkflowExecutionNotFoundError; - const runPipeline = (currentInput: unknown): AsyncResult => { - const work = async () => { - const resolved = await resolveDefinitionAndValidateInput( - this.contract, - workflowName, - temporalOptions.workflowId, - currentInput, - searchAttributes as Record | undefined, - ); - // A technical throw inside the resolver is captured as a defect; - // re-throw it here so it rides this thunk's throw→defect net. - assertNoDefect(resolved); - if (resolved.isErr()) return Err(resolved.error); - const { definition, typedSearchAttributes } = resolved.value; + | WorkflowAlreadyStartedError; - try { - // Transmit the caller's ORIGINAL args (validated above, parsed by - // the worker on receive — D1). - const result = await this.client.workflow.execute(workflowName, { + const runPipeline = (currentInput: unknown): AsyncResult => + resolveDefinitionAndValidateInput( + this.contract, + workflowName, + temporalOptions.workflowId, + currentInput, + searchAttributes as Record | undefined, + ).flatMap(({ definition, typedSearchAttributes }) => + // Transmit the caller's ORIGINAL args (validated above, parsed by + // the worker on receive — D1). + fromPromise( + this.client.workflow.execute(workflowName, { ...temporalOptions, taskQueue: this.contract.taskQueue, args: currentInput === undefined ? [] : [currentInput], ...(typedSearchAttributes ? { typedSearchAttributes } : {}), - }); - - // Output parsing runs *after* the Temporal call returns — kept - // inline because it's specific to executeWorkflow's start-and-wait - // shape; the helper only handles pre-call concerns. This is the - // RECEIVING side of the result boundary: the worker validated and - // transmitted its original return value, so the parse (and any - // schema transform) happens exactly once, here. - const outputResult = await definition.output["~standard"].validate(result); - if (outputResult.issues) { - return Err( - new WorkflowValidationError( - workflowName, - "output", - outputResult.issues, - temporalOptions.workflowId, - ), - ); - } - - return Ok(outputResult.value as Ok); - } catch (error) { + }), // executeWorkflow combines start + result, so it can surface any // of the discriminated kinds: the start-phase classification - // first, then the shared rehydrate-then-classify result tail. - const alreadyStarted = classifyStartError(error); - if (alreadyStarted) return Err(alreadyStarted); - const classified = await classifyExecutionResultError( - definition, - error, - temporalOptions.workflowId, - ); - if (classified) return Err(classified as Err); - // Unrecognized, technical failure — route to the defect channel. - // oxlint-disable-next-line unthrown/no-throw -- defect-channel routing: this throw inside the makeAsyncResult work thunk IS how a technical fault becomes a defect, never a modeled Err - throw new RuntimeClientError("executeWorkflow", error); - } - }; - return makeAsyncResult(work); - }; + // first, then the shared result-phase classification (which + // splits the outcome trio off the generic failure). Anything + // unrecognized is a technical failure on the defect channel. + (error, defect) => + classifyStartError(error) ?? + classifyResultError(error, temporalOptions.workflowId) ?? + defect(new RuntimeClientError("executeWorkflow", error)), + ) + .flatMapErrCases((matcher) => + matcher + // Async tail: a failure matching one of the workflow's + // declared contract errors rehydrates into the typed error; + // otherwise the generic WorkflowFailedError flows through. + // The cast narrows `AnyContractError` to this workflow's + // precise declared-error union (same erase/restore pattern + // as the interceptor boundary). + .with( + P.tag(WORKFLOW_FAILED_ERROR_TAG), + (failed) => + rehydrateFailedResult(definition, failed) as AsyncResult< + never, + | WorkflowContractErrorsOf + | WorkflowFailedError + >, + ) + .with( + P.tag(WORKFLOW_ALREADY_STARTED_ERROR_TAG), + P.tag(WORKFLOW_CANCELLED_ERROR_TAG), + P.tag(WORKFLOW_TERMINATED_ERROR_TAG), + P.tag(WORKFLOW_TIMEOUT_ERROR_TAG), + P.tag(WORKFLOW_EXECUTION_NOT_FOUND_ERROR_TAG), + (error) => Err(error), + ), + ) + .flatMap((result) => + // Output parsing runs *after* the Temporal call returns — the + // RECEIVING side of the result boundary: the worker validated + // and transmitted its original return value, so the parse (and + // any schema transform) happens exactly once, here. + validateStandardSchema(definition.output, result).flatMap((outputResult) => + outputResult.issues + ? Err( + new WorkflowValidationError( + workflowName, + "output", + outputResult.issues, + temporalOptions.workflowId, + ), + ) + : Ok(outputResult.value as ExecuteOk), + ), + ), + ); if (this.interceptors.length === 0) return runPipeline(args); return chainInterceptors( @@ -1085,7 +1188,7 @@ export class ContractClient { input: args, } satisfies ClientInterceptorArgs, (current) => runPipeline(current.input) as AsyncResult, - ) as AsyncResult; + ) as AsyncResult; } /** @@ -1154,6 +1257,10 @@ export class ContractClient { invoke: (name, input) => input === undefined ? workflowHandle.query(name) : workflowHandle.query(name, input), validateOutput: (def) => def.output, + // An unregistered handler / a throwing query handler is a routine + // operational outcome, modeled beside the missing execution. + classifyError: (error, name) => + classifyHandleError(error, workflowHandle.workflowId) ?? classifyQueryError(error, name), }) as TypedWorkflowHandle["queries"]; const signals = buildValidatedProxy({ @@ -1173,6 +1280,7 @@ export class ContractClient { return undefined; }, validateOutput: () => null, + classifyError: (error) => classifyHandleError(error, workflowHandle.workflowId), }) as TypedWorkflowHandle["signals"]; const updates = buildValidatedProxy({ @@ -1188,10 +1296,12 @@ export class ContractClient { args: (input === undefined ? [] : [input]) as [unknown], }), validateOutput: (def) => def.output, + // A rejected admission / failed handler is a routine business + // failure, modeled beside the missing execution. + classifyError: (error, name) => + classifyHandleError(error, workflowHandle.workflowId) ?? classifyUpdateError(error, name), }) as TypedWorkflowHandle["updates"]; - type StartUpdateErr = UpdateValidationError | WorkflowExecutionNotFoundError; - const wrapUpdateHandle = ( updateHandle: WorkflowUpdateHandle, updateName: string, @@ -1200,72 +1310,63 @@ export class ContractClient { updateId: updateHandle.updateId, workflowId: updateHandle.workflowId, workflowRunId: updateHandle.workflowRunId, - result: (): AsyncResult => { - const work = async () => { - try { - const raw = await updateHandle.result(); - // Receive side of the update-result boundary: the handler - // transmitted its original return value; parse it here (D1). - const outputResult = await updateDef.output["~standard"].validate(raw); - if (outputResult.issues) { - return Err(new UpdateValidationError(updateName, "output", outputResult.issues)); - } - return Ok(outputResult.value); - } catch (error) { - const notFound = classifyHandleError(error, updateHandle.workflowId); - if (notFound) return Err(notFound); - // Unrecognized, technical failure — route to the defect channel. - // oxlint-disable-next-line unthrown/no-throw -- defect-channel routing: this throw inside the makeAsyncResult work thunk IS how a technical fault becomes a defect, never a modeled Err - throw new RuntimeClientError("update.result", error); - } - }; - return makeAsyncResult(work); - }, + result: (): AsyncResult => + fromPromise( + updateHandle.result(), + (error, defect) => + classifyHandleError(error, updateHandle.workflowId) ?? + classifyUpdateError(error, updateName) ?? + defect(new RuntimeClientError("update.result", error)), + ).flatMap((raw) => + // Receive side of the update-result boundary: the handler + // transmitted its original return value; parse it here (D1). + validateStandardSchema(updateDef.output, raw).flatMap((outputResult) => + outputResult.issues + ? Err(new UpdateValidationError(updateName, "output", outputResult.issues)) + : Ok(outputResult.value), + ), + ), }); const startUpdate = ( updateName: string, options?: { args?: unknown; updateId?: string; waitForStage?: "ACCEPTED" }, - ): AsyncResult => { - const runPipeline = (currentInput: unknown): AsyncResult => { - const work = async () => { - const updateDef = (definition.updates as Record | undefined)?.[ - updateName - ]; - if (!updateDef) { - // Type-level constraint should already prevent this; defensive - // for raw-call / union-typed-name corner cases. - return Err( - new UpdateValidationError(updateName, "input", [ - { - message: `Update "${updateName}" is not declared on workflow "${workflowName}".`, - }, - ]), - ); - } - const inputResult = await updateDef.input["~standard"].validate(currentInput); - if (inputResult.issues) { - return Err(new UpdateValidationError(updateName, "input", inputResult.issues)); - } - - try { + ): AsyncResult => { + const runPipeline = (currentInput: unknown): AsyncResult => { + const updateDef = (definition.updates as Record | undefined)?.[ + updateName + ]; + if (!updateDef) { + // Type-level constraint should already prevent this; defensive + // for raw-call / union-typed-name corner cases. + return ErrAsync( + new UpdateValidationError(updateName, "input", [ + { + message: `Update "${updateName}" is not declared on workflow "${workflowName}".`, + }, + ]), + ); + } + return validateStandardSchema(updateDef.input, currentInput).flatMap( + (inputResult): AsyncResult => { + if (inputResult.issues) { + return ErrAsync(new UpdateValidationError(updateName, "input", inputResult.issues)); + } // Send the ORIGINAL input — the update handler parses on // receive (D1). An omitted payload travels as empty args. - const updateHandle = await workflowHandle.startUpdate(updateName, { - args: (currentInput === undefined ? [] : [currentInput]) as [unknown], - waitForStage: options?.waitForStage ?? "ACCEPTED", - ...(options?.updateId !== undefined ? { updateId: options.updateId } : {}), - }); - return Ok(wrapUpdateHandle(updateHandle, updateName, updateDef)); - } catch (error) { - const notFound = classifyHandleError(error, workflowHandle.workflowId); - if (notFound) return Err(notFound); - // Unrecognized, technical failure — route to the defect channel. - // oxlint-disable-next-line unthrown/no-throw -- defect-channel routing: this throw inside the makeAsyncResult work thunk IS how a technical fault becomes a defect, never a modeled Err - throw new RuntimeClientError("startUpdate", error); - } - }; - return makeAsyncResult(work); + return fromPromise( + workflowHandle.startUpdate(updateName, { + args: (currentInput === undefined ? [] : [currentInput]) as [unknown], + waitForStage: options?.waitForStage ?? "ACCEPTED", + ...(options?.updateId !== undefined ? { updateId: options.updateId } : {}), + }), + (error, defect) => + classifyHandleError(error, workflowHandle.workflowId) ?? + classifyUpdateError(error, updateName) ?? + defect(new RuntimeClientError("startUpdate", error)), + ).map((updateHandle) => wrapUpdateHandle(updateHandle, updateName, updateDef)); + }, + ); }; // Interceptors wrap the whole pipeline (outside validation), same @@ -1281,62 +1382,64 @@ export class ContractClient { input: options?.args, } satisfies ClientInterceptorArgs, (current) => runPipeline(current.input) as AsyncResult, - ) as AsyncResult; + ) as AsyncResult; }; return { workflowId: workflowHandle.workflowId, runId: ids.runId, firstExecutionRunId: ids.firstExecutionRunId, + raw: workflowHandle, queries, signals, updates, startUpdate: startUpdate as TypedWorkflowHandle["startUpdate"], - result: (): AsyncResult< - ClientInferOutput, - | WorkflowContractErrorsOf - | WorkflowValidationError - | WorkflowFailedError - | WorkflowExecutionNotFoundError - > => { - type Ok = ClientInferOutput; - type Err = - | WorkflowContractErrorsOf - | WorkflowValidationError - | WorkflowFailedError - | WorkflowExecutionNotFoundError; - const work = async () => { - try { - const result = await workflowHandle.result(); - const outputResult = await definition.output["~standard"].validate(result); - if (outputResult.issues) { - return Err( - new WorkflowValidationError( - workflowName, - "output", - outputResult.issues, - workflowHandle.workflowId, - ), - ); - } - return Ok(outputResult.value as Ok); - } catch (error) { - // Shared rehydrate-then-classify tail: a failure matching one of - // the workflow's declared contract errors rehydrates into the - // typed error; everything else falls through to the generic - // classification. - const classified = await classifyExecutionResultError( - definition, - error, - workflowHandle.workflowId, - ); - if (classified) return Err(classified as Err); - // Unrecognized, technical failure — route to the defect channel. - // oxlint-disable-next-line unthrown/no-throw -- defect-channel routing: this throw inside the makeAsyncResult work thunk IS how a technical fault becomes a defect, never a modeled Err - throw new RuntimeClientError("result", error); - } - }; - return makeAsyncResult(work); + result: (): AsyncResult, WorkflowResultErrorsOf> => { + type ResultOk = ClientInferOutput; + return fromPromise( + workflowHandle.result(), + // Result-phase classification: contract-error rehydration happens + // in the async errCases tail below; everything unrecognized is a + // technical failure on the defect channel. + (error, defect) => + classifyResultError(error, workflowHandle.workflowId) ?? + defect(new RuntimeClientError("result", error)), + ) + .flatMapErrCases((matcher) => + matcher + // A failure matching one of the workflow's declared contract + // errors rehydrates into the typed error; everything else + // flows through unchanged. + .with( + P.tag(WORKFLOW_FAILED_ERROR_TAG), + (failed) => + rehydrateFailedResult(definition, failed) as AsyncResult< + never, + WorkflowContractErrorsOf | WorkflowFailedError + >, + ) + .with( + P.tag(WORKFLOW_CANCELLED_ERROR_TAG), + P.tag(WORKFLOW_TERMINATED_ERROR_TAG), + P.tag(WORKFLOW_TIMEOUT_ERROR_TAG), + P.tag(WORKFLOW_EXECUTION_NOT_FOUND_ERROR_TAG), + (error) => Err(error), + ), + ) + .flatMap((result) => + validateStandardSchema(definition.output, result).flatMap((outputResult) => + outputResult.issues + ? Err( + new WorkflowValidationError( + workflowName, + "output", + outputResult.issues, + workflowHandle.workflowId, + ), + ) + : Ok(outputResult.value as ResultOk), + ), + ); }, terminate: (reason?: string): AsyncResult => fromPromise( @@ -1385,14 +1488,28 @@ function createWorkflowNotInContractError( type DefWithInput = { readonly input: StandardSchemaV1 }; +/** + * Union of the modeled operation errors the three handle proxies can + * classify an invoke rejection into. The builder is typed against this + * widened union (each call site's `classifyError` produces the relevant + * subset); the public per-operation precision lives on the + * `ClientInferSignal` / `ClientInferQuery` / `ClientInferUpdate` types the + * proxies are cast to. + */ +type ProxyOperationError = + | WorkflowExecutionNotFoundError + | QueryFailedError + | UpdateFailedError + | UpdateRejectedError; + type ProxyOptions = { readonly defs: Record | undefined; readonly operation: "signal" | "query" | "update"; /** Contract workflow name of the handle — surfaced to interceptors. */ readonly workflowName: string; /** - * Workflow ID of the handle these proxies bind to. Used by - * {@link classifyHandleError} to surface + * Workflow ID of the handle these proxies bind to. Used by the + * `classifyError` callbacks to surface * {@link WorkflowExecutionNotFoundError} with the targeted ID even when * Temporal's error doesn't carry it. */ @@ -1416,16 +1533,27 @@ type ProxyOptions = { * output parsing (used by signals, which don't return a value). */ readonly validateOutput: (def: TDef) => StandardSchemaV1 | null; + /** + * Recognize an `invoke` rejection as a modeled operation error (a missing + * execution, a rejected update, an unregistered query, …). Returns + * `undefined` for anything else — an unrecognized, technical failure the + * proxy routes to the defect channel with a {@link RuntimeClientError} + * cause. + */ + readonly classifyError: (error: unknown, name: string) => ProxyOperationError | undefined; }; /** * Build a `{ name: (args) => AsyncResult<...> }` proxy for a contract's * queries/signals/updates. The three call sites differ only in how they - * invoke Temporal and whether they parse output, so the shared - * input-validate → invoke(original) → output-parse → wrap-Result pipeline - * lives here once. Per the wire-format contract (D1), input validation only - * gates the call — the original value is transmitted and the worker parses - * it — while the result is parsed here on the receiving side. + * invoke Temporal, whether they parse output, and how they classify invoke + * rejections, so the shared input-validate → invoke(original) → + * output-parse pipeline lives here once — as an `AsyncResult` combinator + * chain whose boundary (`fromPromise`) triages every rejection through the + * per-operation `classifyError`. Per the wire-format contract (D1), input + * validation only gates the call — the original value is transmitted and + * the worker parses it — while the result is parsed here on the receiving + * side. */ function buildValidatedProxy({ defs, @@ -1436,44 +1564,40 @@ function buildValidatedProxy): Record< string, - (args?: unknown) => AsyncResult + (args?: unknown) => AsyncResult > { - type ProxyError = TValidationError | WorkflowExecutionNotFoundError; + type ProxyError = TValidationError | ProxyOperationError; const proxy: Record AsyncResult> = {}; if (!defs) return proxy; for (const [name, def] of Object.entries(defs)) { - const runPipeline = (currentInput: unknown): AsyncResult => { - const work = async () => { - const inputResult = await def.input["~standard"].validate(currentInput); - if (inputResult.issues) { - return Err(makeValidationError(name, "input", inputResult.issues)); - } - - try { - // Send the ORIGINAL input — the worker parses on receive (D1). - const result = await invoke(name, currentInput); - const outputSchema = validateOutput(def); - if (!outputSchema) { - return Ok(result); - } - const outputResult = await outputSchema["~standard"].validate(result); - if (outputResult.issues) { - return Err(makeValidationError(name, "output", outputResult.issues)); + const runPipeline = (currentInput: unknown): AsyncResult => + validateStandardSchema(def.input, currentInput).flatMap( + (inputResult): AsyncResult => { + if (inputResult.issues) { + return ErrAsync(makeValidationError(name, "input", inputResult.issues)); } - return Ok(outputResult.value); - } catch (error) { - const notFound = classifyHandleError(error, workflowId); - if (notFound) return Err(notFound); - // Unrecognized, technical failure — route to the defect channel. - // oxlint-disable-next-line unthrown/no-throw -- defect-channel routing: this throw inside the makeAsyncResult work thunk IS how a technical fault becomes a defect, never a modeled Err - throw new RuntimeClientError(operation, error); - } - }; - return makeAsyncResult(work); - }; + // Send the ORIGINAL input — the worker parses on receive (D1). + return fromPromise( + invoke(name, currentInput), + (error, defect) => + classifyError(error, name) ?? defect(new RuntimeClientError(operation, error)), + ).flatMap((result) => { + const outputSchema = validateOutput(def); + if (!outputSchema) { + return OkAsync(result); + } + return validateStandardSchema(outputSchema, result).flatMap((outputResult) => + outputResult.issues + ? Err(makeValidationError(name, "output", outputResult.issues)) + : Ok(outputResult.value), + ); + }); + }, + ); proxy[name] = (args) => { // Interceptors wrap the whole pipeline (outside validation), so a diff --git a/packages/client/src/error-tags.ts b/packages/client/src/error-tags.ts new file mode 100644 index 00000000..ff848894 --- /dev/null +++ b/packages/client/src/error-tags.ts @@ -0,0 +1,131 @@ +/** + * Named constants for the unthrown `_tag` literals of this package's tagged + * errors, so consumers can match without hand-writing the namespaced strings + * (mirrors `@temporal-contract/contract`'s and `@temporal-contract/worker`'s + * `error-tags.ts`): + * + * ```ts + * result.mapErrCases((matcher) => + * matcher.with(P.tag(WORKFLOW_FAILED_ERROR_TAG), (e) => e.workflowId), + * ); + * ``` + * + * Kept in a standalone, dependency-free module (no `unthrown` import) so the + * constants stay importable without pulling in any runtime machinery. + * + * Beside the per-error constants, this module exports grouped tag **bundles** + * (`as const` tuples) for the recurring error unions of the typed client — + * spread them into a grouped matcher arm via {@link tagPatterns} (exported + * from the package root) instead of enumerating each tag by hand: + * + * ```ts + * import { tagPatterns, WORKFLOW_RESULT_ERROR_TAGS } from "@temporal-contract/client"; + * + * result.match({ + * ok: (output) => ..., + * errCases: (matcher) => + * matcher.with(...tagPatterns(WORKFLOW_RESULT_ERROR_TAGS), (error) => ...), + * defect: (cause) => ..., + * }); + * ``` + */ + +/** `_tag` of `RuntimeClientError` — generic technical-failure wrapper (rides the defect channel). */ +export const RUNTIME_CLIENT_ERROR_TAG = "@temporal-contract/RuntimeClientError"; + +/** `_tag` of `WorkflowNotInContractError` — the workflow name isn't declared on the bound contract. */ +export const WORKFLOW_NOT_IN_CONTRACT_ERROR_TAG = "@temporal-contract/WorkflowNotInContractError"; + +/** `_tag` of `WorkflowAlreadyStartedError` — starting collided with an existing execution. */ +export const WORKFLOW_ALREADY_STARTED_ERROR_TAG = "@temporal-contract/WorkflowAlreadyStartedError"; + +/** `_tag` of `WorkflowExecutionNotFoundError` — the targeted execution doesn't exist in the namespace. */ +export const WORKFLOW_EXECUTION_NOT_FOUND_ERROR_TAG = + "@temporal-contract/WorkflowExecutionNotFoundError"; + +/** `_tag` of `WorkflowFailedError` — the execution completed with a (non-outcome) failure. */ +export const WORKFLOW_FAILED_ERROR_TAG = "@temporal-contract/WorkflowFailedError"; + +/** + * `_tag` of the client's `WorkflowCancelledError` — the execution ended + * `Cancelled`. Deliberately the same literal as the worker package's + * in-workflow `WorkflowCancelledError` tag: both mean "this workflow was + * cancelled", observed from different sides of the task queue, so grouped + * matchers treat them alike. + */ +export const WORKFLOW_CANCELLED_ERROR_TAG = "@temporal-contract/WorkflowCancelledError"; + +/** `_tag` of `WorkflowTerminatedError` — the execution was terminated. */ +export const WORKFLOW_TERMINATED_ERROR_TAG = "@temporal-contract/WorkflowTerminatedError"; + +/** `_tag` of `WorkflowTimeoutError` — the execution timed out. */ +export const WORKFLOW_TIMEOUT_ERROR_TAG = "@temporal-contract/WorkflowTimeoutError"; + +/** `_tag` of `WorkflowValidationError` — workflow input/output failed schema validation. */ +export const WORKFLOW_VALIDATION_ERROR_TAG = "@temporal-contract/WorkflowValidationError"; + +/** `_tag` of `QueryValidationError` — query input/output failed schema validation. */ +export const QUERY_VALIDATION_ERROR_TAG = "@temporal-contract/QueryValidationError"; + +/** `_tag` of `QueryFailedError` — the query could not be served (unregistered handler or handler failure). */ +export const QUERY_FAILED_ERROR_TAG = "@temporal-contract/QueryFailedError"; + +/** `_tag` of `SignalValidationError` — signal input failed schema validation. */ +export const SIGNAL_VALIDATION_ERROR_TAG = "@temporal-contract/SignalValidationError"; + +/** `_tag` of `UpdateValidationError` — update input/output failed schema validation (client side). */ +export const UPDATE_VALIDATION_ERROR_TAG = "@temporal-contract/UpdateValidationError"; + +/** `_tag` of `UpdateFailedError` — the update handler failed after admission. */ +export const UPDATE_FAILED_ERROR_TAG = "@temporal-contract/UpdateFailedError"; + +/** `_tag` of `UpdateRejectedError` — the update was rejected at admission by the worker-side validator. */ +export const UPDATE_REJECTED_ERROR_TAG = "@temporal-contract/UpdateRejectedError"; + +/** `_tag` of `ScheduleAlreadyExistsError` — `schedule.create` collided with a running schedule. */ +export const SCHEDULE_ALREADY_EXISTS_ERROR_TAG = "@temporal-contract/ScheduleAlreadyExistsError"; + +/** `_tag` of `ScheduleNotFoundError` — the schedule ID is unknown to the Temporal server. */ +export const SCHEDULE_NOT_FOUND_ERROR_TAG = "@temporal-contract/ScheduleNotFoundError"; + +/** + * Tags of the start-phase error union — what `startWorkflow` (and the start + * half of `executeWorkflow` / `signalWithStart`) can err with: + * `WorkflowNotInContractError | WorkflowValidationError | WorkflowAlreadyStartedError`. + * + * `signalWithStart` additionally errs with `SignalValidationError` — add + * `P.tag(SIGNAL_VALIDATION_ERROR_TAG)` beside the spread for that call. + */ +export const WORKFLOW_START_ERROR_TAGS = [ + WORKFLOW_NOT_IN_CONTRACT_ERROR_TAG, + WORKFLOW_VALIDATION_ERROR_TAG, + WORKFLOW_ALREADY_STARTED_ERROR_TAG, +] as const; + +/** + * Tags of the workflow-outcome trio — the executions that ended without a + * result because the server closed them: cancelled, terminated, or timed + * out. A subset of {@link WORKFLOW_RESULT_ERROR_TAGS} for callers that + * branch on "the workflow was stopped" separately from "the workflow + * failed". + */ +export const WORKFLOW_OUTCOME_ERROR_TAGS = [ + WORKFLOW_CANCELLED_ERROR_TAG, + WORKFLOW_TERMINATED_ERROR_TAG, + WORKFLOW_TIMEOUT_ERROR_TAG, +] as const; + +/** + * Tags of the result-phase error union — what `handle.result()` (and the + * result half of `executeWorkflow`) can err with, beside any contract errors + * declared on the workflow (match those with the contract package's + * `CONTRACT_ERROR_TAG`): + * `WorkflowValidationError | WorkflowFailedError | WorkflowCancelledError | + * WorkflowTerminatedError | WorkflowTimeoutError | WorkflowExecutionNotFoundError`. + */ +export const WORKFLOW_RESULT_ERROR_TAGS = [ + WORKFLOW_VALIDATION_ERROR_TAG, + WORKFLOW_FAILED_ERROR_TAG, + ...WORKFLOW_OUTCOME_ERROR_TAGS, + WORKFLOW_EXECUTION_NOT_FOUND_ERROR_TAG, +] as const; diff --git a/packages/client/src/errors.ts b/packages/client/src/errors.ts index 10f25c9f..863f33f4 100644 --- a/packages/client/src/errors.ts +++ b/packages/client/src/errors.ts @@ -9,7 +9,27 @@ import type { TerminatedFailure, TimeoutFailure, } from "@temporalio/common"; -import { TaggedError } from "unthrown"; +import { P, TaggedError } from "unthrown"; + +import { + QUERY_FAILED_ERROR_TAG, + QUERY_VALIDATION_ERROR_TAG, + RUNTIME_CLIENT_ERROR_TAG, + SCHEDULE_ALREADY_EXISTS_ERROR_TAG, + SCHEDULE_NOT_FOUND_ERROR_TAG, + SIGNAL_VALIDATION_ERROR_TAG, + UPDATE_FAILED_ERROR_TAG, + UPDATE_REJECTED_ERROR_TAG, + UPDATE_VALIDATION_ERROR_TAG, + WORKFLOW_ALREADY_STARTED_ERROR_TAG, + WORKFLOW_CANCELLED_ERROR_TAG, + WORKFLOW_EXECUTION_NOT_FOUND_ERROR_TAG, + WORKFLOW_FAILED_ERROR_TAG, + WORKFLOW_NOT_IN_CONTRACT_ERROR_TAG, + WORKFLOW_TERMINATED_ERROR_TAG, + WORKFLOW_TIMEOUT_ERROR_TAG, + WORKFLOW_VALIDATION_ERROR_TAG, +} from "./error-tags.js"; /** * Union of the actionable Temporal failure types that can surface as the @@ -18,6 +38,12 @@ import { TaggedError } from "unthrown"; * the base class so consumer code can use a single `switch (true)` over * `instanceof` discriminants without an exhaustiveness escape hatch. * + * Note that the cancellation/termination/timeout members are classified into + * their own first-class errors ({@link WorkflowCancelledError}, + * {@link WorkflowTerminatedError}, {@link WorkflowTimeoutError}) before a + * generic `WorkflowFailedError` is ever surfaced, so in practice a + * `WorkflowFailedError.cause` carries one of the remaining members. + * * Re-exported from the package entry point so consumers can import it * directly: `import type { TemporalFailure } from "@temporal-contract/client"`. */ @@ -30,10 +56,44 @@ export type TemporalFailure = | ServerFailure | ActivityFailure; +/** + * The `{ _tag: … }` object patterns for a tuple of tag literals, in tuple + * order — the type produced by {@link tagPatterns}. + */ +export type TagPatterns = { + [K in keyof TTags]: { _tag: TTags[K] }; +}; + +/** + * Map a tag bundle (an `as const` tuple of `_tag` literals — see + * `error-tags.ts`) to a tuple of `P.tag` patterns, so the bundle can be + * spread into a grouped matcher arm: + * + * ```ts + * import { tagPatterns, WORKFLOW_RESULT_ERROR_TAGS } from "@temporal-contract/client"; + * + * result.match({ + * ok: (output) => ..., + * errCases: (matcher) => + * matcher.with(...tagPatterns(WORKFLOW_RESULT_ERROR_TAGS), (error) => ...), + * defect: (cause) => ..., + * }); + * ``` + * + * The tuple shape is preserved (`TagPatterns`), which is what makes + * the spread compile — a plain `tags.map(P.tag)` loses tuple-ness and can't + * be spread into the matcher's variadic `with(...)`. + */ +export function tagPatterns( + tags: TTags, +): TagPatterns { + return tags.map((tag) => P.tag(tag)) as TagPatterns; +} + /** * Generic runtime failure wrapper when no specific error type applies */ -export class RuntimeClientError extends TaggedError("@temporal-contract/RuntimeClientError", { +export class RuntimeClientError extends TaggedError(RUNTIME_CLIENT_ERROR_TAG, { name: "RuntimeClientError", })<{ operation: string; @@ -54,10 +114,9 @@ export class RuntimeClientError extends TaggedError("@temporal-contract/RuntimeC * about a missing *execution* and surfaces here as * {@link WorkflowExecutionNotFoundError}. */ -export class WorkflowNotInContractError extends TaggedError( - "@temporal-contract/WorkflowNotInContractError", - { name: "WorkflowNotInContractError" }, -)<{ +export class WorkflowNotInContractError extends TaggedError(WORKFLOW_NOT_IN_CONTRACT_ERROR_TAG, { + name: "WorkflowNotInContractError", +})<{ workflowName: string; availableWorkflows: readonly string[]; }> { @@ -78,10 +137,9 @@ export class WorkflowNotInContractError extends TaggedError( * branch on it explicitly (e.g. fetch the existing handle and continue) * without inspecting `error.cause` against a Temporal SDK class. */ -export class WorkflowAlreadyStartedError extends TaggedError( - "@temporal-contract/WorkflowAlreadyStartedError", - { name: "WorkflowAlreadyStartedError" }, -)<{ +export class WorkflowAlreadyStartedError extends TaggedError(WORKFLOW_ALREADY_STARTED_ERROR_TAG, { + name: "WorkflowAlreadyStartedError", +})<{ workflowType: string; workflowId: string; cause?: unknown; @@ -105,7 +163,7 @@ export class WorkflowAlreadyStartedError extends TaggedError( * execution mid-flight) */ export class WorkflowExecutionNotFoundError extends TaggedError( - "@temporal-contract/WorkflowExecutionNotFoundError", + WORKFLOW_EXECUTION_NOT_FOUND_ERROR_TAG, { name: "WorkflowExecutionNotFoundError" }, )<{ workflowId: string; @@ -124,8 +182,7 @@ export class WorkflowExecutionNotFoundError extends TaggedError( * Temporal's `WorkflowFailedError`. * * `cause` is the *unwrapped* underlying {@link TemporalFailure} (typically an - * `ApplicationFailure`, `CancelledFailure`, `TerminatedFailure`, or - * `TimeoutFailure`) lifted from Temporal's wrapper, so callers can branch + * `ApplicationFailure`) lifted from Temporal's wrapper, so callers can branch * on the failure category in one step (`err.cause instanceof * ApplicationFailure`) instead of unwrapping twice via the SDK wrapper. The * SDK declares `WorkflowFailedError.cause` as the wider `Error | undefined` @@ -135,9 +192,15 @@ export class WorkflowExecutionNotFoundError extends TaggedError( * static type to the public {@link TemporalFailure} union with a cast, so * consumers see the precise leaf-failure typing instead of a bare `Error`. * + * Cancellation, termination, and timeout outcomes do NOT surface here: they + * are classified into the first-class {@link WorkflowCancelledError}, + * {@link WorkflowTerminatedError}, and {@link WorkflowTimeoutError} before + * this generic wrapper is considered, so `instanceof` digging through + * `cause` is never needed to tell them apart. + * * Returned from `executeWorkflow` and `handle.result()`. */ -export class WorkflowFailedError extends TaggedError("@temporal-contract/WorkflowFailedError", { +export class WorkflowFailedError extends TaggedError(WORKFLOW_FAILED_ERROR_TAG, { name: "WorkflowFailedError", })<{ workflowId: string; @@ -151,6 +214,74 @@ export class WorkflowFailedError extends TaggedError("@temporal-contract/Workflo } } +/** + * Surfaced on the Err channel when the awaited workflow execution ended + * `Cancelled` — Temporal's `WorkflowFailedError` wrapping a + * `CancelledFailure`. `cause` keeps the original {@link CancelledFailure}. + * + * **Swallowing this error hides the cancellation.** Cancellation rides the + * modeled `Err(...)` channel here (mirroring the worker package's + * cancellation errors), so generic error handling that maps every `Err` to a + * blanket "failed" outcome silently conflates "the workflow was cancelled on + * purpose" with "the workflow broke". Give cancellation its own matcher arm + * when the two must diverge. + * + * Returned from `executeWorkflow` and `handle.result()`. + */ +export class WorkflowCancelledError extends TaggedError(WORKFLOW_CANCELLED_ERROR_TAG, { + name: "WorkflowCancelledError", +})<{ + workflowId: string; + cause?: CancelledFailure | undefined; +}> { + constructor(workflowId: string, cause?: CancelledFailure) { + super({ workflowId, cause }); + this.message = `Workflow "${workflowId}" was cancelled.`; + } +} + +/** + * Surfaced on the Err channel when the awaited workflow execution was + * terminated — Temporal's `WorkflowFailedError` wrapping a + * `TerminatedFailure`. `cause` keeps the original {@link TerminatedFailure} + * (whose `message` carries the terminate reason, when one was given). + * + * Returned from `executeWorkflow` and `handle.result()`. + */ +export class WorkflowTerminatedError extends TaggedError(WORKFLOW_TERMINATED_ERROR_TAG, { + name: "WorkflowTerminatedError", +})<{ + workflowId: string; + cause?: TerminatedFailure | undefined; +}> { + constructor(workflowId: string, cause?: TerminatedFailure) { + super({ workflowId, cause }); + this.message = `Workflow "${workflowId}" was terminated${ + cause?.message ? `: ${cause.message}` : "." + }`; + } +} + +/** + * Surfaced on the Err channel when the awaited workflow execution timed + * out — Temporal's `WorkflowFailedError` wrapping a `TimeoutFailure`. + * `cause` keeps the original {@link TimeoutFailure} (whose `timeoutType` + * names which timeout fired). + * + * Returned from `executeWorkflow` and `handle.result()`. + */ +export class WorkflowTimeoutError extends TaggedError(WORKFLOW_TIMEOUT_ERROR_TAG, { + name: "WorkflowTimeoutError", +})<{ + workflowId: string; + cause?: TimeoutFailure | undefined; +}> { + constructor(workflowId: string, cause?: TimeoutFailure) { + super({ workflowId, cause }); + this.message = `Workflow "${workflowId}" timed out.`; + } +} + // Validation-message formatters live in `@temporal-contract/contract` so // client and worker share a single source of truth. The previous local // copies have been removed in favor of the shared `summarizeIssues` import @@ -164,10 +295,9 @@ export class WorkflowFailedError extends TaggedError("@temporal-contract/Workflo * it is absent for call sites without one (e.g. `schedule.create`, where * runs are spawned later). */ -export class WorkflowValidationError extends TaggedError( - "@temporal-contract/WorkflowValidationError", - { name: "WorkflowValidationError" }, -)<{ +export class WorkflowValidationError extends TaggedError(WORKFLOW_VALIDATION_ERROR_TAG, { + name: "WorkflowValidationError", +})<{ workflowName: string; direction: "input" | "output"; issues: ReadonlyArray; @@ -187,7 +317,7 @@ export class WorkflowValidationError extends TaggedError( /** * Surfaced on the Err channel when query input or output validation fails */ -export class QueryValidationError extends TaggedError("@temporal-contract/QueryValidationError", { +export class QueryValidationError extends TaggedError(QUERY_VALIDATION_ERROR_TAG, { name: "QueryValidationError", })<{ queryName: string; @@ -204,10 +334,39 @@ export class QueryValidationError extends TaggedError("@temporal-contract/QueryV } } +/** + * Surfaced on the Err channel when the server could not serve a query — + * either no handler is registered under the query name on the (possibly + * older) workflow execution, or the query handler itself threw. Temporal + * reports both through the same channel (`QueryNotRegisteredError`, an + * `INVALID_ARGUMENT` gRPC failure whose message carries the underlying + * reason), so they are classified into this single modeled error; `cause` + * keeps Temporal's original error for inspection. + * + * A routine operational outcome — a stale execution predating the handler, + * a handler bug — not a technical fault, so it rides the Err channel + * instead of the defect channel. + * + * Returned from the typed handle's `queries.*` proxies. + */ +export class QueryFailedError extends TaggedError(QUERY_FAILED_ERROR_TAG, { + name: "QueryFailedError", +})<{ + queryName: string; + cause?: unknown; +}> { + constructor(queryName: string, cause?: unknown) { + super({ queryName, cause }); + this.message = `Query "${queryName}" failed: ${ + cause instanceof Error ? cause.message : String(cause ?? "unknown error") + }`; + } +} + /** * Surfaced on the Err channel when signal input validation fails */ -export class SignalValidationError extends TaggedError("@temporal-contract/SignalValidationError", { +export class SignalValidationError extends TaggedError(SIGNAL_VALIDATION_ERROR_TAG, { name: "SignalValidationError", })<{ signalName: string; @@ -222,7 +381,7 @@ export class SignalValidationError extends TaggedError("@temporal-contract/Signa /** * Surfaced on the Err channel when update input or output validation fails */ -export class UpdateValidationError extends TaggedError("@temporal-contract/UpdateValidationError", { +export class UpdateValidationError extends TaggedError(UPDATE_VALIDATION_ERROR_TAG, { name: "UpdateValidationError", })<{ updateName: string; @@ -239,16 +398,73 @@ export class UpdateValidationError extends TaggedError("@temporal-contract/Updat } } +/** + * Surfaced on the Err channel when an admitted update's handler failed — + * Temporal's `WorkflowUpdateFailedError`, minus the admission rejections + * classified as {@link UpdateRejectedError}. A routine business failure of + * the update itself (the handler threw an `ApplicationFailure`), not a + * technical fault, so it rides the Err channel instead of the defect + * channel. + * + * `cause` is the *unwrapped* underlying failure (typically an + * `ApplicationFailure`) lifted from Temporal's wrapper, mirroring + * {@link WorkflowFailedError.cause}. + * + * Returned from the typed handle's `updates.*` proxies, `startUpdate`, and + * the update handle's `result()`. + */ +export class UpdateFailedError extends TaggedError(UPDATE_FAILED_ERROR_TAG, { + name: "UpdateFailedError", +})<{ + updateName: string; + cause?: unknown; +}> { + constructor(updateName: string, cause?: unknown) { + super({ updateName, cause }); + this.message = `Update "${updateName}" failed: ${ + cause instanceof Error ? cause.message : String(cause ?? "unknown error") + }`; + } +} + +/** + * Surfaced on the Err channel when an update was rejected at admission by + * the worker-side input validator — the update handler never ran. With a + * `@temporal-contract/worker` on the other side of the task queue, this is + * the update-input schema rejecting the payload (the worker's + * `UpdateInputValidationError`, whose message summarizes the failing + * fields); `cause` keeps that original `ApplicationFailure`. + * + * Distinct from {@link UpdateValidationError} (the *client-side* schema + * check, which fails before anything is sent) and from + * {@link UpdateFailedError} (the handler was admitted and then failed). + * + * Returned from the typed handle's `updates.*` proxies, `startUpdate`, and + * the update handle's `result()`. + */ +export class UpdateRejectedError extends TaggedError(UPDATE_REJECTED_ERROR_TAG, { + name: "UpdateRejectedError", +})<{ + updateName: string; + cause?: unknown; +}> { + constructor(updateName: string, cause?: unknown) { + super({ updateName, cause }); + this.message = `Update "${updateName}" was rejected at admission: ${ + cause instanceof Error ? cause.message : String(cause ?? "unknown error") + }`; + } +} + /** * Surfaced on the Err channel when `schedule.create` collides with a * running (not deleted) schedule bearing the same `scheduleId` — Temporal's * `ScheduleAlreadyRunning`. Idempotent callers can branch on it explicitly * (e.g. fetch the existing handle and continue). */ -export class ScheduleAlreadyExistsError extends TaggedError( - "@temporal-contract/ScheduleAlreadyExistsError", - { name: "ScheduleAlreadyExistsError" }, -)<{ +export class ScheduleAlreadyExistsError extends TaggedError(SCHEDULE_ALREADY_EXISTS_ERROR_TAG, { + name: "ScheduleAlreadyExistsError", +})<{ scheduleId: string; cause?: unknown; }> { @@ -264,7 +480,7 @@ export class ScheduleAlreadyExistsError extends TaggedError( * `ScheduleNotFoundError`. Either the ID is wrong or the schedule was * deleted. */ -export class ScheduleNotFoundError extends TaggedError("@temporal-contract/ScheduleNotFoundError", { +export class ScheduleNotFoundError extends TaggedError(SCHEDULE_NOT_FOUND_ERROR_TAG, { name: "ScheduleNotFoundError", })<{ scheduleId: string; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index ed039ea0..66b7126a 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -12,6 +12,7 @@ export { type TypedWorkflowStartOptions, type TypedWorkflowUpdateHandle, type WorkflowContractErrorsOf, + type WorkflowResultErrorsOf, } from "./client.js"; export type { ClientCallError, @@ -37,19 +38,50 @@ export { type TypedScheduleHandle, } from "./schedule.js"; export { + QueryFailedError, + QueryValidationError, RuntimeClientError, ScheduleAlreadyExistsError, ScheduleNotFoundError, + SignalValidationError, + tagPatterns, + UpdateFailedError, + UpdateRejectedError, + UpdateValidationError, WorkflowAlreadyStartedError, + WorkflowCancelledError, WorkflowExecutionNotFoundError, WorkflowFailedError, WorkflowNotInContractError, + WorkflowTerminatedError, + WorkflowTimeoutError, WorkflowValidationError, - QueryValidationError, - SignalValidationError, - UpdateValidationError, } from "./errors.js"; -export type { TemporalFailure } from "./errors.js"; +export type { TagPatterns, TemporalFailure } from "./errors.js"; +// `_tag` literal constants + grouped bundles for the recurring match +// unions — see `error-tags.ts` and the `tagPatterns` helper above. +export { + QUERY_FAILED_ERROR_TAG, + QUERY_VALIDATION_ERROR_TAG, + RUNTIME_CLIENT_ERROR_TAG, + SCHEDULE_ALREADY_EXISTS_ERROR_TAG, + SCHEDULE_NOT_FOUND_ERROR_TAG, + SIGNAL_VALIDATION_ERROR_TAG, + UPDATE_FAILED_ERROR_TAG, + UPDATE_REJECTED_ERROR_TAG, + UPDATE_VALIDATION_ERROR_TAG, + WORKFLOW_ALREADY_STARTED_ERROR_TAG, + WORKFLOW_CANCELLED_ERROR_TAG, + WORKFLOW_EXECUTION_NOT_FOUND_ERROR_TAG, + WORKFLOW_FAILED_ERROR_TAG, + WORKFLOW_NOT_IN_CONTRACT_ERROR_TAG, + WORKFLOW_OUTCOME_ERROR_TAGS, + WORKFLOW_RESULT_ERROR_TAGS, + WORKFLOW_START_ERROR_TAGS, + WORKFLOW_TERMINATED_ERROR_TAG, + WORKFLOW_TIMEOUT_ERROR_TAG, + WORKFLOW_VALIDATION_ERROR_TAG, +} from "./error-tags.js"; export type { ClientInferInput, ClientInferOutput, diff --git a/packages/client/src/interceptors.ts b/packages/client/src/interceptors.ts index 266b0553..c7cb5b29 100644 --- a/packages/client/src/interceptors.ts +++ b/packages/client/src/interceptors.ts @@ -20,13 +20,19 @@ import type { AnyContractError } from "@temporal-contract/contract/errors"; import type { AsyncResult } from "unthrown"; import type { + QueryFailedError, QueryValidationError, SignalValidationError, + UpdateFailedError, + UpdateRejectedError, UpdateValidationError, WorkflowAlreadyStartedError, + WorkflowCancelledError, WorkflowExecutionNotFoundError, WorkflowFailedError, WorkflowNotInContractError, + WorkflowTerminatedError, + WorkflowTimeoutError, WorkflowValidationError, } from "./errors.js"; @@ -42,10 +48,16 @@ export type ClientCallError = | WorkflowValidationError | WorkflowAlreadyStartedError | WorkflowFailedError + | WorkflowCancelledError + | WorkflowTerminatedError + | WorkflowTimeoutError | WorkflowExecutionNotFoundError | SignalValidationError | QueryValidationError + | QueryFailedError | UpdateValidationError + | UpdateFailedError + | UpdateRejectedError | AnyContractError; /** @@ -119,16 +131,52 @@ export type ClientInterceptor = ( next: ClientInterceptorNext, ) => AsyncResult; +/** + * The keys of a patch an interceptor is allowed to change — the two payload + * fields. Identity fields (`operation`, `workflowName`, `workflowId`, + * `name`) describe *which* call is in flight and are owned by the call site; + * an interceptor observing or patching a call must not be able to relabel + * it mid-chain. + */ +type InterceptorPatch = { + readonly input?: unknown; + readonly signalInput?: unknown; +}; + +/** + * Merge ONLY the payload keys of a patch over the current invocation. A key + * is copied when the patch *carries* it (`"input" in patch`), so patching + * `input: undefined` still overwrites — while any non-payload key an + * interceptor smuggles into the patch object is dropped. + */ +function mergePatch(current: TArgs, patch: InterceptorPatch): TArgs { + const merged = { ...current }; + if ("input" in patch) { + (merged as { input?: unknown }).input = patch.input; + } + if ("signalInput" in patch) { + (merged as { signalInput?: unknown }).signalInput = patch.signalInput; + } + return merged; +} + /** * Run `terminal` through a chain of interceptors, outermost-first. Each - * interceptor's `next(patch)` shallow-merges the patch over the current args - * and advances; calling `next` again re-runs the rest of the chain (retry). + * interceptor's `next(patch)` merges the patch's payload keys (`input`, + * `signalInput` — and only those; identity fields like `workflowName` + * cannot be rewritten) over the current args and advances; calling `next` + * again re-runs the rest of the chain (retry). * * Ported from amqp-contract's `chainInterceptors`. * * @internal */ -export function chainInterceptors( +export function chainInterceptors< + TArgs extends object, + TPatch extends InterceptorPatch, + TValue, + TError, +>( interceptors: readonly (( args: TArgs, next: (patch?: TPatch) => AsyncResult, @@ -139,6 +187,8 @@ export function chainInterceptors => index >= interceptors.length ? terminal(current) - : interceptors[index]!(current, (patch) => run(index + 1, { ...current, ...patch })); + : interceptors[index]!(current, (patch) => + run(index + 1, patch === undefined ? current : mergePatch(current, patch)), + ); return run(0, args); } diff --git a/packages/client/src/internal.ts b/packages/client/src/internal.ts index 37d56f3e..5dcfee97 100644 --- a/packages/client/src/internal.ts +++ b/packages/client/src/internal.ts @@ -5,6 +5,7 @@ * `exports` map, so consumers can't import from `@temporal-contract/client/internal`. * In-package modules and tests import it directly via relative path. */ +import type { StandardSchemaV1 } from "@standard-schema/spec"; import type { AnyWorkflowDefinition, SearchAttributeDefinition, @@ -16,32 +17,41 @@ import { } from "@temporal-contract/contract/errors"; import { _internal_makeAsyncResult } from "@temporal-contract/contract/result-async"; import { WorkflowExecutionAlreadyStartedError } from "@temporalio/client"; -import { WorkflowFailedError as TemporalWorkflowFailedError } from "@temporalio/client"; +import { + QueryNotRegisteredError, + WorkflowFailedError as TemporalWorkflowFailedError, + WorkflowUpdateFailedError, +} from "@temporalio/client"; import { ScheduleAlreadyRunning, ScheduleNotFoundError as TemporalScheduleNotFoundError, } from "@temporalio/client"; import { ApplicationFailure, + CancelledFailure, defineSearchAttributeKey, type SearchAttributePair, + TerminatedFailure, + TimeoutFailure, TypedSearchAttributes, WorkflowNotFoundError as TemporalWorkflowNotFoundError, } from "@temporalio/common"; -import { type AsyncResult, type Result } from "unthrown"; +import { type AsyncResult, Err, fromSafePromise, type Result } from "unthrown"; -// `assertNoDefect` narrows an internally-built `Result` (known to carry only -// ok/err) to `Ok | Err`, re-throwing a stray defect's cause — so call sites -// reach `.value` / `.error` without a manual "impossible defect" guard. -export { _internal_assertNoDefect as assertNoDefect } from "@temporal-contract/contract/result-async"; import { + QueryFailedError, RuntimeClientError, ScheduleAlreadyExistsError, ScheduleNotFoundError, type TemporalFailure, + UpdateFailedError, + UpdateRejectedError, WorkflowAlreadyStartedError, + WorkflowCancelledError, WorkflowExecutionNotFoundError, WorkflowFailedError, + WorkflowTerminatedError, + WorkflowTimeoutError, } from "./errors.js"; /** @@ -63,7 +73,13 @@ const searchAttributeValueChecks: Record< }, DOUBLE: { expected: "a number", check: (v) => typeof v === "number" && Number.isFinite(v) }, BOOL: { expected: "a boolean", check: (v) => typeof v === "boolean" }, - DATETIME: { expected: "a Date", check: (v) => v instanceof Date }, + DATETIME: { + expected: "a valid Date", + // `new Date(NaN)` is still `instanceof Date` but serializes to nothing + // meaningful — reject it here instead of letting the server (or the + // payload converter) fail long after the call site. + check: (v) => v instanceof Date && !Number.isNaN(v.getTime()), + }, KEYWORD_LIST: { expected: "an array of strings", check: (v) => Array.isArray(v) && v.every((entry) => typeof entry === "string"), @@ -73,12 +89,16 @@ const searchAttributeValueChecks: Record< /** * Name a value's runtime type for error messages. `typeof` alone reports * `"object"` for arrays, `Date`s, and `null` — the three shapes search - * attributes actually trip over — so spell those out. + * attributes actually trip over — so spell those out (including the + * invalid-`Date` case, which would otherwise read "must be a valid Date; + * received a Date"). */ function describeRuntimeType(value: unknown): string { if (value === null) return "null"; if (Array.isArray(value)) return "an array"; - if (value instanceof Date) return "a Date"; + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? "an invalid Date" : "a Date"; + } return `a ${typeof value}`; } @@ -94,11 +114,12 @@ function describeRuntimeType(value: unknown): string { * **Throws** a {@link RuntimeClientError} on unknown keys or on values that * don't match the declared kind's runtime type — a *technical* * misconfiguration, not a modeled domain error, so it rides the defect - * channel (this helper always runs inside a `makeAsyncResult` work thunk, - * whose throw→defect net captures it). The TypeScript surface already gates - * the happy path; the runtime check catches typed escape hatches (`as never`, - * `as any`, raw-call interop) where a typo would otherwise silently drop the - * attribute, leaving the workflow unindexed without any signal to the caller. + * channel (this helper always runs inside a combinator callback or a + * `makeAsyncResult` work thunk, whose throw→defect net captures it). The + * TypeScript surface already gates the happy path; the runtime check catches + * typed escape hatches (`as never`, `as any`, raw-call interop) where a typo + * would otherwise silently drop the attribute, leaving the workflow + * unindexed without any signal to the caller. */ export function toTypedSearchAttributes( workflowDef: AnyWorkflowDefinition, @@ -119,7 +140,7 @@ export function toTypedSearchAttributes( if (value === undefined) continue; const def = declared[name]; if (!def) { - // oxlint-disable-next-line unthrown/no-throw -- defect-channel routing: this throw inside the makeAsyncResult work thunk IS how a technical fault becomes a defect, never a modeled Err + // oxlint-disable-next-line unthrown/no-throw -- defect-channel routing: this throw is captured by the enclosing throw→defect net and becomes a defect, never a modeled Err throw new RuntimeClientError( "searchAttributes", new Error( @@ -130,7 +151,7 @@ export function toTypedSearchAttributes( } const { expected, check } = searchAttributeValueChecks[def.kind]; if (!check(value)) { - // oxlint-disable-next-line unthrown/no-throw -- defect-channel routing: this throw inside the makeAsyncResult work thunk IS how a technical fault becomes a defect, never a modeled Err + // oxlint-disable-next-line unthrown/no-throw -- defect-channel routing: this throw is captured by the enclosing throw→defect net and becomes a defect, never a modeled Err throw new RuntimeClientError( "searchAttributes", new Error( @@ -155,9 +176,10 @@ export function toTypedSearchAttributes( * `result.isDefect()` / `result.cause`, re-thrown at the edge) rather than a * manufactured `RuntimeClientError`. * - * Used by `client.ts` (workflow operations) and `schedule.ts` (schedule - * operations) so the unexpected-rejection shape is identical across the - * typed client surface. Delegates to `_internal_makeAsyncResult` from + * The workflow/handle pipelines compose `AsyncResult` combinators directly; + * this wrapper remains for the setup-shaped sites (`TypedClient.create`, + * `schedule.create`) whose multi-step imperative flow doesn't decompose into + * a chain. Delegates to `_internal_makeAsyncResult` from * `@temporal-contract/contract` so the same wrapper is shared between the * client and worker packages. */ @@ -166,6 +188,21 @@ export function makeAsyncResult(work: () => Promise>): AsyncR return _internal_makeAsyncResult(work); } +/** + * Run a Standard Schema validation as an `AsyncResult` boundary. The Ok + * value is the schema's own result object (issues included) — deciding + * whether issues become a modeled `Err` stays at the call site, which knows + * the right validation-error class. A schema that *throws* (instead of + * reporting issues) is a bug in the schema, so it surfaces on the defect + * channel (`fromSafePromise` — every rejection is a defect). + */ +export function validateStandardSchema( + schema: StandardSchemaV1, + value: unknown, +): AsyncResult, never> { + return fromSafePromise((async () => await schema["~standard"].validate(value))()); +} + /** * Attempt to rehydrate a workflow failure's cause into a typed * {@link ContractError} declared on the workflow's `errors` map. Returns @@ -181,6 +218,24 @@ export async function rehydrateWorkflowContractError( return _internal_rehydrateContractError(workflowDef.errors, cause); } +/** + * Async tail of the result-error classification: a {@link WorkflowFailedError} + * whose `cause` matches one of the workflow's declared contract errors + * rehydrates into that typed error; otherwise the original error flows + * through unchanged. Composed via `flatMapErrCases` by the two + * result-awaiting paths (`executeWorkflow` and `handle.result()`) — the + * rehydration validates the error payload against its declared schema, + * which may be async, so it can't run inside a synchronous `qualify`. + */ +export function rehydrateFailedResult( + workflowDef: AnyWorkflowDefinition, + failed: WorkflowFailedError, +): AsyncResult { + return fromSafePromise(rehydrateWorkflowContractError(workflowDef, failed.cause)).flatMap( + (rehydrated) => Err(rehydrated ?? failed), + ); +} + /** * Recognize a thrown error from `client.workflow.start` / `signalWithStart` * as the modeled {@link WorkflowAlreadyStartedError} (Temporal's @@ -221,31 +276,61 @@ export function classifyHandleError( return undefined; } +/** + * Union of the modeled errors {@link classifyResultError} can produce — the + * result-phase classification of `handle.result()` / + * `client.workflow.execute()`. + */ +export type ClassifiedResultError = + | WorkflowFailedError + | WorkflowCancelledError + | WorkflowTerminatedError + | WorkflowTimeoutError + | WorkflowExecutionNotFoundError; + /** * Recognize a thrown error from `handle.result()` / `client.workflow.execute()` * (the latter when waiting on the result phase) as one of the modeled - * {@link WorkflowFailedError} / {@link WorkflowExecutionNotFoundError} - * (Temporal's `WorkflowFailedError` / `WorkflowNotFoundError`). Returns - * `undefined` for anything else — an unrecognized, *technical* failure the - * caller routes to the defect channel with a {@link RuntimeClientError} cause. + * result-phase errors. Returns `undefined` for anything else — an + * unrecognized, *technical* failure the caller routes to the defect channel + * with a {@link RuntimeClientError} cause. * * Temporal's `WorkflowFailedError` is itself a wrapper — the actionable * failure (ApplicationFailure, CancelledFailure, TerminatedFailure, etc.) - * lives on its `cause` field. We forward that inner cause directly so - * consumers can match `err.cause` against the underlying failure class - * without an extra unwrap step. (If Temporal's cause is `undefined`, our - * `cause` is too — same shape as before.) + * lives on its `cause` field. The workflow-outcome causes classify into + * their own first-class errors so consumers never dig through `cause` with + * `instanceof`: + * + * - `CancelledFailure` → {@link WorkflowCancelledError} + * - `TerminatedFailure` → {@link WorkflowTerminatedError} + * - `TimeoutFailure` → {@link WorkflowTimeoutError} + * - anything else → {@link WorkflowFailedError} + * + * In every branch the original inner failure is kept as the surfaced + * error's `cause` (Temporal's wrapper itself is seen through). If Temporal's + * cause is `undefined`, the generic {@link WorkflowFailedError} carries an + * `undefined` cause — same shape as before. */ export function classifyResultError( error: unknown, workflowId: string, -): WorkflowFailedError | WorkflowExecutionNotFoundError | undefined { +): ClassifiedResultError | undefined { if (error instanceof TemporalWorkflowFailedError) { + const cause = error.cause; + if (cause instanceof CancelledFailure) { + return new WorkflowCancelledError(workflowId, cause); + } + if (cause instanceof TerminatedFailure) { + return new WorkflowTerminatedError(workflowId, cause); + } + if (cause instanceof TimeoutFailure) { + return new WorkflowTimeoutError(workflowId, cause); + } // Temporal types `cause` as `Error | undefined`, but the SDK only ever // populates it with a `TemporalFailure` subclass when surfacing a // workflow result failure. Narrow with the public union so consumers // can branch on the leaf failure types without an extra cast. - return new WorkflowFailedError(workflowId, error.cause as TemporalFailure | undefined); + return new WorkflowFailedError(workflowId, cause as TemporalFailure | undefined); } if (error instanceof TemporalWorkflowNotFoundError) { return new WorkflowExecutionNotFoundError(error.workflowId || workflowId, error.runId, error); @@ -254,23 +339,66 @@ export function classifyResultError( } /** - * Shared rehydrate-then-classify tail for the two result-awaiting paths - * (`executeWorkflow` and `handle.result()`): a Temporal `WorkflowFailedError` - * whose cause matches one of the workflow's declared contract errors - * rehydrates into that typed error; everything else falls through to - * {@link classifyResultError}. Returns `undefined` for unrecognized errors — - * the caller routes those to the defect channel. + * The failure `type` the worker package's update-input validator stamps on + * the `ApplicationFailure` it throws from Temporal's synchronous validator + * slot — the wire-level marker of an admission rejection. Kept as a literal + * (not an import) so the client package doesn't depend on the worker + * package; the value is pinned by the worker's `UpdateInputValidationError`. */ -export async function classifyExecutionResultError( - workflowDef: AnyWorkflowDefinition, +const UPDATE_INPUT_VALIDATION_FAILURE_TYPE = "UpdateInputValidationError"; + +/** + * Recognize a thrown error from an update call (`executeUpdate`, + * `startUpdate`, or the update handle's `result()`) as one of the modeled + * update errors. Returns `undefined` for anything else — an unrecognized, + * *technical* failure the caller routes to the defect channel with a + * {@link RuntimeClientError} cause. + * + * Temporal reports both admission rejections and handler failures through + * the same `WorkflowUpdateFailedError` wrapper; the two are told apart by + * the failure `type` the `@temporal-contract/worker` validator stamps on a + * rejection: + * + * - cause is the worker's `UpdateInputValidationError` `ApplicationFailure` + * → {@link UpdateRejectedError} (the handler never ran); + * - anything else → {@link UpdateFailedError} (the admitted handler failed). + * + * The original inner failure is kept as the surfaced error's `cause`. + */ +export function classifyUpdateError( error: unknown, - workflowId: string, -): Promise { - if (error instanceof TemporalWorkflowFailedError) { - const rehydrated = await rehydrateWorkflowContractError(workflowDef, error.cause); - if (rehydrated) return rehydrated; + updateName: string, +): UpdateFailedError | UpdateRejectedError | undefined { + if (error instanceof WorkflowUpdateFailedError) { + const cause = error.cause; + if ( + cause instanceof ApplicationFailure && + cause.type === UPDATE_INPUT_VALIDATION_FAILURE_TYPE + ) { + return new UpdateRejectedError(updateName, cause); + } + return new UpdateFailedError(updateName, cause); + } + return undefined; +} + +/** + * Recognize a thrown error from `handle.query(...)` as the modeled + * {@link QueryFailedError}. Temporal surfaces both "no handler registered + * under this name" and "the query handler threw" as + * `QueryNotRegisteredError` (an `INVALID_ARGUMENT` gRPC failure), so both + * classify here; the original error is kept as `cause`. Returns `undefined` + * for anything else — an unrecognized, *technical* failure the caller + * routes to the defect channel with a {@link RuntimeClientError} cause. + */ +export function classifyQueryError( + error: unknown, + queryName: string, +): QueryFailedError | undefined { + if (error instanceof QueryNotRegisteredError) { + return new QueryFailedError(queryName, error); } - return classifyResultError(error, workflowId); + return undefined; } /** diff --git a/packages/client/src/schedule.spec.ts b/packages/client/src/schedule.spec.ts index 81d65d80..a219fcc8 100644 --- a/packages/client/src/schedule.spec.ts +++ b/packages/client/src/schedule.spec.ts @@ -441,17 +441,116 @@ describe("TypedClient.schedule", () => { } }); - it("routes update through Temporal's ScheduleHandle.update", async () => { + it("update fetches the description, applies updateFn once, and persists the computed options", async () => { const tempHandle = createMockHandle(); tempHandle.update.mockResolvedValue(undefined); + tempHandle.describe.mockResolvedValue({ scheduleId: "daily-sweep", spec: { intervals: [] } }); mockSchedule.getHandle.mockReturnValue(tempHandle); const handle = client.schedule.getHandle("daily-sweep"); - const updateFn = (previous: { spec?: unknown }) => ({ spec: previous.spec ?? {} }); + const updateFn = vi.fn((previous: { spec?: unknown }) => ({ spec: previous.spec ?? {} })); const result = await handle.update(updateFn as never); expect(result).toBeOk(); - expect(tempHandle.update).toHaveBeenCalledWith(updateFn); + // The wrapper fetches the description itself (so it can async-validate + // before anything persists) and hands Temporal a thunk returning the + // already-computed options — updateFn runs exactly once. + expect(tempHandle.describe).toHaveBeenCalledTimes(1); + expect(updateFn).toHaveBeenCalledTimes(1); + expect(updateFn).toHaveBeenCalledWith({ scheduleId: "daily-sweep", spec: { intervals: [] } }); + expect(tempHandle.update).toHaveBeenCalledTimes(1); + const persistFn = tempHandle.update.mock.calls[0]?.[0] as (previous: unknown) => unknown; + expect(persistFn({})).toEqual({ spec: { intervals: [] } }); + }); + + it("update validates args against the contract when the action targets a declared workflow", async () => { + const tempHandle = createMockHandle(); + tempHandle.update.mockResolvedValue(undefined); + mockSchedule.getHandle.mockReturnValue(tempHandle); + + const handle = client.schedule.getHandle("daily-sweep"); + const result = await handle.update((() => ({ + spec: {}, + action: { + type: "startWorkflow", + workflowType: "processOrder", + taskQueue: "schedules-q", + // orderId must be a string — this must be rejected. + args: [{ orderId: 123 }], + }, + })) as never); + + expect(result).toBeErr(); + if (result.isErr()) { + expect(result.error).toBeInstanceOf(WorkflowValidationError); + const error = result.error as WorkflowValidationError; + expect(error.workflowName).toBe("processOrder"); + expect(error.direction).toBe("input"); + } + // Nothing persisted on a validation failure. + expect(tempHandle.update).not.toHaveBeenCalled(); + }); + + it("update accepts valid args for a declared workflow and persists them unchanged", async () => { + const tempHandle = createMockHandle(); + tempHandle.update.mockResolvedValue(undefined); + mockSchedule.getHandle.mockReturnValue(tempHandle); + + const updated = { + spec: {}, + action: { + type: "startWorkflow", + workflowType: "processOrder", + taskQueue: "schedules-q", + args: [{ orderId: "sweep-2" }], + }, + }; + const handle = client.schedule.getHandle("daily-sweep"); + const result = await handle.update((() => updated) as never); + + expect(result).toBeOk(); + const persistFn = tempHandle.update.mock.calls[0]?.[0] as (previous: unknown) => unknown; + // Original args on the wire — validated, not transformed (D1). + expect(persistFn({})).toBe(updated); + }); + + it("update passes through actions whose workflowType isn't declared on the contract", async () => { + // Documented passthrough: the contract has no schema to check an + // undeclared workflowType against, so the options persist as-is. + const tempHandle = createMockHandle(); + tempHandle.update.mockResolvedValue(undefined); + mockSchedule.getHandle.mockReturnValue(tempHandle); + + const handle = client.schedule.getHandle("daily-sweep"); + const result = await handle.update((() => ({ + spec: {}, + action: { + type: "startWorkflow", + workflowType: "someForeignWorkflow", + taskQueue: "other-q", + args: [{ anything: true }], + }, + })) as never); + + expect(result).toBeOk(); + expect(tempHandle.update).toHaveBeenCalledTimes(1); + }); + + it("update surfaces ScheduleNotFoundError when the describe phase hits a missing schedule", async () => { + const tempHandle = { ...createMockHandle(), scheduleId: "missing" }; + tempHandle.describe.mockRejectedValue( + new MockTemporalScheduleNotFoundError("not found", "missing"), + ); + mockSchedule.getHandle.mockReturnValue(tempHandle); + + const handle = client.schedule.getHandle("missing"); + const result = await handle.update(((previous: unknown) => previous) as never); + + expect(result).toBeErr(); + if (result.isErr()) { + expect(result.error).toBeInstanceOf(ScheduleNotFoundError); + } + expect(tempHandle.update).not.toHaveBeenCalled(); }); it("routes backfill through Temporal's ScheduleHandle.backfill", async () => { diff --git a/packages/client/src/schedule.ts b/packages/client/src/schedule.ts index 75b90a69..5ed413b7 100644 --- a/packages/client/src/schedule.ts +++ b/packages/client/src/schedule.ts @@ -1,4 +1,4 @@ -import type { ContractDefinition } from "@temporal-contract/contract"; +import type { AnyWorkflowDefinition, ContractDefinition } from "@temporal-contract/contract"; import type { Backfill, ListScheduleOptions, @@ -13,7 +13,7 @@ import type { ScheduleUpdateOptions, Workflow, } from "@temporalio/client"; -import { type AsyncResult, Ok, Err, fromPromise } from "unthrown"; +import { type AsyncResult, Ok, Err, OkAsync, fromPromise } from "unthrown"; import type { TypedSearchAttributeMap } from "./client.js"; import { @@ -28,6 +28,7 @@ import { classifyScheduleHandleError, makeAsyncResult, toTypedSearchAttributes, + validateStandardSchema, } from "./internal.js"; import type { ClientInferInput } from "./types.js"; @@ -119,19 +120,30 @@ export type TypedScheduleHandle = { /** Fire the schedule's action immediately. */ trigger: (overlap?: ScheduleOverlapPolicy) => AsyncResult; /** - * Update the schedule definition: Temporal fetches the current + * Update the schedule definition: the handle fetches the current * description, hands it to `updateFn`, and persists the returned options. - * `updateFn` may be invoked more than once on conflict — keep it pure. * - * Passthrough of Temporal's `ScheduleHandle.update`; the action's - * `workflowType`/`taskQueue`/`args` are not re-validated against the - * contract here — prefer delete + `create` for contract-level changes. + * When the returned action's `workflowType` names a workflow declared on + * the bound contract, the action's `args` are validated against that + * workflow's input schema before anything is persisted — a mismatch + * surfaces as {@link WorkflowValidationError} on the Err channel and the + * schedule is left untouched. An action whose `workflowType` is NOT + * declared on the contract is persisted as-is (passthrough — the contract + * has no schema to check it against); prefer delete + `create` for + * contract-level changes. + * + * Implementation note: validation is asynchronous (schemas may be), so + * the description is fetched by this wrapper and the *already-computed* + * options are handed to Temporal's `ScheduleHandle.update`. `updateFn` is + * therefore invoked exactly once per call — on a server-side conflict the + * same computed options are retried, rather than `updateFn` being re-run + * against a fresh description. */ update: ( updateFn: ( previous: ScheduleDescription, ) => ScheduleUpdateOptions>, - ) => AsyncResult; + ) => AsyncResult; /** * Run the schedule's action for historical time ranges, as if the * schedule had been active over them. Passthrough of Temporal's @@ -147,19 +159,28 @@ export type TypedScheduleHandle = { /** * Typed wrapper around Temporal's `ScheduleClient`. Exposed as * `contractClient.schedule` — keeps the typed-client surface organized the - * same way Temporal's own `Client.schedule` does. + * same way Temporal's own `Client.schedule` does. Not constructible + * directly: the class is exported for type annotations only. */ export class TypedScheduleClient { + private constructor( + private readonly contract: TContract, + private readonly scheduleClient: ScheduleClient, + ) {} + /** - * Constructed exclusively by {@link ContractClient}'s constructor. Not - * part of the public API — reach it via `typedClient.for(contract).schedule`. + * Constructed exclusively by `ContractClient` (itself handed out by + * `TypedClient.for`). Not part of the public API — reach instances via + * `typedClient.for(contract).schedule`. * * @internal */ - constructor( - private readonly contract: TContract, - private readonly scheduleClient: ScheduleClient, - ) {} + static _internal_create( + contract: TContract, + scheduleClient: ScheduleClient, + ): TypedScheduleClient { + return new TypedScheduleClient(contract, scheduleClient); + } /** * Create a new schedule that, on each fire, starts the named contract @@ -183,8 +204,11 @@ export class TypedScheduleClient { TypedScheduleHandle, WorkflowNotInContractError | WorkflowValidationError | ScheduleAlreadyExistsError > { - type Ok = TypedScheduleHandle; - type Err = WorkflowNotInContractError | WorkflowValidationError | ScheduleAlreadyExistsError; + type CreateOk = TypedScheduleHandle; + type CreateErr = + | WorkflowNotInContractError + | WorkflowValidationError + | ScheduleAlreadyExistsError; const work = async () => { const definition = this.contract.workflows[workflowName]; if (!definition) { @@ -250,7 +274,7 @@ export class TypedScheduleClient { ...(options.state !== undefined ? { state: options.state } : {}), ...(options.memo !== undefined ? { memo: options.memo } : {}), }); - return Ok(wrapScheduleHandle(handle)); + return Ok(wrapScheduleHandle(handle, this.contract)); } catch (error) { const alreadyExists = classifyScheduleCreateError(error, options.scheduleId); if (alreadyExists) return Err(alreadyExists); @@ -259,7 +283,7 @@ export class TypedScheduleClient { throw new RuntimeClientError("schedule.create", error); } }; - return makeAsyncResult(work); + return makeAsyncResult(work); } /** @@ -268,7 +292,7 @@ export class TypedScheduleClient { * {@link ScheduleNotFoundError} if the underlying ID is unknown. */ getHandle(scheduleId: string): TypedScheduleHandle { - return wrapScheduleHandle(this.scheduleClient.getHandle(scheduleId)); + return wrapScheduleHandle(this.scheduleClient.getHandle(scheduleId), this.contract); } /** @@ -282,7 +306,10 @@ export class TypedScheduleClient { } } -function wrapScheduleHandle(handle: ScheduleHandle): TypedScheduleHandle { +function wrapScheduleHandle( + handle: ScheduleHandle, + contract: ContractDefinition, +): TypedScheduleHandle { // Every lifecycle method shares the classify-or-defect tail: a missing // schedule is the modeled Err; anything else rides the defect channel. return { @@ -309,12 +336,58 @@ function wrapScheduleHandle(handle: ScheduleHandle): TypedScheduleHandle { defect(new RuntimeClientError("schedule.trigger", error)), ).map(() => undefined), update: (updateFn) => + // Fetch the current description here (rather than inside Temporal's + // own `update`) so the computed options can be validated with the + // same async schema machinery as `create` BEFORE anything persists. fromPromise( - handle.update(updateFn), + handle.describe(), (error, defect) => classifyScheduleHandleError(error, handle.scheduleId) ?? defect(new RuntimeClientError("schedule.update", error)), - ).map(() => undefined), + ) + .flatMap((previous) => { + // A throwing updateFn is a caller bug — the flatMap net turns it + // into a defect, matching the raw SDK's rejection shape. + const updated = updateFn(previous); + // Temporal's action accepts a workflow *function* beside a string + // type name; a declared contract workflow is always addressed by + // its string name (that's how `create` writes it). + const workflowType = updated.action?.workflowType; + const workflowDef = + typeof workflowType === "string" + ? (contract.workflows[workflowType] as AnyWorkflowDefinition | undefined) + : undefined; + if (!workflowDef) { + // Action doesn't target a declared contract workflow — the + // contract has no schema to check it against, so persist as-is + // (documented passthrough). + return OkAsync(updated); + } + // Same machinery as `create`: async-validate the action's args + // against the declared workflow's input schema, fail early with + // the same typed error, and transmit the ORIGINAL args (D1). + return validateStandardSchema(workflowDef.input, updated.action.args?.[0]).flatMap( + (inputResult) => + inputResult.issues + ? Err( + new WorkflowValidationError( + workflowType as string, + "input", + inputResult.issues, + ), + ) + : Ok(updated), + ); + }) + .flatMap((updated) => + fromPromise( + handle.update(() => updated), + (error, defect) => + classifyScheduleHandleError(error, handle.scheduleId) ?? + defect(new RuntimeClientError("schedule.update", error)), + ), + ) + .map(() => undefined), backfill: (options) => fromPromise( handle.backfill(options), diff --git a/packages/client/src/types-inference.spec.ts b/packages/client/src/types-inference.spec.ts index 56e07693..a73a2c36 100644 --- a/packages/client/src/types-inference.spec.ts +++ b/packages/client/src/types-inference.spec.ts @@ -31,17 +31,24 @@ import { import { describe, expectTypeOf, it } from "vitest"; import { z } from "zod"; +import { ContractClient, type TypedClient } from "./client.js"; +import type { TypedSignalWithStartOptions, TypedWorkflowStartOptions } from "./client.js"; import type { - ContractClient, - TypedClient, - TypedSignalWithStartOptions, - TypedWorkflowStartOptions, -} from "./client.js"; -import type { + QueryFailedError, + QueryValidationError, + UpdateFailedError, + UpdateRejectedError, + UpdateValidationError, WorkflowAlreadyStartedError, + WorkflowCancelledError, + WorkflowExecutionNotFoundError, + WorkflowFailedError, WorkflowNotInContractError, + WorkflowTerminatedError, + WorkflowTimeoutError, WorkflowValidationError, } from "./errors.js"; +import { TypedScheduleClient } from "./schedule.js"; const contractWithSignal = defineContract({ taskQueue: "q", @@ -288,6 +295,113 @@ describe("method-level pins", () => { void _pin; }); + it("executeWorkflow's error union includes the first-class outcome errors, precisely", () => { + const _pin = async (bound: ContractClient) => { + const result = await bound.executeWorkflow("bare", { workflowId: "x", args: { a: "s" } }); + if (result.isErr()) { + // `bare` declares no contract errors, so this is the exact union — + // cancellation/termination/timeout are first-class members, not + // `WorkflowFailedError.cause` variants. + expectTypeOf(result.error).toEqualTypeOf< + | WorkflowNotInContractError + | WorkflowValidationError + | WorkflowAlreadyStartedError + | WorkflowFailedError + | WorkflowCancelledError + | WorkflowTerminatedError + | WorkflowTimeoutError + | WorkflowExecutionNotFoundError + >(); + } + }; + void _pin; + }); + + it("handle.result() surfaces the same outcome-aware union", () => { + const _pin = async (bound: ContractClient) => { + const handleResult = bound.getHandle("bare", "id-1"); + if (!handleResult.isOk()) return; + const result = await handleResult.value.result(); + if (result.isErr()) { + expectTypeOf(result.error).toEqualTypeOf< + | WorkflowValidationError + | WorkflowFailedError + | WorkflowCancelledError + | WorkflowTerminatedError + | WorkflowTimeoutError + | WorkflowExecutionNotFoundError + >(); + } + }; + void _pin; + }); + + it("queries err with QueryFailedError beside validation and not-found", () => { + const _pin = async (bound: ContractClient) => { + const handleResult = bound.getHandle("processOrder", "id-1"); + if (!handleResult.isOk()) return; + const result = await handleResult.value.queries.progress(); + if (result.isErr()) { + expectTypeOf(result.error).toEqualTypeOf< + QueryValidationError | QueryFailedError | WorkflowExecutionNotFoundError + >(); + } + }; + void _pin; + }); + + it("updates err with UpdateRejectedError/UpdateFailedError beside validation and not-found", () => { + const _pin = async (bound: ContractClient) => { + const handleResult = bound.getHandle("processOrder", "id-1"); + if (!handleResult.isOk()) return; + const handle = handleResult.value; + + const executed = await handle.updates.refresh(); + if (executed.isErr()) { + expectTypeOf(executed.error).toEqualTypeOf< + | UpdateValidationError + | UpdateRejectedError + | UpdateFailedError + | WorkflowExecutionNotFoundError + >(); + } + + const started = await handle.startUpdate("refresh"); + if (started.isErr()) { + expectTypeOf(started.error).toEqualTypeOf< + | UpdateValidationError + | UpdateRejectedError + | UpdateFailedError + | WorkflowExecutionNotFoundError + >(); + } + if (started.isOk()) { + const outcome = await started.value.result(); + if (outcome.isErr()) { + expectTypeOf(outcome.error).toEqualTypeOf< + | UpdateValidationError + | UpdateRejectedError + | UpdateFailedError + | WorkflowExecutionNotFoundError + >(); + } + } + }; + void _pin; + }); + + it("ContractClient and TypedScheduleClient are not publicly constructible", () => { + const _pin = () => { + // @ts-expect-error — private constructor: obtain instances via typedClient.for(contract). + const _client = new ContractClient(contractNoSignals, undefined as never, []); + // @ts-expect-error — private constructor: reach it via typedClient.for(contract).schedule. + const _schedule = new TypedScheduleClient(contractNoSignals, undefined as never); + void _client; + void _schedule; + }; + void _pin; + }); + it("getHandle is synchronous and Err-narrows to WorkflowNotInContractError", () => { const _pin = (bound: ContractClient) => { const handleResult = bound.getHandle("processOrder", "id-1", { diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 427c6f8c..6e1d0188 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -7,8 +7,11 @@ import type { import type { AsyncResult } from "unthrown"; import type { + QueryFailedError, QueryValidationError, SignalValidationError, + UpdateFailedError, + UpdateRejectedError, UpdateValidationError, WorkflowExecutionNotFoundError, } from "./errors.js"; @@ -49,13 +52,18 @@ export type ClientInferSignal = ( * an `AsyncResult`; the payload argument is omittable when the schema * accepts `undefined` (e.g. argument-less `defineQuery({ output })`). * The error union names exactly what the handle's query proxy produces: - * input/output-validation failure or a missing execution. + * input/output-validation failure, a query the execution could not serve + * (unregistered handler or a throwing handler — `QueryFailedError`), or a + * missing execution. */ export type ClientInferQuery = ( ...args: undefined extends ClientInferInput ? [input?: ClientInferInput] : [input: ClientInferInput] -) => AsyncResult, QueryValidationError | WorkflowExecutionNotFoundError>; +) => AsyncResult< + ClientInferOutput, + QueryValidationError | QueryFailedError | WorkflowExecutionNotFoundError +>; /** * Infer update handler signature from client perspective. @@ -63,7 +71,9 @@ export type ClientInferQuery = ( * an `AsyncResult`; the payload argument is omittable when the schema * accepts `undefined` (e.g. argument-less `defineUpdate({ output })`). * The error union names exactly what the handle's update proxy produces: - * input/output-validation failure or a missing execution. + * input/output-validation failure, a worker-side admission rejection + * (`UpdateRejectedError`), a failed admitted handler (`UpdateFailedError`), + * or a missing execution. */ export type ClientInferUpdate = ( ...args: undefined extends ClientInferInput @@ -71,7 +81,7 @@ export type ClientInferUpdate = ( : [input: ClientInferInput] ) => AsyncResult< ClientInferOutput, - UpdateValidationError | WorkflowExecutionNotFoundError + UpdateValidationError | UpdateRejectedError | UpdateFailedError | WorkflowExecutionNotFoundError >; /** From 46ccece333a66ba86abd755272c6c4c1c444d211 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 31 Jul 2026 10:04:30 +0200 Subject: [PATCH 07/15] feat(testing)!: option-bag APIs, boundary-faithful runActivityHandler, replay and skew coverage - createContractTest({ contract, ... }) and runActivity(definition, options) per family convention - runActivityHandler routes through the real declareActivitiesHandler wrapping and wire round-trip - @unthrown/vitest wired in; raw boolean asserts migrated to matchers across worker/testing specs - replay-history spec proves the Result machinery replays deterministically - e2e specs: contract-skew degrade + onRehydrationMiss, marker false-positive regression, activityOptionsByName taskQueue routing (Docker) - testcontainers moved to an optional peer dependency with a lazy import and install-hint error --- knip.json | 11 +- packages/testing/package.json | 11 +- .../src/__tests__/contract-test.spec.ts | 15 +- packages/testing/src/activity.spec.ts | 227 +++++++++++++---- packages/testing/src/activity.ts | 229 ++++++++++++++++-- packages/testing/src/contract.ts | 9 +- packages/testing/src/global-setup.ts | 25 +- packages/testing/src/vitest.setup.ts | 6 + packages/testing/vitest.config.ts | 2 + .../src/__tests__/rehydration.contract.ts | 63 +++++ .../__tests__/rehydration.inprocess.spec.ts | 145 +++++++++++ .../src/__tests__/rehydration.workflows.ts | 40 +++ .../src/__tests__/replay.inprocess.spec.ts | 101 ++++++++ .../worker/src/__tests__/routing.contract.ts | 25 ++ packages/worker/src/__tests__/routing.spec.ts | 107 ++++++++ .../worker/src/__tests__/routing.workflows.ts | 24 ++ .../__tests__/time-skipping.inprocess.spec.ts | 35 +-- packages/worker/src/activities-proxy.spec.ts | 30 +-- packages/worker/src/wire-format.spec.ts | 22 +- pnpm-lock.yaml | 10 +- 20 files changed, 990 insertions(+), 147 deletions(-) create mode 100644 packages/testing/src/vitest.setup.ts create mode 100644 packages/worker/src/__tests__/rehydration.contract.ts create mode 100644 packages/worker/src/__tests__/rehydration.inprocess.spec.ts create mode 100644 packages/worker/src/__tests__/rehydration.workflows.ts create mode 100644 packages/worker/src/__tests__/replay.inprocess.spec.ts create mode 100644 packages/worker/src/__tests__/routing.contract.ts create mode 100644 packages/worker/src/__tests__/routing.spec.ts create mode 100644 packages/worker/src/__tests__/routing.workflows.ts diff --git a/knip.json b/knip.json index a76f2475..e8174436 100644 --- a/knip.json +++ b/knip.json @@ -19,7 +19,16 @@ "entry": ["src/__tests__/test.workflows.ts", "src/__tests__/second.workflows.ts"] }, "packages/worker": { - "entry": ["src/__tests__/test.workflows.ts", "src/__tests__/inprocess.workflows.ts"] + "entry": [ + "src/__tests__/test.workflows.ts", + "src/__tests__/inprocess.workflows.ts", + "src/__tests__/rehydration.workflows.ts", + "src/__tests__/routing.workflows.ts", + "src/__tests__/registration-complete.workflows.ts", + "src/__tests__/registration-mismatch.workflows.ts", + "src/__tests__/registration-missing.workflows.ts", + "src/__tests__/registration-raw.workflows.ts" + ] }, "examples/order-processing-worker": { "entry": ["src/application/workflows.ts"] diff --git a/packages/testing/package.json b/packages/testing/package.json index 67a2631a..1e6c435d 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -58,9 +58,6 @@ "test:watch": "vitest --project unit", "typecheck": "tsc --noEmit" }, - "dependencies": { - "testcontainers": "catalog:" - }, "devDependencies": { "@arethetypeswrong/cli": "catalog:", "@btravstack/tsconfig": "catalog:", @@ -70,8 +67,10 @@ "@temporalio/worker": "catalog:", "@temporalio/workflow": "catalog:", "@types/node": "catalog:", + "@unthrown/vitest": "catalog:", "@vitest/coverage-v8": "catalog:", "publint": "catalog:", + "testcontainers": "catalog:", "tsdown": "catalog:", "typedoc": "catalog:", "typedoc-plugin-markdown": "catalog:", @@ -87,9 +86,15 @@ "@temporalio/client": "^1", "@temporalio/testing": "^1", "@temporalio/worker": "^1", + "testcontainers": "^12", "unthrown": "^5.0.0", "vitest": "^4" }, + "peerDependenciesMeta": { + "testcontainers": { + "optional": true + } + }, "engines": { "node": ">=22.19.0" } diff --git a/packages/testing/src/__tests__/contract-test.spec.ts b/packages/testing/src/__tests__/contract-test.spec.ts index 81bcab8f..ebbb1b41 100644 --- a/packages/testing/src/__tests__/contract-test.spec.ts +++ b/packages/testing/src/__tests__/contract-test.spec.ts @@ -27,7 +27,8 @@ const activities = declareActivitiesHandler({ }, }); -const it = createContractTest(testContract, { +const it = createContractTest({ + contract: testContract, workflowsPath: fileURLToPath( new URL(`./test.workflows${extname(import.meta.url)}`, import.meta.url), ), @@ -41,10 +42,7 @@ describe("createContractTest", () => { args: { name: "world" }, }); - expect(result.isOk()).toBe(true); - if (result.isOk()) { - expect(result.value).toEqual({ message: "Hello, WORLD!" }); - } + expect(result).toBeOkWith({ message: "Hello, WORLD!" }); }); it("exposes the running worker and the connection-scoped root", async ({ @@ -68,14 +66,11 @@ describe("createContractTest", () => { args: { name: "fixtures" }, }); - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); if (!handleResult.isOk()) return; expect(handleResult.value.workflowId).toBe(workflowId); const result = await handleResult.value.result(); - expect(result.isOk()).toBe(true); - if (result.isOk()) { - expect(result.value).toEqual({ message: "Hello, FIXTURES!" }); - } + expect(result).toBeOkWith({ message: "Hello, FIXTURES!" }); }); }); diff --git a/packages/testing/src/activity.spec.ts b/packages/testing/src/activity.spec.ts index 0b95d14c..95ff58e9 100644 --- a/packages/testing/src/activity.spec.ts +++ b/packages/testing/src/activity.spec.ts @@ -1,18 +1,33 @@ /** - * Coverage for `runActivity` — real `MockActivityEnvironment` (in-process, - * no Docker): Ok/Err channels flow through untouched, typed error - * constructors are built from the definition, unanticipated throws land on - * the defect channel, and a caller-provided environment enables heartbeat - * observation and cancellation. + * Coverage for `runActivity` and `runActivityHandler` — real + * `MockActivityEnvironment` (in-process, no Docker). + * + * `runActivity` (pure-logic tier): Ok/Err channels flow through untouched, + * typed error constructors are built from the definition, unanticipated + * throws land on the defect channel, and a caller-provided environment + * enables heartbeat observation and cancellation. + * + * `runActivityHandler` (boundary-faithful tier): the same implementations + * routed through the real `declareActivitiesHandler` wrapping — the three + * failure modes `runActivity` hides (invalid error data, output drift, + * undeclared error names) surface the production terminal failures, and a + * declared error round-trips the wire (ApplicationFailure + marker) back + * into a typed `ContractError`. */ import { defineActivity } from "@temporal-contract/contract"; import { ContractError } from "@temporal-contract/contract/errors"; +import { + ApplicationFailure, + ContractErrorDataValidationError, + ActivityInputValidationError, + ActivityOutputValidationError, +} from "@temporal-contract/worker/activity"; import { MockActivityEnvironment } from "@temporalio/testing"; import { ErrAsync, OkAsync, fromSafePromise } from "unthrown"; import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { runActivity } from "./activity.js"; +import { runActivity, runActivityHandler } from "./activity.js"; const charge = defineActivity({ input: z.object({ amount: z.number() }), @@ -28,29 +43,23 @@ const charge = defineActivity({ describe("runActivity", () => { it("returns the implementation's Ok result", async () => { - const result = await runActivity( - charge, - ({ amount }) => OkAsync({ transactionId: `TXN-${amount}` }), - { amount: 42 }, - ); - - expect(result.isOk()).toBe(true); - if (result.isOk()) { - expect(result.value).toEqual({ transactionId: "TXN-42" }); - } + const result = await runActivity(charge, { + implementation: ({ amount }) => OkAsync({ transactionId: `TXN-${amount}` }), + input: { amount: 42 }, + }); + + expect(result).toBeOkWith({ transactionId: "TXN-42" }); }); it("returns a typed contract error built with the errors helper", async () => { - const result = await runActivity( - charge, - ({ amount }, { errors }) => + const result = await runActivity(charge, { + implementation: ({ amount }, { errors }) => ErrAsync(errors.PaymentDeclined({ reason: `insufficient funds for ${amount}` })), - { amount: 9000 }, - ); + input: { amount: 9000 }, + }); - expect(result.isErr()).toBe(true); + expect(result).toBeErrTagged("@temporal-contract/ContractError"); if (result.isErr()) { - expect(result.error).toBeInstanceOf(ContractError); expect(result.error.errorName).toBe("PaymentDeclined"); expect(result.error.data).toEqual({ reason: "insufficient funds for 9000" }); } @@ -58,15 +67,14 @@ describe("runActivity", () => { it("surfaces an unanticipated throw on the defect channel", async () => { const boom = new Error("boom"); - const result = await runActivity( - charge, - () => { + const result = await runActivity(charge, { + implementation: () => { throw boom; }, - { amount: 1 }, - ); + input: { amount: 1 }, + }); - expect(result.isDefect()).toBe(true); + expect(result).toBeDefect(); if (result.isDefect()) { expect(result.cause).toBe(boom); } @@ -77,42 +85,175 @@ describe("runActivity", () => { const heartbeats: unknown[] = []; env.on("heartbeat", (details: unknown) => heartbeats.push(details)); - const result = await runActivity( - charge, - ({ amount }) => { + const result = await runActivity(charge, { + implementation: ({ amount }) => { // Inside `env.run`, `Context.current()` is this environment's // context — the mock env instance exposes it as `env.context`. env.context.heartbeat("halfway"); return OkAsync({ transactionId: `TXN-${amount}` }); }, - { amount: 7 }, - { env }, - ); + input: { amount: 7 }, + env, + }); - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); expect(heartbeats).toEqual(["halfway"]); }); it("surfaces cancellation as a defect", async () => { const env = new MockActivityEnvironment(); - const resultPromise = runActivity( - charge, - () => + const resultPromise = runActivity(charge, { + implementation: () => // Resolves only via rejection: `context.cancelled` rejects with a // CancelledFailure once `env.cancel()` fires — an unmodeled throw, // hence a defect. fromSafePromise(env.context.cancelled.then(() => ({ transactionId: "never" }))), - { amount: 1 }, - { env }, - ); + input: { amount: 1 }, + env, + }); env.cancel(); const result = await resultPromise; - expect(result.isDefect()).toBe(true); + expect(result).toBeDefect(); if (result.isDefect()) { expect(result.cause).toMatchObject({ name: "CancelledFailure" }); } }); }); + +describe("runActivityHandler", () => { + it("round-trips an Ok output through the real wrapping (input parsed, output validated)", async () => { + const seen: unknown[] = []; + const result = await runActivityHandler(charge, { + implementation: (args) => { + seen.push(args); + return OkAsync({ transactionId: `TXN-${args.amount}` }); + }, + input: { amount: 42 }, + }); + + expect(result).toBeOkWith({ transactionId: "TXN-42" }); + // The handler parsed the wire input before handing it over. + expect(seen).toEqual([{ amount: 42 }]); + }); + + it("round-trips a declared error over the wire and rehydrates the typed ContractError", async () => { + const result = await runActivityHandler(charge, { + implementation: ({ amount }, { errors }) => + ErrAsync(errors.PaymentDeclined({ reason: `declined-${amount}` })), + input: { amount: 9 }, + }); + + expect(result).toBeErrTagged("@temporal-contract/ContractError"); + if (result.isErr() && result.error instanceof ContractError) { + expect(result.error.errorName).toBe("PaymentDeclined"); + expect(result.error.data).toEqual({ reason: "declined-9" }); + // The rehydrated error's cause is the wire ApplicationFailure carrying + // the provenance marker at details[1]. + const failure = result.error.cause as ApplicationFailure; + expect(failure).toBeInstanceOf(ApplicationFailure); + expect(failure.type).toBe("PaymentDeclined"); + expect(failure.nonRetryable).toBe(true); + expect(failure.details?.[1]).toEqual({ $tc: 1 }); + } + }); + + it("surfaces error data that violates the declared schema as the production terminal failure", async () => { + // runActivity would return this Err untouched (a green test); the real + // boundary rejects the contract misuse terminally. + const result = await runActivityHandler(charge, { + implementation: (_args, { errors }) => + ErrAsync(errors.PaymentDeclined({ reason: 42 as unknown as string })), + input: { amount: 1 }, + }); + + expect(result).toBeErr(); + if (result.isErr()) { + expect(result.error).toBeInstanceOf(ContractErrorDataValidationError); + if (result.error instanceof ContractErrorDataValidationError) { + expect(result.error.nonRetryable).toBe(true); + expect(result.error.message).toContain('Contract error "PaymentDeclined"'); + } + } + }); + + it("surfaces output drifting from the output schema as ActivityOutputValidationError", async () => { + const result = await runActivityHandler(charge, { + implementation: () => OkAsync({ transactionId: 123 } as unknown as { transactionId: string }), + input: { amount: 1 }, + }); + + expect(result).toBeErr(); + if (result.isErr()) { + expect(result.error).toBeInstanceOf(ActivityOutputValidationError); + expect(result.error.message).toContain("output validation failed"); + } + }); + + it("surfaces an undeclared error name as the production contract-misuse failure", async () => { + const result = await runActivityHandler(charge, { + implementation: () => + ErrAsync( + new ContractError({ + errorName: "NotDeclared", + data: undefined, + message: "smuggled past the contract", + }), + ), + input: { amount: 1 }, + }); + + expect(result).toBeErr(); + if (result.isErr()) { + expect(result.error).toBeInstanceOf(ContractErrorDataValidationError); + expect(result.error.message).toContain('"NotDeclared" is not declared'); + } + }); + + it("parses the wire input like production — invalid input fails before the implementation runs", async () => { + let invoked = false; + const result = await runActivityHandler(charge, { + implementation: () => { + invoked = true; + return OkAsync({ transactionId: "never" }); + }, + input: { amount: "bad" } as unknown as { amount: number }, + }); + + expect(invoked).toBe(false); + expect(result).toBeErr(); + if (result.isErr()) { + expect(result.error).toBeInstanceOf(ActivityInputValidationError); + } + }); + + it("keeps an Err(ApplicationFailure) as the raw failure (no rehydration)", async () => { + const failure = ApplicationFailure.create({ type: "GATEWAY_5XX", message: "boom" }); + const result = await runActivityHandler(charge, { + implementation: () => ErrAsync(failure), + input: { amount: 1 }, + }); + + expect(result).toBeErr(); + if (result.isErr()) { + expect(result.error).toBe(failure); + } + }); + + it("keeps unanticipated throws on the defect channel", async () => { + const boom = new Error("boom"); + const result = await runActivityHandler(charge, { + implementation: () => { + throw boom; + }, + input: { amount: 1 }, + }); + + expect(result).toBeDefect(); + if (result.isDefect()) { + expect(result.cause).toBe(boom); + } + }); +}); diff --git a/packages/testing/src/activity.ts b/packages/testing/src/activity.ts index 767eaf4c..fd032a84 100644 --- a/packages/testing/src/activity.ts +++ b/packages/testing/src/activity.ts @@ -1,29 +1,45 @@ /** * Docker-free unit testing of a single activity implementation. * - * {@link runActivity} executes one activity implementation (the - * `AsyncResult`-returning functions passed to `declareActivitiesHandler`) - * inside `@temporalio/testing`'s `MockActivityEnvironment`, so - * `Context.current()` works — heartbeats are observable and cancellation can - * be triggered — without a worker, a server, or Docker. It builds the typed - * error constructors from the activity's contract definition, mirroring what - * the worker hands implementations at runtime. + * Two entry points, two altitudes: + * + * - {@link runActivity} executes one activity implementation (the + * `AsyncResult`-returning functions passed to `declareActivitiesHandler`) + * inside `@temporalio/testing`'s `MockActivityEnvironment`, so + * `Context.current()` works — heartbeats are observable and cancellation + * can be triggered — without a worker, a server, or Docker. The + * implementation's `AsyncResult` flows through **untouched**: no input + * parse, no output validation, no contract-error conversion. Use it for + * pure-logic unit tests. + * - {@link runActivityHandler} routes the same implementation through the + * **real** `declareActivitiesHandler` wrapping — input parse → + * implementation → output validation → contract-error → `ApplicationFailure` + * wire conversion — then rehydrates the wire failure back into a typed + * `Result`, exercising the full round-trip a workflow-side caller sees. + * Use it for boundary-faithful tests: it fails where production fails + * (schema drift, undeclared error names, invalid error data) even when the + * raw implementation's `Result` looks fine. * * This entry deliberately avoids `vitest` — it only needs * `@temporalio/testing` — so it can be used from any test runner. */ import type { ActivityDefinition, + ClientInferInput, + ClientInferOutput, ErrorDefinition, WorkerInferInput, } from "@temporal-contract/contract"; import { _internal_buildErrorConstructors, + _internal_rehydrateContractError, type ContractErrorConstructors, + type ContractErrorUnion, } from "@temporal-contract/contract/errors"; import { _internal_makeAsyncResult } from "@temporal-contract/contract/result-async"; +import { declareActivitiesHandler, ApplicationFailure } from "@temporal-contract/worker/activity"; import { MockActivityEnvironment } from "@temporalio/testing"; -import type { AsyncResult } from "unthrown"; +import { Err, Ok, type AsyncResult } from "unthrown"; /** * Typed error constructors for an activity's declared `errors` map — the @@ -37,11 +53,12 @@ type ActivityErrorConstructorsOf = TActivi : Record; /** - * Shape of the implementation accepted by {@link runActivity} — the same - * `(args, helpers) => AsyncResult<...>` shape `declareActivitiesHandler` - * expects, with the output/error channels inferred from the function itself. - * The `context` helper is always empty here: implementations relying on - * middleware-injected context should be exercised through a worker instead. + * Shape of the implementation accepted by {@link runActivity} and + * {@link runActivityHandler} — the same `(args, helpers) => AsyncResult<...>` + * shape `declareActivitiesHandler` expects, with the output/error channels + * inferred from the function itself. The `context` helper is always empty + * here: implementations relying on middleware-injected context should be + * exercised through a worker instead. */ export type RunActivityImplementation = ( args: WorkerInferInput, @@ -54,7 +71,14 @@ export type RunActivityImplementation = { + /** The activity implementation under test. */ + implementation: RunActivityImplementation; + /** + * The activity input, in the parsed shape the worker would hand the + * implementation. + */ + input: WorkerInferInput; /** * Reuse a prepared `MockActivityEnvironment` — pass one to observe * heartbeats (`env.on("heartbeat", ...)`), trigger cancellation @@ -71,33 +95,35 @@ export type RunActivityOptions = { * unanticipated throw (including a `CancelledFailure` from cancellation) * surfaces on the `defect` channel. * + * This is the **pure-logic** tier: no input parse, no output validation, no + * contract-error wire conversion. A test that passes here can still fail at + * the production boundary (e.g. an `Err` whose data violates the declared + * schema) — cover that with {@link runActivityHandler}. + * * @example * ```ts * import { runActivity } from "@temporal-contract/testing/activity"; * * const result = await runActivity( * orderContract.workflows.processOrder.activities.chargeCard, - * chargeCard, // (args, { errors }) => AsyncResult<...> - * { amount: 100 }, + * { + * implementation: chargeCard, // (args, { errors }) => AsyncResult<...> + * input: { amount: 100 }, + * }, * ); * - * expect(result.isOk()).toBe(true); + * await expect(result).toBeOk(); // with @unthrown/vitest matchers * ``` * * @param definition - The activity's contract definition (used to build the * typed `errors` constructors handed to the implementation). - * @param implementation - The activity implementation under test. - * @param input - The activity input, in the parsed shape the worker would - * hand the implementation. * @param options - See {@link RunActivityOptions}. */ export function runActivity( definition: TActivity, - implementation: RunActivityImplementation, - input: WorkerInferInput, - options?: RunActivityOptions, + options: RunActivityOptions, ): AsyncResult { - const env = options?.env ?? new MockActivityEnvironment(); + const env = options.env ?? new MockActivityEnvironment(); const helpers = { errors: _internal_buildErrorConstructors( definition.errors, @@ -110,7 +136,160 @@ export function runActivity = TActivity extends { + errors: infer TErrors extends Record; +} + ? ContractErrorUnion | ApplicationFailure + : ApplicationFailure; + +/** + * Options for {@link runActivityHandler}. + */ +export type RunActivityHandlerOptions = { + /** + * The activity implementation under test — the same function you would + * pass to `declareActivitiesHandler`. + */ + implementation: RunActivityImplementation; + /** + * The activity input as a **caller** would send it (the wire value, in the + * input schema's pre-transform shape) — the handler parses it, exactly + * like production. + */ + input: ClientInferInput; + /** + * Diagnostic name used in validation-error messages (mirrors the flat + * runtime activity name). + * + * @defaultValue `"activity"` + */ + activityName?: string; + /** + * Reuse a prepared `MockActivityEnvironment` — same semantics as + * {@link RunActivityOptions.env}. + */ + env?: MockActivityEnvironment; +}; + +/** + * Execute a single activity implementation through the **real** + * `declareActivitiesHandler` wrapping inside a `MockActivityEnvironment`, + * then classify the outcome the way a workflow-side caller would: + * + * - the wire input is parsed against the contract's input schema (an invalid + * input surfaces the production `ActivityInputValidationError`); + * - the implementation's `Ok` output is validated on the sending side and + * parsed on the receiving side, so a transforming output schema applies + * exactly once — and drift from the schema surfaces the production + * `ActivityOutputValidationError`; + * - a typed `Err(errors.X(data))` is converted to its `ApplicationFailure` + * wire shape (`type` = error name, `details[0]` = data, `details[1]` = + * the provenance wire marker) and **rehydrated** back into the typed + * `ContractError` — the full wire round-trip; + * - contract misuse (an undeclared error name, or error data failing its + * declared schema) surfaces the production terminal + * `ContractErrorDataValidationError` instead of a green test; + * - an unanticipated throw stays on the `defect` channel. + * + * Use {@link runActivity} for pure-logic unit tests of the implementation; + * use this when the test should be **boundary-faithful** — passing here + * means the same call succeeds through a real worker. + * + * @example + * ```ts + * import { runActivityHandler } from "@temporal-contract/testing/activity"; + * + * const result = await runActivityHandler( + * orderContract.workflows.processOrder.activities.chargeCard, + * { + * implementation: chargeCard, + * input: { amount: -1 }, + * }, + * ); + * + * // The declared error crossed the wire and rehydrated as a typed error. + * await expect(result).toBeErrTagged("@temporal-contract/ContractError"); + * ``` + * + * @param definition - The activity's contract definition. + * @param options - See {@link RunActivityHandlerOptions}. + */ +export function runActivityHandler( + definition: TActivity, + options: RunActivityHandlerOptions, +): AsyncResult, RunActivityHandlerError> { + const env = options.env ?? new MockActivityEnvironment(); + const activityName = options.activityName ?? "activity"; + + // Reuse the production wrapping end-to-end: a synthetic single-activity + // contract routed through `declareActivitiesHandler` yields the exact + // wrapped handler a worker would register (input parse → implementation → + // output validation → contract-error wire conversion). No validation logic + // is reimplemented here. + const syntheticContract = { + taskQueue: "run-activity-handler", + workflows: {}, + activities: { [activityName]: definition }, + } as unknown as Parameters[0]["contract"]; + const handler = declareActivitiesHandler({ + contract: syntheticContract, + activities: { + [activityName]: options.implementation, + } as unknown as Parameters[0]["activities"], + }); + const wrapped = (handler as Record Promise>)[ + activityName + ]!; + + return _internal_makeAsyncResult(async () => { + let wireOutput: unknown; + try { + wireOutput = await env.run(() => wrapped(options.input)); + } catch (error) { + if (error instanceof ApplicationFailure) { + // Receiving side of the failure boundary: a declared error name whose + // payload validates (with the wire marker corroborating provenance) + // rehydrates into the typed ContractError; anything else — including + // the worker's terminal validation failures — stays the raw + // ApplicationFailure, discriminable via `failure.type`. + const rehydrated = await _internal_rehydrateContractError(definition.errors, error); + return Err((rehydrated ?? error) as RunActivityHandlerError); + } + // Unanticipated throw (a bug, or cancellation) — re-throw inside the + // makeAsyncResult net so it rides the defect channel, like production. + // oxlint-disable-next-line unthrown/no-throw -- defect-channel edge: the unmodeled cause must surface as a defect, mirroring the worker boundary + throw error; + } + + // Receiving side of the output boundary: the handler validated the + // implementation's return but transmitted the ORIGINAL value, so the + // consumer-side parse here applies a transforming output schema exactly + // once — mirroring the workflow-side proxy. + const outputResult = await definition.output["~standard"].validate(wireOutput); + if (outputResult.issues) { + // Unreachable in practice — the handler already validated this value + // against the same schema — so a failure here is a bug (e.g. a + // nondeterministic schema) and belongs on the defect channel. + // oxlint-disable-next-line unthrown/no-throw -- defect-channel edge: a receive-side parse failure after a passing send-side validation is a bug, not a modeled error + throw new Error( + `runActivityHandler: activity "${activityName}" output failed the receive-side parse after passing send-side validation — the output schema appears nondeterministic.`, + ); + } + return Ok(outputResult.value as ClientInferOutput); + }); +} diff --git a/packages/testing/src/contract.ts b/packages/testing/src/contract.ts index dbd5b2f8..4b4b3f08 100644 --- a/packages/testing/src/contract.ts +++ b/packages/testing/src/contract.ts @@ -20,7 +20,8 @@ * * const activities = declareActivitiesHandler({ contract: orderContract, activities: { ... } }); * - * const it = createContractTest(orderContract, { + * const it = createContractTest({ + * contract: orderContract, * workflowsPath: workflowsPathFromURL(import.meta.url, "./order.workflows.js"), * activities, * }); @@ -31,7 +32,7 @@ * workflowId: `order-${Date.now()}`, * args: { orderId: "ORD-1" }, * }); - * expect(result.isOk()).toBe(true); + * await expect(result).toBeOk(); // with @unthrown/vitest matchers * }); * }); * ``` @@ -49,6 +50,8 @@ import { it as baseIt } from "./extension.js"; * Options for {@link createContractTest}. */ export type CreateContractTestOptions = { + /** The contract under test — its task queue names the worker's queue. */ + contract: TContract; /** * Path to the workflows file registered on the worker — typically built * with `workflowsPathFromURL(import.meta.url, "./x.workflows.js")` from @@ -103,9 +106,9 @@ export type ContractTestContext = { * fixtures from `./extension` remain available on the context. */ export function createContractTest( - contract: TContract, options: CreateContractTestOptions, ) { + const { contract } = options; return baseIt.extend>({ worker: [ async ({ workerConnection }, use) => { diff --git a/packages/testing/src/global-setup.ts b/packages/testing/src/global-setup.ts index 8bc8f16d..80147504 100644 --- a/packages/testing/src/global-setup.ts +++ b/packages/testing/src/global-setup.ts @@ -1,6 +1,27 @@ -import { GenericContainer, Wait, Network } from "testcontainers"; import type { TestProject } from "vitest/node"; +/** + * Load `testcontainers` lazily, with a descriptive error when it is not + * installed. It is an *optional* peer dependency: only this entry point (and + * therefore `createContractTest`, which requires the global setup to have + * run) needs it — the Docker-free entries (`/activity`, `/time-skipping`, + * `/extension`) must stay importable without it. + */ +async function loadTestcontainers(): Promise { + try { + return await import("testcontainers"); + } catch (cause) { + // oxlint-disable-next-line unthrown/no-throw -- declaration-time fail-fast config error: the global setup cannot run without its optional peer installed + throw new Error( + "@temporal-contract/testing/global-setup requires the optional peer dependency " + + '"testcontainers" (needed by createGlobalSetup / createContractTest). Install it as a ' + + "dev dependency — e.g. `pnpm add -D testcontainers`. The Docker-free entry points " + + "(/activity, /time-skipping, /extension) do not need it.", + { cause }, + ); + } +} + declare module "vitest" { // oxlint-disable-next-line typescript/consistent-type-definitions -- module augmentation requires interface export interface ProvidedContext { @@ -79,6 +100,8 @@ export function createGlobalSetup( }; return async function setup({ provide }: TestProject) { + const { GenericContainer, Wait, Network } = await loadTestcontainers(); + log("🐳 Starting Temporal test environment..."); // Create a network for containers to communicate diff --git a/packages/testing/src/vitest.setup.ts b/packages/testing/src/vitest.setup.ts new file mode 100644 index 00000000..0d6f5ac4 --- /dev/null +++ b/packages/testing/src/vitest.setup.ts @@ -0,0 +1,6 @@ +// Registers `@unthrown/vitest`'s custom matchers (`toBeOk`, `toBeOkWith`, `toBeErr`, +// `toBeErrTagged`, `toBeDefect`) on Vitest's `expect`, and brings their +// `declare module "vitest"` type augmentation into the compilation so the +// matchers type-check in the spec files. Referenced from `setupFiles` in +// `vitest.config.ts`; not part of the package's published surface. +import "@unthrown/vitest"; diff --git a/packages/testing/vitest.config.ts b/packages/testing/vitest.config.ts index 8bfb0888..9f5bb19d 100644 --- a/packages/testing/vitest.config.ts +++ b/packages/testing/vitest.config.ts @@ -44,6 +44,7 @@ export default defineConfig({ name: "unit", include: ["src/**/*.spec.ts"], exclude: ["src/**/__tests__/*.spec.ts"], + setupFiles: ["./src/vitest.setup.ts"], // The unit specs exercise `extension.ts` with mocked Temporal // connections, so the address normally provided by the // testcontainers global setup is stubbed statically here. @@ -59,6 +60,7 @@ export default defineConfig({ name: "integration", globalSetup: "./src/global-setup.ts", include: ["src/**/__tests__/*.spec.ts"], + setupFiles: ["./src/vitest.setup.ts"], testTimeout: 30_000, }, }, diff --git a/packages/worker/src/__tests__/rehydration.contract.ts b/packages/worker/src/__tests__/rehydration.contract.ts new file mode 100644 index 00000000..3126910b --- /dev/null +++ b/packages/worker/src/__tests__/rehydration.contract.ts @@ -0,0 +1,63 @@ +import { defineActivity, defineContract, defineWorkflow } from "@temporal-contract/contract"; +import { z } from "zod"; + +// Contract pair for the rehydration audit-gap specs +// (`rehydration.inprocess.spec.ts`). Two contracts share the task queue and +// workflow shape; only the *client-side* declaration of `QuoteExpired`'s +// data schema differs, modeling contract skew between a deployed worker and +// a consumer that shipped a stricter schema. + +/** + * `charge` declares a **data-less** error, the shape whose rehydration is + * gated on the wire marker: without `details[1] = { $tc: 1 }`, any unrelated + * `ApplicationFailure` reusing the name as its `type` must NOT surface as + * the typed error. + */ +const charge = defineActivity({ + input: z.object({ mode: z.string() }), + output: z.object({ ok: z.boolean() }), + errors: { + AlreadyCharged: { nonRetryable: true }, + }, + activityOptions: { startToCloseTimeout: "10 seconds" }, +}); + +const quote = defineWorkflow({ + input: z.object({ mode: z.string() }), + output: z.object({ classification: z.string() }), + errors: { + QuoteExpired: { + data: z.object({ quoteId: z.string() }), + nonRetryable: true, + }, + }, + activities: { charge }, +}); + +/** The contract the worker (and its workflow module) is deployed with. */ +export const rehydrationWorkerContract = defineContract({ + taskQueue: "rehydration-inprocess", + workflows: { quote }, +}); + +// Client-side skewed twin: identical except `QuoteExpired.data` is STRICTER +// than what the worker validates against — `quoteId` must start with "Q-". +// A worker emitting `{ quoteId: "legacy-1" }` is valid on its side but fails +// this schema on rehydration. +const quoteSkewed = defineWorkflow({ + input: z.object({ mode: z.string() }), + output: z.object({ classification: z.string() }), + errors: { + QuoteExpired: { + data: z.object({ quoteId: z.string().startsWith("Q-") }), + nonRetryable: true, + }, + }, + activities: { charge }, +}); + +/** The stricter contract a consumer binds on the client side. */ +export const rehydrationClientContract = defineContract({ + taskQueue: "rehydration-inprocess", + workflows: { quote: quoteSkewed }, +}); diff --git a/packages/worker/src/__tests__/rehydration.inprocess.spec.ts b/packages/worker/src/__tests__/rehydration.inprocess.spec.ts new file mode 100644 index 00000000..4776cd90 --- /dev/null +++ b/packages/worker/src/__tests__/rehydration.inprocess.spec.ts @@ -0,0 +1,145 @@ +import { extname } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { ContractError, TypedClient } from "@temporal-contract/client"; +import { onRehydrationMiss, type RehydrationMiss } from "@temporal-contract/contract/errors"; +import { it } from "@temporal-contract/testing/time-skipping"; +import { OkAsync, ErrAsync } from "unthrown"; +/** + * E2e coverage for the two rehydration audit gaps (in-process, no Docker): + * + * 1. **Contract skew** — a worker emits a declared error whose data is valid + * against the worker's schema but fails a STRICTER client-side schema: + * the client must degrade to the generic `WorkflowFailedError` (never a + * wrongly-typed `ContractError`) and the `onRehydrationMiss` diagnostic + * hook must fire with `reason: "data-validation-failed"`. + * 2. **Rehydration false-positive regression** — an `ApplicationFailure` + * carrying a declared *data-less* error's name as its `type` but WITHOUT + * the wire marker (e.g. built with plain `ApplicationFailure.create` in + * an activity) must NOT rehydrate as the typed error on the workflow + * side; only the marker-carrying failure produced by the typed + * constructors does. + */ +import { describe, expect } from "vitest"; + +import { ApplicationFailure, declareActivitiesHandler } from "../activity.js"; +import { TypedWorker } from "../worker.js"; +import { rehydrationClientContract, rehydrationWorkerContract } from "./rehydration.contract.js"; + +const activities = declareActivitiesHandler({ + contract: rehydrationWorkerContract, + activities: { + quote: { + charge: ({ mode }, { errors }) => { + if (mode === "fake-typed") { + // A plain ApplicationFailure that reuses the declared data-less + // error name as its `type` — no wire marker. `nonRetryable` so the + // failure surfaces immediately instead of exhausting retries. + return ErrAsync( + ApplicationFailure.create({ + type: "AlreadyCharged", + message: "raw failure impersonating a declared name", + nonRetryable: true, + }), + ); + } + if (mode === "typed") { + // The genuine typed constructor — converted at the boundary with + // the wire marker at details[1]. + return ErrAsync(errors.AlreadyCharged()); + } + return OkAsync({ ok: true }); + }, + }, + }, +}); + +function workflowPath(filename: string): string { + return fileURLToPath(new URL(`./${filename}${extname(import.meta.url)}`, import.meta.url)); +} + +describe("rehydration at the e2e boundary", () => { + it("does not rehydrate a marker-less ApplicationFailure as a data-less declared error", async ({ + testEnv, + }) => { + const worker = await TypedWorker.create({ + contract: rehydrationWorkerContract, + connection: testEnv.nativeConnection, + workflowsPath: workflowPath("rehydration.workflows"), + activities, + }).get(); + + const typedClient = await TypedClient.create({ client: testEnv.client }).get(); + const client = typedClient.for(rehydrationWorkerContract); + + await worker.raw.runUntil(async () => { + // Control: the typed constructor's failure carries the marker and DOES + // rehydrate into the typed ContractError on the workflow side. + const typed = await client.executeWorkflow("quote", { + workflowId: "rehydration-typed", + args: { mode: "typed" }, + }); + expect(typed).toBeOkWith({ classification: "contract:AlreadyCharged" }); + + // Regression: same `type` string, no marker — must degrade to the + // generic ActivityError, not the typed error. + const fake = await client.executeWorkflow("quote", { + workflowId: "rehydration-fake-typed", + args: { mode: "fake-typed" }, + }); + expect(fake).toBeOkWith({ classification: "generic:@temporal-contract/ActivityError" }); + }); + }); + + it("degrades to the generic failure and fires onRehydrationMiss when the client schema is stricter", async ({ + testEnv, + }) => { + const worker = await TypedWorker.create({ + contract: rehydrationWorkerContract, + connection: testEnv.nativeConnection, + workflowsPath: workflowPath("rehydration.workflows"), + activities, + }).get(); + + const typedClient = await TypedClient.create({ client: testEnv.client }).get(); + const workerSideClient = typedClient.for(rehydrationWorkerContract); + const skewedClient = typedClient.for(rehydrationClientContract); + + const misses: RehydrationMiss[] = []; + onRehydrationMiss((miss) => misses.push(miss)); + try { + await worker.raw.runUntil(async () => { + // Control: with matching schemas the failure rehydrates into the + // typed error, data parsed against the declared schema. + const matching = await workerSideClient.executeWorkflow("quote", { + workflowId: "rehydration-skew-control", + args: { mode: "expired" }, + }); + expect(matching).toBeErrTagged("@temporal-contract/ContractError"); + if (matching.isErr() && matching.error instanceof ContractError) { + expect(matching.error.errorName).toBe("QuoteExpired"); + expect(matching.error.data).toEqual({ quoteId: "legacy-1" }); + } + + // Skew: the stricter client-side schema rejects the (worker-valid) + // data — the result degrades to the generic WorkflowFailedError + // instead of surfacing a wrongly-typed ContractError. + const skewed = await skewedClient.executeWorkflow("quote", { + workflowId: "rehydration-skew", + args: { mode: "expired" }, + }); + expect(skewed).toBeErrTagged("@temporal-contract/WorkflowFailedError"); + }); + + // The degrade was observable: the diagnostic hook reported the miss. + expect(misses).toEqual([ + expect.objectContaining({ + errorName: "QuoteExpired", + reason: "data-validation-failed", + }), + ]); + } finally { + onRehydrationMiss(undefined); + } + }); +}); diff --git a/packages/worker/src/__tests__/rehydration.workflows.ts b/packages/worker/src/__tests__/rehydration.workflows.ts new file mode 100644 index 00000000..ad0cf600 --- /dev/null +++ b/packages/worker/src/__tests__/rehydration.workflows.ts @@ -0,0 +1,40 @@ +import { ContractError, declareWorkflow } from "../workflow.js"; +import { rehydrationWorkerContract } from "./rehydration.contract.js"; + +/** + * Workflow for the rehydration audit-gap specs. Mode-driven: + * + * - `"expired"` — throws the workflow-declared typed error with data that is + * valid on the worker contract but fails the client's stricter twin + * (contract-skew scenario); + * - anything else — calls the `charge` activity and reports how the + * workflow-side proxy classified its failure: `contract:` for a + * rehydrated typed error, `generic:<_tag>` for the untyped fallback. + */ +export const quote = declareWorkflow({ + workflowName: "quote", + contract: rehydrationWorkerContract, + // No `activityOptions`: the only activity carries contract-level options. + implementation: async (context, args) => { + if (args.mode === "expired") { + // Valid against the worker contract's schema; the client's stricter + // schema rejects it on rehydration. + throw context.errors.QuoteExpired({ quoteId: "legacy-1" }); + } + + const result = await context.activities.charge({ mode: args.mode }); + if (result.isDefect()) { + // An unmodeled bug — rethrow the original cause at the edge. + // oxlint-disable-next-line unthrown/no-throw -- defect-cause rethrow at the edge: an unmodeled failure must surface as a Workflow Task failure + throw result.cause; + } + if (result.isErr()) { + const classification = + result.error instanceof ContractError + ? `contract:${result.error.errorName}` + : `generic:${result.error._tag}`; + return { classification }; + } + return { classification: "ok" }; + }, +}); diff --git a/packages/worker/src/__tests__/replay.inprocess.spec.ts b/packages/worker/src/__tests__/replay.inprocess.spec.ts new file mode 100644 index 00000000..a96efd97 --- /dev/null +++ b/packages/worker/src/__tests__/replay.inprocess.spec.ts @@ -0,0 +1,101 @@ +import { extname } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { TypedClient } from "@temporal-contract/client"; +import { it } from "@temporal-contract/testing/time-skipping"; +import { Worker } from "@temporalio/worker"; +import { OkAsync, ErrAsync } from "unthrown"; +/** + * Replay-determinism coverage for the Result/AsyncResult machinery inside + * `declareWorkflow` (in-process, no Docker). + * + * `declareWorkflow` implementations run unthrown pipelines — `AsyncResult` + * activity calls, typed contract-error rehydration, and *async* Standard + * Schema validation at every payload boundary — inside Temporal's + * deterministic sandbox. Nothing else proves those constructs replay + * deterministically: a hidden source of nondeterminism (e.g. an unpatched + * microtask ordering dependency) would only surface on replay, as a + * `DeterminismViolationError`, long after the original run went green. + * + * This spec runs the full pipeline to completion — one happy path and one + * typed-activity-error path (ContractError → ApplicationFailure wire shape → + * rehydration inside the workflow) — fetches each execution's history, and + * replays it with `Worker.runReplayHistory`, which rejects on any + * determinism violation. + */ +import { describe, expect } from "vitest"; + +import { declareActivitiesHandler } from "../activity.js"; +import { TypedWorker } from "../worker.js"; +import { inprocessContract } from "./inprocess.contract.js"; + +const activities = declareActivitiesHandler({ + contract: inprocessContract, + activities: { + placeOrder: { + charge: ({ amount }, { errors }) => { + if (amount < 0) { + return ErrAsync(errors.PaymentDeclined({ reason: "negative-amount" })); + } + return OkAsync({ transactionId: `tx-${amount}` }); + }, + }, + }, +}); + +function workflowPath(filename: string): string { + return fileURLToPath(new URL(`./${filename}${extname(import.meta.url)}`, import.meta.url)); +} + +describe("declareWorkflow replay determinism", () => { + it("replays histories produced by Result-shaped workflows without determinism violations", async ({ + testEnv, + }) => { + const worker = await TypedWorker.create({ + contract: inprocessContract, + connection: testEnv.nativeConnection, + workflowsPath: workflowPath("inprocess.workflows"), + activities, + }).get(); + + const typedClient = await TypedClient.create({ client: testEnv.client }).get(); + const client = typedClient.for(inprocessContract); + + const happyId = "replay-happy"; + const declinedId = "replay-declined"; + + await worker.raw.runUntil(async () => { + // Happy path: async input/output schema validation + an AsyncResult + // activity call resolving Ok. + const charged = await client.executeWorkflow("placeOrder", { + workflowId: happyId, + args: { orderId: "ORD-1", amount: 5 }, + }); + expect(charged).toBeOkWith({ status: "charged:tx-5" }); + + // Typed error path: the activity's declared error crosses the wire as + // an ApplicationFailure and is rehydrated into a typed ContractError + // inside the workflow sandbox (async schema validation on the error + // data), which the implementation folds into a normal completion. + const declined = await client.executeWorkflow("placeOrder", { + workflowId: declinedId, + args: { orderId: "ORD-2", amount: -1 }, + }); + expect(declined).toBeOkWith({ status: "declined:negative-amount" }); + }); + + // Replay both histories against the same workflow code. + // `runReplayHistory` rejects with a DeterminismViolationError (or + // ReplayError) if the unthrown machinery diverges on replay. + for (const workflowId of [happyId, declinedId]) { + const history = await testEnv.client.workflow.getHandle(workflowId).fetchHistory(); + await expect( + Worker.runReplayHistory( + { workflowsPath: workflowPath("inprocess.workflows") }, + history, + workflowId, + ), + ).resolves.toBeUndefined(); + } + }); +}); diff --git a/packages/worker/src/__tests__/routing.contract.ts b/packages/worker/src/__tests__/routing.contract.ts new file mode 100644 index 00000000..249590e3 --- /dev/null +++ b/packages/worker/src/__tests__/routing.contract.ts @@ -0,0 +1,25 @@ +import { defineActivity, defineContract, defineWorkflow } from "@temporal-contract/contract"; +import { z } from "zod"; + +// Contract for the `activityOptionsByName` task-queue routing spec +// (`routing.spec.ts`). + +/** Task queue the dedicated activity worker polls (the routing target). */ +export const ROUTED_ACTIVITY_QUEUE = "routing-activity-q"; + +const reportQueue = defineActivity({ + input: z.object({}), + output: z.object({ handledBy: z.string() }), + activityOptions: { startToCloseTimeout: "10 seconds" }, +}); + +const routedFlow = defineWorkflow({ + input: z.object({}), + output: z.object({ handledBy: z.string() }), + activities: { reportQueue }, +}); + +export const routingContract = defineContract({ + taskQueue: "routing-workflow-q", + workflows: { routedFlow }, +}); diff --git a/packages/worker/src/__tests__/routing.spec.ts b/packages/worker/src/__tests__/routing.spec.ts new file mode 100644 index 00000000..dd01d168 --- /dev/null +++ b/packages/worker/src/__tests__/routing.spec.ts @@ -0,0 +1,107 @@ +import { extname } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { TypedClient, type ContractClient } from "@temporal-contract/client"; +import { it as baseIt } from "@temporal-contract/testing/extension"; +import { Client } from "@temporalio/client"; +import { Worker } from "@temporalio/worker"; +import { OkAsync } from "unthrown"; +/** + * E2e proof that an `activityOptionsByName` entry with a `taskQueue` + * override actually routes the activity to that queue (the documented + * split-worker-pool feature) — against the testcontainers Temporal server. + * + * Topology: + * - a **workflow-only** `TypedWorker` polls the contract's task queue + * (`routing-workflow-q`) and registers NO activities; + * - a raw activity worker polls the dedicated `routing-activity-q` and is + * the only place the `reportQueue` implementation exists. + * + * The workflow can therefore only complete if the activity task was + * dispatched to the dedicated queue: without the override the task would sit + * unpolled on the workflow queue and the execution would hang (test + * timeout). + */ +import { describe, expect, vi } from "vitest"; + +import { declareActivitiesHandler } from "../activity.js"; +import { TypedWorker } from "../worker.js"; +import { ROUTED_ACTIVITY_QUEUE, routingContract } from "./routing.contract.js"; + +const activities = declareActivitiesHandler({ + contract: routingContract, + activities: { + routedFlow: { + reportQueue: () => OkAsync({ handledBy: ROUTED_ACTIVITY_QUEUE }), + }, + }, +}); + +function workflowPath(filename: string): string { + return fileURLToPath(new URL(`./${filename}${extname(import.meta.url)}`, import.meta.url)); +} + +const it = baseIt.extend<{ + workflowWorker: TypedWorker; + activityWorker: Worker; + client: ContractClient; +}>({ + // Workflow-only worker on the contract's queue — deliberately registers no + // activities, so it cannot satisfy the activity task itself. + workflowWorker: [ + async ({ workerConnection }, use) => { + const worker = await TypedWorker.create({ + contract: routingContract, + connection: workerConnection, + namespace: "default", + workflowsPath: workflowPath("routing.workflows"), + }).get(); + + const running = worker.run(); + await vi.waitFor(() => worker.raw.getState() === "RUNNING", { interval: 100 }); + + await use(worker); + + worker.shutdown(); + await running.get(); + }, + { auto: true }, + ], + // Activity-only worker polling the routing target queue — the sole + // registrant of the `reportQueue` implementation. + activityWorker: [ + async ({ workerConnection }, use) => { + const worker = await Worker.create({ + connection: workerConnection, + namespace: "default", + taskQueue: ROUTED_ACTIVITY_QUEUE, + activities, + }); + + const running = worker.run(); + await vi.waitFor(() => worker.getState() === "RUNNING", { interval: 100 }); + + await use(worker); + + worker.shutdown(); + await running; + }, + { auto: true }, + ], + client: async ({ clientConnection }, use) => { + const rawClient = new Client({ connection: clientConnection, namespace: "default" }); + const root = await TypedClient.create({ client: rawClient }).get(); + await use(root.for(routingContract)); + }, +}); + +describe("activityOptionsByName taskQueue routing", () => { + it("routes the overridden activity to its dedicated queue end-to-end", async ({ client }) => { + const result = await client.executeWorkflow("routedFlow", { + workflowId: `routing-${Date.now()}`, + args: {}, + }); + + expect(result).toBeOkWith({ handledBy: ROUTED_ACTIVITY_QUEUE }); + }); +}); diff --git a/packages/worker/src/__tests__/routing.workflows.ts b/packages/worker/src/__tests__/routing.workflows.ts new file mode 100644 index 00000000..14b8256c --- /dev/null +++ b/packages/worker/src/__tests__/routing.workflows.ts @@ -0,0 +1,24 @@ +import { declareWorkflow } from "../workflow.js"; +import { ROUTED_ACTIVITY_QUEUE, routingContract } from "./routing.contract.js"; + +/** + * Workflow for the task-queue routing spec: `activityOptionsByName` routes + * `reportQueue` to a dedicated queue (Temporal's full `ActivityOptions` + * shape, so `taskQueue` works). The spec's activity worker is the ONLY + * worker registering the activity implementation, and it polls only that + * dedicated queue — so the workflow completing at all proves the override + * actually routed the activity task there. + */ +export const routedFlow = declareWorkflow({ + workflowName: "routedFlow", + contract: routingContract, + // No workflow-wide `activityOptions`: the activity carries contract-level + // options; the per-name override shallow-merges `taskQueue` on top. + activityOptionsByName: { + reportQueue: { taskQueue: ROUTED_ACTIVITY_QUEUE }, + }, + implementation: async (context) => { + const { handledBy } = await context.activities.reportQueue({}); + return { handledBy }; + }, +}); diff --git a/packages/worker/src/__tests__/time-skipping.inprocess.spec.ts b/packages/worker/src/__tests__/time-skipping.inprocess.spec.ts index f24751e7..0e3dbe6c 100644 --- a/packages/worker/src/__tests__/time-skipping.inprocess.spec.ts +++ b/packages/worker/src/__tests__/time-skipping.inprocess.spec.ts @@ -72,7 +72,7 @@ describe("time-skipping TestWorkflowEnvironment", () => { workflowsPath: workflowPath("inprocess.workflows"), activities, }); - expect(workerResult.isOk()).toBe(true); + expect(workerResult).toBeOk(); if (!workerResult.isOk()) return; const worker = workerResult.value; @@ -80,7 +80,7 @@ describe("time-skipping TestWorkflowEnvironment", () => { client: testEnv.client, interceptors: [recording], }); - expect(clientResult.isOk()).toBe(true); + expect(clientResult).toBeOk(); if (!clientResult.isOk()) return; const client = clientResult.value.for(inprocessContract); @@ -91,33 +91,24 @@ describe("time-skipping TestWorkflowEnvironment", () => { workflowId: "inprocess-ok", args: { orderId: "ORD-1", amount: 5 }, }); - expect(charged.isOk()).toBe(true); - if (charged.isOk()) { - expect(charged.value.status).toBe("charged:tx-5-trace-1"); - } + expect(charged).toBeOkWith({ status: "charged:tx-5-trace-1" }); // Activity-declared typed error, rehydrated inside the workflow. const declined = await client.executeWorkflow("placeOrder", { workflowId: "inprocess-declined", args: { orderId: "ORD-2", amount: -1 }, }); - expect(declined.isOk()).toBe(true); - if (declined.isOk()) { - expect(declined.value.status).toBe("declined:negative-amount"); - } + expect(declined).toBeOkWith({ status: "declined:negative-amount" }); // Workflow-declared typed error, rehydrated at the client. const empty = await client.executeWorkflow("placeOrder", { workflowId: "inprocess-empty", args: { orderId: "ORD-3", amount: 0 }, }); - expect(empty.isErr()).toBe(true); - if (empty.isErr()) { - expect(empty.error).toBeInstanceOf(ContractError); - if (empty.error instanceof ContractError) { - expect(empty.error.errorName).toBe("EmptyOrder"); - expect(empty.error.data).toEqual({ orderId: "ORD-3" }); - } + expect(empty).toBeErrTagged("@temporal-contract/ContractError"); + if (empty.isErr() && empty.error instanceof ContractError) { + expect(empty.error.errorName).toBe("EmptyOrder"); + expect(empty.error.data).toEqual({ orderId: "ORD-3" }); } }); @@ -144,12 +135,12 @@ describe("time-skipping TestWorkflowEnvironment", () => { workflowsPath: workflowPath("inprocess.workflows"), activities, }); - expect(workerResult.isOk()).toBe(true); + expect(workerResult).toBeOk(); if (!workerResult.isOk()) return; const worker = workerResult.value; const clientResult = await TypedClient.create({ client: testEnv.client }); - expect(clientResult.isOk()).toBe(true); + expect(clientResult).toBeOk(); if (!clientResult.isOk()) return; const client = clientResult.value.for(inprocessContract); @@ -159,11 +150,11 @@ describe("time-skipping TestWorkflowEnvironment", () => { workflowId, args: {}, }); - expect(started.isOk()).toBe(true); + expect(started).toBeOk(); if (!started.isOk()) return; const cancelResult = await started.value.cancel(); - expect(cancelResult.isOk()).toBe(true); + expect(cancelResult).toBeOk(); // Await completion via the raw handle (the result rejects with the // cancellation failure — that rejection is the point), then assert the @@ -182,7 +173,7 @@ describe("time-skipping TestWorkflowEnvironment", () => { workflowsPath: workflowPath("does-not-exist"), activities, }); - expect(workerResult.isDefect()).toBe(true); + expect(workerResult).toBeDefect(); if (workerResult.isDefect()) { const cause = workerResult.cause; expect(cause).toBeInstanceOf(TechnicalError); diff --git a/packages/worker/src/activities-proxy.spec.ts b/packages/worker/src/activities-proxy.spec.ts index 6f6d11bf..2889a7f4 100644 --- a/packages/worker/src/activities-proxy.spec.ts +++ b/packages/worker/src/activities-proxy.spec.ts @@ -13,7 +13,7 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; import { createValidatedActivities } from "./activities-proxy.js"; -import { ActivityCancelledError, ActivityError } from "./errors.js"; +import { ActivityCancelledError, type ActivityError } from "./errors.js"; /** * The error union the Result-shaped proxy actually produces for activities @@ -118,10 +118,7 @@ describe("createValidatedActivities — wire format (validate on send, parse on const result = await activities["transformer"]!({ text: "hi" }); expect(seen).toEqual([{ text: "hi" }]); - expect(result.isOk()).toBe(true); - if (result.isOk()) { - expect(result.value).toEqual({ n: 42 }); - } + expect(result).toBeOkWith({ n: 42 }); }); }); @@ -130,10 +127,7 @@ describe("createValidatedActivities — activities with declared errors", () => const activities = buildProxy(async () => ({ transactionId: "tx" })); const result = await activities["chargePayment"]!({ amount: 1 }); - expect(result.isOk()).toBe(true); - if (result.isOk()) { - expect(result.value).toEqual({ transactionId: "tx" }); - } + expect(result).toBeOkWith({ transactionId: "tx" }); }); it("rehydrates a declared ApplicationFailure into a typed ContractError", async () => { @@ -148,7 +142,7 @@ describe("createValidatedActivities — activities with declared errors", () => }); const result = await activities["chargePayment"]!({ amount: 1 }); - expect(result.isErr()).toBe(true); + expect(result).toBeErrTagged("@temporal-contract/ContractError"); if (result.isErr()) { expect(result.error).toBeInstanceOf(ContractError); const error = result.error as InstanceType; @@ -165,9 +159,8 @@ describe("createValidatedActivities — activities with declared errors", () => }); const result = await activities["chargePayment"]!({ amount: 1 }); - expect(result.isErr()).toBe(true); + expect(result).toBeErrTagged("@temporal-contract/ActivityError"); if (result.isErr()) { - expect(result.error).toBeInstanceOf(ActivityError); const error = result.error as InstanceType; expect(error.activityName).toBe("chargePayment"); expect(error.cause).toBe(failure); @@ -184,10 +177,7 @@ describe("createValidatedActivities — activities with declared errors", () => }); const result = await activities["chargePayment"]!({ amount: 1 }); - expect(result.isErr()).toBe(true); - if (result.isErr()) { - expect(result.error).toBeInstanceOf(ActivityError); - } + expect(result).toBeErrTagged("@temporal-contract/ActivityError"); }); it("discriminates cancellation as ActivityCancelledError", async () => { @@ -196,7 +186,7 @@ describe("createValidatedActivities — activities with declared errors", () => }); const result = await activities["chargePayment"]!({ amount: 1 }); - expect(result.isErr()).toBe(true); + expect(result).toBeErrTagged("@temporal-contract/ActivityCancelledError"); if (result.isErr()) { expect(result.error).toBeInstanceOf(ActivityCancelledError); } @@ -206,9 +196,8 @@ describe("createValidatedActivities — activities with declared errors", () => const activities = buildProxy(async () => ({ transactionId: "tx" })); const result = await activities["chargePayment"]!({ amount: "bad" }); - expect(result.isErr()).toBe(true); + expect(result).toBeErrTagged("@temporal-contract/ActivityError"); if (result.isErr()) { - expect(result.error).toBeInstanceOf(ActivityError); expect((result.error as InstanceType).message).toContain( "input validation failed", ); @@ -219,9 +208,8 @@ describe("createValidatedActivities — activities with declared errors", () => const activities = buildProxy(async () => ({ transactionId: 42 })); const result = await activities["chargePayment"]!({ amount: 1 }); - expect(result.isErr()).toBe(true); + expect(result).toBeErrTagged("@temporal-contract/ActivityError"); if (result.isErr()) { - expect(result.error).toBeInstanceOf(ActivityError); expect((result.error as InstanceType).message).toContain( "output validation failed", ); diff --git a/packages/worker/src/wire-format.spec.ts b/packages/worker/src/wire-format.spec.ts index d024ee3b..4fd296c9 100644 --- a/packages/worker/src/wire-format.spec.ts +++ b/packages/worker/src/wire-format.spec.ts @@ -141,10 +141,7 @@ describe("child workflows — wire format", () => { }, ]); // Parent is the receiving side of the result boundary — parses once. - expect(result.isOk()).toBe(true); - if (result.isOk()) { - expect(result.value).toEqual({ n: 42 }); - } + expect(result).toBeOkWith({ n: 42 }); }); it("startChildWorkflow sends the ORIGINAL args; handle.result() parses once", async () => { @@ -161,13 +158,10 @@ describe("child workflows — wire format", () => { options: expect.objectContaining({ args: [{ text: "hi" }] }), }, ]); - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); if (handleResult.isOk()) { const result = await handleResult.value.result(); - expect(result.isOk()).toBe(true); - if (result.isOk()) { - expect(result.value).toEqual({ n: 42 }); - } + expect(result).toBeOkWith({ n: 42 }); } }); }); @@ -179,7 +173,7 @@ describe("typed child workflow handle — signals and identifiers", () => { args: {}, }); - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); if (handleResult.isOk()) { expect(handleResult.value.workflowId).toBe("child-1"); expect(handleResult.value.firstExecutionRunId).toBe("run-1"); @@ -191,12 +185,12 @@ describe("typed child workflow handle — signals and identifiers", () => { workflowId: "child-1", args: {}, }); - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); if (!handleResult.isOk()) return; const sent = await handleResult.value.signals.note({ text: "hi" }); - expect(sent.isOk()).toBe(true); + expect(sent).toBeOk(); // Validated (transform would yield "hi!") but the ORIGINAL value crosses // the wire — the child's signal handler parses on receive. expect(childSignalCalls).toEqual([{ signalName: "note", args: [{ text: "hi" }] }]); @@ -207,12 +201,12 @@ describe("typed child workflow handle — signals and identifiers", () => { workflowId: "child-1", args: {}, }); - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); if (!handleResult.isOk()) return; const sent = await handleResult.value.signals.note({ text: 42 } as never); - expect(sent.isErr()).toBe(true); + expect(sent).toBeErr(); if (sent.isErr()) { expect(sent.error.message).toContain('signal "note" input validation failed'); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e46b17c0..4b31bb10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -453,10 +453,6 @@ importers: version: 4.4.3 packages/testing: - dependencies: - testcontainers: - specifier: 'catalog:' - version: 12.0.4 devDependencies: '@arethetypeswrong/cli': specifier: 'catalog:' @@ -482,12 +478,18 @@ importers: '@types/node': specifier: 'catalog:' version: 26.1.1 + '@unthrown/vitest': + specifier: 'catalog:' + version: 5.0.0(unthrown@5.0.0)(vitest@4.1.10) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.10(vitest@4.1.10) publint: specifier: 'catalog:' version: 0.3.21 + testcontainers: + specifier: 'catalog:' + version: 12.0.4 tsdown: specifier: 'catalog:' version: 0.22.12(@arethetypeswrong/core@0.18.5)(oxc-resolver@11.21.3)(publint@0.3.21)(tsx@4.23.1)(typescript@6.0.3) From 4bb514c9be31bdf7732cd363ddbd62bac8f390a9 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 31 Jul 2026 11:29:57 +0200 Subject: [PATCH 08/15] feat!: ship ESM-only packages, tighten Temporal peers, move internals off public subpaths - client/worker drop CJS output and legacy main/module/types fields; attw esm-only on all packages - @temporalio/* peer ranges tightened to ^1.16.0 (schedule API + search-attribute imports) - _internal_* symbols move to @temporal-contract/contract/internal; /result-async subpath removed - @standard-schema/spec regular-dep exception documented in dependencies rule --- .agents/rules/dependencies.md | 16 +- packages/client/package.json | 23 +- packages/client/src/internal.ts | 6 +- packages/contract/package.json | 10 +- packages/contract/src/errors-impl.ts | 331 +++++++++++++++++ packages/contract/src/errors.spec.ts | 3 +- packages/contract/src/errors.ts | 349 ++---------------- ...{result-async.spec.ts => internal.spec.ts} | 2 +- .../src/{result-async.ts => internal.ts} | 27 +- packages/testing/package.json | 6 +- packages/testing/src/activity.ts | 8 +- packages/testing/tsconfig.json | 2 +- packages/testing/vitest.config.ts | 4 +- packages/worker/package.json | 42 +-- packages/worker/src/activities-proxy.ts | 6 +- packages/worker/src/activity.ts | 2 +- packages/worker/src/internal.ts | 2 +- packages/worker/src/workflow.ts | 7 +- 18 files changed, 426 insertions(+), 420 deletions(-) create mode 100644 packages/contract/src/errors-impl.ts rename packages/contract/src/{result-async.spec.ts => internal.spec.ts} (98%) rename packages/contract/src/{result-async.ts => internal.ts} (75%) diff --git a/.agents/rules/dependencies.md b/.agents/rules/dependencies.md index 3b3235b9..035a62c1 100644 --- a/.agents/rules/dependencies.md +++ b/.agents/rules/dependencies.md @@ -38,12 +38,16 @@ All dependency versions are centralized in `pnpm-workspace.yaml` under the `cata Anything that appears in a published package's **public type signatures** must be a peer dep, not a regular dep — otherwise downstream consumers can end up with two disjoint nominal types in their typechecker (theirs and ours), even though the runtime classes are compatible. -| Package | Peer dependencies | -| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| client | `@temporalio/client ^1`, `@temporalio/common ^1`, `unthrown ^5` | -| worker | `@temporalio/common ^1`, `@temporalio/worker ^1`, `@temporalio/workflow ^1`, `unthrown ^5` | -| contract | `unthrown ^5` (optional — only needed when using `result-async`) | -| testing | `@temporal-contract/client`, `@temporal-contract/contract`, `@temporal-contract/worker` (concrete `^8.x` ranges — see below), `unthrown ^5` (the contract-aware fixtures expose `TypedClient`/`ContractClient`/`ActivitiesHandler`/`AsyncResult` in their public types), `vitest ^4` (the `globalSetup` hook integrates with vitest's test runner), `@temporalio/client ^1`, `@temporalio/testing ^1`, `@temporalio/worker ^1` (all exposed by the fixtures' public types — e.g. `TestWorkflowEnvironment` in `time-skipping`) | +| Package | Peer dependencies | +| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| client | `@temporalio/client ^1.16.0`, `@temporalio/common ^1.16.0`, `unthrown ^5` | +| worker | `@temporalio/common ^1.16.0`, `@temporalio/worker ^1.16.0`, `@temporalio/workflow ^1.16.0`, `unthrown ^5` | +| contract | `unthrown ^5` (optional — only needed when using the `/errors` or `/internal` entry points) | +| testing | `@temporal-contract/client`, `@temporal-contract/contract`, `@temporal-contract/worker` (concrete `^8.x` ranges — see below), `unthrown ^5` (the contract-aware fixtures expose `TypedClient`/`ContractClient`/`ActivitiesHandler`/`AsyncResult` in their public types), `vitest ^4` (the `globalSetup` hook integrates with vitest's test runner), `@temporalio/client ^1.16.0`, `@temporalio/testing ^1.16.0`, `@temporalio/worker ^1.16.0` (all exposed by the fixtures' public types — e.g. `TestWorkflowEnvironment` in `time-skipping`) | + +The `@temporalio/*` peer floor is `^1.16.0` across all packages: the client hard-requires the Schedule API on `Client` instances (runtime-checked at `TypedClient.create`) and imports `defineSearchAttributeKey`/`TypedSearchAttributes` top-level from `@temporalio/common`, so `^1` overstated compatibility. Keep the floor in sync across the four packages when raising it. + +**Deliberate exception — `@standard-schema/spec`:** it appears in public type signatures (e.g. `StandardSchemaV1.Issue` on validation errors) but stays a **regular dep**, not a peer. It is a types-only structural package — no runtime code and no nominal classes, so the "two disjoint nominal types" failure mode above cannot occur: any two copies of the spec are structurally identical to the typechecker. Making it a peer would only push install churn onto consumers for zero benefit. When you add a peer dep, also add it to `devDependencies` (with the same `"catalog:"` reference) so the local workspace build still resolves it. The workspace has `autoInstallPeers: false`, so peers must be present somewhere on the install side. diff --git a/packages/client/package.json b/packages/client/package.json index 8691be37..bbb9f138 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -26,27 +26,18 @@ ], "type": "module", "sideEffects": false, - "main": "./dist/index.cjs", - "module": "./dist/index.mjs", - "types": "./dist/index.d.mts", "exports": { ".": { - "import": { - "types": "./dist/index.d.mts", - "default": "./dist/index.mjs" - }, - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - } + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" }, "./package.json": "./package.json" }, "scripts": { - "build": "tsdown src/index.ts --format cjs,esm --dts --clean", + "build": "tsdown src/index.ts --format esm --dts --clean", "build:docs": "typedoc", - "check:package": "publint --strict && attw --pack .", - "dev": "tsdown src/index.ts --format cjs,esm --dts --watch", + "check:package": "publint --strict && attw --pack . --profile esm-only", + "dev": "tsdown src/index.ts --format esm --dts --watch", "test": "vitest run --project unit", "test:integration": "vitest run --project integration", "test:watch": "vitest --project unit", @@ -78,8 +69,8 @@ "zod": "catalog:" }, "peerDependencies": { - "@temporalio/client": "^1", - "@temporalio/common": "^1", + "@temporalio/client": "^1.16.0", + "@temporalio/common": "^1.16.0", "unthrown": "^5.0.0" }, "engines": { diff --git a/packages/client/src/internal.ts b/packages/client/src/internal.ts index 5dcfee97..29e5c518 100644 --- a/packages/client/src/internal.ts +++ b/packages/client/src/internal.ts @@ -11,11 +11,11 @@ import type { SearchAttributeDefinition, SearchAttributeKind, } from "@temporal-contract/contract"; +import { type AnyContractError } from "@temporal-contract/contract/errors"; import { + _internal_makeAsyncResult, _internal_rehydrateContractError, - type AnyContractError, -} from "@temporal-contract/contract/errors"; -import { _internal_makeAsyncResult } from "@temporal-contract/contract/result-async"; +} from "@temporal-contract/contract/internal"; import { WorkflowExecutionAlreadyStartedError } from "@temporalio/client"; import { QueryNotRegisteredError, diff --git a/packages/contract/package.json b/packages/contract/package.json index 582e93ad..a3647c8f 100644 --- a/packages/contract/package.json +++ b/packages/contract/package.json @@ -32,17 +32,17 @@ "types": "./dist/errors.d.mts", "import": "./dist/errors.mjs" }, - "./result-async": { - "types": "./dist/result-async.d.mts", - "import": "./dist/result-async.mjs" + "./internal": { + "types": "./dist/internal.d.mts", + "import": "./dist/internal.mjs" }, "./package.json": "./package.json" }, "scripts": { - "build": "tsdown src/index.ts src/errors.ts src/result-async.ts --format esm --dts --clean", + "build": "tsdown src/index.ts src/errors.ts src/internal.ts --format esm --dts --clean", "build:docs": "typedoc", "check:package": "publint --strict && attw --pack . --profile esm-only", - "dev": "tsdown src/index.ts src/errors.ts src/result-async.ts --format esm --dts --watch", + "dev": "tsdown src/index.ts src/errors.ts src/internal.ts --format esm --dts --watch", "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit" diff --git a/packages/contract/src/errors-impl.ts b/packages/contract/src/errors-impl.ts new file mode 100644 index 00000000..d2a6b04d --- /dev/null +++ b/packages/contract/src/errors-impl.ts @@ -0,0 +1,331 @@ +/** + * Runtime support for contract-declared typed domain errors. + * + * Implementation module behind two entry points: the public surface is + * re-exported from `./errors.ts` (`@temporal-contract/contract/errors`) and + * the `_internal_*` helpers from `./internal.ts` + * (`@temporal-contract/contract/internal`). It lives outside the package + * root because it imports `unthrown` at runtime — the root entry must stay + * importable without the optional `unthrown` peer installed (defining a + * contract needs no Result machinery). + * + * The worker and client packages both build on this module: + * - the worker hands implementations typed **constructors** for the errors + * declared on their activity/workflow, and converts a returned/thrown + * {@link ContractError} into a Temporal `ApplicationFailure` + * (`type` = error name, `details[0]` = validated data, `details[1]` = + * the {@link CONTRACT_ERROR_WIRE_MARKER} envelope marker, `nonRetryable` + * from the contract) at the boundary; + * - the workflow-side activities proxy and the client **rehydrate** a + * matching `ApplicationFailure` back into a {@link ContractError}, so + * consumers branch on a typed, schema-validated error union instead of + * string-matching failure types. + */ +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import { TaggedError } from "unthrown"; + +import { CONTRACT_ERROR_TAG, TECHNICAL_ERROR_TAG } from "./error-tags.js"; +import type { AnySchema, ErrorDefinition, InferErrorData, InferErrorDataInput } from "./types.js"; + +export { CONTRACT_ERROR_TAG, TECHNICAL_ERROR_TAG } from "./error-tags.js"; + +/** + * Error for technical/runtime failures that cannot be prevented by + * TypeScript — connection failures, missing runtime capabilities, worker + * bundling errors. These are *unmodeled* infrastructure faults, never + * anticipated domain failures, so they ride the `Defect` channel: the + * creation factories (`TypedClient.create`, `TypedWorker.create`) surface them as a + * `Defect` whose `cause` is a `TechnicalError` instance (inspect via `match`'s + * `defect` handler, `recoverDefect`, or `tapDefect`) — this class never + * appears in a `Result`'s modeled `E` channel. + * + * The class is retained (and still exported) so the descriptive message and + * `cause` survive for logging; it is only ever used as a defect's `cause`. + */ +export class TechnicalError extends TaggedError(TECHNICAL_ERROR_TAG, { + name: "TechnicalError", +})<{ + cause?: unknown; +}> { + constructor(message: string, cause?: unknown) { + super({ cause }); + this.message = message; + } +} + +/** + * A typed domain error declared on a contract's `errors` map. + * + * One class covers every declared error; the `errorName` field is the + * per-error discriminant (it equals the key in the contract's `errors` map + * and the `ApplicationFailure.type` on the wire). Narrow a union with it: + * + * ```ts + * if (result.isErr() && result.error instanceof ContractError) { + * switch (result.error.errorName) { + * case "PaymentDeclined": + * result.error.data; // { reason: string } + * } + * } + * ``` + * + * The unthrown `_tag` ("@temporal-contract/ContractError") discriminates a + * `ContractError` from the other tagged errors in a Result's error channel + * (e.g. via `result.match({ errCases: (m) => m.with(P.tag("@temporal-contract/ContractError"), …) })`); + * `errorName` then narrows to the concrete declared error. + */ +export class ContractError extends TaggedError( + CONTRACT_ERROR_TAG, + { name: "ContractError" }, +)<{ + /** Declared error name — the `ApplicationFailure.type` discriminator. */ + errorName: TName; + /** Structured payload validated against the declared `data` schema. */ + data: TData; + cause?: unknown; +}> { + // unthrown 5 reserves `name`, `message`, and `stack` in the TaggedError + // payload; accept `message` as a constructor argument and assign it + // post-`super` instead. + constructor(args: { errorName: TName; data: TData; message: string; cause?: unknown }) { + const { message, ...payload } = args; + super(payload); + this.message = message; + } +} + +/** + * Widest `ContractError` instantiation — useful as a constraint or for + * `instanceof`-style narrowing before discriminating on `errorName`. + */ +export type AnyContractError = ContractError; + +/** + * Per-instance options accepted by a typed error constructor. The + * `nonRetryable` flag is deliberately absent: retry semantics live on the + * contract's {@link ErrorDefinition}, not the call site. + */ +export type ContractErrorOptions = { + readonly message?: string; + readonly cause?: unknown; +}; + +/** + * Consumer-side union of {@link ContractError} instances for a declared + * `errors` map — `data` is typed with each schema's *output* (post-transform) + * shape. This is the union surfaced on the error channel of workflow-side + * activity calls and client-side workflow results. + */ +export type ContractErrorUnion> = { + [K in keyof TErrors & string]: ContractError>; +}[keyof TErrors & string]; + +/** + * Producer-side union of {@link ContractError} instances for a declared + * `errors` map — `data` is typed with each schema's *input* (pre-transform) + * shape, matching what the typed constructors build. + */ +export type ContractErrorInputUnion> = { + [K in keyof TErrors & string]: ContractError>; +}[keyof TErrors & string]; + +/** + * Map of typed error constructors for a declared `errors` map, handed to + * implementations (activity helpers / workflow context). Errors with a + * `data` schema take the payload first; data-less errors take only options. + */ +export type ContractErrorConstructors> = { + [K in keyof TErrors & string]: TErrors[K] extends { data: AnySchema } + ? ( + data: InferErrorDataInput, + options?: ContractErrorOptions, + ) => ContractError> + : (options?: ContractErrorOptions) => ContractError; +}; + +/** + * Build the runtime constructor map for a declared `errors` record. Each + * constructor is a thin factory — data validation happens later, at the + * Temporal boundary, where async Standard Schema validation is possible. + * + * @internal — exported on the `./internal` subpath for the sibling worker + * package. Not part of the public API; no semver guarantee. + */ +export function _internal_buildErrorConstructors( + declaredErrors: Record | undefined, +): Record AnyContractError> { + const constructors: Record AnyContractError> = {}; + if (!declaredErrors) return constructors; + + for (const [errorName, definition] of Object.entries(declaredErrors)) { + // The declared shape decides the runtime signature: with a `data` + // schema the first argument is the payload, otherwise it's the options + // bag. This mirrors the compile-time `ContractErrorConstructors` split. + constructors[errorName] = definition.data + ? (data?: unknown, options?: unknown) => { + const opts = (options ?? {}) as ContractErrorOptions; + return new ContractError({ + errorName, + data, + message: opts.message ?? definition.message ?? `Contract error "${errorName}"`, + ...(opts.cause !== undefined ? { cause: opts.cause } : {}), + }); + } + : (options?: unknown) => { + const opts = (options ?? {}) as ContractErrorOptions; + return new ContractError({ + errorName, + data: undefined, + message: opts.message ?? definition.message ?? `Contract error "${errorName}"`, + ...(opts.cause !== undefined ? { cause: opts.cause } : {}), + }); + }; + } + + return Object.freeze(constructors); +} + +/** + * Structural view of a Temporal `ApplicationFailure` — the fields the + * rehydrator reads. Kept structural so this package doesn't depend on + * `@temporalio/common`; callers perform the `instanceof ApplicationFailure` + * check on their side and pass the instance in. + */ +export type ApplicationFailureLike = { + readonly type?: string | undefined | null; + readonly message?: string | undefined; + readonly details?: readonly unknown[] | null | undefined; +}; + +/** + * Wire-envelope marker carried at `details[1]` of every `ApplicationFailure` + * produced from a {@link ContractError} (`details[0]` stays the data + * payload). It marks the failure as temporal-contract provenance, versioned + * for future envelope evolution, so the rehydrator can tell a genuine + * contract error from an unrelated `ApplicationFailure` that merely reuses a + * declared error name as its `type` string. + */ +export const CONTRACT_ERROR_WIRE_MARKER = { $tc: 1 } as const; + +/** Does the failure's `details` carry the {@link CONTRACT_ERROR_WIRE_MARKER}? */ +function hasWireMarker(details: readonly unknown[] | null | undefined): boolean { + const candidate = details?.[1]; + return ( + typeof candidate === "object" && + candidate !== null && + (candidate as Record)["$tc"] === CONTRACT_ERROR_WIRE_MARKER.$tc + ); +} + +/** + * Diagnostic payload describing a rehydration miss: a failure whose `type` + * matched a declared error name but that could not be rehydrated as the + * typed {@link ContractError} and degraded to the caller's generic failure + * classification. + */ +export type RehydrationMiss = { + /** The declared error name that `failure.type` matched. */ + readonly errorName: string; + /** + * Why rehydration degraded: + * - `"data-validation-failed"` — the declared `data` schema rejected + * `details[0]` (schema drift or a foreign failure with a payload); + * - `"missing-wire-marker"` — a data-less declared error without the + * {@link CONTRACT_ERROR_WIRE_MARKER}, i.e. most likely an unrelated + * `ApplicationFailure` reusing the declared name as its `type`. + */ + readonly reason: "data-validation-failed" | "missing-wire-marker"; + /** Validation issues when `reason` is `"data-validation-failed"`. */ + readonly issues?: ReadonlyArray; + /** The failure that was being rehydrated. */ + readonly failure: ApplicationFailureLike; +}; + +let rehydrationMissHandler: ((miss: RehydrationMiss) => void) | undefined; + +/** + * Register a module-level diagnostic hook invoked whenever a failure whose + * `type` matches a declared error name fails to rehydrate as a typed + * {@link ContractError} (see {@link RehydrationMiss}). The degrade-to-generic + * behavior is unchanged — this only makes it observable. The worker and + * client packages wire this into their loggers; pass `undefined` to + * unregister. A throwing handler is swallowed: diagnostics must never break + * error classification. + */ +export function onRehydrationMiss(handler: ((miss: RehydrationMiss) => void) | undefined): void { + rehydrationMissHandler = handler; +} + +function reportRehydrationMiss(miss: RehydrationMiss): void { + if (!rehydrationMissHandler) return; + try { + rehydrationMissHandler(miss); + } catch { + // Deliberately swallowed — a throwing diagnostic hook must not turn a + // degrade-to-generic path into a hard failure. + } +} + +/** + * Attempt to rehydrate an `ApplicationFailure` back into a typed + * {@link ContractError}, by matching `failure.type` against the declared + * error names and validating `failure.details[0]` against the declared + * `data` schema. + * + * Provenance rules: + * - errors **with** a `data` schema: schema validation is the gate — the + * {@link CONTRACT_ERROR_WIRE_MARKER} at `details[1]` is preferred but not + * required (a validating payload is strong-enough evidence); + * - errors **without** a `data` schema: the marker is **required** — + * otherwise any unrelated `ApplicationFailure` whose `type` happens to + * equal a declared data-less error name would be surfaced as the typed + * domain error. + * + * Returns `undefined` when the failure doesn't correspond to a declared + * error (unknown `type`, payload that no longer validates, or a data-less + * name without the marker) — callers fall through to their generic failure + * classification, so a mismatch degrades to today's untyped behavior instead + * of producing a wrong typed error. Degrades are reported through the + * {@link onRehydrationMiss} hook so they are observable. + * + * @internal — exported on the `./internal` subpath for the sibling worker + * and client packages. Not part of the public API; no semver guarantee. + */ +export async function _internal_rehydrateContractError( + declaredErrors: Record | undefined, + failure: ApplicationFailureLike, +): Promise { + if (!declaredErrors || !failure.type) return undefined; + + const definition = declaredErrors[failure.type]; + if (!definition) return undefined; + + let data: unknown = undefined; + if (definition.data) { + const validated = await definition.data["~standard"].validate(failure.details?.[0]); + if (validated.issues) { + reportRehydrationMiss({ + errorName: failure.type, + reason: "data-validation-failed", + issues: validated.issues, + failure, + }); + return undefined; + } + data = validated.value; + } else if (!hasWireMarker(failure.details)) { + reportRehydrationMiss({ + errorName: failure.type, + reason: "missing-wire-marker", + failure, + }); + return undefined; + } + + return new ContractError({ + errorName: failure.type, + data, + message: failure.message ?? definition.message ?? `Contract error "${failure.type}"`, + cause: failure, + }); +} diff --git a/packages/contract/src/errors.spec.ts b/packages/contract/src/errors.spec.ts index 33575a07..7ab9efaa 100644 --- a/packages/contract/src/errors.spec.ts +++ b/packages/contract/src/errors.spec.ts @@ -2,8 +2,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; import { - _internal_buildErrorConstructors, - _internal_rehydrateContractError, CONTRACT_ERROR_TAG, CONTRACT_ERROR_WIRE_MARKER, ContractError, @@ -11,6 +9,7 @@ import { TECHNICAL_ERROR_TAG, TechnicalError, } from "./errors.js"; +import { _internal_buildErrorConstructors, _internal_rehydrateContractError } from "./internal.js"; describe("_internal_buildErrorConstructors", () => { const declaredErrors = { diff --git a/packages/contract/src/errors.ts b/packages/contract/src/errors.ts index a9bbf10e..e3307b4c 100644 --- a/packages/contract/src/errors.ts +++ b/packages/contract/src/errors.ts @@ -1,330 +1,29 @@ /** - * Runtime support for contract-declared typed domain errors. + * Public entry point `@temporal-contract/contract/errors` — the typed + * domain-error surface (classes, types, and the rehydration-miss diagnostic + * hook). * - * Lives in its own entry point (`@temporal-contract/contract/errors`) rather - * than the package root because it imports `unthrown` at runtime — the root - * entry must stay importable without the optional `unthrown` peer installed - * (defining a contract needs no Result machinery). Same pattern as - * `./result-async`. + * Lives in its own entry point rather than the package root because the + * implementation imports `unthrown` at runtime — the root entry must stay + * importable without the optional `unthrown` peer installed (defining a + * contract needs no Result machinery). * - * The worker and client packages both build on this module: - * - the worker hands implementations typed **constructors** for the errors - * declared on their activity/workflow, and converts a returned/thrown - * {@link ContractError} into a Temporal `ApplicationFailure` - * (`type` = error name, `details[0]` = validated data, `details[1]` = - * the {@link CONTRACT_ERROR_WIRE_MARKER} envelope marker, `nonRetryable` - * from the contract) at the boundary; - * - the workflow-side activities proxy and the client **rehydrate** a - * matching `ApplicationFailure` back into a {@link ContractError}, so - * consumers branch on a typed, schema-validated error union instead of - * string-matching failure types. + * The `_internal_*` helpers backing the sibling worker/client/testing + * packages are deliberately NOT re-exported here — they live on the + * `@temporal-contract/contract/internal` entry point, which carries no + * semver guarantee. */ -import type { StandardSchemaV1 } from "@standard-schema/spec"; -import { TaggedError } from "unthrown"; - -import { CONTRACT_ERROR_TAG, TECHNICAL_ERROR_TAG } from "./error-tags.js"; -import type { AnySchema, ErrorDefinition, InferErrorData, InferErrorDataInput } from "./types.js"; - export { CONTRACT_ERROR_TAG, TECHNICAL_ERROR_TAG } from "./error-tags.js"; - -/** - * Error for technical/runtime failures that cannot be prevented by - * TypeScript — connection failures, missing runtime capabilities, worker - * bundling errors. These are *unmodeled* infrastructure faults, never - * anticipated domain failures, so they ride the `Defect` channel: the - * creation factories (`TypedClient.create`, `TypedWorker.create`) surface them as a - * `Defect` whose `cause` is a `TechnicalError` instance (inspect via `match`'s - * `defect` handler, `recoverDefect`, or `tapDefect`) — this class never - * appears in a `Result`'s modeled `E` channel. - * - * The class is retained (and still exported) so the descriptive message and - * `cause` survive for logging; it is only ever used as a defect's `cause`. - */ -export class TechnicalError extends TaggedError(TECHNICAL_ERROR_TAG, { - name: "TechnicalError", -})<{ - cause?: unknown; -}> { - constructor(message: string, cause?: unknown) { - super({ cause }); - this.message = message; - } -} - -/** - * A typed domain error declared on a contract's `errors` map. - * - * One class covers every declared error; the `errorName` field is the - * per-error discriminant (it equals the key in the contract's `errors` map - * and the `ApplicationFailure.type` on the wire). Narrow a union with it: - * - * ```ts - * if (result.isErr() && result.error instanceof ContractError) { - * switch (result.error.errorName) { - * case "PaymentDeclined": - * result.error.data; // { reason: string } - * } - * } - * ``` - * - * The unthrown `_tag` ("@temporal-contract/ContractError") discriminates a - * `ContractError` from the other tagged errors in a Result's error channel - * (e.g. via `result.match({ errCases: (m) => m.with(P.tag("@temporal-contract/ContractError"), …) })`); - * `errorName` then narrows to the concrete declared error. - */ -export class ContractError extends TaggedError( - CONTRACT_ERROR_TAG, - { name: "ContractError" }, -)<{ - /** Declared error name — the `ApplicationFailure.type` discriminator. */ - errorName: TName; - /** Structured payload validated against the declared `data` schema. */ - data: TData; - cause?: unknown; -}> { - // unthrown 5 reserves `name`, `message`, and `stack` in the TaggedError - // payload; accept `message` as a constructor argument and assign it - // post-`super` instead. - constructor(args: { errorName: TName; data: TData; message: string; cause?: unknown }) { - const { message, ...payload } = args; - super(payload); - this.message = message; - } -} - -/** - * Widest `ContractError` instantiation — useful as a constraint or for - * `instanceof`-style narrowing before discriminating on `errorName`. - */ -export type AnyContractError = ContractError; - -/** - * Per-instance options accepted by a typed error constructor. The - * `nonRetryable` flag is deliberately absent: retry semantics live on the - * contract's {@link ErrorDefinition}, not the call site. - */ -export type ContractErrorOptions = { - readonly message?: string; - readonly cause?: unknown; -}; - -/** - * Consumer-side union of {@link ContractError} instances for a declared - * `errors` map — `data` is typed with each schema's *output* (post-transform) - * shape. This is the union surfaced on the error channel of workflow-side - * activity calls and client-side workflow results. - */ -export type ContractErrorUnion> = { - [K in keyof TErrors & string]: ContractError>; -}[keyof TErrors & string]; - -/** - * Producer-side union of {@link ContractError} instances for a declared - * `errors` map — `data` is typed with each schema's *input* (pre-transform) - * shape, matching what the typed constructors build. - */ -export type ContractErrorInputUnion> = { - [K in keyof TErrors & string]: ContractError>; -}[keyof TErrors & string]; - -/** - * Map of typed error constructors for a declared `errors` map, handed to - * implementations (activity helpers / workflow context). Errors with a - * `data` schema take the payload first; data-less errors take only options. - */ -export type ContractErrorConstructors> = { - [K in keyof TErrors & string]: TErrors[K] extends { data: AnySchema } - ? ( - data: InferErrorDataInput, - options?: ContractErrorOptions, - ) => ContractError> - : (options?: ContractErrorOptions) => ContractError; -}; - -/** - * Build the runtime constructor map for a declared `errors` record. Each - * constructor is a thin factory — data validation happens later, at the - * Temporal boundary, where async Standard Schema validation is possible. - * - * @internal — exported under a deliberately-internal-looking name for the - * sibling worker package. Not part of the public API; no semver guarantee. - */ -export function _internal_buildErrorConstructors( - declaredErrors: Record | undefined, -): Record AnyContractError> { - const constructors: Record AnyContractError> = {}; - if (!declaredErrors) return constructors; - - for (const [errorName, definition] of Object.entries(declaredErrors)) { - // The declared shape decides the runtime signature: with a `data` - // schema the first argument is the payload, otherwise it's the options - // bag. This mirrors the compile-time `ContractErrorConstructors` split. - constructors[errorName] = definition.data - ? (data?: unknown, options?: unknown) => { - const opts = (options ?? {}) as ContractErrorOptions; - return new ContractError({ - errorName, - data, - message: opts.message ?? definition.message ?? `Contract error "${errorName}"`, - ...(opts.cause !== undefined ? { cause: opts.cause } : {}), - }); - } - : (options?: unknown) => { - const opts = (options ?? {}) as ContractErrorOptions; - return new ContractError({ - errorName, - data: undefined, - message: opts.message ?? definition.message ?? `Contract error "${errorName}"`, - ...(opts.cause !== undefined ? { cause: opts.cause } : {}), - }); - }; - } - - return Object.freeze(constructors); -} - -/** - * Structural view of a Temporal `ApplicationFailure` — the fields the - * rehydrator reads. Kept structural so this package doesn't depend on - * `@temporalio/common`; callers perform the `instanceof ApplicationFailure` - * check on their side and pass the instance in. - */ -export type ApplicationFailureLike = { - readonly type?: string | undefined | null; - readonly message?: string | undefined; - readonly details?: readonly unknown[] | null | undefined; -}; - -/** - * Wire-envelope marker carried at `details[1]` of every `ApplicationFailure` - * produced from a {@link ContractError} (`details[0]` stays the data - * payload). It marks the failure as temporal-contract provenance, versioned - * for future envelope evolution, so the rehydrator can tell a genuine - * contract error from an unrelated `ApplicationFailure` that merely reuses a - * declared error name as its `type` string. - */ -export const CONTRACT_ERROR_WIRE_MARKER = { $tc: 1 } as const; - -/** Does the failure's `details` carry the {@link CONTRACT_ERROR_WIRE_MARKER}? */ -function hasWireMarker(details: readonly unknown[] | null | undefined): boolean { - const candidate = details?.[1]; - return ( - typeof candidate === "object" && - candidate !== null && - (candidate as Record)["$tc"] === CONTRACT_ERROR_WIRE_MARKER.$tc - ); -} - -/** - * Diagnostic payload describing a rehydration miss: a failure whose `type` - * matched a declared error name but that could not be rehydrated as the - * typed {@link ContractError} and degraded to the caller's generic failure - * classification. - */ -export type RehydrationMiss = { - /** The declared error name that `failure.type` matched. */ - readonly errorName: string; - /** - * Why rehydration degraded: - * - `"data-validation-failed"` — the declared `data` schema rejected - * `details[0]` (schema drift or a foreign failure with a payload); - * - `"missing-wire-marker"` — a data-less declared error without the - * {@link CONTRACT_ERROR_WIRE_MARKER}, i.e. most likely an unrelated - * `ApplicationFailure` reusing the declared name as its `type`. - */ - readonly reason: "data-validation-failed" | "missing-wire-marker"; - /** Validation issues when `reason` is `"data-validation-failed"`. */ - readonly issues?: ReadonlyArray; - /** The failure that was being rehydrated. */ - readonly failure: ApplicationFailureLike; -}; - -let rehydrationMissHandler: ((miss: RehydrationMiss) => void) | undefined; - -/** - * Register a module-level diagnostic hook invoked whenever a failure whose - * `type` matches a declared error name fails to rehydrate as a typed - * {@link ContractError} (see {@link RehydrationMiss}). The degrade-to-generic - * behavior is unchanged — this only makes it observable. The worker and - * client packages wire this into their loggers; pass `undefined` to - * unregister. A throwing handler is swallowed: diagnostics must never break - * error classification. - */ -export function onRehydrationMiss(handler: ((miss: RehydrationMiss) => void) | undefined): void { - rehydrationMissHandler = handler; -} - -function reportRehydrationMiss(miss: RehydrationMiss): void { - if (!rehydrationMissHandler) return; - try { - rehydrationMissHandler(miss); - } catch { - // Deliberately swallowed — a throwing diagnostic hook must not turn a - // degrade-to-generic path into a hard failure. - } -} - -/** - * Attempt to rehydrate an `ApplicationFailure` back into a typed - * {@link ContractError}, by matching `failure.type` against the declared - * error names and validating `failure.details[0]` against the declared - * `data` schema. - * - * Provenance rules: - * - errors **with** a `data` schema: schema validation is the gate — the - * {@link CONTRACT_ERROR_WIRE_MARKER} at `details[1]` is preferred but not - * required (a validating payload is strong-enough evidence); - * - errors **without** a `data` schema: the marker is **required** — - * otherwise any unrelated `ApplicationFailure` whose `type` happens to - * equal a declared data-less error name would be surfaced as the typed - * domain error. - * - * Returns `undefined` when the failure doesn't correspond to a declared - * error (unknown `type`, payload that no longer validates, or a data-less - * name without the marker) — callers fall through to their generic failure - * classification, so a mismatch degrades to today's untyped behavior instead - * of producing a wrong typed error. Degrades are reported through the - * {@link onRehydrationMiss} hook so they are observable. - * - * @internal — exported under a deliberately-internal-looking name for the - * sibling worker and client packages. Not part of the public API; no semver - * guarantee. - */ -export async function _internal_rehydrateContractError( - declaredErrors: Record | undefined, - failure: ApplicationFailureLike, -): Promise { - if (!declaredErrors || !failure.type) return undefined; - - const definition = declaredErrors[failure.type]; - if (!definition) return undefined; - - let data: unknown = undefined; - if (definition.data) { - const validated = await definition.data["~standard"].validate(failure.details?.[0]); - if (validated.issues) { - reportRehydrationMiss({ - errorName: failure.type, - reason: "data-validation-failed", - issues: validated.issues, - failure, - }); - return undefined; - } - data = validated.value; - } else if (!hasWireMarker(failure.details)) { - reportRehydrationMiss({ - errorName: failure.type, - reason: "missing-wire-marker", - failure, - }); - return undefined; - } - - return new ContractError({ - errorName: failure.type, - data, - message: failure.message ?? definition.message ?? `Contract error "${failure.type}"`, - cause: failure, - }); -} +export { + type AnyContractError, + type ApplicationFailureLike, + CONTRACT_ERROR_WIRE_MARKER, + ContractError, + type ContractErrorConstructors, + type ContractErrorInputUnion, + type ContractErrorOptions, + type ContractErrorUnion, + onRehydrationMiss, + type RehydrationMiss, + TechnicalError, +} from "./errors-impl.js"; diff --git a/packages/contract/src/result-async.spec.ts b/packages/contract/src/internal.spec.ts similarity index 98% rename from packages/contract/src/result-async.spec.ts rename to packages/contract/src/internal.spec.ts index e8b8db37..47d605ac 100644 --- a/packages/contract/src/result-async.spec.ts +++ b/packages/contract/src/internal.spec.ts @@ -12,7 +12,7 @@ import { Ok, Err } from "unthrown"; */ import { describe, expect, it } from "vitest"; -import { _internal_makeAsyncResult } from "./result-async.js"; +import { _internal_makeAsyncResult } from "./internal.js"; class TestError extends Error { constructor(public readonly tag: string) { diff --git a/packages/contract/src/result-async.ts b/packages/contract/src/internal.ts similarity index 75% rename from packages/contract/src/result-async.ts rename to packages/contract/src/internal.ts index 8487623c..f474fbd4 100644 --- a/packages/contract/src/result-async.ts +++ b/packages/contract/src/internal.ts @@ -1,14 +1,12 @@ /** - * Internal helper shared across `@temporal-contract/client` and - * `@temporal-contract/worker` for wrapping a result-producing async function - * in an `AsyncResult`, routing any unanticipated rejection through unthrown's - * `defect` channel. + * Internal entry point `@temporal-contract/contract/internal` — helpers + * shared across the sibling `@temporal-contract/client`, + * `@temporal-contract/worker`, and `@temporal-contract/testing` packages. * - * Lives in `@temporal-contract/contract` so the two consuming packages don't - * each carry their own copy. Exported from the package's public surface - * under a deliberately-internal-looking name (`_internal_makeAsyncResult`) - * so users don't import it by accident — there is no semver guarantee on - * this entry point. + * They live in `@temporal-contract/contract` so the consuming packages don't + * each carry their own copy. **Not part of the public API** — the dedicated + * `./internal` subpath and the `_internal_` name prefixes both signal that + * there is no semver guarantee on anything exported here. */ import { fromSafePromise, @@ -18,6 +16,11 @@ import { type Result, } from "unthrown"; +export { + _internal_buildErrorConstructors, + _internal_rehydrateContractError, +} from "./errors-impl.js"; + /** * Wrap an async function returning `Promise>` in an * `AsyncResult`, catching synchronous throws and rejected promises and @@ -33,8 +36,8 @@ import { * unmodeled. The `.flatMap((inner) => inner)` flattens the nested * `Result` the thunk resolves with, surfacing its modeled error channel. * - * @internal — exported under `_internal_makeAsyncResult` for use by the - * sibling client and worker packages. Not part of the public API. + * @internal — exported on the `./internal` subpath for use by the sibling + * client and worker packages. Not part of the public API. */ export function _internal_makeAsyncResult( // oxlint-disable-next-line unthrown/prefer-async-result -- this IS the Promise→AsyncResult conversion seam: the work thunk's throw/rejection is what becomes the defect, and an async implementer cannot be annotated AsyncResult @@ -55,7 +58,7 @@ export function _internal_makeAsyncResult( * narrows the result to `Ok | Err` for the caller, which can then branch on * `isErr` / `isOk` and reach `.value` / `.error` cleanly. * - * @internal — exported under `_internal_assertNoDefect` for the sibling client + * @internal — exported on the `./internal` subpath for the sibling client * and worker packages. Not part of the public API. */ export function _internal_assertNoDefect( diff --git a/packages/testing/package.json b/packages/testing/package.json index 1e6c435d..abffc353 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -83,9 +83,9 @@ "@temporal-contract/client": "^8.0.0-beta.4", "@temporal-contract/contract": "^8.0.0-beta.4", "@temporal-contract/worker": "^8.0.0-beta.4", - "@temporalio/client": "^1", - "@temporalio/testing": "^1", - "@temporalio/worker": "^1", + "@temporalio/client": "^1.16.0", + "@temporalio/testing": "^1.16.0", + "@temporalio/worker": "^1.16.0", "testcontainers": "^12", "unthrown": "^5.0.0", "vitest": "^4" diff --git a/packages/testing/src/activity.ts b/packages/testing/src/activity.ts index fd032a84..3bbafc2a 100644 --- a/packages/testing/src/activity.ts +++ b/packages/testing/src/activity.ts @@ -31,12 +31,14 @@ import type { WorkerInferInput, } from "@temporal-contract/contract"; import { - _internal_buildErrorConstructors, - _internal_rehydrateContractError, type ContractErrorConstructors, type ContractErrorUnion, } from "@temporal-contract/contract/errors"; -import { _internal_makeAsyncResult } from "@temporal-contract/contract/result-async"; +import { + _internal_buildErrorConstructors, + _internal_makeAsyncResult, + _internal_rehydrateContractError, +} from "@temporal-contract/contract/internal"; import { declareActivitiesHandler, ApplicationFailure } from "@temporal-contract/worker/activity"; import { MockActivityEnvironment } from "@temporalio/testing"; import { Err, Ok, type AsyncResult } from "unthrown"; diff --git a/packages/testing/tsconfig.json b/packages/testing/tsconfig.json index fff9349a..3272c621 100644 --- a/packages/testing/tsconfig.json +++ b/packages/testing/tsconfig.json @@ -18,7 +18,7 @@ "@temporal-contract/client": ["../client/src/index.ts"], "@temporal-contract/contract": ["../contract/src/index.ts"], "@temporal-contract/contract/errors": ["../contract/src/errors.ts"], - "@temporal-contract/contract/result-async": ["../contract/src/result-async.ts"], + "@temporal-contract/contract/internal": ["../contract/src/internal.ts"], "@temporal-contract/worker/activity": ["../worker/src/activity.ts"], "@temporal-contract/worker/worker": ["../worker/src/worker.ts"], "@temporal-contract/worker/workflow": ["../worker/src/workflow.ts"] diff --git a/packages/testing/vitest.config.ts b/packages/testing/vitest.config.ts index 9f5bb19d..ae172f10 100644 --- a/packages/testing/vitest.config.ts +++ b/packages/testing/vitest.config.ts @@ -15,8 +15,8 @@ const workspaceAliases = [ replacement: sibling("../contract/src/errors.ts"), }, { - find: /^@temporal-contract\/contract\/result-async$/, - replacement: sibling("../contract/src/result-async.ts"), + find: /^@temporal-contract\/contract\/internal$/, + replacement: sibling("../contract/src/internal.ts"), }, { find: /^@temporal-contract\/worker\/activity$/, diff --git a/packages/worker/package.json b/packages/worker/package.json index a0534b31..7a9466ca 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -28,42 +28,24 @@ "sideEffects": false, "exports": { "./activity": { - "import": { - "types": "./dist/activity.d.mts", - "default": "./dist/activity.mjs" - }, - "require": { - "types": "./dist/activity.d.cts", - "default": "./dist/activity.cjs" - } + "types": "./dist/activity.d.mts", + "import": "./dist/activity.mjs" }, "./package.json": "./package.json", "./worker": { - "import": { - "types": "./dist/worker.d.mts", - "default": "./dist/worker.mjs" - }, - "require": { - "types": "./dist/worker.d.cts", - "default": "./dist/worker.cjs" - } + "types": "./dist/worker.d.mts", + "import": "./dist/worker.mjs" }, "./workflow": { - "import": { - "types": "./dist/workflow.d.mts", - "default": "./dist/workflow.mjs" - }, - "require": { - "types": "./dist/workflow.d.cts", - "default": "./dist/workflow.cjs" - } + "types": "./dist/workflow.d.mts", + "import": "./dist/workflow.mjs" } }, "scripts": { - "build": "tsdown src/activity.ts src/worker.ts src/workflow.ts --format cjs,esm --dts --clean", + "build": "tsdown src/activity.ts src/worker.ts src/workflow.ts --format esm --dts --clean", "build:docs": "typedoc", - "check:package": "publint --strict && attw --pack . --profile node16", - "dev": "tsdown src/activity.ts src/worker.ts src/workflow.ts --format cjs,esm --dts --watch", + "check:package": "publint --strict && attw --pack . --profile esm-only", + "dev": "tsdown src/activity.ts src/worker.ts src/workflow.ts --format esm --dts --watch", "test": "vitest run --project unit", "test:integration": "vitest run --project integration --project integration-inprocess", "test:watch": "vitest --project unit", @@ -96,9 +78,9 @@ "zod": "catalog:" }, "peerDependencies": { - "@temporalio/common": "^1", - "@temporalio/worker": "^1", - "@temporalio/workflow": "^1", + "@temporalio/common": "^1.16.0", + "@temporalio/worker": "^1.16.0", + "@temporalio/workflow": "^1.16.0", "unthrown": "^5.0.0" }, "engines": { diff --git a/packages/worker/src/activities-proxy.ts b/packages/worker/src/activities-proxy.ts index 66c829a8..cfb6f85b 100644 --- a/packages/worker/src/activities-proxy.ts +++ b/packages/worker/src/activities-proxy.ts @@ -5,10 +5,8 @@ import type { ErrorDefinition, } from "@temporal-contract/contract"; import { summarizeIssues } from "@temporal-contract/contract"; -import { - _internal_rehydrateContractError, - type ContractErrorUnion, -} from "@temporal-contract/contract/errors"; +import { type ContractErrorUnion } from "@temporal-contract/contract/errors"; +import { _internal_rehydrateContractError } from "@temporal-contract/contract/internal"; import { ActivityFailure, ApplicationFailure } from "@temporalio/common"; /** * Activity inference types + the validated-activities proxy used by diff --git a/packages/worker/src/activity.ts b/packages/worker/src/activity.ts index d40c40c7..cef5bf99 100644 --- a/packages/worker/src/activity.ts +++ b/packages/worker/src/activity.ts @@ -22,12 +22,12 @@ import { type ErrorDefinition, } from "@temporal-contract/contract"; import { - _internal_buildErrorConstructors, CONTRACT_ERROR_TAG, type AnyContractError, type ContractErrorConstructors, type ContractErrorInputUnion, } from "@temporal-contract/contract/errors"; +import { _internal_buildErrorConstructors } from "@temporal-contract/contract/internal"; import { ApplicationFailure } from "@temporalio/common"; import { P, type AsyncResult } from "unthrown"; diff --git a/packages/worker/src/internal.ts b/packages/worker/src/internal.ts index 920ce443..bf931b14 100644 --- a/packages/worker/src/internal.ts +++ b/packages/worker/src/internal.ts @@ -47,7 +47,7 @@ export function formatChildWorkflowValidationMessage( export { _internal_makeAsyncResult as makeAsyncResult, _internal_assertNoDefect as assertNoDefect, -} from "@temporal-contract/contract/result-async"; +} from "@temporal-contract/contract/internal"; /** * Extract the single payload from a Temporal handler's `...args` array. diff --git a/packages/worker/src/workflow.ts b/packages/worker/src/workflow.ts index 205190fe..ca0bbc0e 100644 --- a/packages/worker/src/workflow.ts +++ b/packages/worker/src/workflow.ts @@ -16,11 +16,8 @@ import type { SignalDefinition, UpdateDefinition, } from "@temporal-contract/contract"; -import { - _internal_buildErrorConstructors, - ContractError, - type ContractErrorConstructors, -} from "@temporal-contract/contract/errors"; +import { ContractError, type ContractErrorConstructors } from "@temporal-contract/contract/errors"; +import { _internal_buildErrorConstructors } from "@temporal-contract/contract/internal"; import { type ActivityOptions, type WorkflowInfo, workflowInfo } from "@temporalio/workflow"; import type { AsyncResult } from "unthrown"; From 9af48682b3fb84de6524a0e270ce541b7a2aff10 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 31 Jul 2026 15:38:12 +0200 Subject: [PATCH 09/15] =?UTF-8?q?docs(examples)!:=20adopt=20v8=20idioms=20?= =?UTF-8?q?=E2=80=94=20tag=20constants,=20object-pattern=20narrowing,=20ma?= =?UTF-8?q?tch=20folds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - client matchers use exported tag constants and tagPatterns bundles - ContractError discrimination via the { errorName } object pattern - workflow error handling via match({ ok, errCases, defect }) with rethrow-on-defect - fix README link to the examples overview --- .../order-processing-client/src/client.ts | 43 +++-- examples/order-processing-worker/README.md | 3 +- .../src/application/workflows.ts | 167 +++++++++++------- 3 files changed, 126 insertions(+), 87 deletions(-) diff --git a/examples/order-processing-client/src/client.ts b/examples/order-processing-client/src/client.ts index 71d4b4c5..a000a2ae 100644 --- a/examples/order-processing-client/src/client.ts +++ b/examples/order-processing-client/src/client.ts @@ -1,9 +1,17 @@ import { + SCHEDULE_NOT_FOUND_ERROR_TAG, + SCHEDULE_ALREADY_EXISTS_ERROR_TAG, + SIGNAL_VALIDATION_ERROR_TAG, tagPatterns, TypedClient, + WORKFLOW_ALREADY_STARTED_ERROR_TAG, + WORKFLOW_EXECUTION_NOT_FOUND_ERROR_TAG, + WORKFLOW_FAILED_ERROR_TAG, + WORKFLOW_NOT_IN_CONTRACT_ERROR_TAG, WORKFLOW_OUTCOME_ERROR_TAGS, WORKFLOW_RESULT_ERROR_TAGS, WORKFLOW_START_ERROR_TAGS, + WORKFLOW_VALIDATION_ERROR_TAG, } from "@temporal-contract/client"; import { orderProcessingContract, @@ -104,10 +112,10 @@ async function run() { ok: () => logger.info("✍️ Approval signal sent"), errCases: (matcher) => matcher - .with(P.tag("@temporal-contract/SignalValidationError"), (err) => + .with(P.tag(SIGNAL_VALIDATION_ERROR_TAG), (err) => logger.error({ error: err }, "❌ Signal payload rejected by the contract"), ) - .with(P.tag("@temporal-contract/WorkflowExecutionNotFoundError"), (err) => + .with(P.tag(WORKFLOW_EXECUTION_NOT_FOUND_ERROR_TAG), (err) => logger.error({ error: err }, "❌ Workflow execution not found"), ), defect: (cause) => logger.error({ cause }, "❌ Unexpected failure sending signal"), @@ -124,9 +132,9 @@ async function run() { errCases: (matcher) => matcher // Typed domain error declared in the workflow's `errors:` block. The - // only declared error is PaymentDeclined, so `err.errorName` narrows - // to "PaymentDeclined" and `err.data` to `{ reason: string }`. - .with(P.tag("@temporal-contract/ContractError"), (err) => + // object pattern narrows the shared-`_tag` `ContractError` union by + // `errorName`, so `err.data` is typed `{ reason: string }`. + .with({ errorName: "PaymentDeclined" }, (err) => logger.error( { errorName: err.errorName, reason: err.data.reason }, `❌ Payment declined: ${err.data.reason}`, @@ -192,7 +200,7 @@ async function run() { ), errCases: (matcher) => matcher - .with(P.tag("@temporal-contract/ContractError"), (err) => + .with({ errorName: "PaymentDeclined" }, (err) => logger.error({ errorName: err.errorName }, "❌ Payment declined"), ) // The first-class outcome errors get their own arm here: a @@ -201,13 +209,13 @@ async function run() { .with(...tagPatterns(WORKFLOW_OUTCOME_ERROR_TAGS), (err) => logger.warn({ error: err }, `🛑 Workflow ${err.name}: execution was stopped`), ) - .with(P.tag("@temporal-contract/WorkflowValidationError"), (err) => + .with(P.tag(WORKFLOW_VALIDATION_ERROR_TAG), (err) => logger.error({ error: err }, "❌ Workflow output validation failed"), ) - .with(P.tag("@temporal-contract/WorkflowFailedError"), (err) => + .with(P.tag(WORKFLOW_FAILED_ERROR_TAG), (err) => logger.error({ error: err, cause: err.cause }, "❌ Workflow completed with failure"), ) - .with(P.tag("@temporal-contract/WorkflowExecutionNotFoundError"), (err) => + .with(P.tag(WORKFLOW_EXECUTION_NOT_FOUND_ERROR_TAG), (err) => logger.error({ error: err }, "❌ Workflow execution not found in namespace"), ), defect: (cause) => logger.error({ cause }, "❌ Unexpected failure awaiting result"), @@ -248,8 +256,9 @@ async function run() { errCases: (matcher) => matcher // The typed PaymentDeclined contract error, rehydrated from the - // workflow's ApplicationFailure wire shape. - .with(P.tag("@temporal-contract/ContractError"), (err) => + // workflow's ApplicationFailure wire shape and narrowed by the + // `errorName` object pattern. + .with({ errorName: "PaymentDeclined" }, (err) => logger.error( { errorName: err.errorName, reason: err.data.reason }, `❌ Payment declined: ${err.data.reason}`, @@ -259,7 +268,7 @@ async function run() { // (or in retention). Production callers can re-fetch the existing // handle; here we just log and move on. (Handled before the grouped // bundles so it keeps its dedicated branch.) - .with(P.tag("@temporal-contract/WorkflowAlreadyStartedError"), (err) => + .with(P.tag(WORKFLOW_ALREADY_STARTED_ERROR_TAG), (err) => logger.warn({ error: err }, "⏭️ Workflow already started — skipping"), ) // Everything else executeWorkflow can err with — the start-phase and @@ -297,15 +306,15 @@ async function run() { matcher // Create-if-absent: a colliding running schedule is a modeled error, // so idempotent callers just reuse the existing one. - .with(P.tag("@temporal-contract/ScheduleAlreadyExistsError"), (err) => { + .with(P.tag(SCHEDULE_ALREADY_EXISTS_ERROR_TAG), (err) => { logger.info({ scheduleId: err.scheduleId }, "⏭️ Schedule already exists — reusing it"); return orders.schedule.getHandle(err.scheduleId); }) - .with(P.tag("@temporal-contract/WorkflowNotInContractError"), (err) => { + .with(P.tag(WORKFLOW_NOT_IN_CONTRACT_ERROR_TAG), (err) => { logger.error({ error: err }, "❌ Workflow not declared in the contract"); return undefined; }) - .with(P.tag("@temporal-contract/WorkflowValidationError"), (err) => { + .with(P.tag(WORKFLOW_VALIDATION_ERROR_TAG), (err) => { logger.error({ error: err }, "❌ Schedule args rejected by the contract"); return undefined; }), @@ -321,7 +330,7 @@ async function run() { triggerResult.match({ ok: () => logger.info("🧹 Cleanup run triggered immediately"), errCases: (matcher) => - matcher.with(P.tag("@temporal-contract/ScheduleNotFoundError"), (err) => + matcher.with(P.tag(SCHEDULE_NOT_FOUND_ERROR_TAG), (err) => logger.error({ error: err }, "❌ Schedule vanished before it could be triggered"), ), defect: (cause) => logger.error({ cause }, "❌ Unexpected failure triggering schedule"), @@ -333,7 +342,7 @@ async function run() { logger.info("💡 What this client demonstrated:"); logger.info(" - TypedClient.create({ client }) + .for(contract) split"); logger.info(" - Typed signals (with and without payload) and queries"); - logger.info(" - A typed contract error (PaymentDeclined) matched with P.tag"); + logger.info(" - A typed contract error matched with the { errorName } object pattern"); logger.info(" - schedule.create with the ScheduleAlreadyExistsError branch"); logger.info(" - Exhaustive error matching — every tag or it doesn't compile"); diff --git a/examples/order-processing-worker/README.md b/examples/order-processing-worker/README.md index 6dcc792f..7173fa58 100644 --- a/examples/order-processing-worker/README.md +++ b/examples/order-processing-worker/README.md @@ -35,9 +35,8 @@ pnpm dev 📖 **[Read the full documentation →](https://btravstack.github.io/temporal-contract)** -- [Example Overview](https://btravstack.github.io/temporal-contract/examples/basic-order-processing) +- [Examples overview](https://btravstack.github.io/temporal-contract/examples/) - [Your first workflow](https://btravstack.github.io/temporal-contract/tutorial/your-first-workflow) -- [All Examples](https://btravstack.github.io/temporal-contract/examples/) ## License diff --git a/examples/order-processing-worker/src/application/workflows.ts b/examples/order-processing-worker/src/application/workflows.ts index a07a9121..646af43d 100644 --- a/examples/order-processing-worker/src/application/workflows.ts +++ b/examples/order-processing-worker/src/application/workflows.ts @@ -2,8 +2,15 @@ import { orderProcessingContract, type OrderStatusSchema, } from "@temporal-contract/sample-order-processing-contract"; -import { declareWorkflow } from "@temporal-contract/worker/workflow"; -import { condition, isCancellation, log } from "@temporalio/workflow"; +import { + ACTIVITY_CANCELLED_ERROR_TAG, + ACTIVITY_ERROR_TAG, + declareWorkflow, + rethrowCancellation, + WORKFLOW_CANCELLED_ERROR_TAG, +} from "@temporal-contract/worker/workflow"; +import { condition, log } from "@temporalio/workflow"; +import { P } from "unthrown"; import type { z } from "zod"; type OrderStatus = z.infer; @@ -29,9 +36,13 @@ const APPROVAL_TIMEOUT = "5 minutes"; * (domain + infrastructure). `processPayment` declares a contract error, * so its workflow-side call returns an `AsyncResult` whose error channel * carries the typed `PaymentDeclined` (plus the generic activity errors). - * - A declined payment is rethrown as this workflow's own declared contract - * error (`context.errors.PaymentDeclined`), so the typed client rehydrates - * it — the one failure path that is an *error*, not a "failed" result. + * - That error channel is folded once, at the call site, with + * `match({ ok, errCases, defect })`: a declined payment is rethrown as + * this workflow's own declared contract error + * (`context.errors.PaymentDeclined`) so the typed client rehydrates it, + * cancellation is re-raised with `rethrowCancellation` so the execution + * ends `Cancelled`, an undeclared activity failure becomes a "failed" + * order result, and a defect fails the Workflow Task. * * Determinism note: everything here is replay-safe — `condition` and `log` * come from `@temporalio/workflow`, signal/query state is plain local data, @@ -138,57 +149,67 @@ export const processOrder = declareWorkflow({ // `processPayment` declares `errors` in the contract, so the call returns // `AsyncResult` instead of a throwing Promise. - const paymentResult = await activities.processPayment({ - customerId: order.customerId, - amount: order.totalAmount, - }); - - if (!paymentResult.isOk()) { - status = "failed"; - - if (paymentResult.isDefect()) { + // ActivityCancelledError>` instead of a throwing Promise. Fold all three + // channels once, at the call site — every arm either produces a value or + // deliberately ends the workflow. + const paymentOutcome = await activities + .processPayment({ customerId: order.customerId, amount: order.totalAmount }) + .match({ + ok: (payment) => payment, + errCases: (matcher) => + matcher + // The only declared error on `processPayment` — the object + // pattern narrows the shared-`_tag` `ContractError` union by + // `errorName`, typing `failure.data` as `{ reason: string }`. + .with({ errorName: "PaymentDeclined" }, async (failure) => { + status = "failed"; + log.error(`Payment declined for order ${order.orderId}: ${failure.data.reason}`); + + await activities.sendNotification({ + customerId: order.customerId, + subject: "Order Failed", + message: `We're sorry, but your order ${order.orderId} could not be processed. Your payment was declined (${failure.data.reason}).`, + }); + + // Rethrow as this workflow's own declared contract error: the + // execution fails with `ApplicationFailure(type: "PaymentDeclined")` + // and the typed client rehydrates it into a `ContractError`. + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ApplicationFailure model: `throw context.errors.X(...)` is how a workflow fails terminally with a typed contract error (CLAUDE.md rule 2 exception) + throw context.errors.PaymentDeclined( + { reason: failure.data.reason }, + { cause: failure }, + ); + }) + // Cancellation rides the modeled Err channel — mapping it to a + // "failed" order would complete the workflow instead of honoring + // the cancel. Re-raise it so the execution ends `Cancelled`. + .with(P.tag(ACTIVITY_CANCELLED_ERROR_TAG), (failure) => rethrowCancellation(failure)) + // Undeclared activity failure (retries exhausted, timeout): + // surface a failed order result. + .with(P.tag(ACTIVITY_ERROR_TAG), (failure) => { + status = "failed"; + log.error(`Payment activity failed for order ${order.orderId}: ${failure.message}`); + return { + orderId: order.orderId, + status: "failed" as const, + failureReason: "Payment could not be processed", + errorCode: "PAYMENT_UNAVAILABLE", + }; + }), // Unmodeled failure (a bug, not an anticipated outcome) — rethrow at // the edge so Temporal surfaces the Workflow Task failure. - // oxlint-disable-next-line unthrown/no-throw -- defect-cause rethrow at the edge: an unmodeled failure must surface as a Workflow Task failure - throw paymentResult.cause; - } - const failure = paymentResult.error; - - switch (failure._tag) { - case "@temporal-contract/ContractError": { - // The only declared error on `processPayment` is PaymentDeclined, - // so `failure.data` is typed `{ reason: string }`. - log.error(`Payment declined for order ${order.orderId}: ${failure.data.reason}`); - - await activities.sendNotification({ - customerId: order.customerId, - subject: "Order Failed", - message: `We're sorry, but your order ${order.orderId} could not be processed. Your payment was declined (${failure.data.reason}).`, - }); - - // Rethrow as this workflow's own declared contract error: the - // execution fails with `ApplicationFailure(type: "PaymentDeclined")` - // and the typed client rehydrates it into a `ContractError`. - // oxlint-disable-next-line unthrown/no-throw -- sanctioned ApplicationFailure model: `throw context.errors.X(...)` is how a workflow fails terminally with a typed contract error (CLAUDE.md rule 2 exception) - throw context.errors.PaymentDeclined({ reason: failure.data.reason }, { cause: failure }); - } - case "@temporal-contract/ActivityError": - case "@temporal-contract/ActivityCancelledError": { - // Unclassified activity failure (retries exhausted, timeout, - // cancellation): surface a failed order result. - log.error(`Payment activity failed for order ${order.orderId}: ${failure.message}`); - return { - orderId: order.orderId, - status: "failed" as const, - failureReason: "Payment could not be processed", - errorCode: "PAYMENT_UNAVAILABLE", - }; - } - } + defect: (cause) => { + // oxlint-disable-next-line unthrown/no-throw -- defect-cause rethrow at the edge: an unmodeled failure must surface as a Workflow Task failure + throw cause; + }, + }); + + if ("status" in paymentOutcome) { + // The fold produced the workflow's failed output — return it as-is. + return paymentOutcome; } - const payment = paymentResult.value; + const payment = paymentOutcome; log.info(`Payment successful: ${payment.transactionId}`); // ------------------------------------------------------------------ @@ -235,23 +256,33 @@ export const processOrder = declareWorkflow({ log.info(`Shipment created: ${shippingResult.trackingNumber}`); - // Step 5: Send success notification (non-critical) - try { - await activities.sendNotification({ - customerId: order.customerId, - subject: "Order Confirmed", - message: `Your order ${order.orderId} has been confirmed and will be shipped. Tracking: ${shippingResult.trackingNumber}`, + // Step 5: Send success notification (non-critical). `sendNotification` + // declares no errors, so it is a throwing Promise — `cancellableScope` + // folds it into the Result discipline instead of a `try/catch`: + // cancellation surfaces as `Err(WorkflowCancelledError)`, anything else + // it throws is a defect. + await context + .cancellableScope(() => + activities.sendNotification({ + customerId: order.customerId, + subject: "Order Confirmed", + message: `Your order ${order.orderId} has been confirmed and will be shipped. Tracking: ${shippingResult.trackingNumber}`, + }), + ) + .match({ + ok: () => undefined, + // Cancellation must propagate — absorbing it here would complete the + // workflow after a cancel request instead of ending it `Cancelled`. + errCases: (matcher) => + matcher.with(P.tag(WORKFLOW_CANCELLED_ERROR_TAG), (cancelled) => + rethrowCancellation(cancelled), + ), + // Non-critical: the order is already shipped, so even an unmodeled + // notification failure is only worth a warning. + defect: (cause) => { + log.warn(`Failed to send confirmation notification: ${cause}`); + }, }); - } catch (error) { - // Cancellation must propagate — swallowing it here would leave the - // workflow running after a cancel request. - if (isCancellation(error)) { - // oxlint-disable-next-line unthrown/no-throw -- cancellation rethrow: swallowing a CancelledFailure would leave the workflow running after a cancel request - throw error; - } - // Non-critical: log but continue - log.warn(`Failed to send confirmation notification: ${error}`); - } // Success! status = "completed"; From e4699044f7e912403e2537e56c08c228a6b46f4c Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 31 Jul 2026 15:40:45 +0200 Subject: [PATCH 10/15] chore: add changeset for the v8 audit remediation --- .changeset/v8-audit-remediation.md | 54 ++++ docs/.vitepress/config.ts | 1 + docs/api/index.md | 12 +- docs/examples/index.md | 2 +- docs/explanation/nexus.md | 2 +- docs/explanation/validation-boundaries.md | 10 +- docs/explanation/workflow-determinism.md | 2 +- docs/how-to/add-activity-middleware.md | 15 +- docs/how-to/configure-a-worker.md | 71 ++++- docs/how-to/continue-as-new.md | 14 +- docs/how-to/define-a-contract.md | 47 ++- docs/how-to/handle-cancellation.md | 59 +++- docs/how-to/implement-activities.md | 99 ++++-- .../index-workflows-with-search-attributes.md | 6 +- docs/how-to/install.md | 35 ++- docs/how-to/intercept-client-calls.md | 46 ++- docs/how-to/migrate-from-neverthrow.md | 61 ++-- docs/how-to/model-domain-errors.md | 76 +++-- docs/how-to/run-child-workflows.md | 15 +- docs/how-to/schedule-workflows.md | 68 ++-- docs/how-to/test-workflows.md | 127 +++++--- docs/how-to/troubleshoot.md | 44 ++- docs/how-to/tune-activity-options.md | 17 +- docs/how-to/upgrade-to-v8.md | 174 ++++++++++- .../how-to/use-signals-queries-and-updates.md | 65 ++-- docs/index.md | 29 +- docs/reference/client-surface.md | 160 +++++++--- docs/reference/contract-surface.md | 84 ++++- docs/reference/glossary.md | 6 +- docs/reference/testing-surface.md | 292 ++++++++++++++++++ docs/reference/worker-surface.md | 219 +++++++++++-- docs/tutorial/adding-signals-and-queries.md | 47 +-- docs/tutorial/your-first-workflow.md | 61 ++-- packages/contract/typedoc.json | 2 +- packages/worker/typedoc.json | 2 +- 35 files changed, 1590 insertions(+), 434 deletions(-) create mode 100644 .changeset/v8-audit-remediation.md create mode 100644 docs/reference/testing-surface.md diff --git a/.changeset/v8-audit-remediation.md b/.changeset/v8-audit-remediation.md new file mode 100644 index 00000000..08f4c223 --- /dev/null +++ b/.changeset/v8-audit-remediation.md @@ -0,0 +1,54 @@ +--- +"@temporal-contract/contract": major +"@temporal-contract/client": major +"@temporal-contract/worker": major +"@temporal-contract/testing": major +--- + +v8 audit remediation — a second full-surface pass hardening robustness, the unthrown integration, developer experience, and btravstack-family consistency before 8.0 stabilises. + +**All packages.** + +- ESM-only everywhere: `@temporal-contract/client` and `@temporal-contract/worker` drop their CJS output and legacy `main`/`module`/`types` fields; all four packages verify with `attw --profile esm-only`. +- `@temporalio/*` peer ranges tightened from `^1` to `^1.16.0` — the real floor for the Schedule API and the top-level `@temporalio/common` search-attribute imports. +- Error tags are exported as literal-typed constants (`CONTRACT_ERROR_TAG`, `ACTIVITY_ERROR_TAG`, `WORKFLOW_FAILED_ERROR_TAG`, …) so consumers match with `P.tag(CONST)` instead of hand-written strings; the classes consume the constants so tag and constant cannot drift. + +**`@temporal-contract/contract`:** + +- Type-helper renames to the family-standard `Infer*` prefix: `SignalNamesOf`/`QueryNamesOf`/`UpdateNamesOf`/`DeclaredErrorsOf` → `InferSignalNames`/`InferQueryNames`/`InferUpdateNames`/`InferDeclaredErrors`. +- `defineActivity`'s `defaultOptions` key is renamed `activityOptions` (merge precedence unchanged). +- Duration strings are validated against the `ms` grammar at `defineContract` time — `"5 minutos"`, `""`, and negative durations now fail at definition, naming the offending path, instead of at the worker. +- Temporal-reserved names are rejected at `defineContract`: the `__temporal_` prefix and the exact `__stack_trace` / `__enhanced_stack_trace` query names. +- Activity-only contracts are allowed — `workflows` may be `{}` when at least one global activity is declared (dedicated activity-pool task queues). +- The typed-error wire encoding carries a provenance marker (`details[1] = { $tc: 1 }`). Data-less declared errors now require the marker to rehydrate, closing a false positive where any `ApplicationFailure` sharing a declared error's `type` string was surfaced as the typed domain error. A degrade-to-generic rehydration miss fires the new `onRehydrationMiss` diagnostic hook instead of failing silently. +- The `/result-async` subpath is removed; the `_internal_*` helpers move behind a dedicated `@temporal-contract/contract/internal` subpath (not a public API). + +**`@temporal-contract/worker`:** + +- A shared activity referenced from several contract scopes must be implemented once at the global level or with the same function reference; two different implementations for one flattened name now throw at declaration time (previously the last one silently clobbered the rest). +- The in-workflow handler binders are renamed to the `handle*` convention: `context.defineSignal`/`defineQuery`/`defineUpdate` → `context.handleSignal`/`handleQuery`/`handleUpdate` (no collision with the contract-authoring `define*` helpers or Temporal's own functions). +- Previously-internal types are now exported so implementations can be factored out of the `declareWorkflow`/`declareActivitiesHandler` calls: `WorkflowContext`, `DeclareWorkflowOptions`, `WorkflowImplementation`, the child-workflow handle types, the signal/query/update handler-implementation types, `WorkflowInferActivity`, `DeclareActivitiesHandlerOptions`, `TypedContinueAsNewOptions`, plus the new `ActivityImplementationFor` / `GlobalActivityImplementationFor` helpers. +- `qualifyFailure(errorType, options)` requires an `expected` discriminator (an error class, an array of classes, a predicate, or the explicit literal `"any"`). Causes matching `expected` are wrapped into the modeled `ApplicationFailure`; everything else — a `TypeError` from a bug, say — rides the **defect** channel instead of being mislabelled a business error. A matched inner `ApplicationFailure` with `nonRetryable: true` is inherited by default. +- New `rethrowCancellation(error)` helper. When an activity declares an `errors` map, cancellation surfaces as `Err(ActivityCancelledError)`; generic error handling that folds every `Err` to a fallback would complete the workflow instead of cancelling it. The cancellation error classes' JSDoc documents the hazard and the helper. +- Async query/update schemas are rejected at bind time (`ContractMisuseError`) rather than on the first live request. +- `context.continueAsNew` can no longer have its validated `workflowType`/`taskQueue` overridden through the options bag. +- `ChildWorkflowError` carries a structured `workflowName`; the input/output `ValidationError` subclasses carry a `readonly direction: "input" | "output"`. +- `TypedWorker.create` verifies workflow registration by default — a contract workflow missing from the `workflowsPath` bundle, or an export whose name differs from its `workflowName`, fails creation with a contract-aware message. Opt out with `verifyWorkflowRegistration: false`. + +**`@temporal-contract/client`:** + +- Workflow outcomes are first-class typed errors on `executeWorkflow`/`handle.result()`: `WorkflowCancelledError`, `WorkflowTerminatedError`, `WorkflowTimeoutError` (each retaining the original `TemporalFailure` as `cause`) — no more `err.cause instanceof CancelledFailure` digging the matcher can't see. +- Update and query operational failures are modeled instead of leaking as defects: `UpdateFailedError`, `UpdateRejectedError`, `QueryFailedError` (the last covering Temporal's `QueryNotRegisteredError`). +- `P`-composable tag bundles (`WORKFLOW_START_ERROR_TAGS`, `WORKFLOW_OUTCOME_ERROR_TAGS`, `WORKFLOW_RESULT_ERROR_TAGS`) and a `tagPatterns(tags)` helper collapse the recurring multi-tag `match` arms. +- `handle.raw` exposes the underlying `@temporalio/client` `WorkflowHandle`. +- `ContractClient` and `TypedScheduleClient` are no longer constructible directly (use `typedClient.for(...)` / the schedule accessor); `ContractClient` exposes readonly `contract` and `taskQueue` getters. +- Client interceptors may patch only `input`/`signalInput`, never identity fields such as `workflowName`. +- `TypedScheduleHandle.update()` validates the updated action's `args` against the contract when its `workflowType` is a declared workflow. Invalid `DATETIME` search-attribute values (`new Date(NaN)`) are rejected. +- Internals: the imperative `assertNoDefect` thunks are replaced with `AsyncResult` combinator chains, so defects flow through channels without manual re-wrapping. + +**`@temporal-contract/testing`:** + +- The factories move to the family option-bag convention: `createContractTest({ contract, workflowsPath, ... })` and `runActivity(definition, { implementation, input, env? })`. +- New `runActivityHandler(definition, { ... })` routes through the real `declareActivitiesHandler` wrapping — input parse, output validation, contract-error wire conversion, and rehydration — so a test exercises what production does, not just the raw implementation. +- `@unthrown/vitest` is wired in; the package's assertions use `toBeOk`/`toBeErrTagged`/`toBeDefect`. +- `testcontainers` is now an optional peer dependency, required only for `createContractTest`; the Docker-free `/time-skipping` and `/activity` entries no longer pull it in. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 010c0baa..9606d28e 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -78,6 +78,7 @@ const DOCS_SIDEBAR = [ { text: "Contract surface", link: "/reference/contract-surface" }, { text: "Worker surface", link: "/reference/worker-surface" }, { text: "Client surface", link: "/reference/client-surface" }, + { text: "Testing surface", link: "/reference/testing-surface" }, { text: "Errors", link: "/reference/errors" }, { text: "Glossary", link: "/reference/glossary" }, { text: "API reference", link: "/api/" }, diff --git a/docs/api/index.md b/docs/api/index.md index 4f7bcfdf..7fcae3f5 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -19,13 +19,21 @@ from the previous `neverthrow`-based version. - [@temporal-contract/testing](./testing/) - Testing utilities with testcontainers +Each package is documented per **public entry point**: contract as `index` +(the root) and `errors`; worker as `activity`, `worker`, and `workflow`; testing +as `activity`, `contract`, `extension`, `global-setup`, and `time-skipping`. The +internal `@temporal-contract/contract/internal` entry carries no semver +guarantee and is intentionally excluded. + ## Hand-written reference -The generated pages above describe every exported symbol. For grouped, -narrative reference — option tables, error channels, merge order — see: +The generated pages above describe every symbol exported from those public +entry points. For grouped, narrative reference — option tables, error channels, +merge order — see: - [Contract surface](/reference/contract-surface) - [Worker surface](/reference/worker-surface) - [Client surface](/reference/client-surface) +- [Testing surface](/reference/testing-surface) - [Errors](/reference/errors) - [Glossary](/reference/glossary) diff --git a/docs/examples/index.md b/docs/examples/index.md index bc8e6de7..f61b0035 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -59,7 +59,7 @@ Watch the execution at . ```bash # Integration tests — needs Docker -pnpm --filter @temporal-contract/sample-order-processing-worker test +pnpm --filter @temporal-contract/sample-order-processing-worker test:integration ``` ## Worth reading in the source diff --git a/docs/explanation/nexus.md b/docs/explanation/nexus.md index cdadfc5c..f4ac879e 100644 --- a/docs/explanation/nexus.md +++ b/docs/explanation/nexus.md @@ -119,7 +119,7 @@ contract covers its input and output again, at the cost of an extra hop: processOrder: { chargeViaNexus: ({ customerId, amount }) => fromPromise(nexusClient.executeOperation("charge", { customerId, amount }), - qualifyFailure("NEXUS_CHARGE_FAILED")), + qualifyFailure("NEXUS_CHARGE_FAILED", { expected: NexusOperationError })), } ``` diff --git a/docs/explanation/validation-boundaries.md b/docs/explanation/validation-boundaries.md index 373649bb..33d86f50 100644 --- a/docs/explanation/validation-boundaries.md +++ b/docs/explanation/validation-boundaries.md @@ -150,10 +150,14 @@ _shape_ (a hand-rolled structural check; the contract package has no runtime schema-library dependency): - `taskQueue` present and non-empty -- at least one workflow +- at least one workflow _or_ global activity (activity-only contracts are valid + — a dedicated activity-pool worker needs no workflows) - no unknown keys at the contract root (strict — only `taskQueue`, `workflows`, `activities`) -- every name a valid JavaScript identifier +- every name a valid JavaScript identifier, and not a Temporal-reserved name + (the `__temporal_` prefix, `__stack_trace`, `__enhanced_stack_trace`) +- every duration option a valid `ms` string (`"5 minutes"`, `"30s"`) — a typo + like `"5 minutos"` fails here, not at the worker - every schema slot Standard Schema compatible - no activity-name collisions in the flat runtime namespace — reusing the _same_ definition object across workflows is fine (that is one activity, not @@ -161,7 +165,7 @@ schema-library dependency): hint to hoist the shared activity to the global `activities` block - no workflow name colliding with a global activity name (they share the root of the worker's implementations map) -- no unknown keys in `defaultOptions` +- no unknown keys in `activityOptions` Because this runs at import time, a malformed contract fails when the process starts rather than when a workflow first executes. diff --git a/docs/explanation/workflow-determinism.md b/docs/explanation/workflow-determinism.md index d6872ed9..e40eaac6 100644 --- a/docs/explanation/workflow-determinism.md +++ b/docs/explanation/workflow-determinism.md @@ -75,7 +75,7 @@ is the boundary worth guarding. ```typescript let approved = false; -context.defineSignal("approve", () => { +context.handleSignal("approve", () => { approved = true; }); ``` diff --git a/docs/how-to/add-activity-middleware.md b/docs/how-to/add-activity-middleware.md index 322968ae..fc3bdcb2 100644 --- a/docs/how-to/add-activity-middleware.md +++ b/docs/how-to/add-activity-middleware.md @@ -67,7 +67,11 @@ The most useful thing middleware does is extend the typed context that flows to implementations. Use `declareActivityMiddleware` to pin the in and out types: ```typescript -import { declareActivityMiddleware, type EmptyContext } from "@temporal-contract/worker/activity"; +import { + ApplicationFailure, + declareActivityMiddleware, + type EmptyContext, +} from "@temporal-contract/worker/activity"; import { ErrAsync } from "unthrown"; const withTenant = declareActivityMiddleware( @@ -96,7 +100,9 @@ export const activities = declareActivitiesHandler({ // context.tenantId: string fromPromise( gateway.charge(context.tenantId, customerId, amount), - qualifyFailure("CHARGE_FAILED"), + // `expected` is required: name the anticipated failure class (or a + // predicate). Everything else rides the defect channel. + qualifyFailure("CHARGE_FAILED", { expected: GatewayError }), ), }, }, @@ -145,7 +151,10 @@ export const activities = declareActivitiesHandler({ activities: { processOrder: { chargeCard: ({ customerId, amount }, { context }) => - fromPromise(context.gateway.charge(customerId, amount), qualifyFailure("CHARGE_FAILED")), + fromPromise( + context.gateway.charge(customerId, amount), + qualifyFailure("CHARGE_FAILED", { expected: GatewayError }), + ), }, }, }); diff --git a/docs/how-to/configure-a-worker.md b/docs/how-to/configure-a-worker.md index da289399..17c246d8 100644 --- a/docs/how-to/configure-a-worker.md +++ b/docs/how-to/configure-a-worker.md @@ -19,13 +19,14 @@ const worker = await TypedWorker.create({ connection, workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"), activities, -}).get(); +}).getOrThrow(); -await worker.run().get(); +await worker.run().getOrThrow(); ``` `taskQueue` comes from the contract, so you never repeat it. Everything else on Temporal's `WorkerOptions` is accepted and passed through. +`TypedWorker.create` replaces the old free `createWorker` function. ## Handle a failed start @@ -46,19 +47,55 @@ if (result.isDefect()) { process.exit(1); } -await result.value.run().get(); +// Past the guard the only remaining variant is `Ok` — the error channel is +// `never` — so unwrap and run. +await result.getOrThrow().run().getOrThrow(); ``` -`.get()` is the terse form — on a defect it rethrows the original cause with its -stack intact, which is usually what you want at process startup. +`.getOrThrow()` is the terse form — on a defect it rethrows the original cause +with its stack intact, which is usually what you want at process startup. (With +a `never` error channel `.get()` would compile too, but `.getOrThrow()` is the +one idiom that stays correct if a modeled error is ever added.) `run()` has the same shape: it returns `AsyncResult`, so a worker that fails while running surfaces as a defect (a `TechnicalError` cause) rather -than a rejected promise — `await worker.run().get()` rethrows it at the edge. -The underlying Temporal `Worker` stays available as `worker.raw` for anything -the typed surface doesn't cover (`worker.raw.getState()`, +than a rejected promise — `await worker.run().getOrThrow()` rethrows it at the +edge. The underlying Temporal `Worker` stays available as `worker.raw` for +anything the typed surface doesn't cover (`worker.raw.getState()`, `worker.raw.runUntil(...)`). +## Verify workflow registration + +`TypedWorker.create` verifies workflow registration by default: it imports the +`workflowsPath` module and checks that every contract workflow is exported +under its declared name. Creation fails (a `TechnicalError`-caused defect) when + +- a contract workflow is missing from the bundle — a forgotten + `declareWorkflow` export that would otherwise surface only when the first + task for it was dispatched; or +- a workflow is exported under a name that differs from its `workflowName` — + Temporal registers workflows by export name, so the mismatch would register + it as the wrong workflow type. + +Opt out with `verifyWorkflowRegistration: false`, for example when the +workflows module intentionally exports helpers whose names shadow contract +workflows: + +```typescript +const worker = await TypedWorker.create({ + contract: orderContract, + connection, + workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"), + activities, + verifyWorkflowRegistration: false, +}).getOrThrow(); +``` + +The check is best-effort: it only runs when `workflowsPath` is provided +(prebuilt `workflowBundle`s are skipped), and a module that cannot be imported +in the main thread is skipped silently — `Worker.create`'s bundler is the +authority on whether the module loads at all. + ## Resolve the workflows path Temporal bundles workflow code into an isolated sandbox, so it needs a _path_, @@ -109,9 +146,9 @@ const worker = await TypedWorker.create({ connection, workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"), // no `activities` -}).get(); +}).getOrThrow(); -await worker.run().get(); +await worker.run().getOrThrow(); ``` This is the split-deployment pattern: workflows are deterministic and @@ -134,7 +171,7 @@ const worker = await TypedWorker.create({ // Cap the rate at which this worker pulls new work. maxTaskQueueActivitiesPerSecond: 50, -}).get(); +}).getOrThrow(); ``` Activity concurrency is the usual bottleneck. Raise it for I/O-bound work; keep @@ -143,14 +180,14 @@ it low for CPU-bound or memory-hungry activities. ## Shut down gracefully ```typescript -const worker = await TypedWorker.create({/* ... */}).get(); +const worker = await TypedWorker.create({/* ... */}).getOrThrow(); process.on("SIGTERM", () => { console.log("draining..."); worker.shutdown(); }); -await worker.run().get(); // resolves once in-flight tasks finish +await worker.run().getOrThrow(); // resolves once in-flight tasks finish await connection.close(); ``` @@ -180,16 +217,16 @@ const [orderWorker, shipmentWorker] = await Promise.all([ connection, workflowsPath: workflowsPathFromURL(import.meta.url, "./order.workflows.js"), activities: orderActivities, - }).get(), + }).getOrThrow(), TypedWorker.create({ contract: shipmentContract, connection, workflowsPath: workflowsPathFromURL(import.meta.url, "./shipment.workflows.js"), activities: shipmentActivities, - }).get(), + }).getOrThrow(), ]); -await Promise.all([orderWorker.run().get(), shipmentWorker.run().get()]); +await Promise.all([orderWorker.run().getOrThrow(), shipmentWorker.run().getOrThrow()]); ``` They share the connection. Split them into separate processes when their @@ -216,7 +253,7 @@ const worker = await TypedWorker.create({ namespace: "my-namespace.a1b2c", workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"), activities, -}).get(); +}).getOrThrow(); ``` ## Add logging diff --git a/docs/how-to/continue-as-new.md b/docs/how-to/continue-as-new.md index 4006ba76..b27204d1 100644 --- a/docs/how-to/continue-as-new.md +++ b/docs/how-to/continue-as-new.md @@ -48,19 +48,21 @@ Base the decision on something deterministic. `context.info` exposes Temporal's ```typescript implementation: async (context, args) => { let processed = args.processed; + let cursor = args.cursor; while (true) { - const batch = await context.activities.fetchBatch({ cursor: args.cursor }); + const batch = await context.activities.fetchBatch({ cursor }); if (batch.items.length === 0) { return { processed }; } await context.activities.processBatch({ items: batch.items }); processed += batch.items.length; + cursor = batch.nextCursor; // advance, so the next fetch makes progress // Temporal's own signal that history is getting long. if (context.info.continueAsNewSuggested) { - return context.continueAsNew({ cursor: batch.nextCursor, processed }); + return context.continueAsNew({ cursor, processed }); } } }; @@ -118,14 +120,16 @@ return context.continueAsNew( { subscriptionId: args.subscriptionId, cycle: args.cycle + 1 }, { workflowRunTimeout: "7 days", - retry: { maximumAttempts: 3 }, + workflowTaskTimeout: "10 seconds", memo: { tenant: args.tenantId }, }, ); ``` -`workflowType` and `taskQueue` are derived from the contract and cannot be set -here. +`TypedContinueAsNewOptions` is Temporal's `ContinueAsNewOptions` minus +`workflowType` and `taskQueue` (derived from the contract, and ignored if you +try to set them). There is no `retry` option — a continued run inherits the +chain's retry policy; use activity retry policies for step-level retries. ## Drain handlers first diff --git a/docs/how-to/define-a-contract.md b/docs/how-to/define-a-contract.md index cfa19036..86f4fedc 100644 --- a/docs/how-to/define-a-contract.md +++ b/docs/how-to/define-a-contract.md @@ -60,17 +60,26 @@ surface small and makes ownership obvious. ::: warning Activity names share one flat namespace At runtime Temporal resolves activities from a single map, regardless of which -workflow calls them. `defineContract` therefore rejects any duplicate name — -whether the collision is workflow-vs-global or between two different workflows: +workflow calls them. `defineContract` therefore rejects a duplicate name that +points at two **different** definitions — whether the collision is +workflow-vs-global or between two different workflows: ``` -workflow "cancelOrder" has activity "chargeCard" that conflicts with the -same-named activity in workflow "processOrder". Activities share a single flat -namespace at runtime — rename one of them. +Contract validation failed: workflow "cancelOrder" has activity "chargeCard" +that conflicts with a different same-named activity in workflow +"processOrder". Activities share a single flat namespace at runtime — hoist +the shared activity to the contract's global "activities" block, or rename +one of them. ``` -If two workflows genuinely need the same operation, declare it once as a global -activity. +Referencing the **same** `defineActivity` result from several scopes is +allowed — it is one activity, and it flattens unambiguously. On the worker +side you must then implement it with the same function reference (or hoist it +to the global block); `declareActivitiesHandler` rejects two different +implementations for one flat name at declaration time. + +If two workflows genuinely need the same operation, declaring it once as a +global activity remains the simplest shape. ::: ## Reuse schemas @@ -209,16 +218,19 @@ every worker: const sendNotification = defineActivity({ input: NotificationSchema, output: z.void(), - defaultOptions: { + activityOptions: { startToCloseTimeout: "30 seconds", retry: { maximumAttempts: 5 }, }, }); ``` -`defaultOptions` is a strict object — a typo like `startToCloseTimeOut` fails at -`defineContract` time instead of being silently ignored. For the full merge -order, see [Tune activity options](/how-to/tune-activity-options). +`activityOptions` is a strict object — a typo like `startToCloseTimeOut` fails +at `defineContract` time instead of being silently ignored, and duration +strings are validated against the `ms` grammar (`"30 seconds"`, `"5m"`, +`"1.5h"`), so `"5 minutos"` fails at definition instead of surfacing later as +an opaque worker error. For the full merge order, see +[Tune activity options](/how-to/tune-activity-options). ## Keep contracts focused @@ -246,11 +258,18 @@ Workflows in different contracts still call each other — see It validates at call time and throws on: - a missing or empty `taskQueue`; -- an empty `workflows` map (at least one is required); +- a contract that declares nothing — at least one workflow **or** one global + activity is required (`workflows: {}` with global `activities` is valid: an + activity-only contract models a dedicated activity-pool task queue); +- an unknown top-level key (e.g. a misspelled `workflow`); - any name that is not a valid JavaScript identifier; +- a name Temporal reserves for its SDK internals — anything starting with + `__temporal_`, plus the exact query names `__stack_trace` and + `__enhanced_stack_trace`; - an `input`, `output`, or error `data` that is not Standard Schema compatible; -- duplicate activity names across the flat namespace; -- unknown keys in `defaultOptions`. +- duplicate activity names across the flat namespace that point at + **different** definitions (sharing one `defineActivity` result is allowed); +- unknown keys or malformed duration strings in `activityOptions`. ``` Contract validation failed: taskQueue cannot be empty diff --git a/docs/how-to/handle-cancellation.md b/docs/how-to/handle-cancellation.md index cc8fbded..1d200254 100644 --- a/docs/how-to/handle-cancellation.md +++ b/docs/how-to/handle-cancellation.md @@ -8,8 +8,10 @@ for completed steps, and record why it ended. ```typescript const bound = client.getHandle("processOrder", "order-123"); // synchronous Result -if (bound.isErr()) { - throw bound.error; +if (!bound.isOk()) { + // Narrow positively: after ruling out Ok, the value is Err or Defect, so + // reading `bound.value` would not compile — a defect rides `bound.cause`. + throw bound.isErr() ? bound.error : bound.cause; } // Cooperative: the workflow observes the request and exits on its own terms. @@ -41,12 +43,17 @@ export const processOrder = declareWorkflow({ context.activities.chargeCard({ customerId: order.customerId, amount: order.total }), ); - if (charged.isErr()) { - // Cancelled mid-charge — nothing to compensate yet. - return { status: "cancelled" as const }; + // Narrow positively: an `AsyncResult` has three channels (Ok/Err/Defect), + // so `charged.value` only compiles after `isOk()`. + if (charged.isOk()) { + return { status: "completed" as const, transactionId: charged.value.transactionId }; + } + if (charged.isDefect()) { + throw charged.cause; // a genuine bug thrown inside the scope, not a cancel } - return { status: "completed" as const, transactionId: charged.value.transactionId }; + // Err(WorkflowCancelledError): cancelled mid-charge — nothing to compensate. + return { status: "cancelled" as const }; }, }); ``` @@ -55,6 +62,30 @@ The `Err` channel of a scope is exactly one type: `WorkflowCancelledError`. Anything _else_ thrown inside the scope is an unmodeled failure and rides the defect channel — so a genuine bug is never mistaken for a cancellation. +### Activities that declare their own errors + +When an activity declares an `errors` map, cancelling it no longer throws +through — it surfaces as `Err(ActivityCancelledError)`, one more member of that +activity's error union. Generic handling that folds _every_ `Err` to a fallback +value will therefore let the workflow **complete** instead of cancelling: + +```typescript +import { rethrowCancellation } from "@temporal-contract/worker/workflow"; + +const charged = await context.activities.chargeCard({ ... }); +if (!charged.isOk()) { + if (charged.isDefect()) throw charged.cause; + // Re-raise a cancellation instead of swallowing it into the fallback path. + // For any other declared error, handle it as usual below. + rethrowCancellation(charged.error); + return handleChargeFailure(charged.error); +} +``` + +`rethrowCancellation` throws the underlying `CancelledFailure` when the error is +a cancellation and is a no-op otherwise, so the workflow ends **Cancelled** the +way the operator's `cancel()` intended. + ## Clean up without being interrupted Once a workflow is cancelled, further activity calls are cancelled too. Cleanup @@ -104,6 +135,7 @@ Long-running activities receive cancellation through the Temporal activity runtime. Heartbeating is what makes an activity cancellable at all: ```typescript +import { ApplicationFailure } from "@temporalio/common"; import { Context } from "@temporalio/activity"; import { CancelledFailure } from "@temporalio/common"; import { fromPromise } from "unthrown"; @@ -112,9 +144,16 @@ processOrder: { exportLedger: ({ accountId }) => fromPromise( (async () => { + const { cancellationSignal } = Context.current(); for (const page of await ledger.pages(accountId)) { - // Throws CancelledFailure once cancellation is requested. + // heartbeat() reports liveness (and is what arms the heartbeatTimeout + // and cancellation); it returns void and never throws. Observe the + // request through the abort signal — or let a cancellation-aware call + // (Context.current().sleep(), a signal-wired fetch) throw for you. Context.current().heartbeat(page.cursor); + if (cancellationSignal.aborted) { + throw new CancelledFailure("export cancelled"); + } await store.write(page); } return { exported: true }; @@ -180,12 +219,12 @@ A child's fate follows `parentClosePolicy`: await context.executeChildWorkflow(orderContract, "collectPayment", { workflowId: `payment-${order.orderId}`, args: { ... }, - parentClosePolicy: "REQUEST_CANCEL", // default: cancel the child too + parentClosePolicy: "REQUEST_CANCEL", // opt in; Temporal's default is TERMINATE }); ``` -- `REQUEST_CANCEL` — cancel the child when the parent closes (default) -- `TERMINATE` — kill it, no cleanup +- `TERMINATE` — kill the child when the parent closes, no cleanup (Temporal's default) +- `REQUEST_CANCEL` — cancel the child so it can compensate and exit on its own terms - `ABANDON` — let it outlive the parent A cancelled child surfaces as `Err(ChildWorkflowCancelledError)`. diff --git a/docs/how-to/implement-activities.md b/docs/how-to/implement-activities.md index c03a0c0e..d8f119e8 100644 --- a/docs/how-to/implement-activities.md +++ b/docs/how-to/implement-activities.md @@ -11,22 +11,28 @@ import { declareActivitiesHandler, qualifyFailure } from "@temporal-contract/wor import { fromPromise } from "unthrown"; import { orderContract } from "./contract.js"; +// Your service clients and their error classes. +import { gateway, GatewayError, mailer, MailerError } from "./services.js"; export const activities = declareActivitiesHandler({ contract: orderContract, activities: { // Global activity — declared on the contract, so it sits at the root. sendNotification: ({ customerId, message }) => - fromPromise(mailer.send(customerId, message), qualifyFailure("NOTIFICATION_FAILED")), + fromPromise( + mailer.send(customerId, message), + qualifyFailure("NOTIFICATION_FAILED", { expected: MailerError }), + ), // Workflow-scoped activities nest under their workflow's name. processOrder: { chargeCard: ({ customerId, amount }) => - fromPromise(gateway.charge(customerId, amount), qualifyFailure("CHARGE_FAILED")).map( - (c) => ({ - transactionId: c.id, - }), - ), + fromPromise( + gateway.charge(customerId, amount), + qualifyFailure("CHARGE_FAILED", { expected: GatewayError }), + ).map((c) => ({ + transactionId: c.id, + })), }, }, }); @@ -46,44 +52,65 @@ Activities return `AsyncResult` instead of throwing. `fromPromise` is the bridge: ```typescript -fromPromise(promise, qualifyFailure("SOMETHING_FAILED")); +fromPromise(promise, qualifyFailure("SOMETHING_FAILED", { expected: SomeSdkError })); ``` -`qualifyFailure(type)` builds the error mapper. When the promise rejects it wraps the -rejection in a Temporal `ApplicationFailure`: +`qualifyFailure(type, { expected })` builds a **triaging** qualifier. A +rejection whose cause matches `expected` is _anticipated_: it is wrapped in a +Temporal `ApplicationFailure` whose `type` is the one you declared. Anything +else is an unanticipated bug and rides unthrown's **defect** channel instead — +it re-throws at the activity edge with its original cause, so a `TypeError` +from a typo never masquerades as `SOMETHING_FAILED`. + +`expected` is **required** and accepts: + +- an error-class constructor (matched with `instanceof`), +- an array of constructors (any match wraps), +- a predicate `(cause: unknown) => boolean`, +- the literal `"any"` — a deliberate, greppable escape hatch that wraps every + rejection (the pre-v8 blanket behavior). -- an `Error` rejection keeps its own message and is preserved as `cause`, so - stack traces survive the activity → workflow boundary; -- anything else falls back to `options.message`, or `String(error)`. +For a matched `Error` cause, the wrapper keeps the cause's own message and +preserves it as `cause`, so stack traces survive the activity → workflow +boundary; a matched non-`Error` cause falls back to `options.message`, or +`String(cause)`. ```typescript qualifyFailure("CARD_DECLINED", { - message: "Payment gateway rejected the charge", // used when the rejection isn't an Error + expected: [CardDeclinedError, GatewayTimeoutError], // the failures you anticipate + message: "Payment gateway rejected the charge", // used when the cause isn't an Error nonRetryable: true, // Temporal stops retrying immediately details: [{ gateway: "stripe" }], // structured payload for the workflow }); ``` -::: warning `qualifyFailure` always wraps -Even when the rejection is _already_ an `ApplicationFailure`, `qualifyFailure` wraps -it, so the resulting `type` is guaranteed to be the one you declared — retry -policies keyed on `nonRetryableErrorTypes` can rely on that. - -The flip side: an inner `ApplicationFailure`'s own `type` and -`nonRetryable: true` are masked. If that inner failure must stay non-retryable, -pass `{ nonRetryable: true }` yourself or write a custom mapper. +::: warning A matched cause is always wrapped +Even when the matched rejection is _already_ an `ApplicationFailure`, +`qualifyFailure` wraps it, so the resulting `type` is guaranteed to be the one +you declared — retry policies keyed on `nonRetryableErrorTypes` can rely on +that, and the original failure is preserved as `cause`. + +Retryability of the wrapper: an explicit `nonRetryable` option wins +unconditionally; when it is omitted and the matched cause is an +`ApplicationFailure` with `nonRetryable: true`, the wrapper **inherits** +`nonRetryable: true` — a permanent inner failure no longer silently becomes +retryable just because it was re-typed. Pass `nonRetryable: false` to force +the wrapped failure retryable. ::: -For full control, skip `qualifyFailure` and write the mapper by hand: +For full control, skip `qualifyFailure` and write the qualifier by hand — it +receives the cause and a `defect` callback for the unanticipated branch: ```typescript -fromPromise(gateway.charge(customerId, amount), (error) => - ApplicationFailure.create({ - type: "CHARGE_FAILED", - message: error instanceof Error ? error.message : "charge failed", - nonRetryable: error instanceof PermanentDeclineError, - cause: error instanceof Error ? error : undefined, - }), +fromPromise(gateway.charge(customerId, amount), (error, defect) => + error instanceof GatewayError + ? ApplicationFailure.create({ + type: "CHARGE_FAILED", + message: error.message, + nonRetryable: error instanceof PermanentDeclineError, + cause: error, + }) + : defect(error), ); ``` @@ -97,11 +124,17 @@ so you do not need a separate `@temporalio/common` import. ```typescript processOrder: { chargeCard: ({ customerId, amount }) => - fromPromise(riskEngine.score(customerId), qualifyFailure("RISK_CHECK_FAILED")) + fromPromise( + riskEngine.score(customerId), + qualifyFailure("RISK_CHECK_FAILED", { expected: RiskEngineError }), + ) .flatMap((score) => score > 0.9 ? ErrAsync(ApplicationFailure.create({ type: "HIGH_RISK", nonRetryable: true })) - : fromPromise(gateway.charge(customerId, amount), qualifyFailure("CHARGE_FAILED")), + : fromPromise( + gateway.charge(customerId, amount), + qualifyFailure("CHARGE_FAILED", { expected: GatewayError }), + ), ) .map((charge) => ({ transactionId: charge.id })), } @@ -129,7 +162,7 @@ export const activities = declareActivitiesHandler({ chargeCard: ({ customerId, amount }, { context }) => fromPromise( context.gateway.charge(customerId, amount), - qualifyFailure("CHARGE_FAILED"), + qualifyFailure("CHARGE_FAILED", { expected: GatewayError }), ).map((c) => ({ transactionId: c.id })), }, }, @@ -176,7 +209,7 @@ processOrder: { return { synced: true, attempts: attempt }; })(), - qualifyFailure("CATALOG_SYNC_FAILED"), + qualifyFailure("CATALOG_SYNC_FAILED", { expected: CatalogError }), ), } ``` diff --git a/docs/how-to/index-workflows-with-search-attributes.md b/docs/how-to/index-workflows-with-search-attributes.md index eede4af0..56a5e406 100644 --- a/docs/how-to/index-workflows-with-search-attributes.md +++ b/docs/how-to/index-workflows-with-search-attributes.md @@ -99,8 +99,10 @@ instance into a typed partial object: import { readTypedSearchAttributes } from "@temporal-contract/client"; const bound = client.getHandle("processOrder", "order-123"); // synchronous Result -if (bound.isErr()) { - throw bound.error; +if (!bound.isOk()) { + // After ruling out Ok the value is Err or Defect, so `bound.value` would not + // compile — narrow positively and rethrow either channel. + throw bound.isErr() ? bound.error : bound.cause; } const described = await bound.value.describe(); diff --git a/docs/how-to/install.md b/docs/how-to/install.md index 5f32f03f..62b3c2dc 100644 --- a/docs/how-to/install.md +++ b/docs/how-to/install.md @@ -62,15 +62,32 @@ describes a compatible set. Do not mix versions. These are peers, not dependencies, because they appear in the packages' public types. Your code and the library must resolve to the _same_ copy. -| Peer | Required by | Range | -| ---------------------- | --------------------------------- | -------- | -| `unthrown` | contract, worker, client, testing | `^5.0.0` | -| `@temporalio/common` | worker, client | `^1` | -| `@temporalio/worker` | worker, testing | `^1` | -| `@temporalio/workflow` | worker | `^1` | -| `@temporalio/client` | client, testing | `^1` | -| `@temporalio/testing` | testing | `^1` | -| `vitest` | testing | `^4` | +| Peer | Required by | Range | +| ---------------------- | -------------------------------------------- | --------- | +| `unthrown` | contract (optional), worker, client, testing | `^5.0.0` | +| `@temporalio/common` | worker, client | `^1.16.0` | +| `@temporalio/worker` | worker, testing | `^1.16.0` | +| `@temporalio/workflow` | worker | `^1.16.0` | +| `@temporalio/client` | client, testing | `^1.16.0` | +| `@temporalio/testing` | testing | `^1.16.0` | +| `vitest` | testing | `^4` | +| `testcontainers` | testing (optional) | `^12` | + +Two of these are **optional**: + +- `unthrown` is an optional peer of the _contract_ package: defining a + contract needs no Result machinery, so the package root stays importable + without it. You only need it there when importing the + `@temporal-contract/contract/errors` surface — and the worker, client, and + testing packages require it unconditionally, so in practice any project + with more than the contract package installs it anyway. +- `testcontainers` is an optional peer of the _testing_ package, needed only + by `createContractTest` and the `/global-setup` entry (the Dockerized + Temporal server). The Docker-free entries (`/activity`, `/time-skipping`, + `/extension`) work without it. + +The `@temporalio/*` floor is **1.16.0** — the typed client relies on the +Schedule API wired into `Client` in that release. The testing package additionally peer-depends on the other three `@temporal-contract/*` packages — its contract-aware fixtures hand you a diff --git a/docs/how-to/intercept-client-calls.md b/docs/how-to/intercept-client-calls.md index a83f7ede..009234af 100644 --- a/docs/how-to/intercept-client-calls.md +++ b/docs/how-to/intercept-client-calls.md @@ -46,7 +46,12 @@ const auditing: ClientInterceptor = (args, next) => ## Add trace context -`next({ input })` shallow-merges a patch over the invocation before validation: +`next({ input })` shallow-merges a patch over the invocation before validation. +A patch may carry only the two payload fields — `input`, and `signalInput` for +`signalWithStart`. The identity fields (`operation`, `workflowName`, +`workflowId`, `name`) describe _which_ call is in flight and are owned by the +call site; any other key smuggled into the patch object is dropped, so an +interceptor cannot relabel a call mid-chain: ```typescript const tracing: ClientInterceptor = (args, next) => { @@ -54,15 +59,33 @@ const tracing: ClientInterceptor = (args, next) => { attributes: { "workflow.id": args.workflowId, "workflow.type": args.workflowName }, }); - return next({ - input: { - ...(args.input as Record), - traceparent: span.spanContext().traceId, - }, - }).tap(() => span.end()); + return ( + next({ + input: { + ...(args.input as Record), + traceparent: span.spanContext().traceId, + }, + }) + // `.tap()` fires only on `ok`, so ending the span there alone leaks it on + // every failure. `.tapFailure()` runs on both the `err` and `defect` + // channels, so the span is ended exactly once whatever the outcome. + .tap(() => span.end()) + .tapFailure(() => { + span.setStatus({ code: SpanStatusCode.ERROR }); + span.end(); + }) + ); }; ``` +::: tip When the span export itself can fail +`span.end()` returns nothing, so `tap` / `tapFailure` are the right tools. If +your effect _returns a `Result`_ — flushing to a collector that can fail, say — +reach for the effect-returning surface instead: `flatTap` on the `ok` side and +`flatTapErrCases` on the `err` side, so the effect's own failure threads through +rather than being silently dropped. +::: + ::: warning The patched field must be on the contract The patch goes through the same schema validation as the original input. If `traceparent` is not part of the workflow's input schema, the call fails with @@ -111,9 +134,12 @@ const fallback: ClientInterceptor = (args, next) => ::: warning The matcher is exhaustive `flatMapErrCases`, `mapErrCases`, `tapErrCases`, and `recoverErrCases` all -require a match that covers every member of the error union — here the nine -members of `ClientCallError`. A single `.with(P.tag(...))` arm will not compile. -Handle the cases you care about, then close with `P._`. +require a match that covers every member of the error union — every member of +`ClientCallError`, which now also carries the widened outcome errors +(`WorkflowCancelledError`, `WorkflowTerminatedError`, `WorkflowTimeoutError`) and +the update/query errors (`UpdateFailedError`, `UpdateRejectedError`, +`QueryFailedError`). A single `.with(P.tag(...))` arm will not compile. Handle +the cases you care about, then close with `P._`. ::: Calling `next()` twice re-runs the rest of the chain, so retries compose. diff --git a/docs/how-to/migrate-from-neverthrow.md b/docs/how-to/migrate-from-neverthrow.md index fca1b5bb..7f625874 100644 --- a/docs/how-to/migrate-from-neverthrow.md +++ b/docs/how-to/migrate-from-neverthrow.md @@ -1,11 +1,13 @@ # Migrate from neverthrow -Releases before 5.0 used [neverthrow](https://github.com/supermacro/neverthrow) -for the `Result` type. temporal-contract now uses -[unthrown](https://github.com/btravstack/unthrown) throughout — workflows, -activities, and the typed client. - -If you are on a recent version and only need the 7 → 8 step, see +temporal-contract used [neverthrow](https://github.com/supermacro/neverthrow) +for its `Result` type through contract **v2.x**; neverthrow was removed at +**v3.0.0**. It now uses [unthrown](https://github.com/btravstack/unthrown) +throughout — workflows, activities, and the typed client. This guide maps the +neverthrow idioms you may still be carrying to their unthrown equivalents. + +This is a different step from the unthrown-4 → unthrown-5 change that landed in +v8. If you are already on unthrown and only need the 7 → 8 upgrade, see [Upgrade to v8](/how-to/upgrade-to-v8) instead. ## Why the change @@ -62,23 +64,23 @@ const activity = (): AsyncResult => ... ## Method mapping -| neverthrow | unthrown | Note | -| -------------------------------- | -------------------------------------- | --------------------------- | -| `ok(v)` | `Ok(v)` | | -| `err(e)` | `Err(e)` | | -| `okAsync(v)` | `OkAsync(v)` or `Ok(v).toAsync()` | | -| `errAsync(e)` | `ErrAsync(e)` or `Err(e).toAsync()` | | -| `ResultAsync.fromPromise(p, f)` | `fromPromise(p, f)` | free function | -| `ResultAsync.fromSafePromise(p)` | `fromSafePromise(p)` | free function | -| `.map(f)` | `.map(f)` | unchanged | -| `.andThen(f)` | `.flatMap(f)` | renamed | -| `.mapErr(f)` | `.mapErrCases(m => …)` | takes a matcher | -| `.orElse(f)` | `.recoverErrCases(m => …)` | takes a matcher | -| `.match(ok, err)` | `.match({ ok, errCases, defect })` | object form, three channels | -| `Result.combine([…])` | `all([…])` | free function | -| `.isOk()` / `.isErr()` | `.isOk()` / `.isErr()` / `.isDefect()` | plus free functions | -| `.unwrapOr(v)` | `.getOr(v)` | | -| `._unsafeUnwrap()` | `.getOrThrow()` | | +| neverthrow | unthrown | Note | +| -------------------------------- | -------------------------------------- | ------------------------------- | +| `ok(v)` | `Ok(v)` | | +| `err(e)` | `Err(e)` | | +| `okAsync(v)` | `OkAsync(v)` or `Ok(v).toAsync()` | | +| `errAsync(e)` | `ErrAsync(e)` or `Err(e).toAsync()` | | +| `ResultAsync.fromPromise(p, f)` | `fromPromise(p, f)` | free function | +| `ResultAsync.fromSafePromise(p)` | `fromSafePromise(p)` | free function | +| `.map(f)` | `.map(f)` | unchanged | +| `.andThen(f)` | `.flatMap(f)` | renamed | +| `.mapErr(f)` | `.mapErrCases(m => …)` | takes a matcher | +| `.orElse(f)` | `.flatMapErrCases(m => …)` | matcher; arms return a `Result` | +| `.match(ok, err)` | `.match({ ok, errCases, defect })` | object form, three channels | +| `Result.combine([…])` | `all([…])` | free function | +| `.isOk()` / `.isErr()` | `.isOk()` / `.isErr()` / `.isDefect()` | plus free functions | +| `.unwrapOr(v)` | `.getOr(v)` | | +| `._unsafeUnwrap()` | `.getOrThrow()` | | ## `andThen` becomes `flatMap` @@ -217,11 +219,14 @@ import { Err, Ok, fromPromise } from "unthrown"; import { qualifyFailure } from "@temporal-contract/worker/activity"; const chargeCard = ({ customerId, amount }) => - fromPromise(gateway.charge(customerId, amount), qualifyFailure("CHARGE_FAILED")).flatMap( - (charge) => - charge.declined - ? Err(ApplicationFailure.create({ type: "DECLINED", nonRetryable: true })) - : Ok({ transactionId: charge.id }), + fromPromise( + gateway.charge(customerId, amount), + // `expected` names the anticipated failure class; unmatched throws stay defects. + qualifyFailure("CHARGE_FAILED", { expected: GatewayError }), + ).flatMap((charge) => + charge.declined + ? Err(ApplicationFailure.create({ type: "DECLINED", nonRetryable: true })) + : Ok({ transactionId: charge.id }), ); ``` diff --git a/docs/how-to/model-domain-errors.md b/docs/how-to/model-domain-errors.md index 7f5441e3..33f567d2 100644 --- a/docs/how-to/model-domain-errors.md +++ b/docs/how-to/model-domain-errors.md @@ -55,12 +55,16 @@ export const activities = declareActivitiesHandler({ activities: { processOrder: { chargeCard: ({ customerId, amount }, { errors }) => - fromPromise(gateway.charge(customerId, amount), qualifyFailure("CHARGE_FAILED")).flatMap( - (charge) => - charge.declined - ? // Typed on the caller's side; `nonRetryable` comes from the contract. - Err(errors.CardDeclined({ reason: charge.declineCode, retryAfter: 3600 })) - : Ok({ transactionId: charge.id }), + fromPromise( + gateway.charge(customerId, amount), + // `expected` is required: name the anticipated failure class (or a + // predicate). Anything else rides the defect channel. + qualifyFailure("CHARGE_FAILED", { expected: GatewayError }), + ).flatMap((charge) => + charge.declined + ? // Typed on the caller's side; `nonRetryable` comes from the contract. + Err(errors.CardDeclined({ reason: charge.declineCode, retryAfter: 3600 })) + : Ok({ transactionId: charge.id }), ), }, }, @@ -129,6 +133,7 @@ Declaring errors on an activity **changes its workflow-side call signature.** So an errors-declaring activity is awaited as a result, not a plain value: ```typescript +import { CONTRACT_ERROR_TAG } from "@temporal-contract/contract"; import { P } from "unthrown"; implementation: async (context, order) => { @@ -141,13 +146,18 @@ implementation: async (context, order) => { ok: (payment) => ({ status: "completed" as const, transactionId: payment.transactionId }), errCases: (matcher) => matcher - .with(P.tag("@temporal-contract/ContractError"), (error) => { - // error.errorName narrows; error.data is typed from the schema - if (error.errorName === "CardDeclined") { - return { status: "failed" as const, reason: error.data.reason }; - } - return { status: "failed" as const, reason: error.errorName }; - }) + // Object-pattern: every ContractError shares one `_tag`, so a specific + // declared error is discriminated on `errorName`. `error.data` then + // narrows to that error's schema. + .with({ errorName: "CardDeclined" }, (error) => ({ + status: "failed" as const, + reason: error.data.reason, + })) + // Any other declared error on this activity (here: GatewayUnavailable). + .with(P.tag(CONTRACT_ERROR_TAG), (error) => ({ + status: "failed" as const, + reason: error.errorName, + })) .with( P.tag("@temporal-contract/ActivityError"), P.tag("@temporal-contract/ActivityCancelledError"), @@ -179,6 +189,7 @@ A workflow whose declared error caused the failure surfaces it as a `WorkflowFailedError`: ```typescript +import { CONTRACT_ERROR_TAG } from "@temporal-contract/contract"; import { P } from "unthrown"; const result = await client.executeWorkflow("processOrder", { @@ -190,7 +201,7 @@ result.match({ ok: (output) => console.log("done:", output), errCases: (matcher) => matcher - .with(P.tag("@temporal-contract/ContractError"), (error) => { + .with(P.tag(CONTRACT_ERROR_TAG), (error) => { switch (error.errorName) { case "EmptyOrder": return console.error("no items on order", error.data.orderId); @@ -212,10 +223,15 @@ result.match({ Two levels of discrimination are at work: -- the unthrown `_tag` (`"@temporal-contract/ContractError"`) separates a - contract error from the client's other error classes; +- the unthrown `_tag` — the exported `CONTRACT_ERROR_TAG` constant, whose value + is `"@temporal-contract/ContractError"` — separates a contract error from the + client's other error classes. Prefer the constant to a hand-typed string: it + is greppable and immune to typos. It is exported from the package root and + from `@temporal-contract/contract/errors`; - `errorName` then narrows to the specific declared error, with `data` typed - accordingly. + accordingly. Because every declared error shares that one `_tag`, matching a + single error by tag alone is impossible — discriminate on `errorName`, + either with a `switch` or an object pattern (`.with({ errorName: "EmptyOrder" }, ...)`). ## What travels on the wire @@ -228,16 +244,32 @@ ApplicationFailure { type: "CardDeclined", // the declared key message: "The card was declined", nonRetryable: true, // from the contract - details: [{ reason: "expired" }], + details: [ + { reason: "expired" }, // details[0] — the original payload + { $tc: 1 }, // details[1] — the wire marker + ], } │ ▼ ContractError { errorName: "CardDeclined", data: { reason: "expired" } } ``` +`details[1]` carries a small envelope marker (`{ $tc: 1 }`) that tags the +failure as a rehydratable contract error. It governs rehydration: + +- for an error **with** a `data` schema, schema validation of `details[0]` is + the gate — the marker is corroborating but not required; +- for a **data-less** error, the marker is **required**. Without it, any + unrelated `ApplicationFailure` whose `type` happens to equal a declared + data-less error name would be mis-surfaced as the typed domain error. + The payload is validated when it is raised _and_ re-validated when it is rehydrated, so a schema change that breaks compatibility surfaces as a clear -validation error rather than a silently wrong object. +validation error rather than a silently wrong object. When a failure does not +correspond to a declared error — unknown `type`, a payload that no longer +validates, or a data-less name without the marker — rehydration degrades to +the generic failure classification instead of producing a wrong typed error, +and reports the miss through the `onRehydrationMiss` diagnostic hook. ## Failure modes @@ -245,10 +277,14 @@ validation error rather than a silently wrong object. throws `ContractErrorDataValidationError`: ``` -Error "CardExpired" is not declared on activity "chargeCard". +Error "CardExpired" is not declared on activity "processOrder.chargeCard". Declared errors: CardDeclined, GatewayUnavailable. ``` +The activity is named by its flat label — a workflow-scoped activity appears as +`workflowName.activityName` (here `processOrder.chargeCard`), a global activity +as its bare name. + **Payload fails its schema** — same terminal error, with the schema issues attached. Both are deterministic contract-misuse bugs, so they fail loudly rather than letting a malformed failure cross the wire. diff --git a/docs/how-to/run-child-workflows.md b/docs/how-to/run-child-workflows.md index 2680642d..1c5f4144 100644 --- a/docs/how-to/run-child-workflows.md +++ b/docs/how-to/run-child-workflows.md @@ -113,8 +113,10 @@ implementation: async (context, order) => { }; ``` -The payload is validated before sending and parsed by the child on receive; -for a payload-less signal (`defineSignal()`), the argument is omittable. +The payload is validated before sending and parsed by the child on receive. +Unlike a _client_ handle — where a payload-less signal's argument is omittable +— a child handle's signal sender always takes an explicit argument, so pass +`undefined` for a payload-less signal (`applyDiscount(undefined)`). ## Run children in parallel @@ -181,7 +183,7 @@ await context.executeChildWorkflow(orderContract, "collectPayment", { workflowId: `payment-${order.orderId}`, args: { customerId: order.customerId, amount: order.total }, - // What happens to the child if the parent closes. + // What happens to the child if the parent closes (Temporal's default is TERMINATE). parentClosePolicy: "REQUEST_CANCEL", // or "TERMINATE" | "ABANDON" workflowExecutionTimeout: "1 hour", @@ -193,9 +195,10 @@ await context.executeChildWorkflow(orderContract, "collectPayment", { }); ``` -`parentClosePolicy` is the one to think about: the default cancels children -when the parent closes. Use `ABANDON` for fire-and-forget work that should -outlive its parent. +`parentClosePolicy` is the one to think about: the default (`TERMINATE`) kills +children when the parent closes. Choose `REQUEST_CANCEL` if a child needs to +compensate first, or `ABANDON` for fire-and-forget work that should outlive its +parent. ## Choose child workflows or activities diff --git a/docs/how-to/schedule-workflows.md b/docs/how-to/schedule-workflows.md index 2ee52d18..a8121f52 100644 --- a/docs/how-to/schedule-workflows.md +++ b/docs/how-to/schedule-workflows.md @@ -10,6 +10,10 @@ created: const ledger = typedClient.for(ledgerContract); ``` +`ledger.schedule` is a `TypedScheduleClient`, reached only through the +contract-bound client — the class is exported for type annotations but is not +constructible directly. + ## Create a schedule ```typescript @@ -186,30 +190,31 @@ attributes](/how-to/index-workflows-with-search-attributes). The handle mirrors Temporal's lifecycle methods, wrapped in `AsyncResult`: ```typescript -const created = await ledger.schedule.create("reconcileLedger", {/* ... */}); -if (created.isErr()) throw created.error; - -const schedule = created.value; +// `create`'s Err channel is modeled (not `never`), so `.getOrThrow()` throws +// the modeled error itself to reach the handle. `.get()` would not even +// compile here — it is defined only when the error type is `never`. +const schedule = (await ledger.schedule.create("reconcileLedger", {/* ... */})).getOrThrow(); -// `.get()` rethrows a defect's original cause. Without it, `await` merely -// collapses the AsyncResult to a Result and the failure is discarded. -await schedule.pause("incident #4821").get(); -await schedule.unpause("incident resolved").get(); +// Handle methods carry `E = ScheduleNotFoundError`, so `.getOrThrow()` throws +// that modeled error (and rethrows a defect's cause). Without it, `await` +// merely collapses the AsyncResult to a Result and the failure is discarded. +await schedule.pause("incident #4821").getOrThrow(); +await schedule.unpause("incident resolved").getOrThrow(); // Run it right now, without waiting for the next tick. -await schedule.trigger().get(); +await schedule.trigger().getOrThrow(); // Inspect current state. const described = await schedule.describe(); -if (described.isErr()) { +if (described.isOk()) { + console.log(described.value.state.paused, described.value.info.nextActionTimes); +} else if (described.isErr()) { console.error("schedule is gone:", described.error.scheduleId); -} else if (described.isDefect()) { - console.error("describe failed:", described.cause); } else { - console.log(described.value.state.paused, described.value.info.nextActionTimes); + console.error("describe failed:", described.cause); } -await schedule.delete().get(); +await schedule.delete().getOrThrow(); ``` Every method returns `AsyncResult` — the one @@ -220,16 +225,21 @@ fault on the defect channel. ::: warning `await` alone does not surface the failure `AsyncResult` is a success-only thenable: awaiting it yields a `Result`, and the underlying promise never rejects. `await schedule.pause(...)` therefore discards -a failure silently. Chain `.get()` (which rethrows an `Err` or a defect's -original cause) or branch on `isErr()` / `isDefect()` — the same applies to -every `AsyncResult` in this library. +a failure silently. Because the error channel here is modeled +(`ScheduleNotFoundError`), reach for `.getOrThrow()` (it throws the modeled error +and rethrows a defect's cause) or branch on `isOk()` / `isErr()` / `isDefect()`. +Plain `.get()` is _not_ an option — it compiles only when `E = never`. The same +applies to every `AsyncResult` in this library. ::: ## Update or backfill a schedule -`update` is fetch-modify-persist: Temporal hands your function the current -description and persists what it returns. It may be invoked more than once on -conflict — keep it pure: +`update` is fetch-modify-persist: the handle fetches the current description, +hands it to your function, and persists what it returns. The wrapper does the +describe itself so it can validate the result before persisting, so your +function runs **exactly once** per call — a server-side conflict retries the +already-computed options rather than re-running your function against a fresh +description: ```typescript await schedule @@ -237,12 +247,18 @@ await schedule ...previous, spec: { cronExpressions: ["0 3 * * *"] }, // move to 03:00 })) - .get(); + .getOrThrow(); ``` -The action's `workflowType` / `taskQueue` / `args` are **not** re-validated -against the contract here — for contract-level changes, prefer delete + -`create`. +When the returned action's `workflowType` names a workflow **declared on the +bound contract**, the action's `args` are validated against that workflow's +input schema before anything is persisted — a mismatch surfaces as +`WorkflowValidationError` on the `err` channel and leaves the schedule +untouched. `update` therefore returns +`AsyncResult`, which is +why `.getOrThrow()` (not `.get()`) is the extractor here. An action whose +`workflowType` is _not_ on the contract is persisted as-is (there is no schema +to check it against); for contract-level changes prefer delete + `create`. `backfill` runs the schedule's action over historical time ranges, as if the schedule had been active then: @@ -254,7 +270,7 @@ await schedule end: new Date("2026-07-08T00:00:00Z"), overlap: "ALLOW_ALL", }) - .get(); + .getOrThrow(); ``` ## Reach an existing schedule @@ -265,7 +281,7 @@ synchronous and does no server round-trip — a wrong id surfaces as ```typescript const handle = ledger.schedule.getHandle("nightly-reconcile"); -await handle.pause("manual intervention").get(); +await handle.pause("manual intervention").getOrThrow(); ``` ## List schedules diff --git a/docs/how-to/test-workflows.md b/docs/how-to/test-workflows.md index f0e9ab66..811606fb 100644 --- a/docs/how-to/test-workflows.md +++ b/docs/how-to/test-workflows.md @@ -11,8 +11,22 @@ cheapest one that can catch the bug you care about. ## Tier 1 — contract and activity handlers -Activities are ordinary functions returning `AsyncResult`. Test them directly, -no Temporal involved. +An activity implementation is an ordinary function returning `AsyncResult`, but +do **not** assert on the map that `declareActivitiesHandler` returns — those are +the _wrapped_ handlers a worker registers, and they **throw** `ApplicationFailure` +across the boundary rather than returning a `Result`. `@temporal-contract/testing/activity` +gives you two entry points instead, and neither needs a worker, a server, or +Docker: + +- **`runActivity`** runs the **raw** implementation and hands back its + `AsyncResult` untouched — no input parse, no output validation, no + contract-error wire conversion. This is the pure-logic tier. +- **`runActivityHandler`** routes the same implementation through the **real** + `declareActivitiesHandler` wrapping — input parse → implementation → output + validation → contract-error → `ApplicationFailure` wire conversion → + rehydration back to a typed `Result`. Use it when a test must be + boundary-faithful: passing here means the same call succeeds through a real + worker. Install the unthrown matchers and register them: @@ -34,23 +48,33 @@ export default defineConfig({ }); ``` -Then assert on the result channels: +Then run the implementation with `runActivity` and assert on the result +channels. The subject is the activity's contract definition; the implementation +and input travel in the options bag: ```typescript +import { runActivity } from "@temporal-contract/testing/activity"; import { describe, expect, it } from "vitest"; -import { activities } from "./activities.js"; +import { chargeCard } from "./activities.js"; +import { orderContract } from "./contract.js"; describe("chargeCard", () => { it("returns a transaction id", async () => { - const result = await activities.chargeCard({ customerId: "CUST-1", amount: 42 }); + const result = await runActivity(orderContract.workflows.processOrder.activities.chargeCard, { + implementation: chargeCard, // (args, { errors }) => AsyncResult<...> + input: { customerId: "CUST-1", amount: 42 }, + }); expect(result).toBeOk(); expect(result).toBeOkWith({ transactionId: expect.any(String) }); }); it("surfaces a declined card as a contract error", async () => { - const result = await activities.chargeCard({ customerId: "DECLINE", amount: 42 }); + const result = await runActivity(orderContract.workflows.processOrder.activities.chargeCard, { + implementation: chargeCard, + input: { customerId: "DECLINE", amount: 42 }, + }); expect(result).toBeErrTagged("@temporal-contract/ContractError"); }); @@ -58,7 +82,39 @@ describe("chargeCard", () => { ``` `@unthrown/vitest` provides `toBeOk`, `toBeOkWith`, `toBeErr`, `toBeErrTagged`, -and `toBeDefect`. +and `toBeDefect` — prefer them over `expect(result.isOk()).toBe(true)`. + +### Test the full boundary with `runActivityHandler` + +`runActivity` runs the raw implementation, so an `Err` whose data violates the +declared schema, or an output the schema rejects, still looks green. When the +test must fail exactly where production fails, use `runActivityHandler`: it wraps +the implementation with the real `declareActivitiesHandler`, parses the input as +a caller would send it (the pre-transform wire shape), validates the output, and +round-trips a typed `Err` through its `ApplicationFailure` wire form and back: + +```typescript +import { runActivityHandler } from "@temporal-contract/testing/activity"; +import { expect, it } from "vitest"; + +import { chargeCard } from "./activities.js"; +import { orderContract } from "./contract.js"; + +it("rehydrates a declared error across the wire", async () => { + const result = await runActivityHandler( + orderContract.workflows.processOrder.activities.chargeCard, + { + implementation: chargeCard, + input: { customerId: "DECLINE", amount: 42 }, + }, + ); + + // The declared error crossed the wire and rehydrated as a typed error; + // an undeclared error name or invalid error data would surface the + // production terminal `ContractErrorDataValidationError` instead. + expect(result).toBeErrTagged("@temporal-contract/ContractError"); +}); +``` Contracts are worth testing too — `defineContract` throws on a malformed one, so a test that merely imports it is a real check: @@ -92,7 +148,7 @@ export const makeActivities = (deps: { gateway: PaymentGateway }) => chargeCard: ({ customerId, amount }, { context }) => fromPromise( context.gateway.charge(customerId, amount), - qualifyFailure("CHARGE_FAILED"), + qualifyFailure("CHARGE_FAILED", { expected: GatewayError }), ).map((c) => ({ transactionId: c.id })), }, }, @@ -105,34 +161,12 @@ const activities = makeActivities({ }); ``` -### Run one activity with `Context.current()` working +### Observe heartbeats and cancellation -Calling an implementation directly breaks the moment it touches -`Context.current()` — heartbeats, cancellation, activity info. `runActivity` -from `@temporal-contract/testing/activity` executes a single implementation -inside `@temporalio/testing`'s `MockActivityEnvironment`, building the typed -`errors` constructors from the contract definition exactly as the worker -would — still no worker, server, or Docker. It returns the implementation's -`AsyncResult` untouched (an unanticipated throw lands on the defect channel), -and the entry is vitest-free, so it works from any test runner: - -```typescript -import { runActivity } from "@temporal-contract/testing/activity"; -import { expect, it } from "vitest"; - -import { chargeCard } from "./activities.js"; -import { orderContract } from "./contract.js"; - -it("charges the card", async () => { - const result = await runActivity( - orderContract.workflows.processOrder.activities.chargeCard, - chargeCard, // (args, { errors }) => AsyncResult<...> - { customerId: "CUST-1", amount: 42 }, - ); - - expect(result).toBeOk(); -}); -``` +`runActivity` executes the implementation inside `@temporalio/testing`'s +`MockActivityEnvironment`, so `Context.current()` works — heartbeats, +cancellation, and activity info are all live. Both entry points are vitest-free +(they only need `@temporalio/testing`), so they work from any test runner. Pass your own environment via the `env` option to observe heartbeats or trigger cancellation: @@ -150,12 +184,11 @@ it("heartbeats while downloading", async () => { const heartbeats: unknown[] = []; env.on("heartbeat", (details) => heartbeats.push(details)); - const result = await runActivity( - orderContract.workflows.processOrder.activities.downloadReport, - downloadReport, - { reportId: "R-1" }, - { env }, - ); + const result = await runActivity(orderContract.workflows.processOrder.activities.downloadReport, { + implementation: downloadReport, + input: { reportId: "R-1" }, + env, + }); expect(result).toBeOk(); expect(heartbeats.length).toBeGreaterThan(0); @@ -288,6 +321,15 @@ For visibility queries, search attributes, schedules, and retention, you need a real cluster. `@temporal-contract/testing/global-setup` starts Temporal and PostgreSQL in testcontainers for the suite's lifetime. +`testcontainers` is an **optional** peer dependency, pulled in only by this +Dockerized tier (`createGlobalSetup` / `createContractTest`) — the +`/time-skipping` and `/activity` entries do not need it. Install it alongside +`@temporal-contract/testing` for tier 3: + +```bash +pnpm add -D @temporal-contract/testing testcontainers +``` + ```typescript // vitest.config.ts import { defineConfig } from "vitest/config"; @@ -333,7 +375,8 @@ import { describe, expect } from "vitest"; import { activities } from "./activities.js"; import { orderContract } from "./contract.js"; -const it = createContractTest(orderContract, { +const it = createContractTest({ + contract: orderContract, workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"), activities, // omit for a workflow-only worker // workerOptions: forwarded to TypedWorker.create (namespace, interceptors, tuning) diff --git a/docs/how-to/troubleshoot.md b/docs/how-to/troubleshoot.md index fd48af1b..dddf8ebe 100644 --- a/docs/how-to/troubleshoot.md +++ b/docs/how-to/troubleshoot.md @@ -230,19 +230,25 @@ log.info("processing order", { orderId }); ## Result and error handling -### `GetError` thrown unexpectedly - -`.get()` throws a `GetError` when the result is an `Err`. Either narrow first, -or use the extractor that matches your intent: - -| Method | On `Err` | On `Defect` | -| ------------------ | ------------------------ | ------------------ | -| `.get()` | throws `GetError(error)` | rethrows the cause | -| `.getOrThrow()` | throws the error itself | rethrows the cause | -| `.getOr(fallback)` | returns the fallback | rethrows the cause | -| `.getOrNull()` | `null` | rethrows the cause | - -Every extractor rethrows a defect — that is intentional. +### `.get()` does not compile on a result that can fail + +`.get()` is defined **only** when the error channel is empty (`E = never`) — it +is for results that cannot fail, like the `AsyncResult<…, never>` that +`TypedWorker.create` and `TypedClient.create` return. On a result with a modeled +error it will not compile at all — `.get()` never accepts an `Err`. (The +`GetError` class still exists, but only as a defensive runtime guard against an +unsound cast past the type gate; well-typed code never reaches it.) Reach for +the extractor that matches your intent: + +| Method | Compiles when | On `Err` | On `Defect` | +| ------------------ | ---------------- | ----------------------- | ------------------ | +| `.get()` | `E = never` | — (cannot fail) | rethrows the cause | +| `.getOrThrow()` | `E` is non-empty | throws the error itself | rethrows the cause | +| `.getOr(fallback)` | any | returns the fallback | rethrows the cause | +| `.getOrNull()` | any | `null` | rethrows the cause | + +Every extractor rethrows a defect — that is intentional. To branch on a modeled +error instead of throwing, narrow with `isErr()` first. ### A failure I expected on `err` arrives as a `defect` @@ -283,7 +289,8 @@ retry: { ``` ```typescript -qualifyFailure("CARD_DECLINED", { nonRetryable: true }); +// `expected` is required — name the anticipated class (or a predicate). +qualifyFailure("CARD_DECLINED", { expected: CardDeclinedError, nonRetryable: true }); ``` ### An activity is not retried at all @@ -291,9 +298,12 @@ qualifyFailure("CARD_DECLINED", { nonRetryable: true }); Something upstream set `nonRetryable: true`, or the type is listed in `retry.nonRetryableErrorTypes`. -Watch for `qualifyFailure` masking an inner failure: it always wraps, so an inner -`ApplicationFailure`'s own `nonRetryable: true` is overridden by the wrapper's. -Pass `{ nonRetryable: true }` explicitly if it must stay permanent. +Watch for `qualifyFailure` re-typing a matched failure: a cause matching +`expected` is always wrapped into a fresh `ApplicationFailure` with the declared +`type`. When you omit `nonRetryable`, the wrapper _inherits_ a matched inner +`ApplicationFailure`'s own `nonRetryable: true` (a permanent inner failure no +longer silently becomes retryable); pass `{ nonRetryable: false }` to force it +retryable, or `{ nonRetryable: true }` to make it permanent regardless. ### An activity times out but the work finished diff --git a/docs/how-to/tune-activity-options.md b/docs/how-to/tune-activity-options.md index f066f11c..cb91adaf 100644 --- a/docs/how-to/tune-activity-options.md +++ b/docs/how-to/tune-activity-options.md @@ -8,7 +8,7 @@ three places to set them, merged from least to most specific. ``` declareWorkflow({ activityOptions }) ← workflow-wide default ↓ overridden by -defineActivity({ defaultOptions }) ← the activity's own contract default +defineActivity({ activityOptions }) ← the activity's own contract default ↓ overridden by declareWorkflow({ activityOptionsByName }) ← explicit per-activity override ``` @@ -46,7 +46,7 @@ particular caller, go on the contract: const sendNotification = defineActivity({ input: NotificationSchema, output: z.void(), - defaultOptions: { + activityOptions: { startToCloseTimeout: "30 seconds", retry: { maximumAttempts: 5 }, }, @@ -56,10 +56,12 @@ const sendNotification = defineActivity({ Now every workflow that calls `sendNotification` gets those settings without repeating them. -::: tip `defaultOptions` is strict +::: tip `activityOptions` is strict Unknown keys are rejected at `defineContract` time. A typo like `startToCloseTimeOut` fails immediately instead of being silently dropped when -the worker merges options. +the worker merges options. Duration strings are validated there too, against +the `ms` grammar (`"30 seconds"`, `"5m"`, `"1.5h"`) — so `"5 minutos"` fails at +definition instead of surfacing later as an opaque worker error. ::: ## Override per activity @@ -148,8 +150,9 @@ of a [declared contract error](/how-to/model-domain-errors). Two ways to make a failure permanent: ```typescript -// Per call site — this instance is permanent. -qualifyFailure("CARD_DECLINED", { nonRetryable: true }); +// Per call site — this instance is permanent. `expected` is always required; +// `nonRetryable: true` opts this wrapper out of the retry policy. +qualifyFailure("CARD_DECLINED", { expected: GatewayError, nonRetryable: true }); // From the contract — every instance of this declared error is permanent. errors: { @@ -163,7 +166,7 @@ definition, where every caller can see them. ## Omitting `activityOptions` `activityOptions` is optional _if_ every reachable activity is covered by a -contract-level `defaultOptions` or an `activityOptionsByName` entry. If some +contract-level `activityOptions` or an `activityOptionsByName` entry. If some activity has neither, `declareWorkflow` throws at declaration time and names the uncovered activities — a startup failure rather than a runtime one. diff --git a/docs/how-to/upgrade-to-v8.md b/docs/how-to/upgrade-to-v8.md index 28e6e944..2cec4a68 100644 --- a/docs/how-to/upgrade-to-v8.md +++ b/docs/how-to/upgrade-to-v8.md @@ -46,8 +46,8 @@ pnpm add unthrown@^5.0.0 ``` If an intermediate beta had you install `ts-pattern` as a peer, remove it — -unthrown's matcher is built in again as of beta.6, and unthrown has zero runtime -dependencies: +unthrown's matcher is built in again as of unthrown `5.0.0-beta.6`, and unthrown +has zero runtime dependencies: ```bash pnpm remove ts-pattern @@ -160,7 +160,10 @@ if (created.isDefect()) { console.error("client setup failed:", created.cause); // a TechnicalError process.exit(1); } -const typedClient = created.value; +// The error channel is `never`, so `.get()` unwraps directly. (Reading +// `created.value` after only an `isDefect()` guard does not compile — the +// non-defect branch is still `Ok | Err`, and `Err` has no `.value`.) +const typedClient = created.get(); ``` Or, more concisely — `.get()` rethrows a defect's original cause: @@ -217,17 +220,20 @@ modeled; everything else (transport faults, unrecognized rejections) rides the defect channel: ```typescript -// 8.0 — `.get()` rethrows an Err or a defect's original cause. -await schedule.pause("maintenance").get(); +// 8.0 — the modeled error is `ScheduleNotFoundError`, so use `.getOrThrow()` +// (it throws the modeled `Err`, or rethrows a defect's cause). `.get()` would +// NOT compile here — it is only valid when the error channel is `never`. +await schedule.pause("maintenance").getOrThrow(); ``` See the [schedules section](#_11-schedules-typed-errors-and-a-fuller-surface) below for the full 8.0 schedule surface. -::: warning A bare `await` swallows the defect +::: warning A bare `await` swallows the failure `AsyncResult` is a success-only thenable: awaiting it collapses it to a `Result`, and the underlying promise never rejects. `await schedule.pause(...)` -on its own discards the failure. Chain `.get()`, or branch on `isDefect()`. +on its own discards the outcome — the modeled `ScheduleNotFoundError` (an `Err`, +not a defect) included. Chain `.getOrThrow()`, or branch on `isErr()`. ::: ## 5. Interceptors and middleware @@ -313,10 +319,19 @@ of an `AsyncResult` — drop the `await`. It also accepts an options object: // 7.x const bound = await typedClient.getHandle("processOrder", "order-123"); -// 8.0 +// 8.0 — sync Result now; `.getOrThrow()` throws the modeled +// WorkflowNotInContractError (or rethrows a defect's cause). +const handle = orders.getHandle("processOrder", "order-123").getOrThrow(); + +// Or branch explicitly — but reading `.value` after only an `isErr()` guard +// does NOT compile (the non-Err branch is still `Ok | Defect`, and `Defect` +// has no `.value`); narrow with `isOk()`: const bound = orders.getHandle("processOrder", "order-123"); -if (bound.isErr()) throw bound.error; // WorkflowNotInContractError -const handle = bound.value; +if (bound.isOk()) { + useHandle(bound.value); +} else if (bound.isErr()) { + throw bound.error; // WorkflowNotInContractError +} // 8.0 — bind a specific run const pinned = orders.getHandle("processOrder", "order-123", { runId }); @@ -402,16 +417,19 @@ schema issues). The execution continues untouched. ## 9. Worker: renames and stricter declaration checks -### `qualify` → `qualifyFailure` +### `qualify` → `qualifyFailure`, now with required triage -A mechanical rename, no alias kept: +Renamed (no alias kept) **and** given a required `expected` discriminator — see +[§12](#_12-second-breaking-pass-8-0-audit-remediation) for the full rationale. +`qualifyFailure` no longer blanket-wraps every rejection; you name which causes +are modeled business failures, and everything else rides the defect channel: ```diff - import { declareActivitiesHandler, qualify } from "@temporal-contract/worker/activity"; + import { declareActivitiesHandler, qualifyFailure } from "@temporal-contract/worker/activity"; - fromPromise(gateway.charge(customerId, amount), qualify("CHARGE_FAILED")) -+ fromPromise(gateway.charge(customerId, amount), qualifyFailure("CHARGE_FAILED")) ++ fromPromise(gateway.charge(customerId, amount), qualifyFailure("CHARGE_FAILED", { expected: GatewayError })) ``` ### `createWorker` → `TypedWorker.create` @@ -612,6 +630,120 @@ New on the surface: - **`schedule.list(options?)`** — an `AsyncIterable` passthrough of Temporal's `ScheduleClient.list`. +## 12. Second breaking pass (8.0 audit remediation) + +A second review before 8.0 stabilised hardened the boundaries, the `unthrown` +integration, and family consistency. These land in the same major. + +### Mechanical renames + +| 7.x / earlier 8.0 beta | 8.0 | +| ------------------------------------------------------- | ----------------------------------------------------------- | +| `SignalNamesOf` / `QueryNamesOf` / `UpdateNamesOf` | `InferSignalNames` / `InferQueryNames` / `InferUpdateNames` | +| `DeclaredErrorsOf` | `InferDeclaredErrors` | +| `defineActivity({ defaultOptions })` | `defineActivity({ activityOptions })` | +| `context.defineSignal` / `defineQuery` / `defineUpdate` | `context.handleSignal` / `handleQuery` / `handleUpdate` | +| `@temporal-contract/contract/result-async` | removed — internals live at `.../internal` (private) | + +The `Infer*` prefix aligns with amqp-contract; `handle*` frees `define*` for +contract authoring alone (and stops colliding with `@temporalio/workflow`'s +own `defineSignal`). + +### `qualifyFailure` triages instead of blanket-wrapping + +`expected` is now **required** — an error class, an array of classes, a +predicate, or the explicit literal `"any"`. Causes that match are wrapped into +the modeled `ApplicationFailure`; everything else (a `TypeError` from a bug, +say) rides the **defect** channel instead of being mislabelled a business +error. A matched inner `ApplicationFailure` with `nonRetryable: true` is now +inherited by default. + +```diff +- qualifyFailure("CHARGE_FAILED") ++ qualifyFailure("CHARGE_FAILED", { expected: GatewayError }) ++ // deliberately keep the old catch-all behaviour, made explicit: ++ qualifyFailure("CHARGE_FAILED", { expected: "any" }) +``` + +### Client: cancellation, termination, timeout, update, and query are modeled + +`executeWorkflow` / `handle.result()` gain `WorkflowCancelledError`, +`WorkflowTerminatedError`, `WorkflowTimeoutError` (each keeping the original +`TemporalFailure` as `cause`) instead of burying the outcome in +`WorkflowFailedError.cause`. Update and query failures — `UpdateFailedError`, +`UpdateRejectedError`, `QueryFailedError` — are modeled `Err`s where they +previously leaked as defects. Widen (or, more likely, let the exhaustive +matcher force you to widen) your `result()` / update / query match arms. + +Collapse the recurring multi-tag arms with the new bundles and `tagPatterns`: + +```typescript +import { tagPatterns, WORKFLOW_RESULT_ERROR_TAGS } from "@temporal-contract/client"; + +result.match({ + ok: (value) => value, + errCases: (matcher) => + matcher.with(...tagPatterns(WORKFLOW_RESULT_ERROR_TAGS), (error) => report(error)), + defect: (cause) => report(cause), +}); +``` + +### Cancellation can be swallowed by declared-error activities + +When an activity declares an `errors` map, cancelling it surfaces as +`Err(ActivityCancelledError)` — a value a generic "map every `Err` to a +fallback" handler will absorb, completing the workflow instead of cancelling +it. Re-raise with the new `rethrowCancellation(error)` from +`@temporal-contract/worker/workflow`. See +[Handle cancellation](/how-to/handle-cancellation). + +### Worker: safer declarations + +- A shared activity referenced from several scopes must be the **same function + reference** or hoisted to the global `activities` map — two different + implementations for one flattened name now throw at declaration (they used to + silently clobber). +- `TypedWorker.create` **verifies workflow registration** by default: a + contract workflow missing from the bundle, or an export whose name differs + from its `workflowName`, fails creation. Opt out with + `verifyWorkflowRegistration: false`. +- Async query/update schemas are rejected at **bind time** (`ContractMisuseError`), + not on the first request. +- `ChildWorkflowError` carries a structured `workflowName`; the input/output + `ValidationError` subclasses carry a `direction: "input" | "output"`. + +### Client construction and interceptors + +- `ContractClient` and `TypedScheduleClient` are no longer constructible + directly — obtain them via `typedClient.for(...)` and `client.schedule`. + `ContractClient` exposes readonly `contract` and `taskQueue` getters, and + `handle.raw` reaches the underlying `WorkflowHandle`. +- Client interceptor patches may set **only** `input` / `signalInput`; identity + fields such as `workflowName` are no longer patchable. + +### Testing: option bags and a boundary-faithful tier + +```diff +- createContractTest(orderContract, { workflowsPath, activities }) ++ createContractTest({ contract: orderContract, workflowsPath, activities }) + +- runActivity(definition, implementation, input) ++ runActivity(definition, { implementation, input }) +``` + +New `runActivityHandler(definition, { ... })` runs the implementation through +the real `declareActivitiesHandler` wrapping (input parse, output validation, +contract-error wire round-trip) so a test fails exactly where production does. +`testcontainers` is now an **optional** peer, needed only for +`createContractTest`. + +### Packaging + +- Every package is **ESM-only** (client and worker dropped their CJS output and + legacy `main`/`module`/`types` fields). +- `@temporalio/*` peer ranges tightened to `^1.16.0` (the real floor for the + Schedule API and the search-attribute imports). + ## Checklist - [ ] All four `@temporal-contract/*` packages on the same 8.0 version @@ -639,6 +771,22 @@ New on the surface: schedule-handle matchers handle `ScheduleNotFoundError` - [ ] No schema relies on its transform running on the send side (each boundary now parses once, on receive) +- [ ] `SignalNamesOf` / `QueryNamesOf` / `UpdateNamesOf` / `DeclaredErrorsOf` + → the `Infer*` prefix; `defineActivity` `defaultOptions` → `activityOptions` +- [ ] `context.defineSignal` / `defineQuery` / `defineUpdate` → + `handleSignal` / `handleQuery` / `handleUpdate` +- [ ] Every `qualifyFailure(...)` passes `{ expected }` (or `{ expected: "any" }` + to keep the old catch-all deliberately) +- [ ] `result()` / update / query matchers handle the new modeled errors + (`WorkflowCancelledError` / `Terminated` / `Timeout`, `UpdateFailedError`, + `UpdateRejectedError`, `QueryFailedError`) +- [ ] Cancellation isn't swallowed by a declared-error activity — + `rethrowCancellation` where a generic `Err` fallback would complete the run +- [ ] Shared activities implemented once (same reference or hoisted global) +- [ ] `createContractTest({ contract, ... })` and `runActivity(def, { ... })` + use the option bag; `testcontainers` installed only where `createContractTest` runs +- [ ] Imports of `@temporal-contract/contract/result-async` removed +- [ ] `@temporalio/*` resolve to `^1.16.0`; no CJS `require` of these packages - [ ] `pnpm typecheck` clean The exhaustive matcher does most of the work: once it compiles, the migration is diff --git a/docs/how-to/use-signals-queries-and-updates.md b/docs/how-to/use-signals-queries-and-updates.md index c52ba871..c2e897a2 100644 --- a/docs/how-to/use-signals-queries-and-updates.md +++ b/docs/how-to/use-signals-queries-and-updates.md @@ -15,6 +15,8 @@ and confirm. ```typescript import { + defineActivity, + defineContract, defineQuery, defineSignal, defineUpdate, @@ -22,6 +24,16 @@ import { } from "@temporal-contract/contract"; import { z } from "zod"; +const listSkus = defineActivity({ + input: z.object({ catalogId: z.string() }), + output: z.array(z.string()), +}); + +const importSku = defineActivity({ + input: z.object({ sku: z.string() }), + output: z.void(), +}); + const getProgress = defineQuery({ // no `input` — an argument-less query output: z.object({ completed: z.number(), total: z.number() }), @@ -36,13 +48,19 @@ const addItems = defineUpdate({ output: z.object({ total: z.number() }), }); -export const importCatalog = defineWorkflow({ +const importCatalog = defineWorkflow({ input: z.object({ catalogId: z.string() }), output: z.object({ imported: z.number() }), + activities: { listSkus, importSku }, queries: { getProgress }, signals: { cancelRequested }, updates: { addItems }, }); + +export const catalogContract = defineContract({ + taskQueue: "catalog", + workflows: { importCatalog }, +}); ``` `input` is optional on all three. Omit it — `defineSignal()`, @@ -50,12 +68,18 @@ export const importCatalog = defineWorkflow({ receives `undefined` while the client-side payload argument becomes omittable. No `z.void()` ceremony. -::: warning Query schemas must validate synchronously -Temporal requires query handlers to complete synchronously, so a query's input -and output schemas must too. Plain object schemas are fine; async refinements -(`z.string().refine(async ...)`) are not. Standard Schema does not expose the -sync/async distinction at the type level, so this is enforced at runtime — the -worker throws if a schema returns a `Promise`. +::: warning Query and update-input schemas must validate synchronously +Temporal runs query handlers and the update **validator** slot synchronously, +so a query's `input` and `output` schemas — and an update's `input` schema — +must validate synchronously too. Plain object schemas are fine; async +refinements (`z.string().refine(async ...)`) are not. An update's `output` +schema may be async, because the update handler itself runs asynchronously. + +Standard Schema does not expose the sync/async distinction at the type level, +so this is checked at **bind time** — when the workflow first binds its +handlers, not on the first request. A schema whose `validate()` returns a +`Promise` in one of those slots fails there with a `ContractMisuseError` (a +non-retryable `ApplicationFailure`). ::: ## Handle them in the workflow @@ -77,18 +101,18 @@ export const importCatalog = declareWorkflow({ let cancelReason: string | undefined; // Query: synchronous, reads only. - context.defineQuery("getProgress", () => ({ + context.handleQuery("getProgress", () => ({ completed, total: completed + pending.length, })); // Signal: fire-and-forget. - context.defineSignal("cancelRequested", (signalArgs) => { + context.handleSignal("cancelRequested", (signalArgs) => { cancelReason = signalArgs.reason; }); // Update: async, returns a value to the caller. - context.defineUpdate("addItems", async (updateArgs) => { + context.handleUpdate("addItems", async (updateArgs) => { pending = [...pending, ...updateArgs.skus]; return { total: completed + pending.length }; }); @@ -139,10 +163,10 @@ const started = await catalog.startWorkflow("importCatalog", { args: { catalogId: "cat-1" }, }); -if (started.isErr()) { - throw started.error; -} -const handle = started.value; +// `.getOrThrow()` unwraps the `Ok` and rethrows a modeled error or a defect — +// narrowing with `isErr()` alone would leave the defect variant, which has no +// `.value`. +const handle = started.getOrThrow(); // Query — the payload argument is omittable for an input-less definition const progress = await handle.queries.getProgress(); @@ -217,10 +241,9 @@ whose error channel covers a workflow name that is not on the contract: ```typescript const bound = catalog.getHandle("importCatalog", "import-2024"); -if (bound.isErr()) { - throw bound.error; -} -const handle = bound.value; +// Unwrap the sync `Result`: `.getOrThrow()` raises the modeled error (a +// workflow name not on the contract) or a defect, and hands back the handle. +const handle = bound.getOrThrow(); const progress = await handle.queries.getProgress(); console.log(progress.getOrThrow()); @@ -280,16 +303,16 @@ It must not call activities, sleep, mutate state, or await anything: ```typescript // ✅ -context.defineQuery("getProgress", () => ({ completed, total })); +context.handleQuery("getProgress", () => ({ completed, total })); // ❌ modifies state — corrupts replay -context.defineQuery("getProgress", () => { +context.handleQuery("getProgress", () => { queryCount += 1; return { completed, total }; }); // ❌ not synchronous — will not type-check -context.defineQuery("getProgress", async () => ({ completed, total })); +context.handleQuery("getProgress", async () => ({ completed, total })); ``` ## Next diff --git a/docs/index.md b/docs/index.md index fa462ea4..f2ef1800 100644 --- a/docs/index.md +++ b/docs/index.md @@ -70,6 +70,7 @@ import { declareActivitiesHandler, qualifyFailure } from "@temporal-contract/wor import { fromPromise } from "unthrown"; import { orderContract } from "./contract.js"; +import { gateway, GatewayError } from "./services.js"; export const activities = declareActivitiesHandler({ contract: orderContract, @@ -77,11 +78,14 @@ export const activities = declareActivitiesHandler({ // Workflow-scoped activities nest under their workflow, mirroring the contract. processOrder: { chargeCard: ({ customerId, amount }) => - fromPromise(gateway.charge(customerId, amount), qualifyFailure("CHARGE_FAILED")).map( - (charge) => ({ - transactionId: charge.id, - }), - ), + fromPromise( + gateway.charge(customerId, amount), + // Anticipated failures become a typed ApplicationFailure; anything + // else (a bug) stays a loud defect. + qualifyFailure("CHARGE_FAILED", { expected: GatewayError }), + ).map((charge) => ({ + transactionId: charge.id, + })), }, }, }); @@ -109,9 +113,13 @@ export const processOrder = declareWorkflow({ ``` ```typescript [4. Client] -import { TypedClient } from "@temporal-contract/client"; +import { + tagPatterns, + TypedClient, + WORKFLOW_RESULT_ERROR_TAGS, + WORKFLOW_START_ERROR_TAGS, +} from "@temporal-contract/client"; import { Client, Connection } from "@temporalio/client"; -import { P } from "unthrown"; import { orderContract } from "./contract.js"; @@ -130,11 +138,8 @@ result.match({ ok: (output) => console.log(output.transactionId), // ✅ typed errCases: (matcher) => matcher.with( - P.tag("@temporal-contract/WorkflowNotInContractError"), - P.tag("@temporal-contract/WorkflowValidationError"), - P.tag("@temporal-contract/WorkflowAlreadyStartedError"), - P.tag("@temporal-contract/WorkflowFailedError"), - P.tag("@temporal-contract/WorkflowExecutionNotFoundError"), + ...tagPatterns(WORKFLOW_START_ERROR_TAGS), + ...tagPatterns(WORKFLOW_RESULT_ERROR_TAGS), (error) => console.error("failed:", error.message), ), defect: (cause) => console.error("unexpected:", cause), diff --git a/docs/reference/client-surface.md b/docs/reference/client-surface.md index a79d6c90..25540b77 100644 --- a/docs/reference/client-surface.md +++ b/docs/reference/client-surface.md @@ -13,12 +13,15 @@ hands out **contract-scoped** `ContractClient`s via `for()`. ### `TypedClient.create(options)` ```typescript +// options: CreateClientOptions static create(options: { client: Client; // from @temporalio/client interceptors?: readonly ClientInterceptor[]; // outermost first }): AsyncResult; ``` +The options bag is the exported `CreateClientOptions`. + **No modeled error.** Setup faults — a `Client` older than 1.16 (no Schedule API), a connection that cannot be established — ride the defect channel with a `TechnicalError` cause. `.get()` rethrows the original cause: @@ -58,8 +61,17 @@ validation and the interceptor chain. ## `ContractClient` -Obtained from `TypedClient.for` — its constructor is not public API. Written -as an annotation: `ContractClient`. +Obtained from `TypedClient.for` — **not constructible directly** (its +constructor is not public API). Written as an annotation: +`ContractClient`. + +Two readonly getters expose what it is bound to, for logging and metrics +labels: + +| Getter | Type | +| ----------- | ------------------------ | +| `contract` | `TContract` | +| `taskQueue` | `TContract["taskQueue"]` | ### `executeWorkflow(workflowName, options)` @@ -73,10 +85,18 @@ Starts and waits. | WorkflowValidationError | WorkflowAlreadyStartedError | WorkflowFailedError + | WorkflowCancelledError // the server closed the execution: + | WorkflowTerminatedError // first-class outcome errors, each + | WorkflowTimeoutError // keeping the TemporalFailure as `cause` | WorkflowExecutionNotFoundError > ``` +A cancelled / terminated / timed-out execution surfaces as its own +first-class error rather than being buried in `WorkflowFailedError.cause` — no +`instanceof` digging. Cancellation is a modeled `Err`, so give it its own +matcher arm instead of folding it into a blanket "failed" branch. + ### `startWorkflow(workflowName, options)` Returns a handle as soon as the workflow starts. @@ -171,20 +191,21 @@ For `handle.startUpdate`: ## `TypedWorkflowHandle` -| Member | Type | -| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| `workflowId` | `string` | -| `runId` | `string \| undefined` — the bound run, when known | -| `firstExecutionRunId` | `string \| undefined` — first run of the chain, when known | -| `queries` | `Record AsyncResult>` | -| `signals` | `Record AsyncResult>` | -| `updates` | `Record AsyncResult>` | -| `startUpdate(name, opts?)` | `AsyncResult, UpdateValidationError \| WorkflowExecutionNotFoundError>` | -| `result()` | `AsyncResult` | -| `terminate(reason?)` | `AsyncResult` | -| `cancel()` | `AsyncResult` | -| `describe()` | `AsyncResult` | -| `fetchHistory()` | `AsyncResult` | +| Member | Type | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `workflowId` | `string` | +| `runId` | `string \| undefined` — the bound run, when known | +| `firstExecutionRunId` | `string \| undefined` — first run of the chain, when known | +| `raw` | the underlying `@temporalio/client` `WorkflowHandle` — escape hatch; bypasses validation and interceptors | +| `queries` | `Record AsyncResult>` | +| `signals` | `Record AsyncResult>` | +| `updates` | `Record AsyncResult>` | +| `startUpdate(name, opts?)` | `AsyncResult, UpdateValidationError \| UpdateRejectedError \| UpdateFailedError \| WorkflowExecutionNotFoundError>` | +| `result()` | `AsyncResult` | +| `terminate(reason?)` | `AsyncResult` | +| `cancel()` | `AsyncResult` | +| `describe()` | `AsyncResult` | +| `fetchHistory()` | `AsyncResult` | `queries`, `signals`, and `updates` are generated from the contract — only the declared operations exist, with their schemas' types. Payloads are validated @@ -193,10 +214,19 @@ omittable for input-less definitions. Results (queries, updates, `result()`) are parsed on receive against the contract's output schema. The `updates` map executes and waits (Temporal's `executeUpdate`); -`startUpdate` starts without waiting and returns a handle. +`startUpdate` starts without waiting and returns a handle. An update +distinguishes a worker-side admission rejection (`UpdateRejectedError`) from a +failed admitted handler (`UpdateFailedError`); a query with no registered +handler, or one whose handler threw, surfaces as `QueryFailedError`. All are +modeled `Err`s — before 8.0 they leaked as defects. `result()` surfaces a declared contract error as a `ContractError` instead of a -generic `WorkflowFailedError`. +generic `WorkflowFailedError`, and a cancelled / terminated / timed-out +execution as the first-class `WorkflowCancelledError` / `WorkflowTerminatedError` +/ `WorkflowTimeoutError` (each keeps the original `TemporalFailure` as `cause`). +The failure-to-`ContractError` rehydration reads the wire failure through the +`ApplicationFailureLike` shape (exported from +[`@temporal-contract/contract/errors`](/reference/contract-surface#tag-constants-and-the-wire-marker)). ### `TypedWorkflowUpdateHandle` @@ -291,27 +321,30 @@ Passthrough of Temporal's `ScheduleClient.list`. ### `TypedScheduleHandle` -| Member | Type | -| ------------------- | --------------------------------------------------------- | -| `scheduleId` | `string` | -| `pause(note?)` | `AsyncResult` | -| `unpause(note?)` | `AsyncResult` | -| `trigger(overlap?)` | `AsyncResult` | -| `update(updateFn)` | `AsyncResult` | -| `backfill(options)` | `AsyncResult` | -| `delete()` | `AsyncResult` | -| `describe()` | `AsyncResult` | +| Member | Type | +| ------------------- | --------------------------------------------------------------------- | +| `scheduleId` | `string` | +| `pause(note?)` | `AsyncResult` | +| `unpause(note?)` | `AsyncResult` | +| `trigger(overlap?)` | `AsyncResult` | +| `update(updateFn)` | `AsyncResult` | +| `backfill(options)` | `AsyncResult` | +| `delete()` | `AsyncResult` | +| `describe()` | `AsyncResult` | The one anticipated failure — the schedule does not exist on the server — is modeled. Any other failure is a technical fault on the defect channel with a `RuntimeClientError` cause. -`update(updateFn)` is fetch-modify-persist: Temporal hands `updateFn` the -current description and persists what it returns. It may be invoked more than -once on conflict — keep it pure. The action's `workflowType` / `taskQueue` / -`args` are **not** re-validated against the contract here; prefer delete + -`create` for contract-level changes. `backfill` runs the schedule's action -over historical time ranges. +`update(updateFn)` is describe-modify-persist: the client fetches the current +description, hands it to `updateFn`, and persists what it returns. When the +updated action's `workflowType` is a declared workflow, its `args` **are** +validated against that workflow's input schema first — a mismatch surfaces as +`WorkflowValidationError` on the err channel and nothing is persisted. (An +action whose `workflowType` is not on the contract stays a passthrough.) +`updateFn` runs exactly once per call; a server-side conflict retries the +already-computed options rather than re-invoking it. `backfill` runs the +schedule's action over historical time ranges. ## Interceptors @@ -347,8 +380,11 @@ Discriminated on `operation`: (patch?: { input?: unknown; signalInput?: unknown }) => AsyncResult; ``` -The patch shallow-merges over the invocation. Calling `next` again re-runs the -rest of the chain. Returning without calling it short-circuits. +The patch shallow-merges over the invocation, and may set **only** `input` +(and `signalInput` for `signalWithStart`) — identity fields like +`workflowName` / `workflowId` are not patchable. A patched `input` is validated +exactly like the caller's original. Calling `next` again re-runs the rest of +the chain. Returning without calling it short-circuits. ### `ClientCallError` @@ -360,14 +396,52 @@ channel. ## Errors exported here -`RuntimeClientError`, `WorkflowNotInContractError`, -`WorkflowAlreadyStartedError`, `WorkflowExecutionNotFoundError`, -`WorkflowFailedError`, `WorkflowValidationError`, `QueryValidationError`, -`SignalValidationError`, `UpdateValidationError`, `ScheduleAlreadyExistsError`, -`ScheduleNotFoundError`, `TechnicalError`, `ContractError`. +Setup / lifecycle: `RuntimeClientError`, `TechnicalError`. + +Start phase: `WorkflowNotInContractError`, `WorkflowValidationError`, +`WorkflowAlreadyStartedError`. + +Result phase: `WorkflowFailedError`, plus the first-class outcome errors +`WorkflowCancelledError`, `WorkflowTerminatedError`, `WorkflowTimeoutError` +(each keeps the original `TemporalFailure` as `cause`), and +`WorkflowExecutionNotFoundError`. + +Interaction phase: `QueryValidationError`, `QueryFailedError`, +`SignalValidationError`, `UpdateValidationError`, `UpdateRejectedError` +(worker-side admission rejection), `UpdateFailedError` (admitted handler +failed). + +Schedules: `ScheduleAlreadyExistsError`, `ScheduleNotFoundError`. -Plus the types `TemporalFailure`, `AnyContractError`, `ContractErrorUnion`, -`WorkflowContractErrorsOf`. +Plus `ContractError` and the types `TemporalFailure`, `AnyContractError`, +`ContractErrorUnion`, `WorkflowContractErrorsOf`, `WorkflowResultErrorsOf`. + +### Tag constants and bundles + +Every error above has a literal-typed `_tag` constant +(`WORKFLOW_FAILED_ERROR_TAG`, `UPDATE_REJECTED_ERROR_TAG`, …). Three `const` +bundles group the recurring match unions: + +| Bundle | Tags | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `WORKFLOW_START_ERROR_TAGS` | `WorkflowNotInContractError`, `WorkflowValidationError`, `WorkflowAlreadyStartedError` | +| `WORKFLOW_OUTCOME_ERROR_TAGS` | `WorkflowCancelledError`, `WorkflowTerminatedError`, `WorkflowTimeoutError` | +| `WORKFLOW_RESULT_ERROR_TAGS` | validation, failed, the outcome trio, execution-not-found — what `result()` (and the result half of `executeWorkflow`) can err with, beside any declared contract errors | + +`tagPatterns(tags)` maps a bundle to a tuple of `P.tag(...)` patterns you spread +into one grouped matcher arm — collapsing a verbatim block of `P.tag(...)` +lines. The tuple shape is preserved as `TagPatterns` (exported): + +```typescript +import { tagPatterns, WORKFLOW_RESULT_ERROR_TAGS } from "@temporal-contract/client"; + +result.match({ + ok: (value) => value, + errCases: (matcher) => + matcher.with(...tagPatterns(WORKFLOW_RESULT_ERROR_TAGS), (error) => report(error)), + defect: (cause) => report(cause), +}); +``` See the [errors reference](/reference/errors). diff --git a/docs/reference/contract-surface.md b/docs/reference/contract-surface.md index 3bc76abb..47b47a3e 100644 --- a/docs/reference/contract-surface.md +++ b/docs/reference/contract-surface.md @@ -19,7 +19,7 @@ function defineContract(definition: T): T; | Field | Type | Required | Description | | ------------ | ------------------------------------ | -------- | ---------------------------------------------------- | | `taskQueue` | `string` | yes | Non-empty. The queue workers poll and clients target | -| `workflows` | `Record` | yes | At least one entry | +| `workflows` | `Record` | yes | At least one workflow **or** one global activity | | `activities` | `Record` | no | Global activities, reachable from every workflow | **Throws** `Error` when the structure is invalid. The check is a hand-rolled @@ -27,7 +27,9 @@ structural validator — the contract package has no runtime schema-library dependency. Checked at call time: - `taskQueue` empty or missing -- `workflows` empty +- neither a workflow nor a global activity is declared (an empty `workflows` + is fine when at least one global activity exists — see activity-only + contracts below) - an unknown key at the contract root (strict — only the three fields above) - any key that is not a valid JavaScript identifier (`/^[a-zA-Z_$][a-zA-Z0-9_$]*$/`) - an `input`, `output`, or error `data` that is not Standard Schema compatible @@ -38,7 +40,20 @@ dependency. Checked at call time: hoisting shared activities to the global `activities` block - a workflow name colliding with a global activity name (they share the root of the worker's implementations map) -- unknown keys inside `defaultOptions` +- unknown keys inside `activityOptions` +- a **reserved name** — any workflow / activity / signal / query / update / + search-attribute / error name starting with `__temporal_`, or the exact + names `__stack_trace` / `__enhanced_stack_trace` (used internally by the + Temporal SDK) +- an **invalid duration** in an `activityOptions` timeout / retry-interval — + strings are validated against the `ms` grammar (`"30s"`, `"5 minutes"`, + `"1.5h"`, or a long-form unit), so `"5 minutos"`, `""`, and `"abc"` throw at + definition; a numeric duration must be a non-negative finite number of + milliseconds + +**Activity-only contracts are allowed.** `workflows` may be `{}` as long as at +least one global activity is declared — the "at least one workflow" rule above +relaxes when the contract exists purely to serve activities. ### `defineWorkflow(definition)` @@ -55,12 +70,17 @@ dependency. Checked at call time: ### `defineActivity(definition)` -| Field | Type | Required | -| ---------------- | --------------------------------- | -------- | -| `input` | `AnySchema` | yes | -| `output` | `AnySchema` | yes | -| `errors` | `Record` | no | -| `defaultOptions` | `ActivityDefaultOptions` | no | +| Field | Type | Required | +| ----------------- | --------------------------------- | -------- | +| `input` | `AnySchema` | yes | +| `output` | `AnySchema` | yes | +| `errors` | `Record` | no | +| `activityOptions` | `ActivityDefaultOptions` | no | + +::: info Renamed in 8.0 +The contract-level activity-options field is `activityOptions` (it was +`defaultOptions` before 8.0). Its type is still `ActivityDefaultOptions`. +::: ### `defineSignal(definition?)` @@ -164,7 +184,7 @@ type DurationValue = string | number; // "30 seconds" | 30_000 ``` Merge order, least to most specific: `declareWorkflow({ activityOptions })` → -`defineActivity({ defaultOptions })` → `declareWorkflow({ activityOptionsByName })`. +`defineActivity({ activityOptions })` → `declareWorkflow({ activityOptionsByName })`. See [Tune activity options](/how-to/tune-activity-options). ## Formatting helpers @@ -188,13 +208,39 @@ if (result.isErr() && result.error instanceof WorkflowValidationError) { ## Errors Exported from `@temporal-contract/contract/errors`, and re-exported by the -worker and client packages so you rarely import from here directly. +worker and client packages so you rarely import from here directly. (The +package **root** does not import `unthrown`, so the runtime error machinery +lives on this separate `/errors` entry; the root re-exports only the two tag +_constants_ below.) - `TechnicalError` — infrastructure fault. Only ever a defect's `cause`, never in a modeled `E` channel - `ContractError` — a declared domain error, carrying `errorName` and `data` - `AnyContractError`, `ContractErrorUnion`, `ContractErrorInputUnion`, `ContractErrorConstructors`, `ContractErrorOptions` +- `ApplicationFailureLike` — the structural `{ type?, message?, details? }` + shape the rehydration path reads off the wire (a superset of Temporal's + `ApplicationFailure`) + +### Tag constants and the wire marker + +- `CONTRACT_ERROR_TAG` (`"@temporal-contract/ContractError"`) and + `TECHNICAL_ERROR_TAG` — literal `_tag` constants, exported from **both** the + package root and `/errors`, for `P.tag(...)` matching. +- `CONTRACT_ERROR_WIRE_MARKER` — the provenance marker written as + `details[1] = { $tc: 1 }` when a contract error crosses the wire + (`details[0]` is the validated data). A **data-less** declared error now + requires this marker to rehydrate — closing the false positive where any + `ApplicationFailure` sharing a matching `type` was surfaced as the typed + error. + +### `onRehydrationMiss(handler)` + +A diagnostic hook fired when an inbound `ApplicationFailure` matches a declared +error's `type` but fails to rehydrate — data that does not validate, or a +data-less error missing the wire marker — so it degrades to a generic failure +instead. The handler receives a `RehydrationMiss` (exported) describing the +miss. Use it to alert on contract/producer drift. See the [errors reference](/reference/errors). @@ -213,16 +259,26 @@ validation only accepts `undefined`. ### Error inference -`ErrorDefinition`, `DeclaredErrorsOf`, `InferErrorData`, `InferErrorDataInput` +`ErrorDefinition`, `InferDeclaredErrors`, `InferErrorData`, `InferErrorDataInput` `InferErrorData` gives the schema's **output** (post-transform) shape — what a consumer receives. `InferErrorDataInput` gives the **input** (pre-transform) shape — what a producer passes to the constructor. +::: info Renamed in 8.0 +`DeclaredErrorsOf` is now `InferDeclaredErrors` (part of the `Infer*` naming +sweep). +::: + ### Name extraction -`InferWorkflowNames`, `InferActivityNames`, `SignalNamesOf`, `QueryNamesOf`, -`UpdateNamesOf` +`InferWorkflowNames`, `InferActivityNames`, `InferSignalNames`, +`InferQueryNames`, `InferUpdateNames` + +::: info Renamed in 8.0 +`SignalNamesOf` / `QueryNamesOf` / `UpdateNamesOf` are now `InferSignalNames` / +`InferQueryNames` / `InferUpdateNames`. +::: ### Direction-aware inference diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index 93294fc9..41c64fb5 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -48,8 +48,10 @@ throw, an infrastructure fault. Not part of the modeled error type. Carries the raw failure on `result.cause` and re-throws when unwrapped, so genuine bugs surface loudly. See [The result model](/explanation/the-result-model). -**Qualification** — turning an untyped rejection into a modeled error at a -boundary. `fromPromise(promise, qualifyFailure("TYPE"))` is the common form. +**Qualification** — turning an _expected_ rejection into a modeled error at a +boundary, while everything else rides the defect channel. +`fromPromise(promise, qualifyFailure("TYPE", { expected: SomeError }))` is the +common form. **`TaggedError`** — unthrown's base for error classes, stamping a `_tag` discriminant. temporal-contract namespaces its tags with the package scope diff --git a/docs/reference/testing-surface.md b/docs/reference/testing-surface.md new file mode 100644 index 00000000..120fb754 --- /dev/null +++ b/docs/reference/testing-surface.md @@ -0,0 +1,292 @@ +# Testing surface + +`@temporal-contract/testing` has five entry points and no root export — each +tier is importable on its own so a Docker-free unit test never pulls in the +testcontainers stack. + +| Entry point | Tier | +| ------------------------------------------ | -------------------------------------------------- | +| `@temporal-contract/testing/activity` | Docker-free single-activity unit tests | +| `@temporal-contract/testing/time-skipping` | In-process time-skipping `TestWorkflowEnvironment` | +| `@temporal-contract/testing/contract` | Full stack over a testcontainers Temporal server | +| `@temporal-contract/testing/extension` | Raw connection fixtures (client + worker) | +| `@temporal-contract/testing/global-setup` | Vitest `globalSetup` that boots the test server | + +Generated per-symbol docs: [API reference](/api/testing/). + +::: tip House assertion style +All examples use the [`@unthrown/vitest`](https://github.com/btravstack/unthrown) +matchers — `toBeOk`, `toBeOkWith`, `toBeErr`, `toBeErrTagged`, `toBeDefect` — +over `expect(r.isOk()).toBe(true)`. They read the `Result` channel directly and +give better failure messages. Register them once in a Vitest setup file. +::: + +## `@temporal-contract/testing/activity` + +Two altitudes for testing one activity implementation — the +`(args, helpers) => AsyncResult<...>` function you pass to +`declareActivitiesHandler` — inside `@temporalio/testing`'s +`MockActivityEnvironment`. No worker, server, or Docker. + +### `runActivity(definition, options)` + +```typescript +function runActivity( + definition: TActivity, // e.g. contract.workflows.processOrder.activities.chargeCard + options: { + implementation: (args, helpers) => AsyncResult; + input: WorkerInferInput; // the parsed shape the worker hands the impl + env?: MockActivityEnvironment; // reuse one to observe heartbeats / cancel + }, +): AsyncResult; +``` + +The **pure-logic** tier: the implementation's `AsyncResult` flows through +**untouched** — no input parse, no output validation, no contract-error wire +conversion. `Ok`/`Err` pass as-is; an unanticipated throw (including a +`CancelledFailure`) lands on the `defect` channel. The typed `errors` +constructors are built from `definition.errors` and handed to the +implementation; `context` is always empty here (exercise middleware-injected +context through a real worker). + +```typescript +import { runActivity } from "@temporal-contract/testing/activity"; + +const result = await runActivity(orderContract.workflows.processOrder.activities.chargeCard, { + implementation: chargeCard, + input: { amount: 100 }, +}); + +await expect(result).toBeOk(); +``` + +Pass a prepared `MockActivityEnvironment` via `env` to observe heartbeats +(`env.on("heartbeat", ...)`), trigger cancellation (`env.cancel()`), or +customize the activity info. + +### `runActivityHandler(definition, options)` + +```typescript +function runActivityHandler( + definition: TActivity, + options: { + implementation: (args, helpers) => AsyncResult; + input: ClientInferInput; // the WIRE value, as a caller sends it + activityName?: string; // diagnostic name in validation errors (default "activity") + env?: MockActivityEnvironment; + }, +): AsyncResult, RunActivityHandlerError>; +``` + +The **boundary-faithful** tier: routes the same implementation through the +**real** `declareActivitiesHandler` wrapping and classifies the outcome the way +a workflow-side caller would — + +- the wire input is parsed against the contract's input schema (an invalid + input surfaces the production `ActivityInputValidationError`); +- an `Ok` output is validated on send and parsed on receive, so a transforming + output schema applies exactly once, and drift surfaces + `ActivityOutputValidationError`; +- a typed `Err(errors.X(data))` is converted to its `ApplicationFailure` wire + shape (`type` = error name, `details[0]` = data, `details[1]` = the wire + marker) and **rehydrated** back into the typed `ContractError` — the full + round-trip; +- contract misuse (undeclared error name, or error data failing its schema) + surfaces the production `ContractErrorDataValidationError`; +- an unanticipated throw stays on the `defect` channel. + +`RunActivityHandlerError` is the activity's declared errors +(rehydrated, post-transform) plus `ApplicationFailure` for everything else that +crossed the boundary. + +```typescript +import { runActivityHandler } from "@temporal-contract/testing/activity"; + +const result = await runActivityHandler( + orderContract.workflows.processOrder.activities.chargeCard, + { + implementation: chargeCard, + input: { amount: -1 }, + }, +); + +await expect(result).toBeErrTagged("@temporal-contract/ContractError"); +``` + +Use `runActivity` for pure logic; reach for `runActivityHandler` when the test +should fail exactly where production fails. `RunActivityImplementation`, +`RunActivityOptions`, `RunActivityHandlerOptions`, and `RunActivityHandlerError` +are exported. + +## `@temporal-contract/testing/time-skipping` + +In-process, Docker-free workflow tests via Temporal's time-skipping +`TestWorkflowEnvironment` — a lightweight local binary (downloaded and cached +by `@temporalio/testing` on first use) that fast-forwards timers, so full +contract/handler tests run in seconds without a cluster. + +### `it` + +A ready-made Vitest `it` with a worker-scoped `testEnv` fixture (one +environment per Vitest worker process, torn down on exit). + +```typescript +import { it } from "@temporal-contract/testing/time-skipping"; +import { TypedWorker, workflowsPathFromURL } from "@temporal-contract/worker/worker"; +import { TypedClient } from "@temporal-contract/client"; + +it("processes the order", async ({ testEnv }) => { + const worker = await TypedWorker.create({ + contract: myContract, + connection: testEnv.nativeConnection, + workflowsPath: workflowsPathFromURL(import.meta.url, "./test.workflows.js"), + activities, + }).get(); + const client = (await TypedClient.create({ client: testEnv.client }).get()).for(myContract); + + await worker.raw.runUntil(async () => { + const result = await client.executeWorkflow("processOrder", { + workflowId: "order-1", + args: { orderId: "ORD-1" }, + }); + expect(result).toBeOk(); + }); +}); +``` + +### `createTimeSkippingTest(options?)` + +Build the same `it` with pinned environment options (e.g. a specific test-server +version) — options are forwarded to `TestWorkflowEnvironment.createTimeSkipping` +unchanged. + +### `createTimeSkippingEnvironment(options?)` + +Create a `TestWorkflowEnvironment` directly for suites that prefer explicit +`beforeAll`/`afterAll` management (remember `env.teardown()`). + +## `@temporal-contract/testing/contract` + +The full integration stack for one contract over the testcontainers-provided +Temporal server: a running worker on the contract's task queue, the +connection-scoped `TypedClient` root, and the contract-bound `ContractClient`. + +### `createContractTest(options)` + +```typescript +function createContractTest(options: { + contract: TContract; // its taskQueue names the worker's queue + workflowsPath: string; // workflowsPathFromURL(import.meta.url, "./x.workflows.js") + activities?: ActivitiesHandler; // omit for a workflow-only worker + workerOptions?: Omit< + CreateWorkerOptions, + "activities" | "connection" | "contract" | "workflowsPath" + >; +}): TestFunction; // a Vitest `it` with contract fixtures +``` + +Returns a Vitest `it` whose fixtures expose: + +| Fixture | Type | +| ------------- | --------------------------------------------------------------------------------------------------------- | +| `client` | `ContractClient` | +| `typedClient` | `TypedClient` (the root) | +| `worker` | `TypedWorker` (started `auto` before the test, shut down after; `worker.raw` for the underlying `Worker`) | + +The connection fixtures from `/extension` (`clientConnection`, +`workerConnection`) remain on the context. + +```typescript +import { createContractTest } from "@temporal-contract/testing/contract"; +import { declareActivitiesHandler } from "@temporal-contract/worker/activity"; +import { workflowsPathFromURL } from "@temporal-contract/worker/worker"; +import { describe, expect } from "vitest"; + +import { orderContract } from "./order.contract.js"; + +const activities = declareActivitiesHandler({ contract: orderContract, activities: {/* ... */} }); + +const it = createContractTest({ + contract: orderContract, + workflowsPath: workflowsPathFromURL(import.meta.url, "./order.workflows.js"), + activities, +}); + +describe("order processing", () => { + it("processes an order end-to-end", async ({ client }) => { + const result = await client.executeWorkflow("processOrder", { + workflowId: `order-${Date.now()}`, + args: { orderId: "ORD-1" }, + }); + await expect(result).toBeOk(); + }); +}); +``` + +`CreateContractTestOptions` and `ContractTestContext` are exported. + +::: warning Requires the global setup +`createContractTest` needs the `@temporal-contract/testing/global-setup` global +setup registered on the test project (below), which is what boots the +testcontainers Temporal server it connects to. +::: + +## `@temporal-contract/testing/global-setup` + +### default export — `createGlobalSetup()` + +A Vitest `globalSetup` that starts a Temporal server (PostgreSQL + +`temporalio/auto-setup`) via testcontainers and provides its address to the +`/extension` and `/contract` fixtures. Register it directly: + +```typescript +// vitest.config.ts +export default defineConfig({ + test: { globalSetup: "@temporal-contract/testing/global-setup" }, +}); +``` + +### `createGlobalSetup(options?)` + +Build a configured setup — reference this factory from your own global-setup +module to pin images, inject Temporal env, or silence progress logs. +`CreateGlobalSetupOptions`: + +| Field | Type | Default | +| --------------- | ------------------------ | -------------------------------- | +| `postgresImage` | `string` | `"postgres:18.1"` | +| `temporalImage` | `string` | `"temporalio/auto-setup:1.29.1"` | +| `temporalEnv` | `Record` | `{}` (merged over the defaults) | +| `quiet` | `boolean` | `false` | + +```typescript +// temporal-global-setup.ts +import { createGlobalSetup } from "@temporal-contract/testing/global-setup"; + +export default createGlobalSetup({ temporalImage: "temporalio/auto-setup:1.28.0", quiet: true }); +``` + +## `@temporal-contract/testing/extension` + +A Vitest `it` extended with two raw connection fixtures backed by the +global-setup server: `clientConnection` (`@temporalio/client` `Connection`) and +`workerConnection` (`@temporalio/worker` `NativeConnection`). Use it when you +need connections but want to wire the client/worker yourself; `/contract` builds +on top of it. + +## Optional peer: `testcontainers` + +`testcontainers` is an **optional** peer dependency. It is required only for the +`/global-setup` entry (and therefore `createContractTest`, which depends on the +server it boots). The Docker-free entries — `/activity`, `/time-skipping`, +`/extension` — stay importable without it. When it is missing, `/global-setup` +fails with a descriptive install hint (`pnpm add -D testcontainers`). + +`vitest` and the three sibling `@temporal-contract/*` packages are required +peers; `unthrown` is required for the `AsyncResult` surface. + +## Next + +- [Test workflows](/how-to/test-workflows) +- [Worker surface](/reference/worker-surface) +- [Client surface](/reference/client-surface) diff --git a/docs/reference/worker-surface.md b/docs/reference/worker-surface.md index 6ced9bb2..6b62207d 100644 --- a/docs/reference/worker-surface.md +++ b/docs/reference/worker-surface.md @@ -31,10 +31,16 @@ function declareWorkflow( | `activityOptionsByName` | `Record` | no | `activityOptions` may be omitted only if every reachable activity is covered by -a contract-level `defaultOptions` or an `activityOptionsByName` entry. -Otherwise `declareWorkflow` throws at declaration time, listing the uncovered -activities. An unknown `workflowName` also fails at declaration time, listing -the contract's available workflow names. +a contract-level `defineActivity({ activityOptions })` or an +`activityOptionsByName` entry. Otherwise `declareWorkflow` throws at declaration +time, listing the uncovered activities. An unknown `workflowName` also fails at +declaration time (a `ContractMisuseError`), listing the contract's available +workflow names. + +`DeclareWorkflowOptions` and +`WorkflowImplementation` — the option-bag and the +`(context, args) => Promise` implementation shape — are exported so a +workflow can be declared or annotated standalone. The returned function carries `name === workflowName`, which is how Temporal derives the workflow type. @@ -57,6 +63,10 @@ workflow-scoped plus global — flattened to one namespace. Each returns a **plain value**, not a `Result`. Input is validated before the call, output after. A failure throws. +The map's type is `WorkflowInferWorkflowContextActivities` and a single entry's is `WorkflowInferActivity` — +both exported for annotating helpers that take `context.activities`. + #### `info` Temporal's `WorkflowInfo`: `workflowId`, `runId`, `attempt`, @@ -76,30 +86,39 @@ error takes only options (`{ message?, cause? }`). Empty object when the workflow declares no errors. -#### `defineSignal(name, handler)` +#### `handleSignal(name, handler)` ```typescript -(signalName: K, handler: (args: Input) => void | Promise) => void; +(signalName: K, handler: SignalHandlerImplementation<...>) => void; +// handler: (args: Input) => void | Promise ``` An incoming signal whose payload fails the schema is **dropped and logged** (`log.warn` with the signal name and issues) — it never fails the execution. A signal is fire-and-forget; any stale client can send one. -#### `defineQuery(name, handler)` +#### `handleQuery(name, handler)` ```typescript -(queryName: K, handler: (args: Input) => Output) => void; +(queryName: K, handler: QueryHandlerImplementation<...>) => void; +// handler: (args: Input) => Output ``` -Must be synchronous. +Must be synchronous. Both query schemas (input and output) must validate +synchronously — an async-validating schema (e.g. a zod async refinement) +trips a `ContractMisuseError` at bind time, not at first request. -#### `defineUpdate(name, handler)` +#### `handleUpdate(name, handler)` ```typescript -(updateName: K, handler: (args: Input) => Promise) => void; +(updateName: K, handler: UpdateHandlerImplementation<...>) => void; +// handler: (args: Input) => Promise ``` +The update's **input** schema must validate synchronously (it feeds Temporal's +synchronous validator slot); the output schema may be async. An async input +schema trips a `ContractMisuseError` at bind time. + Names are constrained to what the contract declares. Register handlers inside the implementation so they can close over workflow state. For an input-less definition (`defineSignal()`, `defineQuery({ output })`, @@ -109,6 +128,16 @@ Binding a name the contract does not declare — possible only from untyped code — throws `ContractMisuseError`, failing the execution terminally instead of hanging it in Workflow Task retries. +`SignalHandlerImplementation`, `QueryHandlerImplementation`, and +`UpdateHandlerImplementation` are exported, so a handler can be declared +standalone and assigned in. + +::: info Renamed in 8.0 +These were `context.defineSignal` / `defineQuery` / `defineUpdate` before 8.0. +The `handle*` verb keeps the in-workflow binding tier distinct from the +`define*` contract-authoring tier. See the [upgrade guide](/how-to/upgrade-to-v8). +::: + #### `startChildWorkflow(contract, workflowName, options)` ```typescript @@ -127,10 +156,15 @@ of hanging it in Workflow Task retries. | `signals` | `Record AsyncResult>` | | `result()` | `AsyncResult` | -The `signals` map mirrors the client handle's: one sender per signal declared -on the child's contract entry. The payload is validated before sending — an -invalid payload fails early as `Err(ChildWorkflowError)` — and the child -parses it on receive. +The `signals` map (type `TypedChildWorkflowSignals`, exported) mirrors the +client handle's: one sender per signal declared on the child's contract entry. +The payload is validated before sending — an invalid payload fails early as +`Err(ChildWorkflowError)` — and the child parses it on receive. + +`ChildWorkflowError`, `ChildWorkflowCancelledError`, and +`ChildWorkflowNotFoundError` each carry the child's `workflowName` as a +structured field. `TypedChildWorkflowHandle` and `TypedChildWorkflowOptions` +are exported for annotating stored handles. #### `executeChildWorkflow(contract, workflowName, options)` @@ -171,8 +205,10 @@ defect channel, so the modeled error channel stays exactly one type. Args are validated against the destination workflow's input schema before Temporal is called; on failure it throws `WorkflowInputValidationError`. -`TypedContinueAsNewOptions` is Temporal's `ContinueAsNewOptions` without -`workflowType` and `taskQueue`. +`TypedContinueAsNewOptions` (exported) is Temporal's `ContinueAsNewOptions` +without `workflowType` and `taskQueue` — the validated target wins, so a +`workflowType`/`taskQueue` slipped through untyped code is ignored. There is +no `retry` option. Never returns normally. @@ -188,7 +224,38 @@ Never returns normally. `WorkflowOutputValidationError` There is no `SignalInputValidationError` — an invalid signal payload is -dropped and logged, never thrown (see `defineSignal` above). +dropped and logged, never thrown (see `handleSignal` above). + +Each `ValidationError` subclass carries a readonly `direction: "input" | +"output"` field (the class names are unchanged; they remain +`ApplicationFailure` subclasses discriminated by `failure.type`). + +#### `rethrowCancellation(error): never` + +Re-raise a cancellation that surfaced on the modeled `Err(...)` channel. +`WorkflowCancelledError` (from `cancellableScope`), `ChildWorkflowCancelledError`, +and `ActivityCancelledError` are values — generic error handling that maps every +`Err` to a fallback would **complete** the workflow as `Completed` instead of +letting it end `Cancelled`. Pass the error to `rethrowCancellation` to re-raise +the original `CancelledFailure`: + +```typescript +if (result.isErr()) { + rethrowCancellation(result.error); // re-raises a cancellation; returns for anything else + return { status: "failed" }; +} +``` + +#### Worker error-tag constants + +Literal-typed `_tag` constants mirroring the contract package, for +`P.tag(...)` without hand-writing the namespaced strings: +`ACTIVITY_ERROR_TAG`, `ACTIVITY_CANCELLED_ERROR_TAG`, +`ACTIVITY_DEFINITION_NOT_FOUND_ERROR_TAG`, `CHILD_WORKFLOW_ERROR_TAG`, +`CHILD_WORKFLOW_CANCELLED_ERROR_TAG`, `CHILD_WORKFLOW_NOT_FOUND_ERROR_TAG`, +`WORKFLOW_CANCELLED_ERROR_TAG`. (The `ValidationError` subclasses are +`ApplicationFailure`s discriminated by `failure.type`, so they have no tag +constant.) Plus `ContractError`, `AnyContractError`, `ContractErrorConstructors`, `ContractErrorOptions`, `ContractErrorUnion`. @@ -216,12 +283,46 @@ workflow's name, mirroring the contract. A workflow that declares no activities needs no entry at all. The returned object is flat because Temporal resolves one namespace at runtime. +The options bag is typed as `DeclareActivitiesHandlerOptions` (exported). + TypeScript requires every activity in the contract to be implemented, and the declaration **fails fast** at runtime too: a declared activity with no implementation throws at declaration time (listing the missing names), and a stray key — an implementation the contract never declared — throws `ActivityDefinitionNotFoundError`. +**Shared activity across scopes.** One `defineActivity` object may be +referenced from several workflow scopes. Because Temporal has a single flat +activity namespace, every scope must supply the **same function reference** +(the duplicate is deduped, first registration wins) or the activity must be +hoisted to the contract's global `activities` map. Supplying two _different_ +implementations for the same flat name throws at declaration time, naming the +activity and both scopes. + +#### Standalone implementation types + +To type an activity implementation outside the `declareActivitiesHandler` call +(so it can live in its own module with precise `args`/`helpers` inference) and +assign it into the nested map later: + +- `GlobalActivityImplementationFor` — a global + activity. +- `ActivityImplementationFor` — a + workflow-local activity. + +Both take an optional trailing `TContext` type parameter mirroring the +handler's injected context. + +```typescript +const validateOrder: ActivityImplementationFor< + typeof myContract, + "orderWorkflow", + "validateOrder" +> = (args, { errors }) => + args.orderId ? OkAsync({ valid: true }) : ErrAsync(errors.EmptyOrder({})); +``` + ### Activity implementation signature ```typescript @@ -248,24 +349,59 @@ What the wrapper does with your result: The wrapper does not hide `@temporalio/activity` — `Context.current()`, `activityInfo()`, and heartbeats are all still available inside the body. -### `qualifyFailure(type, options?)` +### `qualifyFailure(type, options)` ```typescript function qualifyFailure( type: string, - options?: { message?: string; nonRetryable?: boolean; details?: unknown[] }, -): (error: unknown) => ApplicationFailure; + options: { + // REQUIRED — which rejection causes are anticipated + expected: ErrorClass | readonly ErrorClass[] | ((cause: unknown) => boolean) | "any"; + message?: string; + nonRetryable?: boolean; + details?: unknown[]; + }, +): (cause: unknown, defect) => ApplicationFailure | TDefect; +``` + +Builds a **triaging** qualifier for `fromPromise`. `expected` is **required** — +it is the per-cause decision _is this failure part of the activity's model, or +a bug?_ A cause matching `expected` (an error class, an array of classes, a +predicate, or the literal `"any"`) is wrapped into the modeled +`ApplicationFailure` of the given `type`; **everything else rides the defect +channel** and re-throws at the activity edge with its original cause — so a +`TypeError` from a typo can no longer masquerade as your declared failure and +inherit its retry semantics. + +```typescript +import { qualifyFailure } from "@temporal-contract/worker/activity"; +import { fromPromise } from "unthrown"; + +fromPromise( + gateway.charge(customerId, amount), + qualifyFailure("CHARGE_FAILED", { expected: GatewayError }), +); + +// several anticipated classes; a predicate works too +qualifyFailure("CARD_DECLINED", { + expected: [CardDeclinedError, GatewayTimeoutError], + nonRetryable: true, +}); ``` -Builds an error mapper for `fromPromise`. An `Error` rejection keeps its message -and is preserved as `cause`; anything else falls back to `options.message`, then -`String(error)`. +Prefer a class or predicate for `expected`; `"any"` is a deliberate, greppable +escape hatch that restores the pre-8.0 blanket-wrap behavior. -::: warning Always wraps -Even an `ApplicationFailure` rejection is wrapped, guaranteeing the resulting -`type`. The consequence is that an inner failure's own `type` and -`nonRetryable: true` are masked — pass `{ nonRetryable: true }` yourself if it -must stay permanent. +For a matched `Error` cause the wrapper keeps its message and preserves it as +`cause`; a matched non-`Error` cause falls back to `options.message`, then +`String(cause)`. + +::: info nonRetryable precedence +Explicit `options.nonRetryable` wins unconditionally. Omitted, a matched cause +that is itself an `ApplicationFailure` with `nonRetryable: true` propagates its +non-retryability to the wrapper — a permanent inner failure no longer silently +becomes retryable just because it was re-typed. Set `nonRetryable: false` +explicitly to force the wrapper retryable. ::: ### `ApplicationFailure` @@ -325,7 +461,10 @@ than the anything-goes `{}`. `ActivityDefinitionNotFoundError`, `ActivityInputValidationError`, `ActivityOutputValidationError`, `ContractErrorDataValidationError`, -`ValidationError`, plus the `ContractError` surface. +`ValidationError`, plus the `ContractError` surface. The `/activity` entry also +re-exports the worker error-tag constants (`ACTIVITY_ERROR_TAG`, +`ACTIVITY_CANCELLED_ERROR_TAG`, and the rest — see the workflow entry's +[error-tag constants](#worker-error-tag-constants)). ## `@temporal-contract/worker/worker` @@ -344,15 +483,29 @@ class TypedWorker { ``` The worker-side sibling of `TypedClient.create` — the org's `Typed*.create()` -factory shape. `CreateWorkerOptions` is Temporal's `WorkerOptions` without -`taskQueue` (taken from the contract), plus `contract` and an optional -`activities`. +factory shape. `CreateWorkerOptions` (exported) is Temporal's +`WorkerOptions` without `taskQueue` (taken from the contract), plus `contract`, +an optional `activities`, and `verifyWorkflowRegistration`. **`activities` is optional.** Omit it for a workflow-only worker — one that polls exclusively for Workflow Tasks, leaving activities to a separate worker process on the same task queue. See [Configure a worker](/how-to/configure-a-worker#run-a-workflow-only-worker). +**`verifyWorkflowRegistration` (defaults to `true`).** A best-effort startup +check that the `workflowsPath` module registers every contract workflow under +its declared name. `TypedWorker.create` imports the module in the main thread, +identifies `declareWorkflow`-produced exports via their brand marker, and fails +creation (a `TechnicalError`-caused defect) when a contract workflow has +neither a `declareWorkflow`-produced export nor a plain function export under +its name, or when a declared workflow is exported under a name that differs +from its `workflowName` (Temporal registers workflows by _export_ name, so the +mismatch would register the wrong workflow type). The check only runs when +`workflowsPath` is a string (prebuilt `workflowBundle`s are skipped) and a +module that cannot be imported in the main thread is skipped silently — the +`Worker.create` bundling step is the authority on load failures. Set to `false` +to opt out. + **No modeled error.** Bundling failures, bad connections, and invalid options are technical faults on the **defect** channel with a `TechnicalError` cause. Inspect with `isDefect()` / `match({ defect })` / `recoverDefect`, or use diff --git a/docs/tutorial/adding-signals-and-queries.md b/docs/tutorial/adding-signals-and-queries.md index ef4d3a16..a7f5f81c 100644 --- a/docs/tutorial/adding-signals-and-queries.md +++ b/docs/tutorial/adding-signals-and-queries.md @@ -94,11 +94,14 @@ export const orderContract = defineContract({ A signal has only `input` — there is nothing to return. Queries and updates have both. -::: warning Queries must validate synchronously -Temporal requires query handlers to complete synchronously, so a query's -schemas must too. Plain Zod, Valibot, or ArkType object schemas are fine; an -async refinement (`z.string().refine(async ...)`) is not. Standard Schema -doesn't expose sync-vs-async at the type level, so this is checked at runtime. +::: warning Queries (and update inputs) must validate synchronously +Temporal runs query handlers synchronously, so a query's schemas must validate +synchronously too — and so must an **update's input** schema, which feeds +Temporal's synchronous update validator. Plain Zod, Valibot, or ArkType object +schemas are fine; an async refinement (`z.string().refine(async ...)`) is not. +Standard Schema doesn't expose sync-vs-async at the type level, so the worker +probes the schema when the handler is bound and throws a `ContractMisuseError` +at bind time. ::: ## Step 2 — Handle them in the workflow @@ -125,13 +128,13 @@ export const processOrder = declareWorkflow({ let amount = order.amount; let approvedBy: string | undefined; - context.defineQuery("getStatus", () => ({ state, amount })); + context.handleQuery("getStatus", () => ({ state, amount })); - context.defineSignal("approve", (args) => { + context.handleSignal("approve", (args) => { approvedBy = args.approvedBy; }); - context.defineUpdate("changeAmount", async (args) => { + context.handleUpdate("changeAmount", async (args) => { amount = args.amount; return { amount }; }); @@ -164,7 +167,7 @@ Points worth pausing on: - **Handlers are registered inside the implementation**, not at module scope. That is what lets them close over `state`, `amount`, and `approvedBy`. -- **`context.defineQuery("getStatus", ...)`** is checked against the contract. +- **`context.handleQuery("getStatus", ...)`** is checked against the contract. Misspell the name and it is a compile error; the handler's argument and return types come from the contract's schemas. - **The query handler is synchronous.** The update handler is `async`. That @@ -184,9 +187,8 @@ when you need to interact mid-flight. Use `startWorkflow` instead: it returns a Replace `src/client.ts`: ```typescript -import { TypedClient } from "@temporal-contract/client"; +import { tagPatterns, TypedClient, WORKFLOW_RESULT_ERROR_TAGS } from "@temporal-contract/client"; import { Client, Connection } from "@temporalio/client"; -import { P } from "unthrown"; import { orderContract } from "./contract.js"; @@ -205,8 +207,15 @@ const started = await client.for(orderContract).startWorkflow("processOrder", { }, }); -if (started.isErr()) { - console.error("could not start:", started.error.message); +// `isOk()` narrows the Result. Note the shape: unthrown Results have THREE +// variants — Ok, Err, and Defect — so "not Err" is not the same as "Ok". +// Narrow on `isOk()` before touching `.value`. +if (!started.isOk()) { + if (started.isErr()) { + console.error("could not start:", started.error.message); + } else { + console.error("unexpected failure:", started.cause); + } process.exit(1); } @@ -234,9 +243,9 @@ result.match({ ok: (output) => console.log("charged:", output.transactionId), errCases: (matcher) => matcher.with( - P.tag("@temporal-contract/WorkflowValidationError"), - P.tag("@temporal-contract/WorkflowFailedError"), - P.tag("@temporal-contract/WorkflowExecutionNotFoundError"), + // One arm for the whole result-phase union: validation, failure, + // cancelled/terminated/timed out, and a missing execution. + ...tagPatterns(WORKFLOW_RESULT_ERROR_TAGS), (error) => console.error("workflow failed:", error.message), ), defect: (cause) => console.error("unexpected:", cause), @@ -271,8 +280,10 @@ exactly once. Try an update the contract forbids: ```typescript -const updated = await handle.updates.changeAmount({ amount: -5 }); -console.log(updated.isErr() ? updated.error.message : updated.value); +const rejected = await handle.updates.changeAmount({ amount: -5 }); +if (rejected.isErr()) { + console.log(rejected.error.message); +} ``` ``` diff --git a/docs/tutorial/your-first-workflow.md b/docs/tutorial/your-first-workflow.md index 65e1935d..91be5114 100644 --- a/docs/tutorial/your-first-workflow.md +++ b/docs/tutorial/your-first-workflow.md @@ -170,12 +170,16 @@ export const activities = declareActivitiesHandler({ // mirroring the contract. processOrder: { chargeCard: ({ customerId, amount }) => - fromPromise(paymentGateway.charge(customerId, amount), qualifyFailure("CHARGE_FAILED")).map( - (charge) => ({ transactionId: charge.id }), - ), + fromPromise( + paymentGateway.charge(customerId, amount), + qualifyFailure("CHARGE_FAILED", { expected: Error }), + ).map((charge) => ({ transactionId: charge.id })), sendReceipt: ({ customerId, transactionId }) => - fromPromise(mailer.send(customerId, transactionId), qualifyFailure("RECEIPT_FAILED")), + fromPromise( + mailer.send(customerId, transactionId), + qualifyFailure("RECEIPT_FAILED", { expected: Error }), + ), }, }, }); @@ -185,11 +189,21 @@ export const activities = declareActivitiesHandler({ takes a promise that might reject and turns it into an `AsyncResult`: - if the promise resolves, you get the value on the `ok` channel; -- if it rejects, `qualifyFailure("CHARGE_FAILED")` wraps the rejection in a Temporal - `ApplicationFailure` whose `type` is `"CHARGE_FAILED"`. - -That `type` is what Temporal's retry policies key on, which is why it is worth -naming deliberately. +- if it rejects with a cause matching `expected`, `qualifyFailure("CHARGE_FAILED", ...)` + wraps the rejection in a Temporal `ApplicationFailure` whose `type` is + `"CHARGE_FAILED"`; +- if it rejects with anything else, the rejection stays a **defect** — an + unanticipated bug that surfaces loudly instead of masquerading as a charge + failure. + +`expected` is required: it is your triage decision about which failures are +part of the activity's model. The stand-in gateway throws a plain `Error`, so +`expected: Error` is honest here; against a real SDK you would name its error +class instead (see +[Implement activities](/how-to/implement-activities)). + +The failure `type` is what Temporal's retry policies key on, which is why it is +worth naming deliberately. ::: tip Why nested? `chargeCard` and `sendReceipt` are declared inside `processOrder` in the @@ -216,6 +230,10 @@ export const processOrder = declareWorkflow({ contract: orderContract, activityOptions: { startToCloseTimeout: "1 minute", + // Cap retries so a persistently failing activity fails the workflow + // instead of retrying forever (Temporal's default policy is unlimited + // attempts). Step 7 relies on this. + retry: { maximumAttempts: 3 }, }, implementation: async (context, order) => { const { transactionId } = await context.activities.chargeCard({ @@ -295,9 +313,13 @@ resolves one relative to the current file — the ESM-safe equivalent of Open a second terminal. Create `src/client.ts`: ```typescript -import { TypedClient } from "@temporal-contract/client"; +import { + tagPatterns, + TypedClient, + WORKFLOW_RESULT_ERROR_TAGS, + WORKFLOW_START_ERROR_TAGS, +} from "@temporal-contract/client"; import { Client, Connection } from "@temporalio/client"; -import { P } from "unthrown"; import { orderContract } from "./contract.js"; @@ -325,11 +347,10 @@ result.match({ }, errCases: (matcher) => matcher.with( - P.tag("@temporal-contract/WorkflowNotInContractError"), - P.tag("@temporal-contract/WorkflowValidationError"), - P.tag("@temporal-contract/WorkflowAlreadyStartedError"), - P.tag("@temporal-contract/WorkflowFailedError"), - P.tag("@temporal-contract/WorkflowExecutionNotFoundError"), + // Tag bundles cover the start-phase and result-phase error unions in + // one arm — no hand-written list of tags to keep in sync. + ...tagPatterns(WORKFLOW_START_ERROR_TAGS), + ...tagPatterns(WORKFLOW_RESULT_ERROR_TAGS), (error) => console.error("workflow failed:", error.message), ), defect: (cause) => console.error("unexpected:", cause), @@ -381,9 +402,11 @@ amount: 5000, // paymentGateway.charge throws above 1000 ``` This time the workflow _does_ start. The activity fails, Temporal retries it -under the default policy, and after the retries are exhausted the client sees a -`WorkflowFailedError`. Watch the retries happen live in the Web UI — that -durability is what Temporal is for, and the contract has not gotten in its way. +under the `maximumAttempts: 3` policy you set in Step 4, and once the third +attempt fails the client sees a `WorkflowFailedError`. Watch the retries happen +live in the Web UI — that durability is what Temporal is for, and the contract +has not gotten in its way. (Without the cap, Temporal's default policy would +retry the activity indefinitely and the workflow would simply stay `Running`.) ## What you built diff --git a/packages/contract/typedoc.json b/packages/contract/typedoc.json index 406fbfbf..9c4f23dc 100644 --- a/packages/contract/typedoc.json +++ b/packages/contract/typedoc.json @@ -1,5 +1,5 @@ { "extends": "@btravstack/typedoc/base.json", - "entryPoints": ["src/index.ts"], + "entryPoints": ["src/index.ts", "src/errors.ts"], "out": "docs" } diff --git a/packages/worker/typedoc.json b/packages/worker/typedoc.json index 37c5a13a..1ac611cf 100644 --- a/packages/worker/typedoc.json +++ b/packages/worker/typedoc.json @@ -1,5 +1,5 @@ { "extends": "@btravstack/typedoc/base.json", - "entryPoints": ["src/activity.ts", "src/workflow.ts"], + "entryPoints": ["src/activity.ts", "src/worker.ts", "src/workflow.ts"], "out": "docs" } From 351b56b0e18cb7d1b75917192b961e73e9df25f0 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 31 Jul 2026 18:25:01 +0200 Subject: [PATCH 11/15] fix(worker): catch non-Promise thenables and hostile brand reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses both review comments on #357. `bindQueryHandler` / `bindUpdateHandler`'s per-call sync guards used `instanceof Promise` while the bind-time probe used `isThenable`. Standard Schema types the async branch as `Promise`, but an implementation may hand back any `PromiseLike` — that slipped past `instanceof Promise`, and the helper then read `.issues` (undefined, i.e. "valid") and `.value` (undefined) straight off the thenable, handing the handler an unvalidated `undefined` instead of tripping `ContractMisuseError`. Both call sites now share an `isAsyncValidation` helper that matches the probe's check and detaches the thenable's settlement. `_internal_declaredWorkflowName` read a symbol property off an arbitrary module export, so a `Proxy` with a throwing `get` trap (or a throwing getter) aborted the whole workflow-registration check with an unrelated exception. The read is now guarded — anything that fails to yield a string brand is simply "not a declared workflow". Also fixes the red Integration Tests job: turbo ran the four `test:integration` tasks in parallel, each spinning up its own Temporal + Postgres testcontainer pair, and the contention pushed the client suite's fixture setup past Vitest's 10s `hookTimeout` default. Serialize the task (the workaround already documented in the PR description) and give the integration projects a `hookTimeout` sized for fixture setup that opens Temporal connections and builds a workflow bundle per worker. Co-Authored-By: Claude Opus 5 (1M context) --- .../order-processing-worker/vitest.config.ts | 5 ++ package.json | 2 +- packages/client/vitest.config.ts | 6 ++ packages/testing/vitest.config.ts | 4 ++ packages/worker/src/handlers.spec.ts | 44 ++++++++++++++ packages/worker/src/handlers.ts | 33 ++++++++-- packages/worker/src/workflow-brand.spec.ts | 60 +++++++++++++++++++ packages/worker/src/workflow-brand.ts | 13 +++- packages/worker/vitest.config.ts | 5 ++ 9 files changed, 166 insertions(+), 6 deletions(-) create mode 100644 packages/worker/src/workflow-brand.spec.ts diff --git a/examples/order-processing-worker/vitest.config.ts b/examples/order-processing-worker/vitest.config.ts index 4a008282..c5aa6137 100644 --- a/examples/order-processing-worker/vitest.config.ts +++ b/examples/order-processing-worker/vitest.config.ts @@ -5,6 +5,11 @@ export default defineConfig({ globalSetup: "@temporal-contract/testing/global-setup", reporters: ["default"], setupFiles: ["./src/vitest.setup.ts"], + // These specs drive a real Temporal server through testcontainers, and + // fixture setup builds a workflow bundle per worker. Vitest's 5s test / + // 10s hook defaults are sized for unit tests, not for that. + testTimeout: 30_000, + hookTimeout: 60_000, coverage: { provider: "v8", reporter: ["text", "json", "json-summary", "html"], diff --git a/package.json b/package.json index 951caf00..69726b57 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "prepare": "lefthook install", "release": "pnpm build && changeset publish", "test": "turbo run test", - "test:integration": "turbo run test:integration", + "test:integration": "turbo run test:integration --concurrency=1", "typecheck": "turbo run typecheck", "version": "changeset version" }, diff --git a/packages/client/vitest.config.ts b/packages/client/vitest.config.ts index 12afe347..ad8521f0 100644 --- a/packages/client/vitest.config.ts +++ b/packages/client/vitest.config.ts @@ -23,6 +23,12 @@ export default defineConfig({ globalSetup: "@temporal-contract/testing/global-setup", include: ["src/**/__tests__/*.spec.ts"], testTimeout: 10_000, + // The per-test fixtures open three Temporal connections and build a + // workflow bundle per worker before the test body runs. Vitest's + // 10s hook default was never sized for that — on a loaded CI runner + // it trips before anything is actually wrong. `testTimeout` above + // still bounds the test body itself. + hookTimeout: 60_000, setupFiles: ["./src/vitest.setup.ts"], }, }, diff --git a/packages/testing/vitest.config.ts b/packages/testing/vitest.config.ts index ae172f10..e291e777 100644 --- a/packages/testing/vitest.config.ts +++ b/packages/testing/vitest.config.ts @@ -62,6 +62,10 @@ export default defineConfig({ include: ["src/**/__tests__/*.spec.ts"], setupFiles: ["./src/vitest.setup.ts"], testTimeout: 30_000, + // Fixture setup opens Temporal connections and builds a workflow + // bundle before the test body runs; Vitest's 10s hook default is + // too tight for that on a loaded CI runner. + hookTimeout: 60_000, }, }, ], diff --git a/packages/worker/src/handlers.spec.ts b/packages/worker/src/handlers.spec.ts index 8ebff0a4..cb3e0805 100644 --- a/packages/worker/src/handlers.spec.ts +++ b/packages/worker/src/handlers.spec.ts @@ -77,6 +77,19 @@ const asyncStringSchema = { }, }; +/** + * A validation result that is `PromiseLike` but NOT a `Promise` — the shape + * an `instanceof Promise` guard misses. The cast is the point: Standard + * Schema *types* the async branch as `Promise`, so only a runtime + * check can catch an implementation (a JS consumer, another realm's promise, + * a deferred wrapper) that returns a plain thenable instead. + */ +function bareThenable(value: unknown): Promise<{ value: unknown; issues: undefined }> { + // oxlint-disable-next-line unicorn/no-thenable -- the thenable IS the fixture: this test proves the sync guard catches a non-Promise PromiseLike + const thenable = { then: (resolve: (r: unknown) => void) => resolve({ value }) }; + return thenable as unknown as Promise<{ value: unknown; issues: undefined }>; +} + const workflow = defineWorkflow({ input: z.object({ id: z.string() }), output: z.object({}), @@ -302,6 +315,37 @@ describe("bindQueryHandler", () => { expect(() => entry.impl(["x"])).toThrow(/validation must be synchronous/); }); + it("the per-call guard catches a non-Promise thenable, not just a native Promise", () => { + // Standard Schema types the async signature as `Promise`, but an + // implementation may hand back any PromiseLike. An `instanceof Promise` + // guard would wave this through, and the helper would then read `.issues` + // (undefined → "valid") and `.value` (undefined) off the thenable and pass + // an unvalidated `undefined` to the handler. + captured.length = 0; + const thenableDodgingSchema = { + "~standard": { + version: 1 as const, + vendor: "test-thenable", + validate: (input: unknown) => + typeof input === "symbol" ? { value: input, issues: undefined } : bareThenable(input), + }, + }; + const wf = defineWorkflow({ + input: z.object({}), + output: z.object({}), + queries: { + progress: { input: thenableDodgingSchema, output: z.number() }, + }, + }); + const handler = vi.fn().mockReturnValue(1); + bindQueryHandler(wf, "probe", "progress", handler as never); + const entry = captured.find((c) => c.kind === "query" && c.name === "progress")!; + expect(() => entry.impl(["x"])).toThrow(ContractMisuseError); + expect(() => entry.impl(["x"])).toThrow(/validation must be synchronous/); + // The handler never ran on the unvalidated payload. + expect(handler).not.toHaveBeenCalled(); + }); + it("throws ContractMisuseError when the workflow has no queries block", () => { const noQueries = defineWorkflow({ input: z.object({}), diff --git a/packages/worker/src/handlers.ts b/packages/worker/src/handlers.ts index e4d47d95..19d9d9d7 100644 --- a/packages/worker/src/handlers.ts +++ b/packages/worker/src/handlers.ts @@ -85,6 +85,31 @@ function isThenable(value: unknown): value is PromiseLike { ); } +/** + * Per-call guard for the sync-only schema slots, kept shape-compatible with + * {@link assertSyncSchema}'s probe. + * + * Standard Schema types the async signature as `Promise`, but an + * implementation may legally hand back any `PromiseLike` — a wrapper, a + * deferred, a `then`-able from another realm. `instanceof Promise` misses + * those, and the caller would then read `.issues` (`undefined` → "no issues") + * and `.value` (`undefined`) straight off the thenable, silently handing the + * handler an unvalidated `undefined` instead of tripping + * {@link ContractMisuseError}. Matching the probe's `isThenable` closes that. + * + * A detected thenable's settlement is detached for the same reason the probe + * detaches its own: nothing awaits it, and a rejection would otherwise surface + * as an unhandled rejection while the {@link ContractMisuseError} is in flight. + */ +function isAsyncValidation(result: unknown): result is PromiseLike { + if (!isThenable(result)) return false; + result.then( + () => undefined, + () => undefined, + ); + return true; +} + /** * Bind-time probe for the sync-only schema slots (query input/output, update * input). Standard Schema permits `validate` to return a Promise (e.g. Zod @@ -253,7 +278,7 @@ export function bindQueryHandler( const input = extractHandlerInput(args); const inputResult = queryDef.input["~standard"].validate(input); - if (inputResult instanceof Promise) { + if (isAsyncValidation(inputResult)) { // oxlint-disable-next-line unthrown/no-throw -- sanctioned ContractMisuseError model: non-retryable ApplicationFailure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new ContractMisuseError( `Query "${queryName}" validation must be synchronous. Use a schema library that supports synchronous validation for queries.`, @@ -267,7 +292,7 @@ export function bindQueryHandler( const result = handler(inputResult.value); const outputResult = queryDef.output["~standard"].validate(result); - if (outputResult instanceof Promise) { + if (isAsyncValidation(outputResult)) { // oxlint-disable-next-line unthrown/no-throw -- sanctioned ContractMisuseError model: non-retryable ApplicationFailure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new ContractMisuseError( `Query "${queryName}" output validation must be synchronous. Use a schema library that supports synchronous validation for queries.`, @@ -360,7 +385,7 @@ export function bindUpdateHandler( // worth surfacing. const input = extractHandlerInput(args); const inputResult = updateDef.input["~standard"].validate(input); - if (inputResult instanceof Promise) { + if (isAsyncValidation(inputResult)) { // oxlint-disable-next-line unthrown/no-throw -- sanctioned ContractMisuseError model: non-retryable ApplicationFailure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new ContractMisuseError(updateInputMustBeSynchronousMessage(updateName)); } @@ -390,7 +415,7 @@ export function bindUpdateHandler( const input = extractHandlerInput(args); const inputResult = updateDef.input["~standard"].validate(input); - if (inputResult instanceof Promise) { + if (isAsyncValidation(inputResult)) { // oxlint-disable-next-line unthrown/no-throw -- sanctioned ContractMisuseError model: non-retryable ApplicationFailure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new ContractMisuseError(updateInputMustBeSynchronousMessage(updateName)); } diff --git a/packages/worker/src/workflow-brand.spec.ts b/packages/worker/src/workflow-brand.spec.ts new file mode 100644 index 00000000..e21c127c --- /dev/null +++ b/packages/worker/src/workflow-brand.spec.ts @@ -0,0 +1,60 @@ +/** + * Coverage for the `declareWorkflow` brand reader used by `TypedWorker`'s + * workflow-registration check. + * + * The reader is fed arbitrary module exports, so every assertion here is + * about it staying total: non-functions, unbranded functions, non-string + * brands, and — the case that matters most — exports whose property read + * itself throws. A throwing read must degrade to "not a declared workflow", + * not abort the registration check with an unrelated exception. + */ +import { describe, expect, it } from "vitest"; + +import { DECLARED_WORKFLOW_BRAND, _internal_declaredWorkflowName } from "./workflow-brand.js"; + +describe("_internal_declaredWorkflowName", () => { + it("returns the declared name for a branded function", () => { + const branded = Object.assign(() => undefined, { + [DECLARED_WORKFLOW_BRAND]: "orderWorkflow", + }); + expect(_internal_declaredWorkflowName(branded)).toBe("orderWorkflow"); + }); + + it("returns undefined for non-functions and unbranded functions", () => { + expect(_internal_declaredWorkflowName(undefined)).toBeUndefined(); + expect(_internal_declaredWorkflowName(null)).toBeUndefined(); + expect(_internal_declaredWorkflowName("orderWorkflow")).toBeUndefined(); + expect( + _internal_declaredWorkflowName({ [DECLARED_WORKFLOW_BRAND]: "notAFunction" }), + ).toBeUndefined(); + expect(_internal_declaredWorkflowName(() => undefined)).toBeUndefined(); + }); + + it("returns undefined when the brand is present but not a string", () => { + const branded = Object.assign(() => undefined, { [DECLARED_WORKFLOW_BRAND]: 42 }); + expect(_internal_declaredWorkflowName(branded)).toBeUndefined(); + }); + + it("returns undefined instead of propagating a throwing getter", () => { + const hostile = () => undefined; + Object.defineProperty(hostile, DECLARED_WORKFLOW_BRAND, { + get() { + // oxlint-disable-next-line unthrown/no-throw -- test double: simulates a module export whose property read throws + throw new Error("hostile getter"); + }, + }); + expect(() => _internal_declaredWorkflowName(hostile)).not.toThrow(); + expect(_internal_declaredWorkflowName(hostile)).toBeUndefined(); + }); + + it("returns undefined instead of propagating a Proxy `get` trap that throws", () => { + const hostile = new Proxy(() => undefined, { + get() { + // oxlint-disable-next-line unthrown/no-throw -- test double: simulates a Proxy export with a throwing get trap + throw new Error("hostile trap"); + }, + }); + expect(() => _internal_declaredWorkflowName(hostile)).not.toThrow(); + expect(_internal_declaredWorkflowName(hostile)).toBeUndefined(); + }); +}); diff --git a/packages/worker/src/workflow-brand.ts b/packages/worker/src/workflow-brand.ts index 1d8c3ade..9facc49e 100644 --- a/packages/worker/src/workflow-brand.ts +++ b/packages/worker/src/workflow-brand.ts @@ -22,10 +22,21 @@ export const DECLARED_WORKFLOW_BRAND = Symbol.for("temporal-contract.declareWork * with, or `undefined` for anything else. Used by `TypedWorker`'s * workflow-registration completeness check. * + * The candidate is an arbitrary module export, so the property read itself is + * untrusted: a `Proxy` with a throwing `get` trap, or a function carrying a + * throwing getter, would otherwise abort the whole registration check with an + * unrelated exception. Anything that fails to yield a plain string brand — + * including by throwing — is simply "not a declared workflow". + * * @internal */ export function _internal_declaredWorkflowName(candidate: unknown): string | undefined { if (typeof candidate !== "function") return undefined; - const brand = (candidate as { [DECLARED_WORKFLOW_BRAND]?: unknown })[DECLARED_WORKFLOW_BRAND]; + let brand: unknown; + try { + brand = (candidate as { [DECLARED_WORKFLOW_BRAND]?: unknown })[DECLARED_WORKFLOW_BRAND]; + } catch { + return undefined; + } return typeof brand === "string" ? brand : undefined; } diff --git a/packages/worker/vitest.config.ts b/packages/worker/vitest.config.ts index 4f2a4e21..061ad987 100644 --- a/packages/worker/vitest.config.ts +++ b/packages/worker/vitest.config.ts @@ -24,6 +24,11 @@ export default defineConfig({ include: ["src/**/__tests__/*.spec.ts"], exclude: ["src/**/__tests__/*.inprocess.spec.ts"], testTimeout: 10_000, + // Fixture setup opens Temporal connections and builds a workflow + // bundle per worker before the test body runs; Vitest's 10s hook + // default is too tight for that on a loaded CI runner. The test + // body itself is still bounded by `testTimeout` above. + hookTimeout: 60_000, setupFiles: ["./src/vitest.setup.ts"], }, }, From c9703081ad6d24f3456cddca8c689e740cb0cbf0 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 31 Jul 2026 18:27:55 +0200 Subject: [PATCH 12/15] fix(ci): drop the duplicate --concurrency flag from test:integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reusable CI workflow already invokes the root script with `--concurrency 3`, so hard-coding `--concurrency=1` inside it produced `turbo run … --concurrency=1 --concurrency 3`, which turbo rejects with "the argument '--concurrency ' cannot be used multiple times". The `hookTimeout` bump on the integration projects is the fix that actually addresses the original failure (a 10s hook timeout during fixture setup); serializing the task stays available as the documented local workaround. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 69726b57..951caf00 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "prepare": "lefthook install", "release": "pnpm build && changeset publish", "test": "turbo run test", - "test:integration": "turbo run test:integration --concurrency=1", + "test:integration": "turbo run test:integration", "typecheck": "turbo run typecheck", "version": "changeset version" }, From 6785391fce2893f6636a568dc6ecedb985a27158 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 1 Aug 2026 00:03:32 +0200 Subject: [PATCH 13/15] fix!: rename ActivityDefaultOptions, order the duration guard, state schedule update concurrency Review remediation on the v8 audit branch. - `ActivityDefaultOptions` -> `ContractActivityOptions`. The property was renamed to `activityOptions` in the earlier sweep but the type name was missed; the new name also keeps the contract-level, portable subset distinct from Temporal's own `ActivityOptions`, which is what the worker-side `activityOptionsByName` overrides take. - `assertDuration` checks the cheap length cap before running the regex. The pattern is linear so this was never exploitable, just the wrong order. - `TypedScheduleHandle.update`'s JSDoc claimed Temporal retries `updateFn` on a server-side conflict. It does not: in SDK 1.20.3 `ScheduleHandle.update` describes once, calls `updateFn` once, and `_updateSchedule` sends no conflict token, so `UpdateSchedule` is unconditional. Concurrent updates are last-writer-wins in the raw SDK too. Documented plainly, including the one thing the wrapper does change (an extra `describe`, slightly widening the window) and the one guarantee it keeps (validated options == persisted options). --- packages/client/src/schedule.ts | 18 ++++++++++++------ packages/contract/src/builder.ts | 4 +++- packages/contract/src/index.ts | 2 +- packages/contract/src/types.ts | 7 ++++--- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/client/src/schedule.ts b/packages/client/src/schedule.ts index 5ed413b7..c47f8001 100644 --- a/packages/client/src/schedule.ts +++ b/packages/client/src/schedule.ts @@ -132,12 +132,18 @@ export type TypedScheduleHandle = { * has no schema to check it against); prefer delete + `create` for * contract-level changes. * - * Implementation note: validation is asynchronous (schemas may be), so - * the description is fetched by this wrapper and the *already-computed* - * options are handed to Temporal's `ScheduleHandle.update`. `updateFn` is - * therefore invoked exactly once per call — on a server-side conflict the - * same computed options are retried, rather than `updateFn` being re-run - * against a fresh description. + * Concurrency: **last writer wins.** Temporal's `UpdateSchedule` RPC is + * unconditional — the TypeScript SDK sends no conflict token and does not + * re-run `updateFn` on a conflict — so a concurrent modification landing + * between the read and the write is overwritten. That is true of the raw + * SDK too; this wrapper does not weaken it, but it does widen the window + * slightly: validation is asynchronous (schemas may be), so the wrapper + * fetches the description itself and hands the *already-computed* options + * to `ScheduleHandle.update`, which describes again internally. `updateFn` + * is invoked exactly once per call, and the options that are validated are + * exactly the options that are persisted. + * + * If two writers can race on one schedule, serialize them yourself. */ update: ( updateFn: ( diff --git a/packages/contract/src/builder.ts b/packages/contract/src/builder.ts index 467a78e0..1942571e 100644 --- a/packages/contract/src/builder.ts +++ b/packages/contract/src/builder.ts @@ -570,7 +570,9 @@ function assertDuration(context: string, key: string, value: unknown): void { if (typeof value !== "string") { fail(`${context}: ${key} must be an ms-formatted string or a number of milliseconds`); } - if (!MS_DURATION_PATTERN.test(value) || value.length > 100) { + // Cheap length cap first: the regex is linear, but there is no reason to + // run it over an arbitrarily long string when the cap rejects it anyway. + if (value.length > 100 || !MS_DURATION_PATTERN.test(value)) { fail( `${context}: ${key} has invalid duration "${value}" — expected an ms-formatted string (a number followed by an optional unit ms/s/m/h/d/w/y or its long form, e.g. "30s", "5 minutes", "1.5h") or a number of milliseconds`, ); diff --git a/packages/contract/src/index.ts b/packages/contract/src/index.ts index afb58efd..0cf52769 100644 --- a/packages/contract/src/index.ts +++ b/packages/contract/src/index.ts @@ -28,7 +28,7 @@ export type { InferErrorData, InferErrorDataInput, // Contract-level activity option defaults - ActivityDefaultOptions, + ContractActivityOptions, ActivityRetryPolicy, DurationValue, // Search attributes diff --git a/packages/contract/src/types.ts b/packages/contract/src/types.ts index 9e66ad58..cdbab51c 100644 --- a/packages/contract/src/types.ts +++ b/packages/contract/src/types.ts @@ -64,7 +64,8 @@ export type ActivityRetryPolicy = { }; /** - * Contract-level default `ActivityOptions` for a single activity. + * Contract-level default activity options for a single activity — the + * portable subset of Temporal's `ActivityOptions`. * * Declared on `defineActivity` so operational behavior (timeouts, retry * policy) ships with the contract as a single source of truth shared by @@ -80,7 +81,7 @@ export type ActivityRetryPolicy = { * deliberately excluded — those belong to the worker's * `activityOptionsByName`, not the portable contract. */ -export type ActivityDefaultOptions = { +export type ContractActivityOptions = { readonly startToCloseTimeout?: DurationValue; readonly scheduleToCloseTimeout?: DurationValue; readonly scheduleToStartTimeout?: DurationValue; @@ -99,7 +100,7 @@ export type ActivityDefinition< readonly input: TInput; readonly output: TOutput; readonly errors?: TErrors; - readonly activityOptions?: ActivityDefaultOptions; + readonly activityOptions?: ContractActivityOptions; }; /** From 8181ea4f6bf51bc81302eba3e94809e0450a8f2e Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 1 Aug 2026 00:03:43 +0200 Subject: [PATCH 14/15] docs(examples)!: give the order-processing domain real error classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example used `expected: Error` at every `qualifyFailure` site, which matches essentially every throw — the pre-v8 catch-all spelled differently. It was accurate (the domain only threw plain `Error`s) but it neutralized the feature the flagship example is meant to teach, and a reader landing on one of the bare sites had nothing to go on. Adds `domain/errors.ts` with one class per port under a shared `OrderProcessingError` base, and throws those from the use cases and the payment adapter. Each activity now names the failure it actually anticipates (`expected: ShippingError`, `expected: PaymentError`, ...), so the triage is visible: a provider fault is a modeled activity failure, a `TypeError` from a bug stays a defect and re-throws at the edge with its original stack. --- .../src/application/activities.ts | 34 +++++++++----- .../src/domain/errors.ts | 44 +++++++++++++++++++ .../usecases/create-shipment.usecase.ts | 5 ++- .../usecases/process-payment.usecase.ts | 5 ++- .../usecases/purge-expired-orders.usecase.ts | 3 +- .../domain/usecases/refund-payment.usecase.ts | 3 +- .../usecases/release-inventory.usecase.ts | 3 +- .../usecases/reserve-inventory.usecase.ts | 5 ++- .../usecases/send-notification.usecase.ts | 7 +-- .../adapters/payment.adapter.ts | 3 +- 10 files changed, 89 insertions(+), 23 deletions(-) create mode 100644 examples/order-processing-worker/src/domain/errors.ts diff --git a/examples/order-processing-worker/src/application/activities.ts b/examples/order-processing-worker/src/application/activities.ts index 1d1258c7..f7b9d0cf 100644 --- a/examples/order-processing-worker/src/application/activities.ts +++ b/examples/order-processing-worker/src/application/activities.ts @@ -11,6 +11,13 @@ import { createShipmentUseCase, refundPaymentUseCase, } from "../dependencies.js"; +import { + InventoryError, + NotificationError, + OrderRepositoryError, + PaymentError, + ShippingError, +} from "../domain/errors.js"; /** * Activity implementations using unthrown's `AsyncResult` pattern. @@ -53,10 +60,14 @@ export const activities = declareActivitiesHandler({ fromPromise( sendNotificationUseCase.execute(customerId, subject, message), qualifyFailure("NOTIFICATION_FAILED", { - // Anticipated failure shape: the domain layer signals technical - // faults by throwing plain `Error`s. Anything else (a TypeError - // from a bug, ...) stays a defect and re-throws at the edge. - expected: Error, + // `expected` names the failures this activity *anticipates*. The + // notification port signals its faults with `NotificationError`, so + // only those become the modeled `NOTIFICATION_FAILED` failure. + // Anything else — a `TypeError` from a bug, a null deref — is not a + // business failure: it rides unthrown's defect channel and re-throws + // at the activity edge with its original stack. Use + // `expected: "any"` if you deliberately want the old catch-all. + expected: NotificationError, message: "Failed to send notification", }), ), @@ -64,7 +75,10 @@ export const activities = declareActivitiesHandler({ purgeExpiredOrders: ({ olderThanDays }) => fromPromise( purgeExpiredOrdersUseCase.execute(olderThanDays), - qualifyFailure("ORDER_PURGE_FAILED", { expected: Error, message: "Order purge failed" }), + qualifyFailure("ORDER_PURGE_FAILED", { + expected: OrderRepositoryError, + message: "Order purge failed", + }), ).map((purgedCount) => ({ purgedCount })), processOrder: { @@ -79,7 +93,7 @@ export const activities = declareActivitiesHandler({ fromPromise( processPaymentUseCase.execute(customerId, amount), qualifyFailure("PAYMENT_GATEWAY_ERROR", { - expected: Error, + expected: PaymentError, message: "Payment gateway call failed", }), ).flatMap((outcome) => { @@ -93,7 +107,7 @@ export const activities = declareActivitiesHandler({ fromPromise( reserveInventoryUseCase.execute(items), qualifyFailure("INVENTORY_RESERVATION_FAILED", { - expected: Error, + expected: InventoryError, message: "Inventory reservation failed", }), ), @@ -102,7 +116,7 @@ export const activities = declareActivitiesHandler({ fromPromise( releaseInventoryUseCase.execute(reservationId), qualifyFailure("INVENTORY_RELEASE_FAILED", { - expected: Error, + expected: InventoryError, message: "Inventory release failed", }), ), @@ -111,7 +125,7 @@ export const activities = declareActivitiesHandler({ fromPromise( createShipmentUseCase.execute(orderId, customerId), qualifyFailure("SHIPMENT_CREATION_FAILED", { - expected: Error, + expected: ShippingError, message: "Shipment creation failed", }), ), @@ -119,7 +133,7 @@ export const activities = declareActivitiesHandler({ refundPayment: (transactionId) => fromPromise( refundPaymentUseCase.execute(transactionId), - qualifyFailure("REFUND_FAILED", { expected: Error, message: "Refund failed" }), + qualifyFailure("REFUND_FAILED", { expected: PaymentError, message: "Refund failed" }), ), }, }, diff --git a/examples/order-processing-worker/src/domain/errors.ts b/examples/order-processing-worker/src/domain/errors.ts new file mode 100644 index 00000000..3b6f526e --- /dev/null +++ b/examples/order-processing-worker/src/domain/errors.ts @@ -0,0 +1,44 @@ +/** + * Technical failure shapes for the order-processing domain. + * + * These exist so each activity boundary can *triage* rather than blanket-wrap. + * `qualifyFailure(type, { expected })` names the failures an activity + * anticipates: a matching cause becomes the modeled `ApplicationFailure`, and + * anything else — a `TypeError` from a bug, a null deref — rides unthrown's + * **defect** channel and re-throws at the activity edge with its original + * stack, instead of being mislabelled as a business failure. + * + * Naming one class per port is what makes that triage meaningful. A blanket + * `expected: Error` would match essentially every throw, which is the + * pre-v8 catch-all behavior spelled differently — if you genuinely want it, + * write `expected: "any"` so the intent is explicit and greppable. + * + * Distinct from the contract's **declared** `errors` (`PaymentDeclined`): + * those model expected *business* outcomes, are returned as + * `Err(errors.PaymentDeclined(...))` rather than thrown, and cross the wire + * as typed `ContractError`s. The classes here are plain technical faults. + */ + +/** Base class for every technical fault raised by this example's domain. */ +export class OrderProcessingError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + // Keep `Error.name` the concrete subclass name for readable logs. + this.name = new.target.name; + } +} + +/** The notification provider could not accept the message. */ +export class NotificationError extends OrderProcessingError {} + +/** The payment gateway could not process the charge or refund. */ +export class PaymentError extends OrderProcessingError {} + +/** The inventory service could not reserve or release stock. */ +export class InventoryError extends OrderProcessingError {} + +/** The shipping provider could not create the shipment. */ +export class ShippingError extends OrderProcessingError {} + +/** The order store could not complete the requested operation. */ +export class OrderRepositoryError extends OrderProcessingError {} diff --git a/examples/order-processing-worker/src/domain/usecases/create-shipment.usecase.ts b/examples/order-processing-worker/src/domain/usecases/create-shipment.usecase.ts index 549b612e..5970acb8 100644 --- a/examples/order-processing-worker/src/domain/usecases/create-shipment.usecase.ts +++ b/examples/order-processing-worker/src/domain/usecases/create-shipment.usecase.ts @@ -1,4 +1,5 @@ import type { ShippingResult } from "../entities/order.schema.js"; +import { ShippingError } from "../errors.js"; import type { ShippingPort } from "../ports/shipping.port.js"; /** @@ -13,12 +14,12 @@ export class CreateShipmentUseCase { // Business validation if (!orderId || orderId.trim() === "") { // oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...)) - throw new Error("Order ID is required"); + throw new ShippingError("Order ID is required"); } if (!customerId || customerId.trim() === "") { // oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...)) - throw new Error("Customer ID is required"); + throw new ShippingError("Customer ID is required"); } // Delegate to shipping port diff --git a/examples/order-processing-worker/src/domain/usecases/process-payment.usecase.ts b/examples/order-processing-worker/src/domain/usecases/process-payment.usecase.ts index c106f64c..add58355 100644 --- a/examples/order-processing-worker/src/domain/usecases/process-payment.usecase.ts +++ b/examples/order-processing-worker/src/domain/usecases/process-payment.usecase.ts @@ -1,4 +1,5 @@ import type { PaymentOutcome } from "../entities/order.schema.js"; +import { PaymentError } from "../errors.js"; import type { PaymentPort } from "../ports/payment.port.js"; /** @@ -13,12 +14,12 @@ export class ProcessPaymentUseCase { // Business validation if (amount <= 0) { // oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...)) - throw new Error("Payment amount must be positive"); + throw new PaymentError("Payment amount must be positive"); } if (!customerId || customerId.trim() === "") { // oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...)) - throw new Error("Customer ID is required"); + throw new PaymentError("Customer ID is required"); } // Delegate to payment port diff --git a/examples/order-processing-worker/src/domain/usecases/purge-expired-orders.usecase.ts b/examples/order-processing-worker/src/domain/usecases/purge-expired-orders.usecase.ts index 37e79e03..fca3fbec 100644 --- a/examples/order-processing-worker/src/domain/usecases/purge-expired-orders.usecase.ts +++ b/examples/order-processing-worker/src/domain/usecases/purge-expired-orders.usecase.ts @@ -1,3 +1,4 @@ +import { OrderRepositoryError } from "../errors.js"; import type { OrderRepositoryPort } from "../ports/order-repository.port.js"; /** @@ -12,7 +13,7 @@ export class PurgeExpiredOrdersUseCase { // Business validation if (!Number.isInteger(olderThanDays) || olderThanDays <= 0) { // oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...)) - throw new Error("olderThanDays must be a positive integer"); + throw new OrderRepositoryError("olderThanDays must be a positive integer"); } // Delegate to order repository port diff --git a/examples/order-processing-worker/src/domain/usecases/refund-payment.usecase.ts b/examples/order-processing-worker/src/domain/usecases/refund-payment.usecase.ts index 2a3067ed..13ad0642 100644 --- a/examples/order-processing-worker/src/domain/usecases/refund-payment.usecase.ts +++ b/examples/order-processing-worker/src/domain/usecases/refund-payment.usecase.ts @@ -1,3 +1,4 @@ +import { PaymentError } from "../errors.js"; import type { PaymentPort } from "../ports/payment.port.js"; /** @@ -12,7 +13,7 @@ export class RefundPaymentUseCase { // Business validation if (!transactionId || transactionId.trim() === "") { // oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...)) - throw new Error("Transaction ID is required"); + throw new PaymentError("Transaction ID is required"); } // Delegate to payment port diff --git a/examples/order-processing-worker/src/domain/usecases/release-inventory.usecase.ts b/examples/order-processing-worker/src/domain/usecases/release-inventory.usecase.ts index a7d2acba..4f71795c 100644 --- a/examples/order-processing-worker/src/domain/usecases/release-inventory.usecase.ts +++ b/examples/order-processing-worker/src/domain/usecases/release-inventory.usecase.ts @@ -1,3 +1,4 @@ +import { InventoryError } from "../errors.js"; import type { InventoryPort } from "../ports/inventory.port.js"; /** @@ -12,7 +13,7 @@ export class ReleaseInventoryUseCase { // Business validation if (!reservationId || reservationId.trim() === "") { // oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...)) - throw new Error("Reservation ID is required"); + throw new InventoryError("Reservation ID is required"); } // Delegate to inventory port diff --git a/examples/order-processing-worker/src/domain/usecases/reserve-inventory.usecase.ts b/examples/order-processing-worker/src/domain/usecases/reserve-inventory.usecase.ts index b96cec75..b3aa6573 100644 --- a/examples/order-processing-worker/src/domain/usecases/reserve-inventory.usecase.ts +++ b/examples/order-processing-worker/src/domain/usecases/reserve-inventory.usecase.ts @@ -1,4 +1,5 @@ import type { InventoryReservation, OrderItem } from "../entities/order.schema.js"; +import { InventoryError } from "../errors.js"; import type { InventoryPort } from "../ports/inventory.port.js"; /** @@ -13,13 +14,13 @@ export class ReserveInventoryUseCase { // Business validation if (!items || items.length === 0) { // oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...)) - throw new Error("At least one item is required"); + throw new InventoryError("At least one item is required"); } for (const item of items) { if (item.quantity <= 0) { // oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...)) - throw new Error(`Invalid quantity for product ${item.productId}`); + throw new InventoryError(`Invalid quantity for product ${item.productId}`); } } diff --git a/examples/order-processing-worker/src/domain/usecases/send-notification.usecase.ts b/examples/order-processing-worker/src/domain/usecases/send-notification.usecase.ts index e467a5e0..1d572001 100644 --- a/examples/order-processing-worker/src/domain/usecases/send-notification.usecase.ts +++ b/examples/order-processing-worker/src/domain/usecases/send-notification.usecase.ts @@ -1,3 +1,4 @@ +import { NotificationError } from "../errors.js"; import type { NotificationPort } from "../ports/notification.port.js"; /** @@ -12,17 +13,17 @@ export class SendNotificationUseCase { // Business validation if (!customerId || customerId.trim() === "") { // oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...)) - throw new Error("Customer ID is required"); + throw new NotificationError("Customer ID is required"); } if (!subject || subject.trim() === "") { // oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...)) - throw new Error("Subject is required"); + throw new NotificationError("Subject is required"); } if (!message || message.trim() === "") { // oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...)) - throw new Error("Message is required"); + throw new NotificationError("Message is required"); } // Delegate to notification port diff --git a/examples/order-processing-worker/src/infrastructure/adapters/payment.adapter.ts b/examples/order-processing-worker/src/infrastructure/adapters/payment.adapter.ts index f613ff96..950bbc24 100644 --- a/examples/order-processing-worker/src/infrastructure/adapters/payment.adapter.ts +++ b/examples/order-processing-worker/src/infrastructure/adapters/payment.adapter.ts @@ -1,4 +1,5 @@ import type { PaymentOutcome } from "../../domain/entities/order.schema.js"; +import { PaymentError } from "../../domain/errors.js"; import type { PaymentPort } from "../../domain/ports/payment.port.js"; import { logger } from "../../logger.js"; @@ -57,7 +58,7 @@ export class MockPaymentAdapter implements PaymentPort { } else { logger.error(`❌ Refund failed`); // oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...)) - throw new Error("Payment processor rejected refund request"); + throw new PaymentError("Payment processor rejected refund request"); } } } From 8e6ad466a2bad893c497d8c06cb5aee4f27b49b1 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 1 Aug 2026 00:03:44 +0200 Subject: [PATCH 15/15] docs: state the wire-marker deploy ordering and the options-type rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The v8 upgrade guide now has a rolling-upgrade warning for the typed-error wire marker. A data-less contract error emitted by a 7.x worker carries no marker, so an 8.0 reader degrades it to a generic failure — silently, unless `onRehydrationMiss` is registered. Documents workers-before-callers ordering, the hook, and that schema-bearing errors are unaffected. Added to the checklist. - `ActivityDefaultOptions` -> `ContractActivityOptions` in the rename table, the checklist, the contract-surface reference, and the changeset. --- .changeset/v8-audit-remediation.md | 2 +- docs/how-to/upgrade-to-v8.md | 38 +++++++++++++++++++++++++++++- docs/reference/contract-surface.md | 10 ++++---- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/.changeset/v8-audit-remediation.md b/.changeset/v8-audit-remediation.md index 08f4c223..b076264e 100644 --- a/.changeset/v8-audit-remediation.md +++ b/.changeset/v8-audit-remediation.md @@ -16,7 +16,7 @@ v8 audit remediation — a second full-surface pass hardening robustness, the un **`@temporal-contract/contract`:** - Type-helper renames to the family-standard `Infer*` prefix: `SignalNamesOf`/`QueryNamesOf`/`UpdateNamesOf`/`DeclaredErrorsOf` → `InferSignalNames`/`InferQueryNames`/`InferUpdateNames`/`InferDeclaredErrors`. -- `defineActivity`'s `defaultOptions` key is renamed `activityOptions` (merge precedence unchanged). +- `defineActivity`'s `defaultOptions` key is renamed `activityOptions` (merge precedence unchanged), and its type `ActivityDefaultOptions` → `ContractActivityOptions` — the new name keeps the contract-level, portable subset distinct from Temporal's own `ActivityOptions`, which is what the worker-side `activityOptionsByName` overrides take. - Duration strings are validated against the `ms` grammar at `defineContract` time — `"5 minutos"`, `""`, and negative durations now fail at definition, naming the offending path, instead of at the worker. - Temporal-reserved names are rejected at `defineContract`: the `__temporal_` prefix and the exact `__stack_trace` / `__enhanced_stack_trace` query names. - Activity-only contracts are allowed — `workflows` may be `{}` when at least one global activity is declared (dedicated activity-pool task queues). diff --git a/docs/how-to/upgrade-to-v8.md b/docs/how-to/upgrade-to-v8.md index 2cec4a68..75277bb2 100644 --- a/docs/how-to/upgrade-to-v8.md +++ b/docs/how-to/upgrade-to-v8.md @@ -642,6 +642,7 @@ integration, and family consistency. These land in the same major. | `SignalNamesOf` / `QueryNamesOf` / `UpdateNamesOf` | `InferSignalNames` / `InferQueryNames` / `InferUpdateNames` | | `DeclaredErrorsOf` | `InferDeclaredErrors` | | `defineActivity({ defaultOptions })` | `defineActivity({ activityOptions })` | +| `ActivityDefaultOptions` (type) | `ContractActivityOptions` | | `context.defineSignal` / `defineQuery` / `defineUpdate` | `context.handleSignal` / `handleQuery` / `handleUpdate` | | `@temporal-contract/contract/result-async` | removed — internals live at `.../internal` (private) | @@ -697,6 +698,37 @@ it. Re-raise with the new `rethrowCancellation(error)` from `@temporal-contract/worker/workflow`. See [Handle cancellation](/how-to/handle-cancellation). +### Typed errors carry a wire marker — mind the deploy order + +A contract error now crosses the wire with a provenance marker in +`ApplicationFailure.details[1]` (`{ $tc: 1 }`). For an error that declares a +`data` schema, validating `details[0]` is still the gate. For a **data-less** +error the marker is **required** — that closes a false positive where any +unrelated `ApplicationFailure` whose `type` happened to equal a declared +data-less error name was surfaced as the typed domain error. + +::: warning Rolling upgrades +The marker is written by 8.0 workers only. During a rolling deploy, a +**data-less** contract error emitted by a still-7.x worker carries no marker, +so an 8.0 workflow or client will not rehydrate it — it degrades to the +generic failure classification. Nothing throws and nothing is logged by +default, so a `match` arm keyed on the typed error silently stops matching for +the duration of the window. + +Order the deploy **workers first, then clients/callers**, and drain in-flight +executions before cutting callers over. To make the window observable, register +the diagnostic hook — it fires on every degrade-to-generic: + +```typescript +import { onRehydrationMiss } from "@temporal-contract/contract/errors"; + +onRehydrationMiss((miss) => logger.warn({ miss }, "contract error degraded to generic")); +``` + +Errors that declare a `data` schema are unaffected: they rehydrate on schema +validation, marker or not. +::: + ### Worker: safer declarations - A shared activity referenced from several scopes must be the **same function @@ -772,7 +804,8 @@ contract-error wire round-trip) so a test fails exactly where production does. - [ ] No schema relies on its transform running on the send side (each boundary now parses once, on receive) - [ ] `SignalNamesOf` / `QueryNamesOf` / `UpdateNamesOf` / `DeclaredErrorsOf` - → the `Infer*` prefix; `defineActivity` `defaultOptions` → `activityOptions` + → the `Infer*` prefix; `defineActivity` `defaultOptions` → `activityOptions`; + the `ActivityDefaultOptions` type → `ContractActivityOptions` - [ ] `context.defineSignal` / `defineQuery` / `defineUpdate` → `handleSignal` / `handleQuery` / `handleUpdate` - [ ] Every `qualifyFailure(...)` passes `{ expected }` (or `{ expected: "any" }` @@ -786,6 +819,9 @@ contract-error wire round-trip) so a test fails exactly where production does. - [ ] `createContractTest({ contract, ... })` and `runActivity(def, { ... })` use the option bag; `testcontainers` installed only where `createContractTest` runs - [ ] Imports of `@temporal-contract/contract/result-async` removed +- [ ] Rolling deploy ordered **workers before callers**, and `onRehydrationMiss` + registered — a data-less contract error from a 7.x worker carries no wire + marker and degrades to a generic failure until the workers are cut over - [ ] `@temporalio/*` resolve to `^1.16.0`; no CJS `require` of these packages - [ ] `pnpm typecheck` clean diff --git a/docs/reference/contract-surface.md b/docs/reference/contract-surface.md index 47b47a3e..b0a31659 100644 --- a/docs/reference/contract-surface.md +++ b/docs/reference/contract-surface.md @@ -75,11 +75,13 @@ relaxes when the contract exists purely to serve activities. | `input` | `AnySchema` | yes | | `output` | `AnySchema` | yes | | `errors` | `Record` | no | -| `activityOptions` | `ActivityDefaultOptions` | no | +| `activityOptions` | `ContractActivityOptions` | no | ::: info Renamed in 8.0 The contract-level activity-options field is `activityOptions` (it was -`defaultOptions` before 8.0). Its type is still `ActivityDefaultOptions`. +`defaultOptions` before 8.0), and its type is `ContractActivityOptions` (it +was `ActivityDefaultOptions`). The type name is now distinct from Temporal's +own `ActivityOptions`, which is what the worker-side overrides take. ::: ### `defineSignal(definition?)` @@ -159,12 +161,12 @@ type ErrorDefinition = { | `message` | Default message. Overridable per instance | | `nonRetryable` | `true` stops Temporal retrying. Default `false` | -### `ActivityDefaultOptions` +### `ContractActivityOptions` A strict object — unknown keys are rejected at `defineContract` time. ```typescript -type ActivityDefaultOptions = { +type ContractActivityOptions = { startToCloseTimeout?: DurationValue; scheduleToCloseTimeout?: DurationValue; scheduleToStartTimeout?: DurationValue;