src/schedule.js
Rama main · réplica de lectura del repositorio original.
const FIELDS = [['minute', 0, 59], ['hour', 0, 23], ['day', 1, 31], ['month', 1, 12], ['weekday', 0, 7]];
const MINUTE = 60_000;
export const CATCH_UP_MS = 7 * 24 * 60 * MINUTE;
function parseField(name, src, lo, hi) {
if (src === '*') return null;
const out = new Set();
for (const item of src.split(',')) {
const m = /^(\*|\d+)(?:-(\d+))?(?:\/(\d+))?$/.exec(item);
if (!m) throw new Error(`The ${name} field '${item}' is not a cron term: use *, a number, a-b, or a step like */5`);
const step = m[3] ? Number(m[3]) : 1;
const from = m[1] === '*' ? lo : Number(m[1]);
const to = m[2] ? Number(m[2]) : m[1] === '*' ? hi : from;
if (step < 1 || from < lo || to > hi || from > to) throw new Error(`The ${name} field '${item}' is out of range ${lo}-${hi}`);
for (let v = from; v <= to; v += step) out.add(name === 'weekday' && v === 7 ? 0 : v);
}
return out;
}
export function parseCron(expr) {
const parts = String(expr ?? '').trim().split(/\s+/).filter(Boolean);
if (parts.length !== 5) throw new Error(`A cron schedule has five fields (minute hour day month weekday), not ${parts.length}`);
const cron = { text: parts.join(' ') };
FIELDS.forEach(([name, lo, hi], i) => { cron[name] = parseField(name, parts[i], lo, hi); });
return cron;
}
export function cronMatches(cron, at) {
const fits = (name) => cron[name] === null || cron[name].has(at[name]);
if (!fits('minute') || !fits('hour') || !fits('month')) return false;
if (cron.day !== null && cron.weekday !== null) return fits('day') || fits('weekday');
return fits('day') && fits('weekday');
}
const formatters = new Map();
const WEEKDAYS = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
export function zonedParts(date, zone) {
let fmt = formatters.get(zone);
if (!fmt) {
fmt = new Intl.DateTimeFormat('en-US', { timeZone: zone, hourCycle: 'h23', minute: 'numeric', hour: 'numeric', day: 'numeric', month: 'numeric', weekday: 'short' });
formatters.set(zone, fmt);
}
const at = {};
for (const { type, value } of fmt.formatToParts(date)) {
if (type === 'weekday') at.weekday = WEEKDAYS[value];
else if (type === 'minute' || type === 'hour' || type === 'day' || type === 'month') at[type] = Number(value);
}
return at;
}
export function dueBetween(cron, zone, since, until) {
const from = Math.max(since.getTime(), until.getTime() - CATCH_UP_MS);
const last = Math.floor(until.getTime() / MINUTE) * MINUTE;
for (let t = last; t > from; t -= MINUTE) {
const d = new Date(t);
if (cronMatches(cron, zonedParts(d, zone))) return d;
}
return null;
}
export const POLL_WINDOW_MS = 60 * MINUTE;
export const POLL_SLOWEST_DEFAULT = 60;
export const POLL_SLOWEST_MAX = 24 * 60;
export function nextEvery(every, changed, slowest) {
return changed ? 1 : Math.min(Math.max(1, every) * 2, slowest);
}
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
export function diffItems(snapshot, items, keyOf) {
const changes = [];
const seen = {};
for (const item of items) {
const key = keyOf(item);
if (key == null || key === '' || typeof key === 'object') continue;
const k = String(key);
seen[k] = item;
if (!snapshot) continue;
const prev = Object.hasOwn(snapshot, k) ? snapshot[k] : undefined;
if (prev !== undefined && same(prev, item)) continue;
const fields = item && typeof item === 'object' ? Object.keys(item) : [];
const changed = prev && typeof prev === 'object'
? [...new Set([...fields, ...Object.keys(prev)])].filter((f) => !same(prev[f], item?.[f]))
: fields;
changes.push({ key: k, item, values: Object.fromEntries(changed.filter((f) => item && f in item).map((f) => [f, item[f]])) });
}
return { changes, seen };
}
export function retryAfterMs(header, now) {
if (header == null || header === '') return null;
const text = String(header).trim();
if (/^\d+$/.test(text)) return Number(text) * 1000;
const at = Date.parse(text);
return Number.isNaN(at) ? null : Math.max(0, at - now);
}
export function pollPrune(log, now) {
return (log ?? []).filter((at) => now - at < POLL_WINDOW_MS);
}
export function armSchedules(tick, { every = MINUTE, setTimer = setInterval, clearTimer = clearInterval, log = console.error } = {}) {
const timer = setTimer(() => {
try { tick(); } catch (err) { log(`weave: schedule tick failed: ${err?.message ?? err}`); }
}, every);
timer?.unref?.();
return { stop() { clearTimer(timer); } };
}