@cronwatch/sdk 0.13.1 → 0.14.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 (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 +47 -9
  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 +47 -9
  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 +47 -9
  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 +47 -9
  24. package/dist/datadog.js.map +1 -1
  25. package/dist/discord.cjs +55 -10
  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 +55 -10
  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 +47 -9
  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 +47 -9
  38. package/dist/honeybadger.js.map +1 -1
  39. package/dist/index.cjs +281 -100
  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 +281 -100
  44. package/dist/index.js.map +1 -1
  45. package/dist/mailgun.cjs +47 -9
  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 +47 -9
  50. package/dist/mailgun.js.map +1 -1
  51. package/dist/newrelic.cjs +47 -9
  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 +47 -9
  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 +47 -9
  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 +47 -9
  70. package/dist/postmark.js.map +1 -1
  71. package/dist/resend.cjs +47 -9
  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 +47 -9
  76. package/dist/resend.js.map +1 -1
  77. package/dist/rollbar.cjs +47 -9
  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 +47 -9
  82. package/dist/rollbar.js.map +1 -1
  83. package/dist/sendgrid.cjs +47 -9
  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 +47 -9
  88. package/dist/sendgrid.js.map +1 -1
  89. package/dist/sentry.cjs +47 -9
  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 +47 -9
  94. package/dist/sentry.js.map +1 -1
  95. package/dist/ses.cjs +47 -9
  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 +47 -9
  100. package/dist/ses.js.map +1 -1
  101. package/dist/slack.cjs +59 -11
  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 +59 -11
  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 +47 -9
  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 +47 -9
  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 +39 -9
  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 +39 -9
  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 };
@@ -860,11 +991,15 @@ function stringify(part) {
860
991
  }
861
992
  }
862
993
  function createRecorder(run) {
994
+ const window = OUTPUT_CAP + REDACT_EDGE + 2;
995
+ const copy = (text) => JSON.parse(JSON.stringify(text));
863
996
  const lines = [];
864
997
  let size = 0;
865
998
  const head = [];
866
999
  let headSize = 0;
867
- let dropped = false;
1000
+ let whole = "";
1001
+ let tail2 = "";
1002
+ let logged = false;
868
1003
  const metrics = {};
869
1004
  const controller = new AbortController();
870
1005
  const context = {
@@ -873,16 +1008,23 @@ function createRecorder(run) {
873
1008
  startedAt: run.startedAt,
874
1009
  signal: controller.signal,
875
1010
  log(...parts) {
876
- const line = parts.map(stringify).join(" ");
1011
+ let line = parts.map(stringify).join(" ");
1012
+ const separator = logged ? "\n" : "";
1013
+ logged = true;
1014
+ if (whole !== null) whole = whole.length + separator.length + line.length <= 2 * OUTPUT_CAP ? whole + separator + line : null;
1015
+ tail2 = copy((tail2 + separator + copy(line.slice(-OUTPUT_CAP))).slice(-OUTPUT_CAP));
877
1016
  if (headSize < OUTPUT_CAP) {
878
- head.push(line);
1017
+ head.push(copy(line.slice(0, OUTPUT_CAP - headSize)));
879
1018
  headSize += line.length + 1;
880
1019
  }
1020
+ line = stripNul(line);
1021
+ if (line.length >= window) {
1022
+ line = copy(line.slice(-68609));
1023
+ }
881
1024
  lines.push(line);
882
1025
  size += line.length + 1;
883
- while (size > 64 * 1024 && lines.length > 1) {
1026
+ while (size > window && lines.length > 1) {
884
1027
  size -= lines.shift().length + 1;
885
- dropped = true;
886
1028
  }
887
1029
  },
888
1030
  metric(name, value) {
@@ -899,10 +1041,8 @@ function createRecorder(run) {
899
1041
  context,
900
1042
  output: () => lines.length === 0 ? null : lines.join("\n"),
901
1043
  expectText: () => {
902
- if (lines.length === 0) return null;
903
- const all = lines.join("\n");
904
- if (!dropped && all.length <= 2 * OUTPUT_CAP) return all;
905
- return head.join("\n").slice(0, OUTPUT_CAP) + "\n" + all.slice(-OUTPUT_CAP);
1044
+ if (!logged) return null;
1045
+ return whole ?? head.join("\n").slice(0, OUTPUT_CAP) + "\n" + tail2;
906
1046
  },
907
1047
  metrics: () => ({ ...metrics }),
908
1048
  abort: () => controller.abort()
@@ -1076,7 +1216,7 @@ function staticAsset(path, base) {
1076
1216
  }
1077
1217
 
1078
1218
  // src/version.ts
1079
- var VERSION = "0.13.1";
1219
+ var VERSION = "0.14.1";
1080
1220
  var API_VERSION = 1;
1081
1221
 
1082
1222
  // src/routes/timeline.ts
@@ -1129,13 +1269,13 @@ function dueTimes(job, parsed, runs, from, to) {
1129
1269
  if (parsed.kind === "interval") {
1130
1270
  const every = parsed.everyMs;
1131
1271
  if ((to - from) / every > MAX_TICKS) return { times: [], dense: true };
1132
- const starts = runs.map((r) => r.startedAt).sort((a, b) => a - b);
1272
+ const ordered = [...runs].sort((a, b) => a.startedAt - b.startedAt);
1273
+ const dues = ordered.map((r) => intervalDue(every, r.startedAt, finishedAtOf(r)));
1133
1274
  const times2 = /* @__PURE__ */ new Set();
1134
- for (const start of starts) {
1135
- const t2 = start + every;
1275
+ for (const t2 of dues) {
1136
1276
  if (t2 >= from && t2 <= to) times2.add(t2);
1137
1277
  }
1138
- let t = starts.length ? starts[starts.length - 1] + every : job.nextExpectedAt;
1278
+ let t = dues.length ? dues[dues.length - 1] : job.nextExpectedAt;
1139
1279
  if (t !== null) {
1140
1280
  if (t < from) t += Math.ceil((from - t) / every) * every;
1141
1281
  for (; t <= to; t += every) times2.add(t);
@@ -1148,8 +1288,8 @@ function dueTimes(job, parsed, runs, from, to) {
1148
1288
  function missedAt(job, parsed, times, now) {
1149
1289
  if (!parsed || !job.open.includes("missed")) return null;
1150
1290
  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);
1291
+ const lastRun = job.lastRun;
1292
+ if (lastRun) return safely(() => expectation(parsed, lastRun.startedAt, lastRun.startedAt, grace, finishedAtOf(lastRun))?.dueAt ?? null, null);
1153
1293
  const past = times.filter((t) => t + grace < now);
1154
1294
  return past.length ? past[past.length - 1] : null;
1155
1295
  }
@@ -1858,13 +1998,15 @@ function signInReturn(referer, publicOrigin, base) {
1858
1998
  return url.searchParams.has("token") ? `${base}/` : referer;
1859
1999
  }
1860
2000
  function stripBase(pathname, base) {
1861
- let path = pathname.startsWith(base) ? pathname.slice(base.length) : pathname;
2001
+ if (base !== "" && pathname !== base && !pathname.startsWith(`${base}/`)) return null;
2002
+ let path = pathname.slice(base.length);
1862
2003
  if (path === "") path = "/";
1863
2004
  if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
1864
2005
  return path;
1865
2006
  }
1866
2007
  var MAX_REQUEST_BODY = 1 << 20;
1867
2008
  var TOO_LARGE = /* @__PURE__ */ Symbol("too large");
2009
+ var BAD_BODY = /* @__PURE__ */ Symbol("malformed body");
1868
2010
  async function readLimited(request) {
1869
2011
  const length = Number(request.headers.get("content-length"));
1870
2012
  if (Number.isFinite(length) && length > MAX_REQUEST_BODY) return TOO_LARGE;
@@ -1911,14 +2053,14 @@ async function readBody(request) {
1911
2053
  try {
1912
2054
  if (json2) {
1913
2055
  const data = await copy.json();
1914
- return typeof data === "object" && data !== null ? Object.fromEntries(Object.entries(data).map(([k, v]) => [k, String(v)])) : {};
2056
+ return typeof data === "object" && data !== null && !Array.isArray(data) ? Object.fromEntries(Object.entries(data).map(([k, v]) => [k, String(v)])) : BAD_BODY;
1915
2057
  }
1916
2058
  const fields = await copy.formData();
1917
2059
  const out = {};
1918
2060
  for (const [k, v] of fields.entries()) out[k] = String(v);
1919
2061
  return out;
1920
2062
  } catch {
1921
- return {};
2063
+ return BAD_BODY;
1922
2064
  }
1923
2065
  }
1924
2066
  function crossSite(request, publicOrigin) {
@@ -1984,6 +2126,7 @@ function buildRoutes(cw, options = {}) {
1984
2126
  const signInPage = () => generated ? html(messagePage("Sign in", "CRONWATCH_TOKEN is not set, so this development server made a token. The sign-in link is in the server log: open it once, or enter the token from it below, and this browser stays signed in.", base, true), 401) : html(messagePage("Sign in", "Enter your CRONWATCH_TOKEN and this browser stays signed in.", base, true), 401);
1985
2127
  if (token && method === "POST" && path === "/signin") {
1986
2128
  const body = await readBody(request);
2129
+ if (body === BAD_BODY) return html(messagePage("Bad request", "Malformed request body", base), 400);
1987
2130
  const sent = body === TOO_LARGE ? void 0 : body.token;
1988
2131
  if (sent === void 0 || !constantTimeEqual(sent, token)) return signInPage();
1989
2132
  return redirect(signInReturn(request.headers.get("referer"), publicOrigin, base), { "set-cookie": await signInCookie() });
@@ -2041,6 +2184,7 @@ function buildRoutes(cw, options = {}) {
2041
2184
  if (parts[2] === "silence") {
2042
2185
  const body = await readBody(request);
2043
2186
  if (body === TOO_LARGE) return html(messagePage("Not silenced", "The request was too large.", base), 413);
2187
+ if (body === BAD_BODY) return html(messagePage("Not silenced", "Malformed request body", base), 400);
2044
2188
  let duration;
2045
2189
  try {
2046
2190
  duration = silenceDuration(body.for);
@@ -2078,6 +2222,7 @@ function buildRoutes(cw, options = {}) {
2078
2222
  if (rest[2] === "silence") {
2079
2223
  const body = await readBody(request);
2080
2224
  if (body === TOO_LARGE) return api({ ok: false, error: "Request body too large" }, 413);
2225
+ if (body === BAD_BODY) return api({ ok: false, error: "Malformed request body" }, 400);
2081
2226
  let duration;
2082
2227
  try {
2083
2228
  duration = silenceDuration(body.for ?? url.searchParams.get("for"));
@@ -2114,6 +2259,7 @@ function buildRoutes(cw, options = {}) {
2114
2259
  try {
2115
2260
  const url = new URL(request.url);
2116
2261
  const path = stripBase(url.pathname, base);
2262
+ if (path === null) return html(messagePage("Not found", url.pathname, base), 404);
2117
2263
  wantsHtml = !path.startsWith("/api");
2118
2264
  return await serve(request, url, path, wantsHtml);
2119
2265
  } catch (e) {
@@ -2468,7 +2614,7 @@ var Cronwatch = class {
2468
2614
  warnNoSecret() {
2469
2615
  if (this.warnedNoSecret) return;
2470
2616
  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");
2617
+ this.report(new Error("handler() refused a request because no CRON_SECRET is set; pass secret: null to allow unauthenticated requests"), "handler");
2472
2618
  }
2473
2619
  /** onError, for places that must carry on even when onError itself throws. */
2474
2620
  report(error, where) {
@@ -2481,6 +2627,13 @@ var Cronwatch = class {
2481
2627
  if (!this.ready) {
2482
2628
  this.ready = (async () => {
2483
2629
  if (this.store.init) await this.store.init();
2630
+ const missing = ["compareAndSetState", "updateRunIf"].filter((method) => typeof this.store[method] !== "function");
2631
+ if (missing.length > 0) {
2632
+ this.report(
2633
+ 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`),
2634
+ "store"
2635
+ );
2636
+ }
2484
2637
  if (this.usingDefaultStore && isProduction()) {
2485
2638
  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
2639
  }
@@ -2628,10 +2781,11 @@ var Cronwatch = class {
2628
2781
  await this.store.insertRun({ ...run });
2629
2782
  recorded = true;
2630
2783
  } catch (e) {
2631
- this.onError(e, `recording ${name}`);
2784
+ this.report(e, `recording ${name}`);
2632
2785
  }
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();
2786
+ const begun = { ...run };
2787
+ const started = recorded ? this.updateState(name, (before) => ({ state: holdRun(onRunStart(definition, begun, before, this.now()), run.id), result: void 0 })).then(() => {
2788
+ }, (e) => this.report(e, `starting ${name}`)) : Promise.resolve();
2635
2789
  const recorder = createRecorder(run);
2636
2790
  const clearTimer = after(timeoutMs(definition), () => recorder.abort());
2637
2791
  let result;
@@ -2656,7 +2810,7 @@ var Cronwatch = class {
2656
2810
  const ignored = await this.recordFinish(definition, run, recorded, finishedAt);
2657
2811
  if (ignored) this.report(new Error(`run ${run.id} of ${name} ${ignored}; ignored`), `finishing ${name}`);
2658
2812
  } catch (e) {
2659
- this.onError(e, `recording ${name}`);
2813
+ this.report(e, `recording ${name}`);
2660
2814
  }
2661
2815
  return { run, result, error, threw };
2662
2816
  }
@@ -2704,9 +2858,10 @@ var Cronwatch = class {
2704
2858
  if (stored.job !== run.job) return `belongs to job "${stored.job}"`;
2705
2859
  }
2706
2860
  }
2861
+ const tracked = (await this.readState(run.job)).pendingRuns?.includes(run.id) ?? false;
2707
2862
  const claim = await this.claimFinish(run);
2708
2863
  if ("ignored" in claim) return claim.ignored;
2709
- if (!claim.lateAfterTimeout || run.status === "ok") await this.finishRun(toStored(definition), run, finishedAt);
2864
+ if (!claim.lateAfterTimeout || run.status === "ok") await this.finishRun(toStored(definition), run, finishedAt, tracked && !claim.lateAfterTimeout);
2710
2865
  return null;
2711
2866
  }
2712
2867
  /** A conditional write (Store.updateRunIf), or for a store without one, a read then a plain write. */
@@ -2759,7 +2914,7 @@ ${id}`;
2759
2914
  await this.ensureReady();
2760
2915
  stored = await this.store.getRun(id);
2761
2916
  } catch (e) {
2762
- this.onError(e, `recording ${name}`);
2917
+ this.report(e, `recording ${name}`);
2763
2918
  }
2764
2919
  if (stored) return this.existingHandle(definition, stored);
2765
2920
  }
@@ -2783,10 +2938,10 @@ ${id}`;
2783
2938
  } catch (e) {
2784
2939
  const stored = id === void 0 ? null : await this.store.getRun(id).catch(() => null);
2785
2940
  if (stored) return this.existingHandle(definition, stored);
2786
- this.onError(e, `recording ${name}`);
2941
+ this.report(e, `recording ${name}`);
2787
2942
  }
2788
2943
  if (recorded) {
2789
- await this.updateState(name, (before) => ({ state: holdRun(onRunStart(before), run.id), result: void 0 })).catch((e) => this.onError(e, `starting ${name}`));
2944
+ 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
2945
  }
2791
2946
  return this.runHandle(definition, run.id, run, recorded, null);
2792
2947
  }
@@ -2798,7 +2953,7 @@ ${id}`;
2798
2953
  await this.ensureReady();
2799
2954
  stored = await this.store.getRun(runId);
2800
2955
  } catch (e) {
2801
- this.onError(e, `resuming ${definition.name}`);
2956
+ this.report(e, `resuming ${definition.name}`);
2802
2957
  return this.runHandle(definition, runId, null, true, null);
2803
2958
  }
2804
2959
  if (!stored) return this.runHandle(definition, runId, null, true, "was not found");
@@ -2999,9 +3154,9 @@ ${id}`;
2999
3154
  throw e;
3000
3155
  }
3001
3156
  if (!evaluate) return [];
3002
- await this.updateState(run.job, (before) => ({ state: holdRun(onRunStart(before), run.id), result: void 0 }));
3157
+ await this.updateState(run.job, (before) => ({ state: holdRun(onRunStart(definition, run, before, this.now()), run.id), result: void 0 }));
3003
3158
  if (run.status === "running") return [];
3004
- return this.finishRun(definition, run, this.now());
3159
+ return this.finishRun(definition, run, this.now(), true);
3005
3160
  }
3006
3161
  /** recordRun() for a run already stored. */
3007
3162
  async recordOver(definition, stored, run, evaluate) {
@@ -3010,6 +3165,7 @@ ${id}`;
3010
3165
  return [];
3011
3166
  }
3012
3167
  if (stored.status !== "running" && stored.status !== "timeout" || run.status === "running") return [];
3168
+ const tracked = (await this.readState(run.job)).pendingRuns?.includes(run.id) ?? false;
3013
3169
  const claim = await this.claimFinish(run);
3014
3170
  if ("ignored" in claim) {
3015
3171
  this.report(new Error(`run ${run.id} of ${run.job} ${claim.ignored}; ignored`), `recording ${run.job}`);
@@ -3020,24 +3176,25 @@ ${id}`;
3020
3176
  return [];
3021
3177
  }
3022
3178
  if (claim.lateAfterTimeout && run.status !== "ok") return [];
3023
- return this.finishRun(definition, run, this.now());
3179
+ return this.finishRun(definition, run, this.now(), tracked && !claim.lateAfterTimeout);
3024
3180
  }
3025
3181
  /**
3026
3182
  * Evaluate a finished run (ok, failed, or timed out by a check), already
3027
3183
  * written, against the job's state and send what that produces. The
3028
3184
  * alerts are written with that state (see outbox()). Never throws.
3029
3185
  */
3030
- async finishRun(definition, run, now) {
3186
+ async finishRun(definition, run, now, pendingOnly = false) {
3031
3187
  let held;
3032
3188
  try {
3033
3189
  let history = null;
3034
3190
  ({ result: held } = await this.updateState(run.job, async (previous) => {
3191
+ if (pendingOnly && !(previous.pendingRuns ?? []).includes(run.id)) return { state: previous, result: { alerts: [], dropped: 0 } };
3035
3192
  history ??= await this.history(run);
3036
3193
  const before = releaseRun(previous, run.id);
3037
3194
  return this.outbox(applySilence(before, onRunFinish(definition, run, before, history, now), now), definition, now);
3038
3195
  }));
3039
3196
  } catch (e) {
3040
- this.onError(e, `evaluating ${run.job}`);
3197
+ this.report(e, `evaluating ${run.job}`);
3041
3198
  return [];
3042
3199
  }
3043
3200
  this.reportDropped(run.job, held.dropped);
@@ -3076,7 +3233,7 @@ ${id}`;
3076
3233
  }
3077
3234
  /**
3078
3235
  * 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
3236
+ * alerts to the channels that refused them, and prune old runs. Call it from an interval
3080
3237
  * (start()), a cron hitting the mounted routes, or by hand. Concurrent
3081
3238
  * calls share one check.
3082
3239
  */
@@ -3176,7 +3333,7 @@ ${id}`;
3176
3333
  continue;
3177
3334
  }
3178
3335
  if (run.status === "running" || typeof run.finishedAt !== "number" || run.finishedAt > now - SEND_LEASE_MS) continue;
3179
- alerts.push(...await this.finishRun(stored.definition, run, now));
3336
+ alerts.push(...await this.finishRun(stored.definition, run, now, true));
3180
3337
  } catch (e) {
3181
3338
  this.onError(e, `evaluating run ${id} of ${stored.name}`);
3182
3339
  }
@@ -3296,7 +3453,7 @@ ${id}`;
3296
3453
  this.warnedDeferredStart = true;
3297
3454
  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
3455
  }
3299
- const tick = () => this.check().catch((e) => this.onError(e, "check"));
3456
+ const tick = () => this.check().catch((e) => this.report(e, "check"));
3300
3457
  this.timer = setInterval(tick, ms);
3301
3458
  if (typeof this.timer === "object" && "unref" in this.timer) this.timer.unref();
3302
3459
  this.firstTick = setTimeout(() => {
@@ -3345,21 +3502,28 @@ ${id}`;
3345
3502
  if (alerts.length === 0 || this.deferDelivery) return alerts;
3346
3503
  const delivered = [];
3347
3504
  const failed = [];
3505
+ let reached = false;
3348
3506
  for (const alert of alerts) {
3349
3507
  if (this.triage && alert.type !== "recovered") await this.addTriage(alert, TRIAGE_TIMEOUT_MS);
3350
- (await this.deliver(alert) ? delivered : failed).push(alert);
3508
+ const sent = await this.deliver(alert);
3509
+ reached ||= sent.reached;
3510
+ if (sent.left) failed.push(sent.left);
3511
+ else delivered.push(alert);
3351
3512
  }
3352
- await this.recordDelivery(name, delivered, failed, [], now);
3513
+ await this.recordDelivery(name, delivered, failed, [], now, reached);
3353
3514
  return alerts;
3354
3515
  }
3355
3516
  /**
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.
3517
+ * Send the alerts some channel refused last time, once each, oldest
3518
+ * first, to the channels that still owe them (HeldAlert.owed) and no
3519
+ * others, from the copy held in state, its run trimmed (heldRun). They
3520
+ * are claimed first (claimUndelivered): moved into `sending` with a lease,
3521
+ * in one conditional write, so when several processes check at once only
3522
+ * the one whose write lands sends them. An alert that no longer describes
3523
+ * the job (staleAlert) is dropped instead. Retries across a check share
3524
+ * RETRY_BUDGET_MS of wall-clock time; claimed alerts it has no time left
3525
+ * for go back to the queue for the next check. Returns the alerts some
3526
+ * channel took on this attempt.
3363
3527
  */
3364
3528
  async retryUndelivered(name, state, now, budget) {
3365
3529
  if ((state.undelivered ?? []).length === 0 || this.deferDelivery) return [];
@@ -3370,12 +3534,13 @@ ${id}`;
3370
3534
  return { state: claim.state, result: claim.claimed };
3371
3535
  }));
3372
3536
  } catch (e) {
3373
- this.onError(e, `claiming undelivered alerts for ${name}`);
3537
+ this.report(e, `claiming undelivered alerts for ${name}`);
3374
3538
  return [];
3375
3539
  }
3376
3540
  if (claimed.length === 0) return [];
3377
3541
  const delivered = [];
3378
3542
  const failed = [];
3543
+ const sent = [];
3379
3544
  for (const alert of claimed) {
3380
3545
  const left = RETRY_BUDGET_MS - budget.spentMs;
3381
3546
  if (left <= 0) {
@@ -3384,45 +3549,61 @@ ${id}`;
3384
3549
  }
3385
3550
  const started = Date.now();
3386
3551
  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);
3552
+ const outcome = await this.deliver(alert);
3553
+ if (outcome.reached) sent.push(withoutOwed(alert));
3554
+ if (outcome.left) failed.push(outcome.left);
3555
+ else delivered.push(alert);
3388
3556
  budget.spentMs += Math.max(0, Date.now() - started);
3389
3557
  }
3390
- await this.recordDelivery(name, delivered, failed, [], now);
3391
- return delivered;
3558
+ await this.recordDelivery(name, delivered, failed, [], now, sent.length > 0);
3559
+ return sent;
3392
3560
  }
3393
3561
  /**
3394
3562
  * Mark delivered alerts done, drop stale ones, and keep failed ones for the
3395
3563
  * 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.
3564
+ * replaces its stored copy, so a triage made on this attempt and the
3565
+ * channels that still owe it are kept. lastAlertAt moves when `reached`,
3566
+ * some channel accepted an alert on this attempt. When this write fails,
3567
+ * alerts still in `sending` are retried once their lease runs out.
3399
3568
  */
3400
- async recordDelivery(name, delivered, failed, dropped, now) {
3569
+ async recordDelivery(name, delivered, failed, dropped, now, reached) {
3401
3570
  try {
3402
3571
  const { result: trimmed } = await this.updateState(name, (previous) => {
3403
- const { state, dropped: letGo } = recordSent(normalizeState(previous, name), delivered, failed, dropped, now);
3572
+ const { state, dropped: letGo } = recordSent(normalizeState(previous, name), delivered, failed, dropped, now, reached);
3404
3573
  return { state, result: letGo };
3405
3574
  });
3406
3575
  this.reportDropped(name, trimmed);
3407
3576
  } catch (e) {
3408
- this.onError(e, `recording alert delivery for ${name}`);
3577
+ this.report(e, `recording alert delivery for ${name}`);
3409
3578
  }
3410
3579
  }
3411
- /** Send to every channel at once. True when at least one accepted it, or there are none. */
3580
+ /**
3581
+ * Send to every channel that still owes the alert (owedChannels: all of
3582
+ * them for an alert not sent before), at once, each failure reported under
3583
+ * its channel's name. `left` is the alert with `owed` cut to the channels
3584
+ * that refused it, or null when none did (or none is left to owe it);
3585
+ * `reached` is whether any channel accepted it. With no channels at all it
3586
+ * counts as delivered, as it always has.
3587
+ */
3412
3588
  async deliver(alert) {
3413
- if (this.alerts.length === 0) return true;
3414
- const results = await Promise.all(
3415
- this.alerts.map(async (channel) => {
3589
+ if (this.alerts.length === 0) return { left: null, reached: true };
3590
+ const keys = channelKeys(this.alerts.map((channel) => channel.name));
3591
+ const owed = owedChannels(alert, keys);
3592
+ if (owed.length === 0) return { left: null, reached: false };
3593
+ const plain = withoutOwed(alert);
3594
+ const failed = [];
3595
+ await Promise.all(
3596
+ this.alerts.map(async (channel, i) => {
3597
+ if (!owed.includes(keys[i])) return;
3416
3598
  try {
3417
- await withTimeout(channel.send(alert, { onError: (e) => this.report(e, `alert channel ${channel.name}`) }), CHANNEL_TIMEOUT_MS);
3418
- return true;
3599
+ await withTimeout(channel.send(plain, { onError: (e) => this.report(e, `alert channel ${channel.name}`) }), CHANNEL_TIMEOUT_MS);
3419
3600
  } catch (e) {
3420
- this.onError(e, `alert channel ${channel.name}`);
3421
- return false;
3601
+ failed.push(keys[i]);
3602
+ this.report(e, `alert channel ${channel.name}`);
3422
3603
  }
3423
3604
  })
3424
3605
  );
3425
- return results.includes(true);
3606
+ return { left: afterSend(alert, owed, failed), reached: failed.length < owed.length };
3426
3607
  }
3427
3608
  /** Sets alert.triage to the diagnosis, or to null when there is none, so it is tried once per alert. */
3428
3609
  async addTriage(alert, timeout) {
@@ -3434,7 +3615,7 @@ ${id}`;
3434
3615
  } catch (e) {
3435
3616
  controller.abort();
3436
3617
  alert.triage = null;
3437
- this.onError(e, `triage for ${alert.job}`);
3618
+ this.report(e, `triage for ${alert.job}`);
3438
3619
  }
3439
3620
  }
3440
3621
  };