@multiplier-labs/stepflow 0.3.3 → 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-11_comment-assessment.md +161 -0
- package/.codekin/reports/comments/2026-04-14_comment-assessment.md +34 -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-11_dependency-health.md +93 -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-11_security-audit.md +297 -0
- package/.github/workflows/publish.yml +6 -0
- package/dist/{chunk-4VOKMGP6.js → chunk-5XJBAPXF.js} +517 -522
- package/dist/chunk-5XJBAPXF.js.map +1 -0
- package/dist/{chunk-KHTUWLVZ.js → chunk-FMPJBK2N.js} +55 -10
- package/dist/chunk-FMPJBK2N.js.map +1 -0
- package/dist/{chunk-ML35PIHX.js → chunk-JPXPB7U5.js} +52 -2
- package/dist/chunk-JPXPB7U5.js.map +1 -0
- package/dist/{chunk-GYPNJBDP.js → chunk-U3PCVK6O.js} +266 -47
- package/dist/chunk-U3PCVK6O.js.map +1 -0
- package/dist/events/index.d.ts +56 -3
- package/dist/events/index.js +1 -1
- package/dist/{index-Dzr437iW.d.ts → index-Cd0IZ40L.d.ts} +50 -11
- package/dist/index.d.ts +2 -12
- package/dist/index.js +764 -485
- package/dist/index.js.map +1 -1
- package/dist/scheduler/index.d.ts +1 -1
- package/dist/scheduler/index.js +2 -2
- package/dist/storage/index.d.ts +313 -47
- package/dist/storage/index.js +4 -2
- package/docs/stepflow-api-reference.md +3 -1
- package/package.json +7 -4
- package/src/core/engine/concurrency.ts +229 -0
- package/src/core/engine/lifecycle.ts +176 -0
- package/src/core/engine.test.ts +70 -0
- package/src/core/engine.ts +91 -185
- 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 +40 -359
- package/src/events/webhook.test.ts +292 -0
- package/src/events/webhook.ts +320 -77
- package/src/planning/evaluate-condition.test.ts +153 -0
- package/src/planning/evaluate-condition.ts +105 -0
- package/src/planning/planner.ts +1 -83
- package/src/planning/registry.ts +3 -85
- package/src/scheduler/cron.test.ts +9 -1
- package/src/scheduler/cron.ts +26 -2
- package/src/scheduler/postgres-persistence.ts +27 -15
- package/src/scheduler/sqlite-persistence.ts +28 -3
- 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 +171 -690
- package/src/storage/sqlite-core.ts +151 -0
- package/src/storage/sqlite.test.ts +177 -1
- package/src/storage/sqlite.ts +183 -93
- package/src/utils/index.ts +2 -0
- package/src/utils/pinned-fetch.test.ts +84 -0
- package/src/utils/pinned-fetch.ts +118 -0
- package/src/utils/postgres-deps.ts +16 -0
- 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-4VOKMGP6.js.map +0 -1
- package/dist/chunk-GYPNJBDP.js.map +0 -1
- package/dist/chunk-KHTUWLVZ.js.map +0 -1
- package/dist/chunk-ML35PIHX.js.map +0 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared condition evaluation helpers used by the planner and registry.
|
|
3
|
+
*
|
|
4
|
+
* Extracted to a single module so the planner's recipe scoring and the
|
|
5
|
+
* registry's `query({ matchConditions })` filter cannot drift apart.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import RE2 from 're2';
|
|
9
|
+
import type { ConditionOperator } from './types';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Maximum length (in characters) accepted for a `matches` regex pattern.
|
|
13
|
+
*
|
|
14
|
+
* Recipe conditions can come from user-supplied configuration; even with RE2's
|
|
15
|
+
* linear-time guarantee, very large patterns waste CPU/memory at compile time
|
|
16
|
+
* and inflate the per-evaluation cost. Cap the source length so a pathological
|
|
17
|
+
* recipe cannot hand the planner a megabyte of regex.
|
|
18
|
+
*/
|
|
19
|
+
export const MAX_REGEX_PATTERN_LENGTH = 512;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get a nested value from an object using dot notation.
|
|
23
|
+
*/
|
|
24
|
+
export function getNestedValue(
|
|
25
|
+
obj: Record<string, unknown>,
|
|
26
|
+
path: string
|
|
27
|
+
): unknown {
|
|
28
|
+
return path.split('.').reduce<unknown>((current, key) => {
|
|
29
|
+
if (current && typeof current === 'object' && key in current) {
|
|
30
|
+
return (current as Record<string, unknown>)[key];
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}, obj);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Evaluate a single condition against a value.
|
|
38
|
+
*
|
|
39
|
+
* The `matches` operator uses RE2 (not the JS RegExp engine) to avoid
|
|
40
|
+
* catastrophic-backtracking DoS from untrusted recipe inputs.
|
|
41
|
+
*/
|
|
42
|
+
export function evaluateCondition(
|
|
43
|
+
operator: ConditionOperator,
|
|
44
|
+
fieldValue: unknown,
|
|
45
|
+
conditionValue: unknown
|
|
46
|
+
): boolean {
|
|
47
|
+
switch (operator) {
|
|
48
|
+
case 'eq':
|
|
49
|
+
return fieldValue === conditionValue;
|
|
50
|
+
|
|
51
|
+
case 'neq':
|
|
52
|
+
return fieldValue !== conditionValue;
|
|
53
|
+
|
|
54
|
+
case 'gt':
|
|
55
|
+
return typeof fieldValue === 'number' && typeof conditionValue === 'number'
|
|
56
|
+
? fieldValue > conditionValue
|
|
57
|
+
: false;
|
|
58
|
+
|
|
59
|
+
case 'gte':
|
|
60
|
+
return typeof fieldValue === 'number' && typeof conditionValue === 'number'
|
|
61
|
+
? fieldValue >= conditionValue
|
|
62
|
+
: false;
|
|
63
|
+
|
|
64
|
+
case 'lt':
|
|
65
|
+
return typeof fieldValue === 'number' && typeof conditionValue === 'number'
|
|
66
|
+
? fieldValue < conditionValue
|
|
67
|
+
: false;
|
|
68
|
+
|
|
69
|
+
case 'lte':
|
|
70
|
+
return typeof fieldValue === 'number' && typeof conditionValue === 'number'
|
|
71
|
+
? fieldValue <= conditionValue
|
|
72
|
+
: false;
|
|
73
|
+
|
|
74
|
+
case 'contains':
|
|
75
|
+
if (typeof fieldValue === 'string' && typeof conditionValue === 'string') {
|
|
76
|
+
return fieldValue.includes(conditionValue);
|
|
77
|
+
}
|
|
78
|
+
if (Array.isArray(fieldValue)) {
|
|
79
|
+
return fieldValue.includes(conditionValue);
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
|
|
83
|
+
case 'matches':
|
|
84
|
+
if (typeof fieldValue === 'string' && typeof conditionValue === 'string') {
|
|
85
|
+
if (conditionValue.length > MAX_REGEX_PATTERN_LENGTH) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
return new RE2(conditionValue).test(fieldValue);
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
|
|
96
|
+
case 'exists':
|
|
97
|
+
return fieldValue !== undefined && fieldValue !== null && fieldValue !== '';
|
|
98
|
+
|
|
99
|
+
case 'notExists':
|
|
100
|
+
return fieldValue === undefined || fieldValue === null || fieldValue === '';
|
|
101
|
+
|
|
102
|
+
default:
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
package/src/planning/planner.ts
CHANGED
|
@@ -3,10 +3,9 @@
|
|
|
3
3
|
* Selects recipes and generates plans based on conditions and input analysis.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import RE2 from 're2';
|
|
7
6
|
import { generateId } from '../utils/id';
|
|
7
|
+
import { evaluateCondition, getNestedValue } from './evaluate-condition';
|
|
8
8
|
import type {
|
|
9
|
-
ConditionOperator,
|
|
10
9
|
Plan,
|
|
11
10
|
PlannedStep,
|
|
12
11
|
Planner,
|
|
@@ -22,87 +21,6 @@ import type {
|
|
|
22
21
|
WorkflowKind,
|
|
23
22
|
} from './types';
|
|
24
23
|
|
|
25
|
-
// ============================================================================
|
|
26
|
-
// Condition Evaluation
|
|
27
|
-
// ============================================================================
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Get a nested value from an object using dot notation.
|
|
31
|
-
*/
|
|
32
|
-
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
|
33
|
-
return path.split('.').reduce<unknown>((current, key) => {
|
|
34
|
-
if (current && typeof current === 'object' && key in current) {
|
|
35
|
-
return (current as Record<string, unknown>)[key];
|
|
36
|
-
}
|
|
37
|
-
return undefined;
|
|
38
|
-
}, obj);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Evaluate a single condition against a value.
|
|
43
|
-
*/
|
|
44
|
-
function evaluateCondition(
|
|
45
|
-
operator: ConditionOperator,
|
|
46
|
-
fieldValue: unknown,
|
|
47
|
-
conditionValue: unknown
|
|
48
|
-
): boolean {
|
|
49
|
-
switch (operator) {
|
|
50
|
-
case 'eq':
|
|
51
|
-
return fieldValue === conditionValue;
|
|
52
|
-
|
|
53
|
-
case 'neq':
|
|
54
|
-
return fieldValue !== conditionValue;
|
|
55
|
-
|
|
56
|
-
case 'gt':
|
|
57
|
-
return typeof fieldValue === 'number' && typeof conditionValue === 'number'
|
|
58
|
-
? fieldValue > conditionValue
|
|
59
|
-
: false;
|
|
60
|
-
|
|
61
|
-
case 'gte':
|
|
62
|
-
return typeof fieldValue === 'number' && typeof conditionValue === 'number'
|
|
63
|
-
? fieldValue >= conditionValue
|
|
64
|
-
: false;
|
|
65
|
-
|
|
66
|
-
case 'lt':
|
|
67
|
-
return typeof fieldValue === 'number' && typeof conditionValue === 'number'
|
|
68
|
-
? fieldValue < conditionValue
|
|
69
|
-
: false;
|
|
70
|
-
|
|
71
|
-
case 'lte':
|
|
72
|
-
return typeof fieldValue === 'number' && typeof conditionValue === 'number'
|
|
73
|
-
? fieldValue <= conditionValue
|
|
74
|
-
: false;
|
|
75
|
-
|
|
76
|
-
case 'contains':
|
|
77
|
-
if (typeof fieldValue === 'string' && typeof conditionValue === 'string') {
|
|
78
|
-
return fieldValue.includes(conditionValue);
|
|
79
|
-
}
|
|
80
|
-
if (Array.isArray(fieldValue)) {
|
|
81
|
-
return fieldValue.includes(conditionValue);
|
|
82
|
-
}
|
|
83
|
-
return false;
|
|
84
|
-
|
|
85
|
-
case 'matches':
|
|
86
|
-
if (typeof fieldValue === 'string' && typeof conditionValue === 'string') {
|
|
87
|
-
try {
|
|
88
|
-
return new RE2(conditionValue).test(fieldValue);
|
|
89
|
-
} catch {
|
|
90
|
-
return false;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
return false;
|
|
94
|
-
|
|
95
|
-
case 'exists':
|
|
96
|
-
return fieldValue !== undefined && fieldValue !== null && fieldValue !== '';
|
|
97
|
-
|
|
98
|
-
case 'notExists':
|
|
99
|
-
return fieldValue === undefined || fieldValue === null || fieldValue === '';
|
|
100
|
-
|
|
101
|
-
default:
|
|
102
|
-
return false;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
24
|
/**
|
|
107
25
|
* Score a recipe's conditions against input.
|
|
108
26
|
* Returns a score from 0-100 based on how well conditions match.
|
package/src/planning/registry.ts
CHANGED
|
@@ -3,11 +3,10 @@
|
|
|
3
3
|
* Provides storage and retrieval of recipes and handlers.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import RE2 from 're2';
|
|
7
6
|
import type { WorkflowStep } from '../core/types';
|
|
7
|
+
import { evaluateCondition, getNestedValue } from './evaluate-condition';
|
|
8
8
|
import type {
|
|
9
9
|
Recipe,
|
|
10
|
-
RecipeCondition,
|
|
11
10
|
RecipeRegistry,
|
|
12
11
|
RecipeQueryOptions,
|
|
13
12
|
RegisteredStepHandler,
|
|
@@ -247,9 +246,6 @@ export class MemoryRecipeRegistry implements RecipeRegistry {
|
|
|
247
246
|
/**
|
|
248
247
|
* Evaluate recipe conditions against an input.
|
|
249
248
|
* Returns true if all conditions match.
|
|
250
|
-
*
|
|
251
|
-
* Note: This intentionally mirrors the condition evaluation in planner.ts
|
|
252
|
-
* to keep registry queries independent of the planner implementation.
|
|
253
249
|
*/
|
|
254
250
|
private evaluateConditions(
|
|
255
251
|
conditions: Recipe['conditions'],
|
|
@@ -258,88 +254,10 @@ export class MemoryRecipeRegistry implements RecipeRegistry {
|
|
|
258
254
|
if (!conditions || conditions.length === 0) return true;
|
|
259
255
|
|
|
260
256
|
return conditions.every(condition => {
|
|
261
|
-
const fieldValue =
|
|
262
|
-
return
|
|
257
|
+
const fieldValue = getNestedValue(input, condition.field);
|
|
258
|
+
return evaluateCondition(condition.operator, fieldValue, condition.value);
|
|
263
259
|
});
|
|
264
260
|
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Get a nested value from an object using dot notation.
|
|
268
|
-
*/
|
|
269
|
-
private getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
|
270
|
-
return path.split('.').reduce<unknown>((current, key) => {
|
|
271
|
-
if (current && typeof current === 'object' && key in current) {
|
|
272
|
-
return (current as Record<string, unknown>)[key];
|
|
273
|
-
}
|
|
274
|
-
return undefined;
|
|
275
|
-
}, obj);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* Evaluate a single condition.
|
|
280
|
-
*/
|
|
281
|
-
private evaluateCondition(
|
|
282
|
-
condition: RecipeCondition,
|
|
283
|
-
fieldValue: unknown
|
|
284
|
-
): boolean {
|
|
285
|
-
const { operator, value } = condition;
|
|
286
|
-
|
|
287
|
-
switch (operator) {
|
|
288
|
-
case 'eq':
|
|
289
|
-
return fieldValue === value;
|
|
290
|
-
|
|
291
|
-
case 'neq':
|
|
292
|
-
return fieldValue !== value;
|
|
293
|
-
|
|
294
|
-
case 'gt':
|
|
295
|
-
return typeof fieldValue === 'number' && typeof value === 'number'
|
|
296
|
-
? fieldValue > value
|
|
297
|
-
: false;
|
|
298
|
-
|
|
299
|
-
case 'gte':
|
|
300
|
-
return typeof fieldValue === 'number' && typeof value === 'number'
|
|
301
|
-
? fieldValue >= value
|
|
302
|
-
: false;
|
|
303
|
-
|
|
304
|
-
case 'lt':
|
|
305
|
-
return typeof fieldValue === 'number' && typeof value === 'number'
|
|
306
|
-
? fieldValue < value
|
|
307
|
-
: false;
|
|
308
|
-
|
|
309
|
-
case 'lte':
|
|
310
|
-
return typeof fieldValue === 'number' && typeof value === 'number'
|
|
311
|
-
? fieldValue <= value
|
|
312
|
-
: false;
|
|
313
|
-
|
|
314
|
-
case 'contains':
|
|
315
|
-
if (typeof fieldValue === 'string' && typeof value === 'string') {
|
|
316
|
-
return fieldValue.includes(value);
|
|
317
|
-
}
|
|
318
|
-
if (Array.isArray(fieldValue)) {
|
|
319
|
-
return fieldValue.includes(value);
|
|
320
|
-
}
|
|
321
|
-
return false;
|
|
322
|
-
|
|
323
|
-
case 'matches':
|
|
324
|
-
if (typeof fieldValue === 'string' && typeof value === 'string') {
|
|
325
|
-
try {
|
|
326
|
-
return new RE2(value).test(fieldValue);
|
|
327
|
-
} catch {
|
|
328
|
-
return false;
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
return false;
|
|
332
|
-
|
|
333
|
-
case 'exists':
|
|
334
|
-
return fieldValue !== undefined && fieldValue !== null && fieldValue !== '';
|
|
335
|
-
|
|
336
|
-
case 'notExists':
|
|
337
|
-
return fieldValue === undefined || fieldValue === null || fieldValue === '';
|
|
338
|
-
|
|
339
|
-
default:
|
|
340
|
-
return false;
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
261
|
}
|
|
344
262
|
|
|
345
263
|
// ============================================================================
|
|
@@ -223,6 +223,14 @@ describe('CronScheduler', () => {
|
|
|
223
223
|
// Run pending timers for poll interval
|
|
224
224
|
await vi.runOnlyPendingTimersAsync();
|
|
225
225
|
|
|
226
|
+
// Stop the scheduler and shut down the engine while fake timers are
|
|
227
|
+
// still active so that any setImmediate/setTimeout handles created
|
|
228
|
+
// under fake timers are cleared with the fake clear* functions.
|
|
229
|
+
// Clearing fake-timer handles with real clearImmediate corrupts
|
|
230
|
+
// Node 20/22's internal timer state and breaks subsequent tests.
|
|
231
|
+
await scheduler.stop();
|
|
232
|
+
await engine.shutdown();
|
|
233
|
+
|
|
226
234
|
vi.useRealTimers();
|
|
227
235
|
|
|
228
236
|
// Note: With fake timers, the actual workflow may not complete
|
|
@@ -460,7 +468,7 @@ describe('CronScheduler', () => {
|
|
|
460
468
|
expect(loaded).toBeDefined();
|
|
461
469
|
expect(loaded!.nextRunAt).toBeUndefined();
|
|
462
470
|
expect(logger.error).toHaveBeenCalledWith(
|
|
463
|
-
expect.stringContaining('
|
|
471
|
+
expect.stringContaining('Invalid cron expression'),
|
|
464
472
|
expect.anything()
|
|
465
473
|
);
|
|
466
474
|
|
package/src/scheduler/cron.ts
CHANGED
|
@@ -117,6 +117,16 @@ export class CronScheduler implements Scheduler {
|
|
|
117
117
|
return;
|
|
118
118
|
}
|
|
119
119
|
|
|
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
|
+
|
|
120
130
|
this.logger.info('Starting scheduler...');
|
|
121
131
|
|
|
122
132
|
// Load schedules from persistence if available
|
|
@@ -128,10 +138,24 @@ export class CronScheduler implements Scheduler {
|
|
|
128
138
|
this.logger.info(`Loaded ${loaded.length} schedules from persistence`);
|
|
129
139
|
}
|
|
130
140
|
|
|
131
|
-
// 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.
|
|
132
144
|
for (const schedule of this.schedules.values()) {
|
|
133
145
|
if (schedule.triggerType === 'cron' && schedule.cronExpression) {
|
|
134
|
-
|
|
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
|
+
}
|
|
135
159
|
}
|
|
136
160
|
}
|
|
137
161
|
|
|
@@ -15,6 +15,8 @@ let sql: any;
|
|
|
15
15
|
let pgModule: any;
|
|
16
16
|
import type { WorkflowSchedule } from './types.js';
|
|
17
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,6 +150,7 @@ 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
156
|
this.schema = config.schema ?? 'public';
|
|
@@ -159,6 +169,7 @@ export class PostgresSchedulePersistence implements SchedulePersistence {
|
|
|
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
|
/**
|
|
@@ -483,6 +494,19 @@ export class PostgresSchedulePersistence implements SchedulePersistence {
|
|
|
483
494
|
// Helper Methods
|
|
484
495
|
// ============================================================================
|
|
485
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
|
+
|
|
486
510
|
private rowToSchedule(row: WorkflowSchedulesTable): WorkflowSchedule {
|
|
487
511
|
return {
|
|
488
512
|
id: row.id,
|
|
@@ -491,21 +515,9 @@ export class PostgresSchedulePersistence implements SchedulePersistence {
|
|
|
491
515
|
cronExpression: row.cron_expression ?? undefined,
|
|
492
516
|
timezone: row.timezone ?? undefined,
|
|
493
517
|
triggerOnWorkflowKind: row.trigger_on_workflow_kind ?? undefined,
|
|
494
|
-
triggerOnStatus: row.trigger_on_status
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
: row.trigger_on_status)
|
|
498
|
-
: undefined,
|
|
499
|
-
input: row.input_json
|
|
500
|
-
? (typeof row.input_json === 'string'
|
|
501
|
-
? JSON.parse(row.input_json)
|
|
502
|
-
: row.input_json)
|
|
503
|
-
: undefined,
|
|
504
|
-
metadata: row.metadata_json
|
|
505
|
-
? (typeof row.metadata_json === 'string'
|
|
506
|
-
? JSON.parse(row.metadata_json)
|
|
507
|
-
: row.metadata_json)
|
|
508
|
-
: 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,
|
|
509
521
|
enabled: row.enabled,
|
|
510
522
|
lastRunAt: row.last_run_at ? new Date(row.last_run_at) : undefined,
|
|
511
523
|
lastRunId: row.last_run_id ?? undefined,
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
import type { Database, Statement } from 'better-sqlite3';
|
|
8
8
|
import type { WorkflowSchedule } from './types';
|
|
9
9
|
import type { SchedulePersistence } from './cron';
|
|
10
|
+
import type { Logger, RunStatus } from '../core/types';
|
|
10
11
|
import { generateId } from '../utils/id';
|
|
12
|
+
import { safeJsonParse as sharedSafeJsonParse } from '../utils/safe-json';
|
|
11
13
|
|
|
12
14
|
// ============================================================================
|
|
13
15
|
// Configuration Types
|
|
@@ -22,6 +24,13 @@ export interface SQLiteSchedulePersistenceConfig {
|
|
|
22
24
|
|
|
23
25
|
/** Table name for schedules (default: workflow_schedules) */
|
|
24
26
|
tableName?: string;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Optional structured logger used to surface JSON corruption events.
|
|
30
|
+
* Without one, parse failures still increment the corruption counter
|
|
31
|
+
* exposed via `getJsonParseCorruptionCount()` but produce no log output.
|
|
32
|
+
*/
|
|
33
|
+
logger?: Logger;
|
|
25
34
|
}
|
|
26
35
|
|
|
27
36
|
// ============================================================================
|
|
@@ -47,6 +56,7 @@ export interface SQLiteSchedulePersistenceConfig {
|
|
|
47
56
|
export class SQLiteSchedulePersistence implements SchedulePersistence {
|
|
48
57
|
private db: Database;
|
|
49
58
|
private tableName: string;
|
|
59
|
+
private logger: Logger | undefined;
|
|
50
60
|
private stmts: {
|
|
51
61
|
insert: Statement;
|
|
52
62
|
update: Statement;
|
|
@@ -61,6 +71,7 @@ export class SQLiteSchedulePersistence implements SchedulePersistence {
|
|
|
61
71
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(this.tableName)) {
|
|
62
72
|
throw new Error(`Invalid table name: ${this.tableName}`);
|
|
63
73
|
}
|
|
74
|
+
this.logger = config.logger;
|
|
64
75
|
this.initializeDatabase();
|
|
65
76
|
}
|
|
66
77
|
|
|
@@ -217,6 +228,20 @@ export class SQLiteSchedulePersistence implements SchedulePersistence {
|
|
|
217
228
|
// Helper Methods
|
|
218
229
|
// ============================================================================
|
|
219
230
|
|
|
231
|
+
private safeJsonParse(
|
|
232
|
+
json: string,
|
|
233
|
+
fallback: unknown,
|
|
234
|
+
rowId: string,
|
|
235
|
+
column: string,
|
|
236
|
+
): unknown {
|
|
237
|
+
return sharedSafeJsonParse(json, fallback, {
|
|
238
|
+
component: 'SQLiteSchedulePersistence',
|
|
239
|
+
rowId,
|
|
240
|
+
column,
|
|
241
|
+
logger: this.logger,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
220
245
|
private rowToSchedule(row: ScheduleRow): WorkflowSchedule {
|
|
221
246
|
return {
|
|
222
247
|
id: row.id,
|
|
@@ -226,10 +251,10 @@ export class SQLiteSchedulePersistence implements SchedulePersistence {
|
|
|
226
251
|
timezone: row.timezone ?? undefined,
|
|
227
252
|
triggerOnWorkflowKind: row.trigger_on_workflow_kind ?? undefined,
|
|
228
253
|
triggerOnStatus: row.trigger_on_status
|
|
229
|
-
?
|
|
254
|
+
? this.safeJsonParse(row.trigger_on_status, undefined, row.id, 'trigger_on_status') as RunStatus[] | undefined
|
|
230
255
|
: undefined,
|
|
231
|
-
input: row.input ?
|
|
232
|
-
metadata: row.metadata ?
|
|
256
|
+
input: row.input ? this.safeJsonParse(row.input, undefined, row.id, 'input') as Record<string, unknown> | undefined : undefined,
|
|
257
|
+
metadata: row.metadata ? this.safeJsonParse(row.metadata, undefined, row.id, 'metadata') as Record<string, unknown> | undefined : undefined,
|
|
233
258
|
enabled: row.enabled === 1,
|
|
234
259
|
lastRunAt: row.last_run_at ? new Date(row.last_run_at) : undefined,
|
|
235
260
|
lastRunId: row.last_run_id ?? undefined,
|