reqon-dsl 0.3.0 → 0.4.0
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/README.md +23 -3
- package/dist/ast/nodes.d.ts +8 -0
- package/dist/auth/circuit-breaker.d.ts +11 -0
- package/dist/auth/circuit-breaker.js +83 -12
- package/dist/auth/credentials.d.ts +6 -1
- package/dist/auth/credentials.js +12 -4
- package/dist/auth/oauth2-provider.js +13 -3
- package/dist/auth/rate-limiter.d.ts +8 -1
- package/dist/auth/rate-limiter.js +30 -10
- package/dist/auth/token-store.js +8 -1
- package/dist/cli.d.ts +11 -1
- package/dist/cli.js +65 -6
- package/dist/config/constants.d.ts +15 -4
- package/dist/config/constants.js +15 -4
- package/dist/control/server.d.ts +17 -0
- package/dist/control/server.js +82 -5
- package/dist/control/types.d.ts +6 -0
- package/dist/debug/cli-debugger.js +8 -3
- package/dist/execution/store.js +2 -2
- package/dist/execution-log/events.d.ts +125 -0
- package/dist/execution-log/events.js +17 -0
- package/dist/execution-log/fold.d.ts +38 -0
- package/dist/execution-log/fold.js +54 -0
- package/dist/execution-log/index.d.ts +18 -0
- package/dist/execution-log/index.js +6 -0
- package/dist/execution-log/postgres-store.d.ts +36 -0
- package/dist/execution-log/postgres-store.js +108 -0
- package/dist/execution-log/resume.d.ts +11 -0
- package/dist/execution-log/resume.js +5 -0
- package/dist/execution-log/sqlite-store.d.ts +16 -0
- package/dist/execution-log/sqlite-store.js +101 -0
- package/dist/execution-log/store.d.ts +72 -0
- package/dist/execution-log/store.js +182 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/interpreter/context.d.ts +15 -0
- package/dist/interpreter/context.js +3 -0
- package/dist/interpreter/evaluator.js +38 -8
- package/dist/interpreter/executor.d.ts +63 -1
- package/dist/interpreter/executor.js +406 -30
- package/dist/interpreter/fetch-handler.d.ts +39 -1
- package/dist/interpreter/fetch-handler.js +84 -15
- package/dist/interpreter/http.d.ts +31 -2
- package/dist/interpreter/http.js +187 -26
- package/dist/interpreter/index.d.ts +3 -3
- package/dist/interpreter/index.js +3 -3
- package/dist/interpreter/pagination.d.ts +1 -1
- package/dist/interpreter/pagination.js +7 -1
- package/dist/interpreter/step-handlers/for-handler.d.ts +3 -0
- package/dist/interpreter/step-handlers/for-handler.js +18 -3
- package/dist/interpreter/step-handlers/match-handler.js +5 -2
- package/dist/interpreter/step-handlers/store-handler.d.ts +7 -1
- package/dist/interpreter/step-handlers/store-handler.js +25 -16
- package/dist/interpreter/step-handlers/validate-handler.js +4 -1
- package/dist/interpreter/step-handlers/webhook-handler.d.ts +1 -0
- package/dist/interpreter/step-handlers/webhook-handler.js +13 -3
- package/dist/interpreter/store-manager.d.ts +1 -1
- package/dist/interpreter/store-manager.js +5 -1
- package/dist/loader/index.js +5 -8
- package/dist/mcp/sandbox.d.ts +41 -0
- package/dist/mcp/sandbox.js +76 -0
- package/dist/mcp/server.js +62 -9
- package/dist/oas/loader.d.ts +13 -1
- package/dist/oas/loader.js +25 -3
- package/dist/oas/mock-generator.js +13 -4
- package/dist/oas/validator.js +45 -5
- package/dist/observability/events.d.ts +6 -2
- package/dist/observability/events.js +0 -5
- package/dist/observability/logger.js +17 -10
- package/dist/observability/otel.d.ts +8 -0
- package/dist/observability/otel.js +45 -10
- package/dist/parser/action-parser.js +2 -2
- package/dist/parser/base.d.ts +7 -0
- package/dist/parser/base.js +11 -0
- package/dist/parser/expressions.d.ts +1 -0
- package/dist/parser/expressions.js +17 -4
- package/dist/parser/fetch-parser.js +13 -2
- package/dist/pause/index.d.ts +1 -0
- package/dist/pause/index.js +1 -0
- package/dist/pause/log-store.d.ts +33 -0
- package/dist/pause/log-store.js +98 -0
- package/dist/pause/manager.d.ts +12 -0
- package/dist/pause/manager.js +77 -28
- package/dist/pause/store.js +5 -3
- package/dist/scheduler/cron-parser.d.ts +10 -3
- package/dist/scheduler/cron-parser.js +227 -48
- package/dist/scheduler/scheduler.js +56 -22
- package/dist/stores/factory.d.ts +6 -0
- package/dist/stores/factory.js +11 -1
- package/dist/stores/file.js +9 -17
- package/dist/stores/memory.js +3 -12
- package/dist/stores/postgrest.d.ts +28 -0
- package/dist/stores/postgrest.js +84 -37
- package/dist/sync/index.d.ts +3 -2
- package/dist/sync/index.js +2 -1
- package/dist/sync/log-store.d.ts +30 -0
- package/dist/sync/log-store.js +45 -0
- package/dist/sync/store.js +1 -1
- package/dist/trace/index.d.ts +2 -0
- package/dist/trace/index.js +1 -0
- package/dist/trace/log-view.d.ts +57 -0
- package/dist/trace/log-view.js +76 -0
- package/dist/trace/recorder.d.ts +5 -1
- package/dist/trace/recorder.js +19 -6
- package/dist/trace/store.d.ts +6 -0
- package/dist/trace/store.js +47 -22
- package/dist/utils/deep-merge.d.ts +10 -0
- package/dist/utils/deep-merge.js +23 -0
- package/dist/utils/file.d.ts +13 -4
- package/dist/utils/file.js +70 -12
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/long-timeout.d.ts +19 -0
- package/dist/utils/long-timeout.js +33 -0
- package/dist/utils/path.d.ts +22 -1
- package/dist/utils/path.js +46 -1
- package/dist/utils/redact.d.ts +22 -0
- package/dist/utils/redact.js +42 -0
- package/dist/webhook/server.d.ts +9 -0
- package/dist/webhook/server.js +115 -30
- package/dist/webhook/types.d.ts +9 -1
- package/package.json +22 -4
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Parse a cron expression and calculate the next run time
|
|
3
3
|
*
|
|
4
4
|
* Cron format: "minute hour day-of-month month day-of-week"
|
|
5
|
-
* Supports: numbers, ranges (1-5), steps (
|
|
5
|
+
* Supports: numbers, ranges (1-5), steps (* /5), lists (1,3,5), and wildcards (*)
|
|
6
6
|
*/
|
|
7
7
|
export function parseCronExpression(expression) {
|
|
8
8
|
const parts = expression.trim().split(/\s+/);
|
|
@@ -14,11 +14,29 @@ export function parseCronExpression(expression) {
|
|
|
14
14
|
hour: parseField(parts[1], 0, 23),
|
|
15
15
|
dayOfMonth: parseField(parts[2], 1, 31),
|
|
16
16
|
month: parseField(parts[3], 1, 12),
|
|
17
|
-
dayOfWeek: parseField(parts[4], 0, 6), // 0 = Sunday
|
|
17
|
+
dayOfWeek: parseField(parts[4], 0, 6, true), // 0 = Sunday; 7 also = Sunday
|
|
18
|
+
// POSIX day matching keys off whether each field is a literal wildcard.
|
|
19
|
+
dayOfMonthRestricted: parts[2] !== '*',
|
|
20
|
+
dayOfWeekRestricted: parts[4] !== '*',
|
|
18
21
|
};
|
|
19
22
|
}
|
|
20
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Parse one cron field into the explicit set of integers it matches.
|
|
25
|
+
*
|
|
26
|
+
* Validation is strict and fails fast (rather than silently yielding an empty
|
|
27
|
+
* set or, worse, hanging): steps must be integers >= 1, bounds must be numeric,
|
|
28
|
+
* and ranges must not run backwards. When `allowSundaySeven` is set (day-of-week
|
|
29
|
+
* only) a literal 7 is accepted and normalised to 0 (Sunday).
|
|
30
|
+
*/
|
|
31
|
+
function parseField(field, min, max, allowSundaySeven = false) {
|
|
21
32
|
const values = new Set();
|
|
33
|
+
const toInt = (token, label) => {
|
|
34
|
+
const n = parseInt(token, 10);
|
|
35
|
+
if (!Number.isInteger(n) || !/^[+-]?\d+$/.test(token.trim())) {
|
|
36
|
+
throw new Error(`Invalid cron ${label} '${token}' in '${field}': expected an integer`);
|
|
37
|
+
}
|
|
38
|
+
return n;
|
|
39
|
+
};
|
|
22
40
|
for (const part of field.split(',')) {
|
|
23
41
|
if (part === '*') {
|
|
24
42
|
// All values
|
|
@@ -30,88 +48,244 @@ function parseField(field, min, max) {
|
|
|
30
48
|
// Step values (e.g., */5 or 1-10/2)
|
|
31
49
|
const [range, stepStr] = part.split('/');
|
|
32
50
|
const step = parseInt(stepStr, 10);
|
|
51
|
+
if (!Number.isInteger(step) || step < 1) {
|
|
52
|
+
throw new Error(`Invalid cron step '${stepStr}' in '${part}': step must be an integer >= 1`);
|
|
53
|
+
}
|
|
33
54
|
let start = min;
|
|
34
55
|
let end = max;
|
|
35
56
|
if (range !== '*') {
|
|
36
57
|
if (range.includes('-')) {
|
|
37
|
-
const [rangeStart, rangeEnd] = range.split('-')
|
|
38
|
-
start = rangeStart;
|
|
39
|
-
end = rangeEnd;
|
|
58
|
+
const [rangeStart, rangeEnd] = range.split('-');
|
|
59
|
+
start = toInt(rangeStart, 'range start');
|
|
60
|
+
end = toInt(rangeEnd, 'range end');
|
|
40
61
|
}
|
|
41
62
|
else {
|
|
42
|
-
start =
|
|
63
|
+
start = toInt(range, 'range start');
|
|
43
64
|
}
|
|
44
65
|
}
|
|
66
|
+
if (end < start) {
|
|
67
|
+
throw new Error(`Invalid cron range '${range}' in '${part}': ${start} is after ${end}`);
|
|
68
|
+
}
|
|
45
69
|
for (let i = start; i <= end; i += step) {
|
|
46
70
|
values.add(i);
|
|
47
71
|
}
|
|
48
72
|
}
|
|
49
73
|
else if (part.includes('-')) {
|
|
50
74
|
// Range (e.g., 1-5)
|
|
51
|
-
const [
|
|
75
|
+
const [startStr, endStr] = part.split('-');
|
|
76
|
+
const start = toInt(startStr, 'range start');
|
|
77
|
+
const end = toInt(endStr, 'range end');
|
|
78
|
+
if (end < start) {
|
|
79
|
+
throw new Error(`Invalid cron range '${part}': ${start} is after ${end}`);
|
|
80
|
+
}
|
|
52
81
|
for (let i = start; i <= end; i++) {
|
|
53
82
|
values.add(i);
|
|
54
83
|
}
|
|
55
84
|
}
|
|
56
85
|
else {
|
|
57
86
|
// Single value
|
|
58
|
-
values.add(
|
|
87
|
+
values.add(toInt(part, 'value'));
|
|
59
88
|
}
|
|
60
89
|
}
|
|
61
|
-
//
|
|
90
|
+
// Day-of-week 7 is an alias for Sunday (0) in standard cron.
|
|
91
|
+
const normalized = new Set();
|
|
62
92
|
for (const value of values) {
|
|
63
|
-
|
|
93
|
+
normalized.add(allowSundaySeven && value === 7 ? 0 : value);
|
|
94
|
+
}
|
|
95
|
+
// Validate all values are in range (NaN is rejected by `toInt` above, but
|
|
96
|
+
// guard explicitly so an out-of-range NaN can never slip through).
|
|
97
|
+
for (const value of normalized) {
|
|
98
|
+
if (Number.isNaN(value) || value < min || value > max) {
|
|
64
99
|
throw new Error(`Cron field value ${value} out of range [${min}, ${max}]`);
|
|
65
100
|
}
|
|
66
101
|
}
|
|
67
|
-
return Array.from(
|
|
102
|
+
return Array.from(normalized).sort((a, b) => a - b);
|
|
103
|
+
}
|
|
104
|
+
/** Number of days in a given (1-based) month, accounting for leap years. */
|
|
105
|
+
function daysInMonth(year, month) {
|
|
106
|
+
return new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
107
|
+
}
|
|
108
|
+
/** The day-of-week (0 = Sunday) for a calendar date — timezone-independent. */
|
|
109
|
+
function weekdayOf(year, month, day) {
|
|
110
|
+
return new Date(Date.UTC(year, month - 1, day)).getUTCDay();
|
|
111
|
+
}
|
|
112
|
+
/** Read the wall-clock fields of an instant in `timeZone`. */
|
|
113
|
+
function partsInZone(instant, timeZone) {
|
|
114
|
+
const dtf = new Intl.DateTimeFormat('en-US', {
|
|
115
|
+
timeZone,
|
|
116
|
+
hourCycle: 'h23',
|
|
117
|
+
year: 'numeric',
|
|
118
|
+
month: '2-digit',
|
|
119
|
+
day: '2-digit',
|
|
120
|
+
hour: '2-digit',
|
|
121
|
+
minute: '2-digit',
|
|
122
|
+
second: '2-digit',
|
|
123
|
+
});
|
|
124
|
+
const map = {};
|
|
125
|
+
for (const part of dtf.formatToParts(new Date(instant))) {
|
|
126
|
+
if (part.type !== 'literal')
|
|
127
|
+
map[part.type] = parseInt(part.value, 10);
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
year: map.year,
|
|
131
|
+
month: map.month,
|
|
132
|
+
day: map.day,
|
|
133
|
+
hour: map.hour === 24 ? 0 : map.hour,
|
|
134
|
+
minute: map.minute,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/** Offset (ms) such that wall-clock-in-zone = instant + offset, at `instant`. */
|
|
138
|
+
function zoneOffsetMs(instant, timeZone) {
|
|
139
|
+
const dtf = new Intl.DateTimeFormat('en-US', {
|
|
140
|
+
timeZone,
|
|
141
|
+
hourCycle: 'h23',
|
|
142
|
+
year: 'numeric',
|
|
143
|
+
month: '2-digit',
|
|
144
|
+
day: '2-digit',
|
|
145
|
+
hour: '2-digit',
|
|
146
|
+
minute: '2-digit',
|
|
147
|
+
second: '2-digit',
|
|
148
|
+
});
|
|
149
|
+
const m = {};
|
|
150
|
+
for (const part of dtf.formatToParts(new Date(instant))) {
|
|
151
|
+
if (part.type !== 'literal')
|
|
152
|
+
m[part.type] = parseInt(part.value, 10);
|
|
153
|
+
}
|
|
154
|
+
const hour = m.hour === 24 ? 0 : m.hour;
|
|
155
|
+
return Date.UTC(m.year, m.month - 1, m.day, hour, m.minute, m.second) - instant;
|
|
68
156
|
}
|
|
69
157
|
/**
|
|
70
|
-
*
|
|
158
|
+
* Convert a wall-clock time in `timeZone` to the corresponding UTC instant.
|
|
159
|
+
* Around DST transitions the wall time may be ambiguous or nonexistent; this
|
|
160
|
+
* resolves to a deterministic nearby instant.
|
|
71
161
|
*/
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
162
|
+
function wallToInstant(wc, timeZone) {
|
|
163
|
+
const asUTC = Date.UTC(wc.year, wc.month - 1, wc.day, wc.hour, wc.minute);
|
|
164
|
+
const offset = zoneOffsetMs(asUTC, timeZone);
|
|
165
|
+
let instant = asUTC - offset;
|
|
166
|
+
const offset2 = zoneOffsetMs(instant, timeZone);
|
|
167
|
+
if (offset2 !== offset)
|
|
168
|
+
instant = asUTC - offset2;
|
|
169
|
+
return instant;
|
|
170
|
+
}
|
|
171
|
+
/** Advance a wall-clock by one minute, rolling over fields as needed. */
|
|
172
|
+
function addMinute(wc) {
|
|
173
|
+
let { year, month, day, hour, minute } = wc;
|
|
174
|
+
minute += 1;
|
|
175
|
+
if (minute > 59) {
|
|
176
|
+
minute = 0;
|
|
177
|
+
hour += 1;
|
|
178
|
+
if (hour > 23) {
|
|
179
|
+
hour = 0;
|
|
180
|
+
day += 1;
|
|
181
|
+
if (day > daysInMonth(year, month)) {
|
|
182
|
+
day = 1;
|
|
183
|
+
month += 1;
|
|
184
|
+
if (month > 12) {
|
|
185
|
+
month = 1;
|
|
186
|
+
year += 1;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { year, month, day, hour, minute };
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Calculate the next run time for a cron schedule, evaluated against
|
|
195
|
+
* wall-clock time in `timeZone` (default UTC, so DST never shifts the result).
|
|
196
|
+
* Day-of-month and day-of-week follow POSIX: when both are restricted the
|
|
197
|
+
* match is their union (OR), not their intersection.
|
|
198
|
+
*/
|
|
199
|
+
export function getNextCronRun(schedule, after = new Date(), timeZone = 'UTC') {
|
|
200
|
+
// Fail fast on combinations that can never match (e.g. Feb 31) instead of
|
|
201
|
+
// grinding through a full multi-year minute-by-minute scan before throwing.
|
|
202
|
+
assertReachableDayMonth(schedule);
|
|
203
|
+
// Start from the first whole minute strictly after `after`.
|
|
204
|
+
const wc = addMinute(partsInZone(after.getTime(), timeZone));
|
|
77
205
|
const maxIterations = 4 * 366 * 24 * 60;
|
|
78
206
|
for (let i = 0; i < maxIterations; i++) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
207
|
+
if (!schedule.month.includes(wc.month)) {
|
|
208
|
+
wc.month += 1;
|
|
209
|
+
if (wc.month > 12) {
|
|
210
|
+
wc.month = 1;
|
|
211
|
+
wc.year += 1;
|
|
212
|
+
}
|
|
213
|
+
wc.day = 1;
|
|
214
|
+
wc.hour = 0;
|
|
215
|
+
wc.minute = 0;
|
|
85
216
|
continue;
|
|
86
217
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
218
|
+
if (wc.day > daysInMonth(wc.year, wc.month)) {
|
|
219
|
+
wc.month += 1;
|
|
220
|
+
if (wc.month > 12) {
|
|
221
|
+
wc.month = 1;
|
|
222
|
+
wc.year += 1;
|
|
223
|
+
}
|
|
224
|
+
wc.day = 1;
|
|
225
|
+
wc.hour = 0;
|
|
226
|
+
wc.minute = 0;
|
|
91
227
|
continue;
|
|
92
228
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
229
|
+
if (!matchesDay(schedule, wc)) {
|
|
230
|
+
wc.day += 1;
|
|
231
|
+
wc.hour = 0;
|
|
232
|
+
wc.minute = 0;
|
|
97
233
|
continue;
|
|
98
234
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
235
|
+
if (!schedule.hour.includes(wc.hour)) {
|
|
236
|
+
wc.hour += 1;
|
|
237
|
+
wc.minute = 0;
|
|
238
|
+
if (wc.hour > 23) {
|
|
239
|
+
wc.hour = 0;
|
|
240
|
+
wc.day += 1;
|
|
241
|
+
}
|
|
103
242
|
continue;
|
|
104
243
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
244
|
+
if (!schedule.minute.includes(wc.minute)) {
|
|
245
|
+
wc.minute += 1;
|
|
246
|
+
if (wc.minute > 59) {
|
|
247
|
+
wc.minute = 0;
|
|
248
|
+
wc.hour += 1;
|
|
249
|
+
if (wc.hour > 23) {
|
|
250
|
+
wc.hour = 0;
|
|
251
|
+
wc.day += 1;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
108
254
|
continue;
|
|
109
255
|
}
|
|
110
|
-
|
|
111
|
-
return next;
|
|
256
|
+
return new Date(wallToInstant(wc, timeZone));
|
|
112
257
|
}
|
|
113
258
|
throw new Error('Could not find next cron run time within 4 years');
|
|
114
259
|
}
|
|
260
|
+
/** Maximum days any given (1-based) month can have, allowing for leap Februarys. */
|
|
261
|
+
const MAX_DAYS_IN_MONTH = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
262
|
+
/**
|
|
263
|
+
* Throw if a restricted day-of-month can never occur in any allowed month.
|
|
264
|
+
* Only applies when day-of-week is a wildcard: with a restricted weekday the
|
|
265
|
+
* POSIX OR means a matching day still occurs every month, so it is reachable.
|
|
266
|
+
*/
|
|
267
|
+
function assertReachableDayMonth(schedule) {
|
|
268
|
+
if (!schedule.dayOfMonthRestricted || schedule.dayOfWeekRestricted)
|
|
269
|
+
return;
|
|
270
|
+
const reachable = schedule.month.some((m) => schedule.dayOfMonth.some((d) => d <= MAX_DAYS_IN_MONTH[m]));
|
|
271
|
+
if (!reachable) {
|
|
272
|
+
throw new Error(`Cron schedule can never match: day-of-month [${schedule.dayOfMonth.join(',')}] never occurs in month(s) [${schedule.month.join(',')}]`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/** POSIX day matching: union of day-of-month and day-of-week when both set. */
|
|
276
|
+
function matchesDay(schedule, wc) {
|
|
277
|
+
const domMatch = schedule.dayOfMonth.includes(wc.day);
|
|
278
|
+
const dowMatch = schedule.dayOfWeek.includes(weekdayOf(wc.year, wc.month, wc.day));
|
|
279
|
+
const domR = schedule.dayOfMonthRestricted;
|
|
280
|
+
const dowR = schedule.dayOfWeekRestricted;
|
|
281
|
+
if (domR && dowR)
|
|
282
|
+
return domMatch || dowMatch;
|
|
283
|
+
if (domR)
|
|
284
|
+
return domMatch;
|
|
285
|
+
if (dowR)
|
|
286
|
+
return dowMatch;
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
115
289
|
/**
|
|
116
290
|
* Convert interval schedule to milliseconds
|
|
117
291
|
*/
|
|
@@ -142,7 +316,7 @@ export function getNextRunTime(schedule, after = new Date()) {
|
|
|
142
316
|
throw new Error('Cron schedule missing cron expression');
|
|
143
317
|
}
|
|
144
318
|
const cronSchedule = parseCronExpression(schedule.cronExpression);
|
|
145
|
-
return getNextCronRun(cronSchedule, after);
|
|
319
|
+
return getNextCronRun(cronSchedule, after, schedule.timezone ?? 'UTC');
|
|
146
320
|
}
|
|
147
321
|
case 'once': {
|
|
148
322
|
if (!schedule.runAt) {
|
|
@@ -178,19 +352,24 @@ export function shouldRunNow(schedule, lastRun, checkIntervalMs = 1000) {
|
|
|
178
352
|
if (!schedule.cronExpression)
|
|
179
353
|
return false;
|
|
180
354
|
const cronSchedule = parseCronExpression(schedule.cronExpression);
|
|
181
|
-
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
355
|
+
// Drift-free: the job is due once its next scheduled time after the last
|
|
356
|
+
// run has arrived. A late or slow poll never silently skips a tick — the
|
|
357
|
+
// overshot run still satisfies `nextRun <= now`, instead of needing `now`
|
|
358
|
+
// to land within a 1s window of the scheduled time.
|
|
359
|
+
const baseline = lastRun ?? new Date(now.getTime() - checkIntervalMs);
|
|
360
|
+
const nextRun = getNextCronRun(cronSchedule, baseline, schedule.timezone ?? 'UTC');
|
|
361
|
+
return nextRun.getTime() <= now.getTime();
|
|
185
362
|
}
|
|
186
363
|
case 'once': {
|
|
187
364
|
if (!schedule.runAt)
|
|
188
365
|
return false;
|
|
189
366
|
if (lastRun)
|
|
190
367
|
return false; // Already ran
|
|
368
|
+
// Fire as soon as the scheduled instant has arrived. A missed tick must
|
|
369
|
+
// not strand the job: once runAt is in the past (and it has never run),
|
|
370
|
+
// the next check still catches it, rather than only a ±checkInterval window.
|
|
191
371
|
const runAt = new Date(schedule.runAt);
|
|
192
|
-
|
|
193
|
-
return diff <= checkIntervalMs;
|
|
372
|
+
return now.getTime() >= runAt.getTime();
|
|
194
373
|
}
|
|
195
374
|
default:
|
|
196
375
|
return false;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { readFile,
|
|
1
|
+
import { readFile, mkdir } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { writeFileAtomic } from '../utils/file.js';
|
|
3
4
|
import { MissionExecutor } from '../interpreter/executor.js';
|
|
4
5
|
import { getNextRunTime, shouldRunNow } from './cron-parser.js';
|
|
6
|
+
import { setLongTimeout } from '../utils/long-timeout.js';
|
|
5
7
|
/**
|
|
6
8
|
* Scheduler for running missions on a schedule
|
|
7
9
|
*/
|
|
@@ -35,9 +37,18 @@ export class Scheduler {
|
|
|
35
37
|
throw new Error(`Mission '${mission.name}' has no schedule defined`);
|
|
36
38
|
}
|
|
37
39
|
this.missions.set(mission.name, { mission, filePath });
|
|
38
|
-
// Create or update job state
|
|
40
|
+
// Create or update job state. A schedule that can never produce a next run
|
|
41
|
+
// (e.g. an impossible cron like Feb 31) must not abort registration — the
|
|
42
|
+
// job is registered with an undefined nextRun and the check loop isolates
|
|
43
|
+
// it so it can't starve sibling jobs.
|
|
39
44
|
const existingJob = this.state.jobs[mission.name];
|
|
40
|
-
|
|
45
|
+
let nextRun = null;
|
|
46
|
+
try {
|
|
47
|
+
nextRun = getNextRunTime(mission.schedule);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
this.log(`Mission '${mission.name}' has an unschedulable cron: ${error.message}`);
|
|
51
|
+
}
|
|
41
52
|
this.state.jobs[mission.name] = {
|
|
42
53
|
id: mission.name,
|
|
43
54
|
missionName: mission.name,
|
|
@@ -97,7 +108,7 @@ export class Scheduler {
|
|
|
97
108
|
}
|
|
98
109
|
// Clear all pending retry timers to prevent memory leaks
|
|
99
110
|
for (const timer of this.retryTimers) {
|
|
100
|
-
|
|
111
|
+
timer.clear();
|
|
101
112
|
}
|
|
102
113
|
this.retryTimers.clear();
|
|
103
114
|
// Save state
|
|
@@ -109,19 +120,25 @@ export class Scheduler {
|
|
|
109
120
|
*/
|
|
110
121
|
async checkAndRun() {
|
|
111
122
|
const now = new Date();
|
|
123
|
+
// Each job is isolated: a throw while evaluating one schedule (e.g. an
|
|
124
|
+
// impossible cron) must not break the loop and starve siblings. Due jobs
|
|
125
|
+
// are dispatched without awaiting so a slow mission can't head-of-line
|
|
126
|
+
// block the rest of the tick.
|
|
112
127
|
for (const [name, job] of Object.entries(this.state.jobs)) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
128
|
+
try {
|
|
129
|
+
if (!job.enabled)
|
|
130
|
+
continue;
|
|
131
|
+
const scheduledMission = this.missions.get(name);
|
|
132
|
+
if (!scheduledMission)
|
|
133
|
+
continue;
|
|
134
|
+
const schedule = scheduledMission.mission.schedule;
|
|
135
|
+
if (!schedule)
|
|
136
|
+
continue; // Mission must have schedule
|
|
137
|
+
// Check if job should run
|
|
138
|
+
if (!shouldRunNow(schedule, job.lastRun, this.config.checkInterval))
|
|
139
|
+
continue;
|
|
123
140
|
// Check if already running
|
|
124
|
-
if (job.isRunning &&
|
|
141
|
+
if (job.isRunning && schedule.skipIfRunning !== false) {
|
|
125
142
|
this.emitEvent({
|
|
126
143
|
type: 'skipped',
|
|
127
144
|
jobId: job.id,
|
|
@@ -131,8 +148,14 @@ export class Scheduler {
|
|
|
131
148
|
});
|
|
132
149
|
continue;
|
|
133
150
|
}
|
|
134
|
-
//
|
|
135
|
-
|
|
151
|
+
// Dispatch without awaiting: the loop keeps checking other jobs while
|
|
152
|
+
// this mission runs. Failures are handled inside runJob.
|
|
153
|
+
void this.runJob(job, scheduledMission).catch((error) => {
|
|
154
|
+
this.log(`Job '${name}' run error: ${error.message}`);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
this.log(`Error checking job '${name}': ${error.message}`);
|
|
136
159
|
}
|
|
137
160
|
}
|
|
138
161
|
}
|
|
@@ -206,8 +229,16 @@ export class Scheduler {
|
|
|
206
229
|
}
|
|
207
230
|
finally {
|
|
208
231
|
job.isRunning = false;
|
|
209
|
-
// Calculate next run time
|
|
210
|
-
|
|
232
|
+
// Calculate next run time; never let an unschedulable cron throw out of
|
|
233
|
+
// the finally block and mask the run's real outcome.
|
|
234
|
+
try {
|
|
235
|
+
job.nextRun =
|
|
236
|
+
getNextRunTime(scheduledMission.mission.schedule, new Date()) ?? undefined;
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
job.nextRun = undefined;
|
|
240
|
+
this.log(`Could not compute next run for '${job.missionName}': ${error.message}`);
|
|
241
|
+
}
|
|
211
242
|
await this.saveState();
|
|
212
243
|
}
|
|
213
244
|
}
|
|
@@ -221,8 +252,9 @@ export class Scheduler {
|
|
|
221
252
|
if (job.consecutiveFailures <= retryConfig.maxRetries) {
|
|
222
253
|
this.log(`Job '${job.missionName}' failed, retrying in ${retryConfig.delaySeconds}s ` +
|
|
223
254
|
`(attempt ${job.consecutiveFailures}/${retryConfig.maxRetries})`);
|
|
224
|
-
// Schedule retry and track the timer to prevent memory leaks
|
|
225
|
-
|
|
255
|
+
// Schedule retry and track the timer to prevent memory leaks. Use a
|
|
256
|
+
// long-delay-safe timer so a large retry delay can't overflow setTimeout.
|
|
257
|
+
const timer = setLongTimeout(() => {
|
|
226
258
|
this.retryTimers.delete(timer);
|
|
227
259
|
const scheduledMission = this.missions.get(job.missionName);
|
|
228
260
|
if (scheduledMission && this.running) {
|
|
@@ -318,7 +350,9 @@ export class Scheduler {
|
|
|
318
350
|
try {
|
|
319
351
|
await mkdir(this.config.stateDir, { recursive: true });
|
|
320
352
|
const statePath = join(this.config.stateDir, 'state.json');
|
|
321
|
-
|
|
353
|
+
// Atomic write (temp file + fsync + rename) so a crash mid-write can't
|
|
354
|
+
// truncate or corrupt state.json.
|
|
355
|
+
await writeFileAtomic(statePath, JSON.stringify(this.state, null, 2));
|
|
322
356
|
}
|
|
323
357
|
catch (error) {
|
|
324
358
|
this.log(`Failed to save scheduler state: ${error.message}`);
|
package/dist/stores/factory.d.ts
CHANGED
|
@@ -16,6 +16,12 @@ export interface CreateStoreOptions {
|
|
|
16
16
|
postgrest?: Omit<PostgRESTOptions, 'table'>;
|
|
17
17
|
/** Logger instance */
|
|
18
18
|
logger?: Logger;
|
|
19
|
+
/**
|
|
20
|
+
* Explicitly allow sql/nosql to fall back to a local file store. Without
|
|
21
|
+
* this, an unimplemented sql/nosql store hard-errors rather than silently
|
|
22
|
+
* writing JSON to disk (a data-loss trap).
|
|
23
|
+
*/
|
|
24
|
+
allowFileFallback?: boolean;
|
|
19
25
|
}
|
|
20
26
|
/**
|
|
21
27
|
* Create a store adapter based on type
|
package/dist/stores/factory.js
CHANGED
|
@@ -40,6 +40,11 @@ export function createStore(options) {
|
|
|
40
40
|
});
|
|
41
41
|
}
|
|
42
42
|
// TODO: Implement raw SQL adapter (pg, mysql2, etc.)
|
|
43
|
+
if (!options.allowFileFallback) {
|
|
44
|
+
throw new Error(`SQL store is not implemented. Configure a PostgREST/Supabase backend, or ` +
|
|
45
|
+
`opt into the local file fallback (enable development mode / allowFileFallback) ` +
|
|
46
|
+
`for store '${options.name}'.`);
|
|
47
|
+
}
|
|
43
48
|
logger.warn(`SQL store not yet implemented, falling back to file store for '${options.name}'`);
|
|
44
49
|
return new FileStore(options.name, {
|
|
45
50
|
...options.fileOptions,
|
|
@@ -47,6 +52,11 @@ export function createStore(options) {
|
|
|
47
52
|
});
|
|
48
53
|
case 'nosql':
|
|
49
54
|
// TODO: Implement NoSQL adapter
|
|
55
|
+
if (!options.allowFileFallback) {
|
|
56
|
+
throw new Error(`NoSQL store is not implemented. Configure a real backend, or opt into the ` +
|
|
57
|
+
`local file fallback (enable development mode / allowFileFallback) for ` +
|
|
58
|
+
`store '${options.name}'.`);
|
|
59
|
+
}
|
|
50
60
|
logger.warn(`NoSQL store not yet implemented, falling back to file store for '${options.name}'`);
|
|
51
61
|
return new FileStore(options.name, {
|
|
52
62
|
...options.fileOptions,
|
|
@@ -60,7 +70,7 @@ export function createStore(options) {
|
|
|
60
70
|
* Map DSL store type to adapter type
|
|
61
71
|
* In development mode, sql/nosql fall back to file stores
|
|
62
72
|
*/
|
|
63
|
-
export function resolveStoreType(dslType, developmentMode =
|
|
73
|
+
export function resolveStoreType(dslType, developmentMode = false) {
|
|
64
74
|
// These types are used directly
|
|
65
75
|
if (dslType === 'memory' || dslType === 'file' || dslType === 'postgrest') {
|
|
66
76
|
return dslType;
|
package/dist/stores/file.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { writeFile } from 'node:fs/promises';
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
3
|
import { join, dirname } from 'node:path';
|
|
4
4
|
import { applyStoreFilter } from './types.js';
|
|
5
|
-
import { ensureDirectory, readJsonFile, serialize, } from '../utils/file.js';
|
|
5
|
+
import { ensureDirectory, readJsonFile, serialize, writeFileAtomic, writeFileAtomicSync, } from '../utils/file.js';
|
|
6
|
+
import { safeJoin } from '../utils/path.js';
|
|
7
|
+
import { deepMerge } from '../utils/deep-merge.js';
|
|
6
8
|
const DEFAULT_OPTIONS = {
|
|
7
9
|
baseDir: '.reqon-data',
|
|
8
10
|
persist: 'immediate',
|
|
@@ -44,7 +46,7 @@ export class FileStore {
|
|
|
44
46
|
*/
|
|
45
47
|
constructor(name, options = {}) {
|
|
46
48
|
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
47
|
-
this.filePath =
|
|
49
|
+
this.filePath = safeJoin(this.options.baseDir, `${name}.json`);
|
|
48
50
|
// Lazy initialization - init() is called on first operation
|
|
49
51
|
}
|
|
50
52
|
/**
|
|
@@ -111,14 +113,14 @@ export class FileStore {
|
|
|
111
113
|
async writeToDisk() {
|
|
112
114
|
const obj = Object.fromEntries(this.data);
|
|
113
115
|
const content = serialize(obj, this.options.pretty);
|
|
114
|
-
await
|
|
116
|
+
await writeFileAtomic(this.filePath, content);
|
|
115
117
|
this.dirty = false;
|
|
116
118
|
}
|
|
117
119
|
/** Synchronous write for flush/close operations */
|
|
118
120
|
writeToDiskSync() {
|
|
119
121
|
const obj = Object.fromEntries(this.data);
|
|
120
122
|
const content = serialize(obj, this.options.pretty);
|
|
121
|
-
|
|
123
|
+
writeFileAtomicSync(this.filePath, content);
|
|
122
124
|
this.dirty = false;
|
|
123
125
|
}
|
|
124
126
|
async get(key) {
|
|
@@ -144,12 +146,7 @@ export class FileStore {
|
|
|
144
146
|
// Upsert all records in memory first (no disk I/O per record)
|
|
145
147
|
for (const { key, value } of records) {
|
|
146
148
|
const existing = this.data.get(key);
|
|
147
|
-
|
|
148
|
-
this.data.set(key, { ...existing, ...value });
|
|
149
|
-
}
|
|
150
|
-
else {
|
|
151
|
-
this.data.set(key, { ...value });
|
|
152
|
-
}
|
|
149
|
+
this.data.set(key, existing ? deepMerge(existing, value) : { ...value });
|
|
153
150
|
}
|
|
154
151
|
// Single persist operation for all records
|
|
155
152
|
await this.persist();
|
|
@@ -157,12 +154,7 @@ export class FileStore {
|
|
|
157
154
|
async update(key, value) {
|
|
158
155
|
await this.ensureInitialized();
|
|
159
156
|
const existing = this.data.get(key);
|
|
160
|
-
|
|
161
|
-
this.data.set(key, { ...existing, ...value });
|
|
162
|
-
}
|
|
163
|
-
else {
|
|
164
|
-
this.data.set(key, value);
|
|
165
|
-
}
|
|
157
|
+
this.data.set(key, existing ? deepMerge(existing, value) : { ...value });
|
|
166
158
|
await this.persist();
|
|
167
159
|
}
|
|
168
160
|
async delete(key) {
|
package/dist/stores/memory.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { applyStoreFilter } from './types.js';
|
|
2
|
+
import { deepMerge } from '../utils/deep-merge.js';
|
|
2
3
|
export class MemoryStore {
|
|
3
4
|
data = new Map();
|
|
4
5
|
name;
|
|
@@ -19,22 +20,12 @@ export class MemoryStore {
|
|
|
19
20
|
async bulkUpsert(records) {
|
|
20
21
|
for (const { key, value } of records) {
|
|
21
22
|
const existing = this.data.get(key);
|
|
22
|
-
|
|
23
|
-
this.data.set(key, { ...existing, ...value });
|
|
24
|
-
}
|
|
25
|
-
else {
|
|
26
|
-
this.data.set(key, { ...value });
|
|
27
|
-
}
|
|
23
|
+
this.data.set(key, existing ? deepMerge(existing, value) : { ...value });
|
|
28
24
|
}
|
|
29
25
|
}
|
|
30
26
|
async update(key, value) {
|
|
31
27
|
const existing = this.data.get(key);
|
|
32
|
-
|
|
33
|
-
this.data.set(key, { ...existing, ...value });
|
|
34
|
-
}
|
|
35
|
-
else {
|
|
36
|
-
this.data.set(key, value);
|
|
37
|
-
}
|
|
28
|
+
this.data.set(key, existing ? deepMerge(existing, value) : { ...value });
|
|
38
29
|
}
|
|
39
30
|
async delete(key) {
|
|
40
31
|
this.data.delete(key);
|