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.
Files changed (122) hide show
  1. package/README.md +23 -3
  2. package/dist/ast/nodes.d.ts +8 -0
  3. package/dist/auth/circuit-breaker.d.ts +11 -0
  4. package/dist/auth/circuit-breaker.js +83 -12
  5. package/dist/auth/credentials.d.ts +6 -1
  6. package/dist/auth/credentials.js +12 -4
  7. package/dist/auth/oauth2-provider.js +13 -3
  8. package/dist/auth/rate-limiter.d.ts +8 -1
  9. package/dist/auth/rate-limiter.js +30 -10
  10. package/dist/auth/token-store.js +8 -1
  11. package/dist/cli.d.ts +11 -1
  12. package/dist/cli.js +65 -6
  13. package/dist/config/constants.d.ts +15 -4
  14. package/dist/config/constants.js +15 -4
  15. package/dist/control/server.d.ts +17 -0
  16. package/dist/control/server.js +82 -5
  17. package/dist/control/types.d.ts +6 -0
  18. package/dist/debug/cli-debugger.js +8 -3
  19. package/dist/execution/store.js +2 -2
  20. package/dist/execution-log/events.d.ts +125 -0
  21. package/dist/execution-log/events.js +17 -0
  22. package/dist/execution-log/fold.d.ts +38 -0
  23. package/dist/execution-log/fold.js +54 -0
  24. package/dist/execution-log/index.d.ts +18 -0
  25. package/dist/execution-log/index.js +6 -0
  26. package/dist/execution-log/postgres-store.d.ts +36 -0
  27. package/dist/execution-log/postgres-store.js +108 -0
  28. package/dist/execution-log/resume.d.ts +11 -0
  29. package/dist/execution-log/resume.js +5 -0
  30. package/dist/execution-log/sqlite-store.d.ts +16 -0
  31. package/dist/execution-log/sqlite-store.js +101 -0
  32. package/dist/execution-log/store.d.ts +72 -0
  33. package/dist/execution-log/store.js +182 -0
  34. package/dist/index.d.ts +4 -3
  35. package/dist/index.js +4 -3
  36. package/dist/interpreter/context.d.ts +15 -0
  37. package/dist/interpreter/context.js +3 -0
  38. package/dist/interpreter/evaluator.js +38 -8
  39. package/dist/interpreter/executor.d.ts +63 -1
  40. package/dist/interpreter/executor.js +406 -30
  41. package/dist/interpreter/fetch-handler.d.ts +39 -1
  42. package/dist/interpreter/fetch-handler.js +84 -15
  43. package/dist/interpreter/http.d.ts +31 -2
  44. package/dist/interpreter/http.js +187 -26
  45. package/dist/interpreter/index.d.ts +3 -3
  46. package/dist/interpreter/index.js +3 -3
  47. package/dist/interpreter/pagination.d.ts +1 -1
  48. package/dist/interpreter/pagination.js +7 -1
  49. package/dist/interpreter/step-handlers/for-handler.d.ts +3 -0
  50. package/dist/interpreter/step-handlers/for-handler.js +18 -3
  51. package/dist/interpreter/step-handlers/match-handler.js +5 -2
  52. package/dist/interpreter/step-handlers/store-handler.d.ts +7 -1
  53. package/dist/interpreter/step-handlers/store-handler.js +25 -16
  54. package/dist/interpreter/step-handlers/validate-handler.js +4 -1
  55. package/dist/interpreter/step-handlers/webhook-handler.d.ts +1 -0
  56. package/dist/interpreter/step-handlers/webhook-handler.js +13 -3
  57. package/dist/interpreter/store-manager.d.ts +1 -1
  58. package/dist/interpreter/store-manager.js +5 -1
  59. package/dist/loader/index.js +5 -8
  60. package/dist/mcp/sandbox.d.ts +41 -0
  61. package/dist/mcp/sandbox.js +76 -0
  62. package/dist/mcp/server.js +62 -9
  63. package/dist/oas/loader.d.ts +13 -1
  64. package/dist/oas/loader.js +25 -3
  65. package/dist/oas/mock-generator.js +13 -4
  66. package/dist/oas/validator.js +45 -5
  67. package/dist/observability/events.d.ts +6 -2
  68. package/dist/observability/events.js +0 -5
  69. package/dist/observability/logger.js +17 -10
  70. package/dist/observability/otel.d.ts +8 -0
  71. package/dist/observability/otel.js +45 -10
  72. package/dist/parser/action-parser.js +2 -2
  73. package/dist/parser/base.d.ts +7 -0
  74. package/dist/parser/base.js +11 -0
  75. package/dist/parser/expressions.d.ts +1 -0
  76. package/dist/parser/expressions.js +17 -4
  77. package/dist/parser/fetch-parser.js +13 -2
  78. package/dist/pause/index.d.ts +1 -0
  79. package/dist/pause/index.js +1 -0
  80. package/dist/pause/log-store.d.ts +33 -0
  81. package/dist/pause/log-store.js +98 -0
  82. package/dist/pause/manager.d.ts +12 -0
  83. package/dist/pause/manager.js +77 -28
  84. package/dist/pause/store.js +5 -3
  85. package/dist/scheduler/cron-parser.d.ts +10 -3
  86. package/dist/scheduler/cron-parser.js +227 -48
  87. package/dist/scheduler/scheduler.js +56 -22
  88. package/dist/stores/factory.d.ts +6 -0
  89. package/dist/stores/factory.js +11 -1
  90. package/dist/stores/file.js +9 -17
  91. package/dist/stores/memory.js +3 -12
  92. package/dist/stores/postgrest.d.ts +28 -0
  93. package/dist/stores/postgrest.js +84 -37
  94. package/dist/sync/index.d.ts +3 -2
  95. package/dist/sync/index.js +2 -1
  96. package/dist/sync/log-store.d.ts +30 -0
  97. package/dist/sync/log-store.js +45 -0
  98. package/dist/sync/store.js +1 -1
  99. package/dist/trace/index.d.ts +2 -0
  100. package/dist/trace/index.js +1 -0
  101. package/dist/trace/log-view.d.ts +57 -0
  102. package/dist/trace/log-view.js +76 -0
  103. package/dist/trace/recorder.d.ts +5 -1
  104. package/dist/trace/recorder.js +19 -6
  105. package/dist/trace/store.d.ts +6 -0
  106. package/dist/trace/store.js +47 -22
  107. package/dist/utils/deep-merge.d.ts +10 -0
  108. package/dist/utils/deep-merge.js +23 -0
  109. package/dist/utils/file.d.ts +13 -4
  110. package/dist/utils/file.js +70 -12
  111. package/dist/utils/index.d.ts +1 -1
  112. package/dist/utils/index.js +1 -1
  113. package/dist/utils/long-timeout.d.ts +19 -0
  114. package/dist/utils/long-timeout.js +33 -0
  115. package/dist/utils/path.d.ts +22 -1
  116. package/dist/utils/path.js +46 -1
  117. package/dist/utils/redact.d.ts +22 -0
  118. package/dist/utils/redact.js +42 -0
  119. package/dist/webhook/server.d.ts +9 -0
  120. package/dist/webhook/server.js +115 -30
  121. package/dist/webhook/types.d.ts +9 -1
  122. 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), lists (1,3,5), and wildcards (*)
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
- function parseField(field, min, max) {
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('-').map((n) => parseInt(n, 10));
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 = parseInt(range, 10);
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 [start, end] = part.split('-').map((n) => parseInt(n, 10));
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(parseInt(part, 10));
87
+ values.add(toInt(part, 'value'));
59
88
  }
60
89
  }
61
- // Validate all values are in range
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
- if (value < min || value > max) {
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(values).sort((a, b) => a - b);
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
- * Calculate the next run time for a cron schedule
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
- export function getNextCronRun(schedule, after = new Date()) {
73
- const next = new Date(after);
74
- next.setSeconds(0, 0);
75
- next.setMinutes(next.getMinutes() + 1); // Start from next minute
76
- // Try up to 4 years to find a match
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
- // Check month
80
- if (!schedule.month.includes(next.getMonth() + 1)) {
81
- // Move to first day of next matching month
82
- next.setMonth(next.getMonth() + 1);
83
- next.setDate(1);
84
- next.setHours(0, 0, 0, 0);
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
- // Check day of month
88
- if (!schedule.dayOfMonth.includes(next.getDate())) {
89
- next.setDate(next.getDate() + 1);
90
- next.setHours(0, 0, 0, 0);
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
- // Check day of week
94
- if (!schedule.dayOfWeek.includes(next.getDay())) {
95
- next.setDate(next.getDate() + 1);
96
- next.setHours(0, 0, 0, 0);
229
+ if (!matchesDay(schedule, wc)) {
230
+ wc.day += 1;
231
+ wc.hour = 0;
232
+ wc.minute = 0;
97
233
  continue;
98
234
  }
99
- // Check hour
100
- if (!schedule.hour.includes(next.getHours())) {
101
- next.setHours(next.getHours() + 1);
102
- next.setMinutes(0, 0, 0);
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
- // Check minute
106
- if (!schedule.minute.includes(next.getMinutes())) {
107
- next.setMinutes(next.getMinutes() + 1);
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
- // Found a match!
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
- const nextRun = getNextCronRun(cronSchedule, lastRun ?? new Date(0));
182
- // Check if we're within the check interval of the next run time
183
- const diff = Math.abs(now.getTime() - nextRun.getTime());
184
- return diff <= checkIntervalMs;
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
- const diff = Math.abs(now.getTime() - runAt.getTime());
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, writeFile, mkdir } from 'node:fs/promises';
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
- const nextRun = getNextRunTime(mission.schedule);
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
- clearTimeout(timer);
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
- if (!job.enabled)
114
- continue;
115
- const scheduledMission = this.missions.get(name);
116
- if (!scheduledMission)
117
- continue;
118
- const schedule = scheduledMission.mission.schedule;
119
- if (!schedule)
120
- continue; // Mission must have schedule
121
- // Check if job should run
122
- if (shouldRunNow(schedule, job.lastRun, this.config.checkInterval)) {
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 && (schedule.skipIfRunning !== false)) {
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
- // Run the job
135
- await this.runJob(job, scheduledMission);
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
- job.nextRun = getNextRunTime(scheduledMission.mission.schedule, new Date()) ?? undefined;
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
- const timer = setTimeout(() => {
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
- await writeFile(statePath, JSON.stringify(this.state, null, 2));
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}`);
@@ -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
@@ -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 = true) {
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;
@@ -1,8 +1,10 @@
1
1
  import { writeFile } from 'node:fs/promises';
2
- import { writeFileSync, existsSync } from 'node:fs';
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 = join(this.options.baseDir, `${name}.json`);
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 writeFile(this.filePath, content, 'utf-8');
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
- writeFileSync(this.filePath, content, 'utf-8');
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
- if (existing) {
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
- if (existing) {
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) {
@@ -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
- if (existing) {
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
- if (existing) {
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);