@cronwatch/sdk 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/dist/anthropic.cjs +3 -3
  2. package/dist/anthropic.cjs.map +1 -1
  3. package/dist/anthropic.d.cts +1 -1
  4. package/dist/anthropic.d.ts +1 -1
  5. package/dist/anthropic.js +3 -3
  6. package/dist/anthropic.js.map +1 -1
  7. package/dist/bugsnag.cjs +28 -5
  8. package/dist/bugsnag.cjs.map +1 -1
  9. package/dist/bugsnag.d.cts +1 -1
  10. package/dist/bugsnag.d.ts +1 -1
  11. package/dist/bugsnag.js +28 -5
  12. package/dist/bugsnag.js.map +1 -1
  13. package/dist/{client-DAJH7YiG.d.ts → client-C8j7B2Bo.d.ts} +31 -19
  14. package/dist/{client-z1LdR_m2.d.cts → client-CHjrNHH-.d.cts} +31 -19
  15. package/dist/d1.cjs.map +1 -1
  16. package/dist/d1.d.cts +2 -2
  17. package/dist/d1.d.ts +2 -2
  18. package/dist/d1.js.map +1 -1
  19. package/dist/datadog.cjs +28 -5
  20. package/dist/datadog.cjs.map +1 -1
  21. package/dist/datadog.d.cts +1 -1
  22. package/dist/datadog.d.ts +1 -1
  23. package/dist/datadog.js +28 -5
  24. package/dist/datadog.js.map +1 -1
  25. package/dist/discord.cjs +28 -1
  26. package/dist/discord.cjs.map +1 -1
  27. package/dist/discord.d.cts +1 -1
  28. package/dist/discord.d.ts +1 -1
  29. package/dist/discord.js +28 -1
  30. package/dist/discord.js.map +1 -1
  31. package/dist/{email-CCUzlLe9.d.cts → email-D7SfsU0F.d.ts} +2 -2
  32. package/dist/{email-CFtDqlvZ.d.ts → email-zSazJClH.d.cts} +2 -2
  33. package/dist/honeybadger.cjs +28 -5
  34. package/dist/honeybadger.cjs.map +1 -1
  35. package/dist/honeybadger.d.cts +1 -1
  36. package/dist/honeybadger.d.ts +1 -1
  37. package/dist/honeybadger.js +28 -5
  38. package/dist/honeybadger.js.map +1 -1
  39. package/dist/index.cjs +249 -84
  40. package/dist/index.cjs.map +1 -1
  41. package/dist/index.d.cts +11 -7
  42. package/dist/index.d.ts +11 -7
  43. package/dist/index.js +249 -84
  44. package/dist/index.js.map +1 -1
  45. package/dist/mailgun.cjs +28 -5
  46. package/dist/mailgun.cjs.map +1 -1
  47. package/dist/mailgun.d.cts +2 -2
  48. package/dist/mailgun.d.ts +2 -2
  49. package/dist/mailgun.js +28 -5
  50. package/dist/mailgun.js.map +1 -1
  51. package/dist/newrelic.cjs +28 -5
  52. package/dist/newrelic.cjs.map +1 -1
  53. package/dist/newrelic.d.cts +1 -1
  54. package/dist/newrelic.d.ts +1 -1
  55. package/dist/newrelic.js +28 -5
  56. package/dist/newrelic.js.map +1 -1
  57. package/dist/pg-cron.cjs.map +1 -1
  58. package/dist/pg-cron.d.cts +2 -2
  59. package/dist/pg-cron.d.ts +2 -2
  60. package/dist/pg-cron.js.map +1 -1
  61. package/dist/postgres.cjs.map +1 -1
  62. package/dist/postgres.d.cts +2 -2
  63. package/dist/postgres.d.ts +2 -2
  64. package/dist/postgres.js.map +1 -1
  65. package/dist/postmark.cjs +28 -5
  66. package/dist/postmark.cjs.map +1 -1
  67. package/dist/postmark.d.cts +2 -2
  68. package/dist/postmark.d.ts +2 -2
  69. package/dist/postmark.js +28 -5
  70. package/dist/postmark.js.map +1 -1
  71. package/dist/resend.cjs +28 -5
  72. package/dist/resend.cjs.map +1 -1
  73. package/dist/resend.d.cts +2 -2
  74. package/dist/resend.d.ts +2 -2
  75. package/dist/resend.js +28 -5
  76. package/dist/resend.js.map +1 -1
  77. package/dist/rollbar.cjs +28 -5
  78. package/dist/rollbar.cjs.map +1 -1
  79. package/dist/rollbar.d.cts +1 -1
  80. package/dist/rollbar.d.ts +1 -1
  81. package/dist/rollbar.js +28 -5
  82. package/dist/rollbar.js.map +1 -1
  83. package/dist/sendgrid.cjs +28 -5
  84. package/dist/sendgrid.cjs.map +1 -1
  85. package/dist/sendgrid.d.cts +2 -2
  86. package/dist/sendgrid.d.ts +2 -2
  87. package/dist/sendgrid.js +28 -5
  88. package/dist/sendgrid.js.map +1 -1
  89. package/dist/sentry.cjs +28 -5
  90. package/dist/sentry.cjs.map +1 -1
  91. package/dist/sentry.d.cts +1 -1
  92. package/dist/sentry.d.ts +1 -1
  93. package/dist/sentry.js +28 -5
  94. package/dist/sentry.js.map +1 -1
  95. package/dist/ses.cjs +28 -5
  96. package/dist/ses.cjs.map +1 -1
  97. package/dist/ses.d.cts +2 -2
  98. package/dist/ses.d.ts +2 -2
  99. package/dist/ses.js +28 -5
  100. package/dist/ses.js.map +1 -1
  101. package/dist/slack.cjs +32 -2
  102. package/dist/slack.cjs.map +1 -1
  103. package/dist/slack.d.cts +6 -2
  104. package/dist/slack.d.ts +6 -2
  105. package/dist/slack.js +32 -2
  106. package/dist/slack.js.map +1 -1
  107. package/dist/sqlite.cjs.map +1 -1
  108. package/dist/sqlite.d.cts +2 -2
  109. package/dist/sqlite.d.ts +2 -2
  110. package/dist/sqlite.js.map +1 -1
  111. package/dist/twilio.cjs +28 -5
  112. package/dist/twilio.cjs.map +1 -1
  113. package/dist/twilio.d.cts +1 -1
  114. package/dist/twilio.d.ts +1 -1
  115. package/dist/twilio.js +28 -5
  116. package/dist/twilio.js.map +1 -1
  117. package/dist/{types-D6t0Z846.d.cts → types-B8IFVK5k.d.cts} +23 -7
  118. package/dist/{types-D6t0Z846.d.ts → types-B8IFVK5k.d.ts} +23 -7
  119. package/dist/webhook.cjs +10 -2
  120. package/dist/webhook.cjs.map +1 -1
  121. package/dist/webhook.d.cts +24 -6
  122. package/dist/webhook.d.ts +24 -6
  123. package/dist/webhook.js +10 -2
  124. package/dist/webhook.js.map +1 -1
  125. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -172,6 +172,13 @@ function countFrom(from) {
172
172
  return from >= FIRST_DATE_MS ? from : FIRST_DATE_MS - 1;
173
173
  }
174
174
  function fireAfter(parsed, from) {
175
+ const found = croneFireAfter(parsed, from);
176
+ const start = countFrom(from);
177
+ if (start === null) return found;
178
+ const moved = gapFireAfter(parsed, start);
179
+ return moved !== null && (found === null || moved < found) ? moved : found;
180
+ }
181
+ function croneFireAfter(parsed, from) {
175
182
  const cron = crons.get(parsed);
176
183
  if (!cron) throw new Error(`schedule "${parsed.source}" was not made by parseSchedule`);
177
184
  if (never.has(parsed)) return null;
@@ -187,6 +194,43 @@ function fireAfter(parsed, from) {
187
194
  }
188
195
  return null;
189
196
  }
197
+ function springGap(timezone, at) {
198
+ const jump = springJump(timezone, at);
199
+ return jump && at < jump.jumpAt + jump.gapMs ? jump : null;
200
+ }
201
+ var JUMP_LOOKBACK_MS = 3 * 36e5;
202
+ function springJump(timezone, at) {
203
+ if (at - JUMP_LOOKBACK_MS < FIRST_DATE_MS || at >= LAST_DATE_MS) return null;
204
+ const after2 = utcOffset(at, timezone);
205
+ const before = utcOffset(at - JUMP_LOOKBACK_MS, timezone);
206
+ if (after2 <= before) return null;
207
+ let lo = at - JUMP_LOOKBACK_MS;
208
+ let hi = at;
209
+ while (hi - lo > 6e4) {
210
+ const mid = lo + Math.floor((hi - lo) / 2);
211
+ if (utcOffset(mid, timezone) === after2) hi = mid;
212
+ else lo = mid;
213
+ }
214
+ return { jumpAt: Math.floor(hi / 6e4) * 6e4, gapMs: after2 - before, beforeMs: before };
215
+ }
216
+ var wallCrons = /* @__PURE__ */ new WeakMap();
217
+ function wallCron(parsed) {
218
+ let wall = wallCrons.get(parsed);
219
+ if (!wall) {
220
+ wall = new Cron(parsed.source, { timezone: "UTC" });
221
+ wallCrons.set(parsed, wall);
222
+ }
223
+ return wall;
224
+ }
225
+ function gapFireAfter(parsed, start) {
226
+ if (never.has(parsed)) return null;
227
+ const gap = springGap(parsed.timezone, start);
228
+ if (!gap) return null;
229
+ const wall = wallCron(parsed);
230
+ const [slot] = runsAfter(wall, 1, start + gap.beforeMs);
231
+ if (slot === void 0 || slot >= gap.jumpAt + gap.beforeMs + gap.gapMs) return null;
232
+ return slot - gap.beforeMs;
233
+ }
190
234
  function firesBetween(parsed, from, to, limit) {
191
235
  const cron = crons.get(parsed);
192
236
  if (!cron) throw new Error(`schedule "${parsed.source}" was not made by parseSchedule`);
@@ -197,15 +241,34 @@ function firesBetween(parsed, from, to, limit) {
197
241
  let probe = start;
198
242
  let last = start;
199
243
  for (let guard = 0; guard < 1e3; guard++) {
244
+ if (springGap(parsed.timezone, last)) {
245
+ const t = fireAfter(parsed, last);
246
+ if (t === null || t > to) return out;
247
+ out.push(t);
248
+ last = probe = t;
249
+ if (out.length > limit) return null;
250
+ continue;
251
+ }
200
252
  const batch = runsAfter(cron, Math.min(limit + 1 - out.length, 24), probe);
201
253
  if (batch.length === 0) return out;
254
+ let stepped = false;
202
255
  for (const t of batch) {
203
256
  if (t <= last) continue;
257
+ if (springGap(parsed.timezone, t)) {
258
+ const next = fireAfter(parsed, last);
259
+ if (next === null || next > to) return out;
260
+ out.push(next);
261
+ last = probe = next;
262
+ if (out.length > limit) return null;
263
+ stepped = true;
264
+ break;
265
+ }
204
266
  if (t > to) return out;
205
267
  out.push(t);
206
268
  last = t;
207
269
  if (out.length > limit) return null;
208
270
  }
271
+ if (stepped) continue;
209
272
  const end = batch[batch.length - 1];
210
273
  probe = end > probe ? end : probe + 36e5;
211
274
  }
@@ -218,10 +281,14 @@ function nextFire(parsed, from, lastRunAt) {
218
281
  }
219
282
  return fireAfter(parsed, from);
220
283
  }
221
- function expectation(parsed, lastRunAt, registeredAt, graceMs2) {
284
+ function intervalDue(everyMs, startedAt, finishedAt) {
285
+ const due = startedAt + everyMs;
286
+ return finishedAt !== null && finishedAt > due ? finishedAt + everyMs : due;
287
+ }
288
+ function expectation(parsed, lastRunAt, registeredAt, graceMs2, lastFinishedAt = null) {
222
289
  let dueAt;
223
290
  if (parsed.kind === "interval") {
224
- dueAt = (lastRunAt ?? registeredAt) + parsed.everyMs;
291
+ dueAt = lastRunAt === null ? registeredAt + parsed.everyMs : intervalDue(parsed.everyMs, lastRunAt, lastFinishedAt);
225
292
  } else if (lastRunAt === null) {
226
293
  dueAt = fireAfter(parsed, registeredAt - 1);
227
294
  } else {
@@ -230,33 +297,25 @@ function expectation(parsed, lastRunAt, registeredAt, graceMs2) {
230
297
  return dueAt === null ? null : { dueAt, deadline: dueAt + graceMs2 };
231
298
  }
232
299
  function dueAfterRun(parsed, startedAt) {
233
- const next = fireAfter(parsed, startedAt);
234
- if (next === null) return null;
235
- const following = fireAfter(parsed, next);
236
- const covers = runCovers(startedAt, next, following) || inSpringForwardGap(parsed, startedAt, next);
237
- return covers ? following : next;
300
+ let next = fireAfter(parsed, startedAt);
301
+ for (let guard = 0; next !== null && guard < 1e5; guard++) {
302
+ const following = fireAfter(parsed, next);
303
+ const covers = runCovers(startedAt, next, following) || inSpringForwardGap(parsed, startedAt, next);
304
+ if (!covers) return next;
305
+ next = following;
306
+ }
307
+ return next;
238
308
  }
239
309
  function runCovers(startedAt, dueAt, followingAt = null) {
240
310
  const slack = followingAt === null ? EARLY_SLACK_MS : Math.min(EARLY_SLACK_MS, Math.floor((followingAt - dueAt) / 2));
241
311
  return startedAt >= dueAt - slack;
242
312
  }
243
313
  function inSpringForwardGap(parsed, startedAt, fireAt) {
244
- const LOOKBACK = 3 * 36e5;
245
- if (fireAt - LOOKBACK < FIRST_DATE_MS) return false;
246
- const after2 = utcOffset(fireAt, parsed.timezone);
247
- const before = utcOffset(fireAt - LOOKBACK, parsed.timezone);
248
- const gap = after2 - before;
249
- if (gap <= 0) return false;
250
- let lo = fireAt - LOOKBACK;
251
- let hi = fireAt;
252
- while (hi - lo > 6e4) {
253
- const mid = lo + Math.floor((hi - lo) / 2);
254
- if (utcOffset(mid, parsed.timezone) === after2) hi = mid;
255
- else lo = mid;
256
- }
257
- const jumpAt = Math.floor(hi / 6e4) * 6e4;
258
- if (fireAt - jumpAt >= gap || startedAt < jumpAt - EARLY_SLACK_MS || startedAt >= fireAt) return false;
259
- return fireAfter(parsed, jumpAt - 1) === fireAt;
314
+ const gap = springGap(parsed.timezone, fireAt);
315
+ if (!gap || startedAt < gap.jumpAt - EARLY_SLACK_MS || startedAt >= fireAt) return false;
316
+ const wall = wallCron(parsed);
317
+ const fires = (at) => runsAfter(wall, 1, at - 1)[0] === at;
318
+ return fires(fireAt + gap.beforeMs) && !fires(fireAt + gap.beforeMs + gap.gapMs);
260
319
  }
261
320
  var formatters = /* @__PURE__ */ new Map();
262
321
  function utcOffset(at, timezone) {
@@ -353,15 +412,75 @@ var SEND_LEASE_MS = 5 * 6e4;
353
412
  function alertKey(alert) {
354
413
  return `${alert.type}|${alert.at}|${alert.run?.id ?? ""}`;
355
414
  }
415
+ var HELD_TEXT_CAP = 2048;
416
+ var HELD_TEXT_SLACK = 64;
417
+ function heldRun(run) {
418
+ const error = typeof run.error === "string" && run.error.length > HELD_TEXT_CAP + HELD_TEXT_SLACK ? keepHead(run.error) : run.error;
419
+ const output = typeof run.output === "string" && run.output.length > HELD_TEXT_CAP + HELD_TEXT_SLACK ? keepTail(run.output) : run.output;
420
+ return error === run.error && output === run.output ? run : { ...run, error, output };
421
+ }
422
+ var HELD_MESSAGE_CAP = 4096;
423
+ function keepHead(text, cap = HELD_TEXT_CAP) {
424
+ let end = cap;
425
+ if (isHighSurrogate(text.charCodeAt(end - 1))) end -= 1;
426
+ return `${text.slice(0, end)}
427
+ [${text.length - end} more characters trimmed]`;
428
+ }
429
+ function keepTail(text) {
430
+ let start = text.length - HELD_TEXT_CAP;
431
+ if (isLowSurrogate(text.charCodeAt(start)) && isHighSurrogate(text.charCodeAt(start - 1))) start += 1;
432
+ return `[${start} earlier characters trimmed]
433
+ ${text.slice(start)}`;
434
+ }
435
+ function isHighSurrogate(code) {
436
+ return code >= 55296 && code <= 56319;
437
+ }
438
+ function isLowSurrogate(code) {
439
+ return code >= 56320 && code <= 57343;
440
+ }
441
+ function heldAlert(alert) {
442
+ const run = alert.run;
443
+ const trimmedRun = isObject(run) ? heldRun(run) : run;
444
+ const message = alert.message;
445
+ const trimmedMessage = typeof message === "string" && message.length > HELD_MESSAGE_CAP + HELD_TEXT_SLACK ? keepHead(message, HELD_MESSAGE_CAP) : message;
446
+ if (trimmedRun === run && trimmedMessage === message) return alert;
447
+ return { ...alert, run: trimmedRun, message: trimmedMessage };
448
+ }
449
+ function channelKeys(names) {
450
+ const seen = /* @__PURE__ */ new Map();
451
+ const taken = /* @__PURE__ */ new Set();
452
+ return names.map((name) => {
453
+ let n = (seen.get(name) ?? 0) + 1;
454
+ seen.set(name, n);
455
+ let key = n === 1 ? name : `${name}#${n}`;
456
+ while (taken.has(key)) key = `${name}#${++n}`;
457
+ taken.add(key);
458
+ return key;
459
+ });
460
+ }
461
+ function owedChannels(alert, configured) {
462
+ const owed = alert.owed;
463
+ if (!Array.isArray(owed)) return [...configured];
464
+ return configured.filter((key) => owed.includes(key));
465
+ }
466
+ function afterSend(alert, tried, failed) {
467
+ const owed = tried.filter((key) => failed.includes(key));
468
+ return owed.length === 0 ? null : { ...alert, owed };
469
+ }
470
+ function withoutOwed(alert) {
471
+ if (!("owed" in alert)) return alert;
472
+ const { owed: _owed, ...rest } = alert;
473
+ return rest;
474
+ }
356
475
  function newest(list, max) {
357
476
  return { kept: list.slice(-max), dropped: Math.max(0, list.length - max) };
358
477
  }
359
478
  function queueUndelivered(state, alerts) {
360
479
  const next = cloneState(state);
361
- const byKey = new Map(alerts.map((alert) => [alertKey(alert), alert]));
480
+ const byKey = new Map(alerts.map((alert) => [alertKey(alert), heldAlert(alert)]));
362
481
  const queue = next.undelivered.map((alert) => byKey.get(alertKey(alert)) ?? alert);
363
482
  const known = new Set(queue.map(alertKey));
364
- queue.push(...alerts.filter((alert) => !known.has(alertKey(alert))));
483
+ queue.push(...[...byKey.values()].filter((alert) => !known.has(alertKey(alert))));
365
484
  const { kept, dropped } = newest(queue, MAX_UNDELIVERED);
366
485
  next.undelivered = kept;
367
486
  return { state: next, dropped };
@@ -370,7 +489,7 @@ function holdAlerts(state, alerts, until, deferred) {
370
489
  if (alerts.length === 0) return { state, dropped: 0 };
371
490
  if (deferred) return queueUndelivered(state, alerts);
372
491
  const next = cloneState(state);
373
- const { kept, dropped } = newest([...next.sending ?? [], ...alerts.map((alert) => ({ until, alert }))], MAX_UNDELIVERED);
492
+ const { kept, dropped } = newest([...next.sending ?? [], ...alerts.map((alert) => ({ until, alert: heldAlert(alert) }))], MAX_UNDELIVERED);
374
493
  next.sending = kept;
375
494
  return { state: next, dropped };
376
495
  }
@@ -384,7 +503,7 @@ function releaseSending(state, now) {
384
503
  lapsed.filter((entry) => typeof entry?.alert === "object" && entry.alert !== null).map((entry) => entry.alert)
385
504
  );
386
505
  }
387
- function recordSent(state, delivered, failed, stale, now) {
506
+ function recordSent(state, delivered, failed, stale, now, reached = delivered.length > 0) {
388
507
  const next = cloneState(state);
389
508
  const done = new Set([...delivered, ...stale].map(alertKey));
390
509
  next.undelivered = next.undelivered.filter((alert) => !done.has(alertKey(alert)));
@@ -392,7 +511,7 @@ function recordSent(state, delivered, failed, stale, now) {
392
511
  const held = (next.sending ?? []).filter((entry) => !(typeof entry?.alert === "object" && entry.alert !== null && sent.has(alertKey(entry.alert))));
393
512
  if (held.length > 0) next.sending = held;
394
513
  else delete next.sending;
395
- if (delivered.length > 0) next.lastAlertAt = now;
514
+ if (reached) next.lastAlertAt = now;
396
515
  return queueUndelivered(next, failed);
397
516
  }
398
517
  function claimUndelivered(state, now, until) {
@@ -500,9 +619,21 @@ function formatNumber(n) {
500
619
  if (Number.isInteger(n)) return n.toLocaleString("en-US");
501
620
  return n.toLocaleString("en-US", { maximumFractionDigits: 4 });
502
621
  }
503
- function onRunStart(state) {
622
+ function finishedAtOf(run) {
623
+ return run.status !== "running" && typeof run.finishedAt === "number" ? run.finishedAt : null;
624
+ }
625
+ function catchesUp(def, run, now) {
626
+ if (!def.schedule) return true;
627
+ try {
628
+ const exp = expectation(parseSchedule(def.schedule, def.timezone), run.startedAt, run.startedAt, graceMs(def), finishedAtOf(run));
629
+ return exp === null || now <= exp.deadline;
630
+ } catch {
631
+ return true;
632
+ }
633
+ }
634
+ function onRunStart(def, run, state, now) {
504
635
  const next = cloneState(state);
505
- closeCondition(next, "missed");
636
+ if (catchesUp(def, run, now)) closeCondition(next, "missed");
506
637
  closeCondition(next, "stuck");
507
638
  return next;
508
639
  }
@@ -511,7 +642,7 @@ function onRunFinish(def, run, state, history, now) {
511
642
  const alerts = [];
512
643
  if (run.status === "ok") {
513
644
  next.consecutiveFailures = 0;
514
- closeCondition(next, "missed");
645
+ if (catchesUp(def, run, now)) closeCondition(next, "missed");
515
646
  closeCondition(next, "stuck");
516
647
  closeCondition(next, "failed");
517
648
  const slow = slowThreshold(def, history);
@@ -548,7 +679,7 @@ function onRunFinish(def, run, state, history, now) {
548
679
  return { state: next, alerts };
549
680
  }
550
681
  next.consecutiveFailures = Math.min(next.consecutiveFailures + 1, MAX_DURATION_MS);
551
- closeCondition(next, "missed");
682
+ if (catchesUp(def, run, now)) closeCondition(next, "missed");
552
683
  const threshold = Math.max(1, def.failuresBeforeAlert ?? 1);
553
684
  const condition = run.status === "timeout" ? "stuck" : "failed";
554
685
  if (next.consecutiveFailures >= threshold) {
@@ -573,8 +704,8 @@ function onCheck(def, stored, lastRun, state, now) {
573
704
  const parsed = parseSchedule(def.schedule, def.timezone);
574
705
  const grace = graceMs(def);
575
706
  const lastRunAt = lastRun?.startedAt ?? null;
576
- const exp = expectation(parsed, lastRunAt, stored.createdAt, grace);
577
- const nextExpectedAt = parsed.kind === "interval" ? nextFire(parsed, stored.createdAt, lastRunAt) : nextFire(parsed, now, null);
707
+ const exp = expectation(parsed, lastRunAt, stored.createdAt, grace, lastRun ? finishedAtOf(lastRun) : null);
708
+ const nextExpectedAt = parsed.kind === "interval" ? exp.dueAt : nextFire(parsed, now, null);
578
709
  if (!exp) return { state: next, alerts, nextExpectedAt, dueAt: null };
579
710
  if (parsed.kind === "interval" && lastRun?.status === "running") {
580
711
  return { state: next, alerts, nextExpectedAt, dueAt: exp.dueAt };
@@ -1076,7 +1207,7 @@ function staticAsset(path, base) {
1076
1207
  }
1077
1208
 
1078
1209
  // src/version.ts
1079
- var VERSION = "0.13.0";
1210
+ var VERSION = "0.14.0";
1080
1211
  var API_VERSION = 1;
1081
1212
 
1082
1213
  // src/routes/timeline.ts
@@ -1129,13 +1260,13 @@ function dueTimes(job, parsed, runs, from, to) {
1129
1260
  if (parsed.kind === "interval") {
1130
1261
  const every = parsed.everyMs;
1131
1262
  if ((to - from) / every > MAX_TICKS) return { times: [], dense: true };
1132
- const starts = runs.map((r) => r.startedAt).sort((a, b) => a - b);
1263
+ const ordered = [...runs].sort((a, b) => a.startedAt - b.startedAt);
1264
+ const dues = ordered.map((r) => intervalDue(every, r.startedAt, finishedAtOf(r)));
1133
1265
  const times2 = /* @__PURE__ */ new Set();
1134
- for (const start of starts) {
1135
- const t2 = start + every;
1266
+ for (const t2 of dues) {
1136
1267
  if (t2 >= from && t2 <= to) times2.add(t2);
1137
1268
  }
1138
- let t = starts.length ? starts[starts.length - 1] + every : job.nextExpectedAt;
1269
+ let t = dues.length ? dues[dues.length - 1] : job.nextExpectedAt;
1139
1270
  if (t !== null) {
1140
1271
  if (t < from) t += Math.ceil((from - t) / every) * every;
1141
1272
  for (; t <= to; t += every) times2.add(t);
@@ -1148,8 +1279,8 @@ function dueTimes(job, parsed, runs, from, to) {
1148
1279
  function missedAt(job, parsed, times, now) {
1149
1280
  if (!parsed || !job.open.includes("missed")) return null;
1150
1281
  const grace = safely(() => graceMs(job.definition), 0);
1151
- const last = job.lastRun?.startedAt ?? null;
1152
- if (last !== null) return safely(() => expectation(parsed, last, last, grace)?.dueAt ?? null, null);
1282
+ const lastRun = job.lastRun;
1283
+ if (lastRun) return safely(() => expectation(parsed, lastRun.startedAt, lastRun.startedAt, grace, finishedAtOf(lastRun))?.dueAt ?? null, null);
1153
1284
  const past = times.filter((t) => t + grace < now);
1154
1285
  return past.length ? past[past.length - 1] : null;
1155
1286
  }
@@ -1858,7 +1989,8 @@ function signInReturn(referer, publicOrigin, base) {
1858
1989
  return url.searchParams.has("token") ? `${base}/` : referer;
1859
1990
  }
1860
1991
  function stripBase(pathname, base) {
1861
- let path = pathname.startsWith(base) ? pathname.slice(base.length) : pathname;
1992
+ if (base !== "" && pathname !== base && !pathname.startsWith(`${base}/`)) return null;
1993
+ let path = pathname.slice(base.length);
1862
1994
  if (path === "") path = "/";
1863
1995
  if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
1864
1996
  return path;
@@ -2114,6 +2246,7 @@ function buildRoutes(cw, options = {}) {
2114
2246
  try {
2115
2247
  const url = new URL(request.url);
2116
2248
  const path = stripBase(url.pathname, base);
2249
+ if (path === null) return html(messagePage("Not found", url.pathname, base), 404);
2117
2250
  wantsHtml = !path.startsWith("/api");
2118
2251
  return await serve(request, url, path, wantsHtml);
2119
2252
  } catch (e) {
@@ -2468,7 +2601,7 @@ var Cronwatch = class {
2468
2601
  warnNoSecret() {
2469
2602
  if (this.warnedNoSecret) return;
2470
2603
  this.warnedNoSecret = true;
2471
- this.onError(new Error("handler() refused a request because no CRON_SECRET is set; pass secret: null to allow unauthenticated requests"), "handler");
2604
+ this.report(new Error("handler() refused a request because no CRON_SECRET is set; pass secret: null to allow unauthenticated requests"), "handler");
2472
2605
  }
2473
2606
  /** onError, for places that must carry on even when onError itself throws. */
2474
2607
  report(error, where) {
@@ -2481,6 +2614,13 @@ var Cronwatch = class {
2481
2614
  if (!this.ready) {
2482
2615
  this.ready = (async () => {
2483
2616
  if (this.store.init) await this.store.init();
2617
+ const missing = ["compareAndSetState", "updateRunIf"].filter((method) => typeof this.store[method] !== "function");
2618
+ if (missing.length > 0) {
2619
+ this.report(
2620
+ new Error(`the store has no ${missing.join(" or ")}, so two processes sharing it can overwrite each other's state or both evaluate the same run; it is safe only while one process at a time uses it`),
2621
+ "store"
2622
+ );
2623
+ }
2484
2624
  if (this.usingDefaultStore && isProduction()) {
2485
2625
  console.warn("[cronwatch] using the in-memory store: runs and state are lost on restart. Pass a store from @cronwatch/sdk/sqlite or @cronwatch/sdk/postgres.");
2486
2626
  }
@@ -2628,10 +2768,11 @@ var Cronwatch = class {
2628
2768
  await this.store.insertRun({ ...run });
2629
2769
  recorded = true;
2630
2770
  } catch (e) {
2631
- this.onError(e, `recording ${name}`);
2771
+ this.report(e, `recording ${name}`);
2632
2772
  }
2633
- const started = recorded ? this.updateState(name, (before) => ({ state: holdRun(onRunStart(before), run.id), result: void 0 })).then(() => {
2634
- }, (e) => this.onError(e, `starting ${name}`)) : Promise.resolve();
2773
+ const begun = { ...run };
2774
+ const started = recorded ? this.updateState(name, (before) => ({ state: holdRun(onRunStart(definition, begun, before, this.now()), run.id), result: void 0 })).then(() => {
2775
+ }, (e) => this.report(e, `starting ${name}`)) : Promise.resolve();
2635
2776
  const recorder = createRecorder(run);
2636
2777
  const clearTimer = after(timeoutMs(definition), () => recorder.abort());
2637
2778
  let result;
@@ -2656,7 +2797,7 @@ var Cronwatch = class {
2656
2797
  const ignored = await this.recordFinish(definition, run, recorded, finishedAt);
2657
2798
  if (ignored) this.report(new Error(`run ${run.id} of ${name} ${ignored}; ignored`), `finishing ${name}`);
2658
2799
  } catch (e) {
2659
- this.onError(e, `recording ${name}`);
2800
+ this.report(e, `recording ${name}`);
2660
2801
  }
2661
2802
  return { run, result, error, threw };
2662
2803
  }
@@ -2759,7 +2900,7 @@ ${id}`;
2759
2900
  await this.ensureReady();
2760
2901
  stored = await this.store.getRun(id);
2761
2902
  } catch (e) {
2762
- this.onError(e, `recording ${name}`);
2903
+ this.report(e, `recording ${name}`);
2763
2904
  }
2764
2905
  if (stored) return this.existingHandle(definition, stored);
2765
2906
  }
@@ -2783,10 +2924,10 @@ ${id}`;
2783
2924
  } catch (e) {
2784
2925
  const stored = id === void 0 ? null : await this.store.getRun(id).catch(() => null);
2785
2926
  if (stored) return this.existingHandle(definition, stored);
2786
- this.onError(e, `recording ${name}`);
2927
+ this.report(e, `recording ${name}`);
2787
2928
  }
2788
2929
  if (recorded) {
2789
- await this.updateState(name, (before) => ({ state: holdRun(onRunStart(before), run.id), result: void 0 })).catch((e) => this.onError(e, `starting ${name}`));
2930
+ await this.updateState(name, (before) => ({ state: holdRun(onRunStart(definition, run, before, this.now()), run.id), result: void 0 })).catch((e) => this.report(e, `starting ${name}`));
2790
2931
  }
2791
2932
  return this.runHandle(definition, run.id, run, recorded, null);
2792
2933
  }
@@ -2798,7 +2939,7 @@ ${id}`;
2798
2939
  await this.ensureReady();
2799
2940
  stored = await this.store.getRun(runId);
2800
2941
  } catch (e) {
2801
- this.onError(e, `resuming ${definition.name}`);
2942
+ this.report(e, `resuming ${definition.name}`);
2802
2943
  return this.runHandle(definition, runId, null, true, null);
2803
2944
  }
2804
2945
  if (!stored) return this.runHandle(definition, runId, null, true, "was not found");
@@ -2999,7 +3140,7 @@ ${id}`;
2999
3140
  throw e;
3000
3141
  }
3001
3142
  if (!evaluate) return [];
3002
- await this.updateState(run.job, (before) => ({ state: holdRun(onRunStart(before), run.id), result: void 0 }));
3143
+ await this.updateState(run.job, (before) => ({ state: holdRun(onRunStart(definition, run, before, this.now()), run.id), result: void 0 }));
3003
3144
  if (run.status === "running") return [];
3004
3145
  return this.finishRun(definition, run, this.now());
3005
3146
  }
@@ -3037,7 +3178,7 @@ ${id}`;
3037
3178
  return this.outbox(applySilence(before, onRunFinish(definition, run, before, history, now), now), definition, now);
3038
3179
  }));
3039
3180
  } catch (e) {
3040
- this.onError(e, `evaluating ${run.job}`);
3181
+ this.report(e, `evaluating ${run.job}`);
3041
3182
  return [];
3042
3183
  }
3043
3184
  this.reportDropped(run.job, held.dropped);
@@ -3076,7 +3217,7 @@ ${id}`;
3076
3217
  }
3077
3218
  /**
3078
3219
  * Look for missed and stuck runs across every job, send alerts, retry
3079
- * alerts no channel accepted, and prune old runs. Call it from an interval
3220
+ * alerts to the channels that refused them, and prune old runs. Call it from an interval
3080
3221
  * (start()), a cron hitting the mounted routes, or by hand. Concurrent
3081
3222
  * calls share one check.
3082
3223
  */
@@ -3296,7 +3437,7 @@ ${id}`;
3296
3437
  this.warnedDeferredStart = true;
3297
3438
  console.warn('[cronwatch] startChecking() was called with deliver: "check", so these checks send no alerts. Another process must run checks with deliver: "now" (the default) to send them.');
3298
3439
  }
3299
- const tick = () => this.check().catch((e) => this.onError(e, "check"));
3440
+ const tick = () => this.check().catch((e) => this.report(e, "check"));
3300
3441
  this.timer = setInterval(tick, ms);
3301
3442
  if (typeof this.timer === "object" && "unref" in this.timer) this.timer.unref();
3302
3443
  this.firstTick = setTimeout(() => {
@@ -3345,21 +3486,28 @@ ${id}`;
3345
3486
  if (alerts.length === 0 || this.deferDelivery) return alerts;
3346
3487
  const delivered = [];
3347
3488
  const failed = [];
3489
+ let reached = false;
3348
3490
  for (const alert of alerts) {
3349
3491
  if (this.triage && alert.type !== "recovered") await this.addTriage(alert, TRIAGE_TIMEOUT_MS);
3350
- (await this.deliver(alert) ? delivered : failed).push(alert);
3492
+ const sent = await this.deliver(alert);
3493
+ reached ||= sent.reached;
3494
+ if (sent.left) failed.push(sent.left);
3495
+ else delivered.push(alert);
3351
3496
  }
3352
- await this.recordDelivery(name, delivered, failed, [], now);
3497
+ await this.recordDelivery(name, delivered, failed, [], now, reached);
3353
3498
  return alerts;
3354
3499
  }
3355
3500
  /**
3356
- * Send the alerts that no channel accepted last time, once each, oldest
3357
- * first. They are claimed first (claimUndelivered): moved into `sending`
3358
- * with a lease, in one conditional write, so when several processes check
3359
- * at once only the one whose write lands sends them. An alert that no
3360
- * longer describes the job (staleAlert) is dropped instead. Retries across
3361
- * a check share RETRY_BUDGET_MS of wall-clock time; claimed alerts it has
3362
- * no time left for go back to the queue for the next check.
3501
+ * Send the alerts some channel refused last time, once each, oldest
3502
+ * first, to the channels that still owe them (HeldAlert.owed) and no
3503
+ * others, from the copy held in state, its run trimmed (heldRun). They
3504
+ * are claimed first (claimUndelivered): moved into `sending` with a lease,
3505
+ * in one conditional write, so when several processes check at once only
3506
+ * the one whose write lands sends them. An alert that no longer describes
3507
+ * the job (staleAlert) is dropped instead. Retries across a check share
3508
+ * RETRY_BUDGET_MS of wall-clock time; claimed alerts it has no time left
3509
+ * for go back to the queue for the next check. Returns the alerts some
3510
+ * channel took on this attempt.
3363
3511
  */
3364
3512
  async retryUndelivered(name, state, now, budget) {
3365
3513
  if ((state.undelivered ?? []).length === 0 || this.deferDelivery) return [];
@@ -3370,12 +3518,13 @@ ${id}`;
3370
3518
  return { state: claim.state, result: claim.claimed };
3371
3519
  }));
3372
3520
  } catch (e) {
3373
- this.onError(e, `claiming undelivered alerts for ${name}`);
3521
+ this.report(e, `claiming undelivered alerts for ${name}`);
3374
3522
  return [];
3375
3523
  }
3376
3524
  if (claimed.length === 0) return [];
3377
3525
  const delivered = [];
3378
3526
  const failed = [];
3527
+ const sent = [];
3379
3528
  for (const alert of claimed) {
3380
3529
  const left = RETRY_BUDGET_MS - budget.spentMs;
3381
3530
  if (left <= 0) {
@@ -3384,45 +3533,61 @@ ${id}`;
3384
3533
  }
3385
3534
  const started = Date.now();
3386
3535
  if (this.triage && alert.type !== "recovered" && alert.triage === void 0) await this.addTriage(alert, Math.min(TRIAGE_TIMEOUT_MS, left));
3387
- (await this.deliver(alert) ? delivered : failed).push(alert);
3536
+ const outcome = await this.deliver(alert);
3537
+ if (outcome.reached) sent.push(withoutOwed(alert));
3538
+ if (outcome.left) failed.push(outcome.left);
3539
+ else delivered.push(alert);
3388
3540
  budget.spentMs += Math.max(0, Date.now() - started);
3389
3541
  }
3390
- await this.recordDelivery(name, delivered, failed, [], now);
3391
- return delivered;
3542
+ await this.recordDelivery(name, delivered, failed, [], now, sent.length > 0);
3543
+ return sent;
3392
3544
  }
3393
3545
  /**
3394
3546
  * Mark delivered alerts done, drop stale ones, and keep failed ones for the
3395
3547
  * next check, taking them all out of `sending` (recordSent). A failed alert
3396
- * replaces its stored copy, so a triage made on this attempt is kept.
3397
- * lastAlertAt moves only on a delivery. When this write fails, alerts
3398
- * still in `sending` are retried once their lease runs out.
3548
+ * replaces its stored copy, so a triage made on this attempt and the
3549
+ * channels that still owe it are kept. lastAlertAt moves when `reached`,
3550
+ * some channel accepted an alert on this attempt. When this write fails,
3551
+ * alerts still in `sending` are retried once their lease runs out.
3399
3552
  */
3400
- async recordDelivery(name, delivered, failed, dropped, now) {
3553
+ async recordDelivery(name, delivered, failed, dropped, now, reached) {
3401
3554
  try {
3402
3555
  const { result: trimmed } = await this.updateState(name, (previous) => {
3403
- const { state, dropped: letGo } = recordSent(normalizeState(previous, name), delivered, failed, dropped, now);
3556
+ const { state, dropped: letGo } = recordSent(normalizeState(previous, name), delivered, failed, dropped, now, reached);
3404
3557
  return { state, result: letGo };
3405
3558
  });
3406
3559
  this.reportDropped(name, trimmed);
3407
3560
  } catch (e) {
3408
- this.onError(e, `recording alert delivery for ${name}`);
3561
+ this.report(e, `recording alert delivery for ${name}`);
3409
3562
  }
3410
3563
  }
3411
- /** Send to every channel at once. True when at least one accepted it, or there are none. */
3564
+ /**
3565
+ * Send to every channel that still owes the alert (owedChannels: all of
3566
+ * them for an alert not sent before), at once, each failure reported under
3567
+ * its channel's name. `left` is the alert with `owed` cut to the channels
3568
+ * that refused it, or null when none did (or none is left to owe it);
3569
+ * `reached` is whether any channel accepted it. With no channels at all it
3570
+ * counts as delivered, as it always has.
3571
+ */
3412
3572
  async deliver(alert) {
3413
- if (this.alerts.length === 0) return true;
3414
- const results = await Promise.all(
3415
- this.alerts.map(async (channel) => {
3573
+ if (this.alerts.length === 0) return { left: null, reached: true };
3574
+ const keys = channelKeys(this.alerts.map((channel) => channel.name));
3575
+ const owed = owedChannels(alert, keys);
3576
+ if (owed.length === 0) return { left: null, reached: false };
3577
+ const plain = withoutOwed(alert);
3578
+ const failed = [];
3579
+ await Promise.all(
3580
+ this.alerts.map(async (channel, i) => {
3581
+ if (!owed.includes(keys[i])) return;
3416
3582
  try {
3417
- await withTimeout(channel.send(alert, { onError: (e) => this.report(e, `alert channel ${channel.name}`) }), CHANNEL_TIMEOUT_MS);
3418
- return true;
3583
+ await withTimeout(channel.send(plain, { onError: (e) => this.report(e, `alert channel ${channel.name}`) }), CHANNEL_TIMEOUT_MS);
3419
3584
  } catch (e) {
3420
- this.onError(e, `alert channel ${channel.name}`);
3421
- return false;
3585
+ failed.push(keys[i]);
3586
+ this.report(e, `alert channel ${channel.name}`);
3422
3587
  }
3423
3588
  })
3424
3589
  );
3425
- return results.includes(true);
3590
+ return { left: afterSend(alert, owed, failed), reached: failed.length < owed.length };
3426
3591
  }
3427
3592
  /** Sets alert.triage to the diagnosis, or to null when there is none, so it is tried once per alert. */
3428
3593
  async addTriage(alert, timeout) {
@@ -3434,7 +3599,7 @@ ${id}`;
3434
3599
  } catch (e) {
3435
3600
  controller.abort();
3436
3601
  alert.triage = null;
3437
- this.onError(e, `triage for ${alert.job}`);
3602
+ this.report(e, `triage for ${alert.job}`);
3438
3603
  }
3439
3604
  }
3440
3605
  };