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
7 changes: 7 additions & 0 deletions .changeset/adopt-unthrown-v5-beta.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@temporal-contract/contract": major
"@temporal-contract/worker": major
"@temporal-contract/client": major
---

Adopt unthrown v5 (beta): the error combinators and `match`'s `err` handler now take a ts-pattern matcher callback; peer bumped to `^5.0.0-beta.3`.
11 changes: 11 additions & 0 deletions .changeset/pre.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"mode": "pre",
"tag": "beta",
"initialVersions": {
"@temporal-contract/client": "7.0.0",
"@temporal-contract/contract": "7.0.0",
"@temporal-contract/testing": "7.0.0",
"@temporal-contract/worker": "7.0.0"
},
"changesets": []
}
106 changes: 61 additions & 45 deletions examples/order-processing-client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
type OrderSchema,
} from "@temporal-contract/sample-order-processing-contract";
import { Client, Connection } from "@temporalio/client";
import { matchTags } from "unthrown";
import { tag } from "unthrown";
import type { z } from "zod";

import { logger } from "./logger.js";
Expand Down Expand Up @@ -91,7 +91,7 @@ async function run() {
// 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 `matchTags` then folds the combined result exhaustively — every
// 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
Expand All @@ -102,8 +102,8 @@ async function run() {
})
.flatMap((handle) => handle.result());

matchTags(result, {
Ok: (output) => {
result.match({
ok: (output) => {
if (output.status === "completed") {
logger.info(
{
Expand All @@ -124,32 +124,40 @@ async function run() {
);
}
},
"@temporal-contract/WorkflowNotFoundError": (err) =>
logger.error({ error: err, orderId: order.orderId }, "❌ Workflow not found"),
"@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.
"@temporal-contract/WorkflowAlreadyStartedError": (err) =>
logger.warn(
{ error: err, orderId: order.orderId },
"⏭️ Workflow already started — skipping",
),
"@temporal-contract/WorkflowFailedError": (err) =>
logger.error(
{ error: err, orderId: order.orderId, cause: err.cause },
"❌ Workflow completed with failure",
),
"@temporal-contract/WorkflowExecutionNotFoundError": (err) =>
logger.error(
{ error: err, orderId: order.orderId },
"❌ Workflow execution not found in namespace",
),
"@temporal-contract/RuntimeClientError": (err) =>
logger.error({ error: err, orderId: order.orderId }, "❌ Workflow execution failed"),
err: (matcher) =>
matcher
.with(tag("@temporal-contract/WorkflowNotFoundError"), (err) =>
logger.error({ error: err, orderId: order.orderId }, "❌ Workflow not found"),
)
.with(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(tag("@temporal-contract/WorkflowAlreadyStartedError"), (err) =>
logger.warn(
{ error: err, orderId: order.orderId },
"⏭️ Workflow already started — skipping",
),
)
.with(tag("@temporal-contract/WorkflowFailedError"), (err) =>
logger.error(
{ error: err, orderId: order.orderId, cause: err.cause },
"❌ Workflow completed with failure",
),
)
.with(tag("@temporal-contract/WorkflowExecutionNotFoundError"), (err) =>
logger.error(
{ error: err, orderId: order.orderId },
"❌ Workflow execution not found in namespace",
),
)
.with(tag("@temporal-contract/RuntimeClientError"), (err) =>
logger.error({ error: err, orderId: order.orderId }, "❌ Workflow execution failed"),
),
// A defect is an unmodeled failure (a bug), not an anticipated outcome.
Defect: (cause) =>
defect: (cause) =>
logger.error({ cause, orderId: order.orderId }, "❌ Unexpected failure processing order"),
});
}
Expand Down Expand Up @@ -178,8 +186,8 @@ async function run() {

// Handle the result by tag — `executeWorkflow` can surface both start-phase
// and result-phase errors, so its union is the widest.
matchTags(result, {
Ok: (output) => {
result.match({
ok: (output) => {
const summary = {
id: output.orderId,
success: output.status === "completed",
Expand All @@ -190,20 +198,28 @@ async function run() {
};
logger.info({ data: summary }, `📊 Order summary: ${summary.message}`);
},
"@temporal-contract/WorkflowNotFoundError": (err) =>
logger.error({ error: err }, "❌ Workflow not found"),
"@temporal-contract/WorkflowValidationError": (err) =>
logger.error({ error: err }, "❌ Validation failed"),
"@temporal-contract/WorkflowAlreadyStartedError": (err) =>
logger.warn({ error: err }, "⏭️ Workflow already started"),
"@temporal-contract/WorkflowFailedError": (err) =>
logger.error({ error: err, cause: err.cause }, "❌ Workflow completed with failure"),
"@temporal-contract/WorkflowExecutionNotFoundError": (err) =>
logger.error({ error: err }, "❌ Workflow execution not found in namespace"),
"@temporal-contract/RuntimeClientError": (err) =>
logger.error({ error: err }, "❌ Workflow execution failed"),
err: (matcher) =>
matcher
.with(tag("@temporal-contract/WorkflowNotFoundError"), (err) =>
logger.error({ error: err }, "❌ Workflow not found"),
)
.with(tag("@temporal-contract/WorkflowValidationError"), (err) =>
logger.error({ error: err }, "❌ Validation failed"),
)
.with(tag("@temporal-contract/WorkflowAlreadyStartedError"), (err) =>
logger.warn({ error: err }, "⏭️ Workflow already started"),
)
.with(tag("@temporal-contract/WorkflowFailedError"), (err) =>
logger.error({ error: err, cause: err.cause }, "❌ Workflow completed with failure"),
)
.with(tag("@temporal-contract/WorkflowExecutionNotFoundError"), (err) =>
logger.error({ error: err }, "❌ Workflow execution not found in namespace"),
)
.with(tag("@temporal-contract/RuntimeClientError"), (err) =>
logger.error({ error: err }, "❌ Workflow execution failed"),
),
// A defect is an unmodeled failure (a bug), not an anticipated outcome.
Defect: (cause) => logger.error({ cause }, "❌ Unexpected failure executing workflow"),
defect: (cause) => logger.error({ cause }, "❌ Unexpected failure executing workflow"),
});

logger.info("\n✨ Done!");
Expand All @@ -213,7 +229,7 @@ async function run() {
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 matchTags");
logger.info(" - Exhaustive error matching with unthrown match");

process.exit(0);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
"peerDependencies": {
"@temporalio/client": "^1",
"@temporalio/common": "^1",
"unthrown": "^4.1.0"
"unthrown": "^5.0.0-beta.3"
},
"engines": {
"node": ">=22.19.0"
Expand Down
8 changes: 5 additions & 3 deletions packages/client/src/__tests__/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
import { it as baseIt } from "@temporal-contract/testing/extension";
import { Client } from "@temporalio/client";
import { Worker } from "@temporalio/worker";
import { P } from "unthrown";
import { describe, expect, vi, beforeEach } from "vitest";

import { TypedClient } from "../client.js";
Expand Down Expand Up @@ -401,9 +402,10 @@ describe("Client Package - Integration Tests", () => {
matched = true;
expect(value).toEqual({ result: "Processed: test" });
},
err: () => {
throw new Error("Should not be called");
},
err: (matcher) =>
matcher.with(P._, () => {
throw new Error("Should not be called");
}),
defect: () => {
throw new Error("Should not be called");
},
Expand Down
18 changes: 11 additions & 7 deletions packages/client/src/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
TypedSearchAttributes,
WorkflowNotFoundError as TemporalWorkflowNotFoundError,
} from "@temporalio/common";
import { Err } from "unthrown";
import { Err, P } from "unthrown";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { z } from "zod";

Expand Down Expand Up @@ -784,9 +784,10 @@ describe("TypedClient", () => {
matched = true;
expect(value).toEqual({ result: "success" });
},
err: () => {
throw new Error("Should not be called");
},
err: (matcher) =>
matcher.with(P._, () => {
throw new Error("Should not be called");
}),
defect: () => {
throw new Error("Should not be called");
},
Expand Down Expand Up @@ -1512,9 +1513,12 @@ describe("TypedClient — interceptors", () => {
.mockRejectedValueOnce(new Error("transient"))
.mockResolvedValueOnce({ result: "ok" });
const retryOnce: ClientInterceptor = (_args, next) =>
next().flatMapErr(
(error): ReturnType<typeof next> =>
error instanceof RuntimeClientError ? next() : Err(error).toAsync(),
next().flatMapErr((matcher) =>
matcher.with(
P._,
(error): ReturnType<typeof next> =>
error instanceof RuntimeClientError ? next() : Err(error).toAsync(),
),
);

const result = await clientWith([retryOnce]).executeWorkflow("testWorkflow", {
Expand Down
10 changes: 5 additions & 5 deletions packages/client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,7 @@ export class TypedClient<TContract extends ContractDefinition> {
*
* await result.match({
* ok: async (handle) => { await handle.pause("maintenance"); },
* err: (error) => console.error("schedule create failed", error),
* err: (matcher) => matcher.with(P._, (error) => console.error("schedule create failed", error)),
* defect: (cause) => console.error("unexpected failure", cause),
* });
* ```
Expand Down Expand Up @@ -546,7 +546,7 @@ export class TypedClient<TContract extends ContractDefinition> {
* const result = await handle.result();
* // ... handle result
* },
* err: (error) => console.error('Failed to start:', error),
* err: (matcher) => matcher.with(P._, (error) => console.error('Failed to start:', error)),
* defect: (cause) => console.error('Unexpected failure:', cause),
* });
* ```
Expand Down Expand Up @@ -637,7 +637,7 @@ export class TypedClient<TContract extends ContractDefinition> {
*
* await result.match({
* ok: (handle) => console.log('signaled run', handle.signaledRunId),
* err: (error) => console.error('signalWithStart failed', error),
* err: (matcher) => matcher.with(P._, (error) => console.error('signalWithStart failed', error)),
* defect: (cause) => console.error('unexpected failure', cause),
* });
* ```
Expand Down Expand Up @@ -761,7 +761,7 @@ export class TypedClient<TContract extends ContractDefinition> {
*
* await result.match({
* ok: (output) => console.log('Order processed:', output.status),
* err: (error) => console.error('Processing failed:', error),
* err: (matcher) => matcher.with(P._, (error) => console.error('Processing failed:', error)),
* defect: (cause) => console.error('Unexpected failure:', cause),
* });
* ```
Expand Down Expand Up @@ -894,7 +894,7 @@ export class TypedClient<TContract extends ContractDefinition> {
* const result = await handle.result();
* // ... handle result
* },
* err: (error) => console.error('Failed to get handle:', error),
* err: (matcher) => matcher.with(P._, (error) => console.error('Failed to get handle:', error)),
* defect: (cause) => console.error('Unexpected failure:', cause),
* });
* ```
Expand Down
6 changes: 4 additions & 2 deletions packages/client/src/interceptors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,10 @@ export type ClientInterceptorNext = (patch?: {
* @example Retry a transient failure once
* ```ts
* const retryOnce: ClientInterceptor = (args, next) =>
* next().flatMapErr((error) =>
* error instanceof RuntimeClientError ? next() : Err(error).toAsync(),
* next().flatMapErr((matcher) =>
* matcher.with(P._, (error) =>
* error instanceof RuntimeClientError ? next() : Err(error).toAsync(),
* ),
* );
* ```
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/contract/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
"vitest": "catalog:"
},
"peerDependencies": {
"unthrown": "^4.1.0"
"unthrown": "^5.0.0-beta.3"
},
"peerDependenciesMeta": {
"unthrown": {
Expand Down
2 changes: 1 addition & 1 deletion packages/contract/src/errors.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ describe("_internal_buildErrorConstructors", () => {
expect(withoutData.cause).toBe(cause);
});

it("carries the unthrown tag for matchTags-style discrimination", () => {
it("carries the unthrown tag for match/tag-style discrimination", () => {
const constructors = _internal_buildErrorConstructors(declaredErrors);

expect(constructors["OutOfStock"]!()._tag).toBe("@temporal-contract/ContractError");
Expand Down
4 changes: 2 additions & 2 deletions packages/contract/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ export class TechnicalError extends TaggedError("@temporal-contract/TechnicalErr
*
* The unthrown `_tag` ("@temporal-contract/ContractError") discriminates a
* `ContractError` from the other tagged errors in a Result's error channel
* (e.g. via `matchTags`); `errorName` then narrows to the concrete declared
* error.
* (e.g. via `result.match({ err: (m) => m.with(tag("@temporal-contract/ContractError"), …) })`);
* `errorName` then narrows to the concrete declared error.
*/
export class ContractError<TName extends string = string, TData = unknown> extends TaggedError(
"@temporal-contract/ContractError",
Expand Down
2 changes: 1 addition & 1 deletion packages/worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
"@temporalio/common": "^1",
"@temporalio/worker": "^1",
"@temporalio/workflow": "^1",
"unthrown": "^4.1.0"
"unthrown": "^5.0.0-beta.3"
},
"engines": {
"node": ">=22.19.0"
Expand Down
10 changes: 6 additions & 4 deletions packages/worker/src/activity-contract-errors.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { defineContract } from "@temporal-contract/contract";
import { ContractError } from "@temporal-contract/contract/errors";
import { Ok, Err, type AsyncResult } from "unthrown";
import { Ok, Err, P, type AsyncResult } from "unthrown";
/**
* Runtime coverage for `declareActivitiesHandler`'s contract-declared typed
* errors, middleware chain, and dependency context — the boundary where an
Expand Down Expand Up @@ -298,9 +298,11 @@ describe("declareActivitiesHandler — middleware", () => {
it("observes typed errors on the err channel", async () => {
const observed: unknown[] = [];
const observing: ActivityMiddleware = (_invocation, next) =>
next().tapErr((error) => {
observed.push(error);
});
next().tapErr((matcher) =>
matcher.with(P._, (error) => {
observed.push(error);
}),
);

const activities = declareActivitiesHandler({
contract,
Expand Down
Loading
Loading