@dbos-inc/dbos-sdk 5.1.10 → 5.2.4-preview

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 (47) hide show
  1. package/dist/schemas/system_db_schema.d.ts +2 -1
  2. package/dist/schemas/system_db_schema.d.ts.map +1 -1
  3. package/dist/schemas/system_db_schema.js.map +1 -1
  4. package/dist/src/client.d.ts +3 -1
  5. package/dist/src/client.d.ts.map +1 -1
  6. package/dist/src/client.js +5 -4
  7. package/dist/src/client.js.map +1 -1
  8. package/dist/src/config.d.ts.map +1 -1
  9. package/dist/src/config.js +2 -0
  10. package/dist/src/config.js.map +1 -1
  11. package/dist/src/context.d.ts +3 -0
  12. package/dist/src/context.d.ts.map +1 -1
  13. package/dist/src/context.js +13 -1
  14. package/dist/src/context.js.map +1 -1
  15. package/dist/src/dbos-executor.d.ts +15 -2
  16. package/dist/src/dbos-executor.d.ts.map +1 -1
  17. package/dist/src/dbos-executor.js +33 -32
  18. package/dist/src/dbos-executor.js.map +1 -1
  19. package/dist/src/dbos.d.ts.map +1 -1
  20. package/dist/src/dbos.js +5 -0
  21. package/dist/src/dbos.js.map +1 -1
  22. package/dist/src/error.d.ts +8 -0
  23. package/dist/src/error.d.ts.map +1 -1
  24. package/dist/src/error.js +17 -2
  25. package/dist/src/error.js.map +1 -1
  26. package/dist/src/index.d.ts +1 -1
  27. package/dist/src/index.d.ts.map +1 -1
  28. package/dist/src/index.js +2 -1
  29. package/dist/src/index.js.map +1 -1
  30. package/dist/src/scheduler/crontab.d.ts +9 -2
  31. package/dist/src/scheduler/crontab.d.ts.map +1 -1
  32. package/dist/src/scheduler/crontab.js +418 -181
  33. package/dist/src/scheduler/crontab.js.map +1 -1
  34. package/dist/src/scheduler/scheduler.d.ts.map +1 -1
  35. package/dist/src/scheduler/scheduler.js +14 -8
  36. package/dist/src/scheduler/scheduler.js.map +1 -1
  37. package/dist/src/sysdb_migrations/internal/migrations.js +5 -0
  38. package/dist/src/sysdb_migrations/internal/migrations.js.map +1 -1
  39. package/dist/src/system_database.d.ts +30 -14
  40. package/dist/src/system_database.d.ts.map +1 -1
  41. package/dist/src/system_database.js +291 -131
  42. package/dist/src/system_database.js.map +1 -1
  43. package/dist/src/wfqueue.d.ts.map +1 -1
  44. package/dist/src/wfqueue.js +12 -8
  45. package/dist/src/wfqueue.js.map +1 -1
  46. package/dist/tsconfig.tsbuildinfo +1 -1
  47. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
- // This code was based on code from node-cron:
2
+ // This code was based on code from node-cron 4.6.0:
3
3
  // https://github.com/node-cron/node-cron
4
4
  /*
5
5
  ISC License
@@ -18,7 +18,19 @@ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
18
18
  OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
19
19
  */
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.TimeMatcher = exports.convertExpression = exports.validateTimezone = exports.validateCrontab = void 0;
21
+ exports.TimeMatcher = exports.validateTimezone = exports.validateCrontab = exports.convertExpression = void 0;
22
+ const NICKNAMES = {
23
+ '@yearly': '0 0 1 1 *',
24
+ '@annually': '0 0 1 1 *',
25
+ '@monthly': '0 0 1 * *',
26
+ '@weekly': '0 0 * * 0',
27
+ '@daily': '0 0 * * *',
28
+ '@midnight': '0 0 * * *',
29
+ '@hourly': '0 * * * *',
30
+ };
31
+ function resolveNickname(expression) {
32
+ return NICKNAMES[expression.trim().toLowerCase()] ?? expression;
33
+ }
22
34
  function removeExtraSpaces(str) {
23
35
  return str.replace(/\s{2,}/g, ' ').trim();
24
36
  }
@@ -28,6 +40,14 @@ function prependSecondExpression(expressions) {
28
40
  }
29
41
  return expressions;
30
42
  }
43
+ // `?` is an alias for `*`, accepted only as a whole day-of-month or day-of-week field
44
+ function convertQuestionMarks(expressions) {
45
+ if (expressions[3] === '?')
46
+ expressions[3] = '*';
47
+ if (expressions[5] === '?')
48
+ expressions[5] = '*';
49
+ return expressions;
50
+ }
31
51
  const months = [
32
52
  'january',
33
53
  'february',
@@ -63,29 +83,15 @@ function convertWeekDayName(weekExpression, items) {
63
83
  return weekExpression;
64
84
  }
65
85
  function weekDayNamesConversion(expression) {
66
- // Don't fold 7 (Sunday) into 0 here: pre-expansion it corrupts ranges like 5-7. See convertSundaySeven.
67
86
  expression = convertWeekDayName(expression, weekDays);
68
87
  return convertWeekDayName(expression, shortWeekDays);
69
88
  }
70
- // Fold day-of-week 7 (Sunday) into 0 after range/step expansion, removing resulting duplicates.
71
- function convertSundaySeven(expressions) {
72
- const seen = new Set();
73
- const folded = [];
74
- for (const value of expressions[5].split(',')) {
75
- const day = value === '7' ? '0' : value;
76
- if (!seen.has(day)) {
77
- seen.add(day);
78
- folded.push(day);
79
- }
80
- }
81
- expressions[5] = folded.join(',');
82
- return expressions;
83
- }
89
+ // Converts every `*` token in a comma list, not just the first
84
90
  function convertAsterisk(expression, replacement) {
85
- if (expression.indexOf('*') !== -1) {
86
- return expression.replace('*', replacement);
87
- }
88
- return expression;
91
+ return expression
92
+ .split(',')
93
+ .map((token) => (token.indexOf('*') !== -1 ? token.replace('*', replacement) : token))
94
+ .join(',');
89
95
  }
90
96
  // Based on position in full crontab, convert asterisk to appropriate range
91
97
  function convertAsterisksToRanges(expressions) {
@@ -97,98 +103,193 @@ function convertAsterisksToRanges(expressions) {
97
103
  expressions[5] = convertAsterisk(expressions[5], '0-6');
98
104
  return expressions;
99
105
  }
100
- function replaceWithRange(expression, text, init, end) {
106
+ // Only whole `n-n` or `n-n/step` tokens are expanded; malformed ones are left for validation to reject
107
+ const rangeRegEx = /^(\d+)-(\d+)(?:\/(\d+))?$/;
108
+ const FIELD_BOUNDS = [
109
+ { min: 0, max: 59 },
110
+ { min: 0, max: 59 },
111
+ { min: 0, max: 23 },
112
+ { min: 1, max: 31 },
113
+ { min: 1, max: 12 },
114
+ { min: 0, max: 6 },
115
+ ];
116
+ function expandRange(initTxt, endTxt, stepTxt, bounds) {
117
+ const step = parseInt(stepTxt, 10);
118
+ // A non-positive step would never terminate; leave the token for validation to reject.
119
+ if (!(step >= 1))
120
+ return `${initTxt}-${endTxt}/${stepTxt}`;
121
+ const first = parseInt(initTxt, 10);
122
+ const last = parseInt(endTxt, 10);
101
123
  const numbers = [];
102
- let last = parseInt(end);
103
- let first = parseInt(init);
104
- if (first > last) {
105
- last = parseInt(init);
106
- first = parseInt(end);
107
- }
108
- for (let i = first; i <= last; i++) {
109
- numbers.push(i.toString());
124
+ if (first <= last) {
125
+ for (let i = first; i <= last; i += step) {
126
+ numbers.push(i);
127
+ }
128
+ return numbers.join();
110
129
  }
111
- return expression.replace(new RegExp(text, 'i'), numbers.join());
112
- }
113
- function convertRange(expression) {
114
- const rangeRegEx = /(\d+)-(\d+)/;
115
- let match = rangeRegEx.exec(expression);
116
- while (match !== null && match.length > 0) {
117
- expression = replaceWithRange(expression, match[0], match[1], match[2]);
118
- match = rangeRegEx.exec(expression);
130
+ // An inverted range wraps through the field's upper bound (e.g. hours `22-2` -> 22,23,0,1,2)
131
+ const { min, max } = bounds;
132
+ const size = max - min + 1;
133
+ const span = (((last - first) % size) + size) % size;
134
+ for (let offset = 0; offset <= span; offset += step) {
135
+ let value = first + offset;
136
+ if (value > max)
137
+ value -= size;
138
+ numbers.push(value);
119
139
  }
120
- return expression;
140
+ return numbers.join();
141
+ }
142
+ function convertRange(expression, bounds) {
143
+ return expression
144
+ .split(',')
145
+ .map((token) => {
146
+ const match = rangeRegEx.exec(token.trim());
147
+ return match ? expandRange(match[1], match[2], match[3] || '1', bounds) : token;
148
+ })
149
+ .join();
121
150
  }
122
151
  function convertAllRanges(expressions) {
123
152
  for (let i = 0; i < expressions.length; i++) {
124
- expressions[i] = convertRange(expressions[i]);
153
+ expressions[i] = convertRange(expressions[i], FIELD_BOUNDS[i]);
125
154
  }
126
155
  return expressions;
127
156
  }
128
- function convertSteps(expressions) {
129
- const stepValuePattern = /^(.+)\/(\w+)$/;
130
- for (let i = 0; i < expressions.length; i++) {
131
- const match = stepValuePattern.exec(expressions[i]);
132
- const isStepValue = match !== null && match.length > 0;
133
- if (isStepValue) {
134
- const baseDivider = match[2];
135
- if (isNaN(parseInt(baseDivider))) {
136
- throw new Error(baseDivider + ' is not a valid step value');
137
- }
138
- const values = match[1].split(',');
139
- const stepValues = [];
140
- const divider = parseInt(baseDivider, 10);
141
- for (let j = 0; j <= values.length; j++) {
142
- const value = parseInt(values[j], 10);
143
- if (value % divider === 0) {
144
- stepValues.push(`${value}`);
145
- }
146
- }
147
- expressions[i] = stepValues.join(',');
148
- }
149
- }
150
- return expressions;
151
- }
152
- // Function that takes care of normalization.
157
+ // Parses integers, keeping the L / L-n / nW / LW / nL / n#m tokens as uppercase literals
153
158
  function normalizeIntegers(expressions) {
154
- for (let i = 0; i < expressions.length; i++) {
155
- const numbers = expressions[i].split(',');
156
- for (let j = 0; j < numbers.length; j++) {
157
- numbers[j] = parseInt(numbers[j]).toString();
159
+ return expressions.map((expression) => expression.split(',').map((raw) => {
160
+ const token = raw.trim();
161
+ if (/^\d+$/.test(token))
162
+ return parseInt(token, 10);
163
+ if (/^l$/i.test(token) || /^l-\d{1,2}$/i.test(token) || /^[0-7]l$/i.test(token) || /w/i.test(token)) {
164
+ return token.toUpperCase();
158
165
  }
159
- expressions[i] = numbers.join(',');
160
- }
161
- return expressions;
166
+ // Anything else is kept verbatim so validation rejects it rather than parseInt truncating it
167
+ return token;
168
+ }));
169
+ }
170
+ /*
171
+ * Converts a crontab into six arrays of allowed values, translating month and week day names,
172
+ * asterisks, ranges and steps into integers.
173
+ *
174
+ * Month names example:
175
+ * - expression 0 1 1 January,Sep *
176
+ * - Will be translated to 0 1 1 1,9 *
177
+ *
178
+ * Week day names example:
179
+ * - expression 0 1 1 2 Monday,Sat
180
+ * - Will be translated to 0 1 1 1,5 *
181
+ *
182
+ * Ranges example:
183
+ * - expression 1-5 * * * *
184
+ * - Will be translated to 1,2,3,4,5 * * * *
185
+ */
186
+ function convertExpression(crontab) {
187
+ let expressions = removeExtraSpaces(resolveNickname(crontab)).split(' ');
188
+ expressions = prependSecondExpression(expressions);
189
+ expressions = convertQuestionMarks(expressions);
190
+ expressions[4] = monthNamesConversion(expressions[4]);
191
+ expressions[5] = weekDayNamesConversion(expressions[5]);
192
+ expressions = convertAsterisksToRanges(expressions);
193
+ expressions = convertAllRanges(expressions);
194
+ const fields = normalizeIntegers(expressions);
195
+ // Fold day-of-week 7 (Sunday) into 0 only after range expansion, so ranges like 5-7 survive
196
+ fields[5] = [
197
+ ...new Set(fields[5].map((value) => {
198
+ if (value === 7)
199
+ return 0;
200
+ if (typeof value === 'string' && value.startsWith('7'))
201
+ return '0' + value.slice(1);
202
+ return value;
203
+ })),
204
+ ];
205
+ return fields;
162
206
  }
207
+ exports.convertExpression = convertExpression;
208
+ //////////
209
+ /// Validation
210
+ //////////
163
211
  const validationRegex = /^(?:\d+|\*|\*\/\d+)$/;
164
- // Check comma-delimited list to see if elements are in range
165
- function isValidExpression(expression, min, max) {
166
- const options = expression.split(',');
167
- for (const option of options) {
168
- const optionAsInt = parseInt(option, 10);
169
- if ((!Number.isNaN(optionAsInt) && (optionAsInt < min || optionAsInt > max)) || !validationRegex.test(option)) {
212
+ // `#` is allowed for the day-of-week `n#m` token and `?` for the day-field alias
213
+ const ALLOWED_CHARS_REGEX = /^[a-zA-Z0-9-*/,#? ]+$/;
214
+ // Check a field's values to see if they are in range
215
+ function isValidExpression(values, min, max) {
216
+ for (const value of values) {
217
+ const valueAsInt = parseInt(`${value}`, 10);
218
+ if ((!Number.isNaN(valueAsInt) && (valueAsInt < min || valueAsInt > max)) || !validationRegex.test(`${value}`)) {
170
219
  return false;
171
220
  }
172
221
  }
173
222
  return true;
174
223
  }
175
- function isInvalidSecond(expression) {
176
- return !isValidExpression(expression, 0, 59);
224
+ function isInvalidSecond(values) {
225
+ return !isValidExpression(values, 0, 59);
177
226
  }
178
- function isInvalidMinute(expression) {
179
- return !isValidExpression(expression, 0, 59);
227
+ function isInvalidMinute(values) {
228
+ return !isValidExpression(values, 0, 59);
180
229
  }
181
- function isInvalidHour(expression) {
182
- return !isValidExpression(expression, 0, 23);
230
+ function isInvalidHour(values) {
231
+ return !isValidExpression(values, 0, 23);
183
232
  }
184
- function isInvalidDayOfMonth(expression) {
185
- return !isValidExpression(expression, 1, 31);
233
+ const DAY_OF_MONTH_W_TOKEN = /^(\d{1,2}|L)W$/;
234
+ const DAY_OF_MONTH_OFFSET_TOKEN = /^L-(\d{1,2})$/;
235
+ function isInvalidDayOfMonth(values) {
236
+ // L, LW, nW (1-31) and L-n (1-30) are valid only in this field; everything else must be a day number
237
+ const days = values.filter((value) => {
238
+ if (value === 'L')
239
+ return false;
240
+ const weekday = DAY_OF_MONTH_W_TOKEN.exec(`${value}`);
241
+ if (weekday) {
242
+ if (weekday[1] === 'L')
243
+ return false;
244
+ const target = parseInt(weekday[1], 10);
245
+ return target < 1 || target > 31;
246
+ }
247
+ const offset = DAY_OF_MONTH_OFFSET_TOKEN.exec(`${value}`);
248
+ if (offset) {
249
+ const n = parseInt(offset[1], 10);
250
+ return n < 1 || n > 30;
251
+ }
252
+ return true;
253
+ });
254
+ return !isValidExpression(days, 1, 31);
186
255
  }
187
- function isInvalidMonth(expression) {
188
- return !isValidExpression(expression, 1, 12);
256
+ // `W` is only valid on a single day (`15W`) or `L` (`LW`); checked before range expansion hides misuse like `1-15W`
257
+ function hasInvalidWModifier(rawDayOfMonth) {
258
+ if (!/w/i.test(rawDayOfMonth))
259
+ return false;
260
+ return rawDayOfMonth.split(',').some((token) => {
261
+ const value = token.trim();
262
+ return /w/i.test(value) && !/^(\d{1,2}|L)W$/i.test(value);
263
+ });
189
264
  }
190
- function isInvalidWeekDay(expression) {
191
- return !isValidExpression(expression, 0, 7);
265
+ function isInvalidMonth(values) {
266
+ return !isValidExpression(values, 1, 12);
267
+ }
268
+ function isInvalidWeekDay(values) {
269
+ // `n#m` (mth n-day of the month) and `nL` (last n-day of the month) are valid only in this field
270
+ const days = values.filter((value) => !isNthWeekdayToken(value) && !/^[0-7]L$/.test(`${value}`));
271
+ return !isValidExpression(days, 0, 7);
272
+ }
273
+ // The last day each month can ever reach, counting February as 29
274
+ const MAX_DAYS_IN_MONTH = {
275
+ 1: 31,
276
+ 2: 29,
277
+ 3: 31,
278
+ 4: 30,
279
+ 5: 31,
280
+ 6: 30,
281
+ 7: 31,
282
+ 8: 31,
283
+ 9: 30,
284
+ 10: 31,
285
+ 11: 30,
286
+ 12: 31,
287
+ };
288
+ // True when no listed day exists in any listed month (e.g. `30 2`), ignoring date-dependent tokens
289
+ function isImpossibleDayOfMonth(days, months) {
290
+ if (days.some((day) => typeof day !== 'number'))
291
+ return false;
292
+ return !months.some((month) => days.some((day) => day <= MAX_DAYS_IN_MONTH[month]));
192
293
  }
193
294
  function validateFields(patterns, executablePatterns) {
194
295
  if (isInvalidSecond(executablePatterns[0]))
@@ -197,26 +298,35 @@ function validateFields(patterns, executablePatterns) {
197
298
  throw new Error(`${patterns[1]} is a invalid expression for minute`);
198
299
  if (isInvalidHour(executablePatterns[2]))
199
300
  throw new Error(`${patterns[2]} is a invalid expression for hour`);
200
- if (isInvalidDayOfMonth(executablePatterns[3]))
301
+ if (isInvalidDayOfMonth(executablePatterns[3]) || hasInvalidWModifier(patterns[3]))
201
302
  throw new Error(`${patterns[3]} is a invalid expression for day of month`);
202
303
  if (isInvalidMonth(executablePatterns[4]))
203
304
  throw new Error(`${patterns[4]} is a invalid expression for month`);
204
305
  if (isInvalidWeekDay(executablePatterns[5]))
205
306
  throw new Error(`${patterns[5]} is a invalid expression for week day`);
307
+ if (isImpossibleDayOfMonth(executablePatterns[3], executablePatterns[4]))
308
+ throw new Error(`${patterns[3]} ${patterns[4]} is an impossible day of month for the given month`);
309
+ }
310
+ function parseCrontab(pattern) {
311
+ if (typeof pattern !== 'string')
312
+ throw new TypeError('pattern must be a string!');
313
+ const resolved = resolveNickname(pattern);
314
+ if (!ALLOWED_CHARS_REGEX.test(resolved))
315
+ throw new TypeError('pattern includes illegal characters!');
316
+ const raw = removeExtraSpaces(resolved).split(' ');
317
+ if (raw.length !== 5 && raw.length !== 6)
318
+ throw new Error(`expected 5 or 6 fields but got ${raw.length}`);
319
+ const patterns = raw.length === 5 ? ['0', ...raw] : raw;
320
+ const executablePatterns = convertExpression(resolved);
321
+ validateFields(patterns, executablePatterns);
322
+ return executablePatterns;
206
323
  }
207
324
  /**
208
325
  * Validates a Cron-Job expression pattern.
209
326
  * Throws on error.
210
327
  */
211
328
  function validateCrontab(pattern) {
212
- if (typeof pattern !== 'string')
213
- throw new TypeError('pattern must be a string!');
214
- const patterns = pattern.split(' ');
215
- const executablePatterns = convertExpression(pattern).split(' ');
216
- if (patterns.length === 5)
217
- patterns.unshift('0');
218
- validateFields(patterns, executablePatterns);
219
- return executablePatterns.join(' ');
329
+ parseCrontab(pattern);
220
330
  }
221
331
  exports.validateCrontab = validateCrontab;
222
332
  /**
@@ -232,103 +342,230 @@ function validateTimezone(timezone) {
232
342
  }
233
343
  }
234
344
  exports.validateTimezone = validateTimezone;
235
- /*
236
- * The node-cron core allows only numbers (including multiple numbers e.g 1,2).
237
- * This module is going to translate the month names, week day names and ranges
238
- * to integers relatives.
239
- *
240
- * Month names example:
241
- * - expression 0 1 1 January,Sep *
242
- * - Will be translated to 0 1 1 1,9 *
243
- *
244
- * Week day names example:
245
- * - expression 0 1 1 2 Monday,Sat
246
- * - Will be translated to 0 1 1 1,5 *
247
- *
248
- * Ranges example:
249
- * - expression 1-5 * * * *
250
- * - Will be translated to 1,2,3,4,5 * * * *
251
- */
252
- function convertExpression(crontab) {
253
- let expressions = removeExtraSpaces(crontab).split(' ');
254
- expressions = prependSecondExpression(expressions);
255
- expressions[4] = monthNamesConversion(expressions[4]);
256
- expressions[5] = weekDayNamesConversion(expressions[5]);
257
- expressions = convertAsterisksToRanges(expressions);
258
- expressions = convertAllRanges(expressions);
259
- expressions = convertSteps(expressions);
260
- expressions = normalizeIntegers(expressions);
261
- expressions = convertSundaySeven(expressions);
262
- return expressions.join(' ');
263
- }
264
- exports.convertExpression = convertExpression;
265
345
  //////////
266
- /// Time matcher
346
+ /// Day matching
267
347
  //////////
268
- function matchPattern(pattern, nvalue) {
269
- const value = `${nvalue}`;
270
- if (pattern.indexOf(',') !== -1) {
271
- const patterns = pattern.split(',');
272
- return patterns.includes(value);
348
+ function weekdayOf(year, month, day) {
349
+ return new Date(Date.UTC(year, month - 1, day)).getUTCDay();
350
+ }
351
+ function lastDayOfMonth(year, month) {
352
+ return new Date(Date.UTC(year, month, 0)).getUTCDate();
353
+ }
354
+ // The nearest weekday (Mon-Fri) to `target` without leaving the month, or -1 if the month has no such day
355
+ function nearestWeekday(year, month, target) {
356
+ const last = lastDayOfMonth(year, month);
357
+ if (target < 1 || target > last)
358
+ return -1;
359
+ const weekday = weekdayOf(year, month, target);
360
+ if (weekday === 6)
361
+ return target === 1 ? target + 2 : target - 1;
362
+ if (weekday === 0)
363
+ return target === last ? target - 2 : target + 1;
364
+ return target;
365
+ }
366
+ function matchesDayOfMonth(field, year, month, day) {
367
+ for (const value of field) {
368
+ if (value === day)
369
+ return true;
370
+ if (typeof value !== 'string')
371
+ continue;
372
+ if (value === 'L' && day === lastDayOfMonth(year, month))
373
+ return true;
374
+ const weekdayMatch = DAY_OF_MONTH_W_TOKEN.exec(value);
375
+ if (weekdayMatch) {
376
+ const target = weekdayMatch[1] === 'L' ? lastDayOfMonth(year, month) : parseInt(weekdayMatch[1], 10);
377
+ if (nearestWeekday(year, month, target) === day)
378
+ return true;
379
+ }
380
+ const offsetMatch = DAY_OF_MONTH_OFFSET_TOKEN.exec(value);
381
+ if (offsetMatch) {
382
+ const target = lastDayOfMonth(year, month) - parseInt(offsetMatch[1], 10);
383
+ if (target >= 1 && target === day)
384
+ return true;
385
+ }
273
386
  }
274
- return pattern === value;
387
+ return false;
275
388
  }
389
+ const LAST_WEEKDAY_REGEX = /^([0-7])L$/;
390
+ const NTH_WEEKDAY_REGEX = /^([0-7])#([1-5])$/;
391
+ function isNthWeekdayToken(value) {
392
+ return typeof value === 'string' && NTH_WEEKDAY_REGEX.test(value);
393
+ }
394
+ function matchesDayOfWeek(field, year, month, day) {
395
+ const weekday = weekdayOf(year, month, day);
396
+ for (const value of field) {
397
+ if (value === weekday)
398
+ return true;
399
+ if (typeof value !== 'string')
400
+ continue;
401
+ const nth = NTH_WEEKDAY_REGEX.exec(value);
402
+ if (nth) {
403
+ if (parseInt(nth[1], 10) % 7 === weekday && Math.floor((day - 1) / 7) + 1 === parseInt(nth[2], 10))
404
+ return true;
405
+ continue;
406
+ }
407
+ const last = LAST_WEEKDAY_REGEX.exec(value);
408
+ if (last && parseInt(last[1], 10) % 7 === weekday && day + 7 > lastDayOfMonth(year, month))
409
+ return true;
410
+ }
411
+ return false;
412
+ }
413
+ const HOUR_MS = 60 * 60 * 1000;
414
+ const DAY_MS = 24 * HOUR_MS;
415
+ // Constructing a DateTimeFormat is expensive, so reuse one per timezone
416
+ const formatters = new Map();
417
+ function getFormatter(timezone) {
418
+ const key = timezone ?? '';
419
+ let formatter = formatters.get(key);
420
+ if (!formatter) {
421
+ formatter = new Intl.DateTimeFormat('en-US', {
422
+ year: 'numeric',
423
+ month: 'numeric',
424
+ day: 'numeric',
425
+ hour: 'numeric',
426
+ minute: 'numeric',
427
+ second: 'numeric',
428
+ hourCycle: 'h23',
429
+ timeZone: timezone,
430
+ });
431
+ formatters.set(key, formatter);
432
+ }
433
+ return formatter;
434
+ }
435
+ function wallClock(ms, timezone) {
436
+ const parts = {};
437
+ for (const part of getFormatter(timezone).formatToParts(ms)) {
438
+ if (part.type !== 'literal')
439
+ parts[part.type] = parseInt(part.value, 10);
440
+ }
441
+ return {
442
+ year: parts.year,
443
+ month: parts.month,
444
+ day: parts.day,
445
+ hour: parts.hour % 24,
446
+ minute: parts.minute,
447
+ second: parts.second,
448
+ };
449
+ }
450
+ // The timezone's UTC offset (local minus UTC) in effect at the given instant
451
+ function offsetAt(ms, timezone) {
452
+ const w = wallClock(ms, timezone);
453
+ return Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second) - Math.floor(ms / 1000) * 1000;
454
+ }
455
+ // A century is far beyond any real recurrence, so a schedule not found within it never fires
456
+ const MAX_SEARCH_DAYS = 366 * 100;
276
457
  class TimeMatcher {
277
- #pattern;
278
- #expressions;
279
- #dtf;
280
458
  #timezone;
459
+ #seconds;
460
+ #minutes;
461
+ #hours;
462
+ #daysOfMonth;
463
+ #months;
464
+ #daysOfWeek;
465
+ #dayOffsets;
281
466
  constructor(pattern, timezone) {
282
- validateCrontab(pattern);
283
- this.#pattern = convertExpression(pattern);
284
- this.#timezone = timezone;
285
- this.#expressions = this.#pattern.split(' ');
286
- this.#dtf = this.#timezone
287
- ? new Intl.DateTimeFormat('en-US', {
288
- year: 'numeric',
289
- month: '2-digit',
290
- day: '2-digit',
291
- hour: '2-digit',
292
- minute: '2-digit',
293
- second: '2-digit',
294
- hourCycle: 'h23',
295
- fractionalSecondDigits: 3,
296
- timeZone: this.#timezone,
297
- })
298
- : null;
467
+ const fields = parseCrontab(pattern);
468
+ // An empty timezone means the process's local timezone
469
+ this.#timezone = timezone || undefined;
470
+ // Fail fast on an invalid timezone
471
+ getFormatter(this.#timezone);
472
+ this.#seconds = [...fields[0]].sort((a, b) => a - b);
473
+ this.#minutes = [...fields[1]].sort((a, b) => a - b);
474
+ this.#hours = [...fields[2]].sort((a, b) => a - b);
475
+ this.#daysOfMonth = fields[3];
476
+ this.#months = fields[4];
477
+ this.#daysOfWeek = fields[5];
299
478
  }
300
479
  match(date) {
301
- date = this.#apply(date);
302
- const runOnSecond = matchPattern(this.#expressions[0], date.getSeconds());
303
- const runOnMinute = matchPattern(this.#expressions[1], date.getMinutes());
304
- const runOnHour = matchPattern(this.#expressions[2], date.getHours());
305
- const runOnDay = this.#runsThisDay(date);
306
- return runOnSecond && runOnMinute && runOnHour && runOnDay;
480
+ const w = wallClock(typeof date === 'number' ? date : date.getTime(), this.#timezone);
481
+ return (this.#seconds.includes(w.second) &&
482
+ this.#minutes.includes(w.minute) &&
483
+ this.#hours.includes(w.hour) &&
484
+ this.#runsThisDay(w.year, w.month, w.day));
307
485
  }
308
- #runsThisDay(date) {
309
- const runOnDay = matchPattern(this.#expressions[3], date.getDate());
310
- const runOnMonth = matchPattern(this.#expressions[4], date.getMonth() + 1);
311
- const runOnWeekDay = matchPattern(this.#expressions[5], date.getDay());
312
- return runOnDay && runOnMonth && runOnWeekDay;
486
+ #runsThisDay(year, month, day) {
487
+ return (this.#months.includes(month) &&
488
+ matchesDayOfMonth(this.#daysOfMonth, year, month, day) &&
489
+ matchesDayOfWeek(this.#daysOfWeek, year, month, day));
313
490
  }
491
+ /**
492
+ * Returns the first matching instant strictly after `date`, walking the calendar day by day
493
+ * instead of scanning every second. If the schedule can never fire, returns a non-matching
494
+ * instant at the end of the search horizon.
495
+ */
314
496
  nextWakeupTime(date) {
315
- // This is conservative. Some schedules never occur, such as the 30th of February, but you can ask for them
316
- const time = typeof date === 'number' ? date : date.getTime();
317
- let msec = Math.round(time);
318
- // This can be optimized by skipping ahead, but unit test first
319
- for (let maxIters = 3600; --maxIters; maxIters > 0) {
320
- msec += 1000;
321
- const nd = new Date(msec);
322
- if (this.match(nd))
323
- return nd;
497
+ const baseMs = typeof date === 'number' ? date : date.getTime();
498
+ let { year, month, day } = wallClock(baseMs, this.#timezone);
499
+ for (let i = 0; i < MAX_SEARCH_DAYS; i++) {
500
+ if (this.#runsThisDay(year, month, day)) {
501
+ const next = this.#firstMatchOnDay(year, month, day, baseMs);
502
+ if (next !== undefined)
503
+ return new Date(next);
504
+ }
505
+ const nextDay = new Date(Date.UTC(year, month - 1, day + 1));
506
+ year = nextDay.getUTCFullYear();
507
+ month = nextDay.getUTCMonth() + 1;
508
+ day = nextDay.getUTCDate();
509
+ }
510
+ return new Date(baseMs + MAX_SEARCH_DAYS * DAY_MS);
511
+ }
512
+ // The offset before and after the (at most one) UTC offset change affecting this local day
513
+ #offsetsForDay(dayStart) {
514
+ if (this.#dayOffsets?.dayStart === dayStart)
515
+ return this.#dayOffsets;
516
+ // Every instant whose local date is this day lies within this window, as offsets span UTC-12 to UTC+14
517
+ let lo = dayStart - 14 * HOUR_MS;
518
+ let hi = dayStart + DAY_MS + 12 * HOUR_MS;
519
+ const before = offsetAt(lo, this.#timezone);
520
+ const after = offsetAt(hi, this.#timezone);
521
+ if (before !== after) {
522
+ // Binary search for the first second on the new offset
523
+ while (hi - lo > 1000) {
524
+ const mid = lo + Math.floor((hi - lo) / 2000) * 1000;
525
+ if (offsetAt(mid, this.#timezone) === before)
526
+ lo = mid;
527
+ else
528
+ hi = mid;
529
+ }
324
530
  }
325
- return new Date(msec);
531
+ this.#dayOffsets = { dayStart, before, after, transition: hi };
532
+ return this.#dayOffsets;
326
533
  }
327
- #apply(date) {
328
- if (this.#dtf) {
329
- return new Date(this.#dtf.format(date));
534
+ // Earliest instant after baseMs whose local time on this day matches the time-of-day fields
535
+ #firstMatchOnDay(year, month, day, baseMs) {
536
+ const dayStart = Date.UTC(year, month - 1, day);
537
+ const { before, after, transition } = this.#offsetsForDay(dayStart);
538
+ const minOffset = Math.min(before, after);
539
+ const maxOffset = Math.max(before, after);
540
+ // Local times are ascending, so stop once no later one can map to an instant before `best`
541
+ let best = Infinity;
542
+ for (const hour of this.#hours) {
543
+ const hourStart = dayStart + hour * HOUR_MS;
544
+ if (hourStart - maxOffset >= best)
545
+ break;
546
+ if (hourStart + HOUR_MS - minOffset <= baseMs)
547
+ continue;
548
+ for (const minute of this.#minutes) {
549
+ const minuteStart = hourStart + minute * 60_000;
550
+ if (minuteStart - maxOffset >= best)
551
+ break;
552
+ if (minuteStart + 60_000 - minOffset <= baseMs)
553
+ continue;
554
+ for (const second of this.#seconds) {
555
+ const local = minuteStart + second * 1000;
556
+ if (local - maxOffset >= best)
557
+ break;
558
+ // A local time repeated by a fall-back exists under both offsets; one skipped by a spring-forward under neither
559
+ const withBefore = local - before;
560
+ if (withBefore > baseMs && withBefore < best && withBefore < transition)
561
+ best = withBefore;
562
+ const withAfter = local - after;
563
+ if (withAfter > baseMs && withAfter < best && withAfter >= transition)
564
+ best = withAfter;
565
+ }
566
+ }
330
567
  }
331
- return typeof date === 'number' ? new Date(date) : date;
568
+ return best === Infinity ? undefined : best;
332
569
  }
333
570
  }
334
571
  exports.TimeMatcher = TimeMatcher;