@basaltkit/scheduler 1.2.1 → 1.3.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/README.md CHANGED
@@ -208,10 +208,15 @@ respects each entry's overlap guard and `onFailure` handler.
208
208
 
209
209
  Registers a `Scheduler` (singleton) under the `SCHEDULER` token; on `boot` it calls `define`, publishes the entries to the container's metadata (key `schedule:entries`, consumed by `basalt schedule:list`), registers the `schedule:run` command, and starts the timer; on `shutdown` it calls `stop()`.
210
210
 
211
- | Option | Type | Required? | Default | Description |
212
- |---|---|---|---|---|
213
- | `define` | `(schedule: Scheduler) => void` | No | — | Callback where you declare the schedules (receives the Scheduler at boot). |
214
- | `autostart` | `boolean` | No | `true` | Starts the timer at boot. Turn off in tests. |
211
+ | Option | Type | Default | Purpose |
212
+ |---|---|---|---|
213
+ | `define` | `(schedule: Scheduler) => void` | — | Callback where you declare the schedules (receives the Scheduler at boot). |
214
+ | `autostart` | `boolean` | `true` | Starts the timer at boot. Turn it off in tests so the process isn't holding a 60 s interval. |
215
+ | `lock` | `ScheduleLock` | — | Cross-replica mutex, **required** as soon as any entry calls `.onOneServer()`. Boot throws without it. See [`ScheduleLock`](#the-schedulelock-contract). |
216
+ | `lockTtlMs` | `number` | `60_000` | TTL of each per-entry, per-tick lock key. The key already embeds the minute, so this only has to outlive clock skew between replicas — one tick window is the right default. |
217
+
218
+ `SchedulerOptions` (`lock`, `lockTtlMs`) are the same two the `Scheduler` constructor takes, so a
219
+ hand-built `new Scheduler({ lock })` behaves identically.
215
220
 
216
221
  ### `class Scheduler`
217
222
 
@@ -220,9 +225,12 @@ Registers a `Scheduler` (singleton) under the `SCHEDULER` token; on `boot` it ca
220
225
  | `call` | `(name: string, task: () => void \| Promise<void>) => ScheduleEntry` | Schedules a function with a name. |
221
226
  | `job` | `<T>(job: JobDefinition<T>, payload?) => ScheduleEntry` | Schedules the `dispatch` of an `@basaltkit/queue` job (payload required if the job needs one). |
222
227
  | `list` | `() => { name, cron, timezone }[]` | Describes all entries. |
223
- | `tick` | `(date?: Date) => Promise<void>` | Runs the entries due at that instant (default: now). Aggregates failures without `onFailure` into an `AggregateError`. |
224
- | `start` | `() => void` | Aligns to the next minute and then `tick()`s every 60s. Idempotent. |
228
+ | `names` | `() => string[]` | Names of every entry used by the CLI to validate/suggest. |
229
+ | `runNow` | `(name: string) => Promise<boolean>` | Runs one entry on demand, **ignoring its cron and its `.onOneServer()` lock** (a manual trigger is deliberate). `false` if no entry has that name. The overlap guard and `onFailure` still apply. |
230
+ | `tick` | `(date?: Date) => Promise<void>` | Runs the entries due at that instant (default: now), concurrently. Aggregates failures without `onFailure` into an `AggregateError`. |
231
+ | `start` | `() => void` | Aligns to the next minute and then `tick()`s every 60s. Idempotent; the timers are `unref`'d. |
225
232
  | `stop` | `() => void` | Stops the timers. |
233
+ | `skippedByLock` | `number` | Ticks skipped because another replica held the lock — for observability and tests. |
226
234
 
227
235
  ### `class ScheduleEntry` (returned by `call`/`job`)
228
236
 
@@ -231,7 +239,8 @@ Frequency methods (all return `this`): `everyMinute()`, `everyMinutes(n)`, `hour
231
239
  | Method/property | Type | Default | Description |
232
240
  |---|---|---|---|
233
241
  | `timezone(tz)` | `(tz: string) => this` | `'UTC'` | IANA timezone in which the time is interpreted. |
234
- | `withoutOverlapping()` | `() => this` | off | Skips the run if the previous one is still in progress. |
242
+ | `withoutOverlapping()` | `() => this` | off | Skips the run if the previous one is still in progress — **in this process only**. |
243
+ | `onOneServer()` | `() => this` | off | Runs the entry on exactly ONE replica per tick instead of on every pod. Requires `schedulerPlugin({ lock })`; boot throws without one. |
235
244
  | `onFailure(handler)` | `((error: unknown) => void) => this` | — | Receives the error instead of propagating it. |
236
245
  | `describe()` | `() => { name, cron, timezone }` | — | Description of the entry. |
237
246
  | `isDue(date)` | `(date: Date) => boolean` | — | Is the entry due at this instant? |
@@ -257,6 +266,112 @@ Exported for tooling and tests; you don't usually need them:
257
266
 
258
267
  - `SCHEDULER: Token<Scheduler>` — to get the Scheduler from the container: `app.container.get(SCHEDULER)`.
259
268
 
269
+ ### Errors
270
+
271
+ | Error | Code | When |
272
+ |---|---|---|
273
+ | `CronParseError` | `CRON_INVALID` | An expression passed to `.cron()` (or built internally) isn't 5 fields, uses unsupported syntax (`MON`, `@daily`, `?`, `L`), has a reversed range (`5-1`), a step below 1, or a value outside its field's bounds. Extends `BasaltError`; raised at **definition** time, so a typo fails at boot rather than becoming a task that silently never fires. |
274
+ | `Error` (plain) | — | At `boot`, when an entry uses `.onOneServer()` but no `lock` was configured. See [Boot-time enforcement](#boot-time-enforcement). |
275
+ | `AggregateError` | — (built-in) | From `tick()`, when one or more due entries failed without an `onFailure` handler — or when a `.onOneServer()` entry's `lock.acquire` rejected. All due entries still ran; `error.errors` holds each failure. Swallowed by the automatic timer path so a failing task can't kill the process. |
276
+
277
+ ### Hooks & callbacks
278
+
279
+ The scheduler has no hook bus. Two per-entry callbacks and one injected collaborator:
280
+
281
+ | Callback | Where | Receives | Default when unset |
282
+ |---|---|---|---|
283
+ | `onFailure(handler)` | `ScheduleEntry` | `(error: unknown)` | The error propagates — aggregated into the tick's `AggregateError`, then **swallowed** by the automatic timer. Set it, or a failure in production is invisible. |
284
+ | `lock.acquire` | `SchedulerOptions` | `(key: string, ttlMs: number) => Promise<boolean>` | No lock. Boot throws if any entry needs one. |
285
+
286
+ ## Multiple replicas — `.onOneServer()`
287
+
288
+ `withoutOverlapping()` guards one process. It does nothing about the real production problem:
289
+ you run four pods, every pod boots the scheduler, and at 03:00 the nightly billing job runs
290
+ **four times**. Mark the entry `.onOneServer()` and give the plugin a lock:
291
+
292
+ ```ts
293
+ import Redis from 'ioredis'
294
+ import { schedulerPlugin, type ScheduleLock } from '@basaltkit/scheduler'
295
+
296
+ const redis = new Redis(process.env.REDIS_URL!)
297
+
298
+ const lock: ScheduleLock = {
299
+ async acquire(key, ttlMs) {
300
+ return (await redis.set(key, '1', 'PX', ttlMs, 'NX')) === 'OK'
301
+ },
302
+ }
303
+
304
+ schedulerPlugin({
305
+ lock,
306
+ define: (schedule) => {
307
+ schedule.job(ReconcileBilling, { mode: 'full' }).daily().at('03:00').onOneServer()
308
+ },
309
+ })
310
+ ```
311
+
312
+ ### The `ScheduleLock` contract
313
+
314
+ ```ts
315
+ interface ScheduleLock {
316
+ acquire(key: string, ttlMs: number): Promise<boolean>
317
+ }
318
+ ```
319
+
320
+ One method, and the guarantees it must provide are the whole point:
321
+
322
+ - **`acquire` must be atomic across processes.** For a given `key`, exactly one caller anywhere
323
+ in the fleet may get `true` until the TTL expires. Redis `SET key value PX ttl NX` is the
324
+ canonical implementation. A check-then-set (`GET` then `SET`) is **not** atomic and silently
325
+ reintroduces the duplicate runs you added the lock to prevent.
326
+ - **The key must actually expire.** The scheduler never deletes it. A store without TTL support
327
+ would let the first tick's key block that entry forever.
328
+ - **There is deliberately no `release`.** The key covers the whole tick window, so a fast first
329
+ run cannot be followed by a late replica acquiring the freed key and running the same minute a
330
+ second time. The TTL is the release.
331
+
332
+ The key the scheduler builds is `basalt:schedule:<entry name>:<minute, ISO, seconds zeroed>` —
333
+ one key per entry per minute. `lockTtlMs` (default `60_000`) only has to outlive clock skew
334
+ between replicas.
335
+
336
+ ### What happens when acquisition fails
337
+
338
+ Two distinct cases, and they are treated very differently:
339
+
340
+ | Case | Behaviour |
341
+ |---|---|
342
+ | `acquire` resolves `false` — another replica won this tick | The entry is skipped on this replica and `scheduler.skippedByLock` increments. This is the normal path on N−1 of your N pods, every tick. Not an error. |
343
+ | `acquire` **rejects** — the lock store is down, times out, or the credentials expired | Treated as a **task failure**: the rejection is collected into the tick's `AggregateError` exactly like a thrown task, so it is visible. The entry does **not** run. It is *not* treated as permission to proceed. |
344
+
345
+ That last row is the deliberate design choice. Failing open — running everywhere when the lock
346
+ store is unreachable — would mean a Redis outage silently turns one nightly billing run into
347
+ four. Failing closed means a Redis outage skips a tick, which a subsequent tick or a manual
348
+ `basalt schedule:run <name>` can recover.
349
+
350
+ ### Boot-time enforcement
351
+
352
+ If any entry calls `.onOneServer()` and no `lock` was configured, `boot` **throws**:
353
+
354
+ ```
355
+ schedulerPlugin: an entry uses .onOneServer() but no `lock` was configured.
356
+ Pass `schedulerPlugin({ lock })` with an atomic cross-replica lock (e.g. Redis SET NX PX) — see ScheduleLock.
357
+ ```
358
+
359
+ Failing the deploy is the point: silently running the entry on every replica is exactly the
360
+ failure mode `.onOneServer()` exists to prevent, and it is invisible until the duplicate charges
361
+ land.
362
+
363
+ ### What the lock does not cover
364
+
365
+ - **`runNow()` / `basalt schedule:run <name>`** bypass the lock entirely — a manual trigger is
366
+ deliberate, and you asked *this* process to run it.
367
+ - **Entries without `.onOneServer()`** are untouched and still run on every replica.
368
+ - **The lock is per tick, not per run.** If a task overruns its minute, the next minute's key is
369
+ a different key. Combine with `.withoutOverlapping()` if the same replica must not stack runs,
370
+ and prefer `schedule.job(...)` so a queue worker (with its own idempotency) does the work.
371
+
372
+ Cron expressions are validated at definition time — `CronParseError` on unsupported syntax like
373
+ `MON` or out-of-range values, so a typo fails at boot instead of becoming a job that never fires.
374
+
260
375
  ## Common errors and solutions (FAQ)
261
376
 
262
377
  **My `daily().at('03:00')` task runs at the wrong time.**
@@ -266,7 +381,16 @@ Times are UTC by default. Add `.timezone('Europe/Lisbon')` (or your IANA timezon
266
381
  `cron()` only accepts the classic 5-field format (`min hour day month day-of-week`). 6-field formats (with seconds) are not supported.
267
382
 
268
383
  **The task runs twice (two servers).**
269
- The scheduler runs in every process where the plugin starts. If you have multiple replicas, enable the scheduler on only one (e.g. via an environment variable) or schedule `schedule.job(...)` with idempotent jobs.
384
+ The scheduler runs in every process where the plugin starts. Mark the entry `.onOneServer()` and pass `schedulerPlugin({ lock })` an atomic cross-replica lock see the *Multiple replicas* section above. `withoutOverlapping()` will not help: it guards one process only.
385
+
386
+ **Boot fails with "an entry uses .onOneServer() but no `lock` was configured".**
387
+ Exactly as intended — booting without the lock would run that entry on every replica. Supply a `ScheduleLock` (Redis `SET key v PX ttl NX`), or drop `.onOneServer()`.
388
+
389
+ **An `.onOneServer()` entry stopped running everywhere at once.**
390
+ Its lock store is unreachable. A rejecting `acquire` is treated as a task failure (visible in the tick's `AggregateError`), never as permission to run on every replica. Fix the store; a later tick or `basalt schedule:run <name>` recovers the missed run.
391
+
392
+ **`skippedByLock` keeps climbing.**
393
+ Normal. On N replicas, N−1 of them skip each `.onOneServer()` tick. Only worry if it climbs on *every* replica — that means nobody is acquiring, i.e. a stale key without a TTL.
270
394
 
271
395
  **A task failed and I didn't see anything.**
272
396
  In automatic mode, failures without `onFailure` are silenced so as not to crash the process. Set `onFailure` on each entry (or log inside it).
package/dist/cron.js CHANGED
@@ -4,12 +4,58 @@ export class CronParseError extends BasaltError {
4
4
  super('CRON_INVALID', `Invalid cron expression "${expression}": ${detail}`);
5
5
  }
6
6
  }
7
+ const FIELD_BOUNDS = [
8
+ ['minute', 0, 59],
9
+ ['hour', 0, 23],
10
+ ['day-of-month', 1, 31],
11
+ ['month', 1, 12],
12
+ ['day-of-week', 0, 6],
13
+ ];
14
+ /**
15
+ * Validates one cron field against the syntax {@link fieldMatches} actually
16
+ * supports: asterisk, asterisk-slash-n steps, single values, `a-b` ranges and comma lists.
17
+ * Anything else (names like MON, out-of-range values, `5-1`) previously became
18
+ * NaN comparisons — a job that silently NEVER fires. Fail at parse time instead.
19
+ */
20
+ function assertField(expression, field, name, min, max) {
21
+ const invalid = (detail) => {
22
+ throw new CronParseError(expression, `${name} field "${field}": ${detail}`);
23
+ };
24
+ if (field === '*')
25
+ return;
26
+ for (const part of field.split(',')) {
27
+ const step = /^\*\/(\d+)$/.exec(part);
28
+ if (step) {
29
+ if (Number(step[1]) < 1)
30
+ invalid('step must be >= 1');
31
+ continue;
32
+ }
33
+ const range = /^(\d+)-(\d+)$/.exec(part);
34
+ if (range) {
35
+ const [from, to] = [Number(range[1]), Number(range[2])];
36
+ if (from > to)
37
+ invalid(`range ${from}-${to} is reversed`);
38
+ if (from < min || to > max)
39
+ invalid(`range ${from}-${to} outside ${min}-${max}`);
40
+ continue;
41
+ }
42
+ if (!/^\d+$/.test(part))
43
+ invalid(`"${part}" is not supported (use *, */n, n, a-b or comma lists; names like MON are not)`);
44
+ const value = Number(part);
45
+ if (value < min || value > max)
46
+ invalid(`${value} outside ${min}-${max}`);
47
+ }
48
+ }
7
49
  export function parseCron(expression) {
8
50
  const parts = expression.trim().split(/\s+/);
9
51
  if (parts.length !== 5) {
10
52
  throw new CronParseError(expression, `expected 5 fields, received ${parts.length}`);
11
53
  }
12
54
  const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
55
+ parts.forEach((field, i) => {
56
+ const [name, min, max] = FIELD_BOUNDS[i];
57
+ assertField(expression, field, name, min, max);
58
+ });
13
59
  return { minute, hour, dayOfMonth, month, dayOfWeek };
14
60
  }
15
61
  export function cronToString(fields) {
package/dist/index.d.ts CHANGED
@@ -2,6 +2,24 @@ import type { JobDefinition } from '@basaltkit/queue';
2
2
  export { CronParseError, cronMatches, parseCron, fieldMatches, zonedParts } from './cron.js';
3
3
  export type { CronFields, ZonedParts } from './cron.js';
4
4
  type Task = () => void | Promise<void>;
5
+ /**
6
+ * Cross-replica mutex for `.onOneServer()` entries. `acquire` must be ATOMIC
7
+ * across processes (e.g. Redis `SET key value PX ttl NX`): it returns true for
8
+ * exactly one caller per key until the TTL expires. There is deliberately no
9
+ * `release` — the key covers the tick window, so a fast first run cannot be
10
+ * followed by a late replica re-acquiring and running the same minute again.
11
+ *
12
+ * ioredis example:
13
+ *
14
+ * const lock: ScheduleLock = {
15
+ * async acquire(key, ttlMs) {
16
+ * return (await redis.set(key, '1', 'PX', ttlMs, 'NX')) === 'OK'
17
+ * },
18
+ * }
19
+ */
20
+ export interface ScheduleLock {
21
+ acquire(key: string, ttlMs: number): Promise<boolean>;
22
+ }
5
23
  /**
6
24
  * A scheduled entry, built fluently:
7
25
  *
@@ -14,6 +32,7 @@ export declare class ScheduleEntry {
14
32
  private fields;
15
33
  private tz;
16
34
  private noOverlap;
35
+ private oneServer;
17
36
  private failureHandler;
18
37
  private running;
19
38
  /** count of executions skipped due to overlap — visible for observability/tests */
@@ -39,6 +58,16 @@ export declare class ScheduleEntry {
39
58
  timezone(tz: string): this;
40
59
  /** If the previous execution is still running, the new one is skipped. */
41
60
  withoutOverlapping(): this;
61
+ /**
62
+ * On a horizontally-scaled deployment, run this entry on ONE replica per tick
63
+ * instead of on every pod. Requires a `lock` on the Scheduler (see
64
+ * {@link ScheduleLock}) — without one, boot fails loud rather than silently
65
+ * running the job N times. `runNow()`/`schedule:run` bypass the lock (a manual
66
+ * trigger is deliberate).
67
+ */
68
+ onOneServer(): this;
69
+ /** @internal whether this entry asked for cross-replica locking. */
70
+ get wantsOneServer(): boolean;
42
71
  onFailure(handler: (error: unknown) => void): this;
43
72
  /** Entry description — consumed by `basalt schedule list`. */
44
73
  describe(): {
@@ -51,10 +80,28 @@ export declare class ScheduleEntry {
51
80
  run(): Promise<void>;
52
81
  private onDayOfWeek;
53
82
  }
83
+ export interface SchedulerOptions {
84
+ /** Cross-replica lock for `.onOneServer()` entries. */
85
+ lock?: ScheduleLock;
86
+ /**
87
+ * TTL for each per-entry, per-tick lock key. Default 60_000 (one tick window
88
+ * — the key embeds the minute, so it only needs to outlive clock skew).
89
+ */
90
+ lockTtlMs?: number;
91
+ }
54
92
  export declare class Scheduler {
55
93
  private readonly entries;
56
94
  private timer;
57
95
  private interval;
96
+ private readonly lock;
97
+ private readonly lockTtlMs;
98
+ /** ticks skipped because another replica held the lock — for observability/tests */
99
+ skippedByLock: number;
100
+ constructor(options?: SchedulerOptions);
101
+ /** @internal true when any entry requested `.onOneServer()`. */
102
+ get needsLock(): boolean;
103
+ /** @internal whether a lock was configured. */
104
+ get hasLock(): boolean;
58
105
  /** Schedules the dispatch of a @basaltkit/queue job. */
59
106
  job<T>(job: JobDefinition<T>, ...payload: T extends void ? [] : [T]): ScheduleEntry;
60
107
  /** Schedules a named function. */
@@ -85,7 +132,7 @@ export declare class Scheduler {
85
132
  private add;
86
133
  }
87
134
  export declare const SCHEDULER: import("@basaltkit/core").Token<Scheduler>;
88
- export interface SchedulerPluginOptions {
135
+ export interface SchedulerPluginOptions extends SchedulerOptions {
89
136
  /** Callback that defines the schedules — receives the Scheduler at boot. */
90
137
  define?: (schedule: Scheduler) => void;
91
138
  /** Starts the timer at boot. Default: true (turn off in tests). */
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ export class ScheduleEntry {
19
19
  };
20
20
  tz = 'UTC';
21
21
  noOverlap = false;
22
+ oneServer = false;
22
23
  failureHandler;
23
24
  running = false;
24
25
  /** count of executions skipped due to overlap — visible for observability/tests */
@@ -84,6 +85,21 @@ export class ScheduleEntry {
84
85
  this.noOverlap = true;
85
86
  return this;
86
87
  }
88
+ /**
89
+ * On a horizontally-scaled deployment, run this entry on ONE replica per tick
90
+ * instead of on every pod. Requires a `lock` on the Scheduler (see
91
+ * {@link ScheduleLock}) — without one, boot fails loud rather than silently
92
+ * running the job N times. `runNow()`/`schedule:run` bypass the lock (a manual
93
+ * trigger is deliberate).
94
+ */
95
+ onOneServer() {
96
+ this.oneServer = true;
97
+ return this;
98
+ }
99
+ /** @internal whether this entry asked for cross-replica locking. */
100
+ get wantsOneServer() {
101
+ return this.oneServer;
102
+ }
87
103
  onFailure(handler) {
88
104
  this.failureHandler = handler;
89
105
  return this;
@@ -123,6 +139,22 @@ export class Scheduler {
123
139
  entries = [];
124
140
  timer;
125
141
  interval;
142
+ lock;
143
+ lockTtlMs;
144
+ /** ticks skipped because another replica held the lock — for observability/tests */
145
+ skippedByLock = 0;
146
+ constructor(options = {}) {
147
+ this.lock = options.lock;
148
+ this.lockTtlMs = options.lockTtlMs ?? 60_000;
149
+ }
150
+ /** @internal true when any entry requested `.onOneServer()`. */
151
+ get needsLock() {
152
+ return this.entries.some((entry) => entry.wantsOneServer);
153
+ }
154
+ /** @internal whether a lock was configured. */
155
+ get hasLock() {
156
+ return this.lock !== undefined;
157
+ }
126
158
  /** Schedules the dispatch of a @basaltkit/queue job. */
127
159
  job(job, ...payload) {
128
160
  return this.add(new ScheduleEntry(job.name, () => job.dispatch(payload[0])));
@@ -160,6 +192,19 @@ export class Scheduler {
160
192
  const errors = [];
161
193
  await Promise.all(due.map(async (entry) => {
162
194
  try {
195
+ if (entry.wantsOneServer && this.lock) {
196
+ // One key per entry per tick window: exactly one replica acquires
197
+ // it; the others skip this minute's run. A lock-store failure is
198
+ // treated as a task failure (visible), not as permission to run on
199
+ // every replica at once.
200
+ const minute = new Date(date);
201
+ minute.setSeconds(0, 0);
202
+ const key = `basalt:schedule:${entry.name}:${minute.toISOString()}`;
203
+ if (!(await this.lock.acquire(key, this.lockTtlMs))) {
204
+ this.skippedByLock++;
205
+ return;
206
+ }
207
+ }
163
208
  await entry.run();
164
209
  }
165
210
  catch (error) {
@@ -209,12 +254,21 @@ export function schedulerPlugin(options = {}) {
209
254
  return definePlugin({
210
255
  name: 'basalt:scheduler',
211
256
  register({ container }) {
212
- container.singleton(SCHEDULER, () => new Scheduler());
257
+ container.singleton(SCHEDULER, () => new Scheduler({
258
+ ...(options.lock ? { lock: options.lock } : {}),
259
+ ...(options.lockTtlMs !== undefined ? { lockTtlMs: options.lockTtlMs } : {}),
260
+ }));
213
261
  registerScheduleRunCommand(container);
214
262
  },
215
263
  boot({ container }) {
216
264
  const scheduler = container.get(SCHEDULER);
217
265
  options.define?.(scheduler);
266
+ if (scheduler.needsLock && !scheduler.hasLock) {
267
+ // Fail closed at boot: silently running the entry on every replica is
268
+ // exactly the failure mode .onOneServer() exists to prevent.
269
+ throw new Error('schedulerPlugin: an entry uses .onOneServer() but no `lock` was configured. ' +
270
+ 'Pass `schedulerPlugin({ lock })` with an atomic cross-replica lock (e.g. Redis SET NX PX) — see ScheduleLock.');
271
+ }
218
272
  // Expose entries to tooling (CLI `basalt schedule:list`).
219
273
  const metadata = ensureMetadata(container);
220
274
  for (const entry of scheduler.list())
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "@basaltkit/scheduler",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
4
+ "engines": {
5
+ "node": ">=22.5.0"
6
+ },
4
7
  "description": "Fluent Basalt scheduler: schedule.job(X).daily().at('03:00'), timezones, withoutOverlapping and @basaltkit/queue integration.",
5
8
  "license": "MIT",
6
9
  "type": "module",
10
+ "sideEffects": false,
7
11
  "exports": {
8
12
  ".": {
9
13
  "types": "./dist/index.d.ts",
@@ -14,8 +18,8 @@
14
18
  "dist"
15
19
  ],
16
20
  "dependencies": {
17
- "@basaltkit/core": "^1.1.2",
18
- "@basaltkit/queue": "^1.2.1"
21
+ "@basaltkit/core": "^1.3.1",
22
+ "@basaltkit/queue": "^1.4.1"
19
23
  },
20
24
  "devDependencies": {
21
25
  "@types/node": "^26.3.0",