@donkeylabs/server 0.4.7 → 0.4.8

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.
@@ -0,0 +1,509 @@
1
+ # Workflows
2
+
3
+ Workflows provide step function / state machine orchestration for complex multi-step processes. Built on top of the Jobs service, workflows support sequential tasks, parallel execution, conditional branching, retries, and real-time progress via SSE.
4
+
5
+ ## Overview
6
+
7
+ Use workflows when you need to:
8
+ - Orchestrate multi-step processes (order processing, onboarding, data pipelines)
9
+ - Run steps in parallel and wait for all to complete
10
+ - Make decisions based on previous step outputs
11
+ - Track progress across long-running processes
12
+ - Automatically retry failed steps with backoff
13
+
14
+ ## Quick Start
15
+
16
+ ### 1. Define a Workflow
17
+
18
+ ```typescript
19
+ import { workflow } from "@donkeylabs/server";
20
+
21
+ const orderWorkflow = workflow("process-order")
22
+ .task("validate", {
23
+ job: "validate-order",
24
+ input: (ctx) => ({ orderId: ctx.input.orderId }),
25
+ })
26
+ .choice("check-inventory", {
27
+ choices: [
28
+ {
29
+ condition: (ctx) => ctx.steps.validate.inStock,
30
+ next: "fulfill",
31
+ },
32
+ ],
33
+ default: "backorder",
34
+ })
35
+ .parallel("fulfill", {
36
+ branches: [
37
+ workflow.branch("shipping")
38
+ .task("ship", { job: "create-shipment" })
39
+ .build(),
40
+ workflow.branch("notification")
41
+ .task("notify", { job: "send-confirmation" })
42
+ .build(),
43
+ ],
44
+ next: "complete",
45
+ })
46
+ .task("backorder", {
47
+ job: "create-backorder",
48
+ next: "complete",
49
+ })
50
+ .pass("complete", { end: true })
51
+ .build();
52
+ ```
53
+
54
+ ### 2. Register and Start
55
+
56
+ ```typescript
57
+ // Register the workflow
58
+ ctx.core.workflows.register(orderWorkflow);
59
+
60
+ // Start an instance
61
+ const instanceId = await ctx.core.workflows.start("process-order", {
62
+ orderId: "ORD-123",
63
+ customerId: "CUST-456",
64
+ });
65
+ ```
66
+
67
+ ### 3. Track Progress
68
+
69
+ ```typescript
70
+ // Via Events
71
+ ctx.core.events.on("workflow.progress", (data) => {
72
+ console.log(`${data.workflowName}: ${data.progress}%`);
73
+ });
74
+
75
+ // Via SSE (client subscribes to workflow:${instanceId})
76
+ ctx.core.events.on("workflow.progress", (data) => {
77
+ ctx.core.sse.broadcast(`workflow:${data.instanceId}`, "progress", data);
78
+ });
79
+ ```
80
+
81
+ ## Step Types
82
+
83
+ ### Task
84
+
85
+ Executes a job (in-process or external) and waits for completion.
86
+
87
+ ```typescript
88
+ workflow("example")
89
+ .task("step-name", {
90
+ // Required: job to execute
91
+ job: "my-job-name",
92
+
93
+ // Optional: transform workflow context to job input
94
+ input: (ctx) => ({
95
+ orderId: ctx.input.orderId,
96
+ previousResult: ctx.steps.previousStep,
97
+ }),
98
+
99
+ // Optional: transform job result to step output
100
+ output: (result, ctx) => ({
101
+ processed: true,
102
+ data: result.data,
103
+ }),
104
+
105
+ // Optional: retry configuration
106
+ retry: {
107
+ maxAttempts: 3,
108
+ intervalMs: 1000,
109
+ backoffRate: 2,
110
+ maxIntervalMs: 30000,
111
+ },
112
+
113
+ // Optional: step timeout in ms
114
+ timeout: 60000,
115
+
116
+ // Control flow (one of these)
117
+ next: "next-step", // Go to specific step
118
+ end: true, // End workflow (mutually exclusive with next)
119
+ })
120
+ ```
121
+
122
+ ### Parallel
123
+
124
+ Runs multiple workflow branches concurrently.
125
+
126
+ ```typescript
127
+ workflow("example")
128
+ .parallel("parallel-step", {
129
+ // Required: branches to execute
130
+ branches: [
131
+ workflow.branch("branch-a")
132
+ .task("task-a1", { job: "job-a1" })
133
+ .task("task-a2", { job: "job-a2" })
134
+ .build(),
135
+
136
+ workflow.branch("branch-b")
137
+ .task("task-b1", { job: "job-b1" })
138
+ .build(),
139
+ ],
140
+
141
+ // Optional: error handling
142
+ onError: "fail-fast", // Stop all on first error (default)
143
+ // onError: "wait-all", // Wait for all branches, collect errors
144
+
145
+ // Control flow
146
+ next: "next-step",
147
+ })
148
+ ```
149
+
150
+ The output of a parallel step is an object with each branch's output:
151
+
152
+ ```typescript
153
+ {
154
+ "branch-a": { /* branch-a output */ },
155
+ "branch-b": { /* branch-b output */ }
156
+ }
157
+ ```
158
+
159
+ ### Choice
160
+
161
+ Conditional branching based on workflow context.
162
+
163
+ ```typescript
164
+ workflow("example")
165
+ .choice("decision-point", {
166
+ // Evaluated in order, first match wins
167
+ choices: [
168
+ {
169
+ condition: (ctx) => ctx.steps.validate.amount > 1000,
170
+ next: "large-order-flow",
171
+ },
172
+ {
173
+ condition: (ctx) => ctx.steps.validate.isPriority,
174
+ next: "priority-flow",
175
+ },
176
+ ],
177
+
178
+ // Fallback if no conditions match
179
+ default: "standard-flow",
180
+ })
181
+ ```
182
+
183
+ ### Pass
184
+
185
+ Transform data or create a no-op step.
186
+
187
+ ```typescript
188
+ workflow("example")
189
+ // Transform data
190
+ .pass("transform", {
191
+ transform: (ctx) => ({
192
+ summary: {
193
+ input: ctx.input,
194
+ results: ctx.steps,
195
+ },
196
+ }),
197
+ next: "next-step",
198
+ })
199
+
200
+ // Static result
201
+ .pass("static", {
202
+ result: { status: "initialized" },
203
+ next: "next-step",
204
+ })
205
+
206
+ // End marker (shorthand)
207
+ .end("done")
208
+ ```
209
+
210
+ ## Workflow Context
211
+
212
+ Every step receives a `WorkflowContext` with:
213
+
214
+ ```typescript
215
+ interface WorkflowContext {
216
+ /** Original workflow input */
217
+ input: any;
218
+
219
+ /** Results from completed steps (keyed by step name) */
220
+ steps: Record<string, any>;
221
+
222
+ /** Current workflow instance */
223
+ instance: WorkflowInstance;
224
+
225
+ /** Type-safe step result getter */
226
+ getStepResult<T>(stepName: string): T | undefined;
227
+ }
228
+ ```
229
+
230
+ Example usage in step configuration:
231
+
232
+ ```typescript
233
+ .task("process", {
234
+ job: "process-data",
235
+ input: (ctx) => ({
236
+ // Access original input
237
+ orderId: ctx.input.orderId,
238
+
239
+ // Access previous step output
240
+ validationResult: ctx.steps.validate,
241
+
242
+ // Type-safe access
243
+ amount: ctx.getStepResult<{ amount: number }>("calculate")?.amount,
244
+ }),
245
+ })
246
+ ```
247
+
248
+ ## Retry Configuration
249
+
250
+ Configure retries at the step level or set defaults for the entire workflow:
251
+
252
+ ```typescript
253
+ // Default retry for all steps
254
+ workflow("example")
255
+ .defaultRetry({
256
+ maxAttempts: 3,
257
+ intervalMs: 1000,
258
+ backoffRate: 2,
259
+ maxIntervalMs: 30000,
260
+ })
261
+ .task("step1", { job: "job1" }) // Uses default
262
+ .task("step2", {
263
+ job: "job2",
264
+ retry: { maxAttempts: 5 }, // Override
265
+ })
266
+ ```
267
+
268
+ Retry parameters:
269
+ - `maxAttempts`: Maximum retry attempts (including first try)
270
+ - `intervalMs`: Initial delay between retries (default: 1000)
271
+ - `backoffRate`: Multiplier for each retry (default: 2)
272
+ - `maxIntervalMs`: Maximum delay cap (default: 30000)
273
+ - `retryOn`: Array of error messages to retry on (default: all errors)
274
+
275
+ ## Workflow Timeout
276
+
277
+ Set a timeout for the entire workflow:
278
+
279
+ ```typescript
280
+ workflow("example")
281
+ .timeout(3600000) // 1 hour max
282
+ .task("step1", { job: "job1" })
283
+ .task("step2", { job: "job2" })
284
+ .build();
285
+ ```
286
+
287
+ ## Events
288
+
289
+ Workflows emit events at key points:
290
+
291
+ | Event | Data | Description |
292
+ |-------|------|-------------|
293
+ | `workflow.started` | `{ instanceId, workflowName, input }` | Workflow started |
294
+ | `workflow.progress` | `{ instanceId, workflowName, progress, currentStep, completedSteps, totalSteps }` | Progress update |
295
+ | `workflow.completed` | `{ instanceId, workflowName, output }` | Workflow completed |
296
+ | `workflow.failed` | `{ instanceId, workflowName, error }` | Workflow failed |
297
+ | `workflow.cancelled` | `{ instanceId, workflowName }` | Workflow cancelled |
298
+ | `workflow.step.started` | `{ instanceId, workflowName, stepName, stepType }` | Step started |
299
+ | `workflow.step.completed` | `{ instanceId, workflowName, stepName, output }` | Step completed |
300
+ | `workflow.step.failed` | `{ instanceId, workflowName, stepName, error, attempts }` | Step failed |
301
+ | `workflow.step.retry` | `{ instanceId, workflowName, stepName, attempt, maxAttempts, delay, error }` | Step retrying |
302
+
303
+ ### Example: SSE Progress Broadcasting
304
+
305
+ ```typescript
306
+ // Broadcast all workflow events to SSE
307
+ const workflowEvents = [
308
+ "workflow.progress",
309
+ "workflow.completed",
310
+ "workflow.failed",
311
+ "workflow.step.started",
312
+ "workflow.step.completed",
313
+ ];
314
+
315
+ for (const event of workflowEvents) {
316
+ ctx.core.events.on(event, (data) => {
317
+ ctx.core.sse.broadcast(`workflow:${data.instanceId}`, event, data);
318
+ });
319
+ }
320
+ ```
321
+
322
+ ## API Reference
323
+
324
+ ### Workflows Service
325
+
326
+ ```typescript
327
+ interface Workflows {
328
+ /** Register a workflow definition */
329
+ register(definition: WorkflowDefinition): void;
330
+
331
+ /** Start a new workflow instance */
332
+ start<T = any>(workflowName: string, input: T): Promise<string>;
333
+
334
+ /** Get a workflow instance by ID */
335
+ getInstance(instanceId: string): Promise<WorkflowInstance | null>;
336
+
337
+ /** Cancel a running workflow */
338
+ cancel(instanceId: string): Promise<boolean>;
339
+
340
+ /** Get all instances of a workflow */
341
+ getInstances(workflowName: string, status?: WorkflowStatus): Promise<WorkflowInstance[]>;
342
+
343
+ /** Resume workflows after server restart */
344
+ resume(): Promise<void>;
345
+
346
+ /** Stop the workflow service */
347
+ stop(): Promise<void>;
348
+ }
349
+ ```
350
+
351
+ ### Workflow Instance
352
+
353
+ ```typescript
354
+ interface WorkflowInstance {
355
+ id: string;
356
+ workflowName: string;
357
+ status: "pending" | "running" | "completed" | "failed" | "cancelled" | "timed_out";
358
+ currentStep?: string;
359
+ input: any;
360
+ output?: any;
361
+ error?: string;
362
+ stepResults: Record<string, StepResult>;
363
+ createdAt: Date;
364
+ startedAt?: Date;
365
+ completedAt?: Date;
366
+ }
367
+
368
+ interface StepResult {
369
+ stepName: string;
370
+ status: "pending" | "running" | "completed" | "failed" | "skipped";
371
+ input?: any;
372
+ output?: any;
373
+ error?: string;
374
+ startedAt?: Date;
375
+ completedAt?: Date;
376
+ attempts: number;
377
+ }
378
+ ```
379
+
380
+ ## Persistence
381
+
382
+ By default, workflows use an in-memory adapter. For production, implement a `WorkflowAdapter`:
383
+
384
+ ```typescript
385
+ interface WorkflowAdapter {
386
+ createInstance(instance: Omit<WorkflowInstance, "id">): Promise<WorkflowInstance>;
387
+ getInstance(instanceId: string): Promise<WorkflowInstance | null>;
388
+ updateInstance(instanceId: string, updates: Partial<WorkflowInstance>): Promise<void>;
389
+ deleteInstance(instanceId: string): Promise<boolean>;
390
+ getInstancesByWorkflow(workflowName: string, status?: WorkflowStatus): Promise<WorkflowInstance[]>;
391
+ getRunningInstances(): Promise<WorkflowInstance[]>;
392
+ }
393
+ ```
394
+
395
+ Configure via `ServerConfig`:
396
+
397
+ ```typescript
398
+ const server = new AppServer({
399
+ db: createDatabase(),
400
+ workflows: {
401
+ adapter: new MyDatabaseWorkflowAdapter(db),
402
+ pollInterval: 1000, // How often to check job completion
403
+ },
404
+ });
405
+ ```
406
+
407
+ ## Server Restart Resilience
408
+
409
+ Workflows automatically resume after server restart:
410
+
411
+ 1. On startup, `workflows.resume()` is called
412
+ 2. All instances with `status: "running"` are retrieved
413
+ 3. Execution continues from the current step
414
+
415
+ For this to work properly:
416
+ - Use a persistent adapter (not in-memory) in production
417
+ - Jobs should be idempotent when possible
418
+ - The Jobs service must also support restart resilience
419
+
420
+ ## Complete Example
421
+
422
+ ```typescript
423
+ import { AppServer, workflow, createDatabase } from "@donkeylabs/server";
424
+
425
+ // Define workflow
426
+ const onboardingWorkflow = workflow("user-onboarding")
427
+ .timeout(86400000) // 24 hour max
428
+ .defaultRetry({ maxAttempts: 3 })
429
+
430
+ .task("create-account", {
431
+ job: "create-user-account",
432
+ input: (ctx) => ctx.input,
433
+ })
434
+
435
+ .task("send-welcome-email", {
436
+ job: "send-email",
437
+ input: (ctx) => ({
438
+ to: ctx.input.email,
439
+ template: "welcome",
440
+ userId: ctx.steps["create-account"].userId,
441
+ }),
442
+ })
443
+
444
+ .choice("check-plan", {
445
+ choices: [
446
+ {
447
+ condition: (ctx) => ctx.input.plan === "enterprise",
448
+ next: "enterprise-setup",
449
+ },
450
+ ],
451
+ default: "standard-setup",
452
+ })
453
+
454
+ .task("enterprise-setup", {
455
+ job: "setup-enterprise",
456
+ input: (ctx) => ({ userId: ctx.steps["create-account"].userId }),
457
+ next: "complete",
458
+ })
459
+
460
+ .task("standard-setup", {
461
+ job: "setup-standard",
462
+ input: (ctx) => ({ userId: ctx.steps["create-account"].userId }),
463
+ next: "complete",
464
+ })
465
+
466
+ .pass("complete", {
467
+ transform: (ctx) => ({
468
+ userId: ctx.steps["create-account"].userId,
469
+ plan: ctx.input.plan,
470
+ setupComplete: true,
471
+ }),
472
+ end: true,
473
+ })
474
+ .build();
475
+
476
+ // Setup server
477
+ const server = new AppServer({ db: createDatabase() });
478
+
479
+ // Register jobs that workflows use
480
+ server.getCore().jobs.register("create-user-account", async (data) => {
481
+ // ... create user
482
+ return { userId: "user-123" };
483
+ });
484
+
485
+ server.getCore().jobs.register("send-email", async (data) => {
486
+ // ... send email
487
+ return { sent: true };
488
+ });
489
+
490
+ // ... register other jobs
491
+
492
+ // Register workflow
493
+ server.getCore().workflows.register(onboardingWorkflow);
494
+
495
+ // Start workflow from a route
496
+ router.route("onboard").typed({
497
+ input: z.object({
498
+ email: z.string().email(),
499
+ name: z.string(),
500
+ plan: z.enum(["free", "pro", "enterprise"]),
501
+ }),
502
+ handle: async (input, ctx) => {
503
+ const instanceId = await ctx.core.workflows.start("user-onboarding", input);
504
+ return { instanceId };
505
+ },
506
+ });
507
+
508
+ await server.start();
509
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/server",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
4
4
  "type": "module",
5
5
  "description": "Type-safe plugin system for building RPC-style APIs with Bun",
6
6
  "main": "./src/index.ts",