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
10 changes: 5 additions & 5 deletions .agents/rules/handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ 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()`
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`.
Expand Down Expand Up @@ -73,7 +73,7 @@ await worker.run();

## Cancellation

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`.
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";
Expand All @@ -97,14 +97,14 @@ implementation: async (context, args) => {
```

- `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.
- `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` 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`.

## ApplicationFailure semantics

`ApplicationFailure` (re-exported from `@temporal-contract/worker/activity`) is Temporal's first-class failure type. The wrapper at `packages/worker/src/activity.ts:8-15` rethrows the `err(...)` payload at the activity boundary, where Temporal recognizes it natively and applies the configured retry policy.
`ApplicationFailure` (re-exported from `@temporal-contract/worker/activity`) is Temporal's first-class failure type. The wrapper at `packages/worker/src/activity.ts:8-15` rethrows the `Err(...)` payload at the activity boundary, where Temporal recognizes it natively and applies the configured retry policy.

Fields that matter:

Expand All @@ -129,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 `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, 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 `AsyncResult` discipline as everything else.
2 changes: 1 addition & 1 deletion .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 an `AsyncResult`. 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 Down
14 changes: 14 additions & 0 deletions .changeset/unthrown-v1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@temporal-contract/contract": major
"@temporal-contract/worker": major
"@temporal-contract/client": major
"@temporal-contract/testing": major
---

Upgrade to [`unthrown`](https://github.com/btravstack/unthrown) 1.0.0.

unthrown 1.0.0 renames the result constructors to PascalCase: `ok` → `Ok`, `err` → `Err`, `defect` → `Defect`. All packages are updated, and the `unthrown` peer-dependency range moves to `^1`.

**Breaking for consumers** who construct results directly (e.g. in activity implementations): replace `ok(value)` / `err(failure)` with `Ok(value)` / `Err(failure)` (and `ok(value).toAsync()` / `err(failure).toAsync()` at promise boundaries), and bump `unthrown` to `^1`. The `result.match({ ok, err, defect })` handler keys are unchanged (they are object keys, not constructors), and `matchTags` / `TaggedError` / `fromPromise` / `fromSafePromise` / `.toAsync()` and the `result.isOk()` / `isErr()` / `isDefect()` narrowing are all unchanged.

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 `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.
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
12 changes: 6 additions & 6 deletions docs/guide/activity-handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ type ProcessPaymentHandler = ActivitiesHandler<typeof contract>["processPayment"

const sendEmail: SendEmailHandler = ({ to, body }) => {
// Implementation — must return AsyncResult<T, ApplicationFailure>
return ok({ sent: true }).toAsync();
return Ok({ sent: true }).toAsync();
};
```

Expand Down Expand Up @@ -193,14 +193,14 @@ Mock activities with correct types:

```typescript
import type { ActivitiesHandler } from "@temporal-contract/worker/activity";
import { ok } from "unthrown";
import { Ok } from "unthrown";

type Handlers = ActivitiesHandler<typeof orderContract>;

// Create mock activities for testing
const mockActivities: Handlers = {
sendEmail: ({ to, body }) => ok({ sent: true }).toAsync(),
processPayment: ({ amount }) => ok({ transactionId: "TEST-TXN" }).toAsync(),
sendEmail: ({ to, body }) => Ok({ sent: true }).toAsync(),
processPayment: ({ amount }) => Ok({ transactionId: "TEST-TXN" }).toAsync(),
Comment thread
btravers marked this conversation as resolved.
};

// Use in tests
Expand Down Expand Up @@ -316,10 +316,10 @@ Always extract types for better maintainability:
```typescript
// ✅ Good
type Handlers = ActivitiesHandler<typeof contract>;
const sendEmail: Handlers["sendEmail"] = ({ to, body }) => ok({ sent: true }).toAsync();
const sendEmail: Handlers["sendEmail"] = ({ to, body }) => Ok({ sent: true }).toAsync();

// ❌ Avoid inline typing
const sendEmail = ({ to, body }: { to: string; body: string }) => ok({ sent: true }).toAsync();
const sendEmail = ({ to, body }: { to: string; body: string }) => Ok({ sent: true }).toAsync();
```

### 2. Organize by Domain
Expand Down
4 changes: 2 additions & 2 deletions docs/guide/client-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,14 +340,14 @@ Mock the client for testing:

```typescript
import { describe, it, expect, vi } from "vitest";
import { ok } from "unthrown";
import { Ok } from "unthrown";

describe("OrderService", () => {
it("should process order", async () => {
const mockClient = {
executeWorkflow: vi
.fn()
.mockResolvedValue(ok({ status: "success", transactionId: "tx-123" })),
.mockResolvedValue(Ok({ status: "success", transactionId: "tx-123" })),
};
Comment thread
btravers marked this conversation as resolved.

const service = new OrderService(mockClient);
Expand Down
18 changes: 9 additions & 9 deletions docs/guide/entry-points.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ declareActivitiesHandler({
activities: {
processPayment: ({ amount }) => {
// ✅ amount is number
return ok({ transactionId: "TXN-123" }).toAsync();
return Ok({ transactionId: "TXN-123" }).toAsync();
// ✅ Must return AsyncResult<{ transactionId: string }, ApplicationFailure>
},
},
Expand Down Expand Up @@ -370,22 +370,22 @@ Clear separation of concerns:

```typescript
// activities/shared.ts
import { ok } from "unthrown";
import { Ok } from "unthrown";

export const sharedActivities = {
sendEmail: ({ to, body }) => ok({ sent: true }).toAsync(),
logEvent: ({ event }) => ok({ logged: true }).toAsync(),
sendEmail: ({ to, body }) => Ok({ sent: true }).toAsync(),
logEvent: ({ event }) => Ok({ logged: true }).toAsync(),
};

// activities/order.ts
import { ok } from "unthrown";
import { Ok } from "unthrown";
import { sharedActivities } from "./shared";

export const orderActivities = declareActivitiesHandler({
contract: orderContract,
activities: {
...sharedActivities,
processPayment: ({ amount }) => ok({ transactionId: "TXN" }).toAsync(),
processPayment: ({ amount }) => Ok({ transactionId: "TXN" }).toAsync(),
},
Comment thread
btravers marked this conversation as resolved.
});
```
Expand All @@ -394,14 +394,14 @@ export const orderActivities = declareActivitiesHandler({

```typescript
// activities/index.ts
import { ok } from "unthrown";
import { Ok } from "unthrown";

const baseActivities = {
validateInput: ({ data }) => ok({ valid: true }).toAsync(),
validateInput: ({ data }) => Ok({ valid: true }).toAsync(),
};

const paymentActivities = {
processPayment: ({ amount }) => ok({ transactionId: "TXN" }).toAsync(),
processPayment: ({ amount }) => Ok({ transactionId: "TXN" }).toAsync(),
};

export const activities = declareActivitiesHandler({
Expand Down
Loading
Loading