Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .agents/rules/contract-patterns.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Contract Patterns

## Verb policy (btravstack-wide naming convention)

API names follow a three-tier verb convention, shared with amqp-contract:

| Verb | Meaning | Examples |
| ---------------------------- | -------------------------------------------- | -------------------------------------------------------------------------- |
| `define*` | Contract/schema authoring (pure, no runtime) | `defineContract`, `defineWorkflow`, `defineActivity`, `defineSignal`, … |
| `declare*` | Binding an implementation to a contract | `declareWorkflow`, `declareActivitiesHandler`, `declareActivityMiddleware` |
| `create*` / `Typed*` classes | Runtime object factories | `TypedWorker.create`, `TypedClient.create`, `createContractTest` |

Additionally, **in-workflow** handler binding uses `handle*` on the workflow context: `context.handleSignal`, `context.handleQuery`, `context.handleUpdate`. When naming a new export, pick the tier by what it does — never a `define*` that touches runtime, never a `declare*` that authors schema.

**Signature shape (Deno rule, btravstack-wide): exported functions take at most 2 positional arguments; everything else goes into a trailing options object.** Prefer a single options bag for factories (`defineContract({...})`, `declareWorkflow({...})`), and `fn(subject, options)` where one positional subject is natural (e.g. `startUpdate(updateName, options)`). Never add a third positional parameter to a public function.

## Defining a Contract

**Composition-first (org rule, shared with amqp-contract): define resources
Expand Down
16 changes: 10 additions & 6 deletions .agents/rules/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,16 @@ All dependency versions are centralized in `pnpm-workspace.yaml` under the `cata

Anything that appears in a published package's **public type signatures** must be a peer dep, not a regular dep — otherwise downstream consumers can end up with two disjoint nominal types in their typechecker (theirs and ours), even though the runtime classes are compatible.

| Package | Peer dependencies |
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| client | `@temporalio/client ^1`, `@temporalio/common ^1`, `unthrown ^5` |
| worker | `@temporalio/common ^1`, `@temporalio/worker ^1`, `@temporalio/workflow ^1`, `unthrown ^5` |
| contract | `unthrown ^5` (optional — only needed when using `result-async`) |
| testing | `@temporal-contract/client`, `@temporal-contract/contract`, `@temporal-contract/worker` (concrete `^8.x` ranges — see below), `unthrown ^5` (the contract-aware fixtures expose `TypedClient`/`ContractClient`/`ActivitiesHandler`/`AsyncResult` in their public types), `vitest ^4` (the `globalSetup` hook integrates with vitest's test runner), `@temporalio/client ^1`, `@temporalio/testing ^1`, `@temporalio/worker ^1` (all exposed by the fixtures' public types — e.g. `TestWorkflowEnvironment` in `time-skipping`) |
| Package | Peer dependencies |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| client | `@temporalio/client ^1.16.0`, `@temporalio/common ^1.16.0`, `unthrown ^5` |
| worker | `@temporalio/common ^1.16.0`, `@temporalio/worker ^1.16.0`, `@temporalio/workflow ^1.16.0`, `unthrown ^5` |
| contract | `unthrown ^5` (optional — only needed when using the `/errors` or `/internal` entry points) |
| testing | `@temporal-contract/client`, `@temporal-contract/contract`, `@temporal-contract/worker` (concrete `^8.x` ranges — see below), `unthrown ^5` (the contract-aware fixtures expose `TypedClient`/`ContractClient`/`ActivitiesHandler`/`AsyncResult` in their public types), `vitest ^4` (the `globalSetup` hook integrates with vitest's test runner), `@temporalio/client ^1.16.0`, `@temporalio/testing ^1.16.0`, `@temporalio/worker ^1.16.0` (all exposed by the fixtures' public types — e.g. `TestWorkflowEnvironment` in `time-skipping`) |

The `@temporalio/*` peer floor is `^1.16.0` across all packages: the client hard-requires the Schedule API on `Client` instances (runtime-checked at `TypedClient.create`) and imports `defineSearchAttributeKey`/`TypedSearchAttributes` top-level from `@temporalio/common`, so `^1` overstated compatibility. Keep the floor in sync across the four packages when raising it.

**Deliberate exception — `@standard-schema/spec`:** it appears in public type signatures (e.g. `StandardSchemaV1.Issue` on validation errors) but stays a **regular dep**, not a peer. It is a types-only structural package — no runtime code and no nominal classes, so the "two disjoint nominal types" failure mode above cannot occur: any two copies of the spec are structurally identical to the typechecker. Making it a peer would only push install churn onto consumers for zero benefit.

When you add a peer dep, also add it to `devDependencies` (with the same `"catalog:"` reference) so the local workspace build still resolves it. The workspace has `autoInstallPeers: false`, so peers must be present somewhere on the install side.

Expand Down
63 changes: 39 additions & 24 deletions .agents/rules/handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,22 @@ export const activities = declareActivitiesHandler({
});
```

`fromPromise(promise, qualifyFailure)` forces every rejection through `qualifyFailure`, which
returns the modeled error `E` (here an `ApplicationFailure`). For the common
case, the worker package exports a `qualifyFailure(type, options?)` helper that builds
that function — `fromPromise(inventoryService.check(orderId), qualifyFailure("INVENTORY_CHECK_FAILED"))`
preserves an `Error` rejection's message and `cause`, with `options.nonRetryable`
/ `options.details` / `options.message` (non-`Error` fallback) available. For a
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`.
`fromPromise(promise, qualify)` forces every rejection through `qualify`, which
returns the modeled error `E` (here an `ApplicationFailure`) or routes the cause
to the defect channel. For the common case, the worker package exports a
`qualifyFailure(errorType, options)` helper that builds that function —
`options.expected` is **required** (an error-class constructor, an array of
them, a predicate, or the literal `"any"` escape hatch): matching causes are
wrapped (preserving an `Error` rejection's message and `cause`, and inheriting
`nonRetryable: true` from a matched non-retryable `ApplicationFailure` unless
`options.nonRetryable` overrides), everything else becomes a defect. E.g.
`fromPromise(inventoryService.check(orderId), qualifyFailure("INVENTORY_CHECK_FAILED", { expected: InventoryServiceError }))`.
`options.details` / `options.message` (non-`Error` fallback) remain available. For a
value you already have, use the canonical pre-lifted constructors
`OkAsync(value)` / `ErrAsync(failure)` (prefer `OkAsync()` zero-arg over
`OkAsync(undefined)`); `.toAsync()` is for lifting a sync `Result` you already
hold, not for direct construction — unthrown has no lowercase
`okAsync`/`errAsync`.

Canonical example: `examples/order-processing-worker/src/application/activities.ts`.

Expand Down Expand Up @@ -71,7 +78,7 @@ export const processOrder = declareWorkflow({
implementation: async (context, args) => {
// context.activities — typed, validated activities
// context.info — WorkflowInfo
// context.defineSignal/defineQuery/defineUpdate — handler registration
// context.handleSignal/handleQuery/handleUpdate — handler binding
// context.executeChildWorkflow / context.startChildWorkflow
// context.cancellableScope / context.nonCancellableScope — see below

Expand Down Expand Up @@ -100,21 +107,29 @@ Typed-error semantics inside the workflow context:

## Worker Setup

`createWorker` returns `AsyncResult<Worker, never>` — bundling / connection
failures are _technical_ infrastructure faults, so they ride the **defect**
channel (a `TechnicalError` instance as the cause), not the modeled Err
channel. `activities` is optional — omit it for a workflow-only worker. Same
shape on the client: the connection-scoped `TypedClient.create({ client })`
returns `AsyncResult<TypedClient, never>` (a `TechnicalError`-caused defect on
setup failure); bind a contract with the synchronous, infallible
`typedClient.for(contract)`, which returns a `ContractClient<TContract>` (the
type to use in annotations). There is no `TypedClient.createOrThrow` and no
`createWorkerOrThrow` — use `.get()` on the returned `AsyncResult`.
`TypedWorker.create(options)` — the org's `Typed*.create()` factory shape,
mirroring `TypedClient.create` — returns `AsyncResult<TypedWorker, never>`:
bundling / connection failures are _technical_ infrastructure faults, so they
ride the **defect** channel (a `TechnicalError` instance as the cause), not
the modeled Err channel. `activities` is optional — omit it for a
workflow-only worker. Same shape on the client: the connection-scoped
`TypedClient.create({ client })` returns `AsyncResult<TypedClient, never>` (a
`TechnicalError`-caused defect on setup failure); bind a contract with the
synchronous, infallible `typedClient.for(contract)`, which returns a
`ContractClient<TContract>` (the type to use in annotations). There is no
`TypedClient.createOrThrow`, no `createWorker`, and no `createWorkerOrThrow`
— use `.get()` on the returned `AsyncResult`.

`TypedWorker` owns the unthrown-disciplined lifecycle: `run()` returns
`AsyncResult<void, never>` (a mid-run crash is a `TechnicalError`-caused
defect; the internal promise never rejects) and `shutdown()` initiates a
graceful drain. Everything else Temporal offers (`runUntil`, `getState`)
lives on the raw escape hatch `worker.raw`.

```typescript
import { createWorker, workflowsPathFromURL } from "@temporal-contract/worker/worker";
import { TypedWorker, workflowsPathFromURL } from "@temporal-contract/worker/worker";

const workerResult = await createWorker({
const workerResult = await TypedWorker.create({
contract: myContract,
connection,
workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"),
Expand All @@ -125,7 +140,7 @@ if (workerResult.isDefect()) {
process.exit(1);
}

await workerResult.value.run();
await workerResult.get().run().get();
```

## Cancellation
Expand Down Expand Up @@ -184,7 +199,7 @@ ApplicationFailure.create({

## Anti-patterns

- **Never throw** from activities — Temporal sees thrown errors as `ApplicationFailure(type: "Error", retryable: true)` by default, which masks the real failure type and triggers unwanted retries. Use `Err(ApplicationFailure.create({ type, message, nonRetryable })).toAsync()` (or a `fromPromise(promise, qualifyFailure)` chain whose `qualifyFailure` returns the `ApplicationFailure`) instead.
- **Never throw** from activities — Temporal sees thrown errors as `ApplicationFailure(type: "Error", retryable: true)` by default, which masks the real failure type and triggers unwanted retries. Use `ErrAsync(ApplicationFailure.create({ type, message, nonRetryable }))` (or a `fromPromise(promise, qualifyFailure)` chain whose `qualifyFailure` returns the `ApplicationFailure`) instead.
- **Never use `any`** — use `unknown` and validate with schemas. Enforced by oxlint.
- **Always use `.js` extensions** in imports (even for TypeScript files) — required by ESM module resolution.
- **Don't `try/catch` `CancelledFailure` in workflows** — use `cancellableScope` so cancellation flows through the same `AsyncResult` discipline as everything else.
20 changes: 20 additions & 0 deletions .changeset/family-consistency-typed-worker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@temporal-contract/worker": major
"@temporal-contract/testing": major
"@temporal-contract/contract": major
"@temporal-contract/client": major
---

Family-consistency audit: `TypedWorker.create` replaces `createWorker`, and `OkAsync`/`ErrAsync` become the canonical pre-lifted constructors.

**Breaking (worker).** The free `createWorker` function is removed in favour of a static factory on a new `TypedWorker` class — the worker-side sibling of `TypedClient.create` and the org's shared `Typed*.create()` shape (matching amqp-contract's `TypedAmqpClient.create` / `TypedAmqpWorker.create`):

- `TypedWorker.create(options)` takes the same `CreateWorkerOptions` and returns `AsyncResult<TypedWorker, never>` — bundling/connection failures stay technical defects with a `TechnicalError` cause; unwrap with `.get()`.
- `worker.run()` returns `AsyncResult<void, never>`: a worker that fails while running surfaces as a `TechnicalError`-caused defect and the underlying promise never rejects (safe to hold across a test without unhandled-rejection guards). `await worker.run().get()` rethrows at the edge.
- `worker.shutdown()` delegates to the raw worker; everything else Temporal's runtime owns (`runUntil`, `getState`, …) lives on the `worker.raw` escape hatch.

Migration: `createWorker(opts).get()` → `TypedWorker.create(opts).get()`, `await worker.run()` → `await worker.run().get()`, `worker.runUntil(...)`/`worker.getState()` → `worker.raw.runUntil(...)`/`worker.raw.getState()`.

**Breaking (testing).** `createContractTest`'s `worker` fixture now exposes the `TypedWorker` (the raw Temporal `Worker` is at `worker.raw`).

**Docs/idiom sweep (all packages).** Pre-lifted async results are now built with `OkAsync(value)` / `ErrAsync(error)` (canonical since unthrown 4.1) instead of `Ok(value).toAsync()` / `Err(error).toAsync()` throughout the docs, examples, and TSDoc; `.toAsync()` remains for lifting an existing sync `Result`.
Loading
Loading