@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
@@ -4,13 +4,13 @@
4
4
  * Provides time-based and workflow-completion-based triggers for workflows.
5
5
  */
6
6
 
7
- import { CronExpressionParser } from "cron-parser";
8
- import type { WorkflowKind, RunStatus, Logger } from "../core/types";
9
- import type { WorkflowEngine } from "../core/engine";
10
- import type { Scheduler, WorkflowSchedule, TriggerType } from "./types";
11
- import type { WorkflowEvent } from "../events/types";
12
- import { generateId } from "../utils/id";
13
- import { ConsoleLogger } from "../utils/logger";
7
+ import { CronExpressionParser } from 'cron-parser';
8
+ import type { WorkflowKind, RunStatus, Logger } from '../core/types';
9
+ import type { WorkflowEngine } from '../core/engine';
10
+ import type { Scheduler, WorkflowSchedule, TriggerType } from './types';
11
+ import type { WorkflowEvent } from '../events/types';
12
+ import { generateId } from '../utils/id';
13
+ import { ConsoleLogger } from '../utils/logger';
14
14
 
15
15
  // ============================================================================
16
16
  // Configuration Types
@@ -45,10 +45,7 @@ export interface SchedulePersistence {
45
45
  saveSchedule(schedule: WorkflowSchedule): Promise<void>;
46
46
 
47
47
  /** Update a schedule */
48
- updateSchedule(
49
- scheduleId: string,
50
- updates: Partial<WorkflowSchedule>,
51
- ): Promise<void>;
48
+ updateSchedule(scheduleId: string, updates: Partial<WorkflowSchedule>): Promise<void>;
52
49
 
53
50
  /** Delete a schedule */
54
51
  deleteSchedule(scheduleId: string): Promise<void>;
@@ -116,11 +113,21 @@ export class CronScheduler implements Scheduler {
116
113
  */
117
114
  async start(): Promise<void> {
118
115
  if (this.running) {
119
- this.logger.warn("Scheduler is already running");
116
+ this.logger.warn('Scheduler is already running');
120
117
  return;
121
118
  }
122
119
 
123
- this.logger.info("Starting scheduler...");
120
+ // Clean up any leftover subscriptions from a previous start/stop cycle
121
+ if (this.eventUnsubscribe) {
122
+ this.eventUnsubscribe();
123
+ this.eventUnsubscribe = null;
124
+ }
125
+ if (this.pollTimer) {
126
+ clearInterval(this.pollTimer);
127
+ this.pollTimer = null;
128
+ }
129
+
130
+ this.logger.info('Starting scheduler...');
124
131
 
125
132
  // Load schedules from persistence if available
126
133
  if (this.persistence) {
@@ -131,18 +138,29 @@ export class CronScheduler implements Scheduler {
131
138
  this.logger.info(`Loaded ${loaded.length} schedules from persistence`);
132
139
  }
133
140
 
134
- // Calculate next run times for cron schedules
141
+ // Calculate next run times for cron schedules.
142
+ // Wrap each schedule in try-catch so one invalid persisted cron expression
143
+ // doesn't prevent the rest from loading.
135
144
  for (const schedule of this.schedules.values()) {
136
- if (schedule.triggerType === "cron" && schedule.cronExpression) {
137
- this.updateNextRunTime(schedule);
145
+ if (schedule.triggerType === 'cron' && schedule.cronExpression) {
146
+ try {
147
+ CronExpressionParser.parse(schedule.cronExpression, {
148
+ tz: schedule.timezone,
149
+ });
150
+ this.updateNextRunTime(schedule);
151
+ } catch (error) {
152
+ this.logger.error(
153
+ `Invalid cron expression "${schedule.cronExpression}" for schedule ${schedule.id}, disabling schedule:`,
154
+ error
155
+ );
156
+ schedule.enabled = false;
157
+ schedule.nextRunAt = undefined;
158
+ }
138
159
  }
139
160
  }
140
161
 
141
162
  // Start polling for cron schedules
142
- this.pollTimer = setInterval(
143
- () => this.checkSchedules(),
144
- this.pollInterval,
145
- );
163
+ this.pollTimer = setInterval(() => this.checkSchedules(), this.pollInterval);
146
164
 
147
165
  // Subscribe to workflow completion events
148
166
  this.eventUnsubscribe = this.engine.subscribeToAll((event) => {
@@ -150,7 +168,7 @@ export class CronScheduler implements Scheduler {
150
168
  });
151
169
 
152
170
  this.running = true;
153
- this.logger.info("Scheduler started");
171
+ this.logger.info('Scheduler started');
154
172
  }
155
173
 
156
174
  /**
@@ -162,7 +180,7 @@ export class CronScheduler implements Scheduler {
162
180
  return;
163
181
  }
164
182
 
165
- this.logger.info("Stopping scheduler...");
183
+ this.logger.info('Stopping scheduler...');
166
184
 
167
185
  if (this.pollTimer) {
168
186
  clearInterval(this.pollTimer);
@@ -175,22 +193,20 @@ export class CronScheduler implements Scheduler {
175
193
  }
176
194
 
177
195
  this.running = false;
178
- this.logger.info("Scheduler stopped");
196
+ this.logger.info('Scheduler stopped');
179
197
  }
180
198
 
181
199
  /**
182
200
  * Add a new schedule.
183
201
  */
184
- async addSchedule(
185
- scheduleData: Omit<WorkflowSchedule, "id">,
186
- ): Promise<WorkflowSchedule> {
202
+ async addSchedule(scheduleData: Omit<WorkflowSchedule, 'id'>): Promise<WorkflowSchedule> {
187
203
  const schedule: WorkflowSchedule = {
188
204
  ...scheduleData,
189
205
  id: generateId(),
190
206
  };
191
207
 
192
208
  // Validate cron expression if provided
193
- if (schedule.triggerType === "cron" && schedule.cronExpression) {
209
+ if (schedule.triggerType === 'cron' && schedule.cronExpression) {
194
210
  try {
195
211
  CronExpressionParser.parse(schedule.cronExpression, {
196
212
  tz: schedule.timezone,
@@ -204,11 +220,9 @@ export class CronScheduler implements Scheduler {
204
220
  }
205
221
 
206
222
  // Validate workflow completion trigger
207
- if (schedule.triggerType === "workflow_completed") {
223
+ if (schedule.triggerType === 'workflow_completed') {
208
224
  if (!schedule.triggerOnWorkflowKind) {
209
- throw new Error(
210
- "triggerOnWorkflowKind is required for workflow_completed triggers",
211
- );
225
+ throw new Error('triggerOnWorkflowKind is required for workflow_completed triggers');
212
226
  }
213
227
  }
214
228
 
@@ -219,9 +233,7 @@ export class CronScheduler implements Scheduler {
219
233
  await this.persistence.saveSchedule(schedule);
220
234
  }
221
235
 
222
- this.logger.info(
223
- `Added schedule: ${schedule.id} (${schedule.workflowKind})`,
224
- );
236
+ this.logger.info(`Added schedule: ${schedule.id} (${schedule.workflowKind})`);
225
237
 
226
238
  return schedule;
227
239
  }
@@ -246,10 +258,7 @@ export class CronScheduler implements Scheduler {
246
258
  /**
247
259
  * Update a schedule.
248
260
  */
249
- async updateSchedule(
250
- scheduleId: string,
251
- updates: Partial<WorkflowSchedule>,
252
- ): Promise<void> {
261
+ async updateSchedule(scheduleId: string, updates: Partial<WorkflowSchedule>): Promise<void> {
253
262
  const schedule = this.schedules.get(scheduleId);
254
263
  if (!schedule) {
255
264
  throw new Error(`Schedule not found: ${scheduleId}`);
@@ -262,10 +271,7 @@ export class CronScheduler implements Scheduler {
262
271
  Object.assign(schedule, updates);
263
272
 
264
273
  // Revalidate and update next run time if cron expression changed
265
- if (
266
- updates.cronExpression !== undefined ||
267
- updates.timezone !== undefined
268
- ) {
274
+ if (updates.cronExpression !== undefined || updates.timezone !== undefined) {
269
275
  if (schedule.cronExpression) {
270
276
  try {
271
277
  CronExpressionParser.parse(schedule.cronExpression, {
@@ -273,9 +279,7 @@ export class CronScheduler implements Scheduler {
273
279
  });
274
280
  this.updateNextRunTime(schedule);
275
281
  } catch (error) {
276
- throw new Error(
277
- `Invalid cron expression: ${schedule.cronExpression}`,
278
- );
282
+ throw new Error(`Invalid cron expression: ${schedule.cronExpression}`);
279
283
  }
280
284
  }
281
285
  }
@@ -325,16 +329,13 @@ export class CronScheduler implements Scheduler {
325
329
 
326
330
  for (const schedule of this.schedules.values()) {
327
331
  if (!schedule.enabled) continue;
328
- if (schedule.triggerType !== "cron") continue;
332
+ if (schedule.triggerType !== 'cron') continue;
329
333
  if (!schedule.nextRunAt) continue;
330
334
 
331
335
  if (schedule.nextRunAt <= now) {
332
336
  // Execute the schedule
333
337
  this.executeSchedule(schedule).catch((error) => {
334
- this.logger.error(
335
- `Failed to execute schedule ${schedule.id}:`,
336
- error,
337
- );
338
+ this.logger.error(`Failed to execute schedule ${schedule.id}:`, error);
338
339
  });
339
340
 
340
341
  // Update next run time
@@ -349,28 +350,21 @@ export class CronScheduler implements Scheduler {
349
350
  private handleWorkflowEvent(event: WorkflowEvent): void {
350
351
  // Only handle run completion events
351
352
  // Note: orchestrator emits 'run.completed' for success, 'run.failed' for failure
352
- if (
353
- event.eventType !== "run.completed" &&
354
- event.eventType !== "run.failed"
355
- ) {
353
+ if (event.eventType !== 'run.completed' && event.eventType !== 'run.failed') {
356
354
  return;
357
355
  }
358
356
 
359
- const completedStatus: RunStatus =
360
- event.eventType === "run.completed" ? "succeeded" : "failed";
357
+ const completedStatus: RunStatus = event.eventType === 'run.completed' ? 'succeeded' : 'failed';
361
358
  const completedKind = event.kind;
362
359
 
363
360
  // Find schedules triggered by this workflow completion
364
361
  for (const schedule of this.schedules.values()) {
365
362
  if (!schedule.enabled) continue;
366
- if (schedule.triggerType !== "workflow_completed") continue;
363
+ if (schedule.triggerType !== 'workflow_completed') continue;
367
364
  if (schedule.triggerOnWorkflowKind !== completedKind) continue;
368
365
 
369
366
  // Check if the status matches
370
- if (
371
- schedule.triggerOnStatus &&
372
- !schedule.triggerOnStatus.includes(completedStatus)
373
- ) {
367
+ if (schedule.triggerOnStatus && !schedule.triggerOnStatus.includes(completedStatus)) {
374
368
  continue;
375
369
  }
376
370
 
@@ -389,11 +383,9 @@ export class CronScheduler implements Scheduler {
389
383
  */
390
384
  private async executeSchedule(
391
385
  schedule: WorkflowSchedule,
392
- triggerContext?: { triggerRunId?: string; triggerStatus?: RunStatus },
386
+ triggerContext?: { triggerRunId?: string; triggerStatus?: RunStatus }
393
387
  ): Promise<string> {
394
- this.logger.info(
395
- `Executing schedule: ${schedule.id} (${schedule.workflowKind})`,
396
- );
388
+ this.logger.info(`Executing schedule: ${schedule.id} (${schedule.workflowKind})`);
397
389
 
398
390
  // Build metadata with trigger info
399
391
  const metadata = {
@@ -439,10 +431,7 @@ export class CronScheduler implements Scheduler {
439
431
 
440
432
  schedule.nextRunAt = interval.next().toDate();
441
433
  } catch (error) {
442
- this.logger.error(
443
- `Failed to parse cron expression for schedule ${schedule.id}:`,
444
- error,
445
- );
434
+ this.logger.error(`Failed to parse cron expression for schedule ${schedule.id}:`, error);
446
435
  schedule.nextRunAt = undefined;
447
436
  }
448
437
  }
@@ -2,17 +2,7 @@
2
2
  * Scheduler module exports.
3
3
  */
4
4
 
5
- export * from "./types";
6
- export {
7
- CronScheduler,
8
- type CronSchedulerConfig,
9
- type SchedulePersistence,
10
- } from "./cron";
11
- export {
12
- SQLiteSchedulePersistence,
13
- type SQLiteSchedulePersistenceConfig,
14
- } from "./sqlite-persistence";
15
- export {
16
- PostgresSchedulePersistence,
17
- type PostgresSchedulePersistenceConfig,
18
- } from "./postgres-persistence";
5
+ export * from './types';
6
+ export { CronScheduler, type CronSchedulerConfig, type SchedulePersistence } from './cron';
7
+ export { SQLiteSchedulePersistence, type SQLiteSchedulePersistenceConfig } from './sqlite-persistence';
8
+ export { PostgresSchedulePersistence, type PostgresSchedulePersistenceConfig } from './postgres-persistence';
@@ -4,17 +4,19 @@
4
4
  * Stores workflow schedules in a PostgreSQL database table using Kysely.
5
5
  */
6
6
 
7
- import type { Kysely as KyselyType } from "kysely";
8
- import type { Pool, PoolConfig } from "pg";
9
- import { loadPostgresDeps } from "../utils/postgres-deps.js";
7
+ import type { Kysely as KyselyType } from 'kysely';
8
+ import type { Pool, PoolConfig } from 'pg';
9
+ import { loadPostgresDeps } from '../utils/postgres-deps.js';
10
10
 
11
11
  // Lazy-loaded dependencies - populated by loadPostgresDeps() during initialize()
12
12
  let Kysely: any;
13
13
  let PostgresDialect: any;
14
14
  let sql: any;
15
15
  let pgModule: any;
16
- import type { WorkflowSchedule } from "./types.js";
17
- import type { SchedulePersistence } from "./cron.js";
16
+ import type { WorkflowSchedule } from './types.js';
17
+ import type { SchedulePersistence } from './cron.js';
18
+ import type { Logger, RunStatus } from '../core/types.js';
19
+ import { safeJsonParse as sharedSafeJsonParse } from '../utils/safe-json.js';
18
20
 
19
21
  // ============================================================================
20
22
  // Database Types (Kysely schema)
@@ -97,6 +99,13 @@ export interface PostgresSchedulePersistenceConfig {
97
99
  * @default true
98
100
  */
99
101
  autoMigrate?: boolean;
102
+
103
+ /**
104
+ * Optional structured logger used to surface JSON corruption events.
105
+ * Without one, parse failures still increment the corruption counter
106
+ * exposed via `getJsonParseCorruptionCount()` but produce no log output.
107
+ */
108
+ logger?: Logger;
100
109
  }
101
110
 
102
111
  // ============================================================================
@@ -141,24 +150,26 @@ export class PostgresSchedulePersistence implements SchedulePersistence {
141
150
  private autoMigrate: boolean;
142
151
  private initialized = false;
143
152
  private config: PostgresSchedulePersistenceConfig;
153
+ private logger: Logger | undefined;
144
154
 
145
155
  constructor(config: PostgresSchedulePersistenceConfig) {
146
- this.schema = config.schema ?? "public";
156
+ this.schema = config.schema ?? 'public';
147
157
  if (!/^[a-zA-Z_][a-zA-Z0-9_]{0,62}$/.test(this.schema)) {
148
158
  throw new Error(
149
159
  `Invalid schema name "${this.schema}". Schema must start with a letter or underscore, ` +
150
- `contain only alphanumeric characters and underscores, and be at most 63 characters.`,
160
+ `contain only alphanumeric characters and underscores, and be at most 63 characters.`
151
161
  );
152
162
  }
153
- this.tableName = config.tableName ?? "workflow_schedules";
163
+ this.tableName = config.tableName ?? 'workflow_schedules';
154
164
  if (!/^[a-zA-Z_][a-zA-Z0-9_]{0,62}$/.test(this.tableName)) {
155
165
  throw new Error(
156
166
  `Invalid table name "${this.tableName}". Table name must start with a letter or underscore, ` +
157
- `contain only alphanumeric characters and underscores, and be at most 63 characters.`,
167
+ `contain only alphanumeric characters and underscores, and be at most 63 characters.`
158
168
  );
159
169
  }
160
170
  this.autoMigrate = config.autoMigrate !== false;
161
171
  this.config = config;
172
+ this.logger = config.logger;
162
173
  }
163
174
 
164
175
  /**
@@ -172,7 +183,7 @@ export class PostgresSchedulePersistence implements SchedulePersistence {
172
183
  private ensureInitialized(): void {
173
184
  if (!this.initialized) {
174
185
  throw new Error(
175
- "PostgresSchedulePersistence is not initialized. Call initialize() before using the adapter.",
186
+ 'PostgresSchedulePersistence is not initialized. Call initialize() before using the adapter.'
176
187
  );
177
188
  }
178
189
  }
@@ -196,16 +207,14 @@ export class PostgresSchedulePersistence implements SchedulePersistence {
196
207
  this.pool = this.config.pool;
197
208
  this.ownsPool = false;
198
209
  } else if (this.config.connectionString) {
199
- this.pool = new pgModule.Pool({
200
- connectionString: this.config.connectionString,
201
- });
210
+ this.pool = new pgModule.Pool({ connectionString: this.config.connectionString });
202
211
  this.ownsPool = true;
203
212
  } else if (this.config.poolConfig) {
204
213
  this.pool = new pgModule.Pool(this.config.poolConfig);
205
214
  this.ownsPool = true;
206
215
  } else {
207
216
  throw new Error(
208
- "PostgresSchedulePersistenceConfig must include either pool, connectionString, or poolConfig",
217
+ 'PostgresSchedulePersistenceConfig must include either pool, connectionString, or poolConfig'
209
218
  );
210
219
  }
211
220
 
@@ -238,16 +247,13 @@ export class PostgresSchedulePersistence implements SchedulePersistence {
238
247
  // ============================================================================
239
248
 
240
249
  private async createTables(): Promise<void> {
241
- const fullTableName =
242
- this.schema === "public"
243
- ? this.tableName
244
- : `${this.schema}.${this.tableName}`;
250
+ const fullTableName = this.schema === 'public'
251
+ ? this.tableName
252
+ : `${this.schema}.${this.tableName}`;
245
253
 
246
254
  // Create schema if not public
247
- if (this.schema !== "public") {
248
- await sql`CREATE SCHEMA IF NOT EXISTS ${sql.ref(this.schema)}`.execute(
249
- this.db,
250
- );
255
+ if (this.schema !== 'public') {
256
+ await sql`CREATE SCHEMA IF NOT EXISTS ${sql.ref(this.schema)}`.execute(this.db);
251
257
  }
252
258
 
253
259
  // Create schedules table.
@@ -316,12 +322,12 @@ export class PostgresSchedulePersistence implements SchedulePersistence {
316
322
 
317
323
  async loadSchedules(): Promise<WorkflowSchedule[]> {
318
324
  this.ensureInitialized();
319
- const rows = (await this.qb
325
+ const rows = await this.qb
320
326
  .selectFrom(this.tableName as any)
321
327
  .selectAll()
322
- .execute()) as WorkflowSchedulesTable[];
328
+ .execute() as WorkflowSchedulesTable[];
323
329
 
324
- return rows.map((row) => this.rowToSchedule(row));
330
+ return rows.map(row => this.rowToSchedule(row));
325
331
  }
326
332
 
327
333
  async saveSchedule(schedule: WorkflowSchedule): Promise<void> {
@@ -335,13 +341,9 @@ export class PostgresSchedulePersistence implements SchedulePersistence {
335
341
  cron_expression: schedule.cronExpression ?? null,
336
342
  timezone: schedule.timezone ?? null,
337
343
  trigger_on_workflow_kind: schedule.triggerOnWorkflowKind ?? null,
338
- trigger_on_status: schedule.triggerOnStatus
339
- ? JSON.stringify(schedule.triggerOnStatus)
340
- : null,
344
+ trigger_on_status: schedule.triggerOnStatus ? JSON.stringify(schedule.triggerOnStatus) : null,
341
345
  input_json: schedule.input ? JSON.stringify(schedule.input) : null,
342
- metadata_json: schedule.metadata
343
- ? JSON.stringify(schedule.metadata)
344
- : null,
346
+ metadata_json: schedule.metadata ? JSON.stringify(schedule.metadata) : null,
345
347
  enabled: schedule.enabled,
346
348
  last_run_at: schedule.lastRunAt ?? null,
347
349
  last_run_id: schedule.lastRunId ?? null,
@@ -352,20 +354,17 @@ export class PostgresSchedulePersistence implements SchedulePersistence {
352
354
  .execute();
353
355
  }
354
356
 
355
- async updateSchedule(
356
- scheduleId: string,
357
- updates: Partial<WorkflowSchedule>,
358
- ): Promise<void> {
357
+ async updateSchedule(scheduleId: string, updates: Partial<WorkflowSchedule>): Promise<void> {
359
358
  this.ensureInitialized();
360
359
  // Partial update pattern: fetch the existing row, merge with incoming updates,
361
360
  // then build a column-level update object. Each field is only included if it was
362
361
  // explicitly provided in `updates` (or affected by the merge), so unchanged
363
362
  // columns are left untouched in the database.
364
- const existing = (await this.qb
363
+ const existing = await this.qb
365
364
  .selectFrom(this.tableName as any)
366
365
  .selectAll()
367
- .where("id", "=", scheduleId)
368
- .executeTakeFirst()) as WorkflowSchedulesTable | undefined;
366
+ .where('id', '=', scheduleId)
367
+ .executeTakeFirst() as WorkflowSchedulesTable | undefined;
369
368
 
370
369
  if (!existing) {
371
370
  throw new Error(`Schedule not found: ${scheduleId}`);
@@ -383,30 +382,18 @@ export class PostgresSchedulePersistence implements SchedulePersistence {
383
382
  dbKey: keyof WorkflowSchedulesTable;
384
383
  serialize?: (value: any) => any;
385
384
  }> = [
386
- { domainKey: "workflowKind", dbKey: "workflow_kind" },
387
- { domainKey: "triggerType", dbKey: "trigger_type" },
388
- { domainKey: "cronExpression", dbKey: "cron_expression" },
389
- { domainKey: "timezone", dbKey: "timezone" },
390
- { domainKey: "triggerOnWorkflowKind", dbKey: "trigger_on_workflow_kind" },
391
- {
392
- domainKey: "triggerOnStatus",
393
- dbKey: "trigger_on_status",
394
- serialize: (v) => (v ? JSON.stringify(v) : null),
395
- },
396
- {
397
- domainKey: "input",
398
- dbKey: "input_json",
399
- serialize: (v) => (v ? JSON.stringify(v) : null),
400
- },
401
- {
402
- domainKey: "metadata",
403
- dbKey: "metadata_json",
404
- serialize: (v) => (v ? JSON.stringify(v) : null),
405
- },
406
- { domainKey: "enabled", dbKey: "enabled" },
407
- { domainKey: "lastRunAt", dbKey: "last_run_at" },
408
- { domainKey: "lastRunId", dbKey: "last_run_id" },
409
- { domainKey: "nextRunAt", dbKey: "next_run_at" },
385
+ { domainKey: 'workflowKind', dbKey: 'workflow_kind' },
386
+ { domainKey: 'triggerType', dbKey: 'trigger_type' },
387
+ { domainKey: 'cronExpression', dbKey: 'cron_expression' },
388
+ { domainKey: 'timezone', dbKey: 'timezone' },
389
+ { domainKey: 'triggerOnWorkflowKind', dbKey: 'trigger_on_workflow_kind' },
390
+ { domainKey: 'triggerOnStatus', dbKey: 'trigger_on_status', serialize: v => v ? JSON.stringify(v) : null },
391
+ { domainKey: 'input', dbKey: 'input_json', serialize: v => v ? JSON.stringify(v) : null },
392
+ { domainKey: 'metadata', dbKey: 'metadata_json', serialize: v => v ? JSON.stringify(v) : null },
393
+ { domainKey: 'enabled', dbKey: 'enabled' },
394
+ { domainKey: 'lastRunAt', dbKey: 'last_run_at' },
395
+ { domainKey: 'lastRunId', dbKey: 'last_run_id' },
396
+ { domainKey: 'nextRunAt', dbKey: 'next_run_at' },
410
397
  ];
411
398
 
412
399
  const updateData: Partial<WorkflowSchedulesTable> = {
@@ -425,7 +412,7 @@ export class PostgresSchedulePersistence implements SchedulePersistence {
425
412
  await this.qb
426
413
  .updateTable(this.tableName as any)
427
414
  .set(updateData)
428
- .where("id", "=", scheduleId)
415
+ .where('id', '=', scheduleId)
429
416
  .execute();
430
417
  }
431
418
 
@@ -433,7 +420,7 @@ export class PostgresSchedulePersistence implements SchedulePersistence {
433
420
  this.ensureInitialized();
434
421
  await this.qb
435
422
  .deleteFrom(this.tableName as any)
436
- .where("id", "=", scheduleId)
423
+ .where('id', '=', scheduleId)
437
424
  .execute();
438
425
  }
439
426
 
@@ -446,11 +433,11 @@ export class PostgresSchedulePersistence implements SchedulePersistence {
446
433
  */
447
434
  async getSchedule(scheduleId: string): Promise<WorkflowSchedule | null> {
448
435
  this.ensureInitialized();
449
- const row = (await this.qb
436
+ const row = await this.qb
450
437
  .selectFrom(this.tableName as any)
451
438
  .selectAll()
452
- .where("id", "=", scheduleId)
453
- .executeTakeFirst()) as WorkflowSchedulesTable | undefined;
439
+ .where('id', '=', scheduleId)
440
+ .executeTakeFirst() as WorkflowSchedulesTable | undefined;
454
441
 
455
442
  return row ? this.rowToSchedule(row) : null;
456
443
  }
@@ -462,78 +449,75 @@ export class PostgresSchedulePersistence implements SchedulePersistence {
462
449
  this.ensureInitialized();
463
450
  const now = new Date();
464
451
 
465
- const rows = (await this.qb
452
+ const rows = await this.qb
466
453
  .selectFrom(this.tableName as any)
467
454
  .selectAll()
468
- .where("enabled", "=", true)
469
- .where("trigger_type", "=", "cron")
470
- .where("next_run_at", "<=", now)
471
- .execute()) as WorkflowSchedulesTable[];
455
+ .where('enabled', '=', true)
456
+ .where('trigger_type', '=', 'cron')
457
+ .where('next_run_at', '<=', now)
458
+ .execute() as WorkflowSchedulesTable[];
472
459
 
473
- return rows.map((row) => this.rowToSchedule(row));
460
+ return rows.map(row => this.rowToSchedule(row));
474
461
  }
475
462
 
476
463
  /**
477
464
  * Get schedules by workflow kind.
478
465
  */
479
- async getSchedulesByWorkflowKind(
480
- workflowKind: string,
481
- ): Promise<WorkflowSchedule[]> {
466
+ async getSchedulesByWorkflowKind(workflowKind: string): Promise<WorkflowSchedule[]> {
482
467
  this.ensureInitialized();
483
- const rows = (await this.qb
468
+ const rows = await this.qb
484
469
  .selectFrom(this.tableName as any)
485
470
  .selectAll()
486
- .where("workflow_kind", "=", workflowKind)
487
- .execute()) as WorkflowSchedulesTable[];
471
+ .where('workflow_kind', '=', workflowKind)
472
+ .execute() as WorkflowSchedulesTable[];
488
473
 
489
- return rows.map((row) => this.rowToSchedule(row));
474
+ return rows.map(row => this.rowToSchedule(row));
490
475
  }
491
476
 
492
477
  /**
493
478
  * Get workflow completion triggers for a specific workflow kind.
494
479
  */
495
- async getCompletionTriggers(
496
- triggerOnWorkflowKind: string,
497
- ): Promise<WorkflowSchedule[]> {
480
+ async getCompletionTriggers(triggerOnWorkflowKind: string): Promise<WorkflowSchedule[]> {
498
481
  this.ensureInitialized();
499
- const rows = (await this.qb
482
+ const rows = await this.qb
500
483
  .selectFrom(this.tableName as any)
501
484
  .selectAll()
502
- .where("enabled", "=", true)
503
- .where("trigger_type", "=", "workflow_completed")
504
- .where("trigger_on_workflow_kind", "=", triggerOnWorkflowKind)
505
- .execute()) as WorkflowSchedulesTable[];
485
+ .where('enabled', '=', true)
486
+ .where('trigger_type', '=', 'workflow_completed')
487
+ .where('trigger_on_workflow_kind', '=', triggerOnWorkflowKind)
488
+ .execute() as WorkflowSchedulesTable[];
506
489
 
507
- return rows.map((row) => this.rowToSchedule(row));
490
+ return rows.map(row => this.rowToSchedule(row));
508
491
  }
509
492
 
510
493
  // ============================================================================
511
494
  // Helper Methods
512
495
  // ============================================================================
513
496
 
497
+ private safeParseOptionalField(value: unknown, rowId: string, column: string): unknown {
498
+ if (!value) return undefined;
499
+ if (typeof value === 'string') {
500
+ return sharedSafeJsonParse(value, undefined, {
501
+ component: 'PostgresSchedulePersistence',
502
+ rowId,
503
+ column,
504
+ logger: this.logger,
505
+ });
506
+ }
507
+ return value;
508
+ }
509
+
514
510
  private rowToSchedule(row: WorkflowSchedulesTable): WorkflowSchedule {
515
511
  return {
516
512
  id: row.id,
517
513
  workflowKind: row.workflow_kind,
518
- triggerType: row.trigger_type as WorkflowSchedule["triggerType"],
514
+ triggerType: row.trigger_type as WorkflowSchedule['triggerType'],
519
515
  cronExpression: row.cron_expression ?? undefined,
520
516
  timezone: row.timezone ?? undefined,
521
517
  triggerOnWorkflowKind: row.trigger_on_workflow_kind ?? undefined,
522
- triggerOnStatus: row.trigger_on_status
523
- ? typeof row.trigger_on_status === "string"
524
- ? JSON.parse(row.trigger_on_status)
525
- : row.trigger_on_status
526
- : undefined,
527
- input: row.input_json
528
- ? typeof row.input_json === "string"
529
- ? JSON.parse(row.input_json)
530
- : row.input_json
531
- : undefined,
532
- metadata: row.metadata_json
533
- ? typeof row.metadata_json === "string"
534
- ? JSON.parse(row.metadata_json)
535
- : row.metadata_json
536
- : undefined,
518
+ triggerOnStatus: this.safeParseOptionalField(row.trigger_on_status, row.id, 'trigger_on_status') as RunStatus[] | undefined,
519
+ input: this.safeParseOptionalField(row.input_json, row.id, 'input_json') as Record<string, unknown> | undefined,
520
+ metadata: this.safeParseOptionalField(row.metadata_json, row.id, 'metadata_json') as Record<string, unknown> | undefined,
537
521
  enabled: row.enabled,
538
522
  lastRunAt: row.last_run_at ? new Date(row.last_run_at) : undefined,
539
523
  lastRunId: row.last_run_id ?? undefined,