@stonyx/cron 0.2.0 → 0.2.1-alpha.1

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.
package/src/service.js ADDED
@@ -0,0 +1,303 @@
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
+ * Supports pluggable store interface (memory-only by default, ORM in PR 2).
6
+ * All state mutations are serialized via async locking.
7
+ */
8
+ import config from 'stonyx/config';
9
+ import log from 'stonyx/log';
10
+ import MinHeap from './min-heap.js';
11
+ import { createJob, updateJob, markRunning, applyResult, isDue } from './job.js';
12
+ import { computeNextRunAtMs } from './schedule.js';
13
+ import { locked } from './locked.js';
14
+ import { normalizeJobInput, recoverFlatParams } from './normalize.js';
15
+ import RunLog from './run-log.js';
16
+
17
+ const MAX_TIMER_DELAY_MS = 60_000;
18
+
19
+ export default class CronService {
20
+ constructor() {
21
+ this.jobs = new Map(); // id → job
22
+ this.heap = new MinHeap(); // ordered by nextRunAtMs
23
+ this.timer = null;
24
+ this.running = false;
25
+ this.runLog = new RunLog();
26
+ this.started = false;
27
+
28
+ // Pluggable callbacks for consumers
29
+ this.onJobDue = null; // async (job) => { status, error?, summary? }
30
+ }
31
+
32
+ // ── Lifecycle ──────────────────────────────────────────────
33
+
34
+ /**
35
+ * Start the service. Loads jobs from store (if any), arms timer.
36
+ */
37
+ async start(initialJobs) {
38
+ if (this.started) return;
39
+ this.started = true;
40
+
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
+
50
+ this.armTimer();
51
+ }
52
+
53
+ /**
54
+ * Stop the service. Clears timer.
55
+ */
56
+ stop() {
57
+ this.started = false;
58
+ clearTimeout(this.timer);
59
+ this.timer = null;
60
+ }
61
+
62
+ // ── CRUD ───────────────────────────────────────────────────
63
+
64
+ /**
65
+ * Get service status.
66
+ */
67
+ status() {
68
+ const peek = this.heap.peek();
69
+ return {
70
+ started: this.started,
71
+ jobCount: this.jobs.size,
72
+ nextWakeAtMs: peek ? peek.nextTrigger : undefined,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * List jobs, optionally including disabled ones.
78
+ */
79
+ list(opts) {
80
+ const includeDisabled = opts?.includeDisabled ?? false;
81
+ const jobs = [...this.jobs.values()];
82
+ const filtered = includeDisabled ? jobs : jobs.filter(j => j.enabled);
83
+ return filtered.sort((a, b) => (a.state.nextRunAtMs ?? Infinity) - (b.state.nextRunAtMs ?? Infinity));
84
+ }
85
+
86
+ /**
87
+ * Get a single job by ID.
88
+ */
89
+ get(id) {
90
+ return this.jobs.get(id) || null;
91
+ }
92
+
93
+ /**
94
+ * Add a new job. Input is normalized for AI compatibility.
95
+ */
96
+ async add(rawInput) {
97
+ return locked(() => {
98
+ const input = normalizeJobInput(recoverFlatParams(rawInput));
99
+ const job = createJob(input);
100
+ this.jobs.set(job.id, job);
101
+
102
+ if (job.enabled && job.state.nextRunAtMs) {
103
+ this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
104
+ this.armTimer();
105
+ }
106
+
107
+ return job;
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Update an existing job.
113
+ */
114
+ async update(id, patch) {
115
+ return locked(() => {
116
+ const job = this.jobs.get(id);
117
+ if (!job) throw new Error(`Job not found: ${id}`);
118
+
119
+ const oldNextRun = job.state.nextRunAtMs;
120
+ updateJob(job, patch);
121
+
122
+ // Update heap entry
123
+ this.removeFromHeap(id);
124
+ if (job.enabled && job.state.nextRunAtMs) {
125
+ this.heap.push({ key: id, nextTrigger: job.state.nextRunAtMs });
126
+ }
127
+
128
+ if (job.state.nextRunAtMs !== oldNextRun) {
129
+ this.armTimer();
130
+ }
131
+
132
+ return job;
133
+ });
134
+ }
135
+
136
+ /**
137
+ * Remove a job.
138
+ */
139
+ async remove(id) {
140
+ return locked(() => {
141
+ const job = this.jobs.get(id);
142
+ if (!job) throw new Error(`Job not found: ${id}`);
143
+
144
+ this.jobs.delete(id);
145
+ this.removeFromHeap(id);
146
+ this.runLog.removeJob(id);
147
+ this.armTimer();
148
+ });
149
+ }
150
+
151
+ /**
152
+ * Manually trigger a job.
153
+ *
154
+ * @param {string} id - Job ID
155
+ * @param {"due"|"force"} [mode="force"] - "due" only runs if the job is due, "force" runs regardless
156
+ */
157
+ async run(id, mode = 'force') {
158
+ const job = this.jobs.get(id);
159
+ if (!job) throw new Error(`Job not found: ${id}`);
160
+
161
+ if (mode === 'due' && !isDue(job, Date.now())) {
162
+ return { status: 'skipped', reason: 'not due' };
163
+ }
164
+
165
+ return this.executeJob(job);
166
+ }
167
+
168
+ /**
169
+ * Get run history for a job.
170
+ */
171
+ runs(id, limit) {
172
+ return this.runLog.get(id, limit);
173
+ }
174
+
175
+ // ── Timer Engine ──────────────────────────────────────��────
176
+
177
+ armTimer() {
178
+ clearTimeout(this.timer);
179
+ if (!this.started) return;
180
+
181
+ const peek = this.heap.peek();
182
+ if (!peek) return;
183
+
184
+ const delay = Math.min(Math.max(peek.nextTrigger - Date.now(), 0), MAX_TIMER_DELAY_MS);
185
+ this.timer = setTimeout(() => this.onTimer(), delay);
186
+ }
187
+
188
+ async onTimer() {
189
+ if (this.running) {
190
+ // Already processing — re-arm at max delay to prevent scheduler death
191
+ this.timer = setTimeout(() => this.onTimer(), MAX_TIMER_DELAY_MS);
192
+ return;
193
+ }
194
+
195
+ this.running = true;
196
+
197
+ try {
198
+ await locked(async () => {
199
+ const nowMs = Date.now();
200
+ const dueJobs = this.findDueJobs(nowMs);
201
+
202
+ for (const job of dueJobs) {
203
+ markRunning(job);
204
+ }
205
+
206
+ for (const job of dueJobs) {
207
+ await this.executeJob(job);
208
+ }
209
+ });
210
+ } finally {
211
+ this.running = false;
212
+ this.armTimer();
213
+ }
214
+ }
215
+
216
+ findDueJobs(nowMs) {
217
+ const due = [];
218
+
219
+ while (!this.heap.isEmpty()) {
220
+ const peek = this.heap.peek();
221
+ if (peek.nextTrigger > nowMs) break;
222
+
223
+ this.heap.pop();
224
+ const job = this.jobs.get(peek.key);
225
+ if (job && isDue(job, nowMs)) {
226
+ due.push(job);
227
+ }
228
+ }
229
+
230
+ return due;
231
+ }
232
+
233
+ async executeJob(job) {
234
+ const startMs = Date.now();
235
+ let status = 'ok';
236
+ let error;
237
+ let summary;
238
+
239
+ try {
240
+ if (this.onJobDue) {
241
+ const result = await this.onJobDue(job);
242
+ if (result) {
243
+ status = result.status || 'ok';
244
+ error = result.error;
245
+ summary = result.summary;
246
+ }
247
+ }
248
+ } catch (err) {
249
+ status = 'error';
250
+ error = err?.message || String(err);
251
+ this.log(`Job "${job.name}" (${job.id}) failed: ${error}`);
252
+ }
253
+
254
+ const durationMs = Date.now() - startMs;
255
+
256
+ applyResult(job, status, error, durationMs);
257
+
258
+ // Log the run
259
+ this.runLog.record({
260
+ jobId: job.id,
261
+ status,
262
+ error,
263
+ summary,
264
+ runAtMs: startMs,
265
+ durationMs,
266
+ nextRunAtMs: job.state.nextRunAtMs,
267
+ });
268
+
269
+ // Handle one-shot auto-delete
270
+ if (job.deleteAfterRun && status === 'ok' && !job.enabled) {
271
+ this.jobs.delete(job.id);
272
+ this.runLog.removeJob(job.id);
273
+ return { status, summary, deleted: true };
274
+ }
275
+
276
+ // Re-insert into heap if still active
277
+ if (job.enabled && job.state.nextRunAtMs) {
278
+ this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
279
+ }
280
+
281
+ return { status, error, summary, durationMs };
282
+ }
283
+
284
+ // ── Helpers ────────────────────────────────────────────────
285
+
286
+ removeFromHeap(id) {
287
+ // MinHeap doesn't support remove-by-key efficiently,
288
+ // so we rebuild. Fine for typical job counts (< 1000).
289
+ const remaining = [];
290
+ while (!this.heap.isEmpty()) {
291
+ const item = this.heap.pop();
292
+ if (item.key !== id) remaining.push(item);
293
+ }
294
+ for (const item of remaining) {
295
+ this.heap.push(item);
296
+ }
297
+ }
298
+
299
+ log(message) {
300
+ if (!config.cron?.log) return;
301
+ log.cron(`Cron — ${message}`);
302
+ }
303
+ }
@@ -1,15 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(node --version:*)",
5
- "Bash(source ~/.nvm/nvm.sh)",
6
- "Bash(nvm use)",
7
- "Bash(pnpm install:*)",
8
- "Bash(pnpm store:*)",
9
- "Bash(npm view:*)",
10
- "Bash(npm publish:*)",
11
- "Bash(npm version:*)",
12
- "Bash(curl:*)"
13
- ]
14
- }
15
- }
package/.git/config DELETED
@@ -1,24 +0,0 @@
1
- [core]
2
- repositoryformatversion = 0
3
- filemode = true
4
- bare = false
5
- logallrefupdates = true
6
- ignorecase = true
7
- precomposeunicode = true
8
- [remote "origin"]
9
- url = git@github.com:abofs/stonyx-cron.git
10
- fetch = +refs/heads/*:refs/remotes/origin/*
11
- [branch "dev"]
12
- remote = origin
13
- merge = refs/heads/dev
14
- vscode-merge-base = origin/dev
15
- [branch "stone/cron-updates"]
16
- remote = origin
17
- merge = refs/heads/stone/cron-updates
18
- vscode-merge-base = origin/stone/cron-updates
19
- gk-last-accessed = 2026-01-26T20:25:14.543Z
20
- gk-last-modified = 2026-01-26T20:25:14.543Z
21
- [branch "main"]
22
- remote = origin
23
- merge = refs/heads/main
24
- vscode-merge-base = origin/main
@@ -1,9 +0,0 @@
1
- /**
2
- * commonJS Bootstrap loading - Stonyx must be loaded first, prior to the rest of the application
3
- */
4
- const { default:Stonyx } = require('stonyx');
5
- const { default:config } = require('./config/environment.js');
6
-
7
- new Stonyx(config, __dirname);
8
-
9
- module.exports = Stonyx;