From df7d6efc853678ea24d0db9170de281edc3ce4b5 Mon Sep 17 00:00:00 2001 From: Ben Weis Date: Tue, 24 Feb 2026 14:15:45 -0500 Subject: [PATCH 1/3] feat: add @temporal-contract/*-effect packages - Introduced a new packages for an Effect-native typed Temporal client, enabling seamless interaction with workflows using Effect's error handling and type safety. - Added necessary dependencies and configurations in package.json and pnpm-lock.yaml. - Created initial structure including README, TypeScript configuration, and test setup. - Implemented core functionality for executing and starting workflows with typed inputs and outputs. - Added error handling classes for better error management in workflow execution. This package serves as a foundational component for building Effect-native applications with Temporal. --- package.json | 1 + packages/client-effect/README.md | 168 +++++++ packages/client-effect/package.json | 67 +++ packages/client-effect/src/client.spec.ts | 434 ++++++++++++++++ packages/client-effect/src/client.ts | 475 ++++++++++++++++++ packages/client-effect/src/errors.ts | 53 ++ packages/client-effect/src/index.ts | 16 + packages/client-effect/src/layer.ts | 77 +++ packages/client-effect/tsconfig.json | 9 + packages/client-effect/vitest.config.ts | 22 + packages/contract-effect/README.md | 151 ++++++ packages/contract-effect/package.json | 62 +++ packages/contract-effect/src/contract.spec.ts | 190 +++++++ packages/contract-effect/src/contract.ts | 100 ++++ packages/contract-effect/src/index.ts | 26 + packages/contract-effect/src/types.ts | 130 +++++ packages/contract-effect/tsconfig.json | 9 + packages/contract-effect/vitest.config.ts | 20 + packages/worker-effect/README.md | 197 ++++++++ packages/worker-effect/package.json | 68 +++ packages/worker-effect/src/activity.spec.ts | 354 +++++++++++++ packages/worker-effect/src/activity.ts | 377 ++++++++++++++ packages/worker-effect/src/errors.ts | 60 +++ packages/worker-effect/src/index.ts | 12 + packages/worker-effect/tsconfig.json | 9 + packages/worker-effect/vitest.config.ts | 22 + pnpm-lock.yaml | 161 +++++- pnpm-workspace.yaml | 1 + 28 files changed, 3269 insertions(+), 2 deletions(-) create mode 100644 packages/client-effect/README.md create mode 100644 packages/client-effect/package.json create mode 100644 packages/client-effect/src/client.spec.ts create mode 100644 packages/client-effect/src/client.ts create mode 100644 packages/client-effect/src/errors.ts create mode 100644 packages/client-effect/src/index.ts create mode 100644 packages/client-effect/src/layer.ts create mode 100644 packages/client-effect/tsconfig.json create mode 100644 packages/client-effect/vitest.config.ts create mode 100644 packages/contract-effect/README.md create mode 100644 packages/contract-effect/package.json create mode 100644 packages/contract-effect/src/contract.spec.ts create mode 100644 packages/contract-effect/src/contract.ts create mode 100644 packages/contract-effect/src/index.ts create mode 100644 packages/contract-effect/src/types.ts create mode 100644 packages/contract-effect/tsconfig.json create mode 100644 packages/contract-effect/vitest.config.ts create mode 100644 packages/worker-effect/README.md create mode 100644 packages/worker-effect/package.json create mode 100644 packages/worker-effect/src/activity.spec.ts create mode 100644 packages/worker-effect/src/activity.ts create mode 100644 packages/worker-effect/src/errors.ts create mode 100644 packages/worker-effect/src/index.ts create mode 100644 packages/worker-effect/tsconfig.json create mode 100644 packages/worker-effect/vitest.config.ts diff --git a/package.json b/package.json index f23f299c..7dc49bec 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@changesets/cli": "catalog:", "@commitlint/cli": "catalog:", "@commitlint/config-conventional": "catalog:", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.3", "knip": "catalog:", "lefthook": "catalog:", "oxfmt": "catalog:", diff --git a/packages/client-effect/README.md b/packages/client-effect/README.md new file mode 100644 index 00000000..f48d9586 --- /dev/null +++ b/packages/client-effect/README.md @@ -0,0 +1,168 @@ +# @temporal-contract/client-effect + +Effect-native typed Temporal client. All methods return `Effect` using `Data.TaggedError` errors, enabling `Effect.catchTag` pattern matching throughout your application. + +## Installation + +```bash +pnpm add @temporal-contract/client-effect @temporal-contract/contract-effect effect +``` + +## Quick Start + +### 1. Define your contract + +```typescript +// contract.ts +import { Schema } from "effect"; +import { defineEffectContract } from "@temporal-contract/contract-effect"; + +export const orderContract = defineEffectContract({ + taskQueue: "order-processing", + workflows: { + processOrder: { + input: Schema.Struct({ orderId: Schema.String }), + output: Schema.Struct({ + orderId: Schema.String, + status: Schema.Literal("success", "failed"), + }), + }, + }, +}); +``` + +### 2. Create the client + +```typescript +import { Effect, pipe } from "effect"; +import { Connection, Client } from "@temporalio/client"; +import { EffectTypedClient } from "@temporal-contract/client-effect"; +import { orderContract } from "./contract"; + +const connection = await Connection.connect(); +const client = EffectTypedClient.create(orderContract, new Client({ connection })); +``` + +### 3. Execute workflows + +```typescript +const program = pipe( + client.executeWorkflow("processOrder", { + workflowId: "order-123", + args: { orderId: "ORD-123" }, + }), + // Typed error handling with Effect.catchTag + Effect.catchTag("WorkflowValidationError", (e) => + Effect.fail(new MyAppError(`Schema validation failed: ${e.direction}`)), + ), + Effect.catchTag("WorkflowNotFoundError", (e) => + Effect.fail(new MyAppError(`Unknown workflow: ${e.workflowName}`)), + ), + Effect.catchTag("RuntimeClientError", (e) => + Effect.fail(new MyAppError(`Temporal error in ${e.operation}`)), + ), +); + +const result = await Effect.runPromise(program); +``` + +## API Reference + +### `EffectTypedClient` + +#### `EffectTypedClient.create(contract, client)` + +Create a typed client from a contract and a Temporal `Client` instance. + +#### `client.executeWorkflow(name, options)` + +Start a workflow and wait for its result. + +Returns `Effect` + +#### `client.startWorkflow(name, options)` + +Start a workflow and return a typed handle immediately. + +Returns `Effect, WorkflowNotFoundError | WorkflowValidationError | RuntimeClientError>` + +#### `client.getHandle(name, workflowId)` + +Get a handle to an existing workflow. + +Returns `Effect, WorkflowNotFoundError | RuntimeClientError>` + +### `EffectTypedWorkflowHandle` + +All methods return `Effect`: + +| Method | Return type | +| ------------------------ | ------------------------------------------------------------------- | +| `result()` | `Effect` | +| `queries.myQuery(args)` | `Effect` | +| `signals.mySignal(args)` | `Effect` | +| `updates.myUpdate(args)` | `Effect` | +| `terminate(reason?)` | `Effect` | +| `cancel()` | `Effect` | +| `describe()` | `Effect` | +| `fetchHistory()` | `Effect` | + +### Error types + +All errors are `Data.TaggedError` subclasses, so they work with `Effect.catchTag` and `Effect.catchTags`: + +| Error | `_tag` | Key fields | +| ------------------------- | --------------------------- | ----------------------------------------- | +| `WorkflowNotFoundError` | `"WorkflowNotFoundError"` | `workflowName`, `availableWorkflows` | +| `WorkflowValidationError` | `"WorkflowValidationError"` | `workflowName`, `direction`, `parseError` | +| `QueryValidationError` | `"QueryValidationError"` | `queryName`, `direction`, `parseError` | +| `SignalValidationError` | `"SignalValidationError"` | `signalName`, `parseError` | +| `UpdateValidationError` | `"UpdateValidationError"` | `updateName`, `direction`, `parseError` | +| `RuntimeClientError` | `"RuntimeClientError"` | `operation`, `cause` | + +## Layer-based DI + +For applications fully built with Effect's Layer system: + +```typescript +import { Effect, Layer, pipe } from "effect"; +import { makeTemporalClientTag, makeTemporalClientLayer } from "@temporal-contract/client-effect"; +import { orderContract } from "./contract"; + +// Create a Context.Tag for this contract's client +const OrderClient = makeTemporalClientTag("OrderClient"); + +// Create a Layer that establishes the connection and provides the client +const OrderClientLive = makeTemporalClientLayer(OrderClient, orderContract, { + address: "localhost:7233", +}); + +// Use the client in your program via Context.Tag +const program = Effect.gen(function* () { + const client = yield* OrderClient; + return yield* client.executeWorkflow("processOrder", { + workflowId: "order-123", + args: { orderId: "ORD-123" }, + }); +}); + +// Provide the Layer when running +await Effect.runPromise(pipe(program, Effect.provide(OrderClientLive))); +``` + +## Why Effect over boxed? + +Compared to `@temporal-contract/client` which uses `Future>`: + +| Feature | `client` (boxed) | `client-effect` (Effect) | +| -------------------- | ----------------------------- | ------------------------------------------------ | +| Error handling | `result.match({ Ok, Error })` | `Effect.catchTag("ErrorName", ...)` | +| Error types | Plain Error subclasses | `Data.TaggedError` with structured fields | +| Composition | `Future.flatMap` | `Effect.gen` / `Effect.flatMap` | +| DI | External | `Layer` / `Context.Tag` | +| Observability | Manual | Effect's built-in tracing with `Effect.withSpan` | +| Error exhaustiveness | Runtime `instanceof` | Compile-time `_tag` union | + +## License + +MIT diff --git a/packages/client-effect/package.json b/packages/client-effect/package.json new file mode 100644 index 00000000..5af6b772 --- /dev/null +++ b/packages/client-effect/package.json @@ -0,0 +1,67 @@ +{ + "name": "@temporal-contract/client-effect", + "version": "0.1.0", + "description": "Effect-native client for consuming temporal-contract workflows", + "keywords": [ + "client", + "contract", + "effect", + "temporal", + "typescript" + ], + "homepage": "https://github.com/btravers/temporal-contract#readme", + "bugs": { + "url": "https://github.com/btravers/temporal-contract/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/btravers/temporal-contract.git", + "directory": "packages/client-effect" + }, + "license": "MIT", + "author": "Ben Weis ", + "type": "module", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown src/index.ts --format cjs,esm --dts --clean", + "dev": "tsdown src/index.ts --format cjs,esm --dts --watch", + "test": "vitest run --project unit", + "test:watch": "vitest --project unit", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@temporal-contract/contract-effect": "workspace:*" + }, + "devDependencies": { + "@temporal-contract/tsconfig": "workspace:*", + "@temporalio/client": "catalog:", + "@types/node": "catalog:", + "@vitest/coverage-v8": "catalog:", + "effect": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "@temporalio/client": "^1", + "effect": "^3" + } +} diff --git a/packages/client-effect/src/client.spec.ts b/packages/client-effect/src/client.spec.ts new file mode 100644 index 00000000..e6d910d2 --- /dev/null +++ b/packages/client-effect/src/client.spec.ts @@ -0,0 +1,434 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Schema, Effect, Exit, Cause } from "effect"; +import { defineEffectContract } from "@temporal-contract/contract-effect"; +import { Client } from "@temporalio/client"; +import { EffectTypedClient } from "./client.js"; +import { RuntimeClientError, WorkflowNotFoundError, WorkflowValidationError } from "./errors.js"; + +const createMockWorkflow = () => ({ + start: vi.fn(), + execute: vi.fn(), + getHandle: vi.fn(), +}); + +const mockWorkflow = createMockWorkflow(); + +vi.mock("@temporalio/client", () => ({ + WorkflowHandle: vi.fn(), + Client: vi.fn(), + Connection: vi.fn(), +})); + +const testContract = defineEffectContract({ + taskQueue: "test-queue", + workflows: { + testWorkflow: { + input: Schema.Struct({ name: Schema.String, value: Schema.Number }), + output: Schema.Struct({ result: Schema.String }), + queries: { + getStatus: { + input: Schema.Tuple(), + output: Schema.String, + }, + }, + signals: { + updateProgress: { + input: Schema.Tuple(Schema.Number), + }, + }, + updates: { + setConfig: { + input: Schema.Tuple(Schema.Struct({ value: Schema.String })), + output: Schema.Boolean, + }, + }, + }, + simpleWorkflow: { + input: Schema.Struct({ message: Schema.String }), + output: Schema.String, + }, + }, +}); + +describe("EffectTypedClient", () => { + let typedClient: EffectTypedClient; + + beforeEach(() => { + vi.clearAllMocks(); + const rawClient = { workflow: mockWorkflow } as unknown as Client; + typedClient = EffectTypedClient.create(testContract, rawClient); + }); + + describe("EffectTypedClient.create", () => { + it("should create a typed client instance", () => { + expect(typedClient).toBeInstanceOf(EffectTypedClient); + }); + }); + + describe("executeWorkflow", () => { + it("should execute a workflow with valid input and succeed", async () => { + mockWorkflow.execute.mockResolvedValue({ result: "success" }); + + const result = await Effect.runPromise( + typedClient.executeWorkflow("testWorkflow", { + workflowId: "test-123", + args: { name: "hello", value: 42 }, + }), + ); + + expect(result).toEqual({ result: "success" }); + expect(mockWorkflow.execute).toHaveBeenCalledWith("testWorkflow", { + workflowId: "test-123", + taskQueue: "test-queue", + args: [{ name: "hello", value: 42 }], + }); + }); + + it("should fail with WorkflowValidationError for invalid input", async () => { + const exit = await Effect.runPromiseExit( + typedClient.executeWorkflow("testWorkflow", { + workflowId: "test-123", + // @ts-expect-error intentionally wrong type + args: { name: "hello", value: "not-a-number" }, + }), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.failureOption(exit.cause); + expect(error._tag).toBe("Some"); + if (error._tag === "Some") { + expect(error.value._tag).toBe("WorkflowValidationError"); + expect(error.value).toBeInstanceOf(WorkflowValidationError); + } + } + }); + + it("should fail with WorkflowValidationError for invalid output", async () => { + mockWorkflow.execute.mockResolvedValue({ wrong: "output" }); + + const exit = await Effect.runPromiseExit( + typedClient.executeWorkflow("testWorkflow", { + workflowId: "test-123", + args: { name: "hello", value: 42 }, + }), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.failureOption(exit.cause); + if (error._tag === "Some") { + expect(error.value._tag).toBe("WorkflowValidationError"); + const e = error.value as WorkflowValidationError; + expect(e.direction).toBe("output"); + } + } + }); + + it("should fail with WorkflowNotFoundError for unknown workflow", async () => { + const exit = await Effect.runPromiseExit( + typedClient.executeWorkflow("nonExistentWorkflow" as unknown as "testWorkflow", { + workflowId: "test-123", + args: {} as { name: string; value: number }, + }), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.failureOption(exit.cause); + if (error._tag === "Some") { + expect(error.value._tag).toBe("WorkflowNotFoundError"); + expect(error.value).toBeInstanceOf(WorkflowNotFoundError); + } + } + }); + + it("should fail with RuntimeClientError when workflow execution throws", async () => { + mockWorkflow.execute.mockRejectedValue(new Error("Workflow execution failed")); + + const exit = await Effect.runPromiseExit( + typedClient.executeWorkflow("testWorkflow", { + workflowId: "test-123", + args: { name: "hello", value: 42 }, + }), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.failureOption(exit.cause); + if (error._tag === "Some") { + expect(error.value._tag).toBe("RuntimeClientError"); + expect(error.value).toBeInstanceOf(RuntimeClientError); + } + } + }); + + it("should support Effect.catchTag for typed error handling", async () => { + mockWorkflow.execute.mockRejectedValue(new Error("failed")); + + const result = await Effect.runPromise( + Effect.catchTag( + typedClient.executeWorkflow("testWorkflow", { + workflowId: "test-123", + args: { name: "hello", value: 42 }, + }), + "RuntimeClientError", + (_e) => Effect.succeed({ result: "fallback" }), + ), + ); + + expect(result).toEqual({ result: "fallback" }); + }); + }); + + describe("startWorkflow", () => { + it("should start a workflow and return a typed handle", async () => { + const mockHandle = { + workflowId: "test-123", + result: vi.fn().mockResolvedValue({ result: "success" }), + query: vi.fn(), + signal: vi.fn(), + executeUpdate: vi.fn(), + terminate: vi.fn(), + cancel: vi.fn(), + describe: vi.fn(), + fetchHistory: vi.fn(), + }; + + mockWorkflow.start.mockResolvedValue(mockHandle); + + const handle = await Effect.runPromise( + typedClient.startWorkflow("testWorkflow", { + workflowId: "test-123", + args: { name: "hello", value: 42 }, + }), + ); + + expect(handle.workflowId).toBe("test-123"); + expect(mockWorkflow.start).toHaveBeenCalledWith("testWorkflow", { + workflowId: "test-123", + taskQueue: "test-queue", + args: [{ name: "hello", value: 42 }], + }); + }); + + it("should fail with WorkflowNotFoundError for unknown workflow", async () => { + const exit = await Effect.runPromiseExit( + typedClient.startWorkflow("nonExistentWorkflow" as unknown as "testWorkflow", { + workflowId: "test-123", + args: {} as { name: string; value: number }, + }), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.failureOption(exit.cause); + if (error._tag === "Some") { + expect(error.value._tag).toBe("WorkflowNotFoundError"); + } + } + }); + }); + + describe("getHandle", () => { + it("should get a handle for an existing workflow", async () => { + const mockHandle = { + workflowId: "test-123", + result: vi.fn().mockResolvedValue({ result: "success" }), + query: vi.fn(), + signal: vi.fn(), + executeUpdate: vi.fn(), + terminate: vi.fn(), + cancel: vi.fn(), + describe: vi.fn(), + fetchHistory: vi.fn(), + }; + + mockWorkflow.getHandle.mockReturnValue(mockHandle); + + const handle = await Effect.runPromise(typedClient.getHandle("testWorkflow", "test-123")); + + expect(handle.workflowId).toBe("test-123"); + }); + + it("should fail with WorkflowNotFoundError for unknown workflow", async () => { + const exit = await Effect.runPromiseExit( + typedClient.getHandle("nonExistentWorkflow" as unknown as "testWorkflow", "test-123"), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.failureOption(exit.cause); + if (error._tag === "Some") { + expect(error.value._tag).toBe("WorkflowNotFoundError"); + } + } + }); + }); + + describe("EffectTypedWorkflowHandle", () => { + type MockHandle = { + workflowId: string; + result: ReturnType; + query: ReturnType; + signal: ReturnType; + executeUpdate: ReturnType; + terminate: ReturnType; + cancel: ReturnType; + describe: ReturnType; + fetchHistory: ReturnType; + }; + + let mockHandle: MockHandle; + + beforeEach(() => { + mockHandle = { + workflowId: "test-123", + result: vi.fn().mockResolvedValue({ result: "success" }), + query: vi.fn(), + signal: vi.fn().mockResolvedValue(undefined), + executeUpdate: vi.fn().mockResolvedValue(true), + terminate: vi.fn().mockResolvedValue(undefined), + cancel: vi.fn().mockResolvedValue(undefined), + describe: vi.fn().mockResolvedValue({ + workflowId: "test-123", + type: "testWorkflow", + status: { name: "RUNNING" }, + }), + fetchHistory: vi.fn().mockResolvedValue({ events: [] }), + }; + + mockWorkflow.start.mockResolvedValue(mockHandle); + }); + + it("should call result() as an Effect", async () => { + const handle = await Effect.runPromise( + typedClient.startWorkflow("testWorkflow", { + workflowId: "test-123", + args: { name: "hello", value: 42 }, + }), + ); + + const result = await Effect.runPromise(handle.result()); + expect(result).toEqual({ result: "success" }); + }); + + it("should call queries as Effects", async () => { + mockHandle.query.mockResolvedValue("running"); + + const handle = await Effect.runPromise( + typedClient.startWorkflow("testWorkflow", { + workflowId: "test-123", + args: { name: "hello", value: 42 }, + }), + ); + + const status = await Effect.runPromise(handle.queries.getStatus([])); + expect(status).toBe("running"); + }); + + it("should call signals as Effects", async () => { + const handle = await Effect.runPromise( + typedClient.startWorkflow("testWorkflow", { + workflowId: "test-123", + args: { name: "hello", value: 42 }, + }), + ); + + await Effect.runPromise(handle.signals.updateProgress([50])); + expect(mockHandle.signal).toHaveBeenCalledWith("updateProgress", [50]); + }); + + it("should call updates as Effects", async () => { + mockHandle.executeUpdate.mockResolvedValue(true); + + const handle = await Effect.runPromise( + typedClient.startWorkflow("testWorkflow", { + workflowId: "test-123", + args: { name: "hello", value: 42 }, + }), + ); + + const updated = await Effect.runPromise(handle.updates.setConfig([{ value: "new-config" }])); + expect(updated).toBe(true); + }); + + it("should call terminate as an Effect", async () => { + const handle = await Effect.runPromise( + typedClient.startWorkflow("testWorkflow", { + workflowId: "test-123", + args: { name: "hello", value: 42 }, + }), + ); + + await Effect.runPromise(handle.terminate("test reason")); + expect(mockHandle.terminate).toHaveBeenCalledWith("test reason"); + }); + + it("should call cancel as an Effect", async () => { + const handle = await Effect.runPromise( + typedClient.startWorkflow("testWorkflow", { + workflowId: "test-123", + args: { name: "hello", value: 42 }, + }), + ); + + await Effect.runPromise(handle.cancel()); + expect(mockHandle.cancel).toHaveBeenCalled(); + }); + + it("should call describe as an Effect", async () => { + const handle = await Effect.runPromise( + typedClient.startWorkflow("testWorkflow", { + workflowId: "test-123", + args: { name: "hello", value: 42 }, + }), + ); + + const description = await Effect.runPromise(handle.describe()); + expect(description).toEqual(expect.objectContaining({ workflowId: "test-123" })); + }); + }); + + describe("Effect.catchTag pattern matching", () => { + it("should allow catching specific error tags", async () => { + const exit = await Effect.runPromiseExit( + Effect.catchTags( + typedClient.executeWorkflow("testWorkflow", { + workflowId: "test-123", + // @ts-expect-error intentionally wrong type + args: { name: 123, value: "bad" }, + }), + { + WorkflowValidationError: (_e) => Effect.succeed({ result: "validation-fallback" }), + WorkflowNotFoundError: (_e) => Effect.succeed({ result: "not-found-fallback" }), + RuntimeClientError: (_e) => Effect.succeed({ result: "runtime-fallback" }), + }, + ), + ); + + expect(Exit.isSuccess(exit)).toBe(true); + if (Exit.isSuccess(exit)) { + expect(exit.value).toEqual({ result: "validation-fallback" }); + } + }); + + it("WorkflowNotFoundError has correct fields", async () => { + const exit = await Effect.runPromiseExit( + typedClient.executeWorkflow("nonExistentWorkflow" as unknown as "testWorkflow", { + workflowId: "test-123", + args: {} as { name: string; value: number }, + }), + ); + + if (Exit.isFailure(exit)) { + const error = Cause.failureOption(exit.cause); + if (error._tag === "Some" && error.value._tag === "WorkflowNotFoundError") { + const e = error.value as WorkflowNotFoundError; + expect(e.workflowName).toBe("nonExistentWorkflow"); + expect(e.availableWorkflows).toContain("testWorkflow"); + } + } + }); + }); +}); diff --git a/packages/client-effect/src/client.ts b/packages/client-effect/src/client.ts new file mode 100644 index 00000000..f0c682dc --- /dev/null +++ b/packages/client-effect/src/client.ts @@ -0,0 +1,475 @@ +import { Client, WorkflowHandle } from "@temporalio/client"; +import type { WorkflowStartOptions } from "@temporalio/client"; +import { Effect, Schema } from "effect"; +import type { + EffectClientInferInput, + EffectClientInferOutput, + EffectClientInferWorkflowQueries, + EffectClientInferWorkflowSignals, + EffectClientInferWorkflowUpdates, + EffectContractDefinition, + EffectWorkflowDefinition, +} from "@temporal-contract/contract-effect"; +import { + QueryValidationError, + RuntimeClientError, + SignalValidationError, + UpdateValidationError, + WorkflowNotFoundError, + WorkflowValidationError, +} from "./errors.js"; + +/** + * Options for starting a workflow — same shape as Temporal's WorkflowStartOptions + * but with typed args derived from the contract schema. + */ +export type EffectTypedWorkflowStartOptions< + TContract extends EffectContractDefinition, + TWorkflowName extends Extract, +> = Omit & { + args: EffectClientInferInput; +}; + +/** + * Typed workflow handle with Effect-native return types + */ +export type EffectTypedWorkflowHandle = { + workflowId: string; + + /** + * Type-safe queries — each returns Effect + */ + queries: { + [K in keyof EffectClientInferWorkflowQueries]: EffectClientInferWorkflowQueries[K] extends ( + ...args: infer Args + ) => Promise + ? (...args: Args) => Effect.Effect + : never; + }; + + /** + * Type-safe signals — each returns Effect + */ + signals: { + [K in keyof EffectClientInferWorkflowSignals]: EffectClientInferWorkflowSignals[K] extends ( + ...args: infer Args + ) => Promise + ? (...args: Args) => Effect.Effect + : never; + }; + + /** + * Type-safe updates — each returns Effect + */ + updates: { + [K in keyof EffectClientInferWorkflowUpdates]: EffectClientInferWorkflowUpdates[K] extends ( + ...args: infer Args + ) => Promise + ? (...args: Args) => Effect.Effect + : never; + }; + + /** + * Get the workflow result + */ + result: () => Effect.Effect< + EffectClientInferOutput, + WorkflowValidationError | RuntimeClientError + >; + + /** + * Terminate the workflow + */ + terminate: (reason?: string) => Effect.Effect; + + /** + * Cancel the workflow + */ + cancel: () => Effect.Effect; + + /** + * Get the workflow execution description (status, metadata, etc.) + */ + describe: () => Effect.Effect< + Awaited>, + RuntimeClientError + >; + + /** + * Fetch the full workflow execution history + */ + fetchHistory: () => Effect.Effect< + Awaited>, + RuntimeClientError + >; +}; + +/** + * Effect-native typed Temporal client + * + * All methods return `Effect` instead of `Future>`. + * Errors are `Data.TaggedError` subclasses, enabling `Effect.catchTag` pattern matching. + * + * Works exclusively with contracts defined via `defineEffectContract` from + * `@temporal-contract/contract-effect`. + * + * @example + * ```ts + * import { Effect, pipe } from "effect"; + * import { Connection, Client } from "@temporalio/client"; + * import { EffectTypedClient } from "@temporal-contract/client-effect"; + * import { orderContract } from "./contract"; + * + * const connection = await Connection.connect(); + * const client = EffectTypedClient.create(orderContract, new Client({ connection })); + * + * const program = pipe( + * client.executeWorkflow("processOrder", { + * workflowId: "order-123", + * args: { orderId: "ORD-123", customerId: "CUST-1" }, + * }), + * Effect.catchTag("WorkflowValidationError", (e) => + * Effect.fail(new MyAppError(`Validation: ${e.parseError.message}`)) + * ), + * Effect.catchTag("RuntimeClientError", (e) => + * Effect.fail(new MyAppError(`Runtime: ${String(e.cause)}`)) + * ), + * ); + * + * const result = await Effect.runPromise(program); + * ``` + */ +export class EffectTypedClient { + private constructor( + private readonly contract: TContract, + private readonly client: Client, + ) {} + + /** + * Create an Effect-native typed Temporal client from a contract + */ + static create( + contract: TContract, + client: Client, + ): EffectTypedClient { + return new EffectTypedClient(contract, client); + } + + /** + * Start a workflow and return a typed handle + * + * @example + * ```ts + * const handle = await Effect.runPromise( + * client.startWorkflow("processOrder", { + * workflowId: "order-123", + * args: { orderId: "ORD-123" }, + * }) + * ); + * ``` + */ + startWorkflow>( + workflowName: TWorkflowName, + { args, ...temporalOptions }: EffectTypedWorkflowStartOptions, + ): Effect.Effect< + EffectTypedWorkflowHandle, + WorkflowNotFoundError | WorkflowValidationError | RuntimeClientError + > { + return Effect.gen(this, function* () { + const definition = this.contract.workflows[workflowName] as + | TContract["workflows"][TWorkflowName] + | undefined; + + if (!definition) { + return yield* Effect.fail( + new WorkflowNotFoundError({ + workflowName, + availableWorkflows: Object.keys(this.contract.workflows), + }), + ); + } + + const validatedInput = yield* Schema.decodeUnknown(definition.input)(args).pipe( + Effect.mapError( + (parseError) => + new WorkflowValidationError({ + workflowName, + direction: "input", + parseError, + }), + ), + ); + + const handle = yield* Effect.tryPromise({ + try: () => + this.client.workflow.start(workflowName, { + ...temporalOptions, + taskQueue: this.contract.taskQueue, + args: [validatedInput], + }), + catch: (e) => new RuntimeClientError({ operation: "startWorkflow", cause: e }), + }); + + return this.createTypedHandle(handle, definition, workflowName); + }); + } + + /** + * Execute a workflow (start and wait for result) + * + * @example + * ```ts + * const result = await Effect.runPromise( + * pipe( + * client.executeWorkflow("processOrder", { + * workflowId: "order-123", + * args: { orderId: "ORD-123" }, + * }), + * Effect.catchTag("WorkflowNotFoundError", () => Effect.succeed(defaultResult)), + * ) + * ); + * ``` + */ + executeWorkflow>( + workflowName: TWorkflowName, + { args, ...temporalOptions }: EffectTypedWorkflowStartOptions, + ): Effect.Effect< + EffectClientInferOutput, + WorkflowNotFoundError | WorkflowValidationError | RuntimeClientError + > { + return Effect.gen(this, function* () { + const definition = this.contract.workflows[workflowName] as + | TContract["workflows"][TWorkflowName] + | undefined; + + if (!definition) { + return yield* Effect.fail( + new WorkflowNotFoundError({ + workflowName, + availableWorkflows: Object.keys(this.contract.workflows), + }), + ); + } + + const validatedInput = yield* Schema.decodeUnknown(definition.input)(args).pipe( + Effect.mapError( + (parseError) => + new WorkflowValidationError({ + workflowName, + direction: "input", + parseError, + }), + ), + ); + + const rawResult = yield* Effect.tryPromise({ + try: () => + this.client.workflow.execute(workflowName, { + ...temporalOptions, + taskQueue: this.contract.taskQueue, + args: [validatedInput], + }), + catch: (e) => new RuntimeClientError({ operation: "executeWorkflow", cause: e }), + }); + + return yield* Schema.decodeUnknown(definition.output)(rawResult).pipe( + Effect.mapError( + (parseError) => + new WorkflowValidationError({ + workflowName, + direction: "output", + parseError, + }), + ), + ); + }); + } + + /** + * Get a handle to an existing workflow by ID + * + * @example + * ```ts + * const handle = await Effect.runPromise( + * client.getHandle("processOrder", "order-123") + * ); + * ``` + */ + getHandle>( + workflowName: TWorkflowName, + workflowId: string, + ): Effect.Effect< + EffectTypedWorkflowHandle, + WorkflowNotFoundError | RuntimeClientError + > { + return Effect.gen(this, function* () { + const definition = this.contract.workflows[workflowName] as + | TContract["workflows"][TWorkflowName] + | undefined; + + if (!definition) { + return yield* Effect.fail( + new WorkflowNotFoundError({ + workflowName, + availableWorkflows: Object.keys(this.contract.workflows), + }), + ); + } + + const handle = yield* Effect.try({ + try: () => this.client.workflow.getHandle(workflowId), + catch: (e) => new RuntimeClientError({ operation: "getHandle", cause: e }), + }); + + return this.createTypedHandle(handle, definition, workflowName); + }); + } + + private createTypedHandle( + workflowHandle: WorkflowHandle, + definition: TWorkflow, + workflowName: string, + ): EffectTypedWorkflowHandle { + // Build each map into a plain Record first, then cast once at the return site. + // The per-entry type cannot be verified by TypeScript at construction time because + // the keys and value types are derived from a runtime-iterated generic object. + const queries: Record = {}; + if (definition.queries) { + for (const [queryName, queryDef] of Object.entries(definition.queries)) { + queries[queryName] = ( + args: EffectClientInferInput, + ): Effect.Effect => + Effect.gen(function* () { + const validatedInput = yield* Schema.decodeUnknown(queryDef.input)(args).pipe( + Effect.mapError( + (parseError) => + new QueryValidationError({ queryName, direction: "input", parseError }), + ), + ); + + const rawResult = yield* Effect.tryPromise({ + try: () => workflowHandle.query(queryName, validatedInput), + catch: (e) => new RuntimeClientError({ operation: "query", cause: e }), + }); + + return yield* Schema.decodeUnknown(queryDef.output)(rawResult).pipe( + Effect.mapError( + (parseError) => + new QueryValidationError({ queryName, direction: "output", parseError }), + ), + ); + }); + } + } + + const signals: Record = {}; + if (definition.signals) { + for (const [signalName, signalDef] of Object.entries(definition.signals)) { + signals[signalName] = ( + args: EffectClientInferInput, + ): Effect.Effect => + Effect.gen(function* () { + const validatedInput = yield* Schema.decodeUnknown(signalDef.input)(args).pipe( + Effect.mapError( + (parseError) => new SignalValidationError({ signalName, parseError }), + ), + ); + + yield* Effect.tryPromise({ + try: () => workflowHandle.signal(signalName, validatedInput), + catch: (e) => new RuntimeClientError({ operation: "signal", cause: e }), + }); + }); + } + } + + const updates: Record = {}; + if (definition.updates) { + for (const [updateName, updateDef] of Object.entries(definition.updates)) { + updates[updateName] = ( + args: EffectClientInferInput, + ): Effect.Effect => + Effect.gen(function* () { + const validatedInput = yield* Schema.decodeUnknown(updateDef.input)(args).pipe( + Effect.mapError( + (parseError) => + new UpdateValidationError({ updateName, direction: "input", parseError }), + ), + ); + + const rawResult = yield* Effect.tryPromise({ + try: () => workflowHandle.executeUpdate(updateName, { args: [validatedInput] }), + catch: (e) => new RuntimeClientError({ operation: "update", cause: e }), + }); + + return yield* Schema.decodeUnknown(updateDef.output)(rawResult).pipe( + Effect.mapError( + (parseError) => + new UpdateValidationError({ updateName, direction: "output", parseError }), + ), + ); + }); + } + } + + return { + workflowId: workflowHandle.workflowId, + // Necessary: dynamic construction cannot be verified against the mapped type at compile time + queries: queries as EffectTypedWorkflowHandle["queries"], + signals: signals as EffectTypedWorkflowHandle["signals"], + updates: updates as EffectTypedWorkflowHandle["updates"], + + result: (): Effect.Effect< + EffectClientInferOutput, + WorkflowValidationError | RuntimeClientError + > => + Effect.gen(function* () { + const rawResult = yield* Effect.tryPromise({ + try: () => workflowHandle.result(), + catch: (e) => new RuntimeClientError({ operation: "result", cause: e }), + }); + + return yield* Schema.decodeUnknown(definition.output)(rawResult).pipe( + Effect.mapError( + (parseError) => + new WorkflowValidationError({ + workflowName, + direction: "output", + parseError, + }), + ), + ); + }), + + terminate: (reason?: string): Effect.Effect => + Effect.tryPromise({ + try: () => workflowHandle.terminate(reason).then(() => undefined), + catch: (e) => new RuntimeClientError({ operation: "terminate", cause: e }), + }), + + cancel: (): Effect.Effect => + Effect.tryPromise({ + try: () => workflowHandle.cancel().then(() => undefined), + catch: (e) => new RuntimeClientError({ operation: "cancel", cause: e }), + }), + + describe: (): Effect.Effect< + Awaited>, + RuntimeClientError + > => + Effect.tryPromise({ + try: () => workflowHandle.describe(), + catch: (e) => new RuntimeClientError({ operation: "describe", cause: e }), + }), + + fetchHistory: (): Effect.Effect< + Awaited>, + RuntimeClientError + > => + Effect.tryPromise({ + try: () => workflowHandle.fetchHistory(), + catch: (e) => new RuntimeClientError({ operation: "fetchHistory", cause: e }), + }), + }; + } +} diff --git a/packages/client-effect/src/errors.ts b/packages/client-effect/src/errors.ts new file mode 100644 index 00000000..f0866b6a --- /dev/null +++ b/packages/client-effect/src/errors.ts @@ -0,0 +1,53 @@ +import { Data } from "effect"; +import type { ParseError } from "effect/ParseResult"; + +/** + * Error raised when a workflow name is not found in the contract definition + */ +export class WorkflowNotFoundError extends Data.TaggedError("WorkflowNotFoundError")<{ + readonly workflowName: string; + readonly availableWorkflows: readonly string[]; +}> {} + +/** + * Error raised when workflow input or output fails schema validation + */ +export class WorkflowValidationError extends Data.TaggedError("WorkflowValidationError")<{ + readonly workflowName: string; + readonly direction: "input" | "output"; + readonly parseError: ParseError; +}> {} + +/** + * Error raised when query input or output fails schema validation + */ +export class QueryValidationError extends Data.TaggedError("QueryValidationError")<{ + readonly queryName: string; + readonly direction: "input" | "output"; + readonly parseError: ParseError; +}> {} + +/** + * Error raised when signal input fails schema validation + */ +export class SignalValidationError extends Data.TaggedError("SignalValidationError")<{ + readonly signalName: string; + readonly parseError: ParseError; +}> {} + +/** + * Error raised when update input or output fails schema validation + */ +export class UpdateValidationError extends Data.TaggedError("UpdateValidationError")<{ + readonly updateName: string; + readonly direction: "input" | "output"; + readonly parseError: ParseError; +}> {} + +/** + * Error raised when a Temporal SDK operation fails at runtime + */ +export class RuntimeClientError extends Data.TaggedError("RuntimeClientError")<{ + readonly operation: string; + readonly cause: unknown; +}> {} diff --git a/packages/client-effect/src/index.ts b/packages/client-effect/src/index.ts new file mode 100644 index 00000000..04e4032e --- /dev/null +++ b/packages/client-effect/src/index.ts @@ -0,0 +1,16 @@ +export { + EffectTypedClient, + type EffectTypedWorkflowHandle, + type EffectTypedWorkflowStartOptions, +} from "./client.js"; + +export { + QueryValidationError, + RuntimeClientError, + SignalValidationError, + UpdateValidationError, + WorkflowNotFoundError, + WorkflowValidationError, +} from "./errors.js"; + +export { makeTemporalClientLayer, makeTemporalClientTag } from "./layer.js"; diff --git a/packages/client-effect/src/layer.ts b/packages/client-effect/src/layer.ts new file mode 100644 index 00000000..7e325006 --- /dev/null +++ b/packages/client-effect/src/layer.ts @@ -0,0 +1,77 @@ +import { Connection, Client } from "@temporalio/client"; +import type { ConnectionOptions } from "@temporalio/client"; +import { Context, Effect, Layer } from "effect"; +import type { EffectContractDefinition } from "@temporal-contract/contract-effect"; +import { EffectTypedClient } from "./client.js"; +import { RuntimeClientError } from "./errors.js"; + +/** + * Generic Context.Tag for EffectTypedClient + * + * Because TypeScript generics cannot be used in `extends Context.Tag`, this + * is provided as a factory so callers can create a correctly-typed tag for + * their specific contract. + * + * @example + * ```ts + * import { makeTemporalClientTag } from "@temporal-contract/client-effect/layer"; + * import { orderContract } from "./contract"; + * + * export const OrderClient = makeTemporalClientTag("OrderClient"); + * export type OrderClient = Context.Tag.Service; + * ``` + */ +export function makeTemporalClientTag( + identifier: string, +): Context.Tag, EffectTypedClient> { + return Context.GenericTag>(identifier); +} + +/** + * Build a Layer that provides an EffectTypedClient for the given contract + * + * The Layer establishes the Temporal connection and wraps the resulting client. + * Use this when building a full Effect application with Layer-based dependency + * injection. + * + * @example + * ```ts + * import { Effect, Layer, pipe } from "effect"; + * import { makeTemporalClientTag, makeTemporalClientLayer } from "@temporal-contract/client-effect"; + * import { orderContract } from "./contract"; + * + * const OrderClient = makeTemporalClientTag("OrderClient"); + * + * const OrderClientLive = makeTemporalClientLayer(OrderClient, orderContract, { + * address: "localhost:7233", + * }); + * + * // Use in your program: + * const program = Effect.gen(function* () { + * const client = yield* OrderClient; + * const result = yield* client.executeWorkflow("processOrder", { + * workflowId: "order-123", + * args: { orderId: "ORD-123" }, + * }); + * return result; + * }); + * + * await Effect.runPromise(pipe(program, Effect.provide(OrderClientLive))); + * ``` + */ +export function makeTemporalClientLayer( + tag: Context.Tag, EffectTypedClient>, + contract: TContract, + connectOptions?: ConnectionOptions, +): Layer.Layer, RuntimeClientError> { + return Layer.effect( + tag, + Effect.gen(function* () { + const connection = yield* Effect.tryPromise({ + try: () => Connection.connect(connectOptions ?? {}), + catch: (e) => new RuntimeClientError({ operation: "connect", cause: e }), + }); + return EffectTypedClient.create(contract, new Client({ connection })); + }), + ); +} diff --git a/packages/client-effect/tsconfig.json b/packages/client-effect/tsconfig.json new file mode 100644 index 00000000..d699628b --- /dev/null +++ b/packages/client-effect/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@temporal-contract/tsconfig/base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/client-effect/vitest.config.ts b/packages/client-effect/vitest.config.ts new file mode 100644 index 00000000..4a15de6d --- /dev/null +++ b/packages/client-effect/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "vitest/config"; +import { fileURLToPath, URL } from "node:url"; + +export default defineConfig({ + resolve: { + alias: { + "@temporal-contract/contract-effect": fileURLToPath( + new URL("../contract-effect/src/index.ts", import.meta.url), + ), + }, + }, + test: { + name: "unit", + reporters: ["default"], + include: ["src/**/*.spec.ts"], + coverage: { + provider: "v8", + reporter: ["text", "json", "json-summary", "html"], + include: ["src/**"], + }, + }, +}); diff --git a/packages/contract-effect/README.md b/packages/contract-effect/README.md new file mode 100644 index 00000000..464d54d6 --- /dev/null +++ b/packages/contract-effect/README.md @@ -0,0 +1,151 @@ +# @temporal-contract/contract-effect + +Effect Schema contract builder for `temporal-contract`. Use this package to define type-safe Temporal contracts using [Effect Schema](https://effect.website/docs/schema/introduction) instead of Zod/Valibot/ArkType. + +This is the foundational package for the fully Effect-native stack: + +``` +@temporal-contract/contract-effect ← this package + ↑ +@temporal-contract/client-effect @temporal-contract/worker-effect +``` + +## Installation + +```bash +pnpm add @temporal-contract/contract-effect effect +``` + +## Quick Start + +```typescript +import { Schema } from "effect"; +import { defineEffectContract } from "@temporal-contract/contract-effect"; + +export const orderContract = defineEffectContract({ + taskQueue: "order-processing", + workflows: { + processOrder: { + input: Schema.Struct({ + orderId: Schema.String, + customerId: Schema.String, + }), + output: Schema.Struct({ + orderId: Schema.String, + status: Schema.Literal("success", "failed"), + }), + activities: { + chargePayment: { + input: Schema.Struct({ + customerId: Schema.String, + amount: Schema.Number, + }), + output: Schema.Struct({ + transactionId: Schema.String, + }), + }, + }, + signals: { + cancel: { + input: Schema.Struct({ reason: Schema.String }), + }, + }, + queries: { + getStatus: { + input: Schema.Tuple(), + output: Schema.Literal("pending", "running", "complete"), + }, + }, + }, + }, + activities: { + // Global activities available across all workflows + log: { + input: Schema.Struct({ level: Schema.String, message: Schema.String }), + output: Schema.Void, + }, + }, +}); +``` + +## Key Concepts + +### Why Effect Schema? + +Effect Schema is deeply integrated with the Effect ecosystem. Unlike Standard Schema adapters, it provides: + +- **Bidirectional codecs** — schemas describe both decoding (raw → typed) and encoding (typed → raw) in one definition +- **Tagged errors** — `ParseError` carries structured information about exactly what went wrong +- **Effect-native** — decoding returns `Effect` that composes naturally with the rest of your application + +### Input vs Output types + +Effect Schema distinguishes between two representations: + +| Type | Description | Example | +| -------------------------- | ------------------------------ | -------------------------- | +| `Schema.Schema.Type` | Decoded/parsed TypeScript type | `Date` | +| `Schema.Schema.Encoded` | Encoded/serialized form | `string` (ISO date string) | + +The library uses this consistently: + +- **Client** sends `Encoded` (raw wire format), receives `Type` (parsed result) +- **Worker** receives `Type` (already parsed), returns `Encoded` (raw form) + +### Type utilities + +```typescript +import type { + EffectClientInferInput, // Encoded form (what client sends) + EffectClientInferOutput, // Type form (what client receives) + EffectWorkerInferInput, // Type form (what worker receives) + EffectWorkerInferOutput, // Encoded form (what worker returns) +} from "@temporal-contract/contract-effect"; +``` + +### Schema transformations + +Schemas with transformations (e.g. `DateFromString`) carry different input and output types: + +```typescript +const DateFromString = Schema.transform(Schema.String, Schema.DateFromSelf, { + strict: true, + decode: (s) => new Date(s), + encode: (d) => d.toISOString(), +}); + +const def = { + input: Schema.Struct({ createdAt: DateFromString }), + // ... +}; + +// Client sends a string: { createdAt: "2024-01-01T00:00:00.000Z" } +type ClientInput = EffectClientInferInput; // { createdAt: string } + +// Worker receives a Date: { createdAt: Date } +type WorkerInput = EffectWorkerInferInput; // { createdAt: Date } +``` + +## API Reference + +### `defineEffectContract(contract)` + +Identity function that narrows the contract type for TypeScript inference. The runtime value is returned unchanged. + +```typescript +function defineEffectContract(contract: T): T; +``` + +### Types + +- `EffectContractDefinition` — the top-level contract type +- `EffectWorkflowDefinition` — a single workflow with input/output schemas +- `EffectActivityDefinition` — an activity with input/output schemas +- `EffectSignalDefinition` — a signal with input schema (no output) +- `EffectQueryDefinition` — a query with input/output schemas +- `EffectUpdateDefinition` — an update with input/output schemas +- `AnyEffectSchema` — `Schema.Schema` + +## License + +MIT diff --git a/packages/contract-effect/package.json b/packages/contract-effect/package.json new file mode 100644 index 00000000..7b30e1d7 --- /dev/null +++ b/packages/contract-effect/package.json @@ -0,0 +1,62 @@ +{ + "name": "@temporal-contract/contract-effect", + "version": "0.1.0", + "description": "Effect Schema contract builder for temporal-contract", + "keywords": [ + "contract", + "effect", + "schema", + "temporal", + "typescript" + ], + "homepage": "https://github.com/btravers/temporal-contract#readme", + "bugs": { + "url": "https://github.com/btravers/temporal-contract/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/btravers/temporal-contract.git", + "directory": "packages/contract-effect" + }, + "license": "MIT", + "author": "Ben Weis ", + "type": "module", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown src/index.ts --format cjs,esm --dts --clean", + "dev": "tsdown src/index.ts --format cjs,esm --dts --watch", + "test": "vitest run --project unit", + "test:watch": "vitest --project unit", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@temporal-contract/tsconfig": "workspace:*", + "@types/node": "catalog:", + "@vitest/coverage-v8": "catalog:", + "effect": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "effect": "^3" + } +} diff --git a/packages/contract-effect/src/contract.spec.ts b/packages/contract-effect/src/contract.spec.ts new file mode 100644 index 00000000..bd8c837b --- /dev/null +++ b/packages/contract-effect/src/contract.spec.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from "vitest"; +import { Schema } from "effect"; +import { defineEffectContract } from "./contract.js"; +import type { + EffectClientInferInput, + EffectClientInferOutput, + EffectWorkerInferInput, + EffectWorkerInferOutput, +} from "./types.js"; + +describe("defineEffectContract", () => { + it("should return the contract unchanged (identity function)", () => { + const contract = defineEffectContract({ + taskQueue: "test-queue", + workflows: { + testWorkflow: { + input: Schema.Struct({ name: Schema.String }), + output: Schema.Struct({ result: Schema.String }), + }, + }, + }); + + expect(contract.taskQueue).toBe("test-queue"); + expect(contract.workflows["testWorkflow"]).toBeDefined(); + }); + + it("should preserve task queue", () => { + const contract = defineEffectContract({ + taskQueue: "my-special-queue", + workflows: {}, + }); + + expect(contract.taskQueue).toBe("my-special-queue"); + }); + + it("should support workflow with activities", () => { + const contract = defineEffectContract({ + taskQueue: "order-queue", + workflows: { + processOrder: { + input: Schema.Struct({ orderId: Schema.String }), + output: Schema.Struct({ status: Schema.String }), + activities: { + chargePayment: { + input: Schema.Struct({ amount: Schema.Number }), + output: Schema.Struct({ transactionId: Schema.String }), + }, + }, + }, + }, + }); + + expect(contract.workflows["processOrder"]?.activities?.["chargePayment"]).toBeDefined(); + }); + + it("should support workflow with signals, queries, and updates", () => { + const contract = defineEffectContract({ + taskQueue: "signal-queue", + workflows: { + longRunning: { + input: Schema.Struct({ id: Schema.String }), + output: Schema.Struct({ completed: Schema.Boolean }), + signals: { + cancel: { + input: Schema.Struct({ reason: Schema.String }), + }, + }, + queries: { + getStatus: { + input: Schema.Tuple(), + output: Schema.String, + }, + }, + updates: { + pause: { + input: Schema.Struct({ durationMs: Schema.Number }), + output: Schema.Boolean, + }, + }, + }, + }, + }); + + const wf = contract.workflows["longRunning"]; + expect(wf?.signals?.["cancel"]).toBeDefined(); + expect(wf?.queries?.["getStatus"]).toBeDefined(); + expect(wf?.updates?.["pause"]).toBeDefined(); + }); + + it("should support global activities at contract level", () => { + const contract = defineEffectContract({ + taskQueue: "global-queue", + workflows: { + myWorkflow: { + input: Schema.Struct({ value: Schema.String }), + output: Schema.Struct({ result: Schema.String }), + }, + }, + activities: { + log: { + input: Schema.Struct({ message: Schema.String }), + output: Schema.Void, + }, + }, + }); + + expect(contract.activities?.["log"]).toBeDefined(); + }); +}); + +describe("Effect Schema type inference", () => { + it("should infer correct client input type (encoded)", () => { + const orderSchema = Schema.Struct({ + orderId: Schema.String, + amount: Schema.Number, + }); + + const def = { + input: orderSchema, + output: Schema.Struct({ status: Schema.String }), + }; + + type Input = EffectClientInferInput; + type _Check = Input extends { orderId: string; amount: number } ? true : false; + const _typeCheck: _Check = true; + expect(_typeCheck).toBe(true); + }); + + it("should infer correct client output type (decoded)", () => { + const def = { + input: Schema.Struct({ id: Schema.String }), + output: Schema.Struct({ status: Schema.String }), + }; + + type Output = EffectClientInferOutput; + type _Check = Output extends { status: string } ? true : false; + const _typeCheck: _Check = true; + expect(_typeCheck).toBe(true); + }); + + it("should infer correct worker input type (decoded)", () => { + const def = { + input: Schema.Struct({ name: Schema.String }), + output: Schema.Struct({ result: Schema.String }), + }; + + type Input = EffectWorkerInferInput; + type _Check = Input extends { name: string } ? true : false; + const _typeCheck: _Check = true; + expect(_typeCheck).toBe(true); + }); + + it("should infer correct worker output type (encoded)", () => { + const def = { + input: Schema.Struct({ id: Schema.String }), + output: Schema.Struct({ count: Schema.Number }), + }; + + type Output = EffectWorkerInferOutput; + type _Check = Output extends { count: number } ? true : false; + const _typeCheck: _Check = true; + expect(_typeCheck).toBe(true); + }); + + it("should support transforming schemas with different input/output types", () => { + // Schema that transforms: encoded = string, decoded = Date + const DateFromString = Schema.transform(Schema.String, Schema.DateFromSelf, { + strict: true, + decode: (s) => new Date(s), + encode: (d) => d.toISOString(), + }); + + const def = { + input: Schema.Struct({ createdAt: DateFromString }), + output: Schema.Struct({ id: Schema.String }), + }; + + // Client sends the encoded form (string) + type ClientInput = EffectClientInferInput; + type _ClientInputCheck = ClientInput extends { createdAt: string } ? true : false; + const _clientInputCheck: _ClientInputCheck = true; + expect(_clientInputCheck).toBe(true); + + // Worker receives the decoded form (Date) + type WorkerInput = EffectWorkerInferInput; + type _WorkerInputCheck = WorkerInput extends { createdAt: Date } ? true : false; + const _workerInputCheck: _WorkerInputCheck = true; + expect(_workerInputCheck).toBe(true); + }); +}); diff --git a/packages/contract-effect/src/contract.ts b/packages/contract-effect/src/contract.ts new file mode 100644 index 00000000..4636748e --- /dev/null +++ b/packages/contract-effect/src/contract.ts @@ -0,0 +1,100 @@ +import type { Schema } from "effect"; + +/** + * Any Effect Schema with no context requirements. + * + * Uses `any` (not `unknown`) for `A` and `I` so that concrete schemas like + * `Schema.Struct({...})` remain assignable. `Schema.Schema` + * causes contravariance failures on the `annotations` method, preventing concrete + * schemas from satisfying the bound. `R = never` is preserved to guarantee no + * service context requirements. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyEffectSchema = Schema.Schema; + +/** + * Activity definition using Effect Schema + */ +export type EffectActivityDefinition = { + input: AnyEffectSchema; + output: AnyEffectSchema; +}; + +/** + * Signal definition using Effect Schema (fire-and-forget, no output) + */ +export type EffectSignalDefinition = { + input: AnyEffectSchema; +}; + +/** + * Query definition using Effect Schema + */ +export type EffectQueryDefinition = { + input: AnyEffectSchema; + output: AnyEffectSchema; +}; + +/** + * Update definition using Effect Schema + */ +export type EffectUpdateDefinition = { + input: AnyEffectSchema; + output: AnyEffectSchema; +}; + +/** + * Workflow definition using Effect Schema + */ +export type EffectWorkflowDefinition = { + input: AnyEffectSchema; + output: AnyEffectSchema; + activities?: Record; + signals?: Record; + queries?: Record; + updates?: Record; +}; + +/** + * Full contract definition using Effect Schema + * + * Use this instead of `ContractDefinition` from `@temporal-contract/contract` + * when building a fully Effect-native Temporal stack. + */ +export type EffectContractDefinition = { + taskQueue: string; + workflows: Record; + activities?: Record; +}; + +/** + * Define a type-safe Temporal contract using Effect Schema + * + * This is the Effect-native analog of `defineContract` from `@temporal-contract/contract`. + * Use this with `@temporal-contract/client-effect` and `@temporal-contract/worker-effect`. + * + * @example + * ```ts + * import { Schema } from "effect"; + * import { defineEffectContract } from "@temporal-contract/contract-effect"; + * + * export const orderContract = defineEffectContract({ + * taskQueue: "order-processing", + * workflows: { + * processOrder: { + * input: Schema.Struct({ orderId: Schema.String }), + * output: Schema.Struct({ status: Schema.String, orderId: Schema.String }), + * activities: { + * chargePayment: { + * input: Schema.Struct({ amount: Schema.Number }), + * output: Schema.Struct({ transactionId: Schema.String }), + * }, + * }, + * }, + * }, + * }); + * ``` + */ +export function defineEffectContract(contract: T): T { + return contract; +} diff --git a/packages/contract-effect/src/index.ts b/packages/contract-effect/src/index.ts new file mode 100644 index 00000000..a2c37495 --- /dev/null +++ b/packages/contract-effect/src/index.ts @@ -0,0 +1,26 @@ +export { + defineEffectContract, + type AnyEffectSchema, + type EffectActivityDefinition, + type EffectContractDefinition, + type EffectQueryDefinition, + type EffectSignalDefinition, + type EffectUpdateDefinition, + type EffectWorkflowDefinition, +} from "./contract.js"; + +export type { + EffectClientInferActivity, + EffectClientInferInput, + EffectClientInferOutput, + EffectClientInferQuery, + EffectClientInferSignal, + EffectClientInferUpdate, + EffectClientInferWorkflow, + EffectClientInferWorkflowQueries, + EffectClientInferWorkflowSignals, + EffectClientInferWorkflowUpdates, + EffectClientInferWorkflows, + EffectWorkerInferInput, + EffectWorkerInferOutput, +} from "./types.js"; diff --git a/packages/contract-effect/src/types.ts b/packages/contract-effect/src/types.ts new file mode 100644 index 00000000..90fce3e1 --- /dev/null +++ b/packages/contract-effect/src/types.ts @@ -0,0 +1,130 @@ +import type { Schema } from "effect"; +import type { + AnyEffectSchema, + EffectActivityDefinition, + EffectContractDefinition, + EffectQueryDefinition, + EffectSignalDefinition, + EffectUpdateDefinition, + EffectWorkflowDefinition, +} from "./contract.js"; + +/** + * Infer the client-facing input type from a definition + * + * Client perspective: sends the encoded (raw/unvalidated) form of the input schema. + * This is the "before parsing" representation — e.g. strings for dates before + * transformation, raw union discriminants, etc. + */ +export type EffectClientInferInput = Schema.Schema.Encoded< + T["input"] +>; + +/** + * Infer the client-facing output type from a definition + * + * Client perspective: receives the decoded (parsed/validated) form of the output schema. + */ +export type EffectClientInferOutput = Schema.Schema.Type< + T["output"] +>; + +/** + * Infer the worker-facing input type from a definition + * + * Worker perspective: receives the decoded (parsed/validated) form of the input schema. + */ +export type EffectWorkerInferInput = Schema.Schema.Type< + T["input"] +>; + +/** + * Infer the worker-facing output type from a definition + * + * Worker perspective: returns the encoded (raw) form of the output schema — + * the worker produces the raw value which is then validated before being + * sent back to the client. + */ +export type EffectWorkerInferOutput = Schema.Schema.Encoded< + T["output"] +>; + +// --------------------------------------------------------------------------- +// Workflow-level inference helpers +// --------------------------------------------------------------------------- + +/** + * Infer workflow function signature from client perspective + */ +export type EffectClientInferWorkflow = ( + args: EffectClientInferInput, +) => Promise>; + +/** + * Infer activity function signature from client perspective + */ +export type EffectClientInferActivity = ( + args: EffectClientInferInput, +) => Promise>; + +/** + * Infer signal handler signature from client perspective + */ +export type EffectClientInferSignal = ( + args: EffectClientInferInput, +) => Promise; + +/** + * Infer query handler signature from client perspective + */ +export type EffectClientInferQuery = ( + args: EffectClientInferInput, +) => Promise>; + +/** + * Infer update handler signature from client perspective + */ +export type EffectClientInferUpdate = ( + args: EffectClientInferInput, +) => Promise>; + +// --------------------------------------------------------------------------- +// Contract-level inference helpers +// --------------------------------------------------------------------------- + +/** + * Infer all workflows from a contract (client perspective) + */ +export type EffectClientInferWorkflows = { + [K in keyof TContract["workflows"]]: EffectClientInferWorkflow; +}; + +/** + * Infer workflow-specific signals from a workflow definition (client perspective) + */ +export type EffectClientInferWorkflowSignals = + T["signals"] extends Record + ? { + [K in keyof T["signals"]]: EffectClientInferSignal; + } + : Record; + +/** + * Infer workflow-specific queries from a workflow definition (client perspective) + */ +export type EffectClientInferWorkflowQueries = + T["queries"] extends Record + ? { + [K in keyof T["queries"]]: EffectClientInferQuery; + } + : Record; + +/** + * Infer workflow-specific updates from a workflow definition (client perspective) + */ +export type EffectClientInferWorkflowUpdates = + T["updates"] extends Record + ? { + [K in keyof T["updates"]]: EffectClientInferUpdate; + } + : Record; diff --git a/packages/contract-effect/tsconfig.json b/packages/contract-effect/tsconfig.json new file mode 100644 index 00000000..d699628b --- /dev/null +++ b/packages/contract-effect/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@temporal-contract/tsconfig/base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/contract-effect/vitest.config.ts b/packages/contract-effect/vitest.config.ts new file mode 100644 index 00000000..6fc22535 --- /dev/null +++ b/packages/contract-effect/vitest.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + reporters: ["default"], + coverage: { + provider: "v8", + reporter: ["text", "json", "json-summary", "html"], + include: ["src/**"], + }, + projects: [ + { + test: { + name: "unit", + include: ["src/**/*.spec.ts"], + }, + }, + ], + }, +}); diff --git a/packages/worker-effect/README.md b/packages/worker-effect/README.md new file mode 100644 index 00000000..8e03b810 --- /dev/null +++ b/packages/worker-effect/README.md @@ -0,0 +1,197 @@ +# @temporal-contract/worker-effect + +Effect-native activity handler for `temporal-contract` workers. Activity implementations return `Effect` instead of `Future>`, with optional Layer-based service injection. + +## Installation + +```bash +pnpm add @temporal-contract/worker-effect @temporal-contract/contract-effect effect +``` + +## Quick Start + +### 1. Define your contract + +```typescript +// contract.ts +import { Schema } from "effect"; +import { defineEffectContract } from "@temporal-contract/contract-effect"; + +export const orderContract = defineEffectContract({ + taskQueue: "order-processing", + workflows: { + processOrder: { + input: Schema.Struct({ orderId: Schema.String }), + output: Schema.Struct({ status: Schema.String }), + activities: { + chargePayment: { + input: Schema.Struct({ amount: Schema.Number }), + output: Schema.Struct({ transactionId: Schema.String }), + }, + }, + }, + }, +}); +``` + +### 2. Implement activities with Effect + +```typescript +// activities.ts +import { Effect } from "effect"; +import { declareActivitiesHandler, ActivityError } from "@temporal-contract/worker-effect"; +import { orderContract } from "./contract"; + +export const activities = declareActivitiesHandler({ + contract: orderContract, + activities: { + processOrder: { + chargePayment: (args) => + Effect.tryPromise({ + try: () => paymentService.charge(args.amount), + catch: (e) => + new ActivityError({ + code: "CHARGE_FAILED", + message: "Failed to charge payment", + cause: e, + }), + }), + }, + }, +}); +``` + +### 3. Use with a Temporal Worker + +```typescript +// worker.ts +import { Worker, NativeConnection } from "@temporalio/worker"; +import { activities } from "./activities"; +import { orderContract } from "./contract"; + +const connection = await NativeConnection.connect({ address: "localhost:7233" }); +const worker = await Worker.create({ + connection, + taskQueue: orderContract.taskQueue, + workflowsPath: new URL("./workflows.js", import.meta.url).pathname, + activities, +}); + +await worker.run(); +``` + +## Layer-based Service Injection + +For activities that depend on services (database, HTTP client, etc.), use `declareActivitiesHandlerWithLayer`. The Layer is built once at startup; all activity invocations share the same runtime. + +```typescript +import { Effect, Layer, Context } from "effect"; +import { declareActivitiesHandlerWithLayer, ActivityError } from "@temporal-contract/worker-effect"; +import { orderContract } from "./contract"; + +// Define a service tag +class Database extends Context.Tag("Database")< + Database, + { + query: (sql: string, params: unknown[]) => Effect.Effect; + } +>() {} + +// Provide a live implementation +const DatabaseLive = Layer.effect( + Database, + Effect.gen(function* () { + const pool = yield* Effect.tryPromise({ + try: () => createConnectionPool(process.env.DATABASE_URL), + catch: (e) => + new ActivityError({ code: "DB_CONNECT", message: "DB connection failed", cause: e }), + }); + return { + query: (sql, params) => + Effect.tryPromise({ + try: () => pool.query(sql, params), + catch: (e) => new ActivityError({ code: "DB_QUERY", message: "Query failed", cause: e }), + }), + }; + }), +); + +export const activities = await declareActivitiesHandlerWithLayer({ + contract: orderContract, + layer: DatabaseLive, + activities: { + processOrder: { + chargePayment: (args) => + Effect.gen(function* () { + const db = yield* Database; + const [row] = yield* db.query( + "INSERT INTO payments (amount) VALUES (?) RETURNING transaction_id", + [args.amount], + ); + return { transactionId: String(row) }; + }), + }, + }, +}); +``` + +## API Reference + +### `declareActivitiesHandler(options)` + +Synchronous variant for activities with no Effect service dependencies. + +```typescript +function declareActivitiesHandler(options: { + contract: TContract; + activities: ContractEffectActivitiesImplementations; +}): EffectActivitiesHandler; +``` + +### `declareActivitiesHandlerWithLayer(options)` + +Async variant for activities that need services from Effect context. + +```typescript +async function declareActivitiesHandlerWithLayer(options: { + contract: TContract; + layer: Layer.Layer; + activities: ContractEffectActivitiesImplementationsR; +}): Promise>; +``` + +### Error types + +| Error | `_tag` | Description | +| --------------------------------- | ----------------------------------- | ----------------------------------------------------------------------- | +| `ActivityError` | `"ActivityError"` | Wrap all technical errors in this. Temporal uses it for retry policies. | +| `ActivityDefinitionNotFoundError` | `"ActivityDefinitionNotFoundError"` | Activity not in contract (thrown at setup time) | +| `ActivityInputValidationError` | `"ActivityInputValidationError"` | Input schema validation failed | +| `ActivityOutputValidationError` | `"ActivityOutputValidationError"` | Output schema validation failed | + +All error types are `Data.TaggedError` subclasses. They extend `Error`, so Temporal's retry machinery handles them correctly. + +### ActivityError + +Wrap all technical exceptions in `ActivityError` so Temporal can: + +1. Detect retryable vs non-retryable errors +2. Serialize the error for the workflow to inspect +3. Apply the retry policy defined on the activity options + +```typescript +Effect.tryPromise({ + try: () => externalService.call(args), + catch: (e) => new ActivityError({ code: "CALL_FAILED", message: "...", cause: e }), +}); +``` + +## Important: Workflows cannot use Effect + +Temporal workflows run inside a deterministic V8 sandbox. Effect's fiber machinery is not sandbox-safe. **Do not import `effect` inside workflow files.** + +For workflow implementations, continue using `@temporal-contract/worker/workflow` with `@temporal-contract/boxed`. + +## License + +MIT diff --git a/packages/worker-effect/package.json b/packages/worker-effect/package.json new file mode 100644 index 00000000..d3e885d5 --- /dev/null +++ b/packages/worker-effect/package.json @@ -0,0 +1,68 @@ +{ + "name": "@temporal-contract/worker-effect", + "version": "0.1.0", + "description": "Effect-native activity handler for temporal-contract workers", + "keywords": [ + "activities", + "contract", + "effect", + "temporal", + "typescript", + "worker" + ], + "homepage": "https://github.com/btravers/temporal-contract#readme", + "bugs": { + "url": "https://github.com/btravers/temporal-contract/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/btravers/temporal-contract.git", + "directory": "packages/worker-effect" + }, + "license": "MIT", + "author": "Ben Weis ", + "type": "module", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown src/index.ts --format cjs,esm --dts --clean", + "dev": "tsdown src/index.ts --format cjs,esm --dts --watch", + "test": "vitest run --project unit", + "test:watch": "vitest --project unit", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@temporal-contract/contract-effect": "workspace:*" + }, + "devDependencies": { + "@temporal-contract/tsconfig": "workspace:*", + "@temporalio/worker": "catalog:", + "@types/node": "catalog:", + "@vitest/coverage-v8": "catalog:", + "effect": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "@temporalio/worker": "^1", + "effect": "^3" + } +} diff --git a/packages/worker-effect/src/activity.spec.ts b/packages/worker-effect/src/activity.spec.ts new file mode 100644 index 00000000..68d1bcc0 --- /dev/null +++ b/packages/worker-effect/src/activity.spec.ts @@ -0,0 +1,354 @@ +import { describe, expect, it } from "vitest"; +import { Context, Effect, Layer, Schema } from "effect"; +import { defineEffectContract } from "@temporal-contract/contract-effect"; +import { + ActivityDefinitionNotFoundError, + ActivityError, + ActivityInputValidationError, + ActivityOutputValidationError, +} from "./errors.js"; +import { declareActivitiesHandler, declareActivitiesHandlerWithLayer } from "./activity.js"; + +describe("declareActivitiesHandler", () => { + it("should create an activities handler from Effect implementations", () => { + const contract = defineEffectContract({ + taskQueue: "test-queue", + workflows: { + testWorkflow: { + input: Schema.Struct({ value: Schema.String }), + output: Schema.Struct({ result: Schema.String }), + }, + }, + activities: { + sendEmail: { + input: Schema.Struct({ to: Schema.String, subject: Schema.String }), + output: Schema.Struct({ sent: Schema.Boolean }), + }, + }, + }); + + const activities = declareActivitiesHandler({ + contract, + activities: { + testWorkflow: {}, + sendEmail: (_args) => Effect.succeed({ sent: true }), + }, + }); + + expect(activities).toEqual(expect.objectContaining({ sendEmail: expect.any(Function) })); + }); + + it("should execute activity successfully and return the value", async () => { + const contract = defineEffectContract({ + taskQueue: "test-queue", + workflows: {}, + activities: { + processPayment: { + input: Schema.Struct({ amount: Schema.Number, currency: Schema.String }), + output: Schema.Struct({ transactionId: Schema.String }), + }, + }, + }); + + const activities = declareActivitiesHandler({ + contract, + activities: { + processPayment: (args) => Effect.succeed({ transactionId: `tx-${args.amount}` }), + }, + }); + + const result = await activities.processPayment({ amount: 100, currency: "USD" }); + expect(result).toEqual({ transactionId: "tx-100" }); + }); + + it("should throw ActivityInputValidationError for invalid input", async () => { + const contract = defineEffectContract({ + taskQueue: "test-queue", + workflows: {}, + activities: { + strictActivity: { + input: Schema.Struct({ amount: Schema.Number }), + output: Schema.Struct({ success: Schema.Boolean }), + }, + }, + }); + + const activities = declareActivitiesHandler({ + contract, + activities: { + strictActivity: (_args) => Effect.succeed({ success: true }), + }, + }); + + await expect( + // @ts-expect-error intentionally invalid input + activities.strictActivity({ amount: "not-a-number" }), + ).rejects.toBeInstanceOf(ActivityInputValidationError); + }); + + it("should throw ActivityOutputValidationError when output schema fails", async () => { + const contract = defineEffectContract({ + taskQueue: "test-queue", + workflows: {}, + activities: { + fetchData: { + input: Schema.Struct({ id: Schema.String }), + output: Schema.Struct({ value: Schema.Number }), + }, + }, + }); + + const activities = declareActivitiesHandler({ + contract, + activities: { + // @ts-expect-error intentionally wrong output type + fetchData: (_args) => Effect.succeed({ value: "not-a-number" }), + }, + }); + + await expect(activities.fetchData({ id: "abc" })).rejects.toBeInstanceOf( + ActivityOutputValidationError, + ); + }); + + it("should rethrow ActivityError for Temporal retry policies", async () => { + const contract = defineEffectContract({ + taskQueue: "test-queue", + workflows: {}, + activities: { + failingActivity: { + input: Schema.Struct({ value: Schema.String }), + output: Schema.Struct({ result: Schema.String }), + }, + }, + }); + + const activities = declareActivitiesHandler({ + contract, + activities: { + failingActivity: (_args) => + Effect.fail( + new ActivityError({ + code: "ACTIVITY_FAILED", + message: "Something went wrong", + cause: { info: "details" }, + }), + ), + }, + }); + + await expect(activities.failingActivity({ value: "test" })).rejects.toMatchObject({ + _tag: "ActivityError", + code: "ACTIVITY_FAILED", + message: "Something went wrong", + }); + }); + + it("should wrap defects (unexpected errors) in ActivityError", async () => { + const contract = defineEffectContract({ + taskQueue: "test-queue", + workflows: {}, + activities: { + buggingActivity: { + input: Schema.Struct({ value: Schema.String }), + output: Schema.Struct({ result: Schema.String }), + }, + }, + }); + + const activities = declareActivitiesHandler({ + contract, + activities: { + buggingActivity: (_args) => Effect.die(new Error("Unexpected crash")), + }, + }); + + await expect(activities.buggingActivity({ value: "test" })).rejects.toMatchObject({ + _tag: "ActivityError", + code: "DEFECT", + }); + }); + + it("should support Effect.gen for activity implementations", async () => { + const contract = defineEffectContract({ + taskQueue: "test-queue", + workflows: {}, + activities: { + compute: { + input: Schema.Struct({ x: Schema.Number, y: Schema.Number }), + output: Schema.Struct({ sum: Schema.Number, product: Schema.Number }), + }, + }, + }); + + const activities = declareActivitiesHandler({ + contract, + activities: { + compute: (args) => + Effect.gen(function* () { + yield* Effect.sleep("0 millis"); + return { sum: args.x + args.y, product: args.x * args.y }; + }), + }, + }); + + const result = await activities.compute({ x: 3, y: 4 }); + expect(result).toEqual({ sum: 7, product: 12 }); + }); + + it("should support workflow-scoped activities (flattened to root level)", async () => { + const contract = defineEffectContract({ + taskQueue: "test-queue", + workflows: { + orderWorkflow: { + input: Schema.Struct({ orderId: Schema.String }), + output: Schema.Struct({ status: Schema.String }), + activities: { + validateOrder: { + input: Schema.Struct({ orderId: Schema.String }), + output: Schema.Struct({ valid: Schema.Boolean }), + }, + }, + }, + }, + }); + + const activities = declareActivitiesHandler({ + contract, + activities: { + orderWorkflow: { + validateOrder: (args) => Effect.succeed({ valid: args.orderId.length > 0 }), + }, + }, + }); + + // validateOrder is available at root level (Temporal flat structure) + const result = await activities.validateOrder({ orderId: "123" }); + expect(result).toEqual({ valid: true }); + }); + + it("should throw ActivityDefinitionNotFoundError for unknown activities", () => { + const contract = defineEffectContract({ + taskQueue: "test-queue", + workflows: {}, + activities: { + validActivity: { + input: Schema.Struct({ value: Schema.String }), + output: Schema.Struct({ result: Schema.String }), + }, + }, + }); + + expect(() => { + declareActivitiesHandler({ + contract, + activities: { + validActivity: (_args: unknown) => Effect.succeed({ result: "test" }), + // @ts-expect-error intentionally unknown activity + unknownActivity: (_args: unknown) => Effect.succeed({ result: "test" }), + }, + }); + }).toThrow(ActivityDefinitionNotFoundError); + }); + + it("ActivityError._tag is 'ActivityError' for catchTag usage", async () => { + const contract = defineEffectContract({ + taskQueue: "test-queue", + workflows: {}, + activities: { + myActivity: { + input: Schema.Struct({ value: Schema.String }), + output: Schema.Struct({ result: Schema.String }), + }, + }, + }); + + const activities = declareActivitiesHandler({ + contract, + activities: { + myActivity: (_args) => Effect.fail(new ActivityError({ code: "ERR", message: "fail" })), + }, + }); + + const err = await activities.myActivity({ value: "x" }).catch((e) => e); + expect(err._tag).toBe("ActivityError"); + expect(err instanceof Error).toBe(true); + }); +}); + +describe("declareActivitiesHandlerWithLayer", () => { + it("should inject Effect services into activity implementations", async () => { + const contract = defineEffectContract({ + taskQueue: "test-queue", + workflows: {}, + activities: { + greet: { + input: Schema.Struct({ name: Schema.String }), + output: Schema.Struct({ greeting: Schema.String }), + }, + }, + }); + + class Greeter extends Context.Tag("Greeter") string }>() {} + + const GreeterLive = Layer.succeed(Greeter, { + greet: (name) => `Hello, ${name}!`, + }); + + const activities = await declareActivitiesHandlerWithLayer({ + contract, + layer: GreeterLive, + activities: { + greet: (args) => + Effect.gen(function* () { + const greeter = yield* Greeter; + return { greeting: greeter.greet(args.name) }; + }), + }, + }); + + const result = await activities.greet({ name: "World" }); + expect(result).toEqual({ greeting: "Hello, World!" }); + }); + + it("should rethrow ActivityError from Layer-based activities", async () => { + const contract = defineEffectContract({ + taskQueue: "test-queue", + workflows: {}, + activities: { + fetch: { + input: Schema.Struct({ id: Schema.String }), + output: Schema.Struct({ data: Schema.String }), + }, + }, + }); + + class Fetcher extends Context.Tag("Fetcher")< + Fetcher, + { fetch: (id: string) => Effect.Effect } + >() {} + + const FetcherLive = Layer.succeed(Fetcher, { + fetch: (_id) => + Effect.fail(new ActivityError({ code: "FETCH_FAILED", message: "Not found" })), + }); + + const activities = await declareActivitiesHandlerWithLayer({ + contract, + layer: FetcherLive, + activities: { + fetch: (args) => + Effect.gen(function* () { + const fetcher = yield* Fetcher; + const data = yield* fetcher.fetch(args.id); + return { data }; + }), + }, + }); + + await expect(activities.fetch({ id: "missing" })).rejects.toMatchObject({ + _tag: "ActivityError", + code: "FETCH_FAILED", + }); + }); +}); diff --git a/packages/worker-effect/src/activity.ts b/packages/worker-effect/src/activity.ts new file mode 100644 index 00000000..f22b46ad --- /dev/null +++ b/packages/worker-effect/src/activity.ts @@ -0,0 +1,377 @@ +import { Cause, Effect, Exit, Layer, ManagedRuntime, Option, Schema } from "effect"; +import type { + EffectActivityDefinition, + EffectContractDefinition, + EffectWorkerInferInput, + EffectWorkerInferOutput, +} from "@temporal-contract/contract-effect"; +import { + ActivityDefinitionNotFoundError, + ActivityError, + ActivityInputValidationError, + ActivityOutputValidationError, +} from "./errors.js"; + +// --------------------------------------------------------------------------- +// Activity implementation types +// --------------------------------------------------------------------------- + +/** + * Activity implementation returning an Effect with no service requirements + */ +type EffectActivityImplementation = ( + args: EffectWorkerInferInput, +) => Effect.Effect, ActivityError>; + +/** + * Activity implementation returning an Effect that requires services from context + */ +type EffectActivityImplementationR = ( + args: EffectWorkerInferInput, +) => Effect.Effect, ActivityError, R>; + +// --------------------------------------------------------------------------- +// Activities maps +// --------------------------------------------------------------------------- + +type EffectActivitiesImplementations> = + { + [K in keyof TActivities]: EffectActivityImplementation; + }; + +type EffectActivitiesImplementationsR< + TActivities extends Record, + R, +> = { + [K in keyof TActivities]: EffectActivityImplementationR; +}; + +type ContractEffectActivitiesImplementations = + (TContract["activities"] extends Record + ? EffectActivitiesImplementations + : Record) & { + [TWorkflow in keyof TContract["workflows"]]: TContract["workflows"][TWorkflow]["activities"] extends Record< + string, + EffectActivityDefinition + > + ? EffectActivitiesImplementations + : Record; + }; + +type ContractEffectActivitiesImplementationsR< + TContract extends EffectContractDefinition, + R, +> = (TContract["activities"] extends Record + ? EffectActivitiesImplementationsR + : Record) & { + [TWorkflow in keyof TContract["workflows"]]: TContract["workflows"][TWorkflow]["activities"] extends Record< + string, + EffectActivityDefinition + > + ? EffectActivitiesImplementationsR + : Record; +}; + +// --------------------------------------------------------------------------- +// Temporal-compatible activities handler (flat Promise-based functions) +// --------------------------------------------------------------------------- + +type ActivityImplementation = ( + args: EffectWorkerInferInput, +) => Promise>; + +type ActivitiesHandler> = { + [K in keyof TActivities]: ActivityImplementation; +}; + +type UnionToIntersection = (U extends unknown ? (k: U) => void : never) extends ( + k: infer I, +) => void + ? I + : never; + +/** + * Temporal-compatible activities object produced by `declareActivitiesHandler` + * + * All activity implementations are flattened to the root level (Temporal requirement). + */ +export type EffectActivitiesHandler = + (TContract["activities"] extends Record + ? ActivitiesHandler + : Record) & + UnionToIntersection< + { + [TWorkflow in keyof TContract["workflows"]]: TContract["workflows"][TWorkflow]["activities"] extends Record< + string, + EffectActivityDefinition + > + ? ActivitiesHandler + : Record; + }[keyof TContract["workflows"]] + >; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Convert a typed error from `Cause` back into a thrown exception for Temporal. + * + * `ActivityError` and validation errors all extend `Error` via `Data.TaggedError`, + * so Temporal's retry machinery correctly detects and handles them. + */ +function extractAndThrowCause(cause: Cause.Cause): never { + const typed = Cause.failureOption(cause); + if (Option.isSome(typed)) { + throw typed.value; + } + // Defect (unexpected die/interrupt) — wrap in ActivityError so Temporal can retry + throw new ActivityError({ + code: "DEFECT", + message: "Unexpected activity failure", + cause: Cause.squash(cause), + }); +} + +/** + * Build a single Temporal-compatible wrapper around an Effect activity implementation. + * + * Handles: + * - Input validation via Effect Schema + * - Running the Effect implementation via the provided runtime + * - Output validation via Effect Schema + * - Converting Effect failures back to thrown errors for Temporal retry policies + */ +function makeWrapped( + activityName: string, + activityDef: EffectActivityDefinition, + effectImpl: (args: unknown) => Effect.Effect, + runtime: { runPromiseExit: (effect: Effect.Effect) => Promise> }, +): (...args: unknown[]) => Promise { + return async (...args: unknown[]) => { + const input = args.length === 1 ? args[0] : args; + + const program = Effect.gen(function* () { + const validatedInput = yield* Schema.decodeUnknown(activityDef.input)(input).pipe( + Effect.mapError( + (parseError) => new ActivityInputValidationError({ activityName, parseError }), + ), + ); + + const output = yield* effectImpl(validatedInput); + + return yield* Schema.decodeUnknown(activityDef.output)(output).pipe( + Effect.mapError( + (parseError) => new ActivityOutputValidationError({ activityName, parseError }), + ), + ); + }); + + const exit = await runtime.runPromiseExit(program); + + if (Exit.isSuccess(exit)) { + return exit.value; + } + + return extractAndThrowCause(exit.cause); + }; +} + +// Default runtime (no service requirements) +const defaultRuntime = { + runPromiseExit: (effect: Effect.Effect) => Effect.runPromiseExit(effect), +}; + +// --------------------------------------------------------------------------- +// Public API: plain variant (no Layer) +// --------------------------------------------------------------------------- + +/** + * Options for `declareActivitiesHandler` + */ +type DeclareActivitiesHandlerOptions = { + contract: TContract; + activities: ContractEffectActivitiesImplementations; +}; + +/** + * Create a Temporal-compatible activities handler from Effect implementations + * + * Activity implementations return `Effect` instead of + * `Future>`. The handler validates inputs and outputs + * using the contract's Effect Schemas and converts Effect failures back into thrown + * errors so Temporal's retry policies work correctly. + * + * Use this variant when your activities have no Effect service dependencies. + * For activities that need injected services, use `declareActivitiesHandlerWithLayer`. + * + * @example + * ```ts + * import { Effect } from "effect"; + * import { declareActivitiesHandler, ActivityError } from "@temporal-contract/worker-effect"; + * import { orderContract } from "./contract"; + * + * export const activities = declareActivitiesHandler({ + * contract: orderContract, + * activities: { + * orderWorkflow: { + * chargePayment: (args) => + * Effect.tryPromise({ + * try: () => paymentService.charge(args.amount), + * catch: (e) => new ActivityError({ code: "CHARGE_FAILED", message: "...", cause: e }), + * }), + * }, + * }, + * }); + * ``` + */ +export function declareActivitiesHandler( + options: DeclareActivitiesHandlerOptions, +): EffectActivitiesHandler { + return buildActivitiesHandler(options.contract, options.activities, defaultRuntime); +} + +// --------------------------------------------------------------------------- +// Public API: Layer variant (with Effect service injection) +// --------------------------------------------------------------------------- + +/** + * Options for `declareActivitiesHandlerWithLayer` + */ +type DeclareActivitiesHandlerWithLayerOptions = { + contract: TContract; + /** + * Effect Layer providing the services required by the activity implementations. + * The Layer is built once at startup; all activity invocations share the same runtime. + */ + layer: Layer.Layer; + activities: ContractEffectActivitiesImplementationsR; +}; + +/** + * Create a Temporal-compatible activities handler from Effect implementations + * that depend on Effect services provided via a Layer. + * + * The Layer is used to build a `ManagedRuntime` once at startup. All activity + * invocations share this runtime, so services like database connections are + * only initialised once. + * + * @example + * ```ts + * import { Effect, Layer, Context } from "effect"; + * import { declareActivitiesHandlerWithLayer, ActivityError } from "@temporal-contract/worker-effect"; + * import { orderContract } from "./contract"; + * + * // Define a service + * class Database extends Context.Tag("Database") Effect.Effect + * }>() {} + * + * // Provide a live implementation + * const DatabaseLive = Layer.succeed(Database, { + * query: (sql) => Effect.tryPromise({ + * try: () => db.execute(sql), + * catch: (e) => new ActivityError({ code: "DB_ERROR", message: String(e) }), + * }), + * }); + * + * export const activities = declareActivitiesHandlerWithLayer({ + * contract: orderContract, + * layer: DatabaseLive, + * activities: { + * orderWorkflow: { + * chargePayment: (args) => Effect.gen(function* () { + * const db = yield* Database; + * const row = yield* db.query(`SELECT * FROM orders WHERE id = '${args.orderId}'`); + * return { transactionId: String(row) }; + * }), + * }, + * }, + * }); + * ``` + */ +export async function declareActivitiesHandlerWithLayer< + TContract extends EffectContractDefinition, + R, +>( + options: DeclareActivitiesHandlerWithLayerOptions, +): Promise> { + const runtime = ManagedRuntime.make(options.layer); + + return buildActivitiesHandler(options.contract, options.activities, runtime); +} + +// --------------------------------------------------------------------------- +// Core builder (shared by both variants) +// --------------------------------------------------------------------------- + +function buildActivitiesHandler( + contract: TContract, + activities: Record, + runtime: { + runPromiseExit: (effect: Effect.Effect) => Promise>; + }, +): EffectActivitiesHandler { + const wrapped = {} as EffectActivitiesHandler; + + // 1. Wrap global activities (contract.activities) + if (contract.activities) { + for (const [activityName, impl] of Object.entries(activities)) { + // Skip workflow namespace keys + if (contract.workflows && activityName in contract.workflows) { + continue; + } + + const activityDef = (contract.activities as Record)[ + activityName + ]; + + if (!activityDef) { + throw new ActivityDefinitionNotFoundError({ + activityName, + availableActivities: Object.keys(contract.activities), + }); + } + + (wrapped as Record)[activityName] = makeWrapped( + activityName, + activityDef, + impl as (args: unknown) => Effect.Effect, + runtime, + ); + } + } + + // 2. Wrap workflow-scoped activities (flattened to root level for Temporal) + if (contract.workflows) { + for (const [workflowName, workflowDef] of Object.entries(contract.workflows)) { + const wfActivitiesImpl = activities[workflowName] as Record | undefined; + + if (!wfActivitiesImpl) { + continue; + } + + const wfDefs = workflowDef.activities ?? {}; + + for (const [activityName, impl] of Object.entries(wfActivitiesImpl)) { + const activityDef = (wfDefs as Record)[activityName]; + + if (!activityDef) { + throw new ActivityDefinitionNotFoundError({ + activityName: `${workflowName}.${activityName}`, + availableActivities: Object.keys(wfDefs), + }); + } + + (wrapped as Record)[activityName] = makeWrapped( + `${workflowName}.${activityName}`, + activityDef, + impl as (args: unknown) => Effect.Effect, + runtime, + ); + } + } + } + + return wrapped; +} diff --git a/packages/worker-effect/src/errors.ts b/packages/worker-effect/src/errors.ts new file mode 100644 index 00000000..956a8043 --- /dev/null +++ b/packages/worker-effect/src/errors.ts @@ -0,0 +1,60 @@ +import { Data } from "effect"; +import type { ParseError } from "effect/ParseResult"; + +/** + * Activity error — wrap technical exceptions in this before returning from an + * activity so Temporal can apply retry policies correctly. + * + * Extends `Error` (via `Data.TaggedError`) so Temporal's retry machinery + * continues to work correctly. + * + * @example + * ```ts + * import { Effect } from "effect"; + * import { ActivityError } from "@temporal-contract/worker-effect"; + * + * const chargePayment = (args: { amount: number }) => + * Effect.tryPromise({ + * try: () => paymentService.charge(args.amount), + * catch: (e) => + * new ActivityError({ + * code: "CHARGE_FAILED", + * message: "Failed to charge payment", + * cause: e, + * }), + * }); + * ``` + */ +export class ActivityError extends Data.TaggedError("ActivityError")<{ + readonly code: string; + readonly message: string; + readonly cause?: unknown; +}> {} + +/** + * Error raised when an activity definition is not found in the contract + */ +export class ActivityDefinitionNotFoundError extends Data.TaggedError( + "ActivityDefinitionNotFoundError", +)<{ + readonly activityName: string; + readonly availableActivities: readonly string[]; +}> {} + +/** + * Error raised when activity input fails schema validation + */ +export class ActivityInputValidationError extends Data.TaggedError("ActivityInputValidationError")<{ + readonly activityName: string; + readonly parseError: ParseError; +}> {} + +/** + * Error raised when activity output fails schema validation + */ +export class ActivityOutputValidationError extends Data.TaggedError( + "ActivityOutputValidationError", +)<{ + readonly activityName: string; + readonly parseError: ParseError; +}> {} diff --git a/packages/worker-effect/src/index.ts b/packages/worker-effect/src/index.ts new file mode 100644 index 00000000..613f47a1 --- /dev/null +++ b/packages/worker-effect/src/index.ts @@ -0,0 +1,12 @@ +export { + declareActivitiesHandler, + declareActivitiesHandlerWithLayer, + type EffectActivitiesHandler, +} from "./activity.js"; + +export { + ActivityDefinitionNotFoundError, + ActivityError, + ActivityInputValidationError, + ActivityOutputValidationError, +} from "./errors.js"; diff --git a/packages/worker-effect/tsconfig.json b/packages/worker-effect/tsconfig.json new file mode 100644 index 00000000..d699628b --- /dev/null +++ b/packages/worker-effect/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@temporal-contract/tsconfig/base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/worker-effect/vitest.config.ts b/packages/worker-effect/vitest.config.ts new file mode 100644 index 00000000..4a15de6d --- /dev/null +++ b/packages/worker-effect/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "vitest/config"; +import { fileURLToPath, URL } from "node:url"; + +export default defineConfig({ + resolve: { + alias: { + "@temporal-contract/contract-effect": fileURLToPath( + new URL("../contract-effect/src/index.ts", import.meta.url), + ), + }, + }, + test: { + name: "unit", + reporters: ["default"], + include: ["src/**/*.spec.ts"], + coverage: { + provider: "v8", + reporter: ["text", "json", "json-summary", "html"], + include: ["src/**"], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae830c63..78bc77df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,9 @@ catalogs: arktype: specifier: 2.1.29 version: 2.1.29 + effect: + specifier: 3.15.1 + version: 3.15.1 knip: specifier: 5.83.1 version: 5.83.1 @@ -122,6 +125,9 @@ importers: '@commitlint/config-conventional': specifier: 'catalog:' version: 20.4.1 + '@rolldown/binding-darwin-arm64': + specifier: 1.0.0-rc.3 + version: 1.0.0-rc.3 knip: specifier: 'catalog:' version: 5.83.1(@types/node@25.2.3)(typescript@5.9.3) @@ -456,6 +462,37 @@ importers: specifier: 'catalog:' version: 4.3.6 + packages/client-effect: + dependencies: + '@temporal-contract/contract-effect': + specifier: workspace:* + version: link:../contract-effect + devDependencies: + '@temporal-contract/tsconfig': + specifier: workspace:* + version: link:../../tools/tsconfig + '@temporalio/client': + specifier: 'catalog:' + version: 1.14.1 + '@types/node': + specifier: 'catalog:' + version: 25.2.3 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.0.18(vitest@4.0.18(@types/node@25.2.3)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + effect: + specifier: 'catalog:' + version: 3.15.1 + tsdown: + specifier: 'catalog:' + version: 0.20.3(oxc-resolver@11.17.1)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.0.18(@types/node@25.2.3)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + packages/client-nestjs: dependencies: '@temporal-contract/client': @@ -551,6 +588,30 @@ importers: specifier: 'catalog:' version: 4.0.18(@types/node@25.2.3)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + packages/contract-effect: + devDependencies: + '@temporal-contract/tsconfig': + specifier: workspace:* + version: link:../../tools/tsconfig + '@types/node': + specifier: 'catalog:' + version: 25.2.3 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.0.18(vitest@4.0.18(@types/node@25.2.3)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + effect: + specifier: 'catalog:' + version: 3.15.1 + tsdown: + specifier: 'catalog:' + version: 0.20.3(oxc-resolver@11.17.1)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.0.18(@types/node@25.2.3)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + packages/testing: dependencies: '@temporalio/client': @@ -649,6 +710,37 @@ importers: specifier: 'catalog:' version: 4.3.6 + packages/worker-effect: + dependencies: + '@temporal-contract/contract-effect': + specifier: workspace:* + version: link:../contract-effect + devDependencies: + '@temporal-contract/tsconfig': + specifier: workspace:* + version: link:../../tools/tsconfig + '@temporalio/worker': + specifier: 'catalog:' + version: 1.14.1 + '@types/node': + specifier: 'catalog:' + version: 25.2.3 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.0.18(vitest@4.0.18(@types/node@25.2.3)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + effect: + specifier: 'catalog:' + version: 3.15.1 + tsdown: + specifier: 'catalog:' + version: 0.20.3(oxc-resolver@11.17.1)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.0.18(@types/node@25.2.3)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + packages/worker-nestjs: dependencies: '@temporal-contract/contract': @@ -1632,41 +1724,49 @@ packages: resolution: {integrity: sha512-Cwm6A071ww60QouJ9LoHAwBgEoZzHQ0Qaqk2E7WLfBdiQN9mLXIDhnrpn04hlRElRPhLiu/dtg+o5PPLvaINXQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-arm64-musl@11.17.1': resolution: {integrity: sha512-+hwlE2v3m0r3sk93SchJL1uyaKcPjf+NGO/TD2DZUDo+chXx7FfaEj0nUMewigSt7oZ2sQN9Z4NJOtUa75HE5Q==} cpu: [arm64] os: [linux] + libc: [musl] '@oxc-resolver/binding-linux-ppc64-gnu@11.17.1': resolution: {integrity: sha512-bO+rsaE5Ox8cFyeL5Ct5tzot1TnQpFa/Wmu5k+hqBYSH2dNVDGoi0NizBN5QV8kOIC6O5MZr81UG4yW/2FyDTA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-riscv64-gnu@11.17.1': resolution: {integrity: sha512-B/P+hxKQ1oX4YstI9Lyh4PGzqB87Ddqj/A4iyRBbPdXTcxa+WW3oRLx1CsJKLmHPdDk461Hmbghq1Bm3pl+8Aw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-riscv64-musl@11.17.1': resolution: {integrity: sha512-ulp2H3bFXzd/th2maH+QNKj5qgOhJ3v9Yspdf1svTw3CDOuuTl6sRKsWQ7MUw0vnkSNvQndtflBwVXgzZvURsQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@oxc-resolver/binding-linux-s390x-gnu@11.17.1': resolution: {integrity: sha512-LAXYVe3rKk09Zo9YKF2ZLBcH8sz8Oj+JIyiUxiHtq0hiYLMsN6dOpCf2hzQEjPAmsSEA/hdC1PVKeXo+oma8mQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-x64-gnu@11.17.1': resolution: {integrity: sha512-3RAhxipMKE8RCSPn7O//sj440i+cYTgYbapLeOoDvQEt6R1QcJjTsFgI4iz99FhVj3YbPxlZmcLB5VW+ipyRTA==} cpu: [x64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-x64-musl@11.17.1': resolution: {integrity: sha512-wpjMEubGU8r9VjZTLdZR3aPHaBqTl8Jl8F4DBbgNoZ+yhkhQD1/MGvY70v2TLnAI6kAHSvcqgfvaqKDa2iWsPQ==} cpu: [x64] os: [linux] + libc: [musl] '@oxc-resolver/binding-openharmony-arm64@11.17.1': resolution: {integrity: sha512-XIE4w17RYAVIgx+9Gs3deTREq5tsmalbatYOOBGNdH7n0DfTE600c7wYXsp7ANc3BPDXsInnOzXDEPCvO1F6cg==} @@ -1740,48 +1840,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-arm64-musl@0.33.0': resolution: {integrity: sha512-O1YIzymGRdWj9cG5iVTjkP7zk9/hSaVN8ZEbqMnWZjLC1phXlv54cUvANGGXndgJp2JS4W9XENn7eo5I4jZueg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-ppc64-gnu@0.33.0': resolution: {integrity: sha512-2lrkNe+B0w1tCgQTaozfUNQCYMbqKKCGcnTDATmWCZzO77W2sh+3n04r1lk9Q1CK3bI+C3fPwhFPUR2X2BvlyQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-gnu@0.33.0': resolution: {integrity: sha512-8DSG1q0M6097vowHAkEyHnKed75/BWr1IBtgCJfytnWQg+Jn1X4DryhfjqonKZOZiv74oFQl5J8TCbdDuXXdtQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-musl@0.33.0': resolution: {integrity: sha512-eWaxnpPz7+p0QGUnw7GGviVBDOXabr6Cd0w7S/vnWTqQo9z1VroT7XXFnJEZ3dBwxMB9lphyuuYi/GLTCxqxlg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-s390x-gnu@0.33.0': resolution: {integrity: sha512-+mH8cQTqq+Tu2CdoB2/Wmk9CqotXResi+gPvXpb+AAUt/LiwpicTQqSolMheQKogkDTYHPuUiSN23QYmy7IXNQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-gnu@0.33.0': resolution: {integrity: sha512-fjyslAYAPE2+B6Ckrs5LuDQ6lB1re5MumPnzefAXsen3JGwiRilra6XdjUmszTNoExJKbewoxxd6bcLSTpkAJQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-musl@0.33.0': resolution: {integrity: sha512-ve/jGBlTt35Jl/I0A0SfCQX3wKnadzPDdyOFEwe2ZgHHIT9uhqhAv1PaVXTenSBpauICEWYH8mWy+ittzlVE/A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxfmt/binding-openharmony-arm64@0.33.0': resolution: {integrity: sha512-lsWRgY9e+uPvwXnuDiJkmJ2Zs3XwwaQkaALJ3/SXU9kjZP0Qh8/tGW8Tk/Z6WL32sDxx+aOK5HuU7qFY9dHJhg==} @@ -1854,48 +1962,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-arm64-musl@1.48.0': resolution: {integrity: sha512-adu5txuwGvQ4C4fjYHJD+vnY+OCwCixBzn7J3KF3iWlVHBBImcosSv+Ye+fbMMJui4HGjifNXzonjKm9pXmOiw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxlint/binding-linux-ppc64-gnu@1.48.0': resolution: {integrity: sha512-inlQQRUnHCny/7b7wA6NjEoJSSZPNea4qnDhWyeqBYWx8ukf2kzNDSiamfhOw6bfAYPm/PVlkVRYaNXQbkLeTQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-gnu@1.48.0': resolution: {integrity: sha512-YiJx6sW6bYebQDZRVWLKm/Drswx/hcjIgbLIhULSn0rRcBKc7d9V6mkqPjKDbhcxJgQD5Zi0yVccJiOdF40AWA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-musl@1.48.0': resolution: {integrity: sha512-zwSqxMgmb2ITamNfDv9Q9EKBc/4ZhCBP9gkg2hhcgR6sEVGPUDl1AKPC89CBKMxkmPUi3685C38EvqtZn5OtHw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxlint/binding-linux-s390x-gnu@1.48.0': resolution: {integrity: sha512-c/+2oUWAOsQB5JTem0rW8ODlZllF6pAtGSGXoLSvPTonKI1vAwaKhD9Qw1X36jRbcI3Etkpu/9z/RRjMba8vFQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-gnu@1.48.0': resolution: {integrity: sha512-PhauDqeFW5DGed6QxCY5lXZYKSlcBdCXJnH03ZNU6QmDZ0BFM/zSy1oPT2MNb1Afx1G6yOOVk8ErjWsQ7c59ng==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-musl@1.48.0': resolution: {integrity: sha512-6d7LIFFZGiavbHndhf1cK9kG9qmy2Dmr37sV9Ep7j3H+ciFdKSuOzdLh85mEUYMih+b+esMDlF5DU0WQRZPQjw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxlint/binding-openharmony-arm64@1.48.0': resolution: {integrity: sha512-r+0KK9lK6vFp3tXAgDMOW32o12dxvKS3B9La1uYMGdWAMoSeu2RzG34KmzSpXu6MyLDl4aSVyZLFM8KGdEjwaw==} @@ -1996,24 +2112,28 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.3': resolution: {integrity: sha512-Z03/wrqau9Bicfgb3Dbs6SYTHliELk2PM2LpG2nFd+cGupTMF5kanLEcj2vuuJLLhptNyS61rtk7SOZ+lPsTUA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.3': resolution: {integrity: sha512-iSXXZsQp08CSilff/DCTFZHSVEpEwdicV3W8idHyrByrcsRDVh9sGC3sev6d8BygSGj3vt8GvUKBPCoyMA4tgQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.3': resolution: {integrity: sha512-qaj+MFudtdCv9xZo9znFvkgoajLdc+vwf0Kz5N44g+LU5XMe+IsACgn3UG7uTRlCCvhMAGXm1XlpEA5bZBrOcw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.3': resolution: {integrity: sha512-U662UnMETyjT65gFmG9ma+XziENrs7BBnENi/27swZPYagubfHRirXHG2oMl+pEax2WvO7Kb9gHZmMakpYqBHQ==} @@ -2075,66 +2195,79 @@ packages: resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.1': resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.1': resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.1': resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.1': resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.1': resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.1': resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.1': resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.1': resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.1': resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.1': resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.1': resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.1': resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.57.1': resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} @@ -2236,24 +2369,28 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] '@swc/core-linux-arm64-musl@1.15.10': resolution: {integrity: sha512-4uAHO3nbfbrTcmO/9YcVweTQdx5fN3l7ewwl5AEK4yoC4wXmoBTEPHAVdKNe4r9+xrTgd4BgyPsy0409OjjlMw==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] '@swc/core-linux-x64-gnu@1.15.10': resolution: {integrity: sha512-W0h9ONNw1pVIA0cN7wtboOSTl4Jk3tHq+w2cMPQudu9/+3xoCxpFb9ZdehwCAk29IsvdWzGzY6P7dDVTyFwoqg==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] '@swc/core-linux-x64-musl@1.15.10': resolution: {integrity: sha512-XQNZlLZB62S8nAbw7pqoqwy91Ldy2RpaMRqdRN3T+tAg6Xg6FywXRKCsLh6IQOadr4p1+lGnqM/Wn35z5a/0Vw==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] '@swc/core-win32-arm64-msvc@1.15.10': resolution: {integrity: sha512-qnAGrRv5Nj/DATxAmCnJQRXXQqnJwR0trxLndhoHoxGci9MuguNIjWahS0gw8YZFjgTinbTxOwzatkoySihnmw==} @@ -3278,6 +3415,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + effect@3.15.1: + resolution: {integrity: sha512-n3bDF6K3R+FSVuH+dSVU3ya2pI4Wt/tnKzum3DC/3b5e0E9HfhrhbkonOkYU3AVJJOzCA6zZE2/y6EUgQNAY4g==} + electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} @@ -3381,6 +3521,10 @@ packages: extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + fast-copy@4.0.2: resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} @@ -4156,6 +4300,9 @@ packages: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -6107,8 +6254,7 @@ snapshots: '@rolldown/binding-android-arm64@1.0.0-rc.3': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-rc.3': - optional: true + '@rolldown/binding-darwin-arm64@1.0.0-rc.3': {} '@rolldown/binding-darwin-x64@1.0.0-rc.3': optional: true @@ -7483,6 +7629,11 @@ snapshots: eastasianwidth@0.2.0: {} + effect@3.15.1: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + electron-to-chromium@1.5.267: {} emoji-regex-xs@1.0.0: {} @@ -7611,6 +7762,10 @@ snapshots: extendable-error@0.1.7: {} + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + fast-copy@4.0.2: {} fast-deep-equal@3.1.3: {} @@ -8426,6 +8581,8 @@ snapshots: punycode.js@2.3.1: {} + pure-rand@6.1.0: {} + quansync@0.2.11: {} quansync@1.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c04a3540..72ee1716 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,6 +6,7 @@ packages: catalog: "@changesets/cli": 2.29.8 + effect: 3.15.1 "@commitlint/cli": 20.4.1 "@commitlint/config-conventional": 20.4.1 "@nestjs/common": 11.1.13 From 161aab4d270fbea21c4729d44b12e8f029057bd8 Mon Sep 17 00:00:00 2001 From: Ben Weis Date: Tue, 24 Feb 2026 14:58:42 -0500 Subject: [PATCH 2/3] docs: add docs support --- docs/api/index.md | 6 ++++++ docs/package.json | 3 +++ docs/scripts/copy-docs.ts | 6 ++++++ packages/client-effect/package.json | 3 +++ packages/client-effect/typedoc.json | 5 +++++ packages/contract-effect/package.json | 3 +++ packages/contract-effect/typedoc.json | 5 +++++ packages/worker-effect/package.json | 3 +++ packages/worker-effect/typedoc.json | 5 +++++ pnpm-lock.yaml | 27 +++++++++++++++++++++++++++ 10 files changed, 66 insertions(+) create mode 100644 packages/client-effect/typedoc.json create mode 100644 packages/contract-effect/typedoc.json create mode 100644 packages/worker-effect/typedoc.json diff --git a/docs/api/index.md b/docs/api/index.md index 554edc06..89ef5df9 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -14,6 +14,12 @@ Welcome to the temporal-contract API documentation. This documentation is auto-g - [@temporal-contract/client-nestjs](./client-nestjs/) - NestJS client module - [@temporal-contract/worker-nestjs](./worker-nestjs/) - NestJS worker module +## EffectTS Integration + +- [@temporal-contract/client-effect](./client-effect/) - EffectTS client module +- [@temporal-contract/contract-effect](./contract-effect/) - EffectTS contract module +- [@temporal-contract/worker-effect](./worker-effect/) - EffectTS worker module + ## Testing - [@temporal-contract/testing](./testing/) - Testing utilities with testcontainers diff --git a/docs/package.json b/docs/package.json index 7b19bd41..a7205d8e 100644 --- a/docs/package.json +++ b/docs/package.json @@ -14,10 +14,13 @@ "dependencies": { "@temporal-contract/boxed": "workspace:*", "@temporal-contract/client": "workspace:*", + "@temporal-contract/client-effect": "workspace:*", "@temporal-contract/client-nestjs": "workspace:*", "@temporal-contract/contract": "workspace:*", + "@temporal-contract/contract-effect": "workspace:*", "@temporal-contract/testing": "workspace:*", "@temporal-contract/worker": "workspace:*", + "@temporal-contract/worker-effect": "workspace:*", "@temporal-contract/worker-nestjs": "workspace:*" }, "devDependencies": { diff --git a/docs/scripts/copy-docs.ts b/docs/scripts/copy-docs.ts index a3ef23ec..db852ba5 100644 --- a/docs/scripts/copy-docs.ts +++ b/docs/scripts/copy-docs.ts @@ -7,6 +7,9 @@ import "@temporal-contract/contract"; import "@temporal-contract/testing/global-setup"; import "@temporal-contract/worker/activity"; import "@temporal-contract/worker-nestjs"; +import "@temporal-contract/contract-effect"; +import "@temporal-contract/client-effect"; +import "@temporal-contract/worker-effect"; import { cp, mkdir, rm } from "node:fs/promises"; import { dirname, join } from "node:path"; @@ -20,10 +23,13 @@ const packages = [ "boxed", "client", "client-nestjs", + "client-effect", "contract", + "contract-effect", "testing", "worker", "worker-nestjs", + "worker-effect", ]; async function copyDocs(): Promise { diff --git a/packages/client-effect/package.json b/packages/client-effect/package.json index 5af6b772..457c9d06 100644 --- a/packages/client-effect/package.json +++ b/packages/client-effect/package.json @@ -42,6 +42,7 @@ ], "scripts": { "build": "tsdown src/index.ts --format cjs,esm --dts --clean", + "build:docs": "typedoc", "dev": "tsdown src/index.ts --format cjs,esm --dts --watch", "test": "vitest run --project unit", "test:watch": "vitest --project unit", @@ -57,6 +58,8 @@ "@vitest/coverage-v8": "catalog:", "effect": "catalog:", "tsdown": "catalog:", + "typedoc": "catalog:", + "typedoc-plugin-markdown": "catalog:", "typescript": "catalog:", "vitest": "catalog:" }, diff --git a/packages/client-effect/typedoc.json b/packages/client-effect/typedoc.json new file mode 100644 index 00000000..1ce964eb --- /dev/null +++ b/packages/client-effect/typedoc.json @@ -0,0 +1,5 @@ +{ + "extends": "@temporal-contract/typedoc/base.json", + "entryPoints": ["src/index.ts"], + "out": "docs" +} diff --git a/packages/contract-effect/package.json b/packages/contract-effect/package.json index 7b30e1d7..abb5225a 100644 --- a/packages/contract-effect/package.json +++ b/packages/contract-effect/package.json @@ -42,6 +42,7 @@ ], "scripts": { "build": "tsdown src/index.ts --format cjs,esm --dts --clean", + "build:docs": "typedoc", "dev": "tsdown src/index.ts --format cjs,esm --dts --watch", "test": "vitest run --project unit", "test:watch": "vitest --project unit", @@ -53,6 +54,8 @@ "@vitest/coverage-v8": "catalog:", "effect": "catalog:", "tsdown": "catalog:", + "typedoc": "catalog:", + "typedoc-plugin-markdown": "catalog:", "typescript": "catalog:", "vitest": "catalog:" }, diff --git a/packages/contract-effect/typedoc.json b/packages/contract-effect/typedoc.json new file mode 100644 index 00000000..1ce964eb --- /dev/null +++ b/packages/contract-effect/typedoc.json @@ -0,0 +1,5 @@ +{ + "extends": "@temporal-contract/typedoc/base.json", + "entryPoints": ["src/index.ts"], + "out": "docs" +} diff --git a/packages/worker-effect/package.json b/packages/worker-effect/package.json index d3e885d5..d09761a4 100644 --- a/packages/worker-effect/package.json +++ b/packages/worker-effect/package.json @@ -43,6 +43,7 @@ ], "scripts": { "build": "tsdown src/index.ts --format cjs,esm --dts --clean", + "build:docs": "typedoc", "dev": "tsdown src/index.ts --format cjs,esm --dts --watch", "test": "vitest run --project unit", "test:watch": "vitest --project unit", @@ -58,6 +59,8 @@ "@vitest/coverage-v8": "catalog:", "effect": "catalog:", "tsdown": "catalog:", + "typedoc": "catalog:", + "typedoc-plugin-markdown": "catalog:", "typescript": "catalog:", "vitest": "catalog:" }, diff --git a/packages/worker-effect/typedoc.json b/packages/worker-effect/typedoc.json new file mode 100644 index 00000000..1ce964eb --- /dev/null +++ b/packages/worker-effect/typedoc.json @@ -0,0 +1,5 @@ +{ + "extends": "@temporal-contract/typedoc/base.json", + "entryPoints": ["src/index.ts"], + "out": "docs" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78bc77df..7a458156 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,18 +155,27 @@ importers: '@temporal-contract/client': specifier: workspace:* version: link:../packages/client + '@temporal-contract/client-effect': + specifier: workspace:* + version: link:../packages/client-effect '@temporal-contract/client-nestjs': specifier: workspace:* version: link:../packages/client-nestjs '@temporal-contract/contract': specifier: workspace:* version: link:../packages/contract + '@temporal-contract/contract-effect': + specifier: workspace:* + version: link:../packages/contract-effect '@temporal-contract/testing': specifier: workspace:* version: link:../packages/testing '@temporal-contract/worker': specifier: workspace:* version: link:../packages/worker + '@temporal-contract/worker-effect': + specifier: workspace:* + version: link:../packages/worker-effect '@temporal-contract/worker-nestjs': specifier: workspace:* version: link:../packages/worker-nestjs @@ -486,6 +495,12 @@ importers: tsdown: specifier: 'catalog:' version: 0.20.3(oxc-resolver@11.17.1)(typescript@5.9.3) + typedoc: + specifier: 'catalog:' + version: 0.28.17(typescript@5.9.3) + typedoc-plugin-markdown: + specifier: 'catalog:' + version: 4.10.0(typedoc@0.28.17(typescript@5.9.3)) typescript: specifier: 'catalog:' version: 5.9.3 @@ -605,6 +620,12 @@ importers: tsdown: specifier: 'catalog:' version: 0.20.3(oxc-resolver@11.17.1)(typescript@5.9.3) + typedoc: + specifier: 'catalog:' + version: 0.28.17(typescript@5.9.3) + typedoc-plugin-markdown: + specifier: 'catalog:' + version: 4.10.0(typedoc@0.28.17(typescript@5.9.3)) typescript: specifier: 'catalog:' version: 5.9.3 @@ -734,6 +755,12 @@ importers: tsdown: specifier: 'catalog:' version: 0.20.3(oxc-resolver@11.17.1)(typescript@5.9.3) + typedoc: + specifier: 'catalog:' + version: 0.28.17(typescript@5.9.3) + typedoc-plugin-markdown: + specifier: 'catalog:' + version: 4.10.0(typedoc@0.28.17(typescript@5.9.3)) typescript: specifier: 'catalog:' version: 5.9.3 From 0698a2e86256a9f5411338e426047f40e1a293d3 Mon Sep 17 00:00:00 2001 From: Ben Weis Date: Tue, 24 Feb 2026 15:01:45 -0500 Subject: [PATCH 3/3] test: added integration testing for effect api --- .../package.json | 30 ++ .../src/application/activities.ts | 90 +++++ .../src/application/workflows.ts | 142 ++++++++ .../src/contract.ts | 109 ++++++ .../src/dependencies.ts | 23 ++ .../src/domain/entities/order.schema.ts | 12 + .../src/domain/ports/inventory.port.ts | 6 + .../src/domain/ports/notification.port.ts | 3 + .../src/domain/ports/payment.port.ts | 6 + .../src/domain/ports/shipping.port.ts | 5 + .../usecases/create-shipment.usecase.ts | 12 + .../usecases/process-payment.usecase.ts | 12 + .../domain/usecases/refund-payment.usecase.ts | 10 + .../usecases/release-inventory.usecase.ts | 10 + .../usecases/reserve-inventory.usecase.ts | 11 + .../usecases/send-notification.usecase.ts | 12 + .../adapters/inventory.adapter.ts | 17 + .../adapters/notification.adapter.ts | 8 + .../adapters/payment.adapter.ts | 29 ++ .../adapters/shipping.adapter.ts | 13 + .../src/integration.spec.ts | 337 ++++++++++++++++++ .../src/logger.ts | 13 + .../tsconfig.json | 9 + .../vitest.config.ts | 14 + pnpm-lock.yaml | 52 +++ 25 files changed, 985 insertions(+) create mode 100644 examples/order-processing-worker-effect/package.json create mode 100644 examples/order-processing-worker-effect/src/application/activities.ts create mode 100644 examples/order-processing-worker-effect/src/application/workflows.ts create mode 100644 examples/order-processing-worker-effect/src/contract.ts create mode 100644 examples/order-processing-worker-effect/src/dependencies.ts create mode 100644 examples/order-processing-worker-effect/src/domain/entities/order.schema.ts create mode 100644 examples/order-processing-worker-effect/src/domain/ports/inventory.port.ts create mode 100644 examples/order-processing-worker-effect/src/domain/ports/notification.port.ts create mode 100644 examples/order-processing-worker-effect/src/domain/ports/payment.port.ts create mode 100644 examples/order-processing-worker-effect/src/domain/ports/shipping.port.ts create mode 100644 examples/order-processing-worker-effect/src/domain/usecases/create-shipment.usecase.ts create mode 100644 examples/order-processing-worker-effect/src/domain/usecases/process-payment.usecase.ts create mode 100644 examples/order-processing-worker-effect/src/domain/usecases/refund-payment.usecase.ts create mode 100644 examples/order-processing-worker-effect/src/domain/usecases/release-inventory.usecase.ts create mode 100644 examples/order-processing-worker-effect/src/domain/usecases/reserve-inventory.usecase.ts create mode 100644 examples/order-processing-worker-effect/src/domain/usecases/send-notification.usecase.ts create mode 100644 examples/order-processing-worker-effect/src/infrastructure/adapters/inventory.adapter.ts create mode 100644 examples/order-processing-worker-effect/src/infrastructure/adapters/notification.adapter.ts create mode 100644 examples/order-processing-worker-effect/src/infrastructure/adapters/payment.adapter.ts create mode 100644 examples/order-processing-worker-effect/src/infrastructure/adapters/shipping.adapter.ts create mode 100644 examples/order-processing-worker-effect/src/integration.spec.ts create mode 100644 examples/order-processing-worker-effect/src/logger.ts create mode 100644 examples/order-processing-worker-effect/tsconfig.json create mode 100644 examples/order-processing-worker-effect/vitest.config.ts diff --git a/examples/order-processing-worker-effect/package.json b/examples/order-processing-worker-effect/package.json new file mode 100644 index 00000000..fd0dedc5 --- /dev/null +++ b/examples/order-processing-worker-effect/package.json @@ -0,0 +1,30 @@ +{ + "name": "@temporal-contract/sample-order-processing-worker-effect", + "private": true, + "description": "Effect-native order processing worker using temporal-contract", + "type": "module", + "scripts": { + "test:integration": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@temporal-contract/contract-effect": "workspace:*", + "@temporal-contract/worker-effect": "workspace:*", + "@temporalio/worker": "catalog:", + "@temporalio/workflow": "catalog:", + "effect": "catalog:", + "pino": "catalog:", + "pino-pretty": "catalog:" + }, + "devDependencies": { + "@temporal-contract/client-effect": "workspace:*", + "@temporal-contract/testing": "workspace:*", + "@temporal-contract/tsconfig": "workspace:*", + "@temporalio/client": "catalog:", + "@types/node": "catalog:", + "@vitest/coverage-v8": "catalog:", + "tsx": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/examples/order-processing-worker-effect/src/application/activities.ts b/examples/order-processing-worker-effect/src/application/activities.ts new file mode 100644 index 00000000..89456abd --- /dev/null +++ b/examples/order-processing-worker-effect/src/application/activities.ts @@ -0,0 +1,90 @@ +import { Effect } from "effect"; +import { declareActivitiesHandler, ActivityError } from "@temporal-contract/worker-effect"; +import { orderEffectContract } from "../contract.js"; +import { logger } from "../logger.js"; +import { + processPaymentUseCase, + reserveInventoryUseCase, + releaseInventoryUseCase, + createShipmentUseCase, + sendNotificationUseCase, + refundPaymentUseCase, +} from "../dependencies.js"; + +export const activities = declareActivitiesHandler({ + contract: orderEffectContract, + activities: { + log: ({ level, message }) => + Effect.sync(() => { + logger.info({ level }, message); + }), + + sendNotification: ({ customerId, subject, message }) => + Effect.tryPromise({ + try: () => sendNotificationUseCase.execute(customerId, subject, message), + catch: (error) => + new ActivityError({ + code: "NOTIFICATION_FAILED", + message: error instanceof Error ? error.message : "Failed to send notification", + cause: error, + }), + }), + + processOrder: { + processPayment: ({ customerId, amount }) => + Effect.tryPromise({ + try: () => processPaymentUseCase.execute(customerId, amount), + catch: (error) => + new ActivityError({ + code: "PAYMENT_FAILED", + message: error instanceof Error ? error.message : "Payment processing failed", + cause: error, + }), + }), + + reserveInventory: (items) => + Effect.tryPromise({ + try: () => reserveInventoryUseCase.execute([...items]), + catch: (error) => + new ActivityError({ + code: "INVENTORY_RESERVATION_FAILED", + message: error instanceof Error ? error.message : "Inventory reservation failed", + cause: error, + }), + }), + + releaseInventory: (reservationId) => + Effect.tryPromise({ + try: () => releaseInventoryUseCase.execute(reservationId), + catch: (error) => + new ActivityError({ + code: "INVENTORY_RELEASE_FAILED", + message: error instanceof Error ? error.message : "Inventory release failed", + cause: error, + }), + }), + + createShipment: ({ orderId, customerId }) => + Effect.tryPromise({ + try: () => createShipmentUseCase.execute(orderId, customerId), + catch: (error) => + new ActivityError({ + code: "SHIPMENT_CREATION_FAILED", + message: error instanceof Error ? error.message : "Shipment creation failed", + cause: error, + }), + }), + + refundPayment: (transactionId) => + Effect.tryPromise({ + try: () => refundPaymentUseCase.execute(transactionId), + catch: (error) => + new ActivityError({ + code: "REFUND_FAILED", + message: error instanceof Error ? error.message : "Refund failed", + cause: error, + }), + }), + }, + }, +}); diff --git a/examples/order-processing-worker-effect/src/application/workflows.ts b/examples/order-processing-worker-effect/src/application/workflows.ts new file mode 100644 index 00000000..d3f52bc3 --- /dev/null +++ b/examples/order-processing-worker-effect/src/application/workflows.ts @@ -0,0 +1,142 @@ +import { proxyActivities } from "@temporalio/workflow"; + +type LogLevel = "fatal" | "error" | "warn" | "info" | "debug" | "trace"; + +type OrderItem = { productId: string; quantity: number; price: number }; + +type Order = { + orderId: string; + customerId: string; + items: OrderItem[]; + totalAmount: number; +}; + +type PaymentResult = + | { status: "success"; transactionId: string; paidAmount: number } + | { status: "failed" }; + +type InventoryReservation = { reserved: boolean; reservationId?: string }; + +type ShippingResult = { trackingNumber: string; estimatedDelivery: string }; + +type OrderResult = { + orderId: string; + status: "completed" | "failed"; + transactionId?: string; + trackingNumber?: string; + failureReason?: string; + errorCode?: string; +}; + +type Activities = { + log(args: { level: LogLevel; message: string }): Promise; + sendNotification(args: { customerId: string; subject: string; message: string }): Promise; + processPayment(args: { customerId: string; amount: number }): Promise; + reserveInventory(items: OrderItem[]): Promise; + releaseInventory(reservationId: string): Promise; + createShipment(args: { orderId: string; customerId: string }): Promise; + refundPayment(transactionId: string): Promise; +}; + +const activities = proxyActivities({ + startToCloseTimeout: "1 minute", +}); + +export async function processOrder(order: Order): Promise { + let paymentTransactionId: string | undefined; + + await activities.log({ + level: "info", + message: `Starting order processing for ${order.orderId}`, + }); + + await activities.log({ + level: "info", + message: `Processing payment of $${order.totalAmount}`, + }); + + const paymentResult = await activities.processPayment({ + customerId: order.customerId, + amount: order.totalAmount, + }); + + if (paymentResult.status === "failed") { + await activities.log({ level: "error", message: "Payment failed: Card declined" }); + + await activities.sendNotification({ + customerId: order.customerId, + subject: "Order Failed", + message: `Your order ${order.orderId} could not be processed. Payment was declined.`, + }); + + return { + orderId: order.orderId, + status: "failed" as const, + failureReason: "Payment was declined", + errorCode: "PAYMENT_FAILED", + }; + } + + paymentTransactionId = paymentResult.transactionId; + await activities.log({ level: "info", message: `Payment successful: ${paymentTransactionId}` }); + + await activities.log({ level: "info", message: "Reserving inventory" }); + const inventoryResult = await activities.reserveInventory(order.items); + + if (!inventoryResult.reserved) { + await activities.log({ level: "error", message: "Inventory reservation failed" }); + await activities.log({ level: "info", message: "Rolling back: refunding payment" }); + await activities.refundPayment(paymentTransactionId); + + await activities.sendNotification({ + customerId: order.customerId, + subject: "Order Failed", + message: `Your order ${order.orderId} could not be processed. Items out of stock. Payment refunded.`, + }); + + return { + orderId: order.orderId, + status: "failed" as const, + failureReason: "One or more items are out of stock", + errorCode: "OUT_OF_STOCK", + }; + } + + await activities.log({ + level: "info", + message: `Inventory reserved: ${inventoryResult.reservationId}`, + }); + + await activities.log({ level: "info", message: "Creating shipment" }); + const shippingResult = await activities.createShipment({ + orderId: order.orderId, + customerId: order.customerId, + }); + + await activities.log({ + level: "info", + message: `Shipment created: ${shippingResult.trackingNumber}`, + }); + + try { + await activities.sendNotification({ + customerId: order.customerId, + subject: "Order Confirmed", + message: `Your order ${order.orderId} is confirmed. Tracking: ${shippingResult.trackingNumber}`, + }); + } catch (error) { + await activities.log({ + level: "warn", + message: `Failed to send confirmation: ${error}`, + }); + } + + await activities.log({ level: "info", message: `Order ${order.orderId} processed successfully` }); + + return { + orderId: order.orderId, + status: "completed" as const, + transactionId: paymentTransactionId, + trackingNumber: shippingResult.trackingNumber, + }; +} diff --git a/examples/order-processing-worker-effect/src/contract.ts b/examples/order-processing-worker-effect/src/contract.ts new file mode 100644 index 00000000..763f7066 --- /dev/null +++ b/examples/order-processing-worker-effect/src/contract.ts @@ -0,0 +1,109 @@ +import { Schema } from "effect"; +import { defineEffectContract } from "@temporal-contract/contract-effect"; + +export const OrderItemSchema = Schema.Struct({ + productId: Schema.String, + quantity: Schema.Number.pipe(Schema.greaterThan(0)), + price: Schema.Number.pipe(Schema.greaterThan(0)), +}); + +export const OrderSchema = Schema.Struct({ + orderId: Schema.String, + customerId: Schema.String, + items: Schema.Array(OrderItemSchema), + totalAmount: Schema.Number.pipe(Schema.greaterThan(0)), +}); + +export const PaymentResultSchema = Schema.Union( + Schema.Struct({ + status: Schema.Literal("success"), + transactionId: Schema.String, + paidAmount: Schema.Number, + }), + Schema.Struct({ + status: Schema.Literal("failed"), + }), +); + +export const InventoryReservationSchema = Schema.Struct({ + reserved: Schema.Boolean, + reservationId: Schema.optional(Schema.String), +}); + +export const ShippingResultSchema = Schema.Struct({ + trackingNumber: Schema.String, + estimatedDelivery: Schema.String, +}); + +export const OrderResultSchema = Schema.Struct({ + orderId: Schema.String, + status: Schema.Union(Schema.Literal("completed"), Schema.Literal("failed")), + transactionId: Schema.optional(Schema.String), + trackingNumber: Schema.optional(Schema.String), + failureReason: Schema.optional(Schema.String), + errorCode: Schema.optional(Schema.String), +}); + +export const orderEffectContract = defineEffectContract({ + taskQueue: "order-processing-effect", + + activities: { + log: { + input: Schema.Struct({ + level: Schema.Union( + Schema.Literal("fatal"), + Schema.Literal("error"), + Schema.Literal("warn"), + Schema.Literal("info"), + Schema.Literal("debug"), + Schema.Literal("trace"), + ), + message: Schema.String, + }), + output: Schema.Void, + }, + + sendNotification: { + input: Schema.Struct({ + customerId: Schema.String, + subject: Schema.String, + message: Schema.String, + }), + output: Schema.Void, + }, + }, + + workflows: { + processOrder: { + input: OrderSchema, + output: OrderResultSchema, + + activities: { + processPayment: { + input: Schema.Struct({ customerId: Schema.String, amount: Schema.Number }), + output: PaymentResultSchema, + }, + + reserveInventory: { + input: Schema.Array(OrderItemSchema), + output: InventoryReservationSchema, + }, + + releaseInventory: { + input: Schema.String, + output: Schema.Void, + }, + + createShipment: { + input: Schema.Struct({ orderId: Schema.String, customerId: Schema.String }), + output: ShippingResultSchema, + }, + + refundPayment: { + input: Schema.String, + output: Schema.Void, + }, + }, + }, + }, +}); diff --git a/examples/order-processing-worker-effect/src/dependencies.ts b/examples/order-processing-worker-effect/src/dependencies.ts new file mode 100644 index 00000000..8ac1735c --- /dev/null +++ b/examples/order-processing-worker-effect/src/dependencies.ts @@ -0,0 +1,23 @@ +import { ProcessPaymentUseCase } from "./domain/usecases/process-payment.usecase.js"; +import { ReserveInventoryUseCase } from "./domain/usecases/reserve-inventory.usecase.js"; +import { ReleaseInventoryUseCase } from "./domain/usecases/release-inventory.usecase.js"; +import { CreateShipmentUseCase } from "./domain/usecases/create-shipment.usecase.js"; +import { SendNotificationUseCase } from "./domain/usecases/send-notification.usecase.js"; +import { RefundPaymentUseCase } from "./domain/usecases/refund-payment.usecase.js"; + +import { MockPaymentAdapter } from "./infrastructure/adapters/payment.adapter.js"; +import { MockInventoryAdapter } from "./infrastructure/adapters/inventory.adapter.js"; +import { MockShippingAdapter } from "./infrastructure/adapters/shipping.adapter.js"; +import { ConsoleNotificationAdapter } from "./infrastructure/adapters/notification.adapter.js"; + +export const paymentAdapter = new MockPaymentAdapter(); +const inventoryAdapter = new MockInventoryAdapter(); +const shippingAdapter = new MockShippingAdapter(); +const notificationAdapter = new ConsoleNotificationAdapter(); + +export const processPaymentUseCase = new ProcessPaymentUseCase(paymentAdapter); +export const reserveInventoryUseCase = new ReserveInventoryUseCase(inventoryAdapter); +export const releaseInventoryUseCase = new ReleaseInventoryUseCase(inventoryAdapter); +export const createShipmentUseCase = new CreateShipmentUseCase(shippingAdapter); +export const sendNotificationUseCase = new SendNotificationUseCase(notificationAdapter); +export const refundPaymentUseCase = new RefundPaymentUseCase(paymentAdapter); diff --git a/examples/order-processing-worker-effect/src/domain/entities/order.schema.ts b/examples/order-processing-worker-effect/src/domain/entities/order.schema.ts new file mode 100644 index 00000000..41db4e76 --- /dev/null +++ b/examples/order-processing-worker-effect/src/domain/entities/order.schema.ts @@ -0,0 +1,12 @@ +import type { Schema } from "effect"; +import type { + OrderItemSchema, + PaymentResultSchema, + InventoryReservationSchema, + ShippingResultSchema, +} from "../../contract.js"; + +export type OrderItem = Schema.Schema.Type; +export type PaymentResult = Schema.Schema.Type; +export type InventoryReservation = Schema.Schema.Type; +export type ShippingResult = Schema.Schema.Type; diff --git a/examples/order-processing-worker-effect/src/domain/ports/inventory.port.ts b/examples/order-processing-worker-effect/src/domain/ports/inventory.port.ts new file mode 100644 index 00000000..40716953 --- /dev/null +++ b/examples/order-processing-worker-effect/src/domain/ports/inventory.port.ts @@ -0,0 +1,6 @@ +import type { InventoryReservation, OrderItem } from "../entities/order.schema.js"; + +export type InventoryPort = { + reserveInventory(items: OrderItem[]): Promise; + releaseInventory(reservationId: string): Promise; +}; diff --git a/examples/order-processing-worker-effect/src/domain/ports/notification.port.ts b/examples/order-processing-worker-effect/src/domain/ports/notification.port.ts new file mode 100644 index 00000000..27061d44 --- /dev/null +++ b/examples/order-processing-worker-effect/src/domain/ports/notification.port.ts @@ -0,0 +1,3 @@ +export type NotificationPort = { + sendNotification(customerId: string, subject: string, message: string): Promise; +}; diff --git a/examples/order-processing-worker-effect/src/domain/ports/payment.port.ts b/examples/order-processing-worker-effect/src/domain/ports/payment.port.ts new file mode 100644 index 00000000..266fbef4 --- /dev/null +++ b/examples/order-processing-worker-effect/src/domain/ports/payment.port.ts @@ -0,0 +1,6 @@ +import type { PaymentResult } from "../entities/order.schema.js"; + +export type PaymentPort = { + processPayment(customerId: string, amount: number): Promise; + refundPayment(transactionId: string): Promise; +}; diff --git a/examples/order-processing-worker-effect/src/domain/ports/shipping.port.ts b/examples/order-processing-worker-effect/src/domain/ports/shipping.port.ts new file mode 100644 index 00000000..5e39e2c7 --- /dev/null +++ b/examples/order-processing-worker-effect/src/domain/ports/shipping.port.ts @@ -0,0 +1,5 @@ +import type { ShippingResult } from "../entities/order.schema.js"; + +export type ShippingPort = { + createShipment(orderId: string, customerId: string): Promise; +}; diff --git a/examples/order-processing-worker-effect/src/domain/usecases/create-shipment.usecase.ts b/examples/order-processing-worker-effect/src/domain/usecases/create-shipment.usecase.ts new file mode 100644 index 00000000..951a1ed3 --- /dev/null +++ b/examples/order-processing-worker-effect/src/domain/usecases/create-shipment.usecase.ts @@ -0,0 +1,12 @@ +import type { ShippingPort } from "../ports/shipping.port.js"; +import type { ShippingResult } from "../entities/order.schema.js"; + +export class CreateShipmentUseCase { + constructor(private readonly shippingPort: ShippingPort) {} + + async execute(orderId: string, customerId: string): Promise { + if (!orderId.trim()) throw new Error("Order ID is required"); + if (!customerId.trim()) throw new Error("Customer ID is required"); + return this.shippingPort.createShipment(orderId, customerId); + } +} diff --git a/examples/order-processing-worker-effect/src/domain/usecases/process-payment.usecase.ts b/examples/order-processing-worker-effect/src/domain/usecases/process-payment.usecase.ts new file mode 100644 index 00000000..db4b7120 --- /dev/null +++ b/examples/order-processing-worker-effect/src/domain/usecases/process-payment.usecase.ts @@ -0,0 +1,12 @@ +import type { PaymentPort } from "../ports/payment.port.js"; +import type { PaymentResult } from "../entities/order.schema.js"; + +export class ProcessPaymentUseCase { + constructor(private readonly paymentPort: PaymentPort) {} + + async execute(customerId: string, amount: number): Promise { + if (amount <= 0) throw new Error("Payment amount must be positive"); + if (!customerId.trim()) throw new Error("Customer ID is required"); + return this.paymentPort.processPayment(customerId, amount); + } +} diff --git a/examples/order-processing-worker-effect/src/domain/usecases/refund-payment.usecase.ts b/examples/order-processing-worker-effect/src/domain/usecases/refund-payment.usecase.ts new file mode 100644 index 00000000..e2270583 --- /dev/null +++ b/examples/order-processing-worker-effect/src/domain/usecases/refund-payment.usecase.ts @@ -0,0 +1,10 @@ +import type { PaymentPort } from "../ports/payment.port.js"; + +export class RefundPaymentUseCase { + constructor(private readonly paymentPort: PaymentPort) {} + + async execute(transactionId: string): Promise { + if (!transactionId.trim()) throw new Error("Transaction ID is required"); + return this.paymentPort.refundPayment(transactionId); + } +} diff --git a/examples/order-processing-worker-effect/src/domain/usecases/release-inventory.usecase.ts b/examples/order-processing-worker-effect/src/domain/usecases/release-inventory.usecase.ts new file mode 100644 index 00000000..1c44881b --- /dev/null +++ b/examples/order-processing-worker-effect/src/domain/usecases/release-inventory.usecase.ts @@ -0,0 +1,10 @@ +import type { InventoryPort } from "../ports/inventory.port.js"; + +export class ReleaseInventoryUseCase { + constructor(private readonly inventoryPort: InventoryPort) {} + + async execute(reservationId: string): Promise { + if (!reservationId.trim()) throw new Error("Reservation ID is required"); + return this.inventoryPort.releaseInventory(reservationId); + } +} diff --git a/examples/order-processing-worker-effect/src/domain/usecases/reserve-inventory.usecase.ts b/examples/order-processing-worker-effect/src/domain/usecases/reserve-inventory.usecase.ts new file mode 100644 index 00000000..1f11153d --- /dev/null +++ b/examples/order-processing-worker-effect/src/domain/usecases/reserve-inventory.usecase.ts @@ -0,0 +1,11 @@ +import type { InventoryPort } from "../ports/inventory.port.js"; +import type { InventoryReservation, OrderItem } from "../entities/order.schema.js"; + +export class ReserveInventoryUseCase { + constructor(private readonly inventoryPort: InventoryPort) {} + + async execute(items: OrderItem[]): Promise { + if (!items.length) throw new Error("At least one item is required"); + return this.inventoryPort.reserveInventory(items); + } +} diff --git a/examples/order-processing-worker-effect/src/domain/usecases/send-notification.usecase.ts b/examples/order-processing-worker-effect/src/domain/usecases/send-notification.usecase.ts new file mode 100644 index 00000000..c9c98513 --- /dev/null +++ b/examples/order-processing-worker-effect/src/domain/usecases/send-notification.usecase.ts @@ -0,0 +1,12 @@ +import type { NotificationPort } from "../ports/notification.port.js"; + +export class SendNotificationUseCase { + constructor(private readonly notificationPort: NotificationPort) {} + + async execute(customerId: string, subject: string, message: string): Promise { + if (!customerId.trim()) throw new Error("Customer ID is required"); + if (!subject.trim()) throw new Error("Subject is required"); + if (!message.trim()) throw new Error("Message is required"); + return this.notificationPort.sendNotification(customerId, subject, message); + } +} diff --git a/examples/order-processing-worker-effect/src/infrastructure/adapters/inventory.adapter.ts b/examples/order-processing-worker-effect/src/infrastructure/adapters/inventory.adapter.ts new file mode 100644 index 00000000..318f31dc --- /dev/null +++ b/examples/order-processing-worker-effect/src/infrastructure/adapters/inventory.adapter.ts @@ -0,0 +1,17 @@ +import type { InventoryPort } from "../../domain/ports/inventory.port.js"; +import type { InventoryReservation, OrderItem } from "../../domain/entities/order.schema.js"; +import { logger } from "../../logger.js"; + +export class MockInventoryAdapter implements InventoryPort { + async reserveInventory(items: OrderItem[]): Promise { + logger.info({ itemCount: items.length }, `📦 Reserving inventory`); + const reservationId = `RES${Date.now()}`; + logger.info({ reservationId }, `✅ Inventory reserved`); + return { reserved: true, reservationId }; + } + + async releaseInventory(reservationId: string): Promise { + logger.info({ reservationId }, `🔓 Releasing inventory`); + logger.info(`✅ Inventory released`); + } +} diff --git a/examples/order-processing-worker-effect/src/infrastructure/adapters/notification.adapter.ts b/examples/order-processing-worker-effect/src/infrastructure/adapters/notification.adapter.ts new file mode 100644 index 00000000..9f6b887a --- /dev/null +++ b/examples/order-processing-worker-effect/src/infrastructure/adapters/notification.adapter.ts @@ -0,0 +1,8 @@ +import type { NotificationPort } from "../../domain/ports/notification.port.js"; +import { logger } from "../../logger.js"; + +export class ConsoleNotificationAdapter implements NotificationPort { + async sendNotification(customerId: string, subject: string, _message: string): Promise { + logger.info({ customerId, subject }, `📧 Notification sent`); + } +} diff --git a/examples/order-processing-worker-effect/src/infrastructure/adapters/payment.adapter.ts b/examples/order-processing-worker-effect/src/infrastructure/adapters/payment.adapter.ts new file mode 100644 index 00000000..7ee7616d --- /dev/null +++ b/examples/order-processing-worker-effect/src/infrastructure/adapters/payment.adapter.ts @@ -0,0 +1,29 @@ +import type { PaymentPort } from "../../domain/ports/payment.port.js"; +import type { PaymentResult } from "../../domain/entities/order.schema.js"; +import { logger } from "../../logger.js"; + +export class MockPaymentAdapter implements PaymentPort { + async processPayment(customerId: string, amount: number): Promise { + logger.info({ customerId, amount }, `💳 Processing payment of $${amount}`); + + const success = Math.random() > 0.1; + + if (success) { + const result: PaymentResult = { + status: "success" as const, + transactionId: `TXN${Date.now()}`, + paidAmount: amount, + }; + logger.info({ transactionId: result.transactionId }, `✅ Payment processed`); + return result; + } + + logger.error(`❌ Payment failed`); + return { status: "failed" as const }; + } + + async refundPayment(transactionId: string): Promise { + logger.info({ transactionId }, `💰 Processing refund`); + logger.info(`✅ Refund successful`); + } +} diff --git a/examples/order-processing-worker-effect/src/infrastructure/adapters/shipping.adapter.ts b/examples/order-processing-worker-effect/src/infrastructure/adapters/shipping.adapter.ts new file mode 100644 index 00000000..6d9e3f2e --- /dev/null +++ b/examples/order-processing-worker-effect/src/infrastructure/adapters/shipping.adapter.ts @@ -0,0 +1,13 @@ +import type { ShippingPort } from "../../domain/ports/shipping.port.js"; +import type { ShippingResult } from "../../domain/entities/order.schema.js"; +import { logger } from "../../logger.js"; + +export class MockShippingAdapter implements ShippingPort { + async createShipment(orderId: string, _customerId: string): Promise { + logger.info({ orderId }, `📮 Creating shipment`); + const trackingNumber = `TRACK${Date.now()}${Math.random().toString(36).substring(2, 8).toUpperCase()}`; + const estimatedDelivery = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(); + logger.info({ trackingNumber }, `✅ Shipment created`); + return { trackingNumber, estimatedDelivery }; + } +} diff --git a/examples/order-processing-worker-effect/src/integration.spec.ts b/examples/order-processing-worker-effect/src/integration.spec.ts new file mode 100644 index 00000000..ef0bb35f --- /dev/null +++ b/examples/order-processing-worker-effect/src/integration.spec.ts @@ -0,0 +1,337 @@ +import { describe, expect, vi } from "vitest"; +import { Worker } from "@temporalio/worker"; +import { Client } from "@temporalio/client"; +import { Effect, Exit, Cause } from "effect"; +import { EffectTypedClient } from "@temporal-contract/client-effect"; +import type { WorkflowValidationError } from "@temporal-contract/client-effect"; +import { it as baseIt } from "@temporal-contract/testing/extension"; +import { extname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { orderEffectContract, type OrderSchema } from "./contract.js"; +import { activities } from "./application/activities.js"; +import { paymentAdapter } from "./dependencies.js"; +import type { Schema } from "effect"; + +type Order = Schema.Schema.Type; + +const it = baseIt.extend<{ + worker: Worker; + client: EffectTypedClient; +}>({ + worker: [ + async ({ workerConnection }, use) => { + const worker = await Worker.create({ + connection: workerConnection, + namespace: "default", + taskQueue: orderEffectContract.taskQueue, + workflowsPath: workflowPath("application/workflows"), + activities, + }); + + worker.run().catch((err) => { + console.error("Worker failed:", err); + }); + + await vi.waitFor(() => worker.getState() === "RUNNING", { interval: 100, timeout: 5000 }); + + await use(worker); + + await worker.shutdown(); + + await vi.waitFor(() => worker.getState() === "STOPPED", { interval: 100, timeout: 5000 }); + }, + { auto: true }, + ], + + client: async ({ clientConnection }, use) => { + const rawClient = new Client({ connection: clientConnection, namespace: "default" }); + const client = EffectTypedClient.create(orderEffectContract, rawClient); + await use(client); + }, +}); + +describe("Order Processing Workflow - Integration Tests (Effect)", () => { + it("should process an order successfully", async ({ client }) => { + // GIVEN + vi.spyOn(paymentAdapter, "processPayment").mockResolvedValue({ + transactionId: "TXN-MOCK-123", + status: "success", + paidAmount: 0, + }); + + const order: Order = { + orderId: `ORD-TEST-${Date.now()}`, + customerId: "CUST-TEST-001", + items: [ + { productId: "PROD-001", quantity: 2, price: 29.99 }, + { productId: "PROD-002", quantity: 1, price: 49.99 }, + ], + totalAmount: 109.97, + }; + + // WHEN — Effect.runPromise returns the value directly on success + const result = await Effect.runPromise( + client.executeWorkflow("processOrder", { + workflowId: order.orderId, + args: order, + }), + ); + + // THEN + expect(result).toEqual( + expect.objectContaining({ + orderId: order.orderId, + status: "completed", + transactionId: expect.any(String), + trackingNumber: expect.any(String), + }), + ); + }); + + it("should handle startWorkflow and typed handle result()", async ({ client }) => { + // GIVEN + vi.spyOn(paymentAdapter, "processPayment").mockResolvedValue({ + transactionId: "TXN-MOCK-456", + status: "success", + paidAmount: 0, + }); + + const order: Order = { + orderId: `ORD-TEST-${Date.now()}`, + customerId: "CUST-TEST-002", + items: [{ productId: "PROD-003", quantity: 1, price: 99.99 }], + totalAmount: 99.99, + }; + + // WHEN + const handle = await Effect.runPromise( + client.startWorkflow("processOrder", { + workflowId: order.orderId, + args: order, + }), + ); + + // THEN — handle.workflowId is typed and correct + expect(handle.workflowId).toBe(order.orderId); + + const result = await Effect.runPromise(handle.result()); + + expect(result).toEqual( + expect.objectContaining({ + orderId: order.orderId, + status: "completed", + transactionId: expect.any(String), + trackingNumber: expect.any(String), + }), + ); + }); + + it("should retrieve a handle for an existing workflow via getHandle", async ({ client }) => { + // GIVEN + vi.spyOn(paymentAdapter, "processPayment").mockResolvedValue({ + transactionId: "TXN-MOCK-789", + status: "success", + paidAmount: 0, + }); + + const order: Order = { + orderId: `ORD-TEST-${Date.now()}`, + customerId: "CUST-TEST-003", + items: [{ productId: "PROD-004", quantity: 3, price: 19.99 }], + totalAmount: 59.97, + }; + + await Effect.runPromise( + client.startWorkflow("processOrder", { + workflowId: order.orderId, + args: order, + }), + ); + + // WHEN — get handle by workflow ID + const handle = await Effect.runPromise(client.getHandle("processOrder", order.orderId)); + + // THEN + expect(handle.workflowId).toBe(order.orderId); + + const result = await Effect.runPromise(handle.result()); + expect(result).toEqual( + expect.objectContaining({ + orderId: order.orderId, + status: "completed", + }), + ); + }); + + it("should describe a running workflow via Effect handle", async ({ client }) => { + // GIVEN + vi.spyOn(paymentAdapter, "processPayment").mockResolvedValue({ + transactionId: "TXN-MOCK-DESC", + status: "success", + paidAmount: 0, + }); + + const order: Order = { + orderId: `ORD-TEST-${Date.now()}`, + customerId: "CUST-TEST-004", + items: [{ productId: "PROD-005", quantity: 1, price: 149.99 }], + totalAmount: 149.99, + }; + + // WHEN + const handle = await Effect.runPromise( + client.startWorkflow("processOrder", { + workflowId: order.orderId, + args: order, + }), + ); + + const description = await Effect.runPromise(handle.describe()); + + // THEN + expect(description).toEqual( + expect.objectContaining({ + workflowId: order.orderId, + type: "processOrder", + }), + ); + + // Wait for completion + await Effect.runPromise(handle.result()); + }); + + it("should fail with WorkflowValidationError for invalid input schema", async ({ client }) => { + // GIVEN — quantity is a string instead of a number + const invalidOrder = { + orderId: `ORD-TEST-${Date.now()}`, + customerId: "CUST-TEST-005", + items: [{ productId: "PROD-006", quantity: "not-a-number", price: 29.99 }], + totalAmount: 29.99, + }; + + // WHEN — client validates input against Effect Schema before sending to Temporal + const exit = await Effect.runPromiseExit( + client.executeWorkflow("processOrder", { + workflowId: invalidOrder.orderId, + args: invalidOrder as unknown as Order, + }), + ); + + // THEN — typed error with direction and parseError + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.failureOption(exit.cause); + expect(error._tag).toBe("Some"); + if (error._tag === "Some") { + const e = error.value as WorkflowValidationError; + expect(e._tag).toBe("WorkflowValidationError"); + expect(e.workflowName).toBe("processOrder"); + expect(e.direction).toBe("input"); + } + } + }); + + it("should use Effect.catchTag for typed error recovery", async ({ client }) => { + // GIVEN — invalid input triggers WorkflowValidationError + const invalidOrder = { + orderId: `ORD-TEST-${Date.now()}`, + customerId: "CUST-TEST-006", + items: [{ productId: "PROD-007", quantity: -1, price: 29.99 }], + totalAmount: 29.99, + }; + + // WHEN — recover from validation error with Effect.catchTag + const result = await Effect.runPromise( + Effect.catchTag( + client.executeWorkflow("processOrder", { + workflowId: invalidOrder.orderId, + args: invalidOrder as unknown as Order, + }), + "WorkflowValidationError", + (_e) => + Effect.succeed({ + orderId: invalidOrder.orderId, + status: "failed" as const, + failureReason: "Validation failed", + errorCode: "INVALID_INPUT", + }), + ), + ); + + // THEN — the catchTag handler produced a fallback result + expect(result).toEqual( + expect.objectContaining({ + status: "failed", + errorCode: "INVALID_INPUT", + }), + ); + }); + + it("should handle payment failure and return failed status", async ({ client }) => { + // GIVEN + vi.spyOn(paymentAdapter, "processPayment").mockResolvedValue({ status: "failed" }); + + const order: Order = { + orderId: `ORD-TEST-${Date.now()}`, + customerId: "CUST-TEST-007", + items: [{ productId: "PROD-008", quantity: 1, price: 99.99 }], + totalAmount: 99.99, + }; + + // WHEN + const result = await Effect.runPromise( + client.executeWorkflow("processOrder", { + workflowId: order.orderId, + args: order, + }), + ); + + // THEN + expect(result).toEqual( + expect.objectContaining({ + orderId: order.orderId, + status: "failed", + errorCode: "PAYMENT_FAILED", + failureReason: "Payment was declined", + }), + ); + }); + + it("should use Effect.catchTags to exhaustively handle all error variants", async ({ + client, + }) => { + // GIVEN — invalid input + const invalidOrder = { + orderId: `ORD-TEST-${Date.now()}`, + customerId: "", + items: [], + totalAmount: 0, + }; + + // WHEN — handle all possible typed errors exhaustively + const exit = await Effect.runPromiseExit( + Effect.catchTags( + client.executeWorkflow("processOrder", { + workflowId: invalidOrder.orderId, + args: invalidOrder as unknown as Order, + }), + { + WorkflowValidationError: (e) => + Effect.succeed({ caught: "validation", direction: e.direction }), + WorkflowNotFoundError: (e) => + Effect.succeed({ caught: "not-found", workflow: e.workflowName }), + RuntimeClientError: (e) => Effect.succeed({ caught: "runtime", operation: e.operation }), + }, + ), + ); + + expect(Exit.isSuccess(exit)).toBe(true); + if (Exit.isSuccess(exit)) { + expect(exit.value).toEqual(expect.objectContaining({ caught: "validation" })); + } + }); +}); + +function workflowPath(filename: string): string { + return fileURLToPath(new URL(`./${filename}${extname(import.meta.url)}`, import.meta.url)); +} diff --git a/examples/order-processing-worker-effect/src/logger.ts b/examples/order-processing-worker-effect/src/logger.ts new file mode 100644 index 00000000..483b6d86 --- /dev/null +++ b/examples/order-processing-worker-effect/src/logger.ts @@ -0,0 +1,13 @@ +import pino from "pino"; + +export const logger = pino({ + level: process.env["LOG_LEVEL"] || "info", + transport: { + target: "pino-pretty", + options: { + colorize: true, + translateTime: "HH:MM:ss", + ignore: "pid,hostname", + }, + }, +}); diff --git a/examples/order-processing-worker-effect/tsconfig.json b/examples/order-processing-worker-effect/tsconfig.json new file mode 100644 index 00000000..d699628b --- /dev/null +++ b/examples/order-processing-worker-effect/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@temporal-contract/tsconfig/base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/examples/order-processing-worker-effect/vitest.config.ts b/examples/order-processing-worker-effect/vitest.config.ts new file mode 100644 index 00000000..68445077 --- /dev/null +++ b/examples/order-processing-worker-effect/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globalSetup: "@temporal-contract/testing/global-setup", + reporters: ["default"], + coverage: { + provider: "v8", + reporter: ["text", "json", "json-summary", "html"], + include: ["src/**"], + }, + testTimeout: 30_000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a458156..73b749fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -313,6 +313,58 @@ importers: specifier: 'catalog:' version: 4.0.18(@types/node@25.2.3)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + examples/order-processing-worker-effect: + dependencies: + '@temporal-contract/contract-effect': + specifier: workspace:* + version: link:../../packages/contract-effect + '@temporal-contract/worker-effect': + specifier: workspace:* + version: link:../../packages/worker-effect + '@temporalio/worker': + specifier: 'catalog:' + version: 1.14.1 + '@temporalio/workflow': + specifier: 'catalog:' + version: 1.14.1 + effect: + specifier: 'catalog:' + version: 3.15.1 + pino: + specifier: 'catalog:' + version: 10.3.1 + pino-pretty: + specifier: 'catalog:' + version: 13.1.3 + devDependencies: + '@temporal-contract/client-effect': + specifier: workspace:* + version: link:../../packages/client-effect + '@temporal-contract/testing': + specifier: workspace:* + version: link:../../packages/testing + '@temporal-contract/tsconfig': + specifier: workspace:* + version: link:../../tools/tsconfig + '@temporalio/client': + specifier: 'catalog:' + version: 1.14.1 + '@types/node': + specifier: 'catalog:' + version: 25.2.3 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.0.18(vitest@4.0.18(@types/node@25.2.3)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + tsx: + specifier: 'catalog:' + version: 4.21.0 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.0.18(@types/node@25.2.3)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + examples/order-processing-worker-nestjs: dependencies: '@nestjs/common':