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