@stonyx/cron 0.2.1-alpha.2 → 0.2.1-alpha.21

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.
@@ -1,246 +0,0 @@
1
- /**
2
- * 5-field cron expression parser with next-occurrence computation.
3
- * No external dependencies — built for stonyx-cron.
4
- *
5
- * Fields: minute(0-59) hour(0-23) day-of-month(1-31) month(1-12) day-of-week(0-6)
6
- * Supports: wildcards(*), ranges(1-5), steps(* /5), lists(1,3,5), names(jan-dec, sun-sat)
7
- */
8
-
9
- const MONTH_NAMES = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 };
10
- const DAY_NAMES = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
11
-
12
- const FIELD_RANGES = [
13
- { min: 0, max: 59 }, // minute
14
- { min: 0, max: 23 }, // hour
15
- { min: 1, max: 31 }, // day of month
16
- { min: 1, max: 12 }, // month
17
- { min: 0, max: 6 }, // day of week
18
- ];
19
-
20
- /**
21
- * Parse a single cron field into a sorted array of allowed values.
22
- * @param {string} field - The field string (e.g., "1-5", "* /15", "mon,wed,fri")
23
- * @param {number} fieldIndex - Index (0=minute, 1=hour, 2=dom, 3=month, 4=dow)
24
- * @returns {number[]} Sorted array of allowed integer values
25
- */
26
- export function parseField(field, fieldIndex) {
27
- const { min, max } = FIELD_RANGES[fieldIndex];
28
- const names = fieldIndex === 3 ? MONTH_NAMES : fieldIndex === 4 ? DAY_NAMES : null;
29
-
30
- const resolveToken = (token) => {
31
- if (names) {
32
- const lower = token.toLowerCase();
33
- if (lower in names) return names[lower];
34
- }
35
- const n = Number(token);
36
- if (!Number.isInteger(n)) throw new Error(`Invalid cron value: "${token}" in field ${fieldIndex}`);
37
- // Normalize day-of-week 7 → 0 (both mean Sunday)
38
- if (fieldIndex === 4 && n === 7) return 0;
39
- return n;
40
- };
41
-
42
- const results = new Set();
43
-
44
- for (const part of field.split(',')) {
45
- const trimmed = part.trim();
46
- const [rangeStr, stepStr] = trimmed.split('/');
47
- const step = stepStr !== undefined ? Number(stepStr) : 1;
48
-
49
- if (!Number.isInteger(step) || step < 1) {
50
- throw new Error(`Invalid step "${stepStr}" in cron field ${fieldIndex}`);
51
- }
52
-
53
- let start, end;
54
-
55
- if (rangeStr === '*') {
56
- start = min;
57
- end = max;
58
- } else if (rangeStr.includes('-')) {
59
- const [lo, hi] = rangeStr.split('-');
60
- start = resolveToken(lo);
61
- end = resolveToken(hi);
62
- } else {
63
- start = resolveToken(rangeStr);
64
- end = stepStr !== undefined ? max : start;
65
- }
66
-
67
- if (start < min || start > max || end < min || end > max) {
68
- throw new Error(`Value out of range [${min}-${max}] in cron field ${fieldIndex}: "${trimmed}"`);
69
- }
70
-
71
- for (let v = start; v <= end; v += step) {
72
- results.add(v);
73
- }
74
- }
75
-
76
- return [...results].sort((a, b) => a - b);
77
- }
78
-
79
- /**
80
- * Parse a 5-field cron expression into field arrays.
81
- * @param {string} expr - Cron expression (e.g., "0 9 * * 1-5")
82
- * @returns {{ minutes: number[], hours: number[], daysOfMonth: number[], months: number[], daysOfWeek: number[] }}
83
- */
84
- export function parseCronExpression(expr) {
85
- const fields = expr.trim().split(/\s+/);
86
- if (fields.length !== 5) {
87
- throw new Error(`Cron expression must have exactly 5 fields, got ${fields.length}: "${expr}"`);
88
- }
89
-
90
- return {
91
- minutes: parseField(fields[0], 0),
92
- hours: parseField(fields[1], 1),
93
- daysOfMonth: parseField(fields[2], 2),
94
- months: parseField(fields[3], 3),
95
- daysOfWeek: parseField(fields[4], 4),
96
- };
97
- }
98
-
99
- /**
100
- * Get the number of days in a given month/year.
101
- */
102
- function daysInMonth(year, month) {
103
- return new Date(year, month, 0).getDate();
104
- }
105
-
106
- /**
107
- * Check if a day-of-month + day-of-week pair matches the parsed expression.
108
- *
109
- * Standard cron behavior: if BOTH dom and dow are restricted (not *),
110
- * then EITHER matching is sufficient (OR logic).
111
- * If only one is restricted, it acts as the sole filter.
112
- */
113
- function dayMatches(parsed, domWild, dowWild, dayOfMonth, dayOfWeek) {
114
- const domMatch = parsed.daysOfMonth.includes(dayOfMonth);
115
- const dowMatch = parsed.daysOfWeek.includes(dayOfWeek);
116
-
117
- if (domWild && dowWild) return true;
118
- if (domWild) return dowMatch;
119
- if (dowWild) return domMatch;
120
- return domMatch || dowMatch; // Both restricted → OR
121
- }
122
-
123
- /**
124
- * Compute the next occurrence of a cron expression after a given timestamp.
125
- *
126
- * @param {string} expr - 5-field cron expression
127
- * @param {number} afterMs - Timestamp in milliseconds (exclusive — finds strictly after this)
128
- * @param {string} [tz] - IANA timezone (defaults to system timezone)
129
- * @returns {number|undefined} Next occurrence in milliseconds, or undefined if none within 4 years
130
- */
131
- export function nextOccurrence(expr, afterMs, tz) {
132
- const parsed = parseCronExpression(expr);
133
- const exprFields = expr.trim().split(/\s+/);
134
- const domWild = exprFields[2] === '*';
135
- const dowWild = exprFields[4] === '*';
136
-
137
- // Start from the next whole minute after afterMs
138
- const startDate = new Date(afterMs);
139
- startDate.setSeconds(0, 0);
140
- startDate.setMinutes(startDate.getMinutes() + 1);
141
-
142
- // Convert to target timezone for field matching
143
- const formatter = new Intl.DateTimeFormat('en-US', {
144
- timeZone: tz || undefined,
145
- year: 'numeric', month: 'numeric', day: 'numeric',
146
- hour: 'numeric', minute: 'numeric', hour12: false,
147
- weekday: 'short',
148
- });
149
-
150
- const dayMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
151
-
152
- // Parse formatted date parts in the target timezone
153
- function getLocalParts(date) {
154
- const parts = {};
155
- for (const { type, value } of formatter.formatToParts(date)) {
156
- parts[type] = value;
157
- }
158
- return {
159
- year: Number(parts.year),
160
- month: Number(parts.month),
161
- day: Number(parts.day),
162
- hour: Number(parts.hour === '24' ? 0 : parts.hour),
163
- minute: Number(parts.minute),
164
- weekday: dayMap[parts.weekday] ?? 0,
165
- };
166
- }
167
-
168
- // Search limit: 4 years of minutes (≈ 2.1M iterations max)
169
- const maxMs = afterMs + 4 * 365.25 * 24 * 60 * 60 * 1000;
170
- let candidate = new Date(startDate);
171
-
172
- while (candidate.getTime() <= maxMs) {
173
- const p = getLocalParts(candidate);
174
-
175
- // Check month
176
- if (!parsed.months.includes(p.month)) {
177
- // Advance to next matching month
178
- const nextMonth = parsed.months.find(m => m > p.month);
179
- if (nextMonth) {
180
- // Stay in same year, advance to first day of nextMonth
181
- candidate = advanceToMonth(candidate, p.year, nextMonth, tz, formatter, dayMap);
182
- } else {
183
- // Wrap to next year, first matching month
184
- candidate = advanceToMonth(candidate, p.year + 1, parsed.months[0], tz, formatter, dayMap);
185
- }
186
- continue;
187
- }
188
-
189
- // Check day (dom + dow)
190
- if (!dayMatches(parsed, domWild, dowWild, p.day, p.weekday)) {
191
- candidate.setMinutes(candidate.getMinutes() + (24 * 60 - p.hour * 60 - p.minute));
192
- continue;
193
- }
194
-
195
- // Check hour
196
- if (!parsed.hours.includes(p.hour)) {
197
- const nextHour = parsed.hours.find(h => h > p.hour);
198
- if (nextHour) {
199
- candidate.setMinutes(candidate.getMinutes() + ((nextHour - p.hour) * 60 - p.minute));
200
- } else {
201
- // Advance to next day
202
- candidate.setMinutes(candidate.getMinutes() + ((24 - p.hour) * 60 - p.minute));
203
- }
204
- continue;
205
- }
206
-
207
- // Check minute
208
- if (!parsed.minutes.includes(p.minute)) {
209
- const nextMin = parsed.minutes.find(m => m > p.minute);
210
- if (nextMin) {
211
- candidate.setMinutes(candidate.getMinutes() + (nextMin - p.minute));
212
- } else {
213
- // Advance to next hour
214
- candidate.setMinutes(candidate.getMinutes() + (60 - p.minute));
215
- }
216
- continue;
217
- }
218
-
219
- // All fields match
220
- return candidate.getTime();
221
- }
222
-
223
- return undefined;
224
- }
225
-
226
- /**
227
- * Create a Date advanced to the start of a specific month in a specific year,
228
- * using the target timezone's midnight.
229
- */
230
- function advanceToMonth(current, year, month, tz, formatter, dayMap) {
231
- // Create a new date at ~start of the target month in UTC, then adjust
232
- const d = new Date(current);
233
- // Jump to approximately the right time
234
- d.setFullYear(year, month - 1, 1);
235
- d.setHours(0, 0, 0, 0);
236
- return d;
237
- }
238
-
239
- /**
240
- * Validate a cron expression without computing next occurrence.
241
- * @param {string} expr - 5-field cron expression
242
- * @throws {Error} if the expression is invalid
243
- */
244
- export function validateCronExpression(expr) {
245
- parseCronExpression(expr);
246
- }
package/src/job.js DELETED
@@ -1,200 +0,0 @@
1
- /**
2
- * Job data model and state machine for the advanced scheduling system.
3
- */
4
- import { computeNextRunAtMs, validateSchedule } from './schedule.js';
5
-
6
- /**
7
- * Error backoff table (milliseconds).
8
- * Applied after consecutive errors to prevent hammering.
9
- */
10
- const ERROR_BACKOFF_MS = [30_000, 60_000, 300_000, 900_000, 3_600_000];
11
-
12
- export function errorBackoffMs(consecutiveErrors) {
13
- if (consecutiveErrors < 1) return 0;
14
- return ERROR_BACKOFF_MS[Math.min(consecutiveErrors - 1, ERROR_BACKOFF_MS.length - 1)];
15
- }
16
-
17
- /**
18
- * Create a new job object from input.
19
- *
20
- * @param {object} input - Job creation input
21
- * @param {string} input.name - Job name
22
- * @param {object} input.schedule - Schedule definition (at/every/cron)
23
- * @param {object} input.payload - What to execute
24
- * @param {string} [input.description]
25
- * @param {boolean} [input.enabled=true]
26
- * @param {boolean} [input.deleteAfterRun=false]
27
- * @param {string} [input.sessionTarget="isolated"]
28
- * @param {string} [input.wakeMode="now"]
29
- * @param {object} [input.delivery]
30
- * @returns {object} Complete job object with state
31
- */
32
- export function createJob(input) {
33
- validateSchedule(input.schedule);
34
-
35
- const nowMs = Date.now();
36
- const enabled = input.enabled !== false;
37
- const deleteAfterRun = input.deleteAfterRun ?? (input.schedule.kind === 'at');
38
-
39
- const job = {
40
- id: crypto.randomUUID(),
41
- name: input.name,
42
- description: input.description || undefined,
43
- enabled,
44
- deleteAfterRun,
45
- createdAtMs: nowMs,
46
- updatedAtMs: nowMs,
47
- schedule: { ...input.schedule },
48
- sessionTarget: input.sessionTarget || 'isolated',
49
- wakeMode: input.wakeMode || 'now',
50
- payload: { ...input.payload },
51
- delivery: input.delivery ? { ...input.delivery } : undefined,
52
- state: {
53
- nextRunAtMs: undefined,
54
- runningAtMs: undefined,
55
- lastRunAtMs: undefined,
56
- lastStatus: undefined,
57
- lastError: undefined,
58
- lastDurationMs: undefined,
59
- consecutiveErrors: 0,
60
- scheduleErrorCount: 0,
61
- },
62
- };
63
-
64
- // Compute initial next run
65
- if (enabled) {
66
- try {
67
- job.state.nextRunAtMs = computeNextRunAtMs(job.schedule, nowMs);
68
- } catch {
69
- job.state.scheduleErrorCount = 1;
70
- }
71
- }
72
-
73
- return job;
74
- }
75
-
76
- /**
77
- * Apply an update patch to a job.
78
- *
79
- * @param {object} job - Existing job
80
- * @param {object} patch - Fields to update
81
- * @returns {object} Updated job (same reference, mutated)
82
- */
83
- export function updateJob(job, patch) {
84
- const nowMs = Date.now();
85
-
86
- if (patch.name !== undefined) job.name = patch.name;
87
- if (patch.description !== undefined) job.description = patch.description || undefined;
88
- if (patch.deleteAfterRun !== undefined) job.deleteAfterRun = patch.deleteAfterRun;
89
- if (patch.sessionTarget !== undefined) job.sessionTarget = patch.sessionTarget;
90
- if (patch.wakeMode !== undefined) job.wakeMode = patch.wakeMode;
91
- if (patch.payload !== undefined) job.payload = { ...patch.payload };
92
- if (patch.delivery !== undefined) job.delivery = patch.delivery ? { ...patch.delivery } : undefined;
93
-
94
- if (patch.schedule !== undefined) {
95
- validateSchedule(patch.schedule);
96
- job.schedule = { ...patch.schedule };
97
- job.state.scheduleErrorCount = 0;
98
- // Recompute next run
99
- if (job.enabled) {
100
- try {
101
- job.state.nextRunAtMs = computeNextRunAtMs(job.schedule, nowMs);
102
- } catch {
103
- job.state.scheduleErrorCount = 1;
104
- }
105
- }
106
- }
107
-
108
- if (patch.enabled !== undefined) {
109
- job.enabled = patch.enabled;
110
- if (job.enabled && !job.state.nextRunAtMs) {
111
- try {
112
- job.state.nextRunAtMs = computeNextRunAtMs(job.schedule, nowMs);
113
- } catch {
114
- job.state.scheduleErrorCount++;
115
- }
116
- }
117
- if (!job.enabled) {
118
- job.state.nextRunAtMs = undefined;
119
- }
120
- }
121
-
122
- job.updatedAtMs = nowMs;
123
- return job;
124
- }
125
-
126
- /**
127
- * Mark a job as started (running).
128
- */
129
- export function markRunning(job) {
130
- job.state.runningAtMs = Date.now();
131
- }
132
-
133
- /**
134
- * Apply the result of a job execution.
135
- *
136
- * @param {object} job - The job
137
- * @param {"ok"|"error"|"skipped"} status - Execution result
138
- * @param {string} [error] - Error message if status is "error"
139
- * @param {number} [durationMs] - Execution duration
140
- */
141
- export function applyResult(job, status, error, durationMs) {
142
- const nowMs = Date.now();
143
-
144
- job.state.lastRunAtMs = job.state.runningAtMs || nowMs;
145
- job.state.runningAtMs = undefined;
146
- job.state.lastStatus = status;
147
- job.state.lastError = status === 'error' ? error : undefined;
148
- job.state.lastDurationMs = durationMs;
149
-
150
- if (status === 'error') {
151
- job.state.consecutiveErrors = (job.state.consecutiveErrors || 0) + 1;
152
- } else {
153
- job.state.consecutiveErrors = 0;
154
- }
155
-
156
- // One-shot jobs: disable after any terminal status
157
- if (job.schedule.kind === 'at') {
158
- job.enabled = false;
159
- job.state.nextRunAtMs = undefined;
160
- return;
161
- }
162
-
163
- // Recurring jobs: compute next run with backoff
164
- if (job.enabled) {
165
- try {
166
- const normalNext = computeNextRunAtMs(job.schedule, nowMs);
167
- if (normalNext === undefined) {
168
- job.enabled = false;
169
- job.state.nextRunAtMs = undefined;
170
- return;
171
- }
172
-
173
- if (status === 'error' && job.state.consecutiveErrors > 0) {
174
- const backoff = errorBackoffMs(job.state.consecutiveErrors);
175
- job.state.nextRunAtMs = Math.max(normalNext, nowMs + backoff);
176
- } else {
177
- job.state.nextRunAtMs = normalNext;
178
- }
179
-
180
- job.state.scheduleErrorCount = 0;
181
- } catch {
182
- job.state.scheduleErrorCount = (job.state.scheduleErrorCount || 0) + 1;
183
- // Auto-disable after 3 consecutive schedule computation errors
184
- if (job.state.scheduleErrorCount >= 3) {
185
- job.enabled = false;
186
- job.state.nextRunAtMs = undefined;
187
- }
188
- }
189
- }
190
- }
191
-
192
- /**
193
- * Check if a job is due to run.
194
- */
195
- export function isDue(job, nowMs) {
196
- return job.enabled
197
- && !job.state.runningAtMs
198
- && job.state.nextRunAtMs !== undefined
199
- && job.state.nextRunAtMs <= nowMs;
200
- }
package/src/locked.js DELETED
@@ -1,34 +0,0 @@
1
- /**
2
- * Async locking mechanism to serialize state mutations.
3
- * Prevents concurrent operations from corrupting job state.
4
- */
5
-
6
- let chain = Promise.resolve();
7
-
8
- /**
9
- * Execute a function with exclusive access to cron state.
10
- * Operations queue behind each other — no concurrent mutations.
11
- *
12
- * @param {Function} fn - Async function to execute under lock
13
- * @returns {Promise<*>} Result of fn
14
- */
15
- export async function locked(fn) {
16
- let resolve;
17
- const prev = chain;
18
- chain = new Promise(r => { resolve = r; });
19
-
20
- await prev;
21
-
22
- try {
23
- return await fn();
24
- } finally {
25
- resolve();
26
- }
27
- }
28
-
29
- /**
30
- * Reset the lock chain. Only for testing.
31
- */
32
- export function resetLock() {
33
- chain = Promise.resolve();
34
- }
package/src/main.js DELETED
@@ -1,112 +0,0 @@
1
- /*
2
- * Copyright 2025 Stone Costa
3
- *
4
- * Licensed under the Apache License, Version 2.0 (the 'License');
5
- * you may not use this file except in compliance with the License.
6
- * You may obtain a copy of the License at
7
- *
8
- * http://www.apache.org/licenses/LICENSE-2.0
9
- *
10
- * Unless required by applicable law or agreed to in writing, software
11
- * distributed under the License is distributed on an "AS IS" BASIS,
12
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
- * See the License for the specific language governing permissions and
14
- * limitations under the License.
15
- */
16
-
17
- import config from 'stonyx/config';
18
- import log from 'stonyx/log';
19
- import { getTimestamp } from "@stonyx/utils/date";
20
- import MinHeap from '@stonyx/cron/min-heap';
21
-
22
- export default class Cron {
23
- jobs = {};
24
- heap = new MinHeap();
25
- timer = null;
26
-
27
- constructor() {
28
- if (Cron.instance) return Cron.instance;
29
- Cron.instance = this;
30
- }
31
-
32
- scheduleNextRun() {
33
- clearTimeout(this.timer);
34
-
35
- const { heap } = this;
36
-
37
- if (heap.isEmpty()) return;
38
-
39
- const nextJob = heap.peek();
40
- const delay = Math.max(0, nextJob.nextTrigger - getTimestamp()) * 1000;
41
-
42
- this.timer = setTimeout(() => this.runDueJobs(), delay);
43
- }
44
-
45
- async runDueJobs() {
46
- const now = getTimestamp();
47
- const { heap } = this;
48
-
49
- while (!heap.isEmpty() && heap.peek().nextTrigger <= now) {
50
- const job = heap.pop();
51
-
52
- if (config.debug) this.log('job has been triggered', job.key);
53
-
54
- try {
55
- await job.callback();
56
- } catch (err) {
57
- log.error(`Cron job "${job.key}" failed:`, err);
58
- }
59
-
60
- this.setNextTrigger(job);
61
- heap.push(job);
62
- }
63
-
64
- this.scheduleNextRun();
65
- }
66
-
67
- register(key, callback, interval, runOnInit=false) {
68
- const job = { callback, interval, key };
69
- this.jobs[key] = job;
70
- this.setNextTrigger(job);
71
- this.heap.push(job);
72
-
73
- if (config.debug) {
74
- this.log(`job has been registered with interval: ${interval}`, key);
75
- }
76
-
77
- if (runOnInit) {
78
- try {
79
- callback();
80
- } catch (err) {
81
- log.error(`Cron job "${key}" failed on init:`, err);
82
- }
83
- }
84
-
85
- this.scheduleNextRun();
86
- }
87
-
88
- unregister(key) {
89
- const { heap, jobs } = this;
90
- const job = jobs[key];
91
-
92
- if (!job) return;
93
-
94
- delete jobs[key];
95
- heap.remove(job);
96
-
97
- if (config.debug) this.log('job has been unregistered', key);
98
-
99
- this.scheduleNextRun();
100
- }
101
-
102
- setNextTrigger(job) {
103
- job.nextTrigger = getTimestamp() + parseInt(job.interval, 10);
104
- }
105
-
106
- log(text, key = null) {
107
- if (!config.cron?.log) return;
108
-
109
- const tag = key ? `Cron::${key}` : `Cron`;
110
- log.cron(`${tag} - ${text}:`);
111
- }
112
- }
package/src/min-heap.js DELETED
@@ -1,73 +0,0 @@
1
- export default class MinHeap {
2
- constructor() {
3
- this.items = [];
4
- }
5
-
6
- push(job) {
7
- this.items.push(job);
8
- this.bubbleUp();
9
- }
10
-
11
- pop() {
12
- if (this.items.length === 1) return this.items.pop();
13
- const top = this.items[0];
14
- this.items[0] = this.items.pop();
15
- this.bubbleDown();
16
- return top;
17
- }
18
-
19
- peek() {
20
- return this.items[0];
21
- }
22
-
23
- bubbleUp() {
24
- let idx = this.items.length - 1;
25
- while (idx > 0) {
26
- const parentIdx = Math.floor((idx - 1) / 2);
27
- if (this.items[idx].nextTrigger >= this.items[parentIdx].nextTrigger) break;
28
- [this.items[idx], this.items[parentIdx]] = [this.items[parentIdx], this.items[idx]];
29
- idx = parentIdx;
30
- }
31
- }
32
-
33
- bubbleDown() {
34
- let idx = 0;
35
- const length = this.items.length;
36
-
37
- while (true) {
38
- let leftIdx = 2 * idx + 1;
39
- let rightIdx = 2 * idx + 2;
40
- let swapIdx = null;
41
-
42
- if (leftIdx < length && this.items[leftIdx].nextTrigger < this.items[idx].nextTrigger) {
43
- swapIdx = leftIdx;
44
- }
45
- if (
46
- rightIdx < length &&
47
- this.items[rightIdx].nextTrigger < (
48
- swapIdx === null ? this.items[idx].nextTrigger : this.items[leftIdx].nextTrigger
49
- )
50
- ) {
51
- swapIdx = rightIdx;
52
- }
53
- if (swapIdx === null) break;
54
- [this.items[idx], this.items[swapIdx]] = [this.items[swapIdx], this.items[idx]];
55
- idx = swapIdx;
56
- }
57
- }
58
-
59
- remove(job) {
60
- const idx = this.items.indexOf(job);
61
- if (idx === -1) return;
62
- const end = this.items.pop();
63
- if (idx < this.items.length) {
64
- this.items[idx] = end;
65
- this.bubbleUp();
66
- this.bubbleDown();
67
- }
68
- }
69
-
70
- isEmpty() {
71
- return this.items.length === 0;
72
- }
73
- }