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

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,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,85 @@
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
+ interface JobDueResult {
8
+ status?: string;
9
+ error?: string;
10
+ summary?: string;
11
+ }
12
+ interface ExecuteResult {
13
+ status: string;
14
+ error?: string;
15
+ summary?: string;
16
+ durationMs?: number;
17
+ deleted?: boolean;
18
+ reason?: string;
19
+ }
20
+ interface ServiceStatus {
21
+ started: boolean;
22
+ jobCount: number;
23
+ nextWakeAtMs: number | undefined;
24
+ }
25
+ interface ListOptions {
26
+ includeDisabled?: boolean;
27
+ }
28
+ type OnJobDueCallback = (job: Job) => Promise<JobDueResult | void> | JobDueResult | void;
29
+ export default class CronService {
30
+ jobs: Map<string, Job>;
31
+ heap: MinHeap<HeapEntry>;
32
+ timer: ReturnType<typeof setTimeout> | null;
33
+ running: boolean;
34
+ runLog: RunLog;
35
+ started: boolean;
36
+ onJobDue: OnJobDueCallback | null;
37
+ constructor();
38
+ /**
39
+ * Start the service. Loads jobs from store (if any), arms timer.
40
+ */
41
+ start(initialJobs?: Job[]): Promise<void>;
42
+ /**
43
+ * Stop the service. Clears timer.
44
+ */
45
+ stop(): void;
46
+ /**
47
+ * Get service status.
48
+ */
49
+ status(): ServiceStatus;
50
+ /**
51
+ * List jobs, optionally including disabled ones.
52
+ */
53
+ list(opts?: ListOptions): Job[];
54
+ /**
55
+ * Get a single job by ID.
56
+ */
57
+ get(id: string): Job | null;
58
+ /**
59
+ * Add a new job. Input is normalized for AI compatibility.
60
+ */
61
+ add(rawInput: Record<string, unknown>): Promise<Job>;
62
+ /**
63
+ * Update an existing job.
64
+ */
65
+ update(id: string, patch: JobPatch): Promise<Job>;
66
+ /**
67
+ * Remove a job.
68
+ */
69
+ remove(id: string): Promise<void>;
70
+ /**
71
+ * Manually trigger a job.
72
+ */
73
+ run(id: string, mode?: 'due' | 'force'): Promise<ExecuteResult>;
74
+ /**
75
+ * Get run history for a job.
76
+ */
77
+ runs(id: string, limit?: number): ReturnType<RunLog['get']>;
78
+ armTimer(): void;
79
+ onTimer(): Promise<void>;
80
+ findDueJobs(nowMs: number): Job[];
81
+ executeJob(job: Job): Promise<ExecuteResult>;
82
+ removeFromHeap(id: string): void;
83
+ log(message: string): void;
84
+ }
85
+ export {};
@@ -0,0 +1,271 @@
1
+ /**
2
+ * CronService - the main API for advanced job scheduling.
3
+ *
4
+ * Manages jobs in memory with a min-heap for efficient next-job lookup.
5
+ * All state mutations are serialized via async locking.
6
+ */
7
+ import config from 'stonyx/config';
8
+ import log from 'stonyx/log';
9
+ import MinHeap from './min-heap.js';
10
+ import { createJob, updateJob, markRunning, applyResult, isDue } from './job.js';
11
+ import { locked } from './locked.js';
12
+ import { normalizeJobInput, recoverFlatParams } from './normalize.js';
13
+ import RunLog from './run-log.js';
14
+ const MAX_TIMER_DELAY_MS = 60_000;
15
+ export default class CronService {
16
+ jobs;
17
+ heap;
18
+ timer;
19
+ running;
20
+ runLog;
21
+ started;
22
+ // Pluggable callbacks for consumers
23
+ onJobDue;
24
+ constructor() {
25
+ this.jobs = new Map();
26
+ this.heap = new MinHeap();
27
+ this.timer = null;
28
+ this.running = false;
29
+ this.runLog = new RunLog();
30
+ this.started = false;
31
+ this.onJobDue = null;
32
+ }
33
+ // -- Lifecycle -------------------------------------------------------
34
+ /**
35
+ * Start the service. Loads jobs from store (if any), arms timer.
36
+ */
37
+ async start(initialJobs) {
38
+ if (this.started)
39
+ return;
40
+ this.started = true;
41
+ if (initialJobs) {
42
+ for (const job of initialJobs) {
43
+ this.jobs.set(job.id, job);
44
+ if (job.enabled && job.state.nextRunAtMs) {
45
+ this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
46
+ }
47
+ }
48
+ }
49
+ this.armTimer();
50
+ }
51
+ /**
52
+ * Stop the service. Clears timer.
53
+ */
54
+ stop() {
55
+ this.started = false;
56
+ if (this.timer)
57
+ clearTimeout(this.timer);
58
+ this.timer = null;
59
+ }
60
+ // -- CRUD ------------------------------------------------------------
61
+ /**
62
+ * Get service status.
63
+ */
64
+ status() {
65
+ const peek = this.heap.peek();
66
+ return {
67
+ started: this.started,
68
+ jobCount: this.jobs.size,
69
+ nextWakeAtMs: peek ? peek.nextTrigger : undefined,
70
+ };
71
+ }
72
+ /**
73
+ * List jobs, optionally including disabled ones.
74
+ */
75
+ list(opts) {
76
+ const includeDisabled = opts?.includeDisabled ?? false;
77
+ const jobs = [...this.jobs.values()];
78
+ const filtered = includeDisabled ? jobs : jobs.filter(j => j.enabled);
79
+ return filtered.sort((a, b) => (a.state.nextRunAtMs ?? Infinity) - (b.state.nextRunAtMs ?? Infinity));
80
+ }
81
+ /**
82
+ * Get a single job by ID.
83
+ */
84
+ get(id) {
85
+ return this.jobs.get(id) || null;
86
+ }
87
+ /**
88
+ * Add a new job. Input is normalized for AI compatibility.
89
+ */
90
+ async add(rawInput) {
91
+ return locked(() => {
92
+ const input = normalizeJobInput(recoverFlatParams(rawInput));
93
+ const job = createJob(input);
94
+ this.jobs.set(job.id, job);
95
+ if (job.enabled && job.state.nextRunAtMs) {
96
+ this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
97
+ this.armTimer();
98
+ }
99
+ return job;
100
+ });
101
+ }
102
+ /**
103
+ * Update an existing job.
104
+ */
105
+ async update(id, patch) {
106
+ return locked(() => {
107
+ const job = this.jobs.get(id);
108
+ if (!job)
109
+ throw new Error(`Job not found: ${id}`);
110
+ const oldNextRun = job.state.nextRunAtMs;
111
+ updateJob(job, patch);
112
+ // Update heap entry
113
+ this.removeFromHeap(id);
114
+ if (job.enabled && job.state.nextRunAtMs) {
115
+ this.heap.push({ key: id, nextTrigger: job.state.nextRunAtMs });
116
+ }
117
+ if (job.state.nextRunAtMs !== oldNextRun) {
118
+ this.armTimer();
119
+ }
120
+ return job;
121
+ });
122
+ }
123
+ /**
124
+ * Remove a job.
125
+ */
126
+ async remove(id) {
127
+ return locked(() => {
128
+ const job = this.jobs.get(id);
129
+ if (!job)
130
+ throw new Error(`Job not found: ${id}`);
131
+ this.jobs.delete(id);
132
+ this.removeFromHeap(id);
133
+ this.runLog.removeJob(id);
134
+ this.armTimer();
135
+ });
136
+ }
137
+ /**
138
+ * Manually trigger a job.
139
+ */
140
+ async run(id, mode = 'force') {
141
+ const job = this.jobs.get(id);
142
+ if (!job)
143
+ throw new Error(`Job not found: ${id}`);
144
+ if (mode === 'due' && !isDue(job, Date.now())) {
145
+ return { status: 'skipped', reason: 'not due' };
146
+ }
147
+ return this.executeJob(job);
148
+ }
149
+ /**
150
+ * Get run history for a job.
151
+ */
152
+ runs(id, limit) {
153
+ return this.runLog.get(id, limit);
154
+ }
155
+ // -- Timer Engine ----------------------------------------------------
156
+ armTimer() {
157
+ if (this.timer)
158
+ clearTimeout(this.timer);
159
+ if (!this.started)
160
+ return;
161
+ const peek = this.heap.peek();
162
+ if (!peek)
163
+ return;
164
+ const delay = Math.min(Math.max(peek.nextTrigger - Date.now(), 0), MAX_TIMER_DELAY_MS);
165
+ this.timer = setTimeout(() => this.onTimer(), delay);
166
+ }
167
+ async onTimer() {
168
+ if (this.running) {
169
+ // Already processing - re-arm at max delay to prevent scheduler death
170
+ this.timer = setTimeout(() => this.onTimer(), MAX_TIMER_DELAY_MS);
171
+ return;
172
+ }
173
+ this.running = true;
174
+ try {
175
+ await locked(async () => {
176
+ const nowMs = Date.now();
177
+ const dueJobs = this.findDueJobs(nowMs);
178
+ for (const job of dueJobs) {
179
+ markRunning(job);
180
+ }
181
+ for (const job of dueJobs) {
182
+ await this.executeJob(job);
183
+ }
184
+ });
185
+ }
186
+ finally {
187
+ this.running = false;
188
+ this.armTimer();
189
+ }
190
+ }
191
+ findDueJobs(nowMs) {
192
+ const due = [];
193
+ while (!this.heap.isEmpty()) {
194
+ const peek = this.heap.peek();
195
+ if (!peek || peek.nextTrigger > nowMs)
196
+ break;
197
+ this.heap.pop();
198
+ const job = this.jobs.get(peek.key);
199
+ if (job && isDue(job, nowMs)) {
200
+ due.push(job);
201
+ }
202
+ }
203
+ return due;
204
+ }
205
+ async executeJob(job) {
206
+ const startMs = Date.now();
207
+ let status = 'ok';
208
+ let error;
209
+ let summary;
210
+ try {
211
+ if (this.onJobDue) {
212
+ const result = await this.onJobDue(job);
213
+ if (result) {
214
+ status = result.status || 'ok';
215
+ error = result.error;
216
+ summary = result.summary;
217
+ }
218
+ }
219
+ }
220
+ catch (err) {
221
+ status = 'error';
222
+ error = err instanceof Error ? err.message : String(err);
223
+ this.log(`Job "${job.name}" (${job.id}) failed: ${error}`);
224
+ }
225
+ const durationMs = Date.now() - startMs;
226
+ const validStatus = (status === 'ok' || status === 'error' || status === 'skipped') ? status : 'error';
227
+ applyResult(job, validStatus, error, durationMs);
228
+ // Log the run
229
+ this.runLog.record({
230
+ jobId: job.id,
231
+ status,
232
+ error,
233
+ summary,
234
+ runAtMs: startMs,
235
+ durationMs,
236
+ nextRunAtMs: job.state.nextRunAtMs,
237
+ });
238
+ // Handle one-shot auto-delete
239
+ if (job.deleteAfterRun && status === 'ok' && !job.enabled) {
240
+ this.jobs.delete(job.id);
241
+ this.runLog.removeJob(job.id);
242
+ return { status, summary, deleted: true };
243
+ }
244
+ // Re-insert into heap if still active
245
+ if (job.enabled && job.state.nextRunAtMs) {
246
+ this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
247
+ }
248
+ return { status, error, summary, durationMs };
249
+ }
250
+ // -- Helpers ---------------------------------------------------------
251
+ removeFromHeap(id) {
252
+ // MinHeap doesn't support remove-by-key efficiently,
253
+ // so we rebuild. Fine for typical job counts (< 1000).
254
+ const remaining = [];
255
+ while (!this.heap.isEmpty()) {
256
+ const item = this.heap.pop();
257
+ if (!item)
258
+ break;
259
+ if (item.key !== id)
260
+ remaining.push(item);
261
+ }
262
+ for (const item of remaining) {
263
+ this.heap.push(item);
264
+ }
265
+ }
266
+ log(message) {
267
+ if (!config.cron?.log)
268
+ return;
269
+ log.cron(`Cron — ${message}`);
270
+ }
271
+ }
package/package.json CHANGED
@@ -3,23 +3,53 @@
3
3
  "keywords": [
4
4
  "stonyx-module"
5
5
  ],
6
- "version": "0.2.1-alpha.2",
6
+ "version": "0.2.1-alpha.20",
7
7
  "description": "Cron/job scheduler for Stonyx framework",
8
- "main": "src/main.js",
8
+ "main": "dist/main.js",
9
+ "types": "dist/main.d.ts",
9
10
  "type": "module",
10
11
  "files": [
11
- "*"
12
+ "dist",
13
+ "config",
14
+ "README.md"
12
15
  ],
13
16
  "exports": {
14
- ".": "./src/main.js",
15
- "./service": "./src/service.js",
16
- "./cron-parser": "./src/cron-parser.js",
17
- "./schedule": "./src/schedule.js",
18
- "./job": "./src/job.js",
19
- "./normalize": "./src/normalize.js",
20
- "./locked": "./src/locked.js",
21
- "./run-log": "./src/run-log.js",
22
- "./min-heap": "./src/min-heap.js"
17
+ ".": {
18
+ "types": "./dist/main.d.ts",
19
+ "default": "./dist/main.js"
20
+ },
21
+ "./service": {
22
+ "types": "./dist/service.d.ts",
23
+ "default": "./dist/service.js"
24
+ },
25
+ "./cron-parser": {
26
+ "types": "./dist/cron-parser.d.ts",
27
+ "default": "./dist/cron-parser.js"
28
+ },
29
+ "./schedule": {
30
+ "types": "./dist/schedule.d.ts",
31
+ "default": "./dist/schedule.js"
32
+ },
33
+ "./job": {
34
+ "types": "./dist/job.d.ts",
35
+ "default": "./dist/job.js"
36
+ },
37
+ "./normalize": {
38
+ "types": "./dist/normalize.d.ts",
39
+ "default": "./dist/normalize.js"
40
+ },
41
+ "./locked": {
42
+ "types": "./dist/locked.d.ts",
43
+ "default": "./dist/locked.js"
44
+ },
45
+ "./run-log": {
46
+ "types": "./dist/run-log.d.ts",
47
+ "default": "./dist/run-log.js"
48
+ },
49
+ "./min-heap": {
50
+ "types": "./dist/min-heap.d.ts",
51
+ "default": "./dist/min-heap.js"
52
+ }
23
53
  },
24
54
  "publishConfig": {
25
55
  "access": "public",
@@ -39,14 +69,21 @@
39
69
  },
40
70
  "homepage": "https://github.com/abofs/stonyx-cron#readme",
41
71
  "devDependencies": {
42
- "@stonyx/utils": "0.2.3-beta.7",
72
+ "@stonyx/utils": "0.2.3-beta.26",
73
+ "@types/node": "^25.5.2",
74
+ "@types/qunit": "^2.19.13",
75
+ "@types/sinon": "^21.0.1",
43
76
  "qunit": "^2.24.1",
44
- "sinon": "^21.0.0"
77
+ "sinon": "^21.0.0",
78
+ "tsx": "^4.21.0",
79
+ "typescript": "^5.8.3"
45
80
  },
46
81
  "dependencies": {
47
- "stonyx": "0.2.3-beta.11"
82
+ "stonyx": "0.2.3-beta.76"
48
83
  },
49
84
  "scripts": {
50
- "test": "stonyx test"
85
+ "build": "tsc",
86
+ "build:test": "tsc -p tsconfig.test.json",
87
+ "test": "pnpm build && NODE_ENV=test node --import tsx/esm --import ./test/setup.ts node_modules/qunit/bin/qunit.js 'test/**/*-test.ts'"
51
88
  }
52
89
  }