Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .agents/rules/adding-a-package.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,6 @@ If the new package introduces a public concept agents should know about (a new e
## What NOT to do

- **Don't add a root `.` entry to `exports`** unless the package is single-entry. Subpath-only is intentional for multi-entry packages.
- **Don't put `neverthrow` (or any other type-bearing dep) in `dependencies`** — peer-dep, see [dependencies.md](./dependencies.md).
- **Don't put `unthrown` (or any other type-bearing dep) in `dependencies`** — peer-dep, see [dependencies.md](./dependencies.md).
- **Don't import from `@temporal-contract/<other>` via relative path.** Use the workspace-resolved package name even for sibling packages.
- **Don't skip the changeset.** CI will pass without one, but the release will skip the package silently. Changesets are the only release mechanism.
8 changes: 5 additions & 3 deletions .agents/rules/code-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@ ApplicationFailure.create({

## Error Handling

- Use neverthrow's `Result<T, E>` / `ResultAsync<T, E>` instead of throwing exceptions
- Activities return `ResultAsync<T, ApplicationFailure>`
- Client methods return `ResultAsync<T, E>` with specific error types
- Use unthrown's `Result<T, E>` / `AsyncResult<T, E>` instead of throwing exceptions
- Activities return `AsyncResult<T, ApplicationFailure>`
- Client methods return `AsyncResult<T, E>` with specific error types
- Narrow results 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
- An unanticipated throw surfaces on unthrown's third **`defect`** channel, not as a typed `err`; build error classes with `TaggedError("Name")<{ ...payload }>`
- Wrap technical exceptions in `ApplicationFailure` (re-exported from `@temporal-contract/worker/activity`) with a `type` field; set `nonRetryable: true` for permanent failures

## Module System
Expand Down
2 changes: 1 addition & 1 deletion .agents/rules/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ pnpm test:integration # Run integration tests (requires Docker)
| `chore` | Maintenance / housekeeping (release commits, lockfile bumps) |
| `revert` | Reverts a prior commit |

Add `!` after the type for a breaking change (e.g. `feat!: replace boxed with neverthrow`). Header is capped at 100 chars; the type must be lowercase.
Add `!` after the type for a breaking change (e.g. `feat!: replace neverthrow with unthrown`). Header is capped at 100 chars; the type must be lowercase.

## Versioning & Release

Expand Down
6 changes: 3 additions & 3 deletions .agents/rules/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
| `@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 |
| `neverthrow` | `Result` / `ResultAsync` — peer dep of `client`/`worker` |
| `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) |

Expand Down Expand Up @@ -39,8 +39,8 @@ Anything that appears in a published package's **public type signatures** must b

| Package | Peer dependencies |
| -------- | -------------------------------------------------------------------------------------------- |
| client | `@temporalio/client ^1.16.0`, `@temporalio/common ^1`, `neverthrow ^8` |
| worker | `@temporalio/common ^1`, `@temporalio/worker ^1`, `@temporalio/workflow ^1`, `neverthrow ^8` |
| client | `@temporalio/client ^1.16.0`, `@temporalio/common ^1`, `unthrown ^0.1` |
| worker | `@temporalio/common ^1`, `@temporalio/worker ^1`, `@temporalio/workflow ^1`, `unthrown ^0.1` |
| contract | none (pure type definitions) |
| testing | `vitest ^4` (the `globalSetup` hook integrates with vitest's test runner) |

Expand Down
25 changes: 16 additions & 9 deletions .agents/rules/handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@

## Activity Handler

Use `declareActivitiesHandler` with neverthrow's `ResultAsync`:
Use `declareActivitiesHandler` with unthrown's `AsyncResult`:

```typescript
import { declareActivitiesHandler, ApplicationFailure } from "@temporal-contract/worker/activity";
import { ResultAsync } from "neverthrow";
import { fromPromise } from "unthrown";

export const activities = declareActivitiesHandler({
contract: myContract,
activities: {
validateInventory: (args) =>
ResultAsync.fromPromise(inventoryService.check(args.orderId), (error) =>
fromPromise(inventoryService.check(args.orderId), (error) =>
ApplicationFailure.create({
type: "INVENTORY_CHECK_FAILED",
message: error instanceof Error ? error.message : "Failed to check inventory",
Expand All @@ -23,6 +23,11 @@ export const activities = declareActivitiesHandler({
});
```

`fromPromise(promise, qualify)` forces every rejection through `qualify`, which
returns the modeled error `E` (here an `ApplicationFailure`). For a value you
already have, lift a sync result with `ok(value).toAsync()` / `err(failure).toAsync()`
— unthrown has no `okAsync`/`errAsync`.

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

## Workflow Declaration
Expand Down Expand Up @@ -68,15 +73,17 @@ await worker.run();

## Cancellation

Workflows opt into cancellation control via `context.cancellableScope` / `context.nonCancellableScope`. They fold cancellation into the project's `ResultAsync` shape — callers branch on `err(WorkflowCancelledError)` instead of catching `CancelledFailure`.
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 (result.isErr()) {
if (isErr(result)) {
// Workflow was cancelled. Cleanup that must not be cancelled itself
// goes inside `nonCancellableScope`.
await context.nonCancellableScope(async () => {
Expand All @@ -89,9 +96,9 @@ implementation: async (context, args) => {
};
```

- `cancellableScope<T>(fn)` — returns `ResultAsync<T, WorkflowCancelledError>`. Cancels propagate from outside.
- `cancellableScope<T>(fn)` — returns `AsyncResult<T, WorkflowCancelledError>`. Cancels propagate from outside.
- `nonCancellableScope<T>(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` propagate as `ResultAsync` rejections — they don't get wrapped, so domain errors keep their identity for upstream `try/catch`.
- 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.

Canonical implementation: `packages/worker/src/cancellation.ts:38` (`cancellableScope`), `:75` (`nonCancellableScope`). Error class: `packages/worker/src/errors.ts:193`.

Expand Down Expand Up @@ -122,7 +129,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 `errAsync(ApplicationFailure.create({ type, message, nonRetryable }))` (or `.mapErr(...)` on a `ResultAsync.fromPromise(...)` chain) 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, qualify)` chain whose `qualify` 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 `ResultAsync` discipline as everything else.
- **Don't `try/catch` `CancelledFailure` in workflows** — use `cancellableScope` so cancellation flows through the same `AsyncResult` discipline as everything else.
8 changes: 4 additions & 4 deletions .agents/rules/project-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
- Monorepo managed with **pnpm workspaces** and **Turborepo**
- Packages publish to npm under the `@temporal-contract/` scope
- Uses **Standard Schema** (Zod, Valibot, ArkType) for runtime validation
- Uses **Result/ResultAsync** pattern (via `neverthrow`) instead of throwing exceptions
- Uses **Result/AsyncResult** pattern (via `unthrown`) instead of throwing exceptions

## Repo Layout

Expand All @@ -24,13 +24,13 @@
| ---------- | --------------------------------------------------- | ---------------------------------------------------------- |
| `contract` | `packages/contract/src/builder.ts` | Contract builder (`defineContract`) and type definitions |
| `worker` | `packages/worker/src/{activity,workflow,worker}.ts` | Type-safe worker, workflow declarations, activity handlers |
| `client` | `packages/client/src/client.ts` | Type-safe client for consuming workflows via `ResultAsync` |
| `client` | `packages/client/src/client.ts` | Type-safe client for consuming workflows via `AsyncResult` |
| `testing` | `packages/testing/src/global-setup.ts` | Testing utilities (global setup, Temporal test server) |

## Key Concepts

- **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 `ResultAsync<T, E>` for all operations.
- **Result** — `Result<T, E>` and `ResultAsync<T, E>` from neverthrow for explicit error handling.
- **Client** — `TypedClient.create()` returns `AsyncResult<T, E>` for all operations.
- **Result** — `Result<T, E>` and `AsyncResult<T, E>` 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).
4 changes: 2 additions & 2 deletions .agents/rules/workflow-determinism.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ That's also why activity inputs/outputs must be serializable (validated through

## Cancellation primitives are deterministic

Use `context.cancellableScope` / `context.nonCancellableScope` (`packages/worker/src/cancellation.ts:38`, `:75`) — they wrap Temporal's `CancellationScope` and surface cancellation as `err(WorkflowCancelledError)` in a `ResultAsync`. Don't `try/catch` `CancelledFailure` directly; that bypasses the project's `Result` discipline.
Use `context.cancellableScope` / `context.nonCancellableScope` (`packages/worker/src/cancellation.ts:38`, `:75`) — they wrap Temporal's `CancellationScope` and surface cancellation as `err(WorkflowCancelledError)` in an `AsyncResult`. Don't `try/catch` `CancelledFailure` directly; that bypasses the project's `Result` discipline.

## Side-effect escape hatch

Expand All @@ -36,4 +36,4 @@ If you absolutely need non-determinism inside workflow code (e.g. logging at a c

- `examples/order-processing-worker/src/application/workflows.ts` — uses `context.activities.*` for every effectful call, never reaches for native primitives.
- `packages/worker/src/__tests__/test.workflows.ts` — minimal workflows used in integration tests.
- `packages/worker/src/cancellation.ts:38` — `cancellableScope` implementation showing the `ResultAsync` adapter pattern.
- `packages/worker/src/cancellation.ts:38` — `cancellableScope` implementation showing the `AsyncResult` adapter pattern.
20 changes: 20 additions & 0 deletions .changeset/migrate-to-unthrown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@temporal-contract/contract": major
"@temporal-contract/worker": major
"@temporal-contract/client": major
"@temporal-contract/testing": major
---

Replace `neverthrow` with [`unthrown`](https://github.com/btravstack/unthrown) for the Result/error-handling spine across all packages. This is a breaking change to the public API.

**What changed**

- **`ResultAsync<T, E>` → `AsyncResult<T, E>`.** Every activity, workflow-context, child-workflow, schedule, and typed-client method that returned a `ResultAsync` now returns an `AsyncResult`. The `unthrown` peer dependency replaces `neverthrow`.
- **No `okAsync` / `errAsync`.** Lift a synchronous `Result` with `.toAsync()` instead: `ok(value).toAsync()`, `err(failure).toAsync()`. Promise boundaries use `fromPromise(promise, qualify)` / `fromSafePromise(promise)`.
- **Narrow before accessing the payload.** Both the `result.isOk()` / `isErr()` / `isDefect()` methods and the matching free functions `isOk(result)` / `isErr(result)` / `isDefect(result)` (imported from `unthrown`) are type guards; the codebase uses the methods. Narrow before touching `.value` / `.error` / `.cause`.
- **New `defect` channel.** Unanticipated throws (a thrown exception the code did not model) now surface on `unthrown`'s third `defect` channel — inspected via `result.isDefect()` / `result.cause` and re-thrown at the edge — rather than as a typed `err`. Deliberate boundary classification (e.g. mapping a Temporal SDK rejection to `WorkflowExecutionNotFoundError`) still produces a modeled `err`. `result.match({ ok, err, defect })` folds all three.
- **`WorkflowScopeError` removed.** Non-cancellation errors thrown inside `cancellableScope` / `nonCancellableScope` are unmodeled failures and now ride the `defect` channel. The scopes' error union narrows to `WorkflowCancelledError`.
- **The client's "unexpected" `RuntimeClientError` wrap is gone.** An unanticipated rejection in a client operation now surfaces as a defect, not a manufactured `RuntimeClientError`. `RuntimeClientError` is still produced by deliberate boundary classification.
- **Error classes use `TaggedError`.** The worker `WorkerError` hierarchy and the entire client `TypedClientError` hierarchy are now built with `unthrown`'s `TaggedError`, each carrying a `_tag` discriminant (foldable with `matchTags`). The `_tag` is **package-namespaced** — e.g. `"@temporal-contract/WorkflowExecutionNotFoundError"` — so it never collides with a consumer's own tags; each error's `.name` stays the bare class name for readable logs. `ChildWorkflowCancelledError` is now a sibling of `ChildWorkflowError` (distinct `_tag`) rather than a subclass — discriminate on `_tag` / `instanceof ChildWorkflowCancelledError` instead of relying on `instanceof ChildWorkflowError` matching cancellation. The worker's `ValidationError` subclasses are unchanged — they still extend Temporal's `ApplicationFailure` for terminal-failure semantics.

See the [Migrating from neverthrow](https://btravstack.github.io/temporal-contract/guide/migrating-to-unthrown) guide.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `ResultAsync<T, E>` from `neverthrow`.** Never throw — wrap technical errors in `ApplicationFailure` and surface them via `errAsync(...)` (or `.mapErr(...)` on a `ResultAsync.fromPromise(...)` chain). The client uses neverthrow's `Result` for sync returns. There is no `@swan-io/boxed` and no `@temporal-contract/boxed` package — those were removed.
2. **Activities and the typed client return `AsyncResult<T, E>` 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.
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.
Expand Down
Loading
Loading