@blamejs/core 0.4.1

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