@blamejs/core 0.7.18 → 0.7.19

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.
Files changed (167) hide show
  1. package/CHANGELOG.md +425 -423
  2. package/README.md +150 -150
  3. package/bin/blamejs.js +0 -0
  4. package/index.js +310 -308
  5. package/lib/api-key.js +660 -660
  6. package/lib/api-snapshot.js +338 -338
  7. package/lib/app-shutdown.js +385 -385
  8. package/lib/app.js +365 -365
  9. package/lib/archive.js +250 -250
  10. package/lib/atomic-file.js +544 -544
  11. package/lib/audit-chain.js +177 -177
  12. package/lib/audit-sign.js +344 -344
  13. package/lib/audit-tools.js +677 -677
  14. package/lib/audit.js +766 -766
  15. package/lib/auth/jwt-external.js +365 -0
  16. package/lib/auth/jwt.js +337 -311
  17. package/lib/auth/lockout.js +436 -436
  18. package/lib/auth/oauth.js +721 -721
  19. package/lib/auth/passkey.js +181 -181
  20. package/lib/auth/password.js +628 -594
  21. package/lib/backup/bundle.js +217 -217
  22. package/lib/backup/crypto.js +176 -176
  23. package/lib/backup/index.js +515 -515
  24. package/lib/backup/manifest.js +282 -282
  25. package/lib/break-glass.js +1338 -1338
  26. package/lib/bundler.js +441 -441
  27. package/lib/cache-redis.js +256 -256
  28. package/lib/cache.js +1206 -1206
  29. package/lib/canonical-json.js +115 -115
  30. package/lib/chain-writer.js +234 -234
  31. package/lib/cli-helpers.js +206 -206
  32. package/lib/cli.js +2334 -2334
  33. package/lib/cluster-provider-db.js +317 -317
  34. package/lib/cluster-storage.js +226 -226
  35. package/lib/cluster.js +703 -703
  36. package/lib/config-drift.js +301 -301
  37. package/lib/consent.js +222 -222
  38. package/lib/constants.js +191 -191
  39. package/lib/cookies.js +315 -315
  40. package/lib/credential-hash.js +322 -322
  41. package/lib/crypto.js +266 -266
  42. package/lib/csv.js +275 -275
  43. package/lib/db-declare-row-policy.js +267 -267
  44. package/lib/db-declare-view.js +420 -420
  45. package/lib/db-query.js +406 -406
  46. package/lib/db-schema.js +319 -319
  47. package/lib/db.js +1288 -1288
  48. package/lib/deprecate.js +222 -222
  49. package/lib/dev.js +335 -335
  50. package/lib/dual-control.js +473 -473
  51. package/lib/error-page.js +420 -420
  52. package/lib/external-db-migrate.js +441 -441
  53. package/lib/external-db.js +1061 -1061
  54. package/lib/file-type.js +273 -273
  55. package/lib/forms.js +422 -422
  56. package/lib/framework-error.js +293 -293
  57. package/lib/framework-schema.js +717 -717
  58. package/lib/handlers.js +350 -350
  59. package/lib/http-client-cookie-jar.js +508 -508
  60. package/lib/http-client.js +1195 -1195
  61. package/lib/i18n.js +878 -878
  62. package/lib/jobs.js +185 -185
  63. package/lib/log-stream-cloudwatch.js +369 -369
  64. package/lib/log-stream-local.js +146 -146
  65. package/lib/log-stream-otlp-grpc.js +410 -410
  66. package/lib/log-stream-otlp.js +286 -286
  67. package/lib/log-stream-syslog.js +302 -302
  68. package/lib/log-stream-webhook.js +199 -199
  69. package/lib/log-stream.js +330 -330
  70. package/lib/log.js +500 -500
  71. package/lib/mail-bounce.js +528 -528
  72. package/lib/mail-dkim.js +369 -369
  73. package/lib/mail.js +981 -981
  74. package/lib/metrics.js +683 -683
  75. package/lib/middleware/api-encrypt.js +936 -936
  76. package/lib/middleware/attach-user.js +157 -157
  77. package/lib/middleware/bearer-auth.js +152 -0
  78. package/lib/middleware/body-parser.js +1170 -1170
  79. package/lib/middleware/bot-guard.js +178 -178
  80. package/lib/middleware/compression.js +452 -452
  81. package/lib/middleware/cors.js +314 -314
  82. package/lib/middleware/csp-nonce.js +348 -348
  83. package/lib/middleware/csrf-protect.js +316 -316
  84. package/lib/middleware/db-role-for.js +264 -264
  85. package/lib/middleware/health.js +392 -392
  86. package/lib/middleware/index.js +82 -79
  87. package/lib/middleware/rate-limit.js +358 -358
  88. package/lib/middleware/request-id.js +61 -61
  89. package/lib/middleware/request-log.js +168 -168
  90. package/lib/middleware/require-auth.js +104 -104
  91. package/lib/middleware/security-headers.js +116 -116
  92. package/lib/middleware/sse.js +166 -166
  93. package/lib/migrations.js +383 -383
  94. package/lib/mtls-ca.js +518 -518
  95. package/lib/mtls-engine-default.js +481 -481
  96. package/lib/network-dns.js +632 -632
  97. package/lib/network-heartbeat.js +290 -290
  98. package/lib/network-nts.js +574 -574
  99. package/lib/network-proxy.js +265 -265
  100. package/lib/network-tls.js +328 -328
  101. package/lib/network.js +233 -233
  102. package/lib/notify.js +612 -612
  103. package/lib/ntp-check.js +229 -229
  104. package/lib/numeric-bounds.js +111 -111
  105. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  106. package/lib/object-store/azure-blob.js +488 -488
  107. package/lib/object-store/gcs-bucket-ops.js +351 -351
  108. package/lib/object-store/gcs.js +519 -519
  109. package/lib/object-store/http-put.js +153 -153
  110. package/lib/object-store/index.js +197 -197
  111. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  112. package/lib/object-store/sigv4.js +903 -903
  113. package/lib/observability.js +151 -151
  114. package/lib/otel-export.js +269 -269
  115. package/lib/pagination.js +464 -464
  116. package/lib/parsers/index.js +80 -80
  117. package/lib/parsers/safe-env.js +642 -642
  118. package/lib/parsers/safe-ini.js +292 -292
  119. package/lib/parsers/safe-toml.js +784 -784
  120. package/lib/parsers/safe-xml.js +390 -390
  121. package/lib/parsers/safe-yaml.js +1015 -1015
  122. package/lib/permissions.js +708 -708
  123. package/lib/pqc-agent.js +87 -87
  124. package/lib/pqc-gate.js +279 -279
  125. package/lib/protobuf-encoder.js +190 -190
  126. package/lib/protocol-dispatcher.js +161 -161
  127. package/lib/pubsub-redis.js +167 -167
  128. package/lib/pubsub.js +429 -429
  129. package/lib/queue-local.js +476 -476
  130. package/lib/queue-redis.js +745 -745
  131. package/lib/queue-sqs.js +319 -319
  132. package/lib/queue.js +695 -695
  133. package/lib/redis-client.js +519 -519
  134. package/lib/request-helpers.js +340 -340
  135. package/lib/restore-bundle.js +237 -237
  136. package/lib/restore-rollback.js +259 -259
  137. package/lib/restore.js +409 -409
  138. package/lib/retry.js +376 -376
  139. package/lib/router.js +748 -748
  140. package/lib/safe-async.js +735 -735
  141. package/lib/safe-buffer.js +237 -237
  142. package/lib/safe-json.js +541 -541
  143. package/lib/safe-schema.js +1266 -1266
  144. package/lib/safe-url.js +159 -159
  145. package/lib/scheduler.js +706 -706
  146. package/lib/security-assert.js +373 -373
  147. package/lib/seeders.js +618 -618
  148. package/lib/session.js +535 -478
  149. package/lib/slug.js +269 -269
  150. package/lib/ssrf-guard.js +401 -401
  151. package/lib/storage.js +471 -471
  152. package/lib/subject.js +281 -281
  153. package/lib/template.js +791 -791
  154. package/lib/testing.js +798 -798
  155. package/lib/time.js +310 -310
  156. package/lib/totp.js +302 -302
  157. package/lib/tracing.js +494 -494
  158. package/lib/uuid.js +132 -132
  159. package/lib/validate-opts.js +340 -340
  160. package/lib/vault/index.js +308 -308
  161. package/lib/vault/rotate.js +784 -784
  162. package/lib/vault/wrap.js +296 -296
  163. package/lib/vendor/noble-ciphers.cjs +9 -9
  164. package/lib/webhook.js +595 -595
  165. package/lib/websocket.js +1048 -1048
  166. package/package.json +77 -77
  167. package/sbom.cyclonedx.json +7 -7
package/lib/scheduler.js CHANGED
@@ -1,706 +1,706 @@
1
- "use strict";
2
- /**
3
- * scheduler — cron + interval scheduler over lib/jobs (or direct fn).
4
- *
5
- * The framework's primitive for "run X at Y" — backed by jobs/queue
6
- * for retries, audit, and cluster-aware dispatch, with a direct-fn
7
- * escape hatch for the simple cases.
8
- *
9
- * var sched = b.scheduler.create({
10
- * jobs: jobsInstance, // optional; needed for { job: "name" }
11
- * cluster: b.cluster, // optional; gates fires to leader only
12
- * audit: true, // default true
13
- * });
14
- *
15
- * sched.schedule({
16
- * name: "nightly-cleanup",
17
- * cron: "0 2 * * *", // POSIX 5-field cron
18
- * timezone: "America/New_York", // IANA name; default = server-local
19
- * job: "cleanup", // dispatched via jobs.enqueue
20
- * payload: { scope: "all" },
21
- * });
22
- *
23
- * sched.schedule({
24
- * name: "stats-aggregation",
25
- * every: 300000, // ms between runs
26
- * baseline: "00:00", // HH:MM anchor (optional)
27
- * timezone: "America/New_York",
28
- * job: "aggregate-stats",
29
- * });
30
- *
31
- * sched.schedule({
32
- * name: "heartbeat",
33
- * every: 60000,
34
- * run: async function () { … }, // direct function (no jobs needed)
35
- * });
36
- *
37
- * await sched.start(); // arms timers
38
- * await sched.stop(); // clears timers, drops pending fires
39
- *
40
- * sched.list(); // → [{ name, when, lastRun, nextRun, running }]
41
- *
42
- * Cron grammar (5 fields, space-separated):
43
- *
44
- * minute (0–59) hour (0–23) dom (1–31) month (1–12) dow (0–7; 0/7=Sun)
45
- *
46
- * Each field accepts: * N N,M,… A-B *\/N A-B/N
47
- *
48
- * Shorthands: @hourly @daily @midnight @weekly @monthly @yearly @annually
49
- *
50
- * Cluster gating: when opts.cluster is wired and the local node is not
51
- * the leader, schedule fires no-op. The leader still computes nextRun
52
- * locally so a leader transition picks up cleanly.
53
- *
54
- * Exactly-once-globally: when opts.cluster is wired, every fire first
55
- * INSERTs a row into _blamejs_scheduler_ticks keyed on (taskName,
56
- * scheduledAtUnix). The PRIMARY KEY race ensures that even if two
57
- * nodes briefly believe they are the leader (split-brain on lease
58
- * boundary), only the row-winner runs the task. The loser increments
59
- * task.tickClaimLost (visible via list()) and skips silently. Task
60
- * handlers should still be idempotent — operators may add jobs.enqueue
61
- * dedup keys for defense-in-depth.
62
- *
63
- * Tick-claim retention: rows older than opts.tickRetentionMs (default
64
- * 7 days) are pruned automatically — at most once per opts.pruneInterval
65
- * Ms (default 60s) — by the leader on its next successful fire. Operators
66
- * can also call sched.pruneTickClaims(olderThanMs?) on demand to force
67
- * a sweep (e.g. from a maintenance script) and observe the count via
68
- * the system.scheduler.tick.pruned audit event.
69
- *
70
- * Watchdog: if a fire's promise hasn't settled after MAX_JOB_MS
71
- * (10min default; opts.maxJobMs to override), the running flag is
72
- * force-cleared and a warning emitted, so a hung job doesn't lock out
73
- * future fires.
74
- */
75
-
76
- var lazyRequire = require("./lazy-require");
77
- var audit = lazyRequire(function () { return require("./audit"); });
78
- var log = lazyRequire(function () { return require("./log").boot("scheduler"); });
79
- var clusterStorage = require("./cluster-storage");
80
- var validateOpts = require("./validate-opts");
81
- var C = require("./constants");
82
- var { SchedulerError } = require("./framework-error");
83
-
84
- var DEFAULT_MAX_JOB_MS = C.TIME.minutes(10);
85
- var DEFAULT_TICK_RETENTION_MS = C.TIME.days(7);
86
- var DEFAULT_TICK_PRUNE_INTERVAL_MS = C.TIME.minutes(1);
87
-
88
- // ---- Cron parsing ----
89
-
90
- var CRON_SHORTHANDS = {
91
- "@yearly": "0 0 1 1 *",
92
- "@annually": "0 0 1 1 *",
93
- "@monthly": "0 0 1 * *",
94
- "@weekly": "0 0 * * 0",
95
- "@daily": "0 0 * * *",
96
- "@midnight": "0 0 * * *",
97
- "@hourly": "0 * * * *",
98
- };
99
-
100
- var CRON_FIELD_RANGES = [
101
- { name: "minute", min: 0, max: 59 },
102
- { name: "hour", min: 0, max: 23 },
103
- { name: "dom", min: 1, max: 31 },
104
- { name: "month", min: 1, max: 12 },
105
- { name: "dow", min: 0, max: 7 }, // 0 and 7 both mean Sunday
106
- ];
107
-
108
- function _parseCronField(text, range) {
109
- var parts = String(text).split(",");
110
- var set = new Set();
111
- for (var i = 0; i < parts.length; i++) {
112
- var part = parts[i].trim();
113
- if (part.length === 0) {
114
- throw new SchedulerError("scheduler/invalid-cron",
115
- "empty term in cron field '" + range.name + "'", true);
116
- }
117
- var step = 1;
118
- var stepIdx = part.indexOf("/");
119
- if (stepIdx !== -1) {
120
- var stepStr = part.slice(stepIdx + 1);
121
- step = parseInt(stepStr, 10);
122
- if (!Number.isFinite(step) || step < 1) {
123
- throw new SchedulerError("scheduler/invalid-cron",
124
- "bad step '" + stepStr + "' in cron field '" + range.name + "'", true);
125
- }
126
- // Reject step > field-range, even though the for-loop below would
127
- // silently produce a single-value schedule (e.g. `*/99999` for
128
- // minutes degenerates to "minute 0 of every hour"). An operator
129
- // typing `*/99999` clearly meant something else; silent acceptance
130
- // hides the typo and produces a schedule that fires once per hour
131
- // when the operator probably wanted once per N minutes for small N.
132
- // The bound is `range.max - range.min + 1` so e.g. minutes (0-59)
133
- // accepts step up to 60 (inclusive — `*/60` is "minute 0 of every
134
- // hour" written with a redundant step).
135
- var rangeSize = range.max - range.min + 1;
136
- if (step > rangeSize) {
137
- throw new SchedulerError("scheduler/invalid-cron",
138
- "step '" + stepStr + "' exceeds field range (" + rangeSize +
139
- ") in cron field '" + range.name + "'", true);
140
- }
141
- part = part.slice(0, stepIdx);
142
- }
143
- var lo, hi;
144
- if (part === "*") {
145
- lo = range.min; hi = range.max;
146
- } else if (part.indexOf("-") !== -1) {
147
- var seg = part.split("-");
148
- if (seg.length !== 2) {
149
- throw new SchedulerError("scheduler/invalid-cron",
150
- "bad range '" + part + "' in cron field '" + range.name + "'", true);
151
- }
152
- lo = parseInt(seg[0], 10);
153
- hi = parseInt(seg[1], 10);
154
- } else {
155
- lo = parseInt(part, 10);
156
- hi = lo;
157
- }
158
- if (!Number.isFinite(lo) || !Number.isFinite(hi) || lo > hi ||
159
- lo < range.min || hi > range.max) {
160
- throw new SchedulerError("scheduler/invalid-cron",
161
- "value '" + part + "' out of range " + range.min + "-" + range.max +
162
- " in cron field '" + range.name + "'", true);
163
- }
164
- for (var v = lo; v <= hi; v += step) set.add(v);
165
- }
166
- // Normalize Sunday: dow 7 → 0 (so the matcher can use a single set)
167
- if (range.name === "dow" && set.has(7)) { set.add(0); set.delete(7); }
168
- return set;
169
- }
170
-
171
- function parseCron(expr) {
172
- if (typeof expr !== "string" || expr.length === 0) {
173
- throw new SchedulerError("scheduler/invalid-cron",
174
- "cron expression must be a non-empty string", true);
175
- }
176
- var trimmed = expr.trim();
177
- if (CRON_SHORTHANDS[trimmed.toLowerCase()]) {
178
- trimmed = CRON_SHORTHANDS[trimmed.toLowerCase()];
179
- }
180
- var fields = trimmed.split(/\s+/);
181
- if (fields.length !== 5) {
182
- throw new SchedulerError("scheduler/invalid-cron",
183
- "cron expression must have 5 fields (got " + fields.length + "): " + expr, true);
184
- }
185
- var sets = [];
186
- for (var i = 0; i < 5; i++) {
187
- sets.push(_parseCronField(fields[i], CRON_FIELD_RANGES[i]));
188
- }
189
- return {
190
- expr: trimmed,
191
- minute: sets[0],
192
- hour: sets[1],
193
- dom: sets[2],
194
- month: sets[3],
195
- dow: sets[4],
196
- // Whether dom or dow was constrained — matters for the cron quirk
197
- // where day-of-month and day-of-week are OR'd when both are set.
198
- domRestricted: sets[2].size < (CRON_FIELD_RANGES[2].max - CRON_FIELD_RANGES[2].min + 1),
199
- dowRestricted: sets[4].size < 7,
200
- };
201
- }
202
-
203
- // ---- Timezone-aware wall-clock helpers ----
204
- //
205
- // We need "what's the wall clock in TZ for time T?" and "given wall
206
- // clock W in TZ, what UTC instant does that correspond to?". Intl
207
- // gives us the first cheaply. The second is approximated by walking
208
- // minute-by-minute — accurate enough for cron schedules (DST gaps fire
209
- // at the next valid wall-clock instant; overlaps fire once at the
210
- // first matching instant).
211
-
212
- function _getWallClockParts(date, timeZone) {
213
- if (!timeZone) {
214
- return {
215
- year: date.getFullYear(),
216
- month: date.getMonth() + 1,
217
- day: date.getDate(),
218
- hour: date.getHours(),
219
- minute: date.getMinutes(),
220
- dow: date.getDay(),
221
- };
222
- }
223
- var fmt = new Intl.DateTimeFormat("en-US", {
224
- timeZone: timeZone,
225
- year: "numeric", month: "2-digit", day: "2-digit",
226
- hour: "2-digit", minute: "2-digit", weekday: "short",
227
- hour12: false,
228
- });
229
- var parts = {};
230
- fmt.formatToParts(date).forEach(function (p) { parts[p.type] = p.value; });
231
- var dowMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
232
- // Some locales emit "24:00" for midnight — normalize before parseInt so
233
- // the integer literal stays out of the source.
234
- var hr = (parts.hour === "24") ? 0 : parseInt(parts.hour, 10);
235
- return {
236
- year: parseInt(parts.year, 10),
237
- month: parseInt(parts.month, 10),
238
- day: parseInt(parts.day, 10),
239
- hour: hr,
240
- minute: parseInt(parts.minute, 10),
241
- dow: dowMap[parts.weekday] || 0,
242
- };
243
- }
244
-
245
- function _validateTimezone(tz) {
246
- if (!tz) return null;
247
- try {
248
- new Intl.DateTimeFormat("en-US", { timeZone: tz }).format(new Date());
249
- return tz;
250
- } catch (_e) {
251
- throw new SchedulerError("scheduler/invalid-timezone",
252
- "unknown IANA timezone '" + tz + "'", true);
253
- }
254
- }
255
-
256
- function _matchesCron(cron, parts) {
257
- if (!cron.minute.has(parts.minute)) return false;
258
- if (!cron.hour.has(parts.hour)) return false;
259
- if (!cron.month.has(parts.month)) return false;
260
- // POSIX cron quirk: when both dom AND dow are restricted, the day
261
- // matches if EITHER matches (OR). When only one is restricted,
262
- // standard AND.
263
- var domOk = cron.dom.has(parts.day);
264
- var dowOk = cron.dow.has(parts.dow);
265
- if (cron.domRestricted && cron.dowRestricted) return domOk || dowOk;
266
- if (cron.domRestricted) return domOk;
267
- if (cron.dowRestricted) return dowOk;
268
- return true; // both fully wild
269
- }
270
-
271
- // nextCronFire — earliest UTC ms ≥ `after` whose wall-clock in `tz`
272
- // matches the cron sets. Walks minute by minute; bounded at ~530K
273
- // iterations (1 year of minutes) before giving up with a clear error.
274
- function nextCronFire(cron, after, timeZone) {
275
- var MINUTE_MS = C.TIME.minutes(1);
276
- // Round up to the next whole minute boundary
277
- var t = new Date(after.getTime() + (MINUTE_MS - (after.getTime() % MINUTE_MS)) % MINUTE_MS);
278
- if (t.getTime() <= after.getTime()) t = new Date(t.getTime() + MINUTE_MS);
279
- // 1 year of minute-walks plus a 1-hour DST/leap cushion.
280
- var maxIters = (C.TIME.days(366) / MINUTE_MS) + (C.TIME.hours(1) / MINUTE_MS);
281
- for (var i = 0; i < maxIters; i++) {
282
- var parts = _getWallClockParts(t, timeZone);
283
- if (_matchesCron(cron, parts)) return t.getTime();
284
- t = new Date(t.getTime() + MINUTE_MS);
285
- }
286
- throw new SchedulerError("scheduler/cron-no-fire",
287
- "cron expression '" + cron.expr + "' produced no fire within 1 year " +
288
- "(impossible date constraint?)", true);
289
- }
290
-
291
- // nextBaselineFire — next UTC ms whose wall-clock in `tz` matches HH:MM.
292
- function nextBaselineFire(timeOfDay, timeZone, after) {
293
- var match = String(timeOfDay).match(/^(\d{1,2}):(\d{2})$/);
294
- if (!match) {
295
- throw new SchedulerError("scheduler/invalid-baseline",
296
- "baseline must be HH:MM (got '" + timeOfDay + "')", true);
297
- }
298
- var hh = parseInt(match[1], 10);
299
- var mm = parseInt(match[2], 10);
300
- if (hh < 0 || hh > 23 || mm < 0 || mm > 59) {
301
- throw new SchedulerError("scheduler/invalid-baseline",
302
- "baseline '" + timeOfDay + "' is not a valid 24h time", true);
303
- }
304
- var MINUTE_MS = C.TIME.minutes(1);
305
- var t = new Date(after.getTime() + (MINUTE_MS - (after.getTime() % MINUTE_MS)) % MINUTE_MS);
306
- if (t.getTime() <= after.getTime()) t = new Date(t.getTime() + MINUTE_MS);
307
- // 1 day of minute-walks plus a 1-hour DST cushion.
308
- for (var i = 0; i < (C.TIME.hours(24) / MINUTE_MS) + (C.TIME.hours(1) / MINUTE_MS); i++) {
309
- var parts = _getWallClockParts(t, timeZone);
310
- if (parts.hour === hh && parts.minute === mm) return t.getTime();
311
- t = new Date(t.getTime() + MINUTE_MS);
312
- }
313
- throw new SchedulerError("scheduler/baseline-no-fire",
314
- "baseline '" + timeOfDay + "' produced no fire within 24h+ (timezone bug?)", true);
315
- }
316
-
317
- // ---- Engine ----
318
-
319
- function create(opts) {
320
- opts = opts || {};
321
- validateOpts(opts, [
322
- "jobs", "cluster", "audit",
323
- "maxJobMs", "tickRetentionMs", "pruneIntervalMs",
324
- ], "scheduler");
325
- var jobsInstance = opts.jobs || null;
326
- var clusterInstance = opts.cluster || null;
327
- var auditOn = opts.audit !== false;
328
- var maxJobMs = opts.maxJobMs || DEFAULT_MAX_JOB_MS;
329
- var tickRetentionMs = opts.tickRetentionMs != null
330
- ? opts.tickRetentionMs : DEFAULT_TICK_RETENTION_MS;
331
- var pruneIntervalMs = opts.pruneIntervalMs != null
332
- ? opts.pruneIntervalMs : DEFAULT_TICK_PRUNE_INTERVAL_MS;
333
-
334
- // name → task
335
- var tasks = new Map();
336
- var timers = new Set();
337
- var started = false;
338
- var lastPruneAt = 0;
339
-
340
- var _err = SchedulerError.factory;
341
-
342
- function _emit(action, info, outcome) {
343
- if (!auditOn) return;
344
- audit().safeEmit({
345
- action: action,
346
- outcome: outcome,
347
- metadata: info || {},
348
- reason: info && info.reason ? info.reason : null,
349
- });
350
- }
351
-
352
- function _isLeaderHere() {
353
- if (!clusterInstance) return true;
354
- try {
355
- if (typeof clusterInstance.isLeader === "function") return !!clusterInstance.isLeader();
356
- } catch (_e) { /* treat unknown leadership state as not-leader */ }
357
- return false;
358
- }
359
-
360
- function schedule(spec) {
361
- if (started) {
362
- throw _err("ALREADY_STARTED",
363
- "scheduler.schedule: cannot register '" + (spec && spec.name) +
364
- "' after start() — schedule all tasks before calling start()", true);
365
- }
366
- if (!spec || typeof spec !== "object") {
367
- throw _err("INVALID_SPEC", "scheduler.schedule requires a spec object", true);
368
- }
369
- if (typeof spec.name !== "string" || spec.name.length === 0) {
370
- throw _err("INVALID_NAME", "scheduler.schedule: spec.name is required", true);
371
- }
372
- if (tasks.has(spec.name)) {
373
- throw _err("DUPLICATE_NAME",
374
- "scheduler.schedule: '" + spec.name + "' is already scheduled", true);
375
- }
376
-
377
- var hasCron = typeof spec.cron === "string";
378
- var hasEvery = typeof spec.every === "number";
379
- if ((hasCron && hasEvery) || (!hasCron && !hasEvery)) {
380
- throw _err("INVALID_SPEC",
381
- "scheduler.schedule: spec must set exactly one of cron / every (got cron=" +
382
- hasCron + ", every=" + hasEvery + ")", true);
383
- }
384
- if (hasEvery && (!Number.isFinite(spec.every) || spec.every < C.TIME.seconds(1))) {
385
- throw _err("INVALID_SPEC",
386
- "scheduler.schedule: spec.every must be a number ≥ 1000 ms", true);
387
- }
388
- var hasJob = typeof spec.job === "string" && spec.job.length > 0;
389
- var hasRun = typeof spec.run === "function";
390
- if ((hasJob && hasRun) || (!hasJob && !hasRun)) {
391
- throw _err("INVALID_SPEC",
392
- "scheduler.schedule: spec must set exactly one of job / run", true);
393
- }
394
- if (hasJob && !jobsInstance) {
395
- throw _err("INVALID_SPEC",
396
- "scheduler.schedule: spec.job requires opts.jobs at scheduler.create — " +
397
- "use spec.run for direct-function tasks when jobs is unwired", true);
398
- }
399
-
400
- var tz = _validateTimezone(spec.timezone || null);
401
- var task = {
402
- name: spec.name,
403
- timezone: tz,
404
- job: hasJob ? spec.job : null,
405
- payload: spec.payload || null,
406
- run: hasRun ? spec.run : null,
407
- enqueueOpts: spec.enqueueOpts || null,
408
- lastRun: null,
409
- lastFinish: null,
410
- lastError: null,
411
- running: false,
412
- runningSince: 0,
413
- fires: 0,
414
- misses: 0, // skipped because previous run still in-flight
415
- nonLeaderSkips: 0,
416
- tickClaimLost: 0, // lost the tick-claim race to another leader (cluster only)
417
- };
418
- if (hasCron) {
419
- task.kind = "cron";
420
- task.cron = parseCron(spec.cron);
421
- task.exprDesc = "cron " + task.cron.expr + (tz ? " " + tz : "");
422
- task.nextRun = nextCronFire(task.cron, new Date(), tz);
423
- } else {
424
- task.kind = "every";
425
- task.every = spec.every;
426
- if (spec.baseline) {
427
- task.baseline = spec.baseline;
428
- task.nextRun = nextBaselineFire(spec.baseline, tz, new Date());
429
- } else {
430
- // Initial offset: fire one full interval after start (consistent
431
- // with how operators usually expect interval timers).
432
- task.nextRun = Date.now() + spec.every;
433
- }
434
- task.exprDesc = "every " + spec.every + "ms" +
435
- (spec.baseline ? " from " + spec.baseline : "") +
436
- (tz ? " " + tz : "");
437
- }
438
-
439
- tasks.set(spec.name, task);
440
- return task;
441
- }
442
-
443
- function _computeNextRun(task, after) {
444
- if (task.kind === "cron") {
445
- return nextCronFire(task.cron, new Date(after), task.timezone);
446
- }
447
- // every: anchor on baseline if set (so day-to-day drift stays
448
- // bounded), otherwise pure interval from `after`.
449
- if (task.baseline) {
450
- return nextBaselineFire(task.baseline, task.timezone, new Date(after));
451
- }
452
- return after + task.every;
453
- }
454
-
455
- function _fireOnce(task) {
456
- // Skip if previous run still in flight.
457
- if (task.running) {
458
- // Watchdog: if we're past MAX_JOB_MS, force-clear and let this fire.
459
- if (task.runningSince && (Date.now() - task.runningSince) > maxJobMs) {
460
- try {
461
- log().warn("[scheduler] '" + task.name + "' exceeded " +
462
- (maxJobMs / C.TIME.seconds(1)) + "s — forcing reset");
463
- } catch (_e) { /* logger best-effort */ }
464
- _emit("system.scheduler.task.watchdog", { name: task.name }, "failure");
465
- task.running = false;
466
- } else {
467
- task.misses++;
468
- _emit("system.scheduler.task.skipped",
469
- { name: task.name, reason: "previous-run-in-flight" }, "denied");
470
- return;
471
- }
472
- }
473
-
474
- // Cluster leader gate. Compute nextRun even when not leader so a
475
- // leader transition picks up cleanly without a state reload.
476
- if (!_isLeaderHere()) {
477
- task.nonLeaderSkips++;
478
- task.nextRun = _computeNextRun(task, Date.now());
479
- return;
480
- }
481
-
482
- // Capture the nominal scheduled time for this tick before we
483
- // recompute nextRun for the next firing.
484
- var nominalRun = task.nextRun;
485
-
486
- // Compute the next fire time forward from now (not from nominal
487
- // nextRun) so a long-running fire doesn't queue up backlog ticks.
488
- // Done before any await so _arm() reads the fresh value when it
489
- // re-arms after this synchronous return.
490
- task.nextRun = _computeNextRun(task, Date.now());
491
-
492
- // Cluster mode: race for the tick-claim row. Loser of the INSERT
493
- // skips silently. Single-node mode (no clusterInstance wired) fires
494
- // unconditionally — there's only one process so no contention is
495
- // possible.
496
- if (clusterInstance) {
497
- var tickKey = task.name + ":" + nominalRun;
498
- var claimedBy = (typeof clusterInstance.currentNodeId === "function")
499
- ? clusterInstance.currentNodeId() : "unknown";
500
- clusterStorage.execute(
501
- "INSERT INTO _blamejs_scheduler_ticks " +
502
- "(tickKey, name, scheduledAtUnix, claimedAtUnix, claimedBy) " +
503
- "VALUES (?, ?, ?, ?, ?) " +
504
- "ON CONFLICT (tickKey) DO NOTHING",
505
- [tickKey, task.name, nominalRun, Date.now(), claimedBy]
506
- ).then(function (result) {
507
- var won = (result && result.rowCount > 0);
508
- if (won) {
509
- _runFire(task);
510
- } else {
511
- task.tickClaimLost++;
512
- _emit("system.scheduler.tick.lost", {
513
- name: task.name, tickKey: tickKey, claimedBy: claimedBy,
514
- }, "denied");
515
- }
516
- }, function (e) {
517
- try {
518
- log().warn("[scheduler] tick-claim failed for '" + task.name + "'",
519
- { error: (e && e.message) || String(e) });
520
- } catch (_e) { /* logger best-effort */ }
521
- _emit("system.scheduler.tick.error", {
522
- name: task.name, tickKey: tickKey,
523
- reason: (e && e.message) || String(e),
524
- }, "failure");
525
- });
526
- return;
527
- }
528
-
529
- _runFire(task);
530
- }
531
-
532
- // Operator-callable prune. Deletes _blamejs_scheduler_ticks rows whose
533
- // scheduledAtUnix is older than `olderThanMs` (default = retention
534
- // window passed to scheduler.create). Returns a Promise that resolves
535
- // to the number of rows removed. No-op if cluster wiring is absent
536
- // (single-node scheduler doesn't write tick rows).
537
- async function pruneTickClaims(olderThanMs) {
538
- if (!clusterInstance) return 0;
539
- var threshold = Date.now() - (
540
- typeof olderThanMs === "number" ? olderThanMs : tickRetentionMs
541
- );
542
- var result = await clusterStorage.execute(
543
- "DELETE FROM _blamejs_scheduler_ticks WHERE scheduledAtUnix < ?",
544
- [threshold]
545
- );
546
- var removed = (result && result.rowCount) || 0;
547
- if (removed > 0) {
548
- _emit("system.scheduler.tick.pruned", {
549
- rowsDeleted: removed,
550
- olderThanUnix: threshold,
551
- });
552
- }
553
- return removed;
554
- }
555
-
556
- // Rate-limited best-effort prune called after a successful tick claim.
557
- // Errors are swallowed — pruning is housekeeping, not part of the fire
558
- // critical path.
559
- function _maybePruneTickClaims() {
560
- if (!clusterInstance) return;
561
- if (tickRetentionMs <= 0) return;
562
- var now = Date.now();
563
- if (now - lastPruneAt < pruneIntervalMs) return;
564
- lastPruneAt = now;
565
- pruneTickClaims().catch(function (e) {
566
- try {
567
- log().warn("[scheduler] tick-claim prune failed",
568
- { error: (e && e.message) || String(e) });
569
- } catch (_e) { /* logger best-effort */ }
570
- });
571
- }
572
-
573
- function _runFire(task) {
574
- _maybePruneTickClaims();
575
- task.fires++;
576
- task.running = true;
577
- task.runningSince = Date.now();
578
- task.lastRun = new Date().toISOString();
579
- var startedAt = Date.now();
580
-
581
- var promise;
582
- try {
583
- if (task.job) {
584
- promise = jobsInstance.enqueue(task.job, task.payload || {}, task.enqueueOpts || {});
585
- } else {
586
- promise = Promise.resolve(task.run());
587
- }
588
- } catch (e) {
589
- promise = Promise.reject(e);
590
- }
591
-
592
- Promise.resolve(promise).then(function (_v) {
593
- task.running = false;
594
- task.runningSince = 0;
595
- task.lastFinish = new Date().toISOString();
596
- task.lastError = null;
597
- _emit("system.scheduler.task.success", {
598
- name: task.name,
599
- kind: task.kind,
600
- durationMs: Date.now() - startedAt,
601
- viaJob: !!task.job,
602
- });
603
- }, function (e) {
604
- task.running = false;
605
- task.runningSince = 0;
606
- task.lastFinish = new Date().toISOString();
607
- task.lastError = (e && e.message) || String(e);
608
- try {
609
- log().error("[scheduler] '" + task.name + "' failed", { error: task.lastError });
610
- } catch (_e) { /* logger best-effort */ }
611
- _emit("system.scheduler.task.failure", {
612
- name: task.name,
613
- kind: task.kind,
614
- durationMs: Date.now() - startedAt,
615
- viaJob: !!task.job,
616
- reason: task.lastError,
617
- }, "failure");
618
- });
619
- }
620
-
621
- function _arm(task) {
622
- var delay = Math.max(0, task.nextRun - Date.now());
623
- var t = setTimeout(function () {
624
- timers.delete(t);
625
- if (!started) return;
626
- _fireOnce(task);
627
- if (!started) return;
628
- _arm(task);
629
- }, delay);
630
- if (typeof t.unref === "function") t.unref();
631
- timers.add(t);
632
- }
633
-
634
- async function start() {
635
- if (started) return;
636
- started = true;
637
- tasks.forEach(function (task) { _arm(task); });
638
- _emit("scheduler.start", { count: tasks.size });
639
- }
640
-
641
- async function stop() {
642
- if (!started) return;
643
- started = false;
644
- timers.forEach(function (t) {
645
- try { clearTimeout(t); }
646
- catch (e) { log().debug("stop-cleanup-failed", { op: "clearTimeout", error: e.message }); }
647
- });
648
- timers.clear();
649
- _emit("scheduler.stop", { count: tasks.size });
650
- }
651
-
652
- function list() {
653
- var out = [];
654
- tasks.forEach(function (task) {
655
- out.push({
656
- name: task.name,
657
- when: task.exprDesc,
658
- kind: task.kind,
659
- timezone: task.timezone || null,
660
- lastRun: task.lastRun,
661
- lastFinish: task.lastFinish,
662
- lastError: task.lastError,
663
- nextRun: task.nextRun ? new Date(task.nextRun).toISOString() : null,
664
- running: task.running,
665
- fires: task.fires,
666
- misses: task.misses,
667
- nonLeaderSkips: task.nonLeaderSkips,
668
- tickClaimLost: task.tickClaimLost,
669
- });
670
- });
671
- return out;
672
- }
673
-
674
- function _resetForTest() {
675
- tasks.forEach(function (_t, _n) { /* noop — drop refs below */ });
676
- timers.forEach(function (t) {
677
- try { clearTimeout(t); }
678
- catch (e) { log().debug("stop-cleanup-failed", { op: "clearTimeout", error: e.message }); }
679
- });
680
- timers.clear();
681
- tasks.clear();
682
- started = false;
683
- }
684
-
685
- return {
686
- schedule: schedule,
687
- start: start,
688
- stop: stop,
689
- list: list,
690
- pruneTickClaims: pruneTickClaims,
691
- _fireOnce: function (name) { // test hook
692
- var task = tasks.get(name);
693
- if (!task) throw _err("UNKNOWN_NAME", "no task '" + name + "'", true);
694
- _fireOnce(task);
695
- },
696
- _resetForTest: _resetForTest,
697
- };
698
- }
699
-
700
- module.exports = {
701
- create: create,
702
- parseCron: parseCron,
703
- nextCronFire: nextCronFire,
704
- nextBaselineFire: nextBaselineFire,
705
- SchedulerError: SchedulerError,
706
- };
1
+ "use strict";
2
+ /**
3
+ * scheduler — cron + interval scheduler over lib/jobs (or direct fn).
4
+ *
5
+ * The framework's primitive for "run X at Y" — backed by jobs/queue
6
+ * for retries, audit, and cluster-aware dispatch, with a direct-fn
7
+ * escape hatch for the simple cases.
8
+ *
9
+ * var sched = b.scheduler.create({
10
+ * jobs: jobsInstance, // optional; needed for { job: "name" }
11
+ * cluster: b.cluster, // optional; gates fires to leader only
12
+ * audit: true, // default true
13
+ * });
14
+ *
15
+ * sched.schedule({
16
+ * name: "nightly-cleanup",
17
+ * cron: "0 2 * * *", // POSIX 5-field cron
18
+ * timezone: "America/New_York", // IANA name; default = server-local
19
+ * job: "cleanup", // dispatched via jobs.enqueue
20
+ * payload: { scope: "all" },
21
+ * });
22
+ *
23
+ * sched.schedule({
24
+ * name: "stats-aggregation",
25
+ * every: 300000, // ms between runs
26
+ * baseline: "00:00", // HH:MM anchor (optional)
27
+ * timezone: "America/New_York",
28
+ * job: "aggregate-stats",
29
+ * });
30
+ *
31
+ * sched.schedule({
32
+ * name: "heartbeat",
33
+ * every: 60000,
34
+ * run: async function () { … }, // direct function (no jobs needed)
35
+ * });
36
+ *
37
+ * await sched.start(); // arms timers
38
+ * await sched.stop(); // clears timers, drops pending fires
39
+ *
40
+ * sched.list(); // → [{ name, when, lastRun, nextRun, running }]
41
+ *
42
+ * Cron grammar (5 fields, space-separated):
43
+ *
44
+ * minute (0–59) hour (0–23) dom (1–31) month (1–12) dow (0–7; 0/7=Sun)
45
+ *
46
+ * Each field accepts: * N N,M,… A-B *\/N A-B/N
47
+ *
48
+ * Shorthands: @hourly @daily @midnight @weekly @monthly @yearly @annually
49
+ *
50
+ * Cluster gating: when opts.cluster is wired and the local node is not
51
+ * the leader, schedule fires no-op. The leader still computes nextRun
52
+ * locally so a leader transition picks up cleanly.
53
+ *
54
+ * Exactly-once-globally: when opts.cluster is wired, every fire first
55
+ * INSERTs a row into _blamejs_scheduler_ticks keyed on (taskName,
56
+ * scheduledAtUnix). The PRIMARY KEY race ensures that even if two
57
+ * nodes briefly believe they are the leader (split-brain on lease
58
+ * boundary), only the row-winner runs the task. The loser increments
59
+ * task.tickClaimLost (visible via list()) and skips silently. Task
60
+ * handlers should still be idempotent — operators may add jobs.enqueue
61
+ * dedup keys for defense-in-depth.
62
+ *
63
+ * Tick-claim retention: rows older than opts.tickRetentionMs (default
64
+ * 7 days) are pruned automatically — at most once per opts.pruneInterval
65
+ * Ms (default 60s) — by the leader on its next successful fire. Operators
66
+ * can also call sched.pruneTickClaims(olderThanMs?) on demand to force
67
+ * a sweep (e.g. from a maintenance script) and observe the count via
68
+ * the system.scheduler.tick.pruned audit event.
69
+ *
70
+ * Watchdog: if a fire's promise hasn't settled after MAX_JOB_MS
71
+ * (10min default; opts.maxJobMs to override), the running flag is
72
+ * force-cleared and a warning emitted, so a hung job doesn't lock out
73
+ * future fires.
74
+ */
75
+
76
+ var lazyRequire = require("./lazy-require");
77
+ var audit = lazyRequire(function () { return require("./audit"); });
78
+ var log = lazyRequire(function () { return require("./log").boot("scheduler"); });
79
+ var clusterStorage = require("./cluster-storage");
80
+ var validateOpts = require("./validate-opts");
81
+ var C = require("./constants");
82
+ var { SchedulerError } = require("./framework-error");
83
+
84
+ var DEFAULT_MAX_JOB_MS = C.TIME.minutes(10);
85
+ var DEFAULT_TICK_RETENTION_MS = C.TIME.days(7);
86
+ var DEFAULT_TICK_PRUNE_INTERVAL_MS = C.TIME.minutes(1);
87
+
88
+ // ---- Cron parsing ----
89
+
90
+ var CRON_SHORTHANDS = {
91
+ "@yearly": "0 0 1 1 *",
92
+ "@annually": "0 0 1 1 *",
93
+ "@monthly": "0 0 1 * *",
94
+ "@weekly": "0 0 * * 0",
95
+ "@daily": "0 0 * * *",
96
+ "@midnight": "0 0 * * *",
97
+ "@hourly": "0 * * * *",
98
+ };
99
+
100
+ var CRON_FIELD_RANGES = [
101
+ { name: "minute", min: 0, max: 59 },
102
+ { name: "hour", min: 0, max: 23 },
103
+ { name: "dom", min: 1, max: 31 },
104
+ { name: "month", min: 1, max: 12 },
105
+ { name: "dow", min: 0, max: 7 }, // 0 and 7 both mean Sunday
106
+ ];
107
+
108
+ function _parseCronField(text, range) {
109
+ var parts = String(text).split(",");
110
+ var set = new Set();
111
+ for (var i = 0; i < parts.length; i++) {
112
+ var part = parts[i].trim();
113
+ if (part.length === 0) {
114
+ throw new SchedulerError("scheduler/invalid-cron",
115
+ "empty term in cron field '" + range.name + "'", true);
116
+ }
117
+ var step = 1;
118
+ var stepIdx = part.indexOf("/");
119
+ if (stepIdx !== -1) {
120
+ var stepStr = part.slice(stepIdx + 1);
121
+ step = parseInt(stepStr, 10);
122
+ if (!Number.isFinite(step) || step < 1) {
123
+ throw new SchedulerError("scheduler/invalid-cron",
124
+ "bad step '" + stepStr + "' in cron field '" + range.name + "'", true);
125
+ }
126
+ // Reject step > field-range, even though the for-loop below would
127
+ // silently produce a single-value schedule (e.g. `*/99999` for
128
+ // minutes degenerates to "minute 0 of every hour"). An operator
129
+ // typing `*/99999` clearly meant something else; silent acceptance
130
+ // hides the typo and produces a schedule that fires once per hour
131
+ // when the operator probably wanted once per N minutes for small N.
132
+ // The bound is `range.max - range.min + 1` so e.g. minutes (0-59)
133
+ // accepts step up to 60 (inclusive — `*/60` is "minute 0 of every
134
+ // hour" written with a redundant step).
135
+ var rangeSize = range.max - range.min + 1;
136
+ if (step > rangeSize) {
137
+ throw new SchedulerError("scheduler/invalid-cron",
138
+ "step '" + stepStr + "' exceeds field range (" + rangeSize +
139
+ ") in cron field '" + range.name + "'", true);
140
+ }
141
+ part = part.slice(0, stepIdx);
142
+ }
143
+ var lo, hi;
144
+ if (part === "*") {
145
+ lo = range.min; hi = range.max;
146
+ } else if (part.indexOf("-") !== -1) {
147
+ var seg = part.split("-");
148
+ if (seg.length !== 2) {
149
+ throw new SchedulerError("scheduler/invalid-cron",
150
+ "bad range '" + part + "' in cron field '" + range.name + "'", true);
151
+ }
152
+ lo = parseInt(seg[0], 10);
153
+ hi = parseInt(seg[1], 10);
154
+ } else {
155
+ lo = parseInt(part, 10);
156
+ hi = lo;
157
+ }
158
+ if (!Number.isFinite(lo) || !Number.isFinite(hi) || lo > hi ||
159
+ lo < range.min || hi > range.max) {
160
+ throw new SchedulerError("scheduler/invalid-cron",
161
+ "value '" + part + "' out of range " + range.min + "-" + range.max +
162
+ " in cron field '" + range.name + "'", true);
163
+ }
164
+ for (var v = lo; v <= hi; v += step) set.add(v);
165
+ }
166
+ // Normalize Sunday: dow 7 → 0 (so the matcher can use a single set)
167
+ if (range.name === "dow" && set.has(7)) { set.add(0); set.delete(7); }
168
+ return set;
169
+ }
170
+
171
+ function parseCron(expr) {
172
+ if (typeof expr !== "string" || expr.length === 0) {
173
+ throw new SchedulerError("scheduler/invalid-cron",
174
+ "cron expression must be a non-empty string", true);
175
+ }
176
+ var trimmed = expr.trim();
177
+ if (CRON_SHORTHANDS[trimmed.toLowerCase()]) {
178
+ trimmed = CRON_SHORTHANDS[trimmed.toLowerCase()];
179
+ }
180
+ var fields = trimmed.split(/\s+/);
181
+ if (fields.length !== 5) {
182
+ throw new SchedulerError("scheduler/invalid-cron",
183
+ "cron expression must have 5 fields (got " + fields.length + "): " + expr, true);
184
+ }
185
+ var sets = [];
186
+ for (var i = 0; i < 5; i++) {
187
+ sets.push(_parseCronField(fields[i], CRON_FIELD_RANGES[i]));
188
+ }
189
+ return {
190
+ expr: trimmed,
191
+ minute: sets[0],
192
+ hour: sets[1],
193
+ dom: sets[2],
194
+ month: sets[3],
195
+ dow: sets[4],
196
+ // Whether dom or dow was constrained — matters for the cron quirk
197
+ // where day-of-month and day-of-week are OR'd when both are set.
198
+ domRestricted: sets[2].size < (CRON_FIELD_RANGES[2].max - CRON_FIELD_RANGES[2].min + 1),
199
+ dowRestricted: sets[4].size < 7,
200
+ };
201
+ }
202
+
203
+ // ---- Timezone-aware wall-clock helpers ----
204
+ //
205
+ // We need "what's the wall clock in TZ for time T?" and "given wall
206
+ // clock W in TZ, what UTC instant does that correspond to?". Intl
207
+ // gives us the first cheaply. The second is approximated by walking
208
+ // minute-by-minute — accurate enough for cron schedules (DST gaps fire
209
+ // at the next valid wall-clock instant; overlaps fire once at the
210
+ // first matching instant).
211
+
212
+ function _getWallClockParts(date, timeZone) {
213
+ if (!timeZone) {
214
+ return {
215
+ year: date.getFullYear(),
216
+ month: date.getMonth() + 1,
217
+ day: date.getDate(),
218
+ hour: date.getHours(),
219
+ minute: date.getMinutes(),
220
+ dow: date.getDay(),
221
+ };
222
+ }
223
+ var fmt = new Intl.DateTimeFormat("en-US", {
224
+ timeZone: timeZone,
225
+ year: "numeric", month: "2-digit", day: "2-digit",
226
+ hour: "2-digit", minute: "2-digit", weekday: "short",
227
+ hour12: false,
228
+ });
229
+ var parts = {};
230
+ fmt.formatToParts(date).forEach(function (p) { parts[p.type] = p.value; });
231
+ var dowMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
232
+ // Some locales emit "24:00" for midnight — normalize before parseInt so
233
+ // the integer literal stays out of the source.
234
+ var hr = (parts.hour === "24") ? 0 : parseInt(parts.hour, 10);
235
+ return {
236
+ year: parseInt(parts.year, 10),
237
+ month: parseInt(parts.month, 10),
238
+ day: parseInt(parts.day, 10),
239
+ hour: hr,
240
+ minute: parseInt(parts.minute, 10),
241
+ dow: dowMap[parts.weekday] || 0,
242
+ };
243
+ }
244
+
245
+ function _validateTimezone(tz) {
246
+ if (!tz) return null;
247
+ try {
248
+ new Intl.DateTimeFormat("en-US", { timeZone: tz }).format(new Date());
249
+ return tz;
250
+ } catch (_e) {
251
+ throw new SchedulerError("scheduler/invalid-timezone",
252
+ "unknown IANA timezone '" + tz + "'", true);
253
+ }
254
+ }
255
+
256
+ function _matchesCron(cron, parts) {
257
+ if (!cron.minute.has(parts.minute)) return false;
258
+ if (!cron.hour.has(parts.hour)) return false;
259
+ if (!cron.month.has(parts.month)) return false;
260
+ // POSIX cron quirk: when both dom AND dow are restricted, the day
261
+ // matches if EITHER matches (OR). When only one is restricted,
262
+ // standard AND.
263
+ var domOk = cron.dom.has(parts.day);
264
+ var dowOk = cron.dow.has(parts.dow);
265
+ if (cron.domRestricted && cron.dowRestricted) return domOk || dowOk;
266
+ if (cron.domRestricted) return domOk;
267
+ if (cron.dowRestricted) return dowOk;
268
+ return true; // both fully wild
269
+ }
270
+
271
+ // nextCronFire — earliest UTC ms ≥ `after` whose wall-clock in `tz`
272
+ // matches the cron sets. Walks minute by minute; bounded at ~530K
273
+ // iterations (1 year of minutes) before giving up with a clear error.
274
+ function nextCronFire(cron, after, timeZone) {
275
+ var MINUTE_MS = C.TIME.minutes(1);
276
+ // Round up to the next whole minute boundary
277
+ var t = new Date(after.getTime() + (MINUTE_MS - (after.getTime() % MINUTE_MS)) % MINUTE_MS);
278
+ if (t.getTime() <= after.getTime()) t = new Date(t.getTime() + MINUTE_MS);
279
+ // 1 year of minute-walks plus a 1-hour DST/leap cushion.
280
+ var maxIters = (C.TIME.days(366) / MINUTE_MS) + (C.TIME.hours(1) / MINUTE_MS);
281
+ for (var i = 0; i < maxIters; i++) {
282
+ var parts = _getWallClockParts(t, timeZone);
283
+ if (_matchesCron(cron, parts)) return t.getTime();
284
+ t = new Date(t.getTime() + MINUTE_MS);
285
+ }
286
+ throw new SchedulerError("scheduler/cron-no-fire",
287
+ "cron expression '" + cron.expr + "' produced no fire within 1 year " +
288
+ "(impossible date constraint?)", true);
289
+ }
290
+
291
+ // nextBaselineFire — next UTC ms whose wall-clock in `tz` matches HH:MM.
292
+ function nextBaselineFire(timeOfDay, timeZone, after) {
293
+ var match = String(timeOfDay).match(/^(\d{1,2}):(\d{2})$/);
294
+ if (!match) {
295
+ throw new SchedulerError("scheduler/invalid-baseline",
296
+ "baseline must be HH:MM (got '" + timeOfDay + "')", true);
297
+ }
298
+ var hh = parseInt(match[1], 10);
299
+ var mm = parseInt(match[2], 10);
300
+ if (hh < 0 || hh > 23 || mm < 0 || mm > 59) {
301
+ throw new SchedulerError("scheduler/invalid-baseline",
302
+ "baseline '" + timeOfDay + "' is not a valid 24h time", true);
303
+ }
304
+ var MINUTE_MS = C.TIME.minutes(1);
305
+ var t = new Date(after.getTime() + (MINUTE_MS - (after.getTime() % MINUTE_MS)) % MINUTE_MS);
306
+ if (t.getTime() <= after.getTime()) t = new Date(t.getTime() + MINUTE_MS);
307
+ // 1 day of minute-walks plus a 1-hour DST cushion.
308
+ for (var i = 0; i < (C.TIME.hours(24) / MINUTE_MS) + (C.TIME.hours(1) / MINUTE_MS); i++) {
309
+ var parts = _getWallClockParts(t, timeZone);
310
+ if (parts.hour === hh && parts.minute === mm) return t.getTime();
311
+ t = new Date(t.getTime() + MINUTE_MS);
312
+ }
313
+ throw new SchedulerError("scheduler/baseline-no-fire",
314
+ "baseline '" + timeOfDay + "' produced no fire within 24h+ (timezone bug?)", true);
315
+ }
316
+
317
+ // ---- Engine ----
318
+
319
+ function create(opts) {
320
+ opts = opts || {};
321
+ validateOpts(opts, [
322
+ "jobs", "cluster", "audit",
323
+ "maxJobMs", "tickRetentionMs", "pruneIntervalMs",
324
+ ], "scheduler");
325
+ var jobsInstance = opts.jobs || null;
326
+ var clusterInstance = opts.cluster || null;
327
+ var auditOn = opts.audit !== false;
328
+ var maxJobMs = opts.maxJobMs || DEFAULT_MAX_JOB_MS;
329
+ var tickRetentionMs = opts.tickRetentionMs != null
330
+ ? opts.tickRetentionMs : DEFAULT_TICK_RETENTION_MS;
331
+ var pruneIntervalMs = opts.pruneIntervalMs != null
332
+ ? opts.pruneIntervalMs : DEFAULT_TICK_PRUNE_INTERVAL_MS;
333
+
334
+ // name → task
335
+ var tasks = new Map();
336
+ var timers = new Set();
337
+ var started = false;
338
+ var lastPruneAt = 0;
339
+
340
+ var _err = SchedulerError.factory;
341
+
342
+ function _emit(action, info, outcome) {
343
+ if (!auditOn) return;
344
+ audit().safeEmit({
345
+ action: action,
346
+ outcome: outcome,
347
+ metadata: info || {},
348
+ reason: info && info.reason ? info.reason : null,
349
+ });
350
+ }
351
+
352
+ function _isLeaderHere() {
353
+ if (!clusterInstance) return true;
354
+ try {
355
+ if (typeof clusterInstance.isLeader === "function") return !!clusterInstance.isLeader();
356
+ } catch (_e) { /* treat unknown leadership state as not-leader */ }
357
+ return false;
358
+ }
359
+
360
+ function schedule(spec) {
361
+ if (started) {
362
+ throw _err("ALREADY_STARTED",
363
+ "scheduler.schedule: cannot register '" + (spec && spec.name) +
364
+ "' after start() — schedule all tasks before calling start()", true);
365
+ }
366
+ if (!spec || typeof spec !== "object") {
367
+ throw _err("INVALID_SPEC", "scheduler.schedule requires a spec object", true);
368
+ }
369
+ if (typeof spec.name !== "string" || spec.name.length === 0) {
370
+ throw _err("INVALID_NAME", "scheduler.schedule: spec.name is required", true);
371
+ }
372
+ if (tasks.has(spec.name)) {
373
+ throw _err("DUPLICATE_NAME",
374
+ "scheduler.schedule: '" + spec.name + "' is already scheduled", true);
375
+ }
376
+
377
+ var hasCron = typeof spec.cron === "string";
378
+ var hasEvery = typeof spec.every === "number";
379
+ if ((hasCron && hasEvery) || (!hasCron && !hasEvery)) {
380
+ throw _err("INVALID_SPEC",
381
+ "scheduler.schedule: spec must set exactly one of cron / every (got cron=" +
382
+ hasCron + ", every=" + hasEvery + ")", true);
383
+ }
384
+ if (hasEvery && (!Number.isFinite(spec.every) || spec.every < C.TIME.seconds(1))) {
385
+ throw _err("INVALID_SPEC",
386
+ "scheduler.schedule: spec.every must be a number ≥ 1000 ms", true);
387
+ }
388
+ var hasJob = typeof spec.job === "string" && spec.job.length > 0;
389
+ var hasRun = typeof spec.run === "function";
390
+ if ((hasJob && hasRun) || (!hasJob && !hasRun)) {
391
+ throw _err("INVALID_SPEC",
392
+ "scheduler.schedule: spec must set exactly one of job / run", true);
393
+ }
394
+ if (hasJob && !jobsInstance) {
395
+ throw _err("INVALID_SPEC",
396
+ "scheduler.schedule: spec.job requires opts.jobs at scheduler.create — " +
397
+ "use spec.run for direct-function tasks when jobs is unwired", true);
398
+ }
399
+
400
+ var tz = _validateTimezone(spec.timezone || null);
401
+ var task = {
402
+ name: spec.name,
403
+ timezone: tz,
404
+ job: hasJob ? spec.job : null,
405
+ payload: spec.payload || null,
406
+ run: hasRun ? spec.run : null,
407
+ enqueueOpts: spec.enqueueOpts || null,
408
+ lastRun: null,
409
+ lastFinish: null,
410
+ lastError: null,
411
+ running: false,
412
+ runningSince: 0,
413
+ fires: 0,
414
+ misses: 0, // skipped because previous run still in-flight
415
+ nonLeaderSkips: 0,
416
+ tickClaimLost: 0, // lost the tick-claim race to another leader (cluster only)
417
+ };
418
+ if (hasCron) {
419
+ task.kind = "cron";
420
+ task.cron = parseCron(spec.cron);
421
+ task.exprDesc = "cron " + task.cron.expr + (tz ? " " + tz : "");
422
+ task.nextRun = nextCronFire(task.cron, new Date(), tz);
423
+ } else {
424
+ task.kind = "every";
425
+ task.every = spec.every;
426
+ if (spec.baseline) {
427
+ task.baseline = spec.baseline;
428
+ task.nextRun = nextBaselineFire(spec.baseline, tz, new Date());
429
+ } else {
430
+ // Initial offset: fire one full interval after start (consistent
431
+ // with how operators usually expect interval timers).
432
+ task.nextRun = Date.now() + spec.every;
433
+ }
434
+ task.exprDesc = "every " + spec.every + "ms" +
435
+ (spec.baseline ? " from " + spec.baseline : "") +
436
+ (tz ? " " + tz : "");
437
+ }
438
+
439
+ tasks.set(spec.name, task);
440
+ return task;
441
+ }
442
+
443
+ function _computeNextRun(task, after) {
444
+ if (task.kind === "cron") {
445
+ return nextCronFire(task.cron, new Date(after), task.timezone);
446
+ }
447
+ // every: anchor on baseline if set (so day-to-day drift stays
448
+ // bounded), otherwise pure interval from `after`.
449
+ if (task.baseline) {
450
+ return nextBaselineFire(task.baseline, task.timezone, new Date(after));
451
+ }
452
+ return after + task.every;
453
+ }
454
+
455
+ function _fireOnce(task) {
456
+ // Skip if previous run still in flight.
457
+ if (task.running) {
458
+ // Watchdog: if we're past MAX_JOB_MS, force-clear and let this fire.
459
+ if (task.runningSince && (Date.now() - task.runningSince) > maxJobMs) {
460
+ try {
461
+ log().warn("[scheduler] '" + task.name + "' exceeded " +
462
+ (maxJobMs / C.TIME.seconds(1)) + "s — forcing reset");
463
+ } catch (_e) { /* logger best-effort */ }
464
+ _emit("system.scheduler.task.watchdog", { name: task.name }, "failure");
465
+ task.running = false;
466
+ } else {
467
+ task.misses++;
468
+ _emit("system.scheduler.task.skipped",
469
+ { name: task.name, reason: "previous-run-in-flight" }, "denied");
470
+ return;
471
+ }
472
+ }
473
+
474
+ // Cluster leader gate. Compute nextRun even when not leader so a
475
+ // leader transition picks up cleanly without a state reload.
476
+ if (!_isLeaderHere()) {
477
+ task.nonLeaderSkips++;
478
+ task.nextRun = _computeNextRun(task, Date.now());
479
+ return;
480
+ }
481
+
482
+ // Capture the nominal scheduled time for this tick before we
483
+ // recompute nextRun for the next firing.
484
+ var nominalRun = task.nextRun;
485
+
486
+ // Compute the next fire time forward from now (not from nominal
487
+ // nextRun) so a long-running fire doesn't queue up backlog ticks.
488
+ // Done before any await so _arm() reads the fresh value when it
489
+ // re-arms after this synchronous return.
490
+ task.nextRun = _computeNextRun(task, Date.now());
491
+
492
+ // Cluster mode: race for the tick-claim row. Loser of the INSERT
493
+ // skips silently. Single-node mode (no clusterInstance wired) fires
494
+ // unconditionally — there's only one process so no contention is
495
+ // possible.
496
+ if (clusterInstance) {
497
+ var tickKey = task.name + ":" + nominalRun;
498
+ var claimedBy = (typeof clusterInstance.currentNodeId === "function")
499
+ ? clusterInstance.currentNodeId() : "unknown";
500
+ clusterStorage.execute(
501
+ "INSERT INTO _blamejs_scheduler_ticks " +
502
+ "(tickKey, name, scheduledAtUnix, claimedAtUnix, claimedBy) " +
503
+ "VALUES (?, ?, ?, ?, ?) " +
504
+ "ON CONFLICT (tickKey) DO NOTHING",
505
+ [tickKey, task.name, nominalRun, Date.now(), claimedBy]
506
+ ).then(function (result) {
507
+ var won = (result && result.rowCount > 0);
508
+ if (won) {
509
+ _runFire(task);
510
+ } else {
511
+ task.tickClaimLost++;
512
+ _emit("system.scheduler.tick.lost", {
513
+ name: task.name, tickKey: tickKey, claimedBy: claimedBy,
514
+ }, "denied");
515
+ }
516
+ }, function (e) {
517
+ try {
518
+ log().warn("[scheduler] tick-claim failed for '" + task.name + "'",
519
+ { error: (e && e.message) || String(e) });
520
+ } catch (_e) { /* logger best-effort */ }
521
+ _emit("system.scheduler.tick.error", {
522
+ name: task.name, tickKey: tickKey,
523
+ reason: (e && e.message) || String(e),
524
+ }, "failure");
525
+ });
526
+ return;
527
+ }
528
+
529
+ _runFire(task);
530
+ }
531
+
532
+ // Operator-callable prune. Deletes _blamejs_scheduler_ticks rows whose
533
+ // scheduledAtUnix is older than `olderThanMs` (default = retention
534
+ // window passed to scheduler.create). Returns a Promise that resolves
535
+ // to the number of rows removed. No-op if cluster wiring is absent
536
+ // (single-node scheduler doesn't write tick rows).
537
+ async function pruneTickClaims(olderThanMs) {
538
+ if (!clusterInstance) return 0;
539
+ var threshold = Date.now() - (
540
+ typeof olderThanMs === "number" ? olderThanMs : tickRetentionMs
541
+ );
542
+ var result = await clusterStorage.execute(
543
+ "DELETE FROM _blamejs_scheduler_ticks WHERE scheduledAtUnix < ?",
544
+ [threshold]
545
+ );
546
+ var removed = (result && result.rowCount) || 0;
547
+ if (removed > 0) {
548
+ _emit("system.scheduler.tick.pruned", {
549
+ rowsDeleted: removed,
550
+ olderThanUnix: threshold,
551
+ });
552
+ }
553
+ return removed;
554
+ }
555
+
556
+ // Rate-limited best-effort prune called after a successful tick claim.
557
+ // Errors are swallowed — pruning is housekeeping, not part of the fire
558
+ // critical path.
559
+ function _maybePruneTickClaims() {
560
+ if (!clusterInstance) return;
561
+ if (tickRetentionMs <= 0) return;
562
+ var now = Date.now();
563
+ if (now - lastPruneAt < pruneIntervalMs) return;
564
+ lastPruneAt = now;
565
+ pruneTickClaims().catch(function (e) {
566
+ try {
567
+ log().warn("[scheduler] tick-claim prune failed",
568
+ { error: (e && e.message) || String(e) });
569
+ } catch (_e) { /* logger best-effort */ }
570
+ });
571
+ }
572
+
573
+ function _runFire(task) {
574
+ _maybePruneTickClaims();
575
+ task.fires++;
576
+ task.running = true;
577
+ task.runningSince = Date.now();
578
+ task.lastRun = new Date().toISOString();
579
+ var startedAt = Date.now();
580
+
581
+ var promise;
582
+ try {
583
+ if (task.job) {
584
+ promise = jobsInstance.enqueue(task.job, task.payload || {}, task.enqueueOpts || {});
585
+ } else {
586
+ promise = Promise.resolve(task.run());
587
+ }
588
+ } catch (e) {
589
+ promise = Promise.reject(e);
590
+ }
591
+
592
+ Promise.resolve(promise).then(function (_v) {
593
+ task.running = false;
594
+ task.runningSince = 0;
595
+ task.lastFinish = new Date().toISOString();
596
+ task.lastError = null;
597
+ _emit("system.scheduler.task.success", {
598
+ name: task.name,
599
+ kind: task.kind,
600
+ durationMs: Date.now() - startedAt,
601
+ viaJob: !!task.job,
602
+ });
603
+ }, function (e) {
604
+ task.running = false;
605
+ task.runningSince = 0;
606
+ task.lastFinish = new Date().toISOString();
607
+ task.lastError = (e && e.message) || String(e);
608
+ try {
609
+ log().error("[scheduler] '" + task.name + "' failed", { error: task.lastError });
610
+ } catch (_e) { /* logger best-effort */ }
611
+ _emit("system.scheduler.task.failure", {
612
+ name: task.name,
613
+ kind: task.kind,
614
+ durationMs: Date.now() - startedAt,
615
+ viaJob: !!task.job,
616
+ reason: task.lastError,
617
+ }, "failure");
618
+ });
619
+ }
620
+
621
+ function _arm(task) {
622
+ var delay = Math.max(0, task.nextRun - Date.now());
623
+ var t = setTimeout(function () {
624
+ timers.delete(t);
625
+ if (!started) return;
626
+ _fireOnce(task);
627
+ if (!started) return;
628
+ _arm(task);
629
+ }, delay);
630
+ if (typeof t.unref === "function") t.unref();
631
+ timers.add(t);
632
+ }
633
+
634
+ async function start() {
635
+ if (started) return;
636
+ started = true;
637
+ tasks.forEach(function (task) { _arm(task); });
638
+ _emit("scheduler.start", { count: tasks.size });
639
+ }
640
+
641
+ async function stop() {
642
+ if (!started) return;
643
+ started = false;
644
+ timers.forEach(function (t) {
645
+ try { clearTimeout(t); }
646
+ catch (e) { log().debug("stop-cleanup-failed", { op: "clearTimeout", error: e.message }); }
647
+ });
648
+ timers.clear();
649
+ _emit("scheduler.stop", { count: tasks.size });
650
+ }
651
+
652
+ function list() {
653
+ var out = [];
654
+ tasks.forEach(function (task) {
655
+ out.push({
656
+ name: task.name,
657
+ when: task.exprDesc,
658
+ kind: task.kind,
659
+ timezone: task.timezone || null,
660
+ lastRun: task.lastRun,
661
+ lastFinish: task.lastFinish,
662
+ lastError: task.lastError,
663
+ nextRun: task.nextRun ? new Date(task.nextRun).toISOString() : null,
664
+ running: task.running,
665
+ fires: task.fires,
666
+ misses: task.misses,
667
+ nonLeaderSkips: task.nonLeaderSkips,
668
+ tickClaimLost: task.tickClaimLost,
669
+ });
670
+ });
671
+ return out;
672
+ }
673
+
674
+ function _resetForTest() {
675
+ tasks.forEach(function (_t, _n) { /* noop — drop refs below */ });
676
+ timers.forEach(function (t) {
677
+ try { clearTimeout(t); }
678
+ catch (e) { log().debug("stop-cleanup-failed", { op: "clearTimeout", error: e.message }); }
679
+ });
680
+ timers.clear();
681
+ tasks.clear();
682
+ started = false;
683
+ }
684
+
685
+ return {
686
+ schedule: schedule,
687
+ start: start,
688
+ stop: stop,
689
+ list: list,
690
+ pruneTickClaims: pruneTickClaims,
691
+ _fireOnce: function (name) { // test hook
692
+ var task = tasks.get(name);
693
+ if (!task) throw _err("UNKNOWN_NAME", "no task '" + name + "'", true);
694
+ _fireOnce(task);
695
+ },
696
+ _resetForTest: _resetForTest,
697
+ };
698
+ }
699
+
700
+ module.exports = {
701
+ create: create,
702
+ parseCron: parseCron,
703
+ nextCronFire: nextCronFire,
704
+ nextBaselineFire: nextBaselineFire,
705
+ SchedulerError: SchedulerError,
706
+ };