Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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.1`.
Comment thread
btravers marked this conversation as resolved.
Outdated
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.2"
},
"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
30 changes: 16 additions & 14 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 @@ -802,7 +802,7 @@ export class TypedClient<TContract extends ContractDefinition> {
);
// The resolver only ever builds ok/err; assert away the impossible defect.
assertNoDefect(resolved);
if (resolved.isErr()) return Err(resolved.error);
if (resolved.isErr()) return Err<Err>(resolved.error);
const { definition, validatedInput, typedSearchAttributes } = resolved.value;

try {
Expand All @@ -818,7 +818,9 @@ export class TypedClient<TContract extends ContractDefinition> {
// shape; the helper only handles pre-call concerns.
const outputResult = await definition.output["~standard"].validate(result);
if (outputResult.issues) {
return Err(createWorkflowValidationError(workflowName, "output", outputResult.issues));
return Err<Err>(
createWorkflowValidationError(workflowName, "output", outputResult.issues),
);
}

return Ok(outputResult.value as Ok);
Expand All @@ -828,7 +830,7 @@ export class TypedClient<TContract extends ContractDefinition> {
// routing through a dedicated helper — this is the only call site
// that needs the full union.
if (error instanceof WorkflowExecutionAlreadyStartedError) {
return Err(
return Err<Err>(
new WorkflowAlreadyStartedError(error.workflowType, error.workflowId, error),
);
}
Expand All @@ -848,23 +850,23 @@ export class TypedClient<TContract extends ContractDefinition> {
// 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(
return Err<Err>(
new WorkflowFailedError(
temporalOptions.workflowId,
error.cause as TemporalFailure | undefined,
),
);
}
if (error instanceof TemporalWorkflowNotFoundError) {
return Err(
return Err<Err>(
new WorkflowExecutionNotFoundError(
error.workflowId || temporalOptions.workflowId,
error.runId,
error,
),
);
}
return Err(createRuntimeClientError("executeWorkflow", error));
return Err<Err>(createRuntimeClientError("executeWorkflow", error));
}
};
return makeAsyncResult(work);
Expand Down Expand Up @@ -894,7 +896,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 Expand Up @@ -992,7 +994,7 @@ export class TypedClient<TContract extends ContractDefinition> {
const result = await workflowHandle.result();
const outputResult = await definition.output["~standard"].validate(result);
if (outputResult.issues) {
return Err(
return Err<Err>(
new WorkflowValidationError(
workflowHandle.workflowId,
"output",
Expand All @@ -1011,7 +1013,7 @@ export class TypedClient<TContract extends ContractDefinition> {
return Err(rehydrated as Err);
}
}
return Err(classifyResultError("result", error, workflowHandle.workflowId));
return Err<Err>(classifyResultError("result", error, workflowHandle.workflowId));
}
};
return makeAsyncResult(work);
Expand Down Expand Up @@ -1138,7 +1140,7 @@ function buildValidatedProxy<TDef extends DefWithInput, TValidationError extends
}
return Ok(outputResult.value);
} catch (error) {
return Err(classifyHandleError(operation, error, workflowId));
return Err<ProxyError>(classifyHandleError(operation, error, workflowId));
}
};
return makeAsyncResult(work);
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.2"
},
"peerDependenciesMeta": {
"unthrown": {
Expand Down
Loading
Loading