@stonyx/cron 0.2.1-beta.14 → 0.2.1-beta.140

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.
@@ -0,0 +1,60 @@
1
+ /**
2
+ * In-memory run log for job execution history.
3
+ * Stores recent execution results per job with auto-pruning.
4
+ */
5
+ const DEFAULT_MAX_ENTRIES_PER_JOB = 100;
6
+ export default class RunLog {
7
+ maxEntries;
8
+ entries;
9
+ constructor(maxEntriesPerJob = DEFAULT_MAX_ENTRIES_PER_JOB) {
10
+ this.maxEntries = maxEntriesPerJob;
11
+ this.entries = new Map();
12
+ }
13
+ /**
14
+ * Record a job execution result.
15
+ */
16
+ record(entry) {
17
+ const log = {
18
+ ts: Date.now(),
19
+ jobId: entry.jobId,
20
+ status: entry.status,
21
+ error: entry.error,
22
+ summary: entry.summary,
23
+ runAtMs: entry.runAtMs,
24
+ durationMs: entry.durationMs,
25
+ nextRunAtMs: entry.nextRunAtMs,
26
+ };
27
+ if (!this.entries.has(entry.jobId)) {
28
+ this.entries.set(entry.jobId, []);
29
+ }
30
+ const logs = this.entries.get(entry.jobId);
31
+ if (!logs)
32
+ return;
33
+ logs.push(log);
34
+ // Auto-prune
35
+ if (logs.length > this.maxEntries) {
36
+ logs.splice(0, logs.length - this.maxEntries);
37
+ }
38
+ }
39
+ /**
40
+ * Get run history for a job.
41
+ */
42
+ get(jobId, limit = 20) {
43
+ const logs = this.entries.get(jobId);
44
+ if (!logs)
45
+ return [];
46
+ return logs.slice(-limit).reverse();
47
+ }
48
+ /**
49
+ * Remove all entries for a job.
50
+ */
51
+ removeJob(jobId) {
52
+ this.entries.delete(jobId);
53
+ }
54
+ /**
55
+ * Clear all entries.
56
+ */
57
+ clear() {
58
+ this.entries.clear();
59
+ }
60
+ }
@@ -0,0 +1,23 @@
1
+ export interface AtSchedule {
2
+ kind: 'at';
3
+ at: string | number;
4
+ }
5
+ export interface EverySchedule {
6
+ kind: 'every';
7
+ everyMs: number;
8
+ anchorMs?: number;
9
+ }
10
+ export interface CronSchedule {
11
+ kind: 'cron';
12
+ expr: string;
13
+ tz?: string;
14
+ }
15
+ export type Schedule = AtSchedule | EverySchedule | CronSchedule;
16
+ /**
17
+ * Compute the next run time for a schedule.
18
+ */
19
+ export declare function computeNextRunAtMs(schedule: Schedule, nowMs: number): number | undefined;
20
+ /**
21
+ * Validate a schedule definition.
22
+ */
23
+ export declare function validateSchedule(schedule: Schedule): void;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Schedule types and next-run computation.
3
+ *
4
+ * Three schedule kinds:
5
+ * - "at": One-shot at an absolute ISO-8601 timestamp
6
+ * - "every": Recurring interval in milliseconds
7
+ * - "cron": 5-field cron expression with optional timezone
8
+ */
9
+ import { nextOccurrence, validateCronExpression } from './cron-parser.js';
10
+ /**
11
+ * Compute the next run time for a schedule.
12
+ */
13
+ export function computeNextRunAtMs(schedule, nowMs) {
14
+ if (schedule.kind === 'at') {
15
+ const atMs = typeof schedule.at === 'number' ? schedule.at : Date.parse(schedule.at);
16
+ if (!Number.isFinite(atMs))
17
+ return undefined;
18
+ return atMs > nowMs ? atMs : undefined;
19
+ }
20
+ if (schedule.kind === 'every') {
21
+ const everyMs = Math.max(1, Math.floor(schedule.everyMs));
22
+ const anchor = Math.max(0, Math.floor(schedule.anchorMs ?? nowMs));
23
+ if (nowMs < anchor)
24
+ return anchor;
25
+ const elapsed = nowMs - anchor;
26
+ const steps = Math.max(1, Math.floor((elapsed + everyMs - 1) / everyMs));
27
+ return anchor + steps * everyMs;
28
+ }
29
+ if (schedule.kind === 'cron') {
30
+ const tz = schedule.tz?.trim() || undefined;
31
+ // Round nowMs down to the current second to avoid sub-second drift
32
+ const nowSecondMs = Math.floor(nowMs / 1000) * 1000;
33
+ return nextOccurrence(schedule.expr.trim(), nowSecondMs, tz);
34
+ }
35
+ throw new Error(`Unknown schedule kind: "${schedule.kind}"`);
36
+ }
37
+ /**
38
+ * Validate a schedule definition.
39
+ */
40
+ export function validateSchedule(schedule) {
41
+ if (!schedule || typeof schedule !== 'object') {
42
+ throw new Error('Schedule must be an object');
43
+ }
44
+ if (schedule.kind === 'at') {
45
+ const atMs = typeof schedule.at === 'number' ? schedule.at : Date.parse(schedule.at);
46
+ if (!Number.isFinite(atMs)) {
47
+ throw new Error(`Invalid "at" timestamp: "${schedule.at}"`);
48
+ }
49
+ return;
50
+ }
51
+ if (schedule.kind === 'every') {
52
+ if (typeof schedule.everyMs !== 'number' || schedule.everyMs < 1) {
53
+ throw new Error(`"every" schedule requires everyMs >= 1, got: ${schedule.everyMs}`);
54
+ }
55
+ return;
56
+ }
57
+ if (schedule.kind === 'cron') {
58
+ if (typeof schedule.expr !== 'string' || !schedule.expr.trim()) {
59
+ throw new Error('"cron" schedule requires a non-empty expr string');
60
+ }
61
+ validateCronExpression(schedule.expr.trim());
62
+ return;
63
+ }
64
+ throw new Error(`Unknown schedule kind: "${schedule.kind}"`);
65
+ }
@@ -0,0 +1,182 @@
1
+ import MinHeap, { type HeapItem } from './min-heap.js';
2
+ import { type Job, type JobPatch } from './job.js';
3
+ import RunLog from './run-log.js';
4
+ interface HeapEntry extends HeapItem {
5
+ key: string;
6
+ }
7
+ /**
8
+ * Why a `run()` did not invoke the callback. Exported so a consumer can write a
9
+ * total handler over it: the union is closed and narrowed (it was `string`
10
+ * before #34), so an exhaustive `switch` is now both possible and expected.
11
+ */
12
+ export type SkipReason = 'not due' | 'already running' | 'removed';
13
+ export interface JobDueResult {
14
+ status?: string;
15
+ error?: string;
16
+ summary?: string;
17
+ }
18
+ export interface ExecuteResult {
19
+ status: string;
20
+ error?: string;
21
+ summary?: string;
22
+ durationMs?: number;
23
+ deleted?: boolean;
24
+ /** Only set when `status` is `'skipped'`. */
25
+ reason?: SkipReason;
26
+ }
27
+ export interface ServiceStatus {
28
+ started: boolean;
29
+ jobCount: number;
30
+ nextWakeAtMs: number | undefined;
31
+ }
32
+ export interface ListOptions {
33
+ includeDisabled?: boolean;
34
+ }
35
+ export type OnJobDueCallback = (job: Job) => Promise<JobDueResult | void> | JobDueResult | void;
36
+ export default class CronService {
37
+ #private;
38
+ jobs: Map<string, Job>;
39
+ heap: MinHeap<HeapEntry>;
40
+ timer: ReturnType<typeof setTimeout> | null;
41
+ running: boolean;
42
+ runLog: RunLog;
43
+ started: boolean;
44
+ onJobDue: OnJobDueCallback | null;
45
+ constructor();
46
+ /**
47
+ * Start the service. Loads jobs from store (if any), arms timer. A no-op if
48
+ * already started.
49
+ *
50
+ * `initialJobs` crosses a serialization boundary — it is whatever the
51
+ * consumer's store handed back — so `Job[]` is a compile-time claim about
52
+ * runtime data. Three behaviours follow from that and are worth knowing
53
+ * before you call this, because all three are deliberate and two of them
54
+ * differ from a plain "load and arm":
55
+ *
56
+ * 1. WRITES TO `row.state`. A STALE claim (`state.runningAtMs` set by a
57
+ * process that is gone) is released, because nothing else ever will —
58
+ * there is no lease on the field (#35) — and left in place it is a job
59
+ * that is dead forever while `status()` reports it healthy. A LIVE claim,
60
+ * held by an invocation still running in this process, is left alone:
61
+ * releasing it would let the timer start a second concurrent invocation of
62
+ * a job that is already running.
63
+ *
64
+ * 2. THROWS on a row this class cannot use, rather than accepting it. A row
65
+ * whose `state` is missing, or frozen (`structuredClone` + `Object.freeze`
66
+ * is an ordinary defensive rehydration), throws out of `start()` where the
67
+ * caller's own `await` can catch it. The alternative is a `TypeError` from
68
+ * inside a bare timer callback later — an unhandled rejection, and
69
+ * process-fatal under Node's default.
70
+ *
71
+ * 3. ARMS THE TIMER EVEN IF IT THROWS. The rows loaded before the throw are
72
+ * registered and scheduled. Without this, a throw leaves `started: true`
73
+ * (so a retry is a no-op) with jobs in the heap and no timer: nothing ever
74
+ * fires and `status()` still reports healthy.
75
+ *
76
+ * Which of the three you can observe depends on the CONTENT of the rows, not
77
+ * on whether they were deserialized — 1 fires only on a row that already
78
+ * carries a claim, and 2/3 only on a row this class cannot use. Hand it
79
+ * well-formed deserialized rows with no claim set and none of the three is
80
+ * observable. Hand it a deserialized row that DOES carry one and 1 and 2 are
81
+ * exactly what you get: measured, a stale `state.runningAtMs` of 1 comes back
82
+ * `undefined`, and a `structuredClone` + `Object.freeze` row makes `start()`
83
+ * throw `TypeError: Cannot assign to read only property 'runningAtMs'` with
84
+ * the timer still armed behind it. Hand it live `Job` objects this service is
85
+ * currently executing and only 1 is in play, by design — and on those it
86
+ * deliberately does nothing.
87
+ */
88
+ start(initialJobs?: Job[]): Promise<void>;
89
+ /**
90
+ * Stop the service. Clears timer.
91
+ */
92
+ stop(): void;
93
+ /**
94
+ * Get service status.
95
+ */
96
+ status(): ServiceStatus;
97
+ /**
98
+ * List jobs, optionally including disabled ones.
99
+ */
100
+ list(opts?: ListOptions): Job[];
101
+ /**
102
+ * Get a single job by ID.
103
+ */
104
+ get(id: string): Job | null;
105
+ /**
106
+ * Add a new job. Input is normalized for AI compatibility.
107
+ */
108
+ add(rawInput: Record<string, unknown>): Promise<Job>;
109
+ /**
110
+ * Update an existing job.
111
+ */
112
+ update(id: string, patch: JobPatch): Promise<Job>;
113
+ /**
114
+ * Remove a job.
115
+ */
116
+ remove(id: string): Promise<void>;
117
+ /**
118
+ * Manually trigger a job.
119
+ *
120
+ * Returns `{ status: 'skipped', reason }` without invoking the callback when
121
+ * the job is not due (`mode: 'due'`), is already in flight
122
+ * (`'already running'`), or was removed before the claim landed
123
+ * (`'removed'`). Before the phase split, a forced run against an in-flight
124
+ * job launched a second concurrent invocation.
125
+ *
126
+ * THROWS (rather than returning a skip) when `id` is not a registered job:
127
+ * `Error("Job not found: <id>")`. A job that disappears between this lookup
128
+ * and the claim is the `'removed'` skip above, not a throw — the two differ
129
+ * only by the timing of a race, and the second is a legitimate outcome
130
+ * whereas the first is a caller error.
131
+ *
132
+ * CONCURRENCY: the same job is bounded to one in-flight invocation on every
133
+ * path, and the timer path invokes due jobs one at a time. `run()` fan-out
134
+ * across DIFFERENT jobs is deliberately unbounded — N concurrent `run()`
135
+ * calls produce N concurrent consumer callbacks. Before the phase split
136
+ * these serialized behind the module-global lock; that serialization was the
137
+ * bug rather than the feature (one hung callback wedged every other caller),
138
+ * so it is not restored here. The fan-out is caller-driven and the scheduler
139
+ * never produces it on its own. A per-invoke bound belongs above this layer;
140
+ * it is tracked on stonyx-cron#35 alongside the execution timeout.
141
+ *
142
+ * NOT FIXED HERE: the phase split fixes the LOCK wedge, not the TIMER. A
143
+ * callback that never settles still stops `onTimer`'s sequential loop
144
+ * forever — `running` stays true, every later tick early-returns and re-arms,
145
+ * and the hung job's batch siblings stay claimed and off-heap having never
146
+ * been invoked. CRUD still resolves and `status()` still reports
147
+ * `started: true`, so that failure is now silent where it used to be loud.
148
+ * Bounding the callback and releasing batch siblings is stonyx-cron#35. Do
149
+ * not read this method's doc as "the hang is fixed".
150
+ */
151
+ run(id: string, mode?: 'due' | 'force'): Promise<ExecuteResult>;
152
+ /**
153
+ * Get run history for a job.
154
+ */
155
+ runs(id: string, limit?: number): ReturnType<RunLog['get']>;
156
+ armTimer(): void;
157
+ onTimer(): Promise<void>;
158
+ findDueJobs(nowMs: number): Job[];
159
+ /**
160
+ * Execute a job in three phases:
161
+ *
162
+ * 1. claim (locked) — take ownership of the job, detach it from the heap
163
+ * 2. invoke (UNLOCKED) — await the consumer callback
164
+ * 3. settle (locked) — apply the result, log it, re-insert into the heap
165
+ *
166
+ * The critical section deliberately excludes phase 2. `onJobDue` is
167
+ * arbitrary, unbounded consumer code; awaiting it under the module-global
168
+ * lock is what wedged every subsequent `locked()` call (add/update/remove)
169
+ * when a callback never settled.
170
+ *
171
+ * `onTimer` performs the batch claim (`findDueJobs` + `markRunning`) for all
172
+ * due jobs under a single lock and then enters at phase 2 via
173
+ * `#executeClaimed`. That entry point is `#private` rather than a parameter
174
+ * on this method: as a published `alreadyClaimed` flag it would be a
175
+ * supported way to skip phase 1 entirely, defeating the claim guard and
176
+ * allowing concurrent `onJobDue` invocations for the same job.
177
+ */
178
+ executeJob(job: Job): Promise<ExecuteResult>;
179
+ removeFromHeap(id: string): void;
180
+ log(message: string): void;
181
+ }
182
+ export {};