opencode-effect-enforcer 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +278 -0
- package/guidance/effect-first-development.md +1247 -0
- package/guidance/post__effect-and-the-near-inexpressible-majesty-of-layers.md +490 -0
- package/guidance/post__parse-dont-validate.md +109 -0
- package/guidance/progressive-disclosure-guidance.md +38 -0
- package/package.json +63 -0
- package/patterns/avoid-any.md +37 -0
- package/patterns/avoid-data-tagged-error.md +34 -0
- package/patterns/avoid-direct-json.md +51 -0
- package/patterns/avoid-direct-tag-checks.md +54 -0
- package/patterns/avoid-expect-in-if.md +52 -0
- package/patterns/avoid-mutable-state.md +70 -0
- package/patterns/avoid-native-fetch.md +61 -0
- package/patterns/avoid-node-imports.md +86 -0
- package/patterns/avoid-non-null-assertion.md +44 -0
- package/patterns/avoid-object-type.md +46 -0
- package/patterns/avoid-option-getorthrow.md +39 -0
- package/patterns/avoid-platform-coupling.md +43 -0
- package/patterns/avoid-process-env.md +43 -0
- package/patterns/avoid-react-hooks.md +73 -0
- package/patterns/avoid-schema-suffix.md +45 -0
- package/patterns/avoid-sync-fs.md +68 -0
- package/patterns/avoid-try-catch.md +47 -0
- package/patterns/avoid-ts-ignore.md +38 -0
- package/patterns/avoid-untagged-errors.md +67 -0
- package/patterns/avoid-yield-ref.md +46 -0
- package/patterns/casting-awareness.md +46 -0
- package/patterns/context-tag-extends.md +84 -0
- package/patterns/effect-catchall-default.md +61 -0
- package/patterns/effect-promise-vs-trypromise.md +47 -0
- package/patterns/effect-run-in-body.md +58 -0
- package/patterns/imperative-loops.md +76 -0
- package/patterns/prefer-arr-sort.md +52 -0
- package/patterns/prefer-duration-values.md +56 -0
- package/patterns/prefer-effect-fn.md +161 -0
- package/patterns/prefer-match-over-switch.md +48 -0
- package/patterns/prefer-option-over-null.md +56 -0
- package/patterns/prefer-redacted-config.md +70 -0
- package/patterns/prefer-schema-class.md +54 -0
- package/patterns/require-effect-concurrency.md +83 -0
- package/patterns/stream-large-files.md +63 -0
- package/patterns/throw-in-effect-gen.md +62 -0
- package/patterns/use-clock-service.md +45 -0
- package/patterns/use-command-executor-service.md +54 -0
- package/patterns/use-console-service.md +54 -0
- package/patterns/use-filesystem-service.md +59 -0
- package/patterns/use-http-client-service.md +77 -0
- package/patterns/use-path-service.md +53 -0
- package/patterns/use-random-service.md +45 -0
- package/patterns/use-temp-file-scoped.md +66 -0
- package/patterns/vm-in-wrong-file.md +51 -0
- package/patterns/yield-in-for-loop.md +61 -0
- package/skills/effect-ai-chat/SKILL.md +472 -0
- package/skills/effect-ai-language-model/SKILL.md +652 -0
- package/skills/effect-ai-prompt/SKILL.md +752 -0
- package/skills/effect-ai-provider/SKILL.md +668 -0
- package/skills/effect-ai-streaming/SKILL.md +418 -0
- package/skills/effect-ai-tool/SKILL.md +1132 -0
- package/skills/effect-atom-rpc/SKILL.md +488 -0
- package/skills/effect-atom-state/SKILL.md +640 -0
- package/skills/effect-batching/SKILL.md +614 -0
- package/skills/effect-cache/SKILL.md +570 -0
- package/skills/effect-cli/SKILL.md +523 -0
- package/skills/effect-command-executor/SKILL.md +675 -0
- package/skills/effect-concurrency-testing/SKILL.md +612 -0
- package/skills/effect-config/SKILL.md +580 -0
- package/skills/effect-context-witness/SKILL.md +274 -0
- package/skills/effect-domain-modeling/SKILL.md +1212 -0
- package/skills/effect-domain-predicates/SKILL.md +867 -0
- package/skills/effect-error-handling/SKILL.md +1581 -0
- package/skills/effect-fiber/SKILL.md +731 -0
- package/skills/effect-filesystem/SKILL.md +624 -0
- package/skills/effect-graph/SKILL.md +571 -0
- package/skills/effect-http-api/SKILL.md +1760 -0
- package/skills/effect-http-client/SKILL.md +989 -0
- package/skills/effect-http-server/SKILL.md +920 -0
- package/skills/effect-incremental-migration/SKILL.md +362 -0
- package/skills/effect-layer-design/SKILL.md +642 -0
- package/skills/effect-managed-runtime/SKILL.md +395 -0
- package/skills/effect-mcp-server/SKILL.md +608 -0
- package/skills/effect-observability/SKILL.md +719 -0
- package/skills/effect-optics/SKILL.md +554 -0
- package/skills/effect-parallelization/SKILL.md +668 -0
- package/skills/effect-path/SKILL.md +296 -0
- package/skills/effect-pattern-matching/SKILL.md +914 -0
- package/skills/effect-platform-abstraction/SKILL.md +1175 -0
- package/skills/effect-platform-layers/SKILL.md +514 -0
- package/skills/effect-pubsub-event-bus/SKILL.md +384 -0
- package/skills/effect-react-composition/SKILL.md +986 -0
- package/skills/effect-react-vm/SKILL.md +675 -0
- package/skills/effect-rpc-api/SKILL.md +624 -0
- package/skills/effect-rpc-client/SKILL.md +666 -0
- package/skills/effect-rpc-cluster/SKILL.md +1623 -0
- package/skills/effect-rpc-server/SKILL.md +767 -0
- package/skills/effect-scheduling/SKILL.md +124 -0
- package/skills/effect-schema-composition/SKILL.md +975 -0
- package/skills/effect-schema-v4/SKILL.md +691 -0
- package/skills/effect-scope/SKILL.md +682 -0
- package/skills/effect-service-implementation/SKILL.md +656 -0
- package/skills/effect-socket/SKILL.md +703 -0
- package/skills/effect-sql/SKILL.md +781 -0
- package/skills/effect-stream/SKILL.md +765 -0
- package/skills/effect-testing/SKILL.md +1331 -0
- package/skills/effect-typeclass-design/SKILL.md +161 -0
- package/skills/effect-wide-events/Article.md +66 -0
- package/skills/effect-wide-events/SKILL.md +95 -0
- package/skills/effect-workflow/SKILL.md +810 -0
- package/src/agent-policy.ts +22 -0
- package/src/enforcer.ts +104 -0
- package/src/frontmatter.ts +34 -0
- package/src/guidance.ts +66 -0
- package/src/index.ts +38 -0
- package/src/pattern-catalog.ts +115 -0
- package/src/pattern-matcher.ts +178 -0
- package/src/pattern.ts +97 -0
- package/src/skills.ts +29 -0
- package/src/write-projection.ts +66 -0
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: effect-workflow
|
|
3
|
+
description: Build durable workflows with Effect using Workflow, Activity, DurableClock, DurableDeferred, and DurableQueue for execution that survives restarts, supports compensation (saga pattern), and integrates with Effect Cluster for distribution.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
You are an Effect TypeScript expert specializing in durable workflow execution using the `effect/unstable/workflow` module.
|
|
7
|
+
|
|
8
|
+
## Effect Source Reference
|
|
9
|
+
|
|
10
|
+
The Effect v4 source is available at `~/.local/share/opencode/repos/github.com/Effect-TS/effect@main/`.
|
|
11
|
+
Browse and read files there directly to look up APIs, types, and implementations.
|
|
12
|
+
|
|
13
|
+
Key source files:
|
|
14
|
+
|
|
15
|
+
- `packages/effect/src/unstable/workflow/Workflow.ts` — Workflow definition, compensation, annotations
|
|
16
|
+
- `packages/effect/src/unstable/workflow/Activity.ts` — Activity definition, retry, idempotency
|
|
17
|
+
- `packages/effect/src/unstable/workflow/WorkflowEngine.ts` — Engine service, in-memory layer, encoded interface
|
|
18
|
+
- `packages/effect/src/unstable/workflow/DurableClock.ts` — Durable sleep/timers
|
|
19
|
+
- `packages/effect/src/unstable/workflow/DurableDeferred.ts` — Durable signal/wait, tokens, done/succeed/fail
|
|
20
|
+
- `packages/effect/src/unstable/workflow/DurableQueue.ts` — Durable queue handing work to persisted background workers
|
|
21
|
+
|
|
22
|
+
## IMPORTANT: Unstable API
|
|
23
|
+
|
|
24
|
+
The workflow module lives under `effect/unstable/workflow`. APIs may change between versions. All imports use this path:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import {
|
|
28
|
+
Workflow,
|
|
29
|
+
Activity,
|
|
30
|
+
WorkflowEngine,
|
|
31
|
+
DurableClock,
|
|
32
|
+
DurableDeferred,
|
|
33
|
+
DurableQueue
|
|
34
|
+
} from 'effect/unstable/workflow';
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Core Concepts
|
|
38
|
+
|
|
39
|
+
Effect Workflow provides **durable execution** — workflows that survive process restarts through event sourcing. The key idea: activities are the units of side-effectful work whose results get persisted. On replay, persisted results are returned without re-executing the activity.
|
|
40
|
+
|
|
41
|
+
### Architecture
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
Workflow → defines the overall process (name, payload, success/error schemas)
|
|
45
|
+
Activity → a discrete unit of work inside a workflow (results are persisted)
|
|
46
|
+
WorkflowEngine → orchestrates execution, replay, suspension, resumption
|
|
47
|
+
DurableClock → sleep/timer that persists across restarts
|
|
48
|
+
DurableDeferred → wait-for-external-signal that persists across restarts
|
|
49
|
+
DurableQueue → hand work to a persisted background worker and await its result
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Workflow Definition
|
|
53
|
+
|
|
54
|
+
Use `Workflow.make` to define a workflow. Every workflow has:
|
|
55
|
+
|
|
56
|
+
- A unique `name`
|
|
57
|
+
- A `payload` schema (struct fields or Schema)
|
|
58
|
+
- An `idempotencyKey` function that produces a deterministic execution ID from the payload
|
|
59
|
+
- Optional `success` and `error` schemas (default `Schema.Void` / `Schema.Never`)
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { Workflow } from 'effect/unstable/workflow';
|
|
63
|
+
import { Schema } from 'effect';
|
|
64
|
+
|
|
65
|
+
const SendEmail = Workflow.make({
|
|
66
|
+
name: 'SendEmail',
|
|
67
|
+
payload: {
|
|
68
|
+
to: Schema.String,
|
|
69
|
+
subject: Schema.String,
|
|
70
|
+
body: Schema.String
|
|
71
|
+
},
|
|
72
|
+
idempotencyKey: (payload) => `${payload.to}:${payload.subject}`,
|
|
73
|
+
success: Schema.Void,
|
|
74
|
+
error: Schema.String
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Registering a Workflow Handler
|
|
79
|
+
|
|
80
|
+
Use `workflow.toLayer(handler)` to register the execution logic. The handler receives the decoded payload and execution ID:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
const SendEmailLive = SendEmail.toLayer((payload, executionId) =>
|
|
84
|
+
Effect.gen(function* () {
|
|
85
|
+
// Activities go here — their results are persisted
|
|
86
|
+
yield* validateRecipient; // an Activity
|
|
87
|
+
yield* sendViaProvider; // an Activity
|
|
88
|
+
})
|
|
89
|
+
);
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Executing a Workflow
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
// Execute and wait for result
|
|
96
|
+
const result =
|
|
97
|
+
yield*
|
|
98
|
+
SendEmail.execute({
|
|
99
|
+
to: 'user@example.com',
|
|
100
|
+
subject: 'Hello',
|
|
101
|
+
body: 'World'
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Fire-and-forget — returns the execution ID
|
|
105
|
+
const executionId =
|
|
106
|
+
yield*
|
|
107
|
+
SendEmail.execute(
|
|
108
|
+
{ to: 'user@example.com', subject: 'Hello', body: 'World' },
|
|
109
|
+
{ discard: true }
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// Poll for result
|
|
113
|
+
const maybeResult = yield* SendEmail.poll(executionId);
|
|
114
|
+
// returns Effect<Option<Result<A, E>>>
|
|
115
|
+
// Option.None → workflow not yet started or no record
|
|
116
|
+
// Option.Some(Workflow.Complete<A, E>) → finished; .exit holds the Exit
|
|
117
|
+
// Option.Some(Workflow.Suspended) → suspended waiting on something
|
|
118
|
+
|
|
119
|
+
// Interrupt a running workflow
|
|
120
|
+
yield* SendEmail.interrupt(executionId);
|
|
121
|
+
|
|
122
|
+
// Resume a suspended workflow
|
|
123
|
+
yield* SendEmail.resume(executionId);
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Deterministic Execution ID
|
|
127
|
+
|
|
128
|
+
The execution ID is computed as a hash of `"${name}-${idempotencyKey(payload)}"`. This means executing the same workflow with the same payload is idempotent — it returns the existing execution rather than starting a new one.
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
const id =
|
|
132
|
+
yield*
|
|
133
|
+
SendEmail.executionId({
|
|
134
|
+
to: 'user@example.com',
|
|
135
|
+
subject: 'Hello',
|
|
136
|
+
body: 'World'
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Activities
|
|
141
|
+
|
|
142
|
+
Activities are the **atomic units of work** inside a workflow. Their results are persisted by the engine, so on replay they return the cached result without re-executing.
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import { Activity } from 'effect/unstable/workflow';
|
|
146
|
+
|
|
147
|
+
const validateRecipient = Activity.make({
|
|
148
|
+
name: 'ValidateRecipient',
|
|
149
|
+
success: Schema.Struct({ valid: Schema.Boolean }),
|
|
150
|
+
error: Schema.String,
|
|
151
|
+
execute: Effect.gen(function* () {
|
|
152
|
+
// This code runs at most once per workflow execution
|
|
153
|
+
// (unless the activity itself fails and is retried)
|
|
154
|
+
const result = yield* checkEmailService(payload.to);
|
|
155
|
+
return { valid: result.isValid };
|
|
156
|
+
})
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Activity is an Effect
|
|
161
|
+
|
|
162
|
+
An `Activity` extends `Effect.Effect` (it is implemented via `Effectable.Prototype`), so you can yield it directly inside a workflow handler:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
const handler = SendEmail.toLayer((payload, executionId) =>
|
|
166
|
+
Effect.gen(function* () {
|
|
167
|
+
const validation = yield* validateRecipient; // yields the Activity directly
|
|
168
|
+
})
|
|
169
|
+
);
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Activity Retry
|
|
173
|
+
|
|
174
|
+
Use `Activity.retry` to retry an effect within an activity. The engine tracks the attempt count automatically and exposes it via `Activity.CurrentAttempt`:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
import { Activity } from 'effect/unstable/workflow';
|
|
178
|
+
|
|
179
|
+
const sendWithRetry = Activity.make({
|
|
180
|
+
name: 'SendWithRetry',
|
|
181
|
+
success: Schema.Void,
|
|
182
|
+
error: Schema.String,
|
|
183
|
+
execute: pipe(sendEmailEffect, Activity.retry({ times: 3 }))
|
|
184
|
+
});
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`Activity.retry` accepts the same options as `Effect.retry` *minus* `schedule` — the activity owns the attempt counter, so retries are attempt-based (`times`, `until`, `while`, `catch`, etc.) rather than schedule-based.
|
|
188
|
+
|
|
189
|
+
Because an `Activity` *is* an `Effect`, pipe it directly through compensation/retry combinators — there is no `.asEffect()` method (`Effect.Yieldable` was removed in beta.66):
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
yield* SomeActivity.pipe(
|
|
193
|
+
workflow.withCompensation((value, cause) => rollback(value)),
|
|
194
|
+
Activity.retry({ times: 5 })
|
|
195
|
+
);
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
You can also access `Activity.CurrentAttempt` directly inside an activity's `execute` to branch by attempt:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
Activity.make({
|
|
202
|
+
name: 'SendEmail',
|
|
203
|
+
execute: Effect.gen(function*() {
|
|
204
|
+
const attempt = yield* Activity.CurrentAttempt;
|
|
205
|
+
if (attempt < 5) return yield* Effect.fail(new TransientError());
|
|
206
|
+
return yield* sendEmail();
|
|
207
|
+
})
|
|
208
|
+
});
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Activities also expose `.execute` and `.executeEncoded` properties — the latter returns the JSON-encoded form of success/error, useful for generic activity wrappers and logging.
|
|
212
|
+
|
|
213
|
+
### Interrupt Retry Policy
|
|
214
|
+
|
|
215
|
+
Activities have a built-in `interruptRetryPolicy` — if an activity is interrupted (e.g., by process shutdown), it automatically retries with exponential backoff up to 10 times. You can override this:
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
Activity.make({
|
|
219
|
+
name: 'LongRunning',
|
|
220
|
+
execute: longRunningEffect,
|
|
221
|
+
interruptRetryPolicy: Schedule.recurs(5)
|
|
222
|
+
});
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Idempotency Keys
|
|
226
|
+
|
|
227
|
+
Generate deterministic idempotency keys for external API calls within activities:
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
const key = yield* Activity.idempotencyKey('stripe-charge');
|
|
231
|
+
// Incorporates the execution ID + activity name
|
|
232
|
+
|
|
233
|
+
// Include the attempt number for retry-aware keys
|
|
234
|
+
const keyWithAttempt =
|
|
235
|
+
yield*
|
|
236
|
+
Activity.idempotencyKey('stripe-charge', {
|
|
237
|
+
includeAttempt: true
|
|
238
|
+
});
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Racing Activities
|
|
242
|
+
|
|
243
|
+
Race multiple activities — the first to complete wins, and the result is durably stored:
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
const result =
|
|
247
|
+
yield*
|
|
248
|
+
Activity.raceAll('fastest-provider', [
|
|
249
|
+
sendViaProviderA,
|
|
250
|
+
sendViaProviderB,
|
|
251
|
+
sendViaProviderC
|
|
252
|
+
]);
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## DurableClock — Durable Sleep
|
|
256
|
+
|
|
257
|
+
`DurableClock.sleep` creates a timer that survives process restarts. Short sleeps (<=60s by default) run in-memory as regular activities. Longer sleeps are scheduled through the engine.
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
import { DurableClock } from 'effect/unstable/workflow';
|
|
261
|
+
|
|
262
|
+
// Inside a workflow handler:
|
|
263
|
+
yield*
|
|
264
|
+
DurableClock.sleep({
|
|
265
|
+
name: 'wait-before-retry',
|
|
266
|
+
duration: '30 minutes'
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Customize the in-memory threshold (default 60 seconds)
|
|
270
|
+
yield*
|
|
271
|
+
DurableClock.sleep({
|
|
272
|
+
name: 'cooldown',
|
|
273
|
+
duration: '5 minutes',
|
|
274
|
+
inMemoryThreshold: '2 minutes' // sleeps <= 2min run in-memory
|
|
275
|
+
});
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Under the hood, a DurableClock creates a `DurableDeferred` and the engine schedules a wake-up after the duration elapses.
|
|
279
|
+
|
|
280
|
+
## DurableDeferred — Wait for External Signals
|
|
281
|
+
|
|
282
|
+
`DurableDeferred` lets a workflow pause and wait for an external event (e.g., a webhook, user approval, payment confirmation). The state is persisted, so the workflow can resume after restart.
|
|
283
|
+
|
|
284
|
+
### Creating and Awaiting
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
import { DurableDeferred } from 'effect/unstable/workflow';
|
|
288
|
+
import { Schema, Exit } from 'effect';
|
|
289
|
+
|
|
290
|
+
// Define the deferred with typed schemas
|
|
291
|
+
const PaymentConfirmation = DurableDeferred.make('payment-confirmation', {
|
|
292
|
+
success: Schema.Struct({ transactionId: Schema.String }),
|
|
293
|
+
error: Schema.String
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// Inside a workflow: wait for the signal
|
|
297
|
+
const confirmation = yield* DurableDeferred.await(PaymentConfirmation);
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
The engine registers the awaited deferred before reading it. Completing that deferred while the workflow run is still active preempts a run parked on it, retains the pending result, and replays so the completion is observed. The in-memory engine follows the same behavior as `ClusterWorkflowEngine`; this closes the race where a live completion could otherwise be missed until a later retry.
|
|
301
|
+
|
|
302
|
+
### Completing from Outside
|
|
303
|
+
|
|
304
|
+
External code (e.g., a webhook handler) completes the deferred using a **token**:
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
// Inside the workflow: generate a token to give to external systems
|
|
308
|
+
const token = yield* DurableDeferred.token(PaymentConfirmation);
|
|
309
|
+
// token is a branded string encoding workflow + execution + deferred name
|
|
310
|
+
|
|
311
|
+
// --- Later, from outside the workflow (e.g., webhook handler): ---
|
|
312
|
+
|
|
313
|
+
// Succeed
|
|
314
|
+
yield*
|
|
315
|
+
DurableDeferred.succeed(PaymentConfirmation, {
|
|
316
|
+
token,
|
|
317
|
+
value: { transactionId: 'tx_123' }
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// Or fail
|
|
321
|
+
yield*
|
|
322
|
+
DurableDeferred.fail(PaymentConfirmation, {
|
|
323
|
+
token,
|
|
324
|
+
error: 'Payment declined'
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// Or use done() with a full Exit
|
|
328
|
+
yield*
|
|
329
|
+
DurableDeferred.done(PaymentConfirmation, {
|
|
330
|
+
token,
|
|
331
|
+
exit: Exit.succeed({ transactionId: 'tx_123' })
|
|
332
|
+
});
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### Token Generation Without Being Inside a Workflow
|
|
336
|
+
|
|
337
|
+
You can generate tokens from outside a workflow if you know the workflow and payload:
|
|
338
|
+
|
|
339
|
+
```ts
|
|
340
|
+
// From execution ID
|
|
341
|
+
const token = DurableDeferred.tokenFromExecutionId(PaymentConfirmation, {
|
|
342
|
+
workflow: SendEmail,
|
|
343
|
+
executionId: 'abc123'
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// From payload (computes the execution ID)
|
|
347
|
+
const token =
|
|
348
|
+
yield*
|
|
349
|
+
DurableDeferred.tokenFromPayload(PaymentConfirmation, {
|
|
350
|
+
workflow: SendEmail,
|
|
351
|
+
payload: { to: 'user@example.com', subject: 'Hello', body: 'World' }
|
|
352
|
+
});
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### Token Parsing
|
|
356
|
+
|
|
357
|
+
Tokens are base64url-encoded and can be parsed:
|
|
358
|
+
|
|
359
|
+
```ts
|
|
360
|
+
const parsed = DurableDeferred.TokenParsed.fromString(token);
|
|
361
|
+
// { workflowName: "SendEmail", executionId: "abc123", deferredName: "payment-confirmation" }
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### Piping an Effect into a DurableDeferred
|
|
365
|
+
|
|
366
|
+
`DurableDeferred.into` runs an effect and stores its result in the deferred on completion:
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
yield* pipe(someEffect, DurableDeferred.into(PaymentConfirmation));
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### Racing with DurableDeferred
|
|
373
|
+
|
|
374
|
+
`DurableDeferred.raceAll` races multiple effects and durably stores the first result:
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
const result =
|
|
378
|
+
yield*
|
|
379
|
+
DurableDeferred.raceAll({
|
|
380
|
+
name: 'first-response',
|
|
381
|
+
success: Schema.String,
|
|
382
|
+
error: Schema.Never,
|
|
383
|
+
effects: [fetchFromA, fetchFromB]
|
|
384
|
+
});
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
## DurableQueue — Hand Work to Background Workers
|
|
388
|
+
|
|
389
|
+
`DurableQueue` lets a workflow delegate a unit of work to a **persisted background worker** and suspend until the worker records a result. The workflow calls `process` to enqueue an item and wait; a separate worker created with `worker` / `makeWorker` takes the item, runs the handler, and completes the waiting workflow through a `DurableDeferred` token.
|
|
390
|
+
|
|
391
|
+
```ts
|
|
392
|
+
import { DurableQueue, Workflow, WorkflowEngine } from 'effect/unstable/workflow';
|
|
393
|
+
import { PersistedQueue } from 'effect/unstable/persistence';
|
|
394
|
+
import { Effect, Layer, Schema } from 'effect';
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
### Defining a queue — `DurableQueue.make`
|
|
398
|
+
|
|
399
|
+
```ts
|
|
400
|
+
const ApiQueue = DurableQueue.make({
|
|
401
|
+
name: 'ApiQueue',
|
|
402
|
+
payload: { id: Schema.String },
|
|
403
|
+
success: Schema.Void, // default Schema.Void
|
|
404
|
+
error: Schema.Never, // default Schema.Never
|
|
405
|
+
idempotencyKey: (payload) => payload.id
|
|
406
|
+
});
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
The `name`, the payload/success/error schemas, and the `idempotencyKey` are **persisted coordination state**. Keep them deterministic and stable across deployments — changing them is a persistence migration. The `idempotencyKey` becomes the persisted queue item id.
|
|
410
|
+
|
|
411
|
+
### Producing — `DurableQueue.process`
|
|
412
|
+
|
|
413
|
+
Call `process` from inside a workflow handler. It encodes the payload, offers it to the persisted queue with a deferred token, suspends the workflow, and resumes with the worker's typed success or error:
|
|
414
|
+
|
|
415
|
+
```ts
|
|
416
|
+
const MyWorkflowLayer = MyWorkflow.toLayer((payload) =>
|
|
417
|
+
Effect.gen(function* () {
|
|
418
|
+
yield* DurableQueue.process(ApiQueue, { id: 'api-call-1' });
|
|
419
|
+
// resumes here once a worker records the result
|
|
420
|
+
})
|
|
421
|
+
);
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
`process(queue, payload, { retrySchedule? })` requires **`WorkflowEngine | WorkflowInstance | PersistedQueue.PersistedQueueFactory`** in context — it runs as activity-style work *inside* a running workflow. `retrySchedule` only governs retries of transient `PersistedQueueError`s while offering the item, not the handler.
|
|
425
|
+
|
|
426
|
+
### Consuming — `DurableQueue.worker` / `makeWorker`
|
|
427
|
+
|
|
428
|
+
`worker` returns a `Layer` that forks background workers; `makeWorker` returns the underlying `Effect<never>` if you want to fork it yourself:
|
|
429
|
+
|
|
430
|
+
```ts
|
|
431
|
+
const ApiWorker = DurableQueue.worker(
|
|
432
|
+
ApiQueue,
|
|
433
|
+
(payload) => Effect.log(`processing ${payload.id}`),
|
|
434
|
+
{ concurrency: 5 } // process up to 5 items concurrently; default 1
|
|
435
|
+
);
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
`worker` / `makeWorker` require **`WorkflowEngine | PersistedQueue.PersistedQueueFactory`** (plus the handler's own `R`). Note they do **not** require `WorkflowInstance`, unlike `process` — workers run outside any single workflow instance.
|
|
439
|
+
|
|
440
|
+
### Service requirements & idempotency
|
|
441
|
+
|
|
442
|
+
Provide a `PersistedQueueFactory`. Tests wire the in-memory store:
|
|
443
|
+
|
|
444
|
+
```ts
|
|
445
|
+
const PersistedQueueLayer = PersistedQueue.layer.pipe(
|
|
446
|
+
Layer.provideMerge(PersistedQueue.layerStoreMemory)
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
const AppLayer = Layer.mergeAll(MyWorkflowLayer, ApiWorker).pipe(
|
|
450
|
+
Layer.provideMerge(WorkflowEngine.layerMemory),
|
|
451
|
+
Layer.provideMerge(PersistedQueueLayer)
|
|
452
|
+
);
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
Delivery is **at least once** per the backing `PersistedQueue`, so worker handlers must be **idempotent** and tolerate retries, duplicate observations, and worker restarts.
|
|
456
|
+
|
|
457
|
+
## Compensation (Saga Pattern)
|
|
458
|
+
|
|
459
|
+
`Workflow.withCompensation` registers rollback logic that runs if the **entire workflow** fails. This enables the saga pattern for distributed transactions.
|
|
460
|
+
|
|
461
|
+
```ts
|
|
462
|
+
const handler = OrderWorkflow.toLayer((payload, executionId) =>
|
|
463
|
+
Effect.gen(function* () {
|
|
464
|
+
// Reserve inventory — if workflow fails later, compensate
|
|
465
|
+
const reservation = yield* pipe(
|
|
466
|
+
reserveInventory,
|
|
467
|
+
OrderWorkflow.withCompensation((reservationId, cause) =>
|
|
468
|
+
cancelReservation(reservationId)
|
|
469
|
+
)
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
// Charge payment — if workflow fails later, compensate
|
|
473
|
+
const charge = yield* pipe(
|
|
474
|
+
chargePayment,
|
|
475
|
+
OrderWorkflow.withCompensation((chargeId, cause) =>
|
|
476
|
+
refundPayment(chargeId)
|
|
477
|
+
)
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
// Ship order — no compensation needed for the last step
|
|
481
|
+
yield* shipOrder(reservation, charge);
|
|
482
|
+
})
|
|
483
|
+
);
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
The compensation function receives:
|
|
487
|
+
|
|
488
|
+
1. The **success value** of the compensated effect (so you can use it for rollback)
|
|
489
|
+
2. The **Cause** of the workflow failure
|
|
490
|
+
|
|
491
|
+
**Important**: Compensation only works for top-level effects in the workflow, not for nested activities.
|
|
492
|
+
|
|
493
|
+
## Workflow Annotations
|
|
494
|
+
|
|
495
|
+
### CaptureDefects
|
|
496
|
+
|
|
497
|
+
Controls whether defects (unexpected errors) are captured in the workflow result. Default: `true`.
|
|
498
|
+
|
|
499
|
+
```ts
|
|
500
|
+
const MyWorkflow = Workflow.make({ ... }).annotate(Workflow.CaptureDefects, false)
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
### SuspendOnFailure
|
|
504
|
+
|
|
505
|
+
When `true`, the workflow suspends on any error instead of failing. You can then manually resume it:
|
|
506
|
+
|
|
507
|
+
```ts
|
|
508
|
+
const MyWorkflow = Workflow.make({ ... }).annotate(Workflow.SuspendOnFailure, true)
|
|
509
|
+
|
|
510
|
+
// Later, after fixing the issue:
|
|
511
|
+
yield* MyWorkflow.resume(executionId)
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
## Workflow Scope
|
|
515
|
+
|
|
516
|
+
Access the workflow's scope, which lives for the entire execution (across replays):
|
|
517
|
+
|
|
518
|
+
```ts
|
|
519
|
+
// Get the scope
|
|
520
|
+
const workflowScope = yield* Workflow.scope;
|
|
521
|
+
|
|
522
|
+
// Provide scope to a scoped effect
|
|
523
|
+
yield* Workflow.provideScope(myScopedEffect);
|
|
524
|
+
|
|
525
|
+
// Add a finalizer to the workflow scope
|
|
526
|
+
yield*
|
|
527
|
+
Workflow.addFinalizer((exit) =>
|
|
528
|
+
Effect.log(`Workflow completed with: ${exit}`)
|
|
529
|
+
);
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
Use `Workflow.addFinalizer` for terminal work that must observe an interrupt deposited through `Workflow.interrupt`. A body-level `Effect.onExit` finalizer cannot observe that deposited workflow interrupt; workflow-scope finalization is deliberately aligned between the in-memory and cluster engines.
|
|
533
|
+
|
|
534
|
+
## WorkflowEngine
|
|
535
|
+
|
|
536
|
+
The `WorkflowEngine` is a service that orchestrates workflow execution. It handles registration, execution, replay, suspension, and resumption.
|
|
537
|
+
|
|
538
|
+
### In-Memory Engine (Testing/Development)
|
|
539
|
+
|
|
540
|
+
For testing and local development, use the in-memory engine:
|
|
541
|
+
|
|
542
|
+
```ts
|
|
543
|
+
import { WorkflowEngine } from 'effect/unstable/workflow';
|
|
544
|
+
|
|
545
|
+
const TestLayer = Layer.mergeAll(
|
|
546
|
+
SendEmailLive
|
|
547
|
+
// ... other workflow registrations
|
|
548
|
+
).pipe(Layer.provideMerge(WorkflowEngine.layerMemory));
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
**Warning**: The in-memory engine does NOT provide durability guarantees. Use it only for testing.
|
|
552
|
+
|
|
553
|
+
### Production Engine — `ClusterWorkflowEngine.layer`
|
|
554
|
+
|
|
555
|
+
For production, use `ClusterWorkflowEngine.layer` from `effect/unstable/cluster`. It wires the workflow engine into `Sharding` + `MessageStorage` so executions, activities, and durable signals survive restarts and can be distributed across runners:
|
|
556
|
+
|
|
557
|
+
```ts
|
|
558
|
+
import { Layer } from 'effect';
|
|
559
|
+
import { ClusterWorkflowEngine } from 'effect/unstable/cluster';
|
|
560
|
+
|
|
561
|
+
const WorkflowsLayer = Layer.mergeAll(
|
|
562
|
+
SendEmailLive,
|
|
563
|
+
ProcessOrderLive
|
|
564
|
+
).pipe(Layer.provideMerge(ClusterWorkflowEngine.layer));
|
|
565
|
+
|
|
566
|
+
// then provide the cluster bundle (NodeClusterSocket.layer / SingleRunner.layer / TestRunner.layer)
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
The `ClusterWorkflowEngine` requires `Sharding | MessageStorage` in context; both come from any of the cluster runtime bundles. See the `effect-rpc-cluster` skill for cluster setup.
|
|
570
|
+
|
|
571
|
+
#### Workflow shard-group routing
|
|
572
|
+
|
|
573
|
+
A workflow can be annotated with `ClusterSchema.ShardGroup` (from `effect/unstable/cluster`), exactly like an entity:
|
|
574
|
+
|
|
575
|
+
```ts
|
|
576
|
+
import { ClusterSchema } from 'effect/unstable/cluster';
|
|
577
|
+
|
|
578
|
+
const OrderWorkflow = Workflow.make({ /* ... */ })
|
|
579
|
+
.annotate(ClusterSchema.ShardGroup, () => 'workflow');
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
`ClusterWorkflowEngine` reads that annotation when computing the workflow entity's address, so the workflow's entity messages, durable clock wake-ups, and registered durable-deferred completions all route through the owning workflow's shard group. If you use any group other than `'default'`, you must include it on the appropriate runners via `ShardingConfig.availableShardGroups` / `assignedShardGroups` (e.g. `['default', 'workflow']`) — otherwise those messages have nowhere to land. See the `effect-rpc-cluster` skill for `ShardingConfig` details.
|
|
583
|
+
|
|
584
|
+
`ClusterWorkflowEngine` gives workflow execution entities and the durable-clock entity a fixed `10 seconds` idle timeout. Completed and suspended executions release the runner's bounded entity-residency slots quickly; because their state is durable, the next workflow, deferred, or clock message recreates the entity from storage. This is intentional passivation, not loss of a suspended workflow.
|
|
585
|
+
|
|
586
|
+
### Custom Engine Implementation
|
|
587
|
+
|
|
588
|
+
For production, implement the `WorkflowEngine.Encoded` interface and use `WorkflowEngine.makeUnsafe`:
|
|
589
|
+
|
|
590
|
+
```ts
|
|
591
|
+
const engine = WorkflowEngine.makeUnsafe({
|
|
592
|
+
register: (workflow, execute) => ...,
|
|
593
|
+
// execute receives { executionId, payload, discard, parent? }. The engine
|
|
594
|
+
// passes `parent` even for discard executions, so child interruption links
|
|
595
|
+
// back to the parent workflow before the deterministic execution id returns.
|
|
596
|
+
execute: (workflow, { executionId, payload, discard, parent }) => ...,
|
|
597
|
+
poll: (workflow, executionId) => ...,
|
|
598
|
+
interrupt: (workflow, executionId) => ...,
|
|
599
|
+
// Required by WorkflowEngine.Encoded. interruptUnsafe is a more
|
|
600
|
+
// direct stop that CAN bypass compensation and parent/child cleanup
|
|
601
|
+
// guarantees that `interrupt` upholds — prefer `interrupt` unless you
|
|
602
|
+
// explicitly need the harder stop.
|
|
603
|
+
interruptUnsafe: (workflow, executionId) => ...,
|
|
604
|
+
resume: (workflow, executionId) => ...,
|
|
605
|
+
activityExecute: (activity, attempt) => ...,
|
|
606
|
+
deferredResult: (deferred) => ...,
|
|
607
|
+
deferredDone: (options) => ...,
|
|
608
|
+
scheduleClock: (workflow, options) => ...
|
|
609
|
+
})
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
The `Encoded` interface works with raw/encoded values (JSON-safe), while the `WorkflowEngine` service handles schema encoding/decoding automatically.
|
|
613
|
+
|
|
614
|
+
### Suspended Retry Schedule
|
|
615
|
+
|
|
616
|
+
When a workflow suspends (waiting for an activity or deferred), the engine retries with `Schedule.min([Schedule.exponential("200 millis", 1.5), Schedule.spaced("30 seconds")])`: exponential backoff from 200ms, capped at 30s. (`Schedule.andThen` / `andThenResult` were renamed to `Schedule.concat` / `concatResult`, and the old `Schedule.either` cap pattern is now `Schedule.min`.) Override per-workflow:
|
|
617
|
+
|
|
618
|
+
```ts
|
|
619
|
+
const MyWorkflow = Workflow.make({
|
|
620
|
+
name: 'MyWorkflow',
|
|
621
|
+
payload: { id: Schema.String },
|
|
622
|
+
idempotencyKey: (p) => p.id,
|
|
623
|
+
suspendedRetrySchedule: Schedule.spaced('5 seconds')
|
|
624
|
+
});
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
## Complete Example: Order Processing Workflow
|
|
628
|
+
|
|
629
|
+
```ts
|
|
630
|
+
import { Effect, Exit, Layer, Schema, pipe } from 'effect';
|
|
631
|
+
import {
|
|
632
|
+
Activity,
|
|
633
|
+
DurableClock,
|
|
634
|
+
DurableDeferred,
|
|
635
|
+
Workflow,
|
|
636
|
+
WorkflowEngine
|
|
637
|
+
} from 'effect/unstable/workflow';
|
|
638
|
+
|
|
639
|
+
// --- Schemas ---
|
|
640
|
+
|
|
641
|
+
class OrderError extends Schema.TaggedClass<OrderError>()('OrderError', {
|
|
642
|
+
message: Schema.String
|
|
643
|
+
}) {}
|
|
644
|
+
|
|
645
|
+
// --- Deferred for external payment confirmation ---
|
|
646
|
+
|
|
647
|
+
const PaymentApproval = DurableDeferred.make('payment-approval', {
|
|
648
|
+
success: Schema.Struct({ transactionId: Schema.String }),
|
|
649
|
+
error: OrderError
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
// --- Activities ---
|
|
653
|
+
|
|
654
|
+
const validateOrder = Activity.make({
|
|
655
|
+
name: 'ValidateOrder',
|
|
656
|
+
success: Schema.Struct({ valid: Schema.Boolean }),
|
|
657
|
+
error: OrderError,
|
|
658
|
+
execute: Effect.gen(function* () {
|
|
659
|
+
// validate order details
|
|
660
|
+
return { valid: true };
|
|
661
|
+
})
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
const reserveInventory = Activity.make({
|
|
665
|
+
name: 'ReserveInventory',
|
|
666
|
+
success: Schema.Struct({ reservationId: Schema.String }),
|
|
667
|
+
error: OrderError,
|
|
668
|
+
execute: Effect.gen(function* () {
|
|
669
|
+
const key = yield* Activity.idempotencyKey('reserve');
|
|
670
|
+
// call inventory service with idempotency key
|
|
671
|
+
return { reservationId: 'res_001' };
|
|
672
|
+
})
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
const shipOrder = Activity.make({
|
|
676
|
+
name: 'ShipOrder',
|
|
677
|
+
success: Schema.Void,
|
|
678
|
+
error: OrderError,
|
|
679
|
+
execute: Effect.gen(function* () {
|
|
680
|
+
// call shipping service
|
|
681
|
+
})
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
// --- Workflow ---
|
|
685
|
+
|
|
686
|
+
const ProcessOrder = Workflow.make({
|
|
687
|
+
name: 'ProcessOrder',
|
|
688
|
+
payload: {
|
|
689
|
+
orderId: Schema.String,
|
|
690
|
+
items: Schema.Array(Schema.String)
|
|
691
|
+
},
|
|
692
|
+
idempotencyKey: (payload) => payload.orderId,
|
|
693
|
+
success: Schema.Struct({ transactionId: Schema.String }),
|
|
694
|
+
error: OrderError
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
const ProcessOrderLive = ProcessOrder.toLayer((payload, executionId) =>
|
|
698
|
+
Effect.gen(function* () {
|
|
699
|
+
// Step 1: Validate
|
|
700
|
+
yield* validateOrder;
|
|
701
|
+
|
|
702
|
+
// Step 2: Reserve inventory with compensation
|
|
703
|
+
const reservation = yield* pipe(
|
|
704
|
+
reserveInventory,
|
|
705
|
+
ProcessOrder.withCompensation(({ reservationId }, _cause) =>
|
|
706
|
+
Effect.log(`Cancelling reservation ${reservationId}`)
|
|
707
|
+
)
|
|
708
|
+
);
|
|
709
|
+
|
|
710
|
+
// Step 3: Wait for payment (external signal)
|
|
711
|
+
const payment = yield* DurableDeferred.await(PaymentApproval);
|
|
712
|
+
|
|
713
|
+
// Step 4: Wait before shipping
|
|
714
|
+
yield* DurableClock.sleep({
|
|
715
|
+
name: 'pre-ship-delay',
|
|
716
|
+
duration: '5 minutes'
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
// Step 5: Ship
|
|
720
|
+
yield* shipOrder;
|
|
721
|
+
|
|
722
|
+
return payment;
|
|
723
|
+
})
|
|
724
|
+
);
|
|
725
|
+
|
|
726
|
+
// --- Running ---
|
|
727
|
+
|
|
728
|
+
const MainLive = ProcessOrderLive.pipe(
|
|
729
|
+
Layer.provideMerge(WorkflowEngine.layerMemory)
|
|
730
|
+
);
|
|
731
|
+
|
|
732
|
+
// Execute the workflow
|
|
733
|
+
const program = Effect.gen(function* () {
|
|
734
|
+
const executionId = yield* ProcessOrder.execute(
|
|
735
|
+
{ orderId: 'order_123', items: ['item_a'] },
|
|
736
|
+
{ discard: true }
|
|
737
|
+
);
|
|
738
|
+
|
|
739
|
+
// Later, from a webhook: complete the payment deferred
|
|
740
|
+
const token = DurableDeferred.tokenFromExecutionId(PaymentApproval, {
|
|
741
|
+
workflow: ProcessOrder,
|
|
742
|
+
executionId
|
|
743
|
+
});
|
|
744
|
+
yield* DurableDeferred.succeed(PaymentApproval, {
|
|
745
|
+
token,
|
|
746
|
+
value: { transactionId: 'tx_abc' }
|
|
747
|
+
});
|
|
748
|
+
});
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
## Anti-Patterns
|
|
752
|
+
|
|
753
|
+
### DON'T put side effects outside activities
|
|
754
|
+
|
|
755
|
+
Non-activity code re-executes on every replay. Only put deterministic logic outside activities.
|
|
756
|
+
|
|
757
|
+
```ts
|
|
758
|
+
// BAD — this HTTP call runs on every replay
|
|
759
|
+
const result = yield* httpClient.get('/api/data');
|
|
760
|
+
|
|
761
|
+
// GOOD — wrap in an activity
|
|
762
|
+
const fetchData = Activity.make({
|
|
763
|
+
name: 'FetchData',
|
|
764
|
+
success: Schema.String,
|
|
765
|
+
execute: httpClient.get('/api/data')
|
|
766
|
+
});
|
|
767
|
+
const result = yield* fetchData;
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
### DON'T use non-deterministic logic outside activities
|
|
771
|
+
|
|
772
|
+
Random numbers, current time, UUIDs — these all produce different values on replay.
|
|
773
|
+
|
|
774
|
+
```ts
|
|
775
|
+
// BAD
|
|
776
|
+
const id = yield* Effect.sync(() => crypto.randomUUID());
|
|
777
|
+
|
|
778
|
+
// GOOD
|
|
779
|
+
const generateId = Activity.make({
|
|
780
|
+
name: 'GenerateId',
|
|
781
|
+
success: Schema.String,
|
|
782
|
+
execute: Effect.sync(() => crypto.randomUUID())
|
|
783
|
+
});
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
### DON'T nest compensations inside activities
|
|
787
|
+
|
|
788
|
+
Compensation finalizers are only registered for top-level effects in the workflow.
|
|
789
|
+
|
|
790
|
+
## Integration with Effect Cluster
|
|
791
|
+
|
|
792
|
+
For production durability and distribution, swap `WorkflowEngine.layerMemory` for `ClusterWorkflowEngine.layer` (from `effect/unstable/cluster`):
|
|
793
|
+
|
|
794
|
+
```ts
|
|
795
|
+
import { ClusterWorkflowEngine } from 'effect/unstable/cluster';
|
|
796
|
+
import { NodeClusterSocket } from '@effect/platform-node';
|
|
797
|
+
|
|
798
|
+
const MainLive = Layer.mergeAll(SendEmailLive, ProcessOrderLive).pipe(
|
|
799
|
+
Layer.provideMerge(ClusterWorkflowEngine.layer),
|
|
800
|
+
Layer.provide(
|
|
801
|
+
NodeClusterSocket.layer({ storage: 'sql' }).pipe(Layer.provide(SqlClientLayer))
|
|
802
|
+
)
|
|
803
|
+
);
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
`ClusterWorkflowEngine.layer` builds an `Entity` per workflow under the hood — durable execution state lives in `MessageStorage`, runner-to-runner routing comes from `Sharding`, and replays are driven by the entity's mailbox. The `Workflow.Execution<Name>` type in the context represents the execution identity within the cluster.
|
|
807
|
+
|
|
808
|
+
The internal workflow and durable-clock entities passivate after `10 seconds` of inactivity so they do not retain scarce runner residency while completed or suspended. Plan runner capacity with `ShardingConfig.maxResidentEntities`; persisted wake-ups remain in storage until a slot is available.
|
|
809
|
+
|
|
810
|
+
To expose workflows over RPC or HTTP without writing dispatch glue, use `WorkflowProxy.toRpcGroup` / `WorkflowProxy.toHttpApiGroup` and the matching `WorkflowProxyServer.layerRpcHandlers` / `layerHttpApi`. See the `effect-rpc-cluster` skill for the bridge patterns.
|