@multiplier-labs/stepflow 0.3.1 → 0.3.4

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 (112) hide show
  1. package/.codekin/reports/code-review/2026-04-11_code-review-daily.md +146 -0
  2. package/.codekin/reports/code-review/2026-04-13_code-review-daily.md +74 -0
  3. package/.codekin/reports/comments/2026-04-04_comment-assessment.md +179 -0
  4. package/.codekin/reports/comments/2026-04-11_comment-assessment.md +161 -0
  5. package/.codekin/reports/comments/2026-04-14_comment-assessment.md +34 -0
  6. package/.codekin/reports/complexity/2026-04-03_complexity-report.md +152 -0
  7. package/.codekin/reports/complexity/2026-04-11_complexity-report.md +152 -0
  8. package/.codekin/reports/complexity/2026-04-14_complexity-report.md +34 -0
  9. package/.codekin/reports/dependencies/2026-04-04_dependency-health.md +94 -0
  10. package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +93 -0
  11. package/.codekin/reports/docs-audit/2026-04-04_docs-audit.md +10 -0
  12. package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +138 -0
  13. package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +121 -0
  14. package/.codekin/reports/security/2026-04-03_security-audit.md +208 -0
  15. package/.codekin/reports/security/2026-04-11_security-audit.md +297 -0
  16. package/.github/CODEOWNERS +15 -0
  17. package/.github/dependabot.yml +23 -0
  18. package/.github/workflows/ci.yml +38 -0
  19. package/.github/workflows/publish.yml +17 -5
  20. package/.github/workflows/security.yml +29 -0
  21. package/CHANGELOG.md +34 -0
  22. package/README.md +24 -0
  23. package/SECURITY.md +40 -0
  24. package/dist/{chunk-FQCT3I2A.js → chunk-5XJBAPXF.js} +527 -551
  25. package/dist/chunk-5XJBAPXF.js.map +1 -0
  26. package/dist/{chunk-U5QGN3RT.js → chunk-FMPJBK2N.js} +67 -55
  27. package/dist/chunk-FMPJBK2N.js.map +1 -0
  28. package/dist/{chunk-GXY66V7Q.js → chunk-JPXPB7U5.js} +52 -2
  29. package/dist/chunk-JPXPB7U5.js.map +1 -0
  30. package/dist/{chunk-XCF4PLN7.js → chunk-U3PCVK6O.js} +276 -61
  31. package/dist/chunk-U3PCVK6O.js.map +1 -0
  32. package/dist/events/index.d.ts +60 -7
  33. package/dist/events/index.js +1 -1
  34. package/dist/{index-CPfGsmrp.d.ts → index-Cd0IZ40L.d.ts} +56 -17
  35. package/dist/index.d.ts +15 -23
  36. package/dist/index.js +784 -547
  37. package/dist/index.js.map +1 -1
  38. package/dist/scheduler/index.d.ts +4 -4
  39. package/dist/scheduler/index.js +2 -2
  40. package/dist/storage/index.d.ts +322 -56
  41. package/dist/storage/index.js +4 -2
  42. package/dist/{types-BlwFEGzj.d.ts → types-DSEjiLMj.d.ts} +16 -15
  43. package/dist/{types-DzkEqnQJ.d.ts → types-DZUdWmCc.d.ts} +2 -2
  44. package/dist/{types-BVShq7zT.d.ts → types-K5Gjk3H_.d.ts} +4 -4
  45. package/docs/stepflow-api-reference.md +5 -4
  46. package/package.json +12 -8
  47. package/src/core/engine/concurrency.ts +229 -0
  48. package/src/core/engine/lifecycle.ts +176 -0
  49. package/src/core/engine.test.ts +536 -522
  50. package/src/core/engine.ts +116 -235
  51. package/src/core/index.ts +3 -3
  52. package/src/core/orchestrator/persistence.ts +165 -0
  53. package/src/core/orchestrator/plan-resolution.ts +140 -0
  54. package/src/core/orchestrator/state-transitions.ts +109 -0
  55. package/src/core/orchestrator/step-runner.ts +427 -0
  56. package/src/core/orchestrator.ts +57 -395
  57. package/src/core/types.ts +7 -29
  58. package/src/events/index.ts +4 -4
  59. package/src/events/memory.test.ts +39 -41
  60. package/src/events/memory.ts +7 -10
  61. package/src/events/socketio.test.ts +112 -162
  62. package/src/events/socketio.ts +20 -27
  63. package/src/events/types.ts +16 -19
  64. package/src/events/webhook.test.ts +489 -235
  65. package/src/events/webhook.ts +355 -139
  66. package/src/index.ts +19 -19
  67. package/src/planning/evaluate-condition.test.ts +153 -0
  68. package/src/planning/evaluate-condition.ts +105 -0
  69. package/src/planning/index.ts +6 -3
  70. package/src/planning/planner.ts +45 -152
  71. package/src/planning/planning.test.ts +303 -366
  72. package/src/planning/registry.test.ts +204 -251
  73. package/src/planning/registry.ts +20 -106
  74. package/src/planning/types.ts +23 -27
  75. package/src/scheduler/cron.test.ts +149 -150
  76. package/src/scheduler/cron.ts +58 -69
  77. package/src/scheduler/index.ts +4 -14
  78. package/src/scheduler/postgres-persistence.ts +89 -105
  79. package/src/scheduler/sqlite-persistence.test.ts +96 -96
  80. package/src/scheduler/sqlite-persistence.ts +40 -22
  81. package/src/scheduler/types.ts +4 -9
  82. package/src/storage/index.ts +5 -5
  83. package/src/storage/memory.test.ts +133 -183
  84. package/src/storage/memory.ts +21 -42
  85. package/src/storage/postgres-core.ts +550 -0
  86. package/src/storage/postgres-migration.test.ts +69 -0
  87. package/src/storage/postgres-transaction-pagination.test.ts +107 -0
  88. package/src/storage/postgres.ts +222 -937
  89. package/src/storage/sqlite-core.ts +151 -0
  90. package/src/storage/sqlite.test.ts +330 -281
  91. package/src/storage/sqlite.ts +225 -170
  92. package/src/storage/types.ts +33 -58
  93. package/src/utils/errors.test.ts +78 -80
  94. package/src/utils/errors.ts +27 -57
  95. package/src/utils/id.ts +3 -3
  96. package/src/utils/index.ts +6 -4
  97. package/src/utils/logger.test.ts +52 -58
  98. package/src/utils/logger.ts +4 -4
  99. package/src/utils/pinned-fetch.test.ts +84 -0
  100. package/src/utils/pinned-fetch.ts +118 -0
  101. package/src/utils/postgres-deps.ts +20 -4
  102. package/src/utils/retry.test.ts +47 -67
  103. package/src/utils/retry.ts +9 -9
  104. package/src/utils/safe-json.test.ts +166 -0
  105. package/src/utils/safe-json.ts +107 -0
  106. package/src/utils/webhook-signature.test.ts +111 -0
  107. package/src/utils/webhook-signature.ts +74 -0
  108. package/dist/chunk-FQCT3I2A.js.map +0 -1
  109. package/dist/chunk-GXY66V7Q.js.map +0 -1
  110. package/dist/chunk-U5QGN3RT.js.map +0 -1
  111. package/dist/chunk-XCF4PLN7.js.map +0 -1
  112. package/src/scheduler/postgres-persistence.test.ts +0 -906
@@ -1,906 +0,0 @@
1
- import { describe, it, expect, beforeEach, vi } from "vitest";
2
- import { PostgresSchedulePersistence } from "./postgres-persistence";
3
- import type { WorkflowSchedule } from "./types";
4
-
5
- // ---------------------------------------------------------------------------
6
- // Mock helpers – We mock `loadPostgresDeps` so we never need a real PG server.
7
- // Instead, a fake Kysely instance backed by an in-memory row store is provided.
8
- // ---------------------------------------------------------------------------
9
-
10
- /** Rows stored in our fake database keyed by schedule id. */
11
- let fakeRows: Map<string, Record<string, any>>;
12
-
13
- /** Tracks SQL raw calls made via the `sql` template tag (for createTables). */
14
- let sqlExecuteCalls: number;
15
-
16
- /**
17
- * Chainable query-builder mock that operates on `fakeRows`.
18
- * Each method returns `this` to allow chaining, except terminal methods
19
- * (execute / executeTakeFirst) which resolve with data from the store.
20
- */
21
- function createQueryBuilder() {
22
- let _table: string;
23
- let _wheres: Array<{ col: string; op: string; val: any }> = [];
24
- let _insertValues: Record<string, any> | null = null;
25
- let _updateValues: Record<string, any> | null = null;
26
-
27
- const matchesWhere = (row: Record<string, any>) =>
28
- _wheres.every(({ col, op, val }) => {
29
- switch (op) {
30
- case "=":
31
- return row[col] === val;
32
- case "<=":
33
- return row[col] <= val;
34
- default:
35
- return true;
36
- }
37
- });
38
-
39
- const qb: any = {
40
- withSchema() {
41
- return qb;
42
- },
43
- selectFrom(table: string) {
44
- _table = table;
45
- _wheres = [];
46
- return qb;
47
- },
48
- selectAll() {
49
- return qb;
50
- },
51
- where(col: string, op: string, val: any) {
52
- _wheres.push({ col, op, val });
53
- return qb;
54
- },
55
- insertInto(table: string) {
56
- _table = table;
57
- return qb;
58
- },
59
- values(vals: Record<string, any>) {
60
- _insertValues = vals;
61
- return qb;
62
- },
63
- updateTable(table: string) {
64
- _table = table;
65
- _wheres = [];
66
- return qb;
67
- },
68
- set(vals: Record<string, any>) {
69
- _updateValues = vals;
70
- return qb;
71
- },
72
- deleteFrom(table: string) {
73
- _table = table;
74
- _wheres = [];
75
- return qb;
76
- },
77
-
78
- async execute() {
79
- // INSERT
80
- if (_insertValues) {
81
- fakeRows.set(_insertValues.id, { ..._insertValues });
82
- _insertValues = null;
83
- return [];
84
- }
85
- // UPDATE
86
- if (_updateValues) {
87
- for (const [, row] of fakeRows) {
88
- if (matchesWhere(row)) {
89
- Object.assign(row, _updateValues);
90
- }
91
- }
92
- _updateValues = null;
93
- return [];
94
- }
95
- // DELETE
96
- if (
97
- _wheres.length >= 0 &&
98
- _insertValues === null &&
99
- _updateValues === null
100
- ) {
101
- // If it's a deleteFrom call
102
- const toDelete: string[] = [];
103
- for (const [id, row] of fakeRows) {
104
- if (matchesWhere(row)) toDelete.push(id);
105
- }
106
- for (const id of toDelete) fakeRows.delete(id);
107
- // For selectFrom case, return matching rows
108
- const results: Record<string, any>[] = [];
109
- for (const [, row] of fakeRows) {
110
- if (matchesWhere(row)) results.push({ ...row });
111
- }
112
- return results;
113
- }
114
- return [];
115
- },
116
-
117
- async executeTakeFirst() {
118
- for (const [, row] of fakeRows) {
119
- if (matchesWhere(row)) return { ...row };
120
- }
121
- return undefined;
122
- },
123
- };
124
-
125
- return qb;
126
- }
127
-
128
- // We need to distinguish between select/delete/update calls properly.
129
- // Let's refactor the mock with an operation tracker.
130
- function createTrackedQueryBuilder() {
131
- let _op: "select" | "insert" | "update" | "delete" = "select";
132
- let _wheres: Array<{ col: string; op: string; val: any }> = [];
133
- let _insertValues: Record<string, any> | null = null;
134
- let _updateValues: Record<string, any> | null = null;
135
-
136
- const matchesWhere = (row: Record<string, any>) =>
137
- _wheres.every(({ col, op, val }) => {
138
- switch (op) {
139
- case "=":
140
- return row[col] === val;
141
- case "<=":
142
- return row[col] <= val;
143
- default:
144
- return true;
145
- }
146
- });
147
-
148
- const qb: any = {
149
- withSchema() {
150
- return qb;
151
- },
152
- selectFrom() {
153
- _op = "select";
154
- _wheres = [];
155
- return qb;
156
- },
157
- selectAll() {
158
- return qb;
159
- },
160
- where(col: string, op: string, val: any) {
161
- _wheres.push({ col, op, val });
162
- return qb;
163
- },
164
- insertInto() {
165
- _op = "insert";
166
- return qb;
167
- },
168
- values(vals: Record<string, any>) {
169
- _insertValues = vals;
170
- return qb;
171
- },
172
- updateTable() {
173
- _op = "update";
174
- _wheres = [];
175
- return qb;
176
- },
177
- set(vals: Record<string, any>) {
178
- _updateValues = vals;
179
- return qb;
180
- },
181
- deleteFrom() {
182
- _op = "delete";
183
- _wheres = [];
184
- return qb;
185
- },
186
-
187
- async execute() {
188
- if (_op === "insert" && _insertValues) {
189
- fakeRows.set(_insertValues.id, { ..._insertValues });
190
- _insertValues = null;
191
- return [];
192
- }
193
- if (_op === "update" && _updateValues) {
194
- for (const [, row] of fakeRows) {
195
- if (matchesWhere(row)) Object.assign(row, _updateValues);
196
- }
197
- _updateValues = null;
198
- return [];
199
- }
200
- if (_op === "delete") {
201
- const toDelete: string[] = [];
202
- for (const [id, row] of fakeRows) {
203
- if (matchesWhere(row)) toDelete.push(id);
204
- }
205
- for (const id of toDelete) fakeRows.delete(id);
206
- return [];
207
- }
208
- // select
209
- const results: Record<string, any>[] = [];
210
- for (const [, row] of fakeRows) {
211
- if (matchesWhere(row)) results.push({ ...row });
212
- }
213
- return results;
214
- },
215
-
216
- async executeTakeFirst() {
217
- for (const [, row] of fakeRows) {
218
- if (matchesWhere(row)) return { ...row };
219
- }
220
- return undefined;
221
- },
222
- };
223
-
224
- return qb;
225
- }
226
-
227
- // ---------------------------------------------------------------------------
228
- // Mock loadPostgresDeps so the class never touches real pg/kysely
229
- // ---------------------------------------------------------------------------
230
-
231
- let mockDb: ReturnType<typeof createTrackedQueryBuilder>;
232
-
233
- vi.mock("../utils/postgres-deps.js", () => ({
234
- loadPostgresDeps: async () => {
235
- sqlExecuteCalls = 0;
236
- return {
237
- Kysely: class {
238
- withSchema() {
239
- return mockDb;
240
- }
241
- destroy() {
242
- return Promise.resolve();
243
- }
244
- },
245
- PostgresDialect: class {
246
- constructor() {}
247
- },
248
- sql: new Proxy(() => {}, {
249
- apply() {
250
- return {
251
- execute: async () => {
252
- sqlExecuteCalls++;
253
- },
254
- };
255
- },
256
- get(_target: any, prop: string) {
257
- if (prop === "ref" || prop === "table") return (v: any) => v;
258
- return undefined;
259
- },
260
- }),
261
- pgModule: {
262
- Pool: class {
263
- end() {
264
- return Promise.resolve();
265
- }
266
- },
267
- },
268
- };
269
- },
270
- }));
271
-
272
- // ---------------------------------------------------------------------------
273
- // Tests
274
- // ---------------------------------------------------------------------------
275
-
276
- describe("PostgresSchedulePersistence", () => {
277
- let persistence: PostgresSchedulePersistence;
278
-
279
- beforeEach(async () => {
280
- fakeRows = new Map();
281
- sqlExecuteCalls = 0;
282
- mockDb = createTrackedQueryBuilder();
283
- persistence = new PostgresSchedulePersistence({
284
- connectionString: "postgresql://test:test@localhost:5432/test",
285
- });
286
- await persistence.initialize();
287
- });
288
-
289
- // ========================================================================
290
- // Constructor / Schema Validation
291
- // ========================================================================
292
-
293
- describe("constructor validation", () => {
294
- it("should accept valid schema names", () => {
295
- expect(
296
- () =>
297
- new PostgresSchedulePersistence({
298
- connectionString: "pg://x",
299
- schema: "my_schema",
300
- }),
301
- ).not.toThrow();
302
- });
303
-
304
- it("should reject invalid schema names", () => {
305
- expect(
306
- () =>
307
- new PostgresSchedulePersistence({
308
- connectionString: "pg://x",
309
- schema: "0bad",
310
- }),
311
- ).toThrow("Invalid schema name");
312
- });
313
-
314
- it("should reject schema names starting with a digit", () => {
315
- expect(
316
- () =>
317
- new PostgresSchedulePersistence({
318
- connectionString: "pg://x",
319
- schema: "1abc",
320
- }),
321
- ).toThrow("Invalid schema name");
322
- });
323
-
324
- it("should reject schema names with special characters", () => {
325
- expect(
326
- () =>
327
- new PostgresSchedulePersistence({
328
- connectionString: "pg://x",
329
- schema: "my-schema",
330
- }),
331
- ).toThrow("Invalid schema name");
332
- });
333
-
334
- it("should reject schema names longer than 63 characters", () => {
335
- const longName = "a".repeat(64);
336
- expect(
337
- () =>
338
- new PostgresSchedulePersistence({
339
- connectionString: "pg://x",
340
- schema: longName,
341
- }),
342
- ).toThrow("Invalid schema name");
343
- });
344
-
345
- it("should accept schema name at max length (63 chars)", () => {
346
- const maxName = "a".repeat(63);
347
- expect(
348
- () =>
349
- new PostgresSchedulePersistence({
350
- connectionString: "pg://x",
351
- schema: maxName,
352
- }),
353
- ).not.toThrow();
354
- });
355
-
356
- it("should accept schema starting with underscore", () => {
357
- expect(
358
- () =>
359
- new PostgresSchedulePersistence({
360
- connectionString: "pg://x",
361
- schema: "_private",
362
- }),
363
- ).not.toThrow();
364
- });
365
-
366
- it("should default schema to public", async () => {
367
- // No error thrown with default schema
368
- const p = new PostgresSchedulePersistence({ connectionString: "pg://x" });
369
- expect(p).toBeDefined();
370
- });
371
- });
372
-
373
- // ========================================================================
374
- // initialize()
375
- // ========================================================================
376
-
377
- describe("initialize", () => {
378
- it("should be idempotent (multiple calls are safe)", async () => {
379
- // Already initialized in beforeEach, calling again should be a no-op
380
- await persistence.initialize();
381
- expect(true).toBe(true); // no error
382
- });
383
-
384
- it("should throw when no connection config is provided", async () => {
385
- const p = new PostgresSchedulePersistence({});
386
- await expect(p.initialize()).rejects.toThrow(
387
- "must include either pool, connectionString, or poolConfig",
388
- );
389
- });
390
-
391
- it("should accept an existing pool", async () => {
392
- const fakePool = { end: vi.fn() } as any;
393
- const p = new PostgresSchedulePersistence({ pool: fakePool });
394
- await p.initialize();
395
- // Should work without errors
396
- });
397
-
398
- it("should accept poolConfig", async () => {
399
- const p = new PostgresSchedulePersistence({
400
- poolConfig: { host: "localhost", port: 5432, database: "test" },
401
- });
402
- await p.initialize();
403
- });
404
-
405
- it("should create tables when autoMigrate is true (default)", async () => {
406
- // sqlExecuteCalls is incremented by our mock sql template tag
407
- // createTables runs multiple SQL statements
408
- expect(sqlExecuteCalls).toBeGreaterThan(0);
409
- });
410
-
411
- it("should skip table creation when autoMigrate is false", async () => {
412
- sqlExecuteCalls = 0;
413
- const p = new PostgresSchedulePersistence({
414
- connectionString: "pg://x",
415
- autoMigrate: false,
416
- });
417
- await p.initialize();
418
- expect(sqlExecuteCalls).toBe(0);
419
- });
420
- });
421
-
422
- // ========================================================================
423
- // ensureInitialized guard
424
- // ========================================================================
425
-
426
- describe("ensureInitialized guard", () => {
427
- it("should throw on loadSchedules before initialize", async () => {
428
- const p = new PostgresSchedulePersistence({ connectionString: "pg://x" });
429
- await expect(p.loadSchedules()).rejects.toThrow("not initialized");
430
- });
431
-
432
- it("should throw on saveSchedule before initialize", async () => {
433
- const p = new PostgresSchedulePersistence({ connectionString: "pg://x" });
434
- await expect(
435
- p.saveSchedule({
436
- id: "x",
437
- workflowKind: "w",
438
- triggerType: "cron",
439
- enabled: true,
440
- }),
441
- ).rejects.toThrow("not initialized");
442
- });
443
-
444
- it("should throw on updateSchedule before initialize", async () => {
445
- const p = new PostgresSchedulePersistence({ connectionString: "pg://x" });
446
- await expect(p.updateSchedule("x", { enabled: false })).rejects.toThrow(
447
- "not initialized",
448
- );
449
- });
450
-
451
- it("should throw on deleteSchedule before initialize", async () => {
452
- const p = new PostgresSchedulePersistence({ connectionString: "pg://x" });
453
- await expect(p.deleteSchedule("x")).rejects.toThrow("not initialized");
454
- });
455
-
456
- it("should throw on getSchedule before initialize", async () => {
457
- const p = new PostgresSchedulePersistence({ connectionString: "pg://x" });
458
- await expect(p.getSchedule("x")).rejects.toThrow("not initialized");
459
- });
460
-
461
- it("should throw on getDueSchedules before initialize", async () => {
462
- const p = new PostgresSchedulePersistence({ connectionString: "pg://x" });
463
- await expect(p.getDueSchedules()).rejects.toThrow("not initialized");
464
- });
465
-
466
- it("should throw on getSchedulesByWorkflowKind before initialize", async () => {
467
- const p = new PostgresSchedulePersistence({ connectionString: "pg://x" });
468
- await expect(p.getSchedulesByWorkflowKind("w")).rejects.toThrow(
469
- "not initialized",
470
- );
471
- });
472
-
473
- it("should throw on getCompletionTriggers before initialize", async () => {
474
- const p = new PostgresSchedulePersistence({ connectionString: "pg://x" });
475
- await expect(p.getCompletionTriggers("w")).rejects.toThrow(
476
- "not initialized",
477
- );
478
- });
479
- });
480
-
481
- // ========================================================================
482
- // saveSchedule
483
- // ========================================================================
484
-
485
- describe("saveSchedule", () => {
486
- it("should save a cron schedule", async () => {
487
- const schedule: WorkflowSchedule = {
488
- id: "test-1",
489
- workflowKind: "test.workflow",
490
- triggerType: "cron",
491
- cronExpression: "0 * * * *",
492
- timezone: "UTC",
493
- enabled: true,
494
- input: { foo: "bar" },
495
- metadata: { user: "test" },
496
- };
497
-
498
- await persistence.saveSchedule(schedule);
499
-
500
- const loaded = await persistence.loadSchedules();
501
- expect(loaded).toHaveLength(1);
502
- expect(loaded[0].id).toBe("test-1");
503
- expect(loaded[0].cronExpression).toBe("0 * * * *");
504
- expect(loaded[0].input).toEqual({ foo: "bar" });
505
- });
506
-
507
- it("should save a workflow completion trigger", async () => {
508
- const schedule: WorkflowSchedule = {
509
- id: "test-2",
510
- workflowKind: "notification.send",
511
- triggerType: "workflow_completed",
512
- triggerOnWorkflowKind: "order.process",
513
- triggerOnStatus: ["succeeded", "failed"],
514
- enabled: true,
515
- };
516
-
517
- await persistence.saveSchedule(schedule);
518
-
519
- const loaded = await persistence.loadSchedules();
520
- expect(loaded).toHaveLength(1);
521
- expect(loaded[0].triggerType).toBe("workflow_completed");
522
- expect(loaded[0].triggerOnWorkflowKind).toBe("order.process");
523
- expect(loaded[0].triggerOnStatus).toEqual(["succeeded", "failed"]);
524
- });
525
-
526
- it("should save schedule with dates", async () => {
527
- const lastRunAt = new Date("2024-01-01T12:00:00Z");
528
- const nextRunAt = new Date("2024-01-01T13:00:00Z");
529
-
530
- const schedule: WorkflowSchedule = {
531
- id: "test-3",
532
- workflowKind: "test.workflow",
533
- triggerType: "cron",
534
- cronExpression: "0 * * * *",
535
- enabled: true,
536
- lastRunAt,
537
- lastRunId: "run-123",
538
- nextRunAt,
539
- };
540
-
541
- await persistence.saveSchedule(schedule);
542
-
543
- const loaded = await persistence.loadSchedules();
544
- expect(loaded[0].lastRunAt).toEqual(lastRunAt);
545
- expect(loaded[0].lastRunId).toBe("run-123");
546
- expect(loaded[0].nextRunAt).toEqual(nextRunAt);
547
- });
548
- });
549
-
550
- // ========================================================================
551
- // loadSchedules
552
- // ========================================================================
553
-
554
- describe("loadSchedules", () => {
555
- it("should return empty array when no schedules", async () => {
556
- const schedules = await persistence.loadSchedules();
557
- expect(schedules).toHaveLength(0);
558
- });
559
-
560
- it("should load multiple schedules", async () => {
561
- await persistence.saveSchedule({
562
- id: "schedule-1",
563
- workflowKind: "workflow.a",
564
- triggerType: "cron",
565
- cronExpression: "0 * * * *",
566
- enabled: true,
567
- });
568
-
569
- await persistence.saveSchedule({
570
- id: "schedule-2",
571
- workflowKind: "workflow.b",
572
- triggerType: "manual",
573
- enabled: false,
574
- });
575
-
576
- const schedules = await persistence.loadSchedules();
577
- expect(schedules).toHaveLength(2);
578
- });
579
- });
580
-
581
- // ========================================================================
582
- // getSchedule
583
- // ========================================================================
584
-
585
- describe("getSchedule", () => {
586
- it("should return schedule by id", async () => {
587
- await persistence.saveSchedule({
588
- id: "get-1",
589
- workflowKind: "test.workflow",
590
- triggerType: "cron",
591
- cronExpression: "0 * * * *",
592
- enabled: true,
593
- });
594
-
595
- const schedule = await persistence.getSchedule("get-1");
596
- expect(schedule).not.toBeNull();
597
- expect(schedule!.id).toBe("get-1");
598
- expect(schedule!.workflowKind).toBe("test.workflow");
599
- });
600
-
601
- it("should return null for non-existent schedule", async () => {
602
- const schedule = await persistence.getSchedule("nonexistent");
603
- expect(schedule).toBeNull();
604
- });
605
- });
606
-
607
- // ========================================================================
608
- // updateSchedule
609
- // ========================================================================
610
-
611
- describe("updateSchedule", () => {
612
- it("should update enabled status", async () => {
613
- await persistence.saveSchedule({
614
- id: "update-1",
615
- workflowKind: "test.workflow",
616
- triggerType: "cron",
617
- cronExpression: "0 * * * *",
618
- enabled: true,
619
- });
620
-
621
- await persistence.updateSchedule("update-1", { enabled: false });
622
-
623
- const loaded = await persistence.loadSchedules();
624
- expect(loaded[0].enabled).toBe(false);
625
- });
626
-
627
- it("should update cron expression", async () => {
628
- await persistence.saveSchedule({
629
- id: "update-2",
630
- workflowKind: "test.workflow",
631
- triggerType: "cron",
632
- cronExpression: "0 * * * *",
633
- enabled: true,
634
- });
635
-
636
- await persistence.updateSchedule("update-2", {
637
- cronExpression: "30 * * * *",
638
- });
639
-
640
- const loaded = await persistence.loadSchedules();
641
- expect(loaded[0].cronExpression).toBe("30 * * * *");
642
- });
643
-
644
- it("should update lastRunAt and lastRunId", async () => {
645
- await persistence.saveSchedule({
646
- id: "update-3",
647
- workflowKind: "test.workflow",
648
- triggerType: "cron",
649
- cronExpression: "0 * * * *",
650
- enabled: true,
651
- });
652
-
653
- const now = new Date();
654
- await persistence.updateSchedule("update-3", {
655
- lastRunAt: now,
656
- lastRunId: "run-456",
657
- });
658
-
659
- const loaded = await persistence.loadSchedules();
660
- expect(loaded[0].lastRunAt).toEqual(now);
661
- expect(loaded[0].lastRunId).toBe("run-456");
662
- });
663
-
664
- it("should throw for non-existent schedule", async () => {
665
- await expect(
666
- persistence.updateSchedule("nonexistent", { enabled: false }),
667
- ).rejects.toThrow("Schedule not found");
668
- });
669
- });
670
-
671
- // ========================================================================
672
- // deleteSchedule
673
- // ========================================================================
674
-
675
- describe("deleteSchedule", () => {
676
- it("should delete a schedule", async () => {
677
- await persistence.saveSchedule({
678
- id: "delete-1",
679
- workflowKind: "test.workflow",
680
- triggerType: "cron",
681
- cronExpression: "0 * * * *",
682
- enabled: true,
683
- });
684
-
685
- await persistence.deleteSchedule("delete-1");
686
-
687
- const loaded = await persistence.loadSchedules();
688
- expect(loaded).toHaveLength(0);
689
- });
690
-
691
- it("should handle deleting non-existent schedule gracefully", async () => {
692
- await persistence.deleteSchedule("nonexistent");
693
- });
694
- });
695
-
696
- // ========================================================================
697
- // getDueSchedules
698
- // ========================================================================
699
-
700
- describe("getDueSchedules", () => {
701
- it("should return enabled cron schedules with past next_run_at", async () => {
702
- const pastDate = new Date("2020-01-01T00:00:00Z");
703
- await persistence.saveSchedule({
704
- id: "due-1",
705
- workflowKind: "test.workflow",
706
- triggerType: "cron",
707
- cronExpression: "0 * * * *",
708
- enabled: true,
709
- nextRunAt: pastDate,
710
- });
711
-
712
- // disabled schedule should not appear
713
- await persistence.saveSchedule({
714
- id: "due-2",
715
- workflowKind: "test.workflow",
716
- triggerType: "cron",
717
- cronExpression: "0 * * * *",
718
- enabled: false,
719
- nextRunAt: pastDate,
720
- });
721
-
722
- // non-cron should not appear
723
- await persistence.saveSchedule({
724
- id: "due-3",
725
- workflowKind: "test.workflow",
726
- triggerType: "manual",
727
- enabled: true,
728
- nextRunAt: pastDate,
729
- });
730
-
731
- const due = await persistence.getDueSchedules();
732
- expect(due).toHaveLength(1);
733
- expect(due[0].id).toBe("due-1");
734
- });
735
- });
736
-
737
- // ========================================================================
738
- // getSchedulesByWorkflowKind
739
- // ========================================================================
740
-
741
- describe("getSchedulesByWorkflowKind", () => {
742
- it("should filter schedules by workflow kind", async () => {
743
- await persistence.saveSchedule({
744
- id: "kind-1",
745
- workflowKind: "target.workflow",
746
- triggerType: "cron",
747
- cronExpression: "0 * * * *",
748
- enabled: true,
749
- });
750
-
751
- await persistence.saveSchedule({
752
- id: "kind-2",
753
- workflowKind: "other.workflow",
754
- triggerType: "cron",
755
- cronExpression: "0 * * * *",
756
- enabled: true,
757
- });
758
-
759
- const schedules =
760
- await persistence.getSchedulesByWorkflowKind("target.workflow");
761
- expect(schedules).toHaveLength(1);
762
- expect(schedules[0].id).toBe("kind-1");
763
- });
764
- });
765
-
766
- // ========================================================================
767
- // getCompletionTriggers
768
- // ========================================================================
769
-
770
- describe("getCompletionTriggers", () => {
771
- it("should return enabled completion triggers for a workflow kind", async () => {
772
- await persistence.saveSchedule({
773
- id: "ct-1",
774
- workflowKind: "notification.send",
775
- triggerType: "workflow_completed",
776
- triggerOnWorkflowKind: "order.process",
777
- triggerOnStatus: ["succeeded"],
778
- enabled: true,
779
- });
780
-
781
- // disabled trigger should not appear
782
- await persistence.saveSchedule({
783
- id: "ct-2",
784
- workflowKind: "notification.send",
785
- triggerType: "workflow_completed",
786
- triggerOnWorkflowKind: "order.process",
787
- enabled: false,
788
- });
789
-
790
- // different trigger kind should not appear
791
- await persistence.saveSchedule({
792
- id: "ct-3",
793
- workflowKind: "notification.send",
794
- triggerType: "workflow_completed",
795
- triggerOnWorkflowKind: "different.workflow",
796
- enabled: true,
797
- });
798
-
799
- const triggers = await persistence.getCompletionTriggers("order.process");
800
- expect(triggers).toHaveLength(1);
801
- expect(triggers[0].id).toBe("ct-1");
802
- });
803
- });
804
-
805
- // ========================================================================
806
- // close()
807
- // ========================================================================
808
-
809
- describe("close", () => {
810
- it("should close without error", async () => {
811
- await persistence.close();
812
- });
813
-
814
- it("should end owned pool on close", async () => {
815
- // connectionString path creates an owned pool
816
- await persistence.close();
817
- // No error means pool.end() was called successfully
818
- });
819
- });
820
-
821
- // ========================================================================
822
- // Data integrity
823
- // ========================================================================
824
-
825
- describe("data integrity", () => {
826
- it("should preserve all fields through save/load cycle", async () => {
827
- const schedule: WorkflowSchedule = {
828
- id: "integrity-1",
829
- workflowKind: "complex.workflow",
830
- triggerType: "workflow_completed",
831
- triggerOnWorkflowKind: "parent.workflow",
832
- triggerOnStatus: ["succeeded"],
833
- input: { nested: { value: 123 } },
834
- metadata: { tags: ["a", "b"] },
835
- enabled: true,
836
- lastRunAt: new Date("2024-06-15T10:30:00Z"),
837
- lastRunId: "run-789",
838
- nextRunAt: new Date("2024-06-15T11:00:00Z"),
839
- };
840
-
841
- await persistence.saveSchedule(schedule);
842
-
843
- const loaded = await persistence.loadSchedules();
844
- expect(loaded[0].id).toBe(schedule.id);
845
- expect(loaded[0].workflowKind).toBe(schedule.workflowKind);
846
- expect(loaded[0].triggerType).toBe(schedule.triggerType);
847
- expect(loaded[0].triggerOnWorkflowKind).toBe(
848
- schedule.triggerOnWorkflowKind,
849
- );
850
- expect(loaded[0].triggerOnStatus).toEqual(schedule.triggerOnStatus);
851
- expect(loaded[0].input).toEqual(schedule.input);
852
- expect(loaded[0].metadata).toEqual(schedule.metadata);
853
- expect(loaded[0].enabled).toBe(schedule.enabled);
854
- expect(loaded[0].lastRunAt).toEqual(schedule.lastRunAt);
855
- expect(loaded[0].lastRunId).toBe(schedule.lastRunId);
856
- expect(loaded[0].nextRunAt).toEqual(schedule.nextRunAt);
857
- });
858
-
859
- it("should handle null/undefined values correctly", async () => {
860
- const schedule: WorkflowSchedule = {
861
- id: "nullable-1",
862
- workflowKind: "test.workflow",
863
- triggerType: "manual",
864
- enabled: false,
865
- };
866
-
867
- await persistence.saveSchedule(schedule);
868
-
869
- const loaded = await persistence.loadSchedules();
870
- expect(loaded[0].cronExpression).toBeUndefined();
871
- expect(loaded[0].timezone).toBeUndefined();
872
- expect(loaded[0].input).toBeUndefined();
873
- expect(loaded[0].metadata).toBeUndefined();
874
- expect(loaded[0].lastRunAt).toBeUndefined();
875
- });
876
- });
877
-
878
- // ========================================================================
879
- // Custom tableName / schema
880
- // ========================================================================
881
-
882
- describe("custom tableName", () => {
883
- it("should use custom tableName when provided", async () => {
884
- const p = new PostgresSchedulePersistence({
885
- connectionString: "pg://x",
886
- tableName: "my_schedules",
887
- });
888
- await p.initialize();
889
- // Should initialize without error using custom table name
890
- });
891
- });
892
-
893
- describe("custom schema", () => {
894
- it("should create schema if not public", async () => {
895
- sqlExecuteCalls = 0;
896
- const p = new PostgresSchedulePersistence({
897
- connectionString: "pg://x",
898
- schema: "custom_schema",
899
- });
900
- await p.initialize();
901
- // createTables runs CREATE SCHEMA + CREATE TABLE + indexes
902
- // More SQL calls than with public schema
903
- expect(sqlExecuteCalls).toBeGreaterThan(0);
904
- });
905
- });
906
- });