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

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,504 @@
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
+ // This is the outermost handler on the timer path, so it is the one
241
+ // that must not be able to throw. `log()` is public, overridable and
242
+ // can reach a file transport, so its own failure is swallowed here
243
+ // rather than being allowed to take the batch down.
244
+ try {
245
+ this.log(`Job "${job.name}" (${job.id}) execution failed unexpectedly: ${describeError(err)}`);
246
+ }
247
+ catch {
248
+ // Nothing left to report to.
249
+ }
250
+ }
251
+ }
252
+ }
253
+ finally {
254
+ this.running = false;
255
+ this.armTimer();
256
+ }
257
+ }
258
+ findDueJobs(nowMs) {
259
+ const due = [];
260
+ while (!this.heap.isEmpty()) {
261
+ const peek = this.heap.peek();
262
+ if (!peek || peek.nextTrigger > nowMs)
263
+ break;
264
+ this.heap.pop();
265
+ const job = this.jobs.get(peek.key);
266
+ if (job && isDue(job, nowMs)) {
267
+ due.push(job);
268
+ }
269
+ }
270
+ return due;
271
+ }
272
+ /**
273
+ * Execute a job in three phases:
274
+ *
275
+ * 1. claim (locked) - take ownership of the job, detach it from the heap
276
+ * 2. invoke (UNLOCKED) - await the consumer callback
277
+ * 3. settle (locked) - apply the result, log it, re-insert into the heap
278
+ *
279
+ * The critical section deliberately excludes phase 2. `onJobDue` is
280
+ * arbitrary, unbounded consumer code; awaiting it under the module-global
281
+ * lock is what wedged every subsequent `locked()` call (add/update/remove)
282
+ * when a callback never settled.
283
+ *
284
+ * `onTimer` performs the batch claim (findDueJobs + markRunning) for all due
285
+ * jobs under a single lock, then enters at phase 2 via `#executeClaimed`.
286
+ * That entry point is a `#private` method rather than a parameter on this
287
+ * one: as a published `alreadyClaimed` boolean it was a supported way for a
288
+ * consumer to skip phase 1 entirely, which defeats the claim guard AC4 asks
289
+ * for and allows concurrent `onJobDue` invocations for the same job.
290
+ */
291
+ async executeJob(job) {
292
+ // -- Phase 1: claim (locked) --
293
+ const refusal = await locked(() => this.#claimJob(job));
294
+ if (refusal)
295
+ return { status: 'skipped', reason: refusal };
296
+ return this.#executeClaimed(job);
297
+ }
298
+ /**
299
+ * Phases 2 and 3 for a job that has already been claimed - either by
300
+ * `executeJob` above or by `onTimer`'s batch claim.
301
+ *
302
+ * Private: reaching this without a claim would run the consumer callback for
303
+ * a job nobody owns.
304
+ */
305
+ async #executeClaimed(job) {
306
+ // Membership re-check. The claim and the invoke are no longer in the same
307
+ // critical section, and sibling callbacks run unlocked, so a `remove()` can
308
+ // land in between AND RESOLVE - it used to deadlock. A resolved `remove()`
309
+ // must keep meaning "this callback will not fire": #settleJob's identity
310
+ // guard only cleans up afterwards, by which point the side effect has
311
+ // already happened. Identity, not id, so a removed-then-replaced key is
312
+ // caught too. Deliberately synchronous with the `onJobDue` call below -
313
+ // nothing can interleave between this check and the invocation.
314
+ //
315
+ // This is the ONE early return in the phase-2/3 control flow that happens
316
+ // after a claim, so it is the one that has to release the claim by hand.
317
+ // (`executeJob`'s refusal return at the call site is pre-claim; `onTimer`'s
318
+ // re-entrancy return never claims; every return inside `#settleJob` is
319
+ // already past phase 3.)
320
+ //
321
+ // Skipping settle entirely here is right - re-inserting or run-logging a
322
+ // removed job is exactly the resurrection `#settleJob`'s identity guard
323
+ // refuses, and its heap entry is already gone. But the claim itself must
324
+ // still come off, because the detached object is NOT unreachable: it is the
325
+ // object `add()` returned and `get()`/`list()` hand out, and
326
+ // `start(initialJobs)` re-registers those objects verbatim, `state`
327
+ // included. Leaving `runningAtMs` set means a consumer that persists jobs
328
+ // rehydrates a permanently dead one - `isDue` false forever, `run()`
329
+ // refused forever, `status()` reporting it healthy. That is the
330
+ // claim-without-settle hazard the try/finally below exists for, reached
331
+ // through a different door.
332
+ //
333
+ // Assigned directly rather than via `applyResult`: this must release the
334
+ // claim and nothing else. No run-log row, no heap entry, no lastStatus, no
335
+ // recomputed nextRunAtMs - the job did not run.
336
+ if (this.jobs.get(job.id) !== job) {
337
+ job.state.runningAtMs = undefined;
338
+ return { status: 'skipped', reason: 'removed' };
339
+ }
340
+ const startMs = Date.now();
341
+ let status = 'ok';
342
+ let error;
343
+ let summary;
344
+ let settled;
345
+ // The claim above marked the job running and detached it from the heap.
346
+ // Phase 3 is the ONLY thing that undoes either, so it must survive every
347
+ // non-local exit from phase 2 - including a throw from the catch handler
348
+ // itself. A claim with no matching settle is not a degraded state, it is a
349
+ // permanently dead job: `runningAtMs` set, no heap entry, `isDue` false
350
+ // forever and `run()` refused forever.
351
+ try {
352
+ // -- Phase 2: invoke (NOT locked) --
353
+ try {
354
+ if (this.onJobDue) {
355
+ const result = await this.onJobDue(job);
356
+ if (result) {
357
+ status = result.status || 'ok';
358
+ error = result.error;
359
+ summary = result.summary;
360
+ }
361
+ }
362
+ }
363
+ catch (err) {
364
+ status = 'error';
365
+ error = describeError(err);
366
+ this.log(`Job "${job.name}" (${job.id}) failed: ${error}`);
367
+ }
368
+ }
369
+ finally {
370
+ // -- Phase 3: settle (locked) --
371
+ settled = await locked(() => this.#settleJob(job, status, error, summary, startMs, Date.now() - startMs));
372
+ }
373
+ return settled;
374
+ }
375
+ /**
376
+ * Phase 1 - claim. Must be called while holding the lock (`locked()`, whose
377
+ * chain is module-global and therefore shared across CronService instances).
378
+ *
379
+ * `#private`, like `#executeClaimed` and for the same reason. Published, this
380
+ * was a supported call performing `markRunning` + `removeFromHeap` with no
381
+ * guaranteed settle - the claim-without-settle shape the try/finally in
382
+ * `#executeClaimed` exists to prevent, and with no lease on `runningAtMs` a
383
+ * single such call permanently strands the job: off the heap, marked running,
384
+ * with nothing that will ever release it. The precondition below cannot be
385
+ * expressed in the type system, so the method must not be reachable from
386
+ * outside the class body.
387
+ *
388
+ * Returns `null` on a successful claim, or the reason the claim was refused.
389
+ * "already running" is what makes a second `run()` report a skip instead of
390
+ * launching a concurrent invocation. "removed" covers the job being deleted
391
+ * between `run()`'s unlocked lookup and this lock turn - claiming then would
392
+ * `markRunning` an orphan and, worse, `removeFromHeap` an id that may now
393
+ * belong to a replacement.
394
+ *
395
+ * Detaching from the heap here (rather than relying on phase 3 to push a
396
+ * fresh entry) is what keeps manual runs from permanently duplicating heap
397
+ * entries.
398
+ */
399
+ #claimJob(job) {
400
+ if (this.jobs.get(job.id) !== job)
401
+ return 'removed';
402
+ if (job.state.runningAtMs)
403
+ return 'already running';
404
+ markRunning(job);
405
+ this.removeFromHeap(job.id);
406
+ return null;
407
+ }
408
+ /**
409
+ * Phase 3 - settle. Must be called while holding the lock.
410
+ *
411
+ * `#private` for the same reason as `#claimJob`: unlocked it would run
412
+ * `applyResult`, a `runLog.record`, a full `removeFromHeap` rebuild, a
413
+ * `heap.push` and an `armTimer` with no mutual exclusion - exactly the
414
+ * corruption `locked()` exists to prevent.
415
+ */
416
+ #settleJob(job, status, error, summary, startMs, durationMs) {
417
+ try {
418
+ const validStatus = (status === 'ok' || status === 'error' || status === 'skipped') ? status : 'error';
419
+ applyResult(job, validStatus, error, durationMs);
420
+ // The callback ran unlocked, so this job may have been removed - or
421
+ // removed and re-registered under the same id (the shape
422
+ // `start(initialJobs)` uses) - while it was in flight. Identity, not id.
423
+ //
424
+ // Deliberately touch NOTHING here. The claim phase already detached this
425
+ // job's own heap entry and nothing re-added it, so there is nothing to
426
+ // clean up; any entry now filed under this id belongs to the
427
+ // replacement, and removing it by id would silently unschedule a live
428
+ // job. Do not resurrect a removed job's heap entry or run log either.
429
+ if (this.jobs.get(job.id) !== job) {
430
+ return { status, error, summary, durationMs };
431
+ }
432
+ // Log the run
433
+ this.runLog.record({
434
+ jobId: job.id,
435
+ status,
436
+ error,
437
+ summary,
438
+ runAtMs: startMs,
439
+ durationMs,
440
+ nextRunAtMs: job.state.nextRunAtMs,
441
+ });
442
+ // Handle one-shot auto-delete. The callback ran unlocked and may have
443
+ // pushed a heap entry for this job via update(), so drop it - the job is
444
+ // about to stop existing.
445
+ if (job.deleteAfterRun && status === 'ok' && !job.enabled) {
446
+ this.jobs.delete(job.id);
447
+ this.removeFromHeap(job.id);
448
+ this.runLog.removeJob(job.id);
449
+ return { status, summary, deleted: true };
450
+ }
451
+ // Re-insert into heap if still active. The callback ran unlocked, so it
452
+ // may itself have added a heap entry for this job (via add/update); drop
453
+ // any such entry first to preserve one-entry-per-key.
454
+ this.removeFromHeap(job.id);
455
+ if (job.enabled && job.state.nextRunAtMs) {
456
+ this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
457
+ }
458
+ return { status, error, summary, durationMs };
459
+ }
460
+ finally {
461
+ // One re-arm covering every exit, rather than one per branch. The claim
462
+ // phase detached this job from the heap, so a timer that fired during the
463
+ // unlocked invoke would have found an empty heap and armed nothing -
464
+ // and `run()` has no `finally { armTimer() }` of its own the way
465
+ // `onTimer` does. Without this a manual run() can leave the scheduler
466
+ // with no pending wake at all.
467
+ this.armTimer();
468
+ }
469
+ }
470
+ // -- Helpers ---------------------------------------------------------
471
+ removeFromHeap(id) {
472
+ // MinHeap doesn't support remove-by-key efficiently,
473
+ // so we rebuild. Fine for typical job counts (< 1000).
474
+ const remaining = [];
475
+ while (!this.heap.isEmpty()) {
476
+ const item = this.heap.pop();
477
+ if (!item)
478
+ break;
479
+ if (item.key !== id)
480
+ remaining.push(item);
481
+ }
482
+ for (const item of remaining) {
483
+ this.heap.push(item);
484
+ }
485
+ }
486
+ log(message) {
487
+ if (!config.cron?.log)
488
+ return;
489
+ // `log.cron` is created by `log.defineType`, which runs in `Cron.init()`
490
+ // (src/main.ts) - a DIFFERENT class. A consumer wiring CronService directly
491
+ // never runs it, while `config/environment.js` defaults `cron.log` to true,
492
+ // so an unguarded call throws `log.cron is not a function`. That throw
493
+ // escapes executeJob's catch, and the error-reporting path must never be
494
+ // the thing that kills the scheduler. `src/types/stonyx.d.ts:19` declares
495
+ // `cron()` unconditionally, so the type system will not catch this.
496
+ const { logColor = '#888', logMethod = 'cron' } = config.cron ?? {};
497
+ if (typeof log[logMethod] !== 'function')
498
+ log.defineType(logMethod, logColor);
499
+ const method = log[logMethod];
500
+ if (typeof method !== 'function')
501
+ return;
502
+ method.call(log, `Cron — ${message}`);
503
+ }
504
+ }
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.30",
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
  }