@blamejs/core 0.7.1 → 0.7.18

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 (180) hide show
  1. package/CHANGELOG.md +423 -389
  2. package/README.md +150 -149
  3. package/bin/blamejs.js +0 -0
  4. package/index.js +308 -282
  5. package/lib/api-key.js +660 -672
  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.js +311 -311
  16. package/lib/auth/lockout.js +436 -436
  17. package/lib/auth/oauth.js +721 -721
  18. package/lib/auth/passkey.js +181 -181
  19. package/lib/auth/password.js +594 -594
  20. package/lib/backup/bundle.js +217 -217
  21. package/lib/backup/crypto.js +176 -176
  22. package/lib/backup/index.js +515 -515
  23. package/lib/backup/manifest.js +282 -282
  24. package/lib/break-glass.js +1338 -1338
  25. package/lib/bundler.js +441 -441
  26. package/lib/cache-redis.js +256 -266
  27. package/lib/cache.js +1206 -1211
  28. package/lib/canonical-json.js +115 -115
  29. package/lib/chain-writer.js +234 -234
  30. package/lib/cli-helpers.js +206 -206
  31. package/lib/cli.js +2334 -2334
  32. package/lib/cluster-provider-db.js +317 -317
  33. package/lib/cluster-storage.js +226 -226
  34. package/lib/cluster.js +703 -703
  35. package/lib/codepoint-class.js +196 -0
  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 -286
  43. package/lib/db-declare-row-policy.js +267 -267
  44. package/lib/db-declare-view.js +420 -421
  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/file-upload.js +1136 -0
  56. package/lib/forms.js +422 -422
  57. package/lib/framework-error.js +293 -202
  58. package/lib/framework-schema.js +717 -717
  59. package/lib/gate-contract.js +971 -0
  60. package/lib/guard-all.js +405 -0
  61. package/lib/guard-archive.js +739 -0
  62. package/lib/guard-csv.js +816 -0
  63. package/lib/guard-email.js +744 -0
  64. package/lib/guard-filename.js +724 -0
  65. package/lib/guard-html.js +976 -0
  66. package/lib/guard-json.js +729 -0
  67. package/lib/guard-markdown.js +586 -0
  68. package/lib/guard-svg.js +976 -0
  69. package/lib/guard-xml.js +405 -0
  70. package/lib/guard-yaml.js +529 -0
  71. package/lib/handlers.js +350 -350
  72. package/lib/http-client-cookie-jar.js +508 -508
  73. package/lib/http-client.js +1195 -1195
  74. package/lib/i18n.js +878 -878
  75. package/lib/jobs.js +185 -185
  76. package/lib/log-stream-cloudwatch.js +369 -369
  77. package/lib/log-stream-local.js +146 -146
  78. package/lib/log-stream-otlp-grpc.js +410 -410
  79. package/lib/log-stream-otlp.js +286 -286
  80. package/lib/log-stream-syslog.js +302 -302
  81. package/lib/log-stream-webhook.js +199 -199
  82. package/lib/log-stream.js +330 -330
  83. package/lib/log.js +500 -500
  84. package/lib/mail-bounce.js +528 -528
  85. package/lib/mail-dkim.js +369 -362
  86. package/lib/mail.js +981 -962
  87. package/lib/metrics.js +683 -683
  88. package/lib/middleware/api-encrypt.js +936 -573
  89. package/lib/middleware/attach-user.js +157 -157
  90. package/lib/middleware/body-parser.js +1170 -1091
  91. package/lib/middleware/bot-guard.js +178 -178
  92. package/lib/middleware/compression.js +452 -452
  93. package/lib/middleware/cors.js +314 -314
  94. package/lib/middleware/csp-nonce.js +348 -348
  95. package/lib/middleware/csrf-protect.js +316 -316
  96. package/lib/middleware/db-role-for.js +264 -269
  97. package/lib/middleware/health.js +392 -392
  98. package/lib/middleware/index.js +79 -79
  99. package/lib/middleware/rate-limit.js +358 -358
  100. package/lib/middleware/request-id.js +61 -61
  101. package/lib/middleware/request-log.js +168 -168
  102. package/lib/middleware/require-auth.js +104 -104
  103. package/lib/middleware/security-headers.js +116 -116
  104. package/lib/middleware/sse.js +166 -166
  105. package/lib/migrations.js +383 -383
  106. package/lib/mtls-ca.js +518 -518
  107. package/lib/mtls-engine-default.js +481 -481
  108. package/lib/network-dns.js +632 -632
  109. package/lib/network-heartbeat.js +290 -290
  110. package/lib/network-nts.js +574 -574
  111. package/lib/network-proxy.js +265 -265
  112. package/lib/network-tls.js +328 -328
  113. package/lib/network.js +233 -233
  114. package/lib/notify.js +612 -614
  115. package/lib/ntp-check.js +229 -229
  116. package/lib/numeric-bounds.js +111 -91
  117. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  118. package/lib/object-store/azure-blob.js +488 -451
  119. package/lib/object-store/gcs-bucket-ops.js +351 -351
  120. package/lib/object-store/gcs.js +519 -479
  121. package/lib/object-store/http-put.js +153 -153
  122. package/lib/object-store/index.js +197 -197
  123. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  124. package/lib/object-store/sigv4.js +903 -855
  125. package/lib/observability.js +151 -151
  126. package/lib/otel-export.js +269 -269
  127. package/lib/pagination.js +464 -464
  128. package/lib/parsers/index.js +80 -80
  129. package/lib/parsers/safe-env.js +642 -642
  130. package/lib/parsers/safe-ini.js +292 -292
  131. package/lib/parsers/safe-toml.js +784 -784
  132. package/lib/parsers/safe-xml.js +390 -390
  133. package/lib/parsers/safe-yaml.js +1015 -1015
  134. package/lib/permissions.js +708 -708
  135. package/lib/pqc-agent.js +87 -87
  136. package/lib/pqc-gate.js +279 -279
  137. package/lib/protobuf-encoder.js +190 -190
  138. package/lib/protocol-dispatcher.js +161 -161
  139. package/lib/pubsub-redis.js +167 -177
  140. package/lib/pubsub.js +429 -429
  141. package/lib/queue-local.js +476 -476
  142. package/lib/queue-redis.js +745 -752
  143. package/lib/queue-sqs.js +319 -319
  144. package/lib/queue.js +695 -695
  145. package/lib/redis-client.js +519 -489
  146. package/lib/request-helpers.js +340 -336
  147. package/lib/restore-bundle.js +237 -237
  148. package/lib/restore-rollback.js +259 -259
  149. package/lib/restore.js +409 -409
  150. package/lib/retry.js +376 -376
  151. package/lib/router.js +748 -748
  152. package/lib/safe-async.js +735 -735
  153. package/lib/safe-buffer.js +237 -237
  154. package/lib/safe-json.js +541 -541
  155. package/lib/safe-schema.js +1266 -1266
  156. package/lib/safe-url.js +159 -159
  157. package/lib/scheduler.js +706 -706
  158. package/lib/security-assert.js +373 -373
  159. package/lib/seeders.js +618 -630
  160. package/lib/session.js +478 -478
  161. package/lib/slug.js +269 -269
  162. package/lib/ssrf-guard.js +401 -401
  163. package/lib/static.js +879 -114
  164. package/lib/storage.js +471 -471
  165. package/lib/subject.js +281 -281
  166. package/lib/template.js +791 -791
  167. package/lib/testing.js +798 -798
  168. package/lib/time.js +310 -310
  169. package/lib/totp.js +302 -302
  170. package/lib/tracing.js +494 -494
  171. package/lib/uuid.js +132 -132
  172. package/lib/validate-opts.js +340 -270
  173. package/lib/vault/index.js +308 -308
  174. package/lib/vault/rotate.js +784 -784
  175. package/lib/vault/wrap.js +296 -296
  176. package/lib/vendor/noble-ciphers.cjs +9 -9
  177. package/lib/webhook.js +595 -598
  178. package/lib/websocket.js +1048 -1048
  179. package/package.json +77 -77
  180. 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
+ };