@multiplier-labs/stepflow 0.3.1

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 (76) hide show
  1. package/.github/workflows/publish.yml +30 -0
  2. package/README.md +408 -0
  3. package/dist/chunk-FQCT3I2A.js +1512 -0
  4. package/dist/chunk-FQCT3I2A.js.map +1 -0
  5. package/dist/chunk-GXY66V7Q.js +111 -0
  6. package/dist/chunk-GXY66V7Q.js.map +1 -0
  7. package/dist/chunk-U5QGN3RT.js +715 -0
  8. package/dist/chunk-U5QGN3RT.js.map +1 -0
  9. package/dist/chunk-XCF4PLN7.js +575 -0
  10. package/dist/chunk-XCF4PLN7.js.map +1 -0
  11. package/dist/events/index.d.ts +346 -0
  12. package/dist/events/index.js +11 -0
  13. package/dist/events/index.js.map +1 -0
  14. package/dist/index-CPfGsmrp.d.ts +567 -0
  15. package/dist/index.d.ts +748 -0
  16. package/dist/index.js +1707 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/scheduler/index.d.ts +6 -0
  19. package/dist/scheduler/index.js +12 -0
  20. package/dist/scheduler/index.js.map +1 -0
  21. package/dist/storage/index.d.ts +402 -0
  22. package/dist/storage/index.js +13 -0
  23. package/dist/storage/index.js.map +1 -0
  24. package/dist/types-BVShq7zT.d.ts +150 -0
  25. package/dist/types-BlwFEGzj.d.ts +276 -0
  26. package/dist/types-DzkEqnQJ.d.ts +75 -0
  27. package/docs/github-packages-setup.md +97 -0
  28. package/docs/stepflow-api-reference.md +1662 -0
  29. package/package.json +85 -0
  30. package/src/core/engine.test.ts +1421 -0
  31. package/src/core/engine.ts +717 -0
  32. package/src/core/index.ts +7 -0
  33. package/src/core/orchestrator.ts +590 -0
  34. package/src/core/types.ts +242 -0
  35. package/src/events/index.ts +8 -0
  36. package/src/events/memory.test.ts +109 -0
  37. package/src/events/memory.ts +95 -0
  38. package/src/events/socketio.test.ts +428 -0
  39. package/src/events/socketio.ts +269 -0
  40. package/src/events/types.ts +121 -0
  41. package/src/events/webhook.test.ts +695 -0
  42. package/src/events/webhook.ts +591 -0
  43. package/src/index.ts +270 -0
  44. package/src/planning/index.ts +52 -0
  45. package/src/planning/planner.ts +601 -0
  46. package/src/planning/planning.test.ts +790 -0
  47. package/src/planning/registry.test.ts +445 -0
  48. package/src/planning/registry.ts +369 -0
  49. package/src/planning/types.ts +570 -0
  50. package/src/scheduler/cron.test.ts +480 -0
  51. package/src/scheduler/cron.ts +449 -0
  52. package/src/scheduler/index.ts +18 -0
  53. package/src/scheduler/postgres-persistence.test.ts +906 -0
  54. package/src/scheduler/postgres-persistence.ts +546 -0
  55. package/src/scheduler/sqlite-persistence.test.ts +235 -0
  56. package/src/scheduler/sqlite-persistence.ts +268 -0
  57. package/src/scheduler/types.ts +78 -0
  58. package/src/storage/index.ts +27 -0
  59. package/src/storage/memory.test.ts +399 -0
  60. package/src/storage/memory.ts +248 -0
  61. package/src/storage/postgres.ts +1550 -0
  62. package/src/storage/sqlite.test.ts +593 -0
  63. package/src/storage/sqlite.ts +662 -0
  64. package/src/storage/types.ts +368 -0
  65. package/src/utils/errors.test.ts +152 -0
  66. package/src/utils/errors.ts +191 -0
  67. package/src/utils/id.ts +21 -0
  68. package/src/utils/index.ts +8 -0
  69. package/src/utils/logger.test.ts +127 -0
  70. package/src/utils/logger.ts +96 -0
  71. package/src/utils/postgres-deps.ts +63 -0
  72. package/src/utils/retry.test.ts +197 -0
  73. package/src/utils/retry.ts +121 -0
  74. package/tsconfig.json +21 -0
  75. package/tsup.config.ts +14 -0
  76. package/vitest.config.ts +9 -0
@@ -0,0 +1,480 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import {
3
+ CronScheduler,
4
+ type CronSchedulerConfig,
5
+ type SchedulePersistence,
6
+ } from "./cron";
7
+ import { WorkflowEngine } from "../core/engine";
8
+ import { MemoryStorageAdapter } from "../storage/memory";
9
+ import { MemoryEventTransport } from "../events/memory";
10
+ import { SilentLogger } from "../utils/logger";
11
+ import type { WorkflowSchedule } from "./types";
12
+
13
+ describe("CronScheduler", () => {
14
+ let engine: WorkflowEngine;
15
+ let scheduler: CronScheduler;
16
+
17
+ beforeEach(() => {
18
+ // Create a workflow engine with silent logger
19
+ engine = new WorkflowEngine({
20
+ storage: new MemoryStorageAdapter(),
21
+ events: new MemoryEventTransport(),
22
+ logger: new SilentLogger(),
23
+ });
24
+
25
+ // Register a test workflow
26
+ engine.registerWorkflow({
27
+ kind: "test.workflow",
28
+ name: "Test Workflow",
29
+ steps: [
30
+ {
31
+ key: "step1",
32
+ name: "Step 1",
33
+ handler: async (ctx) => ({ value: ctx.input.value ?? "default" }),
34
+ },
35
+ ],
36
+ });
37
+
38
+ // Create scheduler
39
+ scheduler = new CronScheduler({
40
+ engine,
41
+ logger: new SilentLogger(),
42
+ pollInterval: 100, // Fast polling for tests
43
+ });
44
+ });
45
+
46
+ afterEach(async () => {
47
+ await scheduler.stop();
48
+ await engine.shutdown();
49
+ });
50
+
51
+ describe("addSchedule", () => {
52
+ it("should add a cron schedule", async () => {
53
+ const schedule = await scheduler.addSchedule({
54
+ workflowKind: "test.workflow",
55
+ triggerType: "cron",
56
+ cronExpression: "0 * * * *", // Every hour
57
+ enabled: true,
58
+ });
59
+
60
+ expect(schedule.id).toBeDefined();
61
+ expect(schedule.workflowKind).toBe("test.workflow");
62
+ expect(schedule.triggerType).toBe("cron");
63
+ expect(schedule.cronExpression).toBe("0 * * * *");
64
+ expect(schedule.nextRunAt).toBeInstanceOf(Date);
65
+ });
66
+
67
+ it("should add a workflow completion trigger", async () => {
68
+ const schedule = await scheduler.addSchedule({
69
+ workflowKind: "notification.send",
70
+ triggerType: "workflow_completed",
71
+ triggerOnWorkflowKind: "order.process",
72
+ triggerOnStatus: ["succeeded"],
73
+ enabled: true,
74
+ });
75
+
76
+ expect(schedule.id).toBeDefined();
77
+ expect(schedule.triggerType).toBe("workflow_completed");
78
+ expect(schedule.triggerOnWorkflowKind).toBe("order.process");
79
+ });
80
+
81
+ it("should reject invalid cron expression", async () => {
82
+ await expect(
83
+ scheduler.addSchedule({
84
+ workflowKind: "test.workflow",
85
+ triggerType: "cron",
86
+ cronExpression: "invalid cron",
87
+ enabled: true,
88
+ }),
89
+ ).rejects.toThrow("Invalid cron expression");
90
+ });
91
+
92
+ it("should require triggerOnWorkflowKind for workflow_completed triggers", async () => {
93
+ await expect(
94
+ scheduler.addSchedule({
95
+ workflowKind: "test.workflow",
96
+ triggerType: "workflow_completed",
97
+ enabled: true,
98
+ }),
99
+ ).rejects.toThrow("triggerOnWorkflowKind is required");
100
+ });
101
+ });
102
+
103
+ describe("removeSchedule", () => {
104
+ it("should remove a schedule", async () => {
105
+ const schedule = await scheduler.addSchedule({
106
+ workflowKind: "test.workflow",
107
+ triggerType: "cron",
108
+ cronExpression: "0 * * * *",
109
+ enabled: true,
110
+ });
111
+
112
+ await scheduler.removeSchedule(schedule.id);
113
+
114
+ const schedules = await scheduler.getSchedules();
115
+ expect(schedules).toHaveLength(0);
116
+ });
117
+
118
+ it("should throw for non-existent schedule", async () => {
119
+ await expect(scheduler.removeSchedule("nonexistent")).rejects.toThrow(
120
+ "Schedule not found",
121
+ );
122
+ });
123
+ });
124
+
125
+ describe("updateSchedule", () => {
126
+ it("should update schedule properties", async () => {
127
+ const schedule = await scheduler.addSchedule({
128
+ workflowKind: "test.workflow",
129
+ triggerType: "cron",
130
+ cronExpression: "0 * * * *",
131
+ enabled: true,
132
+ });
133
+
134
+ await scheduler.updateSchedule(schedule.id, { enabled: false });
135
+
136
+ const updated = scheduler.getSchedule(schedule.id);
137
+ expect(updated?.enabled).toBe(false);
138
+ });
139
+
140
+ it("should update cron expression and recalculate next run", async () => {
141
+ const schedule = await scheduler.addSchedule({
142
+ workflowKind: "test.workflow",
143
+ triggerType: "cron",
144
+ cronExpression: "0 * * * *",
145
+ enabled: true,
146
+ });
147
+
148
+ const originalNextRun = schedule.nextRunAt;
149
+
150
+ await scheduler.updateSchedule(schedule.id, {
151
+ cronExpression: "30 * * * *",
152
+ });
153
+
154
+ const updated = scheduler.getSchedule(schedule.id);
155
+ expect(updated?.cronExpression).toBe("30 * * * *");
156
+ expect(updated?.nextRunAt).not.toEqual(originalNextRun);
157
+ });
158
+ });
159
+
160
+ describe("getSchedules", () => {
161
+ it("should return all schedules", async () => {
162
+ await scheduler.addSchedule({
163
+ workflowKind: "test.workflow",
164
+ triggerType: "cron",
165
+ cronExpression: "0 * * * *",
166
+ enabled: true,
167
+ });
168
+
169
+ await scheduler.addSchedule({
170
+ workflowKind: "test.workflow",
171
+ triggerType: "manual",
172
+ enabled: true,
173
+ });
174
+
175
+ const schedules = await scheduler.getSchedules();
176
+ expect(schedules).toHaveLength(2);
177
+ });
178
+ });
179
+
180
+ describe("triggerNow", () => {
181
+ it("should manually trigger a schedule", async () => {
182
+ const schedule = await scheduler.addSchedule({
183
+ workflowKind: "test.workflow",
184
+ triggerType: "cron",
185
+ cronExpression: "0 0 1 1 *", // Once a year (won't trigger naturally)
186
+ input: { value: "manual" },
187
+ enabled: true,
188
+ });
189
+
190
+ const runId = await scheduler.triggerNow(schedule.id);
191
+
192
+ expect(runId).toBeDefined();
193
+
194
+ // Wait for workflow to complete
195
+ const run = await engine.waitForRun(runId);
196
+ expect(run.status).toBe("succeeded");
197
+ });
198
+
199
+ it("should throw for non-existent schedule", async () => {
200
+ await expect(scheduler.triggerNow("nonexistent")).rejects.toThrow(
201
+ "Schedule not found",
202
+ );
203
+ });
204
+ });
205
+
206
+ describe("cron execution", () => {
207
+ it("should execute scheduled workflow when cron time is reached", async () => {
208
+ // Use fake timers
209
+ vi.useFakeTimers();
210
+
211
+ // Create a schedule that runs every minute
212
+ await scheduler.addSchedule({
213
+ workflowKind: "test.workflow",
214
+ triggerType: "cron",
215
+ cronExpression: "* * * * *", // Every minute
216
+ input: { value: "scheduled" },
217
+ enabled: true,
218
+ });
219
+
220
+ await scheduler.start();
221
+
222
+ // Advance time past the next minute boundary
223
+ const now = new Date();
224
+ const msUntilNextMinute = (60 - now.getSeconds()) * 1000 + 1000;
225
+ vi.advanceTimersByTime(msUntilNextMinute);
226
+
227
+ // Run pending timers for poll interval
228
+ await vi.runOnlyPendingTimersAsync();
229
+
230
+ vi.useRealTimers();
231
+
232
+ // Note: With fake timers, the actual workflow may not complete
233
+ // This test verifies the scheduler logic, not full execution
234
+ });
235
+ });
236
+
237
+ describe("workflow completion triggers", () => {
238
+ it("should trigger workflow on completion of another workflow", async () => {
239
+ // Register a trigger workflow
240
+ engine.registerWorkflow({
241
+ kind: "trigger.workflow",
242
+ name: "Trigger Workflow",
243
+ steps: [
244
+ {
245
+ key: "trigger",
246
+ name: "Trigger",
247
+ handler: async () => ({ triggered: true }),
248
+ },
249
+ ],
250
+ });
251
+
252
+ // Register a dependent workflow
253
+ engine.registerWorkflow({
254
+ kind: "dependent.workflow",
255
+ name: "Dependent Workflow",
256
+ steps: [
257
+ {
258
+ key: "dependent",
259
+ name: "Dependent",
260
+ handler: async (ctx) => ({
261
+ parentRunId: ctx.metadata.triggerRunId,
262
+ }),
263
+ },
264
+ ],
265
+ });
266
+
267
+ // Add a completion trigger
268
+ await scheduler.addSchedule({
269
+ workflowKind: "dependent.workflow",
270
+ triggerType: "workflow_completed",
271
+ triggerOnWorkflowKind: "trigger.workflow",
272
+ triggerOnStatus: ["succeeded"],
273
+ enabled: true,
274
+ });
275
+
276
+ await scheduler.start();
277
+
278
+ // Start the trigger workflow
279
+ const triggerRunId = await engine.startRun({ kind: "trigger.workflow" });
280
+
281
+ // Wait for trigger workflow to complete
282
+ await engine.waitForRun(triggerRunId);
283
+
284
+ // Give time for the completion trigger to fire and dependent workflow to start
285
+ await new Promise((resolve) => setTimeout(resolve, 200));
286
+
287
+ // Check that the dependent workflow was started
288
+ const storage = engine.getStorage();
289
+ const { items } = await storage.listRuns({ kind: "dependent.workflow" });
290
+
291
+ expect(items.length).toBeGreaterThanOrEqual(1);
292
+ });
293
+
294
+ it("should not trigger on non-matching status", async () => {
295
+ // Register a failing trigger workflow
296
+ engine.registerWorkflow({
297
+ kind: "failing.workflow",
298
+ name: "Failing Workflow",
299
+ steps: [
300
+ {
301
+ key: "fail",
302
+ name: "Fail",
303
+ handler: async () => {
304
+ throw new Error("Intentional failure");
305
+ },
306
+ },
307
+ ],
308
+ });
309
+
310
+ // Add a completion trigger for success only
311
+ await scheduler.addSchedule({
312
+ workflowKind: "test.workflow",
313
+ triggerType: "workflow_completed",
314
+ triggerOnWorkflowKind: "failing.workflow",
315
+ triggerOnStatus: ["succeeded"], // Only on success
316
+ enabled: true,
317
+ });
318
+
319
+ await scheduler.start();
320
+
321
+ // Start the failing workflow
322
+ const runId = await engine.startRun({ kind: "failing.workflow" });
323
+
324
+ // Wait for it to fail
325
+ await engine.waitForRun(runId);
326
+
327
+ // Give time for any triggers
328
+ await new Promise((resolve) => setTimeout(resolve, 200));
329
+
330
+ // Check that test.workflow was NOT started
331
+ const storage = engine.getStorage();
332
+ const { items } = await storage.listRuns({ kind: "test.workflow" });
333
+
334
+ expect(items).toHaveLength(0);
335
+ });
336
+ });
337
+
338
+ describe("start/stop", () => {
339
+ it("should start and stop without error", async () => {
340
+ await scheduler.start();
341
+ await scheduler.stop();
342
+ });
343
+
344
+ it("should warn when starting twice", async () => {
345
+ await scheduler.start();
346
+ await scheduler.start(); // Should just warn
347
+ await scheduler.stop();
348
+ });
349
+
350
+ it("should handle stopping when not started", async () => {
351
+ await scheduler.stop(); // Should do nothing
352
+ });
353
+ });
354
+
355
+ describe("persistence", () => {
356
+ it("should load schedules from persistence on start", async () => {
357
+ const mockSchedule: WorkflowSchedule = {
358
+ id: "persisted-1",
359
+ workflowKind: "test.workflow",
360
+ triggerType: "cron",
361
+ cronExpression: "0 * * * *",
362
+ enabled: true,
363
+ };
364
+
365
+ const mockPersistence: SchedulePersistence = {
366
+ loadSchedules: vi.fn().mockResolvedValue([mockSchedule]),
367
+ saveSchedule: vi.fn().mockResolvedValue(undefined),
368
+ updateSchedule: vi.fn().mockResolvedValue(undefined),
369
+ deleteSchedule: vi.fn().mockResolvedValue(undefined),
370
+ };
371
+
372
+ const schedulerWithPersistence = new CronScheduler({
373
+ engine,
374
+ logger: new SilentLogger(),
375
+ persistence: mockPersistence,
376
+ });
377
+
378
+ await schedulerWithPersistence.start();
379
+
380
+ expect(mockPersistence.loadSchedules).toHaveBeenCalled();
381
+
382
+ const schedules = await schedulerWithPersistence.getSchedules();
383
+ expect(schedules).toHaveLength(1);
384
+ expect(schedules[0].id).toBe("persisted-1");
385
+
386
+ await schedulerWithPersistence.stop();
387
+ });
388
+
389
+ it("should save schedule to persistence when added", async () => {
390
+ const mockPersistence: SchedulePersistence = {
391
+ loadSchedules: vi.fn().mockResolvedValue([]),
392
+ saveSchedule: vi.fn().mockResolvedValue(undefined),
393
+ updateSchedule: vi.fn().mockResolvedValue(undefined),
394
+ deleteSchedule: vi.fn().mockResolvedValue(undefined),
395
+ };
396
+
397
+ const schedulerWithPersistence = new CronScheduler({
398
+ engine,
399
+ logger: new SilentLogger(),
400
+ persistence: mockPersistence,
401
+ });
402
+
403
+ await schedulerWithPersistence.addSchedule({
404
+ workflowKind: "test.workflow",
405
+ triggerType: "cron",
406
+ cronExpression: "0 * * * *",
407
+ enabled: true,
408
+ });
409
+
410
+ expect(mockPersistence.saveSchedule).toHaveBeenCalled();
411
+ });
412
+ });
413
+
414
+ describe("updateNextRunTime parse-error path", () => {
415
+ it("should set nextRunAt to undefined and log error for malformed cron expression update", async () => {
416
+ const logger = {
417
+ debug: vi.fn(),
418
+ info: vi.fn(),
419
+ warn: vi.fn(),
420
+ error: vi.fn(),
421
+ };
422
+ const errorScheduler = new CronScheduler({
423
+ engine,
424
+ logger,
425
+ pollInterval: 100,
426
+ });
427
+
428
+ // Add a valid schedule first
429
+ const schedule = await errorScheduler.addSchedule({
430
+ workflowKind: "test.workflow",
431
+ triggerType: "cron",
432
+ cronExpression: "0 * * * *",
433
+ enabled: true,
434
+ });
435
+
436
+ expect(schedule.nextRunAt).toBeInstanceOf(Date);
437
+
438
+ // Now directly update the internal schedule with a malformed expression
439
+ // to trigger the error path in updateNextRunTime
440
+ // We need to bypass the validation in updateSchedule by manipulating the internal state.
441
+ // The updateSchedule method validates and throws, but updateNextRunTime is called
442
+ // during start() when loading from persistence — so we test via start() loading bad data.
443
+ const mockPersistence: SchedulePersistence = {
444
+ loadSchedules: vi.fn().mockResolvedValue([
445
+ {
446
+ id: "bad-cron",
447
+ workflowKind: "test.workflow",
448
+ triggerType: "cron" as const,
449
+ cronExpression: "99 99 99 99 99",
450
+ enabled: true,
451
+ },
452
+ ]),
453
+ saveSchedule: vi.fn().mockResolvedValue(undefined),
454
+ updateSchedule: vi.fn().mockResolvedValue(undefined),
455
+ deleteSchedule: vi.fn().mockResolvedValue(undefined),
456
+ };
457
+
458
+ const badScheduler = new CronScheduler({
459
+ engine,
460
+ logger,
461
+ persistence: mockPersistence,
462
+ pollInterval: 100,
463
+ });
464
+
465
+ await badScheduler.start();
466
+
467
+ // The schedule should have been loaded with nextRunAt = undefined due to parse error
468
+ const loaded = badScheduler.getSchedule("bad-cron");
469
+ expect(loaded).toBeDefined();
470
+ expect(loaded!.nextRunAt).toBeUndefined();
471
+ expect(logger.error).toHaveBeenCalledWith(
472
+ expect.stringContaining("Failed to parse cron expression"),
473
+ expect.anything(),
474
+ );
475
+
476
+ await badScheduler.stop();
477
+ await errorScheduler.stop();
478
+ });
479
+ });
480
+ });