@cronwatch/sdk 0.3.0 → 0.4.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 (136) hide show
  1. package/README.md +8 -7
  2. package/dist/anthropic.d.cts +1 -1
  3. package/dist/anthropic.d.ts +1 -1
  4. package/dist/bugsnag.cjs +108 -0
  5. package/dist/bugsnag.cjs.map +1 -0
  6. package/dist/bugsnag.d.cts +26 -0
  7. package/dist/bugsnag.d.ts +26 -0
  8. package/dist/bugsnag.js +106 -0
  9. package/dist/bugsnag.js.map +1 -0
  10. package/dist/client-B7bqnkI5.d.cts +450 -0
  11. package/dist/client-pmqH3apS.d.ts +450 -0
  12. package/dist/d1.cjs +214 -0
  13. package/dist/d1.cjs.map +1 -0
  14. package/dist/d1.d.cts +42 -0
  15. package/dist/d1.d.ts +42 -0
  16. package/dist/d1.js +212 -0
  17. package/dist/d1.js.map +1 -0
  18. package/dist/datadog.cjs +95 -0
  19. package/dist/datadog.cjs.map +1 -0
  20. package/dist/datadog.d.cts +24 -0
  21. package/dist/datadog.d.ts +24 -0
  22. package/dist/datadog.js +93 -0
  23. package/dist/datadog.js.map +1 -0
  24. package/dist/discord.cjs +2 -0
  25. package/dist/discord.cjs.map +1 -1
  26. package/dist/discord.d.cts +1 -1
  27. package/dist/discord.d.ts +1 -1
  28. package/dist/discord.js +2 -0
  29. package/dist/discord.js.map +1 -1
  30. package/dist/email-BJ-z_epD.d.cts +21 -0
  31. package/dist/email-_H0faCh3.d.ts +21 -0
  32. package/dist/honeybadger.cjs +101 -0
  33. package/dist/honeybadger.cjs.map +1 -0
  34. package/dist/honeybadger.d.cts +23 -0
  35. package/dist/honeybadger.d.ts +23 -0
  36. package/dist/honeybadger.js +99 -0
  37. package/dist/honeybadger.js.map +1 -0
  38. package/dist/index.cjs +692 -124
  39. package/dist/index.cjs.map +1 -1
  40. package/dist/index.d.cts +5 -228
  41. package/dist/index.d.ts +5 -228
  42. package/dist/index.js +692 -124
  43. package/dist/index.js.map +1 -1
  44. package/dist/mailgun.cjs +115 -0
  45. package/dist/mailgun.cjs.map +1 -0
  46. package/dist/mailgun.d.cts +21 -0
  47. package/dist/mailgun.d.ts +21 -0
  48. package/dist/mailgun.js +113 -0
  49. package/dist/mailgun.js.map +1 -0
  50. package/dist/newrelic.cjs +88 -0
  51. package/dist/newrelic.cjs.map +1 -0
  52. package/dist/newrelic.d.cts +25 -0
  53. package/dist/newrelic.d.ts +25 -0
  54. package/dist/newrelic.js +86 -0
  55. package/dist/newrelic.js.map +1 -0
  56. package/dist/node.cjs +191 -0
  57. package/dist/node.cjs.map +1 -0
  58. package/dist/node.d.cts +97 -0
  59. package/dist/node.d.ts +97 -0
  60. package/dist/node.js +186 -0
  61. package/dist/node.js.map +1 -0
  62. package/dist/pg-cron.cjs +286 -0
  63. package/dist/pg-cron.cjs.map +1 -0
  64. package/dist/pg-cron.d.cts +93 -0
  65. package/dist/pg-cron.d.ts +93 -0
  66. package/dist/pg-cron.js +280 -0
  67. package/dist/pg-cron.js.map +1 -0
  68. package/dist/postgres.cjs +33 -1
  69. package/dist/postgres.cjs.map +1 -1
  70. package/dist/postgres.d.cts +1 -1
  71. package/dist/postgres.d.ts +1 -1
  72. package/dist/postgres.js +33 -1
  73. package/dist/postgres.js.map +1 -1
  74. package/dist/postmark.cjs +106 -0
  75. package/dist/postmark.cjs.map +1 -0
  76. package/dist/postmark.d.cts +18 -0
  77. package/dist/postmark.d.ts +18 -0
  78. package/dist/postmark.js +104 -0
  79. package/dist/postmark.js.map +1 -0
  80. package/dist/resend.cjs +114 -0
  81. package/dist/resend.cjs.map +1 -0
  82. package/dist/resend.d.cts +16 -0
  83. package/dist/resend.d.ts +16 -0
  84. package/dist/resend.js +112 -0
  85. package/dist/resend.js.map +1 -0
  86. package/dist/rollbar.cjs +108 -0
  87. package/dist/rollbar.cjs.map +1 -0
  88. package/dist/rollbar.d.cts +20 -0
  89. package/dist/rollbar.d.ts +20 -0
  90. package/dist/rollbar.js +106 -0
  91. package/dist/rollbar.js.map +1 -0
  92. package/dist/sendgrid.cjs +114 -0
  93. package/dist/sendgrid.cjs.map +1 -0
  94. package/dist/sendgrid.d.cts +19 -0
  95. package/dist/sendgrid.d.ts +19 -0
  96. package/dist/sendgrid.js +112 -0
  97. package/dist/sendgrid.js.map +1 -0
  98. package/dist/sentry.cjs +130 -0
  99. package/dist/sentry.cjs.map +1 -0
  100. package/dist/sentry.d.cts +28 -0
  101. package/dist/sentry.d.ts +28 -0
  102. package/dist/sentry.js +127 -0
  103. package/dist/sentry.js.map +1 -0
  104. package/dist/ses.cjs +170 -0
  105. package/dist/ses.cjs.map +1 -0
  106. package/dist/ses.d.cts +25 -0
  107. package/dist/ses.d.ts +25 -0
  108. package/dist/ses.js +168 -0
  109. package/dist/ses.js.map +1 -0
  110. package/dist/slack.cjs +2 -0
  111. package/dist/slack.cjs.map +1 -1
  112. package/dist/slack.d.cts +1 -1
  113. package/dist/slack.d.ts +1 -1
  114. package/dist/slack.js +2 -0
  115. package/dist/slack.js.map +1 -1
  116. package/dist/sqlite.cjs +62 -5
  117. package/dist/sqlite.cjs.map +1 -1
  118. package/dist/sqlite.d.cts +1 -1
  119. package/dist/sqlite.d.ts +1 -1
  120. package/dist/sqlite.js +62 -5
  121. package/dist/sqlite.js.map +1 -1
  122. package/dist/twilio.cjs +168 -0
  123. package/dist/twilio.cjs.map +1 -0
  124. package/dist/twilio.d.cts +52 -0
  125. package/dist/twilio.d.ts +52 -0
  126. package/dist/twilio.js +162 -0
  127. package/dist/twilio.js.map +1 -0
  128. package/dist/{types-C1PyRjI5.d.cts → types-Ddq1MUPL.d.cts} +50 -4
  129. package/dist/{types-C1PyRjI5.d.ts → types-Ddq1MUPL.d.ts} +50 -4
  130. package/dist/webhook.cjs +11 -5
  131. package/dist/webhook.cjs.map +1 -1
  132. package/dist/webhook.d.cts +6 -3
  133. package/dist/webhook.d.ts +6 -3
  134. package/dist/webhook.js +11 -6
  135. package/dist/webhook.js.map +1 -1
  136. package/package.json +211 -5
package/dist/index.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { Cron } from 'croner';
2
- import { createHash } from 'crypto';
3
2
 
4
3
  // src/duration.ts
5
4
  var UNIT_MS = {
@@ -53,6 +52,16 @@ function formatRelative(at, now) {
53
52
  if (abs < 5e3) return text;
54
53
  return diff < 0 ? `${text} ago` : `in ${text}`;
55
54
  }
55
+
56
+ // src/env.ts
57
+ function readEnv(name) {
58
+ return typeof process === "undefined" || !process.env ? void 0 : process.env[name];
59
+ }
60
+ function isDevelopment() {
61
+ if (typeof process === "undefined" || !process.env) return false;
62
+ const mode = process.env.NODE_ENV;
63
+ return mode === "development" || mode === "test";
64
+ }
56
65
  var cache = /* @__PURE__ */ new Map();
57
66
  var crons = /* @__PURE__ */ new WeakMap();
58
67
  var EARLY_SLACK_MS = 6e4;
@@ -305,7 +314,15 @@ function onRunFinish(def, run, state, history, now) {
305
314
  function onCheck(def, stored, lastRun, state, now) {
306
315
  const next = cloneState(state);
307
316
  const alerts = [];
308
- if (!def.schedule) return { state: next, alerts, nextExpectedAt: null, dueAt: null };
317
+ if (!def.schedule) {
318
+ const since = next.open.missed;
319
+ if (since !== void 0) {
320
+ delete next.open.missed;
321
+ next.pendingRecovery = (next.pendingRecovery ?? []).filter((c) => c !== "missed");
322
+ alerts.push({ type: "recovered", run: lastRun, details: { after: ["missed"], reason: "unscheduled", since } });
323
+ }
324
+ return { state: next, alerts, nextExpectedAt: null, dueAt: null };
325
+ }
309
326
  const parsed = parseSchedule(def.schedule, def.timezone);
310
327
  const grace = graceMs(def);
311
328
  const lastRunAt = lastRun?.startedAt ?? null;
@@ -337,6 +354,14 @@ function muteOpens(previous, next) {
337
354
  function isSilenced(state, now) {
338
355
  return state.silencedUntil !== null && state.silencedUntil > now;
339
356
  }
357
+ function applySilence(previous, evaluation, now) {
358
+ if (!isSilenced(previous, now)) return evaluation;
359
+ return { state: muteOpens(previous, evaluation.state), alerts: [] };
360
+ }
361
+ function staleAlert(alert, state) {
362
+ if (alert.type === "recovered") return alert.details.after.some((condition) => state.open[condition] !== void 0);
363
+ return state.open[alert.type] !== alert.at;
364
+ }
340
365
  function jobHealth(def, lastRun, state, now) {
341
366
  const open = openConditions(state);
342
367
  if (isSilenced(state, now)) return "silenced";
@@ -347,6 +372,12 @@ function jobHealth(def, lastRun, state, now) {
347
372
  return "healthy";
348
373
  }
349
374
  function summarize(stored, recent, state, nextExpectedAt, now) {
375
+ return summary(stored, recent, state, nextExpectedAt, (lastRun) => jobHealth(stored.definition, lastRun, state, now));
376
+ }
377
+ function unevaluableSummary(stored, recent, state, now) {
378
+ return summary(stored, recent, state, null, () => isSilenced(state, now) ? "silenced" : "failing");
379
+ }
380
+ function summary(stored, recent, state, nextExpectedAt, health) {
350
381
  const window = recent.slice(0, BASELINE_WINDOW);
351
382
  const lastRun = window[0] ?? null;
352
383
  const finished = window.filter((r) => r.status !== "running");
@@ -354,7 +385,7 @@ function summarize(stored, recent, state, nextExpectedAt, now) {
354
385
  return {
355
386
  name: stored.name,
356
387
  definition: stored.definition,
357
- health: jobHealth(stored.definition, lastRun, state, now),
388
+ health: health(lastRun),
358
389
  open: openConditions(state),
359
390
  lastRun,
360
391
  nextExpectedAt,
@@ -441,6 +472,12 @@ ${out}`);
441
472
  break;
442
473
  }
443
474
  case "recovered": {
475
+ if (draft.details.reason === "unscheduled") {
476
+ title = `${name} is no longer scheduled`;
477
+ const since = draft.details.since;
478
+ lines.push(`${since === void 0 ? "" : `Missed since ${when(since, now)}. `}It has no schedule now, so nothing is due; the missed alert is closed.`);
479
+ break;
480
+ }
444
481
  title = `${name} recovered`;
445
482
  const after = draft.details.after.map((c) => c.replace("_", " ")).join(", ");
446
483
  lines.push(`A run ${run ? when(run.startedAt, now) : "just now"} succeeded${after ? ` after: ${after}` : ""}.`);
@@ -468,8 +505,12 @@ function constantTimeEqual(a, b) {
468
505
  // src/output.ts
469
506
  var OUTPUT_CAP = 16 * 1024;
470
507
  function capOutput(text) {
471
- if (text.length <= OUTPUT_CAP) return text;
472
- return "[earlier output trimmed]\n" + text.slice(text.length - OUTPUT_CAP);
508
+ const clean = stripNul(text);
509
+ if (clean.length <= OUTPUT_CAP) return clean;
510
+ return "[earlier output trimmed]\n" + clean.slice(clean.length - OUTPUT_CAP);
511
+ }
512
+ function stripNul(text) {
513
+ return text.includes("\0") ? text.replace(/\u0000/g, "") : text;
473
514
  }
474
515
  function errorMessage(error) {
475
516
  return capOutput(describeError(error));
@@ -489,25 +530,44 @@ ${frames.join("\n")}` : ""}`;
489
530
  }
490
531
  var REDACTED = "[redacted]";
491
532
  var SECRET_PATTERNS = [
492
- // password=..., API_KEY: ..., "client_secret": "...", TOKEN='...', token=... (but not max_tokens: 800).
493
- // A quoted value is blanked to its closing quote, spaces and all, and keeps its quotes.
533
+ // A PEM private key, header to footer. Without a footer (the output was
534
+ // trimmed) it runs to the end of the base64 body. A "-" that starts five
535
+ // dashes ends the body, so the footer is never swallowed into it.
494
536
  [
495
- /\b([A-Za-z0-9_-]{0,40}(?:secret|token|passw(?:or)?d|pwd|api[_-]?key|access[_-]?key|private[_-]?key|credential)[A-Za-z0-9_-]{0,40}(?<![Tt][Oo][Kk][Ee][Nn][Ss])"?\s{0,3}[=:]\s{0,3})(?:(")[^"\n]{1,4096}"|(')[^'\n]{1,4096}'|["']?[^\s"',;&]{1,4096})/gi,
537
+ /-----BEGIN (?:[A-Z0-9]{1,20} ){0,3}PRIVATE KEY-----(?:[A-Za-z0-9+/=\s,:]|-(?!----)){0,16384}(?:-----END (?:[A-Z0-9]{1,20} ){0,3}PRIVATE KEY-----)?/g,
538
+ REDACTED
539
+ ],
540
+ // password=..., API_KEY: ..., "client_secret": "...", TOKEN='...', token=...,
541
+ // :password=>"..." (but not max_tokens: 800). A quoted value is blanked to
542
+ // its closing quote, spaces and all, and keeps its quotes.
543
+ [
544
+ /\b([A-Za-z0-9_-]{0,40}(?:secret|token|passw(?:or)?d|pwd|api[_-]?key|access[_-]?key|private[_-]?key|credential)[A-Za-z0-9_-]{0,40}(?<![Tt][Oo][Kk][Ee][Nn][Ss])"?\s{0,3}(?:=>|[=:])\s{0,3})(?:(")[^"\n]{1,4096}"|(')[^'\n]{1,4096}'|["']?[^\s"',;&]{1,4096})/gi,
496
545
  (_match, name, double, single) => {
497
546
  const quote = double ?? single ?? "";
498
547
  return `${name}${quote}${REDACTED}${quote}`;
499
548
  }
500
549
  ],
501
- // Credentials inside a URL: postgres://user:password@host
502
- [/(\b[a-z][a-z0-9+.-]{0,30}:\/\/[^\s/:@]{0,256}:)[^\s/@]{1,256}@/gi, `$1${REDACTED}@`],
550
+ // Authorization: Basic <base64> and Authorization: Token <token>, also as a JSON or hash entry.
551
+ [/\b((?:proxy-)?authorization["']?\s{0,3}(?:=>|[=:])\s{0,3}["']?\s{0,3}(?:basic|token)\s{1,3})[A-Za-z0-9._~+/=:-]{1,4096}/gi, `$1${REDACTED}`],
552
+ // Credentials inside a URL: postgres://user:password@host. The password
553
+ // runs to the last "@" before a "/" or a space, so one that contains "@"
554
+ // is blanked whole.
555
+ [/(\b[a-z][a-z0-9+.-]{0,30}:\/\/[^\s/:@]{0,256}:)[^\s/]{1,256}@/gi, `$1${REDACTED}@`],
503
556
  // Authorization: Bearer <token>
504
557
  [/\b(Bearer\s{1,3})[A-Za-z0-9._~+/=-]{8,4096}/g, `$1${REDACTED}`],
505
- // Well-known token shapes: AWS, GitHub, Slack, Stripe, Anthropic and OpenAI style keys.
558
+ // A bare JWT: three base64url segments, the first starting eyJ.
559
+ [/\beyJ[A-Za-z0-9_-]{4,4096}\.[A-Za-z0-9_-]{4,4096}\.[A-Za-z0-9_-]{0,4096}/g, REDACTED],
560
+ // Incoming webhook URLs carry their secret in the path.
561
+ [/(\bhooks\.slack\.com\/(?:services|workflows|triggers)\/)[A-Za-z0-9/_-]{1,255}/gi, `$1${REDACTED}`],
562
+ [/(\bdiscord(?:app)?\.com\/api\/(?:v\d{1,2}\/)?webhooks\/)[A-Za-z0-9/_-]{1,255}/gi, `$1${REDACTED}`],
563
+ // Well-known token shapes: AWS, GitHub, Slack, Stripe, Anthropic, OpenAI and Google style keys.
506
564
  [/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, REDACTED],
507
565
  [/\b(?:gh[pousr]_[A-Za-z0-9]{30,255}|github_pat_[A-Za-z0-9_]{20,255})\b/g, REDACTED],
508
566
  [/\bxox[abposr]-[A-Za-z0-9-]{10,255}/g, REDACTED],
509
567
  [/\b[rsp]k_(?:live|test)_[A-Za-z0-9]{10,255}\b/g, REDACTED],
510
- [/\bsk-[A-Za-z0-9_-]{20,255}/g, REDACTED]
568
+ [/\bwhsec_[A-Za-z0-9+/=]{16,255}/g, REDACTED],
569
+ [/\bsk-[A-Za-z0-9_-]{20,255}/g, REDACTED],
570
+ [/\bAIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])/g, REDACTED]
511
571
  ];
512
572
  function redactSecrets(text) {
513
573
  let out = text;
@@ -779,14 +839,21 @@ function messagePage(title, message, base) {
779
839
 
780
840
  // src/routes/index.ts
781
841
  var COOKIE = "cronwatch_token";
782
- function cookieValue(token) {
783
- return createHash("sha256").update(`cronwatch-cookie:${token}`).digest("hex");
842
+ async function cookieValue(token) {
843
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`cronwatch-cookie:${token}`));
844
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
784
845
  }
785
- var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
786
846
  var DEFAULT_RUNS = 20;
787
847
  var MAX_RUNS = 500;
788
848
  var CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
789
849
  var SECURITY_HEADERS = { "x-content-type-options": "nosniff", "referrer-policy": "same-origin", "x-robots-tag": "noindex" };
850
+ function developmentToken() {
851
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
852
+ return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
853
+ }
854
+ function developmentSignInLine(origin, base, token) {
855
+ return `[cronwatch] CRONWATCH_TOKEN is not set, so this development server made a token for the dashboard. Sign in: ${origin}${base}/?token=${token}`;
856
+ }
790
857
  function safeDecode(value) {
791
858
  try {
792
859
  return decodeURIComponent(value);
@@ -803,6 +870,34 @@ function readCookie(request, name) {
803
870
  }
804
871
  return null;
805
872
  }
873
+ function firstValue(value) {
874
+ const first = value?.split(",")[0]?.trim();
875
+ return first ? first : null;
876
+ }
877
+ function configuredOrigin(value) {
878
+ if (value === void 0 || value === "") return null;
879
+ let url;
880
+ try {
881
+ url = new URL(value);
882
+ } catch {
883
+ throw new Error(`routes: origin must be an absolute URL such as "https://app.example.com", got ${JSON.stringify(value)}`);
884
+ }
885
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`routes: origin must be http or https, got ${JSON.stringify(value)}`);
886
+ return url.origin;
887
+ }
888
+ function forwardedOrigin(request, url) {
889
+ const proto = firstValue(request.headers.get("x-forwarded-proto"))?.toLowerCase() ?? null;
890
+ const host = firstValue(request.headers.get("x-forwarded-host"));
891
+ if (proto === null && host === null) return url.origin;
892
+ if (proto !== null && proto !== "http" && proto !== "https") return url.origin;
893
+ try {
894
+ const built = new URL(`${proto ?? url.protocol.slice(0, -1)}://${host ?? url.host}`);
895
+ if (built.pathname !== "/" || built.username || built.password || built.search || built.hash) return url.origin;
896
+ return built.origin;
897
+ } catch {
898
+ return url.origin;
899
+ }
900
+ }
806
901
  function stripBase(pathname, base) {
807
902
  let path = pathname.startsWith(base) ? pathname.slice(base.length) : pathname;
808
903
  if (path === "") path = "/";
@@ -827,9 +922,9 @@ async function readBody(request) {
827
922
  }
828
923
  return {};
829
924
  }
830
- function crossSite(request, url) {
925
+ function crossSite(request, publicOrigin) {
831
926
  const origin = request.headers.get("origin");
832
- if (origin !== null && origin !== url.origin) return true;
927
+ if (origin !== null && origin !== publicOrigin) return true;
833
928
  const site = request.headers.get("sec-fetch-site");
834
929
  return site !== null && site !== "same-origin" && site !== "none";
835
930
  }
@@ -846,38 +941,54 @@ function runsLimit(value) {
846
941
  }
847
942
  function createRoutes(cw, options = {}) {
848
943
  const optedOut = options.token === null;
849
- const token = optedOut ? null : options.token || process.env.CRONWATCH_TOKEN || null;
944
+ const configured = optedOut ? null : options.token || readEnv("CRONWATCH_TOKEN") || null;
850
945
  const base = (options.basePath ?? "/cronwatch").replace(/\/+$/, "");
851
- const developing = process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test";
946
+ const developing = isDevelopment();
947
+ const fixedOrigin = configuredOrigin(options.origin);
948
+ const trustProxy = options.trustProxy === true;
949
+ const originOf = (request, url) => fixedOrigin ?? (trustProxy ? forwardedOrigin(request, url) : url.origin);
950
+ const generated = !configured && !optedOut && developing;
951
+ const token = generated ? developmentToken() : configured;
952
+ let announced = false;
953
+ let cookie = null;
954
+ const expectedCookie = (value) => cookie ??= cookieValue(value);
852
955
  const serve = async (request, url, path, wantsHtml) => {
853
956
  const method = request.method.toUpperCase();
854
- if (!token && !optedOut && !(developing && LOOPBACK.has(url.hostname))) {
855
- return wantsHtml ? html(messagePage("CronWatch routes are locked", "Set CRONWATCH_TOKEN (or pass token to cw.routes()). Without one the routes only answer localhost in development.", base), 503) : api({ ok: false, error: "CRONWATCH_TOKEN is not set" }, 503);
957
+ const publicOrigin = originOf(request, url);
958
+ if (generated && !announced) {
959
+ announced = true;
960
+ console.info(developmentSignInLine(publicOrigin, base, token));
961
+ }
962
+ if (!token && !optedOut) {
963
+ return wantsHtml ? html(messagePage("CronWatch routes are locked", "Set CRONWATCH_TOKEN (or pass token to cw.routes()), or pass token: null to serve them open behind your own auth.", base), 503) : api({ ok: false, error: "CRONWATCH_TOKEN is not set" }, 503);
856
964
  }
857
- if (method !== "GET" && method !== "HEAD" && crossSite(request, url)) {
965
+ if (method !== "GET" && method !== "HEAD" && crossSite(request, publicOrigin)) {
858
966
  return wantsHtml ? html(messagePage("Cross-site request refused", "Changes can only be made from the dashboard itself.", base), 403) : api({ ok: false, error: "Cross-site request refused" }, 403);
859
967
  }
860
968
  const bearer = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") ?? null;
861
969
  if (token) {
862
970
  const query = wantsHtml && method === "GET" ? url.searchParams.get("token") : null;
863
- const cookie = readCookie(request, COOKIE);
971
+ const sent = readCookie(request, COOKIE);
864
972
  const isCheck = path === "/api/check";
865
973
  const cronSecretOk = isCheck && bearer !== null && cw.cronSecret !== null && constantTimeEqual(bearer, cw.cronSecret);
866
- const tokenOk = bearer !== null ? constantTimeEqual(bearer, token) : query !== null ? constantTimeEqual(query, token) : cookie !== null && constantTimeEqual(cookie, cookieValue(token));
974
+ const tokenOk = bearer !== null ? constantTimeEqual(bearer, token) : query !== null ? constantTimeEqual(query, token) : sent !== null && constantTimeEqual(sent, await expectedCookie(token));
867
975
  if (!cronSecretOk && !tokenOk) {
976
+ if (generated) {
977
+ return wantsHtml ? 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 and this browser stays signed in.", base), 401) : api({ ok: false, error: "Unauthorized: CRONWATCH_TOKEN is not set, so this development server made a token; it is in the server log" }, 401);
978
+ }
868
979
  return wantsHtml ? html(messagePage("Sign in", `Open this page with ?token=<your CRONWATCH_TOKEN> once and it will stay signed in.`, base), 401) : api({ ok: false, error: "Unauthorized" }, 401);
869
980
  }
870
981
  if (query !== null) {
871
982
  url.searchParams.delete("token");
872
- const secure = url.protocol === "https:" ? "; Secure" : "";
983
+ const secure = publicOrigin.startsWith("https:") ? "; Secure" : "";
873
984
  return redirect(url.pathname + (url.search || ""), {
874
- "set-cookie": `${COOKIE}=${cookieValue(token)}; Path=${base || "/"}; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 30}${secure}`
985
+ "set-cookie": `${COOKIE}=${await expectedCookie(token)}; Path=${base || "/"}; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 30}${secure}`
875
986
  });
876
987
  }
877
988
  }
878
989
  const redirectBack = () => {
879
990
  const referer = request.headers.get("referer") ?? "";
880
- return redirect(referer.startsWith(url.origin + "/") ? referer : `${base}/`);
991
+ return redirect(referer.startsWith(publicOrigin + "/") ? referer : `${base}/`);
881
992
  };
882
993
  const decoded = path.split("/").filter(Boolean).map(safeDecode);
883
994
  if (decoded.some((part) => part === null)) {
@@ -1038,6 +1149,7 @@ function memory() {
1038
1149
  }
1039
1150
  },
1040
1151
  async insertRun(run) {
1152
+ if (runs.has(run.id)) throw new Error(`run ${run.id} already exists`);
1041
1153
  runs.set(run.id, clone(run));
1042
1154
  order.set(run.id, ++seq);
1043
1155
  },
@@ -1047,6 +1159,13 @@ function memory() {
1047
1159
  const { status, finishedAt, durationMs, error, output, metrics } = clone(run);
1048
1160
  runs.set(run.id, { ...existing, status, finishedAt, durationMs, error, output, metrics });
1049
1161
  },
1162
+ async updateRunIf(run, fromStatuses) {
1163
+ const existing = runs.get(run.id);
1164
+ if (!existing || !fromStatuses.includes(existing.status)) return false;
1165
+ const { status, finishedAt, durationMs, error, output, metrics } = clone(run);
1166
+ runs.set(run.id, { ...existing, status, finishedAt, durationMs, error, output, metrics });
1167
+ return true;
1168
+ },
1050
1169
  async getRun(id) {
1051
1170
  const r = runs.get(id);
1052
1171
  return r ? clone(r) : null;
@@ -1068,6 +1187,11 @@ function memory() {
1068
1187
  async setState(state) {
1069
1188
  states.set(state.job, clone(state));
1070
1189
  },
1190
+ async compareAndSetState(state, expectedVersion) {
1191
+ if ((states.get(state.job)?.version ?? 0) !== expectedVersion) return false;
1192
+ states.set(state.job, clone(state));
1193
+ return true;
1194
+ },
1071
1195
  async prune(before) {
1072
1196
  const newest = /* @__PURE__ */ new Map();
1073
1197
  for (const run of runs.values()) newest.set(run.job, Math.max(newest.get(run.job) ?? -Infinity, run.startedAt));
@@ -1090,6 +1214,8 @@ var TRIAGE_TIMEOUT_MS = 25e3;
1090
1214
  var CHANNEL_TIMEOUT_MS = 15e3;
1091
1215
  var PRUNE_INTERVAL_MS = 60 * 6e4;
1092
1216
  var MAX_UNDELIVERED = 20;
1217
+ var RETRY_BUDGET_MS = 2e4;
1218
+ var STATE_ATTEMPTS = 10;
1093
1219
  var HISTORY_PAGE = BASELINE_WINDOW + 5;
1094
1220
  var HISTORY_MAX = 200;
1095
1221
  function validateDefinition(def) {
@@ -1135,6 +1261,7 @@ var CronWatch = class {
1135
1261
  store;
1136
1262
  alerts;
1137
1263
  triage;
1264
+ sources;
1138
1265
  /** The secret handler() requests must carry, or null when none is set. */
1139
1266
  cronSecret;
1140
1267
  retentionMs;
@@ -1150,6 +1277,8 @@ var CronWatch = class {
1150
1277
  synced = /* @__PURE__ */ new Set();
1151
1278
  /** The tail of each job's queue of state updates. See serial(). */
1152
1279
  queues = /* @__PURE__ */ new Map();
1280
+ /** start() calls with an id still in flight, so two at once in this process record one run. */
1281
+ starting = /* @__PURE__ */ new Map();
1153
1282
  ready = null;
1154
1283
  checking = null;
1155
1284
  lastPruneAt = 0;
@@ -1157,17 +1286,29 @@ var CronWatch = class {
1157
1286
  firstTick = null;
1158
1287
  usingDefaultStore = false;
1159
1288
  warnedNoSecret = false;
1289
+ warnedDeferredStart = false;
1160
1290
  constructor(options = {}) {
1161
1291
  this.store = options.store ?? (this.usingDefaultStore = true, memory());
1162
1292
  this.alerts = options.alerts ?? [consoleChannel()];
1163
1293
  this.triage = options.triage;
1164
- const secret = options.cronSecret === void 0 ? process.env.CRON_SECRET : options.cronSecret;
1294
+ this.sources = options.sources ?? [];
1295
+ const secret = options.cronSecret === void 0 ? readEnv("CRON_SECRET") : options.cronSecret;
1165
1296
  this.cronSecret = secret ? secret : null;
1166
1297
  this.secretOptOut = options.cronSecret === null;
1167
1298
  this.retentionMs = parseDuration(options.retention ?? "30d", "retention");
1168
1299
  this.defaults = options.defaults ?? {};
1169
1300
  this.now = options.now ?? (() => Date.now());
1170
- this.redact = options.redact === false ? (text) => text : options.redact ?? redactSecrets;
1301
+ const redact = options.redact;
1302
+ this.redact = redact === false ? (text) => text : typeof redact === "function" ? (text) => {
1303
+ try {
1304
+ const out = redact(text);
1305
+ if (typeof out !== "string") throw new TypeError(`redact must return a string, not ${out === null ? "null" : typeof out}`);
1306
+ return out;
1307
+ } catch (e) {
1308
+ this.report(e, "redact");
1309
+ return redactSecrets(text);
1310
+ }
1311
+ } : redactSecrets;
1171
1312
  if (options.deliver !== void 0 && options.deliver !== "now" && options.deliver !== "check") {
1172
1313
  throw new Error(`deliver must be "now" or "check", not ${JSON.stringify(options.deliver)}`);
1173
1314
  }
@@ -1195,6 +1336,12 @@ var CronWatch = class {
1195
1336
  definedJobs() {
1196
1337
  return [...this.definitions.values()];
1197
1338
  }
1339
+ /** A handle on a run started elsewhere, as job(name).resume(runId). The job must be declared in this process. */
1340
+ resumeRun(name, runId) {
1341
+ const definition = this.definitions.get(name);
1342
+ if (!definition) return Promise.reject(new Error(`resumeRun: job "${name}" is not declared; call job() first`));
1343
+ return this.resumeHandle(definition, runId);
1344
+ }
1198
1345
  handle(definition) {
1199
1346
  const self = this;
1200
1347
  return {
@@ -1233,6 +1380,12 @@ var CronWatch = class {
1233
1380
  };
1234
1381
  return json(body, outcome.run.status === "ok" ? 200 : 500);
1235
1382
  };
1383
+ },
1384
+ start(options) {
1385
+ return self.startRun(definition, options ?? {});
1386
+ },
1387
+ resume(runId) {
1388
+ return self.resumeHandle(definition, runId);
1236
1389
  }
1237
1390
  };
1238
1391
  }
@@ -1241,11 +1394,18 @@ var CronWatch = class {
1241
1394
  this.warnedNoSecret = true;
1242
1395
  this.onError(new Error("handler() refused a request because no CRON_SECRET is set; pass secret: null to allow unauthenticated requests"), "handler");
1243
1396
  }
1397
+ /** onError, for places that must carry on even when onError itself throws. */
1398
+ report(error, where) {
1399
+ try {
1400
+ this.onError(error, where);
1401
+ } catch {
1402
+ }
1403
+ }
1244
1404
  async ensureReady() {
1245
1405
  if (!this.ready) {
1246
1406
  this.ready = (async () => {
1247
1407
  if (this.store.init) await this.store.init();
1248
- if (this.usingDefaultStore && process.env.NODE_ENV === "production") {
1408
+ if (this.usingDefaultStore && readEnv("NODE_ENV") === "production") {
1249
1409
  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.");
1250
1410
  }
1251
1411
  })().catch((error) => {
@@ -1264,7 +1424,8 @@ var CronWatch = class {
1264
1424
  /**
1265
1425
  * Runs `fn` after every earlier state update for the same job has settled,
1266
1426
  * so two runs (or a run and a check) in this process never read and write
1267
- * the job's state over each other. Other processes are not coordinated.
1427
+ * the job's state over each other. Other processes are coordinated by
1428
+ * updateState() instead.
1268
1429
  */
1269
1430
  serial(job, fn) {
1270
1431
  const result = (this.queues.get(job) ?? Promise.resolve()).then(fn);
@@ -1280,6 +1441,38 @@ var CronWatch = class {
1280
1441
  async readState(job) {
1281
1442
  return normalizeState(await this.store.getState(job), job);
1282
1443
  }
1444
+ /**
1445
+ * Every read-modify-write of a job's state goes through here. In turn with
1446
+ * this process's other updates to the job (serial()), it reads the state,
1447
+ * asks `change` for the next one, and writes it with the version one
1448
+ * higher, only if the stored version is still the one read. When another
1449
+ * process wrote in between, the write is refused and it starts again from
1450
+ * a fresh read, up to STATE_ATTEMPTS times. So `change` may run more than
1451
+ * once and must only compute: whatever it returns from the attempt that
1452
+ * was written is the result. Nothing is written when the state is
1453
+ * unchanged. Returns the state as stored.
1454
+ */
1455
+ updateState(job, change) {
1456
+ return this.serial(job, async () => {
1457
+ for (let attempt = 1; ; attempt++) {
1458
+ const current = await this.readState(job);
1459
+ const { state, result } = await change(current);
1460
+ if (sameState(state, current)) return { state: current, result };
1461
+ const version = current.version ?? 0;
1462
+ const next = { ...state, version: version + 1 };
1463
+ if (await this.writeState(next, version)) return { state: next, result };
1464
+ if (attempt >= STATE_ATTEMPTS) {
1465
+ throw new Error(`the state of ${job} changed under ${STATE_ATTEMPTS} attempts in a row to update it; gave up`);
1466
+ }
1467
+ }
1468
+ });
1469
+ }
1470
+ /** A conditional write, or for a store without compareAndSetState, a plain one that always succeeds. */
1471
+ async writeState(state, expectedVersion) {
1472
+ if (typeof this.store.compareAndSetState === "function") return this.store.compareAndSetState(state, expectedVersion);
1473
+ await this.store.setState(state);
1474
+ return true;
1475
+ }
1283
1476
  /**
1284
1477
  * Runs a function as a recorded run. The function always runs, whatever
1285
1478
  * the store is doing: store errors go to onError, and the result is the
@@ -1308,11 +1501,8 @@ var CronWatch = class {
1308
1501
  } catch (e) {
1309
1502
  this.onError(e, `recording ${name}`);
1310
1503
  }
1311
- const started = recorded ? this.serial(name, async () => {
1312
- const before = await this.readState(name);
1313
- const after = onRunStart(before);
1314
- if (!sameState(before, after)) await this.store.setState(after);
1315
- }).catch((e) => this.onError(e, `starting ${name}`)) : Promise.resolve();
1504
+ const started = recorded ? this.updateState(name, (before) => ({ state: onRunStart(before), result: void 0 })).then(() => {
1505
+ }, (e) => this.onError(e, `starting ${name}`)) : Promise.resolve();
1316
1506
  const recorder = createRecorder(run);
1317
1507
  const timer = setTimeout(() => recorder.abort(), timeoutMs(definition));
1318
1508
  if (typeof timer === "object" && "unref" in timer) timer.unref();
@@ -1332,6 +1522,21 @@ var CronWatch = class {
1332
1522
  run.durationMs = Math.max(0, finishedAt - startedAt);
1333
1523
  run.metrics = recorder.metrics();
1334
1524
  run.output = recorder.output() ?? (typeof result === "string" ? capOutput(result) : null);
1525
+ this.conclude(definition, run, result, error, threw, recorder.expectText() ?? (typeof result === "string" ? result : null));
1526
+ await started;
1527
+ try {
1528
+ const ignored = await this.recordFinish(definition, run, recorded, finishedAt);
1529
+ if (ignored) this.report(new Error(`run ${run.id} of ${name} ${ignored}; ignored`), `finishing ${name}`);
1530
+ } catch (e) {
1531
+ this.onError(e, `recording ${name}`);
1532
+ }
1533
+ return { run, result, error, threw };
1534
+ }
1535
+ /**
1536
+ * Sets a finished run's status and error from how it ended, then redacts
1537
+ * its output and error. Shared by execute() and RunHandle.finish().
1538
+ */
1539
+ conclude(definition, run, result, error, threw, expectText) {
1335
1540
  if (threw) {
1336
1541
  run.status = "failed";
1337
1542
  run.error = errorMessage(error);
@@ -1339,7 +1544,6 @@ var CronWatch = class {
1339
1544
  run.status = "failed";
1340
1545
  run.error = `HTTP ${result.status}${result.statusText ? ` ${result.statusText}` : ""}`;
1341
1546
  } else {
1342
- const expectText = recorder.expectText() ?? (typeof result === "string" ? result : null);
1343
1547
  const unmet = checkExpectation(definition.expect, expectText);
1344
1548
  if (unmet) {
1345
1549
  run.status = "failed";
@@ -1348,51 +1552,344 @@ var CronWatch = class {
1348
1552
  run.status = "ok";
1349
1553
  }
1350
1554
  }
1351
- if (run.output !== null) run.output = this.redact(run.output);
1352
- if (run.error !== null) run.error = this.redact(run.error);
1353
- await started;
1555
+ if (run.output !== null) run.output = stripNul(this.redact(run.output));
1556
+ if (run.error !== null) run.error = stripNul(this.redact(run.error));
1557
+ }
1558
+ /**
1559
+ * Writes a finished run and evaluates it. `recorded` says whether its
1560
+ * start was written; if not, it is inserted now. Returns why nothing was
1561
+ * recorded (another process finished the run first, say), or null. Throws
1562
+ * when the store does, so a handle can be finished again. Shared by
1563
+ * execute() and RunHandle.finish().
1564
+ */
1565
+ async recordFinish(definition, run, recorded, finishedAt) {
1354
1566
  if (!recorded) {
1567
+ await this.sync(definition);
1355
1568
  try {
1356
- await this.sync(definition);
1357
1569
  await this.store.insertRun(run);
1358
- recorded = true;
1570
+ await this.finishRun(toStored(definition), run, finishedAt);
1571
+ return null;
1359
1572
  } catch (e) {
1360
- this.onError(e, `recording ${name}`);
1573
+ const stored = await this.store.getRun(run.id).catch(() => null);
1574
+ if (!stored) throw e;
1575
+ if (stored.job !== run.job) return `belongs to job "${stored.job}"`;
1361
1576
  }
1362
- if (recorded) await this.finishRun(toStored(definition), run, finishedAt, false);
1363
- } else if (await this.markedTimedOut(run)) {
1577
+ }
1578
+ const claim = await this.claimFinish(run);
1579
+ if ("ignored" in claim) return claim.ignored;
1580
+ if (!claim.lateAfterTimeout || run.status === "ok") await this.finishRun(toStored(definition), run, finishedAt);
1581
+ return null;
1582
+ }
1583
+ /** A conditional write (Store.updateRunIf), or for a store without one, a read then a plain write. */
1584
+ async writeRunIf(run, fromStatuses) {
1585
+ if (typeof this.store.updateRunIf === "function") return this.store.updateRunIf(run, fromStatuses);
1586
+ const stored = await this.store.getRun(run.id);
1587
+ if (!stored || !fromStatuses.includes(stored.status)) return false;
1588
+ await this.store.updateRun(run);
1589
+ return true;
1590
+ }
1591
+ /**
1592
+ * Writes a finished run over its stored row, only while that row is still
1593
+ * running, or else still marked timeout by a check. Only the process whose
1594
+ * write lands goes on to evaluate the run; for the others it returns why
1595
+ * nothing was written. `lateAfterTimeout` means a check already counted
1596
+ * the run as a stuck failure: a late failure must not count twice, while
1597
+ * a late success still closes stuck and recovers. Throws when the store does.
1598
+ */
1599
+ async claimFinish(run) {
1600
+ if (await this.writeRunIf(run, ["running"])) return { lateAfterTimeout: false };
1601
+ if (await this.writeRunIf(run, ["timeout"])) return { lateAfterTimeout: true };
1602
+ const stored = await this.store.getRun(run.id);
1603
+ return { ignored: stored ? `was already finished as ${stored.status}` : "was not found" };
1604
+ }
1605
+ /** job.start(): records a running run and returns a handle to finish it. See JobHandle.start. */
1606
+ async startRun(definition, options) {
1607
+ const id = options.id;
1608
+ if (id === void 0) return this.recordStart(definition, options.trigger);
1609
+ checkRunId(definition.name, id, "start");
1610
+ const key = `${definition.name}
1611
+ ${id}`;
1612
+ const inFlight = this.starting.get(key);
1613
+ if (inFlight) return inFlight;
1614
+ const started = this.recordStart(definition, options.trigger, id).finally(() => {
1615
+ if (this.starting.get(key) === started) this.starting.delete(key);
1616
+ });
1617
+ this.starting.set(key, started);
1618
+ return started;
1619
+ }
1620
+ /**
1621
+ * The start of execute() without the function: the run is inserted and
1622
+ * missed and stuck close (onRunStart). A store that fails is reported and
1623
+ * the handle inserts the finished run instead, as execute() does.
1624
+ */
1625
+ async recordStart(definition, trigger = "start", id) {
1626
+ const name = definition.name;
1627
+ if (id !== void 0) {
1628
+ let stored = null;
1364
1629
  try {
1365
- await this.store.updateRun(run);
1630
+ await this.ensureReady();
1631
+ stored = await this.store.getRun(id);
1366
1632
  } catch (e) {
1367
1633
  this.onError(e, `recording ${name}`);
1368
1634
  }
1369
- } else {
1370
- await this.finishRun(toStored(definition), run, finishedAt, true);
1635
+ if (stored) return this.existingHandle(definition, stored);
1371
1636
  }
1372
- return { run, result, error, threw };
1637
+ const run = {
1638
+ id: id ?? crypto.randomUUID(),
1639
+ job: name,
1640
+ status: "running",
1641
+ startedAt: this.now(),
1642
+ finishedAt: null,
1643
+ durationMs: null,
1644
+ error: null,
1645
+ output: null,
1646
+ metrics: {},
1647
+ trigger
1648
+ };
1649
+ let recorded = false;
1650
+ try {
1651
+ await this.sync(definition);
1652
+ await this.store.insertRun({ ...run });
1653
+ recorded = true;
1654
+ } catch (e) {
1655
+ const stored = id === void 0 ? null : await this.store.getRun(id).catch(() => null);
1656
+ if (stored) return this.existingHandle(definition, stored);
1657
+ this.onError(e, `recording ${name}`);
1658
+ }
1659
+ if (recorded) {
1660
+ await this.updateState(name, (before) => ({ state: onRunStart(before), result: void 0 })).catch((e) => this.onError(e, `starting ${name}`));
1661
+ }
1662
+ return this.runHandle(definition, run.id, run, recorded, null);
1373
1663
  }
1374
- /** Whether a check already marked this run as timed out, for a failure that finished late. */
1375
- async markedTimedOut(run) {
1376
- if (run.status === "ok") return false;
1664
+ /** job.resume() and cw.resumeRun(). A store that cannot be read is reported, and finish() reads it again. */
1665
+ async resumeHandle(definition, runId) {
1666
+ checkRunId(definition.name, runId, "resume");
1667
+ let stored;
1377
1668
  try {
1378
- return (await this.store.getRun(run.id))?.status === "timeout";
1379
- } catch {
1380
- return false;
1669
+ await this.ensureReady();
1670
+ stored = await this.store.getRun(runId);
1671
+ } catch (e) {
1672
+ this.onError(e, `resuming ${definition.name}`);
1673
+ return this.runHandle(definition, runId, null, true, null);
1674
+ }
1675
+ if (!stored) return this.runHandle(definition, runId, null, true, "was not found");
1676
+ return this.existingHandle(definition, stored);
1677
+ }
1678
+ /** A handle on a stored run. One still running, or marked timeout by a check, can be finished. */
1679
+ existingHandle(definition, stored) {
1680
+ if (stored.job !== definition.name) {
1681
+ throw new Error(`run "${stored.id}" belongs to job "${stored.job}", not "${definition.name}"`);
1381
1682
  }
1683
+ const finished = stored.status === "ok" || stored.status === "failed";
1684
+ return this.runHandle(definition, stored.id, stored, true, finished ? `already finished as ${stored.status}` : null);
1685
+ }
1686
+ /**
1687
+ * The handle itself. `base` is the run as last known here, `recorded`
1688
+ * whether its start is in the store, and `inactive` why finish() has
1689
+ * nothing to do, or null. Lines and metrics wait in the handle until
1690
+ * flush() or finish() merges them onto a fresh read of the stored run.
1691
+ */
1692
+ runHandle(definition, id, base, recorded, inactive) {
1693
+ const self = this;
1694
+ const name = definition.name;
1695
+ const fresh = () => createRecorder({ id, job: name, startedAt: base?.startedAt ?? 0 });
1696
+ let recorder = fresh();
1697
+ let finished = inactive !== null;
1698
+ let finishCalled = false;
1699
+ let head = null;
1700
+ let queue = Promise.resolve();
1701
+ const inTurn = (fn) => {
1702
+ const result = queue.then(fn);
1703
+ queue = result.catch(() => {
1704
+ });
1705
+ return result;
1706
+ };
1707
+ const ignored = (why) => self.report(new Error(`run ${id} of ${name} ${why}; ignored`), `finishing ${name}`);
1708
+ const finish = (outcome) => {
1709
+ if (finishCalled) {
1710
+ ignored("was already finished by this handle");
1711
+ return Promise.resolve(null);
1712
+ }
1713
+ finishCalled = true;
1714
+ const wasInactive = finished;
1715
+ finished = true;
1716
+ const retryable = (error) => {
1717
+ finishCalled = false;
1718
+ finished = false;
1719
+ self.report(error, `finishing ${name}`);
1720
+ return null;
1721
+ };
1722
+ return inTurn(async () => {
1723
+ if (wasInactive) {
1724
+ ignored(inactive);
1725
+ return null;
1726
+ }
1727
+ let from = base;
1728
+ if (recorded) {
1729
+ try {
1730
+ from = await self.store.getRun(id) ?? base;
1731
+ } catch (e) {
1732
+ return retryable(e);
1733
+ }
1734
+ }
1735
+ if (!from) {
1736
+ ignored("was not found");
1737
+ return null;
1738
+ }
1739
+ if (from.job !== name) {
1740
+ ignored(`belongs to job "${from.job}"`);
1741
+ return null;
1742
+ }
1743
+ if (from.status === "ok" || from.status === "failed") {
1744
+ ignored(`was already finished as ${from.status}`);
1745
+ return null;
1746
+ }
1747
+ const failed = typeof outcome === "object" && outcome !== null && "error" in outcome;
1748
+ const result = typeof outcome === "string" ? outcome : failed ? void 0 : outcome?.result;
1749
+ const error = failed ? outcome.error : void 0;
1750
+ const finishedAt = self.now();
1751
+ const added = recorder.output() ?? (typeof result === "string" ? capOutput(result) : null);
1752
+ const run = {
1753
+ ...from,
1754
+ status: "running",
1755
+ finishedAt,
1756
+ durationMs: Math.max(0, finishedAt - from.startedAt),
1757
+ error: null,
1758
+ output: joinOutput(from.output, added),
1759
+ metrics: { ...from.metrics, ...recorder.metrics() }
1760
+ };
1761
+ const expectText = joinLines(head, joinLines(from.output, recorder.expectText() ?? (typeof result === "string" ? result : null)));
1762
+ self.conclude(definition, run, result, error, failed, expectText);
1763
+ let why;
1764
+ try {
1765
+ why = await self.recordFinish(definition, run, recorded, finishedAt);
1766
+ } catch (e) {
1767
+ return retryable(e);
1768
+ }
1769
+ if (why) {
1770
+ ignored(why);
1771
+ return null;
1772
+ }
1773
+ return run;
1774
+ });
1775
+ };
1776
+ return {
1777
+ id,
1778
+ job: name,
1779
+ startedAt: base?.startedAt ?? null,
1780
+ get active() {
1781
+ return !finished;
1782
+ },
1783
+ log: (...parts) => recorder.context.log(...parts),
1784
+ metric: (metric, value) => recorder.context.metric(metric, value),
1785
+ metrics: (values) => recorder.context.metrics(values),
1786
+ flush: () => inTurn(async () => {
1787
+ if (finished || !recorded) return;
1788
+ const lines = recorder.output();
1789
+ const metrics = recorder.metrics();
1790
+ if (lines === null && Object.keys(metrics).length === 0) return;
1791
+ const taken = recorder;
1792
+ recorder = fresh();
1793
+ const putBack = () => {
1794
+ const later = recorder;
1795
+ recorder = fresh();
1796
+ for (const text of [taken.expectText(), later.expectText()]) if (text !== null) recorder.context.log(text);
1797
+ recorder.context.metrics({ ...taken.metrics(), ...later.metrics() });
1798
+ };
1799
+ try {
1800
+ const stored = await self.store.getRun(id);
1801
+ if (!stored || stored.status !== "running") return putBack();
1802
+ if (stored.job !== name) {
1803
+ putBack();
1804
+ self.report(new Error(`run ${id} of ${name} belongs to job "${stored.job}"; ignored`), `flushing ${name}`);
1805
+ return;
1806
+ }
1807
+ const output = lines === null ? stored.output : joinOutput(stored.output, stripNul(self.redact(lines)));
1808
+ if (!await self.writeRunIf({ ...stored, output, metrics: { ...stored.metrics, ...metrics } }, ["running"])) return putBack();
1809
+ const text = taken.expectText();
1810
+ if (text !== null && (head === null || head.length < OUTPUT_CAP)) head = (joinLines(head, text) ?? "").slice(0, OUTPUT_CAP);
1811
+ } catch (e) {
1812
+ putBack();
1813
+ self.report(e, `flushing ${name}`);
1814
+ }
1815
+ }),
1816
+ finish,
1817
+ fail: (error) => finish({ error })
1818
+ };
1382
1819
  }
1383
1820
  /**
1384
- * Record a finished run (ok, failed, or timed out by a check), evaluate it
1385
- * against the job's state and send what that produces. Never throws.
1821
+ * Record a run that happened outside this process, for a Source. Its job
1822
+ * must be declared with job() first. Runs are keyed by id: a new one is
1823
+ * inserted, a stored one still running (or marked timeout by a check) is
1824
+ * finished when this one is not running, and anything else is left alone,
1825
+ * so recording the same run twice changes nothing. Finishing is
1826
+ * conditional (see Store.updateRunIf): when two processes record the same
1827
+ * finish, only the one whose write lands evaluates it, and the other
1828
+ * reports it as already finished. A stored run of another job is left
1829
+ * alone and reported. A finished run is judged as if it had been wrapped
1830
+ * here (expect, failures, duration, budgets) and its output and error are
1831
+ * redacted the same way; one finishing after a check marked it timeout is
1832
+ * judged only when it succeeded, as RunHandle.finish() does. Returns the
1833
+ * alerts it sent.
1386
1834
  */
1387
- async finishRun(definition, run, now, write) {
1835
+ async recordRun(input, options = {}) {
1836
+ const declared = this.definitions.get(input.job);
1837
+ if (!declared) throw new Error(`recordRun: job "${input.job}" is not declared; call job() first`);
1838
+ await this.sync(declared);
1839
+ const run = { ...input, metrics: { ...input.metrics } };
1840
+ if (run.status === "ok") {
1841
+ const unmet = checkExpectation(declared.expect, run.output);
1842
+ if (unmet) {
1843
+ run.status = "failed";
1844
+ run.error = unmet;
1845
+ }
1846
+ }
1847
+ if (run.output !== null) run.output = stripNul(this.redact(capOutput(run.output)));
1848
+ if (run.error !== null) run.error = stripNul(this.redact(capOutput(run.error)));
1849
+ const evaluate = options.evaluate !== false;
1850
+ const definition = toStored(declared);
1851
+ const stored = await this.store.getRun(run.id);
1852
+ if (stored) return this.recordOver(definition, stored, run, evaluate);
1853
+ try {
1854
+ await this.store.insertRun(run);
1855
+ } catch (e) {
1856
+ const again = await this.store.getRun(run.id).catch(() => null);
1857
+ if (again) return this.recordOver(definition, again, run, evaluate);
1858
+ throw e;
1859
+ }
1860
+ if (!evaluate) return [];
1861
+ await this.updateState(run.job, (before) => ({ state: onRunStart(before), result: void 0 }));
1862
+ if (run.status === "running") return [];
1863
+ return this.finishRun(definition, run, this.now());
1864
+ }
1865
+ /** recordRun() for a run already stored. */
1866
+ async recordOver(definition, stored, run, evaluate) {
1867
+ if (stored.job !== run.job) {
1868
+ this.report(new Error(`run ${run.id} of ${run.job} belongs to job "${stored.job}"; ignored`), `recording ${run.job}`);
1869
+ return [];
1870
+ }
1871
+ if (stored.status !== "running" && stored.status !== "timeout" || run.status === "running") return [];
1872
+ const claim = await this.claimFinish(run);
1873
+ if ("ignored" in claim) {
1874
+ this.report(new Error(`run ${run.id} of ${run.job} ${claim.ignored}; ignored`), `recording ${run.job}`);
1875
+ return [];
1876
+ }
1877
+ if (!evaluate || claim.lateAfterTimeout && run.status !== "ok") return [];
1878
+ return this.finishRun(definition, run, this.now());
1879
+ }
1880
+ /**
1881
+ * Evaluate a finished run (ok, failed, or timed out by a check), already
1882
+ * written, against the job's state and send what that produces. Never throws.
1883
+ */
1884
+ async finishRun(definition, run, now) {
1388
1885
  let drafts;
1389
1886
  try {
1390
- if (write) await this.store.updateRun(run);
1391
- drafts = await this.serial(run.job, async () => {
1392
- const history = await this.history(run);
1393
- const previous = await this.readState(run.job);
1394
- return (await this.settle(previous, onRunFinish(definition, run, previous, history, now), now)).alerts;
1395
- });
1887
+ let history = null;
1888
+ ({ result: drafts } = await this.updateState(run.job, async (previous) => {
1889
+ history ??= await this.history(run);
1890
+ const settled = applySilence(previous, onRunFinish(definition, run, previous, history, now), now);
1891
+ return { state: settled.state, result: settled.alerts };
1892
+ }));
1396
1893
  } catch (e) {
1397
1894
  this.onError(e, `evaluating ${run.job}`);
1398
1895
  return [];
@@ -1427,30 +1924,51 @@ var CronWatch = class {
1427
1924
  }
1428
1925
  async runCheck() {
1429
1926
  await this.ensureReady();
1927
+ const alerts = [];
1928
+ for (const source of this.sources) {
1929
+ try {
1930
+ alerts.push(...await source.sync(this) ?? []);
1931
+ } catch (e) {
1932
+ this.onError(e, `source ${source.name}`);
1933
+ }
1934
+ }
1430
1935
  for (const definition of this.definitions.values()) await this.sync(definition);
1431
1936
  const now = this.now();
1432
- const alerts = [];
1433
1937
  for (const run of await this.store.runningRuns()) {
1434
- const declared = this.definitions.get(run.job);
1435
- const definition = declared ? toStored(declared) : (await this.store.getJob(run.job))?.definition;
1436
- if (!definition || !isStuck(definition, run, now)) continue;
1437
- run.status = "timeout";
1438
- run.finishedAt = now;
1439
- run.durationMs = now - run.startedAt;
1440
- run.error = `Still running after ${formatDuration(timeoutMs(definition))}; marked as timed out`;
1441
- alerts.push(...await this.finishRun(definition, run, now, true));
1938
+ try {
1939
+ const declared = this.definitions.get(run.job);
1940
+ const definition = declared ? toStored(declared) : (await this.store.getJob(run.job))?.definition;
1941
+ if (!definition || !isStuck(definition, run, now)) continue;
1942
+ const timeout = timeoutMs(definition);
1943
+ run.status = "timeout";
1944
+ run.finishedAt = now;
1945
+ run.durationMs = now - run.startedAt;
1946
+ run.error = `Still running after ${formatDuration(timeout)}; marked as timed out`;
1947
+ if (!await this.writeRunIf(run, ["running"])) continue;
1948
+ alerts.push(...await this.finishRun(definition, run, now));
1949
+ } catch (e) {
1950
+ this.onError(e, `checking ${run.job}`);
1951
+ }
1442
1952
  }
1443
1953
  const jobs = [];
1954
+ const retries = { spentMs: 0 };
1444
1955
  for (const stored of await this.store.listJobs()) {
1445
- const recent = await this.store.listRuns(stored.name, BASELINE_WINDOW);
1446
- const { previous, evaluation, settled } = await this.serial(stored.name, async () => {
1447
- const previous2 = await this.readState(stored.name);
1448
- const evaluation2 = onCheck(stored.definition, stored, recent[0] ?? null, previous2, now);
1449
- return { previous: previous2, evaluation: evaluation2, settled: await this.settle(previous2, evaluation2, now) };
1450
- });
1451
- alerts.push(...await this.retryUndelivered(stored.name, previous, now));
1452
- alerts.push(...await this.dispatch(settled.alerts, stored.definition, now));
1453
- jobs.push(summarize(stored, recent, settled.state, evaluation.nextExpectedAt, now));
1956
+ try {
1957
+ const recent = await this.store.listRuns(stored.name, BASELINE_WINDOW);
1958
+ let nextExpectedAt = null;
1959
+ const { state, result: drafts } = await this.updateState(stored.name, (previous) => {
1960
+ const evaluation = onCheck(stored.definition, stored, recent[0] ?? null, previous, now);
1961
+ nextExpectedAt = evaluation.nextExpectedAt;
1962
+ const settled = applySilence(previous, evaluation, now);
1963
+ return { state: settled.state, result: settled.alerts };
1964
+ });
1965
+ alerts.push(...await this.retryUndelivered(stored.name, state, now, retries));
1966
+ alerts.push(...await this.dispatch(drafts, stored.definition, now));
1967
+ jobs.push(summarize(stored, recent, state, nextExpectedAt, now));
1968
+ } catch (e) {
1969
+ this.onError(e, `checking ${stored.name}`);
1970
+ jobs.push(await this.unevaluable(stored, now));
1971
+ }
1454
1972
  }
1455
1973
  let pruned = 0;
1456
1974
  if (now - this.lastPruneAt > PRUNE_INTERVAL_MS) {
@@ -1463,12 +1981,24 @@ var CronWatch = class {
1463
1981
  }
1464
1982
  return { checkedAt: now, jobs, alerts, pruned };
1465
1983
  }
1466
- /** A job's summary and its newest runs, without alerting. */
1984
+ /** A job's summary and its newest runs, without alerting. A job that cannot be evaluated is reported and shown as failing. */
1467
1985
  async snapshot(stored, now, runs) {
1468
- const recent = await this.store.listRuns(stored.name, Math.max(runs, BASELINE_WINDOW));
1469
- const state = await this.readState(stored.name);
1470
- const { nextExpectedAt } = onCheck(stored.definition, stored, recent[0] ?? null, state, now);
1471
- return { job: summarize(stored, recent, state, nextExpectedAt, now), runs: recent.slice(0, runs) };
1986
+ let recent = [];
1987
+ try {
1988
+ recent = await this.store.listRuns(stored.name, Math.max(runs, BASELINE_WINDOW));
1989
+ const state = await this.readState(stored.name);
1990
+ const { nextExpectedAt } = onCheck(stored.definition, stored, recent[0] ?? null, state, now);
1991
+ return { job: summarize(stored, recent, state, nextExpectedAt, now), runs: recent.slice(0, runs) };
1992
+ } catch (e) {
1993
+ this.onError(e, `reading ${stored.name}`);
1994
+ return { job: await this.unevaluable(stored, now), runs: recent.slice(0, runs) };
1995
+ }
1996
+ }
1997
+ /** The summary of a job whose evaluation failed, from whatever can still be read. */
1998
+ async unevaluable(stored, now) {
1999
+ const recent = await this.store.listRuns(stored.name, BASELINE_WINDOW).catch(() => []);
2000
+ const state = await this.readState(stored.name).catch(() => emptyState(stored.name));
2001
+ return unevaluableSummary(stored, recent, state, now);
1472
2002
  }
1473
2003
  /** Every job the store knows about, with its health. Does not send alerts. */
1474
2004
  async jobs() {
@@ -1515,12 +2045,12 @@ var CronWatch = class {
1515
2045
  /** Read, change and write one job's state, in turn with every other update to it. */
1516
2046
  async patchState(name, change) {
1517
2047
  await this.ensureReady();
1518
- return this.serial(name, async () => {
1519
- const state = await this.readState(name);
1520
- change(state);
1521
- await this.store.setState(state);
1522
- return state;
2048
+ const { state } = await this.updateState(name, (current) => {
2049
+ const next = normalizeState(current, name);
2050
+ change(next);
2051
+ return { state: next, result: void 0 };
1523
2052
  });
2053
+ return state;
1524
2054
  }
1525
2055
  /** Remove a job and its runs from the store. A job still declared in code comes back on its next run. */
1526
2056
  async forget(name) {
@@ -1533,10 +2063,18 @@ var CronWatch = class {
1533
2063
  routes(options) {
1534
2064
  return createRoutes(this, options);
1535
2065
  }
1536
- /** Check on an interval, for long-running servers. Default every minute. */
2066
+ /**
2067
+ * Check on an interval, for long-running servers. Default every minute.
2068
+ * Not for serverless functions or Cloudflare Workers, where nothing runs
2069
+ * between requests: call check() from a cron there instead.
2070
+ */
1537
2071
  start(every = "1m") {
1538
2072
  if (this.timer) return;
1539
2073
  const ms = Math.max(5e3, parseDuration(every, "check interval"));
2074
+ if (this.deferDelivery && !this.warnedDeferredStart) {
2075
+ this.warnedDeferredStart = true;
2076
+ console.warn('[cronwatch] start() was called with deliver: "check", so these checks send no alerts. Another process must run checks with deliver: "now" (the default) to send them.');
2077
+ }
1540
2078
  const tick = () => this.check().catch((e) => this.onError(e, "check"));
1541
2079
  this.timer = setInterval(tick, ms);
1542
2080
  if (typeof this.timer === "object" && "unref" in this.timer) this.timer.unref();
@@ -1556,20 +2094,10 @@ var CronWatch = class {
1556
2094
  this.stop();
1557
2095
  if (this.store.close) await this.store.close();
1558
2096
  }
1559
- /** Save an evaluation's state, honouring silence, and return what should be sent. Call inside serial(). */
1560
- async settle(previous, evaluation, now) {
1561
- let { state, alerts } = evaluation;
1562
- if (isSilenced(previous, now)) {
1563
- state = muteOpens(previous, state);
1564
- alerts = [];
1565
- }
1566
- if (!sameState(state, previous)) await this.store.setState(state);
1567
- return { state, alerts };
1568
- }
1569
2097
  /**
1570
2098
  * Compose, triage and send each draft. The state was saved before this
1571
- * (settle), so a slow channel holds up nothing else; afterwards only the
1572
- * delivery fields are written back, onto a fresh read of the state.
2099
+ * (updateState), so a slow channel holds up nothing else; afterwards only
2100
+ * the delivery fields are written back, onto a fresh read of the state.
1573
2101
  */
1574
2102
  async dispatch(drafts, definition, now) {
1575
2103
  if (drafts.length === 0) return [];
@@ -1581,41 +2109,63 @@ var CronWatch = class {
1581
2109
  if (this.deferDelivery) {
1582
2110
  failed.push(alert);
1583
2111
  } else {
1584
- if (this.triage && alert.type !== "recovered") await this.addTriage(alert);
2112
+ if (this.triage && alert.type !== "recovered") await this.addTriage(alert, TRIAGE_TIMEOUT_MS);
1585
2113
  (await this.deliver(alert) ? delivered : failed).push(alert);
1586
2114
  }
1587
2115
  composed.push(alert);
1588
2116
  }
1589
- await this.recordDelivery(definition.name, delivered, failed, now);
2117
+ await this.recordDelivery(definition.name, delivered, failed, [], now);
1590
2118
  return composed;
1591
2119
  }
1592
- /** Send the alerts that no channel accepted last time, once each. */
1593
- async retryUndelivered(name, state, now) {
2120
+ /**
2121
+ * Send the alerts that no channel accepted last time, once each, oldest
2122
+ * first. `state` is the job's state as this check left it: an alert that
2123
+ * no longer describes it (staleAlert) is dropped instead. Retries across a
2124
+ * check share RETRY_BUDGET_MS of wall-clock time; once it is spent the rest
2125
+ * stay queued for the next check.
2126
+ */
2127
+ async retryUndelivered(name, state, now, budget) {
1594
2128
  const pending = state.undelivered ?? [];
1595
2129
  if (pending.length === 0 || isSilenced(state, now) || this.deferDelivery) return [];
1596
2130
  const delivered = [];
1597
2131
  const failed = [];
2132
+ const dropped = pending.filter((alert) => staleAlert(alert, state));
1598
2133
  for (const alert of pending) {
1599
- if (this.triage && alert.type !== "recovered" && alert.triage === void 0) await this.addTriage(alert);
2134
+ if (dropped.includes(alert)) continue;
2135
+ const left = RETRY_BUDGET_MS - budget.spentMs;
2136
+ if (left <= 0) break;
2137
+ const started = Date.now();
2138
+ if (this.triage && alert.type !== "recovered" && alert.triage === void 0) await this.addTriage(alert, Math.min(TRIAGE_TIMEOUT_MS, left));
1600
2139
  (await this.deliver(alert) ? delivered : failed).push(alert);
2140
+ budget.spentMs += Math.max(0, Date.now() - started);
1601
2141
  }
1602
- await this.recordDelivery(name, delivered, failed, now);
2142
+ await this.recordDelivery(name, delivered, failed, dropped, now);
1603
2143
  return delivered;
1604
2144
  }
1605
- /** Mark delivered alerts done and keep failed ones for the next check. lastAlertAt moves only on a delivery. */
1606
- async recordDelivery(name, delivered, failed, now) {
2145
+ /**
2146
+ * Mark delivered alerts done, drop stale ones, and keep failed ones for the
2147
+ * next check. A failed alert replaces its stored copy, so a triage made on
2148
+ * this attempt is kept. lastAlertAt moves only on a delivery.
2149
+ */
2150
+ async recordDelivery(name, delivered, failed, dropped, now) {
1607
2151
  try {
1608
- await this.serial(name, async () => {
1609
- const previous = await this.readState(name);
2152
+ const { result: trimmed } = await this.updateState(name, (previous) => {
1610
2153
  const state = normalizeState(previous, name);
1611
- const done = new Set(delivered.map(alertKey));
1612
- const kept = state.undelivered.filter((a) => !done.has(alertKey(a)));
2154
+ const done = new Set([...delivered, ...dropped].map(alertKey));
2155
+ const retried = new Map(failed.map((a) => [alertKey(a), a]));
2156
+ const kept = state.undelivered.filter((a) => !done.has(alertKey(a))).map((a) => retried.get(alertKey(a)) ?? a);
1613
2157
  const known = new Set(kept.map(alertKey));
1614
2158
  kept.push(...failed.filter((a) => !known.has(alertKey(a))));
1615
2159
  state.undelivered = kept.slice(-MAX_UNDELIVERED);
1616
2160
  if (delivered.length > 0) state.lastAlertAt = now;
1617
- if (!sameState(state, previous)) await this.store.setState(state);
2161
+ return { state, result: Math.max(0, kept.length - MAX_UNDELIVERED) };
1618
2162
  });
2163
+ if (trimmed > 0) {
2164
+ this.onError(
2165
+ new Error(`${trimmed} undelivered alert${trimmed === 1 ? "" : "s"} for ${name} dropped: only the newest ${MAX_UNDELIVERED} are kept for retry`),
2166
+ `alert queue for ${name}`
2167
+ );
2168
+ }
1619
2169
  } catch (e) {
1620
2170
  this.onError(e, `recording alert delivery for ${name}`);
1621
2171
  }
@@ -1626,7 +2176,7 @@ var CronWatch = class {
1626
2176
  const results = await Promise.all(
1627
2177
  this.alerts.map(async (channel) => {
1628
2178
  try {
1629
- await withTimeout(channel.send(alert), CHANNEL_TIMEOUT_MS);
2179
+ await withTimeout(channel.send(alert, { onError: (e) => this.report(e, `alert channel ${channel.name}`) }), CHANNEL_TIMEOUT_MS);
1630
2180
  return true;
1631
2181
  } catch (e) {
1632
2182
  this.onError(e, `alert channel ${channel.name}`);
@@ -1636,20 +2186,38 @@ var CronWatch = class {
1636
2186
  );
1637
2187
  return results.includes(true);
1638
2188
  }
1639
- async addTriage(alert) {
2189
+ /** Sets alert.triage to the diagnosis, or to null when there is none, so it is tried once per alert. */
2190
+ async addTriage(alert, timeout) {
1640
2191
  const controller = new AbortController();
1641
2192
  try {
1642
2193
  const recentRuns = await this.store.listRuns(alert.job, 5);
1643
- const diagnosis = await withTimeout(this.triage({ alert, recentRuns, signal: controller.signal }), TRIAGE_TIMEOUT_MS);
1644
- if (diagnosis) alert.triage = diagnosis;
2194
+ const diagnosis = await withTimeout(this.triage({ alert, recentRuns, signal: controller.signal }), timeout);
2195
+ alert.triage = typeof diagnosis === "string" && diagnosis !== "" ? diagnosis : null;
1645
2196
  } catch (e) {
1646
2197
  controller.abort();
2198
+ alert.triage = null;
1647
2199
  this.onError(e, `triage for ${alert.job}`);
1648
2200
  }
1649
2201
  }
1650
2202
  };
1651
- function isDevelopment() {
1652
- return process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test";
2203
+ var RESERVED_RUN_ID_PREFIX = "pgcron:";
2204
+ function checkRunId(job, id, method) {
2205
+ if (typeof id !== "string" || id.length === 0 || id.length > 200) {
2206
+ throw new Error(`job "${job}": ${method}() needs a run id of 1 to 200 characters (got ${typeof id === "string" ? `${id.length} characters` : typeof id})`);
2207
+ }
2208
+ if (id.startsWith(RESERVED_RUN_ID_PREFIX)) {
2209
+ throw new Error(`job "${job}": ${method}() cannot take a run id starting with "${RESERVED_RUN_ID_PREFIX}", which the pg_cron source uses for its runs`);
2210
+ }
2211
+ }
2212
+ function joinLines(before, after) {
2213
+ if (before === null || before === "") return after;
2214
+ if (after === null) return before;
2215
+ return `${before}
2216
+ ${after}`;
2217
+ }
2218
+ function joinOutput(before, after) {
2219
+ const joined = joinLines(before, after);
2220
+ return joined === null ? null : capOutput(joined);
1653
2221
  }
1654
2222
  function clampLimit(limit, fallback, min) {
1655
2223
  const n = typeof limit === "number" && Number.isFinite(limit) ? Math.trunc(limit) : fallback;