@donkeylabs/server 2.0.19 → 2.0.21

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/docs/workflows.md CHANGED
@@ -305,6 +305,21 @@ interface WorkflowContext {
305
305
 
306
306
  /** Type-safe step result getter */
307
307
  getStepResult<T>(stepName: string): T | undefined;
308
+
309
+ /** Core services (logger, events, cache, jobs, sse, etc.) */
310
+ core: CoreServices;
311
+
312
+ /** Plugin services - access your plugins' service methods */
313
+ plugins: Record<string, any>;
314
+
315
+ /** Custom metadata that persists across steps (read-only snapshot) */
316
+ metadata: Record<string, any>;
317
+
318
+ /** Set a metadata value that persists across workflow steps */
319
+ setMetadata(key: string, value: any): Promise<void>;
320
+
321
+ /** Get a typed metadata value */
322
+ getMetadata<T>(key: string): T | undefined;
308
323
  }
309
324
  ```
310
325
 
@@ -320,11 +335,59 @@ Example usage in step configuration:
320
335
  handler: async (input, ctx) => {
321
336
  // Access any step's output
322
337
  const calcResult = ctx.getStepResult<{ amount: number }>("calculate");
338
+
339
+ // Access plugin services
340
+ const order = await ctx.plugins.orders.getById(input.orderId);
341
+
342
+ // Use core services
343
+ ctx.core.logger.info("Processing order", { orderId: input.orderId });
344
+
323
345
  return { processed: true, amount: calcResult?.amount };
324
346
  },
325
347
  })
326
348
  ```
327
349
 
350
+ ### Cross-Step Metadata
351
+
352
+ Use metadata to share data across workflow steps that isn't part of the normal step output flow:
353
+
354
+ ```typescript
355
+ .task("validate", {
356
+ inputSchema: z.object({ orderId: z.string() }),
357
+ handler: async (input, ctx) => {
358
+ // Store correlation ID for logging/tracing across steps
359
+ await ctx.setMetadata("correlationId", crypto.randomUUID());
360
+
361
+ // Store complex context that multiple steps need
362
+ await ctx.setMetadata("orderContext", {
363
+ customer: await ctx.plugins.customers.getByOrder(input.orderId),
364
+ flags: { expedited: false, giftWrap: false },
365
+ });
366
+
367
+ return { valid: true };
368
+ },
369
+ })
370
+ .task("fulfill", {
371
+ handler: async (input, ctx) => {
372
+ // Read metadata from previous steps
373
+ const correlationId = ctx.getMetadata<string>("correlationId");
374
+ const orderCtx = ctx.getMetadata<{ customer: Customer; flags: object }>("orderContext");
375
+
376
+ ctx.core.logger.info("Fulfilling order", { correlationId, customer: orderCtx?.customer.id });
377
+
378
+ // Update metadata for downstream steps
379
+ await ctx.setMetadata("orderContext", {
380
+ ...orderCtx,
381
+ flags: { ...orderCtx?.flags, fulfilled: true },
382
+ });
383
+
384
+ return { shipped: true };
385
+ },
386
+ })
387
+ ```
388
+
389
+ Metadata is persisted to the database and survives server restarts.
390
+
328
391
  ## Retry Configuration
329
392
 
330
393
  Configure retries at the step level or set defaults for the entire workflow:
@@ -440,6 +503,8 @@ interface WorkflowInstance {
440
503
  output?: any;
441
504
  error?: string;
442
505
  stepResults: Record<string, StepResult>;
506
+ /** Custom metadata that persists across steps */
507
+ metadata?: Record<string, any>;
443
508
  createdAt: Date;
444
509
  startedAt?: Date;
445
510
  completedAt?: Date;
@@ -459,7 +524,28 @@ interface StepResult {
459
524
 
460
525
  ## Persistence
461
526
 
462
- By default, workflows use an in-memory adapter. For production, implement a `WorkflowAdapter`:
527
+ By default, workflows use a **Kysely database adapter** that stores workflow instances in the same database as your application. This provides automatic persistence and restart resilience.
528
+
529
+ The framework automatically creates and manages the `__donkeylabs_workflow_instances__` table with proper migrations.
530
+
531
+ ### Built-in Kysely Adapter
532
+
533
+ The `KyselyWorkflowAdapter` is automatically configured when you provide a database connection:
534
+
535
+ ```typescript
536
+ const server = new AppServer({
537
+ db: createDatabase(), // Workflows will automatically persist to this database
538
+ workflows: {
539
+ pollInterval: 1000, // How often to check job completion
540
+ cleanupDays: 30, // Auto-cleanup completed workflows older than 30 days (0 to disable)
541
+ cleanupInterval: 3600000, // Cleanup check interval (default: 1 hour)
542
+ },
543
+ });
544
+ ```
545
+
546
+ ### Custom Adapter
547
+
548
+ For custom storage backends, implement the `WorkflowAdapter` interface:
463
549
 
464
550
  ```typescript
465
551
  interface WorkflowAdapter {
@@ -469,6 +555,7 @@ interface WorkflowAdapter {
469
555
  deleteInstance(instanceId: string): Promise<boolean>;
470
556
  getInstancesByWorkflow(workflowName: string, status?: WorkflowStatus): Promise<WorkflowInstance[]>;
471
557
  getRunningInstances(): Promise<WorkflowInstance[]>;
558
+ getAllInstances(options?: GetAllWorkflowsOptions): Promise<WorkflowInstance[]>;
472
559
  }
473
560
  ```
474
561
 
@@ -478,8 +565,8 @@ Configure via `ServerConfig`:
478
565
  const server = new AppServer({
479
566
  db: createDatabase(),
480
567
  workflows: {
481
- adapter: new MyDatabaseWorkflowAdapter(db),
482
- pollInterval: 1000, // How often to check job completion
568
+ adapter: new MyCustomWorkflowAdapter(),
569
+ pollInterval: 1000,
483
570
  },
484
571
  });
485
572
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/server",
3
- "version": "2.0.19",
3
+ "version": "2.0.21",
4
4
  "type": "module",
5
5
  "description": "Type-safe plugin system for building RPC-style APIs with Bun",
6
6
  "main": "./src/index.ts",
@@ -397,6 +397,159 @@ export function createAdminRouter(config: AdminRouteContext) {
397
397
  })
398
398
  );
399
399
 
400
+ // Workflows get route - full instance details with step results
401
+ router.route("workflows.get").typed(
402
+ defineRoute({
403
+ input: z.object({ instanceId: z.string() }),
404
+ output: z.object({
405
+ id: z.string(),
406
+ workflowName: z.string(),
407
+ status: z.string(),
408
+ currentStep: z.string().nullable(),
409
+ input: z.any(),
410
+ output: z.any().nullable(),
411
+ error: z.string().nullable(),
412
+ stepResults: z.record(
413
+ z.object({
414
+ stepName: z.string(),
415
+ status: z.string(),
416
+ input: z.any().optional(),
417
+ output: z.any().optional(),
418
+ error: z.string().optional(),
419
+ startedAt: z.string().nullable(),
420
+ completedAt: z.string().nullable(),
421
+ attempts: z.number(),
422
+ })
423
+ ),
424
+ metadata: z.record(z.any()).nullable(),
425
+ createdAt: z.string(),
426
+ startedAt: z.string().nullable(),
427
+ completedAt: z.string().nullable(),
428
+ parentId: z.string().nullable(),
429
+ branchName: z.string().nullable(),
430
+ }).nullable(),
431
+ handle: async (input, ctx) => {
432
+ if (!checkAuth(ctx)) {
433
+ throw ctx.errors.Forbidden("Unauthorized");
434
+ }
435
+ const instance = await ctx.core.workflows.getInstance(input.instanceId);
436
+ if (!instance) {
437
+ return null;
438
+ }
439
+
440
+ // Transform step results with proper date serialization
441
+ const stepResults: Record<string, any> = {};
442
+ for (const [key, result] of Object.entries(instance.stepResults)) {
443
+ stepResults[key] = {
444
+ stepName: result.stepName,
445
+ status: result.status,
446
+ input: result.input,
447
+ output: result.output,
448
+ error: result.error,
449
+ startedAt: result.startedAt?.toISOString() ?? null,
450
+ completedAt: result.completedAt?.toISOString() ?? null,
451
+ attempts: result.attempts,
452
+ };
453
+ }
454
+
455
+ return {
456
+ id: instance.id,
457
+ workflowName: instance.workflowName,
458
+ status: instance.status,
459
+ currentStep: instance.currentStep ?? null,
460
+ input: instance.input,
461
+ output: instance.output ?? null,
462
+ error: instance.error ?? null,
463
+ stepResults,
464
+ metadata: instance.metadata ?? null,
465
+ createdAt: instance.createdAt.toISOString(),
466
+ startedAt: instance.startedAt?.toISOString() ?? null,
467
+ completedAt: instance.completedAt?.toISOString() ?? null,
468
+ parentId: instance.parentId ?? null,
469
+ branchName: instance.branchName ?? null,
470
+ };
471
+ },
472
+ })
473
+ );
474
+
475
+ // Workflows subscribe route - SSE for real-time workflow events
476
+ // Subscribes to workflow:{instanceId} channel for progress, completed, and failed events
477
+ router.route("workflows.subscribe").sse({
478
+ input: z.object({
479
+ instanceId: z.string(),
480
+ }),
481
+ events: {
482
+ progress: z.object({
483
+ progress: z.number(),
484
+ currentStep: z.string().optional(),
485
+ completedSteps: z.number(),
486
+ totalSteps: z.number(),
487
+ }),
488
+ "step.started": z.object({
489
+ stepName: z.string(),
490
+ }),
491
+ "step.completed": z.object({
492
+ stepName: z.string(),
493
+ output: z.any().optional(),
494
+ }),
495
+ "step.failed": z.object({
496
+ stepName: z.string(),
497
+ error: z.string(),
498
+ }),
499
+ completed: z.object({
500
+ output: z.any().optional(),
501
+ }),
502
+ failed: z.object({
503
+ error: z.string(),
504
+ }),
505
+ },
506
+ handle: (input, ctx) => {
507
+ if (!checkAuth(ctx)) {
508
+ return [];
509
+ }
510
+ // Subscribe to the workflow-specific channel
511
+ return [`workflow:${input.instanceId}`];
512
+ },
513
+ });
514
+
515
+ // Workflows subscribe all route - SSE for all workflow events (useful for dashboard)
516
+ // Subscribes to workflow:* pattern for all workflow updates
517
+ router.route("workflows.subscribeAll").sse({
518
+ input: z.object({
519
+ // Optional filter by workflow name
520
+ workflowName: z.string().optional(),
521
+ }),
522
+ events: {
523
+ "workflow.started": z.object({
524
+ instanceId: z.string(),
525
+ workflowName: z.string(),
526
+ }),
527
+ "workflow.progress": z.object({
528
+ instanceId: z.string(),
529
+ workflowName: z.string(),
530
+ progress: z.number(),
531
+ currentStep: z.string().optional(),
532
+ }),
533
+ "workflow.completed": z.object({
534
+ instanceId: z.string(),
535
+ workflowName: z.string(),
536
+ }),
537
+ "workflow.failed": z.object({
538
+ instanceId: z.string(),
539
+ workflowName: z.string(),
540
+ error: z.string(),
541
+ }),
542
+ },
543
+ handle: (input, ctx) => {
544
+ if (!checkAuth(ctx)) {
545
+ return [];
546
+ }
547
+ // Subscribe to the global workflow events channel
548
+ // Events are emitted via ctx.core.events and need to be bridged to SSE
549
+ return ["workflows:all"];
550
+ },
551
+ });
552
+
400
553
  // Audit list route
401
554
  router.route("audit.list").typed(
402
555
  defineRoute({
package/src/core/cron.ts CHANGED
@@ -35,8 +35,16 @@ export interface Cron {
35
35
  // Supports: * (any), specific values, ranges (1-5), steps (*/5)
36
36
  // Format: second minute hour dayOfMonth month dayOfWeek
37
37
  // Also supports 5-field format (minute hour dayOfMonth month dayOfWeek)
38
+ //
39
+ // Day-of-month/day-of-week semantics:
40
+ // - Standard cron uses OR semantics when both are restricted (not *)
41
+ // - If BOTH day-of-month and day-of-week are explicitly set, job runs if EITHER matches
42
+ // - If one is *, the other is used exclusively
38
43
  class CronExpression {
39
44
  private fields: [number[], number[], number[], number[], number[], number[]];
45
+ // Track if day fields were explicitly set (not *)
46
+ private dayOfMonthRestricted: boolean;
47
+ private dayOfWeekRestricted: boolean;
40
48
 
41
49
  constructor(expression: string) {
42
50
  const parts = expression.trim().split(/\s+/);
@@ -44,6 +52,8 @@ class CronExpression {
44
52
  // Support both 5-field and 6-field cron
45
53
  if (parts.length === 5) {
46
54
  // minute hour dayOfMonth month dayOfWeek
55
+ this.dayOfMonthRestricted = parts[2] !== "*";
56
+ this.dayOfWeekRestricted = parts[4] !== "*";
47
57
  this.fields = [
48
58
  [0], // seconds (always 0)
49
59
  this.parseField(parts[0]!, 0, 59), // minutes
@@ -54,6 +64,8 @@ class CronExpression {
54
64
  ];
55
65
  } else if (parts.length === 6) {
56
66
  // second minute hour dayOfMonth month dayOfWeek
67
+ this.dayOfMonthRestricted = parts[3] !== "*";
68
+ this.dayOfWeekRestricted = parts[5] !== "*";
57
69
  this.fields = [
58
70
  this.parseField(parts[0]!, 0, 59), // seconds
59
71
  this.parseField(parts[1]!, 0, 59), // minutes
@@ -99,14 +111,25 @@ class CronExpression {
99
111
  const month = date.getMonth() + 1;
100
112
  const dayOfWeek = date.getDay();
101
113
 
102
- return (
103
- this.fields[0].includes(second) &&
104
- this.fields[1].includes(minute) &&
105
- this.fields[2].includes(hour) &&
106
- this.fields[3].includes(dayOfMonth) &&
107
- this.fields[4].includes(month) &&
108
- this.fields[5].includes(dayOfWeek)
109
- );
114
+ // Check time fields (always AND)
115
+ if (!this.fields[0].includes(second)) return false;
116
+ if (!this.fields[1].includes(minute)) return false;
117
+ if (!this.fields[2].includes(hour)) return false;
118
+ if (!this.fields[4].includes(month)) return false;
119
+
120
+ // Day matching uses standard cron semantics:
121
+ // - If both day-of-month AND day-of-week are restricted (not *), use OR
122
+ // - Otherwise, use AND (one or both are unrestricted)
123
+ const domMatches = this.fields[3].includes(dayOfMonth);
124
+ const dowMatches = this.fields[5].includes(dayOfWeek);
125
+
126
+ if (this.dayOfMonthRestricted && this.dayOfWeekRestricted) {
127
+ // Both restricted: OR semantics - job runs if EITHER matches
128
+ return domMatches || dowMatches;
129
+ } else {
130
+ // One or both unrestricted: AND semantics
131
+ return domMatches && dowMatches;
132
+ }
110
133
  }
111
134
 
112
135
  /**
@@ -150,9 +173,21 @@ class CronExpression {
150
173
  if (dayOfMonth > daysInMonth) continue; // Skip invalid days for this month
151
174
 
152
175
  // Check if this day matches day-of-week constraint
176
+ // Use OR semantics if both are restricted, AND otherwise
153
177
  const testDate = new Date(next.getFullYear(), targetMonth, dayOfMonth);
154
178
  const dayOfWeek = testDate.getDay();
155
- if (!daysOfWeek.includes(dayOfWeek)) continue;
179
+ const domMatches = daysOfMonth.includes(dayOfMonth);
180
+ const dowMatches = daysOfWeek.includes(dayOfWeek);
181
+
182
+ let dayMatches: boolean;
183
+ if (this.dayOfMonthRestricted && this.dayOfWeekRestricted) {
184
+ // Both restricted: OR semantics
185
+ dayMatches = domMatches || dowMatches;
186
+ } else {
187
+ // One or both unrestricted: AND semantics
188
+ dayMatches = domMatches && dowMatches;
189
+ }
190
+ if (!dayMatches) continue;
156
191
 
157
192
  // Skip days in the past
158
193
  if (testDate < new Date(from.getFullYear(), from.getMonth(), from.getDate())) continue;
@@ -308,6 +343,9 @@ class CronImpl implements Cron {
308
343
  if (this.running) return;
309
344
  this.running = true;
310
345
 
346
+ // Catch-up check for missed runs since last shutdown
347
+ this.catchUpMissedRuns();
348
+
311
349
  // Check every second
312
350
  this.timer = setInterval(() => {
313
351
  const now = new Date();
@@ -329,6 +367,49 @@ class CronImpl implements Cron {
329
367
  }, 1000);
330
368
  }
331
369
 
370
+ /**
371
+ * Catch up on missed runs since last shutdown.
372
+ * For each task with a lastRun timestamp, execute any runs that were missed.
373
+ * Limited to 10 catch-up runs per task to avoid flooding.
374
+ */
375
+ private catchUpMissedRuns(): void {
376
+ const now = new Date();
377
+ const maxCatchUp = 10;
378
+
379
+ for (const task of this.tasks.values()) {
380
+ if (!task.enabled || !task.lastRun) continue;
381
+
382
+ try {
383
+ const cronExpr = new CronExpression(task.expression);
384
+ let missedRun = cronExpr.getNextRun(task.lastRun);
385
+ let catchUpCount = 0;
386
+
387
+ // Execute any missed runs (up to limit to avoid flooding)
388
+ while (missedRun && missedRun < now && catchUpCount < maxCatchUp) {
389
+ console.log(`[Cron] Catching up missed run for "${task.name}" at ${missedRun.toISOString()}`);
390
+
391
+ // Execute the handler asynchronously
392
+ Promise.resolve(task.handler()).catch(err => {
393
+ console.error(`[Cron] Catch-up task "${task.name}" failed:`, err);
394
+ });
395
+
396
+ task.lastRun = missedRun;
397
+ missedRun = cronExpr.getNextRun(missedRun);
398
+ catchUpCount++;
399
+ }
400
+
401
+ // Update nextRun to the next scheduled time
402
+ task.nextRun = cronExpr.getNextRun(now);
403
+
404
+ if (catchUpCount > 0) {
405
+ console.log(`[Cron] Caught up ${catchUpCount} missed runs for "${task.name}"`);
406
+ }
407
+ } catch (err) {
408
+ console.error(`[Cron] Error catching up task "${task.name}":`, err);
409
+ }
410
+ }
411
+ }
412
+
332
413
  async stop(): Promise<void> {
333
414
  this.running = false;
334
415
  if (this.timer) {
package/src/core/index.ts CHANGED
@@ -132,6 +132,7 @@ export {
132
132
  export {
133
133
  type Workflows,
134
134
  type WorkflowsConfig,
135
+ type WorkflowRegisterOptions,
135
136
  type WorkflowDefinition,
136
137
  type WorkflowInstance,
137
138
  type WorkflowStatus,
@@ -154,6 +155,30 @@ export {
154
155
  createWorkflows,
155
156
  } from "./workflows";
156
157
 
158
+ export {
159
+ type WorkflowSocketServer,
160
+ type WorkflowSocketServerOptions,
161
+ type WorkflowSocketConfig,
162
+ type WorkflowEvent,
163
+ type WorkflowEventType,
164
+ type ProxyRequest,
165
+ type ProxyResponse,
166
+ type WorkflowMessage,
167
+ createWorkflowSocketServer,
168
+ isWorkflowEvent,
169
+ isProxyRequest,
170
+ parseWorkflowMessage,
171
+ } from "./workflow-socket";
172
+
173
+ export {
174
+ type ProxyConnection,
175
+ WorkflowProxyConnection,
176
+ createPluginProxy,
177
+ createCoreProxy,
178
+ createPluginsProxy,
179
+ createCoreServicesProxy,
180
+ } from "./workflow-proxy";
181
+
157
182
  export {
158
183
  type Processes,
159
184
  type ProcessesConfig,
@@ -192,6 +217,12 @@ export {
192
217
  createProcessSocketServer,
193
218
  } from "./process-socket";
194
219
 
220
+ export {
221
+ WorkflowStateMachine,
222
+ type StateMachineEvents,
223
+ type StateMachineConfig,
224
+ } from "./workflow-state-machine";
225
+
195
226
  export {
196
227
  KyselyWorkflowAdapter,
197
228
  type KyselyWorkflowAdapterConfig,