@cronwatch/sdk 0.13.1 → 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.cjs CHANGED
@@ -174,6 +174,13 @@ function countFrom(from) {
174
174
  return from >= FIRST_DATE_MS ? from : FIRST_DATE_MS - 1;
175
175
  }
176
176
  function fireAfter(parsed, from) {
177
+ const found = croneFireAfter(parsed, from);
178
+ const start = countFrom(from);
179
+ if (start === null) return found;
180
+ const moved = gapFireAfter(parsed, start);
181
+ return moved !== null && (found === null || moved < found) ? moved : found;
182
+ }
183
+ function croneFireAfter(parsed, from) {
177
184
  const cron = crons.get(parsed);
178
185
  if (!cron) throw new Error(`schedule "${parsed.source}" was not made by parseSchedule`);
179
186
  if (never.has(parsed)) return null;
@@ -189,6 +196,43 @@ function fireAfter(parsed, from) {
189
196
  }
190
197
  return null;
191
198
  }
199
+ function springGap(timezone, at) {
200
+ const jump = springJump(timezone, at);
201
+ return jump && at < jump.jumpAt + jump.gapMs ? jump : null;
202
+ }
203
+ var JUMP_LOOKBACK_MS = 3 * 36e5;
204
+ function springJump(timezone, at) {
205
+ if (at - JUMP_LOOKBACK_MS < FIRST_DATE_MS || at >= LAST_DATE_MS) return null;
206
+ const after2 = utcOffset(at, timezone);
207
+ const before = utcOffset(at - JUMP_LOOKBACK_MS, timezone);
208
+ if (after2 <= before) return null;
209
+ let lo = at - JUMP_LOOKBACK_MS;
210
+ let hi = at;
211
+ while (hi - lo > 6e4) {
212
+ const mid = lo + Math.floor((hi - lo) / 2);
213
+ if (utcOffset(mid, timezone) === after2) hi = mid;
214
+ else lo = mid;
215
+ }
216
+ return { jumpAt: Math.floor(hi / 6e4) * 6e4, gapMs: after2 - before, beforeMs: before };
217
+ }
218
+ var wallCrons = /* @__PURE__ */ new WeakMap();
219
+ function wallCron(parsed) {
220
+ let wall = wallCrons.get(parsed);
221
+ if (!wall) {
222
+ wall = new croner.Cron(parsed.source, { timezone: "UTC" });
223
+ wallCrons.set(parsed, wall);
224
+ }
225
+ return wall;
226
+ }
227
+ function gapFireAfter(parsed, start) {
228
+ if (never.has(parsed)) return null;
229
+ const gap = springGap(parsed.timezone, start);
230
+ if (!gap) return null;
231
+ const wall = wallCron(parsed);
232
+ const [slot] = runsAfter(wall, 1, start + gap.beforeMs);
233
+ if (slot === void 0 || slot >= gap.jumpAt + gap.beforeMs + gap.gapMs) return null;
234
+ return slot - gap.beforeMs;
235
+ }
192
236
  function firesBetween(parsed, from, to, limit) {
193
237
  const cron = crons.get(parsed);
194
238
  if (!cron) throw new Error(`schedule "${parsed.source}" was not made by parseSchedule`);
@@ -199,15 +243,34 @@ function firesBetween(parsed, from, to, limit) {
199
243
  let probe = start;
200
244
  let last = start;
201
245
  for (let guard = 0; guard < 1e3; guard++) {
246
+ if (springGap(parsed.timezone, last)) {
247
+ const t = fireAfter(parsed, last);
248
+ if (t === null || t > to) return out;
249
+ out.push(t);
250
+ last = probe = t;
251
+ if (out.length > limit) return null;
252
+ continue;
253
+ }
202
254
  const batch = runsAfter(cron, Math.min(limit + 1 - out.length, 24), probe);
203
255
  if (batch.length === 0) return out;
256
+ let stepped = false;
204
257
  for (const t of batch) {
205
258
  if (t <= last) continue;
259
+ if (springGap(parsed.timezone, t)) {
260
+ const next = fireAfter(parsed, last);
261
+ if (next === null || next > to) return out;
262
+ out.push(next);
263
+ last = probe = next;
264
+ if (out.length > limit) return null;
265
+ stepped = true;
266
+ break;
267
+ }
206
268
  if (t > to) return out;
207
269
  out.push(t);
208
270
  last = t;
209
271
  if (out.length > limit) return null;
210
272
  }
273
+ if (stepped) continue;
211
274
  const end = batch[batch.length - 1];
212
275
  probe = end > probe ? end : probe + 36e5;
213
276
  }
@@ -220,10 +283,14 @@ function nextFire(parsed, from, lastRunAt) {
220
283
  }
221
284
  return fireAfter(parsed, from);
222
285
  }
223
- function expectation(parsed, lastRunAt, registeredAt, graceMs2) {
286
+ function intervalDue(everyMs, startedAt, finishedAt) {
287
+ const due = startedAt + everyMs;
288
+ return finishedAt !== null && finishedAt > due ? finishedAt + everyMs : due;
289
+ }
290
+ function expectation(parsed, lastRunAt, registeredAt, graceMs2, lastFinishedAt = null) {
224
291
  let dueAt;
225
292
  if (parsed.kind === "interval") {
226
- dueAt = (lastRunAt ?? registeredAt) + parsed.everyMs;
293
+ dueAt = lastRunAt === null ? registeredAt + parsed.everyMs : intervalDue(parsed.everyMs, lastRunAt, lastFinishedAt);
227
294
  } else if (lastRunAt === null) {
228
295
  dueAt = fireAfter(parsed, registeredAt - 1);
229
296
  } else {
@@ -232,33 +299,25 @@ function expectation(parsed, lastRunAt, registeredAt, graceMs2) {
232
299
  return dueAt === null ? null : { dueAt, deadline: dueAt + graceMs2 };
233
300
  }
234
301
  function dueAfterRun(parsed, startedAt) {
235
- const next = fireAfter(parsed, startedAt);
236
- if (next === null) return null;
237
- const following = fireAfter(parsed, next);
238
- const covers = runCovers(startedAt, next, following) || inSpringForwardGap(parsed, startedAt, next);
239
- return covers ? following : next;
302
+ let next = fireAfter(parsed, startedAt);
303
+ for (let guard = 0; next !== null && guard < 1e5; guard++) {
304
+ const following = fireAfter(parsed, next);
305
+ const covers = runCovers(startedAt, next, following) || inSpringForwardGap(parsed, startedAt, next);
306
+ if (!covers) return next;
307
+ next = following;
308
+ }
309
+ return next;
240
310
  }
241
311
  function runCovers(startedAt, dueAt, followingAt = null) {
242
312
  const slack = followingAt === null ? EARLY_SLACK_MS : Math.min(EARLY_SLACK_MS, Math.floor((followingAt - dueAt) / 2));
243
313
  return startedAt >= dueAt - slack;
244
314
  }
245
315
  function inSpringForwardGap(parsed, startedAt, fireAt) {
246
- const LOOKBACK = 3 * 36e5;
247
- if (fireAt - LOOKBACK < FIRST_DATE_MS) return false;
248
- const after2 = utcOffset(fireAt, parsed.timezone);
249
- const before = utcOffset(fireAt - LOOKBACK, parsed.timezone);
250
- const gap = after2 - before;
251
- if (gap <= 0) return false;
252
- let lo = fireAt - LOOKBACK;
253
- let hi = fireAt;
254
- while (hi - lo > 6e4) {
255
- const mid = lo + Math.floor((hi - lo) / 2);
256
- if (utcOffset(mid, parsed.timezone) === after2) hi = mid;
257
- else lo = mid;
258
- }
259
- const jumpAt = Math.floor(hi / 6e4) * 6e4;
260
- if (fireAt - jumpAt >= gap || startedAt < jumpAt - EARLY_SLACK_MS || startedAt >= fireAt) return false;
261
- return fireAfter(parsed, jumpAt - 1) === fireAt;
316
+ const gap = springGap(parsed.timezone, fireAt);
317
+ if (!gap || startedAt < gap.jumpAt - EARLY_SLACK_MS || startedAt >= fireAt) return false;
318
+ const wall = wallCron(parsed);
319
+ const fires = (at) => runsAfter(wall, 1, at - 1)[0] === at;
320
+ return fires(fireAt + gap.beforeMs) && !fires(fireAt + gap.beforeMs + gap.gapMs);
262
321
  }
263
322
  var formatters = /* @__PURE__ */ new Map();
264
323
  function utcOffset(at, timezone) {
@@ -355,15 +414,75 @@ var SEND_LEASE_MS = 5 * 6e4;
355
414
  function alertKey(alert) {
356
415
  return `${alert.type}|${alert.at}|${alert.run?.id ?? ""}`;
357
416
  }
417
+ var HELD_TEXT_CAP = 2048;
418
+ var HELD_TEXT_SLACK = 64;
419
+ function heldRun(run) {
420
+ const error = typeof run.error === "string" && run.error.length > HELD_TEXT_CAP + HELD_TEXT_SLACK ? keepHead(run.error) : run.error;
421
+ const output = typeof run.output === "string" && run.output.length > HELD_TEXT_CAP + HELD_TEXT_SLACK ? keepTail(run.output) : run.output;
422
+ return error === run.error && output === run.output ? run : { ...run, error, output };
423
+ }
424
+ var HELD_MESSAGE_CAP = 4096;
425
+ function keepHead(text, cap = HELD_TEXT_CAP) {
426
+ let end = cap;
427
+ if (isHighSurrogate(text.charCodeAt(end - 1))) end -= 1;
428
+ return `${text.slice(0, end)}
429
+ [${text.length - end} more characters trimmed]`;
430
+ }
431
+ function keepTail(text) {
432
+ let start = text.length - HELD_TEXT_CAP;
433
+ if (isLowSurrogate(text.charCodeAt(start)) && isHighSurrogate(text.charCodeAt(start - 1))) start += 1;
434
+ return `[${start} earlier characters trimmed]
435
+ ${text.slice(start)}`;
436
+ }
437
+ function isHighSurrogate(code) {
438
+ return code >= 55296 && code <= 56319;
439
+ }
440
+ function isLowSurrogate(code) {
441
+ return code >= 56320 && code <= 57343;
442
+ }
443
+ function heldAlert(alert) {
444
+ const run = alert.run;
445
+ const trimmedRun = isObject(run) ? heldRun(run) : run;
446
+ const message = alert.message;
447
+ const trimmedMessage = typeof message === "string" && message.length > HELD_MESSAGE_CAP + HELD_TEXT_SLACK ? keepHead(message, HELD_MESSAGE_CAP) : message;
448
+ if (trimmedRun === run && trimmedMessage === message) return alert;
449
+ return { ...alert, run: trimmedRun, message: trimmedMessage };
450
+ }
451
+ function channelKeys(names) {
452
+ const seen = /* @__PURE__ */ new Map();
453
+ const taken = /* @__PURE__ */ new Set();
454
+ return names.map((name) => {
455
+ let n = (seen.get(name) ?? 0) + 1;
456
+ seen.set(name, n);
457
+ let key = n === 1 ? name : `${name}#${n}`;
458
+ while (taken.has(key)) key = `${name}#${++n}`;
459
+ taken.add(key);
460
+ return key;
461
+ });
462
+ }
463
+ function owedChannels(alert, configured) {
464
+ const owed = alert.owed;
465
+ if (!Array.isArray(owed)) return [...configured];
466
+ return configured.filter((key) => owed.includes(key));
467
+ }
468
+ function afterSend(alert, tried, failed) {
469
+ const owed = tried.filter((key) => failed.includes(key));
470
+ return owed.length === 0 ? null : { ...alert, owed };
471
+ }
472
+ function withoutOwed(alert) {
473
+ if (!("owed" in alert)) return alert;
474
+ const { owed: _owed, ...rest } = alert;
475
+ return rest;
476
+ }
358
477
  function newest(list, max) {
359
478
  return { kept: list.slice(-max), dropped: Math.max(0, list.length - max) };
360
479
  }
361
480
  function queueUndelivered(state, alerts) {
362
481
  const next = cloneState(state);
363
- const byKey = new Map(alerts.map((alert) => [alertKey(alert), alert]));
482
+ const byKey = new Map(alerts.map((alert) => [alertKey(alert), heldAlert(alert)]));
364
483
  const queue = next.undelivered.map((alert) => byKey.get(alertKey(alert)) ?? alert);
365
484
  const known = new Set(queue.map(alertKey));
366
- queue.push(...alerts.filter((alert) => !known.has(alertKey(alert))));
485
+ queue.push(...[...byKey.values()].filter((alert) => !known.has(alertKey(alert))));
367
486
  const { kept, dropped } = newest(queue, MAX_UNDELIVERED);
368
487
  next.undelivered = kept;
369
488
  return { state: next, dropped };
@@ -372,7 +491,7 @@ function holdAlerts(state, alerts, until, deferred) {
372
491
  if (alerts.length === 0) return { state, dropped: 0 };
373
492
  if (deferred) return queueUndelivered(state, alerts);
374
493
  const next = cloneState(state);
375
- const { kept, dropped } = newest([...next.sending ?? [], ...alerts.map((alert) => ({ until, alert }))], MAX_UNDELIVERED);
494
+ const { kept, dropped } = newest([...next.sending ?? [], ...alerts.map((alert) => ({ until, alert: heldAlert(alert) }))], MAX_UNDELIVERED);
376
495
  next.sending = kept;
377
496
  return { state: next, dropped };
378
497
  }
@@ -386,7 +505,7 @@ function releaseSending(state, now) {
386
505
  lapsed.filter((entry) => typeof entry?.alert === "object" && entry.alert !== null).map((entry) => entry.alert)
387
506
  );
388
507
  }
389
- function recordSent(state, delivered, failed, stale, now) {
508
+ function recordSent(state, delivered, failed, stale, now, reached = delivered.length > 0) {
390
509
  const next = cloneState(state);
391
510
  const done = new Set([...delivered, ...stale].map(alertKey));
392
511
  next.undelivered = next.undelivered.filter((alert) => !done.has(alertKey(alert)));
@@ -394,7 +513,7 @@ function recordSent(state, delivered, failed, stale, now) {
394
513
  const held = (next.sending ?? []).filter((entry) => !(typeof entry?.alert === "object" && entry.alert !== null && sent.has(alertKey(entry.alert))));
395
514
  if (held.length > 0) next.sending = held;
396
515
  else delete next.sending;
397
- if (delivered.length > 0) next.lastAlertAt = now;
516
+ if (reached) next.lastAlertAt = now;
398
517
  return queueUndelivered(next, failed);
399
518
  }
400
519
  function claimUndelivered(state, now, until) {
@@ -502,9 +621,21 @@ function formatNumber(n) {
502
621
  if (Number.isInteger(n)) return n.toLocaleString("en-US");
503
622
  return n.toLocaleString("en-US", { maximumFractionDigits: 4 });
504
623
  }
505
- function onRunStart(state) {
624
+ function finishedAtOf(run) {
625
+ return run.status !== "running" && typeof run.finishedAt === "number" ? run.finishedAt : null;
626
+ }
627
+ function catchesUp(def, run, now) {
628
+ if (!def.schedule) return true;
629
+ try {
630
+ const exp = expectation(parseSchedule(def.schedule, def.timezone), run.startedAt, run.startedAt, graceMs(def), finishedAtOf(run));
631
+ return exp === null || now <= exp.deadline;
632
+ } catch {
633
+ return true;
634
+ }
635
+ }
636
+ function onRunStart(def, run, state, now) {
506
637
  const next = cloneState(state);
507
- closeCondition(next, "missed");
638
+ if (catchesUp(def, run, now)) closeCondition(next, "missed");
508
639
  closeCondition(next, "stuck");
509
640
  return next;
510
641
  }
@@ -513,7 +644,7 @@ function onRunFinish(def, run, state, history, now) {
513
644
  const alerts = [];
514
645
  if (run.status === "ok") {
515
646
  next.consecutiveFailures = 0;
516
- closeCondition(next, "missed");
647
+ if (catchesUp(def, run, now)) closeCondition(next, "missed");
517
648
  closeCondition(next, "stuck");
518
649
  closeCondition(next, "failed");
519
650
  const slow = slowThreshold(def, history);
@@ -550,7 +681,7 @@ function onRunFinish(def, run, state, history, now) {
550
681
  return { state: next, alerts };
551
682
  }
552
683
  next.consecutiveFailures = Math.min(next.consecutiveFailures + 1, MAX_DURATION_MS);
553
- closeCondition(next, "missed");
684
+ if (catchesUp(def, run, now)) closeCondition(next, "missed");
554
685
  const threshold = Math.max(1, def.failuresBeforeAlert ?? 1);
555
686
  const condition = run.status === "timeout" ? "stuck" : "failed";
556
687
  if (next.consecutiveFailures >= threshold) {
@@ -575,8 +706,8 @@ function onCheck(def, stored, lastRun, state, now) {
575
706
  const parsed = parseSchedule(def.schedule, def.timezone);
576
707
  const grace = graceMs(def);
577
708
  const lastRunAt = lastRun?.startedAt ?? null;
578
- const exp = expectation(parsed, lastRunAt, stored.createdAt, grace);
579
- const nextExpectedAt = parsed.kind === "interval" ? nextFire(parsed, stored.createdAt, lastRunAt) : nextFire(parsed, now, null);
709
+ const exp = expectation(parsed, lastRunAt, stored.createdAt, grace, lastRun ? finishedAtOf(lastRun) : null);
710
+ const nextExpectedAt = parsed.kind === "interval" ? exp.dueAt : nextFire(parsed, now, null);
580
711
  if (!exp) return { state: next, alerts, nextExpectedAt, dueAt: null };
581
712
  if (parsed.kind === "interval" && lastRun?.status === "running") {
582
713
  return { state: next, alerts, nextExpectedAt, dueAt: exp.dueAt };
@@ -1078,7 +1209,7 @@ function staticAsset(path, base) {
1078
1209
  }
1079
1210
 
1080
1211
  // src/version.ts
1081
- var VERSION = "0.13.1";
1212
+ var VERSION = "0.14.0";
1082
1213
  var API_VERSION = 1;
1083
1214
 
1084
1215
  // src/routes/timeline.ts
@@ -1131,13 +1262,13 @@ function dueTimes(job, parsed, runs, from, to) {
1131
1262
  if (parsed.kind === "interval") {
1132
1263
  const every = parsed.everyMs;
1133
1264
  if ((to - from) / every > MAX_TICKS) return { times: [], dense: true };
1134
- const starts = runs.map((r) => r.startedAt).sort((a, b) => a - b);
1265
+ const ordered = [...runs].sort((a, b) => a.startedAt - b.startedAt);
1266
+ const dues = ordered.map((r) => intervalDue(every, r.startedAt, finishedAtOf(r)));
1135
1267
  const times2 = /* @__PURE__ */ new Set();
1136
- for (const start of starts) {
1137
- const t2 = start + every;
1268
+ for (const t2 of dues) {
1138
1269
  if (t2 >= from && t2 <= to) times2.add(t2);
1139
1270
  }
1140
- let t = starts.length ? starts[starts.length - 1] + every : job.nextExpectedAt;
1271
+ let t = dues.length ? dues[dues.length - 1] : job.nextExpectedAt;
1141
1272
  if (t !== null) {
1142
1273
  if (t < from) t += Math.ceil((from - t) / every) * every;
1143
1274
  for (; t <= to; t += every) times2.add(t);
@@ -1150,8 +1281,8 @@ function dueTimes(job, parsed, runs, from, to) {
1150
1281
  function missedAt(job, parsed, times, now) {
1151
1282
  if (!parsed || !job.open.includes("missed")) return null;
1152
1283
  const grace = safely(() => graceMs(job.definition), 0);
1153
- const last = job.lastRun?.startedAt ?? null;
1154
- if (last !== null) return safely(() => expectation(parsed, last, last, grace)?.dueAt ?? null, null);
1284
+ const lastRun = job.lastRun;
1285
+ if (lastRun) return safely(() => expectation(parsed, lastRun.startedAt, lastRun.startedAt, grace, finishedAtOf(lastRun))?.dueAt ?? null, null);
1155
1286
  const past = times.filter((t) => t + grace < now);
1156
1287
  return past.length ? past[past.length - 1] : null;
1157
1288
  }
@@ -1860,7 +1991,8 @@ function signInReturn(referer, publicOrigin, base) {
1860
1991
  return url.searchParams.has("token") ? `${base}/` : referer;
1861
1992
  }
1862
1993
  function stripBase(pathname, base) {
1863
- let path = pathname.startsWith(base) ? pathname.slice(base.length) : pathname;
1994
+ if (base !== "" && pathname !== base && !pathname.startsWith(`${base}/`)) return null;
1995
+ let path = pathname.slice(base.length);
1864
1996
  if (path === "") path = "/";
1865
1997
  if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
1866
1998
  return path;
@@ -2116,6 +2248,7 @@ function buildRoutes(cw, options = {}) {
2116
2248
  try {
2117
2249
  const url = new URL(request.url);
2118
2250
  const path = stripBase(url.pathname, base);
2251
+ if (path === null) return html(messagePage("Not found", url.pathname, base), 404);
2119
2252
  wantsHtml = !path.startsWith("/api");
2120
2253
  return await serve(request, url, path, wantsHtml);
2121
2254
  } catch (e) {
@@ -2470,7 +2603,7 @@ var Cronwatch = class {
2470
2603
  warnNoSecret() {
2471
2604
  if (this.warnedNoSecret) return;
2472
2605
  this.warnedNoSecret = true;
2473
- this.onError(new Error("handler() refused a request because no CRON_SECRET is set; pass secret: null to allow unauthenticated requests"), "handler");
2606
+ this.report(new Error("handler() refused a request because no CRON_SECRET is set; pass secret: null to allow unauthenticated requests"), "handler");
2474
2607
  }
2475
2608
  /** onError, for places that must carry on even when onError itself throws. */
2476
2609
  report(error, where) {
@@ -2483,6 +2616,13 @@ var Cronwatch = class {
2483
2616
  if (!this.ready) {
2484
2617
  this.ready = (async () => {
2485
2618
  if (this.store.init) await this.store.init();
2619
+ const missing = ["compareAndSetState", "updateRunIf"].filter((method) => typeof this.store[method] !== "function");
2620
+ if (missing.length > 0) {
2621
+ this.report(
2622
+ 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`),
2623
+ "store"
2624
+ );
2625
+ }
2486
2626
  if (this.usingDefaultStore && isProduction()) {
2487
2627
  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.");
2488
2628
  }
@@ -2630,10 +2770,11 @@ var Cronwatch = class {
2630
2770
  await this.store.insertRun({ ...run });
2631
2771
  recorded = true;
2632
2772
  } catch (e) {
2633
- this.onError(e, `recording ${name}`);
2773
+ this.report(e, `recording ${name}`);
2634
2774
  }
2635
- const started = recorded ? this.updateState(name, (before) => ({ state: holdRun(onRunStart(before), run.id), result: void 0 })).then(() => {
2636
- }, (e) => this.onError(e, `starting ${name}`)) : Promise.resolve();
2775
+ const begun = { ...run };
2776
+ const started = recorded ? this.updateState(name, (before) => ({ state: holdRun(onRunStart(definition, begun, before, this.now()), run.id), result: void 0 })).then(() => {
2777
+ }, (e) => this.report(e, `starting ${name}`)) : Promise.resolve();
2637
2778
  const recorder = createRecorder(run);
2638
2779
  const clearTimer = after(timeoutMs(definition), () => recorder.abort());
2639
2780
  let result;
@@ -2658,7 +2799,7 @@ var Cronwatch = class {
2658
2799
  const ignored = await this.recordFinish(definition, run, recorded, finishedAt);
2659
2800
  if (ignored) this.report(new Error(`run ${run.id} of ${name} ${ignored}; ignored`), `finishing ${name}`);
2660
2801
  } catch (e) {
2661
- this.onError(e, `recording ${name}`);
2802
+ this.report(e, `recording ${name}`);
2662
2803
  }
2663
2804
  return { run, result, error, threw };
2664
2805
  }
@@ -2761,7 +2902,7 @@ ${id}`;
2761
2902
  await this.ensureReady();
2762
2903
  stored = await this.store.getRun(id);
2763
2904
  } catch (e) {
2764
- this.onError(e, `recording ${name}`);
2905
+ this.report(e, `recording ${name}`);
2765
2906
  }
2766
2907
  if (stored) return this.existingHandle(definition, stored);
2767
2908
  }
@@ -2785,10 +2926,10 @@ ${id}`;
2785
2926
  } catch (e) {
2786
2927
  const stored = id === void 0 ? null : await this.store.getRun(id).catch(() => null);
2787
2928
  if (stored) return this.existingHandle(definition, stored);
2788
- this.onError(e, `recording ${name}`);
2929
+ this.report(e, `recording ${name}`);
2789
2930
  }
2790
2931
  if (recorded) {
2791
- await this.updateState(name, (before) => ({ state: holdRun(onRunStart(before), run.id), result: void 0 })).catch((e) => this.onError(e, `starting ${name}`));
2932
+ 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}`));
2792
2933
  }
2793
2934
  return this.runHandle(definition, run.id, run, recorded, null);
2794
2935
  }
@@ -2800,7 +2941,7 @@ ${id}`;
2800
2941
  await this.ensureReady();
2801
2942
  stored = await this.store.getRun(runId);
2802
2943
  } catch (e) {
2803
- this.onError(e, `resuming ${definition.name}`);
2944
+ this.report(e, `resuming ${definition.name}`);
2804
2945
  return this.runHandle(definition, runId, null, true, null);
2805
2946
  }
2806
2947
  if (!stored) return this.runHandle(definition, runId, null, true, "was not found");
@@ -3001,7 +3142,7 @@ ${id}`;
3001
3142
  throw e;
3002
3143
  }
3003
3144
  if (!evaluate) return [];
3004
- await this.updateState(run.job, (before) => ({ state: holdRun(onRunStart(before), run.id), result: void 0 }));
3145
+ await this.updateState(run.job, (before) => ({ state: holdRun(onRunStart(definition, run, before, this.now()), run.id), result: void 0 }));
3005
3146
  if (run.status === "running") return [];
3006
3147
  return this.finishRun(definition, run, this.now());
3007
3148
  }
@@ -3039,7 +3180,7 @@ ${id}`;
3039
3180
  return this.outbox(applySilence(before, onRunFinish(definition, run, before, history, now), now), definition, now);
3040
3181
  }));
3041
3182
  } catch (e) {
3042
- this.onError(e, `evaluating ${run.job}`);
3183
+ this.report(e, `evaluating ${run.job}`);
3043
3184
  return [];
3044
3185
  }
3045
3186
  this.reportDropped(run.job, held.dropped);
@@ -3078,7 +3219,7 @@ ${id}`;
3078
3219
  }
3079
3220
  /**
3080
3221
  * Look for missed and stuck runs across every job, send alerts, retry
3081
- * alerts no channel accepted, and prune old runs. Call it from an interval
3222
+ * alerts to the channels that refused them, and prune old runs. Call it from an interval
3082
3223
  * (start()), a cron hitting the mounted routes, or by hand. Concurrent
3083
3224
  * calls share one check.
3084
3225
  */
@@ -3298,7 +3439,7 @@ ${id}`;
3298
3439
  this.warnedDeferredStart = true;
3299
3440
  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.');
3300
3441
  }
3301
- const tick = () => this.check().catch((e) => this.onError(e, "check"));
3442
+ const tick = () => this.check().catch((e) => this.report(e, "check"));
3302
3443
  this.timer = setInterval(tick, ms);
3303
3444
  if (typeof this.timer === "object" && "unref" in this.timer) this.timer.unref();
3304
3445
  this.firstTick = setTimeout(() => {
@@ -3347,21 +3488,28 @@ ${id}`;
3347
3488
  if (alerts.length === 0 || this.deferDelivery) return alerts;
3348
3489
  const delivered = [];
3349
3490
  const failed = [];
3491
+ let reached = false;
3350
3492
  for (const alert of alerts) {
3351
3493
  if (this.triage && alert.type !== "recovered") await this.addTriage(alert, TRIAGE_TIMEOUT_MS);
3352
- (await this.deliver(alert) ? delivered : failed).push(alert);
3494
+ const sent = await this.deliver(alert);
3495
+ reached ||= sent.reached;
3496
+ if (sent.left) failed.push(sent.left);
3497
+ else delivered.push(alert);
3353
3498
  }
3354
- await this.recordDelivery(name, delivered, failed, [], now);
3499
+ await this.recordDelivery(name, delivered, failed, [], now, reached);
3355
3500
  return alerts;
3356
3501
  }
3357
3502
  /**
3358
- * Send the alerts that no channel accepted last time, once each, oldest
3359
- * first. They are claimed first (claimUndelivered): moved into `sending`
3360
- * with a lease, in one conditional write, so when several processes check
3361
- * at once only the one whose write lands sends them. An alert that no
3362
- * longer describes the job (staleAlert) is dropped instead. Retries across
3363
- * a check share RETRY_BUDGET_MS of wall-clock time; claimed alerts it has
3364
- * no time left for go back to the queue for the next check.
3503
+ * Send the alerts some channel refused last time, once each, oldest
3504
+ * first, to the channels that still owe them (HeldAlert.owed) and no
3505
+ * others, from the copy held in state, its run trimmed (heldRun). They
3506
+ * are claimed first (claimUndelivered): moved into `sending` with a lease,
3507
+ * in one conditional write, so when several processes check at once only
3508
+ * the one whose write lands sends them. An alert that no longer describes
3509
+ * the job (staleAlert) is dropped instead. Retries across a check share
3510
+ * RETRY_BUDGET_MS of wall-clock time; claimed alerts it has no time left
3511
+ * for go back to the queue for the next check. Returns the alerts some
3512
+ * channel took on this attempt.
3365
3513
  */
3366
3514
  async retryUndelivered(name, state, now, budget) {
3367
3515
  if ((state.undelivered ?? []).length === 0 || this.deferDelivery) return [];
@@ -3372,12 +3520,13 @@ ${id}`;
3372
3520
  return { state: claim.state, result: claim.claimed };
3373
3521
  }));
3374
3522
  } catch (e) {
3375
- this.onError(e, `claiming undelivered alerts for ${name}`);
3523
+ this.report(e, `claiming undelivered alerts for ${name}`);
3376
3524
  return [];
3377
3525
  }
3378
3526
  if (claimed.length === 0) return [];
3379
3527
  const delivered = [];
3380
3528
  const failed = [];
3529
+ const sent = [];
3381
3530
  for (const alert of claimed) {
3382
3531
  const left = RETRY_BUDGET_MS - budget.spentMs;
3383
3532
  if (left <= 0) {
@@ -3386,45 +3535,61 @@ ${id}`;
3386
3535
  }
3387
3536
  const started = Date.now();
3388
3537
  if (this.triage && alert.type !== "recovered" && alert.triage === void 0) await this.addTriage(alert, Math.min(TRIAGE_TIMEOUT_MS, left));
3389
- (await this.deliver(alert) ? delivered : failed).push(alert);
3538
+ const outcome = await this.deliver(alert);
3539
+ if (outcome.reached) sent.push(withoutOwed(alert));
3540
+ if (outcome.left) failed.push(outcome.left);
3541
+ else delivered.push(alert);
3390
3542
  budget.spentMs += Math.max(0, Date.now() - started);
3391
3543
  }
3392
- await this.recordDelivery(name, delivered, failed, [], now);
3393
- return delivered;
3544
+ await this.recordDelivery(name, delivered, failed, [], now, sent.length > 0);
3545
+ return sent;
3394
3546
  }
3395
3547
  /**
3396
3548
  * Mark delivered alerts done, drop stale ones, and keep failed ones for the
3397
3549
  * next check, taking them all out of `sending` (recordSent). A failed alert
3398
- * replaces its stored copy, so a triage made on this attempt is kept.
3399
- * lastAlertAt moves only on a delivery. When this write fails, alerts
3400
- * still in `sending` are retried once their lease runs out.
3550
+ * replaces its stored copy, so a triage made on this attempt and the
3551
+ * channels that still owe it are kept. lastAlertAt moves when `reached`,
3552
+ * some channel accepted an alert on this attempt. When this write fails,
3553
+ * alerts still in `sending` are retried once their lease runs out.
3401
3554
  */
3402
- async recordDelivery(name, delivered, failed, dropped, now) {
3555
+ async recordDelivery(name, delivered, failed, dropped, now, reached) {
3403
3556
  try {
3404
3557
  const { result: trimmed } = await this.updateState(name, (previous) => {
3405
- const { state, dropped: letGo } = recordSent(normalizeState(previous, name), delivered, failed, dropped, now);
3558
+ const { state, dropped: letGo } = recordSent(normalizeState(previous, name), delivered, failed, dropped, now, reached);
3406
3559
  return { state, result: letGo };
3407
3560
  });
3408
3561
  this.reportDropped(name, trimmed);
3409
3562
  } catch (e) {
3410
- this.onError(e, `recording alert delivery for ${name}`);
3563
+ this.report(e, `recording alert delivery for ${name}`);
3411
3564
  }
3412
3565
  }
3413
- /** Send to every channel at once. True when at least one accepted it, or there are none. */
3566
+ /**
3567
+ * Send to every channel that still owes the alert (owedChannels: all of
3568
+ * them for an alert not sent before), at once, each failure reported under
3569
+ * its channel's name. `left` is the alert with `owed` cut to the channels
3570
+ * that refused it, or null when none did (or none is left to owe it);
3571
+ * `reached` is whether any channel accepted it. With no channels at all it
3572
+ * counts as delivered, as it always has.
3573
+ */
3414
3574
  async deliver(alert) {
3415
- if (this.alerts.length === 0) return true;
3416
- const results = await Promise.all(
3417
- this.alerts.map(async (channel) => {
3575
+ if (this.alerts.length === 0) return { left: null, reached: true };
3576
+ const keys = channelKeys(this.alerts.map((channel) => channel.name));
3577
+ const owed = owedChannels(alert, keys);
3578
+ if (owed.length === 0) return { left: null, reached: false };
3579
+ const plain = withoutOwed(alert);
3580
+ const failed = [];
3581
+ await Promise.all(
3582
+ this.alerts.map(async (channel, i) => {
3583
+ if (!owed.includes(keys[i])) return;
3418
3584
  try {
3419
- await withTimeout(channel.send(alert, { onError: (e) => this.report(e, `alert channel ${channel.name}`) }), CHANNEL_TIMEOUT_MS);
3420
- return true;
3585
+ await withTimeout(channel.send(plain, { onError: (e) => this.report(e, `alert channel ${channel.name}`) }), CHANNEL_TIMEOUT_MS);
3421
3586
  } catch (e) {
3422
- this.onError(e, `alert channel ${channel.name}`);
3423
- return false;
3587
+ failed.push(keys[i]);
3588
+ this.report(e, `alert channel ${channel.name}`);
3424
3589
  }
3425
3590
  })
3426
3591
  );
3427
- return results.includes(true);
3592
+ return { left: afterSend(alert, owed, failed), reached: failed.length < owed.length };
3428
3593
  }
3429
3594
  /** Sets alert.triage to the diagnosis, or to null when there is none, so it is tried once per alert. */
3430
3595
  async addTriage(alert, timeout) {
@@ -3436,7 +3601,7 @@ ${id}`;
3436
3601
  } catch (e) {
3437
3602
  controller.abort();
3438
3603
  alert.triage = null;
3439
- this.onError(e, `triage for ${alert.job}`);
3604
+ this.report(e, `triage for ${alert.job}`);
3440
3605
  }
3441
3606
  }
3442
3607
  };