diff --git a/README.md b/README.md index b6a3cfed..9d1f0edf 100644 --- a/README.md +++ b/README.md @@ -36,12 +36,34 @@ const contract = defineContract({ processOrder: { input: z.object({ orderId: z.string() }), output: z.object({ success: z.boolean() }), - activities: { /* ... */ } + activities: { + processPayment: { + input: z.object({ orderId: z.string() }), + output: z.object({ transactionId: z.string() }) + } + } } } }); -// Fully typed everywhere +// Implement activities with Future/Result pattern +import { declareActivitiesHandler, ActivityError } from '@temporal-contract/worker/activity'; +import { Future } from '@swan-io/boxed'; + +const activities = declareActivitiesHandler({ + contract, + activities: { + processPayment: ({ orderId }) => { + return Future.fromPromise(paymentService.process(orderId)) + .mapError((error) => + new ActivityError('PAYMENT_FAILED', 'Payment failed', error) + ) + .mapOk((txId) => ({ transactionId: txId })); + } + } +}); + +// Call from client - fully typed everywhere const result = await client.executeWorkflow('processOrder', { workflowId: 'order-123', args: { orderId: 'ORD-123' } // ✅ TypeScript knows! diff --git a/packages/client/README.md b/packages/client/README.md index 64f4aeb8..19315bef 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -14,10 +14,11 @@ pnpm add @temporal-contract/client @temporal-contract/contract @temporalio/clien ```typescript import { TypedClient } from '@temporal-contract/client'; -import { Connection } from '@temporalio/client'; +import { Connection, Client } from '@temporalio/client'; const connection = await Connection.connect({ address: 'localhost:7233' }); -const client = TypedClient.create(myContract, { connection }); +const temporalClient = new Client({ connection }); +const client = TypedClient.create(myContract, temporalClient); // Execute workflow (fully typed!) const result = await client.executeWorkflow('processOrder', { diff --git a/packages/worker/README.md b/packages/worker/README.md index 439ae24e..73d76fcd 100644 --- a/packages/worker/README.md +++ b/packages/worker/README.md @@ -14,12 +14,23 @@ pnpm add @temporal-contract/worker @temporal-contract/contract @temporalio/workf ```typescript // activities.ts -import { declareActivitiesHandler } from '@temporal-contract/worker/activity'; +import { declareActivitiesHandler, ActivityError } from '@temporal-contract/worker/activity'; +import { Future, Result } from '@swan-io/boxed'; export const activities = declareActivitiesHandler({ contract: myContract, activities: { - sendEmail: async ({ to, body }) => ({ sent: true }) + sendEmail: ({ to, body }) => { + return Future.fromPromise(emailService.send({ to, body })) + .mapError((error) => + new ActivityError( + 'EMAIL_FAILED', + error instanceof Error ? error.message : 'Failed to send email', + error + ) + ) + .mapOk(() => ({ sent: true })); + } } }); @@ -29,28 +40,23 @@ import { declareWorkflow } from '@temporal-contract/worker/workflow'; export const processOrder = declareWorkflow({ workflowName: 'processOrder', contract: myContract, - implementation: async (context, input) => { - await context.activities.sendEmail({ to: 'user@example.com', body: 'Done!' }); + implementation: async ({ activities }, input) => { + // Activities return plain values (Result is unwrapped internally) + await activities.sendEmail({ to: 'user@example.com', body: 'Done!' }); return { success: true }; } }); // worker.ts -import { NativeConnection } from '@temporalio/worker'; -import { createWorker } from '@temporal-contract/worker/worker'; +import { Worker } from '@temporalio/worker'; import { activities } from './activities'; import myContract from './contract'; async function run() { - const connection = await NativeConnection.connect({ - address: 'localhost:7233', - }); - - const worker = await createWorker({ - contract: myContract, - connection, + const worker = await Worker.create({ workflowsPath: require.resolve('./workflows'), activities, + taskQueue: myContract.taskQueue, }); await worker.run(); @@ -70,9 +76,9 @@ import { declareWorkflow } from '@temporal-contract/worker/workflow'; export const parentWorkflow = declareWorkflow({ workflowName: 'parentWorkflow', contract: myContract, - implementation: async (context, input) => { + implementation: async ({ executeChildWorkflow }, input) => { // Execute child workflow from same contract and wait for result - const childResult = await context.executeChildWorkflow(myContract, 'processPayment', { + const childResult = await executeChildWorkflow(myContract, 'processPayment', { workflowId: `payment-${input.orderId}`, args: { amount: input.totalAmount } }); @@ -83,13 +89,13 @@ export const parentWorkflow = declareWorkflow({ }); // Execute child workflow from another contract (another worker) - const notificationResult = await context.executeChildWorkflow(notificationContract, 'sendNotification', { + const notificationResult = await executeChildWorkflow(notificationContract, 'sendNotification', { workflowId: `notification-${input.orderId}`, args: { message: 'Order received' } }); // Or start child workflow without waiting - const handleResult = await context.startChildWorkflow(myContract, 'sendEmail', { + const handleResult = await startChildWorkflow(myContract, 'sendEmail', { workflowId: `email-${input.orderId}`, args: { to: 'user@example.com', body: 'Order received' } }); diff --git a/samples/order-processing-client/README.md b/samples/order-processing-client/README.md index 769d8f92..ad78bce8 100644 --- a/samples/order-processing-client/README.md +++ b/samples/order-processing-client/README.md @@ -8,10 +8,10 @@ This sample demonstrates that a single client can interact with any worker imple This client package demonstrates that: -- Both `order-processing-worker` and `order-processing-worker-boxed` workers implement the **same unified contract** +- Both `order-processing-worker` and `order-processing-worker-nestjs` workers implement the **same unified contract** - A single client works with any worker implementation - From the client's perspective, all workers are identical -- The only difference is in how workers handle errors internally (Promise-based vs. Result/Future pattern) +- Workers handle errors internally using the Result/Future pattern with ActivityError ## Running the Sample @@ -34,17 +34,17 @@ pnpm install && pnpm build 1. Start a worker (choose one): -**Option A: Basic Worker** +**Option A: Standard Worker** ```bash cd ../order-processing-worker pnpm dev ``` -**Option B: Boxed Worker** +**Option B: NestJS Worker** ```bash -cd ../order-processing-worker-boxed +cd ../order-processing-worker-nestjs pnpm dev ``` @@ -89,19 +89,19 @@ The unified contract (`orderProcessingContract`) defines: ### Worker Implementations -Both workers implement the exact same contract but with different patterns: +Both workers implement the exact same contract but with different frameworks: -1. **Basic Worker** (`samples/order-processing-worker`) +1. **Standard Worker** (`samples/order-processing-worker`) - Uses `@temporal-contract/worker` - - Promise-based activities - - Traditional error handling with try/catch - - Simpler to understand and implement - -2. **Boxed Worker** (`samples/order-processing-worker-boxed`) - - Uses `@temporal-contract/worker-boxed` - - Result/Future pattern with `@temporal-contract/boxed` - - Explicit error types in function signatures - - Better type safety and functional composition + - Activities use Result/Future pattern with ActivityError + - Clean Architecture with dependency injection + - Standalone TypeScript application + +2. **NestJS Worker** (`samples/order-processing-worker-nestjs`) + - Uses `@temporal-contract/worker-nestjs` + - Activities use Result/Future pattern with ActivityError + - NestJS dependency injection and decorators + - Better for NestJS-based applications ### Client Perspective diff --git a/samples/order-processing-worker/README.md b/samples/order-processing-worker/README.md index 566e770d..4d7c34d7 100644 --- a/samples/order-processing-worker/README.md +++ b/samples/order-processing-worker/README.md @@ -1,6 +1,6 @@ -# Basic Order Processing +# Order Processing Worker -> Standard Promise-based workflow with Clean Architecture +> Type-safe order processing worker with Clean Architecture and Result/Future pattern ## Running @@ -15,6 +15,7 @@ pnpm dev ## What It Demonstrates - ✅ Type-safe contracts with Zod +- ✅ Result/Future pattern with ActivityError for explicit error handling - ✅ Clean Architecture (Domain → Infrastructure → Application) - ✅ Dependency injection for testability - ✅ Error handling with compensating actions diff --git a/website/docs/guide/activity-handlers.md b/website/docs/guide/activity-handlers.md index 80e61dee..bb37373a 100644 --- a/website/docs/guide/activity-handlers.md +++ b/website/docs/guide/activity-handlers.md @@ -12,20 +12,36 @@ Instead of defining activity implementations inline, you can extract types for r ```typescript import type { ActivityHandlers } from '@temporal-contract/worker/activity'; +import { declareActivitiesHandler, ActivityError } from '@temporal-contract/worker/activity'; +import { Future, Result } from '@swan-io/boxed'; import { orderContract } from './contract'; // Extract all activity handler types from contract type OrderActivityHandlers = ActivityHandlers; -// Implement activities with explicit types -const sendEmail: OrderActivityHandlers['sendEmail'] = async ({ to, body }) => { - await emailService.send({ to, body }); - return { sent: true }; +// Implement activities with explicit types using Future/Result pattern +const sendEmail: OrderActivityHandlers['sendEmail'] = ({ to, body }) => { + return Future.fromPromise(emailService.send({ to, body })) + .mapError((error) => + new ActivityError( + 'EMAIL_FAILED', + error instanceof Error ? error.message : 'Failed to send email', + error + ) + ) + .mapOk(() => ({ sent: true })); }; -const processPayment: OrderActivityHandlers['processPayment'] = async ({ amount }) => { - const txId = await paymentGateway.charge(amount); - return { transactionId: txId }; +const processPayment: OrderActivityHandlers['processPayment'] = ({ amount }) => { + return Future.fromPromise(paymentGateway.charge(amount)) + .mapError((error) => + new ActivityError( + 'PAYMENT_FAILED', + error instanceof Error ? error.message : 'Payment failed', + error + ) + ) + .mapOk((txId) => ({ transactionId: txId })); }; // Use in handler @@ -77,26 +93,44 @@ Implement activities in separate files: ```typescript // activities/email.ts import type { ActivityHandlers } from '@temporal-contract/worker/activity'; +import { ActivityError } from '@temporal-contract/worker/activity'; +import { Future, Result } from '@swan-io/boxed'; import { orderContract } from '../contracts/order.contract'; type Handlers = ActivityHandlers; -export const sendEmail: Handlers['sendEmail'] = async ({ to, body }) => { - await emailService.send({ to, body }); - return { sent: true }; +export const sendEmail: Handlers['sendEmail'] = ({ to, body }) => { + return Future.fromPromise(emailService.send({ to, body })) + .mapError((error) => + new ActivityError( + 'EMAIL_FAILED', + error instanceof Error ? error.message : 'Failed to send email', + error + ) + ) + .mapOk(() => ({ sent: true })); }; ``` ```typescript // activities/payment.ts import type { ActivityHandlers } from '@temporal-contract/worker/activity'; +import { ActivityError } from '@temporal-contract/worker/activity'; +import { Future, Result } from '@swan-io/boxed'; import { orderContract } from '../contracts/order.contract'; type Handlers = ActivityHandlers; -export const processPayment: Handlers['processPayment'] = async ({ amount }) => { - const txId = await paymentGateway.charge(amount); - return { transactionId: txId }; +export const processPayment: Handlers['processPayment'] = ({ amount }) => { + return Future.fromPromise(paymentGateway.charge(amount)) + .mapError((error) => + new ActivityError( + 'PAYMENT_FAILED', + error instanceof Error ? error.message : 'Payment failed', + error + ) + ) + .mapOk((txId) => ({ transactionId: txId })); }; ``` @@ -165,13 +199,14 @@ Mock activities with correct types: ```typescript import type { ActivityHandlers } from '@temporal-contract/worker/activity'; +import { Future, Result } from '@swan-io/boxed'; type Handlers = ActivityHandlers; // Create mock activities for testing const mockActivities: Handlers = { - sendEmail: async ({ to, body }) => ({ sent: true }), - processPayment: async ({ amount }) => ({ transactionId: 'TEST-TXN' }) + sendEmail: ({ to, body }) => Future.value(Result.Ok({ sent: true })), + processPayment: ({ amount }) => Future.value(Result.Ok({ transactionId: 'TEST-TXN' })) }; // Use in tests diff --git a/website/docs/guide/client-usage.md b/website/docs/guide/client-usage.md index 91a1b7ad..6175cc9f 100644 --- a/website/docs/guide/client-usage.md +++ b/website/docs/guide/client-usage.md @@ -33,10 +33,10 @@ const client = TypedClient.create(myContract, temporalClient); ### Basic Execution -Execute a workflow and wait for completion: +Execute a workflow and wait for completion using the Result/Future pattern: ```typescript -const result = await client.executeWorkflow('processOrder', { +const future = client.executeWorkflow('processOrder', { workflowId: 'order-123', args: { orderId: 'ORD-123', @@ -44,8 +44,18 @@ const result = await client.executeWorkflow('processOrder', { }, }); -// Result is fully typed from your contract! -console.log(result.status); // TypeScript knows the shape +// await the Future to get the Result +const result = await future; + +// Handle the Result with pattern matching +result.match({ + Ok: (output) => { + console.log('Order processed:', output.status); // TypeScript knows the shape! + }, + Error: (error) => { + console.error('Workflow failed:', error); + }, +}); ``` ### Start Without Waiting @@ -53,7 +63,7 @@ console.log(result.status); // TypeScript knows the shape Start a workflow without waiting for completion: ```typescript -const handle = await client.startWorkflow('processOrder', { +const handleResult = await client.startWorkflow('processOrder', { workflowId: 'order-123', args: { orderId: 'ORD-123', @@ -61,11 +71,22 @@ const handle = await client.startWorkflow('processOrder', { }, }); -// Get workflow ID -console.log('Started workflow:', handle.workflowId); +handleResult.match({ + Ok: async (handle) => { + // Get workflow ID + console.log('Started workflow:', handle.workflowId); -// Wait for result later -const result = await handle.result(); + // Wait for result later + const result = await handle.result(); + result.match({ + Ok: (output) => console.log('Completed:', output), + Error: (error) => console.error('Failed:', error), + }); + }, + Error: (error) => { + console.error('Failed to start workflow:', error); + }, +}); ``` ## Type Safety diff --git a/website/docs/guide/entry-points.md b/website/docs/guide/entry-points.md index b914e3eb..da4916b5 100644 --- a/website/docs/guide/entry-points.md +++ b/website/docs/guide/entry-points.md @@ -149,12 +149,12 @@ import { orderContract } from 'contract-package'; export const processOrder = declareWorkflow({ workflowName: 'processOrder', contract: orderContract, - implementation: async (context, { orderId }) => { - const payment = await context.activities.processPayment({ + implementation: async ({ activities }, { orderId }) => { + const payment = await activities.processPayment({ amount: 100 }); - await context.activities.sendEmail({ + await activities.sendEmail({ to: 'customer@example.com', body: `Order ${orderId} processed` }); @@ -276,9 +276,9 @@ declareActivitiesHandler({ declareWorkflow({ workflowName: 'processOrder', contract, - implementation: async (context, input) => { + implementation: async ({ activities }, input) => { // ✅ TypeScript knows processPayment exists - const result = await context.activities.processPayment({ + const result = await activities.processPayment({ amount: 100 // ✅ Type checked }); diff --git a/website/docs/guide/getting-started.md b/website/docs/guide/getting-started.md index 2439d768..4b4bd243 100644 --- a/website/docs/guide/getting-started.md +++ b/website/docs/guide/getting-started.md @@ -199,8 +199,8 @@ import { activities } from './activities'; const worker = await Worker.create({ workflowsPath: require.resolve('./workflows'), - activities: activities.activities, - taskQueue: activities.contract.taskQueue, + activities, + taskQueue: 'orders', // or activities.contract.taskQueue }); await worker.run(); @@ -219,13 +219,22 @@ const connection = await Connection.connect({ const temporalClient = new Client({ connection }); const client = TypedClient.create(orderContract, temporalClient); -// Fully typed workflow execution -const result = await client.executeWorkflow('processOrder', { +// Fully typed workflow execution with Result/Future pattern +const future = client.executeWorkflow('processOrder', { workflowId: 'order-123', args: { orderId: 'ORD-123', customerId: 'CUST-456' }, }); -console.log(result.status); // 'success' | 'failed' — fully typed! +const result = await future; + +result.match({ + Ok: (output) => { + console.log(output.status); // 'success' | 'failed' — fully typed! + }, + Error: (error) => { + console.error('Workflow failed:', error); + }, +}); ``` ## What's Next? diff --git a/website/docs/guide/result-pattern.md b/website/docs/guide/result-pattern.md index eb9c9946..addfea9d 100644 --- a/website/docs/guide/result-pattern.md +++ b/website/docs/guide/result-pattern.md @@ -167,52 +167,67 @@ For explicit conversion between the two (rarely needed), see the [@temporal-cont ## Pattern Matching -Use `.match()` for elegant error handling: +Activities return plain values when called from workflows. If an activity fails, it will throw an error: ```typescript -const result = await context.activities.processPayment({ amount: 100 }); - -return result.match({ - Ok: (payment) => { - console.log('Payment succeeded:', payment.transactionId); - return Result.Ok({ success: true }); - }, - Error: (error) => { - console.error('Payment failed:', error); - return Result.Error({ type: 'PaymentFailed', error }); +export const processOrder = declareWorkflow({ + workflowName: 'processOrder', + contract: orderContract, + implementation: async ({ activities }, input) => { + try { + // Activity returns plain value (Result is unwrapped internally) + const payment = await activities.processPayment({ amount: 100 }); + console.log('Payment succeeded:', payment.transactionId); + + return { success: true, transactionId: payment.transactionId }; + } catch (error) { + // Activity errors are thrown + console.error('Payment failed:', error); + return { success: false, transactionId: '' }; + } } }); ``` -## Chaining Results +> [!NOTE] +> For child workflows, you do get Result objects. See the Child Workflows section below. -Chain operations with `.flatMap()`: +## Chaining Activities -```typescript -const result = await context.activities.processPayment({ amount: 100 }) - .flatMap(async (payment) => { - // Only runs if payment succeeded - return context.activities.sendEmail({ - to: 'customer@example.com', - body: `Payment ${payment.transactionId} processed` - }); - }) - .flatMap(async (email) => { - // Only runs if both payment and email succeeded - return context.activities.updateDatabase({ - status: 'completed' - }); - }); +When calling multiple activities, use standard async/await with try/catch: -return result.match({ - Ok: () => Result.Ok({ success: true }), - Error: (error) => Result.Error({ type: 'WorkflowFailed', error }) +```typescript +export const processOrder = declareWorkflow({ + workflowName: 'processOrder', + contract: orderContract, + implementation: async ({ activities }, input) => { + try { + // Activities return plain values + const payment = await activities.processPayment({ amount: 100 }); + + // Next activity only runs if payment succeeded + await activities.sendEmail({ + to: 'customer@example.com', + body: `Payment ${payment.transactionId} processed` + }); + + // Update database + await activities.updateDatabase({ + status: 'completed' + }); + + return { success: true }; + } catch (error) { + console.error('Workflow failed:', error); + return { success: false }; + } + } }); ``` ## Error Types -Define typed errors: +Define typed errors in your activities: ```typescript type PaymentError = @@ -224,14 +239,18 @@ type EmailError = | { type: 'InvalidEmail' } | { type: 'ServiceUnavailable' }; -// Activities return typed errors +// Activities return Future with typed errors processPayment: ({ amount }) => { return Future.fromPromise(paymentGateway.charge(amount)) - .map<{ transactionId: string }>(txId => ({ transactionId: txId })) - .mapError(error => ({ - type: 'CardDeclined', - // error qualification logic - })); + .mapError(error => { + // Wrap domain errors in ActivityError for Temporal retry policies + return new ActivityError( + 'PAYMENT_FAILED', + error instanceof Error ? error.message : 'Payment failed', + error + ); + }) + .mapOk((txId) => ({ transactionId: txId })); } ``` @@ -239,78 +258,125 @@ processPayment: ({ amount }) => { ### 1. Explicit Error Handling -Errors are part of the type system: +Activities use the Result pattern internally, while workflows use try/catch: ```typescript -// TypeScript knows this can fail -const result: Future = - context.activities.processPayment({ amount: 100 }); +// Activity implementation (uses Result pattern) +const processPayment = ({ amount }) => { + return Future.fromPromise(paymentGateway.charge(amount)) + .mapError((error) => + new ActivityError('PAYMENT_FAILED', 'Payment failed', error) + ) + .mapOk((txId) => ({ transactionId: txId })); +}; -// Must handle error case -if (result.isError()) { - // Handle error -} +// Workflow (uses standard try/catch for activities) +export const processOrder = declareWorkflow({ + workflowName: 'processOrder', + contract: myContract, + implementation: async ({ activities }, input) => { + try { + // Activity returns plain value + const payment = await activities.processPayment({ amount: 100 }); + return { success: true, transactionId: payment.transactionId }; + } catch (error) { + // Handle activity error + return { success: false }; + } + } +}); ``` -### 2. No Hidden Exceptions +### 2. No Hidden Exceptions in Activities -All failures are explicit in the return type: +Activities explicitly return Results instead of throwing: ```typescript -// ✅ Clear - returns Result -async function processOrder(): Promise> { /* ... */ } +// ✅ Clear - activity returns Future +const processPayment = ({ amount }) => { + return Future.fromPromise(paymentGateway.charge(amount)) + .mapError((error) => + new ActivityError('PAYMENT_FAILED', 'Payment failed', error) + ) + .mapOk((txId) => ({ transactionId: txId })); +}; // ❌ Unclear - might throw anything -async function processOrder(): Promise { /* ... */ } +async function processPayment({ amount }) { + const txId = await paymentGateway.charge(amount); + return { transactionId: txId }; +} ``` -### 3. Railway-Oriented Programming +### 3. Railway-Oriented Programming (Activities) -Chain operations that short-circuit on error: +Activity implementations can chain operations that short-circuit on error: ```mermaid graph LR - A[validateOrder] -->|Ok| B[checkInventory] + A[validateInput] -->|Ok| B[callAPI] A -->|Error| E[Error Path] - B -->|Ok| C[processPayment] + B -->|Ok| C[processResponse] B -->|Error| E - C -->|Ok| D[sendConfirmation] + C -->|Ok| D[Success] C -->|Error| E - D -->|Ok| F[Success] - D -->|Error| E style A fill:#3b82f6,stroke:#1e40af,color:#fff - style F fill:#10b981,stroke:#059669,color:#fff + style D fill:#10b981,stroke:#059669,color:#fff style E fill:#ef4444,stroke:#dc2626,color:#fff ``` ```typescript -return await validateOrder({ orderId }) - .flatMap(() => checkInventory({ orderId })) - .flatMap(() => processPayment({ amount })) - .flatMap(() => sendConfirmation({ orderId })); -// Stops at first error +// Activity implementation with chaining +const processOrder = ({ orderId }) => { + return validateOrderId(orderId) + .flatMap((validId) => fetchOrder(validId)) + .flatMap((order) => processPayment(order)) + .flatMap((payment) => updateDatabase(payment)) + .mapError((error) => + new ActivityError('ORDER_FAILED', 'Order processing failed', error) + ); + // Stops at first error +}; ``` ### 4. Partial Success Handling -Track partial success in complex workflows: +Track partial success in complex workflows using try/catch blocks: ```typescript -const paymentResult = await processPayment({ amount }); -if (paymentResult.isError()) { - return Result.Error({ step: 'payment', error: paymentResult.getError() }); -} +export const processOrder = declareWorkflow({ + workflowName: 'processOrder', + contract: orderContract, + implementation: async ({ activities }, input) => { + let paymentTransactionId: string | undefined; + + try { + // Step 1: Process payment + const payment = await activities.processPayment({ amount: input.amount }); + paymentTransactionId = payment.transactionId; + + // Step 2: Schedule shipment + await activities.scheduleShipment({ orderId: input.orderId }); + + return { success: true, transactionId: paymentTransactionId }; + } catch (error) { + // Payment succeeded but shipment failed - can handle specially + if (paymentTransactionId) { + // Rollback payment + await activities.refundPayment({ transactionId: paymentTransactionId }); + + return { + success: false, + message: 'Shipment failed, payment refunded', + completedSteps: { payment: paymentTransactionId } + }; + } -const shipmentResult = await scheduleShipment({ orderId }); -if (shipmentResult.isError()) { - // Payment succeeded, shipment failed - can handle specially - return Result.Error({ - step: 'shipment', - error: shipmentResult.getError(), - completedSteps: { payment: paymentResult.get() } - }); -} + return { success: false, message: 'Payment failed' }; + } + } +}); ``` ## Child Workflows @@ -325,9 +391,9 @@ import { declareWorkflow } from '@temporal-contract/worker/workflow'; export const parentWorkflow = declareWorkflow({ workflowName: 'parentWorkflow', contract: myContract, - implementation: async (context, input) => { + implementation: async ({ executeChildWorkflow }, input) => { // Execute child workflow and wait for result - const result = await context.executeChildWorkflow(myContract, 'processPayment', { + const result = await executeChildWorkflow(myContract, 'processPayment', { workflowId: `payment-${input.orderId}`, args: { amount: input.totalAmount } }); @@ -352,9 +418,9 @@ export const parentWorkflow = declareWorkflow({ export const parentWorkflow = declareWorkflow({ workflowName: 'parentWorkflow', contract: myContract, - implementation: async (context, input) => { + implementation: async ({ startChildWorkflow }, input) => { // Start child workflow without waiting - const handleResult = await context.startChildWorkflow(myContract, 'sendNotification', { + const handleResult = await startChildWorkflow(myContract, 'sendNotification', { workflowId: `notification-${input.orderId}`, args: { message: 'Order received' } }); @@ -385,9 +451,9 @@ import { orderContract, notificationContract } from './contracts'; export const orderWorkflow = declareWorkflow({ workflowName: 'processOrder', contract: orderContract, - implementation: async (context, input) => { + implementation: async ({ executeChildWorkflow }, input) => { // Child workflow from another contract - const notifyResult = await context.executeChildWorkflow( + const notifyResult = await executeChildWorkflow( notificationContract, 'sendOrderConfirmation', { @@ -409,19 +475,17 @@ export const orderWorkflow = declareWorkflow({ ## When to Use -### Use Result Pattern When: +### Use Result/Future Pattern When: -- You need explicit error types -- You want to track partial success -- You prefer functional programming style -- You need fine-grained error handling +- **In Activity Implementations**: Always use Future/Result pattern for explicit error handling +- **For Child Workflows**: Child workflows return Results for explicit error handling +- **For Type-Safe Errors**: When you need ActivityError with proper retry policies -### Use Standard Pattern When: +### Use Standard async/await When: -- You're comfortable with exceptions -- You prefer imperative style -- You have simple error handling needs -- You want less boilerplate +- **In Workflow Logic**: Use try/catch when calling activities from workflows +- **For Simple Error Handling**: When standard exception handling is sufficient +- **For Deterministic Code**: Workflows must remain deterministic ## See Also diff --git a/website/docs/guide/worker-implementation.md b/website/docs/guide/worker-implementation.md index ce1f7653..eed2a695 100644 --- a/website/docs/guide/worker-implementation.md +++ b/website/docs/guide/worker-implementation.md @@ -31,25 +31,40 @@ sequenceDiagram ## Activities Handler -Create a handler for all activities: +Create a handler for all activities using the Result/Future pattern: ```typescript -import { declareActivitiesHandler } from '@temporal-contract/worker/activity'; +import { declareActivitiesHandler, ActivityError } from '@temporal-contract/worker/activity'; +import { Future, Result } from '@swan-io/boxed'; import { myContract } from './contract'; export const activities = declareActivitiesHandler({ contract: myContract, activities: { - // Global activities - sendEmail: async ({ to, subject, body }) => { - await emailService.send({ to, subject, body }); - return { sent: true }; + // Global activities - use Future/Result for explicit error handling + sendEmail: ({ to, subject, body }) => { + return Future.fromPromise(emailService.send({ to, subject, body })) + .mapError((error) => + new ActivityError( + 'EMAIL_FAILED', + error instanceof Error ? error.message : 'Failed to send email', + error + ) + ) + .mapOk(() => ({ sent: true })); }, // Workflow-specific activities - processPayment: async ({ customerId, amount }) => { - const txId = await paymentGateway.charge(customerId, amount); - return { transactionId: txId, success: true }; + processPayment: ({ customerId, amount }) => { + return Future.fromPromise(paymentGateway.charge(customerId, amount)) + .mapError((error) => + new ActivityError( + 'PAYMENT_FAILED', + error instanceof Error ? error.message : 'Payment failed', + error + ) + ) + .mapOk((txId) => ({ transactionId: txId, success: true })); }, }, }); @@ -57,7 +72,7 @@ export const activities = declareActivitiesHandler({ ## Workflow Implementation -Implement workflows with typed context: +Implement workflows with typed context. Activities called from workflows return plain values (Result is unwrapped internally): ```typescript import { declareWorkflow } from '@temporal-contract/worker/workflow'; @@ -66,19 +81,21 @@ import { myContract } from './contract'; export const processOrder = declareWorkflow({ workflowName: 'processOrder', contract: myContract, - implementation: async (context, input) => { - // context.activities is fully typed - const payment = await context.activities.processPayment({ + implementation: async ({ activities }, input) => { + // activities is fully typed + // Activities return plain values (Result is unwrapped by the framework) + const payment = await activities.processPayment({ customerId: input.customerId, amount: 100 }); - await context.activities.sendEmail({ + await activities.sendEmail({ to: input.customerId, subject: 'Order Confirmed', body: 'Your order has been processed' }); + // Return plain object (not Result) return { status: payment.success ? 'success' : 'failed', transactionId: payment.transactionId @@ -97,8 +114,8 @@ import { activities } from './activities'; const worker = await Worker.create({ workflowsPath: require.resolve('./workflows'), - activities: activities.activities, - taskQueue: activities.contract.taskQueue, + activities, + taskQueue: 'my-task-queue', // or myContract.taskQueue }); await worker.run(); @@ -141,13 +158,13 @@ return { txId: 'TXN-123' }; // Wrong field name The workflow context is fully typed based on your contract: ```typescript -implementation: async (context, input) => { +implementation: async ({ activities }, input) => { // TypeScript knows all available activities - context.activities.processPayment // ✅ Available - context.activities.unknownActivity // ❌ TypeScript error + activities.processPayment // ✅ Available + activities.unknownActivity // ❌ TypeScript error // Full autocomplete for parameters - await context.activities.processPayment({ + await activities.processPayment({ // IDE shows: customerId: string, amount: number }); } @@ -190,9 +207,9 @@ import { myContract, notificationContract } from './contracts'; export const parentWorkflow = declareWorkflow({ workflowName: 'parentWorkflow', contract: myContract, - implementation: async (context, input) => { + implementation: async ({ executeChildWorkflow }, input) => { // Execute child workflow from same contract and wait for result - const result = await context.executeChildWorkflow(myContract, 'processPayment', { + const result = await executeChildWorkflow(myContract, 'processPayment', { workflowId: `payment-${input.orderId}`, args: { amount: input.totalAmount } }); @@ -215,9 +232,9 @@ Invoke child workflows from different contracts and workers: export const orderWorkflow = declareWorkflow({ workflowName: 'processOrder', contract: orderContract, - implementation: async (context, input) => { + implementation: async ({ executeChildWorkflow }, input) => { // Process payment in same contract - const paymentResult = await context.executeChildWorkflow( + const paymentResult = await executeChildWorkflow( orderContract, 'processPayment', { @@ -231,7 +248,7 @@ export const orderWorkflow = declareWorkflow({ } // Send notification using another worker's contract - const notificationResult = await context.executeChildWorkflow( + const notificationResult = await executeChildWorkflow( notificationContract, 'sendOrderConfirmation', { @@ -256,9 +273,9 @@ Use `startChildWorkflow` to start a child workflow without waiting for its resul export const orderWorkflow = declareWorkflow({ workflowName: 'processOrder', contract: myContract, - implementation: async (context, input) => { + implementation: async ({ startChildWorkflow }, input) => { // Start background notification workflow - const handleResult = await context.startChildWorkflow( + const handleResult = await startChildWorkflow( notificationContract, 'sendEmail', { @@ -366,23 +383,58 @@ export const createActivities = (services: { ### 3. Error Handling -Handle errors appropriately: +Activities use the Future/Result pattern for explicit error handling: ```typescript +import { declareActivitiesHandler, ActivityError } from '@temporal-contract/worker/activity'; +import { Future, Result } from '@swan-io/boxed'; + export const activities = declareActivitiesHandler({ contract: myContract, activities: { - processPayment: async ({ customerId, amount }) => { - try { - const txId = await paymentGateway.charge(customerId, amount); - return { transactionId: txId, success: true }; - } catch (error) { - // Log error - logger.error('Payment failed', error); - - // Return typed error response - return { transactionId: '', success: false }; - } + processPayment: ({ customerId, amount }) => { + return Future.fromPromise(paymentGateway.charge(customerId, amount)) + .mapError((error) => { + // Wrap technical errors in ActivityError + // This enables proper retry policies and error handling + return new ActivityError( + 'PAYMENT_FAILED', + error instanceof Error ? error.message : 'Payment failed', + error + ); + }) + .mapOk((txId) => ({ transactionId: txId, success: true })); + } + } +}); +``` + +In workflows, activities return plain values. If an activity fails, it will throw an error that can be caught: + +```typescript +export const processOrder = declareWorkflow({ + workflowName: 'processOrder', + contract: myContract, + implementation: async ({ activities }, input) => { + try { + // Activity returns plain value if successful + const payment = await activities.processPayment({ + customerId: input.customerId, + amount: 100 + }); + + return { + status: 'success', + transactionId: payment.transactionId + }; + } catch (error) { + // Activity errors are thrown and can be caught + console.error('Payment failed:', error); + + return { + status: 'failed', + transactionId: '' + }; } } }); diff --git a/website/docs/guide/worker-usage.md b/website/docs/guide/worker-usage.md index 9945d174..2afc8219 100644 --- a/website/docs/guide/worker-usage.md +++ b/website/docs/guide/worker-usage.md @@ -83,22 +83,15 @@ export const processOrder = declareWorkflow({ ## Starting a Worker ```typescript -import { NativeConnection, Worker } from '@temporalio/worker'; -import { createWorker } from '@temporal-contract/worker/worker'; +import { Worker } from '@temporalio/worker'; import { myContract } from './contract'; import { activities } from './activities'; async function main() { - const connection = await NativeConnection.connect({ - address: 'localhost:7233', - }); - - const worker = await createWorker({ - contract: myContract, - connection, - namespace: 'default', + const worker = await Worker.create({ workflowsPath: require.resolve('./workflows'), activities, + taskQueue: myContract.taskQueue, }); console.log('Worker started, listening on task queue:', myContract.taskQueue); @@ -170,7 +163,7 @@ implementation: async ({ activities, info, sleep }, input) => { ## Child Workflows -Execute child workflows with type safety: +Execute child workflows with type safety using the Result/Future pattern: ```typescript import { declareWorkflow } from '@temporal-contract/worker/workflow'; @@ -179,8 +172,8 @@ export const parentWorkflow = declareWorkflow({ workflowName: 'parentWorkflow', contract: myContract, implementation: async ({ executeChildWorkflow }, input) => { - // Execute child workflow and wait - const childOutput = await executeChildWorkflow( + // Execute child workflow - returns Future + const childResult = await executeChildWorkflow( myContract, 'processPayment', { @@ -189,11 +182,17 @@ export const parentWorkflow = declareWorkflow({ } ); - // Child workflow returns plain values - return { - success: true, - transactionId: childOutput.transactionId - }; + // Handle the Result with pattern matching + return childResult.match({ + Ok: (output) => ({ + success: true, + transactionId: output.transactionId + }), + Error: (error) => ({ + success: false, + error: error.message + }), + }); }, }); ``` @@ -204,12 +203,10 @@ Handle shutdown signals properly: ```typescript async function main() { - const worker = await createWorker({ - contract: myContract, - connection, - namespace: 'default', + const worker = await Worker.create({ workflowsPath: require.resolve('./workflows'), activities, + taskQueue: myContract.taskQueue, }); // Graceful shutdown @@ -232,20 +229,16 @@ async function main() { Run multiple workers with different contracts: ```typescript -const orderWorker = await createWorker({ - contract: orderContract, - connection, - namespace: 'default', +const orderWorker = await Worker.create({ workflowsPath: require.resolve('./order-workflows'), activities: orderActivities, + taskQueue: orderContract.taskQueue, }); -const paymentWorker = await createWorker({ - contract: paymentContract, - connection, - namespace: 'default', +const paymentWorker = await Worker.create({ workflowsPath: require.resolve('./payment-workflows'), activities: paymentActivities, + taskQueue: paymentContract.taskQueue, }); // Run both workers concurrently diff --git a/website/docs/index.md b/website/docs/index.md index 9d298950..e9da8ee2 100644 --- a/website/docs/index.md +++ b/website/docs/index.md @@ -105,12 +105,21 @@ const contract = defineContract({ }); // ✅ Type-safe client -const result = await client.executeWorkflow('processOrder', { +const future = client.executeWorkflow('processOrder', { workflowId: 'order-123', args: { orderId: 'ORD-123', customerId: 'CUST-456' }, // TypeScript knows! }); -console.log(result.status); // 'success' | 'failed' — full autocomplete! +const result = await future; + +result.match({ + Ok: (output) => { + console.log(output.status); // 'success' | 'failed' — full autocomplete! + }, + Error: (error) => { + console.error('Workflow failed:', error); + }, +}); ``` ## Quick Example @@ -230,22 +239,15 @@ export const processOrder = declareWorkflow({ ``` ```typescript [4. worker.ts] -import { NativeConnection } from '@temporalio/worker'; -import { createWorker } from '@temporal-contract/worker/worker'; +import { Worker } from '@temporalio/worker'; import { orderContract } from './contract'; import { activities } from './activities'; // ✅ Start the worker -const connection = await NativeConnection.connect({ - address: 'localhost:7233', -}); - -const worker = await createWorker({ - contract: orderContract, - connection, - namespace: 'default', - workflowsPath: './workflows', +const worker = await Worker.create({ + workflowsPath: require.resolve('./workflows'), activities, + taskQueue: orderContract.taskQueue, }); await worker.run(); // Worker is now listening! @@ -253,14 +255,15 @@ await worker.run(); // Worker is now listening! ```typescript [5. client.ts] import { TypedClient } from '@temporal-contract/client'; -import { Client } from '@temporalio/client'; +import { Connection, Client } from '@temporalio/client'; import { orderContract } from './contract'; // ✅ Type-safe client calls +const connection = await Connection.connect({ address: 'localhost:7233' }); const temporalClient = new Client({ connection }); const client = TypedClient.create(orderContract, temporalClient); -const result = await client.executeWorkflow('processOrder', { +const future = client.executeWorkflow('processOrder', { workflowId: 'order-123', args: { orderId: 'ORD-123', @@ -269,9 +272,18 @@ const result = await client.executeWorkflow('processOrder', { }, }); -// Full autocomplete on result! -console.log(result.status); // 'success' | 'failed' -console.log(result.transactionId); // string | undefined +const result = await future; + +// Handle Result with pattern matching +result.match({ + Ok: (output) => { + console.log(output.status); // 'success' | 'failed' + console.log(output.transactionId); // string | undefined + }, + Error: (error) => { + console.error('Workflow failed:', error); + }, +}); ``` :::