@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.
- package/.codekin/reports/code-review/2026-04-11_code-review-daily.md +146 -0
- package/.codekin/reports/code-review/2026-04-13_code-review-daily.md +74 -0
- package/.codekin/reports/comments/2026-04-04_comment-assessment.md +179 -0
- package/.codekin/reports/comments/2026-04-11_comment-assessment.md +161 -0
- package/.codekin/reports/comments/2026-04-14_comment-assessment.md +34 -0
- package/.codekin/reports/complexity/2026-04-03_complexity-report.md +152 -0
- package/.codekin/reports/complexity/2026-04-11_complexity-report.md +152 -0
- package/.codekin/reports/complexity/2026-04-14_complexity-report.md +34 -0
- package/.codekin/reports/dependencies/2026-04-04_dependency-health.md +94 -0
- package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +93 -0
- package/.codekin/reports/docs-audit/2026-04-04_docs-audit.md +10 -0
- package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +138 -0
- package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +121 -0
- package/.codekin/reports/security/2026-04-03_security-audit.md +208 -0
- package/.codekin/reports/security/2026-04-11_security-audit.md +297 -0
- package/.github/CODEOWNERS +15 -0
- package/.github/dependabot.yml +23 -0
- package/.github/workflows/ci.yml +38 -0
- package/.github/workflows/publish.yml +17 -5
- package/.github/workflows/security.yml +29 -0
- package/CHANGELOG.md +34 -0
- package/README.md +24 -0
- package/SECURITY.md +40 -0
- package/dist/{chunk-FQCT3I2A.js → chunk-5XJBAPXF.js} +527 -551
- package/dist/chunk-5XJBAPXF.js.map +1 -0
- package/dist/{chunk-U5QGN3RT.js → chunk-FMPJBK2N.js} +67 -55
- package/dist/chunk-FMPJBK2N.js.map +1 -0
- package/dist/{chunk-GXY66V7Q.js → chunk-JPXPB7U5.js} +52 -2
- package/dist/chunk-JPXPB7U5.js.map +1 -0
- package/dist/{chunk-XCF4PLN7.js → chunk-U3PCVK6O.js} +276 -61
- package/dist/chunk-U3PCVK6O.js.map +1 -0
- package/dist/events/index.d.ts +60 -7
- package/dist/events/index.js +1 -1
- package/dist/{index-CPfGsmrp.d.ts → index-Cd0IZ40L.d.ts} +56 -17
- package/dist/index.d.ts +15 -23
- package/dist/index.js +784 -547
- package/dist/index.js.map +1 -1
- package/dist/scheduler/index.d.ts +4 -4
- package/dist/scheduler/index.js +2 -2
- package/dist/storage/index.d.ts +322 -56
- package/dist/storage/index.js +4 -2
- package/dist/{types-BlwFEGzj.d.ts → types-DSEjiLMj.d.ts} +16 -15
- package/dist/{types-DzkEqnQJ.d.ts → types-DZUdWmCc.d.ts} +2 -2
- package/dist/{types-BVShq7zT.d.ts → types-K5Gjk3H_.d.ts} +4 -4
- package/docs/stepflow-api-reference.md +5 -4
- package/package.json +12 -8
- package/src/core/engine/concurrency.ts +229 -0
- package/src/core/engine/lifecycle.ts +176 -0
- package/src/core/engine.test.ts +536 -522
- package/src/core/engine.ts +116 -235
- package/src/core/index.ts +3 -3
- package/src/core/orchestrator/persistence.ts +165 -0
- package/src/core/orchestrator/plan-resolution.ts +140 -0
- package/src/core/orchestrator/state-transitions.ts +109 -0
- package/src/core/orchestrator/step-runner.ts +427 -0
- package/src/core/orchestrator.ts +57 -395
- package/src/core/types.ts +7 -29
- package/src/events/index.ts +4 -4
- package/src/events/memory.test.ts +39 -41
- package/src/events/memory.ts +7 -10
- package/src/events/socketio.test.ts +112 -162
- package/src/events/socketio.ts +20 -27
- package/src/events/types.ts +16 -19
- package/src/events/webhook.test.ts +489 -235
- package/src/events/webhook.ts +355 -139
- package/src/index.ts +19 -19
- package/src/planning/evaluate-condition.test.ts +153 -0
- package/src/planning/evaluate-condition.ts +105 -0
- package/src/planning/index.ts +6 -3
- package/src/planning/planner.ts +45 -152
- package/src/planning/planning.test.ts +303 -366
- package/src/planning/registry.test.ts +204 -251
- package/src/planning/registry.ts +20 -106
- package/src/planning/types.ts +23 -27
- package/src/scheduler/cron.test.ts +149 -150
- package/src/scheduler/cron.ts +58 -69
- package/src/scheduler/index.ts +4 -14
- package/src/scheduler/postgres-persistence.ts +89 -105
- package/src/scheduler/sqlite-persistence.test.ts +96 -96
- package/src/scheduler/sqlite-persistence.ts +40 -22
- package/src/scheduler/types.ts +4 -9
- package/src/storage/index.ts +5 -5
- package/src/storage/memory.test.ts +133 -183
- package/src/storage/memory.ts +21 -42
- package/src/storage/postgres-core.ts +550 -0
- package/src/storage/postgres-migration.test.ts +69 -0
- package/src/storage/postgres-transaction-pagination.test.ts +107 -0
- package/src/storage/postgres.ts +222 -937
- package/src/storage/sqlite-core.ts +151 -0
- package/src/storage/sqlite.test.ts +330 -281
- package/src/storage/sqlite.ts +225 -170
- package/src/storage/types.ts +33 -58
- package/src/utils/errors.test.ts +78 -80
- package/src/utils/errors.ts +27 -57
- package/src/utils/id.ts +3 -3
- package/src/utils/index.ts +6 -4
- package/src/utils/logger.test.ts +52 -58
- package/src/utils/logger.ts +4 -4
- package/src/utils/pinned-fetch.test.ts +84 -0
- package/src/utils/pinned-fetch.ts +118 -0
- package/src/utils/postgres-deps.ts +20 -4
- package/src/utils/retry.test.ts +47 -67
- package/src/utils/retry.ts +9 -9
- package/src/utils/safe-json.test.ts +166 -0
- package/src/utils/safe-json.ts +107 -0
- package/src/utils/webhook-signature.test.ts +111 -0
- package/src/utils/webhook-signature.ts +74 -0
- package/dist/chunk-FQCT3I2A.js.map +0 -1
- package/dist/chunk-GXY66V7Q.js.map +0 -1
- package/dist/chunk-U5QGN3RT.js.map +0 -1
- package/dist/chunk-XCF4PLN7.js.map +0 -1
- package/src/scheduler/postgres-persistence.test.ts +0 -906
package/src/scheduler/cron.ts
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
* Provides time-based and workflow-completion-based triggers for workflows.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { CronExpressionParser } from
|
|
8
|
-
import type { WorkflowKind, RunStatus, Logger } from
|
|
9
|
-
import type { WorkflowEngine } from
|
|
10
|
-
import type { Scheduler, WorkflowSchedule, TriggerType } from
|
|
11
|
-
import type { WorkflowEvent } from
|
|
12
|
-
import { generateId } from
|
|
13
|
-
import { ConsoleLogger } from
|
|
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(
|
|
116
|
+
this.logger.warn('Scheduler is already running');
|
|
120
117
|
return;
|
|
121
118
|
}
|
|
122
119
|
|
|
123
|
-
|
|
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 ===
|
|
137
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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 ===
|
|
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 ===
|
|
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 !==
|
|
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 !==
|
|
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
|
}
|
package/src/scheduler/index.ts
CHANGED
|
@@ -2,17 +2,7 @@
|
|
|
2
2
|
* Scheduler module exports.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
export * from
|
|
6
|
-
export {
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
8
|
-
import type { Pool, PoolConfig } from
|
|
9
|
-
import { loadPostgresDeps } from
|
|
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
|
|
17
|
-
import type { SchedulePersistence } from
|
|
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 ??
|
|
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
|
-
|
|
160
|
+
`contain only alphanumeric characters and underscores, and be at most 63 characters.`
|
|
151
161
|
);
|
|
152
162
|
}
|
|
153
|
-
this.tableName = config.tableName ??
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
243
|
-
|
|
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 !==
|
|
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 =
|
|
325
|
+
const rows = await this.qb
|
|
320
326
|
.selectFrom(this.tableName as any)
|
|
321
327
|
.selectAll()
|
|
322
|
-
.execute()
|
|
328
|
+
.execute() as WorkflowSchedulesTable[];
|
|
323
329
|
|
|
324
|
-
return rows.map(
|
|
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 =
|
|
363
|
+
const existing = await this.qb
|
|
365
364
|
.selectFrom(this.tableName as any)
|
|
366
365
|
.selectAll()
|
|
367
|
-
.where(
|
|
368
|
-
.executeTakeFirst()
|
|
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:
|
|
387
|
-
{ domainKey:
|
|
388
|
-
{ domainKey:
|
|
389
|
-
{ domainKey:
|
|
390
|
-
{ domainKey:
|
|
391
|
-
{
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
},
|
|
396
|
-
{
|
|
397
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
436
|
+
const row = await this.qb
|
|
450
437
|
.selectFrom(this.tableName as any)
|
|
451
438
|
.selectAll()
|
|
452
|
-
.where(
|
|
453
|
-
.executeTakeFirst()
|
|
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 =
|
|
452
|
+
const rows = await this.qb
|
|
466
453
|
.selectFrom(this.tableName as any)
|
|
467
454
|
.selectAll()
|
|
468
|
-
.where(
|
|
469
|
-
.where(
|
|
470
|
-
.where(
|
|
471
|
-
.execute()
|
|
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(
|
|
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 =
|
|
468
|
+
const rows = await this.qb
|
|
484
469
|
.selectFrom(this.tableName as any)
|
|
485
470
|
.selectAll()
|
|
486
|
-
.where(
|
|
487
|
-
.execute()
|
|
471
|
+
.where('workflow_kind', '=', workflowKind)
|
|
472
|
+
.execute() as WorkflowSchedulesTable[];
|
|
488
473
|
|
|
489
|
-
return rows.map(
|
|
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 =
|
|
482
|
+
const rows = await this.qb
|
|
500
483
|
.selectFrom(this.tableName as any)
|
|
501
484
|
.selectAll()
|
|
502
|
-
.where(
|
|
503
|
-
.where(
|
|
504
|
-
.where(
|
|
505
|
-
.execute()
|
|
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(
|
|
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[
|
|
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
|
-
|
|
524
|
-
|
|
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,
|