@durable-effect/jobs 0.0.1-next.4 → 0.0.1-next.6

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.
Files changed (57) hide show
  1. package/README.md +467 -354
  2. package/dist/definitions/continuous.d.ts +25 -8
  3. package/dist/definitions/continuous.d.ts.map +1 -1
  4. package/dist/definitions/continuous.js +18 -8
  5. package/dist/definitions/continuous.js.map +1 -1
  6. package/dist/definitions/debounce.d.ts +18 -4
  7. package/dist/definitions/debounce.d.ts.map +1 -1
  8. package/dist/definitions/debounce.js.map +1 -1
  9. package/dist/definitions/task.d.ts +13 -7
  10. package/dist/definitions/task.d.ts.map +1 -1
  11. package/dist/definitions/task.js.map +1 -1
  12. package/dist/errors.d.ts +1 -14
  13. package/dist/errors.d.ts.map +1 -1
  14. package/dist/errors.js +0 -8
  15. package/dist/errors.js.map +1 -1
  16. package/dist/handlers/continuous/handler.d.ts.map +1 -1
  17. package/dist/handlers/continuous/handler.js +21 -21
  18. package/dist/handlers/continuous/handler.js.map +1 -1
  19. package/dist/handlers/continuous/types.d.ts +2 -2
  20. package/dist/handlers/continuous/types.d.ts.map +1 -1
  21. package/dist/handlers/debounce/handler.d.ts.map +1 -1
  22. package/dist/handlers/debounce/handler.js +23 -12
  23. package/dist/handlers/debounce/handler.js.map +1 -1
  24. package/dist/handlers/debounce/types.d.ts +2 -2
  25. package/dist/handlers/debounce/types.d.ts.map +1 -1
  26. package/dist/handlers/task/handler.d.ts.map +1 -1
  27. package/dist/handlers/task/handler.js +20 -12
  28. package/dist/handlers/task/handler.js.map +1 -1
  29. package/dist/index.d.ts +1 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +1 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/registry/registry.d.ts +5 -4
  34. package/dist/registry/registry.d.ts.map +1 -1
  35. package/dist/registry/registry.js +4 -2
  36. package/dist/registry/registry.js.map +1 -1
  37. package/dist/registry/typed.d.ts +24 -28
  38. package/dist/registry/typed.d.ts.map +1 -1
  39. package/dist/registry/typed.js.map +1 -1
  40. package/dist/registry/types.d.ts +83 -46
  41. package/dist/registry/types.d.ts.map +1 -1
  42. package/dist/retry/errors.d.ts +1 -3
  43. package/dist/retry/errors.d.ts.map +1 -1
  44. package/dist/retry/errors.js +1 -3
  45. package/dist/retry/errors.js.map +1 -1
  46. package/dist/retry/executor.js +1 -1
  47. package/dist/retry/executor.js.map +1 -1
  48. package/dist/retry/types.d.ts +3 -7
  49. package/dist/retry/types.d.ts.map +1 -1
  50. package/dist/services/execution.d.ts +0 -29
  51. package/dist/services/execution.d.ts.map +1 -1
  52. package/dist/services/execution.js +6 -32
  53. package/dist/services/execution.js.map +1 -1
  54. package/dist/services/index.d.ts +1 -1
  55. package/dist/services/index.d.ts.map +1 -1
  56. package/dist/services/index.js.map +1 -1
  57. package/package.json +1 -1
package/README.md CHANGED
@@ -1,490 +1,603 @@
1
1
  # @durable-effect/jobs
2
2
 
3
- Durable jobs for Cloudflare Workers built on Effect. This package provides high-level abstractions for common durable patterns:
3
+ Durable job abstractions for Cloudflare Workers built on [Effect](https://effect.website/). Write business logic as Effect programs while the framework handles durability, retries, and scheduling.
4
4
 
5
- - **Continuous** - Execute functions on a schedule (this guide)
6
- - **Debounce** - Accumulate events and flush on schedule/threshold (coming soon)
7
- - **WorkerPool** - Process events one at a time with retries (coming soon)
5
+ ## Overview
8
6
 
9
- ## Installation
7
+ This package provides three job types for different patterns:
10
8
 
11
- ```bash
12
- npm install @durable-effect/jobs effect
13
- ```
14
-
15
- ## Continuous Primitive
16
-
17
- The Continuous primitive executes a function on a recurring schedule. Perfect for:
9
+ | Job Type | Purpose | Use Cases |
10
+ |----------|---------|-----------|
11
+ | **Continuous** | Recurring work on a schedule | Token refresh, health checks, daily reports |
12
+ | **Debounce** | Batch rapid events before processing | Webhook coalescing, update batching |
13
+ | **Task** | Event-driven state machines | Order workflows, multi-step processes |
18
14
 
19
- - Token refresh
20
- - Periodic data sync
21
- - Health checks
22
- - Report generation
23
- - Cache warming
15
+ Each job instance runs in its own Durable Object, providing:
16
+ - Persistent state that survives restarts
17
+ - Durable alarms for scheduling
18
+ - Automatic retry with configurable backoff
19
+ - Type-safe client with full inference
24
20
 
25
- ### Quick Start
21
+ ## Quick Start
26
22
 
27
- ```ts
23
+ ```typescript
28
24
  import { Effect, Schema } from "effect";
29
- import { Continuous, createDurableJobs } from "@durable-effect/jobs";
25
+ import { createDurableJobs, Continuous } from "@durable-effect/jobs";
30
26
 
31
- // 1. Define your primitive (name comes from the object key in step 2)
27
+ // 1. Define a job
32
28
  const tokenRefresher = Continuous.make({
33
- // Schema for persistent state
34
29
  stateSchema: Schema.Struct({
35
30
  accessToken: Schema.String,
36
31
  refreshToken: Schema.String,
37
32
  expiresAt: Schema.Number,
38
33
  }),
39
-
40
- // Execute every 30 minutes
41
34
  schedule: Continuous.every("30 minutes"),
42
-
43
- // The function to run
44
35
  execute: (ctx) =>
45
36
  Effect.gen(function* () {
46
- console.log(`Refreshing token (run #${ctx.runCount})`);
47
-
48
- // Access current state
49
- const { refreshToken } = ctx.state;
50
-
51
- // Call your refresh API
52
- const newTokens = yield* refreshTokens(refreshToken);
53
-
54
- // Update state (persisted automatically)
55
- ctx.setState({
56
- accessToken: newTokens.access_token,
57
- refreshToken: newTokens.refresh_token,
58
- expiresAt: Date.now() + newTokens.expires_in * 1000,
59
- });
37
+ const state = yield* ctx.state;
38
+ const newToken = yield* refreshAccessToken(state.refreshToken);
39
+ yield* ctx.setState({ ...state, accessToken: newToken, expiresAt: Date.now() + 1800000 });
60
40
  }),
61
41
  });
62
42
 
63
- // 2. Create the Durable Object and Client - keys become primitive names
64
- const { Jobs, JobsClient } = createDurableJobs({
65
- tokenRefresher,
66
- });
43
+ // 2. Create engine and client
44
+ const { Jobs, JobsClient } = createDurableJobs({ tokenRefresher });
67
45
 
68
46
  // 3. Export the Durable Object class
69
47
  export { Jobs };
70
48
 
71
- // 4. Use in your worker
49
+ // 4. Use the client in your worker
72
50
  export default {
73
- async fetch(request: Request, env: Env): Promise<Response> {
74
- const client = JobsClient.fromBinding(env.PRIMITIVES);
75
-
76
- // Start a token refresher for a user (name matches the key from step 2)
77
- await client.continuous("tokenRefresher").start({
78
- id: "user-123",
79
- input: {
80
- accessToken: "",
81
- refreshToken: "rt_initial_token",
82
- expiresAt: 0,
83
- },
84
- });
85
-
86
- return new Response("Token refresher started!");
51
+ async fetch(request: Request, env: Env) {
52
+ const client = JobsClient.fromBinding(env.JOBS);
53
+
54
+ await Effect.runPromise(
55
+ client.continuous("tokenRefresher").start({
56
+ id: "user-123",
57
+ input: { accessToken: "", refreshToken: "rt_abc", expiresAt: 0 },
58
+ })
59
+ );
60
+
61
+ return new Response("OK");
87
62
  },
88
63
  };
89
64
  ```
90
65
 
91
- ### wrangler.toml Configuration
92
-
93
- ```toml
94
- [[durable_objects.bindings]]
95
- name = "PRIMITIVES"
96
- class_name = "Jobs"
66
+ Configure your `wrangler.jsonc`:
67
+
68
+ ```jsonc
69
+ {
70
+ "$schema": "node_modules/wrangler/config-schema.json",
71
+ "name": "my-worker",
72
+ "main": "src/worker.ts",
73
+ "compatibility_date": "2024-11-27",
74
+ "compatibility_flags": ["nodejs_compat"],
75
+
76
+ "durable_objects": {
77
+ "bindings": [
78
+ {
79
+ "name": "JOBS",
80
+ "class_name": "Jobs"
81
+ }
82
+ ]
83
+ },
97
84
 
98
- [[migrations]]
99
- tag = "v1"
100
- new_classes = ["Jobs"]
85
+ "migrations": [
86
+ {
87
+ "tag": "v1",
88
+ "new_classes": ["Jobs"]
89
+ }
90
+ ]
91
+ }
101
92
  ```
102
93
 
103
- ## API Reference
94
+ ---
104
95
 
105
- ### `Continuous.make(config)`
96
+ ## Defining Jobs
106
97
 
107
- Creates a continuous primitive definition. The name is assigned when you register
108
- the primitive via `createDurableJobs()` - the object key becomes the name.
98
+ ### Continuous Jobs
109
99
 
110
- ```ts
111
- const myPrimitive = Continuous.make({
112
- stateSchema: Schema.Struct({ ... }),
113
- schedule: Continuous.every("1 hour"),
114
- execute: (ctx) => Effect.succeed(undefined),
115
- });
100
+ Execute on a fixed schedule. Best for recurring background work.
116
101
 
117
- // Name "myPrimitive" comes from the key
118
- const { Jobs, JobsClient } = createDurableJobs({
119
- myPrimitive,
102
+ ```typescript
103
+ import { Continuous } from "@durable-effect/jobs";
104
+ import { Backoff } from "@durable-effect/core";
105
+
106
+ const healthChecker = Continuous.make({
107
+ stateSchema: Schema.Struct({
108
+ lastCheckAt: Schema.Number,
109
+ consecutiveFailures: Schema.Number,
110
+ }),
111
+
112
+ // Schedule options
113
+ schedule: Continuous.every("5 minutes"), // or Continuous.cron("0 */5 * * *")
114
+
115
+ // Execute immediately on start (default: true)
116
+ startImmediately: true,
117
+
118
+ // Optional retry configuration
119
+ retry: {
120
+ maxAttempts: 3,
121
+ delay: Backoff.exponential({ base: "1 second", max: "30 seconds" }),
122
+ },
123
+
124
+ execute: (ctx) =>
125
+ Effect.gen(function* () {
126
+ const state = yield* ctx.state;
127
+
128
+ // ctx provides rich metadata
129
+ console.log(`Run #${ctx.runCount}, attempt ${ctx.attempt}`);
130
+
131
+ const healthy = yield* checkHealth();
132
+ if (!healthy && state.consecutiveFailures > 10) {
133
+ // Terminate removes all state and cancels the schedule
134
+ return yield* ctx.terminate({ reason: "Too many failures" });
135
+ }
136
+
137
+ yield* ctx.updateState((s) => ({
138
+ lastCheckAt: Date.now(),
139
+ consecutiveFailures: healthy ? 0 : s.consecutiveFailures + 1,
140
+ }));
141
+ }),
120
142
  });
121
143
  ```
122
144
 
123
- #### Parameters
145
+ **Context properties:**
124
146
 
125
- | Parameter | Type | Description |
126
- |-----------|------|-------------|
127
- | `config.stateSchema` | `Schema.Schema<S>` | Effect Schema for validating and serializing state |
128
- | `config.schedule` | `ContinuousSchedule` | When to execute (see Schedules below) |
129
- | `config.startImmediately` | `boolean` | Execute immediately on start (default: `true`) |
130
- | `config.execute` | `(ctx) => Effect<void, E, R>` | Function to execute on schedule |
131
- | `config.onError` | `(error, ctx) => Effect<void>` | Optional error handler |
147
+ | Property | Type | Description |
148
+ |----------|------|-------------|
149
+ | `state` | `Effect<S>` | Current state (yields the value) |
150
+ | `setState(s)` | `Effect<void>` | Replace entire state |
151
+ | `updateState(fn)` | `Effect<void>` | Transform state |
152
+ | `terminate(opts?)` | `Effect<never>` | Stop job and purge state |
153
+ | `instanceId` | `string` | Unique DO instance ID |
154
+ | `jobName` | `string` | Registered job name |
155
+ | `runCount` | `number` | Execution count (1-indexed) |
156
+ | `attempt` | `number` | Current retry attempt (1 = first try) |
157
+ | `isRetry` | `boolean` | Whether this is a retry |
132
158
 
133
- ### Schedules
159
+ ---
134
160
 
135
- #### `Continuous.every(interval)`
161
+ ### Debounce Jobs
136
162
 
137
- Execute at a fixed interval.
163
+ Collect events and process them in batches. Flushes after a delay or when max events reached.
138
164
 
139
- ```ts
140
- Continuous.every("30 minutes")
141
- Continuous.every("1 hour")
142
- Continuous.every("24 hours")
143
- Continuous.every(Duration.minutes(30))
144
- ```
165
+ ```typescript
166
+ import { Debounce } from "@durable-effect/jobs";
145
167
 
146
- #### `Continuous.cron(expression)` (coming soon)
168
+ const webhookBatcher = Debounce.make({
169
+ // Schema for incoming events
170
+ eventSchema: Schema.Struct({
171
+ type: Schema.String,
172
+ contactId: Schema.String,
173
+ data: Schema.Unknown,
174
+ }),
147
175
 
148
- Execute based on a cron expression.
176
+ // Optional: separate state schema (defaults to eventSchema)
177
+ stateSchema: Schema.Struct({
178
+ events: Schema.Array(Schema.Unknown),
179
+ count: Schema.Number,
180
+ }),
149
181
 
150
- ```ts
151
- // Every day at midnight
152
- Continuous.cron("0 0 * * *")
182
+ // Flush timing
183
+ flushAfter: "5 seconds",
184
+ maxEvents: 100, // Optional: flush early if reached
153
185
 
154
- // Every Monday at 9am
155
- Continuous.cron("0 9 * * 1")
186
+ // Optional: custom event reducer (default: keep latest event)
187
+ onEvent: (ctx) =>
188
+ Effect.succeed({
189
+ events: [...ctx.state.events, ctx.event],
190
+ count: ctx.state.count + 1,
191
+ }),
156
192
 
157
- // Every hour
158
- Continuous.cron("0 * * * *")
193
+ // Process the batch
194
+ execute: (ctx) =>
195
+ Effect.gen(function* () {
196
+ const state = yield* ctx.state;
197
+ const count = yield* ctx.eventCount;
198
+
199
+ console.log(`Flushing ${count} events, reason: ${ctx.flushReason}`);
200
+ yield* sendWebhookBatch(state.events);
201
+ }),
202
+ });
159
203
  ```
160
204
 
161
- ### ContinuousContext
205
+ **Execute context:**
162
206
 
163
- The context object passed to your `execute` function:
207
+ | Property | Type | Description |
208
+ |----------|------|-------------|
209
+ | `state` | `Effect<S>` | Accumulated state |
210
+ | `eventCount` | `Effect<number>` | Total events received |
211
+ | `flushReason` | `string` | `"maxEvents"` \| `"flushAfter"` \| `"manual"` |
212
+ | `debounceStartedAt` | `Effect<number>` | When first event arrived |
213
+ | `attempt` | `number` | Retry attempt |
214
+ | `isRetry` | `boolean` | Whether this is a retry |
164
215
 
165
- ```ts
166
- interface ContinuousContext<S> {
167
- // Current state (read-only reference)
168
- readonly state: S;
216
+ **Event context (onEvent):**
169
217
 
170
- // Replace the entire state
171
- readonly setState: (state: S) => void;
218
+ | Property | Type | Description |
219
+ |----------|------|-------------|
220
+ | `event` | `I` | The incoming event |
221
+ | `state` | `S` | Current accumulated state |
222
+ | `eventCount` | `number` | Events so far |
223
+ | `instanceId` | `string` | DO instance ID |
172
224
 
173
- // Update state via function
174
- readonly updateState: (fn: (current: S) => S) => void;
225
+ ---
175
226
 
176
- // Unique instance identifier
177
- readonly instanceId: string;
227
+ ### Task Jobs
178
228
 
179
- // Number of times execute has been called
180
- readonly runCount: number;
229
+ User-controlled state machines. You decide when to schedule execution via events.
181
230
 
182
- // Name of this primitive
183
- readonly primitiveName: string;
184
- }
185
- ```
231
+ ```typescript
232
+ import { Task } from "@durable-effect/jobs";
233
+ import { Duration } from "effect";
186
234
 
187
- ### Client Methods
235
+ const orderProcessor = Task.make({
236
+ stateSchema: Schema.Struct({
237
+ orderId: Schema.String,
238
+ status: Schema.Literal("pending", "processing", "shipped", "delivered"),
239
+ attempts: Schema.Number,
240
+ }),
188
241
 
189
- #### `client.continuous(name).start({ id, input })`
242
+ eventSchema: Schema.Union(
243
+ Schema.Struct({ _tag: Schema.Literal("OrderPlaced"), orderId: Schema.String }),
244
+ Schema.Struct({ _tag: Schema.Literal("PaymentReceived") }),
245
+ Schema.Struct({ _tag: Schema.Literal("Shipped"), trackingNumber: Schema.String }),
246
+ ),
190
247
 
191
- Start a continuous primitive instance.
248
+ // Handle incoming events
249
+ onEvent: (event, ctx) =>
250
+ Effect.gen(function* () {
251
+ switch (event._tag) {
252
+ case "OrderPlaced":
253
+ yield* ctx.setState({
254
+ orderId: event.orderId,
255
+ status: "pending",
256
+ attempts: 0,
257
+ });
258
+ yield* ctx.schedule(Duration.minutes(5)); // Check payment in 5 min
259
+ break;
260
+
261
+ case "PaymentReceived":
262
+ yield* ctx.updateState((s) => ({ ...s, status: "processing" }));
263
+ break;
264
+
265
+ case "Shipped":
266
+ yield* ctx.updateState((s) => ({ ...s, status: "shipped" }));
267
+ yield* ctx.schedule(Duration.hours(24)); // Check delivery tomorrow
268
+ break;
269
+ }
270
+ }),
192
271
 
193
- ```ts
194
- const result = await client.continuous("tokenRefresher").start({
195
- id: "user-123", // Unique instance ID
196
- input: { ... }, // Initial state (must match stateSchema)
197
- });
272
+ // Execute when alarm fires
273
+ execute: (ctx) =>
274
+ Effect.gen(function* () {
275
+ const state = yield* ctx.state;
276
+ if (!state) return;
277
+
278
+ if (state.status === "pending") {
279
+ // Still pending after 5 min - send reminder
280
+ yield* sendPaymentReminder(state.orderId);
281
+ yield* ctx.schedule(Duration.minutes(30)); // Check again
282
+ }
283
+
284
+ if (state.status === "shipped") {
285
+ const delivered = yield* checkDelivery(state.orderId);
286
+ if (delivered) {
287
+ yield* ctx.updateState((s) => ({ ...s, status: "delivered" }));
288
+ yield* ctx.terminate(); // Order complete
289
+ } else {
290
+ yield* ctx.schedule(Duration.hours(24)); // Check again tomorrow
291
+ }
292
+ }
293
+ }),
294
+
295
+ // Optional: handle idle state (no alarm scheduled)
296
+ onIdle: (ctx) =>
297
+ Effect.gen(function* () {
298
+ // Schedule cleanup in 1 hour
299
+ yield* ctx.schedule(Duration.hours(1));
300
+ }),
198
301
 
199
- // Result:
200
- // { _type: "continuous.start", instanceId: string, created: boolean, status: string }
302
+ // Optional: handle errors
303
+ onError: (error, ctx) =>
304
+ Effect.gen(function* () {
305
+ yield* Effect.logError("Task failed", error);
306
+ yield* ctx.updateState((s) => ({ ...s, attempts: s.attempts + 1 }));
307
+ yield* ctx.schedule(Duration.seconds(30)); // Retry
308
+ }),
309
+ });
201
310
  ```
202
311
 
203
- If the instance already exists, returns `{ created: false }` with current status.
312
+ **Event context (onEvent):**
313
+
314
+ | Property | Type | Description |
315
+ |----------|------|-------------|
316
+ | `state` | `Effect<S \| null>` | Current state (null if first event) |
317
+ | `setState(s)` | `Effect<void>` | Set state |
318
+ | `updateState(fn)` | `Effect<void>` | Transform state |
319
+ | `schedule(when)` | `Effect<void>` | Schedule execution |
320
+ | `cancelSchedule()` | `Effect<void>` | Cancel scheduled execution |
321
+ | `getScheduledTime()` | `Effect<number \| null>` | Get scheduled time |
322
+ | `terminate()` | `Effect<never>` | Terminate and purge state |
323
+ | `isFirstEvent` | `boolean` | True if state was null |
324
+ | `eventCount` | `Effect<number>` | Total events received |
325
+
326
+ **Execute context:**
327
+
328
+ | Property | Type | Description |
329
+ |----------|------|-------------|
330
+ | `state` | `Effect<S \| null>` | Current state |
331
+ | `setState/updateState` | `Effect<void>` | Modify state |
332
+ | `schedule/cancelSchedule` | `Effect<void>` | Control scheduling |
333
+ | `terminate()` | `Effect<never>` | Terminate task |
334
+ | `executeCount` | `Effect<number>` | Times execute has run |
335
+ | `eventCount` | `Effect<number>` | Total events received |
336
+ | `createdAt` | `Effect<number>` | Task creation time |
337
+
338
+ ---
204
339
 
205
- #### `client.continuous(name).stop(id, options?)`
340
+ ## Using the Client
206
341
 
207
- Stop a running instance.
342
+ ### Setup
208
343
 
209
- ```ts
210
- const result = await client.continuous("tokenRefresher").stop("user-123", {
211
- reason: "User logged out", // Optional reason
344
+ ```typescript
345
+ const { Jobs, JobsClient } = createDurableJobs({
346
+ tokenRefresher,
347
+ webhookBatcher,
348
+ orderProcessor,
212
349
  });
213
350
 
214
- // Result:
215
- // { _type: "continuous.stop", stopped: boolean, reason: string }
351
+ // In your worker
352
+ const client = JobsClient.fromBinding(env.JOBS);
216
353
  ```
217
354
 
218
- #### `client.continuous(name).trigger(id)`
355
+ ### Continuous Client
356
+
357
+ ```typescript
358
+ // Start a job instance
359
+ const result = yield* client.continuous("tokenRefresher").start({
360
+ id: "user-123", // Instance identifier
361
+ input: { /* initial state */ },
362
+ });
363
+ // Returns: { created: boolean, instanceId: string, status: JobStatus }
219
364
 
220
- Manually trigger immediate execution (bypasses schedule).
365
+ // Trigger immediate execution (bypass schedule)
366
+ yield* client.continuous("tokenRefresher").trigger("user-123");
221
367
 
222
- ```ts
223
- const result = await client.continuous("tokenRefresher").trigger("user-123");
368
+ // Check status
369
+ const status = yield* client.continuous("tokenRefresher").status("user-123");
370
+ // Returns: { status: "running" | "stopped" | "not_found", runCount?, nextRunAt? }
224
371
 
225
- // Result:
226
- // { _type: "continuous.trigger", triggered: boolean }
372
+ // Get current state
373
+ const { state } = yield* client.continuous("tokenRefresher").getState("user-123");
374
+
375
+ // Terminate (cancel alarm + delete all state)
376
+ yield* client.continuous("tokenRefresher").terminate("user-123", {
377
+ reason: "User requested",
378
+ });
227
379
  ```
228
380
 
229
- #### `client.continuous(name).status(id)`
381
+ ### Debounce Client
382
+
383
+ ```typescript
384
+ // Add an event (creates instance if needed)
385
+ const result = yield* client.debounce("webhookBatcher").add({
386
+ id: "contact-456",
387
+ event: { type: "contact.updated", contactId: "456", data: {} },
388
+ });
389
+ // Returns: { created: boolean, eventCount: number, willFlushAt: number | null }
390
+
391
+ // Force immediate flush
392
+ yield* client.debounce("webhookBatcher").flush("contact-456");
230
393
 
231
- Get current status of an instance.
394
+ // Clear without processing
395
+ yield* client.debounce("webhookBatcher").clear("contact-456");
232
396
 
233
- ```ts
234
- const result = await client.continuous("tokenRefresher").status("user-123");
397
+ // Check status
398
+ const status = yield* client.debounce("webhookBatcher").status("contact-456");
399
+ // Returns: { status: "debouncing" | "idle" | "not_found", eventCount?, willFlushAt? }
235
400
 
236
- // Result:
237
- // { _type: "continuous.status", status: string, runCount: number, nextRunAt?: number }
401
+ // Get accumulated state
402
+ const { state } = yield* client.debounce("webhookBatcher").getState("contact-456");
238
403
  ```
239
404
 
240
- #### `client.continuous(name).getState(id)`
405
+ ### Task Client
406
+
407
+ ```typescript
408
+ // Send an event (creates instance if needed)
409
+ const result = yield* client.task("orderProcessor").send({
410
+ id: "order-789",
411
+ event: { _tag: "OrderPlaced", orderId: "order-789" },
412
+ });
413
+ // Returns: { created: boolean, instanceId: string, scheduledAt: number | null }
241
414
 
242
- Get current state of an instance.
415
+ // Trigger immediate execution
416
+ yield* client.task("orderProcessor").trigger("order-789");
243
417
 
244
- ```ts
245
- const result = await client.continuous("tokenRefresher").getState("user-123");
418
+ // Check status
419
+ const status = yield* client.task("orderProcessor").status("order-789");
420
+ // Returns: { status: "active" | "idle" | "not_found", scheduledAt?, eventCount? }
246
421
 
247
- // Result:
248
- // { _type: "continuous.getState", state: S | null }
249
- ```
422
+ // Get state
423
+ const { state, scheduledAt } = yield* client.task("orderProcessor").getState("order-789");
250
424
 
251
- ## Error Handling
425
+ // Terminate
426
+ yield* client.task("orderProcessor").terminate("order-789");
427
+ ```
252
428
 
253
- ### Using `onError`
429
+ ---
254
430
 
255
- Handle errors gracefully without failing the execution:
431
+ ## Common Concepts
256
432
 
257
- ```ts
258
- const resilientPrimitive = Continuous.make({
259
- stateSchema: Schema.Struct({
260
- data: Schema.String,
261
- errorCount: Schema.Number,
262
- lastError: Schema.NullOr(Schema.String),
263
- }),
264
- schedule: Continuous.every("5 minutes"),
433
+ ### Instance IDs
265
434
 
266
- execute: (ctx) =>
267
- Effect.gen(function* () {
268
- // This might fail
269
- const data = yield* fetchExternalData();
270
- ctx.updateState((s) => ({
271
- ...s,
272
- data,
273
- errorCount: 0,
274
- lastError: null,
275
- }));
276
- }),
435
+ Each job instance is identified by a unique ID. The internal Durable Object instance ID follows the pattern:
277
436
 
278
- onError: (error, ctx) =>
279
- Effect.sync(() => {
280
- console.error(`Execution failed: ${error}`);
281
- ctx.updateState((s) => ({
282
- ...s,
283
- errorCount: s.errorCount + 1,
284
- lastError: String(error),
285
- }));
286
- }),
287
- });
437
+ ```
438
+ {jobType}:{jobName}:{userProvidedId}
288
439
  ```
289
440
 
290
- ### Typed Errors
441
+ For example: `continuous:tokenRefresher:user-123`
291
442
 
292
- Define typed errors for better error handling:
443
+ ### State Schemas
293
444
 
294
- ```ts
295
- class RefreshError extends Data.TaggedError("RefreshError")<{
296
- readonly reason: string;
297
- }> {}
445
+ All jobs use Effect Schema for state validation and serialization:
298
446
 
299
- const typedPrimitive = Continuous.make({
300
- stateSchema: Schema.Struct({ token: Schema.String }),
301
- schedule: Continuous.every("1 hour"),
447
+ ```typescript
448
+ const stateSchema = Schema.Struct({
449
+ // Basic types
450
+ count: Schema.Number,
451
+ name: Schema.String,
452
+ active: Schema.Boolean,
302
453
 
303
- execute: (ctx) =>
304
- Effect.gen(function* () {
305
- const result = yield* refreshToken(ctx.state.token);
306
- if (!result.ok) {
307
- return yield* Effect.fail(new RefreshError({ reason: result.error }));
308
- }
309
- ctx.setState({ token: result.token });
310
- }),
454
+ // Rich types (automatically encoded/decoded)
455
+ createdAt: Schema.DateFromSelf,
456
+ data: Schema.Unknown,
311
457
 
312
- onError: (error, ctx) =>
313
- Effect.sync(() => {
314
- // error is typed as RefreshError
315
- console.error(`Refresh failed: ${error.reason}`);
316
- }),
458
+ // Optional fields
459
+ lastError: Schema.optional(Schema.String),
317
460
  });
318
461
  ```
319
462
 
320
- ## Testing
463
+ State is validated on read/write. Invalid state throws `ValidationError`.
321
464
 
322
- Use the test runtime for unit testing:
465
+ ### Terminate vs Stop
323
466
 
324
- ```ts
325
- import { describe, it, expect } from "vitest";
326
- import { Effect, Schema } from "effect";
327
- import { createTestRuntime, NoopTrackerLayer } from "@durable-effect/core";
328
- import { createJobsRuntimeFromLayer } from "@durable-effect/jobs";
329
-
330
- describe("tokenRefresher", () => {
331
- it("refreshes token on schedule", async () => {
332
- // Create test runtime with time control
333
- const { layer, time, handles } = createTestRuntime("test-instance", 1000000);
334
-
335
- // Create registry (manually add name for test registry)
336
- const registry = {
337
- continuous: new Map([["tokenRefresher", { ...tokenRefresher, name: "tokenRefresher" }]]),
338
- debounce: new Map(),
339
- workerPool: new Map(),
340
- };
341
-
342
- // Create runtime from test layer
343
- const runtime = createJobsRuntimeFromLayer(layer, registry);
344
-
345
- // Start the primitive
346
- await runtime.handle({
347
- type: "continuous",
348
- action: "start",
349
- name: "tokenRefresher",
350
- id: "test-user",
351
- input: { accessToken: "", refreshToken: "rt_test", expiresAt: 0 },
352
- });
353
-
354
- // Advance time past schedule
355
- time.advance(30 * 60 * 1000); // 30 minutes
356
-
357
- // Trigger alarm
358
- await runtime.handleAlarm();
359
-
360
- // Verify state was updated
361
- const result = await runtime.handle({
362
- type: "continuous",
363
- action: "getState",
364
- name: "tokenRefresher",
365
- id: "test-user",
366
- });
367
-
368
- expect(result.state.accessToken).toBeDefined();
369
- });
370
- });
467
+ - **`terminate()`**: Removes all state and cancels alarms. The instance ID can be reused to start fresh.
468
+ - Jobs don't have a "pause" concept - they're either running or terminated.
469
+
470
+ ### Schedule Inputs
471
+
472
+ Task's `schedule()` accepts flexible time inputs:
473
+
474
+ ```typescript
475
+ // Duration (from now)
476
+ yield* ctx.schedule(Duration.seconds(30));
477
+ yield* ctx.schedule("5 minutes");
478
+
479
+ // Absolute timestamp (ms since epoch)
480
+ yield* ctx.schedule(Date.now() + 60000);
481
+
482
+ // Date object
483
+ yield* ctx.schedule(new Date("2024-12-31"));
371
484
  ```
372
485
 
373
- ## Examples
486
+ ---
374
487
 
375
- ### Daily Report Generator
488
+ ## Retry Configuration
376
489
 
377
- ```ts
378
- const dailyReport = Continuous.make({
379
- stateSchema: Schema.Struct({
380
- lastReportDate: Schema.NullOr(Schema.String),
381
- totalReports: Schema.Number,
382
- }),
383
- schedule: Continuous.every("24 hours"),
384
- startImmediately: false, // Wait for first schedule
490
+ Configure automatic retries for execute failures:
491
+
492
+ ```typescript
493
+ import { Backoff } from "@durable-effect/core";
385
494
 
495
+ const job = Continuous.make({
496
+ // ...
497
+ retry: {
498
+ maxAttempts: 3,
499
+ delay: Backoff.exponential({
500
+ base: "1 second",
501
+ max: "30 seconds",
502
+ }),
503
+ jitter: true, // Add randomness to prevent thundering herd
504
+ },
386
505
  execute: (ctx) =>
387
506
  Effect.gen(function* () {
388
- const report = yield* generateReport();
389
- yield* sendReport(report);
390
-
391
- ctx.updateState((s) => ({
392
- lastReportDate: new Date().toISOString(),
393
- totalReports: s.totalReports + 1,
394
- }));
507
+ if (ctx.isRetry) {
508
+ console.log(`Retry attempt ${ctx.attempt}`);
509
+ }
510
+ // ... your logic
395
511
  }),
396
512
  });
397
513
  ```
398
514
 
399
- ### Health Check Monitor
515
+ **Retry behavior:**
516
+ - Retries are scheduled via alarms (durable, survives restarts)
517
+ - When all retries exhausted, the job is terminated (state purged)
518
+ - A `job.retryExhausted` event is emitted for observability
400
519
 
401
- ```ts
402
- const healthCheck = Continuous.make({
403
- stateSchema: Schema.Struct({
404
- status: Schema.Literal("healthy", "degraded", "down"),
405
- lastCheck: Schema.Number,
406
- consecutiveFailures: Schema.Number,
407
- }),
408
- schedule: Continuous.every("1 minute"),
520
+ **Backoff strategies:**
409
521
 
410
- execute: (ctx) =>
411
- Effect.gen(function* () {
412
- const isHealthy = yield* checkHealth();
522
+ ```typescript
523
+ // Exponential: 1s, 2s, 4s, 8s... (capped at max)
524
+ Backoff.exponential({ base: "1 second", max: "30 seconds" })
413
525
 
414
- ctx.updateState((s) => ({
415
- status: isHealthy ? "healthy" : s.consecutiveFailures >= 3 ? "down" : "degraded",
416
- lastCheck: Date.now(),
417
- consecutiveFailures: isHealthy ? 0 : s.consecutiveFailures + 1,
418
- }));
526
+ // Linear: 1s, 2s, 3s, 4s...
527
+ Backoff.linear({ base: "1 second", increment: "1 second" })
419
528
 
420
- if (!isHealthy && ctx.state.consecutiveFailures >= 3) {
421
- yield* sendAlert("Service is down!");
422
- }
423
- }),
424
- });
529
+ // Fixed: always 5s
530
+ Backoff.fixed("5 seconds")
425
531
  ```
426
532
 
427
- ### Cache Warmer
533
+ ---
428
534
 
429
- ```ts
430
- const cacheWarmer = Continuous.make({
431
- stateSchema: Schema.Struct({
432
- lastWarmTime: Schema.Number,
433
- itemsWarmed: Schema.Number,
434
- }),
435
- schedule: Continuous.every("15 minutes"),
535
+ ## Logging
436
536
 
437
- execute: (ctx) =>
438
- Effect.gen(function* () {
439
- const items = yield* getPopularItems();
537
+ Control logging per job:
440
538
 
441
- for (const item of items) {
442
- yield* warmCache(item);
443
- }
539
+ ```typescript
540
+ import { LogLevel } from "effect";
444
541
 
445
- ctx.setState({
446
- lastWarmTime: Date.now(),
447
- itemsWarmed: items.length,
448
- });
449
- }),
542
+ const job = Continuous.make({
543
+ // ...
544
+ logging: true, // LogLevel.Debug (all logs)
545
+ logging: false, // LogLevel.Error (failures only) - DEFAULT
546
+ logging: LogLevel.Warning,
547
+ logging: LogLevel.None, // Silent
450
548
  });
451
549
  ```
452
550
 
453
- ## Architecture
551
+ ---
454
552
 
455
- The Continuous primitive is built on a thin Durable Object shell that delegates to a swappable runtime:
553
+ ## Telemetry
456
554
 
555
+ Send job events to an external endpoint:
556
+
557
+ ```typescript
558
+ const { Jobs, JobsClient } = createDurableJobs(
559
+ { tokenRefresher, webhookBatcher },
560
+ {
561
+ tracker: {
562
+ endpoint: "https://events.example.com/ingest",
563
+ env: "production",
564
+ serviceKey: "my-jobs-service",
565
+ },
566
+ }
567
+ );
457
568
  ```
458
- ┌─────────────────────────────────────────────────────────────┐
459
- │ Worker Code │
460
- │ client.continuous("name").start({ id, input }) │
461
- └─────────────────────────────────────────────────────────────┘
462
-
463
-
464
- ┌─────────────────────────────────────────────────────────────┐
465
- │ Durable Object (Thin Shell) │
466
- │ │
467
- │ • Receives RPC calls │
468
- │ • Delegates to JobsRuntime │
469
- │ • Handles alarms │
470
- └─────────────────────────────────────────────────────────────┘
471
-
472
-
473
- ┌─────────────────────────────────────────────────────────────┐
474
- │ Jobs Runtime │
475
- │ │
476
- │ • Routes requests to handlers │
477
- Manages state via StorageAdapter │
478
- Schedules alarms via SchedulerAdapter │
479
- Executes user functions │
480
- └─────────────────────────────────────────────────────────────┘
569
+
570
+ **Emitted events:**
571
+ - `job.started` - Job instance created
572
+ - `job.executed` - Execute completed successfully
573
+ - `job.failed` - Execute threw an error
574
+ - `job.retryExhausted` - All retries failed
575
+ - `job.terminated` - Job instance terminated
576
+ - `debounce.started` - First event added
577
+ - `debounce.flushed` - Batch processed
578
+ - `task.scheduled` - Execution scheduled
579
+
580
+ ---
581
+
582
+ ## Error Types
583
+
584
+ All errors are typed Effect errors:
585
+
586
+ ```typescript
587
+ import {
588
+ JobNotFoundError, // Job name not in registry
589
+ InstanceNotFoundError, // Instance has no metadata
590
+ InvalidStateError, // Invalid state transition
591
+ ValidationError, // Schema validation failed
592
+ ExecutionError, // User function threw
593
+ DuplicateEventError, // Idempotency check failed
594
+ StorageError, // Durable Object storage error
595
+ SchedulerError, // Alarm scheduling error
596
+ } from "@durable-effect/jobs";
481
597
  ```
482
598
 
483
- Each instance has its own:
484
- - **Storage** - Isolated key-value storage for state
485
- - **Alarm** - Single alarm for scheduling next execution
486
- - **Instance ID** - Format: `continuous:{name}:{userProvidedId}`
599
+ ---
487
600
 
488
- ## License
601
+ ## Effect Service Integration
489
602
 
490
- MIT
603
+ TODO