@warlock.js/scheduler 5.22.1 → 5.23.0

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/CHANGELOG.md CHANGED
@@ -1,107 +1,113 @@
1
- # Changelog — @warlock.js/scheduler
2
-
3
- All notable changes to `@warlock.js/scheduler` are documented in this file.
4
-
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
6
-
7
- ## 5.22.1 - 2026-09-25
8
-
9
- ### Changed
10
-
11
- - Lockstep patch release; package APIs are unchanged.
12
-
13
- ## 5.22.0 - 2026-09-25
14
-
15
- ### Fixed
16
-
17
- - Cron and time parsing handle absent fields explicitly under strict TypeScript checks.
18
-
19
- ## 5.21.0 - 2026-09-25
20
-
21
- ### Changed
22
-
23
- - Lockstep release maintenance and dependency refresh.
24
- ## 5.20.0 - 2026-09-24
25
-
26
- ### Added
27
-
28
- - `job.onOneServer({ lockTtl?, key? })` — run each tick on exactly one server. Every server races for a create-only, TTL-bounded cache claim keyed `scheduler.<key ?? name>.<scheduledTickEpochMs>`. The claim is never released, so exactly one server runs each tick even if another server's timer fires late. Losers skip the tick and emit `job:skip`. Requires a shared cache driver (redis/pg) and a job name or `key`. `@warlock.js/cache` is an optional peer dependency. Default claim TTL is `min(interval, 1h)` with a 60s floor (1h for cron jobs).
29
-
30
- ## 5.19.0 - 2026-09-23
31
-
32
- ### Changed
33
-
34
- - Refined package skill-discovery descriptions and regenerated the llms projections.
35
-
36
- ## 5.11.0 - 2026-09-14
37
-
38
- _Released in lockstep with the `@warlock.js/*` family; no package-specific changes in 5.11.0._
39
-
40
- ## 5.10.0 - 2026-09-14
41
-
42
- _Released in lockstep with the `@warlock.js/*` family; no package-specific changes in 5.10.0._
43
-
44
- ## 5.9.0 - 2026-09-13
45
-
46
- _Released in lockstep with the `@warlock.js/*` family; no package-specific changes in 5.9.0._
47
-
48
- ## 5.5.0 - 2026-09-07
49
-
50
- ### Fixed
51
-
52
- - Documentation shipped in this package's `skills/` told users to run `pnpm`-specific commands. `pnpm <binary>` has no npm equivalent, so those instructions failed outright for anyone not using pnpm. Commands are now package-manager neutral.
53
-
54
- ## 5.2.3 - 2026-09-02
55
-
56
- ### Fixed
57
-
58
- - Released in exact lockstep with Core's Web generator repairs so every family dependency remains installable at 5.2.3.
59
-
60
- ## 5.2.2
61
-
62
- - Documented: the README, CHANGELOG, and `overview` skill now describe the
63
- already-shipped `package.json` `"warlock": { "environment": "server" }`
64
- marker — `@warlock.js/scheduler`'s entire runtime surface is server-only.
65
- The marker is build-boundary metadata read by `@warlock.js/web`'s Gate A
66
- (import resolution) and Gate C (emitted-bundle verification); app client
67
- code must not value-import this package, type-only imports are allowed, and
68
- server loaders/controllers/modules may import it freely. No source change.
69
-
70
- ## 5.1.0
71
-
72
- No changes to `@warlock.js/scheduler`. Released in lockstep with the `@warlock.js/web`
73
- React-execution fix and the `@warlock.js/core` CLI additions — see those packages'
74
- changelogs.
75
-
76
- ## 5.0.2 - 2026-08-25
77
-
78
- No changes to `@warlock.js/scheduler`. Released in lockstep with the `@warlock.js/web` SSR
79
- fix (`ssr.noExternal`) — see that package's changelog.
80
-
81
- ## 5.0.1 - 2026-08-25
82
-
83
- No changes to `@warlock.js/scheduler`. Released in lockstep with the `create-warlock` vite
84
- resolution pin and the `@warlock.js/web` peer narrowing — see those packages'
85
- changelogs.
86
-
87
- ## 5.0.0 - 2026-08-25
88
-
89
- ### Changed
90
-
91
- - This package is unchanged in 5.0.0; its version moved only because the Warlock family releases in lockstep.
92
-
93
- ## 4.12.0
94
-
95
- ### Changed
96
-
97
- - Declares its own test runner and pins it to an exact version (`vitest@4.1.10`). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to
98
-
99
- ## 4.6.0
100
-
101
- ### Fixed
102
-
103
- - Warn in development when jobs are registered but `start()` is never called — a one-shot deferred check logs `N job(s) registered but scheduler.start() was never called`, is suppressed once `start()` runs or in production (`NODE_ENV=production`), and is unref'd so it never holds the process open.
104
-
105
- ## 4.1.15
106
-
107
- - Baseline — per-package changelog tracking starts at this version.
1
+ # Changelog — @warlock.js/scheduler
2
+
3
+ All notable changes to `@warlock.js/scheduler` are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
6
+
7
+ ## 5.23.0 - 2026-09-25
8
+
9
+ ### Added
10
+
11
+ - `scheduler.around((job, run) => …)` wraps every job callback execution, each retry attempt included, so jobs can run inside a context such as `AsyncLocalStorage`. Multiple hooks compose with the first registered outermost; the call returns an unsubscribe function. A hook that never calls `run` skips the execution and emits `job:skip` (`JobResult.skipped` / `skipReason`); a hook that throws follows the normal retry/error path.
12
+
13
+ ## 5.22.1 - 2026-09-25
14
+
15
+ ### Changed
16
+
17
+ - Lockstep patch release; package APIs are unchanged.
18
+
19
+ ## 5.22.0 - 2026-09-25
20
+
21
+ ### Fixed
22
+
23
+ - Cron and time parsing handle absent fields explicitly under strict TypeScript checks.
24
+
25
+ ## 5.21.0 - 2026-09-25
26
+
27
+ ### Changed
28
+
29
+ - Lockstep release maintenance and dependency refresh.
30
+ ## 5.20.0 - 2026-09-24
31
+
32
+ ### Added
33
+
34
+ - `job.onOneServer({ lockTtl?, key? })` — run each tick on exactly one server. Every server races for a create-only, TTL-bounded cache claim keyed `scheduler.<key ?? name>.<scheduledTickEpochMs>`. The claim is never released, so exactly one server runs each tick even if another server's timer fires late. Losers skip the tick and emit `job:skip`. Requires a shared cache driver (redis/pg) and a job name or `key`. `@warlock.js/cache` is an optional peer dependency. Default claim TTL is `min(interval, 1h)` with a 60s floor (1h for cron jobs).
35
+
36
+ ## 5.19.0 - 2026-09-23
37
+
38
+ ### Changed
39
+
40
+ - Refined package skill-discovery descriptions and regenerated the llms projections.
41
+
42
+ ## 5.11.0 - 2026-09-14
43
+
44
+ _Released in lockstep with the `@warlock.js/*` family; no package-specific changes in 5.11.0._
45
+
46
+ ## 5.10.0 - 2026-09-14
47
+
48
+ _Released in lockstep with the `@warlock.js/*` family; no package-specific changes in 5.10.0._
49
+
50
+ ## 5.9.0 - 2026-09-13
51
+
52
+ _Released in lockstep with the `@warlock.js/*` family; no package-specific changes in 5.9.0._
53
+
54
+ ## 5.5.0 - 2026-09-07
55
+
56
+ ### Fixed
57
+
58
+ - Documentation shipped in this package's `skills/` told users to run `pnpm`-specific commands. `pnpm <binary>` has no npm equivalent, so those instructions failed outright for anyone not using pnpm. Commands are now package-manager neutral.
59
+
60
+ ## 5.2.3 - 2026-09-02
61
+
62
+ ### Fixed
63
+
64
+ - Released in exact lockstep with Core's Web generator repairs so every family dependency remains installable at 5.2.3.
65
+
66
+ ## 5.2.2
67
+
68
+ - Documented: the README, CHANGELOG, and `overview` skill now describe the
69
+ already-shipped `package.json` `"warlock": { "environment": "server" }`
70
+ marker — `@warlock.js/scheduler`'s entire runtime surface is server-only.
71
+ The marker is build-boundary metadata read by `@warlock.js/web`'s Gate A
72
+ (import resolution) and Gate C (emitted-bundle verification); app client
73
+ code must not value-import this package, type-only imports are allowed, and
74
+ server loaders/controllers/modules may import it freely. No source change.
75
+
76
+ ## 5.1.0
77
+
78
+ No changes to `@warlock.js/scheduler`. Released in lockstep with the `@warlock.js/web`
79
+ React-execution fix and the `@warlock.js/core` CLI additions — see those packages'
80
+ changelogs.
81
+
82
+ ## 5.0.2 - 2026-08-25
83
+
84
+ No changes to `@warlock.js/scheduler`. Released in lockstep with the `@warlock.js/web` SSR
85
+ fix (`ssr.noExternal`) — see that package's changelog.
86
+
87
+ ## 5.0.1 - 2026-08-25
88
+
89
+ No changes to `@warlock.js/scheduler`. Released in lockstep with the `create-warlock` vite
90
+ resolution pin and the `@warlock.js/web` peer narrowing — see those packages'
91
+ changelogs.
92
+
93
+ ## 5.0.0 - 2026-08-25
94
+
95
+ ### Changed
96
+
97
+ - This package is unchanged in 5.0.0; its version moved only because the Warlock family releases in lockstep.
98
+
99
+ ## 4.12.0
100
+
101
+ ### Changed
102
+
103
+ - Declares its own test runner and pins it to an exact version (`vitest@4.1.10`). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to
104
+
105
+ ## 4.6.0
106
+
107
+ ### Fixed
108
+
109
+ - Warn in development when jobs are registered but `start()` is never called — a one-shot deferred check logs `N job(s) registered but scheduler.start() was never called`, is suppressed once `start()` runs or in production (`NODE_ENV=production`), and is unref'd so it never holds the process open.
110
+
111
+ ## 4.1.15
112
+
113
+ - Baseline — per-package changelog tracking starts at this version.
package/cjs/index.cjs CHANGED
@@ -267,6 +267,11 @@ const UNIT_MS = {
267
267
  year: 31536e6
268
268
  };
269
269
  /**
270
+ * Hook wrapping every callback execution (each retry attempt included).
271
+ * Not calling `run` skips the execution; throwing counts as a callback error.
272
+ */
273
+ const HOOK_SKIP_REASON = "An around hook did not run the job";
274
+ /**
270
275
  * Days of week mapping (lowercase for consistency with Day type)
271
276
  */
272
277
  const DAYS_OF_WEEK = [
@@ -322,6 +327,7 @@ var Job = class {
322
327
  this._intervals = {};
323
328
  this._lastRun = null;
324
329
  this._isRunning = false;
330
+ this._aroundHooks = () => [];
325
331
  this._skipIfRunning = false;
326
332
  this._retryConfig = null;
327
333
  this._timezone = "UTC";
@@ -758,6 +764,7 @@ var Job = class {
758
764
  let executedRetries = 0;
759
765
  try {
760
766
  let skipped = false;
767
+ let skipReason;
761
768
  if (this._oneServer) {
762
769
  const lockKey = this._lockKey(scheduledAt);
763
770
  const { cache } = await loadCache();
@@ -765,13 +772,28 @@ var Job = class {
765
772
  onConflict: "create",
766
773
  ttl: this._lockTtl()
767
774
  }))?.wasSet !== true;
768
- if (!skipped) executedRetries = (await this._executeWithRetry()).retries;
769
- } else executedRetries = (await this._executeWithRetry()).retries;
775
+ if (!skipped) {
776
+ const inner = await this._executeWithRetry();
777
+ executedRetries = inner.retries;
778
+ if (inner.skipped) {
779
+ skipped = true;
780
+ skipReason = HOOK_SKIP_REASON;
781
+ }
782
+ }
783
+ } else {
784
+ const inner = await this._executeWithRetry();
785
+ executedRetries = inner.retries;
786
+ if (inner.skipped) {
787
+ skipped = true;
788
+ skipReason = HOOK_SKIP_REASON;
789
+ }
790
+ }
770
791
  result = {
771
792
  success: true,
772
793
  duration: Date.now() - startTime,
773
794
  retries: executedRetries,
774
- ...skipped ? { skipped: true } : {}
795
+ ...skipped ? { skipped: true } : {},
796
+ ...skipReason ? { skipReason } : {}
775
797
  };
776
798
  } catch (error) {
777
799
  result = {
@@ -790,6 +812,16 @@ var Job = class {
790
812
  return result;
791
813
  }
792
814
  /**
815
+ * Bind a provider of around hooks (called by the Scheduler on registration).
816
+ * A provider, not a snapshot, so later `around()` / unsubscribe calls apply.
817
+ *
818
+ * @internal
819
+ */
820
+ bindAroundHooks(provider) {
821
+ this._aroundHooks = provider;
822
+ return this;
823
+ }
824
+ /**
793
825
  * Wait for the currently executing run to complete.
794
826
  *
795
827
  * Multiple concurrent waiters are all resolved when the run finishes.
@@ -833,7 +865,10 @@ var Job = class {
833
865
  let attempts = 0;
834
866
  const maxAttempts = (this._retryConfig?.maxRetries ?? 0) + 1;
835
867
  while (attempts < maxAttempts) try {
836
- await this._callback(this);
868
+ if (!await this._invokeCallback()) return {
869
+ retries: attempts,
870
+ skipped: true
871
+ };
837
872
  return { retries: attempts };
838
873
  } catch (error) {
839
874
  lastError = error;
@@ -846,6 +881,26 @@ var Job = class {
846
881
  throw lastError;
847
882
  }
848
883
  /**
884
+ * Run the callback inside the around hooks (first registered = outermost).
885
+ *
886
+ * @returns false when a hook never called `run` (the callback did not execute)
887
+ */
888
+ async _invokeCallback() {
889
+ let reached = false;
890
+ let chain = async () => {
891
+ reached = true;
892
+ await this._callback(this);
893
+ };
894
+ const hooks = [...this._aroundHooks()];
895
+ for (let index = hooks.length - 1; index >= 0; index--) {
896
+ const hook = hooks[index];
897
+ const next = chain;
898
+ chain = () => hook(this, next);
899
+ }
900
+ await chain();
901
+ return reached;
902
+ }
903
+ /**
849
904
  * Calculate retry delay with optional exponential backoff
850
905
  */
851
906
  _calculateRetryDelay(attempt) {
@@ -971,6 +1026,7 @@ var Scheduler = class extends node_events.EventEmitter {
971
1026
  constructor(..._args) {
972
1027
  super(..._args);
973
1028
  this._jobs = [];
1029
+ this._aroundHooks = [];
974
1030
  this._timeoutId = null;
975
1031
  this._tickInterval = 1e3;
976
1032
  this._runInParallel = false;
@@ -998,12 +1054,33 @@ var Scheduler = class extends node_events.EventEmitter {
998
1054
  * @returns this for chaining
999
1055
  */
1000
1056
  addJob(job) {
1057
+ job.bindAroundHooks(() => this._aroundHooks);
1001
1058
  this._jobs.push(job);
1002
1059
  if (this.isRunning) job.prepare();
1003
1060
  this._armStartWarning();
1004
1061
  return this;
1005
1062
  }
1006
1063
  /**
1064
+ * Wrap every job callback execution (each retry attempt included) in a hook.
1065
+ *
1066
+ * The first registered hook is the outermost. A hook that never calls `run`
1067
+ * skips the execution (`job:skip` is emitted); a hook that throws is treated
1068
+ * like a callback error.
1069
+ *
1070
+ * @returns Unsubscribe function
1071
+ *
1072
+ * @example
1073
+ * ```typescript
1074
+ * scheduler.around((job, run) => storage.run({ job: job.name }, run));
1075
+ * ```
1076
+ */
1077
+ around(hook) {
1078
+ this._aroundHooks = [...this._aroundHooks, hook];
1079
+ return () => {
1080
+ this._aroundHooks = this._aroundHooks.filter((item) => item !== hook);
1081
+ };
1082
+ }
1083
+ /**
1007
1084
  * Alias to create a new job directly and store it
1008
1085
  */
1009
1086
  newJob(name, jobCallback) {
@@ -1021,6 +1098,7 @@ var Scheduler = class extends node_events.EventEmitter {
1021
1098
  * @returns this for chaining
1022
1099
  */
1023
1100
  addJobs(jobs) {
1101
+ for (const job of jobs) job.bindAroundHooks(() => this._aroundHooks);
1024
1102
  this._jobs.push(...jobs);
1025
1103
  if (this.isRunning) for (const job of jobs) job.prepare();
1026
1104
  this._armStartWarning();
@@ -1210,7 +1288,7 @@ var Scheduler = class extends node_events.EventEmitter {
1210
1288
  async _runJob(job) {
1211
1289
  this.emit("job:start", job.name);
1212
1290
  const result = await job.run();
1213
- if (result.skipped) this.emit("job:skip", job.name, "Another server holds the lock");
1291
+ if (result.skipped) this.emit("job:skip", job.name, result.skipReason ?? "Another server holds the lock");
1214
1292
  else if (result.success) this.emit("job:complete", job.name, result);
1215
1293
  else this.emit("job:error", job.name, result.error);
1216
1294
  return result;