diff --git a/.agents/rules/contract-patterns.md b/.agents/rules/contract-patterns.md index 86d5a070..d468c248 100644 --- a/.agents/rules/contract-patterns.md +++ b/.agents/rules/contract-patterns.md @@ -50,8 +50,9 @@ const processOrder = defineWorkflow({ cancel: defineSignal({ input: z.object({ reason: z.string() }) }), }, queries: { + // `input` is optional on signals/queries/updates — omit it for an + // argument-less definition (handler gets `undefined`). getStatus: defineQuery({ - input: z.object({}), output: z.object({ status: z.string() }), }), }, @@ -101,4 +102,4 @@ Any Standard Schema compatible library works: - `errors` — same shape as workflow errors; produced via the `errors` constructors in the implementation's helpers argument, and rehydrated as a typed `AsyncResult` error union on the workflow side - `defaultOptions` — contract-level `ActivityOptions` defaults (timeouts, retry). Merge precedence at the worker: `declareWorkflow` `activityOptions` < `defaultOptions` < `activityOptionsByName` -`defineContract` rejects collisions between workflow-local and global activity names at runtime — `defineContract` runs a Zod validation pass and throws a descriptive error. Activities share a single flat namespace at the worker level, so two activities can't share a name even across workflows. See `packages/contract/src/builder.ts:441` for the validation schema. +`defineContract` validates the contract's structure at runtime with a hand-rolled structural validator (no zod runtime dependency) and throws a descriptive error: strict root keys (only `taskQueue`/`workflows`/`activities`), identifier-safe names, Standard Schema slots, and collision checks. Activities share a single flat namespace at the worker level, so two _different_ definitions can't share a name even across workflows — but reusing the **same definition object** across workflows is allowed (it's one activity), and the collision message recommends hoisting shared activities to the global `activities` block. A workflow name colliding with a global activity name is also rejected (they share the root of the worker implementations map). See `packages/contract/src/builder.ts` (`validateContractDefinition`). diff --git a/.agents/rules/dependencies.md b/.agents/rules/dependencies.md index 5ee6e4f0..3b3235b9 100644 --- a/.agents/rules/dependencies.md +++ b/.agents/rules/dependencies.md @@ -2,16 +2,17 @@ ## Key Dependencies -| Dependency | Where it's used | -| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `@temporalio/client` | Temporal client SDK — peer dep of `client` | -| `@temporalio/worker` | Temporal worker SDK — peer dep of `worker` | -| `@temporalio/workflow` | Temporal workflow API — peer dep of `worker` | -| `@temporalio/common` | Shared Temporal types — peer dep of `client`/`worker` | -| `@standard-schema/spec` | Standard Schema specification — direct dep | -| `unthrown` | `Result` / `AsyncResult` — peer dep of `client`/`worker` | -| `zod` | Direct dep of `contract` (used internally for the `defineContract` runtime validation pass); user-side schema lib for the others | -| `valibot` / `arktype` | User-side schema libraries (Standard Schema) | +| Dependency | Where it's used | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `@temporalio/client` | Temporal client SDK — peer dep of `client` | +| `@temporalio/worker` | Temporal worker SDK — peer dep of `worker` | +| `@temporalio/workflow` | Temporal workflow API — peer dep of `worker` | +| `@temporalio/common` | Shared Temporal types — peer dep of `client`/`worker` | +| `@temporalio/testing` | Time-skipping test server (`TestWorkflowEnvironment`) — peer dep of `testing` | +| `@standard-schema/spec` | Standard Schema specification — direct dep | +| `unthrown` | `Result` / `AsyncResult` — peer dep of `client`/`worker` | +| `zod` | User-side schema library (Standard Schema); dev-only in this repo — `defineContract`'s structural validation is hand-rolled | +| `valibot` / `arktype` | User-side schema libraries (Standard Schema) | `pino` appears in the catalog and is used by `examples/` only — it's not imported from any published package's `src/`. @@ -37,15 +38,17 @@ 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 | `vitest ^4` (the `globalSetup` hook integrates with vitest's test runner), `@temporalio/client ^1`, `@temporalio/worker ^1` (both exposed by the `it` fixture's public types) | +| 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`) | 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. +**Deliberate exception:** the testing package's `@temporal-contract/*` sibling peers have NO matching devDeps. client and worker devDepend on testing for their integration fixtures, so adding the siblings to testing's devDeps would put a cycle in the package graph, which turbo 2 rejects. Local resolution goes through tsconfig `paths` mapped to the siblings' sources plus vitest aliases instead — see the comments in `packages/testing/tsconfig.json` and `packages/testing/vitest.config.ts`. For the same reason these peers use **concrete `^8.x` semver ranges, not `workspace:^`**: pnpm can only rewrite the `workspace:` protocol at pack/publish time for deps that are actually installed, so `workspace:^` here breaks `pnpm pack`/`pnpm publish` (`ERR_PNPM_CANNOT_RESOLVE_WORKSPACE_PROTOCOL`). Changesets keeps the ranges bumped (`updateInternalDependencies: "patch"`, and the four packages are a fixed group). + ## Security `overrides` (`pnpm-workspace.yaml`) `pnpm-workspace.yaml` pins minimum versions for transitive dependencies via its `overrides:` block to close known CVEs (currently `fast-uri`, `protobufjs`, and `testcontainers>undici`). When a security audit flags a new vulnerability, add the pin there (with a comment citing the GHSA and the reachability reasoning) rather than waiting for upstream to update. Advisories that are unreachable in this repo are suppressed via `auditConfig.ignoreGhsas`, each with a documented justification. diff --git a/.agents/rules/handlers.md b/.agents/rules/handlers.md index 653084a3..f420c7ed 100644 --- a/.agents/rules/handlers.md +++ b/.agents/rules/handlers.md @@ -23,14 +23,15 @@ export const activities = declareActivitiesHandler({ }); ``` -`fromPromise(promise, qualify)` forces every rejection through `qualify`, which +`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 `qualify(type, options?)` helper that builds -that function — `fromPromise(inventoryService.check(orderId), qualify("INVENTORY_CHECK_FAILED"))` +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 -value you already have, lift a sync result with `Ok(value).toAsync()` / -`Err(failure).toAsync()` — unthrown has no `okAsync`/`errAsync`. +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`. Canonical example: `examples/order-processing-worker/src/application/activities.ts`. @@ -102,10 +103,13 @@ Typed-error semantics inside the workflow context: `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. Same shape on the client: `TypedClient.create({ contract, client })` +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). Deprecated throwing aliases (`createWorkerOrThrow`, -`TypedClient.createOrThrow`) exist for migration. +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`. ```typescript import { createWorker, workflowsPathFromURL } from "@temporal-contract/worker/worker"; @@ -129,14 +133,12 @@ await workerResult.value.run(); Workflows opt into cancellation control via `context.cancellableScope` / `context.nonCancellableScope`. They fold cancellation into the project's `AsyncResult` shape — callers branch on `Err(WorkflowCancelledError)` instead of catching `CancelledFailure`. ```typescript -import { isErr } from "unthrown"; - implementation: async (context, args) => { const result = await context.cancellableScope(async () => { return context.activities.processStep(args); }); - if (isErr(result)) { + if (result.isErr()) { // Workflow was cancelled. Cleanup that must not be cancelled itself // goes inside `nonCancellableScope`. await context.nonCancellableScope(async () => { @@ -151,7 +153,7 @@ implementation: async (context, args) => { - `cancellableScope(fn)` — returns `AsyncResult`. Cancels propagate from outside. - `nonCancellableScope(fn)` — same shape; _outside_ cancels are ignored. Cancels raised _inside_ still surface as `Err(...)`. Use for graceful-shutdown cleanup. -- Non-cancellation errors thrown by `fn` are _unmodeled_ failures: they ride unthrown's **`defect`** channel (inspectable via `isDefect(result)` / `result.cause`, re-thrown at the edge), not the modeled `err` channel. +- Non-cancellation errors thrown by `fn` are _unmodeled_ failures: they ride unthrown's **`defect`** channel (inspectable via `result.isDefect()` / `result.cause`, re-thrown at the edge), not the modeled `err` channel. Canonical implementation: `packages/worker/src/cancellation.ts:38` (`cancellableScope`), `:75` (`nonCancellableScope`). Error class: `packages/worker/src/errors.ts:193`. @@ -182,7 +184,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, qualify)` chain whose `qualify` 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 `Err(ApplicationFailure.create({ type, message, nonRetryable })).toAsync()` (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/.agents/rules/project-overview.md b/.agents/rules/project-overview.md index cdb2d32c..f791c5ba 100644 --- a/.agents/rules/project-overview.md +++ b/.agents/rules/project-overview.md @@ -31,6 +31,6 @@ - **Contract** — defines task queue, workflows, activities, signals, queries, updates, search attributes with schemas. See [contract-patterns.md](./contract-patterns.md). - **Worker** — `declareWorkflow` + `declareActivitiesHandler` with automatic validation. See [handlers.md](./handlers.md). -- **Client** — `TypedClient.create()` returns `AsyncResult` for all operations. +- **Client** — `TypedClient.create({ client })` is connection-scoped; `client.for(contract)` hands out a contract-bound `ContractClient` whose operations return `AsyncResult`. - **Result** — `Result` and `AsyncResult` from unthrown for explicit error handling, plus a third `defect` channel for unanticipated failures. - **Determinism** — workflow code runs in Temporal's replay sandbox. See [workflow-determinism.md](./workflow-determinism.md). diff --git a/.agents/specs/2026-07-29-typed-client-contract-binding-design.md b/.agents/specs/2026-07-29-typed-client-contract-binding-design.md new file mode 100644 index 00000000..baaf3375 --- /dev/null +++ b/.agents/specs/2026-07-29-typed-client-contract-binding-design.md @@ -0,0 +1,271 @@ +# Design: decouple the client from the contract + +- **Date:** 2026-07-29 +- **Status:** implemented (`feat/v8-full-review-fixes`) +- **Target:** `@temporal-contract/client`, 8.0 beta line +- **Origin:** [millenium!56319 note 2210871](https://gitlab.factory.fonciamillenium.net/FonciaStark/millenium/-/merge_requests/56319#note_2210871) + +> Design docs live in `.agents/specs/`, not `docs/`. The VitePress config sets no +> `srcExclude`, so every `.md` under `docs/` becomes a published page. + +## Context + +The originating comment asks two things: + +> maybe we could create a nestjs provider for the TypedClient +> +> does it make sense to give the contract when calling `TypedClient.create`? It +> means we must have one TypedClient per contract. Instead we could have only one +> TypedClient and give the contract when calling `startWorkflow` or +> `executeWorkflow`? Or maybe we could find an other better DX + +It sits on `lessor-tax-declaration.service.ts` in a draft MR titled _"POC: evaluate +replacing the in-house Temporal integration with temporal-contract (compute +slice)"_, where a `TypedClient` is constructed **inside a request-scoped method**: + +```ts +async refreshDeclaration(declarationId: string): Promise { + // ... + const typedClient = await TypedClient.create({ + contract: leaseAccountingComputeContract, + client: this.client, + }) + .mapErr((error) => match(error).with(tag("@temporal-contract/TechnicalError"), /* ... */).exhaustive()) + .getOrThrow(); + + await typedClient.startWorkflow("computeOneLessorTaxDeclarationWorkflow", { /* ... */ }); +} +``` + +### What the investigation found + +1. **Construction is fallible and async only because of the connection.** `create` + awaits `ensureConnected()` and routes setup faults to the defect channel. + Contract binding itself is synchronous and free — `contract` feeds only + `taskQueue`, workflow lookup, schema validation, and `TypedScheduleClient`. + The two concerns are fused, which is what pushes construction into a request + handler. +2. **Part of the pain is a version artifact.** The consumer pins + `catalog:temporalContract7`. On 8.0 `create` returns + `AsyncResult<..., never>`, so the `mapErr` / + `tag("@temporal-contract/TechnicalError")` ceremony above does not compile and + collapses to `.get()`. +3. **The multi-contract case is not yet present.** Across the millenium monorepo, + temporal-contract is consumed by `applications/plato` (one contract) and, in + this POC, `service-lease-accounting` (`leaseAccountingComputeContract`). One + contract per application; no process binds two today. The driver for this + change is therefore separation of concerns, not contract count — see Rationale. +4. **Precedent exists for contract-per-call.** The worker's + `context.startChildWorkflow(contract, name, options)` takes the contract as a + required first parameter. + +## Rationale + +A client is a **connection**. A contract is a **schema**. Coupling them means a +connection cannot be established without first choosing a schema, which is +backwards: the connection is the scarce, fallible, process-lifetime resource, and +the schema is a free compile-time artifact. + +The practical consequences of the current coupling: + +- Establishing a connection requires naming a contract, so an application with + two contracts opens two clients and runs `ensureConnected()` twice. +- The fallible, async step cannot be hoisted to process start without also fixing + the contract there, which is what pushed construction into a request handler in + the originating MR. + +## Non-goals + +- **A NestJS integration package.** `@temporal-contract/client-nestjs` and + `@temporal-contract/worker-nestjs` were deliberately removed in PR #116 + (`53dfb80`, 2026-03-01) to "simplify the project scope and reduce maintenance + burden". The consumer additionally has its own in-house `@emeria/nestjs-temporal` + (`@ContractActivitiesHandler()`, activity explorer), so the need is already met + outside this repo. The removed provider was also one-module-per-contract, so it + would not have addressed this complaint anyway. +- **Moving the contract onto every call** + (`client.startWorkflow(contract, name, options)`). Rejected: it taxes every + call site, and `client.schedule` would need the contract threaded through + `TypedScheduleClient` as well. Binding once via `for()` gives the same + decoupling without the per-call cost. +- **Applying the same decoupling to the worker.** The client's contract coupling + was accidental — a connection and a schema have nothing to do with each other. + The worker's is essential: a Temporal `Worker` instance polls exactly one task + queue, and the contract is the worker's job description — it supplies the task + queue (`packages/worker/src/worker.ts` passes `taskQueue: contract.taskQueue`), + the workflows and activities to register, and the schemas to validate against. + An unbound worker would have nothing to poll and nothing to run, so + `createWorker({ contract, ... })` stays as-is. (The invariant is one `Worker` + _instance_ per task queue, not one worker process — Temporal scales + horizontally by running many processes on the same queue.) The client-side + principle — share the scarce connection — already holds on the worker side + without an API change: `createWorker` spreads its remaining options through to + `Worker.create`, so a process hosting two contracts runs two `Worker` instances + sharing one `NativeConnection`. The only gap is two contracts declaring the + _same_ `taskQueue` string, which would need one worker registering both + contracts' handlers; that is a design smell (two schema universes interleaved + on one queue) and stays unsupported until a real need appears. + +## Decision + +Split the class in two. + +```ts +/** Connection-scoped. No contract, no type parameter. */ +export class TypedClient { + static create(options: { + client: Client; + interceptors?: readonly ClientInterceptor[]; + }): AsyncResult; + + /** Bind a contract. Synchronous, infallible, memoized per contract identity. */ + for(contract: TContract): ContractClient; +} + +/** Contract-scoped. Everything TypedClient exposes today. */ +export class ContractClient { + startWorkflow(...): ...; + executeWorkflow(...): ...; + signalWithStart(...): ...; + getHandle(...): ...; + readonly schedule: TypedScheduleClient; +} +``` + +```ts +const client = await TypedClient.create({ client: temporal }).get(); // once, at startup + +await client.for(leaseContract).startWorkflow("computeOneLessorTaxDeclarationWorkflow", { + workflowId, + args, +}); +await client.for(platoContract).startWorkflow("someOtherWorkflow", { workflowId, args }); +``` + +No contract is privileged; every contract is reached the same way. + +### Naming + +`TypedClient` keeps its name even though the type parameter moves to +`ContractClient`. Renaming the root (`TemporalClient`, `ClientRoot`, …) was +considered — the unshipped beta is the only cheap window — and rejected: +`TypedClient` is the package's documented entry point, every consumer and doc +starts from it, and the change is easier to teach as "`TypedClient` now hands +out contract-bound clients" than as two renames at once. The name stays honest +enough: the root is what makes the client typed, via `for()`. + +### Semantics + +- **`for()` is infallible.** The `@temporalio/client >= 1.16` check (a missing + `client.schedule`) moves to `TypedClient.create`, where it belongs — it is a + property of the connection, not of any contract. `for()` therefore returns + `ContractClient` directly, with no `AsyncResult` wrapper. This is the + ergonomic win: binding is a plain expression, valid in a field initializer. +- **Memoized and identity-stable.** A + `WeakMap>` on the root, so + repeated `for(c)` returns the same instance rather than rebuilding + `TypedScheduleClient`. The map erases the type parameter, so the store and the + read each require a cast, contained to the two lines inside `for()`. Dropping + memoization was considered (construction is cheap, and the casts would go with + it) but identity stability keeps `for()` free to call in hot paths — the + natural consumer shape is `this.client.for(contract).startWorkflow(...)` per + request — without allocating a `TypedScheduleClient` per call. Note the + constraint this places on a future `for(contract, options)` overload: only the + option-less call could serve from the memo, so the `for(c) === for(c)` + guarantee is documented for the option-less form only. +- **`ContractClient` inherits `client` and `interceptors` from the root.** No + per-call interceptor override — YAGNI, and addable later without a break. +- **`create` still awaits `ensureConnected()`**, once per process rather than once + per contract. + +## Breaking changes + +8.0 has not shipped, so these land inside the existing beta line rather than +forcing a new major. + +| Before | After | +| -------------------------------------------------- | ---------------------------------------------- | +| `TypedClient.create({ contract, client })` | `TypedClient.create({ client }).for(contract)` | +| `TypedClient` (type annotation) | `ContractClient` | +| `TypedClient.createOrThrow(contract, client, ...)` | removed | + +`create({ contract, client })` is **not** retained as a deprecated alias: keeping +it would preserve exactly the contract dependency this change removes. +`createOrThrow` is already `@deprecated` and takes a contract positionally, so it +goes at the same time. + +Roughly 15 references to `TypedClient<...>` exist in-repo (tests, examples, +`docs/reference/client-surface.md`); about 6 are `TypedClient` +annotations that become `ContractClient`. + +## Implementation + +`packages/client/src/client.ts`. + +1. Rename `class TypedClient` to `ContractClient`, keeping + every member as-is. Its constructor stays private. +2. Move the `client.schedule` presence check out of that constructor into the new + root's `create`. +3. Add `class TypedClient` holding `client`, `interceptors`, and the memo + `WeakMap`; `static create({ client, interceptors })` performs the schedule check + and `ensureConnected()`, returning `AsyncResult`. +4. Add `TypedClient#for(contract)`: read the memo, otherwise construct a + `ContractClient` with `(contract, this.client, this.interceptors)`, store, + return. +5. Delete `createOrThrow` and `CreateTypedClientOptions`'s `contract` field. +6. Export `ContractClient` from the package entry point. +7. Add a TSDoc `@example` to both `create` and `for`, each showing its imports — + per the convention that a standalone example block names where its symbols come + from. + +`ContractClient` must be constructible from `TypedClient#for`. Since they are +separate classes, the constructor cannot stay `private`; make it `internal` by +convention (a documented `@internal` TSDoc tag plus omission from the public +surface docs) rather than exporting a factory. + +> `client.ts` is already ~1100 lines. Splitting `ContractClient` into its own +> module is tempting but would balloon the diff and obscure the rename in review. +> Keep both classes in `client.ts` for this change; split separately if it grows. + +## Testing + +| Level | File | Cases | +| ----------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Type | `packages/client/src/types-inference.spec.ts` | `for(c).startWorkflow(name)` accepts only `c`'s workflow names; two different contracts yield mutually incompatible `ContractClient` types; `TypedClient` accepts no type argument, so a stray 7.x-style `TypedClient` fails loudly during migration rather than resolving silently. This is where the real risk lives. | +| Unit | `packages/client/src/client.spec.ts` | `for(c)` twice returns the same instance; two contracts yield distinct instances; each uses its own `taskQueue` and validates against its own schemas; interceptors are inherited; `ensureConnected()` runs once per root; `create` rejects a `Client` without `schedule`. | +| Integration | `packages/client/src/__tests__/` | New `second.contract.ts` + `second.workflows.ts` fixtures and a second `Worker` on the second task queue; one case proving one root drives both contracts and that `for(secondContract).executeWorkflow(...)` is routed to the second queue against a live server. | + +Existing suites need their `TypedClient` fixtures retyped to +`ContractClient` and their construction updated to +`create({ client }).for(contract)`. The integration fixture mirrors the existing +`worker` vitest fixture in `__tests__/client.spec.ts` (`Worker.create` with +`taskQueue`, `workflowsPath`, `{ auto: true }`, shutdown in teardown). + +## Documentation + +- `docs/reference/client-surface.md` — document both classes and `for()`. +- `docs/how-to/upgrade-to-v8.md` — a migration section for the three breaking + changes in the table above. +- Every `TypedClient.create({ contract, client })` occurrence across `docs/`, + `README.md`, `packages/*/README.md` and `examples/` updated to the two-step + form. +- TSDoc `@example` on `create` and `for`, which render into the generated API docs. + +## Release + +A `major` changeset on `@temporal-contract/client` — the change is breaking. The +repo is in changesets pre mode on the `beta` tag, so it folds into the next +`8.0.0-beta.N` rather than opening a 9.0 line; the fixed group bumps all four +packages together. + +## Unrelated defect found while investigating + +`packages/worker/src/workflow.ts` — the TSDoc for `startChildWorkflow` and +`executeChildWorkflow` claims: + +> - Same contract: Pass workflowName from current contract +> - Cross-contract: Pass contract and workflowName to invoke workflows from other workers + +Both signatures take `contract` as a **required** first parameter; there is no +same-contract overload. The documentation describes an API that does not exist. +Fix separately. diff --git a/.agents/specs/2026-07-30-v8-review-remediation.md b/.agents/specs/2026-07-30-v8-review-remediation.md new file mode 100644 index 00000000..3377c6a4 --- /dev/null +++ b/.agents/specs/2026-07-30-v8-review-remediation.md @@ -0,0 +1,185 @@ +# v8 review remediation — decisions and work breakdown + +- **Date:** 2026-07-30 +- **Status:** implemented on `feat/v8-full-review-fixes` (Waves 1-4 complete) +- **Origin:** full six-track review (client, worker, contract+testing, unthrown audit, + amqp-contract consistency, DX/docs) performed 2026-07-29/30. This spec records the + decisions and the fix plan. All breaking changes land inside the unshipped 8.0 beta. +- **Companion:** [2026-07-29-typed-client-contract-binding-design.md](2026-07-29-typed-client-contract-binding-design.md) + (the `TypedClient`/`ContractClient` split), implemented as part of this work. + +## Design decisions + +### D1. Wire format: validate on send, parse on receive (fixes double-transform) + +Today both sides of every boundary run the same Standard Schema and the sender +transmits the **parsed** value, so a transforming schema (`z.coerce.*`, +`.transform(...)`) is applied twice — silent data corruption. + +Decision: **each boundary parses exactly once, on the receiving side.** The sender +still validates (to surface a typed `Err`/`ValidationError` early and to guarantee it +never emits garbage) but transmits the **original** value, discarding the parsed +result. Concretely: + +- Client `startWorkflow`/`executeWorkflow`/`signalWithStart` args: validate, send the + caller's original args. The worker parses and the handler receives the parsed value. +- Workflow/activity/query/update **results**: the producing side validates and returns + the original value; the consuming side (client `result()`/`executeWorkflow`, + workflow's activity proxy, update/query result paths) parses. +- Same rule for signals, updates, queries, child workflows, and both directions of + activities. + +Type surface is unchanged (`ClientInfer*`/`WorkerInfer*` duality already models +input-on-send / output-on-receive). Add tests with a transforming schema proving each +transform applies exactly once end-to-end. Update the worker's "double validation" +rationale comments to describe the new contract. + +### D2. Signals: invalid payloads are dropped and logged, never thrown + +Throwing `SignalInputValidationError` (non-retryable `ApplicationFailure`) from the +signal handler terminally kills the workflow execution — wrong for a fire-and-forget +message any stale client can send. Decision: on validation failure, **drop the signal +and `log.warn`** (via `@temporalio/workflow`'s `log`) with signal name + issues. +Delete `SignalInputValidationError`. Queries/updates keep their current (correct) +semantics. No configurable policy yet — YAGNI, addable without a break. + +### D3. Contract-misuse errors in workflow code become non-retryable ApplicationFailures + +`bindSignalHandler`/`bindQueryHandler`/`bindUpdateHandler` ("not found in contract", +"schema must be synchronous") and `buildRawActivitiesProxy` (activity-options coverage) +currently throw plain `Error` from inside the workflow sandbox, hanging executions in +infinite Workflow Task retries. Decision: introduce a `ContractMisuseError` extends +`ValidationError` (non-retryable `ApplicationFailure`) and use it at all such sites. + +### D4. Scope cuts (deferred, not forgotten) + +Checked = still deferred as of Wave 4 completion (nothing below landed in this work): + +- [x] Typed **local activities** path — new feature, separate spec. _Still deferred._ +- [x] Client `list`/`count` — covered by the new raw escape hatch for now. _Still deferred._ +- [x] amqp-contract / async-contract convergence (`declare*` verbs, `for()` in amqp, + `sideEffects`) — separate repos, separate track. _Still deferred._ +- [x] Configurable invalid-signal policy (D2) and per-binding interceptor overrides. + _Still deferred._ + +## Work breakdown + +### Wave 1 — contract package + meta/docs (parallel) + +Contract (`packages/contract`): + +1. Activity-collision check: allow same-named activities across workflows when they are + the **same object** (reference equality); error message for real collisions should + recommend hoisting shared activities to the global `activities` block. +2. Reject workflow-name vs **global-activity-name** collisions in `defineContract` + (they share the root of the implementations map). +3. Replace the zod meta-validation of contract shape with a hand-rolled structural + validator (amqp-contract style); **drop the zod runtime dependency**. Root shape + check becomes strict (unknown keys rejected), matching `defaultOptions`. +4. Make `input` optional on signal/query/update definitions (absent ⇒ handler input is + `undefined`, no `z.void()` ceremony). Mirror in worker's `extractHandlerInput` + (zero args ⇒ `undefined`) — worker side lands in Wave 3. +5. Delete `InferContractWorkflows` (trivial alias). Fix stale "unthrown 4" comment in + `errors.ts`. +6. Drop the CJS build (ESM-only, rule 5): remove `main`/`module`/`require` conditions, + align `types`. + +Meta/docs (no package source): + +7. `@beta` dist-tag warning + install commands on root README, `docs/how-to/install.md`, + tutorial step 1 (npm `latest` is v7; docs teach v8). +8. CLAUDE.md rule 2 correction: unthrown 5 **does** export `OkAsync`/`ErrAsync`; the + rule should say "no lowercase `okAsync`/`errAsync`; use `OkAsync`/`ErrAsync` or + `.toAsync()`". Same fix in `.agents/rules/handlers.md` (also its free-function + `isErr(result)` example → method style). +9. `dependencies.md`: add `@temporalio/testing ^1` to the testing row; align unthrown + ranges. +10. `examples/README.md`: fix stale "Promise-based worker" / "Result/Future" wording, + list all three example packages. + +### Wave 2 — wire format (client + worker together) + +Implement D1 across `packages/client` and `packages/worker` with transform-schema +tests (unit level in each package; one end-to-end case in the client integration +suite). No other refactors in this wave. + +### Wave 3 — client and worker overhauls (parallel; contract package is frozen) + +Client (`packages/client`): + +11. Implement the `TypedClient`/`ContractClient` split per the companion spec + (including its Testing/Documentation tables). +12. Fix `handle.result()` passing `workflowId` as `workflowName` to + `WorkflowValidationError`; add a `workflowId` field to that error. +13. Schedule surface parity: typed `ScheduleAlreadyExistsError` / + `ScheduleNotFoundError` (classified like workflow errors, replacing + defect-channel-everything), add `update`, `backfill` on the handle and `list` on + the schedule client; `TypedScheduleClient` constructor becomes non-public. +14. Escape hatch + identifiers: `readonly raw` (underlying `Client`) on the root; + `firstExecutionRunId`/`runId` carried on typed handles; `getHandle` accepts + `runId`/options and becomes **synchronous** returning + `Result`; add typed `startUpdate` + alongside `executeUpdate`. +15. Rename `WorkflowNotFoundError` → `WorkflowNotInContractError` (SDK-name squat). +16. Delete the six unused `ClientInfer*` type exports; fix the inverted direction + comment; `: {}` fallbacks → `Record`. +17. Dedupe `executeWorkflow`'s inline copies of `classifyStartError`/ + `classifyResultError`; fold rehydrate-then-classify into one helper. +18. TSDoc/doc fixes: `createOrThrow` note (gone anyway with the split), orphaned + `TypedSearchAttributeMap` doc block, "Thrown when…" → "Surfaced…", module docs + above imports, `readonly` on handle fields and error arrays, search-attribute + value `typeof`-per-kind check, `interceptors.ts` combinator names + (`tapErrCases`/`recoverDefect`). Client README quick start `.getOrThrow()` → + `.get()` and rewritten for the split. Type-level tests per the companion spec plus + method-level inference pins. + +Worker (`packages/worker`): + +19. D2 (signal drop-and-log) and D3 (`ContractMisuseError`). +20. `declareActivitiesHandler`: error on workflow-name/global-activity collision + (defense-in-depth with contract check); iterate **definitions** to fail fast on + declared-but-missing implementations; consistent stray-key handling when + `contract.activities` is undefined. +21. `TypedChildWorkflowHandle`: add typed `signals` map (validated per D1) and + `firstExecutionRunId`. +22. `declareWorkflow`: runtime guard for unknown `workflowName` with available-names + message. +23. Workflow-only workers: `activities` optional on `createWorker`; activity-less + workflows no longer need `{}` entries (key remapping); `extractHandlerInput` zero + args ⇒ `undefined` (pairs with contract change 4). +24. Rename exported `qualify` → `qualifyFailure` (no alias; beta window). +25. `ValidationError` `name` property `enumerable: false`; extract the repeated + triple-nested conditional type helper; dedupe the duplicated sync-schema message. +26. Docs: fix the phantom same-contract child-workflow overload TSDoc; fix + `declareWorkflow`'s `.getOrThrow()` example; rewrite worker README + (`createWorker` + `workflowsPathFromURL`, `.js` imports, named contract export, + correct `cause` idiom); `declareActivitiesHandler` TSDoc example uses + `createWorker`. + +### Wave 4 — testing package, examples, docs sweep, release (after Wave 3) + +Testing (`packages/testing`) — needs the final client API: + +27. Contract-aware fixtures: `createContractTest(contract, options)` yielding + `{ client, worker, testEnv }`; `runActivity(definition, implementation, input)` + over `MockActivityEnvironment`. +28. Forward `TimeSkippingTestWorkflowEnvironmentOptions` through + `createTimeSkippingEnvironment(opts?)` and a fixture factory; descriptive error + when `inject` values are missing (global-setup not registered); close + `workerConnection` in try/catch; add `"./package.json"` export; + `createGlobalSetup(options?)` factory (image tags, env, quiet). +29. Fix `time-skipping.ts` TSDoc `.getOrThrow()` examples. + +Repo-wide: + +30. Examples: extend order-processing with a signal + query + one typed contract + error + a schedule, using the new client API. +31. Docs sweep: `upgrade-to-v8.md` migration sections for every breaking change here; + `client-surface.md`; all construction snippets to + `TypedClient.create({ client }).for(contract)`; regenerate API docs. +32. Adopt `publint --strict` + `attw --pack` (`check:package` script per package, + catalog entries, CI wiring) — from amqp-contract. +33. Changesets: one `major` changeset per affected package (folds into next + `8.0.0-beta.N`). +34. Full verification: build, typecheck, lint, unit tests everywhere; integration + tests if Docker is available. diff --git a/.changeset/config.json b/.changeset/config.json index cec88804..f1c28bb5 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,5 +1,5 @@ { - "$schema": "../node_modules/@changesets/config/schema.json", + "$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json", "changelog": "@changesets/cli/changelog", "commit": false, "fixed": [ diff --git a/.changeset/v8-review-remediation.md b/.changeset/v8-review-remediation.md new file mode 100644 index 00000000..22f5ca44 --- /dev/null +++ b/.changeset/v8-review-remediation.md @@ -0,0 +1,42 @@ +--- +"@temporal-contract/contract": major +"@temporal-contract/client": major +"@temporal-contract/worker": major +"@temporal-contract/testing": major +--- + +v8 review remediation — the full-surface overhaul from the six-track 8.0 review. Headline breaks, per package: + +**All boundaries (client + worker):** payloads are now parsed exactly once, on the receiving side. The sender still validates (surfacing a typed `Err`/`ValidationError` early) but transmits the caller's original value, so transforming schemas (`z.coerce.*`, `.transform(...)`) apply once end-to-end instead of twice. + +**`@temporal-contract/contract`:** + +- `defineContract`'s structural validation is hand-rolled and strict (unknown keys rejected); the zod runtime dependency is gone. +- `input` is optional on signal/query/update definitions — `defineSignal()` / `defineQuery({ output })` with no input means the handler receives `undefined`, no `z.void()` ceremony. +- ESM-only build (the CJS artifacts and `main`/`module`/`require` conditions are removed). +- `InferContractWorkflows` is deleted (trivial alias). + +**`@temporal-contract/client`:** + +- `TypedClient` is split from the contract: `TypedClient.create({ client })` is connection-scoped, and `typedClient.for(contract)` returns the contract-bound `ContractClient` with the workflow/schedule methods. A `readonly raw` escape hatch exposes the underlying `Client`. +- `getHandle` is synchronous, returns `Result`, and accepts `runId`/`firstExecutionRunId` options; handles carry `runId`/`firstExecutionRunId`; typed `startUpdate` joins `executeUpdate`. +- `WorkflowNotFoundError` is renamed `WorkflowNotInContractError`. +- `CreateTypedClientOptions` is renamed `CreateClientOptions` — the family-shared name for the `Typed*.create()` options shape (matching amqp-contract). +- Schedule surface parity: typed `ScheduleAlreadyExistsError`/`ScheduleNotFoundError` on the error channel (instead of defects), plus `update`, `backfill`, and `list`. +- The six unused `ClientInfer*` type exports are deleted. + +**`@temporal-contract/worker`:** + +- Invalid signal payloads are dropped and logged (`log.warn`), never thrown — `SignalInputValidationError` is deleted; a stale client can no longer terminally kill a workflow execution. +- Contract misuse inside workflow code (unknown signal/query/update or workflow name, async schema, uncovered activity options) now fails fast as a non-retryable `ContractMisuseError` `ApplicationFailure` instead of hanging executions in Workflow Task retries. +- Exported `qualify` is renamed `qualifyFailure` (no alias), and the deprecated `createWorkerOrThrow` is removed — use `createWorker(...).get()`. +- `defineActivityMiddleware` is renamed `declareActivityMiddleware` — the family convention is `define*` for contract authoring and `declare*` for implementation-side APIs, and middleware was the one implementation-side holdout. +- `activities` is optional on `createWorker`, and activity-less workflows no longer need `{}` entries in the implementations map. +- `TypedChildWorkflowHandle` gains a typed `signals` map and `firstExecutionRunId`. + +**`@temporal-contract/testing`:** + +- New contract-aware fixtures: `createContractTest(contract, { workflowsPath, activities?, workerOptions? })` yields `{ client, typedClient, worker }` against the testcontainers server, and `runActivity(definition, implementation, input, { env? })` runs one implementation inside `MockActivityEnvironment` (vitest-free). +- Configurable environments: `createTimeSkippingTest(options?)` / `createTimeSkippingEnvironment(options?)` forward `TimeSkippingTestWorkflowEnvironmentOptions`; `createGlobalSetup({ postgresImage?, temporalImage?, temporalEnv?, quiet? })` pins container images and env. +- The package now peer-depends on `@temporal-contract/contract`, `@temporal-contract/client`, `@temporal-contract/worker`, and `unthrown`. +- The time-skipping `testEnv` fixture is correctly typed (the fixture record previously declared a phantom `$worker` key, leaving `testEnv` untyped in consuming suites). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8d3191b..951330a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,3 +18,14 @@ jobs: with: integration-tests: true node-versions: '["", "22.19.0"]' + + package-check: + name: Package + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Setup + uses: ./.github/actions/setup + - name: Validate published surfaces (publint + are-the-types-wrong) + run: pnpm run check:packages diff --git a/AGENTS.md b/AGENTS.md index b6a2785f..e8a1df44 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 `okAsync`/`errAsync`: lift a sync `Result` with `.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 `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. 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/README.md b/README.md index 128f8d11..e079d420 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Implement the activities — note that workflow-scoped activities nest under the workflow, mirroring the contract: ```typescript -import { declareActivitiesHandler, qualify } from "@temporal-contract/worker/activity"; +import { declareActivitiesHandler, qualifyFailure } from "@temporal-contract/worker/activity"; import { fromPromise } from "unthrown"; export const activities = declareActivitiesHandler({ @@ -73,9 +73,11 @@ export const activities = declareActivitiesHandler({ activities: { processOrder: { chargeCard: ({ customerId, amount }) => - fromPromise(gateway.charge(customerId, amount), qualify("CHARGE_FAILED")).map((charge) => ({ - transactionId: charge.id, - })), + fromPromise(gateway.charge(customerId, amount), qualifyFailure("CHARGE_FAILED")).map( + (charge) => ({ + transactionId: charge.id, + }), + ), }, }, }); @@ -112,7 +114,8 @@ partial state, nothing to unwind. - **End-to-end type safety** — workflows, activities, signals, queries, updates, errors, and search attributes all derive from one contract - **Validation at every boundary** — Standard Schema (Zod, Valibot, ArkType) runs - on both sides of every network hop + on both sides of every network hop: validated on send, parsed on receive, so + transforms apply exactly once - **Typed domain errors** — declare failures on the contract; consume them as schema-validated values with an exhaustive matcher - **Explicit error handling** — `Result` / `AsyncResult` from @@ -128,13 +131,22 @@ partial state, nothing to unwind. ## Install +> **8.0 is currently a prerelease.** npm's `latest` tag still resolves to 7.x, +> while this README documents the v8 API. Install the `@temporal-contract/*` +> packages with the `beta` tag until 8.0 is stable — a plain +> `pnpm add @temporal-contract/contract` gives you the previous major. + ```bash -# Core packages -pnpm add @temporal-contract/contract @temporal-contract/worker @temporal-contract/client +# Core packages (8.0 beta — `latest` still resolves 7.x) +pnpm add @temporal-contract/contract@beta @temporal-contract/worker@beta \ + @temporal-contract/client@beta -# Peer dependencies -pnpm add unthrown zod \ +# Peer dependencies (stable releases) +pnpm add unthrown \ @temporalio/client @temporalio/common @temporalio/worker @temporalio/workflow + +# Plus one Standard Schema validator of your choice — zod, valibot, arktype, … +pnpm add zod ``` Requires **Node.js ≥ 22.19**, ESM (`"type": "module"`), and TypeScript `strict`. diff --git a/docs/examples/index.md b/docs/examples/index.md index 09ab6688..bc8e6de7 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -11,11 +11,11 @@ of the same thing step by step. Three packages, mirroring how a real deployment splits: -| Package | Contents | -| --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | -| [`order-processing-contract`](https://github.com/btravstack/temporal-contract/tree/main/examples/order-processing-contract) | Schemas and the contract. Depended on by the other two | -| [`order-processing-worker`](https://github.com/btravstack/temporal-contract/tree/main/examples/order-processing-worker) | Activities, workflow, worker, integration tests | -| [`order-processing-client`](https://github.com/btravstack/temporal-contract/tree/main/examples/order-processing-client) | Starts workflows and handles results | +| Package | Contents | +| --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`order-processing-contract`](https://github.com/btravstack/temporal-contract/tree/main/examples/order-processing-contract) | The shared contract, built composition-first with the `define*` helpers: signals (payload-carrying and payload-less), an argument-less query, a `PaymentDeclined` typed error shared by activity and workflow, and a schedule-ready, activity-less cleanup workflow. Depended on by the other two | +| [`order-processing-worker`](https://github.com/btravstack/temporal-contract/tree/main/examples/order-processing-worker) | Clean-architecture worker: `AsyncResult` activities with `qualifyFailure` and typed error constructors, a `condition`-based approval gate with signal/query handlers, an activity-less schedule-driven workflow, and integration tests | +| [`order-processing-client`](https://github.com/btravstack/temporal-contract/tree/main/examples/order-processing-client) | `TypedClient.create({ client }).for(contract)` in action: typed signals/queries through handles, the synchronous `getHandle`, exhaustive `match` + `P.tag` including the rehydrated `PaymentDeclined` contract error, and `schedule.create` with the create-if-absent idiom | ### What it demonstrates @@ -68,7 +68,7 @@ pnpm --filter @temporal-contract/sample-order-processing-worker test | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | | [`contract.ts`](https://github.com/btravstack/temporal-contract/blob/main/examples/order-processing-contract/src/contract.ts) | The global vs workflow-scoped activity split | | [`workflows.ts`](https://github.com/btravstack/temporal-contract/blob/main/examples/order-processing-worker/src/application/workflows.ts) | Compensation logic, cancellation handling, per-activity options | -| [`activities.ts`](https://github.com/btravstack/temporal-contract/blob/main/examples/order-processing-worker/src/application/activities.ts) | The nested implementation map, `fromPromise` + `qualify` | +| [`activities.ts`](https://github.com/btravstack/temporal-contract/blob/main/examples/order-processing-worker/src/application/activities.ts) | The nested implementation map, `fromPromise` + `qualifyFailure` | ## Smaller, focused examples diff --git a/docs/explanation/nexus.md b/docs/explanation/nexus.md index e67e7396..cdadfc5c 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 }), - qualify("NEXUS_CHARGE_FAILED")), + qualifyFailure("NEXUS_CHARGE_FAILED")), } ``` diff --git a/docs/explanation/the-result-model.md b/docs/explanation/the-result-model.md index a58d9091..d36f9db9 100644 --- a/docs/explanation/the-result-model.md +++ b/docs/explanation/the-result-model.md @@ -17,7 +17,7 @@ three. | `defect` | A failure you did **not** model | `result.cause` | An `err` is a value you produced on purpose — `Err(...)`, or a rejection mapped -through `fromPromise(promise, qualify(...))`. It is part of your type signature, +through `fromPromise(promise, qualifyFailure(...))`. It is part of your type signature, and callers are expected to branch on it. A `defect` is what happens when something throws that you never modeled: a @@ -57,7 +57,7 @@ anticipated failure modes_. Everything that can go wrong is a defect. ```typescript // `.get()` rethrows a defect's original cause — the right behaviour at startup -const client = await TypedClient.create({ contract, client: temporalClient }).get(); +const client = await TypedClient.create({ client: temporalClient }).get(); ``` ## The shapes at each boundary diff --git a/docs/explanation/validation-boundaries.md b/docs/explanation/validation-boundaries.md index 6915b54d..373649bb 100644 --- a/docs/explanation/validation-boundaries.md +++ b/docs/explanation/validation-boundaries.md @@ -1,59 +1,75 @@ # Validation boundaries -Where schemas run, why some data is validated twice, and what that buys. +Where schemas run, why both sides of a hop run one, and what that buys. ## The map +Every boundary follows one rule: **validate on send, parse on receive.** The +sender runs the schema to fail early but transmits the caller's _original_ +value; the receiver parses, and the parsed value is what the handler sees. + ``` ┌─ Client process ───────────────────────────────────────┐ │ executeWorkflow(args) │ │ │ │ -│ ├─▶ ① workflow input schema │ +│ ├─▶ ① validate workflow input (send original) │ │ ▼ │ └──────┼──────────────────────────────────────────────────┘ │ network ┌──────▼─── Worker process ──────────────────────────────┐ │ workflow function │ -│ ├─▶ ② workflow input schema (again) │ +│ ├─▶ ② parse workflow input (handler gets it) │ │ │ │ │ │ context.activities.chargeCard(input) │ -│ │ ├─▶ ③ activity input schema │ +│ │ ├─▶ ③ validate activity input (send orig.) │ │ │ ▼ network │ │ │ activity implementation │ -│ │ ├─▶ ④ activity input schema (again) │ +│ │ ├─▶ ④ parse activity input │ │ │ │ ... your code ... │ -│ │ ├─▶ ⑤ activity output schema │ +│ │ ├─▶ ⑤ validate activity output (send orig.)│ │ │ ▼ network │ -│ │ └─▶ ⑥ activity output schema (again) │ +│ │ └─▶ ⑥ parse activity output │ │ │ │ -│ └─▶ ⑦ workflow output schema │ +│ └─▶ ⑦ validate workflow output (send original) │ └──────┼──────────────────────────────────────────────────┘ │ network ┌──────▼─── Client process ──────────────────────────────┐ -│ └─▶ ⑧ workflow output schema (again) │ +│ └─▶ ⑧ parse workflow output │ └─────────────────────────────────────────────────────────┘ ``` -Signals, queries, and updates follow the same pattern: validated on the client -before dispatch and on the worker before the handler runs. +Signals, queries, updates, and child workflows follow the same pattern: +validated on the sending side before dispatch, parsed on the receiving side +before the handler (or caller) sees the value. -## Why validate twice +## Why both sides run the schema -Points ①/② and ③/④ look redundant. They are not. +Points ①/② and ③/④ look redundant. They are not — they do different jobs. -**The caller-side check is for diagnostics.** It catches bad data _before_ it +**The send-side check is for diagnostics.** It catches bad data _before_ it crosses the network, so you get a descriptive schema error naming the offending field, at the call site, with a stack trace pointing at your code. Without it, the same mistake surfaces as a deserialization failure inside a worker you may -not even own. - -**The callee-side check is authoritative.** The worker cannot assume its caller -used this library. A workflow may be started by the Temporal CLI, the Web UI, -another SDK, or an older version of your own client. The contract is only a -real guarantee if the side that enforces it is the side that runs the code. - -The cost is a schema parse against data that already passed one — negligible -next to a network round-trip. +not even own. Its parsed result is deliberately **discarded** — the wire +carries the original value. + +**The receive-side parse is authoritative.** The worker cannot assume its +caller used this library. A workflow may be started by the Temporal CLI, the +Web UI, another SDK, or an older version of your own client. The contract is +only a real guarantee if the side that enforces it is the side that runs the +code. + +**Parsing once is what keeps transforms correct.** Schemas can transform — +`z.coerce.date()`, `.transform(...)`, `.default(...)`. If both sides applied +the parse and the wire carried the parsed value, every transform would run +twice, silently corrupting data (a date coerced twice, a default applied to an +already-defaulted object). Because the sender transmits the original and only +the receiver's parse "counts", **each transform applies exactly once per +boundary**. It also means what travels the wire — and what you see in the +Temporal Web UI or a raw history export — is the sender's original value. + +The cost is one extra schema run per hop — negligible next to a network round +trip. ## Fail fast, fail nowhere @@ -100,6 +116,7 @@ disagreeing. | Worker, entering a workflow | `WorkflowInputValidationError` | Thrown; terminal | | Worker, leaving a workflow | `WorkflowOutputValidationError` | Thrown; terminal | | Worker, entering/leaving an activity | `ActivityInputValidationError`, `ActivityOutputValidationError` | Thrown; terminal | +| Worker, receiving a signal | — | Signal dropped; `log.warn` | | Contract error payload | `ContractErrorDataValidationError` | Thrown; terminal | Worker-side validation errors extend Temporal's `ApplicationFailure` and are @@ -107,6 +124,14 @@ marked **non-retryable**. This is the right default: a schema mismatch is deterministic. Retrying the same payload against the same schema will fail identically, so retrying would only burn attempts and delay the real signal. +The signal row is the deliberate exception. A signal is a fire-and-forget +message any stale client can send; failing the whole execution over one +malformed payload would let any sender kill any workflow. The worker drops the +signal and logs a warning (via `@temporalio/workflow`'s replay-aware +`log.warn`, with the signal name and the schema issues) — the execution +continues untouched. Client-side, a malformed signal still fails early with +`SignalValidationError` before dispatch. + All of them carry `issues` — the raw Standard Schema issue array — for programmatic inspection, and a human-readable summary in `message`: @@ -121,18 +146,33 @@ if (result.isErr() && result.error instanceof WorkflowValidationError) { ## Structure is validated too `defineContract` validates the contract itself, at call time — not the data, the -_shape_: +_shape_ (a hand-rolled structural check; the contract package has no runtime +schema-library dependency): - `taskQueue` present and non-empty - at least one workflow +- no unknown keys at the contract root (strict — only `taskQueue`, + `workflows`, `activities`) - every name a valid JavaScript identifier - every schema slot Standard Schema compatible -- no activity-name collisions in the flat runtime namespace +- no activity-name collisions in the flat runtime namespace — reusing the + _same_ definition object across workflows is fine (that is one activity, not + a collision); two different definitions under one name is rejected, with a + 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` Because this runs at import time, a malformed contract fails when the process starts rather than when a workflow first executes. +Inside the workflow sandbox, contract misuse — binding a handler for an +undeclared signal/query/update, an async-validating query schema, an activity +no options cover — throws `ContractMisuseError`, a non-retryable +`ApplicationFailure`. It fails the execution terminally instead of hanging it +in an infinite Workflow Task retry loop, which is what a plain `Error` thrown +from sandbox code would cause. + ## Where middleware and interceptors sit The two extension points sit on opposite sides of the boundary, and the diff --git a/docs/how-to/add-activity-middleware.md b/docs/how-to/add-activity-middleware.md index 9b7c3679..ae25e229 100644 --- a/docs/how-to/add-activity-middleware.md +++ b/docs/how-to/add-activity-middleware.md @@ -64,13 +64,13 @@ export const activities = declareActivitiesHandler({ ## Inject typed context The most useful thing middleware does is extend the typed context that flows to -implementations. Use `defineActivityMiddleware` to pin the in and out types: +implementations. Use `declareActivityMiddleware` to pin the in and out types: ```typescript -import { defineActivityMiddleware, type EmptyContext } from "@temporal-contract/worker/activity"; +import { declareActivityMiddleware, type EmptyContext } from "@temporal-contract/worker/activity"; import { Err } from "unthrown"; -const withTenant = defineActivityMiddleware( +const withTenant = declareActivityMiddleware( (invocation, next) => { const tenantId = (invocation.input as { tenantId?: string }).tenantId; @@ -96,7 +96,10 @@ export const activities = declareActivitiesHandler({ processOrder: { chargeCard: ({ customerId, amount }, { context }) => // context.tenantId: string - fromPromise(gateway.charge(context.tenantId, customerId, amount), qualify("CHARGE_FAILED")), + fromPromise( + gateway.charge(context.tenantId, customerId, amount), + qualifyFailure("CHARGE_FAILED"), + ), }, }, }); @@ -144,7 +147,7 @@ export const activities = declareActivitiesHandler({ activities: { processOrder: { chargeCard: ({ customerId, amount }, { context }) => - fromPromise(context.gateway.charge(customerId, amount), qualify("CHARGE_FAILED")), + fromPromise(context.gateway.charge(customerId, amount), qualifyFailure("CHARGE_FAILED")), }, }, }); @@ -157,7 +160,7 @@ export const activities = declareActivitiesHandler({ smuggle unvalidated data past the contract: ```typescript -const normalizeEmail = defineActivityMiddleware((invocation, next) => { +const normalizeEmail = declareActivityMiddleware((invocation, next) => { const input = invocation.input as { email?: string }; if (typeof input.email !== "string") { return next(); diff --git a/docs/how-to/configure-a-worker.md b/docs/how-to/configure-a-worker.md index 92ebde8f..a4534de3 100644 --- a/docs/how-to/configure-a-worker.md +++ b/docs/how-to/configure-a-worker.md @@ -52,11 +52,6 @@ await result.value.run(); `.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. -::: tip `createWorkerOrThrow` is deprecated -It exists to ease migration from the pre-`AsyncResult` API and will be removed -in a future major. Use `createWorker`. -::: - ## Resolve the workflows path Temporal bundles workflow code into an isolated sandbox, so it needs a _path_, @@ -89,7 +84,35 @@ src/ The rule: **`workflows.ts` may import `contract.ts` and nothing with side effects.** See [Architecture](/explanation/architecture). -## Tune concurrency +## Run a workflow-only worker + +`activities` is optional. Omit it and the worker registers no activities and +polls exclusively for Workflow Tasks: + +```typescript +import { createWorker, 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({ + contract: orderContract, + connection, + workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"), + // no `activities` +}).get(); + +await worker.run(); +``` + +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 +same task queue — Temporal routes each task kind to whichever worker polls +for it. ```typescript const worker = await createWorker({ diff --git a/docs/how-to/define-a-contract.md b/docs/how-to/define-a-contract.md index 40fc996e..cfa19036 100644 --- a/docs/how-to/define-a-contract.md +++ b/docs/how-to/define-a-contract.md @@ -126,7 +126,6 @@ const approve = defineSignal({ }); const getStatus = defineQuery({ - input: z.object({}), output: z.object({ state: z.enum(["pending", "approved", "shipped"]) }), }); @@ -145,8 +144,11 @@ const processOrder = defineWorkflow({ }); ``` -Signals have `input` only. Queries and updates have both `input` and `output`. -A no-argument query takes `input: z.object({})`. +Signals have `input` only. Queries and updates have `input` and `output`. On +all three, `input` is optional: omit it for a no-payload signal +(`defineSignal()`) or a no-argument query/update (`defineQuery({ output })`, +`defineUpdate({ output })`) — the handler then receives `undefined`, and the +client-side payload argument becomes omittable. See [Use signals, queries, and updates](/how-to/use-signals-queries-and-updates) for handling them. @@ -172,8 +174,9 @@ const chargeCard = defineActivity({ ``` The error's key becomes the `ApplicationFailure.type` on the wire, `data` is -validated on both sides of the boundary, and `nonRetryable` drives Temporal's -retry policy straight from the contract. +validated when the error is raised and parsed when it is rehydrated on the +consuming side, and `nonRetryable` drives Temporal's retry policy straight +from the contract. Workflows declare errors the same way. See [Model domain errors](/how-to/model-domain-errors). diff --git a/docs/how-to/handle-cancellation.md b/docs/how-to/handle-cancellation.md index 6f92b2b3..cc8fbded 100644 --- a/docs/how-to/handle-cancellation.md +++ b/docs/how-to/handle-cancellation.md @@ -7,7 +7,7 @@ for completed steps, and record why it ended. ## Request cancellation from the client ```typescript -const bound = await client.getHandle("processOrder", "order-123"); +const bound = client.getHandle("processOrder", "order-123"); // synchronous Result if (bound.isErr()) { throw bound.error; } diff --git a/docs/how-to/implement-activities.md b/docs/how-to/implement-activities.md index d62c28d6..965b3476 100644 --- a/docs/how-to/implement-activities.md +++ b/docs/how-to/implement-activities.md @@ -7,7 +7,7 @@ produces the plain object Temporal's worker expects. ## The basic shape ```typescript -import { declareActivitiesHandler, qualify } from "@temporal-contract/worker/activity"; +import { declareActivitiesHandler, qualifyFailure } from "@temporal-contract/worker/activity"; import { fromPromise } from "unthrown"; import { orderContract } from "./contract.js"; @@ -17,14 +17,16 @@ export const activities = declareActivitiesHandler({ activities: { // Global activity — declared on the contract, so it sits at the root. sendNotification: ({ customerId, message }) => - fromPromise(mailer.send(customerId, message), qualify("NOTIFICATION_FAILED")), + fromPromise(mailer.send(customerId, message), qualifyFailure("NOTIFICATION_FAILED")), // Workflow-scoped activities nest under their workflow's name. processOrder: { chargeCard: ({ customerId, amount }) => - fromPromise(gateway.charge(customerId, amount), qualify("CHARGE_FAILED")).map((c) => ({ - transactionId: c.id, - })), + fromPromise(gateway.charge(customerId, amount), qualifyFailure("CHARGE_FAILED")).map( + (c) => ({ + transactionId: c.id, + }), + ), }, }, }); @@ -44,10 +46,10 @@ Activities return `AsyncResult` instead of throwing. `fromPromise` is the bridge: ```typescript -fromPromise(promise, qualify("SOMETHING_FAILED")); +fromPromise(promise, qualifyFailure("SOMETHING_FAILED")); ``` -`qualify(type)` builds the error mapper. When the promise rejects it wraps the +`qualifyFailure(type)` builds the error mapper. When the promise rejects it wraps the rejection in a Temporal `ApplicationFailure`: - an `Error` rejection keeps its own message and is preserved as `cause`, so @@ -55,15 +57,15 @@ rejection in a Temporal `ApplicationFailure`: - anything else falls back to `options.message`, or `String(error)`. ```typescript -qualify("CARD_DECLINED", { +qualifyFailure("CARD_DECLINED", { message: "Payment gateway rejected the charge", // used when the rejection isn't an Error nonRetryable: true, // Temporal stops retrying immediately details: [{ gateway: "stripe" }], // structured payload for the workflow }); ``` -::: warning `qualify` always wraps -Even when the rejection is _already_ an `ApplicationFailure`, `qualify` wraps +::: 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. @@ -72,7 +74,7 @@ The flip side: an inner `ApplicationFailure`'s own `type` and pass `{ nonRetryable: true }` yourself or write a custom mapper. ::: -For full control, skip `qualify` and write the mapper by hand: +For full control, skip `qualifyFailure` and write the mapper by hand: ```typescript fromPromise(gateway.charge(customerId, amount), (error) => @@ -95,11 +97,11 @@ so you do not need a separate `@temporalio/common` import. ```typescript processOrder: { chargeCard: ({ customerId, amount }) => - fromPromise(riskEngine.score(customerId), qualify("RISK_CHECK_FAILED")) + fromPromise(riskEngine.score(customerId), qualifyFailure("RISK_CHECK_FAILED")) .flatMap((score) => score > 0.9 ? Err(ApplicationFailure.create({ type: "HIGH_RISK", nonRetryable: true })).toAsync() - : fromPromise(gateway.charge(customerId, amount), qualify("CHARGE_FAILED")), + : fromPromise(gateway.charge(customerId, amount), qualifyFailure("CHARGE_FAILED")), ) .map((charge) => ({ transactionId: charge.id })), } @@ -125,9 +127,10 @@ export const activities = declareActivitiesHandler({ activities: { processOrder: { chargeCard: ({ customerId, amount }, { context }) => - fromPromise(context.gateway.charge(customerId, amount), qualify("CHARGE_FAILED")).map( - (c) => ({ transactionId: c.id }), - ), + fromPromise( + context.gateway.charge(customerId, amount), + qualifyFailure("CHARGE_FAILED"), + ).map((c) => ({ transactionId: c.id })), }, }, }); @@ -173,7 +176,7 @@ processOrder: { return { synced: true, attempts: attempt }; })(), - qualify("CATALOG_SYNC_FAILED"), + qualifyFailure("CATALOG_SYNC_FAILED"), ), } ``` diff --git a/docs/how-to/index-workflows-with-search-attributes.md b/docs/how-to/index-workflows-with-search-attributes.md index 0ae67153..eede4af0 100644 --- a/docs/how-to/index-workflows-with-search-attributes.md +++ b/docs/how-to/index-workflows-with-search-attributes.md @@ -98,7 +98,7 @@ instance into a typed partial object: ```typescript import { readTypedSearchAttributes } from "@temporal-contract/client"; -const bound = await client.getHandle("processOrder", "order-123"); +const bound = client.getHandle("processOrder", "order-123"); // synchronous Result if (bound.isErr()) { throw bound.error; } diff --git a/docs/how-to/install.md b/docs/how-to/install.md index 632f0c03..5f32f03f 100644 --- a/docs/how-to/install.md +++ b/docs/how-to/install.md @@ -11,6 +11,14 @@ ## Install the packages +::: warning 8.0 is currently a prerelease +The 8.0 line — the API these docs describe — is published under the `beta` +dist-tag, so a plain `npm install @temporal-contract/contract` still resolves +7.x. Install the `@temporal-contract/*` packages with the explicit `@beta` +tag, as the commands below do. The peer dependencies (`unthrown`, +`@temporalio/*`) are stable releases. +::: + Pick the packages for what you are building. Most applications split across processes, so each process installs only what it uses. @@ -18,30 +26,30 @@ processes, so each process installs only what it uses. ```bash [pnpm] # Shared — the contract, imported by every side -pnpm add @temporal-contract/contract +pnpm add @temporal-contract/contract@beta # Worker process -pnpm add @temporal-contract/worker +pnpm add @temporal-contract/worker@beta # Client process -pnpm add @temporal-contract/client +pnpm add @temporal-contract/client@beta # Tests -pnpm add -D @temporal-contract/testing +pnpm add -D @temporal-contract/testing@beta ``` ```bash [npm] -npm install @temporal-contract/contract -npm install @temporal-contract/worker -npm install @temporal-contract/client -npm install -D @temporal-contract/testing +npm install @temporal-contract/contract@beta +npm install @temporal-contract/worker@beta +npm install @temporal-contract/client@beta +npm install -D @temporal-contract/testing@beta ``` ```bash [yarn] -yarn add @temporal-contract/contract -yarn add @temporal-contract/worker -yarn add @temporal-contract/client -yarn add -D @temporal-contract/testing +yarn add @temporal-contract/contract@beta +yarn add @temporal-contract/worker@beta +yarn add @temporal-contract/client@beta +yarn add -D @temporal-contract/testing@beta ``` ::: @@ -54,15 +62,21 @@ 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 | `^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, 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` | + +The testing package additionally peer-depends on the other three +`@temporal-contract/*` packages — its contract-aware fixtures hand you a +`TypedClient` and run a worker, so it must resolve to _your_ copies of +contract, client, and worker. Installing all four packages (as the commands +above do) satisfies it. Plus a [Standard Schema](https://standardschema.dev/) library to write your schemas with — Zod, Valibot, or ArkType. @@ -97,9 +111,8 @@ It must resolve to **v5**. v5 is not compatible with v4; see ## Choose a schema library -Any Standard Schema implementation works. The contract package validates -contract _structure_ with Zod internally, but your schemas can be whatever you -prefer. +Any Standard Schema implementation works — the packages themselves are +schema-library-agnostic, so your schemas can be whatever you prefer. ::: code-group diff --git a/docs/how-to/intercept-client-calls.md b/docs/how-to/intercept-client-calls.md index fe9d8985..f678a373 100644 --- a/docs/how-to/intercept-client-calls.md +++ b/docs/how-to/intercept-client-calls.md @@ -13,12 +13,14 @@ smuggle unvalidated data past the contract. import { TypedClient, type ClientInterceptor } from "@temporal-contract/client"; const client = await TypedClient.create({ - contract: orderContract, client: temporalClient, interceptors: [tracing, retryTransient], // first entry is outermost }).get(); ``` +Interceptors live on the connection-scoped root, so every contract-bound +client obtained via `client.for(contract)` inherits the same chain. + ## Which operations are wrapped `args` is a discriminated union over `operation`: @@ -91,7 +93,7 @@ const retryTransient: ClientInterceptor = (args, next) => }); ``` -Modeled domain errors (`WorkflowNotFoundError`, a `ContractError`, a validation +Modeled domain errors (`WorkflowNotInContractError`, a `ContractError`, a validation failure) stay on the `err` channel. Branch on those with `flatMapErrCases`: ```typescript diff --git a/docs/how-to/migrate-from-neverthrow.md b/docs/how-to/migrate-from-neverthrow.md index 5c857c53..fca1b5bb 100644 --- a/docs/how-to/migrate-from-neverthrow.md +++ b/docs/how-to/migrate-from-neverthrow.md @@ -125,7 +125,7 @@ into one branch is compact: ```typescript matcher.with( - P.tag("@temporal-contract/WorkflowNotFoundError"), + P.tag("@temporal-contract/WorkflowNotInContractError"), P.tag("@temporal-contract/WorkflowValidationError"), P.tag("@temporal-contract/WorkflowFailedError"), (error) => report(error), @@ -214,17 +214,18 @@ const chargeCard = ({ customerId, amount }) => ```typescript // after — unthrown import { Err, Ok, fromPromise } from "unthrown"; -import { qualify } from "@temporal-contract/worker/activity"; +import { qualifyFailure } from "@temporal-contract/worker/activity"; const chargeCard = ({ customerId, amount }) => - fromPromise(gateway.charge(customerId, amount), qualify("CHARGE_FAILED")).flatMap((charge) => - charge.declined - ? Err(ApplicationFailure.create({ type: "DECLINED", nonRetryable: true })) - : Ok({ transactionId: charge.id }), + fromPromise(gateway.charge(customerId, amount), qualifyFailure("CHARGE_FAILED")).flatMap( + (charge) => + charge.declined + ? Err(ApplicationFailure.create({ type: "DECLINED", nonRetryable: true })) + : Ok({ transactionId: charge.id }), ); ``` -`qualify` is a temporal-contract helper that collapses the hand-written +`qualifyFailure` is a temporal-contract helper that collapses the hand-written `ApplicationFailure.create` mapper into one call. ## Combining results diff --git a/docs/how-to/model-domain-errors.md b/docs/how-to/model-domain-errors.md index f05b2708..7f5441e3 100644 --- a/docs/how-to/model-domain-errors.md +++ b/docs/how-to/model-domain-errors.md @@ -47,7 +47,7 @@ contract instead of being scattered across worker configuration. Implementations receive typed constructors as `errors` in the second argument: ```typescript -import { declareActivitiesHandler, qualify } from "@temporal-contract/worker/activity"; +import { declareActivitiesHandler, qualifyFailure } from "@temporal-contract/worker/activity"; import { Err, fromPromise, Ok } from "unthrown"; export const activities = declareActivitiesHandler({ @@ -55,7 +55,7 @@ export const activities = declareActivitiesHandler({ activities: { processOrder: { chargeCard: ({ customerId, amount }, { errors }) => - fromPromise(gateway.charge(customerId, amount), qualify("CHARGE_FAILED")).flatMap( + fromPromise(gateway.charge(customerId, amount), qualifyFailure("CHARGE_FAILED")).flatMap( (charge) => charge.declined ? // Typed on the caller's side; `nonRetryable` comes from the contract. @@ -199,7 +199,7 @@ result.match({ } }) .with( - P.tag("@temporal-contract/WorkflowNotFoundError"), + P.tag("@temporal-contract/WorkflowNotInContractError"), P.tag("@temporal-contract/WorkflowValidationError"), P.tag("@temporal-contract/WorkflowAlreadyStartedError"), P.tag("@temporal-contract/WorkflowFailedError"), @@ -256,7 +256,7 @@ rather than letting a malformed failure cross the wire. ## When not to use this Declared errors are for failures the _caller_ branches on. For technical faults -— a timeout, a connection reset, a bug — use `qualify` and a plain +— a timeout, a connection reset, a bug — use `qualifyFailure` and a plain `ApplicationFailure`. Retries handle those, and the caller has no meaningful decision to make. diff --git a/docs/how-to/run-child-workflows.md b/docs/how-to/run-child-workflows.md index e6a3c8e1..2680642d 100644 --- a/docs/how-to/run-child-workflows.md +++ b/docs/how-to/run-child-workflows.md @@ -81,7 +81,40 @@ implementation: async (context, order) => { }; ``` -The handle exposes `workflowId` and `result()`. +The handle exposes `workflowId`, `firstExecutionRunId` (the anchor of the +child's execution chain, stable across continue-as-new), a typed `signals` +map, and `result()`. + +## Signal a running child + +The handle's `signals` map mirrors the client handle's — one sender per +signal the child's contract entry declares, fully typed: + +```typescript +implementation: async (context, order) => { + const started = await context.startChildWorkflow(orderContract, "collectPayment", { + workflowId: `payment-${order.orderId}`, + args: { customerId: order.customerId, amount: order.total }, + }); + + if (started.isErr()) { + return { status: "failed", reason: started.error.message }; + } + + // Typed: the payload is checked against the child's signal schema. + const signaled = await started.value.signals.applyDiscount({ percent: 10 }); + if (signaled.isErr()) { + // ChildWorkflowError (incl. a payload failing validation before send) + // or ChildWorkflowCancelledError. + } + + const payment = await started.value.result(); + return { status: payment.isOk() ? "completed" : "failed" }; +}; +``` + +The payload is validated before sending and parsed by the child on receive; +for a payload-less signal (`defineSignal()`), the argument is omittable. ## Run children in parallel diff --git a/docs/how-to/schedule-workflows.md b/docs/how-to/schedule-workflows.md index 4d6b425f..2ee52d18 100644 --- a/docs/how-to/schedule-workflows.md +++ b/docs/how-to/schedule-workflows.md @@ -1,14 +1,19 @@ # Schedule workflows Temporal schedules start workflows on a recurring spec, with catch-up policies, -pause/resume, and manual triggers. `client.schedule` is the typed wrapper — the -workflow type and task queue come from the contract, and `args` are validated -against its input schema before the schedule is created. +pause/resume, and manual triggers. `schedule` on a contract-bound client is +the typed wrapper — the workflow type and task queue come from the contract, +and `args` are validated against its input schema before the schedule is +created: + +```typescript +const ledger = typedClient.for(ledgerContract); +``` ## Create a schedule ```typescript -const created = await client.schedule.create("reconcileLedger", { +const created = await ledger.schedule.create("reconcileLedger", { scheduleId: "nightly-reconcile", spec: { cronExpressions: ["0 2 * * *"], // 02:00 daily @@ -23,10 +28,39 @@ if (created.isErr()) { } ``` -The `err` channel is narrow: `WorkflowNotFoundError` (the name is not on the -contract) or `WorkflowValidationError` (the args failed the schema). Technical -faults — a duplicate schedule id, a transport error — ride the defect channel -with a `RuntimeClientError` cause. +The `err` channel is narrow: `WorkflowNotInContractError` (the name is not on +the contract), `WorkflowValidationError` (the args failed the schema), or +`ScheduleAlreadyExistsError` (a running schedule already owns this id). +Technical faults — a transport error, an unrecognized rejection — ride the +defect channel with a `RuntimeClientError` cause. + +## Create-if-absent + +`ScheduleAlreadyExistsError` is a typed branch, so idempotent setup is a +match away — bind to the existing schedule instead of failing: + +```typescript +import { P } from "unthrown"; + +const schedule = created.match({ + ok: (handle) => handle, + errCases: (matcher) => + matcher + .with(P.tag("@temporal-contract/ScheduleAlreadyExistsError"), () => + ledger.schedule.getHandle("nightly-reconcile"), + ) + .with( + P.tag("@temporal-contract/WorkflowNotInContractError"), + P.tag("@temporal-contract/WorkflowValidationError"), + (error) => { + throw error; // programming errors — fail loudly + }, + ), + defect: (cause) => { + throw cause; + }, +}); +``` ## Write the spec @@ -61,7 +95,7 @@ DST shifts will surprise you otherwise. ## Control overlap and catch-up ```typescript -await client.schedule +await ledger.schedule .create("reconcileLedger", { scheduleId: "nightly-reconcile", spec: { cronExpressions: ["0 2 * * *"] }, @@ -84,7 +118,7 @@ will happily run twenty copies at once after an outage. ## Start paused ```typescript -await client.schedule +await ledger.schedule .create("reconcileLedger", { scheduleId: "nightly-reconcile", spec: { cronExpressions: ["0 2 * * *"] }, @@ -104,7 +138,7 @@ await client.schedule `action` carries workflow-level overrides for each run: ```typescript -await client.schedule +await ledger.schedule .create("reconcileLedger", { scheduleId: "nightly-reconcile", spec: { cronExpressions: ["0 2 * * *"] }, @@ -130,7 +164,7 @@ are nested separately. ## Index the spawned runs ```typescript -await client.schedule +await ledger.schedule .create("reconcileLedger", { scheduleId: "nightly-reconcile", spec: { cronExpressions: ["0 2 * * *"] }, @@ -152,7 +186,7 @@ attributes](/how-to/index-workflows-with-search-attributes). The handle mirrors Temporal's lifecycle methods, wrapped in `AsyncResult`: ```typescript -const created = await client.schedule.create("reconcileLedger", {/* ... */}); +const created = await ledger.schedule.create("reconcileLedger", {/* ... */}); if (created.isErr()) throw created.error; const schedule = created.value; @@ -167,7 +201,9 @@ await schedule.trigger().get(); // Inspect current state. const described = await schedule.describe(); -if (described.isDefect()) { +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); @@ -176,26 +212,72 @@ if (described.isDefect()) { await schedule.delete().get(); ``` -Every method returns `AsyncResult` — there is no modeled error. An -unknown schedule id or a transport failure is a technical fault on the defect -channel. +Every method returns `AsyncResult` — the one +anticipated failure, a schedule the server no longer knows, is a typed `Err`. +Anything else (a transport failure, an unrecognized rejection) is a technical +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 defect silently. Chain `.get()` (which rethrows the original cause) or branch -on `isDefect()` — the same applies to every `AsyncResult` in this library. +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. ::: +## 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: + +```typescript +await schedule + .update((previous) => ({ + ...previous, + spec: { cronExpressions: ["0 3 * * *"] }, // move to 03:00 + })) + .get(); +``` + +The action's `workflowType` / `taskQueue` / `args` are **not** re-validated +against the contract here — 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: + +```typescript +await schedule + .backfill({ + start: new Date("2026-07-01T00:00:00Z"), + end: new Date("2026-07-08T00:00:00Z"), + overlap: "ALLOW_ALL", + }) + .get(); +``` + ## Reach an existing schedule -`client.schedule` wraps creation. To bind to a schedule this process did not -create, use the underlying SDK client and wrap it yourself, or keep the handle -from `create`: +`getHandle` binds to a schedule this process did not create. It is +synchronous and does no server round-trip — a wrong id surfaces as +`Err(ScheduleNotFoundError)` from the handle's methods: ```typescript -const handle = temporalClient.schedule.getHandle("nightly-reconcile"); -await handle.pause("manual intervention"); +const handle = ledger.schedule.getHandle("nightly-reconcile"); +await handle.pause("manual intervention").get(); +``` + +## List schedules + +`list` is a passthrough of Temporal's `ScheduleClient.list` — an +`AsyncIterable` of summaries across the namespace (not filtered to the +contract): + +```typescript +for await (const summary of ledger.schedule.list()) { + console.log(summary.scheduleId, summary.action); +} ``` ## Schedules or `sleep`? diff --git a/docs/how-to/test-workflows.md b/docs/how-to/test-workflows.md index 83ab57e0..26c74e02 100644 --- a/docs/how-to/test-workflows.md +++ b/docs/how-to/test-workflows.md @@ -80,7 +80,7 @@ it("rejects an invalid amount", async () => { `createContext` is the seam. Build the handler with fakes: ```typescript -import { declareActivitiesHandler, qualify } from "@temporal-contract/worker/activity"; +import { declareActivitiesHandler, qualifyFailure } from "@temporal-contract/worker/activity"; import { fromPromise } from "unthrown"; export const makeActivities = (deps: { gateway: PaymentGateway }) => @@ -90,9 +90,10 @@ export const makeActivities = (deps: { gateway: PaymentGateway }) => activities: { processOrder: { chargeCard: ({ customerId, amount }, { context }) => - fromPromise(context.gateway.charge(customerId, amount), qualify("CHARGE_FAILED")).map( - (c) => ({ transactionId: c.id }), - ), + fromPromise( + context.gateway.charge(customerId, amount), + qualifyFailure("CHARGE_FAILED"), + ).map((c) => ({ transactionId: c.id })), }, }, }); @@ -104,6 +105,66 @@ const activities = makeActivities({ }); ``` +### Run one activity with `Context.current()` working + +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(); +}); +``` + +Pass your own environment via the `env` option to observe heartbeats or +trigger cancellation: + +```typescript +import { runActivity } from "@temporal-contract/testing/activity"; +import { MockActivityEnvironment } from "@temporalio/testing"; +import { expect, it } from "vitest"; + +import { downloadReport } from "./activities.js"; +import { orderContract } from "./contract.js"; + +it("heartbeats while downloading", async () => { + const env = new MockActivityEnvironment(); + const heartbeats: unknown[] = []; + env.on("heartbeat", (details) => heartbeats.push(details)); + + const result = await runActivity( + orderContract.workflows.processOrder.activities.downloadReport, + downloadReport, + { reportId: "R-1" }, + { env }, + ); + + expect(result).toBeOk(); + expect(heartbeats.length).toBeGreaterThan(0); +}); +``` + +`env.cancel()` triggers cancellation the same way; a `CancelledFailure` the +implementation does not model surfaces on the defect channel. + ## Tier 2 — time-skipping, no Docker `@temporal-contract/testing/time-skipping` runs a lightweight local test server @@ -131,12 +192,11 @@ it("processes an order", async ({ testEnv }) => { }).get(); const client = await TypedClient.create({ - contract: orderContract, client: testEnv.client, }).get(); await worker.runUntil(async () => { - const result = await client.executeWorkflow("processOrder", { + const result = await client.for(orderContract).executeWorkflow("processOrder", { workflowId: "order-test-1", args: { orderId: "ORD-1", customerId: "CUST-1", amount: 42 }, }); @@ -164,10 +224,31 @@ export default defineConfig({ }); ``` -If you prefer explicit lifecycle management over the fixture: +The ready-made `it` uses default environment options. To pin the test-server +version or otherwise configure the environment, build your own `it` with +`createTimeSkippingTest` — options are forwarded to +`TestWorkflowEnvironment.createTimeSkipping` unchanged: + +```typescript +import { createTimeSkippingTest } from "@temporal-contract/testing/time-skipping"; + +const it = createTimeSkippingTest({ + server: { executable: { type: "cached-download", version: "v1.3.0" } }, +}); + +it("runs against the pinned server", async ({ testEnv }) => { + // ... +}); +``` + +If you prefer explicit lifecycle management over the fixture, +`createTimeSkippingEnvironment` accepts the same options (remember to call +`teardown()`): ```typescript import { createTimeSkippingEnvironment } from "@temporal-contract/testing/time-skipping"; +import type { TestWorkflowEnvironment } from "@temporalio/testing"; +import { afterAll, beforeAll } from "vitest"; let testEnv: TestWorkflowEnvironment; @@ -187,7 +268,7 @@ it("expires an unapproved order after 24 hours", async ({ testEnv }) => { // ... worker + client setup ... await worker.runUntil(async () => { - const started = await client.startWorkflow("processOrder", { + const started = await client.for(orderContract).startWorkflow("processOrder", { workflowId: "order-expiry", args: { orderId: "ORD-1", customerId: "CUST-1", amount: 42 }, }); @@ -219,6 +300,58 @@ export default defineConfig({ }); ``` +To pin container images, inject extra Temporal env, or silence the container +progress logs, point `globalSetup` at your own module that default-exports +`createGlobalSetup(options)`: + +```typescript +// temporal-global-setup.ts +import { createGlobalSetup } from "@temporal-contract/testing/global-setup"; + +export default createGlobalSetup({ + postgresImage: "postgres:18.1", + temporalImage: "temporalio/auto-setup:1.28.0", + temporalEnv: { FRONTEND_GRPC_MAX_MESSAGE_SIZE: "10485760" }, + quiet: true, +}); +``` + +### Wire the whole stack with `createContractTest` + +`@temporal-contract/testing/contract` builds a vitest `it` whose fixtures run +a contract against that server: a worker on the contract's task queue +(started before each test, shut down after), the connection-scoped +`TypedClient` root, and the contract-bound `ContractClient`. Destructure +exactly what you use — `client`, `typedClient`, or `worker`: + +```typescript +import { createContractTest } from "@temporal-contract/testing/contract"; +import { workflowsPathFromURL } from "@temporal-contract/worker/worker"; +import { describe, expect } from "vitest"; + +import { activities } from "./activities.js"; +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) +}); + +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", customerId: "CUST-1", amount: 42 }, + }); + + expect(result).toBeOk(); + }); +}); +``` + +### Or wire it yourself with the connection fixtures + `@temporal-contract/testing/extension` supplies connections bound to that container: @@ -238,12 +371,11 @@ it("indexes the order by customer", async ({ clientConnection, workerConnection }).get(); const client = await TypedClient.create({ - contract: orderContract, client: new Client({ connection: clientConnection }), }).get(); await worker.runUntil(async () => { - const result = await client.executeWorkflow("processOrder", { + const result = await client.for(orderContract).executeWorkflow("processOrder", { workflowId: "order-search-1", args: { orderId: "ORD-1", customerId: "CUST-1", amount: 42 }, searchAttributes: { customerId: "CUST-1" }, diff --git a/docs/how-to/troubleshoot.md b/docs/how-to/troubleshoot.md index 8f5013d3..be6ee128 100644 --- a/docs/how-to/troubleshoot.md +++ b/docs/how-to/troubleshoot.md @@ -283,7 +283,7 @@ retry: { ``` ```typescript -qualify("CARD_DECLINED", { nonRetryable: true }); +qualifyFailure("CARD_DECLINED", { nonRetryable: true }); ``` ### An activity is not retried at all @@ -291,7 +291,7 @@ qualify("CARD_DECLINED", { nonRetryable: true }); Something upstream set `nonRetryable: true`, or the type is listed in `retry.nonRetryableErrorTypes`. -Watch for `qualify` masking an inner failure: it always wraps, so an inner +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. diff --git a/docs/how-to/tune-activity-options.md b/docs/how-to/tune-activity-options.md index 106d634b..f066f11c 100644 --- a/docs/how-to/tune-activity-options.md +++ b/docs/how-to/tune-activity-options.md @@ -142,14 +142,14 @@ retry: { ``` `nonRetryableErrorTypes` matches the `type` on the `ApplicationFailure` — the -same string you pass to [`qualify`](/how-to/implement-activities), or the key +same string you pass to [`qualifyFailure`](/how-to/implement-activities), or the key 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. -qualify("CARD_DECLINED", { nonRetryable: true }); +qualifyFailure("CARD_DECLINED", { nonRetryable: true }); // From the contract — every instance of this declared error is permanent. errors: { diff --git a/docs/how-to/upgrade-to-v8.md b/docs/how-to/upgrade-to-v8.md index b3cb5060..ef6d4783 100644 --- a/docs/how-to/upgrade-to-v8.md +++ b/docs/how-to/upgrade-to-v8.md @@ -1,13 +1,20 @@ # Upgrade from 7.x to 8.0 -Version 8 has two breaking changes: +Version 8 has four headline breaking changes: 1. **unthrown 5** — error combinators and `match`'s error handler take a matcher callback, and the bare combinators gained a `Cases` suffix. 2. **Technical errors moved to the defect channel** — `TechnicalError` and `RuntimeClientError` no longer appear in any modeled error union. +3. **The client split in two** — `TypedClient` is connection-scoped; + `TypedClient.create({ client }).for(contract)` hands out a contract-bound + `ContractClient`. +4. **Each boundary parses exactly once** — the sender validates but transmits + the original value; the receiver parses. Transforming schemas are no longer + applied twice. -Both are mechanical. Budget an afternoon for a medium codebase. +Plus a set of smaller renames and semantic fixes, each with its own section +below. Most are mechanical. Budget an afternoon for a medium codebase. ::: warning 8.0 is currently a prerelease The 8.0 line is published under the `beta` tag, so a plain @@ -146,8 +153,9 @@ if (created.isErr()) { } const typedClient = created.value; -// 8.0 -const created = await TypedClient.create({ contract, client }); +// 8.0 — note the contract is gone from `create`; see the client-split +// section below. +const created = await TypedClient.create({ client }); if (created.isDefect()) { console.error("client setup failed:", created.cause); // a TechnicalError process.exit(1); @@ -158,10 +166,11 @@ const typedClient = created.value; Or, more concisely — `.get()` rethrows a defect's original cause: ```typescript -const typedClient = await TypedClient.create({ contract, client }).get(); +const typedClient = await TypedClient.create({ client }).get(); ``` -The same applies to `createWorker`. +The same applies to `createWorker`. The deprecated `createWorkerOrThrow` +migration alias is removed in 8.0 — use `createWorker(...).get()`. ### Every other operation @@ -200,15 +209,21 @@ result.match({ ### Schedule handles -Every `TypedScheduleHandle` method now returns `AsyncResult` (or -`AsyncResult` for `describe`). There is no `err` -branch left to write: +Every `TypedScheduleHandle` method now returns +`AsyncResult` (or +`AsyncResult` for `describe`). The +one _anticipated_ failure — the schedule does not exist on the server — is +modeled; everything else (transport faults, unrecognized rejections) rides the +defect channel: ```typescript -// 8.0 — `.get()` rethrows the defect's original cause. +// 8.0 — `.get()` rethrows an Err or a defect's original cause. await schedule.pause("maintenance").get(); ``` +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 `AsyncResult` is a success-only thenable: awaiting it collapses it to a `Result`, and the underlying promise never rejects. `await schedule.pause(...)` @@ -237,6 +252,341 @@ const retryOnce: ClientInterceptor = (args, next) => }); ``` +## 6. Split the client: `create` then `for` + +A client is a _connection_; a contract is a _schema_. 8.0 decouples them: +`TypedClient` is connection-scoped (no type parameter, no contract), and +binding a contract via `for()` hands out a `ContractClient` that +carries everything the old contract-coupled client had. + +| 7.x | 8.0 | +| -------------------------------------------------- | ---------------------------------------------- | +| `TypedClient.create({ contract, client })` | `TypedClient.create({ client }).for(contract)` | +| `TypedClient` (type annotation) | `ContractClient` | +| `TypedClient.createOrThrow(contract, client, ...)` | removed — use `create(...).get()` | +| `CreateTypedClientOptions` | `CreateClientOptions` | + +```typescript +// 7.x — one client per contract, constructed per contract +import { TypedClient } from "@temporal-contract/client"; + +const typedClient = await TypedClient.create({ contract: orderContract, client }).get(); +await typedClient.startWorkflow("processOrder", { workflowId, args }); + +// 8.0 — one client per connection, contracts bound freely +import { TypedClient, type ContractClient } from "@temporal-contract/client"; + +const typedClient = await TypedClient.create({ client }).get(); // once, at startup +const orders: ContractClient = typedClient.for(orderContract); +await orders.startWorkflow("processOrder", { workflowId, args }); +``` + +`for()` is synchronous, infallible, and memoized per contract identity — +`for(c) === for(c)` — so calling it per request is free. One process serving +two contracts is now one connection: `typedClient.for(otherContract)`. + +While migrating, a stray 7.x-style `TypedClient` annotation fails +loudly — `TypedClient` no longer takes a type argument. + +### `WorkflowNotFoundError` is now `WorkflowNotInContractError` + +The old name squatted on Temporal SDK terminology while meaning something +different — the _name is not on the contract_, a programming error. Rename the +class and every matcher arm: + +```diff +- matcher.with(P.tag("@temporal-contract/WorkflowNotFoundError"), (e) => ...) ++ matcher.with(P.tag("@temporal-contract/WorkflowNotInContractError"), (e) => ...) +``` + +`WorkflowExecutionNotFoundError` (the _execution_ does not exist on the +server) is unchanged. + +### `getHandle` is synchronous now + +Contract lookup needs no I/O, so `getHandle` returns a plain `Result` instead +of an `AsyncResult` — drop the `await`. It also accepts an options object: +`runId` (bind a specific execution), plus Temporal's `firstExecutionRunId` and +`followRuns` passthroughs. + +```typescript +// 7.x +const bound = await typedClient.getHandle("processOrder", "order-123"); + +// 8.0 +const bound = orders.getHandle("processOrder", "order-123"); +if (bound.isErr()) throw bound.error; // WorkflowNotInContractError +const handle = bound.value; + +// 8.0 — bind a specific run +const pinned = orders.getHandle("processOrder", "order-123", { runId }); +``` + +### Deleted type exports + +Six unused `ClientInfer*` aliases are gone: `ClientInferWorkflow`, +`ClientInferActivity`, `ClientInferWorkflows`, `ClientInferActivities`, +`ClientInferWorkflowActivities`, `ClientInferWorkflowContextActivities`. +Still exported: `ClientInferInput`, `ClientInferOutput`, `ClientInferSignal`, +`ClientInferQuery`, `ClientInferUpdate`, `ClientInferWorkflowSignals`, +`ClientInferWorkflowQueries`, `ClientInferWorkflowUpdates`. + +### New surface worth adopting + +Not breaking, but part of the same overhaul: + +- **`typedClient.raw`** — the underlying `@temporalio/client` `Client`, for + anything the typed surface does not cover (`raw.workflow.list(...)`, + `raw.workflow.count(...)`). Bypasses validation and interceptors. +- **`handle.runId` / `handle.firstExecutionRunId`** — carried on typed + handles when known. +- **`handle.startUpdate(name, options)`** — start an update without waiting + for its result; returns a `TypedWorkflowUpdateHandle` whose `result()` + parses the outcome. The `updates` map keeps its execute-and-wait shape. +- **`WorkflowValidationError.workflowId`** — the failing workflow id, carried + on client-side validation errors. +- **Omittable payloads** — for a signal/query/update whose input schema + accepts `undefined` (see the [input-less definitions](#input-less-signals-queries-and-updates) + below), the client-side payload argument is optional: + `handle.queries.getStatus()`. + +## 7. Wire format: each boundary parses exactly once + +::: warning Behavioral change +This changes what is transmitted, not any type. If anything relies on +receiving the send-side _transformed_ value, it is affected. +::: + +In 7.x both sides of every boundary ran the same schema and the sender +transmitted the **parsed** value — so a transforming schema (`z.coerce.*`, +`.transform(...)`) was applied twice, silently corrupting data. + +In 8.0 the sender still **validates** (you get the same typed +`WorkflowValidationError` / `Err` before anything crosses the network) but +transmits the caller's **original** value; the receiving side parses it. Each +transform now applies exactly once per boundary. This holds for workflow +input/output, activities in both directions, signals, queries, updates, and +child workflows. + +What to check: + +- Schemas with transforms that _relied_ on the double application (rare, and + previously a bug) now see the single-parse value. +- Anything reading payloads off the wire — the Temporal Web UI, raw SDK + clients, history exports — now sees the sender's original value, not the + parsed one. +- A contract error's `data` is transmitted as the constructor's original + argument: `ApplicationFailure.details[0]` carries the **pre-transform** + value, and the receiving side parses it against the declared schema. + +Idempotent schemas (no coercion, no transforms — the common case) are +unaffected. + +## 8. Invalid signals are dropped, not fatal + +In 7.x a signal payload failing its schema threw +`SignalInputValidationError` — a non-retryable `ApplicationFailure` — from the +signal handler, **terminally failing the whole workflow execution**. Wrong for +a fire-and-forget message any stale client can send. + +In 8.0 the worker **drops the invalid signal and logs a warning** (via +`@temporalio/workflow`'s replay-aware `log.warn`, with the signal name and the +schema issues). The execution continues untouched. + +- `SignalInputValidationError` no longer exists — delete any `instanceof` + check or import. +- Client-side, sending a malformed signal still fails early with + `SignalValidationError` before dispatch — nothing changed there. +- Queries and updates keep their existing semantics: an invalid query/update + payload rejects that query/update, never the execution. + +## 9. Worker: renames and stricter declaration checks + +### `qualify` → `qualifyFailure` + +A mechanical rename, no alias kept: + +```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")) +``` + +### `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. + +```diff +- const worker = await createWorkerOrThrow({ contract, connection, workflowsPath, activities }); ++ const worker = await createWorker({ contract, connection, workflowsPath, activities }).get(); +``` + +### Contract misuse fails the execution instead of hanging it + +Binding a signal/query/update handler for a name the contract does not +declare, using an async-validating schema where Temporal requires synchronous +validation, or reaching an activity no options cover used to throw a plain +`Error` inside the workflow sandbox — which Temporal treats as a Workflow Task +failure and retries **forever**, leaving the execution silently `Running`. + +8.0 introduces `ContractMisuseError` (a non-retryable `ApplicationFailure`) +at all such sites: a contract-misuse bug now fails the execution terminally +with a clear message. If you monitored for stuck executions caused by these +bugs, they now surface as failed executions instead. + +### Workflow-only workers + +`activities` is now optional on `createWorker`. 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"; + +const worker = await createWorker({ + contract: orderContract, + connection, + workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"), + // no `activities` — workflow-only worker +}).get(); +``` + +Relatedly, a workflow that declares no activities no longer needs an empty +`{}` entry in the `declareActivitiesHandler` map. + +### `declareActivitiesHandler` fails fast + +Declaration now iterates the contract's **definitions**: a declared activity +with no implementation throws at declaration time (instead of an opaque +"activity not registered" on first dispatch), and a stray key — an +implementation for an activity the contract never declared — throws +`ActivityDefinitionNotFoundError`. If your implementation map carried stale +entries, this surfaces them. + +### Typed child-workflow handles grew + +`TypedChildWorkflowHandle` now carries `firstExecutionRunId` and a typed +`signals` map — one sender per signal the child declares, validated on send +and parsed by the child on receive: + +```typescript +const started = await context.startChildWorkflow(orderContract, "collectPayment", { + workflowId: `payment-${order.orderId}`, + args: { customerId: order.customerId, amount: order.total }, +}); + +if (started.isOk()) { + await started.value.signals.applyDiscount({ percent: 10 }); +} +``` + +## 10. Contract package changes + +### Strict root validation, without zod + +`defineContract`'s structural validation is now hand-rolled — **zod is gone +from the contract package's runtime dependencies** (your schemas can of course +still be zod). The root shape check became strict: an unknown key on the +contract literal (`taskQueue`, `workflows`, `activities` are the known ones) +is now rejected at `defineContract` time, matching how `defaultOptions` was +already validated. + +### Activity-name collisions, recalibrated + +- **Sharing the same activity object** across workflows is now allowed — + reference equality means it is one activity, not a collision. +- Two _different_ definitions under the same name is still an error, and the + message now recommends hoisting the shared activity to the contract's + global `activities` block. +- A **workflow name colliding with a global activity name** is now rejected — + they share the root of the worker's implementations map. + +### Input-less signals, queries, and updates + +`input` is now optional on `defineSignal` / `defineQuery` / `defineUpdate`. +Omitted, the definition carries a materialized `UndefinedInputSchema` (a new +exported type) whose validated value is always `undefined` — no more +`z.void()` ceremony: + +```typescript +import { defineQuery, defineSignal, defineUpdate } from "@temporal-contract/contract"; +import { z } from "zod"; + +const stop = defineSignal(); // no payload +const getStatus = defineQuery({ output: z.object({ status: z.string() }) }); +const refresh = defineUpdate({ output: z.object({ refreshedAt: z.string() }) }); +``` + +Handlers receive `undefined`; client-side, the payload argument becomes +omittable (`handle.queries.getStatus()`). + +### Deleted / dropped + +- `InferContractWorkflows` (a trivial alias of `TContract["workflows"]`) is + gone — inline the indexed access. +- The CJS build is gone: `@temporal-contract/contract` is **ESM-only**, like + the other packages. + +## 11. Schedules: typed errors and a fuller surface + +Schedule operations now model their anticipated failures instead of routing +everything to the defect channel: + +| Operation | 8.0 `err` channel | +| -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| `schedule.create` | `WorkflowNotInContractError \| WorkflowValidationError \| ScheduleAlreadyExistsError` | +| handle `pause` / `unpause` / `trigger` / `update` / `backfill` / `delete` / `describe` | `ScheduleNotFoundError` | + +If you matched exhaustively on `schedule.create`'s error union, add a +`ScheduleAlreadyExistsError` arm. The create-if-absent idiom becomes a typed +branch: + +```typescript +import { P } from "unthrown"; + +const created = await orders.schedule.create("reconcileLedger", { + scheduleId: "nightly-reconcile", + spec: { cronExpressions: ["0 2 * * *"] }, + args: { mode: "full" }, +}); + +const schedule = created.match({ + ok: (handle) => handle, + errCases: (matcher) => + matcher + .with( + P.tag("@temporal-contract/ScheduleAlreadyExistsError"), + // Already there — bind to it instead. + () => orders.schedule.getHandle("nightly-reconcile"), + ) + .with( + P.tag("@temporal-contract/WorkflowNotInContractError"), + P.tag("@temporal-contract/WorkflowValidationError"), + (error) => { + throw error; // programming errors + }, + ), + defect: (cause) => { + throw cause; + }, +}); +``` + +New on the surface: + +- **`schedule.getHandle(scheduleId)`** — bind to an existing schedule. +- **`handle.update(updateFn)`** — fetch-modify-persist the schedule + definition (Temporal may call `updateFn` more than once on conflict — keep + it pure). +- **`handle.backfill(options)`** — run the action over historical time + ranges. +- **`schedule.list(options?)`** — an `AsyncIterable` + passthrough of Temporal's `ScheduleClient.list`. + ## Checklist - [ ] All four `@temporal-contract/*` packages on the same 8.0 version @@ -248,6 +598,20 @@ const retryOnce: ClientInterceptor = (args, next) => - [ ] `TypedClient.create` / `createWorker` use `isDefect()` or `.get()` - [ ] No `P.tag("@temporal-contract/RuntimeClientError")` or `P.tag("@temporal-contract/TechnicalError")` arms remain +- [ ] `TypedClient.create({ contract, client })` → + `TypedClient.create({ client }).for(contract)`; annotations use + `ContractClient`; no `createOrThrow` +- [ ] `WorkflowNotFoundError` → `WorkflowNotInContractError` everywhere + (imports and `P.tag` arms) +- [ ] `getHandle` calls drop their `await` (it returns a sync `Result`) +- [ ] `qualify` → `qualifyFailure` in activity implementations +- [ ] `createWorkerOrThrow(...)` → `createWorker(...).get()` +- [ ] No `SignalInputValidationError` imports remain; alerting expects + invalid signals to be dropped and logged, not to fail executions +- [ ] `schedule.create` matchers handle `ScheduleAlreadyExistsError`; + schedule-handle matchers handle `ScheduleNotFoundError` +- [ ] No schema relies on its transform running on the send side (each + boundary now parses once, on receive) - [ ] `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 781800a6..c52ba871 100644 --- a/docs/how-to/use-signals-queries-and-updates.md +++ b/docs/how-to/use-signals-queries-and-updates.md @@ -23,7 +23,7 @@ import { import { z } from "zod"; const getProgress = defineQuery({ - input: z.object({}), // no arguments + // no `input` — an argument-less query output: z.object({ completed: z.number(), total: z.number() }), }); @@ -45,6 +45,11 @@ export const importCatalog = defineWorkflow({ }); ``` +`input` is optional on all three. Omit it — `defineSignal()`, +`defineQuery({ output })`, `defineUpdate({ output })` — and the handler +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 @@ -123,10 +128,13 @@ if (!approvedInTime) { ## Call them from the client -Get a handle, then use the generated `queries`, `signals`, and `updates` maps: +Bind the contract, get a handle, then use the generated `queries`, `signals`, +and `updates` maps: ```typescript -const started = await client.startWorkflow("importCatalog", { +const catalog = typedClient.for(catalogContract); + +const started = await catalog.startWorkflow("importCatalog", { workflowId: "import-2024", args: { catalogId: "cat-1" }, }); @@ -136,8 +144,8 @@ if (started.isErr()) { } const handle = started.value; -// Query -const progress = await handle.queries.getProgress({}); +// Query — the payload argument is omittable for an input-less definition +const progress = await handle.queries.getProgress(); if (progress.isOk()) { console.log(`${progress.value.completed}/${progress.value.total}`); } @@ -171,32 +179,67 @@ simply never receives the signal. Unwrap with `.getOrThrow()`, or branch on `isErr()` / `isDefect()`. ::: +::: info What happens to an invalid signal on the worker +Client-side validation catches a malformed payload before dispatch. If an +invalid signal payload reaches the worker anyway — a stale client, another +SDK — the worker **drops the signal and logs a warning** (`log.warn`, with +the signal name and issues). It never fails the execution: a fire-and-forget +message must not be able to kill a workflow. Queries and updates instead +reject that one call. +::: + +## Start an update without waiting + +The `updates` map executes and waits. To fire an update and collect its +result later, use `startUpdate` — it returns a typed update handle: + +```typescript +const startedUpdate = await handle.startUpdate("addItems", { + args: { skus: ["SKU-11"] }, + updateId: "add-sku-11", // optional dedupe key +}); + +if (startedUpdate.isOk()) { + // ... do other work ... + const outcome = await startedUpdate.value.result(); + console.log(outcome.getOrThrow().total); +} +``` + +The handle carries `updateId`, `workflowId`, and `workflowRunId`; `options` +is omittable for an argument-less update (`defineUpdate({ output })`). + ## Reach an existing workflow -You do not need to have started it. `getHandle` binds to a running execution by -id. It returns an `AsyncResult` — the error channel covers a workflow name that -is not on the contract: +You do not need to have started it. `getHandle` binds to a running execution +by id. It is **synchronous** — no I/O is involved — and returns a `Result` +whose error channel covers a workflow name that is not on the contract: ```typescript -const bound = await client.getHandle("importCatalog", "import-2024"); +const bound = catalog.getHandle("importCatalog", "import-2024"); if (bound.isErr()) { throw bound.error; } const handle = bound.value; -const progress = await handle.queries.getProgress({}); +const progress = await handle.queries.getProgress(); console.log(progress.getOrThrow()); (await handle.signals.cancelRequested({ reason: "budget exhausted" })).getOrThrow(); ``` +Pass options to pin a run or interlock the chain: +`catalog.getHandle("importCatalog", "import-2024", { runId })` binds a +specific execution; `{ firstExecutionRunId }` makes mutating methods refuse +to cross into another execution chain. + ## Signal-with-start To signal a workflow that may not exist yet, `signalWithStart` starts it if needed and delivers the signal either way — one round trip, no race: ```typescript -const result = await client.signalWithStart("importCatalog", { +const result = await catalog.signalWithStart("importCatalog", { workflowId: "import-2024", args: { catalogId: "cat-1" }, signalName: "cancelRequested", diff --git a/docs/index.md b/docs/index.md index d0922b3b..fa462ea4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -25,7 +25,7 @@ hero: features: - icon: { src: /icons/shield-check.svg } title: Validated at every boundary - details: Schemas run on both sides of every network hop. A malformed call is rejected before a workflow is ever started — no history, no partial state. + details: Validated on send, parsed on receive — every network hop is checked and transforms apply exactly once. A malformed call is rejected before a workflow is ever started — no history, no partial state. - icon: { src: /icons/target.svg } title: Failures as typed values @@ -66,7 +66,7 @@ export const orderContract = defineContract({ ``` ```typescript [2. Activities] -import { declareActivitiesHandler, qualify } from "@temporal-contract/worker/activity"; +import { declareActivitiesHandler, qualifyFailure } from "@temporal-contract/worker/activity"; import { fromPromise } from "unthrown"; import { orderContract } from "./contract.js"; @@ -77,9 +77,11 @@ export const activities = declareActivitiesHandler({ // Workflow-scoped activities nest under their workflow, mirroring the contract. processOrder: { chargeCard: ({ customerId, amount }) => - fromPromise(gateway.charge(customerId, amount), qualify("CHARGE_FAILED")).map((charge) => ({ - transactionId: charge.id, - })), + fromPromise(gateway.charge(customerId, amount), qualifyFailure("CHARGE_FAILED")).map( + (charge) => ({ + transactionId: charge.id, + }), + ), }, }, }); @@ -116,11 +118,10 @@ import { orderContract } from "./contract.js"; const connection = await Connection.connect({ address: "localhost:7233" }); const client = await TypedClient.create({ - contract: orderContract, client: new Client({ connection }), }).get(); -const result = await client.executeWorkflow("processOrder", { +const result = await client.for(orderContract).executeWorkflow("processOrder", { workflowId: "order-123", args: { orderId: "ORD-123", customerId: "CUST-456", amount: 99.99 }, }); @@ -129,7 +130,7 @@ result.match({ ok: (output) => console.log(output.transactionId), // ✅ typed errCases: (matcher) => matcher.with( - P.tag("@temporal-contract/WorkflowNotFoundError"), + P.tag("@temporal-contract/WorkflowNotInContractError"), P.tag("@temporal-contract/WorkflowValidationError"), P.tag("@temporal-contract/WorkflowAlreadyStartedError"), P.tag("@temporal-contract/WorkflowFailedError"), diff --git a/docs/reference/client-surface.md b/docs/reference/client-surface.md index 4f6b1fb0..a79d6c90 100644 --- a/docs/reference/client-surface.md +++ b/docs/reference/client-surface.md @@ -4,28 +4,62 @@ Everything exported from `@temporal-contract/client`. Generated per-symbol docs: [API reference](/api/client/). +The surface is split in two: `TypedClient` is **connection-scoped** — it owns +the underlying `Client`, the interceptor chain, and the escape hatch — and +hands out **contract-scoped** `ContractClient`s via `for()`. + ## `TypedClient` ### `TypedClient.create(options)` ```typescript -static create(options: { - contract: TContract; +static create(options: { client: Client; // from @temporalio/client interceptors?: readonly ClientInterceptor[]; // outermost first -}): AsyncResult, never>; +}): AsyncResult; ``` -**No modeled error.** Setup faults ride the defect channel with a +**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: ```typescript -const client = await TypedClient.create({ contract, client: temporalClient }).get(); +import { TypedClient } from "@temporal-contract/client"; +import { Client, Connection } from "@temporalio/client"; + +const connection = await Connection.connect(); +const client = await TypedClient.create({ client: new Client({ connection }) }).get(); ``` -### `TypedClient.createOrThrow(contract, client, interceptors?)` +Create it **once, at process start** — it awaits `ensureConnected()` eagerly +so a bad address or namespace fails here, not on the first operation. + +### `for(contract)` -Synchronous, throwing variant. +```typescript +for(contract: TContract): ContractClient; +``` + +Binds a contract. **Synchronous and infallible** — valid in a field +initializer. Memoized per contract identity, so `for(c) === for(c)` and +calling it per request is free. Interceptors are inherited from the root. + +```typescript +const orders = client.for(orderContract); +const shipments = client.for(shipmentContract); // same connection, second contract +``` + +### `raw` + +The underlying `@temporalio/client` `Client` — the escape hatch for anything +the typed surface does not cover yet (`raw.workflow.list(...)`, +`raw.workflow.count(...)`). Calls made through `raw` bypass contract +validation and the interceptor chain. + +## `ContractClient` + +Obtained from `TypedClient.for` — its constructor is not public API. Written +as an annotation: `ContractClient`. ### `executeWorkflow(workflowName, options)` @@ -35,7 +69,7 @@ Starts and waits. => AsyncResult< Output, | ContractErrorUnion // when the workflow declares errors - | WorkflowNotFoundError + | WorkflowNotInContractError | WorkflowValidationError | WorkflowAlreadyStartedError | WorkflowFailedError @@ -50,7 +84,7 @@ Returns a handle as soon as the workflow starts. ```typescript => AsyncResult< TypedWorkflowHandle, - | WorkflowNotFoundError + | WorkflowNotInContractError | WorkflowValidationError | WorkflowAlreadyStartedError > @@ -63,7 +97,7 @@ Starts the workflow if it does not exist, and delivers the signal either way. ```typescript => AsyncResult< TypedWorkflowHandleWithSignaledRunId, - | WorkflowNotFoundError + | WorkflowNotInContractError | WorkflowValidationError | SignalValidationError | WorkflowAlreadyStartedError @@ -73,14 +107,23 @@ Starts the workflow if it does not exist, and delivers the signal either way. The returned handle adds `signaledRunId` — the run that received the signal, which is not necessarily a newly started one. -### `getHandle(workflowName, workflowId)` +### `getHandle(workflowName, workflowId, options?)` -Binds to an existing execution. +Binds to an existing execution. **Synchronous** — no I/O is involved, so it +returns a plain `Result`: ```typescript -=> AsyncResult, WorkflowNotFoundError> +=> Result, WorkflowNotInContractError> ``` +`TypedGetHandleOptions` extends Temporal's `GetWorkflowHandleOptions`: + +| Field | Effect | +| --------------------- | ------------------------------------------------------------------------- | +| `runId` | Bind to a specific execution instead of the latest | +| `firstExecutionRunId` | Chain interlock — mutating methods refuse to cross into another chain | +| `followRuns` | Whether `result()` follows continue-as-new / retries (Temporal's default) | + ### `schedule` A `TypedScheduleClient`. See below. @@ -98,7 +141,8 @@ Temporal's `WorkflowStartOptions` without `taskQueue`, `args`, | `searchAttributes` | `TypedSearchAttributeMap` (optional) | `workflowId`, `workflowExecutionTimeout`, `workflowRunTimeout`, `retry`, `memo`, -and the rest pass through. +and the rest pass through. `args` is omittable when the workflow's input +schema accepts `undefined`. ### `TypedSignalWithStartOptions` @@ -109,30 +153,62 @@ The above, plus: | `signalName` | a signal name on the workflow | | `signalArgs` | `ClientInferInput` | +`signalArgs` is omittable when the signal's input schema accepts `undefined` +(a payload-less `defineSignal()`). + ::: warning It is `signalName`, not `signal` ::: +### `TypedStartUpdateOptions` + +For `handle.startUpdate`: + +| Field | Type | +| -------------- | --------------------------------------------------------------------------- | +| `args` | `ClientInferInput` — omittable when the schema accepts `undefined` | +| `updateId` | `string` (optional) — dedupe key | +| `waitForStage` | `"ACCEPTED"` — the only supported stage, and the default | + ## `TypedWorkflowHandle` -| Member | Type | -| -------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| `workflowId` | `string` | -| `queries` | `Record AsyncResult>` | -| `signals` | `Record AsyncResult>` | -| `updates` | `Record AsyncResult>` | -| `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 | +| `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` | `queries`, `signals`, and `updates` are generated from the contract — only the -declared operations exist, with their schemas' types. Payloads are validated on -both sides. +declared operations exist, with their schemas' types. Payloads are validated +before dispatch and parsed by the worker on receive; the payload argument is +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. `result()` surfaces a declared contract error as a `ContractError` instead of a generic `WorkflowFailedError`. +### `TypedWorkflowUpdateHandle` + +Returned by `startUpdate`: + +| Member | Type | +| --------------- | ------------------------------------------------------------------------------ | +| `updateId` | `string` | +| `workflowId` | `string` | +| `workflowRunId` | `string \| undefined` | +| `result()` | `AsyncResult` | + ## Search attributes ### `readTypedSearchAttributes(workflowDef, instance)` @@ -164,12 +240,15 @@ Maps each declared attribute name to the TypeScript type its `kind` implies. ## `TypedScheduleClient` -Reached as `client.schedule`. +Reached as `contractClient.schedule`. Not constructible directly. ### `create(workflowName, options)` ```typescript -=> AsyncResult +=> AsyncResult< + TypedScheduleHandle, + WorkflowNotInContractError | WorkflowValidationError | ScheduleAlreadyExistsError + > ``` `TypedScheduleCreateOptions`: @@ -196,19 +275,43 @@ Top-level `memo` is metadata on the schedule; `action.memo` is attached to every workflow it starts. Separate lifecycles, hence separate scopes. ::: -### `TypedScheduleHandle` +### `getHandle(scheduleId)` + +Synchronously wraps an existing schedule id in a `TypedScheduleHandle`. No +server round-trip — a wrong id surfaces as `ScheduleNotFoundError` from the +handle's methods. + +### `list(options?)` + +```typescript +=> AsyncIterable +``` -| Member | Type | -| ------------------- | ----------------------------------------- | -| `scheduleId` | `string` | -| `pause(note?)` | `AsyncResult` | -| `unpause(note?)` | `AsyncResult` | -| `trigger(overlap?)` | `AsyncResult` | -| `delete()` | `AsyncResult` | -| `describe()` | `AsyncResult` | +Passthrough of Temporal's `ScheduleClient.list`. + +### `TypedScheduleHandle` -All have an empty error channel — a failed schedule operation is a technical -fault on the defect channel with a `RuntimeClientError` cause. +| 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. ## Interceptors @@ -221,8 +324,10 @@ type ClientInterceptor = ( ) => AsyncResult; ``` -Wraps operations **outside** the validation pipeline, but a patched input is -validated exactly like the caller's original. +Passed to `TypedClient.create({ interceptors })`, inherited by every +`ContractClient` the root hands out. Wraps operations **outside** the +validation pipeline, but a patched input is validated exactly like the +caller's original. ### `ClientInterceptorArgs` @@ -255,10 +360,11 @@ channel. ## Errors exported here -`RuntimeClientError`, `WorkflowNotFoundError`, `WorkflowAlreadyStartedError`, -`WorkflowExecutionNotFoundError`, `WorkflowFailedError`, -`WorkflowValidationError`, `QueryValidationError`, `SignalValidationError`, -`UpdateValidationError`, `TechnicalError`, `ContractError`. +`RuntimeClientError`, `WorkflowNotInContractError`, +`WorkflowAlreadyStartedError`, `WorkflowExecutionNotFoundError`, +`WorkflowFailedError`, `WorkflowValidationError`, `QueryValidationError`, +`SignalValidationError`, `UpdateValidationError`, `ScheduleAlreadyExistsError`, +`ScheduleNotFoundError`, `TechnicalError`, `ContractError`. Plus the types `TemporalFailure`, `AnyContractError`, `ContractErrorUnion`, `WorkflowContractErrorsOf`. @@ -267,12 +373,9 @@ See the [errors reference](/reference/errors). ## Inference helpers -`ClientInferInput`, `ClientInferOutput`, `ClientInferWorkflow`, -`ClientInferActivity`, `ClientInferSignal`, `ClientInferQuery`, -`ClientInferUpdate`, `ClientInferWorkflows`, `ClientInferActivities`, -`ClientInferWorkflowActivities`, `ClientInferWorkflowSignals`, -`ClientInferWorkflowQueries`, `ClientInferWorkflowUpdates`, -`ClientInferWorkflowContextActivities` +`ClientInferInput`, `ClientInferOutput`, `ClientInferSignal`, +`ClientInferQuery`, `ClientInferUpdate`, `ClientInferWorkflowSignals`, +`ClientInferWorkflowQueries`, `ClientInferWorkflowUpdates` Use these to type a function around the contract without restating its shapes: diff --git a/docs/reference/contract-surface.md b/docs/reference/contract-surface.md index f4445db7..3bc76abb 100644 --- a/docs/reference/contract-surface.md +++ b/docs/reference/contract-surface.md @@ -22,14 +22,22 @@ function defineContract(definition: T): T; | `workflows` | `Record` | yes | At least one entry | | `activities` | `Record` | no | Global activities, reachable from every workflow | -**Throws** `Error` when the structure is invalid. Checked at call time: +**Throws** `Error` when the structure is invalid. The check is a hand-rolled +structural validator — the contract package has no runtime schema-library +dependency. Checked at call time: - `taskQueue` empty or missing - `workflows` empty +- 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 - an activity name that collides across the flat namespace — global vs - workflow-scoped, or between two workflows + workflow-scoped, or two _different_ definitions under one name across + workflows. Reusing the **same definition object** across workflows is + allowed (one activity, not a collision); the collision message recommends + 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` ### `defineWorkflow(definition)` @@ -54,38 +62,44 @@ function defineContract(definition: T): T; | `errors` | `Record` | no | | `defaultOptions` | `ActivityDefaultOptions` | no | -### `defineSignal(definition)` +### `defineSignal(definition?)` | Field | Type | Required | | ------- | ----------- | -------- | -| `input` | `AnySchema` | yes | +| `input` | `AnySchema` | no | Signals return nothing, so there is no `output`. +Omit `input` (or the whole argument: `defineSignal()`) for a payload-less +signal — the definition then carries an `UndefinedInputSchema`, the handler +receives `undefined`, and the client-side payload argument is omittable. + ### `defineQuery(definition)` | Field | Type | Required | | -------- | ----------- | -------- | -| `input` | `AnySchema` | yes | +| `input` | `AnySchema` | no | | `output` | `AnySchema` | yes | ::: warning Synchronous validation only Temporal requires query handlers to complete synchronously, so both schemas must validate synchronously. Async refinements are not supported. Standard Schema does not expose the distinction at the type level, so the worker checks -at runtime and throws if `~standard.validate` returns a `Promise`. +at runtime and fails the execution with a `ContractMisuseError` if +`~standard.validate` returns a `Promise`. ::: -Use `input: z.object({})` for a query with no parameters. +Use `defineQuery({ output })` for a query with no parameters. ### `defineUpdate(definition)` | Field | Type | Required | | -------- | ----------- | -------- | -| `input` | `AnySchema` | yes | +| `input` | `AnySchema` | no | | `output` | `AnySchema` | yes | -Update handlers may be asynchronous. +Update handlers may be asynchronous. `defineUpdate({ output })` declares an +argument-less update. ### `defineSearchAttribute(definition)` @@ -188,10 +202,14 @@ See the [errors reference](/reference/errors). ### Contract shapes -`AnySchema`, `ActivityDefinition`, `SignalDefinition`, `QueryDefinition`, -`UpdateDefinition`, `WorkflowDefinition`, `AnyWorkflowDefinition`, -`ContractDefinition`, `SearchAttributeDefinition`, `SearchAttributeKind`, -`SearchAttributeKindToType` +`AnySchema`, `UndefinedInputSchema`, `ActivityDefinition`, `SignalDefinition`, +`QueryDefinition`, `UpdateDefinition`, `WorkflowDefinition`, +`AnyWorkflowDefinition`, `ContractDefinition`, `SearchAttributeDefinition`, +`SearchAttributeKind`, `SearchAttributeKindToType` + +`UndefinedInputSchema` is the Standard Schema type materialized by +`defineSignal` / `defineQuery` / `defineUpdate` when `input` is omitted — +validation only accepts `undefined`. ### Error inference @@ -203,8 +221,8 @@ shape — what a producer passes to the constructor. ### Name extraction -`InferWorkflowNames`, `InferActivityNames`, `InferContractWorkflows`, -`SignalNamesOf`, `QueryNamesOf`, `UpdateNamesOf` +`InferWorkflowNames`, `InferActivityNames`, `SignalNamesOf`, `QueryNamesOf`, +`UpdateNamesOf` ### Direction-aware inference diff --git a/docs/reference/errors.md b/docs/reference/errors.md index 2421eeee..5ebe8f1f 100644 --- a/docs/reference/errors.md +++ b/docs/reference/errors.md @@ -104,16 +104,16 @@ From `@temporal-contract/client`. `_tag: "@temporal-contract/RuntimeClientError"` · channel: **defect only** A technical failure with no more specific class — an unrecognized Temporal -rejection, a transport error, an unknown schedule id. +rejection, a transport error. | Property | Type | | ----------- | ------------------------- | | `operation` | the operation that failed | | `cause` | the underlying failure | -### `WorkflowNotFoundError` +### `WorkflowNotInContractError` -`_tag: "@temporal-contract/WorkflowNotFoundError"` · channel: `err` +`_tag: "@temporal-contract/WorkflowNotInContractError"` · channel: `err` The workflow name is not on the **contract**. A programming error, not a runtime condition. @@ -131,7 +131,7 @@ From `startWorkflow`, `executeWorkflow`, `signalWithStart`, `getHandle`, `_tag: "@temporal-contract/WorkflowExecutionNotFoundError"` · channel: `err` The targeted **execution** does not exist in the namespace. Distinct from -`WorkflowNotFoundError` above. +`WorkflowNotInContractError` above. | Property | Type | | ------------ | --------------------- | @@ -158,6 +158,30 @@ rejecting a duplicate while a previous run is still in retention. Branch on this to make a start idempotent — fetch the existing handle and continue. +### `ScheduleAlreadyExistsError` + +`_tag: "@temporal-contract/ScheduleAlreadyExistsError"` · channel: `err` + +`schedule.create` collided with an existing (running, not deleted) schedule +under the same id. Branch on it for create-if-absent semantics. + +| Property | Type | +| ------------ | --------- | +| `scheduleId` | `string` | +| `cause` | `unknown` | + +### `ScheduleNotFoundError` + +`_tag: "@temporal-contract/ScheduleNotFoundError"` · channel: `err` + +The schedule id is unknown to the Temporal server — wrong id, or the schedule +was deleted. From every `TypedScheduleHandle` method. + +| Property | Type | +| ------------ | --------- | +| `scheduleId` | `string` | +| `cause` | `unknown` | + ### `WorkflowFailedError` `_tag: "@temporal-contract/WorkflowFailedError"` · channel: `err` @@ -188,12 +212,12 @@ From `executeWorkflow` and `handle.result()`. All `TaggedError`s on the `err` channel, all carrying `issues`. -| Class | Tag suffix | Extra properties | -| ------------------------- | ------------------------- | ------------------------------------------------ | -| `WorkflowValidationError` | `WorkflowValidationError` | `workflowName`, `direction: "input" \| "output"` | -| `QueryValidationError` | `QueryValidationError` | `queryName`, `direction` | -| `SignalValidationError` | `SignalValidationError` | `signalName` | -| `UpdateValidationError` | `UpdateValidationError` | `updateName`, `direction` | +| Class | Tag suffix | Extra properties | +| ------------------------- | ------------------------- | -------------------------------------------------------------- | +| `WorkflowValidationError` | `WorkflowValidationError` | `workflowName`, `direction: "input" \| "output"`, `workflowId` | +| `QueryValidationError` | `QueryValidationError` | `queryName`, `direction` | +| `SignalValidationError` | `SignalValidationError` | `signalName` | +| `UpdateValidationError` | `UpdateValidationError` | `updateName`, `direction` | ## Worker errors @@ -210,16 +234,31 @@ They are thrown, not returned. | `WorkflowOutputValidationError` | Workflow return value fails its schema | | `ActivityInputValidationError` | Activity input fails its schema, or a middleware substitution does | | `ActivityOutputValidationError` | Activity return value fails its schema | -| `SignalInputValidationError` | Signal payload fails its schema | | `QueryInputValidationError` | Query payload fails its schema | | `QueryOutputValidationError` | Query return value fails its schema | | `UpdateInputValidationError` | Update payload fails its schema | | `UpdateOutputValidationError` | Update return value fails its schema | | `ContractErrorDataValidationError` | A contract error's `data` fails its schema, **or** an undeclared error name is raised | +| `ContractMisuseError` | Workflow-sandbox code misuses the contract — see below | `ValidationError` itself is exported as the abstract base, for `instanceof` checks across all of them. +There is **no** `SignalInputValidationError`: a signal payload failing its +schema is dropped and logged (`log.warn`), never thrown — a fire-and-forget +message must not be able to kill the execution. + +### `ContractMisuseError` + +Extends `ValidationError` (non-retryable `ApplicationFailure`), with an empty +`issues` array — the misuse is structural, not a payload failure. Thrown when +workflow-sandbox code misuses the contract surface: binding a +signal/query/update handler for an undeclared name, using an async-validating +schema where Temporal requires synchronous validation, or reaching an activity +no options cover. Failing terminally is the point — a plain `Error` thrown +from sandbox code would be retried as a Workflow Task failure forever, leaving +the execution silently `Running`. + ### `ActivityDefinitionNotFoundError` `_tag: "@temporal-contract/ActivityDefinitionNotFoundError"` @@ -308,20 +347,21 @@ the defect channel instead. ### Client -| Operation | `err` channel | -| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| `TypedClient.create` | `never` | -| `startWorkflow` | `WorkflowNotFoundError \| WorkflowValidationError \| WorkflowAlreadyStartedError` | -| `executeWorkflow` | the above, plus `WorkflowFailedError \| WorkflowExecutionNotFoundError \| ContractErrorUnion` | -| `signalWithStart` | `WorkflowNotFoundError \| WorkflowValidationError \| SignalValidationError \| WorkflowAlreadyStartedError` | -| `getHandle` | `WorkflowNotFoundError` | -| `handle.queries.*` | `QueryValidationError \| WorkflowExecutionNotFoundError` | -| `handle.signals.*` | `SignalValidationError \| WorkflowExecutionNotFoundError` | -| `handle.updates.*` | `UpdateValidationError \| WorkflowExecutionNotFoundError` | -| `handle.result()` | `ContractErrorUnion \| WorkflowValidationError \| WorkflowFailedError \| WorkflowExecutionNotFoundError` | -| `handle.terminate/cancel/describe/fetchHistory` | `WorkflowExecutionNotFoundError` | -| `schedule.create` | `WorkflowNotFoundError \| WorkflowValidationError` | -| `schedule` handle methods | `never` | +| Operation | `err` channel | +| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `TypedClient.create` | `never` | +| `startWorkflow` | `WorkflowNotInContractError \| WorkflowValidationError \| WorkflowAlreadyStartedError` | +| `executeWorkflow` | the above, plus `WorkflowFailedError \| WorkflowExecutionNotFoundError \| ContractErrorUnion` | +| `signalWithStart` | `WorkflowNotInContractError \| WorkflowValidationError \| SignalValidationError \| WorkflowAlreadyStartedError` | +| `getHandle` (sync `Result`) | `WorkflowNotInContractError` | +| `handle.queries.*` | `QueryValidationError \| WorkflowExecutionNotFoundError` | +| `handle.signals.*` | `SignalValidationError \| WorkflowExecutionNotFoundError` | +| `handle.updates.*` | `UpdateValidationError \| WorkflowExecutionNotFoundError` | +| `handle.startUpdate` / update-handle `result()` | `UpdateValidationError \| WorkflowExecutionNotFoundError` | +| `handle.result()` | `ContractErrorUnion \| WorkflowValidationError \| WorkflowFailedError \| WorkflowExecutionNotFoundError` | +| `handle.terminate/cancel/describe/fetchHistory` | `WorkflowExecutionNotFoundError` | +| `schedule.create` | `WorkflowNotInContractError \| WorkflowValidationError \| ScheduleAlreadyExistsError` | +| `schedule` handle methods | `ScheduleNotFoundError` | ### Worker @@ -333,6 +373,7 @@ the defect channel instead. | `startChildWorkflow` | `ChildWorkflowError \| ChildWorkflowCancelledError \| ChildWorkflowNotFoundError` | | `executeChildWorkflow` | same | | child `handle.result()` | `ChildWorkflowError \| ChildWorkflowCancelledError` | +| child `handle.signals.*` | `ChildWorkflowError \| ChildWorkflowCancelledError` | | `cancellableScope` / `nonCancellableScope` | `WorkflowCancelledError` | An empty `err` channel (`never`) means every failure is a defect. diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index fc0e2951..93294fc9 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -49,7 +49,7 @@ 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, qualify("TYPE"))` is the common form. +boundary. `fromPromise(promise, qualifyFailure("TYPE"))` is the common form. **`TaggedError`** — unthrown's base for error classes, stamping a `_tag` discriminant. temporal-contract namespaces its tags with the package scope @@ -102,7 +102,7 @@ history bounded. See [Continue as new](/how-to/continue-as-new). `nonRetryable` flag. **`nonRetryable`** — marks a failure permanent so Temporal stops retrying -immediately. Settable per instance via `qualify`, or per declared error on the +immediately. Settable per instance via `qualifyFailure`, or per declared error on the contract. **Cancellation scope** — a region of workflow code that can be cancelled as a diff --git a/docs/reference/worker-surface.md b/docs/reference/worker-surface.md index 3f6e287f..e926bc30 100644 --- a/docs/reference/worker-surface.md +++ b/docs/reference/worker-surface.md @@ -33,11 +33,18 @@ function declareWorkflow( `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. +activities. An unknown `workflowName` also fails at declaration time, listing +the contract's available workflow names. The returned function carries `name === workflowName`, which is how Temporal derives the workflow type. +Workflow input is **parsed on receive** here — the client validated the args +but transmitted the caller's original value, so a transforming schema applies +exactly once. The return value is validated before completion and transmitted +as-is; the client parses it on receive. See +[Validation boundaries](/explanation/validation-boundaries). + ### `WorkflowContext` The first argument to `implementation`. @@ -75,6 +82,10 @@ Empty object when the workflow declares no errors. (signalName: K, handler: (args: Input) => void | Promise) => void; ``` +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)` ```typescript @@ -90,7 +101,13 @@ Must be synchronous. ``` Names are constrained to what the contract declares. Register handlers inside -the implementation so they can close over workflow state. +the implementation so they can close over workflow state. For an input-less +definition (`defineSignal()`, `defineQuery({ output })`, +`defineUpdate({ output })`) the handler receives `undefined`. + +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. #### `startChildWorkflow(contract, workflowName, options)` @@ -101,8 +118,19 @@ the implementation so they can close over workflow state. > ``` -`TypedChildWorkflowHandle` exposes `workflowId` and -`result(): AsyncResult`. +`TypedChildWorkflowHandle` exposes: + +| Member | Type | +| --------------------- | ---------------------------------------------------------------------------------------------------- | +| `workflowId` | `string` | +| `firstExecutionRunId` | `string` — anchor of the child's execution chain, stable across continue-as-new | +| `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. #### `executeChildWorkflow(contract, workflowName, options)` @@ -153,12 +181,15 @@ Never returns normally. `ActivityError`, `ActivityCancelledError`, `ActivityInputValidationError`, `ActivityOutputValidationError`, `ChildWorkflowError`, `ChildWorkflowCancelledError`, `ChildWorkflowNotFoundError`, -`ContractErrorDataValidationError`, `QueryInputValidationError`, -`QueryOutputValidationError`, `SignalInputValidationError`, +`ContractErrorDataValidationError`, `ContractMisuseError`, +`QueryInputValidationError`, `QueryOutputValidationError`, `UpdateInputValidationError`, `UpdateOutputValidationError`, `ValidationError`, `WorkflowCancelledError`, `WorkflowInputValidationError`, `WorkflowOutputValidationError` +There is no `SignalInputValidationError` — an invalid signal payload is +dropped and logged, never thrown (see `defineSignal` above). + Plus `ContractError`, `AnyContractError`, `ContractErrorConstructors`, `ContractErrorOptions`, `ContractErrorUnion`. @@ -181,10 +212,15 @@ function declareActivitiesHandler( **The input map is nested; the returned handler is flat.** Global activities sit at the root of the map you write; workflow-scoped ones nest under their -workflow's name, mirroring the contract. The returned object is flat because +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. -TypeScript requires every activity in the contract to be implemented. +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`. ### Activity implementation signature @@ -197,22 +233,25 @@ TypeScript requires every activity in the contract to be implemented. The `helpers` argument is optional to consume. +`args` is **parsed on receive** — the calling side validated the payload but +transmitted the original value, so a transforming schema applies exactly once. + What the wrapper does with your result: -| You return | Temporal sees | -| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `Ok(value)` | `value`, validated against the output schema | -| `Err(ApplicationFailure)` | the failure thrown; retry policy applies | -| `Err(contractError)` | `data` validated, thrown as `ApplicationFailure` with `type` = error name, `details[0]` = data, `nonRetryable` from the contract | -| a defect | the original cause re-thrown | +| You return | Temporal sees | +| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `Ok(value)` | your original `value`, validated against the output schema (the consuming side parses it on receive) | +| `Err(ApplicationFailure)` | the failure thrown; retry policy applies | +| `Err(contractError)` | `data` validated, thrown as `ApplicationFailure` with `type` = error name, `details[0]` = the original data, `nonRetryable` from the contract | +| a defect | the original cause re-thrown | The wrapper does not hide `@temporalio/activity` — `Context.current()`, `activityInfo()`, and heartbeats are all still available inside the body. -### `qualify(type, options?)` +### `qualifyFailure(type, options?)` ```typescript -function qualify( +function qualifyFailure( type: string, options?: { message?: string; nonRetryable?: boolean; details?: unknown[] }, ): (error: unknown) => ApplicationFailure; @@ -266,7 +305,7 @@ Runs **inside** the validation boundary. Calling `next` more than once re-runs the rest of the chain (retry). Returning without calling it short-circuits. -#### `defineActivityMiddleware(middleware)` +#### `declareActivityMiddleware(middleware)` Identity helper that pins the context type parameters without a variable annotation. @@ -299,22 +338,18 @@ function createWorker( ``` `CreateWorkerOptions` is Temporal's `WorkerOptions` without `taskQueue` (taken -from the contract), plus `contract` and `activities`. +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 +process on the same task queue. See +[Configure a worker](/how-to/configure-a-worker#run-a-workflow-only-worker). **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 `.get()` to rethrow the original cause. -### `createWorkerOrThrow(options)` - -::: warning Deprecated -Pre-`AsyncResult` behaviour, kept to ease migration. Removed in a future major. -Use `createWorker`. -::: - -Rethrows the original cause rather than the `TechnicalError` wrapper. - ### `workflowsPathFromURL(baseURL, relativePath)` ```typescript diff --git a/docs/tutorial/adding-signals-and-queries.md b/docs/tutorial/adding-signals-and-queries.md index c689fb26..ef4d3a16 100644 --- a/docs/tutorial/adding-signals-and-queries.md +++ b/docs/tutorial/adding-signals-and-queries.md @@ -47,8 +47,7 @@ import { z } from "zod"; // ... chargeCard and sendReceipt as before ... const getStatus = defineQuery({ - // No parameters — an empty object schema, not `z.void()`. - input: z.object({}), + // No parameters — just omit `input`. output: z.object({ state: z.enum(["awaiting-approval", "charging", "done"]), amount: z.number(), @@ -194,11 +193,10 @@ import { orderContract } from "./contract.js"; const connection = await Connection.connect({ address: "localhost:7233" }); const client = await TypedClient.create({ - contract: orderContract, client: new Client({ connection }), }).get(); -const started = await client.startWorkflow("processOrder", { +const started = await client.for(orderContract).startWorkflow("processOrder", { workflowId: `order-${Date.now()}`, args: { orderId: "ORD-2", @@ -215,7 +213,8 @@ if (started.isErr()) { const handle = started.value; // 1. Query the current state. The workflow is parked on `condition`. -const before = await handle.queries.getStatus({}); +// (The payload argument is omittable — `getStatus` declares no input.) +const before = await handle.queries.getStatus(); console.log("status:", before.getOrThrow()); // { state: 'awaiting-approval', amount: 42.5 } // 2. Update the amount and get confirmation back. @@ -263,8 +262,9 @@ charge activity ran. `handle.queries`, `handle.signals`, and `handle.updates` are generated from the contract. Autocomplete lists exactly the operations this workflow declares, with -the right argument and return types, and every payload is validated on both -sides of the wire. +the right argument and return types. Every payload is validated before it is +sent and parsed by the worker on receive, so a transforming schema applies +exactly once. ## Step 4 — See a validation failure diff --git a/docs/tutorial/your-first-workflow.md b/docs/tutorial/your-first-workflow.md index 7bc9b5bd..c06fb2b9 100644 --- a/docs/tutorial/your-first-workflow.md +++ b/docs/tutorial/your-first-workflow.md @@ -37,11 +37,19 @@ npm pkg set type=module Install temporal-contract and its peers: ```bash -npm install @temporal-contract/contract @temporal-contract/worker @temporal-contract/client +npm install @temporal-contract/contract@beta @temporal-contract/worker@beta @temporal-contract/client@beta npm install unthrown zod @temporalio/client @temporalio/common @temporalio/worker @temporalio/workflow npm install -D typescript @types/node tsx ``` +::: warning The `@beta` tag is required +temporal-contract 8.0 — the API this tutorial teaches — is currently a +prerelease published under the `beta` dist-tag. Without `@beta`, npm installs +the 7.x line, and the code in this tutorial will not match. The peers +(`unthrown`, `@temporalio/*`) and `zod` — this tutorial's pick of +[Standard Schema](https://standardschema.dev/) validator — are stable releases. +::: + Create a `tsconfig.json`. The two settings that matter are `module: nodenext` (temporal-contract is ESM-only) and `strict` (the type inference depends on it): @@ -134,7 +142,7 @@ non-deterministic. In temporal-contract they return an `AsyncResult` from Create `src/activities.ts`: ```typescript -import { declareActivitiesHandler, qualify } from "@temporal-contract/worker/activity"; +import { declareActivitiesHandler, qualifyFailure } from "@temporal-contract/worker/activity"; import { fromPromise } from "unthrown"; import { orderContract } from "./contract.js"; @@ -162,22 +170,22 @@ export const activities = declareActivitiesHandler({ // mirroring the contract. processOrder: { chargeCard: ({ customerId, amount }) => - fromPromise(paymentGateway.charge(customerId, amount), qualify("CHARGE_FAILED")).map( + fromPromise(paymentGateway.charge(customerId, amount), qualifyFailure("CHARGE_FAILED")).map( (charge) => ({ transactionId: charge.id }), ), sendReceipt: ({ customerId, transactionId }) => - fromPromise(mailer.send(customerId, transactionId), qualify("RECEIPT_FAILED")), + fromPromise(mailer.send(customerId, transactionId), qualifyFailure("RECEIPT_FAILED")), }, }, }); ``` -`fromPromise(promise, qualify(...))` is the shape you will write most often. It +`fromPromise(promise, qualifyFailure(...))` is the shape you will write most often. It 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, `qualify("CHARGE_FAILED")` wraps the rejection in a Temporal +- 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 @@ -296,11 +304,13 @@ import { orderContract } from "./contract.js"; const connection = await Connection.connect({ address: "localhost:7233" }); const client = await TypedClient.create({ - contract: orderContract, client: new Client({ connection }), }).get(); -const result = await client.executeWorkflow("processOrder", { +// Bind the contract — synchronous, infallible, free to call anywhere. +const orders = client.for(orderContract); + +const result = await orders.executeWorkflow("processOrder", { workflowId: `order-${Date.now()}`, args: { orderId: "ORD-1", @@ -315,7 +325,7 @@ result.match({ }, errCases: (matcher) => matcher.with( - P.tag("@temporal-contract/WorkflowNotFoundError"), + P.tag("@temporal-contract/WorkflowNotInContractError"), P.tag("@temporal-contract/WorkflowValidationError"), P.tag("@temporal-contract/WorkflowAlreadyStartedError"), P.tag("@temporal-contract/WorkflowFailedError"), diff --git a/examples/README.md b/examples/README.md index 1e5f2db9..cb2669fc 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,13 +4,17 @@ ## Available Examples +### [order-processing-contract](./order-processing-contract) + +Shared contract package — domain schemas plus the workflow, activity, signal, query, and typed-error definitions imported by both the worker and the client (composition-first with the `define*` helpers) + ### [order-processing-worker](./order-processing-worker) -Standard Promise-based worker with Clean Architecture +Worker with Clean Architecture; activities return `AsyncResult` from unthrown, the workflow handles signals/queries via `context.defineSignal`/`defineQuery`, and a schedule-driven cleanup workflow shows the activity-less workflow shape ### [order-processing-client](./order-processing-client) -Standalone client demonstrating interaction with the unified contract +Standalone client demonstrating the `TypedClient.create({ client }).for(contract)` split: typed signals (with and without payload), an argument-less query, a typed `PaymentDeclined` contract error matched with `P.tag`, and a recurring schedule with the create-if-absent idiom **Note**: The client example works with the worker implementation seamlessly through the shared contract (`orderProcessingContract`). diff --git a/examples/order-processing-client/README.md b/examples/order-processing-client/README.md index 28b6aa09..7092efe8 100644 --- a/examples/order-processing-client/README.md +++ b/examples/order-processing-client/README.md @@ -6,11 +6,19 @@ This sample demonstrates that a single client can interact with any worker imple ## Overview -This client package demonstrates that: - -- The `order-processing-worker` implements the **unified contract** -- The client works with the worker implementation through the shared contract -- Workers handle errors internally using the Result/Future pattern with ApplicationFailure +This client package demonstrates: + +- The `TypedClient.create({ client })` (connection-scoped root) + + `.for(contract)` (contract-scoped client) split +- Typed signals through the workflow handle — `approveOrder` with a validated + payload, and the payload-less `cancelRequested` sent with no arguments +- An argument-less query (`getOrderStatus`) reading live workflow state +- The synchronous `getHandle`, returning a `Result` whose only Err is + `WorkflowNotInContractError` +- A typed contract error (`PaymentDeclined`) rehydrated from a failed + execution and matched exhaustively with unthrown's `match` + `P.tag` +- A recurring cleanup schedule via `schedule.create(...)`, with the + `ScheduleAlreadyExistsError` branch implementing the create-if-absent idiom ## Running the Sample @@ -47,17 +55,15 @@ pnpm dev ## Testing -The client includes integration tests that verify: - -- Workflow execution through the contract -- Proper input validation via contract schema -- Correct output types matching contract schema -- Workflow history and metadata access - -Run tests: +Integration tests live in the worker package +(`../order-processing-worker/src/integration.spec.ts`) and cover the same +surface this client demonstrates — start/execute, the approval signal + status +query, the payload-less cancellation signal, input validation, and the typed +`PaymentDeclined` contract error: ```bash -pnpm test +cd ../order-processing-worker +pnpm test:integration # requires Docker (testcontainers) ``` ## What to Notice @@ -73,15 +79,21 @@ pnpm test The unified contract (`orderProcessingContract`) defines: -- Global activities: `log`, `sendNotification` +- Global activities: `sendNotification`, `purgeExpiredOrders` - Workflow: `processOrder` - - Activities: `processPayment`, `reserveInventory`, `releaseInventory`, `createShipment`, `refundPayment` + - Activities: `processPayment` (with the `PaymentDeclined` typed error), `reserveInventory`, `releaseInventory`, `createShipment`, `refundPayment` + - Signals: `approveOrder` (payload), `cancelRequested` (payload-less) + - Query: `getOrderStatus` (argument-less) + - Errors: `PaymentDeclined` +- Workflow: `cleanupExpiredOrders` (activity-less; started by the schedule) ### Worker Implementation The worker (`examples/order-processing-worker`) uses `@temporal-contract/worker` with: -- Activities using the Result/Future pattern with ApplicationFailure +- Activities returning unthrown `AsyncResult` values (never throwing), with + `ApplicationFailure` for technical faults and typed constructors for + contract errors - Clean Architecture with dependency injection - Standalone TypeScript application diff --git a/examples/order-processing-client/src/client.ts b/examples/order-processing-client/src/client.ts index 33af50b8..89f868d2 100644 --- a/examples/order-processing-client/src/client.ts +++ b/examples/order-processing-client/src/client.ts @@ -14,8 +14,15 @@ type Order = z.infer; /** * Order Processing Client with unthrown AsyncResult Pattern * - * This client demonstrates how to interact with the unified order processing contract - * using unthrown's `AsyncResult` for explicit error handling. + * Demonstrates the full typed-client surface against the shared contract: + * - `TypedClient.create({ client })` (connection-scoped root) + + * `.for(contract)` (contract-scoped client) + * - start / query / signal / result through a typed workflow handle + * - a payload-less signal (`cancelRequested`) sent through a handle obtained + * with the synchronous `getHandle` + * - `executeWorkflow` with exhaustive error matching, including the typed + * `PaymentDeclined` contract error + * - a recurring schedule (`schedule.create`) with the create-if-absent idiom * * Usage: * 1. Start Temporal server: temporal server start-dev @@ -35,158 +42,189 @@ async function run() { namespace: "default", }); - // Create type-safe client with unthrown AsyncResult pattern — creation - // failures (bad connection, missing Schedule API) are technical faults that - // ride the defect channel (a `TechnicalError` cause), not the Err channel. - const clientResult = await TypedClient.create({ - contract: orderProcessingContract, - client: rawClient, + // Connection-scoped root — create once at process start. Creation failures + // (bad connection, missing Schedule API) are technical faults that ride the + // defect channel (a `TechnicalError` cause), so the Err channel is empty + // (`never`) and `.get()` unwraps directly (a setup defect rethrows its + // cause). + const typedClient = await TypedClient.create({ client: rawClient }).get(); + + // Contract-scoped client — binding a contract is synchronous, infallible, + // and memoized, so `for(contract)` is free to call anywhere. + const orders = typedClient.for(orderProcessingContract); + + // ========================================================================== + // 1. High-value order: start → query → approve signal → result + // ========================================================================== + logger.info("📦 Example 1: high-value order with approval signal + status query..."); + + const approvalOrder: Order = { + orderId: `ORD-${Date.now()}-APPROVAL`, + customerId: "CUST-123", + items: [ + { productId: "PROD-001", quantity: 2, price: 49.99 }, + { productId: "PROD-002", quantity: 1, price: 49.99 }, + ], + totalAmount: 149.97, // above the worker's $100 approval threshold + }; + + const startResult = await orders.startWorkflow("processOrder", { + workflowId: approvalOrder.orderId, + args: approvalOrder, }); - if (!clientResult.isOk()) { + if (!startResult.isOk()) { logger.error( - { err: clientResult.isErr() ? clientResult.error : clientResult.cause }, - "❌ Client creation failed", + { err: startResult.isErr() ? startResult.error : startResult.cause }, + "❌ Failed to start workflow", ); process.exit(1); } - const contractClient = clientResult.value; - - // Example orders to process - const orders: Order[] = [ - { - orderId: `ORD-${Date.now()}-001`, - customerId: "CUST-123", - items: [ - { - productId: "PROD-001", - quantity: 2, - price: 29.99, - }, - { - productId: "PROD-002", - quantity: 1, - price: 49.99, - }, - ], - totalAmount: 109.97, - }, - { - orderId: `ORD-${Date.now()}-002`, - customerId: "CUST-456", - items: [ - { - productId: "PROD-003", - quantity: 3, - price: 19.99, - }, - ], - totalAmount: 59.97, - }, - ]; - - logger.info("📦 Processing orders with unthrown AsyncResult..."); - - for (const order of orders) { - logger.info({ order }, `📦 Creating order: ${order.orderId}`); - - // Chain start → result on the AsyncResult railway: `tap` logs the started - // handle without leaving the railway, `flatMap` sequences the dependent - // `handle.result()` call, and its error union widens to cover both phases. - // A single `match` then folds the combined result exhaustively — every - // modeled error tag (package-namespaced `@temporal-contract/...`) plus `Ok` - // and `Defect` must be handled, or it is a compile error. - const result = await contractClient - .startWorkflow("processOrder", { workflowId: order.orderId, args: order }) - .tap((handle) => { - logger.info({ workflowId: handle.workflowId }, `✅ Workflow started: ${handle.workflowId}`); - logger.info("⌛ Waiting for workflow result..."); - }) - .flatMap((handle) => handle.result()); - - result.match({ - ok: (output) => { - if (output.status === "completed") { - logger.info( - { - orderId: output.orderId, - transactionId: output.transactionId, - trackingNumber: output.trackingNumber, - }, - `🎉 Order ${output.orderId} completed successfully!`, - ); - } else { + const handle = startResult.value; + logger.info({ workflowId: handle.workflowId }, `✅ Workflow started: ${handle.workflowId}`); + + // Argument-less query through the typed handle — the contract declares + // `getOrderStatus` with `defineQuery({ output })`, so no input is passed. + const statusBefore = await handle.queries.getOrderStatus(); + if (statusBefore.isOk()) { + logger.info({ report: statusBefore.value }, `🔎 Status: ${statusBefore.value.status}`); + } + + // Payload-carrying signal, validated against the contract's input schema. + const approvalSent = await handle.signals.approveOrder({ + approvedBy: "ops@example.com", + note: "Verified with finance", + }); + approvalSent.match({ + ok: () => logger.info("✍️ Approval signal sent"), + errCases: (matcher) => + matcher + .with(P.tag("@temporal-contract/SignalValidationError"), (err) => + logger.error({ error: err }, "❌ Signal payload rejected by the contract"), + ) + .with(P.tag("@temporal-contract/WorkflowExecutionNotFoundError"), (err) => + logger.error({ error: err }, "❌ Workflow execution not found"), + ), + defect: (cause) => logger.error({ cause }, "❌ Unexpected failure sending signal"), + }); + + logger.info("⌛ Waiting for workflow result..."); + const approvalOutcome = await handle.result(); + approvalOutcome.match({ + ok: (output) => + logger.info( + { orderId: output.orderId, trackingNumber: output.trackingNumber }, + `🎉 Order ${output.orderId} finished with status "${output.status}"`, + ), + 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) => logger.error( - { - orderId: output.orderId, - failureReason: output.failureReason, - errorCode: output.errorCode, - }, - `❌ Order ${output.orderId} failed`, - ); - } - }, - errCases: (matcher) => - matcher - .with(P.tag("@temporal-contract/WorkflowNotFoundError"), (err) => - logger.error({ error: err, orderId: order.orderId }, "❌ Workflow not found"), - ) - .with(P.tag("@temporal-contract/WorkflowValidationError"), (err) => - logger.error({ error: err, orderId: order.orderId }, "❌ Workflow 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. - .with(P.tag("@temporal-contract/WorkflowAlreadyStartedError"), (err) => - logger.warn( - { error: err, orderId: order.orderId }, - "⏭️ Workflow already started — skipping", - ), - ) - .with(P.tag("@temporal-contract/WorkflowFailedError"), (err) => - logger.error( - { error: err, orderId: order.orderId, cause: err.cause }, - "❌ Workflow completed with failure", - ), - ) - .with(P.tag("@temporal-contract/WorkflowExecutionNotFoundError"), (err) => - logger.error( - { error: err, orderId: order.orderId }, - "❌ Workflow execution not found in namespace", - ), + { errorName: err.errorName, reason: err.data.reason }, + `❌ Payment declined: ${err.data.reason}`, ), - // A defect is an unmodeled failure (a bug) — including technical/ - // infrastructure faults like a dropped connection (a `RuntimeClientError` - // cause) — not an anticipated outcome. - defect: (cause) => - logger.error({ cause, orderId: order.orderId }, "❌ Unexpected failure processing order"), - }); + ) + .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"), + ), + defect: (cause) => logger.error({ cause }, "❌ Unexpected failure awaiting result"), + }); + + // ========================================================================== + // 2. Cancellation: payload-less signal via a handle from `getHandle` + // ========================================================================== + logger.info("📦 Example 2: cancelling a pending order with a payload-less signal..."); + + const cancelOrder: Order = { + orderId: `ORD-${Date.now()}-CANCEL`, + customerId: "CUST-456", + items: [{ productId: "PROD-003", quantity: 3, price: 39.99 }], + totalAmount: 119.97, // above the threshold — waits for approval, giving us time to cancel + }; + + const cancelStart = await orders.startWorkflow("processOrder", { + workflowId: cancelOrder.orderId, + args: cancelOrder, + }); + if (!cancelStart.isOk()) { + logger.error( + { err: cancelStart.isErr() ? cancelStart.error : cancelStart.cause }, + "❌ Failed to start workflow", + ); + process.exit(1); + } + + // `getHandle` is synchronous: the only failure mode is a workflow name + // missing from the contract, surfaced as a sync `Result` Err. Whether the + // *execution* exists is answered lazily by the handle's methods. + const fetchedHandle = orders.getHandle("processOrder", cancelOrder.orderId); + if (!fetchedHandle.isOk()) { + logger.error( + { err: fetchedHandle.isErr() ? fetchedHandle.error : fetchedHandle.cause }, + "❌ Workflow not in contract", + ); + process.exit(1); } + const cancelHandle = fetchedHandle.value; + + // Payload-less signal — `defineSignal()` in the contract, sent with no + // arguments. + await cancelHandle.signals.cancelRequested(); + logger.info("🛑 Cancellation signal sent"); + + const cancelOutcome = await cancelHandle.result(); + cancelOutcome.match({ + ok: (output) => + logger.info( + { orderId: output.orderId, errorCode: output.errorCode }, + `✅ Order ${output.orderId} finished with status "${output.status}"`, + ), + errCases: (matcher) => + matcher + .with(P.tag("@temporal-contract/ContractError"), (err) => + logger.error({ errorName: err.errorName }, "❌ Payment declined"), + ) + .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"), + ), + defect: (cause) => logger.error({ cause }, "❌ Unexpected failure awaiting result"), + }); - // Example using executeWorkflow with AsyncResult pattern - logger.info("\n📦 Example: Using executeWorkflow with AsyncResult..."); + // ========================================================================== + // 3. executeWorkflow: small order, exhaustive error matching + // ========================================================================== + logger.info("📦 Example 3: executeWorkflow with exhaustive error matching..."); - const exampleOrder: Order = { - orderId: `ORD-${Date.now()}-EXAMPLE`, + const quickOrder: Order = { + orderId: `ORD-${Date.now()}-QUICK`, customerId: "CUST-789", - items: [ - { - productId: "PROD-004", - quantity: 1, - price: 99.99, - }, - ], - totalAmount: 99.99, + items: [{ productId: "PROD-004", quantity: 1, price: 59.97 }], + totalAmount: 59.97, // below the threshold — no approval needed }; - // Execute workflow and handle result - const result = await contractClient.executeWorkflow("processOrder", { - workflowId: exampleOrder.orderId, - args: exampleOrder, + // `executeWorkflow` combines start + result, so its error union is the + // widest: every modeled tag (package-namespaced `@temporal-contract/...`) + // plus `Ok` and `Defect` must be handled, or it is a compile error. + const executeResult = await orders.executeWorkflow("processOrder", { + workflowId: quickOrder.orderId, + args: quickOrder, }); - // Handle the result by tag — `executeWorkflow` can surface both start-phase - // and result-phase errors, so its union is the widest. - result.match({ + executeResult.match({ ok: (output) => { const summary = { id: output.orderId, @@ -194,20 +232,31 @@ async function run() { message: output.status === "completed" ? `Order completed with tracking: ${output.trackingNumber}` - : `Order failed: ${output.failureReason}`, + : `Order ${output.status}: ${output.failureReason}`, }; logger.info({ data: summary }, `📊 Order summary: ${summary.message}`); }, errCases: (matcher) => matcher - .with(P.tag("@temporal-contract/WorkflowNotFoundError"), (err) => - logger.error({ error: err }, "❌ Workflow not found"), + // The typed PaymentDeclined contract error, rehydrated from the + // workflow's ApplicationFailure wire shape. + .with(P.tag("@temporal-contract/ContractError"), (err) => + logger.error( + { errorName: err.errorName, reason: err.data.reason }, + `❌ 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. .with(P.tag("@temporal-contract/WorkflowAlreadyStartedError"), (err) => - logger.warn({ error: err }, "⏭️ Workflow already started"), + 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"), @@ -221,14 +270,66 @@ async function run() { defect: (cause) => logger.error({ cause }, "❌ Unexpected failure executing workflow"), }); - logger.info("\n✨ Done!"); + // ========================================================================== + // 4. Schedule: recurring order cleanup (create-if-absent idiom) + // ========================================================================== + logger.info("📦 Example 4: recurring cleanup schedule (create-if-absent)..."); + + const scheduleId = "order-cleanup-nightly"; + const createScheduleResult = await orders.schedule.create("cleanupExpiredOrders", { + scheduleId, + spec: { cronExpressions: ["0 3 * * *"] }, // every night at 03:00 + args: { olderThanDays: 30 }, + }); + + const scheduleHandle = createScheduleResult.match({ + ok: (created) => { + logger.info({ scheduleId: created.scheduleId }, "🗓️ Cleanup schedule created"); + return created; + }, + errCases: (matcher) => + 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) => { + logger.info({ scheduleId: err.scheduleId }, "⏭️ Schedule already exists — reusing it"); + return orders.schedule.getHandle(err.scheduleId); + }) + .with(P.tag("@temporal-contract/WorkflowNotInContractError"), (err) => { + logger.error({ error: err }, "❌ Workflow not declared in the contract"); + return undefined; + }) + .with(P.tag("@temporal-contract/WorkflowValidationError"), (err) => { + logger.error({ error: err }, "❌ Schedule args rejected by the contract"); + return undefined; + }), + defect: (cause) => { + logger.error({ cause }, "❌ Unexpected failure creating schedule"); + return undefined; + }, + }); + + if (scheduleHandle) { + // Fire the cleanup once right now instead of waiting for 03:00. + const triggerResult = await scheduleHandle.trigger(); + triggerResult.match({ + ok: () => logger.info("🧹 Cleanup run triggered immediately"), + errCases: (matcher) => + matcher.with(P.tag("@temporal-contract/ScheduleNotFoundError"), (err) => + logger.error({ error: err }, "❌ Schedule vanished before it could be triggered"), + ), + defect: (cause) => logger.error({ cause }, "❌ Unexpected failure triggering schedule"), + }); + } + + logger.info("✨ Done!"); logger.info(""); - logger.info("💡 Benefits of unthrown AsyncResult:"); - logger.info(" - Explicit error handling - no hidden exceptions"); - logger.info(" - Type-safe error values"); - logger.info(" - Functional composition with flatMap, map, mapErr, flatMapErr"); - logger.info(" - Railway-oriented programming"); - logger.info(" - Exhaustive error matching with unthrown match"); + 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(" - schedule.create with the ScheduleAlreadyExistsError branch"); + logger.info(" - Exhaustive error matching — every tag or it doesn't compile"); process.exit(0); } diff --git a/examples/order-processing-contract/README.md b/examples/order-processing-contract/README.md index 9d39c16a..08d48ad3 100644 --- a/examples/order-processing-contract/README.md +++ b/examples/order-processing-contract/README.md @@ -27,8 +27,15 @@ This package is designed to be shared between: ## What's included -- Contract definition with workflow and activity signatures -- Domain schemas (Order, PaymentResult, etc.) +- Contract definition (composition-first with the `define*` helpers): + - `processOrder` workflow with workflow-local activities, signals + (`approveOrder` with a payload, `cancelRequested` via the payload-less + `defineSignal()` form), an argument-less `getOrderStatus` query + (`defineQuery({ output })`), and a typed `PaymentDeclined` contract error + declared on both the `processPayment` activity and the workflow + - `cleanupExpiredOrders` — an activity-less workflow designed to run on a + Temporal Schedule, using only the global `purgeExpiredOrders` activity +- Domain schemas (Order, PaymentResult, OrderStatus, etc.) ## Usage @@ -51,22 +58,21 @@ export const processOrder = declareWorkflow({ ### In Client Application ```typescript -import { - orderProcessingContract, - Order, -} from "@temporal-contract/sample-order-processing-contract"; +import { orderProcessingContract } from "@temporal-contract/sample-order-processing-contract"; import { TypedClient } from "@temporal-contract/client"; -// Create type-safe client — creation returns AsyncResult<_, never>; setup +// Connection-scoped root — creation returns AsyncResult<_, never>; setup // faults ride the defect channel (a TechnicalError cause), so `get()` panics // (rethrowing that cause) on failure. -const client = await TypedClient.create({ - contract: orderProcessingContract, +const typedClient = await TypedClient.create({ client: new Client({ connection, namespace: "default" }), }).get(); +// Contract-scoped client — binding is synchronous, infallible, and memoized. +const orders = typedClient.for(orderProcessingContract); + // Start workflow with full type safety -const handle = await client.startWorkflow("processOrder", { +const handleResult = await orders.startWorkflow("processOrder", { workflowId: order.orderId, args: order, }); diff --git a/examples/order-processing-contract/src/contract.ts b/examples/order-processing-contract/src/contract.ts index fa8dc3ee..a068948e 100644 --- a/examples/order-processing-contract/src/contract.ts +++ b/examples/order-processing-contract/src/contract.ts @@ -1,101 +1,204 @@ -import { defineContract } from "@temporal-contract/contract"; +import { + defineActivity, + defineContract, + defineQuery, + defineSignal, + defineWorkflow, + type ErrorDefinition, +} from "@temporal-contract/contract"; import { z } from "zod"; import { - OrderSchema, + CleanupOrdersInputSchema, + CleanupOrdersResultSchema, + InventoryReservationSchema, + OrderApprovalSchema, OrderItemSchema, + OrderResultSchema, + OrderSchema, + OrderStatusReportSchema, + PaymentDeclinedDataSchema, PaymentResultSchema, - InventoryReservationSchema, ShippingResultSchema, - OrderResultSchema, } from "./schemas.js"; /** * Order Processing Contract * - * This contract defines a unified order processing system with: - * - Global activities for logging and notifications - * - A workflow for processing orders with payment, inventory, and shipping - * - Support for both standard Promise-based and Result/Future pattern implementations + * A unified order processing system demonstrating the full contract surface: + * - Global activities shared across workflows (`sendNotification`, + * `purgeExpiredOrders`) + * - The `processOrder` workflow with workflow-local activities, signals + * (payload-carrying and payload-less), an argument-less query, and a typed + * contract error + * - The activity-less `cleanupExpiredOrders` workflow, designed to run on a + * Temporal Schedule * - * The contract uses domain schemas as the source of truth for business entities. + * Composition-first: every resource is defined with its `define*` helper and + * referenced here, keeping the contract a readable table of contents. */ // ============================================================================ -// Contract Definition +// Typed contract errors // ============================================================================ -export const orderProcessingContract = defineContract({ - taskQueue: "order-processing", +/** + * Declared on the `processPayment` activity (produced via the activity + * implementation's `errors` helpers) AND on the `processOrder` workflow + * (rethrown via `context.errors`), so a decline travels typed end-to-end: + * activity → workflow → client. On the wire it is an `ApplicationFailure` + * with `type: "PaymentDeclined"` and `details[0]` validated against `data`; + * consumers rehydrate it into a `ContractError`. + */ +const paymentDeclinedError = { + data: PaymentDeclinedDataSchema, + message: "Payment was declined by the payment provider", + nonRetryable: true, +} satisfies ErrorDefinition; - /** - * Global activities available to all workflows - */ - activities: { - /** - * Send a notification to a customer - */ - sendNotification: { - input: z.object({ customerId: z.string(), subject: z.string(), message: z.string() }), - output: z.void(), - }, +// ============================================================================ +// Global activities (shared by all workflows) +// ============================================================================ + +/** + * Send a notification to a customer. + */ +const sendNotification = defineActivity({ + input: z.object({ customerId: z.string(), subject: z.string(), message: z.string() }), + output: z.void(), +}); + +/** + * Purge orders that finished more than `olderThanDays` days ago. Used by the + * schedule-driven `cleanupExpiredOrders` workflow. + */ +const purgeExpiredOrders = defineActivity({ + input: CleanupOrdersInputSchema, + output: CleanupOrdersResultSchema, +}); + +// ============================================================================ +// processOrder — workflow-local activities +// ============================================================================ + +/** + * Process payment for the order. Declares the `PaymentDeclined` typed error: + * the implementation surfaces a decline as + * `Err(errors.PaymentDeclined({ reason }))`, which the workflow receives as + * a typed `ContractError` on the activity call's error channel. + */ +const processPayment = defineActivity({ + input: z.object({ customerId: z.string(), amount: z.number() }), + output: PaymentResultSchema, + errors: { + PaymentDeclined: paymentDeclinedError, }, +}); + +/** + * Reserve inventory for the order items. + */ +const reserveInventory = defineActivity({ + input: z.array(OrderItemSchema), + output: InventoryReservationSchema, +}); + +/** + * Release reserved inventory. + */ +const releaseInventory = defineActivity({ + input: z.string(), + output: z.void(), +}); + +/** + * Create a shipment for the order. + */ +const createShipment = defineActivity({ + input: z.object({ orderId: z.string(), customerId: z.string() }), + output: ShippingResultSchema, +}); + +/** + * Refund a payment (used in case of errors). + */ +const refundPayment = defineActivity({ + input: z.string(), + output: z.void(), +}); + +// ============================================================================ +// processOrder — signals and queries +// ============================================================================ + +/** + * Approve a high-value order, carrying who approved and an optional note. + */ +const approveOrder = defineSignal({ input: OrderApprovalSchema }); + +/** + * Request cancellation of the order. Payload-less — `defineSignal()` without + * an input schema: the handler input is `undefined` and clients send it with + * no arguments. + */ +const cancelRequested = defineSignal(); + +/** + * Read the order's current lifecycle status. Argument-less — + * `defineQuery({ output })` without an input schema. + */ +const getOrderStatus = defineQuery({ output: OrderStatusReportSchema }); + +// ============================================================================ +// Workflows +// ============================================================================ - /** - * Workflows in this contract - */ - workflows: { - /** - * Process an order from payment to shipping - */ - processOrder: { - input: OrderSchema, - output: OrderResultSchema, - - /** - * Activities specific to the processOrder workflow - */ - activities: { - /** - * Process payment for the order - */ - processPayment: { - input: z.object({ customerId: z.string(), amount: z.number() }), - output: PaymentResultSchema, - }, - - /** - * Reserve inventory for the order items - */ - reserveInventory: { - input: z.array(OrderItemSchema), - output: InventoryReservationSchema, - }, - - /** - * Release reserved inventory - */ - releaseInventory: { - input: z.string(), - output: z.void(), - }, - - /** - * Create a shipment for the order - */ - createShipment: { - input: z.object({ orderId: z.string(), customerId: z.string() }), - output: ShippingResultSchema, - }, - - /** - * Refund a payment (used in case of errors) - */ - refundPayment: { - input: z.string(), - output: z.void(), - }, - }, - }, +/** + * Process an order from approval to shipping. + * + * Orders above the worker's approval threshold wait for the `approveOrder` + * signal (or `cancelRequested`) before payment. A declined payment fails the + * execution with the typed `PaymentDeclined` contract error. + */ +const processOrder = defineWorkflow({ + input: OrderSchema, + output: OrderResultSchema, + activities: { + processPayment, + reserveInventory, + releaseInventory, + createShipment, + refundPayment, }, + signals: { + approveOrder, + cancelRequested, + }, + queries: { + getOrderStatus, + }, + errors: { + PaymentDeclined: paymentDeclinedError, + }, +}); + +/** + * Recurring order cleanup, designed to be started by a Temporal Schedule + * (see the client example's `schedule.create` call). It declares NO + * workflow-local activities — it only uses the global `purgeExpiredOrders` — + * so the worker's activities implementation map needs no entry for it. + */ +const cleanupExpiredOrders = defineWorkflow({ + input: CleanupOrdersInputSchema, + output: CleanupOrdersResultSchema, +}); + +// ============================================================================ +// Contract Definition +// ============================================================================ + +export const orderProcessingContract = defineContract({ + taskQueue: "order-processing", + workflows: { processOrder, cleanupExpiredOrders }, + activities: { sendNotification, purgeExpiredOrders }, }); diff --git a/examples/order-processing-contract/src/schemas.ts b/examples/order-processing-contract/src/schemas.ts index 596a4d7e..1d99aca1 100644 --- a/examples/order-processing-contract/src/schemas.ts +++ b/examples/order-processing-contract/src/schemas.ts @@ -17,16 +17,23 @@ export const OrderSchema = z.object({ totalAmount: z.number().positive(), }); -export const PaymentResultSchema = z.discriminatedUnion("status", [ - z.object({ - status: z.literal("success"), - transactionId: z.string(), - paidAmount: z.number(), - }), - z.object({ - status: z.literal("failed"), - }), -]); +/** + * Successful payment. A declined card is NOT an output shape — it is the + * `PaymentDeclined` typed contract error declared on the `processPayment` + * activity (and re-declared on the `processOrder` workflow, which rethrows + * it so the typed client rehydrates it). + */ +export const PaymentResultSchema = z.object({ + transactionId: z.string(), + paidAmount: z.number(), +}); + +/** + * Data payload of the `PaymentDeclined` typed contract error. + */ +export const PaymentDeclinedDataSchema = z.object({ + reason: z.string(), +}); export const InventoryReservationSchema = z.object({ reserved: z.boolean(), @@ -38,11 +45,50 @@ export const ShippingResultSchema = z.object({ estimatedDelivery: z.string(), }); +/** + * Payload of the `approveOrder` signal — who approved, with an optional note. + */ +export const OrderApprovalSchema = z.object({ + approvedBy: z.string(), + note: z.string().optional(), +}); + +/** + * Lifecycle of an order inside the `processOrder` workflow, exposed through + * the `getOrderStatus` query. + */ +export const OrderStatusSchema = z.enum([ + "awaiting_approval", + "processing_payment", + "reserving_inventory", + "creating_shipment", + "completed", + "failed", + "cancelled", +]); + +export const OrderStatusReportSchema = z.object({ + status: OrderStatusSchema, + approvedBy: z.string().optional(), +}); + export const OrderResultSchema = z.object({ orderId: z.string(), - status: z.enum(["completed", "failed"]), + status: z.enum(["completed", "failed", "cancelled"]), transactionId: z.string().optional(), trackingNumber: z.string().optional(), failureReason: z.string().optional(), errorCode: z.string().optional(), }); + +/** + * Input/output of the recurring `cleanupExpiredOrders` workflow, started by + * a Temporal Schedule (see the client example's `schedule.create` call). + */ +export const CleanupOrdersInputSchema = z.object({ + olderThanDays: z.number().int().positive(), +}); + +export const CleanupOrdersResultSchema = z.object({ + purgedCount: z.number().int().nonnegative(), +}); diff --git a/examples/order-processing-worker/README.md b/examples/order-processing-worker/README.md index 26f66b6f..6dcc792f 100644 --- a/examples/order-processing-worker/README.md +++ b/examples/order-processing-worker/README.md @@ -1,6 +1,6 @@ # Order Processing Worker -> Type-safe order processing worker with Clean Architecture and Result/Future pattern +> Type-safe order processing worker with Clean Architecture and unthrown's AsyncResult pattern ## Running @@ -15,7 +15,18 @@ pnpm dev ## What It Demonstrates - ✅ Type-safe contracts with Zod -- ✅ Result/Future pattern with ApplicationFailure for explicit error handling +- ✅ Activities returning `AsyncResult` (never throwing) — technical faults + wrapped via `qualifyFailure(...)`, domain declines produced with the typed + `errors.PaymentDeclined(...)` constructor from the helpers argument +- ✅ Signal and query handlers registered inside the workflow via + `context.defineSignal` / `context.defineQuery` (deterministic, replay-safe + state), including a payload-less signal and an argument-less query +- ✅ An approval gate built on `condition(...)` — orders above $100 wait for + the `approveOrder` signal (or `cancelRequested` / a timeout) +- ✅ A typed contract error rethrown end-to-end: activity → workflow + (`context.errors.PaymentDeclined`) → typed client +- ✅ An activity-less, schedule-driven workflow (`cleanupExpiredOrders`) that + needs no entry in the activities implementation map - ✅ Clean Architecture (Domain → Infrastructure → Application) - ✅ Dependency injection for testability - ✅ Error handling with compensating actions diff --git a/examples/order-processing-worker/src/application/activities.ts b/examples/order-processing-worker/src/application/activities.ts index 8a2a200a..880d8e70 100644 --- a/examples/order-processing-worker/src/application/activities.ts +++ b/examples/order-processing-worker/src/application/activities.ts @@ -1,10 +1,11 @@ import { orderProcessingContract } from "@temporal-contract/sample-order-processing-contract"; -import { declareActivitiesHandler, qualify } from "@temporal-contract/worker/activity"; -import { fromPromise } from "unthrown"; +import { declareActivitiesHandler, qualifyFailure } from "@temporal-contract/worker/activity"; +import { Err, Ok, fromPromise } from "unthrown"; import { sendNotificationUseCase, processPaymentUseCase, + purgeExpiredOrdersUseCase, reserveInventoryUseCase, releaseInventoryUseCase, createShipmentUseCase, @@ -16,20 +17,18 @@ import { * * Instead of throwing exceptions, activities return: * - Ok(value).toAsync() for success - * - Err(ApplicationFailure).toAsync() for failures (or a `fromPromise` - * chain that qualifies a rejection into an `ApplicationFailure`). + * - Err(ApplicationFailure).toAsync() 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 + * typed constructors arrive in the implementation's second (helpers) + * argument and surface to the calling workflow as typed `ContractError`s. * * All technical exceptions MUST be caught and wrapped in `ApplicationFailure` * (Temporal's first-class failure shape, re-exported from - * `@temporal-contract/worker/activity` for convenience). Per-instance - * `nonRetryable: true` opts a specific failure out of the configured - * retry policy. - * - * Benefits: - * - Explicit error types in function signatures - * - Per-instance `nonRetryable` flag for permanent failures - * - Functional composition with map/flatMap/match - * - Native Temporal serialization across the activity → workflow boundary + * `@temporal-contract/worker/activity`). Per-instance `nonRetryable: true` + * opts a specific failure out of the configured retry policy; for contract + * errors, retryability comes from the contract's `errors` declaration. */ // ============================================================================ @@ -39,11 +38,11 @@ import { /** * Create the activities handler with unthrown's AsyncResult pattern. * Activities are thin wrappers that delegate to use cases. - * All activities return `AsyncResult`. * - * Domain errors are wrapped in `ApplicationFailure` so Temporal applies the - * configured retry policy. Set `nonRetryable: true` for permanent failures - * (e.g. validation rejections, insufficient funds). + * The implementation map mirrors the contract's structure: global activities + * at the root, workflow-local activities nested under their workflow's name. + * `cleanupExpiredOrders` declares no workflow-local activities, so it needs + * no entry here — not even an empty `{}` placeholder. */ export const activities = declareActivitiesHandler({ contract: orderProcessingContract, @@ -51,38 +50,58 @@ export const activities = declareActivitiesHandler({ sendNotification: ({ customerId, subject, message }) => fromPromise( sendNotificationUseCase.execute(customerId, subject, message), - qualify("NOTIFICATION_FAILED", { message: "Failed to send notification" }), + qualifyFailure("NOTIFICATION_FAILED", { message: "Failed to send notification" }), ), + purgeExpiredOrders: ({ olderThanDays }) => + fromPromise( + purgeExpiredOrdersUseCase.execute(olderThanDays), + qualifyFailure("ORDER_PURGE_FAILED", { message: "Order purge failed" }), + ).map((purgedCount) => ({ purgedCount })), + processOrder: { - processPayment: ({ customerId, amount }) => + // The second (helpers) argument carries `errors` — typed constructors + // for this activity's contract-declared `errors` map. A declined + // payment is a *modeled* domain outcome: it becomes + // `Err(errors.PaymentDeclined({ reason }))`, which crosses the wire as + // an `ApplicationFailure(type: "PaymentDeclined")` and rehydrates as a + // typed `ContractError` on the workflow side. Only gateway faults ride + // the generic `ApplicationFailure` path. + processPayment: ({ customerId, amount }, { errors }) => fromPromise( processPaymentUseCase.execute(customerId, amount), - qualify("PAYMENT_FAILED", { message: "Payment processing failed" }), - ), + qualifyFailure("PAYMENT_GATEWAY_ERROR", { message: "Payment gateway call failed" }), + ).flatMap((outcome) => { + if (outcome.status === "declined") { + return Err(errors.PaymentDeclined({ reason: outcome.reason })); + } + return Ok({ transactionId: outcome.transactionId, paidAmount: outcome.paidAmount }); + }), reserveInventory: (items) => fromPromise( reserveInventoryUseCase.execute(items), - qualify("INVENTORY_RESERVATION_FAILED", { message: "Inventory reservation failed" }), + qualifyFailure("INVENTORY_RESERVATION_FAILED", { + message: "Inventory reservation failed", + }), ), releaseInventory: (reservationId) => fromPromise( releaseInventoryUseCase.execute(reservationId), - qualify("INVENTORY_RELEASE_FAILED", { message: "Inventory release failed" }), + qualifyFailure("INVENTORY_RELEASE_FAILED", { message: "Inventory release failed" }), ), createShipment: ({ orderId, customerId }) => fromPromise( createShipmentUseCase.execute(orderId, customerId), - qualify("SHIPMENT_CREATION_FAILED", { message: "Shipment creation failed" }), + qualifyFailure("SHIPMENT_CREATION_FAILED", { message: "Shipment creation failed" }), ), refundPayment: (transactionId) => fromPromise( refundPaymentUseCase.execute(transactionId), - qualify("REFUND_FAILED", { message: "Refund failed" }), + qualifyFailure("REFUND_FAILED", { message: "Refund failed" }), ), }, }, diff --git a/examples/order-processing-worker/src/application/workflows.ts b/examples/order-processing-worker/src/application/workflows.ts index 76d27e4d..f1717824 100644 --- a/examples/order-processing-worker/src/application/workflows.ts +++ b/examples/order-processing-worker/src/application/workflows.ts @@ -1,14 +1,41 @@ -import { orderProcessingContract } from "@temporal-contract/sample-order-processing-contract"; +import { + orderProcessingContract, + type OrderStatusSchema, +} from "@temporal-contract/sample-order-processing-contract"; import { declareWorkflow } from "@temporal-contract/worker/workflow"; -import { isCancellation, log } from "@temporalio/workflow"; +import { condition, isCancellation, log } from "@temporalio/workflow"; +import type { z } from "zod"; + +type OrderStatus = z.infer; + +/** + * Orders above this amount wait for a human `approveOrder` signal before + * payment. A constant keeps the decision deterministic across replays. + */ +const APPROVAL_THRESHOLD = 100; + +/** + * How long a high-value order waits for approval before failing. + */ +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 + * deterministic way to expose interactive state. * - Activities use unthrown's `AsyncResult` in their implementation - * (domain + infrastructure). - * - Workflow checks activity results and returns appropriate status. - * - No exceptions thrown — pure functional style with explicit return values. + * (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. + * + * Determinism note: everything here is replay-safe — `condition` and `log` + * come from `@temporalio/workflow`, signal/query state is plain local data, + * and every side effect goes through an activity. * * Logging note: this workflow uses the `log` namespace from * `@temporalio/workflow` (replay-safe, routed through the worker's @@ -19,12 +46,14 @@ import { isCancellation, log } from "@temporalio/workflow"; * domain effects go through activities. * * Flow: - * 1. Log order start - * 2. Process payment → if failed, return early - * 3. Reserve inventory → if failed, refund payment and return - * 4. Create shipment - * 5. Send confirmation - * 6. Return success status + * 1. Register signal handlers (`approveOrder`, `cancelRequested`) and the + * `getOrderStatus` query + * 2. High-value orders wait for approval (or cancellation / timeout) + * 3. Process payment → declined ⇒ notify + throw typed `PaymentDeclined` + * 4. Reserve inventory → if failed, refund payment and return + * 5. Create shipment + * 6. Send confirmation + * 7. Return success status */ export const processOrder = declareWorkflow({ workflowName: "processOrder", @@ -32,53 +61,149 @@ export const processOrder = declareWorkflow({ implementation: async (context, order) => { const { activities, info } = context; - // State tracking for rollback - let paymentTransactionId: string | undefined; + // ------------------------------------------------------------------ + // Interactive state: driven by signals, read by the query. + // ------------------------------------------------------------------ + let status: OrderStatus = + order.totalAmount > APPROVAL_THRESHOLD ? "awaiting_approval" : "processing_payment"; + let approvedBy: string | undefined; + let cancelRequested = false; + + // Payload-carrying signal — the handler receives the schema-parsed input. + context.defineSignal("approveOrder", (approval) => { + approvedBy = approval.approvedBy; + log.info( + `Order ${order.orderId} approved by ${approval.approvedBy}` + + (approval.note ? ` — ${approval.note}` : ""), + ); + }); + + // Payload-less signal (`defineSignal()` in the contract) — no arguments. + context.defineSignal("cancelRequested", () => { + cancelRequested = true; + log.info(`Cancellation requested for order ${order.orderId}`); + }); + + // 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", () => ({ + status, + ...(approvedBy !== undefined ? { approvedBy } : {}), + })); - // Step 1: Log order start log.info(`Starting order processing for ${order.orderId} (workflow: ${info.workflowId})`); - // Step 2: Process payment + // ------------------------------------------------------------------ + // Step 1: approval gate for high-value orders + // ------------------------------------------------------------------ + if (status === "awaiting_approval") { + log.info(`Order ${order.orderId} exceeds $${APPROVAL_THRESHOLD} — awaiting approval signal`); + + // `condition` is Temporal's deterministic wait: it resumes when the + // predicate flips (a signal arrived) or the timeout elapses (false). + const decided = await condition( + () => approvedBy !== undefined || cancelRequested, + APPROVAL_TIMEOUT, + ); + + if (!decided) { + status = "failed"; + log.error(`Order ${order.orderId} approval timed out`); + return { + orderId: order.orderId, + status: "failed" as const, + failureReason: `No approval received within ${APPROVAL_TIMEOUT}`, + errorCode: "APPROVAL_TIMEOUT", + }; + } + } + + if (cancelRequested) { + status = "cancelled"; + log.info(`Order ${order.orderId} cancelled before payment`); + return { + orderId: order.orderId, + status: "cancelled" as const, + failureReason: "Cancellation requested by the customer", + errorCode: "CANCELLED", + }; + } + + // ------------------------------------------------------------------ + // Step 2: process payment (activity with a declared contract error) + // ------------------------------------------------------------------ + status = "processing_payment"; log.info(`Processing payment of $${order.totalAmount}`); + // `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, }); - // Check payment status — return early if failed - if (paymentResult.status === "failed") { - log.error("Payment failed: card declined"); + if (!paymentResult.isOk()) { + status = "failed"; - 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.`, - }); + if (paymentResult.isDefect()) { + // Unmodeled failure (a bug, not an anticipated outcome) — rethrow at + // the edge so Temporal surfaces the Workflow Task failure. + throw paymentResult.cause; + } + const failure = paymentResult.error; - return { - orderId: order.orderId, - status: "failed" as const, - failureReason: "Payment was declined", - errorCode: "PAYMENT_FAILED", - }; + 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`. + 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", + }; + } + } } - paymentTransactionId = paymentResult.transactionId; - log.info(`Payment successful: ${paymentTransactionId}`); + const payment = paymentResult.value; + log.info(`Payment successful: ${payment.transactionId}`); - // Step 3: Reserve inventory + // ------------------------------------------------------------------ + // Step 3: reserve inventory (rollback payment on failure) + // ------------------------------------------------------------------ + status = "reserving_inventory"; log.info("Reserving inventory"); const inventoryResult = await activities.reserveInventory(order.items); - // Check inventory — rollback payment if failed if (!inventoryResult.reserved) { + status = "failed"; log.error("Inventory reservation failed"); // Rollback: Refund payment log.info("Rolling back: refunding payment"); - await activities.refundPayment(paymentTransactionId); - log.info(`Payment refunded: ${paymentTransactionId}`); + await activities.refundPayment(payment.transactionId); + log.info(`Payment refunded: ${payment.transactionId}`); await activities.sendNotification({ customerId: order.customerId, @@ -96,7 +221,10 @@ export const processOrder = declareWorkflow({ log.info(`Inventory reserved: ${inventoryResult.reservationId}`); - // Step 4: Create shipment + // ------------------------------------------------------------------ + // Step 4: create shipment + // ------------------------------------------------------------------ + status = "creating_shipment"; log.info("Creating shipment"); const shippingResult = await activities.createShipment({ orderId: order.orderId, @@ -123,12 +251,13 @@ export const processOrder = declareWorkflow({ } // Success! + status = "completed"; log.info(`Order ${order.orderId} processed successfully`); return { orderId: order.orderId, status: "completed" as const, - transactionId: paymentTransactionId, + transactionId: payment.transactionId, trackingNumber: shippingResult.trackingNumber, }; }, @@ -150,3 +279,27 @@ export const processOrder = declareWorkflow({ }, }, }); + +/** + * Cleanup Expired Orders Workflow Implementation + * + * The schedule-driven counterpart to `processOrder` (see the client + * example's `schedule.create` call). It only uses the global + * `purgeExpiredOrders` activity — it declares no workflow-local activities, + * so the activities handler needs no entry for it. + */ +export const cleanupExpiredOrders = declareWorkflow({ + workflowName: "cleanupExpiredOrders", + contract: orderProcessingContract, + activityOptions: { + startToCloseTimeout: "1 minute", + }, + implementation: async (context, { olderThanDays }) => { + log.info(`Starting order cleanup (older than ${olderThanDays} days)`); + + const { purgedCount } = await context.activities.purgeExpiredOrders({ olderThanDays }); + + log.info(`Order cleanup finished: purged ${purgedCount} orders`); + return { purgedCount }; + }, +}); diff --git a/examples/order-processing-worker/src/dependencies.ts b/examples/order-processing-worker/src/dependencies.ts index babbff55..6aad74cc 100644 --- a/examples/order-processing-worker/src/dependencies.ts +++ b/examples/order-processing-worker/src/dependencies.ts @@ -6,12 +6,14 @@ import { CreateShipmentUseCase } from "./domain/usecases/create-shipment.usecase.js"; // Domain import { ProcessPaymentUseCase } from "./domain/usecases/process-payment.usecase.js"; +import { PurgeExpiredOrdersUseCase } from "./domain/usecases/purge-expired-orders.usecase.js"; import { RefundPaymentUseCase } from "./domain/usecases/refund-payment.usecase.js"; import { ReleaseInventoryUseCase } from "./domain/usecases/release-inventory.usecase.js"; import { ReserveInventoryUseCase } from "./domain/usecases/reserve-inventory.usecase.js"; import { SendNotificationUseCase } from "./domain/usecases/send-notification.usecase.js"; import { MockInventoryAdapter } from "./infrastructure/adapters/inventory.adapter.js"; import { ConsoleNotificationAdapter } from "./infrastructure/adapters/notification.adapter.js"; +import { MockOrderRepositoryAdapter } from "./infrastructure/adapters/order-repository.adapter.js"; // Infrastructure import { MockPaymentAdapter } from "./infrastructure/adapters/payment.adapter.js"; import { MockShippingAdapter } from "./infrastructure/adapters/shipping.adapter.js"; @@ -24,6 +26,7 @@ export const paymentAdapter = new MockPaymentAdapter(); const inventoryAdapter = new MockInventoryAdapter(); const shippingAdapter = new MockShippingAdapter(); const notificationAdapter = new ConsoleNotificationAdapter(); +const orderRepositoryAdapter = new MockOrderRepositoryAdapter(); // ============================================================================ // Use Cases @@ -35,3 +38,4 @@ export const releaseInventoryUseCase = new ReleaseInventoryUseCase(inventoryAdap export const createShipmentUseCase = new CreateShipmentUseCase(shippingAdapter); export const sendNotificationUseCase = new SendNotificationUseCase(notificationAdapter); export const refundPaymentUseCase = new RefundPaymentUseCase(paymentAdapter); +export const purgeExpiredOrdersUseCase = new PurgeExpiredOrdersUseCase(orderRepositoryAdapter); diff --git a/examples/order-processing-worker/src/domain/entities/order.schema.ts b/examples/order-processing-worker/src/domain/entities/order.schema.ts index 97c4cb60..e38660f4 100644 --- a/examples/order-processing-worker/src/domain/entities/order.schema.ts +++ b/examples/order-processing-worker/src/domain/entities/order.schema.ts @@ -17,3 +17,13 @@ export type OrderItem = z.infer; export type PaymentResult = z.infer; export type InventoryReservation = z.infer; export type ShippingResult = z.infer; + +/** + * Domain-level payment outcome. A decline is a *modeled* business outcome of + * the payment gateway — the activity boundary maps it onto the contract's + * `PaymentDeclined` typed error (see application/activities.ts), so it never + * travels as an untyped exception. + */ +export type PaymentOutcome = + | ({ status: "approved" } & PaymentResult) + | { status: "declined"; reason: string }; diff --git a/examples/order-processing-worker/src/domain/ports/order-repository.port.ts b/examples/order-processing-worker/src/domain/ports/order-repository.port.ts new file mode 100644 index 00000000..14adf5f4 --- /dev/null +++ b/examples/order-processing-worker/src/domain/ports/order-repository.port.ts @@ -0,0 +1,10 @@ +/** + * Order Repository Port - Interface for order persistence operations + */ +export type OrderRepositoryPort = { + /** + * Purge orders that finished more than `olderThanDays` days ago. + * Resolves with the number of purged orders. + */ + purgeOrdersOlderThan(olderThanDays: number): Promise; +}; diff --git a/examples/order-processing-worker/src/domain/ports/payment.port.ts b/examples/order-processing-worker/src/domain/ports/payment.port.ts index 8eb92e84..4fe50fde 100644 --- a/examples/order-processing-worker/src/domain/ports/payment.port.ts +++ b/examples/order-processing-worker/src/domain/ports/payment.port.ts @@ -1,13 +1,15 @@ -import type { PaymentResult } from "../entities/order.schema.js"; +import type { PaymentOutcome } from "../entities/order.schema.js"; /** * Payment Port - Interface for payment operations */ export type PaymentPort = { /** - * Process a payment for a customer + * Process a payment for a customer. Resolves with a domain-level outcome: + * approved (with transaction details) or declined (with a reason). + * Rejections are reserved for technical gateway faults. */ - processPayment(customerId: string, amount: number): Promise; + processPayment(customerId: string, amount: number): Promise; /** * Refund a payment transaction 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 6840c881..6a67469a 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,4 @@ -import type { PaymentResult } from "../entities/order.schema.js"; +import type { PaymentOutcome } from "../entities/order.schema.js"; import type { PaymentPort } from "../ports/payment.port.js"; /** @@ -9,7 +9,7 @@ import type { PaymentPort } from "../ports/payment.port.js"; export class ProcessPaymentUseCase { constructor(private readonly paymentPort: PaymentPort) {} - async execute(customerId: string, amount: number): Promise { + async execute(customerId: string, amount: number): Promise { // Business validation if (amount <= 0) { throw new Error("Payment amount must be positive"); 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 new file mode 100644 index 00000000..e2fd8e80 --- /dev/null +++ b/examples/order-processing-worker/src/domain/usecases/purge-expired-orders.usecase.ts @@ -0,0 +1,20 @@ +import type { OrderRepositoryPort } from "../ports/order-repository.port.js"; + +/** + * Purge Expired Orders Use Case + * + * Business logic for the recurring order cleanup (schedule-driven). + */ +export class PurgeExpiredOrdersUseCase { + constructor(private readonly orderRepository: OrderRepositoryPort) {} + + async execute(olderThanDays: number): Promise { + // Business validation + if (!Number.isInteger(olderThanDays) || olderThanDays <= 0) { + throw new Error("olderThanDays must be a positive integer"); + } + + // Delegate to order repository port + return this.orderRepository.purgeOrdersOlderThan(olderThanDays); + } +} diff --git a/examples/order-processing-worker/src/infrastructure/adapters/order-repository.adapter.ts b/examples/order-processing-worker/src/infrastructure/adapters/order-repository.adapter.ts new file mode 100644 index 00000000..6a2c1fd1 --- /dev/null +++ b/examples/order-processing-worker/src/infrastructure/adapters/order-repository.adapter.ts @@ -0,0 +1,22 @@ +import type { OrderRepositoryPort } from "../../domain/ports/order-repository.port.js"; +import { logger } from "../../logger.js"; + +/** + * Mock Order Repository Adapter + * + * Concrete implementation of OrderRepositoryPort for testing/demo purposes + */ +export class MockOrderRepositoryAdapter implements OrderRepositoryPort { + async purgeOrdersOlderThan(olderThanDays: number): Promise { + // Simulate a database sweep. + // In a real implementation this would delete expired rows and return the count. + const purgedCount = Math.floor(Math.random() * 5); + + logger.info( + { olderThanDays, purgedCount }, + `🧹 Purged ${purgedCount} orders older than ${olderThanDays} days`, + ); + + return purgedCount; + } +} 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 0cf44d16..b3e7bb6a 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,4 @@ -import type { PaymentResult } from "../../domain/entities/order.schema.js"; +import type { PaymentOutcome } from "../../domain/entities/order.schema.js"; import type { PaymentPort } from "../../domain/ports/payment.port.js"; import { logger } from "../../logger.js"; @@ -8,7 +8,7 @@ import { logger } from "../../logger.js"; * Concrete implementation of PaymentPort for testing/demo purposes */ export class MockPaymentAdapter implements PaymentPort { - async processPayment(customerId: string, amount: number): Promise { + async processPayment(customerId: string, amount: number): Promise { logger.info( { customerId, amount }, `💳 Processing payment of $${amount} for customer ${customerId}`, @@ -16,11 +16,11 @@ export class MockPaymentAdapter implements PaymentPort { // Simulate payment processing // In real implementation, this would call a payment gateway API - const success = Math.random() > 0.1; // 10% failure rate + const approved = Math.random() > 0.1; // 10% decline rate - if (success) { - const result: PaymentResult = { - status: "success" as const, + if (approved) { + const result: PaymentOutcome = { + status: "approved" as const, transactionId: `TXN${Date.now()}`, paidAmount: amount, }; @@ -32,11 +32,15 @@ export class MockPaymentAdapter implements PaymentPort { return result; } else { - const result: PaymentResult = { - status: "failed" as const, + // A decline is a modeled business outcome, not an exception — the + // activity boundary converts it into the `PaymentDeclined` contract + // error. + const result: PaymentOutcome = { + status: "declined" as const, + reason: "insufficient_funds", }; - logger.error(`❌ Payment failed`); + logger.warn(`❌ Payment declined: ${result.reason}`); return result; } diff --git a/examples/order-processing-worker/src/integration.spec.ts b/examples/order-processing-worker/src/integration.spec.ts index fa7ead55..355d2b4e 100644 --- a/examples/order-processing-worker/src/integration.spec.ts +++ b/examples/order-processing-worker/src/integration.spec.ts @@ -1,7 +1,12 @@ import { extname } from "node:path"; import { fileURLToPath } from "node:url"; -import { TypedClient, WorkflowValidationError } from "@temporal-contract/client"; +import { + ContractError, + TypedClient, + WorkflowValidationError, + type ContractClient, +} from "@temporal-contract/client"; import { orderProcessingContract, type OrderSchema, @@ -19,7 +24,7 @@ type Order = z.infer; const it = baseIt.extend<{ worker: Worker; - client: TypedClient; + client: ContractClient; }>({ worker: [ async ({ workerConnection }, use) => { @@ -48,35 +53,30 @@ const it = baseIt.extend<{ { auto: true }, ], client: async ({ clientConnection }, use) => { - // Create typed client const rawClient = new Client({ connection: clientConnection, namespace: "default", }); - const clientResult = await TypedClient.create({ - contract: orderProcessingContract, - client: rawClient, - }); - if (!clientResult.isOk()) { - throw clientResult.isErr() ? clientResult.error : clientResult.cause; - } + // Connection-scoped root (E = never, so `.get()` unwraps directly), + // then bind the contract for the typed, contract-scoped surface. + const typedClient = await TypedClient.create({ client: rawClient }).get(); - await use(clientResult.value); + await use(typedClient.for(orderProcessingContract)); }, }); describe("Order Processing Workflow - Integration Tests", () => { beforeEach(() => { - // Mock payment adapter to always succeed for deterministic tests + // Mock payment adapter to always approve for deterministic tests vi.spyOn(paymentAdapter, "processPayment").mockResolvedValue({ + status: "approved", transactionId: "TXN-MOCK-123", - status: "success", paidAmount: 0, // Will be overridden by actual call }); }); it("should process an order successfully", async ({ client }) => { - // GIVEN + // GIVEN — below the $100 approval threshold, so no signal is needed const order: Order = { orderId: `ORD-TEST-${Date.now()}`, customerId: "CUST-TEST-001", @@ -84,7 +84,7 @@ describe("Order Processing Workflow - Integration Tests", () => { { productId: "PROD-001", quantity: 2, - price: 29.99, + price: 19.99, }, { productId: "PROD-002", @@ -92,7 +92,7 @@ describe("Order Processing Workflow - Integration Tests", () => { price: 49.99, }, ], - totalAmount: 109.97, + totalAmount: 89.97, }; // WHEN @@ -173,8 +173,9 @@ describe("Order Processing Workflow - Integration Tests", () => { args: order, }); - // THEN - const handleResult = await client.getHandle("processOrder", order.orderId); + // THEN — getHandle is synchronous: the only failure mode is a workflow + // name missing from the contract, surfaced as a sync Result Err. + const handleResult = client.getHandle("processOrder", order.orderId); expect(handleResult).toBeOk(); if (!handleResult.isOk()) throw new Error("Expected Ok result"); @@ -202,10 +203,10 @@ describe("Order Processing Workflow - Integration Tests", () => { { productId: "PROD-005", quantity: 1, - price: 149.99, + price: 49.99, }, ], - totalAmount: 149.99, + totalAmount: 49.99, }; // WHEN @@ -271,10 +272,107 @@ describe("Order Processing Workflow - Integration Tests", () => { } }); - it("should handle payment failure", async ({ client }) => { - // GIVEN - Mock payment to fail + it("should wait for approval on high-value orders (query + signal)", async ({ client }) => { + // GIVEN — above the $100 approval threshold + const order: Order = { + orderId: `ORD-TEST-${Date.now()}`, + customerId: "CUST-TEST-007", + items: [ + { + productId: "PROD-008", + quantity: 2, + price: 74.99, + }, + ], + totalAmount: 149.98, + }; + + const handleResult = await client.startWorkflow("processOrder", { + workflowId: order.orderId, + args: order, + }); + expect(handleResult).toBeOk(); + if (!handleResult.isOk()) throw new Error("Expected Ok result"); + const handle = handleResult.value; + + // WHEN — the argument-less query reports the approval gate + const statusReport = await handle.queries.getOrderStatus(); + expect(statusReport).toBeOk(); + if (statusReport.isOk()) { + expect(statusReport.value).toEqual({ status: "awaiting_approval" }); + } + + // ... and the approval signal (payload validated by the contract) lets + // the workflow proceed + const signalResult = await handle.signals.approveOrder({ + approvedBy: "qa@example.com", + note: "Approved in integration test", + }); + expect(signalResult).toBeOk(); + + // THEN + const result = await handle.result(); + expect(result).toBeOk(); + if (result.isOk()) { + expect(result.value).toEqual({ + orderId: order.orderId, + status: "completed", + transactionId: expect.any(String), + trackingNumber: expect.any(String), + }); + } + }); + + it("should cancel a pending order via the payload-less signal", async ({ client }) => { + // GIVEN — above the threshold, so the workflow parks at the approval gate + const order: Order = { + orderId: `ORD-TEST-${Date.now()}`, + customerId: "CUST-TEST-008", + items: [ + { + productId: "PROD-009", + quantity: 1, + price: 199.99, + }, + ], + totalAmount: 199.99, + }; + + await client.startWorkflow("processOrder", { + workflowId: order.orderId, + args: order, + }); + + const handleResult = client.getHandle("processOrder", order.orderId); + expect(handleResult).toBeOk(); + if (!handleResult.isOk()) throw new Error("Expected Ok result"); + const handle = handleResult.value; + + // WHEN — `cancelRequested` is declared with `defineSignal()` (no input + // schema), so it is sent without arguments + const signalResult = await handle.signals.cancelRequested(); + expect(signalResult).toBeOk(); + + // THEN + const result = await handle.result(); + expect(result).toBeOk(); + if (result.isOk()) { + expect(result.value).toEqual({ + orderId: order.orderId, + status: "cancelled", + failureReason: "Cancellation requested by the customer", + errorCode: "CANCELLED", + }); + } + }); + + it("should surface a declined payment as the typed PaymentDeclined contract error", async ({ + client, + }) => { + // GIVEN - Mock payment to decline vi.spyOn(paymentAdapter, "processPayment").mockResolvedValue({ - status: "failed", + status: "declined", + reason: "insufficient_funds", }); const order: Order = { @@ -296,15 +394,16 @@ describe("Order Processing Workflow - Integration Tests", () => { args: order, }); - // THEN - Should return failed status - expect(result).toBeOk(); - if (result.isOk()) { - expect(result.value).toEqual({ - status: "failed", - errorCode: "PAYMENT_FAILED", - failureReason: "Payment was declined", - orderId: order.orderId, - }); + // THEN — the decline travels typed end-to-end: the activity produced + // `Err(errors.PaymentDeclined(...))`, the workflow rethrew it via + // `context.errors.PaymentDeclined`, and the client rehydrated it into a + // `ContractError` with the schema-validated data payload. + expect(result).toBeErr(); + if (result.isErr()) { + expect(result.error).toBeInstanceOf(ContractError); + const contractError = result.error as ContractError; + expect(contractError.errorName).toBe("PaymentDeclined"); + expect(contractError.data).toEqual({ reason: "insufficient_funds" }); } }); }); diff --git a/knip.json b/knip.json index 927cb9c1..67de14d9 100644 --- a/knip.json +++ b/knip.json @@ -16,7 +16,7 @@ "project": ["src/**/*.ts"] }, "packages/client": { - "entry": ["src/__tests__/test.workflows.ts"] + "entry": ["src/__tests__/test.workflows.ts", "src/__tests__/second.workflows.ts"] }, "packages/worker": { "entry": ["src/__tests__/test.workflows.ts", "src/__tests__/inprocess.workflows.ts"] diff --git a/package.json b/package.json index 2d8a13d1..376fe09b 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "scripts": { "build": "turbo run build", "changeset": "changeset", + "check:packages": "turbo run check:package", "dev": "turbo run dev", "format": "oxfmt .", "knip": "knip", @@ -31,5 +32,8 @@ "oxlint": "catalog:", "turbo": "catalog:" }, + "engines": { + "node": ">=22.19.0" + }, "packageManager": "pnpm@11.7.0" } diff --git a/packages/client/README.md b/packages/client/README.md index a18cf2b2..326cae31 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -16,15 +16,21 @@ pnpm add @temporal-contract/client @temporal-contract/contract @temporalio/clien import { TypedClient } from "@temporal-contract/client"; import { Connection, Client } from "@temporalio/client"; +import { myContract } from "./contract.js"; + const connection = await Connection.connect({ address: "localhost:7233" }); const temporalClient = new Client({ connection }); -const client = await TypedClient.create({ - contract: myContract, - client: temporalClient, -}).getOrThrow(); + +// One connection-scoped root per process. `create` returns +// `AsyncResult` — setup faults are defects, so `.get()` +// unwraps directly. +const client = await TypedClient.create({ client: temporalClient }).get(); + +// Bind a contract — synchronous, infallible, memoized. +const orders = client.for(myContract); // Execute workflow (fully typed!) -const result = await client.executeWorkflow("processOrder", { +const result = await orders.executeWorkflow("processOrder", { workflowId: "order-123", args: { orderId: "ORD-123" }, }); diff --git a/packages/client/package.json b/packages/client/package.json index c4779cb2..90ca68f4 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -45,6 +45,7 @@ "scripts": { "build": "tsdown src/index.ts --format cjs,esm --dts --clean", "build:docs": "typedoc", + "check:package": "publint --strict && attw --pack .", "dev": "tsdown src/index.ts --format cjs,esm --dts --watch", "test": "vitest run --project unit", "test:integration": "vitest run --project integration", @@ -56,6 +57,7 @@ "@temporal-contract/contract": "workspace:*" }, "devDependencies": { + "@arethetypeswrong/cli": "catalog:", "@btravstack/tsconfig": "catalog:", "@btravstack/typedoc": "catalog:", "@temporal-contract/testing": "workspace:*", @@ -66,6 +68,7 @@ "@types/node": "catalog:", "@unthrown/vitest": "catalog:", "@vitest/coverage-v8": "catalog:", + "publint": "catalog:", "tsdown": "catalog:", "typedoc": "catalog:", "typedoc-plugin-markdown": "catalog:", diff --git a/packages/client/src/__tests__/client.spec.ts b/packages/client/src/__tests__/client.spec.ts index 2964ac99..cdf34f05 100644 --- a/packages/client/src/__tests__/client.spec.ts +++ b/packages/client/src/__tests__/client.spec.ts @@ -7,18 +7,23 @@ import { Worker } from "@temporalio/worker"; import { P } from "unthrown"; import { describe, expect, vi, beforeEach } from "vitest"; -import { TypedClient } from "../client.js"; +import { type ContractClient, TypedClient } from "../client.js"; import { WorkflowValidationError } from "../errors.js"; +import { secondContract } from "./second.contract.js"; import { testContract } from "./test.contract.js"; // ============================================================================ // Test Setup // ============================================================================ -const it = baseIt.extend<{ +type WorkerFixtures = { worker: Worker; - client: TypedClient; -}>({ + secondWorker: Worker; + root: TypedClient; + client: ContractClient; +}; + +const it = baseIt.extend({ worker: [ async ({ workerConnection }, use) => { // Create and start worker @@ -57,19 +62,44 @@ const it = baseIt.extend<{ }, { auto: true }, ], - client: async ({ clientConnection }, use) => { - // Create typed client + // Second worker on the second contract's task queue — proves one + // connection-scoped root drives multiple contracts (see the + // "multiple contracts, one root" suite). + secondWorker: [ + async ({ workerConnection }, use) => { + const worker = await Worker.create({ + connection: workerConnection, + namespace: "default", + taskQueue: secondContract.taskQueue, + workflowsPath: workflowPath("second.workflows"), + }); + + worker.run().catch((err) => { + console.error("Second worker failed:", err); + }); + + await vi.waitFor(() => worker.getState() === "RUNNING", { interval: 100 }); + + await use(worker); + + await worker.shutdown(); + + await vi.waitFor(() => worker.getState() === "STOPPED", { interval: 100 }); + }, + { auto: true }, + ], + root: async ({ clientConnection }, use) => { + // One connection-scoped root per process; contracts bind via `for()`. const rawClient = new Client({ connection: clientConnection, namespace: "default", }); - const clientResult = await TypedClient.create({ contract: testContract, client: rawClient }); - if (!clientResult.isOk()) { - throw clientResult.isErr() ? clientResult.error : clientResult.cause; - } - const client = clientResult.value; + const root = (await TypedClient.create({ client: rawClient })).get(); - await use(client); + await use(root); + }, + client: async ({ root }, use) => { + await use(root.for(testContract)); }, }); @@ -142,8 +172,9 @@ describe("Client Package - Integration Tests", () => { args: input, }); - // WHEN - const handleResult = await client.getHandle("simpleWorkflow", workflowId); + // WHEN — getHandle is synchronous: the only failure mode is a + // workflow name missing from the contract. + const handleResult = client.getHandle("simpleWorkflow", workflowId); // THEN expect(handleResult).toBeOk(); @@ -158,6 +189,83 @@ describe("Client Package - Integration Tests", () => { }); }); + describe("Multiple contracts, one root", () => { + it("routes each contract's workflows to its own task queue through one root", async ({ + root, + client, + }) => { + // GIVEN — the shared root, testContract already bound as `client`. + const second = root.for(secondContract); + + // WHEN — drive both contracts through the same connection. + const first = await client.executeWorkflow("simpleWorkflow", { + workflowId: `multi-first-${Date.now()}`, + args: { value: "from-first" }, + }); + const echoed = await second.executeWorkflow("echoWorkflow", { + workflowId: `multi-second-${Date.now()}`, + args: { text: "from-second" }, + }); + + // THEN — each contract's worker (polling its own queue) answered. + expect(first).toBeOk(); + if (first.isOk()) { + expect(first.value).toEqual({ result: "Processed: from-first" }); + } + expect(echoed).toBeOk(); + if (echoed.isOk()) { + expect(echoed.value).toEqual({ echoed: "second-queue: from-second" }); + } + + // Binding is memoized — the same instance serves repeated `for()`. + expect(root.for(secondContract)).toBe(second); + }); + }); + + describe("Handle identifiers", () => { + it("startWorkflow handles carry firstExecutionRunId and runId", async ({ client }) => { + const handleResult = await client.startWorkflow("simpleWorkflow", { + workflowId: `run-ids-${Date.now()}`, + args: { value: "ids" }, + }); + + expect(handleResult).toBeOk(); + if (!handleResult.isOk()) throw new Error("Expected Ok result"); + const handle = handleResult.value; + expect(typeof handle.firstExecutionRunId).toBe("string"); + expect(handle.runId).toBe(handle.firstExecutionRunId); + + await handle.result(); + }); + }); + + describe("Wire format (D1) — transforms apply exactly once per boundary", () => { + it("sends the original input, receiver parses once; output parsed once by the client", async ({ + client, + }) => { + // GIVEN — input schema appends "!", output schema doubles. The client + // validates-and-discards on send; the workflow-side parse is the only + // input transform, and the client-side result parse is the only output + // transform. Double application would yield "hello!!" / 84. + const input = { text: "hello" }; + + // WHEN + const result = await client.executeWorkflow("transformWorkflow", { + workflowId: `transform-${Date.now()}`, + args: input, + }); + + // THEN + expect(result).toBeOk(); + if (result.isOk()) { + expect(result.value).toEqual({ + handlerSaw: "hello!", // exactly one "!" — parsed once, on receive + doubled: 42, // 21 doubled exactly once, by the client's parse + }); + } + }); + }); + describe("Workflow with Activities", () => { it("should execute workflow with activity", async ({ client }) => { // GIVEN @@ -290,6 +398,42 @@ describe("Client Package - Integration Tests", () => { expect(result.value).toEqual({ finalValue: 15 }); } }); + + it("should start an update via startUpdate and await its result on the update handle", async ({ + client, + }) => { + // GIVEN + const workflowId = `start-update-test-${Date.now()}`; + const handleResult = await client.startWorkflow("interactiveWorkflow", { + workflowId, + args: { initialValue: 4 }, + }); + + expect(handleResult).toBeOk(); + if (!handleResult.isOk()) throw new Error("Expected Ok result"); + const handle = handleResult.value; + + // WHEN — start the update without waiting for completion, then await + // its result through the typed update handle. + const updateHandleResult = await handle.startUpdate("multiply", { + args: { factor: 5 }, + }); + + // THEN + expect(updateHandleResult).toBeOk(); + if (!updateHandleResult.isOk()) throw new Error("Expected Ok result"); + const updateHandle = updateHandleResult.value; + expect(updateHandle.workflowId).toBe(workflowId); + expect(typeof updateHandle.updateId).toBe("string"); + + const updateResult = await updateHandle.result(); + expect(updateResult).toBeOk(); + if (updateResult.isOk()) { + expect(updateResult.value).toEqual({ newValue: 20 }); // 4 * 5 + } + + await handle.result(); + }); }); describe("Workflow Handle Operations", () => { @@ -404,7 +548,7 @@ describe("Client Package - Integration Tests", () => { }, errCases: (matcher) => matcher.with( - P.tag("@temporal-contract/WorkflowNotFoundError"), + P.tag("@temporal-contract/WorkflowNotInContractError"), P.tag("@temporal-contract/WorkflowValidationError"), P.tag("@temporal-contract/WorkflowAlreadyStartedError"), P.tag("@temporal-contract/WorkflowFailedError"), diff --git a/packages/client/src/__tests__/second.contract.ts b/packages/client/src/__tests__/second.contract.ts new file mode 100644 index 00000000..cf5e2486 --- /dev/null +++ b/packages/client/src/__tests__/second.contract.ts @@ -0,0 +1,22 @@ +import { defineContract, defineWorkflow } from "@temporal-contract/contract"; +import { z } from "zod"; + +/** + * Second test contract on its own task queue — exercised by the + * "multiple contracts, one root" integration case: one `TypedClient` + * root drives both this contract and `testContract`, each routed to + * its own queue via `root.for(contract)`. + */ +export const secondContract = defineContract({ + taskQueue: "second-client-queue", + workflows: { + echoWorkflow: defineWorkflow({ + input: z.object({ + text: z.string(), + }), + output: z.object({ + echoed: z.string(), + }), + }), + }, +}); diff --git a/packages/client/src/__tests__/second.workflows.ts b/packages/client/src/__tests__/second.workflows.ts new file mode 100644 index 00000000..3fc72881 --- /dev/null +++ b/packages/client/src/__tests__/second.workflows.ts @@ -0,0 +1,9 @@ +/** + * Workflow implementations for `second.contract.ts` — registered on the + * second worker (own task queue) in the integration suite. + */ +export async function echoWorkflow(args: { text: string }): Promise<{ echoed: string }> { + return { + echoed: `second-queue: ${args.text}`, + }; +} diff --git a/packages/client/src/__tests__/test.contract.ts b/packages/client/src/__tests__/test.contract.ts index 59792ad2..4d8b6396 100644 --- a/packages/client/src/__tests__/test.contract.ts +++ b/packages/client/src/__tests__/test.contract.ts @@ -60,6 +60,22 @@ export const testContract = defineContract({ }, }), + // Workflow with transforming input/output schemas — exercises the D1 + // wire format: the sender validates but transmits the ORIGINAL value; + // the receiver parses, so each transform applies exactly once. + transformWorkflow: defineWorkflow({ + input: z.object({ + text: z.string().transform((s) => `${s}!`), + }), + output: z.object({ + // What the (receive-side-parsed) input looked like to the handler. + handlerSaw: z.string(), + // The handler returns the pre-transform number; only the client's + // receive-side parse doubles it. + doubled: z.number().transform((n) => n * 2), + }), + }), + // Workflow with activities workflowWithActivity: defineWorkflow({ input: z.object({ diff --git a/packages/client/src/__tests__/test.workflows.ts b/packages/client/src/__tests__/test.workflows.ts index 1a9416c7..b6661899 100644 --- a/packages/client/src/__tests__/test.workflows.ts +++ b/packages/client/src/__tests__/test.workflows.ts @@ -7,6 +7,8 @@ import { defineUpdate, } from "@temporalio/workflow"; +import { testContract } from "./test.contract.js"; + // Define activity types manually based on the contract type Activities = { logMessage(args: { message: string }): Promise<{}>; @@ -56,6 +58,22 @@ export async function interactiveWorkflow(args: { initialValue: number }) { }; } +// Mirrors what `@temporal-contract/worker`'s `declareWorkflow` does at the +// D1 wire boundary: the client transmits the caller's ORIGINAL args, the +// receiving side parses them exactly once, and the return value is handed to +// Temporal untransformed (the client parses the output on receive). +export async function transformWorkflow(args: { text: string }) { + const parsed = await testContract.workflows.transformWorkflow.input["~standard"].validate(args); + if (parsed.issues) { + throw new Error(`transformWorkflow input validation failed`); + } + const input = parsed.value as { text: string }; + return { + handlerSaw: input.text, + doubled: 21, + }; +} + export async function workflowWithActivity(args: { message: string }) { const processed = await activities.processMessage({ message: args.message }); await activities.logMessage({ message: `Activity result: ${processed.processed}` }); diff --git a/packages/client/src/client.spec.ts b/packages/client/src/client.spec.ts index d98c9d1b..bc09cc32 100644 --- a/packages/client/src/client.spec.ts +++ b/packages/client/src/client.spec.ts @@ -1,4 +1,11 @@ -import { defineContract, defineSearchAttribute, defineWorkflow } from "@temporal-contract/contract"; +import { + defineContract, + defineQuery, + defineSearchAttribute, + defineSignal, + defineUpdate, + defineWorkflow, +} from "@temporal-contract/contract"; import { ContractError, TechnicalError } from "@temporal-contract/contract/errors"; import { type Client, @@ -15,7 +22,7 @@ import { P } from "unthrown"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { z } from "zod"; -import { readTypedSearchAttributes, TypedClient } from "./client.js"; +import { ContractClient, readTypedSearchAttributes, TypedClient } from "./client.js"; import { QueryValidationError, RuntimeClientError, @@ -24,11 +31,30 @@ import { WorkflowAlreadyStartedError, WorkflowExecutionNotFoundError, WorkflowFailedError, - WorkflowNotFoundError, + WorkflowNotInContractError, WorkflowValidationError, } from "./errors.js"; import type { ClientInterceptor } from "./interceptors.js"; +/** + * Test construction helper: build the connection-scoped root and bind the + * contract in one step. `create`'s Err channel is `never`, so `.get()` + * unwraps directly (a setup defect rethrows its cause). + */ +async function bindContract[0]>( + contract: TContract, + rawClient: Client, + interceptors?: ClientInterceptor[], +): Promise> { + const root = ( + await TypedClient.create({ + client: rawClient, + ...(interceptors ? { interceptors } : {}), + }) + ).get(); + return root.for(contract); +} + // Create mock workflow object const createMockWorkflow = () => ({ start: vi.fn(), @@ -80,10 +106,28 @@ vi.mock("@temporalio/client", () => { super(message); } } + class ScheduleAlreadyRunning extends Error { + constructor( + message: string, + public readonly scheduleId: string, + ) { + super(message); + } + } + class ScheduleNotFoundError extends Error { + constructor( + message: string, + public readonly scheduleId: string, + ) { + super(message); + } + } return { WorkflowHandle: vi.fn(), WorkflowExecutionAlreadyStartedError, WorkflowFailedError, + ScheduleAlreadyRunning, + ScheduleNotFoundError, }; }); @@ -136,27 +180,33 @@ describe("TypedClient", () => { }, }); - let typedClient: TypedClient; + let typedClient: ContractClient; - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); const rawClient = { workflow: mockWorkflow, schedule: mockSchedule } as unknown as Client; - typedClient = TypedClient.createOrThrow(testContract, rawClient); + typedClient = await bindContract(testContract, rawClient); }); describe("TypedClient.create", () => { it("returns Ok(TypedClient) for a capable client", async () => { const rawClient = { workflow: mockWorkflow, schedule: mockSchedule } as unknown as Client; - const created = await TypedClient.create({ contract: testContract, client: rawClient }); + const created = await TypedClient.create({ client: rawClient }); expect(created).toBeOk(); if (created.isOk()) { expect(created.value).toBeInstanceOf(TypedClient); } }); + it("exposes the underlying Client as the `raw` escape hatch", async () => { + const rawClient = { workflow: mockWorkflow, schedule: mockSchedule } as unknown as Client; + const created = (await TypedClient.create({ client: rawClient })).get(); + expect(created.raw).toBe(rawClient); + }); + it("surfaces a missing Schedule API as a Defect(TechnicalError) instead of throwing", async () => { const oldClient = { workflow: mockWorkflow } as unknown as Client; - const created = await TypedClient.create({ contract: testContract, client: oldClient }); + const created = await TypedClient.create({ client: oldClient }); expect(created).toBeDefect(); if (created.isDefect()) { const cause = created.cause; @@ -173,7 +223,7 @@ describe("TypedClient", () => { schedule: mockSchedule, connection: { ensureConnected: vi.fn().mockRejectedValue(failing) }, } as unknown as Client; - const created = await TypedClient.create({ contract: testContract, client: rawClient }); + const created = await TypedClient.create({ client: rawClient }); expect(created).toBeDefect(); if (created.isDefect()) { const cause = created.cause; @@ -182,6 +232,104 @@ describe("TypedClient", () => { expect((cause as TechnicalError).cause).toBe(failing); } }); + + it("runs ensureConnected once per root, not once per contract binding", async () => { + const ensureConnected = vi.fn().mockResolvedValue(undefined); + const rawClient = { + workflow: mockWorkflow, + schedule: mockSchedule, + connection: { ensureConnected }, + } as unknown as Client; + const root = (await TypedClient.create({ client: rawClient })).get(); + root.for(testContract); + root.for(testContract); + expect(ensureConnected).toHaveBeenCalledTimes(1); + }); + }); + + describe("TypedClient.for", () => { + const otherContract = defineContract({ + taskQueue: "other-queue", + workflows: { + otherWorkflow: defineWorkflow({ + input: z.object({ id: z.string() }), + output: z.object({ ok: z.boolean() }), + }), + }, + }); + + it("is memoized: for(c) twice returns the same instance", async () => { + const rawClient = { workflow: mockWorkflow, schedule: mockSchedule } as unknown as Client; + const root = (await TypedClient.create({ client: rawClient })).get(); + expect(root.for(testContract)).toBe(root.for(testContract)); + }); + + it("yields distinct instances for distinct contracts", async () => { + const rawClient = { workflow: mockWorkflow, schedule: mockSchedule } as unknown as Client; + const root = (await TypedClient.create({ client: rawClient })).get(); + const first = root.for(testContract); + const second = root.for(otherContract); + expect(first).not.toBe(second as unknown); + expect(first).toBeInstanceOf(ContractClient); + expect(second).toBeInstanceOf(ContractClient); + }); + + it("each binding uses its own contract's taskQueue and schemas", async () => { + const rawClient = { workflow: mockWorkflow, schedule: mockSchedule } as unknown as Client; + const root = (await TypedClient.create({ client: rawClient })).get(); + mockWorkflow.start.mockResolvedValue({ workflowId: "x" }); + + await root.for(testContract).startWorkflow("testWorkflow", { + workflowId: "a", + args: { name: "n", value: 1 }, + }); + await root.for(otherContract).startWorkflow("otherWorkflow", { + workflowId: "b", + args: { id: "i" }, + }); + + expect(mockWorkflow.start).toHaveBeenNthCalledWith( + 1, + "testWorkflow", + expect.objectContaining({ taskQueue: "test-queue" }), + ); + expect(mockWorkflow.start).toHaveBeenNthCalledWith( + 2, + "otherWorkflow", + expect.objectContaining({ taskQueue: "other-queue" }), + ); + + // Each binding validates against its OWN schemas: testContract's + // input shape is rejected by otherContract's workflow. + const invalid = await root.for(otherContract).startWorkflow("otherWorkflow", { + workflowId: "c", + args: { name: "n", value: 1 } as unknown as { id: string }, + }); + expect(invalid).toBeErr(); + if (invalid.isErr()) { + expect(invalid.error).toBeInstanceOf(WorkflowValidationError); + } + }); + + it("bindings inherit the root's interceptors", async () => { + const seen: string[] = []; + const observing: ClientInterceptor = (args, next) => { + seen.push(`${args.operation}:${args.workflowName}`); + return next(); + }; + const rawClient = { workflow: mockWorkflow, schedule: mockSchedule } as unknown as Client; + const root = ( + await TypedClient.create({ client: rawClient, interceptors: [observing] }) + ).get(); + mockWorkflow.execute.mockResolvedValue({ result: "ok" }); + + await root.for(testContract).executeWorkflow("testWorkflow", { + workflowId: "wf-1", + args: { name: "n", value: 1 }, + }); + + expect(seen).toEqual(["executeWorkflow:testWorkflow"]); + }); }); describe("startWorkflow", () => { @@ -244,7 +392,7 @@ describe("TypedClient", () => { expect(result).toBeErr(); if (result.isErr()) { - expect(result.error).toBeInstanceOf(WorkflowNotFoundError); + expect(result.error).toBeInstanceOf(WorkflowNotInContractError); } }); }); @@ -351,7 +499,7 @@ describe("TypedClient", () => { }); }); - it("returns WorkflowNotFoundError when the workflow isn't declared", async () => { + it("returns WorkflowNotInContractError when the workflow isn't declared", async () => { const result = await typedClient.signalWithStart( // @ts-expect-error testing runtime validation "nonExistent", @@ -365,7 +513,7 @@ describe("TypedClient", () => { expect(result).toBeErr(); if (result.isErr()) { - expect(result.error).toBeInstanceOf(WorkflowNotFoundError); + expect(result.error).toBeInstanceOf(WorkflowNotInContractError); } expect(mockWorkflow.signalWithStart).not.toHaveBeenCalled(); }); @@ -436,7 +584,7 @@ describe("TypedClient", () => { mockWorkflow.getHandle.mockReturnValue(mockHandle); - const result = await typedClient.getHandle("testWorkflow", "test-123"); + const result = typedClient.getHandle("testWorkflow", "test-123"); expect(result).toBeOk(); if (result.isOk()) { @@ -445,14 +593,14 @@ describe("TypedClient", () => { }); it("should return Error result for non-existent workflow", async () => { - const result = await typedClient.getHandle( + const result = typedClient.getHandle( "nonExistentWorkflow" as unknown as "testWorkflow", "test-123", ); expect(result).toBeErr(); if (result.isErr()) { - expect(result.error).toBeInstanceOf(WorkflowNotFoundError); + expect(result.error).toBeInstanceOf(WorkflowNotInContractError); } }); }); @@ -794,7 +942,7 @@ describe("TypedClient", () => { }, errCases: (matcher) => matcher.with( - P.tag("@temporal-contract/WorkflowNotFoundError"), + P.tag("@temporal-contract/WorkflowNotInContractError"), P.tag("@temporal-contract/WorkflowValidationError"), P.tag("@temporal-contract/WorkflowAlreadyStartedError"), P.tag("@temporal-contract/WorkflowFailedError"), @@ -856,12 +1004,12 @@ describe("TypedClient", () => { }, }); - let searchClient: TypedClient; + let searchClient: ContractClient; - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); const rawClient = { workflow: mockWorkflow, schedule: mockSchedule } as unknown as Client; - searchClient = TypedClient.createOrThrow(searchContract, rawClient); + searchClient = await bindContract(searchContract, rawClient); }); it("translates declared searchAttributes into Temporal's typedSearchAttributes", async () => { @@ -1187,7 +1335,7 @@ describe("TypedClient", () => { }; mockWorkflow.getHandle.mockReturnValue(handle); - const handleResult = await typedClient.getHandle("testWorkflow", "test-123"); + const handleResult = typedClient.getHandle("testWorkflow", "test-123"); if (!handleResult.isOk()) throw new Error("getHandle should succeed"); const result = await handleResult.value.result(); @@ -1224,7 +1372,7 @@ describe("TypedClient", () => { }; mockWorkflow.getHandle.mockReturnValue(handle); - const handleResult = await typedClient.getHandle("testWorkflow", "test-123"); + const handleResult = typedClient.getHandle("testWorkflow", "test-123"); if (!handleResult.isOk()) throw new Error("getHandle should succeed"); const result = await handleResult.value.cancel(); @@ -1254,7 +1402,7 @@ describe("TypedClient", () => { }; mockWorkflow.getHandle.mockReturnValue(handle); - const handleResult = await typedClient.getHandle("testWorkflow", "test-123"); + const handleResult = typedClient.getHandle("testWorkflow", "test-123"); if (!handleResult.isOk()) throw new Error("getHandle should succeed"); const result = await handleResult.value.terminate("done"); @@ -1280,7 +1428,7 @@ describe("TypedClient", () => { }; mockWorkflow.getHandle.mockReturnValue(handle); - const handleResult = await typedClient.getHandle("testWorkflow", "test-123"); + const handleResult = typedClient.getHandle("testWorkflow", "test-123"); if (!handleResult.isOk()) throw new Error("getHandle should succeed"); const result = await handleResult.value.signals.updateProgress([50]); @@ -1306,7 +1454,7 @@ describe("TypedClient", () => { }; mockWorkflow.getHandle.mockReturnValue(handle); - const handleResult = await typedClient.getHandle("testWorkflow", "test-123"); + const handleResult = typedClient.getHandle("testWorkflow", "test-123"); if (!handleResult.isOk()) throw new Error("getHandle should succeed"); const result = await handleResult.value.describe(); @@ -1320,6 +1468,201 @@ describe("TypedClient", () => { }); }); +describe("TypedClient — wire format (validate on send, parse on receive)", () => { + // D1: each payload boundary parses exactly once, on the receiving side. + // The client validates what it sends (failing early with the typed + // validation error) but transmits the caller's ORIGINAL value; parsed + // results are only used on the receive side (workflow/query/update + // results). Transforming schemas make the two sides observable. + const transformContract = defineContract({ + taskQueue: "wire-q", + workflows: { + transformer: defineWorkflow({ + // Asymmetric transform: input type is `string`, parsed type is `number`. + input: z.string().transform((s) => s.length), + output: z.number().transform((n) => n * 2), + signals: { + ping: { input: z.string().transform((s) => s.length) }, + }, + queries: { + peek: { + input: z.string().transform((s) => s.length), + output: z.number().transform((n) => n * 2), + }, + }, + updates: { + poke: { + input: z.string().transform((s) => s.length), + output: z.number().transform((n) => n * 2), + }, + }, + }), + }, + }); + + let wireClient: ContractClient; + + beforeEach(async () => { + vi.clearAllMocks(); + const rawClient = { workflow: mockWorkflow, schedule: mockSchedule } as unknown as Client; + wireClient = await bindContract(transformContract, rawClient); + }); + + it("startWorkflow transmits the ORIGINAL args, not the parsed value", async () => { + mockWorkflow.start.mockResolvedValue({ workflowId: "wf-1" }); + + const result = await wireClient.startWorkflow("transformer", { + workflowId: "wf-1", + args: "hello", + }); + + expect(result).toBeOk(); + expect(mockWorkflow.start).toHaveBeenCalledWith("transformer", { + workflowId: "wf-1", + taskQueue: "wire-q", + args: ["hello"], // original string — not 5 (the parsed length) + }); + }); + + it("startWorkflow still rejects invalid input before dispatch", async () => { + const result = await wireClient.startWorkflow("transformer", { + workflowId: "wf-1", + // @ts-expect-error testing runtime validation + args: 42, + }); + + expect(result).toBeErr(); + if (result.isErr()) { + expect(result.error).toBeInstanceOf(WorkflowValidationError); + } + expect(mockWorkflow.start).not.toHaveBeenCalled(); + }); + + it("executeWorkflow transmits the ORIGINAL args and parses the result exactly once", async () => { + // The wire carries the producer's original (pre-transform) value; the + // client applies the output transform on receive. + mockWorkflow.execute.mockResolvedValue(21); + + const result = await wireClient.executeWorkflow("transformer", { + workflowId: "wf-2", + args: "hello", + }); + + expect(mockWorkflow.execute).toHaveBeenCalledWith("transformer", { + workflowId: "wf-2", + taskQueue: "wire-q", + args: ["hello"], + }); + expect(result).toBeOk(); + if (result.isOk()) { + expect(result.value).toBe(42); // 21 doubled once — not 84 + } + }); + + it("signalWithStart transmits the ORIGINAL workflow and signal args", async () => { + mockWorkflow.signalWithStart.mockResolvedValue({ + workflowId: "wf-3", + signaledRunId: "run-1", + }); + + const result = await wireClient.signalWithStart("transformer", { + workflowId: "wf-3", + args: "hello", + signalName: "ping", + signalArgs: "hey", + }); + + expect(result).toBeOk(); + expect(mockWorkflow.signalWithStart).toHaveBeenCalledWith("transformer", { + workflowId: "wf-3", + taskQueue: "wire-q", + args: ["hello"], + signal: "ping", + signalArgs: ["hey"], // original string — not 3 + }); + }); + + it("handle.result() parses what the handle returns (receive side)", async () => { + const rawHandle = { + workflowId: "wf-4", + result: vi.fn().mockResolvedValue(21), + query: vi.fn(), + signal: vi.fn(), + executeUpdate: vi.fn(), + }; + mockWorkflow.getHandle.mockReturnValue(rawHandle); + + const handleResult = wireClient.getHandle("transformer", "wf-4"); + if (!handleResult.isOk()) throw new Error("expected Ok"); + const result = await handleResult.value.result(); + + expect(result).toBeOk(); + if (result.isOk()) { + expect(result.value).toBe(42); + } + }); + + it("handle.signals.* transmits the ORIGINAL signal args", async () => { + const rawHandle = { + workflowId: "wf-5", + result: vi.fn(), + query: vi.fn(), + signal: vi.fn().mockResolvedValue(undefined), + executeUpdate: vi.fn(), + }; + mockWorkflow.getHandle.mockReturnValue(rawHandle); + + const handleResult = wireClient.getHandle("transformer", "wf-5"); + if (!handleResult.isOk()) throw new Error("expected Ok"); + const result = await handleResult.value.signals.ping("hey"); + + expect(result).toBeOk(); + expect(rawHandle.signal).toHaveBeenCalledWith("ping", "hey"); + }); + + it("handle.queries.* transmits the ORIGINAL input and parses the result once", async () => { + const rawHandle = { + workflowId: "wf-6", + result: vi.fn(), + query: vi.fn().mockResolvedValue(21), + signal: vi.fn(), + executeUpdate: vi.fn(), + }; + mockWorkflow.getHandle.mockReturnValue(rawHandle); + + const handleResult = wireClient.getHandle("transformer", "wf-6"); + if (!handleResult.isOk()) throw new Error("expected Ok"); + const result = await handleResult.value.queries.peek("hey"); + + expect(rawHandle.query).toHaveBeenCalledWith("peek", "hey"); + expect(result).toBeOk(); + if (result.isOk()) { + expect(result.value).toBe(42); + } + }); + + it("handle.updates.* transmits the ORIGINAL input and parses the result once", async () => { + const rawHandle = { + workflowId: "wf-7", + result: vi.fn(), + query: vi.fn(), + signal: vi.fn(), + executeUpdate: vi.fn().mockResolvedValue(21), + }; + mockWorkflow.getHandle.mockReturnValue(rawHandle); + + const handleResult = wireClient.getHandle("transformer", "wf-7"); + if (!handleResult.isOk()) throw new Error("expected Ok"); + const result = await handleResult.value.updates.poke("hey"); + + expect(rawHandle.executeUpdate).toHaveBeenCalledWith("poke", { args: ["hey"] }); + expect(result).toBeOk(); + if (result.isOk()) { + expect(result.value).toBe(42); + } + }); +}); + describe("TypedClient — workflow contract errors", () => { const erroredContract = defineContract({ taskQueue: "test-queue", @@ -1338,7 +1681,7 @@ describe("TypedClient — workflow contract errors", () => { }); const createClient = () => - TypedClient.createOrThrow(erroredContract, { + bindContract(erroredContract, { workflow: mockWorkflow, schedule: mockSchedule, } as unknown as Client); @@ -1358,7 +1701,9 @@ describe("TypedClient — workflow contract errors", () => { new TemporalWorkflowFailedError("failed", failure, "NON_RETRYABLE_FAILURE"), ); - const result = await createClient().executeWorkflow("processOrder", { + const result = await ( + await createClient() + ).executeWorkflow("processOrder", { workflowId: "order-1", args: { orderId: "ORD-1" }, }); @@ -1379,7 +1724,9 @@ describe("TypedClient — workflow contract errors", () => { new TemporalWorkflowFailedError("failed", failure, "NON_RETRYABLE_FAILURE"), ); - const result = await createClient().executeWorkflow("processOrder", { + const result = await ( + await createClient() + ).executeWorkflow("processOrder", { workflowId: "order-1", args: { orderId: "ORD-1" }, }); @@ -1399,7 +1746,9 @@ describe("TypedClient — workflow contract errors", () => { new TemporalWorkflowFailedError("failed", failure, "NON_RETRYABLE_FAILURE"), ); - const result = await createClient().executeWorkflow("processOrder", { + const result = await ( + await createClient() + ).executeWorkflow("processOrder", { workflowId: "order-1", args: { orderId: "ORD-1" }, }); @@ -1429,7 +1778,7 @@ describe("TypedClient — workflow contract errors", () => { }; mockWorkflow.getHandle.mockReturnValue(rawHandle); - const handleResult = await createClient().getHandle("processOrder", "order-2"); + const handleResult = (await createClient()).getHandle("processOrder", "order-2"); expect(handleResult).toBeOk(); if (!handleResult.isOk()) return; @@ -1463,7 +1812,7 @@ describe("TypedClient — interceptors", () => { }); const clientWith = (interceptors: ClientInterceptor[]) => - TypedClient.createOrThrow( + bindContract( interceptedContract, { workflow: mockWorkflow, schedule: mockSchedule } as unknown as Client, interceptors, @@ -1479,7 +1828,9 @@ describe("TypedClient — interceptors", () => { return next(); }; - const result = await clientWith([mk("outer"), mk("inner")]).executeWorkflow("testWorkflow", { + const result = await ( + await clientWith([mk("outer"), mk("inner")]) + ).executeWorkflow("testWorkflow", { workflowId: "wf-1", args: { name: "n", value: 1 }, }); @@ -1496,7 +1847,9 @@ describe("TypedClient — interceptors", () => { const patching: ClientInterceptor = (_args, next) => next({ input: { name: "patched", value: 42 } }); - const result = await clientWith([patching]).executeWorkflow("testWorkflow", { + const result = await ( + await clientWith([patching]) + ).executeWorkflow("testWorkflow", { workflowId: "wf-2", args: { name: "original", value: 1 }, }); @@ -1511,7 +1864,9 @@ describe("TypedClient — interceptors", () => { it("an invalid patched input is rejected by validation (no bypass)", async () => { const patching: ClientInterceptor = (_args, next) => next({ input: { name: 42 } }); - const result = await clientWith([patching]).executeWorkflow("testWorkflow", { + const result = await ( + await clientWith([patching]) + ).executeWorkflow("testWorkflow", { workflowId: "wf-3", args: { name: "original", value: 1 }, }); @@ -1533,7 +1888,9 @@ describe("TypedClient — interceptors", () => { // any genuinely-modeled `Err` still flows through untouched. const retryOnce: ClientInterceptor = (_args, next) => next().recoverDefect(() => next()); - const result = await clientWith([retryOnce]).executeWorkflow("testWorkflow", { + const result = await ( + await clientWith([retryOnce]) + ).executeWorkflow("testWorkflow", { workflowId: "wf-4", args: { name: "n", value: 1 }, }); @@ -1557,7 +1914,7 @@ describe("TypedClient — interceptors", () => { return next(); }; - const handleResult = await clientWith([observing]).getHandle("testWorkflow", "wf-5"); + const handleResult = (await clientWith([observing])).getHandle("testWorkflow", "wf-5"); expect(handleResult).toBeOk(); if (!handleResult.isOk()) return; const query = await handleResult.value.queries.getStatus([]); @@ -1574,3 +1931,468 @@ describe("TypedClient — interceptors", () => { ]); }); }); + +describe("ContractClient — handle identifiers and validation-error identity", () => { + const identityContract = defineContract({ + taskQueue: "identity-q", + workflows: { + identityWorkflow: defineWorkflow({ + input: z.object({ id: z.string() }), + output: z.object({ ok: z.boolean() }), + }), + }, + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const createClient = () => + bindContract(identityContract, { + workflow: mockWorkflow, + schedule: mockSchedule, + } as unknown as Client); + + it("startWorkflow handles carry runId and firstExecutionRunId from the started run", async () => { + mockWorkflow.start.mockResolvedValue({ + workflowId: "wf-1", + firstExecutionRunId: "run-first", + }); + + const handleResult = await ( + await createClient() + ).startWorkflow("identityWorkflow", { + workflowId: "wf-1", + args: { id: "a" }, + }); + + expect(handleResult).toBeOk(); + if (handleResult.isOk()) { + expect(handleResult.value.firstExecutionRunId).toBe("run-first"); + expect(handleResult.value.runId).toBe("run-first"); + } + }); + + it("getHandle is synchronous, forwards runId + GetWorkflowHandleOptions, and carries the ids", async () => { + const rawHandle = { workflowId: "wf-2", result: vi.fn() }; + mockWorkflow.getHandle.mockReturnValue(rawHandle); + + const handleResult = (await createClient()).getHandle("identityWorkflow", "wf-2", { + runId: "run-9", + firstExecutionRunId: "run-0", + followRuns: false, + }); + + expect(handleResult).toBeOk(); + if (handleResult.isOk()) { + expect(handleResult.value.runId).toBe("run-9"); + expect(handleResult.value.firstExecutionRunId).toBe("run-0"); + } + expect(mockWorkflow.getHandle).toHaveBeenCalledWith("wf-2", "run-9", { + firstExecutionRunId: "run-0", + followRuns: false, + }); + }); + + it("getHandle returns a sync Err(WorkflowNotInContractError) for unknown workflow names", async () => { + const handleResult = (await createClient()).getHandle( + "nonExistent" as unknown as "identityWorkflow", + "wf-3", + ); + + expect(handleResult.isErr()).toBe(true); + if (handleResult.isErr()) { + expect(handleResult.error).toBeInstanceOf(WorkflowNotInContractError); + } + expect(mockWorkflow.getHandle).not.toHaveBeenCalled(); + }); + + it("handle.result() output-validation error carries the workflow NAME and the workflowId", async () => { + // Regression (v8 review item 12): the workflowId used to be passed as + // the workflowName constructor argument. + const rawHandle = { + workflowId: "wf-4", + result: vi.fn().mockResolvedValue({ ok: "not-a-boolean" }), + }; + mockWorkflow.getHandle.mockReturnValue(rawHandle); + + const handleResult = (await createClient()).getHandle("identityWorkflow", "wf-4"); + if (!handleResult.isOk()) throw new Error("expected Ok"); + const result = await handleResult.value.result(); + + expect(result).toBeErr(); + if (result.isErr()) { + expect(result.error).toBeInstanceOf(WorkflowValidationError); + const error = result.error as WorkflowValidationError; + expect(error.workflowName).toBe("identityWorkflow"); + expect(error.workflowId).toBe("wf-4"); + expect(error.direction).toBe("output"); + } + }); + + it("executeWorkflow validation errors carry the workflowId too", async () => { + const client = await createClient(); + + const inputError = await client.executeWorkflow("identityWorkflow", { + workflowId: "wf-5", + args: { id: 42 } as unknown as { id: string }, + }); + expect(inputError).toBeErr(); + if (inputError.isErr()) { + expect((inputError.error as WorkflowValidationError).workflowId).toBe("wf-5"); + expect((inputError.error as WorkflowValidationError).direction).toBe("input"); + } + + mockWorkflow.execute.mockResolvedValue({ ok: "nope" }); + const outputError = await client.executeWorkflow("identityWorkflow", { + workflowId: "wf-6", + args: { id: "a" }, + }); + expect(outputError).toBeErr(); + if (outputError.isErr()) { + expect((outputError.error as WorkflowValidationError).workflowId).toBe("wf-6"); + expect((outputError.error as WorkflowValidationError).direction).toBe("output"); + } + }); +}); + +describe("ContractClient — startUpdate", () => { + const updateContract = defineContract({ + taskQueue: "update-q", + workflows: { + updatable: defineWorkflow({ + input: z.object({ id: z.string() }), + output: z.object({ ok: z.boolean() }), + updates: { + adjust: { + input: z.object({ delta: z.number() }), + output: z.number().transform((n) => n * 2), + }, + }, + }), + }, + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const getUpdatableHandle = async (rawHandle: Record) => { + mockWorkflow.getHandle.mockReturnValue(rawHandle); + const client = await bindContract(updateContract, { + workflow: mockWorkflow, + schedule: mockSchedule, + } as unknown as Client); + const handleResult = client.getHandle("updatable", "wf-up"); + if (!handleResult.isOk()) throw new Error("expected Ok"); + return handleResult.value; + }; + + it("starts the update with options passthrough and returns a typed update handle", async () => { + const startUpdate = vi.fn().mockResolvedValue({ + updateId: "upd-1", + workflowId: "wf-up", + workflowRunId: "run-1", + result: vi.fn().mockResolvedValue(21), + }); + const handle = await getUpdatableHandle({ workflowId: "wf-up", startUpdate }); + + const updateHandleResult = await handle.startUpdate("adjust", { + args: { delta: 3 }, + updateId: "upd-1", + }); + + expect(updateHandleResult).toBeOk(); + expect(startUpdate).toHaveBeenCalledWith("adjust", { + args: [{ delta: 3 }], + waitForStage: "ACCEPTED", + updateId: "upd-1", + }); + if (updateHandleResult.isOk()) { + const updateHandle = updateHandleResult.value; + expect(updateHandle.updateId).toBe("upd-1"); + expect(updateHandle.workflowId).toBe("wf-up"); + expect(updateHandle.workflowRunId).toBe("run-1"); + + // result() parses on receive (D1): the transform applies exactly once. + const result = await updateHandle.result(); + expect(result).toBeOk(); + if (result.isOk()) { + expect(result.value).toBe(42); + } + } + }); + + it("rejects invalid update input before dispatch", async () => { + const startUpdate = vi.fn(); + const handle = await getUpdatableHandle({ workflowId: "wf-up", startUpdate }); + + const updateHandleResult = await handle.startUpdate("adjust", { + args: { delta: "nope" } as unknown as { delta: number }, + }); + + expect(updateHandleResult).toBeErr(); + if (updateHandleResult.isErr()) { + expect(updateHandleResult.error).toBeInstanceOf(UpdateValidationError); + expect((updateHandleResult.error as UpdateValidationError).direction).toBe("input"); + } + expect(startUpdate).not.toHaveBeenCalled(); + }); + + it("surfaces WorkflowExecutionNotFoundError when the execution is gone", async () => { + const startUpdate = vi + .fn() + .mockRejectedValue(new TemporalWorkflowNotFoundError("not found", "wf-up", undefined)); + const handle = await getUpdatableHandle({ workflowId: "wf-up", startUpdate }); + + const updateHandleResult = await handle.startUpdate("adjust", { args: { delta: 1 } }); + + expect(updateHandleResult).toBeErr(); + if (updateHandleResult.isErr()) { + expect(updateHandleResult.error).toBeInstanceOf(WorkflowExecutionNotFoundError); + } + }); + + it("routes unrecognized startUpdate failures to the defect channel", async () => { + const startUpdate = vi.fn().mockRejectedValue(new Error("network down")); + const handle = await getUpdatableHandle({ workflowId: "wf-up", startUpdate }); + + const updateHandleResult = await handle.startUpdate("adjust", { args: { delta: 1 } }); + + expect(updateHandleResult).toBeDefect(); + if (updateHandleResult.isDefect()) { + expect(updateHandleResult.cause).toBeInstanceOf(RuntimeClientError); + expect((updateHandleResult.cause as RuntimeClientError).operation).toBe("startUpdate"); + } + }); +}); + +describe("ContractClient — omittable input-less payloads (runtime)", () => { + // Wave 1 made `defineSignal()` / `defineQuery({output})` / + // `defineUpdate({output})` materialize an UndefinedInputSchema. The + // client side: omitted payloads travel as EMPTY args, not `[undefined]`. + const omittableContract = defineContract({ + taskQueue: "omit-q", + workflows: { + omittable: defineWorkflow({ + input: z.object({ id: z.string() }), + output: z.object({ ok: z.boolean() }), + signals: { + stop: defineSignal(), + }, + queries: { + progress: defineQuery({ output: z.number() }), + }, + updates: { + refresh: defineUpdate({ output: z.boolean() }), + }, + }), + }, + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const getOmittableHandle = async (rawHandle: Record) => { + mockWorkflow.getHandle.mockReturnValue(rawHandle); + const client = await bindContract(omittableContract, { + workflow: mockWorkflow, + schedule: mockSchedule, + } as unknown as Client); + const handleResult = client.getHandle("omittable", "wf-omit"); + if (!handleResult.isOk()) throw new Error("expected Ok"); + return handleResult.value; + }; + + it("payload-less signals send NO payload argument", async () => { + const signal = vi.fn().mockResolvedValue(undefined); + const handle = await getOmittableHandle({ workflowId: "wf-omit", signal }); + + const result = await handle.signals.stop(); + + expect(result).toBeOk(); + expect(signal).toHaveBeenCalledTimes(1); + expect(signal).toHaveBeenCalledWith("stop"); + }); + + it("argument-less queries send NO payload argument", async () => { + const query = vi.fn().mockResolvedValue(7); + const handle = await getOmittableHandle({ workflowId: "wf-omit", query }); + + const result = await handle.queries.progress(); + + expect(result).toBeOk(); + if (result.isOk()) { + expect(result.value).toBe(7); + } + expect(query).toHaveBeenCalledWith("progress"); + }); + + it("argument-less updates send EMPTY args", async () => { + const executeUpdate = vi.fn().mockResolvedValue(true); + const handle = await getOmittableHandle({ workflowId: "wf-omit", executeUpdate }); + + const result = await handle.updates.refresh(); + + expect(result).toBeOk(); + expect(executeUpdate).toHaveBeenCalledWith("refresh", { args: [] }); + }); + + it("startUpdate with an omitted options object sends EMPTY args", async () => { + const startUpdate = vi.fn().mockResolvedValue({ + updateId: "upd-omit", + workflowId: "wf-omit", + workflowRunId: undefined, + result: vi.fn().mockResolvedValue(true), + }); + const handle = await getOmittableHandle({ workflowId: "wf-omit", startUpdate }); + + const result = await handle.startUpdate("refresh"); + + expect(result).toBeOk(); + expect(startUpdate).toHaveBeenCalledWith("refresh", { + args: [], + waitForStage: "ACCEPTED", + }); + }); + + it("signalWithStart with an omitted signal payload sends EMPTY signalArgs", async () => { + mockWorkflow.signalWithStart.mockResolvedValue({ + workflowId: "wf-omit", + signaledRunId: "run-om", + }); + const client = await bindContract(omittableContract, { + workflow: mockWorkflow, + schedule: mockSchedule, + } as unknown as Client); + + const result = await client.signalWithStart("omittable", { + workflowId: "wf-omit", + args: { id: "a" }, + signalName: "stop", + }); + + expect(result).toBeOk(); + expect(mockWorkflow.signalWithStart).toHaveBeenCalledWith("omittable", { + workflowId: "wf-omit", + taskQueue: "omit-q", + args: [{ id: "a" }], + signal: "stop", + signalArgs: [], + }); + }); +}); + +describe("ContractClient — search attribute VALUE validation (runtime)", () => { + const kindContract = defineContract({ + taskQueue: "kinds-q", + workflows: { + kinds: defineWorkflow({ + input: z.object({ id: z.string() }), + output: z.object({}), + searchAttributes: { + priority: defineSearchAttribute({ kind: "INT" }), + placedAt: defineSearchAttribute({ kind: "DATETIME" }), + tags: defineSearchAttribute({ kind: "KEYWORD_LIST" }), + }, + }), + }, + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("rejects values whose runtime type doesn't match the declared kind", async () => { + const client = await bindContract(kindContract, { + workflow: mockWorkflow, + schedule: mockSchedule, + } as unknown as Client); + + const result = await client.startWorkflow("kinds", { + workflowId: "k-1", + args: { id: "a" }, + searchAttributes: { + // INT declared, string provided — escaped the type system via cast. + priority: "high" as unknown as number, + }, + }); + + expect(result).toBeDefect(); + if (result.isDefect()) { + expect(result.cause).toBeInstanceOf(RuntimeClientError); + expect((result.cause as RuntimeClientError).operation).toBe("searchAttributes"); + expect((result.cause as RuntimeClientError).message).toContain("priority"); + expect((result.cause as RuntimeClientError).message).toContain("INT"); + } + expect(mockWorkflow.start).not.toHaveBeenCalled(); + }); + + it("accepts values matching their declared kinds", async () => { + mockWorkflow.start.mockResolvedValue({ workflowId: "k-2" }); + const client = await bindContract(kindContract, { + workflow: mockWorkflow, + schedule: mockSchedule, + } as unknown as Client); + + const result = await client.startWorkflow("kinds", { + workflowId: "k-2", + args: { id: "a" }, + searchAttributes: { + priority: 3, + placedAt: new Date("2026-01-01T00:00:00Z"), + tags: ["a", "b"], + }, + }); + + expect(result).toBeOk(); + }); + + it("rejects non-string entries inside a KEYWORD_LIST", async () => { + const client = await bindContract(kindContract, { + workflow: mockWorkflow, + schedule: mockSchedule, + } as unknown as Client); + + const result = await client.startWorkflow("kinds", { + workflowId: "k-3", + args: { id: "a" }, + searchAttributes: { + tags: ["ok", 42] as unknown as string[], + }, + }); + + expect(result).toBeDefect(); + if (result.isDefect()) { + expect((result.cause as RuntimeClientError).message).toContain("tags"); + } + expect(mockWorkflow.start).not.toHaveBeenCalled(); + }); + + it('names the received runtime type instead of typeof\'s blanket "object"', async () => { + const client = await bindContract(kindContract, { + workflow: mockWorkflow, + schedule: mockSchedule, + } as unknown as Client); + + const cases: Array<{ value: unknown; reported: string }> = [ + { value: new Date("2026-01-01T00:00:00Z"), reported: "a Date" }, + { value: ["not", "an", "int"], reported: "an array" }, + { value: null, reported: "null" }, + ]; + for (const { value, reported } of cases) { + const result = await client.startWorkflow("kinds", { + workflowId: "k-4", + args: { id: "a" }, + searchAttributes: { priority: value as unknown as number }, + }); + + expect(result).toBeDefect(); + if (result.isDefect()) { + expect((result.cause as RuntimeClientError).message).toContain(`received ${reported}.`); + } + } + expect(mockWorkflow.start).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index a88e6b47..964ad54d 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -7,22 +7,24 @@ import type { SearchAttributeKindToType, SignalDefinition, SignalNamesOf, + UpdateDefinition, + UpdateNamesOf, } from "@temporal-contract/contract"; import { TechnicalError, type ContractErrorUnion } from "@temporal-contract/contract/errors"; -import { type Client, type WorkflowHandle } from "@temporalio/client"; -import type { WorkflowSignalWithStartOptions, WorkflowStartOptions } from "@temporalio/client"; -import { WorkflowExecutionAlreadyStartedError } from "@temporalio/client"; -import { WorkflowFailedError as TemporalWorkflowFailedError } from "@temporalio/client"; +import { type Client, type WorkflowHandle, type WorkflowUpdateHandle } from "@temporalio/client"; +import type { + GetWorkflowHandleOptions, + WorkflowSignalWithStartOptions, + WorkflowStartOptions, +} from "@temporalio/client"; import { defineSearchAttributeKey, type TypedSearchAttributes } from "@temporalio/common"; -import { WorkflowNotFoundError as TemporalWorkflowNotFoundError } from "@temporalio/common"; import { type AsyncResult, type Result, Ok, Err, fromPromise } from "unthrown"; import { - type TemporalFailure, - WorkflowAlreadyStartedError, - WorkflowExecutionNotFoundError, - WorkflowFailedError, - WorkflowNotFoundError, + type WorkflowAlreadyStartedError, + type WorkflowExecutionNotFoundError, + type WorkflowFailedError, + WorkflowNotInContractError, WorkflowValidationError, QueryValidationError, SignalValidationError, @@ -37,11 +39,10 @@ import { } from "./interceptors.js"; import { assertNoDefect, + classifyExecutionResultError, classifyHandleError, - classifyResultError, classifyStartError, makeAsyncResult, - rehydrateWorkflowContractError, toTypedSearchAttributes, } from "./internal.js"; import { TypedScheduleClient } from "./schedule.js"; @@ -53,17 +54,6 @@ import type { ClientInferWorkflowUpdates, } from "./types.js"; -/** - * Typed `searchAttributes` map for a workflow, derived from the workflow's - * declared `searchAttributes`. Each key is constrained to a declared - * attribute name; each value's type is determined by the attribute's `kind` - * (e.g. `KEYWORD` → `string`, `INT` → `number`, `DATETIME` → `Date`, - * `KEYWORD_LIST` → `string[]`). - * - * If the workflow declares no search attributes, this resolves to `never`, - * meaning the `searchAttributes` field is effectively absent from the start - * options for that workflow. - */ /** * Union of typed {@link ContractError}s declared on a workflow's `errors` * map, or `never` when the workflow declares none — in which case the member @@ -79,6 +69,17 @@ export type WorkflowContractErrorsOf = ? ContractErrorUnion : never; +/** + * Typed `searchAttributes` map for a workflow, derived from the workflow's + * declared `searchAttributes`. Each key is constrained to a declared + * attribute name; each value's type is determined by the attribute's `kind` + * (e.g. `KEYWORD` → `string`, `INT` → `number`, `DATETIME` → `Date`, + * `KEYWORD_LIST` → `string[]`). + * + * If the workflow declares no search attributes, this resolves to `never`, + * meaning the `searchAttributes` field is effectively absent from the start + * options for that workflow. + */ export type TypedSearchAttributeMap = TWorkflow["searchAttributes"] extends Record ? { @@ -137,26 +138,48 @@ export function readTypedSearchAttributes>; } +/** + * The `args` field of the start-shaped options, typed against the + * workflow's input schema. When the schema accepts `undefined`, the field + * becomes omittable so input-less workflows don't need `args: undefined` + * ceremony. + */ +type WorkflowArgsField = + undefined extends ClientInferInput + ? { args?: ClientInferInput } + : { args: ClientInferInput }; + export type TypedWorkflowStartOptions< TContract extends ContractDefinition, TWorkflowName extends keyof TContract["workflows"] & string, > = Omit< WorkflowStartOptions, "taskQueue" | "args" | "searchAttributes" | "typedSearchAttributes" -> & { - args: ClientInferInput; - /** - * Indexed search attributes for the started workflow. Keys and value types - * are constrained to those declared on the workflow's contract via - * `defineSearchAttribute`. Translated to Temporal's `typedSearchAttributes` - * before the start request is dispatched. - */ - searchAttributes?: TypedSearchAttributeMap; -}; +> & + WorkflowArgsField & { + /** + * Indexed search attributes for the started workflow. Keys and value types + * are constrained to those declared on the workflow's contract via + * `defineSearchAttribute`. Translated to Temporal's `typedSearchAttributes` + * before the start request is dispatched. + */ + searchAttributes?: TypedSearchAttributeMap; + }; + +/** + * The `signalArgs` field of `signalWithStart`'s options, typed against the + * named signal's input schema. When the schema accepts `undefined` (e.g. a + * payload-less `defineSignal()`), the field becomes omittable. + */ +type SignalArgsField = TSignalDef extends SignalDefinition + ? undefined extends ClientInferInput + ? { signalArgs?: ClientInferInput } + : { signalArgs: ClientInferInput } + : { signalArgs?: never }; /** - * Options for {@link TypedClient.signalWithStart} — typed against both the - * workflow's input schema and the named signal's input schema. + * Options for {@link ContractClient.signalWithStart} — typed against both + * the workflow's input schema and the named signal's input schema. */ export type TypedSignalWithStartOptions< TContract extends ContractDefinition, @@ -165,19 +188,75 @@ export type TypedSignalWithStartOptions< > = Omit< WorkflowSignalWithStartOptions, "taskQueue" | "args" | "signal" | "signalArgs" | "searchAttributes" | "typedSearchAttributes" -> & { - args: ClientInferInput; - signalName: TSignalName; - signalArgs: TContract["workflows"][TWorkflowName]["signals"][TSignalName] extends SignalDefinition - ? ClientInferInput - : never; +> & + WorkflowArgsField & + SignalArgsField & { + signalName: TSignalName; + /** + * Indexed search attributes for the started workflow. Keys and value types + * are constrained to those declared on the workflow's contract via + * `defineSearchAttribute`. Translated to Temporal's `typedSearchAttributes` + * before the signalWithStart request is dispatched. + */ + searchAttributes?: TypedSearchAttributeMap; + }; + +/** + * Options for {@link ContractClient.getHandle}. Extends Temporal's + * `GetWorkflowHandleOptions` (`followRuns`, `firstExecutionRunId` — the + * chain interlock ensuring mutating methods don't cross into another + * execution chain) with the optional `runId` of the specific execution to + * bind. + */ +export type TypedGetHandleOptions = GetWorkflowHandleOptions & { + /** + * Run ID of the specific execution to bind the handle to. Omitted, the + * handle addresses the latest execution of the workflow ID. + */ + runId?: string; +}; + +/** + * Options for {@link TypedWorkflowHandle.startUpdate} — the update payload + * plus the passthrough subset of Temporal's `WorkflowUpdateOptions`. + */ +export type TypedStartUpdateOptions = { + /** + * Unique ID for this update request (passthrough of Temporal's + * `updateId`). Meaningful business IDs enable deduplication. + */ + updateId?: string; + /** + * Update lifecycle stage to wait for before the handle is returned. + * Temporal currently only supports `"ACCEPTED"`, which is also the + * default — the option exists as a forward-compatible passthrough. + */ + waitForStage?: "ACCEPTED"; +} & (undefined extends ClientInferInput + ? { args?: ClientInferInput } + : { args: ClientInferInput }); + +/** + * Typed handle to an in-flight update, returned by + * {@link TypedWorkflowHandle.startUpdate}. `result()` parses the update's + * outcome against the contract's output schema on receive (the worker + * transmits its original return value — D1). + */ +export type TypedWorkflowUpdateHandle = { + /** The ID of this update request. */ + readonly updateId: string; + /** The ID of the workflow execution targeted by this update. */ + readonly workflowId: string; + /** The run ID of the targeted execution, when known. */ + readonly workflowRunId: string | undefined; /** - * Indexed search attributes for the started workflow. Keys and value types - * are constrained to those declared on the workflow's contract via - * `defineSearchAttribute`. Translated to Temporal's `typedSearchAttributes` - * before the signalWithStart request is dispatched. + * Wait for and return the update's result, parsed against the contract's + * output schema. */ - searchAttributes?: TypedSearchAttributeMap; + result: () => AsyncResult< + ClientInferOutput, + UpdateValidationError | WorkflowExecutionNotFoundError + >; }; /** @@ -199,7 +278,22 @@ export type TypedWorkflowHandleWithSignaledRunId = { - workflowId: string; + readonly workflowId: string; + + /** + * Run ID of the execution this handle is bound to, when known: the + * started run's ID for `startWorkflow` handles, the caller-provided + * `runId` for `getHandle` handles, `undefined` otherwise (the handle then + * addresses the latest execution). + */ + readonly runId: string | undefined; + + /** + * Run ID of the first execution in the workflow chain, when known (set on + * handles returned by `startWorkflow`, and on `getHandle` handles when the + * caller passed `firstExecutionRunId`). + */ + readonly firstExecutionRunId: string | undefined; /** * Type-safe queries based on workflow definition with Result pattern @@ -226,8 +320,10 @@ export type TypedWorkflowHandle = { }; /** - * Type-safe updates based on workflow definition with Result pattern - * Each update returns AsyncResult instead of Promise + * Type-safe updates based on workflow definition with Result pattern. + * Each update starts the update AND waits for its result (Temporal's + * `executeUpdate`); use {@link startUpdate} to obtain an update handle + * without waiting for completion. */ updates: { [K in keyof ClientInferWorkflowUpdates]: ClientInferWorkflowUpdates[K] extends ( @@ -237,6 +333,30 @@ export type TypedWorkflowHandle = { : never; }; + /** + * Start an update without waiting for its completion — Temporal's + * `startUpdate` beside the `updates` map's execute-and-wait shape. + * 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 + * `undefined` (e.g. an argument-less `defineUpdate({ output })`). + */ + startUpdate: >( + updateName: TUpdateName, + ...options: TWorkflow["updates"][TUpdateName] extends UpdateDefinition + ? undefined extends ClientInferInput + ? [options?: TypedStartUpdateOptions] + : [options: TypedStartUpdateOptions] + : never + ) => AsyncResult< + TypedWorkflowUpdateHandle< + TWorkflow["updates"][TUpdateName] extends UpdateDefinition + ? TWorkflow["updates"][TUpdateName] + : never + >, + UpdateValidationError | WorkflowExecutionNotFoundError + >; + /** * Get workflow result with Result pattern. When the workflow declares * contract errors, a failed execution whose failure matches a declared @@ -281,13 +401,12 @@ export type TypedWorkflowHandle = { /** * Result of {@link resolveDefinitionAndValidateInput} — the contract-side * pre-call ritual the start/signal-with-start/execute methods share. Holds - * the resolved workflow definition, the schema-validated input, and the - * translated typed search attributes (or `undefined` when the workflow - * declared none / the caller passed none). + * the resolved workflow definition and the translated typed search + * attributes (or `undefined` when the workflow declared none / the caller + * passed none). */ type ResolvedWorkflow = { definition: TWorkflow; - validatedInput: unknown; typedSearchAttributes: TypedSearchAttributes | undefined; }; @@ -297,15 +416,20 @@ type ResolvedWorkflow = { * `executeWorkflow`): * * 1. Look up the workflow definition on the contract. - * 2. Surface a `WorkflowNotFoundError` if absent. + * 2. Surface a `WorkflowNotInContractError` if absent. * 3. Validate `args` against the workflow's input schema. * 4. Surface a `WorkflowValidationError` if validation fails. * 5. Translate any caller-supplied `searchAttributes` into Temporal's * `TypedSearchAttributes` shape (or `undefined`). * + * Step 3 validates to fail early with a typed error, but the parsed value is + * deliberately DISCARDED: the caller transmits the original `args`, and the + * worker parses them on receive, so a transforming schema is applied exactly + * once per boundary (never here on the sending side). + * * `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 validation, + * call-specific extras (signal validation, post-call output parsing, * extended error classification) stay at the call site — those are the * differentiators that make each method distinct. */ @@ -315,32 +439,33 @@ async function resolveDefinitionAndValidateInput< >( contract: TContract, workflowName: TWorkflowName, + workflowId: string, args: unknown, searchAttributes: Record | undefined, ): Promise< Result< ResolvedWorkflow, - WorkflowNotFoundError | WorkflowValidationError + WorkflowNotInContractError | WorkflowValidationError > > { const definition = contract.workflows[workflowName]; if (!definition) { - return Err(createWorkflowNotFoundError(workflowName, contract)); + return Err(createWorkflowNotInContractError(workflowName, contract)); } const inputResult = await definition.input["~standard"].validate(args); if (inputResult.issues) { - return Err(createWorkflowValidationError(workflowName, "input", inputResult.issues)); + return Err(new WorkflowValidationError(workflowName, "input", inputResult.issues, workflowId)); } // `toTypedSearchAttributes` throws a `RuntimeClientError` on an undeclared - // key — a technical misconfiguration routed to the defect channel by the - // enclosing `makeAsyncResult` work thunk (never a modeled Err). + // key or a value that doesn't match the declared kind — a technical + // misconfiguration routed to the defect channel by the enclosing + // `makeAsyncResult` work thunk (never a modeled Err). const typedSearchAttributes = toTypedSearchAttributes(definition, workflowName, searchAttributes); return Ok({ definition: definition as TContract["workflows"][TWorkflowName], - validatedInput: inputResult.value, typedSearchAttributes, }); } @@ -349,83 +474,58 @@ async function resolveDefinitionAndValidateInput< * Options for {@link TypedClient.create} — the single options-object shape * shared by the org's `Typed*.create()` factories. */ -export type CreateTypedClientOptions = { - /** The contract this client is typed against. */ - contract: TContract; +export type CreateClientOptions = { /** The underlying `@temporalio/client` `Client`. */ client: Client; /** * Client-side interceptors wrapping `startWorkflow` / `executeWorkflow` / * `signalWithStart` and handle-level `signal` / `query` / `update`, - * outermost-first. See {@link ClientInterceptor}. + * outermost-first, inherited by every contract-bound client handed out by + * {@link TypedClient.for}. See {@link ClientInterceptor}. */ interceptors?: readonly ClientInterceptor[]; }; /** - * Typed Temporal client with unthrown Result/AsyncResult pattern based on a contract + * Connection-scoped root of the typed client surface. * - * Provides type-safe methods to start and execute workflows - * defined in the contract, with explicit error handling using Result pattern. + * A client is a *connection*; a contract is a *schema*. `TypedClient` owns + * the connection-lifetime concerns — the eager `ensureConnected()`, the + * `@temporalio/client` capability check, the interceptor chain, and the + * {@link TypedClient.raw | raw} escape hatch — and hands out contract-bound + * {@link ContractClient}s via {@link TypedClient.for}. Create it once at + * process start; bind contracts freely (binding is synchronous, infallible, + * and memoized). */ -export class TypedClient { +export class TypedClient { /** - * Typed wrapper around Temporal's `client.schedule.create(...)` and - * related lifecycle methods. Fires the underlying `startWorkflow` action - * with args validated against the contract's input schema. - * - * **Requires `@temporalio/client` 1.16+.** The Schedule API was added in - * 1.16; on older versions this property is unset and any access throws. - * The package's peer dep allows the whole `^1` range to stay permissive - * about the installed Temporal version, so consumers on < 1.16 who never - * touch schedules keep working — the constructor below fails fast with a - * clear message for anyone who does reach for the Schedule API too early. - * - * @example - * ```ts - * import { P } from "unthrown"; - * - * const result = await client.schedule.create("processOrder", { - * scheduleId: "daily-sweep", - * spec: { cronExpressions: ["0 2 * * *"] }, - * args: { orderId: "sweep" }, - * }); - * - * await result.match({ - * ok: async (handle) => { await handle.pause("maintenance"); }, - * errCases: (matcher) => - * matcher.with( - * P.tag("@temporal-contract/WorkflowNotFoundError"), - * P.tag("@temporal-contract/WorkflowValidationError"), - * (error) => console.error("schedule create failed", error), - * ), - * defect: (cause) => console.error("unexpected failure", cause), - * }); - * ``` + * The underlying `@temporalio/client` `Client` — the escape hatch for + * anything the typed surface doesn't cover yet (e.g. + * `raw.workflow.list(...)`, `raw.workflow.count(...)`). Calls made through + * `raw` bypass contract validation and the interceptor chain. */ - readonly schedule: TypedScheduleClient; + readonly raw: Client; - private constructor( - private readonly contract: TContract, - private readonly client: Client, - private readonly interceptors: readonly ClientInterceptor[], - ) { - // `client.schedule` is the ScheduleClient wired into Temporal's - // top-level `Client` since 1.16. The peer dep allows all of `^1`, so a - // consumer can be on an older version — fail early with a clear message - // rather than crashing later with a confusing - // `Cannot read properties of undefined`. - if (!client.schedule) { - throw new Error( - "TypedClient requires @temporalio/client >= 1.16 (the Schedule API was added in 1.16). " + - "Found a Client instance without a `schedule` property — please upgrade.", - ); - } - this.schedule = new TypedScheduleClient(contract, client.schedule); + private readonly interceptors: readonly ClientInterceptor[]; + + /** + * Memoized contract bindings, keyed by contract identity, so + * `for(c) === for(c)` and repeated binding in hot paths doesn't rebuild + * the `TypedScheduleClient`. The map erases the contract's type + * parameter; the two casts in {@link TypedClient.for} restore it. + */ + private readonly contractClients = new WeakMap< + ContractDefinition, + ContractClient + >(); + + private constructor(client: Client, interceptors: readonly ClientInterceptor[]) { + this.raw = client; + this.interceptors = interceptors; } /** - * Create a typed Temporal client with unthrown pattern from a contract. + * Create the connection-scoped typed client. * * Returns `AsyncResult` — setup faults are *technical* * infrastructure failures, not anticipated domain errors, so they surface on @@ -440,39 +540,32 @@ export class TypedClient { * * @example * ```ts + * import { TypedClient } from "@temporal-contract/client"; + * import { Client, Connection } from "@temporalio/client"; + * * const connection = await Connection.connect(); * const temporalClient = new Client({ connection }); - * const clientResult = await TypedClient.create({ - * contract: myContract, - * client: temporalClient, - * }); - * if (clientResult.isDefect()) { - * console.error('client setup failed', clientResult.cause); - * return; - * } - * const client = clientResult.value; * - * const result = await client.executeWorkflow('processOrder', { - * workflowId: 'order-123', - * args: { ... }, - * }); + * // Once, at process start. The Err channel is empty (`never`), so + * // `.get()` unwraps directly — a setup defect rethrows its cause. + * const client = await TypedClient.create({ client: temporalClient }).get(); * ``` */ - static create({ - contract, - client, - interceptors, - }: CreateTypedClientOptions): AsyncResult, never> { - const work = async (): Promise, never>> => { - let instance: TypedClient; - try { - instance = new TypedClient(contract, client, interceptors ?? []); - } catch (error) { - // Technical setup fault — throw a `TechnicalError`; `makeAsyncResult`'s - // throw→defect net routes it to the defect channel (never a modeled Err). + static create({ client, interceptors }: CreateClientOptions): AsyncResult { + const work = async (): Promise> => { + // `client.schedule` is the ScheduleClient wired into Temporal's + // top-level `Client` since 1.16. The peer dep allows all of `^1`, so a + // consumer can be on an older version — fail early with a clear + // message rather than crashing later with a confusing + // `Cannot read properties of undefined`. This is a property of the + // connection's client, not of any contract, hence checked here rather + // than in `for()`. + if (!client.schedule) { + // Technical setup fault — `makeAsyncResult`'s throw→defect net + // routes it to the defect channel (never a modeled Err). throw new TechnicalError( - error instanceof Error ? error.message : "Failed to create TypedClient", - error, + "TypedClient requires @temporalio/client >= 1.16 (the Schedule API was added in 1.16). " + + "Found a Client instance without a `schedule` property — please upgrade.", ); } @@ -491,26 +584,114 @@ export class TypedClient { } } - return Ok(instance); + return Ok(new TypedClient(client, interceptors ?? [])); }; return makeAsyncResult(work); } /** - * Create a typed client synchronously, throwing on failure — the - * pre-AsyncResult behavior. + * Bind a contract, returning a {@link ContractClient} typed against it. * - * @deprecated Use {@link TypedClient.create}, which returns - * `AsyncResult` and also validates the - * connection eagerly. This throwing alias exists to ease migration and - * will be removed in a future major. + * Synchronous and infallible — binding a schema to an established + * connection is a free, compile-time-ish operation, so it's valid in a + * field initializer. Memoized per contract identity: the option-less + * `for(c) === for(c)` guarantee holds, so calling it per request is free. + * + * @example + * ```ts + * import { P } from "unthrown"; + * + * import { orderContract } from "./contracts/order.contract.js"; + * + * const orders = client.for(orderContract); + * + * const result = await orders.executeWorkflow("processOrder", { + * workflowId: "order-123", + * args: { orderId: "ORD-123" }, + * }); + * + * await result.match({ + * 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"), + * (error) => console.error("processing failed", error), + * ), + * defect: (cause) => console.error("unexpected failure", cause), + * }); + * ``` */ - static createOrThrow( - contract: TContract, - client: Client, - interceptors?: readonly ClientInterceptor[], - ): TypedClient { - return new TypedClient(contract, client, interceptors ?? []); + for(contract: TContract): ContractClient { + // The WeakMap erases the contract's type parameter; these two casts + // 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); + this.contractClients.set(contract, bound as unknown as ContractClient); + return bound; + } +} + +/** + * Contract-scoped typed Temporal client with unthrown Result/AsyncResult + * pattern. + * + * 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. + */ +export class ContractClient { + /** + * Typed wrapper around Temporal's `client.schedule.create(...)` and + * related lifecycle methods. Fires the underlying `startWorkflow` action + * with args validated against the contract's input schema. + * + * **Requires `@temporalio/client` 1.16+.** The Schedule API was added in + * 1.16; {@link TypedClient.create} fails fast (a defect with a clear + * message) when the underlying `Client` predates it. + * + * @example + * ```ts + * import { P } from "unthrown"; + * + * const result = await contractClient.schedule.create("processOrder", { + * scheduleId: "daily-sweep", + * spec: { cronExpressions: ["0 2 * * *"] }, + * args: { orderId: "sweep" }, + * }); + * + * await result.match({ + * ok: async (handle) => { await handle.pause("maintenance"); }, + * errCases: (matcher) => + * matcher.with( + * P.tag("@temporal-contract/WorkflowNotInContractError"), + * P.tag("@temporal-contract/WorkflowValidationError"), + * P.tag("@temporal-contract/ScheduleAlreadyExistsError"), + * (error) => console.error("schedule create failed", error), + * ), + * defect: (cause) => console.error("unexpected failure", cause), + * }); + * ``` + */ + readonly schedule: TypedScheduleClient; + + /** + * 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); } /** @@ -520,7 +701,7 @@ export class TypedClient { * ```ts * import { P } from "unthrown"; * - * const handleResult = await client.startWorkflow('processOrder', { + * const handleResult = await contractClient.startWorkflow('processOrder', { * workflowId: 'order-123', * args: { orderId: 'ORD-123' }, * workflowExecutionTimeout: '1 day', @@ -534,7 +715,7 @@ export class TypedClient { * }, * errCases: (matcher) => * matcher.with( - * P.tag('@temporal-contract/WorkflowNotFoundError'), + * P.tag('@temporal-contract/WorkflowNotInContractError'), * P.tag('@temporal-contract/WorkflowValidationError'), * P.tag('@temporal-contract/WorkflowAlreadyStartedError'), * (error) => console.error('Failed to start:', error), @@ -545,38 +726,51 @@ export class TypedClient { */ startWorkflow( workflowName: TWorkflowName, - { - args, - searchAttributes, - ...temporalOptions - }: TypedWorkflowStartOptions, + options: TypedWorkflowStartOptions, ): AsyncResult< TypedWorkflowHandle, - WorkflowNotFoundError | WorkflowValidationError | WorkflowAlreadyStartedError + WorkflowNotInContractError | WorkflowValidationError | WorkflowAlreadyStartedError > { + // Widen once at the boundary: `args` is a conditional type (omittable + // for undefined-accepting inputs), which the rest-spread below can't + // decompose while it's still generic. + const { args, searchAttributes, ...temporalOptions } = options as Omit< + WorkflowStartOptions, + "taskQueue" | "args" | "searchAttributes" | "typedSearchAttributes" + > & { args?: unknown; searchAttributes?: Record }; type Ok = TypedWorkflowHandle; - type Err = WorkflowNotFoundError | WorkflowValidationError | WorkflowAlreadyStartedError; + type Err = WorkflowNotInContractError | WorkflowValidationError | WorkflowAlreadyStartedError; const runPipeline = (currentInput: unknown): AsyncResult => { const work = async (): Promise> => { const resolved = await resolveDefinitionAndValidateInput( this.contract, workflowName, + temporalOptions.workflowId, currentInput, searchAttributes as Record | undefined, ); // The resolver only ever builds ok/err; assert away the impossible defect. assertNoDefect(resolved); if (resolved.isErr()) return Err(resolved.error); - const { definition, validatedInput, typedSearchAttributes } = resolved.value; + const { definition, typedSearchAttributes } = resolved.value; 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, { ...temporalOptions, taskQueue: this.contract.taskQueue, - args: [validatedInput], + args: currentInput === undefined ? [] : [currentInput], ...(typedSearchAttributes ? { typedSearchAttributes } : {}), }); - return Ok(this.createTypedHandle(handle, workflowName, definition) as Ok); + return Ok( + this.createTypedHandle(handle, workflowName, definition, { + runId: handle.firstExecutionRunId, + firstExecutionRunId: handle.firstExecutionRunId, + }) as Ok, + ); } catch (error) { const alreadyStarted = classifyStartError(error); if (alreadyStarted) return Err(alreadyStarted); @@ -618,7 +812,7 @@ export class TypedClient { * ```ts * import { P } from "unthrown"; * - * const result = await client.signalWithStart('processOrder', { + * const result = await contractClient.signalWithStart('processOrder', { * workflowId: 'order-123', * args: { orderId: 'ORD-123', customerId: 'CUST-1' }, * signalName: 'cancel', @@ -629,7 +823,7 @@ export class TypedClient { * ok: (handle) => console.log('signaled run', handle.signaledRunId), * errCases: (matcher) => * matcher.with( - * P.tag('@temporal-contract/WorkflowNotFoundError'), + * P.tag('@temporal-contract/WorkflowNotInContractError'), * P.tag('@temporal-contract/WorkflowValidationError'), * P.tag('@temporal-contract/SignalValidationError'), * P.tag('@temporal-contract/WorkflowAlreadyStartedError'), @@ -644,23 +838,29 @@ export class TypedClient { TSignalName extends SignalNamesOf, >( workflowName: TWorkflowName, - { - args, - signalName, - signalArgs, - searchAttributes, - ...temporalOptions - }: TypedSignalWithStartOptions, + options: TypedSignalWithStartOptions, ): AsyncResult< TypedWorkflowHandleWithSignaledRunId, - | WorkflowNotFoundError + | WorkflowNotInContractError | WorkflowValidationError | SignalValidationError | WorkflowAlreadyStartedError > { + // Widen once at the boundary — `args`/`signalArgs` are conditional + // types (omittable for undefined-accepting inputs), which the + // rest-spread below can't decompose while they're still generic. + const { args, signalName, signalArgs, searchAttributes, ...temporalOptions } = options as Omit< + WorkflowSignalWithStartOptions, + "taskQueue" | "args" | "signal" | "signalArgs" | "searchAttributes" | "typedSearchAttributes" + > & { + args?: unknown; + signalName: string; + signalArgs?: unknown; + searchAttributes?: Record; + }; type Ok = TypedWorkflowHandleWithSignaledRunId; type Err = - | WorkflowNotFoundError + | WorkflowNotInContractError | WorkflowValidationError | SignalValidationError | WorkflowAlreadyStartedError; @@ -673,15 +873,18 @@ export class TypedClient { const resolved = await resolveDefinitionAndValidateInput( this.contract, workflowName, + temporalOptions.workflowId, currentInput, searchAttributes as Record | undefined, ); // The resolver only ever builds ok/err; assert away the impossible defect. assertNoDefect(resolved); if (resolved.isErr()) return Err(resolved.error); - const { definition, validatedInput, typedSearchAttributes } = resolved.value; + const { definition, typedSearchAttributes } = resolved.value; - // Validate signal input — call-site-specific, kept inline. + // 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 ]; @@ -705,9 +908,12 @@ export class TypedClient { const handle = await this.client.workflow.signalWithStart(workflowName, { ...temporalOptions, taskQueue: this.contract.taskQueue, - args: [validatedInput], + args: currentInput === undefined ? [] : [currentInput], signal: signalName, - signalArgs: [signalInputResult.value], + // 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( @@ -752,7 +958,7 @@ export class TypedClient { * ```ts * import { P } from "unthrown"; * - * const result = await client.executeWorkflow('processOrder', { + * const result = await contractClient.executeWorkflow('processOrder', { * workflowId: 'order-123', * args: { orderId: 'ORD-123' }, * workflowExecutionTimeout: '1 day', @@ -764,7 +970,7 @@ export class TypedClient { * errCases: (matcher) => * matcher.with( * P.tag('@temporal-contract/ContractError'), - * P.tag('@temporal-contract/WorkflowNotFoundError'), + * P.tag('@temporal-contract/WorkflowNotInContractError'), * P.tag('@temporal-contract/WorkflowValidationError'), * P.tag('@temporal-contract/WorkflowAlreadyStartedError'), * P.tag('@temporal-contract/WorkflowFailedError'), @@ -777,24 +983,25 @@ export class TypedClient { */ executeWorkflow( workflowName: TWorkflowName, - { - args, - searchAttributes, - ...temporalOptions - }: TypedWorkflowStartOptions, + options: TypedWorkflowStartOptions, ): AsyncResult< ClientInferOutput, | WorkflowContractErrorsOf - | WorkflowNotFoundError + | 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 - | WorkflowNotFoundError + | WorkflowNotInContractError | WorkflowValidationError | WorkflowAlreadyStartedError | WorkflowFailedError @@ -804,73 +1011,56 @@ export class TypedClient { const resolved = await resolveDefinitionAndValidateInput( this.contract, workflowName, + temporalOptions.workflowId, currentInput, searchAttributes as Record | undefined, ); // The resolver only ever builds ok/err; assert away the impossible defect. assertNoDefect(resolved); if (resolved.isErr()) return Err(resolved.error); - const { definition, validatedInput, typedSearchAttributes } = resolved.value; + const { definition, typedSearchAttributes } = resolved.value; try { + // Transmit the caller's ORIGINAL args (validated above, parsed by + // the worker on receive — D1). const result = await this.client.workflow.execute(workflowName, { ...temporalOptions, taskQueue: this.contract.taskQueue, - args: [validatedInput], + args: currentInput === undefined ? [] : [currentInput], ...(typedSearchAttributes ? { typedSearchAttributes } : {}), }); - // Output validation runs *after* the Temporal call returns — kept + // 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. + // 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(createWorkflowValidationError(workflowName, "output", outputResult.issues)); - } - - return Ok(outputResult.value as Ok); - } catch (error) { - // executeWorkflow combines start + result, so it can surface any of - // the discriminated kinds. Inline the three checks rather than - // routing through a dedicated helper — this is the only call site - // that needs the full union. - if (error instanceof WorkflowExecutionAlreadyStartedError) { - return Err( - new WorkflowAlreadyStartedError(error.workflowType, error.workflowId, error), - ); - } - if (error instanceof TemporalWorkflowFailedError) { - // A failure matching one of the workflow's declared contract - // errors rehydrates into the typed error (data re-validated - // against the declared schema) instead of the generic wrapper. - const rehydrated = await rehydrateWorkflowContractError(definition, error.cause); - if (rehydrated) { - return Err(rehydrated as Err); - } - // Forward Temporal's nested cause directly — see - // {@link classifyResultError} for the same rationale: Temporal's - // `WorkflowFailedError` is a wrapper, and the actionable failure - // (ApplicationFailure, CancelledFailure, etc.) lives on `.cause`. - // Temporal types `cause` as `Error | undefined`, but the SDK only - // ever populates it with a `TemporalFailure` subclass here; narrow - // with the public union so the typed `cause` lines up with the - // surfaced `WorkflowFailedError`. return Err( - new WorkflowFailedError( + new WorkflowValidationError( + workflowName, + "output", + outputResult.issues, temporalOptions.workflowId, - error.cause as TemporalFailure | undefined, - ), - ); - } - if (error instanceof TemporalWorkflowNotFoundError) { - return Err( - new WorkflowExecutionNotFoundError( - error.workflowId || temporalOptions.workflowId, - error.runId, - error, ), ); } + + 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. throw new RuntimeClientError("executeWorkflow", error); } @@ -892,57 +1082,59 @@ export class TypedClient { } /** - * Get a handle to an existing workflow with AsyncResult pattern + * Get a typed handle to an existing workflow execution. + * + * Synchronous — the only failure mode is a workflow name missing from the + * contract, surfaced as a sync `Result` Err. Whether the *execution* + * exists is a server-side question answered lazily by the handle's + * methods (as {@link WorkflowExecutionNotFoundError}). + * + * Accepts an optional `runId` (bind to a specific execution) and + * Temporal's `GetWorkflowHandleOptions` passthrough — in particular + * `firstExecutionRunId`, the chain interlock ensuring mutating handle + * methods (`terminate`, `cancel`) don't affect executions from another + * chain reusing the workflow ID. * * @example * ```ts - * import { P } from "unthrown"; - * - * const handleResult = await client.getHandle('processOrder', 'order-123'); - * await handleResult.match({ - * ok: async (handle) => { - * const result = await handle.result(); - * // ... handle result - * }, - * errCases: (matcher) => - * matcher.with( - * P.tag('@temporal-contract/WorkflowNotFoundError'), - * (error) => console.error('Failed to get handle:', error), - * ), - * defect: (cause) => console.error('Unexpected failure:', cause), - * }); + * const handleResult = contractClient.getHandle('processOrder', 'order-123'); + * if (!handleResult.isOk()) { + * console.error('Unknown workflow:', handleResult.isErr() ? handleResult.error : handleResult.cause); + * return; + * } + * const result = await handleResult.value.result(); * ``` */ getHandle( workflowName: TWorkflowName, workflowId: string, - ): AsyncResult< + options?: TypedGetHandleOptions, + ): Result< TypedWorkflowHandle, - WorkflowNotFoundError + WorkflowNotInContractError > { - type Ok = TypedWorkflowHandle; - type Err = WorkflowNotFoundError; - const work = async (): Promise> => { - const definition = this.contract.workflows[workflowName]; - if (!definition) { - return Err(createWorkflowNotFoundError(workflowName, this.contract)); - } + const definition = this.contract.workflows[workflowName] as + | TContract["workflows"][TWorkflowName] + | undefined; + if (!definition) { + return Err(createWorkflowNotInContractError(workflowName, this.contract)); + } - try { - const handle = this.client.workflow.getHandle(workflowId); - return Ok(this.createTypedHandle(handle, workflowName, definition) as Ok); - } catch (error) { - // Unrecognized, technical failure — route to the defect channel. - throw new RuntimeClientError("getHandle", error); - } - }; - return makeAsyncResult(work); + const { runId, ...handleOptions }: TypedGetHandleOptions = options ?? {}; + const handle = this.client.workflow.getHandle(workflowId, runId, handleOptions); + return Ok( + this.createTypedHandle(handle, workflowName, definition, { + runId, + firstExecutionRunId: handleOptions.firstExecutionRunId, + }), + ); } private createTypedHandle( workflowHandle: WorkflowHandle, workflowName: string, definition: TWorkflow, + ids: { runId?: string | undefined; firstExecutionRunId?: string | undefined } = {}, ): TypedWorkflowHandle { const queries = buildValidatedProxy({ defs: definition.queries, @@ -952,7 +1144,8 @@ export class TypedClient { interceptors: this.interceptors, makeValidationError: (name, direction, issues) => new QueryValidationError(name, direction, issues), - invoke: (name, validated) => workflowHandle.query(name, validated), + invoke: (name, input) => + input === undefined ? workflowHandle.query(name) : workflowHandle.query(name, input), validateOutput: (def) => def.output, }) as TypedWorkflowHandle["queries"]; @@ -963,8 +1156,13 @@ export class TypedClient { workflowId: workflowHandle.workflowId, interceptors: this.interceptors, makeValidationError: (name, _direction, issues) => new SignalValidationError(name, issues), - invoke: async (name, validated) => { - await workflowHandle.signal(name, validated); + invoke: async (name, input) => { + // A payload-less send travels as empty args, not `[undefined]`. + if (input === undefined) { + await workflowHandle.signal(name); + } else { + await workflowHandle.signal(name, input); + } return undefined; }, validateOutput: () => null, @@ -978,15 +1176,113 @@ export class TypedClient { interceptors: this.interceptors, makeValidationError: (name, direction, issues) => new UpdateValidationError(name, direction, issues), - invoke: (name, validated) => workflowHandle.executeUpdate(name, { args: [validated] }), + invoke: (name, input) => + workflowHandle.executeUpdate(name, { + args: (input === undefined ? [] : [input]) as [unknown], + }), validateOutput: (def) => def.output, }) as TypedWorkflowHandle["updates"]; + type StartUpdateErr = UpdateValidationError | WorkflowExecutionNotFoundError; + + const wrapUpdateHandle = ( + updateHandle: WorkflowUpdateHandle, + updateName: string, + updateDef: UpdateDefinition, + ): TypedWorkflowUpdateHandle => ({ + updateId: updateHandle.updateId, + workflowId: updateHandle.workflowId, + workflowRunId: updateHandle.workflowRunId, + result: (): AsyncResult => { + const work = async (): Promise> => { + 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. + throw new RuntimeClientError("update.result", error); + } + }; + return makeAsyncResult(work); + }, + }); + + const startUpdate = ( + updateName: string, + options?: { args?: unknown; updateId?: string; waitForStage?: "ACCEPTED" }, + ): AsyncResult => { + const runPipeline = (currentInput: unknown): AsyncResult => { + const work = async (): Promise> => { + 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 { + // 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. + throw new RuntimeClientError("startUpdate", error); + } + }; + return makeAsyncResult(work); + }; + + // Interceptors wrap the whole pipeline (outside validation), same + // `update` operation as the execute-and-wait `updates` map. + if (this.interceptors.length === 0) return runPipeline(options?.args); + return chainInterceptors( + this.interceptors, + { + operation: "update", + workflowName, + workflowId: workflowHandle.workflowId, + name: updateName, + input: options?.args, + } satisfies ClientInterceptorArgs, + (current) => runPipeline(current.input) as AsyncResult, + ) as AsyncResult; + }; + return { workflowId: workflowHandle.workflowId, + runId: ids.runId, + firstExecutionRunId: ids.firstExecutionRunId, queries, signals, updates, + startUpdate: startUpdate as TypedWorkflowHandle["startUpdate"], result: (): AsyncResult< ClientInferOutput, | WorkflowContractErrorsOf @@ -1007,25 +1303,25 @@ export class TypedClient { if (outputResult.issues) { return Err( new WorkflowValidationError( - workflowHandle.workflowId, + workflowName, "output", outputResult.issues, + workflowHandle.workflowId, ), ); } return Ok(outputResult.value as Ok); } catch (error) { - // A failure matching one of the workflow's declared contract - // errors rehydrates into the typed error; everything else falls - // through to the generic classification. - if (error instanceof TemporalWorkflowFailedError) { - const rehydrated = await rehydrateWorkflowContractError(definition, error.cause); - if (rehydrated) { - return Err(rehydrated as Err); - } - } - const classified = classifyResultError(error, workflowHandle.workflowId); - if (classified) return Err(classified); + // 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. throw new RuntimeClientError("result", error); } @@ -1070,19 +1366,11 @@ export class TypedClient { } } -function createWorkflowNotFoundError( +function createWorkflowNotInContractError( workflowName: string | number | symbol, contract: ContractDefinition, -): WorkflowNotFoundError { - return new WorkflowNotFoundError(String(workflowName), Object.keys(contract.workflows)); -} - -function createWorkflowValidationError( - workflowName: string | number | symbol, - direction: "input" | "output", - issues: ReadonlyArray, -): WorkflowValidationError { - return new WorkflowValidationError(String(workflowName), direction, issues); +): WorkflowNotInContractError { + return new WorkflowNotInContractError(String(workflowName), Object.keys(contract.workflows)); } type DefWithInput = { readonly input: StandardSchemaV1 }; @@ -1106,10 +1394,16 @@ type ProxyOptions = { direction: "input" | "output", issues: ReadonlyArray, ) => TValidationError; - readonly invoke: (name: string, validatedInput: unknown) => Promise; /** - * Returns the schema to validate the invoke result against, or `null` to skip - * output validation (used by signals, which don't return a value). + * Dispatch the call to Temporal. Receives the caller's ORIGINAL input — + * validated against the contract, but untransformed: the workflow-side + * handler parses the payload on receive (D1). An `undefined` input means + * the caller omitted the payload; implementations send empty args. + */ + readonly invoke: (name: string, input: unknown) => Promise; + /** + * Returns the schema to parse the invoke result against, or `null` to skip + * output parsing (used by signals, which don't return a value). */ readonly validateOutput: (def: TDef) => StandardSchemaV1 | null; }; @@ -1117,9 +1411,11 @@ type ProxyOptions = { /** * 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 validate output, so the shared - * input-validate → invoke → output-validate → wrap-Result pipeline lives - * here once. + * 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. */ function buildValidatedProxy({ defs, @@ -1132,10 +1428,10 @@ function buildValidatedProxy): Record< string, - (args: unknown) => AsyncResult + (args?: unknown) => AsyncResult > { type ProxyError = TValidationError | WorkflowExecutionNotFoundError; - const proxy: Record AsyncResult> = {}; + const proxy: Record AsyncResult> = {}; if (!defs) return proxy; for (const [name, def] of Object.entries(defs)) { @@ -1147,7 +1443,8 @@ function buildValidatedProxy { - constructor(workflowName: string, availableWorkflows: string[]) { + constructor(workflowName: string, availableWorkflows: readonly string[]) { super({ workflowName, availableWorkflows }); this.message = `Workflow "${workflowName}" not found in contract. Available workflows: ${availableWorkflows.join(", ")}`; } @@ -91,7 +96,7 @@ export class WorkflowAlreadyStartedError extends TaggedError( * Discriminated variant of {@link RuntimeClientError} surfaced when an * operation targets a workflow execution that doesn't exist in the * namespace — Temporal's `WorkflowNotFoundError` (distinct from this - * package's contract-level {@link WorkflowNotFoundError}). + * package's contract-level {@link WorkflowNotInContractError}). * * Returned from: * - handle methods: `signal`, `query`, `executeUpdate`, `result`, @@ -152,7 +157,12 @@ export class WorkflowFailedError extends TaggedError("@temporal-contract/Workflo // at the top of this module. /** - * Thrown when workflow input or output validation fails + * Surfaced on the Err channel when workflow input or output validation fails. + * + * `workflowId` identifies the targeted execution when the failing call knows + * it (start/execute/signalWithStart options, a handle's bound execution); + * 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", @@ -161,19 +171,21 @@ export class WorkflowValidationError extends TaggedError( workflowName: string; direction: "input" | "output"; issues: ReadonlyArray; + workflowId?: string | undefined; }> { constructor( workflowName: string, direction: "input" | "output", issues: ReadonlyArray, + workflowId?: string, ) { - super({ workflowName, direction, issues }); + super({ workflowName, direction, issues, workflowId }); this.message = `Validation failed for workflow "${workflowName}" ${direction}: ${summarizeIssues(issues)}`; } } /** - * Thrown when query input or output validation fails + * Surfaced on the Err channel when query input or output validation fails */ export class QueryValidationError extends TaggedError("@temporal-contract/QueryValidationError", { name: "QueryValidationError", @@ -193,7 +205,7 @@ export class QueryValidationError extends TaggedError("@temporal-contract/QueryV } /** - * Thrown when signal input validation fails + * Surfaced on the Err channel when signal input validation fails */ export class SignalValidationError extends TaggedError("@temporal-contract/SignalValidationError", { name: "SignalValidationError", @@ -208,7 +220,7 @@ export class SignalValidationError extends TaggedError("@temporal-contract/Signa } /** - * Thrown when update input or output validation fails + * Surfaced on the Err channel when update input or output validation fails */ export class UpdateValidationError extends TaggedError("@temporal-contract/UpdateValidationError", { name: "UpdateValidationError", @@ -226,3 +238,40 @@ export class UpdateValidationError extends TaggedError("@temporal-contract/Updat this.message = `Validation failed for update "${updateName}" ${direction}: ${summarizeIssues(issues)}`; } } + +/** + * 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" }, +)<{ + scheduleId: string; + cause?: unknown; +}> { + constructor(scheduleId: string, cause?: unknown) { + super({ scheduleId, cause }); + this.message = `Schedule "${scheduleId}" already exists (running, not deleted).`; + } +} + +/** + * Surfaced on the Err channel when a schedule-handle operation targets a + * schedule ID unknown to the Temporal server — Temporal's + * `ScheduleNotFoundError`. Either the ID is wrong or the schedule was + * deleted. + */ +export class ScheduleNotFoundError extends TaggedError("@temporal-contract/ScheduleNotFoundError", { + name: "ScheduleNotFoundError", +})<{ + scheduleId: string; + cause?: unknown; +}> { + constructor(scheduleId: string, cause?: unknown) { + super({ scheduleId, cause }); + this.message = `Schedule "${scheduleId}" not found on the Temporal server.`; + } +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index fa8ec747..ed039ea0 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,12 +1,16 @@ export { + ContractClient, readTypedSearchAttributes, TypedClient, - type CreateTypedClientOptions, + type CreateClientOptions, + type TypedGetHandleOptions, type TypedSearchAttributeMap, type TypedSignalWithStartOptions, + type TypedStartUpdateOptions, type TypedWorkflowHandle, type TypedWorkflowHandleWithSignaledRunId, type TypedWorkflowStartOptions, + type TypedWorkflowUpdateHandle, type WorkflowContractErrorsOf, } from "./client.js"; export type { @@ -15,8 +19,8 @@ export type { ClientInterceptorArgs, ClientInterceptorNext, } from "./interceptors.js"; -// Modeled creation failure — `TypedClient.create` surfaces it on the Err -// channel instead of throwing. +// Technical creation failure — `TypedClient.create` routes it to the Defect +// channel (as the defect's cause) instead of throwing. export { TechnicalError } from "@temporal-contract/contract/errors"; // Typed contract-error surface — a failed execution whose failure matches a // workflow's declared `errors` entry surfaces as a `ContractError` instead @@ -34,10 +38,12 @@ export { } from "./schedule.js"; export { RuntimeClientError, + ScheduleAlreadyExistsError, + ScheduleNotFoundError, WorkflowAlreadyStartedError, WorkflowExecutionNotFoundError, WorkflowFailedError, - WorkflowNotFoundError, + WorkflowNotInContractError, WorkflowValidationError, QueryValidationError, SignalValidationError, @@ -47,16 +53,10 @@ export type { TemporalFailure } from "./errors.js"; export type { ClientInferInput, ClientInferOutput, - ClientInferWorkflow, - ClientInferActivity, ClientInferSignal, ClientInferQuery, ClientInferUpdate, - ClientInferWorkflows, - ClientInferActivities, - ClientInferWorkflowActivities, ClientInferWorkflowSignals, ClientInferWorkflowQueries, ClientInferWorkflowUpdates, - ClientInferWorkflowContextActivities, } from "./types.js"; diff --git a/packages/client/src/interceptors.ts b/packages/client/src/interceptors.ts index 4015a0e6..266b0553 100644 --- a/packages/client/src/interceptors.ts +++ b/packages/client/src/interceptors.ts @@ -1,4 +1,3 @@ -import type { AnyContractError } from "@temporal-contract/contract/errors"; /** * Client-side interceptors — the trace-propagation / retry / observability * seam of the typed client, mirroring amqp-contract's @@ -10,12 +9,14 @@ import type { AnyContractError } from "@temporal-contract/contract/errors"; * * Semantics (first entry is the outermost): * - **observe** — call `next()` and inspect the returned `AsyncResult` - * (`tapErr`, `map`, …); + * (`tapErrCases`, `map`, …); * - **patch args** — `next({ input: ... })` shallow-merges the patch over the * current invocation before it reaches validation; - * - **retry** — call `next` again from a `flatMapErr` branch; + * - **retry** — call `next` again, e.g. from a `recoverDefect` branch for + * transient technical faults; * - **short-circuit** — return your own `AsyncResult` without calling `next`. */ +import type { AnyContractError } from "@temporal-contract/contract/errors"; import type { AsyncResult } from "unthrown"; import type { @@ -25,7 +26,7 @@ import type { WorkflowAlreadyStartedError, WorkflowExecutionNotFoundError, WorkflowFailedError, - WorkflowNotFoundError, + WorkflowNotInContractError, WorkflowValidationError, } from "./errors.js"; @@ -37,7 +38,7 @@ import type { * amqp-contract's `call()`). */ export type ClientCallError = - | WorkflowNotFoundError + | WorkflowNotInContractError | WorkflowValidationError | WorkflowAlreadyStartedError | WorkflowFailedError @@ -109,7 +110,7 @@ export type ClientInterceptorNext = (patch?: { * }); * ``` * - * Modeled domain errors (`WorkflowNotFoundError`, a `ContractError`, …) stay on + * Modeled domain errors (`WorkflowNotInContractError`, a `ContractError`, …) stay on * the `Err` channel — branch on those with `flatMapErrCases` / `match` as usual; * `recoverDefect` / `tapDefect` are for the technical faults on the defect channel. */ diff --git a/packages/client/src/internal.ts b/packages/client/src/internal.ts index bac6ee6a..4e372500 100644 --- a/packages/client/src/internal.ts +++ b/packages/client/src/internal.ts @@ -1,9 +1,3 @@ -import type { AnyWorkflowDefinition, SearchAttributeDefinition } from "@temporal-contract/contract"; -import { - _internal_rehydrateContractError, - type AnyContractError, -} from "@temporal-contract/contract/errors"; -import { _internal_makeAsyncResult } from "@temporal-contract/contract/result-async"; /** * Internal helpers shared across the client package's modules. * @@ -11,8 +5,22 @@ import { _internal_makeAsyncResult } from "@temporal-contract/contract/result-as * `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 { + AnyWorkflowDefinition, + SearchAttributeDefinition, + SearchAttributeKind, +} from "@temporal-contract/contract"; +import { + _internal_rehydrateContractError, + type AnyContractError, +} 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 { + ScheduleAlreadyRunning, + ScheduleNotFoundError as TemporalScheduleNotFoundError, +} from "@temporalio/client"; import { ApplicationFailure, defineSearchAttributeKey, @@ -28,12 +36,52 @@ import { type AsyncResult, type Result } from "unthrown"; export { _internal_assertNoDefect as assertNoDefect } from "@temporal-contract/contract/result-async"; import { RuntimeClientError, + ScheduleAlreadyExistsError, + ScheduleNotFoundError, type TemporalFailure, WorkflowAlreadyStartedError, WorkflowExecutionNotFoundError, WorkflowFailedError, } from "./errors.js"; +/** + * Runtime `typeof`-per-kind check for a search attribute value. The + * TypeScript surface already constrains values on the happy path; this + * catches typed escape hatches (`as never`, raw-call interop) where a + * mistyped value would otherwise be rejected server-side (or silently + * coerced) long after the call site. + */ +const searchAttributeValueChecks: Record< + SearchAttributeKind, + { expected: string; check: (value: unknown) => boolean } +> = { + TEXT: { expected: "a string", check: (v) => typeof v === "string" }, + KEYWORD: { expected: "a string", check: (v) => typeof v === "string" }, + INT: { + expected: "an integer number", + check: (v) => typeof v === "number" && Number.isInteger(v), + }, + 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 }, + KEYWORD_LIST: { + expected: "an array of strings", + check: (v) => Array.isArray(v) && v.every((entry) => typeof entry === "string"), + }, +}; + +/** + * 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. + */ +function describeRuntimeType(value: unknown): string { + if (value === null) return "null"; + if (Array.isArray(value)) return "an array"; + if (value instanceof Date) return "a Date"; + return `a ${typeof value}`; +} + /** * Translate the contract's typed `searchAttributes` map (declared * name → value) into a Temporal `TypedSearchAttributes` instance, so the @@ -43,7 +91,8 @@ import { * values) resolve to `undefined`, matching the Temporal SDK's * "absent ≠ empty" semantics. * - * **Throws** a {@link RuntimeClientError} on unknown keys — a *technical* + * **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 @@ -78,6 +127,16 @@ export function toTypedSearchAttributes( ), ); } + const { expected, check } = searchAttributeValueChecks[def.kind]; + if (!check(value)) { + throw new RuntimeClientError( + "searchAttributes", + new Error( + `Search attribute "${name}" on workflow "${workflowName}" is declared as ` + + `${def.kind} and must be ${expected}; received ${describeRuntimeType(value)}.`, + ), + ); + } const key = defineSearchAttributeKey(name, def.kind); pairs.push({ key, value } as SearchAttributePair); } @@ -190,3 +249,59 @@ export function classifyResultError( } return undefined; } + +/** + * 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. + */ +export async function classifyExecutionResultError( + workflowDef: AnyWorkflowDefinition, + error: unknown, + workflowId: string, +): Promise { + if (error instanceof TemporalWorkflowFailedError) { + const rehydrated = await rehydrateWorkflowContractError(workflowDef, error.cause); + if (rehydrated) return rehydrated; + } + return classifyResultError(error, workflowId); +} + +/** + * Recognize a thrown error from `client.schedule.create` as the modeled + * {@link ScheduleAlreadyExistsError} (Temporal's `ScheduleAlreadyRunning`). + * Returns `undefined` for anything else — an unrecognized, *technical* + * failure the caller routes to the defect channel with a + * {@link RuntimeClientError} cause. Mirrors {@link classifyStartError} on the + * workflow side. + */ +export function classifyScheduleCreateError( + error: unknown, + fallbackScheduleId: string, +): ScheduleAlreadyExistsError | undefined { + if (error instanceof ScheduleAlreadyRunning) { + return new ScheduleAlreadyExistsError(error.scheduleId || fallbackScheduleId, error); + } + return undefined; +} + +/** + * Recognize a thrown error from a schedule handle method (pause, unpause, + * trigger, update, backfill, delete, describe) as the modeled + * {@link ScheduleNotFoundError} (Temporal's error of the same name). Returns + * `undefined` for anything else — an unrecognized, *technical* failure the + * caller routes to the defect channel with a {@link RuntimeClientError} + * cause. Mirrors {@link classifyHandleError} on the workflow side. + */ +export function classifyScheduleHandleError( + error: unknown, + fallbackScheduleId: string, +): ScheduleNotFoundError | undefined { + if (error instanceof TemporalScheduleNotFoundError) { + return new ScheduleNotFoundError(error.scheduleId || fallbackScheduleId, error); + } + return undefined; +} diff --git a/packages/client/src/schedule.spec.ts b/packages/client/src/schedule.spec.ts index 0d5cf3db..81d65d80 100644 --- a/packages/client/src/schedule.spec.ts +++ b/packages/client/src/schedule.spec.ts @@ -10,8 +10,26 @@ import { TypedSearchAttributes } from "@temporalio/common"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; -import { TypedClient } from "./client.js"; -import { RuntimeClientError, WorkflowNotFoundError, WorkflowValidationError } from "./errors.js"; +import { type ContractClient, TypedClient } from "./client.js"; +import { + RuntimeClientError, + ScheduleAlreadyExistsError, + ScheduleNotFoundError, + WorkflowNotInContractError, + WorkflowValidationError, +} from "./errors.js"; + +/** + * Test construction helper: build the connection-scoped root and bind the + * contract in one step (`create`'s Err channel is `never`, so `.get()` + * unwraps directly). + */ +async function bindContract[0]>( + contract: TContract, + rawClient: Client, +): Promise> { + return (await TypedClient.create({ client: rawClient })).get().for(contract); +} const createMockHandle = () => ({ scheduleId: "daily-sweep", @@ -31,9 +49,42 @@ const mockSchedule = { list: vi.fn(), }; -vi.mock("@temporalio/client", () => ({ - WorkflowHandle: vi.fn(), -})); +// Constructable stand-ins for the Temporal error classes the typed client +// discriminates with `instanceof` (see client.spec.ts for the rationale). +vi.mock("@temporalio/client", () => { + class ScheduleAlreadyRunning extends Error { + constructor( + message: string, + public readonly scheduleId: string, + ) { + super(message); + } + } + class ScheduleNotFoundError extends Error { + constructor( + message: string, + public readonly scheduleId: string, + ) { + super(message); + } + } + class WorkflowExecutionAlreadyStartedError extends Error {} + class WorkflowFailedError extends Error {} + return { + WorkflowHandle: vi.fn(), + ScheduleAlreadyRunning, + ScheduleNotFoundError, + WorkflowExecutionAlreadyStartedError, + WorkflowFailedError, + }; +}); + +// Import AFTER the mock declaration so the stand-in classes are used both +// here (to construct rejection values) and inside the classify helpers. +const { + ScheduleAlreadyRunning: MockScheduleAlreadyRunning, + ScheduleNotFoundError: MockTemporalScheduleNotFoundError, +} = await import("@temporalio/client"); describe("TypedClient.schedule", () => { const contract = defineContract({ @@ -46,31 +97,35 @@ describe("TypedClient.schedule", () => { }, }); - let client: TypedClient; + let client: ContractClient; - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); const rawClient = { workflow: { start: vi.fn(), execute: vi.fn(), getHandle: vi.fn() }, schedule: mockSchedule, } as unknown as Client; - client = TypedClient.createOrThrow(contract, rawClient); + client = await bindContract(contract, rawClient); }); describe("@temporalio/client < 1.16 guard", () => { - it("throws a clear error when the underlying Client is missing `schedule`", () => { + it("TypedClient.create surfaces a missing `schedule` as a Defect with a clear message", async () => { // Simulates a consumer who installed @temporalio/client < 1.16 // (where the Schedule API didn't exist). The peer dep allows all of // ^1, so this is a supported install — it just shouldn't crash with a - // confusing `Cannot read properties of undefined`. + // confusing `Cannot read properties of undefined`. The check lives on + // the connection-scoped root (it's a property of the client, not of + // any contract). const oldClient = { workflow: { start: vi.fn(), execute: vi.fn(), getHandle: vi.fn() }, // schedule intentionally absent } as unknown as Client; - expect(() => TypedClient.createOrThrow(contract, oldClient)).toThrow( - /requires @temporalio\/client >= 1\.16/, - ); + const created = await TypedClient.create({ client: oldClient }); + expect(created).toBeDefect(); + if (created.isDefect()) { + expect((created.cause as Error).message).toMatch(/requires @temporalio\/client >= 1\.16/); + } }); }); @@ -103,7 +158,42 @@ describe("TypedClient.schedule", () => { ); }); - it("returns WorkflowNotFoundError when the workflow isn't declared", async () => { + it("transmits the ORIGINAL args, not the parsed value (D1 wire format)", async () => { + // Sender validates and discards the parsed result; the worker parses + // on receive. A transforming input schema makes the difference visible. + const transformContract = defineContract({ + taskQueue: "schedules-q", + workflows: { + transformer: defineWorkflow({ + input: z.string().transform((s) => s.length), + output: z.number(), + }), + }, + }); + const rawClient = { + workflow: { start: vi.fn(), execute: vi.fn(), getHandle: vi.fn() }, + schedule: mockSchedule, + } as unknown as Client; + const transformClient = await bindContract(transformContract, rawClient); + mockSchedule.create.mockResolvedValue(createMockHandle()); + + const result = await transformClient.schedule.create("transformer", { + scheduleId: "transform-sweep", + spec: { cronExpressions: ["0 2 * * *"] }, + args: "hello", + }); + + expect(result).toBeOk(); + expect(mockSchedule.create).toHaveBeenCalledWith( + expect.objectContaining({ + action: expect.objectContaining({ + args: ["hello"], // original string — not 5 (the parsed length) + }), + }), + ); + }); + + it("returns WorkflowNotInContractError when the workflow isn't declared", async () => { const result = await client.schedule.create( // @ts-expect-error testing runtime validation "nonExistent", @@ -116,7 +206,7 @@ describe("TypedClient.schedule", () => { expect(result).toBeErr(); if (result.isErr()) { - expect(result.error).toBeInstanceOf(WorkflowNotFoundError); + expect(result.error).toBeInstanceOf(WorkflowNotInContractError); } expect(mockSchedule.create).not.toHaveBeenCalled(); }); @@ -238,15 +328,15 @@ describe("TypedClient.schedule", () => { }, }); - let searchClient: TypedClient; + let searchClient: ContractClient; - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); const rawClient = { workflow: { start: vi.fn(), execute: vi.fn(), getHandle: vi.fn() }, schedule: mockSchedule, } as unknown as Client; - searchClient = TypedClient.createOrThrow(searchContract, rawClient); + searchClient = await bindContract(searchContract, rawClient); }); it("translates declared searchAttributes into the action's typedSearchAttributes", async () => { @@ -350,5 +440,116 @@ describe("TypedClient.schedule", () => { expect((result.value as { scheduleId: string }).scheduleId).toBe("daily-sweep"); } }); + + it("routes update through Temporal's ScheduleHandle.update", async () => { + const tempHandle = createMockHandle(); + tempHandle.update.mockResolvedValue(undefined); + mockSchedule.getHandle.mockReturnValue(tempHandle); + + const handle = client.schedule.getHandle("daily-sweep"); + const updateFn = (previous: { spec?: unknown }) => ({ spec: previous.spec ?? {} }); + const result = await handle.update(updateFn as never); + + expect(result).toBeOk(); + expect(tempHandle.update).toHaveBeenCalledWith(updateFn); + }); + + it("routes backfill through Temporal's ScheduleHandle.backfill", async () => { + const tempHandle = createMockHandle(); + tempHandle.backfill.mockResolvedValue(undefined); + mockSchedule.getHandle.mockReturnValue(tempHandle); + + const handle = client.schedule.getHandle("daily-sweep"); + const range = { + start: new Date("2026-01-01T00:00:00Z"), + end: new Date("2026-01-02T00:00:00Z"), + }; + const result = await handle.backfill(range); + + expect(result).toBeOk(); + expect(tempHandle.backfill).toHaveBeenCalledWith(range); + }); + }); + + describe("typed schedule errors (parity with the workflow side)", () => { + it("create surfaces ScheduleAlreadyExistsError on Temporal's ScheduleAlreadyRunning", async () => { + mockSchedule.create.mockRejectedValue( + new MockScheduleAlreadyRunning("already running", "daily-sweep"), + ); + + const result = await client.schedule.create("processOrder", { + scheduleId: "daily-sweep", + spec: { cronExpressions: ["0 2 * * *"] }, + args: { orderId: "sweep" }, + }); + + expect(result).toBeErr(); + if (result.isErr()) { + expect(result.error).toBeInstanceOf(ScheduleAlreadyExistsError); + const error = result.error as ScheduleAlreadyExistsError; + expect(error.scheduleId).toBe("daily-sweep"); + expect(error.cause).toBeInstanceOf(MockScheduleAlreadyRunning); + } + }); + + it("handle methods surface ScheduleNotFoundError on Temporal's ScheduleNotFoundError", async () => { + const tempHandle = { ...createMockHandle(), scheduleId: "missing" }; + tempHandle.pause.mockRejectedValue( + new MockTemporalScheduleNotFoundError("not found", "missing"), + ); + tempHandle.delete.mockRejectedValue(new MockTemporalScheduleNotFoundError("not found", "")); + mockSchedule.getHandle.mockReturnValue(tempHandle); + + const handle = client.schedule.getHandle("missing"); + + const paused = await handle.pause(); + expect(paused).toBeErr(); + if (paused.isErr()) { + expect(paused.error).toBeInstanceOf(ScheduleNotFoundError); + expect((paused.error as ScheduleNotFoundError).scheduleId).toBe("missing"); + } + + // Temporal normalizes a missing ID to the empty string; the handle's + // own scheduleId is the fallback so the error stays identifying. + const deleted = await handle.delete(); + expect(deleted).toBeErr(); + if (deleted.isErr()) { + expect((deleted.error as ScheduleNotFoundError).scheduleId).toBe("missing"); + } + }); + + it("unrecognized create failures still ride the defect channel", async () => { + mockSchedule.create.mockRejectedValue(new Error("temporal down")); + + const result = await client.schedule.create("processOrder", { + scheduleId: "daily-sweep", + spec: { cronExpressions: ["0 2 * * *"] }, + args: { orderId: "sweep" }, + }); + + expect(result).toBeDefect(); + if (result.isDefect()) { + expect(result.cause).toBeInstanceOf(RuntimeClientError); + } + }); + }); + + describe("list", () => { + it("is a typed async-iterable passthrough of ScheduleClient.list", async () => { + const summaries = [{ scheduleId: "a" }, { scheduleId: "b" }]; + mockSchedule.list.mockReturnValue( + (async function* () { + for (const summary of summaries) yield summary; + })(), + ); + + const seen: string[] = []; + for await (const summary of client.schedule.list({ pageSize: 10 })) { + seen.push(summary.scheduleId); + } + + expect(seen).toEqual(["a", "b"]); + expect(mockSchedule.list).toHaveBeenCalledWith({ pageSize: 10 }); + }); }); }); diff --git a/packages/client/src/schedule.ts b/packages/client/src/schedule.ts index 5d947811..163f1528 100644 --- a/packages/client/src/schedule.ts +++ b/packages/client/src/schedule.ts @@ -1,5 +1,7 @@ import type { ContractDefinition } from "@temporal-contract/contract"; import type { + Backfill, + ListScheduleOptions, ScheduleClient, ScheduleDescription, ScheduleHandle, @@ -7,12 +9,26 @@ import type { ScheduleOptionsStartWorkflowAction, ScheduleOverlapPolicy, ScheduleSpec, + ScheduleSummary, + ScheduleUpdateOptions, + Workflow, } from "@temporalio/client"; import { type AsyncResult, type Result, Ok, Err, fromPromise } from "unthrown"; import type { TypedSearchAttributeMap } from "./client.js"; -import { RuntimeClientError, WorkflowNotFoundError, WorkflowValidationError } from "./errors.js"; -import { makeAsyncResult, toTypedSearchAttributes } from "./internal.js"; +import { + RuntimeClientError, + type ScheduleAlreadyExistsError, + type ScheduleNotFoundError, + WorkflowNotInContractError, + WorkflowValidationError, +} from "./errors.js"; +import { + classifyScheduleCreateError, + classifyScheduleHandleError, + makeAsyncResult, + toTypedSearchAttributes, +} from "./internal.js"; import type { ClientInferInput } from "./types.js"; /** @@ -83,38 +99,63 @@ export type TypedScheduleCreateOptions< /** * Typed handle to a schedule. Mirrors Temporal's `ScheduleHandle` lifecycle - * methods (`pause`, `unpause`, `trigger`, `describe`, `delete`) wrapped in - * the unthrown AsyncResult pattern so call sites match the rest of the - * typed client. + * methods (`pause`, `unpause`, `trigger`, `update`, `backfill`, `describe`, + * `delete`) wrapped in the unthrown AsyncResult pattern so call sites match + * the rest of the typed client. + * + * Every method surfaces a missing schedule (Temporal's + * `ScheduleNotFoundError` — wrong ID, or the schedule was deleted) as the + * modeled {@link ScheduleNotFoundError} on the Err channel; any other + * failure is a *technical* fault routed to the Defect channel with a + * {@link RuntimeClientError} cause. */ export type TypedScheduleHandle = { /** This schedule's identifier. */ readonly scheduleId: string; + /** Pause the schedule. Optional note becomes part of the audit trail. */ + pause: (note?: string) => AsyncResult; + /** Resume a paused schedule. */ + unpause: (note?: string) => AsyncResult; + /** Fire the schedule's action immediately. */ + trigger: (overlap?: ScheduleOverlapPolicy) => AsyncResult; /** - * Pause the schedule. Optional note becomes part of the audit trail. + * Update the schedule definition: Temporal 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. * - * Returns `AsyncResult` — a failed schedule operation is a - * *technical* fault (an unknown schedule ID, a transport error, …), routed - * to the `Defect` channel with a {@link RuntimeClientError} cause rather - * than surfaced as a modeled `Err`. + * 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. */ - pause: (note?: string) => AsyncResult; - /** Resume a paused schedule. */ - unpause: (note?: string) => AsyncResult; - /** Fire the schedule's action immediately. */ - trigger: (overlap?: ScheduleOverlapPolicy) => AsyncResult; + update: ( + updateFn: ( + previous: ScheduleDescription, + ) => ScheduleUpdateOptions>, + ) => AsyncResult; + /** + * Run the schedule's action for historical time ranges, as if the + * schedule had been active over them. Passthrough of Temporal's + * `ScheduleHandle.backfill`. + */ + backfill: (options: Backfill | Backfill[]) => AsyncResult; /** Delete the schedule. */ - delete: () => AsyncResult; + delete: () => AsyncResult; /** Fetch the schedule's current description from the server. */ - describe: () => AsyncResult; + describe: () => AsyncResult; }; /** * Typed wrapper around Temporal's `ScheduleClient`. Exposed as - * `typedClient.schedule` — keeps the typed-client surface organized the + * `contractClient.schedule` — keeps the typed-client surface organized the * same way Temporal's own `Client.schedule` does. */ export class TypedScheduleClient { + /** + * Constructed exclusively by {@link ContractClient}'s constructor. Not + * part of the public API — reach it via `typedClient.for(contract).schedule`. + * + * @internal + */ constructor( private readonly contract: TContract, private readonly scheduleClient: ScheduleClient, @@ -125,20 +166,31 @@ export class TypedScheduleClient { * workflow with validated args. * * Validates `args` against the workflow's input schema before dispatching - * the create request to Temporal. The workflow's `taskQueue` and - * `workflowType` are pulled from the contract automatically; the typed - * options shape omits them so call sites don't have to repeat themselves. + * the create request to Temporal — but transmits the caller's ORIGINAL + * args (the worker parses them when each scheduled run starts, so a + * transforming schema applies exactly once, on the receiving side). The + * workflow's `taskQueue` and `workflowType` are pulled from the contract + * automatically; the typed options shape omits them so call sites don't + * have to repeat themselves. + * + * A colliding running schedule (same `scheduleId`, not deleted) surfaces + * as {@link ScheduleAlreadyExistsError} on the Err channel. */ create( workflowName: TWorkflowName, options: TypedScheduleCreateOptions, - ): AsyncResult { + ): AsyncResult< + TypedScheduleHandle, + WorkflowNotInContractError | WorkflowValidationError | ScheduleAlreadyExistsError + > { type Ok = TypedScheduleHandle; - type Err = WorkflowNotFoundError | WorkflowValidationError; + type Err = WorkflowNotInContractError | WorkflowValidationError | ScheduleAlreadyExistsError; const work = async (): Promise> => { const definition = this.contract.workflows[workflowName]; if (!definition) { - return Err(new WorkflowNotFoundError(workflowName, Object.keys(this.contract.workflows))); + return Err( + new WorkflowNotInContractError(workflowName, Object.keys(this.contract.workflows)), + ); } const inputResult = await definition.input["~standard"].validate(options.args); @@ -166,7 +218,9 @@ export class TypedScheduleClient { type: "startWorkflow", workflowType: workflowName, taskQueue: this.contract.taskQueue, - args: [inputResult.value] as never, + // Original args on the wire — validated above, parsed by the + // worker when each run starts (D1). + args: [options.args] as never, ...(typedSearchAttributes ? { typedSearchAttributes } : {}), ...(overrides.workflowId !== undefined ? { workflowId: overrides.workflowId } : {}), ...(overrides.workflowExecutionTimeout !== undefined @@ -198,7 +252,9 @@ export class TypedScheduleClient { }); return Ok(wrapScheduleHandle(handle)); } catch (error) { - // Technical failure creating the schedule — route to the defect channel. + const alreadyExists = classifyScheduleCreateError(error, options.scheduleId); + if (alreadyExists) return Err(alreadyExists); + // Unrecognized, technical failure — route to the defect channel. throw new RuntimeClientError("schedule.create", error); } }; @@ -207,36 +263,77 @@ export class TypedScheduleClient { /** * Get a typed handle to an existing schedule. Does not validate that the - * schedule exists — handle methods (`describe`, `pause`, etc.) will - * surface a `RuntimeClientError` if the underlying ID is unknown. + * schedule exists — handle methods (`describe`, `pause`, etc.) surface a + * {@link ScheduleNotFoundError} if the underlying ID is unknown. */ getHandle(scheduleId: string): TypedScheduleHandle { return wrapScheduleHandle(this.scheduleClient.getHandle(scheduleId)); } + + /** + * List schedules in the namespace — a typed async-iterable passthrough of + * Temporal's `ScheduleClient.list`. Not filtered to this contract: + * Temporal's visibility API lists every schedule the namespace knows + * about (use a `query` option to narrow server-side). + */ + list(options?: ListScheduleOptions): AsyncIterable { + return this.scheduleClient.list(options); + } } function wrapScheduleHandle(handle: ScheduleHandle): TypedScheduleHandle { + // Every lifecycle method shares the classify-or-defect tail: a missing + // schedule is the modeled Err; anything else rides the defect channel. return { scheduleId: handle.scheduleId, pause: (note) => - fromPromise(handle.pause(note), (error, defect) => - defect(new RuntimeClientError("schedule.pause", error)), + fromPromise( + handle.pause(note), + (error, defect) => + classifyScheduleHandleError(error, handle.scheduleId) ?? + defect(new RuntimeClientError("schedule.pause", error)), ).map(() => undefined), unpause: (note) => - fromPromise(handle.unpause(note), (error, defect) => - defect(new RuntimeClientError("schedule.unpause", error)), + fromPromise( + handle.unpause(note), + (error, defect) => + classifyScheduleHandleError(error, handle.scheduleId) ?? + defect(new RuntimeClientError("schedule.unpause", error)), ).map(() => undefined), trigger: (overlap) => - fromPromise(handle.trigger(overlap), (error, defect) => - defect(new RuntimeClientError("schedule.trigger", error)), + fromPromise( + handle.trigger(overlap), + (error, defect) => + classifyScheduleHandleError(error, handle.scheduleId) ?? + defect(new RuntimeClientError("schedule.trigger", error)), + ).map(() => undefined), + update: (updateFn) => + fromPromise( + handle.update(updateFn), + (error, defect) => + classifyScheduleHandleError(error, handle.scheduleId) ?? + defect(new RuntimeClientError("schedule.update", error)), + ).map(() => undefined), + backfill: (options) => + fromPromise( + handle.backfill(options), + (error, defect) => + classifyScheduleHandleError(error, handle.scheduleId) ?? + defect(new RuntimeClientError("schedule.backfill", error)), ).map(() => undefined), delete: () => - fromPromise(handle.delete(), (error, defect) => - defect(new RuntimeClientError("schedule.delete", error)), + fromPromise( + handle.delete(), + (error, defect) => + classifyScheduleHandleError(error, handle.scheduleId) ?? + defect(new RuntimeClientError("schedule.delete", error)), ).map(() => undefined), describe: () => - fromPromise(handle.describe(), (error, defect) => - defect(new RuntimeClientError("schedule.describe", error)), + fromPromise( + handle.describe(), + (error, defect) => + classifyScheduleHandleError(error, handle.scheduleId) ?? + defect(new RuntimeClientError("schedule.describe", error)), ), }; } diff --git a/packages/client/src/types-inference.spec.ts b/packages/client/src/types-inference.spec.ts index 33dff89b..69e02889 100644 --- a/packages/client/src/types-inference.spec.ts +++ b/packages/client/src/types-inference.spec.ts @@ -1,26 +1,47 @@ -import { defineContract, defineSignal, defineWorkflow } from "@temporal-contract/contract"; /** * Type-level tests for the typed-client surface. * - * These tests pin the generic-preservation and name-narrowing behaviour - * required by audit findings #2 and #3: + * Three groups of pins: * - * - Workflow input/output generics are preserved through the contract so - * `client.startWorkflow("…", { args })` infers the correct argument - * shape instead of widening to `unknown`. - * - Signal-name constraints narrow to the workflow's declared signals - * (or `never`), so a typo like `signalName: "typo"` on a workflow - * without signals is a compile-time error rather than a runtime - * failure. + * 1. Generic preservation and name narrowing (audit findings #2 and #3): + * workflow input/output generics survive the contract so + * `client.startWorkflow("…", { args })` infers the correct argument + * shape, and signal names narrow to the workflow's declared signals. + * 2. The `TypedClient`/`ContractClient` split: `for(c)` constrains names to + * `c`'s workflows, two contracts yield mutually incompatible + * `ContractClient` types, and `TypedClient` accepts no type argument — + * so a stray 7.x-style `TypedClient` fails loudly during + * migration instead of resolving silently. + * 3. Method-level pins: error-union narrowing, the `searchAttributes` key + * constraint, and omittable payloads for input-less + * signals/queries/updates. * - * Each `expectTypeOf(...)` call's assertion is purely compile-time; the - * outer `it(...)` blocks merely cause the type-checker to visit this - * file. + * Each `expectTypeOf(...)` / `@ts-expect-error` assertion is purely + * compile-time; call-shaped assertions live inside never-invoked functions + * so nothing hits a real connection at runtime. */ +import { + defineContract, + defineQuery, + defineSearchAttribute, + defineSignal, + defineUpdate, + defineWorkflow, +} from "@temporal-contract/contract"; import { describe, expectTypeOf, it } from "vitest"; import { z } from "zod"; -import type { TypedSignalWithStartOptions, TypedWorkflowStartOptions } from "./client.js"; +import type { + ContractClient, + TypedClient, + TypedSignalWithStartOptions, + TypedWorkflowStartOptions, +} from "./client.js"; +import type { + WorkflowAlreadyStartedError, + WorkflowNotInContractError, + WorkflowValidationError, +} from "./errors.js"; const contractWithSignal = defineContract({ taskQueue: "q", @@ -45,6 +66,34 @@ const contractNoSignals = defineContract({ }, }); +const richContract = defineContract({ + taskQueue: "rich-q", + workflows: { + processOrder: defineWorkflow({ + input: z.object({ orderId: z.string() }), + output: z.object({ status: z.string() }), + signals: { + // Payload-less signal — `defineSignal()` materializes an + // UndefinedInputSchema, so the client-side payload is omittable. + stop: defineSignal(), + setPriority: defineSignal({ input: z.object({ level: z.number() }) }), + }, + queries: { + progress: defineQuery({ output: z.number() }), + itemStatus: defineQuery({ input: z.object({ sku: z.string() }), output: z.string() }), + }, + updates: { + refresh: defineUpdate({ output: z.boolean() }), + adjust: defineUpdate({ input: z.object({ delta: z.number() }), output: z.number() }), + }, + searchAttributes: { + customerId: defineSearchAttribute({ kind: "KEYWORD" }), + priority: defineSearchAttribute({ kind: "INT" }), + }, + }), + }, +}); + describe("startWorkflow argument inference (audit fix #2)", () => { it("infers `args` to the workflow's input schema (not unknown)", () => { type Options = TypedWorkflowStartOptions; @@ -78,3 +127,182 @@ describe("signalWithStart name narrowing (audit fix #3)", () => { expectTypeOf().toEqualTypeOf(); }); }); + +describe("TypedClient/ContractClient split", () => { + it("TypedClient accepts no type argument — a stray 7.x-style annotation fails loudly", () => { + // @ts-expect-error — TypedClient is not generic anymore; the contract + // type parameter lives on ContractClient. + type Stray = TypedClient; + const _stray: Stray | undefined = undefined; + void _stray; + }); + + it("for(c) constrains startWorkflow to c's workflow names", () => { + // Never invoked — the body only exercises the type-checker. + const _pin = async (root: TypedClient) => { + const bound = root.for(contractWithSignal); + expectTypeOf(bound.startWorkflow).parameter(0).toEqualTypeOf<"hasSignal">(); + + // @ts-expect-error — "bare" belongs to the OTHER contract. + void bound.startWorkflow("bare", { workflowId: "x", args: { a: "s" } }); + + // @ts-expect-error — typos are compile-time errors. + void bound.startWorkflow("hasSignalTypo", { workflowId: "x", args: { a: "s" } }); + }; + void _pin; + }); + + it("two different contracts yield mutually incompatible ContractClient types", () => { + type A = ContractClient; + type B = ContractClient; + expectTypeOf().not.toEqualTypeOf(); + expectTypeOf().not.toMatchTypeOf(); + expectTypeOf().not.toMatchTypeOf(); + }); + + it("for() preserves the contract's type through to the binding", () => { + const _pin = (root: TypedClient) => { + const bound = root.for(contractNoSignals); + expectTypeOf(bound).toEqualTypeOf>(); + }; + void _pin; + }); +}); + +describe("method-level pins", () => { + it("executeWorkflow narrows the error union to the modeled client errors", () => { + const _pin = async (bound: ContractClient) => { + const result = await bound.executeWorkflow("bare", { + workflowId: "x", + args: { a: "s" }, + }); + if (result.isOk()) { + expectTypeOf(result.value).toEqualTypeOf(); + } + if (result.isErr()) { + // `bare` declares no contract errors, so the union is exactly the + // client-side kinds — no ContractError member, no widening to Error. + expectTypeOf(result.error).toMatchTypeOf< + | WorkflowNotInContractError + | WorkflowValidationError + | WorkflowAlreadyStartedError + | { workflowId: string } + >(); + expectTypeOf(result.error).not.toBeAny(); + expectTypeOf<(typeof result)["error"]>().not.toEqualTypeOf(); + } + }; + void _pin; + }); + + it("startWorkflow narrows the error union to start-phase errors only", () => { + const _pin = async (bound: ContractClient) => { + const result = await bound.startWorkflow("bare", { workflowId: "x", args: { a: "s" } }); + if (result.isErr()) { + expectTypeOf(result.error).toEqualTypeOf< + WorkflowNotInContractError | WorkflowValidationError | WorkflowAlreadyStartedError + >(); + } + }; + void _pin; + }); + + it("searchAttributes keys are constrained to the declared attributes", () => { + const _pin = (bound: ContractClient) => { + void bound.startWorkflow("processOrder", { + workflowId: "x", + args: { orderId: "o" }, + searchAttributes: { customerId: "c", priority: 1 }, + }); + + void bound.startWorkflow("processOrder", { + workflowId: "x", + args: { orderId: "o" }, + // @ts-expect-error — `unknownAttr` isn't declared on processOrder. + searchAttributes: { unknownAttr: "nope" }, + }); + + void bound.startWorkflow("processOrder", { + workflowId: "x", + args: { orderId: "o" }, + // @ts-expect-error — INT attribute values must be numbers. + searchAttributes: { priority: "high" }, + }); + }; + void _pin; + }); + + it("input-less signal/query/update payloads are omittable on the typed handle", () => { + const _pin = async (bound: ContractClient) => { + const handleResult = bound.getHandle("processOrder", "id-1"); + if (!handleResult.isOk()) return; + const handle = handleResult.value; + + // Payload-less definitions: the argument can be omitted entirely. + void handle.signals.stop(); + void handle.queries.progress(); + void handle.updates.refresh(); + void handle.startUpdate("refresh"); + + // Definitions WITH an input still require their payload. + // @ts-expect-error — setPriority requires { level: number }. + void handle.signals.setPriority(); + // @ts-expect-error — itemStatus requires { sku: string }. + void handle.queries.itemStatus(); + // @ts-expect-error — adjust requires { delta: number }. + void handle.updates.adjust(); + // @ts-expect-error — adjust's startUpdate options require args. + void handle.startUpdate("adjust"); + // @ts-expect-error — adjust's startUpdate args are non-optional. + void handle.startUpdate("adjust", {}); + + void handle.signals.setPriority({ level: 2 }); + void handle.startUpdate("adjust", { args: { delta: 1 } }); + }; + void _pin; + }); + + it("payload-less signals are omittable through signalWithStart", () => { + const _pin = (bound: ContractClient) => { + // `stop` takes no payload — `signalArgs` can be omitted. + void bound.signalWithStart("processOrder", { + workflowId: "x", + args: { orderId: "o" }, + signalName: "stop", + }); + + void bound.signalWithStart("processOrder", { + workflowId: "x", + args: { orderId: "o" }, + signalName: "setPriority", + signalArgs: { level: 1 }, + }); + + // @ts-expect-error — setPriority's signalArgs are required. + void bound.signalWithStart("processOrder", { + workflowId: "x", + args: { orderId: "o" }, + signalName: "setPriority", + }); + }; + void _pin; + }); + + it("getHandle is synchronous and Err-narrows to WorkflowNotInContractError", () => { + const _pin = (bound: ContractClient) => { + const handleResult = bound.getHandle("processOrder", "id-1", { + runId: "run-1", + firstExecutionRunId: "run-0", + followRuns: true, + }); + if (handleResult.isErr()) { + expectTypeOf(handleResult.error).toEqualTypeOf(); + } + if (handleResult.isOk()) { + expectTypeOf(handleResult.value.runId).toEqualTypeOf(); + expectTypeOf(handleResult.value.firstExecutionRunId).toEqualTypeOf(); + } + }; + void _pin; + }); +}); diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index c24eca39..5a37ab06 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -1,10 +1,8 @@ import type { - ActivityDefinition, - AnyWorkflowDefinition, SignalDefinition, QueryDefinition, UpdateDefinition, - ContractDefinition, + AnyWorkflowDefinition, } from "@temporal-contract/contract"; import type { AsyncResult } from "unthrown"; @@ -17,80 +15,49 @@ import type { ClientInferInput, ClientInferOutput } from "@temporal-contract/con /** * CLIENT PERSPECTIVE - * Client sends z.output and receives z.input - */ - -/** - * Infer workflow function signature from client perspective - * Client sends z.output and receives z.input - */ -export type ClientInferWorkflow = ( - args: ClientInferInput, -) => Promise>; - -/** - * Infer activity function signature from client perspective - * Client sends z.output and receives z.input + * + * The client sits on the *sending* side of the input boundary and the + * *receiving* side of the output boundary: it sends a schema's input type + * (`z.input`, pre-transform — the worker parses on receive) and receives + * the output type (`z.output`, post-transform — the client parses results + * on receive). */ -export type ClientInferActivity = ( - args: ClientInferInput, -) => Promise>; /** - * Infer signal handler signature from client perspective - * Client sends z.output and returns AsyncResult + * Infer signal handler signature from client perspective. + * Client sends the signal input type; the payload argument is omittable + * when the schema accepts `undefined` (e.g. payload-less `defineSignal()`). */ export type ClientInferSignal = ( - args: ClientInferInput, + ...args: undefined extends ClientInferInput + ? [input?: ClientInferInput] + : [input: ClientInferInput] ) => AsyncResult; /** - * Infer query handler signature from client perspective - * Client sends z.output and receives z.input wrapped in AsyncResult + * Infer query handler signature from client perspective. + * Client sends the query input type and receives the output type wrapped in + * `AsyncResult`; the payload argument is omittable when the schema + * accepts `undefined` (e.g. argument-less `defineQuery({ output })`). */ export type ClientInferQuery = ( - args: ClientInferInput, + ...args: undefined extends ClientInferInput + ? [input?: ClientInferInput] + : [input: ClientInferInput] ) => AsyncResult, Error>; /** - * Infer update handler signature from client perspective - * Client sends z.output and receives z.input wrapped in AsyncResult + * Infer update handler signature from client perspective. + * Client sends the update input type and receives the output type wrapped in + * `AsyncResult`; the payload argument is omittable when the schema + * accepts `undefined` (e.g. argument-less `defineUpdate({ output })`). */ export type ClientInferUpdate = ( - args: ClientInferInput, + ...args: undefined extends ClientInferInput + ? [input?: ClientInferInput] + : [input: ClientInferInput] ) => AsyncResult, Error>; -/** - * CLIENT PERSPECTIVE - Contract-level types - */ - -/** - * Infer all workflows from a contract (client perspective) - */ -export type ClientInferWorkflows = { - [K in keyof TContract["workflows"]]: ClientInferWorkflow; -}; - -/** - * Infer all activities from a contract (client perspective) - */ -export type ClientInferActivities = - TContract["activities"] extends Record - ? { - [K in keyof TContract["activities"]]: ClientInferActivity; - } - : {}; - -/** - * Infer activities from a workflow definition (client perspective) - */ -export type ClientInferWorkflowActivities = - T["activities"] extends Record - ? { - [K in keyof T["activities"]]: ClientInferActivity; - } - : {}; - /** * Infer signals from a workflow definition (client perspective) */ @@ -99,7 +66,7 @@ export type ClientInferWorkflowSignals = ? { [K in keyof T["signals"]]: ClientInferSignal; } - : {}; + : Record; /** * Infer queries from a workflow definition (client perspective) @@ -109,7 +76,7 @@ export type ClientInferWorkflowQueries = ? { [K in keyof T["queries"]]: ClientInferQuery; } - : {}; + : Record; /** * Infer updates from a workflow definition (client perspective) @@ -119,14 +86,4 @@ export type ClientInferWorkflowUpdates = ? { [K in keyof T["updates"]]: ClientInferUpdate; } - : {}; - -/** - * Infer all activities available in a workflow context (client perspective) - * Combines workflow-specific activities with global activities - */ -export type ClientInferWorkflowContextActivities< - TContract extends ContractDefinition, - TWorkflowName extends keyof TContract["workflows"] & string, -> = ClientInferWorkflowActivities & - ClientInferActivities; + : Record; diff --git a/packages/contract/package.json b/packages/contract/package.json index 67aac17b..1cb349b2 100644 --- a/packages/contract/package.json +++ b/packages/contract/package.json @@ -23,68 +23,50 @@ ], "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" }, "./errors": { - "import": { - "types": "./dist/errors.d.mts", - "default": "./dist/errors.mjs" - }, - "require": { - "types": "./dist/errors.d.cts", - "default": "./dist/errors.cjs" - } + "types": "./dist/errors.d.mts", + "import": "./dist/errors.mjs" }, "./result-async": { - "import": { - "types": "./dist/result-async.d.mts", - "default": "./dist/result-async.mjs" - }, - "require": { - "types": "./dist/result-async.d.cts", - "default": "./dist/result-async.cjs" - } + "types": "./dist/result-async.d.mts", + "import": "./dist/result-async.mjs" }, "./package.json": "./package.json" }, "scripts": { - "build": "tsdown src/index.ts src/errors.ts src/result-async.ts --format cjs,esm --dts --clean", + "build": "tsdown src/index.ts src/errors.ts src/result-async.ts --format esm --dts --clean", "build:docs": "typedoc", - "dev": "tsdown src/index.ts src/errors.ts src/result-async.ts --format cjs,esm --dts --watch", + "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", "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit" }, "dependencies": { - "@standard-schema/spec": "catalog:", - "zod": "catalog:" + "@standard-schema/spec": "catalog:" }, "devDependencies": { + "@arethetypeswrong/cli": "catalog:", "@btravstack/tsconfig": "catalog:", "@btravstack/typedoc": "catalog:", "@types/node": "catalog:", "@unthrown/vitest": "catalog:", "@vitest/coverage-v8": "catalog:", "arktype": "catalog:", + "publint": "catalog:", "tsdown": "catalog:", "typedoc": "catalog:", "typedoc-plugin-markdown": "catalog:", "typescript": "catalog:", "unthrown": "catalog:", "valibot": "catalog:", - "vitest": "catalog:" + "vitest": "catalog:", + "zod": "catalog:" }, "peerDependencies": { "unthrown": "^5.0.0" diff --git a/packages/contract/src/builder.spec.ts b/packages/contract/src/builder.spec.ts index 78387802..e1d57930 100644 --- a/packages/contract/src/builder.spec.ts +++ b/packages/contract/src/builder.spec.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { defineContract } from "./builder.js"; +import { + defineActivity, + defineContract, + defineQuery, + defineSignal, + defineUpdate, + defineWorkflow, +} from "./builder.js"; describe("Contract Builder", () => { describe("defineContract", () => { @@ -404,7 +411,7 @@ describe("Contract Builder", () => { }, }), ).toThrow( - 'workflow "processOrder" has activity "sendEmail" that conflicts with a global activity. Consider renaming the workflow-specific activity or removing the global activity "sendEmail".', + 'workflow "processOrder" has activity "sendEmail" that conflicts with a different global activity of the same name. Activities share a single flat namespace at runtime — reference the shared definition from the contract\'s global "activities" block, or rename one of them.', ); }); @@ -436,10 +443,129 @@ describe("Contract Builder", () => { }, }), ).toThrow( - 'workflow "processRefund" has activity "charge" that conflicts with the same-named activity in workflow "processOrder". Activities share a single flat namespace at runtime — rename one of them.', + 'workflow "processRefund" has activity "charge" 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.', ); }); + it("should allow the same activity object to be shared by two workflows", () => { + const charge = defineActivity({ + input: z.object({ amount: z.number() }), + output: z.object({ transactionId: z.string() }), + }); + + expect(() => + defineContract({ + taskQueue: "test", + workflows: { + processOrder: { + input: z.object({}), + output: z.object({}), + activities: { charge }, + }, + processRefund: { + input: z.object({}), + output: z.object({}), + activities: { charge }, + }, + }, + }), + ).not.toThrow(); + }); + + it("should allow a workflow to reference the same object as a global activity", () => { + const sendEmail = defineActivity({ + input: z.object({ to: z.string() }), + output: z.object({ sent: z.boolean() }), + }); + + expect(() => + defineContract({ + taskQueue: "test", + workflows: { + processOrder: { + input: z.object({}), + output: z.object({}), + activities: { sendEmail }, + }, + }, + activities: { sendEmail }, + }), + ).not.toThrow(); + }); + + it("should throw when a global activity has the same name as a workflow", () => { + expect(() => + defineContract({ + taskQueue: "test", + workflows: { + processOrder: { + input: z.object({}), + output: z.object({}), + }, + }, + activities: { + processOrder: { + input: z.object({}), + output: z.object({}), + }, + }, + }), + ).toThrow( + 'global activity "processOrder" has the same name as a workflow. Workflows and global activities share the root of the worker implementations map — rename one of them.', + ); + }); + + it("should throw when a workflow has the same name as a global activity", () => { + // Same collision, declared the other way around: the activity name + // comes first alphabetically and the workflow map holds several keys. + expect(() => + defineContract({ + taskQueue: "test", + workflows: { + aWorkflow: { + input: z.object({}), + output: z.object({}), + }, + sendEmail: { + input: z.object({}), + output: z.object({}), + }, + }, + activities: { + sendEmail: { + input: z.object({}), + output: z.object({}), + }, + }, + }), + ).toThrow( + 'global activity "sendEmail" has the same name as a workflow. Workflows and global activities share the root of the worker implementations map — rename one of them.', + ); + }); + + it("should allow a workflow-local activity to share a workflow's name", () => { + // Workflow-local activity implementations nest under their owning + // workflow in the worker implementations map, so they never collide + // with workflow names at the root. + expect(() => + defineContract({ + taskQueue: "test", + workflows: { + processOrder: { + input: z.object({}), + output: z.object({}), + activities: { + processOrder: { + input: z.object({}), + output: z.object({}), + }, + }, + }, + }, + }), + ).not.toThrow(); + }); + it("should not misclassify a workflow named 'global' as the global activity scope", () => { // A workflow can legally be named "global" — the collision detector must // not confuse it with the global activity scope sentinel. @@ -470,7 +596,7 @@ describe("Contract Builder", () => { }, }), ).toThrow( - 'workflow "other" has activity "send" that conflicts with the same-named activity in workflow "global". Activities share a single flat namespace at runtime — rename one of them.', + 'workflow "other" has activity "send" that conflicts with a different same-named activity in workflow "global". Activities share a single flat namespace at runtime — hoist the shared activity to the contract\'s global "activities" block, or rename one of them.', ); }); @@ -597,6 +723,43 @@ describe("Contract Builder", () => { }), ).not.toThrow(); }); + + it("should throw on an unknown top-level key (strict root)", () => { + expect(() => + defineContract({ + taskQueue: "test", + workflows: { + test: { + input: z.object({}), + output: z.object({}), + }, + }, + // Deliberate typo of `activities` — TypeScript's generic inference + // absorbs the extra key, which is exactly why the runtime root + // check is strict. + activites: {}, + }), + ).toThrow( + 'contract has unknown key "activites" — allowed keys are "taskQueue", "workflows", "activities"', + ); + }); + + it("should accept input-less signal/query/update definitions from the helpers", () => { + expect(() => + defineContract({ + taskQueue: "test", + workflows: { + wf: defineWorkflow({ + input: z.object({}), + output: z.object({}), + signals: { shutdown: defineSignal() }, + queries: { getStatus: defineQuery({ output: z.string() }) }, + updates: { bump: defineUpdate({ output: z.number() }) }, + }), + }, + }), + ).not.toThrow(); + }); }); describe("Edge Cases", () => { diff --git a/packages/contract/src/builder.ts b/packages/contract/src/builder.ts index 9dea273c..8af19bdd 100644 --- a/packages/contract/src/builder.ts +++ b/packages/contract/src/builder.ts @@ -1,14 +1,15 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; -import { z } from "zod"; import type { ActivityDefinition, + AnySchema, AnyWorkflowDefinition, ContractDefinition, QueryDefinition, SearchAttributeDefinition, SearchAttributeKind, SignalDefinition, + UndefinedInputSchema, UpdateDefinition, } from "./types.js"; @@ -75,6 +76,10 @@ export function defineActivity( * @param definition - The signal definition containing input schema * @returns The same definition with preserved types for type inference * + * `input` may be omitted for payload-less signals: the definition then + * carries a materialized schema whose validated value is always `undefined`, + * so the handler input infers as `undefined` — no `z.void()` ceremony. + * * @example * ```typescript * import { defineSignal } from '@temporal-contract/contract'; @@ -86,10 +91,19 @@ export function defineActivity( * approvedBy: z.string(), * }), * }); + * + * // Payload-less signal — the handler input is `undefined`. + * export const shutdown = defineSignal(); * ``` */ -export function defineSignal(definition: TSignal): TSignal { - return definition; +export function defineSignal(definition: TSignal): TSignal; +export function defineSignal(definition?: { + input?: undefined; +}): SignalDefinition; +export function defineSignal( + definition?: SignalDefinition | { input?: undefined }, +): SignalDefinition { + return definition?.input ? (definition as SignalDefinition) : { input: undefinedInputSchema }; } /** @@ -110,6 +124,10 @@ export function defineSignal(definition: TSign * @param definition - The query definition containing input and output schemas * @returns The same definition with preserved types for type inference * + * `input` may be omitted for argument-less queries: the definition then + * carries a materialized schema whose validated value is always `undefined`, + * so the handler input infers as `undefined` — no `z.void()` ceremony. + * * @example * ```typescript * import { defineQuery } from '@temporal-contract/contract'; @@ -122,10 +140,24 @@ export function defineSignal(definition: TSign * updatedAt: z.date(), * }), * }); + * + * // Argument-less query — the handler input is `undefined`. + * export const getProgress = defineQuery({ + * output: z.object({ percent: z.number() }), + * }); * ``` */ -export function defineQuery(definition: TQuery): TQuery { - return definition; +export function defineQuery(definition: TQuery): TQuery; +export function defineQuery(definition: { + input?: undefined; + output: TOutput; +}): QueryDefinition; +export function defineQuery( + definition: QueryDefinition | { input?: undefined; output: AnySchema }, +): QueryDefinition { + return definition.input + ? (definition as QueryDefinition) + : { input: undefinedInputSchema, output: definition.output }; } /** @@ -139,6 +171,10 @@ export function defineQuery(definition: TQuery): * @param definition - The update definition containing input and output schemas * @returns The same definition with preserved types for type inference * + * `input` may be omitted for argument-less updates: the definition then + * carries a materialized schema whose validated value is always `undefined`, + * so the handler input infers as `undefined` — no `z.void()` ceremony. + * * @example * ```typescript * import { defineUpdate } from '@temporal-contract/contract'; @@ -154,10 +190,24 @@ export function defineQuery(definition: TQuery): * totalPrice: z.number(), * }), * }); + * + * // Argument-less update — the handler input is `undefined`. + * export const restock = defineUpdate({ + * output: z.object({ restocked: z.boolean() }), + * }); * ``` */ -export function defineUpdate(definition: TUpdate): TUpdate { - return definition; +export function defineUpdate(definition: TUpdate): TUpdate; +export function defineUpdate(definition: { + input?: undefined; + output: TOutput; +}): UpdateDefinition; +export function defineUpdate( + definition: UpdateDefinition | { input?: undefined; output: AnySchema }, +): UpdateDefinition { + return definition.input + ? (definition as UpdateDefinition) + : { input: undefinedInputSchema, output: definition.output }; } /** @@ -254,8 +304,11 @@ 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 - * - No conflicts between global and workflow-specific activities + * - No ambiguous name collisions between workflows, global activities, and + * workflow-specific activities (referencing the *same* activity definition + * object from several scopes is allowed) * - All schemas implement the Standard Schema specification * * @template TContract - The contract definition type @@ -303,14 +356,8 @@ export function defineWorkflow( export function defineContract( definition: TContract, ): TContract { - // Validate entire contract structure with Zod (including activity conflicts) - const validationResult = contractValidationSchema.safeParse(definition); - - if (!validationResult.success) { - const cleanMessage = getCleanErrorMessage(validationResult.error); - throw new Error(`Contract validation failed: ${cleanMessage}`); - } - + // Validate the entire contract structure (including name collisions) + validateContractDefinition(definition); return definition; } @@ -338,227 +385,425 @@ function isStandardSchema(value: unknown): value is StandardSchemaV1 { } /** - * Schema for validating JavaScript identifiers (workflow names, activity names, etc.) - * Allows: letters, digits, underscore, dollar sign - * Must start with: letter, underscore, or dollar sign + * The materialized `input` schema for input-less signal/query/update + * definitions (see {@link UndefinedInputSchema}). Accepts only an absent + * payload — `undefined`, plus `null` defensively, because JSON-based payload + * converters cannot represent `undefined` and may round-trip it as `null` — + * and always yields `undefined`. + */ +const undefinedInputSchema: UndefinedInputSchema = { + "~standard": { + version: 1, + vendor: "temporal-contract", + validate: (value: unknown): StandardSchemaV1.Result => + value === undefined || value === null + ? { value: undefined } + : { + issues: [{ message: "expected no payload (this definition declares no input schema)" }], + }, + }, +}; + +/* + * STRUCTURAL CONTRACT VALIDATION + * + * Hand-rolled over `unknown` rather than delegated to a schema library: + * TypeScript already rejects wrong shapes for typed callers, but a JavaScript + * caller (or a cast) can pass misspelled keys or non-schema values that would + * otherwise be silently ignored until a worker or client trips over them. + * Validation is first-failure-wins: every helper throws a single-line + * `Contract validation failed: …` error naming the offending path. */ -const identifierSchema = z - .string() - .min(1) - .regex(/^[a-zA-Z_$][a-zA-Z0-9_$]*$/, "must be a valid JavaScript identifier"); /** - * Extract a clean, single-line error message from a Zod validation error. - * - * Uses `error.issues` directly (compatible with Zod v4+) rather than parsing - * `error.message` as JSON, which was a Zod v3 implementation detail. + * Contract names (workflow, activity, signal, query, update, search + * attribute, and error names) must be valid JavaScript identifiers — they + * become property accesses on typed maps. + * Allows: letters, digits, underscore, dollar sign + * Must start with: letter, underscore, or dollar sign */ -function getCleanErrorMessage(error: z.ZodError): string { - const issues = error.issues; - if (!issues || issues.length === 0) { - return error.message; - } +const IDENTIFIER_PATTERN = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; - const firstIssue = issues[0]; - if (!firstIssue) { - return error.message; - } +/** The seven Temporal search attribute kinds (see {@link SearchAttributeKind}). */ +const SEARCH_ATTRIBUTE_KINDS: readonly string[] = [ + "TEXT", + "KEYWORD", + "INT", + "DOUBLE", + "BOOL", + "DATETIME", + "KEYWORD_LIST", +]; + +const DEFAULT_OPTIONS_KEYS = [ + "startToCloseTimeout", + "scheduleToCloseTimeout", + "scheduleToStartTimeout", + "heartbeatTimeout", + "retry", +] as const; + +const DEFAULT_OPTIONS_DURATION_KEYS = [ + "startToCloseTimeout", + "scheduleToCloseTimeout", + "scheduleToStartTimeout", + "heartbeatTimeout", +] as const; + +const RETRY_KEYS = [ + "initialInterval", + "maximumInterval", + "backoffCoefficient", + "maximumAttempts", + "nonRetryableErrorTypes", +] as const; + +/** Throw the canonical single-line contract validation error. */ +function fail(detail: string): never { + throw new Error(`Contract validation failed: ${detail}`); +} - // For record key validation errors (invalid_key), surface the nested issue message - if ( - firstIssue.code === "invalid_key" && - "issues" in firstIssue && - Array.isArray((firstIssue as { issues?: unknown[] }).issues) && - (firstIssue as { issues: { message?: string }[] }).issues.length > 0 - ) { - const nestedMessage = (firstIssue as { issues: { message?: string }[] }).issues[0]?.message; - if (nestedMessage) { - return nestedMessage; - } - } +/** Plain-object check — `null` and arrays don't qualify as definition maps. */ +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} - return firstIssue.message ?? error.message; +function assertIdentifier(kind: string, name: string): void { + if (!IDENTIFIER_PATTERN.test(name)) { + fail(`${kind} name "${name}" must be a valid JavaScript identifier`); + } } /** - * Schema for validating a single `errors` map entry. The `data` schema is - * optional; when present it must be Standard Schema compatible like every - * other schema slot on the contract. + * Reject unknown keys on a strict bag, listing both the offending and the + * allowed keys so a typo is a one-glance fix. */ -const errorDefinitionSchema = z.object({ - data: z - .custom((val) => isStandardSchema(val), { - message: "data must be a Standard Schema compatible schema (e.g., Zod, Valibot, ArkType)", - }) - .optional(), - message: z.string().optional(), - nonRetryable: z.boolean().optional(), -}); +function assertKnownKeys( + context: string, + bag: Record, + allowed: readonly string[], +): void { + const unknown = Object.keys(bag).filter((key) => !allowed.includes(key)); + if (unknown.length > 0) { + const offending = unknown.map((key) => `"${key}"`).join(", "); + const expected = allowed.map((key) => `"${key}"`).join(", "); + fail( + `${context} has unknown key${unknown.length > 1 ? "s" : ""} ${offending} — allowed keys are ${expected}`, + ); + } +} -/** - * Schema for a Temporal duration value (`ms`-formatted string or number of - * milliseconds). - */ -const durationValueSchema = z.union([z.string(), z.number()]); +function assertSchema(context: string, slot: string, value: unknown): void { + if (!isStandardSchema(value)) { + fail( + `${context}: ${slot} must be a Standard Schema compatible schema (e.g., Zod, Valibot, ArkType)`, + ); + } +} /** - * Schema for validating contract-level activity `defaultOptions`. Strict - * objects so a typo (`startToCloseTimeOut`) fails at `defineContract` time - * instead of being silently ignored when the worker merges options. + * A Temporal duration value: an `ms`-formatted string or a number of + * milliseconds. `undefined` (absent) is allowed — every duration slot on + * `defaultOptions` is optional. */ -const activityDefaultOptionsSchema = z.strictObject({ - startToCloseTimeout: durationValueSchema.optional(), - scheduleToCloseTimeout: durationValueSchema.optional(), - scheduleToStartTimeout: durationValueSchema.optional(), - heartbeatTimeout: durationValueSchema.optional(), - retry: z - .strictObject({ - initialInterval: durationValueSchema.optional(), - maximumInterval: durationValueSchema.optional(), - backoffCoefficient: z.number().optional(), - maximumAttempts: z.number().optional(), - nonRetryableErrorTypes: z.array(z.string()).optional(), - }) - .optional(), -}); +function assertDuration(context: string, key: string, value: unknown): void { + if (value !== undefined && typeof value !== "string" && typeof value !== "number") { + fail(`${context}: ${key} must be an ms-formatted string or a number of milliseconds`); + } +} /** - * Schema for validating activity definitions - * Checks that input and output are Standard Schema compatible schemas + * Validate an `errors` map: identifier keys, and per entry an optional + * Standard Schema `data` (like every other schema slot on the contract), + * string `message`, and boolean `nonRetryable`. */ -const activityDefinitionSchema = z.object({ - input: z.custom((val) => isStandardSchema(val), { - message: "input must be a Standard Schema compatible schema (e.g., Zod, Valibot, ArkType)", - }), - output: z.custom((val) => isStandardSchema(val), { - message: "output must be a Standard Schema compatible schema (e.g., Zod, Valibot, ArkType)", - }), - errors: z.record(identifierSchema, errorDefinitionSchema).optional(), - defaultOptions: activityDefaultOptionsSchema.optional(), -}); +function validateErrorsMap(context: string, errors: unknown): void { + if (!isRecord(errors)) { + fail(`${context}: errors must be an object`); + } + for (const [errorName, definition] of Object.entries(errors)) { + assertIdentifier("error", errorName); + const errorContext = `${context} error "${errorName}"`; + if (!isRecord(definition)) { + fail(`${errorContext} must be an object`); + } + if (definition["data"] !== undefined) { + assertSchema(errorContext, "data", definition["data"]); + } + if (definition["message"] !== undefined && typeof definition["message"] !== "string") { + fail(`${errorContext}: message must be a string`); + } + if ( + definition["nonRetryable"] !== undefined && + typeof definition["nonRetryable"] !== "boolean" + ) { + fail(`${errorContext}: nonRetryable must be a boolean`); + } + } +} /** - * Schema for validating signal definitions + * Validate contract-level activity `defaultOptions`. Strict keys so a typo + * (`startToCloseTimeOut`) fails at `defineContract` time instead of being + * silently ignored when the worker merges options. */ -const signalDefinitionSchema = z.object({ - input: z.custom((val) => isStandardSchema(val), { - message: "input must be a Standard Schema compatible schema (e.g., Zod, Valibot, ArkType)", - }), -}); +function validateDefaultOptions(context: string, options: unknown): void { + if (!isRecord(options)) { + fail(`${context}: defaultOptions 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]); + } + + const retry = options["retry"]; + if (retry === undefined) return; + if (!isRecord(retry)) { + fail(`${context}: defaultOptions.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"]); + 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`); + } + } + const nonRetryableErrorTypes = retry["nonRetryableErrorTypes"]; + if ( + nonRetryableErrorTypes !== undefined && + (!Array.isArray(nonRetryableErrorTypes) || + nonRetryableErrorTypes.some((entry) => typeof entry !== "string")) + ) { + fail(`${context}: defaultOptions.retry.nonRetryableErrorTypes must be an array of strings`); + } +} /** - * Schema for validating query definitions + * Validate an activity definition: Standard Schema `input`/`output`, plus + * optional `errors` and `defaultOptions`. */ -const queryDefinitionSchema = z.object({ - input: z.custom((val) => isStandardSchema(val), { - message: "input must be a Standard Schema compatible schema (e.g., Zod, Valibot, ArkType)", - }), - output: z.custom((val) => isStandardSchema(val), { - message: "output must be a Standard Schema compatible schema (e.g., Zod, Valibot, ArkType)", - }), -}); +function validateActivityDefinition(context: string, definition: unknown): void { + if (!isRecord(definition)) { + fail(`${context} must be an object`); + } + assertSchema(context, "input", definition["input"]); + assertSchema(context, "output", definition["output"]); + if (definition["errors"] !== undefined) { + validateErrorsMap(context, definition["errors"]); + } + if (definition["defaultOptions"] !== undefined) { + validateDefaultOptions(context, definition["defaultOptions"]); + } +} /** - * Schema for validating update definitions + * Validate a signal (`input` only), query, or update (`input` + `output`) + * definition. `defineSignal`/`defineQuery`/`defineUpdate` materialize + * {@link undefinedInputSchema} when `input` is omitted, so by the time a + * definition reaches `defineContract` its `input` slot is always present. */ -const updateDefinitionSchema = z.object({ - input: z.custom((val) => isStandardSchema(val), { - message: "input must be a Standard Schema compatible schema (e.g., Zod, Valibot, ArkType)", - }), - output: z.custom((val) => isStandardSchema(val), { - message: "output must be a Standard Schema compatible schema (e.g., Zod, Valibot, ArkType)", - }), -}); +function validateMessageDefinition(context: string, definition: unknown, hasOutput: boolean): void { + if (!isRecord(definition)) { + fail(`${context} must be an object`); + } + assertSchema(context, "input", definition["input"]); + if (hasOutput) { + assertSchema(context, "output", definition["output"]); + } +} + +/** Validate a search attribute definition: `kind` must be one of the seven Temporal kinds. */ +function validateSearchAttributeDefinition(context: string, definition: unknown): void { + if (!isRecord(definition)) { + fail(`${context} must be an object`); + } + const kind = definition["kind"]; + if (typeof kind !== "string" || !SEARCH_ATTRIBUTE_KINDS.includes(kind)) { + const expected = SEARCH_ATTRIBUTE_KINDS.map((entry) => `"${entry}"`).join(", "); + fail(`${context}: kind must be one of ${expected}`); + } +} /** - * Schema for validating search attribute definitions + * Walk an optional `Record` slot of a workflow: the slot + * must be a plain object, every key a valid identifier, every value valid + * per `validateEntry`. */ -const searchAttributeKindSchema = z.enum([ - "TEXT", - "KEYWORD", - "INT", - "DOUBLE", - "BOOL", - "DATETIME", - "KEYWORD_LIST", -]); - -const searchAttributeDefinitionSchema = z.object({ - kind: searchAttributeKindSchema, -}); +function validateDefinitionMap( + context: string, + slot: string, + kind: string, + map: unknown, + validateEntry: (entryContext: string, definition: unknown) => void, +): void { + if (map === undefined) return; + if (!isRecord(map)) { + fail(`${context}: ${slot} must be an object`); + } + for (const [name, definition] of Object.entries(map)) { + assertIdentifier(kind, name); + validateEntry(`${context} ${kind} "${name}"`, definition); + } +} /** - * Schema for validating workflow definitions + * Validate a workflow definition: Standard Schema `input`/`output`, plus the + * optional activities/signals/queries/updates/searchAttributes/errors maps. */ -const workflowDefinitionSchema = z.object({ - input: z.custom((val) => isStandardSchema(val), { - message: "input must be a Standard Schema compatible schema (e.g., Zod, Valibot, ArkType)", - }), - output: z.custom((val) => isStandardSchema(val), { - message: "output must be a Standard Schema compatible schema (e.g., Zod, Valibot, ArkType)", - }), - activities: z.record(identifierSchema, activityDefinitionSchema).optional(), - signals: z.record(identifierSchema, signalDefinitionSchema).optional(), - queries: z.record(identifierSchema, queryDefinitionSchema).optional(), - updates: z.record(identifierSchema, updateDefinitionSchema).optional(), - searchAttributes: z.record(identifierSchema, searchAttributeDefinitionSchema).optional(), - errors: z.record(identifierSchema, errorDefinitionSchema).optional(), -}); +function validateWorkflowDefinition(context: string, definition: unknown): void { + if (!isRecord(definition)) { + fail(`${context} must be an object`); + } + assertSchema(context, "input", definition["input"]); + assertSchema(context, "output", definition["output"]); + validateDefinitionMap( + context, + "activities", + "activity", + definition["activities"], + validateActivityDefinition, + ); + validateDefinitionMap( + context, + "signals", + "signal", + definition["signals"], + (entryContext, entry) => validateMessageDefinition(entryContext, entry, false), + ); + validateDefinitionMap(context, "queries", "query", definition["queries"], (entryContext, entry) => + validateMessageDefinition(entryContext, entry, true), + ); + validateDefinitionMap( + context, + "updates", + "update", + definition["updates"], + (entryContext, entry) => validateMessageDefinition(entryContext, entry, true), + ); + validateDefinitionMap( + context, + "searchAttributes", + "search attribute", + definition["searchAttributes"], + validateSearchAttributeDefinition, + ); + if (definition["errors"] !== undefined) { + validateErrorsMap(context, definition["errors"]); + } +} /** - * Schema for validating a contract definition structure + * Cross-cutting name-collision checks that the per-definition walk can't see. + * + * 1. Workflow names vs **global** activity names: at the worker, workflow + * implementations and global activity implementations share the root of + * the same implementations map, so a shared name is ambiguous. + * 2. Activity names across scopes: activities are registered in a single + * flat namespace at runtime, so a duplicate name silently clobbers + * another — unless every scope references the *same* definition object + * (a shared `defineActivity` result), which flattens unambiguously and + * is therefore allowed. */ -const contractValidationSchema = z - .object({ - taskQueue: z.string().trim().min(1, "taskQueue cannot be empty"), - workflows: z - .record(identifierSchema, workflowDefinitionSchema) - .refine((workflows) => Object.keys(workflows).length > 0, { - message: "at least one workflow is required", - }), - activities: z.record(identifierSchema, activityDefinitionSchema).optional(), - }) - .superRefine((contract, ctx) => { - // Activities are registered in a single flat namespace at runtime, so any - // duplicate name silently clobbers another. Catch all collisions here: - // 1. workflow-specific vs. global, and - // 2. workflow-specific vs. other workflow-specific. - // - // The global owner is tracked with a Symbol rather than a sentinel string - // because workflow names are only validated as JS identifiers — a user - // could legitimately name a workflow "global", and a string sentinel would - // misclassify those collisions. - const GLOBAL_OWNER: unique symbol = Symbol("global"); - type Owner = string | typeof GLOBAL_OWNER; - const owners = new Map(); - - if (contract.activities) { - for (const activityName of Object.keys(contract.activities)) { - owners.set(activityName, GLOBAL_OWNER); +function validateNameCollisions( + workflows: Record, + globalActivities: Record | undefined, +): void { + if (globalActivities) { + for (const activityName of Object.keys(globalActivities)) { + if (Object.hasOwn(workflows, activityName)) { + fail( + `global activity "${activityName}" has the same name as a workflow. Workflows and global activities share the root of the worker implementations map — rename one of them.`, + ); } } + } + + // The global owner is tracked with a Symbol rather than a sentinel string + // because workflow names are only validated as JS identifiers — a user + // could legitimately name a workflow "global", and a string sentinel would + // misclassify those collisions. + const GLOBAL_OWNER: unique symbol = Symbol("global"); + type Owner = { name: string | typeof GLOBAL_OWNER; definition: unknown }; + const owners = new Map(); + + if (globalActivities) { + for (const [activityName, definition] of Object.entries(globalActivities)) { + owners.set(activityName, { name: GLOBAL_OWNER, definition }); + } + } - for (const [workflowName, workflow] of Object.entries(contract.workflows)) { - if (!workflow.activities) { + for (const [workflowName, workflow] of Object.entries(workflows)) { + // Structural validation already ran, so a present `activities` slot is a + // plain object of definitions. + const workflowActivities = (workflow as { activities?: unknown }).activities; + if (!isRecord(workflowActivities)) { + continue; + } + for (const [activityName, definition] of Object.entries(workflowActivities)) { + const previousOwner = owners.get(activityName); + if (!previousOwner) { + owners.set(activityName, { name: workflowName, definition }); continue; } - for (const activityName of Object.keys(workflow.activities)) { - const previousOwner = owners.get(activityName); - if (previousOwner === GLOBAL_OWNER) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `workflow "${workflowName}" has activity "${activityName}" that conflicts with a global activity. Consider renaming the workflow-specific activity or removing the global activity "${activityName}".`, - }); - continue; - } - if (typeof previousOwner === "string") { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `workflow "${workflowName}" has activity "${activityName}" that conflicts with the same-named activity in workflow "${previousOwner}". Activities share a single flat namespace at runtime — rename one of them.`, - }); - continue; - } - owners.set(activityName, workflowName); + if (previousOwner.definition === definition) { + // Same definition object in both scopes — the flat namespace stays + // unambiguous, so sharing one `defineActivity` result is allowed. + continue; + } + if (previousOwner.name === GLOBAL_OWNER) { + fail( + `workflow "${workflowName}" has activity "${activityName}" that conflicts with a different global activity of the same name. Activities share a single flat namespace at runtime — reference the shared definition from the contract's global "activities" block, or rename one of them.`, + ); } + fail( + `workflow "${workflowName}" has activity "${activityName}" that conflicts with a different same-named activity in workflow "${previousOwner.name}". Activities share a single flat namespace at runtime — hoist the shared activity to the contract's global "activities" block, or rename one of them.`, + ); } - }); + } +} + +/** + * 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. + */ +function validateContractDefinition(definition: unknown): void { + if (!isRecord(definition)) { + fail("contract must be an object"); + } + assertKnownKeys("contract", definition, ["taskQueue", "workflows", "activities"]); + + const taskQueue = definition["taskQueue"]; + if (typeof taskQueue !== "string") { + fail("taskQueue must be a string"); + } + if (taskQueue.trim().length === 0) { + fail("taskQueue cannot be empty"); + } + + const workflows = definition["workflows"]; + 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); + } + + const activities = definition["activities"]; + if (activities !== undefined && !isRecord(activities)) { + fail("activities must be an object"); + } + if (activities) { + for (const [activityName, activity] of Object.entries(activities)) { + assertIdentifier("global activity", activityName); + validateActivityDefinition(`global activity "${activityName}"`, activity); + } + } + + validateNameCollisions(workflows, activities); +} diff --git a/packages/contract/src/errors.ts b/packages/contract/src/errors.ts index 925664aa..4f1f046a 100644 --- a/packages/contract/src/errors.ts +++ b/packages/contract/src/errors.ts @@ -77,8 +77,9 @@ export class ContractError exten data: TData; cause?: unknown; }> { - // unthrown 4 reserves `message` (and `name`) in the TaggedError payload; - // accept it as a constructor argument and assign it post-`super` instead. + // 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); diff --git a/packages/contract/src/helpers.spec.ts b/packages/contract/src/helpers.spec.ts index 4e4f624b..bc0e0f96 100644 --- a/packages/contract/src/helpers.spec.ts +++ b/packages/contract/src/helpers.spec.ts @@ -50,6 +50,26 @@ describe("Helper Functions", () => { }), ); }); + + it("should materialize an undefined-input schema when input is omitted", async () => { + const signal = defineSignal(); + + expect(signal.input["~standard"].vendor).toBe("temporal-contract"); + expect(await signal.input["~standard"].validate(undefined)).toEqual({ value: undefined }); + // `null` is accepted defensively — JSON payload converters cannot + // represent `undefined` and may round-trip it as `null`. + expect(await signal.input["~standard"].validate(null)).toEqual({ value: undefined }); + + const rejected = await signal.input["~standard"].validate({ some: "payload" }); + expect(rejected.issues).toBeDefined(); + }); + + it("should treat an empty definition object like an omitted input", async () => { + const signal = defineSignal({}); + + expect(signal.input["~standard"].vendor).toBe("temporal-contract"); + expect(await signal.input["~standard"].validate(undefined)).toEqual({ value: undefined }); + }); }); describe("defineQuery", () => { @@ -80,6 +100,19 @@ describe("Helper Functions", () => { }), ); }); + + it("should materialize an undefined-input schema when input is omitted", async () => { + const query = defineQuery({ + output: z.object({ count: z.number() }), + }); + + expect(query.input["~standard"].vendor).toBe("temporal-contract"); + expect(query.output["~standard"].vendor).toBe("zod"); + expect(await query.input["~standard"].validate(undefined)).toEqual({ value: undefined }); + + const rejected = await query.input["~standard"].validate("unexpected"); + expect(rejected.issues).toBeDefined(); + }); }); describe("defineUpdate", () => { @@ -96,6 +129,18 @@ describe("Helper Functions", () => { }), ); }); + + it("should materialize an undefined-input schema when input is omitted", async () => { + const update = defineUpdate({ + output: z.object({ restocked: z.boolean() }), + }); + + expect(update.input["~standard"].vendor).toBe("temporal-contract"); + expect(await update.input["~standard"].validate(undefined)).toEqual({ value: undefined }); + + const rejected = await update.input["~standard"].validate(42); + expect(rejected.issues).toBeDefined(); + }); }); describe("defineWorkflow", () => { diff --git a/packages/contract/src/index.ts b/packages/contract/src/index.ts index 8c2290ad..f52f9876 100644 --- a/packages/contract/src/index.ts +++ b/packages/contract/src/index.ts @@ -12,6 +12,7 @@ export { formatIssue, summarizeIssues } from "./format.js"; export type { AnySchema, + UndefinedInputSchema, ActivityDefinition, SignalDefinition, QueryDefinition, @@ -35,7 +36,6 @@ export type { // Contract utility types InferWorkflowNames, InferActivityNames, - InferContractWorkflows, // Direction-aware schema inference primitives (shared by worker + client) WorkerInferInput, WorkerInferOutput, diff --git a/packages/contract/src/types-inference.spec.ts b/packages/contract/src/types-inference.spec.ts index a1d16acb..b6b4d14c 100644 --- a/packages/contract/src/types-inference.spec.ts +++ b/packages/contract/src/types-inference.spec.ts @@ -18,13 +18,15 @@ import { defineWorkflow, } from "./builder.js"; import type { + ClientInferInput, + ClientInferOutput, InferActivityNames, - InferContractWorkflows, InferWorkflowNames, QueryNamesOf, SearchAttributeKindToType, SignalNamesOf, UpdateNamesOf, + WorkerInferInput, } from "./types.js"; const contract = defineContract({ @@ -75,14 +77,6 @@ describe("contract inference utilities", () => { expectTypeOf>().toEqualTypeOf(); }); - it("InferContractWorkflows preserves workflow definitions", () => { - type Workflows = InferContractWorkflows; - expectTypeOf().toEqualTypeOf<"processOrder" | "sendNotification">(); - expectTypeOf().toEqualTypeOf< - (typeof contract)["workflows"]["processOrder"]["input"] - >(); - }); - it("defineActivity preserves the literal schema types", () => { const charge = defineActivity({ input: z.object({ amount: z.number() }), @@ -249,3 +243,42 @@ describe("Signal/query/update name helpers (audit fix #3)", () => { expectTypeOf>().toEqualTypeOf<"bump">(); }); }); + +describe("input-less signal/query/update definitions", () => { + it("defineSignal() infers the handler input as undefined on both faces", () => { + const shutdown = defineSignal(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + it("defineSignal({}) behaves like defineSignal()", () => { + const shutdown = defineSignal({}); + expectTypeOf>().toEqualTypeOf(); + }); + + it("defineQuery without input infers input undefined and keeps the output type", () => { + const getStatus = defineQuery({ output: z.string() }); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + it("defineUpdate without input infers input undefined and keeps the output type", () => { + const bump = defineUpdate({ output: z.number() }); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + it("input-less definitions still satisfy the workflow definition constraints", () => { + const wf = defineWorkflow({ + input: z.object({}), + output: z.object({}), + signals: { shutdown: defineSignal() }, + queries: { getStatus: defineQuery({ output: z.string() }) }, + updates: { bump: defineUpdate({ output: z.number() }) }, + }); + expectTypeOf>().toEqualTypeOf<"shutdown">(); + expectTypeOf>().toEqualTypeOf<"getStatus">(); + expectTypeOf>().toEqualTypeOf<"bump">(); + }); +}); diff --git a/packages/contract/src/types.spec.ts b/packages/contract/src/types.spec.ts index 3041ed2e..ac51fd7b 100644 --- a/packages/contract/src/types.spec.ts +++ b/packages/contract/src/types.spec.ts @@ -10,7 +10,6 @@ import type { UpdateDefinition, InferWorkflowNames, InferActivityNames, - InferContractWorkflows, } from "./types.js"; describe("Core Types", () => { @@ -283,7 +282,7 @@ describe("Core Types", () => { }, } satisfies ContractDefinition; - type Workflows = InferContractWorkflows; + type Workflows = (typeof contract)["workflows"]; const workflows: Workflows = contract.workflows; expect(workflows).toEqual( diff --git a/packages/contract/src/types.ts b/packages/contract/src/types.ts index f966b446..7eb2ff0c 100644 --- a/packages/contract/src/types.ts +++ b/packages/contract/src/types.ts @@ -7,6 +7,16 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; */ export type AnySchema = StandardSchemaV1; +/** + * The Standard Schema type materialized by `defineSignal` / `defineQuery` / + * `defineUpdate` when their `input` is omitted: validation only accepts an + * absent payload and always yields `undefined`. Because both type faces are + * `undefined`, handler inputs infer as `undefined` on the worker side, and + * `undefined extends ClientInferInput` lets the client detect + * payload-less sends at the type level. + */ +export type UndefinedInputSchema = StandardSchemaV1; + /** * Definition of a typed domain error on an activity or workflow. * @@ -387,13 +397,3 @@ export type InferActivityNames = TContract["activities"] extends Record ? keyof TContract["activities"] & string : never; - -/** - * Extract all workflows from a contract with their definitions - * - * @example - * ```typescript - * type MyWorkflows = InferContractWorkflows; - * ``` - */ -export type InferContractWorkflows = TContract["workflows"]; diff --git a/packages/testing/README.md b/packages/testing/README.md index 7f5c9742..052c51a1 100644 --- a/packages/testing/README.md +++ b/packages/testing/README.md @@ -10,11 +10,13 @@ pnpm add -D @temporal-contract/testing ``` +The package declares peer dependencies on the other three `@temporal-contract/*` packages (contract, client, worker — the contract-aware fixtures hand you a `TypedClient` and run a worker, so they must resolve to _your_ copies), plus `@temporalio/client`, `@temporalio/testing`, `@temporalio/worker`, `unthrown` (`^5`), and `vitest` (`^4`). Make sure they are installed alongside it — see [Install temporal-contract](https://btravstack.github.io/temporal-contract/how-to/install) for the full matrix. + ## Quick Example ### Global Setup -Configure Vitest to start a Temporal server before all tests: +Configure Vitest to start a Temporal server (via testcontainers — requires Docker) before all tests: ```typescript // vitest.config.ts @@ -28,18 +30,89 @@ export default defineConfig({ }); ``` -### Test Extension +To pin container images, inject extra Temporal env, or silence the progress logs, reference your own module that default-exports `createGlobalSetup(options)`: + +```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, +}); +``` + +### Contract-Aware Fixtures + +`createContractTest` wires the whole stack for one contract — a running worker, the connection-scoped `TypedClient` root, and the contract-bound `ContractClient`: + +```typescript +// order.spec.ts +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(orderContract, { + workflowsPath: workflowsPathFromURL(import.meta.url, "./order.workflows.js"), + activities, // omit for a workflow-only worker +}); + +describe("order processing", () => { + it("processes an order", async ({ client, typedClient, worker }) => { + const result = await client.executeWorkflow("processOrder", { + workflowId: `order-${Date.now()}`, + args: { orderId: "ORD-1" }, + }); + expect(result.isOk()).toBe(true); + }); +}); +``` + +### Unit-Testing a Single Activity + +`runActivity` executes one `AsyncResult`-returning activity implementation inside `@temporalio/testing`'s `MockActivityEnvironment` — no worker, no server, no Docker. Pass your own environment to observe heartbeats or trigger cancellation: + +```typescript +import { runActivity } from "@temporal-contract/testing/activity"; + +const result = await runActivity(chargeCardDefinition, chargeCard, { amount: 100 }); +expect(result.isOk()).toBe(true); +``` + +### Time-Skipping Environment (no Docker) + +The `./time-skipping` entry runs suites against Temporal's in-process time-skipping test server (downloaded and cached by `@temporalio/testing` on first use) — hour-long timers resolve instantly: + +```typescript +import { it } from "@temporal-contract/testing/time-skipping"; +// or pin the server version: +// const it = createTimeSkippingTest({ server: { executable: { type: "cached-download", version: "v1.3.0" } } }); + +it("processes the order", async ({ testEnv }) => { + // testEnv.client, testEnv.nativeConnection, worker.runUntil(...) +}); +``` + +`createTimeSkippingEnvironment(options?)` creates the environment directly for suites preferring explicit `beforeAll`/`afterAll` management. + +### Connection Fixtures -Use the `it` extension for automatic connection management: +Use the `it` extension for automatic connection management against the testcontainers server: ```typescript // my-workflow.spec.ts import { it } from "@temporal-contract/testing/extension"; +import { Client } from "@temporalio/client"; import { expect } from "vitest"; it("should execute workflow", async ({ clientConnection, workerConnection }) => { // clientConnection: Connection from @temporalio/client (auto-connected, auto-closed) - // workerConnection: NativeConnection from @temporalio/worker (auto-connected) + // workerConnection: NativeConnection from @temporalio/worker (auto-connected, auto-closed) const client = new Client({ connection: clientConnection }); // ... use client and workerConnection in your test diff --git a/packages/testing/package.json b/packages/testing/package.json index 5fdd993a..6b511591 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -27,6 +27,14 @@ "type": "module", "sideEffects": false, "exports": { + "./activity": { + "types": "./dist/activity.d.mts", + "import": "./dist/activity.mjs" + }, + "./contract": { + "types": "./dist/contract.d.mts", + "import": "./dist/contract.mjs" + }, "./extension": { "types": "./dist/extension.d.mts", "import": "./dist/extension.mjs" @@ -35,39 +43,51 @@ "types": "./dist/global-setup.d.mts", "import": "./dist/global-setup.mjs" }, + "./package.json": "./package.json", "./time-skipping": { "types": "./dist/time-skipping.d.mts", "import": "./dist/time-skipping.mjs" } }, "scripts": { - "build": "tsdown src/global-setup.ts src/extension.ts src/time-skipping.ts --format esm --dts --clean", + "build": "tsdown src/global-setup.ts src/extension.ts src/time-skipping.ts src/contract.ts src/activity.ts --format esm --dts --clean", "build:docs": "typedoc", - "test": "vitest run", - "test:watch": "vitest", + "check:package": "publint --strict && attw --pack . --profile esm-only", + "test": "vitest run --project unit", + "test:integration": "vitest run --project integration", + "test:watch": "vitest --project unit", "typecheck": "tsc --noEmit" }, "dependencies": { "testcontainers": "catalog:" }, "devDependencies": { + "@arethetypeswrong/cli": "catalog:", "@btravstack/tsconfig": "catalog:", "@btravstack/typedoc": "catalog:", "@temporalio/client": "catalog:", "@temporalio/testing": "catalog:", "@temporalio/worker": "catalog:", + "@temporalio/workflow": "catalog:", "@types/node": "catalog:", "@vitest/coverage-v8": "catalog:", + "publint": "catalog:", "tsdown": "catalog:", "typedoc": "catalog:", "typedoc-plugin-markdown": "catalog:", "typescript": "catalog:", - "vitest": "catalog:" + "unthrown": "catalog:", + "vitest": "catalog:", + "zod": "catalog:" }, "peerDependencies": { + "@temporal-contract/client": "^8.0.0-beta.3", + "@temporal-contract/contract": "^8.0.0-beta.3", + "@temporal-contract/worker": "^8.0.0-beta.3", "@temporalio/client": "^1", "@temporalio/testing": "^1", "@temporalio/worker": "^1", + "unthrown": "^5.0.0", "vitest": "^4" }, "engines": { diff --git a/packages/testing/src/__tests__/contract-test.spec.ts b/packages/testing/src/__tests__/contract-test.spec.ts new file mode 100644 index 00000000..69e42070 --- /dev/null +++ b/packages/testing/src/__tests__/contract-test.spec.ts @@ -0,0 +1,81 @@ +/** + * Integration coverage for `createContractTest` against the + * testcontainers-provided Temporal server (started by this package's own + * `global-setup`): the fixtures wire a running worker, the connection-scoped + * root, and the contract-bound client, and a workflow executes end-to-end + * through them. + */ +import { extname } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { declareActivitiesHandler } from "@temporal-contract/worker/activity"; +import { Ok } from "unthrown"; +import { describe, expect } from "vitest"; + +import { createContractTest } from "../contract.js"; +import { testContract } from "./test.contract.js"; +// The worker loads this module by path (`workflowsPath`); importing it here +// too keeps its exports type-checked and visible to static analysis. +import * as workflows from "./test.workflows.js"; + +const activities = declareActivitiesHandler({ + contract: testContract, + activities: { + greet: { + decorate: ({ name }) => Ok({ decorated: name.toUpperCase() }).toAsync(), + }, + }, +}); + +const it = createContractTest(testContract, { + workflowsPath: fileURLToPath( + new URL(`./test.workflows${extname(import.meta.url)}`, import.meta.url), + ), + activities, +}); + +describe("createContractTest", () => { + it("executes a workflow end-to-end through the contract-bound client", async ({ client }) => { + const result = await client.executeWorkflow("greet", { + workflowId: `greet-${Date.now()}`, + args: { name: "world" }, + }); + + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toEqual({ message: "Hello, WORLD!" }); + } + }); + + it("exposes the running worker and the connection-scoped root", async ({ + worker, + typedClient, + client, + }) => { + expect(worker.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); + // Sanity: the module registered via `workflowsPath` exports the + // workflow the contract declares. + expect(workflows.greet).toBeTypeOf("function"); + }); + + it("starts a workflow and reads its result through a typed handle", async ({ client }) => { + const workflowId = `greet-handle-${Date.now()}`; + const handleResult = await client.startWorkflow("greet", { + workflowId, + args: { name: "fixtures" }, + }); + + expect(handleResult.isOk()).toBe(true); + 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!" }); + } + }); +}); diff --git a/packages/testing/src/__tests__/test.contract.ts b/packages/testing/src/__tests__/test.contract.ts new file mode 100644 index 00000000..ef3e1634 --- /dev/null +++ b/packages/testing/src/__tests__/test.contract.ts @@ -0,0 +1,22 @@ +import { defineActivity, defineContract, defineWorkflow } from "@temporal-contract/contract"; +import { z } from "zod"; + +// Minimal inline contract for exercising the contract-aware fixtures: +// one workflow with one activity. + +const decorate = defineActivity({ + input: z.object({ name: z.string() }), + output: z.object({ decorated: z.string() }), + defaultOptions: { startToCloseTimeout: "10 seconds" }, +}); + +const greet = defineWorkflow({ + input: z.object({ name: z.string() }), + output: z.object({ message: z.string() }), + activities: { decorate }, +}); + +export const testContract = defineContract({ + taskQueue: "testing-contract-fixtures", + workflows: { greet }, +}); diff --git a/packages/testing/src/__tests__/test.workflows.ts b/packages/testing/src/__tests__/test.workflows.ts new file mode 100644 index 00000000..4f964702 --- /dev/null +++ b/packages/testing/src/__tests__/test.workflows.ts @@ -0,0 +1,23 @@ +// Workflow implementation for the fixtures' integration test. +// +// Deliberately written with the raw `@temporalio/workflow` API (like +// packages/client's integration workflows) rather than `declareWorkflow`: +// Temporal's workflow bundler resolves imports through node_modules from +// this file's location, and `@temporal-contract/worker` is a *peer* of this +// package (a dev dep would put a cycle in the workspace package graph), so +// it is not linked here. The typed surface under test — the contract-bound +// client and the worker wiring — is unaffected. +import { proxyActivities } from "@temporalio/workflow"; + +type Activities = { + decorate(args: { name: string }): Promise<{ decorated: string }>; +}; + +const activities = proxyActivities({ + startToCloseTimeout: "10 seconds", +}); + +export async function greet(args: { name: string }): Promise<{ message: string }> { + const { decorated } = await activities.decorate({ name: args.name }); + return { message: `Hello, ${decorated}!` }; +} diff --git a/packages/testing/src/activity.spec.ts b/packages/testing/src/activity.spec.ts new file mode 100644 index 00000000..b87ea1d7 --- /dev/null +++ b/packages/testing/src/activity.spec.ts @@ -0,0 +1,118 @@ +/** + * 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. + */ +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 { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { runActivity } from "./activity.js"; + +const charge = defineActivity({ + input: z.object({ amount: z.number() }), + output: z.object({ transactionId: z.string() }), + errors: { + PaymentDeclined: { + data: z.object({ reason: z.string() }), + nonRetryable: true, + }, + }, + defaultOptions: { startToCloseTimeout: "10 seconds" }, +}); + +describe("runActivity", () => { + it("returns the implementation's Ok result", async () => { + const result = await runActivity( + charge, + ({ amount }) => Ok({ transactionId: `TXN-${amount}` }).toAsync(), + { amount: 42 }, + ); + + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toEqual({ transactionId: "TXN-42" }); + } + }); + + it("returns a typed contract error built with the errors helper", async () => { + const result = await runActivity( + charge, + ({ amount }, { errors }) => + Err(errors.PaymentDeclined({ reason: `insufficient funds for ${amount}` })).toAsync(), + { amount: 9000 }, + ); + + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.error).toBeInstanceOf(ContractError); + expect(result.error.errorName).toBe("PaymentDeclined"); + expect(result.error.data).toEqual({ reason: "insufficient funds for 9000" }); + } + }); + + it("surfaces an unanticipated throw on the defect channel", async () => { + const boom = new Error("boom"); + const result = await runActivity( + charge, + () => { + throw boom; + }, + { amount: 1 }, + ); + + expect(result.isDefect()).toBe(true); + if (result.isDefect()) { + expect(result.cause).toBe(boom); + } + }); + + it("runs in the provided environment, so heartbeats are observable", async () => { + const env = new MockActivityEnvironment(); + const heartbeats: unknown[] = []; + env.on("heartbeat", (details: unknown) => heartbeats.push(details)); + + const result = await runActivity( + charge, + ({ 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 Ok({ transactionId: `TXN-${amount}` }).toAsync(); + }, + { amount: 7 }, + { env }, + ); + + expect(result.isOk()).toBe(true); + expect(heartbeats).toEqual(["halfway"]); + }); + + it("surfaces cancellation as a defect", async () => { + const env = new MockActivityEnvironment(); + + const resultPromise = runActivity( + charge, + () => + // 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 }, + ); + + env.cancel(); + const result = await resultPromise; + + expect(result.isDefect()).toBe(true); + if (result.isDefect()) { + expect(result.cause).toMatchObject({ name: "CancelledFailure" }); + } + }); +}); diff --git a/packages/testing/src/activity.ts b/packages/testing/src/activity.ts new file mode 100644 index 00000000..5d629fbe --- /dev/null +++ b/packages/testing/src/activity.ts @@ -0,0 +1,116 @@ +/** + * 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. + * + * This entry deliberately avoids `vitest` — it only needs + * `@temporalio/testing` — so it can be used from any test runner. + */ +import type { + ActivityDefinition, + ErrorDefinition, + WorkerInferInput, +} from "@temporal-contract/contract"; +import { + _internal_buildErrorConstructors, + type ContractErrorConstructors, +} from "@temporal-contract/contract/errors"; +import { _internal_makeAsyncResult } from "@temporal-contract/contract/result-async"; +import { MockActivityEnvironment } from "@temporalio/testing"; +import type { AsyncResult, Result } from "unthrown"; + +/** + * Typed error constructors for an activity's declared `errors` map — the + * `errors` helper handed to the implementation, mirroring the worker's + * runtime behavior. Empty for activities that declare no errors. + */ +type ActivityErrorConstructorsOf = TActivity extends { + errors: infer TErrors extends Record; +} + ? ContractErrorConstructors + : 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. + */ +export type RunActivityImplementation = ( + args: WorkerInferInput, + helpers: { + readonly errors: ActivityErrorConstructorsOf; + readonly context: Record; + }, +) => AsyncResult; + +/** + * Options for {@link runActivity}. + */ +export type RunActivityOptions = { + /** + * Reuse a prepared `MockActivityEnvironment` — pass one to observe + * heartbeats (`env.on("heartbeat", ...)`), trigger cancellation + * (`env.cancel()`), or customize the activity info. A fresh default + * environment is created when omitted. + */ + env?: MockActivityEnvironment; +}; + +/** + * Execute a single activity implementation against its contract definition + * inside a `MockActivityEnvironment`, returning the implementation's + * `AsyncResult` untouched: `Ok`/`Err` flow through as-is, and an + * unanticipated throw (including a `CancelledFailure` from cancellation) + * surfaces on the `defect` channel. + * + * @example + * ```ts + * import { runActivity } from "@temporal-contract/testing/activity"; + * + * const result = await runActivity( + * orderContract.workflows.processOrder.activities.chargeCard, + * chargeCard, // (args, { errors }) => AsyncResult<...> + * { amount: 100 }, + * ); + * + * expect(result.isOk()).toBe(true); + * ``` + * + * @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, +): AsyncResult { + const env = options?.env ?? new MockActivityEnvironment(); + const helpers = { + errors: _internal_buildErrorConstructors( + definition.errors, + ) as unknown as ActivityErrorConstructorsOf, + context: {}, + }; + + return _internal_makeAsyncResult(() => + env.run(async (): Promise> => { + // Awaiting the AsyncResult yields its settled Result (ok / err / + // defect) without throwing; the outer wrapper re-lifts it, and any + // synchronous throw or rejection lands on the defect channel. + return await implementation(input, helpers); + }), + ); +} diff --git a/packages/testing/src/contract.ts b/packages/testing/src/contract.ts new file mode 100644 index 00000000..c40c183a --- /dev/null +++ b/packages/testing/src/contract.ts @@ -0,0 +1,155 @@ +/** + * Contract-aware Vitest fixtures over the testcontainers-provided Temporal + * server. + * + * {@link createContractTest} wires the whole integration-test stack for one + * contract — a running worker, the connection-scoped {@link TypedClient} + * root, and the contract-bound {@link ContractClient} — on top of the + * connection fixtures from `./extension` (which read the address provided by + * `@temporal-contract/testing/global-setup`). Suites destructure exactly + * what they use: + * + * @example + * ```ts + * 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(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" }, + * }); + * expect(result.isOk()).toBe(true); + * }); + * }); + * ``` + */ +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 { Client } from "@temporalio/client"; +import type { Worker } from "@temporalio/worker"; +import { vi } from "vitest"; + +import { it as baseIt } from "./extension.js"; + +/** + * Options for {@link createContractTest}. + */ +export type CreateContractTestOptions = { + /** + * Path to the workflows file registered on the worker — typically built + * with `workflowsPathFromURL(import.meta.url, "./x.workflows.js")` from + * `@temporal-contract/worker/worker`. + */ + workflowsPath: string; + /** + * Activities handler built with `declareActivitiesHandler`. Omit it for a + * workflow-only worker. + */ + activities?: ActivitiesHandler; + /** + * Extra options forwarded to `createWorker` (e.g. `namespace`, + * interceptors, tuning knobs). The `namespace`, when given, is also used + * by the client fixtures. + */ + workerOptions?: Omit< + CreateWorkerOptions, + "activities" | "connection" | "contract" | "workflowsPath" + >; +}; + +/** + * Fixture context exposed by the `it` returned from + * {@link createContractTest}. + */ +export type ContractTestContext = { + /** Contract-bound client — `typedClient.for(contract)`. */ + client: ContractClient; + /** + * Connection-scoped root, for binding further contracts or reaching the + * `raw` escape hatch. + */ + typedClient: TypedClient; + /** + * The running worker for the contract's task queue — created and started + * before the test (`auto`), shut down after it. + */ + worker: Worker; +}; + +/** + * Build a Vitest `it` whose fixtures run the given contract against the + * testcontainers-provided Temporal server: a worker on the contract's task + * queue (started before each test, shut down after), the connection-scoped + * {@link TypedClient} root, and the contract-bound {@link ContractClient}. + * + * Requires the `@temporal-contract/testing/global-setup` global setup (or a + * module default-exporting `createGlobalSetup(...)`) to be registered on the + * test project. The underlying `clientConnection`/`workerConnection` + * fixtures from `./extension` remain available on the context. + */ +export function createContractTest( + contract: TContract, + options: CreateContractTestOptions, +) { + return baseIt.extend>({ + worker: [ + 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({ + contract, + connection: workerConnection, + workflowsPath: options.workflowsPath, + ...(options.activities !== undefined ? { activities: options.activities } : {}), + ...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(() => {}); + + await vi.waitFor(() => worker.getState() === "RUNNING", { interval: 100 }); + + await use(worker); + + if (worker.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; + }, + { auto: true }, + ], + typedClient: async ({ clientConnection }, use) => { + const namespace = options.workerOptions?.namespace; + const client = new Client({ + connection: clientConnection, + ...(namespace !== undefined ? { namespace } : {}), + }); + // Setup faults are defects (E = never); `get()` unwraps directly. + const typedClient = await TypedClient.create({ client }).get(); + await use(typedClient); + }, + client: async ({ typedClient }, use) => { + await use(typedClient.for(contract)); + }, + }); +} diff --git a/packages/testing/src/extension.spec.ts b/packages/testing/src/extension.spec.ts index 35bd3611..62dbe3e9 100644 --- a/packages/testing/src/extension.spec.ts +++ b/packages/testing/src/extension.spec.ts @@ -4,20 +4,23 @@ * The Temporal connections are mocked so the fixtures can run without a * server; the injected testcontainers address is provided statically via * `test.provide` in `vitest.config.ts`. The specs assert that both fixtures - * connect to the injected address, and that the client connection is closed - * after the test that used it completes (while the worker connection's - * cleanup is deliberately left to the test framework). + * connect to the injected address, that both connections are closed after + * the test that used them completes (the worker connection's close swallows + * the known shutdown race), and that a missing injected address fails with a + * descriptive error naming the global setup. */ import { afterAll, describe, expect, vi } from "vitest"; -import { it } from "./extension.js"; +import { it, resolveTemporalAddress } from "./extension.js"; const mocks = vi.hoisted(() => { const clientClose = vi.fn(() => Promise.resolve()); + const workerClose = vi.fn(() => Promise.resolve()); return { clientClose, + workerClose, clientConnect: vi.fn(() => Promise.resolve({ close: clientClose })), - workerConnect: vi.fn(() => Promise.resolve({ kind: "native-connection" })), + workerConnect: vi.fn(() => Promise.resolve({ close: workerClose })), }; }); @@ -50,6 +53,38 @@ describe("it.workerConnection", () => { workerConnection, }) => { expect(mocks.workerConnect).toHaveBeenCalledExactlyOnceWith({ address: "127.0.0.1:7233" }); - expect(workerConnection).toEqual({ kind: "native-connection" }); + expect(workerConnection).toEqual({ close: mocks.workerClose }); + // Still open while the test runs. + expect(mocks.workerClose).not.toHaveBeenCalled(); + }); + + it("swallows the known close race during teardown", ({ workerConnection }) => { + // The next fixture teardown rejects — the fixture must not fail the run. + mocks.workerClose.mockRejectedValueOnce(new Error("already closed") as never); + expect(workerConnection).toBeDefined(); + }); + + // Both tests above used the fixture, so close was attempted twice — once + // resolving, once rejecting (and swallowed). + afterAll(() => { + expect(mocks.workerClose).toHaveBeenCalledTimes(2); + }); +}); + +describe("resolveTemporalAddress", () => { + it("joins the injected host and port", () => { + expect(resolveTemporalAddress("10.0.0.5", 47_233)).toBe("10.0.0.5:47233"); + }); + + it("fails with an error naming the global setup when the host is missing", () => { + expect(() => resolveTemporalAddress(undefined, 7233)).toThrowError( + /@temporal-contract\/testing\/global-setup/, + ); + }); + + it("fails with an error naming the global setup when the port is missing", () => { + expect(() => resolveTemporalAddress("127.0.0.1", undefined)).toThrowError( + /@temporal-contract\/testing\/global-setup/, + ); }); }); diff --git a/packages/testing/src/extension.ts b/packages/testing/src/extension.ts index ef90fa80..5879182d 100644 --- a/packages/testing/src/extension.ts +++ b/packages/testing/src/extension.ts @@ -16,14 +16,21 @@ export const it = vitestIt.extend<{ workerConnection: async ({}, use) => { const connection = await getTemporalWorkerConnection(); await use(connection); - // Note: NativeConnection.close() may cause issues in test cleanup, - // let the test framework handle cleanup instead + try { + await connection.close(); + } catch { + // NativeConnection.close() races the Rust core's own cleanup when a + // worker that used this connection has just shut down — the call can + // reject with an "already closed" style error even though the + // connection is gone either way. Swallow it so the known shutdown race + // doesn't fail an otherwise green teardown. + } }, }); /** * Get a connection to the Temporal server (for client) - * Must be called after setupTemporalTestContainer has been executed + * Must be called after the testcontainers global setup has been executed */ function getTemporalConnection(): Promise { return Connection.connect({ @@ -33,7 +40,7 @@ function getTemporalConnection(): Promise { /** * Get a native connection to the Temporal server (for worker) - * Must be called after setupTemporalTestContainer has been executed + * Must be called after the testcontainers global setup has been executed */ function getTemporalWorkerConnection(): Promise { return NativeConnection.connect({ @@ -41,6 +48,30 @@ function getTemporalWorkerConnection(): Promise { }); } +/** + * Join the host/port pair injected by the testcontainers global setup into a + * Temporal address, failing with a descriptive error when the global setup + * was never registered (in which case `inject` yields `undefined` and the + * fixtures would otherwise try to connect to `"undefined:undefined"`). + * + * @internal — exported for unit tests only. + */ +export function resolveTemporalAddress(host: string | undefined, port: number | undefined): string { + if (host === undefined || port === undefined) { + throw new Error( + "Temporal test-server address was not injected into this test project. " + + 'Register the testcontainers global setup in your vitest config — globalSetup: "@temporal-contract/testing/global-setup" ' + + "(or a module default-exporting createGlobalSetup(...)) — so the fixtures from @temporal-contract/testing know where to connect.", + ); + } + return `${host}:${port}`; +} + function getTemporalAddress(): string { - return `${inject("__TESTCONTAINERS_TEMPORAL_IP__")}:${inject("__TESTCONTAINERS_TEMPORAL_PORT_7233__")}`; + // The ProvidedContext augmentation types these keys as always present, but + // at runtime they are only there when the global setup actually ran. + return resolveTemporalAddress( + inject("__TESTCONTAINERS_TEMPORAL_IP__") as string | undefined, + inject("__TESTCONTAINERS_TEMPORAL_PORT_7233__") as number | undefined, + ); } diff --git a/packages/testing/src/global-setup.spec.ts b/packages/testing/src/global-setup.spec.ts index e133f447..b1f8e536 100644 --- a/packages/testing/src/global-setup.spec.ts +++ b/packages/testing/src/global-setup.spec.ts @@ -1,16 +1,18 @@ /** - * Coverage for the vitest `globalSetup` hook. + * Coverage for the vitest `globalSetup` hook and its `createGlobalSetup` + * factory. * * `testcontainers` is mocked so no Docker daemon is needed: the specs assert * the postgres → temporal startup order, that the temporal address is - * provided to the test project, and that teardown stops both containers and + * provided to the test project, that teardown stops both containers and * the network — swallowing individual stop failures so one broken container - * doesn't leak the others. + * doesn't leak the others — and that the factory's options (images, extra + * temporal env, quiet) are applied. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { TestProject } from "vitest/node"; -import setup from "./global-setup.js"; +import setup, { createGlobalSetup } from "./global-setup.js"; type StartedContainer = { getHost: () => string; @@ -20,6 +22,7 @@ type StartedContainer = { const mocks = vi.hoisted(() => { const images: string[] = []; + const environments: Array<{ image: string; env: Record }> = []; const startedContainers: Array<{ getHost: () => string; getMappedPort: (port: number) => number; @@ -28,7 +31,9 @@ const mocks = vi.hoisted(() => { const networkStop = vi.fn(() => Promise.resolve()); class FakeGenericContainer { + private readonly image: string; constructor(image: string) { + this.image = image; images.push(image); } withNetwork() { @@ -40,7 +45,8 @@ const mocks = vi.hoisted(() => { withExposedPorts() { return this; } - withEnvironment() { + withEnvironment(env: Record) { + environments.push({ image: this.image, env }); return this; } withHealthCheck() { @@ -66,7 +72,14 @@ const mocks = vi.hoisted(() => { } } - return { images, startedContainers, networkStop, FakeGenericContainer, FakeNetwork }; + return { + images, + environments, + startedContainers, + networkStop, + FakeGenericContainer, + FakeNetwork, + }; }); vi.mock("testcontainers", () => ({ @@ -75,14 +88,20 @@ vi.mock("testcontainers", () => ({ Wait: { forHealthCheck: () => ({}) }, })); -function runSetup(): Promise<{ provide: ReturnType; teardown: unknown }> { +function runSetup( + setupFn: (project: TestProject) => Promise = setup, +): Promise<{ provide: ReturnType; teardown: unknown }> { const provide = vi.fn(); - return setup({ provide } as unknown as TestProject).then((teardown) => ({ provide, teardown })); + return setupFn({ provide } as unknown as TestProject).then((teardown) => ({ + provide, + teardown, + })); } describe("global setup", () => { beforeEach(() => { mocks.images.length = 0; + mocks.environments.length = 0; mocks.startedContainers.length = 0; mocks.networkStop.mockClear(); vi.spyOn(console, "log").mockImplementation(() => {}); @@ -129,3 +148,70 @@ describe("global setup", () => { expect(mocks.networkStop).toHaveBeenCalledTimes(1); }); }); + +describe("createGlobalSetup", () => { + beforeEach(() => { + mocks.images.length = 0; + mocks.environments.length = 0; + mocks.startedContainers.length = 0; + mocks.networkStop.mockClear(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("uses the configured images", async () => { + await runSetup( + createGlobalSetup({ + postgresImage: "postgres:16.4", + temporalImage: "temporalio/auto-setup:1.28.0", + }), + ); + + expect(mocks.images).toEqual(["postgres:16.4", "temporalio/auto-setup:1.28.0"]); + }); + + it("merges extra env into the temporal container, overriding defaults", async () => { + await runSetup( + createGlobalSetup({ + temporalEnv: { + DYNAMIC_CONFIG_FILE_PATH: "/etc/temporal/dynamic.yaml", + DB: "postgres13", + }, + }), + ); + + const temporal = mocks.environments.find(({ image }) => image.startsWith("temporalio/")); + expect(temporal?.env).toMatchObject({ + DYNAMIC_CONFIG_FILE_PATH: "/etc/temporal/dynamic.yaml", + // Overrides the built-in default. + DB: "postgres13", + // Built-in defaults are preserved. + POSTGRES_SEEDS: "postgres", + }); + + // The postgres container's env is untouched. + const postgres = mocks.environments.find(({ image }) => image.startsWith("postgres:")); + expect(postgres?.env).toEqual({ + POSTGRES_DB: "temporal", + POSTGRES_USER: "temporal", + POSTGRES_PASSWORD: "temporal", + }); + }); + + it("silences progress logs with quiet (teardown errors still log)", async () => { + const { teardown } = await runSetup(createGlobalSetup({ quiet: true })); + + expect(console.log).not.toHaveBeenCalled(); + + const [, temporal] = mocks.startedContainers as StartedContainer[]; + temporal?.stop.mockRejectedValueOnce(new Error("already gone")); + await (teardown as () => Promise)(); + + expect(console.log).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/testing/src/global-setup.ts b/packages/testing/src/global-setup.ts index 788d1d5e..8bc8f16d 100644 --- a/packages/testing/src/global-setup.ts +++ b/packages/testing/src/global-setup.ts @@ -10,97 +10,171 @@ declare module "vitest" { } /** - * Setup function for Vitest globalSetup - * Starts a Temporal server container before all tests + * Options for {@link createGlobalSetup}. */ -export default async function setup({ provide }: TestProject) { - console.log("🐳 Starting Temporal test environment..."); - - // Create a network for containers to communicate - const network = await new Network().start(); - - // Start PostgreSQL container first - console.log("🐳 Starting PostgreSQL container..."); - const postgresContainer = await new GenericContainer("postgres:18.1") - .withNetwork(network) - .withNetworkAliases("postgres") - .withExposedPorts(5432) - .withEnvironment({ - POSTGRES_DB: "temporal", - POSTGRES_USER: "temporal", - POSTGRES_PASSWORD: "temporal", - }) - .withHealthCheck({ - test: ["CMD-SHELL", "pg_isready -U temporal"], - interval: 1_000, - retries: 30, - startPeriod: 1_000, - timeout: 1_000, - }) - .withWaitStrategy(Wait.forHealthCheck()) - .start(); - - console.log("✅ PostgreSQL container started"); - - // Start Temporal container - console.log("🐳 Starting Temporal container..."); - const temporalContainer = await new GenericContainer("temporalio/auto-setup:1.29.1") - .withNetwork(network) - .withExposedPorts(7233) - .withEnvironment({ - DB: "postgres12", - DB_PORT: "5432", - POSTGRES_SEEDS: "postgres", - POSTGRES_USER: "temporal", - POSTGRES_PWD: "temporal", - BIND_ON_IP: "0.0.0.0", - TEMPORAL_BROADCAST_ADDRESS: "127.0.0.1", - }) - .withHealthCheck({ - test: ["CMD-SHELL", "tctl --address 127.0.0.1:7233 workflow list"], - interval: 1_000, - retries: 30, - startPeriod: 1_000, - timeout: 1_000, - }) - .withWaitStrategy(Wait.forHealthCheck()) - .start(); - - console.log("✅ Temporal container started"); - - const __TESTCONTAINERS_TEMPORAL_IP__ = temporalContainer.getHost(); - const __TESTCONTAINERS_TEMPORAL_PORT_7233__ = temporalContainer.getMappedPort(7233); - - provide("__TESTCONTAINERS_TEMPORAL_IP__", __TESTCONTAINERS_TEMPORAL_IP__); - provide("__TESTCONTAINERS_TEMPORAL_PORT_7233__", __TESTCONTAINERS_TEMPORAL_PORT_7233__); - - console.log( - `🚀 Temporal test environment is ready at ${__TESTCONTAINERS_TEMPORAL_IP__}:${__TESTCONTAINERS_TEMPORAL_PORT_7233__}`, - ); - - // Return teardown function - return async () => { - console.log("🧹 Cleaning up Temporal test environment..."); - - try { - await temporalContainer.stop(); - console.log("✅ Temporal container stopped"); - } catch (error) { - console.error("⚠️ Error stopping container:", error); - } - - try { - await postgresContainer.stop(); - console.log("✅ PostgreSQL container stopped"); - } catch (error) { - console.error("⚠️ Error stopping PostgreSQL container:", error); - } - - try { - await network.stop(); - console.log("✅ Network stopped"); - } catch (error) { - console.error("⚠️ Error stopping network:", error); - } +export type CreateGlobalSetupOptions = { + /** + * PostgreSQL image reference backing the Temporal server. + * + * @defaultValue `"postgres:18.1"` + */ + postgresImage?: string; + /** + * Temporal auto-setup image reference — pin this to test against a + * specific server version. + * + * @defaultValue `"temporalio/auto-setup:1.29.1"` + */ + temporalImage?: string; + /** + * Extra environment variables merged into the Temporal container (e.g. + * dynamic-config knobs). Keys given here override the built-in defaults. + */ + temporalEnv?: Record; + /** + * Silence the container-progress `console.log`s. Teardown failures still + * log via `console.error`. + * + * @defaultValue `false` + */ + quiet?: boolean; +}; + +/** + * Build a Vitest `globalSetup` function that starts a Temporal server + * (PostgreSQL + `temporalio/auto-setup`) via testcontainers before all tests + * and provides its address to the fixtures in + * `@temporal-contract/testing/extension` and + * `@temporal-contract/testing/contract`. + * + * The package's default export is `createGlobalSetup()` — reference this + * factory from your own global-setup module only when you need to pin + * images, inject extra Temporal env, or silence the progress logs: + * + * @example + * ```ts + * // temporal-global-setup.ts + * import { createGlobalSetup } from "@temporal-contract/testing/global-setup"; + * + * export default createGlobalSetup({ + * temporalImage: "temporalio/auto-setup:1.28.0", + * quiet: true, + * }); + * ``` + */ +export function createGlobalSetup( + options: CreateGlobalSetupOptions = {}, +): (project: TestProject) => Promise<() => Promise> { + const { + postgresImage = "postgres:18.1", + temporalImage = "temporalio/auto-setup:1.29.1", + temporalEnv = {}, + quiet = false, + } = options; + + const log = quiet + ? () => {} + : (message: string) => { + console.log(message); + }; + + return async function setup({ provide }: TestProject) { + log("🐳 Starting Temporal test environment..."); + + // Create a network for containers to communicate + const network = await new Network().start(); + + // Start PostgreSQL container first + log("🐳 Starting PostgreSQL container..."); + const postgresContainer = await new GenericContainer(postgresImage) + .withNetwork(network) + .withNetworkAliases("postgres") + .withExposedPorts(5432) + .withEnvironment({ + POSTGRES_DB: "temporal", + POSTGRES_USER: "temporal", + POSTGRES_PASSWORD: "temporal", + }) + .withHealthCheck({ + test: ["CMD-SHELL", "pg_isready -U temporal"], + interval: 1_000, + retries: 30, + startPeriod: 1_000, + timeout: 1_000, + }) + .withWaitStrategy(Wait.forHealthCheck()) + .start(); + + log("✅ PostgreSQL container started"); + + // Start Temporal container + log("🐳 Starting Temporal container..."); + const temporalContainer = await new GenericContainer(temporalImage) + .withNetwork(network) + .withExposedPorts(7233) + .withEnvironment({ + DB: "postgres12", + DB_PORT: "5432", + POSTGRES_SEEDS: "postgres", + POSTGRES_USER: "temporal", + POSTGRES_PWD: "temporal", + BIND_ON_IP: "0.0.0.0", + TEMPORAL_BROADCAST_ADDRESS: "127.0.0.1", + ...temporalEnv, + }) + .withHealthCheck({ + test: ["CMD-SHELL", "tctl --address 127.0.0.1:7233 workflow list"], + interval: 1_000, + retries: 30, + startPeriod: 1_000, + timeout: 1_000, + }) + .withWaitStrategy(Wait.forHealthCheck()) + .start(); + + log("✅ Temporal container started"); + + const __TESTCONTAINERS_TEMPORAL_IP__ = temporalContainer.getHost(); + const __TESTCONTAINERS_TEMPORAL_PORT_7233__ = temporalContainer.getMappedPort(7233); + + provide("__TESTCONTAINERS_TEMPORAL_IP__", __TESTCONTAINERS_TEMPORAL_IP__); + provide("__TESTCONTAINERS_TEMPORAL_PORT_7233__", __TESTCONTAINERS_TEMPORAL_PORT_7233__); + + log( + `🚀 Temporal test environment is ready at ${__TESTCONTAINERS_TEMPORAL_IP__}:${__TESTCONTAINERS_TEMPORAL_PORT_7233__}`, + ); + + // Return teardown function + return async () => { + log("🧹 Cleaning up Temporal test environment..."); + + try { + await temporalContainer.stop(); + log("✅ Temporal container stopped"); + } catch (error) { + console.error("⚠️ Error stopping container:", error); + } + + try { + await postgresContainer.stop(); + log("✅ PostgreSQL container stopped"); + } catch (error) { + console.error("⚠️ Error stopping PostgreSQL container:", error); + } + + try { + await network.stop(); + log("✅ Network stopped"); + } catch (error) { + console.error("⚠️ Error stopping network:", error); + } + }; }; } + +/** + * Default Vitest `globalSetup` — {@link createGlobalSetup} with the stock + * images and settings. Reference it directly from a vitest config: + * `globalSetup: "@temporal-contract/testing/global-setup"`. + */ +export default createGlobalSetup(); diff --git a/packages/testing/src/time-skipping.spec.ts b/packages/testing/src/time-skipping.spec.ts new file mode 100644 index 00000000..15cd98bb --- /dev/null +++ b/packages/testing/src/time-skipping.spec.ts @@ -0,0 +1,51 @@ +/** + * Coverage for the time-skipping helpers. + * + * `@temporalio/testing` is mocked so no test-server binary is downloaded: + * the specs assert that `createTimeSkippingEnvironment` and the fixture + * factory forward their options to + * `TestWorkflowEnvironment.createTimeSkipping`, and that the fixture tears + * the environment down when the vitest worker exits. + */ +import type { TimeSkippingTestWorkflowEnvironmentOptions } from "@temporalio/testing"; +import { describe, expect, it, vi } from "vitest"; + +import { createTimeSkippingEnvironment, createTimeSkippingTest } from "./time-skipping.js"; + +const mocks = vi.hoisted(() => { + const teardown = vi.fn(() => Promise.resolve()); + return { + teardown, + createTimeSkipping: vi.fn(() => Promise.resolve({ kind: "test-env", teardown })), + }; +}); + +vi.mock("@temporalio/testing", () => ({ + TestWorkflowEnvironment: { createTimeSkipping: mocks.createTimeSkipping }, +})); + +const pinnedServer = { + server: { executable: { type: "cached-download", version: "v1.3.0" } }, +} as TimeSkippingTestWorkflowEnvironmentOptions; + +// Worker-scoped fixtures must be defined at the top level of the file, not +// inside a describe block. +const timeSkippingIt = createTimeSkippingTest(pinnedServer); + +describe("createTimeSkippingEnvironment", () => { + it("forwards its options to TestWorkflowEnvironment.createTimeSkipping", async () => { + const env = await createTimeSkippingEnvironment(pinnedServer); + + expect(mocks.createTimeSkipping).toHaveBeenCalledExactlyOnceWith(pinnedServer); + expect(env).toMatchObject({ kind: "test-env" }); + }); +}); + +describe("createTimeSkippingTest", () => { + timeSkippingIt("creates the environment with the factory's options", ({ testEnv }) => { + expect(testEnv).toMatchObject({ kind: "test-env" }); + expect(mocks.createTimeSkipping).toHaveBeenCalledWith(pinnedServer); + // Worker-scoped fixture: still alive while tests run. + expect(mocks.teardown).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/testing/src/time-skipping.ts b/packages/testing/src/time-skipping.ts index 16d533ef..15792836 100644 --- a/packages/testing/src/time-skipping.ts +++ b/packages/testing/src/time-skipping.ts @@ -26,8 +26,9 @@ * connection: testEnv.nativeConnection, * workflowsPath: workflowsPathFromURL(import.meta.url, "./test.workflows.js"), * activities, - * }).getOrThrow(); - * const client = await TypedClient.create({ contract: myContract, client: testEnv.client }).getOrThrow(); + * }).get(); + * const typedClient = await TypedClient.create({ client: testEnv.client }).get(); + * const client = typedClient.for(myContract); * * await worker.runUntil(async () => { * const result = await client.executeWorkflow("processOrder", { @@ -39,28 +40,63 @@ * }); * ``` */ -import { TestWorkflowEnvironment } from "@temporalio/testing"; +import { + TestWorkflowEnvironment, + type TimeSkippingTestWorkflowEnvironmentOptions, +} from "@temporalio/testing"; import { it as vitestIt } from "vitest"; /** * Create a time-skipping `TestWorkflowEnvironment` directly — for suites * that prefer explicit `beforeAll`/`afterAll` management over the {@link it} - * fixture (remember to call `env.teardown()`). + * fixture (remember to call `env.teardown()`). Options are forwarded to + * `TestWorkflowEnvironment.createTimeSkipping` unchanged (e.g. to pin the + * test-server version via `server.executable`). */ -export function createTimeSkippingEnvironment(): Promise { - return TestWorkflowEnvironment.createTimeSkipping(); +export function createTimeSkippingEnvironment( + options?: TimeSkippingTestWorkflowEnvironmentOptions, +): Promise { + return TestWorkflowEnvironment.createTimeSkipping(options); } -export const it = vitestIt.extend<{ - $worker: { testEnv: TestWorkflowEnvironment }; -}>({ - testEnv: [ - // oxlint-disable-next-line no-empty-pattern - async ({}, use) => { - const env = await TestWorkflowEnvironment.createTimeSkipping(); - await use(env); - await env.teardown(); - }, - { scope: "worker" }, - ], -}); +/** + * Build a Vitest `it` with a worker-scoped `testEnv` fixture backed by a + * time-skipping environment created with the given options — use this + * instead of the ready-made {@link it} when a suite needs to pin the test + * server version or otherwise configure the environment: + * + * @example + * ```ts + * import { createTimeSkippingTest } from "@temporal-contract/testing/time-skipping"; + * + * const it = createTimeSkippingTest({ + * server: { executable: { type: "cached-download", version: "v1.3.0" } }, + * }); + * + * it("runs against the pinned server", async ({ testEnv }) => { ... }); + * ``` + */ +export function createTimeSkippingTest(options?: TimeSkippingTestWorkflowEnvironmentOptions) { + return vitestIt.extend<{ + testEnv: TestWorkflowEnvironment; + }>({ + testEnv: [ + // oxlint-disable-next-line no-empty-pattern + async ({}, use) => { + const env = await TestWorkflowEnvironment.createTimeSkipping(options); + await use(env); + await env.teardown(); + }, + { scope: "worker" }, + ], + }); +} + +/** + * Ready-made `it` with the worker-scoped `testEnv` fixture and default + * environment options — {@link createTimeSkippingTest} with no arguments. + * + * @public consumed from sibling packages' suites; the tsconfig `paths` + * indirection hides that usage from knip. + */ +export const it = createTimeSkippingTest(); diff --git a/packages/testing/tsconfig.json b/packages/testing/tsconfig.json index 046a13df..fff9349a 100644 --- a/packages/testing/tsconfig.json +++ b/packages/testing/tsconfig.json @@ -2,7 +2,27 @@ "extends": "@btravstack/tsconfig/base.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + // `rootDir` spans the packages dir: the `paths` below intentionally pull + // sibling sources into the program, which a src-scoped rootDir would + // reject (TS6059). The config never emits (base sets `noEmit`), so this + // only satisfies the compiler's emit-layout validation. + "rootDir": "..", + // The sibling packages are peer dependencies (declaring them as + // dev/regular deps would put a cycle in the package graph: + // client/worker already devDepend on this package for their integration + // fixtures). They are mapped to source here so typecheck and the dts + // build resolve them without needing the siblings' dist or a + // node_modules link. The emitted output keeps the bare specifiers — + // consumers resolve them through their own (peer) installs. + "paths": { + "@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/worker/activity": ["../worker/src/activity.ts"], + "@temporal-contract/worker/worker": ["../worker/src/worker.ts"], + "@temporal-contract/worker/workflow": ["../worker/src/workflow.ts"] + } }, "include": ["src/**/*"] } diff --git a/packages/testing/typedoc.json b/packages/testing/typedoc.json index 4048ee2f..744961d6 100644 --- a/packages/testing/typedoc.json +++ b/packages/testing/typedoc.json @@ -1,5 +1,11 @@ { "extends": "@btravstack/typedoc/base.json", - "entryPoints": ["src/extension.ts", "src/global-setup.ts"], + "entryPoints": [ + "src/activity.ts", + "src/contract.ts", + "src/extension.ts", + "src/global-setup.ts", + "src/time-skipping.ts" + ], "out": "docs" } diff --git a/packages/testing/vitest.config.ts b/packages/testing/vitest.config.ts index 9d9f9ffc..8bfb0888 100644 --- a/packages/testing/vitest.config.ts +++ b/packages/testing/vitest.config.ts @@ -1,20 +1,67 @@ +import { fileURLToPath } from "node:url"; + import { defineConfig } from "vitest/config"; +// The sibling packages are peers (a dev/regular dep would create a package +// cycle — client/worker devDepend on this package), so their specifiers are +// aliased to source for the test runtime, mirroring tsconfig.json's `paths`. +const sibling = (path: string) => fileURLToPath(new URL(path, import.meta.url)); + +const workspaceAliases = [ + { find: /^@temporal-contract\/client$/, replacement: sibling("../client/src/index.ts") }, + { find: /^@temporal-contract\/contract$/, replacement: sibling("../contract/src/index.ts") }, + { + find: /^@temporal-contract\/contract\/errors$/, + replacement: sibling("../contract/src/errors.ts"), + }, + { + find: /^@temporal-contract\/contract\/result-async$/, + replacement: sibling("../contract/src/result-async.ts"), + }, + { + find: /^@temporal-contract\/worker\/activity$/, + replacement: sibling("../worker/src/activity.ts"), + }, + { find: /^@temporal-contract\/worker\/worker$/, replacement: sibling("../worker/src/worker.ts") }, + { + find: /^@temporal-contract\/worker\/workflow$/, + replacement: sibling("../worker/src/workflow.ts"), + }, +]; + export default defineConfig({ test: { reporters: ["default"], - include: ["src/**/*.spec.ts"], coverage: { provider: "v8", reporter: ["text", "json", "json-summary", "html"], - include: ["src/**"], - }, - // The unit specs exercise `extension.ts` with mocked Temporal - // connections, so the address normally provided by the testcontainers - // global setup is stubbed statically here. - provide: { - __TESTCONTAINERS_TEMPORAL_IP__: "127.0.0.1", - __TESTCONTAINERS_TEMPORAL_PORT_7233__: 7233, + include: ["src/**", "!src/__tests__/**"], }, + projects: [ + { + resolve: { alias: workspaceAliases }, + test: { + name: "unit", + include: ["src/**/*.spec.ts"], + exclude: ["src/**/__tests__/*.spec.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. + provide: { + __TESTCONTAINERS_TEMPORAL_IP__: "127.0.0.1", + __TESTCONTAINERS_TEMPORAL_PORT_7233__: 7233, + }, + }, + }, + { + resolve: { alias: workspaceAliases }, + test: { + name: "integration", + globalSetup: "./src/global-setup.ts", + include: ["src/**/__tests__/*.spec.ts"], + testTimeout: 30_000, + }, + }, + ], }, }); diff --git a/packages/worker/README.md b/packages/worker/README.md index dbd4df6d..4d641cf0 100644 --- a/packages/worker/README.md +++ b/packages/worker/README.md @@ -17,6 +17,8 @@ pnpm add @temporal-contract/worker @temporal-contract/contract @temporalio/workf import { declareActivitiesHandler, ApplicationFailure } from "@temporal-contract/worker/activity"; import { fromPromise } from "unthrown"; +import { myContract } from "./contract.js"; + export const activities = declareActivitiesHandler({ contract: myContract, activities: { @@ -25,15 +27,20 @@ export const activities = declareActivitiesHandler({ ApplicationFailure.create({ type: "EMAIL_FAILED", message: error instanceof Error ? error.message : "Failed to send email", - cause: error, + // Omit `cause` entirely for non-Error rejections — don't pass undefined. + ...(error instanceof Error ? { cause: error } : {}), }), ).map(() => ({ sent: true })), }, }); +``` +```typescript // workflows.ts import { declareWorkflow } from "@temporal-contract/worker/workflow"; +import { myContract } from "./contract.js"; + export const processOrder = declareWorkflow({ workflowName: "processOrder", contract: myContract, @@ -44,23 +51,48 @@ export const processOrder = declareWorkflow({ return { success: true }; }, }); +``` +```typescript // worker.ts -import { Worker } from "@temporalio/worker"; -import { activities } from "./activities"; -import myContract from "./contract"; - -async function run() { - const worker = await Worker.create({ - workflowsPath: require.resolve("./workflows"), - activities, - taskQueue: myContract.taskQueue, - }); - - await worker.run(); +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" }); + +// 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({ + contract: myContract, + connection, + workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"), + activities, +}); +if (workerResult.isDefect()) { + // Bundling / connection failure — a TechnicalError-caused defect, not thrown. + console.error("worker setup failed", workerResult.cause); + process.exit(1); } -run().catch(console.error); +await workerResult.value.run(); +``` + +### Workflow-only workers + +`activities` is optional on `createWorker`. 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({ + contract: myContract, + connection, + workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"), + // no `activities` — this process polls for Workflow Tasks only +}); ``` ### Child Workflows diff --git a/packages/worker/package.json b/packages/worker/package.json index 6ef98123..c89811c9 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -62,6 +62,7 @@ "scripts": { "build": "tsdown src/activity.ts src/worker.ts src/workflow.ts --format cjs,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", "test": "vitest run --project unit", "test:integration": "vitest run --project integration --project integration-inprocess", @@ -73,6 +74,7 @@ "@temporal-contract/contract": "workspace:*" }, "devDependencies": { + "@arethetypeswrong/cli": "catalog:", "@btravstack/tsconfig": "catalog:", "@btravstack/typedoc": "catalog:", "@temporal-contract/client": "workspace:*", @@ -84,6 +86,7 @@ "@types/node": "catalog:", "@unthrown/vitest": "catalog:", "@vitest/coverage-v8": "catalog:", + "publint": "catalog:", "tsdown": "catalog:", "typedoc": "catalog:", "typedoc-plugin-markdown": "catalog:", diff --git a/packages/worker/src/__tests__/time-skipping.inprocess.spec.ts b/packages/worker/src/__tests__/time-skipping.inprocess.spec.ts index 66ae8d05..7704c5eb 100644 --- a/packages/worker/src/__tests__/time-skipping.inprocess.spec.ts +++ b/packages/worker/src/__tests__/time-skipping.inprocess.spec.ts @@ -10,7 +10,8 @@ import { Ok, Err, type AsyncResult } from "unthrown"; * no Docker required. Exercises, in-process: * * - `createWorker` / `TypedClient.create` AsyncResult factories, - * - validation on both sides of the activity boundary, + * - the activity-boundary wire format (sender validates and transmits the + * original value; receiver parses), * - `createContext` seed + accumulating middleware context, * - typed contract errors (activity-side rehydration in the workflow, * workflow-side rehydration at the client), @@ -23,7 +24,7 @@ import { describe, expect } from "vitest"; import { composeActivityMiddleware, declareActivitiesHandler, - defineActivityMiddleware, + declareActivityMiddleware, } from "../activity.js"; import { createWorker, TechnicalError } from "../worker.js"; import { inprocessContract } from "./inprocess.contract.js"; @@ -34,7 +35,7 @@ const errAsync = (error: E): AsyncResult => Err(error).toAsync(); const seenContexts: Record[] = []; const tracing = composeActivityMiddleware( - defineActivityMiddleware<{ gateway: string }, { gateway: string; traceId: string }>( + declareActivityMiddleware<{ gateway: string }, { gateway: string; traceId: string }>( (invocation, next) => next({ context: { ...invocation.context, traceId: "trace-1" } }), ), ); @@ -79,13 +80,12 @@ describe("time-skipping TestWorkflowEnvironment", () => { const worker = workerResult.value; const clientResult = await TypedClient.create({ - contract: inprocessContract, client: testEnv.client, interceptors: [recording], }); expect(clientResult.isOk()).toBe(true); if (!clientResult.isOk()) return; - const client = clientResult.value; + const client = clientResult.value.for(inprocessContract); await worker.runUntil(async () => { // Happy path — the hour-long sleep is skipped, the accumulated diff --git a/packages/worker/src/__tests__/worker.spec.ts b/packages/worker/src/__tests__/worker.spec.ts index cbe3c056..11d8ba0f 100644 --- a/packages/worker/src/__tests__/worker.spec.ts +++ b/packages/worker/src/__tests__/worker.spec.ts @@ -1,7 +1,11 @@ import { extname } from "node:path"; import { fileURLToPath } from "node:url"; -import { TypedClient, WorkflowValidationError } from "@temporal-contract/client"; +import { + TypedClient, + WorkflowValidationError, + type ContractClient, +} 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"; @@ -22,7 +26,7 @@ const errAsync = (error: E): AsyncResult => Err(error).toAsync(); const it = baseIt.extend<{ worker: Worker; - client: TypedClient; + client: ContractClient; }>({ worker: [ async ({ workerConnection }, use) => { @@ -55,17 +59,14 @@ const it = baseIt.extend<{ { auto: true }, ], client: async ({ clientConnection }, use) => { - // Create typed client + // Create the connection-scoped root, then bind the contract. const rawClient = new Client({ connection: clientConnection, namespace: "default", }); - const clientResult = await TypedClient.create({ contract: testContract, client: rawClient }); - if (!clientResult.isOk()) { - throw clientResult.isErr() ? clientResult.error : clientResult.cause; - } + const root = (await TypedClient.create({ client: rawClient })).get(); - await use(clientResult.value); + await use(root.for(testContract)); }, }); @@ -78,8 +79,8 @@ const logMessages: string[] = []; const activities = declareActivitiesHandler({ contract: testContract, activities: { - simpleWorkflow: {}, - + // Workflows without declared activities (simpleWorkflow, + // interactiveWorkflow, parentWorkflow, ...) no longer need `{}` entries. workflowWithActivities: { processPayment: ({ amount }) => { return okAsync({ @@ -95,14 +96,6 @@ const activities = declareActivitiesHandler({ }, }, - interactiveWorkflow: {}, - - parentWorkflow: {}, - - childWorkflow: {}, - - workflowWithFailableActivity: {}, - logMessage: ({ message }) => { logMessages.push(message); return okAsync({}); @@ -190,8 +183,8 @@ describe("Worker Package - Integration Tests", () => { args: input, }); - // WHEN - const handleResult = await client.getHandle("simpleWorkflow", workflowId); + // WHEN — getHandle is synchronous in the new client surface + const handleResult = client.getHandle("simpleWorkflow", workflowId); // THEN expect(handleResult).toBeOk(); diff --git a/packages/worker/src/activities-proxy.spec.ts b/packages/worker/src/activities-proxy.spec.ts index 7151a80b..8e157975 100644 --- a/packages/worker/src/activities-proxy.spec.ts +++ b/packages/worker/src/activities-proxy.spec.ts @@ -55,6 +55,69 @@ describe("createValidatedActivities — activities without declared errors", () }); }); +describe("createValidatedActivities — wire format (validate on send, parse on receive)", () => { + // D1: the workflow-side proxy is the SENDING side of the activity-input + // boundary — it validates (fail early) but transmits the caller's ORIGINAL + // value; the activity worker parses it once on receive. For the result it + // is the RECEIVING side — the activity returned its original value, so the + // proxy applies the output transform exactly once. + const transformDefinition = { + input: z.object({ text: z.string().transform((s: string) => `${s}!`) }), + output: z.object({ n: z.number().transform((n: number) => n * 2) }), + } as unknown as ActivityDefinition; + + const transformErroredDefinition = { + ...transformDefinition, + errors: { + SomethingDeclined: { data: z.object({ reason: z.string() }) }, + }, + } as unknown as ActivityDefinition; + + it("throwing shape: sends the ORIGINAL input over the wire and parses the output once", async () => { + const seen: unknown[] = []; + const activities = createValidatedActivities( + { + transformer: async (input: unknown) => { + seen.push(input); + return { n: 21 }; + }, + }, + { transformer: transformDefinition }, + undefined, + ) as unknown as Record Promise>; + + const result = await activities["transformer"]!({ text: "hi" }); + + // The raw Temporal proxy received the caller's original value, not the + // parsed `{ text: "hi!" }` — the activity worker parses on receive. + expect(seen).toEqual([{ text: "hi" }]); + // The activity's wire value (pre-transform) is parsed exactly once here. + expect(result).toEqual({ n: 42 }); + }); + + it("Result shape: sends the ORIGINAL input over the wire and parses the output once", async () => { + const seen: unknown[] = []; + const activities = createValidatedActivities( + { + transformer: async (input: unknown) => { + seen.push(input); + return { n: 21 }; + }, + }, + { transformer: transformErroredDefinition }, + undefined, + ) as unknown as Record AsyncResult>; + + 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 }); + } + }); +}); + describe("createValidatedActivities — activities with declared errors", () => { it("returns Ok with the validated output on success", async () => { const activities = buildProxy(async () => ({ transactionId: "tx" })); diff --git a/packages/worker/src/activities-proxy.ts b/packages/worker/src/activities-proxy.ts index bcfdd489..a8a34eff 100644 --- a/packages/worker/src/activities-proxy.ts +++ b/packages/worker/src/activities-proxy.ts @@ -31,8 +31,10 @@ import type { ClientInferInput, ClientInferOutput } from "./types.js"; /** * Activity function signature from workflow execution perspective. * - * Workflows call activities with validated input (z.input parsed) and receive - * validated output (z.output). + * Workflows call activities with the input schema's *input* type and receive + * the output schema's *output* (parsed) type: the workflow side validates + * the input but transmits the original value (the activity worker parses it + * on receive), and parses the activity's result on receive. * * The shape depends on whether the activity declares contract errors: * @@ -92,9 +94,15 @@ export type WorkflowInferWorkflowContextActivities< /** * Wrap the raw activities proxy with input/output validation against the - * Standard Schema definitions on the contract. The wrapper enforces data - * integrity at the workflow → activity boundary in addition to the - * activity-side validation that `declareActivitiesHandler` already runs. + * Standard Schema definitions on the contract. Per the wire-format contract + * (validate on send, parse on receive), the wrapper: + * + * - VALIDATES the input before dispatch — failing early with a typed error — + * but transmits the caller's ORIGINAL value; `declareActivitiesHandler` + * (activity worker side) parses it exactly once on receive. + * - PARSES the activity's result on receive — the activity side validated + * its return and transmitted the original value — so a transforming + * output schema is applied exactly once, here. * * Activities that declare contract errors additionally get failure * classification: their wrapper returns an `AsyncResult` whose error channel @@ -141,8 +149,8 @@ export function createValidatedActivities< /** * Validation-only wrapper for activities without declared errors — the - * historical shape: validate input, invoke, validate output, let failures - * throw through to Temporal's native handling. + * historical shape: validate input (send the original), invoke, parse + * output, let failures throw through to Temporal's native handling. */ function makeThrowingActivity( activityName: string, @@ -155,7 +163,8 @@ function makeThrowingActivity( throw new ActivityInputValidationError(activityName, inputResult.issues); } - const result = await rawActivity(inputResult.value); + // Send the ORIGINAL input — the activity worker parses on receive. + const result = await rawActivity(input); const outputResult = await activityDef.output["~standard"].validate(result); if (outputResult.issues) { @@ -201,7 +210,8 @@ function makeResultShapedActivity( let rawOutput: unknown; try { - rawOutput = await rawActivity(inputResult.value); + // Send the ORIGINAL input — the activity worker parses on receive. + rawOutput = await rawActivity(input); } catch (error) { return Err(await classifyActivityError(activityName, activityDef, error)); } diff --git a/packages/worker/src/activity-contract-errors.spec.ts b/packages/worker/src/activity-contract-errors.spec.ts index a9719481..8e3510aa 100644 --- a/packages/worker/src/activity-contract-errors.spec.ts +++ b/packages/worker/src/activity-contract-errors.spec.ts @@ -15,7 +15,7 @@ import { ApplicationFailure, composeActivityMiddleware, declareActivitiesHandler, - defineActivityMiddleware, + declareActivityMiddleware, type ActivityMiddleware, } from "./activity.js"; import { ContractErrorDataValidationError } from "./errors.js"; @@ -94,6 +94,38 @@ describe("declareActivitiesHandler — contract errors", () => { }); }); + it("transmits the ORIGINAL error data, not the parsed value (D1 wire format)", async () => { + // The producer validates the payload (fail early) but `details[0]` + // carries the original value — the consuming side (client / workflow + // proxy rehydration) parses it against the declared schema exactly once. + const transformingContract = defineContract({ + taskQueue: "test-queue", + workflows: { + noop: { input: z.object({}), output: z.object({}) }, + }, + activities: { + flaky: { + input: z.object({}), + output: z.object({}), + errors: { + Nope: { data: z.object({ reason: z.string().transform((s) => `${s}!`) }) }, + }, + }, + }, + }); + const activities = declareActivitiesHandler({ + contract: transformingContract, + activities: { + flaky: (_args, { errors }) => errAsync(errors.Nope({ reason: "declined" })), + }, + }); + + await expect(activities.flaky({})).rejects.toMatchObject({ + type: "Nope", + details: [{ reason: "declined" }], // not [{ reason: "declined!" }] + }); + }); + it("fails fast when the error data does not validate against its schema", async () => { const activities = declareActivitiesHandler({ contract, @@ -347,10 +379,11 @@ describe("declareActivitiesHandler — middleware", () => { const seenByInner: unknown[] = []; const seenByImplementation: unknown[] = []; - const outer = defineActivityMiddleware<{ tenant: string }, { tenant: string; traceId: string }>( - (invocation, next) => next({ context: { ...invocation.context, traceId: "t-1" } }), - ); - const inner = defineActivityMiddleware< + const outer = declareActivityMiddleware< + { tenant: string }, + { tenant: string; traceId: string } + >((invocation, next) => next({ context: { ...invocation.context, traceId: "t-1" } })); + const inner = declareActivityMiddleware< { tenant: string; traceId: string }, { tenant: string; traceId: string; attempt: number } >((invocation, next) => { diff --git a/packages/worker/src/activity.spec.ts b/packages/worker/src/activity.spec.ts index 317237bb..f329c45f 100644 --- a/packages/worker/src/activity.spec.ts +++ b/packages/worker/src/activity.spec.ts @@ -3,7 +3,7 @@ import { Ok, Err, fromSafePromise, type AsyncResult } from "unthrown"; import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { ApplicationFailure, declareActivitiesHandler, qualify } from "./activity.js"; +import { ApplicationFailure, declareActivitiesHandler, qualifyFailure } from "./activity.js"; import { ActivityDefinitionNotFoundError, ActivityInputValidationError, @@ -34,11 +34,11 @@ describe("Worker unthrown Package", () => { }, } satisfies ContractDefinition; - // WHEN + // WHEN — `testWorkflow` declares no activities, so no `testWorkflow: {}` + // placeholder entry is needed (or accepted) in the implementations map. const activities = declareActivitiesHandler({ contract, activities: { - testWorkflow: {}, sendEmail: () => okAsync({ sent: true }), }, }); @@ -322,6 +322,186 @@ describe("Worker unthrown Package", () => { }); }).toThrowError(new ActivityDefinitionNotFoundError("unknownActivity", ["validActivity"])); }); + + it("rejects stray root-level keys even when the contract has no global activities", () => { + // Previously the stray-key check only ran when `contract.activities` + // existed — an unknown root key on an activity-less contract was + // silently ignored. + const contract = { + taskQueue: "test-queue", + workflows: { + noopWorkflow: { + input: z.object({}), + output: z.object({}), + }, + }, + } satisfies ContractDefinition; + + expect(() => { + declareActivitiesHandler({ + contract, + // The implementations type collapses to `{}` here (no globals, no + // workflow activities), which TypeScript can't flag excess keys + // against — the runtime check is the only guard. + activities: { + strayActivity: (_args: unknown) => okAsync({}), + }, + }); + }).toThrowError(new ActivityDefinitionNotFoundError("strayActivity", [])); + }); + + it("fails fast when a declared global activity has no implementation", () => { + const contract = { + taskQueue: "test-queue", + workflows: {}, + activities: { + implemented: { + input: z.object({}), + output: z.object({}), + }, + forgotten: { + input: z.object({}), + output: z.object({}), + }, + }, + } satisfies ContractDefinition; + + expect(() => { + declareActivitiesHandler({ + contract, + // @ts-expect-error - intentionally omitting a declared implementation + activities: { + implemented: (_args: unknown) => okAsync({}), + }, + }); + }).toThrow(/missing implementation for declared activity: forgotten/); + }); + + it("fails fast when a declared workflow-local activity has no implementation", () => { + const contract = { + taskQueue: "test-queue", + workflows: { + orderWorkflow: { + input: z.object({}), + output: z.object({}), + activities: { + validateOrder: { + input: z.object({}), + output: z.object({}), + }, + shipOrder: { + input: z.object({}), + output: z.object({}), + }, + }, + }, + }, + } satisfies ContractDefinition; + + // Namespace present but incomplete — the missing key is reported with + // its owning workflow. + expect(() => { + declareActivitiesHandler({ + contract, + activities: { + // @ts-expect-error - intentionally omitting a declared implementation + orderWorkflow: { + validateOrder: (_args: unknown) => okAsync({}), + }, + }, + }); + }).toThrow(/missing implementation for declared activity: orderWorkflow\.shipOrder/); + + // Namespace absent entirely — every declared activity is reported. + expect(() => { + declareActivitiesHandler({ + contract, + // @ts-expect-error - intentionally omitting the whole namespace + activities: {}, + }); + }).toThrow( + /missing implementations for declared activities: orderWorkflow\.validateOrder, orderWorkflow\.shipOrder/, + ); + }); + + 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. + const contract = { + taskQueue: "test-queue", + workflows: { + conflicted: { + input: z.object({}), + output: z.object({}), + }, + }, + activities: { + conflicted: { + input: z.object({}), + output: z.object({}), + }, + }, + } satisfies ContractDefinition; + + expect(() => { + declareActivitiesHandler({ + contract, + activities: { + conflicted: (_args: unknown) => okAsync({}), + }, + }); + }).toThrow(/global activity "conflicted" has the same name as a workflow/); + }); + }); + + describe("wire format (validate on send, parse on receive)", () => { + // D1: the activity handler is the RECEIVING side of the input boundary — + // it parses the payload exactly once, so the implementation sees the + // transformed value. For the output it is the SENDING side: the return + // value is validated (fail early) but Temporal gets the implementation's + // ORIGINAL value; the workflow-side proxy parses it on receive. + const transformContract = { + taskQueue: "test-queue", + workflows: {}, + activities: { + transformer: { + input: z.object({ text: z.string().transform((s) => `${s}!`) }), + output: z.object({ n: z.number().transform((n) => n * 2) }), + }, + }, + } satisfies ContractDefinition; + + it("the implementation receives the PARSED input (transform applied once)", async () => { + const seen: unknown[] = []; + const activities = declareActivitiesHandler({ + contract: transformContract, + activities: { + transformer: (args) => { + seen.push(args); + return okAsync({ n: 21 }); + }, + }, + }); + + await activities.transformer({ text: "hi" }); + + expect(seen).toEqual([{ text: "hi!" }]); + }); + + it("Temporal gets the implementation's ORIGINAL output, not the parsed value", async () => { + const activities = declareActivitiesHandler({ + contract: transformContract, + activities: { + transformer: () => okAsync({ n: 21 }), + }, + }); + + const result = await activities.transformer({ text: "hi" }); + + // Validated against the output schema, but transmitted untransformed — + // the receiving side (workflow proxy / raw caller) parses it. + expect(result).toEqual({ n: 21 }); + }); }); describe("Error Handling", () => { @@ -395,13 +575,13 @@ describe("Worker unthrown Package", () => { }); }); - describe("qualify", () => { + describe("qualifyFailure", () => { it("wraps an Error rejection in an ApplicationFailure with the given type", () => { // GIVEN const cause = new Error("connection refused"); // WHEN - const failure = qualify("EMAIL_SEND_FAILED")(cause); + const failure = qualifyFailure("EMAIL_SEND_FAILED")(cause); // THEN expect(failure).toBeInstanceOf(ApplicationFailure); @@ -413,7 +593,9 @@ describe("Worker unthrown Package", () => { it("uses the fallback message for a non-Error rejection", () => { // WHEN - const failure = qualify("PAYMENT_FAILED", { message: "Failed to charge card" })("boom"); + const failure = qualifyFailure("PAYMENT_FAILED", { message: "Failed to charge card" })( + "boom", + ); // THEN expect(failure.type).toBe("PAYMENT_FAILED"); @@ -423,7 +605,7 @@ describe("Worker unthrown Package", () => { it("stringifies a non-Error rejection when no fallback message is given", () => { // WHEN - const failure = qualify("PAYMENT_FAILED")({ code: 42 }); + const failure = qualifyFailure("PAYMENT_FAILED")({ code: 42 }); // THEN expect(failure.message).toBe("[object Object]"); @@ -432,7 +614,9 @@ describe("Worker unthrown Package", () => { it("prefers the rejection's own message over the fallback for Error rejections", () => { // WHEN - const failure = qualify("PAYMENT_FAILED", { message: "fallback" })(new Error("declined")); + const failure = qualifyFailure("PAYMENT_FAILED", { message: "fallback" })( + new Error("declined"), + ); // THEN expect(failure.message).toBe("declined"); @@ -440,7 +624,7 @@ describe("Worker unthrown Package", () => { it("forwards nonRetryable and details to the failure", () => { // WHEN - const failure = qualify("INSUFFICIENT_FUNDS", { + const failure = qualifyFailure("INSUFFICIENT_FUNDS", { nonRetryable: true, details: [{ balance: 0 }], })(new Error("declined")); @@ -455,7 +639,7 @@ describe("Worker unthrown Package", () => { const inner = ApplicationFailure.create({ type: "INNER", message: "already modeled" }); // WHEN - const failure = qualify("OUTER")(inner); + const failure = qualifyFailure("OUTER")(inner); // THEN — callers can rely on `type` for retry policies; the original // failure is preserved as `cause`. diff --git a/packages/worker/src/activity.ts b/packages/worker/src/activity.ts index a69865a2..ed963a44 100644 --- a/packages/worker/src/activity.ts +++ b/packages/worker/src/activity.ts @@ -73,20 +73,20 @@ export { * * @example * ```ts - * import { declareActivitiesHandler, qualify } from '@temporal-contract/worker/activity'; + * import { declareActivitiesHandler, qualifyFailure } from '@temporal-contract/worker/activity'; * import { fromPromise } from 'unthrown'; * * export const activities = declareActivitiesHandler({ * contract: myContract, * activities: { * sendEmail: (args) => - * fromPromise(emailService.send(args), qualify('EMAIL_SEND_FAILED')) + * fromPromise(emailService.send(args), qualifyFailure('EMAIL_SEND_FAILED')) * .map(() => ({ sent: true })), * chargeCard: (args) => * fromPromise( * paymentGateway.charge(args), * // Permanent failure: opt out of the configured retry policy. - * qualify('CARD_DECLINED', { nonRetryable: true }), + * qualifyFailure('CARD_DECLINED', { nonRetryable: true }), * ), * }, * }); @@ -102,7 +102,7 @@ export { * `type`/`nonRetryable: true` is masked — pass `{ nonRetryable: true }` here (or * write a custom qualifier) if that inner failure should stay non-retryable. */ -export function qualify( +export function qualifyFailure( type: string, options?: { /** Fallback message when the rejection is not an `Error` (default: `String(error)`). */ @@ -209,6 +209,10 @@ type ResultActivityImplementation< * * In short: write nested (mirror the contract); the wrapper flattens * for Temporal. + * + * Workflows that declare **no activities** are filtered out of the map via + * key remapping — they don't require (or accept) an empty `workflowName: {}` + * placeholder entry. */ type ContractResultActivitiesImplementations< TContract extends ContractDefinition, @@ -218,16 +222,36 @@ type ContractResultActivitiesImplementations< (TContract["activities"] extends Record ? ResultActivitiesImplementations : {}) & - // All workflow-specific activities merged + // All workflow-specific activities merged; workflows without activities + // are remapped away entirely instead of demanding a `{}` entry. { - [TWorkflow in keyof TContract["workflows"]]: TContract["workflows"][TWorkflow]["activities"] extends Record< + [TWorkflow in keyof TContract["workflows"] as WorkflowHasActivities< + TContract["workflows"][TWorkflow] + > extends true + ? TWorkflow + : never]: TContract["workflows"][TWorkflow]["activities"] extends Record< string, ActivityDefinition > ? ResultActivitiesImplementations - : {}; + : never; }; +/** + * `true` when a workflow definition declares a non-empty `activities` map. + * Drives the key remapping in {@link ContractResultActivitiesImplementations} + * so activity-less workflows don't demand placeholder `{}` entries in the + * implementations map. An absent map and an explicitly empty `activities: {}` + * both count as "no activities". + */ +type WorkflowHasActivities = TWorkflow extends { + activities: infer TActivities extends Record; +} + ? [keyof TActivities] extends [never] + ? false + : true + : false; + type ResultActivitiesImplementations< TActivities extends Record, TContext extends Record | EmptyContext = EmptyContext, @@ -289,7 +313,7 @@ export type ActivityMiddlewareNext< * `next({ context })`. A middleware that only reads context leaves both * parameters equal and stays valid unchanged. Compose typed chains with * {@link composeActivityMiddleware}; pin a middleware's context types - * without a variable annotation via {@link defineActivityMiddleware}. + * without a variable annotation via {@link declareActivityMiddleware}. * * @example Log every activity invocation and its outcome (read-only) * ```ts @@ -310,7 +334,7 @@ export type ActivityMiddlewareNext< * * @example Guard-and-narrow: inject a tenant id for everything downstream * ```ts - * const auth = defineActivityMiddleware( + * const auth = declareActivityMiddleware( * (invocation, next) => { * const tenantId = readTenant(invocation.input); * if (!tenantId) { @@ -346,7 +370,7 @@ export type AnyActivityMiddleware = ActivityMiddleware< * Identity helper that pins a middleware's context types without a variable * annotation. (Mirrors amqp-contract's `defineMiddleware`.) */ -export function defineActivityMiddleware< +export function declareActivityMiddleware< TContextIn extends Record | EmptyContext = EmptyContext, TContextOut extends TContextIn = TContextIn, >( @@ -500,7 +524,16 @@ type DeclareActivitiesHandlerOptions< TInjected extends TContext = TContext, > = { contract: TContract; - activities: ContractResultActivitiesImplementations; + /** + * Nested implementations map mirroring the contract's structure — see + * {@link ContractResultActivitiesImplementations}. + * + * Wrapped in `NoInfer` so `TContract`/`TInjected` are inferred from + * `contract`/`middleware` only: letting TypeScript infer *into* the + * key-remapped mapped type breaks contextual typing of the implementation + * lambdas (their `args` degrade to implicit `any`). + */ + activities: NoInfer>; /** * Build the typed dependency context *seed* handed to the middleware * chain and, accumulated, to every implementation as `helpers.context`. @@ -584,7 +617,7 @@ export type ActivitiesHandler = * ```ts * import { declareActivitiesHandler, ApplicationFailure } from '@temporal-contract/worker/activity'; * import { fromPromise, Ok, Err } from 'unthrown'; - * import myContract from './contract.js'; + * import { myContract } from './contract.js'; * * export const activities = declareActivitiesHandler({ * contract: myContract, @@ -599,12 +632,14 @@ export type ActivitiesHandler = * (error) => * // Wrap technical errors in ApplicationFailure. `nonRetryable` * // is per-instance: set it to true on permanent failures so - * // Temporal stops retrying immediately. + * // Temporal stops retrying immediately. Note the conditional + * // spread for `cause` — under `exactOptionalPropertyTypes`, + * // omit the key entirely rather than passing `undefined`. * ApplicationFailure.create({ * type: 'EMAIL_SEND_FAILED', * message: 'Failed to send email', * nonRetryable: false, - * cause: error instanceof Error ? error : undefined, + * ...(error instanceof Error ? { cause: error } : {}), * }), * ).flatMap((outcome) => * outcome.accepted @@ -616,14 +651,17 @@ export type ActivitiesHandler = * }, * }); * - * // Use with Temporal Worker - * import { Worker } from '@temporalio/worker'; - * import { workflowsPathFromURL } from '@temporal-contract/worker/worker'; + * // 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'; * - * const worker = await Worker.create({ + * const connection = await NativeConnection.connect({ address: 'localhost:7233' }); + * const workerResult = await createWorker({ + * contract: myContract, + * connection, * workflowsPath: workflowsPathFromURL(import.meta.url, './workflows.js'), - * activities: activities, - * taskQueue: contract.taskQueue, + * activities, * }); * ``` * @@ -634,8 +672,9 @@ export type ActivitiesHandler = * thrown; `Err(ContractError)` → data validated against the declared schema * and thrown as an `ApplicationFailure` with `type` = error name, * `details[0]` = data, `nonRetryable` from the contract; `Ok(...)` → output - * validated against the contract and resolved; `defect` → original cause - * re-thrown). It does **not** hide Temporal's + * validated against the contract, then resolved with the implementation's + * original value — the calling side parses it on receive; `defect` → + * original cause re-thrown). It does **not** hide Temporal's * `@temporalio/activity` runtime: inside the body you can still call * `Context.current()` from `@temporalio/activity` to access heartbeats * (`heartbeat(details)`, `heartbeatDetails`), activity info (attempt @@ -678,7 +717,10 @@ export function declareActivitiesHandler< return async (...args: unknown[]) => { const input = extractHandlerInput(args); - // Validate input + // Parse input. This is the RECEIVING side of the boundary: the + // workflow-side proxy (or typed client) validated the payload but + // transmitted the caller's original value, so the parse (and any + // schema transform) happens exactly once, here. const inputResult = await activityDef.input["~standard"].validate(input); if (inputResult.issues) { throw new ActivityInputValidationError(label, inputResult.issues); @@ -737,11 +779,15 @@ export function declareActivitiesHandler< // not a domain error). return result.match({ ok: async (value) => { + // Validate the output, but hand Temporal the implementation's + // ORIGINAL value — the consuming side (workflow proxy / typed + // client) parses the result, so a transforming output schema is + // applied exactly once, on receive. const outputResult = await activityDef.output["~standard"].validate(value); if (outputResult.issues) { throw new ActivityOutputValidationError(label, outputResult.issues); } - return outputResult.value; + return value; }, // Convert Err(...) payload to thrown ApplicationFailure for Temporal. // Temporal recognizes this class natively and applies the configured @@ -780,17 +826,42 @@ export function declareActivitiesHandler< }; } - // 1) Wrap global activities defined directly under contract.activities + type ErasedImplementation = ( + args: unknown, + helpers: { errors: unknown; context: unknown }, + ) => AsyncResult; + + const implementationMap = activities as Record; + const workflowDefs = contract.workflows ?? {}; + + // 0) Defense-in-depth: a global activity sharing a workflow's name makes + // the root of this implementations map ambiguous. `defineContract` + // rejects this too; the check is repeated here for contracts built as + // plain object literals (or from JavaScript) that never went through + // `defineContract`. The message is aligned with the contract-side one. if (contract.activities) { - for (const [activityName, impl] of Object.entries(activities)) { - // Skip workflow namespaces if present at root - if (contract.workflows && activityName in contract.workflows) { - continue; + for (const activityName of Object.keys(contract.activities)) { + if (Object.hasOwn(workflowDefs, activityName)) { + throw new Error( + `global activity "${activityName}" has the same name as a workflow. Workflows and global activities share the root of the worker implementations map — rename one of them.`, + ); } + } + } + + // Iterate the contract DEFINITIONS (not just the provided implementations) + // so a declared-but-missing implementation fails fast at declaration time + // with a clear error, instead of surfacing as an opaque "activity not + // registered" failure the first time Temporal dispatches a task for it. + const missingImplementations: string[] = []; - const activityDef = contract.activities[activityName]; - if (!activityDef) { - throw new ActivityDefinitionNotFoundError(activityName, Object.keys(contract.activities)); + // 1) Global activities declared under contract.activities. + if (contract.activities) { + for (const [activityName, activityDef] of Object.entries(contract.activities)) { + const impl = implementationMap[activityName]; + if (typeof impl !== "function") { + missingImplementations.push(activityName); + continue; } // Assign wrapped global activity @@ -798,49 +869,63 @@ export function declareActivitiesHandler< activityName, { activityName, workflowName: undefined }, activityDef, - impl as ( - args: unknown, - helpers: { errors: unknown; context: unknown }, - ) => AsyncResult, + impl as ErasedImplementation, ); } } - // 2) Wrap workflow-scoped activities at root level (flat) - if (contract.workflows) { - for (const [workflowName, workflowDef] of Object.entries(contract.workflows)) { - const wfActivitiesImpl = (activities as Record)[workflowName] as - | Record - | undefined; - if (!wfActivitiesImpl) { - // If no implementations provided for this workflow, skip (TypeScript typing should enforce completeness for declared ones) + // 2) Workflow-scoped activities, flattened to the root level. + for (const [workflowName, workflowDef] of Object.entries(workflowDefs)) { + const wfDefs = workflowDef.activities ?? {}; + const wfActivitiesImpl = implementationMap[workflowName] as Record | undefined; + + for (const [activityName, activityDef] of Object.entries(wfDefs)) { + const impl = wfActivitiesImpl?.[activityName]; + if (typeof impl !== "function") { + missingImplementations.push(`${workflowName}.${activityName}`); continue; } - const wfDefs = workflowDef.activities ?? {}; + // Assign workflow activity directly at root level (flat structure) + (wrappedActivities as Record)[activityName] = makeWrapped( + `${workflowName}.${activityName}`, + { activityName, workflowName }, + activityDef, + impl as ErasedImplementation, + ); + } - for (const [activityName, impl] of Object.entries(wfActivitiesImpl)) { - const activityDef = wfDefs[activityName]; - if (!activityDef) { + // Stray keys inside a workflow namespace: implementations for activities + // the workflow never declared. + if (wfActivitiesImpl) { + for (const activityName of Object.keys(wfActivitiesImpl)) { + if (!Object.hasOwn(wfDefs, activityName)) { throw new ActivityDefinitionNotFoundError( `${workflowName}.${activityName}`, Object.keys(wfDefs), ); } - - // Assign workflow activity directly at root level (flat structure) - (wrappedActivities as Record)[activityName] = makeWrapped( - `${workflowName}.${activityName}`, - { activityName, workflowName }, - activityDef, - impl as ( - args: unknown, - helpers: { errors: unknown; context: unknown }, - ) => AsyncResult, - ); } } } + if (missingImplementations.length > 0) { + throw new Error( + `declareActivitiesHandler: missing implementation${missingImplementations.length > 1 ? "s" : ""} ` + + `for declared activit${missingImplementations.length > 1 ? "ies" : "y"}: ` + + `${missingImplementations.join(", ")}. Every activity declared on the contract must be implemented.`, + ); + } + + // 3) Stray root-level keys: anything that is neither a declared global + // activity nor a workflow namespace is a typo (or a stale entry from a + // renamed activity). This check runs whether or not `contract.activities` + // exists — an unknown key is an error either way. + for (const key of Object.keys(implementationMap)) { + if (Object.hasOwn(workflowDefs, key)) continue; // workflow namespace, validated above + if (contract.activities && Object.hasOwn(contract.activities, key)) continue; + throw new ActivityDefinitionNotFoundError(key, Object.keys(contract.activities ?? {})); + } + return wrappedActivities; } diff --git a/packages/worker/src/child-workflow.spec.ts b/packages/worker/src/child-workflow.spec.ts index 10369d42..ad1b7cb7 100644 --- a/packages/worker/src/child-workflow.spec.ts +++ b/packages/worker/src/child-workflow.spec.ts @@ -176,5 +176,11 @@ describe("classifyChildWorkflowError", () => { const result = classifyChildWorkflowError("result", new Error("boom"), "myChild"); expect(result.message).toContain("execution failed"); }); + + it("uses the signal phrasing for `signal`", () => { + const result = classifyChildWorkflowError("signal", new Error("boom"), "myChild"); + expect(result.message).toContain("Failed to signal child workflow"); + expect(result.message).toContain("myChild"); + }); }); }); diff --git a/packages/worker/src/child-workflow.ts b/packages/worker/src/child-workflow.ts index 7a4e87c5..865eb0f6 100644 --- a/packages/worker/src/child-workflow.ts +++ b/packages/worker/src/child-workflow.ts @@ -3,7 +3,13 @@ * `workflow.ts` to keep that file focused on `declareWorkflow` and its * `WorkflowContext` type. Not part of the worker package's public exports. */ -import type { AnyWorkflowDefinition, ContractDefinition } from "@temporal-contract/contract"; +import type { + AnyWorkflowDefinition, + ContractDefinition, + SignalDefinition, + SignalNamesOf, +} from "@temporal-contract/contract"; +import { summarizeIssues } from "@temporal-contract/contract"; import { type ChildWorkflowHandle, type ChildWorkflowOptions, @@ -24,7 +30,7 @@ import { formatChildWorkflowValidationMessage, makeAsyncResult, } from "./internal.js"; -import type { ClientInferInput, ClientInferOutput, WorkerInferInput } from "./types.js"; +import type { ClientInferInput, ClientInferOutput, SignalDefOf } from "./types.js"; /** * Options for starting a child workflow. `taskQueue` and `args` come from @@ -38,6 +44,23 @@ export type TypedChildWorkflowOptions< args: ClientInferInput; }; +/** + * Typed signal senders for a child workflow, keyed by the signal names + * declared on the child's contract entry. Mirrors the shape of the typed + * client handle's `signals` proxy: one function per declared signal, taking + * the signal's (client-perspective) input and returning an `AsyncResult`. + * + * Per the wire-format rule (D1), the sender validates `args` against the + * signal's input schema — failing early with `Err(ChildWorkflowError)` — + * but transmits the caller's ORIGINAL value; the child's signal handler + * parses it on receive, so a transforming schema applies exactly once. + */ +export type TypedChildWorkflowSignals = { + [K in SignalNamesOf]: ( + args: ClientInferInput>, + ) => AsyncResult; +}; + /** * Typed handle for a child workflow with unthrown `AsyncResult` pattern. */ @@ -50,12 +73,31 @@ export type TypedChildWorkflowHandle = ChildWorkflowError | ChildWorkflowCancelledError >; + /** + * Typed signal senders for the child's declared signals — see + * {@link TypedChildWorkflowSignals}. Empty when the child declares none. + */ + signals: TypedChildWorkflowSignals; + /** * Child workflow ID. */ workflowId: string; + + /** + * Run ID of the child's first execution — the anchor of its execution + * chain (stable across continue-as-new), mirroring the field Temporal + * exposes on its own `ChildWorkflowHandle`. + */ + firstExecutionRunId: string; }; +/** + * Parse a child workflow's result against its output schema. The parent is + * the RECEIVING side of the result boundary — the child validated its return + * and transmitted the original value, so the parse (and any schema + * transform) happens exactly once, here. + */ async function validateChildWorkflowOutput( childDefinition: TChildWorkflow, result: unknown, @@ -72,6 +114,13 @@ async function validateChildWorkflowOutput); } +/** + * Resolve the child-workflow definition and validate `args` against its + * input schema. The parent is the SENDING side of the input boundary, so + * the parsed value is discarded — the caller transmits the original `args` + * and the child's `declareWorkflow` parses them on receive, applying a + * transforming schema exactly once. + */ async function getAndValidateChildWorkflow< TChildContract extends ContractDefinition, TChildWorkflowName extends keyof TChildContract["workflows"] & string, @@ -83,7 +132,6 @@ async function getAndValidateChildWorkflow< Result< { definition: TChildContract["workflows"][TChildWorkflowName]; - validatedInput: WorkerInferInput; taskQueue: string; }, ChildWorkflowError | ChildWorkflowNotFoundError @@ -109,17 +157,60 @@ async function getAndValidateChildWorkflow< ); } - const validatedInput = inputResult.value as WorkerInferInput< - TChildContract["workflows"][TChildWorkflowName] - >; - return Ok({ definition: childDefinition as TChildContract["workflows"][TChildWorkflowName], - validatedInput, taskQueue: childContract.taskQueue, }); } +/** + * Build the typed `signals` map for a child handle. One sender per signal + * declared on the child's contract entry: validates `args` (fail early with + * a descriptive `ChildWorkflowError`), then transmits the caller's ORIGINAL + * value via `handle.signal` — the child parses on receive (D1). Errors from + * the signal call itself are classified like the other child-workflow + * operations (cancellation → `ChildWorkflowCancelledError`). + */ +function createTypedChildSignals( + handle: ChildWorkflowHandle, + childDefinition: TChildWorkflow, + childWorkflowName: string, +): TypedChildWorkflowSignals { + const signals: Record< + string, + (args: unknown) => AsyncResult + > = {}; + + const signalDefs = (childDefinition.signals ?? {}) as Record; + for (const [signalName, signalDef] of Object.entries(signalDefs)) { + signals[signalName] = (args: unknown) => { + const work = async (): Promise< + Result + > => { + const inputResult = await signalDef.input["~standard"].validate(args); + if (inputResult.issues) { + return Err( + new ChildWorkflowError( + `Child workflow "${childWorkflowName}" signal "${signalName}" input validation failed: ${summarizeIssues(inputResult.issues)}`, + ), + ); + } + try { + // Transmit the caller's ORIGINAL args — validated above, parsed by + // the child's signal handler on receive (D1). + await handle.signal(signalName, args); + return Ok(undefined); + } catch (error) { + return Err(classifyChildWorkflowError("signal", error, childWorkflowName)); + } + }; + return makeAsyncResult(work); + }; + } + + return signals as TypedChildWorkflowSignals; +} + function createTypedChildHandle( handle: ChildWorkflowHandle, childDefinition: TChildWorkflow, @@ -127,6 +218,8 @@ function createTypedChildHandle( ): TypedChildWorkflowHandle { return { workflowId: handle.workflowId, + firstExecutionRunId: handle.firstExecutionRunId, + signals: createTypedChildSignals(handle, childDefinition, childWorkflowName), result: (): AsyncResult< ClientInferOutput, ChildWorkflowError | ChildWorkflowCancelledError @@ -174,14 +267,16 @@ export function createStartChildWorkflow< return Err(validationResult.error); } - const { definition: childDefinition, validatedInput, taskQueue } = validationResult.value; + const { definition: childDefinition, taskQueue } = validationResult.value; try { - const { args: _args, ...temporalOptions } = options; + // Transmit the caller's ORIGINAL args — validated above, parsed by + // the child workflow on receive (D1). + const { args: childArgs, ...temporalOptions } = options; const handle = await startChild(childWorkflowName, { ...temporalOptions, taskQueue, - args: [validatedInput], + args: [childArgs], }); const typedHandle = createTypedChildHandle(handle, childDefinition, childWorkflowName) as Ok; @@ -220,14 +315,16 @@ export function createExecuteChildWorkflow< return Err(validationResult.error); } - const { definition: childDefinition, validatedInput, taskQueue } = validationResult.value; + const { definition: childDefinition, taskQueue } = validationResult.value; try { - const { args: _args, ...temporalOptions } = options; + // Transmit the caller's ORIGINAL args — validated above, parsed by + // the child workflow on receive (D1). + const { args: childArgs, ...temporalOptions } = options; const result = await executeChild(childWorkflowName, { ...temporalOptions, taskQueue, - args: [validatedInput], + args: [childArgs], }); const outputValidationResult = await validateChildWorkflowOutput( diff --git a/packages/worker/src/continue-as-new.spec.ts b/packages/worker/src/continue-as-new.spec.ts index d32cbc1d..3bfd536b 100644 --- a/packages/worker/src/continue-as-new.spec.ts +++ b/packages/worker/src/continue-as-new.spec.ts @@ -106,6 +106,26 @@ describe("context.continueAsNew", () => { expect(captured).toHaveLength(0); }); + it("transmits the ORIGINAL args, not the parsed value (D1 wire format)", async () => { + // continueAsNew is a sending side: it validates (fail early) but the new + // run's `declareWorkflow` parses on receive, so a transforming schema + // must not be applied here. + const transformContract = defineContract({ + taskQueue: "tq-transform", + workflows: { + transformer: defineWorkflow({ + input: z.object({ text: z.string().transform((s) => `${s}!`) }), + output: z.object({}), + }), + }, + }); + const continueAsNew = createContinueAsNew(transformContract, "transformer"); + + await expect(continueAsNew({ text: "hi" })).rejects.toThrow("__STUB_CONTINUE_AS_NEW__"); + + expect(captured[0]?.args).toEqual([{ text: "hi" }]); // not [{ text: "hi!" }] + }); + it("cross-contract: pulls workflowType and taskQueue from the destination contract", async () => { const continueAsNew = createContinueAsNew(contract, "counter"); diff --git a/packages/worker/src/contract-errors.ts b/packages/worker/src/contract-errors.ts index dbaa8ef2..5b7bdf29 100644 --- a/packages/worker/src/contract-errors.ts +++ b/packages/worker/src/contract-errors.ts @@ -18,7 +18,10 @@ import { ContractErrorDataValidationError } from "./errors.js"; * * - `type` = the declared error name (drives caller branching and * `retry.nonRetryableErrorTypes`), - * - `details[0]` = the data payload, validated against the declared schema, + * - `details[0]` = the ORIGINAL data payload. It is validated against the + * 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, * - `nonRetryable` = the contract's declaration (default retryable), * - `cause` = the constructor-supplied cause, so stack traces survive. * @@ -49,7 +52,8 @@ export async function contractErrorToApplicationFailure( if (validated.issues) { throw new ContractErrorDataValidationError(error.errorName, validated.issues); } - details = [validated.value]; + // Transmit the ORIGINAL payload — the rehydrating side parses it (D1). + details = [error.data]; } return ApplicationFailure.create({ diff --git a/packages/worker/src/errors.spec.ts b/packages/worker/src/errors.spec.ts index a2d43fde..b67cdc4b 100644 --- a/packages/worker/src/errors.spec.ts +++ b/packages/worker/src/errors.spec.ts @@ -16,7 +16,7 @@ import { describe, expect, it } from "vitest"; import { ActivityInputValidationError, ActivityOutputValidationError, - SignalInputValidationError, + ContractMisuseError, UpdateOutputValidationError, ValidationError, WorkflowInputValidationError, @@ -214,7 +214,7 @@ describe("validation errors are terminal Temporal failures (#251)", () => { () => new WorkflowOutputValidationError("wf", [issue("bad")]), () => new ActivityInputValidationError("act", [issue("bad")]), () => new ActivityOutputValidationError("act", [issue("bad")]), - () => new SignalInputValidationError("sig", [issue("bad")]), + () => new ContractMisuseError("misused"), () => new UpdateOutputValidationError("upd", [issue("bad")]), ]; @@ -249,6 +249,21 @@ describe("validation errors are terminal Temporal failures (#251)", () => { expect(error.name).toBe("Wrapped"); }); + it("keeps `name` non-enumerable, matching plain-Error semantics", () => { + // A serialized / spread / Object.keys'd error must not carry a stray + // `name` data property — plain `Error` keeps it on the prototype. + const error = new WorkflowInputValidationError("wf", [issue("bad")]); + expect(Object.prototype.propertyIsEnumerable.call(error, "name")).toBe(false); + expect(Object.keys(error)).not.toContain("name"); + }); + + it("ContractMisuseError carries the misuse message and empty issues", () => { + const error = new ContractMisuseError('Signal "x" not found in workflow "wf" contract'); + expect(error.message).toBe('Signal "x" not found in workflow "wf" contract'); + expect(error.type).toBe("ContractMisuseError"); + expect(error.issues).toEqual([]); + }); + it("still narrow to their concrete subclass in-process", () => { const error: unknown = new ActivityInputValidationError("act", [issue("bad")]); expect(error).toBeInstanceOf(ActivityInputValidationError); diff --git a/packages/worker/src/errors.ts b/packages/worker/src/errors.ts index 871f441b..ff0965c0 100644 --- a/packages/worker/src/errors.ts +++ b/packages/worker/src/errors.ts @@ -5,7 +5,9 @@ import { TaggedError } from "unthrown"; /** * Base class for the contract's runtime validation failures — workflow and - * activity input/output, plus signal/query/update payloads. + * activity input/output, plus query/update payloads. (Invalid *signal* + * payloads are not errors: signals are fire-and-forget, so the worker drops + * and logs them instead of failing the execution.) * * These extend Temporal's {@link ApplicationFailure} with `nonRetryable: true` * rather than a plain `Error`, and that distinction is load-bearing. The @@ -45,11 +47,13 @@ export abstract class ValidationError extends ApplicationFailure { // and surface the concrete subclass name (matching `type`). `writable: true` // keeps the field reassignable, matching the previous `this.name = ...` // behaviour so consumers (e.g. error-wrapping code) can still adjust it. + // `enumerable: false` matches plain-`Error` semantics — `name` shouldn't + // show up in `Object.keys(...)` / spread / JSON serialization of the error. Object.defineProperty(this, "name", { value: type, writable: true, configurable: true, - enumerable: true, + enumerable: false, }); // Maintains proper stack trace for where our error was thrown (only available on V8) if (Error.captureStackTrace) { @@ -143,23 +147,6 @@ export class WorkflowOutputValidationError extends ValidationError { } } -/** - * Error thrown when signal input validation fails - */ -export class SignalInputValidationError extends ValidationError { - constructor( - public readonly signalName: string, - issues: ReadonlyArray, - ) { - const message = summarizeIssues(issues); - super( - `Signal "${signalName}" input validation failed: ${message}`, - "SignalInputValidationError", - issues, - ); - } -} - /** * Error thrown when query input validation fails */ @@ -249,6 +236,30 @@ export class ContractErrorDataValidationError extends ValidationError { } } +/** + * Error thrown when workflow-sandbox code misuses the contract surface — + * binding a signal/query/update handler for a name the contract doesn't + * declare, using an async-validating schema where Temporal requires + * synchronous validation, or reaching an activity that no options cover. + * + * Extends {@link ValidationError} for the same load-bearing reason as its + * siblings: a plain `Error` thrown from *workflow* code is classified by the + * TypeScript SDK as a Workflow Task failure and retried indefinitely, + * leaving the execution silently `Running` forever. Contract misuse is a + * deterministic programming bug — it never becomes valid on replay — so it + * must fail the Workflow Execution terminally as a non-retryable + * `ApplicationFailure` with a clear message, not hang in an infinite + * Workflow Task retry loop. + * + * Carries no schema `issues` (the misuse is structural, not a payload + * validation failure), so the `issues` array is always empty. + */ +export class ContractMisuseError extends ValidationError { + constructor(message: string) { + super(message, "ContractMisuseError", []); + } +} + /** * Generic error surfaced on the `Err(...)` branch when an activity that * declares contract errors fails for a reason *other* than one of its diff --git a/packages/worker/src/handlers.spec.ts b/packages/worker/src/handlers.spec.ts index 529692c7..f6303e9f 100644 --- a/packages/worker/src/handlers.spec.ts +++ b/packages/worker/src/handlers.spec.ts @@ -1,18 +1,26 @@ -import { defineWorkflow } from "@temporal-contract/contract"; +import { defineSignal as defineContractSignal, defineWorkflow } from "@temporal-contract/contract"; /** * Coverage for the hoisted signal/query/update bind helpers * (`bindSignalHandler`, `bindQueryHandler`, `bindUpdateHandler`). * - * Mocks `@temporalio/workflow`'s `defineSignal/Query/Update` and - * `setHandler` so the helpers are exercisable outside a real workflow + * Mocks `@temporalio/workflow`'s `defineSignal/Query/Update`, `setHandler`, + * and `log` so the helpers are exercisable outside a real workflow * context. Asserts that: * - * - missing-block runtime guards fire with a clear error, - * - unknown-name runtime guards fire with a clear error, - * - validation failures throw the right typed error class, - * - the handler receives the *validated* (parsed) input, + * - missing-block runtime guards fire with a `ContractMisuseError` (a + * non-retryable ApplicationFailure — a plain Error would hang the + * execution in infinite Workflow Task retries), + * - unknown-name runtime guards fire the same way, + * - invalid *signal* payloads are dropped and logged (`log.warn`), never + * thrown — signals are fire-and-forget (D2), + * - query/update validation failures throw the right typed error class, + * - the handler receives the *validated* (parsed) input — the worker is the + * receiving side of the input boundary, so the parse happens exactly once + * here (the client validated but transmitted the caller's original value), * - query's sync-only validation guard rejects async-validating schemas, - * - update's output is validated against the contract before resolving. + * - update/query outputs are validated against the contract, but the + * handler's ORIGINAL return value is what resolves — the client parses the + * result on receive, so a transforming output schema applies exactly once. * * Closes #185. */ @@ -30,6 +38,7 @@ vi.mock("@temporalio/workflow", () => ({ defineSignal: vi.fn((name: string) => ({ kind: "signal", name }) as const), defineQuery: vi.fn((name: string) => ({ kind: "query", name }) as const), defineUpdate: vi.fn((name: string) => ({ kind: "update", name }) as const), + log: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn(), trace: vi.fn() }, setHandler: vi.fn( ( handle: { kind: string; name: string }, @@ -46,11 +55,12 @@ vi.mock("@temporalio/workflow", () => ({ ), })); +const { log } = await import("@temporalio/workflow"); const { bindSignalHandler, bindQueryHandler, bindUpdateHandler } = await import("./handlers.js"); const { + ContractMisuseError, QueryInputValidationError, QueryOutputValidationError, - SignalInputValidationError, UpdateInputValidationError, UpdateOutputValidationError, } = await import("./errors.js"); @@ -96,30 +106,60 @@ describe("bindSignalHandler", () => { expect(handler).toHaveBeenCalledWith([{ reason: "user requested" }]); }); - it("throws SignalInputValidationError when the payload doesn't match the contract", async () => { + it("drops the signal and logs a warning when the payload doesn't match the contract (D2)", async () => { captured.length = 0; - bindSignalHandler(workflow, "probe", "cancel", vi.fn() as never); + vi.mocked(log.warn).mockClear(); + const handler = vi.fn(); + bindSignalHandler(workflow, "probe", "cancel", handler as never); const entry = captured.find((c) => c.kind === "signal" && c.name === "cancel")!; - // Wrong shape — missing `reason`. - await expect(entry.impl([{ wrongField: 1 }])).rejects.toBeInstanceOf( - SignalInputValidationError, - ); + + // Wrong shape — missing `reason`. Signals are fire-and-forget: an + // invalid payload must never fail the execution, so the handler resolves + // without throwing, the user handler is never invoked, and the drop is + // logged with the signal name plus an issues summary. + await expect(entry.impl([{ wrongField: 1 }])).resolves.toBeUndefined(); + + expect(handler).not.toHaveBeenCalled(); + expect(log.warn).toHaveBeenCalledTimes(1); + const message = vi.mocked(log.warn).mock.calls[0]![0] as string; + expect(message).toContain('Dropped signal "cancel"'); + expect(message).toContain("input validation failed"); }); - it("throws a clear error when the workflow has no signals block", () => { + it("throws ContractMisuseError when the workflow has no signals block", () => { const noSignals = defineWorkflow({ input: z.object({}), output: z.object({}), }); - expect(() => bindSignalHandler(noSignals, "probe", "cancel", vi.fn() as never)).toThrow( - /workflow "probe" has no signals/, - ); + const bind = () => bindSignalHandler(noSignals, "probe", "cancel", vi.fn() as never); + expect(bind).toThrow(ContractMisuseError); + expect(bind).toThrow(/workflow "probe" has no signals/); }); - it("throws a clear error when the signal name isn't declared", () => { - expect(() => bindSignalHandler(workflow, "probe", "nope", vi.fn() as never)).toThrow( - /Signal "nope" not found/, - ); + it("throws ContractMisuseError when the signal name isn't declared", () => { + const bind = () => bindSignalHandler(workflow, "probe", "nope", vi.fn() as never); + expect(bind).toThrow(ContractMisuseError); + expect(bind).toThrow(/Signal "nope" not found/); + }); + + it("maps a zero-argument send to `undefined` for payload-less signals", async () => { + // Pairs with the contract change: `defineSignal()` (no input schema) + // materializes an UndefinedInputSchema that only accepts undefined/null. + // A zero-arg Temporal dispatch must therefore extract to `undefined`, + // not `[]`. + captured.length = 0; + const handler = vi.fn(); + const wfWithPayloadlessSignal = defineWorkflow({ + input: z.object({}), + output: z.object({}), + signals: { shutdown: defineContractSignal() }, + }); + bindSignalHandler(wfWithPayloadlessSignal, "probe", "shutdown", handler as never); + const entry = captured.find((c) => c.kind === "signal" && c.name === "shutdown")!; + + await entry.impl(); // zero args — Temporal dispatch of a payload-less signal + + expect(handler).toHaveBeenCalledWith(undefined); }); }); @@ -156,7 +196,7 @@ describe("bindQueryHandler", () => { expect(() => entry.impl([])).toThrow(QueryOutputValidationError); }); - it("throws a clear error when input validation is async (Temporal queries must be sync)", () => { + it("throws ContractMisuseError when input validation is async (Temporal queries must be sync)", () => { captured.length = 0; const wfWithAsyncQuery = defineWorkflow({ input: z.object({}), @@ -167,10 +207,11 @@ describe("bindQueryHandler", () => { }); 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/); }); - it("throws a clear error when output validation is async", () => { + it("throws ContractMisuseError when output validation is async", () => { captured.length = 0; const wfWithAsyncOutput = defineWorkflow({ input: z.object({}), @@ -181,17 +222,24 @@ describe("bindQueryHandler", () => { }); bindQueryHandler(wfWithAsyncOutput, "probe", "progress", vi.fn().mockReturnValue("x") 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/); }); - it("throws a clear error when the workflow has no queries block", () => { + it("throws ContractMisuseError when the workflow has no queries block", () => { const noQueries = defineWorkflow({ input: z.object({}), output: z.object({}), }); - expect(() => bindQueryHandler(noQueries, "probe", "progress", vi.fn() as never)).toThrow( - /workflow "probe" has no queries/, - ); + const bind = () => bindQueryHandler(noQueries, "probe", "progress", vi.fn() as never); + expect(bind).toThrow(ContractMisuseError); + expect(bind).toThrow(/workflow "probe" has no queries/); + }); + + it("throws ContractMisuseError when the query name isn't declared", () => { + const bind = () => bindQueryHandler(workflow, "probe", "nope", vi.fn() as never); + expect(bind).toThrow(ContractMisuseError); + expect(bind).toThrow(/Query "nope" not found/); }); }); @@ -238,7 +286,7 @@ describe("bindUpdateHandler", () => { expect(() => entry.validator!([7])).not.toThrow(); }); - it("validator throws a clear error when the input schema is async (Temporal validators must be sync)", () => { + it("validator throws ContractMisuseError when the input schema is async (Temporal validators must be sync)", () => { // 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 @@ -257,6 +305,7 @@ describe("bindUpdateHandler", () => { 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/); }); @@ -281,13 +330,82 @@ describe("bindUpdateHandler", () => { await expect(entry.impl([1])).rejects.toBeInstanceOf(UpdateOutputValidationError); }); - it("throws a clear error when the workflow has no updates block", () => { + it("throws ContractMisuseError when the workflow has no updates block", () => { const noUpdates = defineWorkflow({ input: z.object({}), output: z.object({}), }); - expect(() => bindUpdateHandler(noUpdates, "probe", "bumpAttempt", vi.fn() as never)).toThrow( - /workflow "probe" has no updates/, - ); + const bind = () => bindUpdateHandler(noUpdates, "probe", "bumpAttempt", vi.fn() as never); + expect(bind).toThrow(ContractMisuseError); + expect(bind).toThrow(/workflow "probe" has no updates/); + }); + + it("throws ContractMisuseError when the update name isn't declared", () => { + const bind = () => bindUpdateHandler(workflow, "probe", "nope", vi.fn() as never); + expect(bind).toThrow(ContractMisuseError); + expect(bind).toThrow(/Update "nope" not found/); + }); +}); + +describe("wire format (validate on send, parse on receive)", () => { + // D1: handler input is the RECEIVING side of the boundary — parsed exactly + // once here. Handler output is the SENDING side — validated, but the + // handler's ORIGINAL return value goes over the wire; the client parses it. + const transformWorkflow = defineWorkflow({ + input: z.object({}), + output: z.object({}), + signals: { + note: { input: z.object({ text: z.string().transform((s) => `${s}!`) }) }, + }, + queries: { + peek: { + input: z.object({ text: z.string().transform((s) => `${s}!`) }), + output: z.object({ n: z.number().transform((n) => n * 2) }), + }, + }, + updates: { + poke: { + input: z.object({ text: z.string().transform((s) => `${s}!`) }), + output: z.object({ n: z.number().transform((n) => n * 2) }), + }, + }, + }); + + it("signal handler receives the PARSED input (transform applied once)", async () => { + captured.length = 0; + const handler = vi.fn(); + bindSignalHandler(transformWorkflow, "probe", "note", handler as never); + const entry = captured.find((c) => c.kind === "signal" && c.name === "note")!; + + await entry.impl({ text: "hi" }); + + expect(handler).toHaveBeenCalledWith({ text: "hi!" }); + }); + + it("query handler receives the PARSED input and its ORIGINAL return goes over the wire", () => { + captured.length = 0; + const handler = vi.fn().mockReturnValue({ n: 21 }); + bindQueryHandler(transformWorkflow, "probe", "peek", handler as never); + const entry = captured.find((c) => c.kind === "query" && c.name === "peek")!; + + const result = entry.impl({ text: "hi" }); + + expect(handler).toHaveBeenCalledWith({ text: "hi!" }); + // Validated against the output schema, but transmitted untransformed — + // the client parses the query result on receive (would be 42 if the + // transform were applied here too). + expect(result).toEqual({ n: 21 }); + }); + + it("update handler receives the PARSED input and its ORIGINAL return goes over the wire", async () => { + captured.length = 0; + const handler = vi.fn(async () => ({ n: 21 })); + bindUpdateHandler(transformWorkflow, "probe", "poke", handler as never); + const entry = captured.find((c) => c.kind === "update" && c.name === "poke")!; + + const result = await entry.impl({ text: "hi" }); + + expect(handler).toHaveBeenCalledWith({ text: "hi!" }); + expect(result).toEqual({ n: 21 }); }); }); diff --git a/packages/worker/src/handlers.ts b/packages/worker/src/handlers.ts index 54d91d4e..bd903051 100644 --- a/packages/worker/src/handlers.ts +++ b/packages/worker/src/handlers.ts @@ -15,12 +15,13 @@ import type { SignalDefinition, UpdateDefinition, } from "@temporal-contract/contract"; -import { defineQuery, defineSignal, defineUpdate, setHandler } from "@temporalio/workflow"; +import { summarizeIssues } from "@temporal-contract/contract"; +import { defineQuery, defineSignal, defineUpdate, log, setHandler } from "@temporalio/workflow"; import { + ContractMisuseError, QueryInputValidationError, QueryOutputValidationError, - SignalInputValidationError, UpdateInputValidationError, UpdateOutputValidationError, } from "./errors.js"; @@ -57,16 +58,39 @@ export type UpdateHandlerImplementation = ( args: WorkerInferInput, ) => Promise>; +/** + * The (verbatim-shared) message for the update input schema's sync-only + * requirement — Temporal's update validator slot is synchronous, and the + * same schema is re-run inside the handler body, so both call sites must + * report the identical constraint. + */ +function updateInputMustBeSynchronousMessage(updateName: string): string { + return ( + `Update "${updateName}" input validation must be synchronous. Use a schema library that ` + + `supports synchronous validation for update inputs (Temporal's update validator slot is synchronous).` + ); +} + /** * Bind a typed signal handler to the running workflow. Validates the * signal payload against the contract's input schema before invoking the * user-supplied handler. * + * An invalid payload is **dropped and logged** (`log.warn` via + * `@temporalio/workflow`'s workflow-safe logger), never thrown: signals are + * fire-and-forget messages that any stale or non-typed client can send, and + * throwing a non-retryable failure from the signal handler would terminally + * kill the whole workflow execution over a message the workflow never asked + * for. + * * The runtime guard against a missing `signals` block — and an unknown * signal name within it — covers the union-typed-`workflowName` case * where the type system's keyset constraint collapses; without the * check, a caller would see `Cannot read properties of undefined` - * instead of a controlled error. + * instead of a controlled error. Both guards throw + * {@link ContractMisuseError} (a non-retryable `ApplicationFailure`) so the + * programming bug fails the execution terminally instead of hanging it in + * an infinite Workflow Task retry loop. */ export function bindSignalHandler( workflowDefinition: AnyWorkflowDefinition, @@ -75,13 +99,15 @@ export function bindSignalHandler( handler: SignalHandlerImplementation, ): void { if (!workflowDefinition.signals) { - throw new Error( + throw new ContractMisuseError( `Signal "${signalName}" cannot be defined: workflow "${workflowName}" has no signals in its contract`, ); } const signalDef = (workflowDefinition.signals as Record)[signalName]; if (!signalDef) { - throw new Error(`Signal "${signalName}" not found in workflow "${workflowName}" contract`); + throw new ContractMisuseError( + `Signal "${signalName}" not found in workflow "${workflowName}" contract`, + ); } const signal = defineSignal(signalName); @@ -89,20 +115,31 @@ export function bindSignalHandler( const input = extractHandlerInput(args); const inputResult = await signalDef.input["~standard"].validate(input); if (inputResult.issues) { - throw new SignalInputValidationError(signalName, inputResult.issues); + // Drop-and-log policy (fire-and-forget semantics): an invalid payload + // must not fail the execution. `log.warn` is replay-aware, so the + // warning is emitted once, not on every replay. + log.warn( + `Dropped signal "${signalName}": input validation failed: ${summarizeIssues(inputResult.issues)}`, + ); + return; } await handler(inputResult.value); }); } /** - * Bind a typed query handler to the running workflow. Validates input - * and output against the contract synchronously. + * Bind a typed query handler to the running workflow. Parses the input + * (receive side of the boundary — the client validated but transmitted + * the caller's original value) and validates the output, returning the + * handler's ORIGINAL return value to Temporal — the client parses the + * query result on receive, so a transforming output schema applies + * exactly once. Both run synchronously. * * Temporal's query API requires a synchronous handler — async - * validation breaks replay determinism. The handler trips a clear error - * if the schema library returns a Promise from `validate(...)`, instead - * of letting the async path silently corrupt query semantics. + * validation breaks replay determinism. The handler trips a clear + * {@link ContractMisuseError} if the schema library returns a Promise from + * `validate(...)`, instead of letting the async path silently corrupt query + * semantics. */ export function bindQueryHandler( workflowDefinition: AnyWorkflowDefinition, @@ -111,13 +148,15 @@ export function bindQueryHandler( handler: QueryHandlerImplementation, ): void { if (!workflowDefinition.queries) { - throw new Error( + throw new ContractMisuseError( `Query "${queryName}" cannot be defined: workflow "${workflowName}" has no queries in its contract`, ); } const queryDef = (workflowDefinition.queries as Record)[queryName]; if (!queryDef) { - throw new Error(`Query "${queryName}" not found in workflow "${workflowName}" contract`); + throw new ContractMisuseError( + `Query "${queryName}" not found in workflow "${workflowName}" contract`, + ); } const query = defineQuery(queryName); @@ -126,7 +165,7 @@ export function bindQueryHandler( const inputResult = queryDef.input["~standard"].validate(input); if (inputResult instanceof Promise) { - throw new Error( + throw new ContractMisuseError( `Query "${queryName}" validation must be synchronous. Use a schema library that supports synchronous validation for queries.`, ); } @@ -138,7 +177,7 @@ export function bindQueryHandler( const outputResult = queryDef.output["~standard"].validate(result); if (outputResult instanceof Promise) { - throw new Error( + throw new ContractMisuseError( `Query "${queryName}" output validation must be synchronous. Use a schema library that supports synchronous validation for queries.`, ); } @@ -146,7 +185,9 @@ export function bindQueryHandler( throw new QueryOutputValidationError(queryName, outputResult.issues); } - return outputResult.value; + // Validated, but the handler's ORIGINAL return goes over the wire — + // the client parses the query result on receive (D1). + return result; }); } @@ -165,16 +206,20 @@ export function bindQueryHandler( * * Because the validator slot is synchronous, the input schema must also * validate synchronously. Standard Schema is allowed to be async (Zod's - * `.refine(async)` is the typical case), but we trip a clear error when - * that happens rather than silently breaking admission semantics — same - * approach as `bindQueryHandler`. Users who need async input checks - * should run them inside the handler body and accept the post-admission - * failure mode, or restructure their schema. + * `.refine(async)` is the typical case), but we trip a clear + * {@link ContractMisuseError} when that happens rather than silently + * breaking admission semantics — same approach as `bindQueryHandler`. + * Users who need async input checks should run them inside the handler + * body and accept the post-admission failure mode, or restructure their + * schema. * * Output validation continues to run inside the handler body. Update * outputs are *not* admission-gated — the handler must execute to * produce a value to validate against — so the async-allowed shape is - * preserved. + * preserved. As with every payload boundary, the handler's ORIGINAL + * return value is what crosses the wire: the client parses the update + * result on receive, so a transforming output schema applies exactly + * once. */ export function bindUpdateHandler( workflowDefinition: AnyWorkflowDefinition, @@ -183,31 +228,34 @@ export function bindUpdateHandler( handler: UpdateHandlerImplementation, ): void { if (!workflowDefinition.updates) { - throw new Error( + throw new ContractMisuseError( `Update "${updateName}" cannot be defined: workflow "${workflowName}" has no updates in its contract`, ); } const updateDef = (workflowDefinition.updates as Record)[updateName]; if (!updateDef) { - throw new Error(`Update "${updateName}" not found in workflow "${workflowName}" contract`); + throw new ContractMisuseError( + `Update "${updateName}" not found in workflow "${workflowName}" contract`, + ); } const update = defineUpdate(updateName); setHandler( update, async (...args: unknown[]) => { - // The validator already accepted the payload — re-parse here so the - // handler receives the schema's transformed value (Standard Schema - // may rewrite shapes during validation, e.g. Zod `.transform`). This - // is sync because the validator already proved the schema is sync; - // any async result here would mean the schema changed under us, - // which is a programmer error worth surfacing. + // The validator already accepted the payload (its parsed value is + // discarded — it only gates admission). This is the boundary's single + // receive-side parse: it always starts from the raw wire payload, so + // the handler receives the schema's transformed value (Standard + // Schema may rewrite shapes during validation, e.g. Zod `.transform`) + // with the transform applied exactly once. It is sync because the + // validator already proved the schema is sync; any async result here + // would mean the schema changed under us, which is a programmer error + // worth surfacing. const input = extractHandlerInput(args); const inputResult = updateDef.input["~standard"].validate(input); if (inputResult instanceof Promise) { - throw new Error( - `Update "${updateName}" input validation must be synchronous. Use a schema library that supports synchronous validation for update inputs (Temporal's update validator slot is synchronous).`, - ); + throw new ContractMisuseError(updateInputMustBeSynchronousMessage(updateName)); } if (inputResult.issues) { // The validator should have caught this; if we reach here, the @@ -224,7 +272,9 @@ export function bindUpdateHandler( throw new UpdateOutputValidationError(updateName, outputResult.issues); } - return outputResult.value; + // Validated, but the handler's ORIGINAL return goes over the wire — + // the client parses the update result on receive (D1). + return result; }, { validator: (...args: unknown[]) => { @@ -232,9 +282,7 @@ export function bindUpdateHandler( const inputResult = updateDef.input["~standard"].validate(input); if (inputResult instanceof Promise) { - throw new Error( - `Update "${updateName}" input validation must be synchronous. Use a schema library that supports synchronous validation for update inputs (Temporal's update validator slot is synchronous).`, - ); + throw new ContractMisuseError(updateInputMustBeSynchronousMessage(updateName)); } if (inputResult.issues) { throw new UpdateInputValidationError(updateName, inputResult.issues); diff --git a/packages/worker/src/internal.ts b/packages/worker/src/internal.ts index 0bf94799..8f24202e 100644 --- a/packages/worker/src/internal.ts +++ b/packages/worker/src/internal.ts @@ -18,6 +18,7 @@ import type { ActivityOptions, ContinueAsNewOptions } from "@temporalio/workflow import { ChildWorkflowCancelledError, ChildWorkflowError, + ContractMisuseError, WorkflowInputValidationError, } from "./errors.js"; @@ -52,14 +53,22 @@ export { * Extract the single payload from a Temporal handler's `...args` array. * * Temporal invokes handlers with whatever was passed via `args: [...]` at the - * call site. The typed-contract layer always sends `args: [validatedInput]`, - * so the common case is a one-element array containing the wrapped input. + * call site. The typed-contract layer always sends `args: [input]` — the + * caller's original (validated but untransformed) value, which the receiving + * handler parses — so the common case is a one-element array containing the + * wrapped input. + * + * Zero arguments map to `undefined`, not `[]`: a payload-less send (e.g. a + * signal declared without an `input` schema, whose materialized + * `UndefinedInputSchema` only accepts `undefined`/`null`) must parse as "no + * payload", and an empty array would be rejected by that schema. * * If a non-typed-contract caller passes multiple positional arguments * (`args: [a, b, c]`), we surface the whole array as the input — the schema * will then reject it unless the contract specifically modeled a tuple. */ export function extractHandlerInput(args: unknown[]): unknown { + if (args.length === 0) return undefined; return args.length === 1 ? args[0] : args; } @@ -123,7 +132,10 @@ export function buildRawActivitiesProxy( }) .map(([name]) => name); if (uncovered.length > 0) { - throw new Error( + // ContractMisuseError (a non-retryable ApplicationFailure), not a plain + // Error: this runs inside the workflow sandbox, where a plain Error + // would be retried as a Workflow Task failure forever (D3). + throw new ContractMisuseError( `declareWorkflow: \`activityOptions\` was omitted but the following activities declare ` + `no contract-level \`defaultOptions\` and have no \`activityOptionsByName\` entry: ` + `${uncovered.join(", ")}. Provide \`activityOptions\` or per-activity options.`, @@ -148,7 +160,7 @@ export function buildRawActivitiesProxy( : []; for (const [name] of overrideEntries) { if (!(name in allDefinitions)) { - throw new Error( + throw new ContractMisuseError( `activityOptionsByName entry "${name}" does not match any declared activity. Available: ${Object.keys(allDefinitions).join(", ") || "none"}.`, ); } @@ -219,7 +231,9 @@ export type TypedContinueAsNewOptions = Omit = + NonNullable extends Record + ? NonNullable[K & + keyof NonNullable] extends SignalDefinition + ? NonNullable[K & keyof NonNullable] + : never + : never; + +/** + * Resolve the {@link QueryDefinition} declared under name `K` on a workflow, + * or `never` when the workflow declares no queries / no such query. + * See {@link SignalDefOf} for the shape rationale. + */ +export type QueryDefOf = + NonNullable extends Record + ? NonNullable[K & + keyof NonNullable] extends QueryDefinition + ? NonNullable[K & keyof NonNullable] + : never + : never; + +/** + * Resolve the {@link UpdateDefinition} declared under name `K` on a workflow, + * or `never` when the workflow declares no updates / no such update. + * See {@link SignalDefOf} for the shape rationale. + */ +export type UpdateDefOf = + NonNullable extends Record + ? NonNullable[K & + keyof NonNullable] extends UpdateDefinition + ? NonNullable[K & keyof NonNullable] + : never + : never; diff --git a/packages/worker/src/wire-format.spec.ts b/packages/worker/src/wire-format.spec.ts new file mode 100644 index 00000000..d024ee3b --- /dev/null +++ b/packages/worker/src/wire-format.spec.ts @@ -0,0 +1,221 @@ +import { defineContract, defineWorkflow } from "@temporal-contract/contract"; +/** + * Wire-format coverage (D1: validate on send, parse on receive) for the + * workflow entry point and the child-workflow helpers. + * + * Every payload boundary parses exactly once, on the receiving side. The + * sending side still validates — failing early with the existing typed + * error — but transmits the caller's ORIGINAL value. Transforming schemas + * (`z.string().transform(...)`) make the two sides observable: + * + * - `declareWorkflow` input: the worker is the receiving side, so the + * implementation sees the parsed value. + * - `declareWorkflow` output: the worker is the sending side, so Temporal + * gets the implementation's original return; the client parses it. + * - child-workflow args: the parent is the sending side (original goes over + * the wire); the child's `declareWorkflow` parses on receive. + * - child-workflow result: the parent is the receiving side (parses once). + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +type ChildCall = { workflowName: string; options: Record }; +type SignalCall = { signalName: string; args: unknown[] }; + +const executeChildCalls: ChildCall[] = []; +const startChildCalls: ChildCall[] = []; +const childSignalCalls: SignalCall[] = []; +let childResultValue: unknown; + +vi.mock("@temporalio/workflow", async () => { + const actual = + await vi.importActual("@temporalio/workflow"); + return { + ...actual, + workflowInfo: () => ({ workflowId: "test-wf", runId: "test-run" }), + executeChild: async (workflowName: string, options: Record) => { + executeChildCalls.push({ workflowName, options }); + return childResultValue; + }, + startChild: async (workflowName: string, options: Record) => { + startChildCalls.push({ workflowName, options }); + return { + workflowId: "child-1", + firstExecutionRunId: "run-1", + signal: async (signalName: string, ...args: unknown[]) => { + childSignalCalls.push({ signalName, args }); + }, + result: async () => childResultValue, + }; + }, + }; +}); + +const { declareWorkflow } = await import("./workflow.js"); +const { createExecuteChildWorkflow, createStartChildWorkflow } = + await import("./child-workflow.js"); + +const contract = defineContract({ + taskQueue: "wire-q", + workflows: { + transformer: defineWorkflow({ + input: z.object({ text: z.string().transform((s) => `${s}!`) }), + output: z.object({ n: z.number().transform((n) => n * 2) }), + }), + signalful: defineWorkflow({ + input: z.object({}), + output: z.object({ n: z.number() }), + signals: { + note: { input: z.object({ text: z.string().transform((s) => `${s}!`) }) }, + }, + }), + }, +}); + +afterEach(() => { + executeChildCalls.length = 0; + startChildCalls.length = 0; + childSignalCalls.length = 0; + childResultValue = undefined; +}); + +describe("declareWorkflow — wire format", () => { + it("the implementation receives the PARSED input (transform applied once)", async () => { + const seen: unknown[] = []; + const handler = declareWorkflow({ + workflowName: "transformer", + contract, + implementation: async (_context, args) => { + seen.push(args); + return { n: 21 }; + }, + }); + + await handler({ text: "hi" }); + + // The client transmitted the original `{ text: "hi" }`; the boundary + // parse happens exactly once, here on the receiving side. + expect(seen).toEqual([{ text: "hi!" }]); + }); + + it("Temporal gets the implementation's ORIGINAL output, not the parsed value", async () => { + const handler = declareWorkflow({ + workflowName: "transformer", + contract, + implementation: async () => ({ n: 21 }), + }); + + const result = await handler({ text: "hi" }); + + // Validated against the output schema, but transmitted untransformed — + // the client parses the result on receive (21 → 42 exactly once). + expect(result).toEqual({ n: 21 }); + }); + + it("still fails the execution on invalid output", async () => { + const handler = declareWorkflow({ + workflowName: "transformer", + contract, + implementation: async () => ({ n: "not a number" }) as never, + }); + + await expect(handler({ text: "hi" })).rejects.toThrow(/output validation failed/); + }); +}); + +describe("child workflows — wire format", () => { + it("executeChildWorkflow sends the ORIGINAL args and parses the result once", async () => { + childResultValue = { n: 21 }; + + const result = await createExecuteChildWorkflow(contract, "transformer", { + workflowId: "child-1", + args: { text: "hi" }, + }); + + // Parent is the sending side of the input boundary — the child's + // `declareWorkflow` parses on receive. + expect(executeChildCalls).toEqual([ + { + workflowName: "transformer", + options: expect.objectContaining({ args: [{ text: "hi" }] }), + }, + ]); + // 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 }); + } + }); + + it("startChildWorkflow sends the ORIGINAL args; handle.result() parses once", async () => { + childResultValue = { n: 21 }; + + const handleResult = await createStartChildWorkflow(contract, "transformer", { + workflowId: "child-1", + args: { text: "hi" }, + }); + + expect(startChildCalls).toEqual([ + { + workflowName: "transformer", + options: expect.objectContaining({ args: [{ text: "hi" }] }), + }, + ]); + expect(handleResult.isOk()).toBe(true); + if (handleResult.isOk()) { + const result = await handleResult.value.result(); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toEqual({ n: 42 }); + } + } + }); +}); + +describe("typed child workflow handle — signals and identifiers", () => { + it("exposes firstExecutionRunId from the underlying handle", async () => { + const handleResult = await createStartChildWorkflow(contract, "signalful", { + workflowId: "child-1", + args: {}, + }); + + expect(handleResult.isOk()).toBe(true); + if (handleResult.isOk()) { + expect(handleResult.value.workflowId).toBe("child-1"); + expect(handleResult.value.firstExecutionRunId).toBe("run-1"); + } + }); + + it("signals validate the args but transmit the ORIGINAL value (D1)", async () => { + const handleResult = await createStartChildWorkflow(contract, "signalful", { + workflowId: "child-1", + args: {}, + }); + expect(handleResult.isOk()).toBe(true); + if (!handleResult.isOk()) return; + + const sent = await handleResult.value.signals.note({ text: "hi" }); + + expect(sent.isOk()).toBe(true); + // 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" }] }]); + }); + + it("signals surface a validation failure as Err(ChildWorkflowError) without sending", async () => { + const handleResult = await createStartChildWorkflow(contract, "signalful", { + workflowId: "child-1", + args: {}, + }); + expect(handleResult.isOk()).toBe(true); + if (!handleResult.isOk()) return; + + const sent = await handleResult.value.signals.note({ text: 42 } as never); + + expect(sent.isErr()).toBe(true); + if (sent.isErr()) { + expect(sent.error.message).toContain('signal "note" input validation failed'); + } + expect(childSignalCalls).toEqual([]); + }); +}); diff --git a/packages/worker/src/worker.spec.ts b/packages/worker/src/worker.spec.ts index d63db3ec..7dc19f95 100644 --- a/packages/worker/src/worker.spec.ts +++ b/packages/worker/src/worker.spec.ts @@ -3,12 +3,7 @@ import { type NativeConnection, Worker } from "@temporalio/worker"; import { describe, expect, it, vi, beforeEach } from "vitest"; import { z } from "zod"; -import { - createWorker, - createWorkerOrThrow, - TechnicalError, - workflowsPathFromURL, -} from "./worker.js"; +import { createWorker, TechnicalError, workflowsPathFromURL } from "./worker.js"; // Mock @temporalio/worker vi.mock("@temporalio/worker", () => ({ @@ -95,8 +90,9 @@ describe("Worker Entry Point", () => { } }); - it("createWorkerOrThrow keeps the throwing shape (deprecated alias)", async () => { - // GIVEN + it("supports workflow-only workers: omitting `activities` omits the key entirely", async () => { + // GIVEN — a deployment where activities run on a separate worker + // process; this one only executes workflows. const contract = { taskQueue: "test-queue", workflows: { @@ -106,18 +102,28 @@ describe("Worker Entry Point", () => { }, }, } satisfies ContractDefinition; - const bundleError = new Error("failed to bundle workflows"); - vi.mocked(Worker.create).mockRejectedValue(bundleError); - // WHEN / THEN — the original cause is rethrown, not the wrapper - await expect( - createWorkerOrThrow({ - contract, - connection: { close: vi.fn() } as unknown as NativeConnection, - workflowsPath: "/path/to/workflows", - activities: {}, - }), - ).rejects.toBe(bundleError); + const mockConnection = { close: vi.fn() } as unknown as NativeConnection; + const mockWorker = { run: vi.fn() } as unknown as Worker; + vi.mocked(Worker.create).mockResolvedValue(mockWorker); + + // WHEN — no `activities` in the options + const workerResult = await createWorker({ + contract, + connection: mockConnection, + workflowsPath: "/path/to/workflows", + }); + + // THEN — Worker.create is called WITHOUT an `activities` key (not with + // `activities: undefined` — exactOptionalPropertyTypes discipline). + expect(Worker.create).toHaveBeenCalledWith({ + connection: mockConnection, + taskQueue: "test-queue", + workflowsPath: "/path/to/workflows", + }); + const callArg = vi.mocked(Worker.create).mock.calls[0]![0]; + expect(Object.keys(callArg)).not.toContain("activities"); + expect(workerResult).toBeOkWith(mockWorker); }); it("should use provided connection", async () => { diff --git a/packages/worker/src/worker.ts b/packages/worker/src/worker.ts index 7566800a..50817125 100644 --- a/packages/worker/src/worker.ts +++ b/packages/worker/src/worker.ts @@ -26,9 +26,17 @@ export type CreateWorkerOptions = Omit< contract: TContract; /** - * Activities handler for this worker + * Activities handler for this worker, built with + * `declareActivitiesHandler`. + * + * Optional — omit it for a **workflow-only worker**. When absent, no + * `activities` are registered with the underlying Temporal Worker, so it + * only polls for Workflow Tasks. This supports the split-deployment + * pattern where workflow code and activity code scale independently: one + * worker process runs the (deterministic, CPU-light) workflows while a + * separate worker process on the same task queue registers the activities. */ - activities: ActivitiesHandler; + activities?: ActivitiesHandler; }; /** @@ -78,10 +86,14 @@ export function createWorker( // 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, + ...(activities !== undefined ? { activities } : {}), taskQueue: contract.taskQueue, }), (cause, defect) => @@ -94,28 +106,6 @@ export function createWorker( ); } -/** - * Create a typed Temporal worker, throwing on failure — the - * pre-AsyncResult behavior. - * - * @deprecated Use {@link createWorker}, which returns - * `AsyncResult` (technical failures ride the defect channel). - * This throwing alias exists to ease migration and will be removed in a - * future major. - */ -export async function createWorkerOrThrow( - options: CreateWorkerOptions, -): Promise { - const result = await createWorker(options); - // A technical failure now rides the defect channel with a `TechnicalError` - // cause; unwrap it so this throwing alias keeps rethrowing the *original* - // cause (its pre-defect behavior), not the wrapper. - if (result.isDefect() && result.cause instanceof TechnicalError) { - throw result.cause.cause ?? result.cause; - } - return result.get(); -} - /** * Helper to resolve a workflow file path relative to the current module's URL. * diff --git a/packages/worker/src/workflow-proxy.spec.ts b/packages/worker/src/workflow-proxy.spec.ts index b8d1cb81..ef20da6e 100644 --- a/packages/worker/src/workflow-proxy.spec.ts +++ b/packages/worker/src/workflow-proxy.spec.ts @@ -38,6 +38,7 @@ vi.mock("@temporalio/workflow", async () => { // Import *after* the mock so the helper module sees our stub. const { buildRawActivitiesProxy } = await import("./internal.js"); +const { ContractMisuseError } = await import("./errors.js"); const { declareWorkflow } = await import("./workflow.js"); const activityDef = (input: z.ZodTypeAny, output: z.ZodTypeAny): ActivityDefinition => @@ -152,11 +153,14 @@ describe("buildRawActivitiesProxy", () => { // A typo in `activityOptionsByName` is caught by the type system at // declaration sites, but a raw call (or a stale options bag from a // renamed activity) shouldn't silently spin up a proxy for a name that - // can never be invoked. Surface a clear error instead. + // can never be invoked. Surface a clear error instead — a + // ContractMisuseError (non-retryable ApplicationFailure), since this + // runs in the workflow sandbox where a plain Error would hang the + // execution in infinite Workflow Task retries (D3). const def: Record = { knownActivity: activityDef(z.object({}), z.object({})), }; - expect(() => + const build = () => buildRawActivitiesProxy( def, undefined, @@ -164,8 +168,18 @@ describe("buildRawActivitiesProxy", () => { { nonExistent: { startToCloseTimeout: "1 second" }, }, - ), - ).toThrow(/nonExistent/); + ); + expect(build).toThrow(ContractMisuseError); + expect(build).toThrow(/nonExistent/); + }); + + it("throws ContractMisuseError when activityOptions is omitted and an activity has no options of its own", () => { + const def: Record = { + uncovered: activityDef(z.object({}), z.object({})), + }; + const build = () => buildRawActivitiesProxy(def, undefined, undefined, undefined); + expect(build).toThrow(ContractMisuseError); + expect(build).toThrow(/uncovered/); }); it("merges workflow-local and global activities into one lookup space", () => { diff --git a/packages/worker/src/workflow.spec.ts b/packages/worker/src/workflow.spec.ts index d7e22914..08399b92 100644 --- a/packages/worker/src/workflow.spec.ts +++ b/packages/worker/src/workflow.spec.ts @@ -11,7 +11,7 @@ import { defineContract, defineWorkflow } from "@temporal-contract/contract"; import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { declareWorkflow } from "./workflow.js"; +import { ContractMisuseError, declareWorkflow } from "./workflow.js"; const contract = defineContract({ taskQueue: "tq", @@ -64,4 +64,23 @@ describe("declareWorkflow", () => { expect(Object.getOwnPropertyDescriptor(fn, "name")?.configurable).toBe(true); }); + + it("throws ContractMisuseError at declaration time for an unknown workflow name", () => { + // Covers JavaScript callers / stale casts the type system can't see. + // Without the guard this crashed later with an opaque `Cannot read + // properties of undefined (reading 'activities')`. + const declare = () => + declareWorkflow({ + // Runtime-only typo — the cast models a JavaScript caller. + workflowName: "notDeclared" as "processOrder", + contract, + implementation: async () => ({}), + activityOptions: { startToCloseTimeout: "1 minute" }, + }); + + expect(declare).toThrow(ContractMisuseError); + expect(declare).toThrow( + /workflow "notDeclared" is not declared on the supplied contract\. Available workflows: processOrder, cancelOrder/, + ); + }); }); diff --git a/packages/worker/src/workflow.ts b/packages/worker/src/workflow.ts index 26bd08f7..e8312eca 100644 --- a/packages/worker/src/workflow.ts +++ b/packages/worker/src/workflow.ts @@ -40,6 +40,7 @@ import { type ChildWorkflowCancelledError, type ChildWorkflowError, type ChildWorkflowNotFoundError, + ContractMisuseError, type WorkflowCancelledError, WorkflowInputValidationError, WorkflowOutputValidationError, @@ -61,6 +62,9 @@ import { import { type ClientInferInput, type ClientInferOutput, + type QueryDefOf, + type SignalDefOf, + type UpdateDefOf, type WorkerInferInput, type WorkerInferOutput, } from "./types.js"; @@ -74,9 +78,9 @@ export { ChildWorkflowError, ChildWorkflowNotFoundError, ContractErrorDataValidationError, + ContractMisuseError, QueryInputValidationError, QueryOutputValidationError, - SignalInputValidationError, UpdateInputValidationError, UpdateOutputValidationError, ValidationError, @@ -166,12 +170,15 @@ export { * import { activities } from './activities.js'; * import myContract from './contract.js'; * - * const worker = await createWorker({ + * const workerResult = await createWorker({ * contract: myContract, * connection, * workflowsPath: workflowsPathFromURL(import.meta.url, './workflows.js'), * activities, - * }).getOrThrow(); + * }); + * // `createWorker` returns AsyncResult — setup failures ride + * // the defect channel, so `.get()` narrows the ok channel directly. + * const worker = workerResult.get(); * ``` */ export function declareWorkflow< @@ -186,8 +193,22 @@ export function declareWorkflow< }: DeclareWorkflowOptions): ( ...args: unknown[] ) => Promise> { - // Get the workflow definition from the contract - const definition = contract.workflows[workflowName] as TContract["workflows"][TWorkflowName]; + // Get the workflow definition from the contract. The runtime guard covers + // JavaScript callers and stale casts the type system can't see — without + // it, a typo'd `workflowName` would crash later with an opaque + // `Cannot read properties of undefined (reading 'activities')`. This runs + // at declaration time in the workflow bundle (sandbox code), so the error + // is a ContractMisuseError — a non-retryable ApplicationFailure — rather + // than a plain Error that Temporal would retry forever (D3). + const definition = contract.workflows[workflowName] as + | TContract["workflows"][TWorkflowName] + | undefined; + if (!definition) { + const available = Object.keys(contract.workflows).join(", ") || "none"; + throw new ContractMisuseError( + `declareWorkflow: workflow "${String(workflowName)}" is not declared on the supplied contract. Available workflows: ${available}`, + ); + } // Build the activities proxy *once* at declaration time, not per workflow // invocation. Temporal's `proxyActivities` is documented as a module-scope @@ -206,17 +227,23 @@ export function declareWorkflow< // as an argument and validate it per-call (no closed-over per-invocation // state). // - // Design note — intentional double-validation: - // Input and output are validated here (workflow side) AND again inside - // `declareActivitiesHandler` (activity worker side). This is deliberate: + // Design note — wire format (validate on send, parse on receive): + // Each activity payload boundary is parsed exactly once, on the receiving + // side, so a transforming schema (`z.coerce.*`, `.transform(...)`) is + // never applied twice: // - // 1. Workflow-side validation catches bad data *before* it crosses the - // task-queue network boundary, giving an early, descriptive error - // instead of a confusing deserialization failure inside the activity. - // 2. Activity-side validation is the authoritative guard, since the - // activity may be called by other callers that do not use this library. + // 1. The workflow-side wrapper (`createValidatedActivities`) VALIDATES the + // input it is about to send — catching bad data *before* it crosses the + // task-queue network boundary with an early, descriptive error — but + // transmits the caller's ORIGINAL value, discarding the parsed result. + // 2. `declareActivitiesHandler` (activity worker side) PARSES the payload + // on receive; it is the authoritative guard, since the activity may be + // called by other callers that do not use this library. // - // The overhead is minimal relative to the network round-trip. + // The activity result flows the other way: the activity side validates its + // return and hands Temporal the original value; the wrapper here parses it + // on receive. The extra send-side validation overhead is minimal relative + // to the network round-trip. let contextActivities: unknown = {}; if (definition.activities || contract.activities) { @@ -252,7 +279,10 @@ export function declareWorkflow< const workflowFn = async (...args: unknown[]) => { const input = extractHandlerInput(args); - // Validate workflow input + // Parse workflow input. This is the RECEIVING side of the input + // boundary: the typed client validated the args but transmitted the + // caller's original value, so the parse (and any schema transform) + // happens exactly once, here. const inputResult = await definition.input["~standard"].validate(input); if (inputResult.issues) { throw new WorkflowInputValidationError(workflowName, inputResult.issues); @@ -312,7 +342,7 @@ export function declareWorkflow< errors: workflowErrorConstructors as WorkflowContext["errors"], }; - // Execute workflow (pass validated input as tuple). + // Execute workflow with the parsed input. // // A thrown typed contract error (`throw context.errors.X(...)`) is // converted to its `ApplicationFailure` wire shape here. This must @@ -336,13 +366,17 @@ export function declareWorkflow< throw error; } - // Validate workflow output + // Validate workflow output, but hand Temporal the implementation's + // ORIGINAL return value. This is the SENDING side of the result + // boundary: the consuming side (client `result()`/`executeWorkflow`, + // parent workflows awaiting a child) parses the payload, so a + // transforming output schema is applied exactly once, on receive. const outputResult = await definition.output["~standard"].validate(result); if (outputResult.issues) { throw new WorkflowOutputValidationError(workflowName, outputResult.issues); } - return outputResult.value as WorkerInferOutput; + return result as WorkerInferOutput; }; // Temporal's client.workflow.start(fn, ...) reads `fn.name` to derive the @@ -526,16 +560,7 @@ type WorkflowContext< */ defineSignal: >( signalName: K, - handler: SignalHandlerImplementation< - NonNullable extends Record< - string, - SignalDefinition - > - ? NonNullable[K] extends SignalDefinition - ? NonNullable[K] - : never - : never - >, + handler: SignalHandlerImplementation>, ) => void; /** @@ -557,16 +582,7 @@ type WorkflowContext< */ defineQuery: >( queryName: K, - handler: QueryHandlerImplementation< - NonNullable extends Record< - string, - QueryDefinition - > - ? NonNullable[K] extends QueryDefinition - ? NonNullable[K] - : never - : never - >, + handler: QueryHandlerImplementation>, ) => void; /** @@ -589,24 +605,18 @@ type WorkflowContext< */ defineUpdate: >( updateName: K, - handler: UpdateHandlerImplementation< - NonNullable extends Record< - string, - UpdateDefinition - > - ? NonNullable[K] extends UpdateDefinition - ? NonNullable[K] - : never - : never - >, + handler: UpdateHandlerImplementation>, ) => void; /** * Start a child workflow and return a typed handle with AsyncResult pattern * - * Supports both same-contract and cross-contract child workflows: - * - Same contract: Pass workflowName from current contract - * - Cross-contract: Pass contract and workflowName to invoke workflows from other workers + * The `contract` argument is always required — it identifies the task + * queue and workflow definition the child runs against: + * - Same-contract child: pass this worker's own contract and one of its + * workflow names. + * - Cross-contract child: pass another worker's contract to invoke a + * workflow it serves (the child's task queue comes from that contract). * * @example * ```ts @@ -655,9 +665,12 @@ type WorkflowContext< /** * Execute a child workflow (start and wait for result) with AsyncResult pattern * - * Supports both same-contract and cross-contract child workflows: - * - Same contract: Pass workflowName from current contract - * - Cross-contract: Pass contract and workflowName to invoke workflows from other workers + * The `contract` argument is always required — it identifies the task + * queue and workflow definition the child runs against: + * - Same-contract child: pass this worker's own contract and one of its + * workflow names. + * - Cross-contract child: pass another worker's contract to invoke a + * workflow it serves (the child's task queue comes from that contract). * * @example * ```ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6904c859..140f29bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,9 @@ settings: catalogs: default: + '@arethetypeswrong/cli': + specifier: 0.18.5 + version: 0.18.5 '@btravstack/commitlint': specifier: 0.1.0 version: 0.1.0 @@ -78,6 +81,9 @@ catalogs: pino-pretty: specifier: 13.1.3 version: 13.1.3 + publint: + specifier: 0.3.21 + version: 0.3.21 testcontainers: specifier: 12.0.4 version: 12.0.4 @@ -246,7 +252,7 @@ importers: version: 26.1.1 tsdown: specifier: 'catalog:' - version: 0.22.12(oxc-resolver@11.21.3)(tsx@4.23.1)(typescript@6.0.3) + 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) typescript: specifier: 'catalog:' version: 6.0.3 @@ -318,6 +324,9 @@ importers: specifier: workspace:* version: link:../contract devDependencies: + '@arethetypeswrong/cli': + specifier: 'catalog:' + version: 0.18.5 '@btravstack/tsconfig': specifier: 'catalog:' version: 0.2.0 @@ -348,9 +357,12 @@ importers: '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.10(vitest@4.1.10) + publint: + specifier: 'catalog:' + version: 0.3.21 tsdown: specifier: 'catalog:' - version: 0.22.12(oxc-resolver@11.21.3)(tsx@4.23.1)(typescript@6.0.3) + 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) typedoc: specifier: 'catalog:' version: 0.28.20(typescript@6.0.3) @@ -375,10 +387,10 @@ importers: '@standard-schema/spec': specifier: 'catalog:' version: 1.1.0 - zod: - specifier: 'catalog:' - version: 4.4.3 devDependencies: + '@arethetypeswrong/cli': + specifier: 'catalog:' + version: 0.18.5 '@btravstack/tsconfig': specifier: 'catalog:' version: 0.2.0 @@ -397,9 +409,12 @@ importers: arktype: specifier: 'catalog:' version: 2.2.3 + publint: + specifier: 'catalog:' + version: 0.3.21 tsdown: specifier: 'catalog:' - version: 0.22.12(oxc-resolver@11.21.3)(tsx@4.23.1)(typescript@6.0.3) + 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) typedoc: specifier: 'catalog:' version: 0.28.20(typescript@6.0.3) @@ -418,6 +433,9 @@ importers: vitest: specifier: 'catalog:' version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(yaml@2.9.0) + zod: + specifier: 'catalog:' + version: 4.4.3 packages/testing: dependencies: @@ -425,6 +443,9 @@ importers: specifier: 'catalog:' version: 12.0.4 devDependencies: + '@arethetypeswrong/cli': + specifier: 'catalog:' + version: 0.18.5 '@btravstack/tsconfig': specifier: 'catalog:' version: 0.2.0 @@ -440,15 +461,21 @@ importers: '@temporalio/worker': specifier: 'catalog:' version: 1.20.3 + '@temporalio/workflow': + specifier: 'catalog:' + version: 1.20.3 '@types/node': specifier: 'catalog:' version: 26.1.1 '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.10(vitest@4.1.10) + publint: + specifier: 'catalog:' + version: 0.3.21 tsdown: specifier: 'catalog:' - version: 0.22.12(oxc-resolver@11.21.3)(tsx@4.23.1)(typescript@6.0.3) + 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) typedoc: specifier: 'catalog:' version: 0.28.20(typescript@6.0.3) @@ -458,9 +485,15 @@ importers: typescript: specifier: 'catalog:' version: 6.0.3 + unthrown: + specifier: 'catalog:' + version: 5.0.0 vitest: specifier: 'catalog:' version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(yaml@2.9.0) + zod: + specifier: 'catalog:' + version: 4.4.3 packages/worker: dependencies: @@ -471,6 +504,9 @@ importers: specifier: workspace:* version: link:../contract devDependencies: + '@arethetypeswrong/cli': + specifier: 'catalog:' + version: 0.18.5 '@btravstack/tsconfig': specifier: 'catalog:' version: 0.2.0 @@ -504,9 +540,12 @@ importers: '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.10(vitest@4.1.10) + publint: + specifier: 'catalog:' + version: 0.3.21 tsdown: specifier: 'catalog:' - version: 0.22.12(oxc-resolver@11.21.3)(tsx@4.23.1)(typescript@6.0.3) + 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) typedoc: specifier: 'catalog:' version: 0.28.20(typescript@6.0.3) @@ -604,9 +643,21 @@ packages: resolution: {integrity: sha512-6mF9LZMUk0QqWvrnxkxBqhswwz6Xfiwy6/gmTzL5HrlhdVG3ITAqGV2k3XmVThP1h0Ulc3VQwiNCD7/Nr4JNlQ==} engines: {node: '>= 14.0.0'} + '@andrewbranch/untar.js@1.0.3': + resolution: {integrity: sha512-Jh15/qVmrLGhkKJBdXlK1+9tY4lZruYjsgkDFj08ZmDiWVBLJcqkok7Z0/R0In+i1rScBpJlSvrTS2Lm41Pbnw==} + '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@arethetypeswrong/cli@0.18.5': + resolution: {integrity: sha512-gM+8vRsQOD/Uc7EnBedUhkG5OCsDWE4uoak5QvomGpMpaky0Eh41p04nIMgrWb8EOmqZUJGc6zz9hsP6E56R7g==} + engines: {node: '>=20'} + hasBin: true + + '@arethetypeswrong/core@0.18.5': + resolution: {integrity: sha512-9ytjzGwxjm9Uz7I9avfbt5vlQt6uk9uRRESzJjqrznl6WKvI6dwYTo+vJ3U02Wrq/mR3iql/PzhvHhKdJIAjDQ==} + engines: {node: '>=20'} + '@ark/schema@0.56.2': resolution: {integrity: sha512-Qx4D2JFbBWpntiHZaTv7bGG4H/M2rigiknezKg/WVyDSaLdE4YCcWAOoFB7pjjDqHbbV2OqRfntm1nnXvwMexg==} @@ -645,6 +696,9 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@braidai/lang@1.1.2': + resolution: {integrity: sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==} + '@braintree/sanitize-url@6.0.4': resolution: {integrity: sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==} @@ -747,6 +801,10 @@ packages: '@chevrotain/types@11.1.2': resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + '@commitlint/cli@21.2.1': resolution: {integrity: sha512-blsZGe29hJ72VGEFVl72IVYX+1vsfINpjA9yWQA6i7OKD/McGEOXg08sKIRKjFk4JvzhV/9n0l3i6NooPLTNfg==} engines: {node: '>=22.12.0'} @@ -1369,6 +1427,9 @@ packages: '@kwsites/file-exists@1.1.1': resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} + '@loaderkit/resolve@1.0.6': + resolution: {integrity: sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==} + '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -1913,6 +1974,10 @@ packages: '@protobufjs/utf8@1.1.2': resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + '@publint/pack@0.1.5': + resolution: {integrity: sha512-edgyN2pP07uXiP4tJs0s8KVmU8M8i60YPbbI0/WDeok1mIJHRXz+CgD8I0nelwDkoCh3EWL/G5kGfbuHjsdbvw==} + engines: {node: '>=18'} + '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} @@ -2196,6 +2261,10 @@ packages: resolution: {integrity: sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==} engines: {node: '>=22'} + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2873,6 +2942,10 @@ packages: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -2893,6 +2966,9 @@ packages: resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} engines: {node: '>=14'} + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + archiver-utils@5.0.2: resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} engines: {node: '>= 14'} @@ -3069,6 +3145,18 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -3085,6 +3173,21 @@ packages: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + + cli-highlight@2.1.11: + resolution: {integrity: sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==} + engines: {node: '>=8.0.0', npm: '>=5.0.0'} + hasBin: true + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -3106,6 +3209,10 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -3421,6 +3528,9 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + emojilib@2.4.0: + resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==} + empathic@2.0.1: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} @@ -3448,6 +3558,10 @@ packages: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -3550,6 +3664,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -3654,6 +3771,9 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + highlight.js@10.7.3: + resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -3921,6 +4041,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lunr@2.3.9: resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} @@ -3941,11 +4065,22 @@ packages: resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} hasBin: true + marked-terminal@7.3.0: + resolution: {integrity: sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==} + engines: {node: '>=16.0.0'} + peerDependencies: + marked: '>=1 <16' + marked@16.4.2: resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} engines: {node: '>= 20'} hasBin: true + marked@9.1.6: + resolution: {integrity: sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==} + engines: {node: '>= 16'} + hasBin: true + mdast-util-to-hast@13.2.1: resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} @@ -4079,6 +4214,9 @@ packages: resolution: {integrity: sha512-kh8ARjh8rMN7Du2igDRO9QJnqCb2xYTJxyQYK7vJJS4TvLLmsbyhiKpSW+t+y26gyOyMd0riphX0GeWKU3ky5g==} engines: {node: '>=12.13'} + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nan@2.27.0: resolution: {integrity: sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==} @@ -4094,6 +4232,10 @@ packages: resolution: {integrity: sha512-IWjIExdVYlmwXuzHdY/Q3lXCv1gbqoAXPazQhy2w4Xgtgha3H0OOujEESVPQcFUFMWm+pAk2gKnb57g8S41JZg==} engines: {node: '>= 20.0.0'} + node-emoji@2.2.0: + resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} + engines: {node: '>=18'} + node-releases@2.0.47: resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} engines: {node: '>=18'} @@ -4105,6 +4247,10 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + obug@2.1.3: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} @@ -4196,6 +4342,15 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse5-htmlparser2-tree-adapter@6.0.1: + resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} + + parse5@5.1.1: + resolution: {integrity: sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==} + + parse5@6.0.1: + resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + path-data-parser@0.1.0: resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} @@ -4296,6 +4451,11 @@ packages: resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} engines: {node: '>=12.0.0'} + publint@0.3.21: + resolution: {integrity: sha512-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ==} + engines: {node: '>=18'} + hasBin: true + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -4423,6 +4583,10 @@ packages: rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -4469,6 +4633,10 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + skin-tone@2.0.0: + resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==} + engines: {node: '>=8'} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -4589,6 +4757,10 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} + supports-hyperlinks@3.2.0: + resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} + engines: {node: '>=14.18'} + swc-loader@0.2.7: resolution: {integrity: sha512-nwYWw3Fh9ame3Rtm7StS9SBLpHRRnYcK7bnpF3UKZmesAK0gw2/ADvlURFAINmPvKtDLzp+GBiP9yLoEjg6S9w==} peerDependencies: @@ -4633,6 +4805,13 @@ packages: text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thingies@2.6.0: resolution: {integrity: sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==} engines: {node: '>=10.18'} @@ -4749,6 +4928,11 @@ packages: peerDependencies: typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x + typescript@5.6.1-rc: + resolution: {integrity: sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ==} + engines: {node: '>=14.17'} + hasBin: true + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -4774,6 +4958,10 @@ packages: resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==} engines: {node: '>=22.19.0'} + unicode-emoji-modifier-base@1.0.0: + resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} + engines: {node: '>=4'} + unionfs@4.6.0: resolution: {integrity: sha512-fJAy3gTHjFi5S3TP5EGdjs/OUMFFvI/ady3T8qVuZfkv8Qi8prV/Q8BuFEgODJslhZTT2z2qdD2lGdee9qjEnA==} @@ -4825,6 +5013,10 @@ packages: typescript: optional: true + validate-npm-package-name@5.0.1: + resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + verkit@0.1.2: resolution: {integrity: sha512-WqkT8n3hqizuCu71W3bUzf5fjBmkbXcudsehe/NbxA8PgqoKnSOY5K0Ba2ckg1qaRaSpSz7as/n9K1R9JXjQKg==} engines: {node: '>=18.12.0'} @@ -4997,6 +5189,10 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -5005,6 +5201,10 @@ packages: resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yargs@16.2.2: + resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==} + engines: {node: '>=10'} + yargs@17.7.3: resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} engines: {node: '>=12'} @@ -5145,11 +5345,34 @@ snapshots: dependencies: '@algolia/client-common': 5.53.0 + '@andrewbranch/untar.js@1.0.3': {} + '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.6.0 tinyexec: 1.2.4 + '@arethetypeswrong/cli@0.18.5': + dependencies: + '@arethetypeswrong/core': 0.18.5 + chalk: 4.1.2 + cli-table3: 0.6.5 + commander: 10.0.1 + marked: 9.1.6 + marked-terminal: 7.3.0(marked@9.1.6) + semver: 7.8.5 + + '@arethetypeswrong/core@0.18.5': + dependencies: + '@andrewbranch/untar.js': 1.0.3 + '@loaderkit/resolve': 1.0.6 + cjs-module-lexer: 1.4.3 + fflate: 0.8.3 + lru-cache: 11.5.2 + semver: 7.8.5 + typescript: 5.6.1-rc + validate-npm-package-name: 5.0.1 + '@ark/schema@0.56.2': dependencies: '@ark/util': 0.56.2 @@ -5181,6 +5404,8 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@braidai/lang@1.1.2': {} + '@braintree/sanitize-url@6.0.4': optional: true @@ -5357,6 +5582,9 @@ snapshots: '@chevrotain/types@11.1.2': {} + '@colors/colors@1.5.0': + optional: true + '@commitlint/cli@21.2.1(@types/node@26.1.1)(conventional-commits-parser@7.0.1)(typescript@6.0.3)': dependencies: '@commitlint/config-conventional': 21.2.0 @@ -5901,6 +6129,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@loaderkit/resolve@1.0.6': + dependencies: + '@braidai/lang': 1.1.2 + '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.29.7 @@ -6233,6 +6465,10 @@ snapshots: '@protobufjs/utf8@1.1.2': {} + '@publint/pack@0.1.5': + dependencies: + tinyexec: 1.2.4 + '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 @@ -6427,6 +6663,8 @@ snapshots: '@simple-libs/stream-utils@2.0.0': {} + '@sindresorhus/is@4.6.0': {} + '@standard-schema/spec@1.1.0': {} '@swc/core-darwin-arm64@1.15.41': @@ -7172,6 +7410,10 @@ snapshots: ansi-colors@4.1.3: {} + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -7184,6 +7426,8 @@ snapshots: ansis@4.3.1: {} + any-promise@1.3.0: {} + archiver-utils@5.0.2: dependencies: glob: 10.5.0 @@ -7352,6 +7596,15 @@ snapshots: chai@6.2.2: {} + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + char-regex@1.0.2: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -7362,6 +7615,29 @@ snapshots: chrome-trace-event@1.0.4: {} + cjs-module-lexer@1.4.3: {} + + cli-highlight@2.1.11: + dependencies: + chalk: 4.1.2 + highlight.js: 10.7.3 + mz: 2.7.0 + parse5: 5.1.1 + parse5-htmlparser2-tree-adapter: 6.0.1 + yargs: 16.2.2 + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -7384,6 +7660,8 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@10.0.1: {} + commander@2.20.3: {} commander@7.2.0: {} @@ -7718,6 +7996,8 @@ snapshots: emoji-regex@9.2.2: {} + emojilib@2.4.0: {} + empathic@2.0.1: {} end-of-stream@1.4.5: @@ -7740,6 +8020,8 @@ snapshots: env-paths@2.2.1: {} + environment@1.1.0: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -7873,6 +8155,8 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + fflate@0.8.3: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -7986,6 +8270,8 @@ snapshots: help-me@5.0.0: {} + highlight.js@10.7.3: {} + hookable@5.5.3: {} hookable@6.1.1: {} @@ -8204,6 +8490,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.5.2: {} + lunr@2.3.9: {} magic-string@0.30.21: @@ -8231,8 +8519,21 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 + marked-terminal@7.3.0(marked@9.1.6): + dependencies: + ansi-escapes: 7.3.0 + ansi-regex: 6.2.2 + chalk: 5.6.2 + cli-highlight: 2.1.11 + cli-table3: 0.6.5 + marked: 9.1.6 + node-emoji: 2.2.0 + supports-hyperlinks: 3.2.0 + marked@16.4.2: {} + marked@9.1.6: {} + mdast-util-to-hast@13.2.1: dependencies: '@types/hast': 3.0.4 @@ -8358,6 +8659,12 @@ snapshots: ms@3.0.0-canary.1: {} + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + nan@2.27.0: optional: true @@ -8367,6 +8674,13 @@ snapshots: nexus-rpc@0.0.2: {} + node-emoji@2.2.0: + dependencies: + '@sindresorhus/is': 4.6.0 + char-regex: 1.0.2 + emojilib: 2.4.0 + skin-tone: 2.0.0 + node-releases@2.0.47: {} non-layered-tidy-tree-layout@2.0.2: @@ -8374,6 +8688,8 @@ snapshots: normalize-path@3.0.0: {} + object-assign@4.1.1: {} + obug@2.1.3: {} obug@2.1.4: {} @@ -8520,6 +8836,14 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse5-htmlparser2-tree-adapter@6.0.1: + dependencies: + parse5: 6.0.1 + + parse5@5.1.1: {} + + parse5@6.0.1: {} + path-data-parser@0.1.0: {} path-exists@4.0.0: {} @@ -8637,6 +8961,13 @@ snapshots: '@types/node': 26.1.1 long: 5.3.2 + publint@0.3.21: + dependencies: + '@publint/pack': 0.1.5 + package-manager-detector: 1.6.0 + picocolors: 1.1.1 + sade: 1.8.1 + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -8802,6 +9133,10 @@ snapshots: dependencies: tslib: 2.8.1 + sade@1.8.1: + dependencies: + mri: 1.2.0 + safe-buffer@5.1.2: {} safe-buffer@5.2.1: {} @@ -8844,6 +9179,10 @@ snapshots: signal-exit@4.1.0: {} + skin-tone@2.0.0: + dependencies: + unicode-emoji-modifier-base: 1.0.0 + slash@3.0.0: {} smol-toml@1.6.1: {} @@ -8967,6 +9306,11 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-hyperlinks@3.2.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + swc-loader@0.2.7(@swc/core@1.15.41)(webpack@5.108.4(@swc/core@1.15.41)): dependencies: '@swc/core': 1.15.41 @@ -9060,6 +9404,14 @@ snapshots: transitivePeerDependencies: - react-native-b4a + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + thingies@2.6.0(tslib@2.8.1): dependencies: tslib: 2.8.1 @@ -9097,7 +9449,7 @@ snapshots: ts-dedent@2.3.0: {} - tsdown@0.22.12(oxc-resolver@11.21.3)(tsx@4.23.1)(typescript@6.0.3): + tsdown@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): dependencies: ansis: 4.3.1 cac: 7.0.0 @@ -9115,6 +9467,8 @@ snapshots: unconfig-core: 7.5.0 verkit: 0.1.2 optionalDependencies: + '@arethetypeswrong/core': 0.18.5 + publint: 0.3.21 tsx: 4.23.1 typescript: 6.0.3 transitivePeerDependencies: @@ -9155,6 +9509,8 @@ snapshots: typescript: 6.0.3 yaml: 2.9.0 + typescript@5.6.1-rc: {} + typescript@6.0.3: {} uc.micro@2.1.0: {} @@ -9172,6 +9528,8 @@ snapshots: undici@8.5.0: {} + unicode-emoji-modifier-base@1.0.0: {} + unionfs@4.6.0: dependencies: fs-monkey: 1.1.0 @@ -9219,6 +9577,8 @@ snapshots: optionalDependencies: typescript: 6.0.3 + validate-npm-package-name@5.0.1: {} + verkit@0.1.2: {} vfile-message@4.0.3: @@ -9433,10 +9793,22 @@ snapshots: yaml@2.9.0: {} + yargs-parser@20.2.9: {} + yargs-parser@21.1.1: {} yargs-parser@22.0.0: {} + yargs@16.2.2: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + yargs@17.7.3: dependencies: cliui: 8.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a1e272ed..d68f9127 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,6 +10,7 @@ packages: - docs catalog: + "@arethetypeswrong/cli": 0.18.5 "@btravstack/commitlint": 0.1.0 "@btravstack/lefthook": 0.1.1 "@btravstack/oxlint": 0.2.1 @@ -34,6 +35,7 @@ catalog: oxlint: 1.74.0 pino: 10.3.1 pino-pretty: 13.1.3 + publint: 0.3.21 testcontainers: 12.0.4 tsdown: 0.22.12 tsx: 4.23.1 diff --git a/turbo.json b/turbo.json index 576b8c56..bb76fcdb 100644 --- a/turbo.json +++ b/turbo.json @@ -29,6 +29,10 @@ "@temporal-contract/docs#build": { "dependsOn": ["^build", "^build:docs"], "outputs": [".vitepress/dist/**"] + }, + "check:package": { + "dependsOn": ["build"], + "outputs": [] } } }