@stonyx/cron 0.2.1-alpha.3 → 0.2.1-alpha.31

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,513 @@
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
+ /**
16
+ * Describe a thrown value without ever throwing.
17
+ *
18
+ * `String(err)` is not total: a null-prototype object, or any object whose
19
+ * `toString`/`Symbol.toPrimitive` throws, raises "Cannot convert object to
20
+ * primitive value". Consumer callbacks throw arbitrary values, so the error
21
+ * handler itself must not be a second failure source.
22
+ */
23
+ function describeError(err) {
24
+ if (err instanceof Error)
25
+ return err.message;
26
+ try {
27
+ return String(err);
28
+ }
29
+ catch {
30
+ return 'unknown error';
31
+ }
32
+ }
33
+ export default class CronService {
34
+ jobs;
35
+ heap;
36
+ timer;
37
+ running;
38
+ runLog;
39
+ started;
40
+ // Pluggable callbacks for consumers
41
+ onJobDue;
42
+ constructor() {
43
+ this.jobs = new Map();
44
+ this.heap = new MinHeap();
45
+ this.timer = null;
46
+ this.running = false;
47
+ this.runLog = new RunLog();
48
+ this.started = false;
49
+ this.onJobDue = null;
50
+ }
51
+ // -- Lifecycle -------------------------------------------------------
52
+ /**
53
+ * Start the service. Loads jobs from store (if any), arms timer.
54
+ */
55
+ async start(initialJobs) {
56
+ if (this.started)
57
+ return;
58
+ this.started = true;
59
+ if (initialJobs) {
60
+ for (const job of initialJobs) {
61
+ this.jobs.set(job.id, job);
62
+ if (job.enabled && job.state.nextRunAtMs) {
63
+ this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
64
+ }
65
+ }
66
+ }
67
+ this.armTimer();
68
+ }
69
+ /**
70
+ * Stop the service. Clears timer.
71
+ */
72
+ stop() {
73
+ this.started = false;
74
+ if (this.timer)
75
+ clearTimeout(this.timer);
76
+ this.timer = null;
77
+ }
78
+ // -- CRUD ------------------------------------------------------------
79
+ /**
80
+ * Get service status.
81
+ */
82
+ status() {
83
+ const peek = this.heap.peek();
84
+ return {
85
+ started: this.started,
86
+ jobCount: this.jobs.size,
87
+ nextWakeAtMs: peek ? peek.nextTrigger : undefined,
88
+ };
89
+ }
90
+ /**
91
+ * List jobs, optionally including disabled ones.
92
+ */
93
+ list(opts) {
94
+ const includeDisabled = opts?.includeDisabled ?? false;
95
+ const jobs = [...this.jobs.values()];
96
+ const filtered = includeDisabled ? jobs : jobs.filter(j => j.enabled);
97
+ return filtered.sort((a, b) => (a.state.nextRunAtMs ?? Infinity) - (b.state.nextRunAtMs ?? Infinity));
98
+ }
99
+ /**
100
+ * Get a single job by ID.
101
+ */
102
+ get(id) {
103
+ return this.jobs.get(id) || null;
104
+ }
105
+ /**
106
+ * Add a new job. Input is normalized for AI compatibility.
107
+ */
108
+ async add(rawInput) {
109
+ return locked(() => {
110
+ const input = normalizeJobInput(recoverFlatParams(rawInput));
111
+ const job = createJob(input);
112
+ this.jobs.set(job.id, job);
113
+ if (job.enabled && job.state.nextRunAtMs) {
114
+ this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
115
+ this.armTimer();
116
+ }
117
+ return job;
118
+ });
119
+ }
120
+ /**
121
+ * Update an existing job.
122
+ */
123
+ async update(id, patch) {
124
+ return locked(() => {
125
+ const job = this.jobs.get(id);
126
+ if (!job)
127
+ throw new Error(`Job not found: ${id}`);
128
+ const oldNextRun = job.state.nextRunAtMs;
129
+ updateJob(job, patch);
130
+ // Update heap entry
131
+ this.removeFromHeap(id);
132
+ if (job.enabled && job.state.nextRunAtMs) {
133
+ this.heap.push({ key: id, nextTrigger: job.state.nextRunAtMs });
134
+ }
135
+ if (job.state.nextRunAtMs !== oldNextRun) {
136
+ this.armTimer();
137
+ }
138
+ return job;
139
+ });
140
+ }
141
+ /**
142
+ * Remove a job.
143
+ */
144
+ async remove(id) {
145
+ return locked(() => {
146
+ const job = this.jobs.get(id);
147
+ if (!job)
148
+ throw new Error(`Job not found: ${id}`);
149
+ this.jobs.delete(id);
150
+ this.removeFromHeap(id);
151
+ this.runLog.removeJob(id);
152
+ this.armTimer();
153
+ });
154
+ }
155
+ /**
156
+ * Manually trigger a job.
157
+ *
158
+ * Returns `{ status: 'skipped', reason }` without invoking the callback when
159
+ * the job is not due (`mode: 'due'`), is already in flight
160
+ * (`'already running'`), or was removed before the claim landed
161
+ * (`'removed'`). Before the phase split a forced run against an in-flight job
162
+ * launched a second concurrent invocation; refusing it is AC4 of #34.
163
+ *
164
+ * CONCURRENCY: the same job is bounded to one in-flight invocation on every
165
+ * path, and the timer path invokes due jobs one at a time. `run()` fan-out
166
+ * across DIFFERENT jobs is deliberately unbounded - N concurrent `run()`
167
+ * calls produce N concurrent consumer callbacks. Before the phase split
168
+ * these serialized behind the module-global lock; that serialization was the
169
+ * bug, not the feature (one hung callback wedged every other caller), so it
170
+ * is not being restored here. The fan-out is caller-driven: it is bounded by
171
+ * how many times the consumer chooses to call `run()`, exactly like any other
172
+ * async API, and the scheduler never produces it on its own. A consumer that
173
+ * exposes `run()` over HTTP or a CLI owns that bound the same way it owns
174
+ * request concurrency for every other handler. A per-invoke bound inside the
175
+ * service is tracked separately (#35).
176
+ */
177
+ async run(id, mode = 'force') {
178
+ const job = this.jobs.get(id);
179
+ if (!job)
180
+ throw new Error(`Job not found: ${id}`);
181
+ if (mode === 'due' && !isDue(job, Date.now())) {
182
+ return { status: 'skipped', reason: 'not due' };
183
+ }
184
+ // Deliberately NOT wrapped in locked(): executeJob takes the lock itself
185
+ // for its claim and settle phases only. Wrapping here would re-create the
186
+ // wedge through a second door, since the callback would again be awaited
187
+ // while a lock is held.
188
+ return this.executeJob(job);
189
+ }
190
+ /**
191
+ * Get run history for a job.
192
+ */
193
+ runs(id, limit) {
194
+ return this.runLog.get(id, limit);
195
+ }
196
+ // -- Timer Engine ----------------------------------------------------
197
+ armTimer() {
198
+ if (this.timer)
199
+ clearTimeout(this.timer);
200
+ if (!this.started)
201
+ return;
202
+ const peek = this.heap.peek();
203
+ if (!peek)
204
+ return;
205
+ const delay = Math.min(Math.max(peek.nextTrigger - Date.now(), 0), MAX_TIMER_DELAY_MS);
206
+ this.timer = setTimeout(() => this.onTimer(), delay);
207
+ }
208
+ async onTimer() {
209
+ if (this.running) {
210
+ // Already processing - re-arm at max delay to prevent scheduler death
211
+ this.timer = setTimeout(() => this.onTimer(), MAX_TIMER_DELAY_MS);
212
+ return;
213
+ }
214
+ this.running = true;
215
+ try {
216
+ // Phase 1 - claim (locked). Collecting due jobs pops them off the heap
217
+ // and marking them running makes them un-collectable by anyone else, so
218
+ // both must happen under the same lock.
219
+ const dueJobs = await locked(() => {
220
+ const nowMs = Date.now();
221
+ const due = this.findDueJobs(nowMs);
222
+ for (const job of due) {
223
+ markRunning(job);
224
+ }
225
+ return due;
226
+ });
227
+ // Phases 2 and 3 run outside the claim lock. The consumer callback is
228
+ // awaited here holding no lock at all, so a callback that never settles
229
+ // cannot poison the lock chain.
230
+ for (const job of dueJobs) {
231
+ try {
232
+ await this.#executeClaimed(job);
233
+ }
234
+ catch (err) {
235
+ // One job's unexpected throw must not abort the batch. Every job in
236
+ // `dueJobs` is already claimed - marked running and detached from the
237
+ // heap - and only its own settle releases it, so aborting here would
238
+ // strand every sibling permanently un-due.
239
+ //
240
+ // Reported on an UNGATED channel. `this.log()` returns early when
241
+ // `config.cron.log` is false - a supported production setting - and a
242
+ // failure here permanently unschedules the job while `status()` keeps
243
+ // reporting the service healthy. Silent-and-healthy is the failure
244
+ // class the phase split exists to remove, so the one handler that
245
+ // survives it must not depend on a log flag. `log.error` is a Stonyx
246
+ // system log type, created in the Log constructor rather than by
247
+ // `defineType`, so it is always callable and never gated.
248
+ //
249
+ // This is the outermost handler on the timer path, so it is the one
250
+ // that must not be able to throw. `log` is a shared singleton whose
251
+ // transports can reach the filesystem, so its own failure is
252
+ // swallowed here rather than being allowed to take the batch down.
253
+ try {
254
+ log.error(`Cron — Job "${job.name}" (${job.id}) execution failed unexpectedly: ${describeError(err)}`);
255
+ }
256
+ catch {
257
+ // Nothing left to report to.
258
+ }
259
+ }
260
+ }
261
+ }
262
+ finally {
263
+ this.running = false;
264
+ this.armTimer();
265
+ }
266
+ }
267
+ findDueJobs(nowMs) {
268
+ const due = [];
269
+ while (!this.heap.isEmpty()) {
270
+ const peek = this.heap.peek();
271
+ if (!peek || peek.nextTrigger > nowMs)
272
+ break;
273
+ this.heap.pop();
274
+ const job = this.jobs.get(peek.key);
275
+ if (job && isDue(job, nowMs)) {
276
+ due.push(job);
277
+ }
278
+ }
279
+ return due;
280
+ }
281
+ /**
282
+ * Execute a job in three phases:
283
+ *
284
+ * 1. claim (locked) - take ownership of the job, detach it from the heap
285
+ * 2. invoke (UNLOCKED) - await the consumer callback
286
+ * 3. settle (locked) - apply the result, log it, re-insert into the heap
287
+ *
288
+ * The critical section deliberately excludes phase 2. `onJobDue` is
289
+ * arbitrary, unbounded consumer code; awaiting it under the module-global
290
+ * lock is what wedged every subsequent `locked()` call (add/update/remove)
291
+ * when a callback never settled.
292
+ *
293
+ * `onTimer` performs the batch claim (findDueJobs + markRunning) for all due
294
+ * jobs under a single lock, then enters at phase 2 via `#executeClaimed`.
295
+ * That entry point is a `#private` method rather than a parameter on this
296
+ * one: as a published `alreadyClaimed` boolean it was a supported way for a
297
+ * consumer to skip phase 1 entirely, which defeats the claim guard AC4 asks
298
+ * for and allows concurrent `onJobDue` invocations for the same job.
299
+ */
300
+ async executeJob(job) {
301
+ // -- Phase 1: claim (locked) --
302
+ const refusal = await locked(() => this.#claimJob(job));
303
+ if (refusal)
304
+ return { status: 'skipped', reason: refusal };
305
+ return this.#executeClaimed(job);
306
+ }
307
+ /**
308
+ * Phases 2 and 3 for a job that has already been claimed - either by
309
+ * `executeJob` above or by `onTimer`'s batch claim.
310
+ *
311
+ * Private: reaching this without a claim would run the consumer callback for
312
+ * a job nobody owns.
313
+ */
314
+ async #executeClaimed(job) {
315
+ // Membership re-check. The claim and the invoke are no longer in the same
316
+ // critical section, and sibling callbacks run unlocked, so a `remove()` can
317
+ // land in between AND RESOLVE - it used to deadlock. A resolved `remove()`
318
+ // must keep meaning "this callback will not fire": #settleJob's identity
319
+ // guard only cleans up afterwards, by which point the side effect has
320
+ // already happened. Identity, not id, so a removed-then-replaced key is
321
+ // caught too. Deliberately synchronous with the `onJobDue` call below -
322
+ // nothing can interleave between this check and the invocation.
323
+ //
324
+ // This is the ONE early return in the phase-2/3 control flow that happens
325
+ // after a claim, so it is the one that has to release the claim by hand.
326
+ // (`executeJob`'s refusal return at the call site is pre-claim; `onTimer`'s
327
+ // re-entrancy return never claims; every return inside `#settleJob` is
328
+ // already past phase 3.)
329
+ //
330
+ // Skipping settle entirely here is right - re-inserting or run-logging a
331
+ // removed job is exactly the resurrection `#settleJob`'s identity guard
332
+ // refuses, and its heap entry is already gone. But the claim itself must
333
+ // still come off, because the detached object is NOT unreachable: it is the
334
+ // object `add()` returned and `get()`/`list()` hand out, and
335
+ // `start(initialJobs)` re-registers those objects verbatim, `state`
336
+ // included. Leaving `runningAtMs` set means a consumer that persists jobs
337
+ // rehydrates a permanently dead one - `isDue` false forever, `run()`
338
+ // refused forever, `status()` reporting it healthy. That is the
339
+ // claim-without-settle hazard the try/finally below exists for, reached
340
+ // through a different door.
341
+ //
342
+ // Assigned directly rather than via `applyResult`: this must release the
343
+ // claim and nothing else. No run-log row, no heap entry, no lastStatus, no
344
+ // recomputed nextRunAtMs - the job did not run.
345
+ if (this.jobs.get(job.id) !== job) {
346
+ job.state.runningAtMs = undefined;
347
+ return { status: 'skipped', reason: 'removed' };
348
+ }
349
+ const startMs = Date.now();
350
+ let status = 'ok';
351
+ let error;
352
+ let summary;
353
+ let settled;
354
+ // The claim above marked the job running and detached it from the heap.
355
+ // Phase 3 is the ONLY thing that undoes either, so it must survive every
356
+ // non-local exit from phase 2 - including a throw from the catch handler
357
+ // itself. A claim with no matching settle is not a degraded state, it is a
358
+ // permanently dead job: `runningAtMs` set, no heap entry, `isDue` false
359
+ // forever and `run()` refused forever.
360
+ try {
361
+ // -- Phase 2: invoke (NOT locked) --
362
+ try {
363
+ if (this.onJobDue) {
364
+ const result = await this.onJobDue(job);
365
+ if (result) {
366
+ status = result.status || 'ok';
367
+ error = result.error;
368
+ summary = result.summary;
369
+ }
370
+ }
371
+ }
372
+ catch (err) {
373
+ status = 'error';
374
+ error = describeError(err);
375
+ this.log(`Job "${job.name}" (${job.id}) failed: ${error}`);
376
+ }
377
+ }
378
+ finally {
379
+ // -- Phase 3: settle (locked) --
380
+ settled = await locked(() => this.#settleJob(job, status, error, summary, startMs, Date.now() - startMs));
381
+ }
382
+ return settled;
383
+ }
384
+ /**
385
+ * Phase 1 - claim. Must be called while holding the lock (`locked()`, whose
386
+ * chain is module-global and therefore shared across CronService instances).
387
+ *
388
+ * `#private`, like `#executeClaimed` and for the same reason. Published, this
389
+ * was a supported call performing `markRunning` + `removeFromHeap` with no
390
+ * guaranteed settle - the claim-without-settle shape the try/finally in
391
+ * `#executeClaimed` exists to prevent, and with no lease on `runningAtMs` a
392
+ * single such call permanently strands the job: off the heap, marked running,
393
+ * with nothing that will ever release it. The precondition below cannot be
394
+ * expressed in the type system, so the method must not be reachable from
395
+ * outside the class body.
396
+ *
397
+ * Returns `null` on a successful claim, or the reason the claim was refused.
398
+ * "already running" is what makes a second `run()` report a skip instead of
399
+ * launching a concurrent invocation. "removed" covers the job being deleted
400
+ * between `run()`'s unlocked lookup and this lock turn - claiming then would
401
+ * `markRunning` an orphan and, worse, `removeFromHeap` an id that may now
402
+ * belong to a replacement.
403
+ *
404
+ * Detaching from the heap here (rather than relying on phase 3 to push a
405
+ * fresh entry) is what keeps manual runs from permanently duplicating heap
406
+ * entries.
407
+ */
408
+ #claimJob(job) {
409
+ if (this.jobs.get(job.id) !== job)
410
+ return 'removed';
411
+ if (job.state.runningAtMs)
412
+ return 'already running';
413
+ markRunning(job);
414
+ this.removeFromHeap(job.id);
415
+ return null;
416
+ }
417
+ /**
418
+ * Phase 3 - settle. Must be called while holding the lock.
419
+ *
420
+ * `#private` for the same reason as `#claimJob`: unlocked it would run
421
+ * `applyResult`, a `runLog.record`, a full `removeFromHeap` rebuild, a
422
+ * `heap.push` and an `armTimer` with no mutual exclusion - exactly the
423
+ * corruption `locked()` exists to prevent.
424
+ */
425
+ #settleJob(job, status, error, summary, startMs, durationMs) {
426
+ try {
427
+ const validStatus = (status === 'ok' || status === 'error' || status === 'skipped') ? status : 'error';
428
+ applyResult(job, validStatus, error, durationMs);
429
+ // The callback ran unlocked, so this job may have been removed - or
430
+ // removed and re-registered under the same id (the shape
431
+ // `start(initialJobs)` uses) - while it was in flight. Identity, not id.
432
+ //
433
+ // Deliberately touch NOTHING here. The claim phase already detached this
434
+ // job's own heap entry and nothing re-added it, so there is nothing to
435
+ // clean up; any entry now filed under this id belongs to the
436
+ // replacement, and removing it by id would silently unschedule a live
437
+ // job. Do not resurrect a removed job's heap entry or run log either.
438
+ if (this.jobs.get(job.id) !== job) {
439
+ return { status, error, summary, durationMs };
440
+ }
441
+ // Log the run
442
+ this.runLog.record({
443
+ jobId: job.id,
444
+ status,
445
+ error,
446
+ summary,
447
+ runAtMs: startMs,
448
+ durationMs,
449
+ nextRunAtMs: job.state.nextRunAtMs,
450
+ });
451
+ // Handle one-shot auto-delete. The callback ran unlocked and may have
452
+ // pushed a heap entry for this job via update(), so drop it - the job is
453
+ // about to stop existing.
454
+ if (job.deleteAfterRun && status === 'ok' && !job.enabled) {
455
+ this.jobs.delete(job.id);
456
+ this.removeFromHeap(job.id);
457
+ this.runLog.removeJob(job.id);
458
+ return { status, summary, deleted: true };
459
+ }
460
+ // Re-insert into heap if still active. The callback ran unlocked, so it
461
+ // may itself have added a heap entry for this job (via add/update); drop
462
+ // any such entry first to preserve one-entry-per-key.
463
+ this.removeFromHeap(job.id);
464
+ if (job.enabled && job.state.nextRunAtMs) {
465
+ this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
466
+ }
467
+ return { status, error, summary, durationMs };
468
+ }
469
+ finally {
470
+ // One re-arm covering every exit, rather than one per branch. The claim
471
+ // phase detached this job from the heap, so a timer that fired during the
472
+ // unlocked invoke would have found an empty heap and armed nothing -
473
+ // and `run()` has no `finally { armTimer() }` of its own the way
474
+ // `onTimer` does. Without this a manual run() can leave the scheduler
475
+ // with no pending wake at all.
476
+ this.armTimer();
477
+ }
478
+ }
479
+ // -- Helpers ---------------------------------------------------------
480
+ removeFromHeap(id) {
481
+ // MinHeap doesn't support remove-by-key efficiently,
482
+ // so we rebuild. Fine for typical job counts (< 1000).
483
+ const remaining = [];
484
+ while (!this.heap.isEmpty()) {
485
+ const item = this.heap.pop();
486
+ if (!item)
487
+ break;
488
+ if (item.key !== id)
489
+ remaining.push(item);
490
+ }
491
+ for (const item of remaining) {
492
+ this.heap.push(item);
493
+ }
494
+ }
495
+ log(message) {
496
+ if (!config.cron?.log)
497
+ return;
498
+ // `log.cron` is created by `log.defineType`, which runs in `Cron.init()`
499
+ // (src/main.ts) - a DIFFERENT class. A consumer wiring CronService directly
500
+ // never runs it, while `config/environment.js` defaults `cron.log` to true,
501
+ // so an unguarded call throws `log.cron is not a function`. That throw
502
+ // escapes executeJob's catch, and the error-reporting path must never be
503
+ // the thing that kills the scheduler. `src/types/stonyx.d.ts:19` declares
504
+ // `cron()` unconditionally, so the type system will not catch this.
505
+ const { logColor = '#888', logMethod = 'cron' } = config.cron ?? {};
506
+ if (typeof log[logMethod] !== 'function')
507
+ log.defineType(logMethod, logColor);
508
+ const method = log[logMethod];
509
+ if (typeof method !== 'function')
510
+ return;
511
+ method.call(log, `Cron — ${message}`);
512
+ }
513
+ }
package/package.json CHANGED
@@ -3,23 +3,53 @@
3
3
  "keywords": [
4
4
  "stonyx-module"
5
5
  ],
6
- "version": "0.2.1-alpha.3",
6
+ "version": "0.2.1-alpha.31",
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
  }