@donkeylabs/server 2.0.18 → 2.0.20
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/caching-strategies.md +677 -0
- package/docs/dev-experience.md +656 -0
- package/docs/hot-reload-limitations.md +166 -0
- package/docs/load-testing.md +974 -0
- package/docs/plugin-registry-design.md +1064 -0
- package/docs/production.md +1229 -0
- package/docs/workflows.md +90 -3
- package/package.json +18 -2
- package/src/admin/routes.ts +153 -0
- package/src/core/cron.ts +184 -15
- package/src/core/index.ts +25 -0
- package/src/core/job-adapter-kysely.ts +176 -73
- package/src/core/job-adapter-sqlite.ts +10 -0
- package/src/core/jobs.ts +112 -17
- package/src/core/migrations/workflows/002_add_metadata_column.ts +28 -0
- package/src/core/process-adapter-kysely.ts +62 -21
- package/src/core/storage-adapter-local.test.ts +199 -0
- package/src/core/storage.test.ts +197 -0
- package/src/core/workflow-adapter-kysely.ts +66 -19
- package/src/core/workflow-executor.ts +469 -0
- package/src/core/workflow-proxy.ts +238 -0
- package/src/core/workflow-socket.ts +447 -0
- package/src/core/workflows.test.ts +415 -0
- package/src/core/workflows.ts +782 -9
- package/src/core.ts +17 -6
- package/src/index.ts +14 -0
- package/src/server.ts +40 -26
- package/src/testing/database.test.ts +263 -0
- package/src/testing/database.ts +173 -0
- package/src/testing/e2e.test.ts +189 -0
- package/src/testing/e2e.ts +272 -0
- package/src/testing/index.ts +18 -0
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
|
|
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
|
|
482
|
-
pollInterval: 1000,
|
|
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.
|
|
3
|
+
"version": "2.0.20",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Type-safe plugin system for building RPC-style APIs with Bun",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
"types": "./src/process-client.ts",
|
|
31
31
|
"import": "./src/process-client.ts"
|
|
32
32
|
},
|
|
33
|
+
"./testing": {
|
|
34
|
+
"types": "./src/testing/index.ts",
|
|
35
|
+
"import": "./src/testing/index.ts"
|
|
36
|
+
},
|
|
33
37
|
"./context": {
|
|
34
38
|
"types": "./context.d.ts"
|
|
35
39
|
},
|
|
@@ -68,7 +72,10 @@
|
|
|
68
72
|
"kysely": "^0.27.0 || ^0.28.0",
|
|
69
73
|
"zod": "^3.20.0",
|
|
70
74
|
"@aws-sdk/client-s3": "^3.0.0",
|
|
71
|
-
"@aws-sdk/s3-request-presigner": "^3.0.0"
|
|
75
|
+
"@aws-sdk/s3-request-presigner": "^3.0.0",
|
|
76
|
+
"@playwright/test": "^1.40.0",
|
|
77
|
+
"pg": "^8.0.0",
|
|
78
|
+
"mysql2": "^3.0.0"
|
|
72
79
|
},
|
|
73
80
|
"peerDependenciesMeta": {
|
|
74
81
|
"@aws-sdk/client-s3": {
|
|
@@ -76,6 +83,15 @@
|
|
|
76
83
|
},
|
|
77
84
|
"@aws-sdk/s3-request-presigner": {
|
|
78
85
|
"optional": true
|
|
86
|
+
},
|
|
87
|
+
"@playwright/test": {
|
|
88
|
+
"optional": true
|
|
89
|
+
},
|
|
90
|
+
"pg": {
|
|
91
|
+
"optional": true
|
|
92
|
+
},
|
|
93
|
+
"mysql2": {
|
|
94
|
+
"optional": true
|
|
79
95
|
}
|
|
80
96
|
},
|
|
81
97
|
"dependencies": {
|
package/src/admin/routes.ts
CHANGED
|
@@ -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,31 +111,142 @@ class CronExpression {
|
|
|
99
111
|
const month = date.getMonth() + 1;
|
|
100
112
|
const dayOfWeek = date.getDay();
|
|
101
113
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
|
135
|
+
/**
|
|
136
|
+
* Get the next run time using an optimized jump algorithm.
|
|
137
|
+
* Instead of iterating second-by-second (which could be 31M iterations),
|
|
138
|
+
* this jumps directly to the next valid value for each field.
|
|
139
|
+
*/
|
|
112
140
|
getNextRun(from: Date = new Date()): Date {
|
|
113
141
|
const next = new Date(from);
|
|
114
142
|
next.setMilliseconds(0);
|
|
115
143
|
next.setSeconds(next.getSeconds() + 1);
|
|
116
144
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
145
|
+
const [seconds, minutes, hours, daysOfMonth, months, daysOfWeek] = this.fields;
|
|
146
|
+
|
|
147
|
+
// Maximum iterations to prevent infinite loops (covers 4 years to handle leap years)
|
|
148
|
+
const maxYearIterations = 4;
|
|
149
|
+
const startYear = next.getFullYear();
|
|
150
|
+
|
|
151
|
+
// Iterate through potential dates (worst case: a few hundred iterations)
|
|
152
|
+
for (let yearOffset = 0; yearOffset <= maxYearIterations; yearOffset++) {
|
|
153
|
+
// Try each valid month
|
|
154
|
+
for (const month of months) {
|
|
155
|
+
const targetMonth = month - 1; // JS months are 0-indexed
|
|
156
|
+
|
|
157
|
+
// Skip months in the past
|
|
158
|
+
if (next.getFullYear() === startYear + yearOffset) {
|
|
159
|
+
if (targetMonth < next.getMonth()) continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Set to this month
|
|
163
|
+
if (targetMonth !== next.getMonth() || next.getFullYear() !== startYear + yearOffset) {
|
|
164
|
+
next.setFullYear(startYear + yearOffset, targetMonth, 1);
|
|
165
|
+
next.setHours(0, 0, 0, 0);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Get days in this month
|
|
169
|
+
const daysInMonth = new Date(next.getFullYear(), targetMonth + 1, 0).getDate();
|
|
170
|
+
|
|
171
|
+
// Try each valid day of month
|
|
172
|
+
for (const dayOfMonth of daysOfMonth) {
|
|
173
|
+
if (dayOfMonth > daysInMonth) continue; // Skip invalid days for this month
|
|
174
|
+
|
|
175
|
+
// Check if this day matches day-of-week constraint
|
|
176
|
+
// Use OR semantics if both are restricted, AND otherwise
|
|
177
|
+
const testDate = new Date(next.getFullYear(), targetMonth, dayOfMonth);
|
|
178
|
+
const dayOfWeek = testDate.getDay();
|
|
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;
|
|
191
|
+
|
|
192
|
+
// Skip days in the past
|
|
193
|
+
if (testDate < new Date(from.getFullYear(), from.getMonth(), from.getDate())) continue;
|
|
194
|
+
|
|
195
|
+
// Set to this day
|
|
196
|
+
if (dayOfMonth !== next.getDate()) {
|
|
197
|
+
next.setDate(dayOfMonth);
|
|
198
|
+
next.setHours(0, 0, 0, 0);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Try each valid hour
|
|
202
|
+
for (const hour of hours) {
|
|
203
|
+
// Skip hours in the past for today
|
|
204
|
+
if (next.getFullYear() === from.getFullYear() &&
|
|
205
|
+
next.getMonth() === from.getMonth() &&
|
|
206
|
+
next.getDate() === from.getDate() &&
|
|
207
|
+
hour < from.getHours()) continue;
|
|
208
|
+
|
|
209
|
+
if (hour !== next.getHours()) {
|
|
210
|
+
next.setHours(hour, 0, 0, 0);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Try each valid minute
|
|
214
|
+
for (const minute of minutes) {
|
|
215
|
+
// Skip minutes in the past for this hour
|
|
216
|
+
if (next.getFullYear() === from.getFullYear() &&
|
|
217
|
+
next.getMonth() === from.getMonth() &&
|
|
218
|
+
next.getDate() === from.getDate() &&
|
|
219
|
+
next.getHours() === from.getHours() &&
|
|
220
|
+
minute < from.getMinutes()) continue;
|
|
221
|
+
|
|
222
|
+
if (minute !== next.getMinutes()) {
|
|
223
|
+
next.setMinutes(minute, 0, 0);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Try each valid second
|
|
227
|
+
for (const second of seconds) {
|
|
228
|
+
// Skip seconds in the past for this minute
|
|
229
|
+
if (next.getFullYear() === from.getFullYear() &&
|
|
230
|
+
next.getMonth() === from.getMonth() &&
|
|
231
|
+
next.getDate() === from.getDate() &&
|
|
232
|
+
next.getHours() === from.getHours() &&
|
|
233
|
+
next.getMinutes() === from.getMinutes() &&
|
|
234
|
+
second <= from.getSeconds()) continue;
|
|
235
|
+
|
|
236
|
+
next.setSeconds(second);
|
|
237
|
+
|
|
238
|
+
// Verify the date is still valid (handles edge cases like month rollover)
|
|
239
|
+
if (next > from && this.matches(next)) {
|
|
240
|
+
return next;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
122
246
|
}
|
|
123
|
-
next.setSeconds(next.getSeconds() + 1);
|
|
124
247
|
}
|
|
125
248
|
|
|
126
|
-
throw new Error("Could not find next run time within
|
|
249
|
+
throw new Error("Could not find next run time within 4 years");
|
|
127
250
|
}
|
|
128
251
|
}
|
|
129
252
|
|
|
@@ -220,6 +343,9 @@ class CronImpl implements Cron {
|
|
|
220
343
|
if (this.running) return;
|
|
221
344
|
this.running = true;
|
|
222
345
|
|
|
346
|
+
// Catch-up check for missed runs since last shutdown
|
|
347
|
+
this.catchUpMissedRuns();
|
|
348
|
+
|
|
223
349
|
// Check every second
|
|
224
350
|
this.timer = setInterval(() => {
|
|
225
351
|
const now = new Date();
|
|
@@ -241,6 +367,49 @@ class CronImpl implements Cron {
|
|
|
241
367
|
}, 1000);
|
|
242
368
|
}
|
|
243
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
|
+
|
|
244
413
|
async stop(): Promise<void> {
|
|
245
414
|
this.running = false;
|
|
246
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,
|