@cronwatch/sdk 0.9.0 → 0.11.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 (86) hide show
  1. package/README.md +4 -4
  2. package/dist/anthropic.d.cts +1 -1
  3. package/dist/anthropic.d.ts +1 -1
  4. package/dist/bugsnag.d.cts +1 -1
  5. package/dist/bugsnag.d.ts +1 -1
  6. package/dist/{client-D35cfnNV.d.cts → client-CRMfhylq.d.cts} +129 -37
  7. package/dist/{client-CQm-8-mR.d.ts → client-D2Xk2600.d.ts} +129 -37
  8. package/dist/d1.cjs +29 -11
  9. package/dist/d1.cjs.map +1 -1
  10. package/dist/d1.d.cts +1 -1
  11. package/dist/d1.d.ts +1 -1
  12. package/dist/d1.js +29 -11
  13. package/dist/d1.js.map +1 -1
  14. package/dist/datadog.d.cts +1 -1
  15. package/dist/datadog.d.ts +1 -1
  16. package/dist/discord.cjs +25 -4
  17. package/dist/discord.cjs.map +1 -1
  18. package/dist/discord.d.cts +20 -4
  19. package/dist/discord.d.ts +20 -4
  20. package/dist/discord.js +22 -3
  21. package/dist/discord.js.map +1 -1
  22. package/dist/{email-BxCp9RQV.d.ts → email-BWuUiCkw.d.ts} +1 -1
  23. package/dist/{email-D1d5niMy.d.cts → email-Dcqe3k8n.d.cts} +1 -1
  24. package/dist/honeybadger.d.cts +1 -1
  25. package/dist/honeybadger.d.ts +1 -1
  26. package/dist/index.cjs +380 -123
  27. package/dist/index.cjs.map +1 -1
  28. package/dist/index.d.cts +15 -9
  29. package/dist/index.d.ts +15 -9
  30. package/dist/index.js +379 -124
  31. package/dist/index.js.map +1 -1
  32. package/dist/mailgun.d.cts +2 -2
  33. package/dist/mailgun.d.ts +2 -2
  34. package/dist/newrelic.d.cts +1 -1
  35. package/dist/newrelic.d.ts +1 -1
  36. package/dist/pg-cron.cjs +19 -8
  37. package/dist/pg-cron.cjs.map +1 -1
  38. package/dist/pg-cron.d.cts +15 -12
  39. package/dist/pg-cron.d.ts +15 -12
  40. package/dist/pg-cron.js +16 -5
  41. package/dist/pg-cron.js.map +1 -1
  42. package/dist/postgres.cjs +28 -10
  43. package/dist/postgres.cjs.map +1 -1
  44. package/dist/postgres.d.cts +1 -1
  45. package/dist/postgres.d.ts +1 -1
  46. package/dist/postgres.js +28 -10
  47. package/dist/postgres.js.map +1 -1
  48. package/dist/postmark.d.cts +2 -2
  49. package/dist/postmark.d.ts +2 -2
  50. package/dist/resend.d.cts +2 -2
  51. package/dist/resend.d.ts +2 -2
  52. package/dist/rollbar.d.cts +1 -1
  53. package/dist/rollbar.d.ts +1 -1
  54. package/dist/sendgrid.d.cts +2 -2
  55. package/dist/sendgrid.d.ts +2 -2
  56. package/dist/sentry.cjs +4 -1
  57. package/dist/sentry.cjs.map +1 -1
  58. package/dist/sentry.d.cts +5 -2
  59. package/dist/sentry.d.ts +5 -2
  60. package/dist/sentry.js +4 -1
  61. package/dist/sentry.js.map +1 -1
  62. package/dist/ses.d.cts +2 -2
  63. package/dist/ses.d.ts +2 -2
  64. package/dist/slack.d.cts +1 -1
  65. package/dist/slack.d.ts +1 -1
  66. package/dist/sqlite.cjs +29 -11
  67. package/dist/sqlite.cjs.map +1 -1
  68. package/dist/sqlite.d.cts +1 -1
  69. package/dist/sqlite.d.ts +1 -1
  70. package/dist/sqlite.js +29 -11
  71. package/dist/sqlite.js.map +1 -1
  72. package/dist/twilio.cjs +10 -4
  73. package/dist/twilio.cjs.map +1 -1
  74. package/dist/twilio.d.cts +12 -7
  75. package/dist/twilio.d.ts +12 -7
  76. package/dist/twilio.js +7 -1
  77. package/dist/twilio.js.map +1 -1
  78. package/dist/{types-Cz47Boks.d.cts → types-DLnlUie8.d.cts} +18 -2
  79. package/dist/{types-Cz47Boks.d.ts → types-DLnlUie8.d.ts} +18 -2
  80. package/dist/webhook.cjs +10 -5
  81. package/dist/webhook.cjs.map +1 -1
  82. package/dist/webhook.d.cts +18 -7
  83. package/dist/webhook.d.ts +18 -7
  84. package/dist/webhook.js +10 -6
  85. package/dist/webhook.js.map +1 -1
  86. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -78,13 +78,46 @@ function beyondDates(at) {
78
78
  function readEnv(name) {
79
79
  return typeof process === "undefined" || !process.env ? void 0 : process.env[name];
80
80
  }
81
+ function readSecretEnv(name) {
82
+ const value = readEnv(name);
83
+ return value === void 0 || value.trim() === "" ? void 0 : value;
84
+ }
85
+ function secretOption(value, what) {
86
+ if (value === void 0 || value === null) return value;
87
+ if (typeof value !== "string") {
88
+ throw new TypeError(`${what} must be a string, or null to opt out, not ${Array.isArray(value) ? "an array" : typeof value}`);
89
+ }
90
+ return value.trim() === "" ? void 0 : value;
91
+ }
92
+ function environment() {
93
+ if (typeof process === "undefined" || !process.env) return "";
94
+ for (const value of [process.env.CRONWATCH_ENV, process.env.APP_ENV, process.env.NODE_ENV]) {
95
+ const name = typeof value === "string" ? value.trim().toLowerCase() : "";
96
+ if (name === "") continue;
97
+ if (name === "prod") return "production";
98
+ if (name === "dev" || name === "local" || name === "test" || name === "testing") return "development";
99
+ return name;
100
+ }
101
+ return "";
102
+ }
81
103
  function isDevelopment() {
82
- if (typeof process === "undefined" || !process.env) return false;
83
- const mode = process.env.NODE_ENV;
84
- return mode === "development" || mode === "test";
104
+ return environment() === "development";
105
+ }
106
+ function isProduction() {
107
+ return environment() === "production";
85
108
  }
86
109
  var cache = /* @__PURE__ */ new Map();
87
110
  var crons = /* @__PURE__ */ new WeakMap();
111
+ var never = /* @__PURE__ */ new WeakSet();
112
+ var PROBE_FROM_MS = Date.UTC(2e3, 0, 1);
113
+ function neverFires(cron) {
114
+ try {
115
+ return cron.nextRuns(1, new Date(PROBE_FROM_MS)).length === 0;
116
+ } catch (error) {
117
+ return error instanceof RangeError && /call stack/i.test(error.message);
118
+ }
119
+ }
120
+ var ONE_TIME_DATE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/;
88
121
  var EARLY_SLACK_MS = 6e4;
89
122
  function parseSchedule(schedule, timezone) {
90
123
  const key = `${timezone ?? ""}|${schedule}`;
@@ -98,6 +131,10 @@ function parseSchedule(schedule, timezone) {
98
131
  if (everyMs < 1e3) throw new Error(`schedule "${schedule}" is shorter than one second`);
99
132
  parsed = { kind: "interval", source: text, everyMs };
100
133
  } else {
134
+ if (text.slice(1).includes(":")) {
135
+ const reason = ONE_TIME_DATE.test(text) ? "CronPattern: a one-time date is not supported" : "Invalid ISO8601 passed to timezone parser.";
136
+ throw new Error(`schedule "${schedule}" is not a cron expression or "every <duration>": ${reason}`);
137
+ }
101
138
  let cron;
102
139
  try {
103
140
  cron = new Cron(text, timezone ? { timezone } : {});
@@ -106,6 +143,7 @@ function parseSchedule(schedule, timezone) {
106
143
  }
107
144
  parsed = { kind: "cron", source: text, ...timezone ? { timezone } : {} };
108
145
  crons.set(parsed, cron);
146
+ if (neverFires(cron)) never.add(parsed);
109
147
  }
110
148
  cache.set(key, parsed);
111
149
  return parsed;
@@ -132,6 +170,7 @@ function countFrom(from) {
132
170
  function fireAfter(parsed, from) {
133
171
  const cron = crons.get(parsed);
134
172
  if (!cron) throw new Error(`schedule "${parsed.source}" was not made by parseSchedule`);
173
+ if (never.has(parsed)) return null;
135
174
  const start = countFrom(from);
136
175
  if (start === null) return null;
137
176
  let probe = start;
@@ -147,6 +186,7 @@ function fireAfter(parsed, from) {
147
186
  function firesBetween(parsed, from, to, limit) {
148
187
  const cron = crons.get(parsed);
149
188
  if (!cron) throw new Error(`schedule "${parsed.source}" was not made by parseSchedule`);
189
+ if (never.has(parsed)) return [];
150
190
  const out = [];
151
191
  const start = countFrom(from);
152
192
  if (start === null) return out;
@@ -266,6 +306,9 @@ function runDuration(startedAt, finishedAt) {
266
306
  const ms = finishedAt - startedAt;
267
307
  return ms > 0 ? Math.min(ms, MAX_DURATION_MS) : 0;
268
308
  }
309
+ function silenceEnd(now, ms) {
310
+ return Math.min(now + Math.floor(Math.min(ms, MAX_DURATION_MS)), MAX_DURATION_MS);
311
+ }
269
312
  function stateVersion(state) {
270
313
  const version = state?.version;
271
314
  return typeof version === "number" && Number.isSafeInteger(version) && version >= 0 ? version + 0 : 0;
@@ -278,17 +321,74 @@ function failureCount(state) {
278
321
  function emptyState(job) {
279
322
  return { job, open: {}, consecutiveFailures: 0, silencedUntil: null, lastAlertAt: null, pendingRecovery: [], undelivered: [] };
280
323
  }
324
+ function isObject(value) {
325
+ return typeof value === "object" && value !== null && !Array.isArray(value);
326
+ }
281
327
  function normalizeState(state, job) {
282
- if (!state) return emptyState(job);
328
+ if (!isObject(state)) return emptyState(job);
329
+ const { sending, ...rest } = state;
330
+ const open = isObject(state.open) ? Object.fromEntries(Object.entries(state.open).filter(([, at]) => typeof at === "number")) : {};
331
+ const number = (value) => typeof value === "number" ? value : null;
332
+ const list = (value) => Array.isArray(value) ? value : [];
283
333
  return {
284
334
  ...emptyState(job),
285
- ...state,
286
- open: { ...state.open },
335
+ ...rest,
336
+ open,
287
337
  consecutiveFailures: failureCount(state),
288
- pendingRecovery: [...state.pendingRecovery ?? []],
289
- undelivered: [...state.undelivered ?? []]
338
+ silencedUntil: number(state.silencedUntil),
339
+ lastAlertAt: number(state.lastAlertAt),
340
+ pendingRecovery: list(state.pendingRecovery).filter((c) => typeof c === "string"),
341
+ undelivered: list(state.undelivered).filter((alert) => isObject(alert)),
342
+ ...Array.isArray(sending) && sending.length > 0 ? { sending: [...sending] } : {}
290
343
  };
291
344
  }
345
+ var MAX_UNDELIVERED = 20;
346
+ var SEND_LEASE_MS = 5 * 6e4;
347
+ function alertKey(alert) {
348
+ return `${alert.type}|${alert.at}|${alert.run?.id ?? ""}`;
349
+ }
350
+ function newest(list, max) {
351
+ return { kept: list.slice(-max), dropped: Math.max(0, list.length - max) };
352
+ }
353
+ function queueUndelivered(state, alerts) {
354
+ const next = cloneState(state);
355
+ const byKey = new Map(alerts.map((alert) => [alertKey(alert), alert]));
356
+ const queue = next.undelivered.map((alert) => byKey.get(alertKey(alert)) ?? alert);
357
+ const known = new Set(queue.map(alertKey));
358
+ queue.push(...alerts.filter((alert) => !known.has(alertKey(alert))));
359
+ const { kept, dropped } = newest(queue, MAX_UNDELIVERED);
360
+ next.undelivered = kept;
361
+ return { state: next, dropped };
362
+ }
363
+ function holdAlerts(state, alerts, until, deferred) {
364
+ if (alerts.length === 0) return { state, dropped: 0 };
365
+ if (deferred) return queueUndelivered(state, alerts);
366
+ const next = cloneState(state);
367
+ const { kept, dropped } = newest([...next.sending ?? [], ...alerts.map((alert) => ({ until, alert }))], MAX_UNDELIVERED);
368
+ next.sending = kept;
369
+ return { state: next, dropped };
370
+ }
371
+ function releaseSending(state, now) {
372
+ const sending = state.sending ?? [];
373
+ const lapsed = sending.filter((entry) => !(typeof entry?.until === "number" && entry.until > now));
374
+ if (lapsed.length === 0) return { state, dropped: 0 };
375
+ const held = sending.filter((entry) => !lapsed.includes(entry));
376
+ return queueUndelivered(
377
+ { ...state, sending: held },
378
+ lapsed.filter((entry) => typeof entry?.alert === "object" && entry.alert !== null).map((entry) => entry.alert)
379
+ );
380
+ }
381
+ function recordSent(state, delivered, failed, stale, now) {
382
+ const next = cloneState(state);
383
+ const done = new Set([...delivered, ...stale].map(alertKey));
384
+ next.undelivered = next.undelivered.filter((alert) => !done.has(alertKey(alert)));
385
+ const sent = new Set([...delivered, ...failed, ...stale].map(alertKey));
386
+ const held = (next.sending ?? []).filter((entry) => !(typeof entry?.alert === "object" && entry.alert !== null && sent.has(alertKey(entry.alert))));
387
+ if (held.length > 0) next.sending = held;
388
+ else delete next.sending;
389
+ if (delivered.length > 0) next.lastAlertAt = now;
390
+ return queueUndelivered(next, failed);
391
+ }
292
392
  function cloneState(state) {
293
393
  return normalizeState(state, state.job);
294
394
  }
@@ -442,8 +542,12 @@ function applySilence(previous, evaluation, now) {
442
542
  return { state: muteOpens(previous, evaluation.state), alerts: [] };
443
543
  }
444
544
  function staleAlert(alert, state) {
445
- if (alert.type === "recovered") return alert.details.after.some((condition) => state.open[condition] !== void 0);
446
- return state.open[alert.type] !== alert.at;
545
+ if (alert.type === "recovered") {
546
+ const after2 = isObject(alert.details) ? alert.details.after : void 0;
547
+ if (!Array.isArray(after2)) return true;
548
+ return after2.some((condition) => typeof condition !== "string" || state.open[condition] !== void 0);
549
+ }
550
+ return typeof alert.at !== "number" || state.open[alert.type] !== alert.at;
447
551
  }
448
552
  function jobHealth(def, lastRun, state, now) {
449
553
  const open = openConditions(state);
@@ -592,7 +696,16 @@ var OUTPUT_CAP = 16 * 1024;
592
696
  function capOutput(text) {
593
697
  const clean = stripNul(text);
594
698
  if (clean.length <= OUTPUT_CAP) return clean;
595
- return "[earlier output trimmed]\n" + clean.slice(clean.length - OUTPUT_CAP);
699
+ return TRIMMED + clean.slice(clean.length - OUTPUT_CAP);
700
+ }
701
+ var TRIMMED = "[earlier output trimmed]\n";
702
+ var REDACT_EDGE = 3 * (OUTPUT_CAP + 1024);
703
+ function redactAndCap(text, redact) {
704
+ const clean = stripNul(text);
705
+ const from = clean.length - (OUTPUT_CAP + REDACT_EDGE);
706
+ if (from <= 0) return capOutput(redact(clean));
707
+ const redacted = stripNul(redact(clean.slice(from)));
708
+ return TRIMMED + redacted.slice(Math.max(redacted.length - OUTPUT_CAP, REDACT_EDGE));
596
709
  }
597
710
  function stripNul(text) {
598
711
  return text.includes("\0") ? text.replace(/\u0000/g, "") : text;
@@ -600,9 +713,6 @@ function stripNul(text) {
600
713
  function stripJsonNul(json2) {
601
714
  return json2.includes("\\u0000") ? json2.replace(/\\(u0000|[\s\S])/g, (escape, next) => next === "u0000" ? "" : escape) : json2;
602
715
  }
603
- function errorMessage(error) {
604
- return capOutput(describeError(error));
605
- }
606
716
  function describeError(error) {
607
717
  if (error instanceof Error) {
608
718
  const frames = (error.stack ?? "").split("\n").filter((line) => /^\s+at /.test(line)).slice(0, 5);
@@ -711,7 +821,7 @@ function createRecorder(run) {
711
821
  };
712
822
  return {
713
823
  context,
714
- output: () => lines.length === 0 ? null : capOutput(lines.join("\n")),
824
+ output: () => lines.length === 0 ? null : lines.join("\n"),
715
825
  expectText: () => {
716
826
  if (lines.length === 0) return null;
717
827
  const all = lines.join("\n");
@@ -750,6 +860,15 @@ function checkExpectation(expect, output) {
750
860
  }
751
861
  return ok ? null : "Output did not pass the expect() check";
752
862
  }
863
+ function readStoredJob(stored) {
864
+ const definition = stored.definition;
865
+ if (typeof definition !== "object" || definition === null || Array.isArray(definition)) {
866
+ return { job: { ...stored, definition: { name: stored.name } }, readable: false };
867
+ }
868
+ const read = { ...definition };
869
+ if ("tags" in read && !(Array.isArray(read.tags) && read.tags.every((tag) => typeof tag === "string"))) delete read.tags;
870
+ return { job: { ...stored, definition: read }, readable: true };
871
+ }
753
872
 
754
873
  // src/routes/escape.ts
755
874
  function escapeHtml(value) {
@@ -1452,7 +1571,7 @@ function jobPage(job, runs, now, base, complete = true) {
1452
1571
  run.error ? `<details class="out error" open><summary>error</summary><pre>${h(run.error)}</pre></details>` : "",
1453
1572
  run.output ? `<details class="out"${run.status === "ok" ? "" : " open"}><summary>output</summary><pre>${h(run.output)}</pre></details>` : ""
1454
1573
  ].join("");
1455
- const metrics = Object.entries(run.metrics).map(([k, v]) => `<span><span class="k">${h(k)}</span> ${h(Number.isInteger(v) ? v : v.toFixed(4))}</span>`).join("");
1574
+ const metrics = Object.entries(run.metrics ?? {}).filter(([, v]) => typeof v === "number" && Number.isFinite(v)).map(([k, v]) => `<span><span class="k">${h(k)}</span> ${h(Number.isInteger(v) ? v : v.toFixed(4))}</span>`).join("");
1456
1575
  return `<tr${detail ? ` class="has-detail"` : ""}>
1457
1576
  <td class="nowrap">${runState(run)}</td>
1458
1577
  <td class="nowrap">${h(when2(run.startedAt, now))} <span class="muted">UTC</span><span class="sub">${stamp(run.startedAt, now)}</span></td>
@@ -1521,12 +1640,18 @@ function jobPage(job, runs, now, base, complete = true) {
1521
1640
  return layout(`${job.name}: CronWatch`, body, base, { refresh: 60 });
1522
1641
  }
1523
1642
  function messagePage(title, message, base, signIn = false) {
1524
- const form = signIn ? `<form class="signin" method="get" action="${h(base)}/"><label for="token">Token</label><input id="token" name="token" type="password" autocomplete="current-password" autocapitalize="off" spellcheck="false" required><button class="primary" type="submit">Sign in</button></form>` : "";
1643
+ const form = signIn ? `<form class="signin" method="post" action="${h(base)}/signin"><label for="token">Token</label><input id="token" name="token" type="password" autocomplete="current-password" autocapitalize="off" spellcheck="false" required><button class="primary" type="submit">Sign in</button></form>` : "";
1525
1644
  return layout(title, `<header class="top">${brand(base)}</header><main class="message"><h1>${h(title)}</h1><p>${h(message)}</p>${form}</main>`, base);
1526
1645
  }
1527
1646
 
1647
+ // src/version.ts
1648
+ var VERSION = "0.11.0";
1649
+ var API_VERSION = 1;
1650
+
1528
1651
  // src/routes/index.ts
1529
1652
  var COOKIE = "cronwatch_token";
1653
+ var LIBRARY = "@cronwatch/sdk";
1654
+ var LANGUAGE = "typescript";
1530
1655
  async function cookieValue(token) {
1531
1656
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`cronwatch-cookie:${token}`));
1532
1657
  return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
@@ -1610,6 +1735,20 @@ function forwardedOrigin(request, url) {
1610
1735
  return url.origin;
1611
1736
  }
1612
1737
  }
1738
+ function bearerToken(header) {
1739
+ const match = header === null ? null : /^Bearer\s+([\s\S]*)$/i.exec(header);
1740
+ return match ? match[1] : null;
1741
+ }
1742
+ function signInReturn(referer, publicOrigin, base) {
1743
+ if (referer === null || !referer.startsWith(publicOrigin + "/")) return `${base}/`;
1744
+ let url;
1745
+ try {
1746
+ url = new URL(referer);
1747
+ } catch {
1748
+ return `${base}/`;
1749
+ }
1750
+ return url.searchParams.has("token") ? `${base}/` : referer;
1751
+ }
1613
1752
  function stripBase(pathname, base) {
1614
1753
  let path = pathname.startsWith(base) ? pathname.slice(base.length) : pathname;
1615
1754
  if (path === "") path = "/";
@@ -1652,8 +1791,12 @@ function runsLimit(value) {
1652
1791
  return Number.isFinite(n) ? Math.min(MAX_RUNS, Math.max(1, n)) : DEFAULT_RUNS;
1653
1792
  }
1654
1793
  function createRoutes(cw, options = {}) {
1794
+ return cw.routes(options);
1795
+ }
1796
+ function buildRoutes(cw, options = {}) {
1655
1797
  const optedOut = options.token === null;
1656
- const configured = optedOut ? null : options.token || readEnv("CRONWATCH_TOKEN") || null;
1798
+ const given = secretOption(options.token, "routes: token");
1799
+ const configured = optedOut ? null : given || readSecretEnv("CRONWATCH_TOKEN") || null;
1657
1800
  const base = (options.basePath ?? "/cronwatch").replace(/\/+$/, "");
1658
1801
  const developing = isDevelopment();
1659
1802
  const fixedOrigin = configuredOrigin(options.origin);
@@ -1685,7 +1828,17 @@ function createRoutes(cw, options = {}) {
1685
1828
  if (method !== "GET" && method !== "HEAD" && crossSite(request, publicOrigin)) {
1686
1829
  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);
1687
1830
  }
1688
- const bearer = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") ?? null;
1831
+ const bearer = bearerToken(request.headers.get("authorization"));
1832
+ const signInCookie = async () => {
1833
+ const secure = publicOrigin.startsWith("https:") ? "; Secure" : "";
1834
+ return `${COOKIE}=${await expectedCookie(token)}; Path=${base || "/"}; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 30}${secure}`;
1835
+ };
1836
+ const signInPage = () => generated ? html(messagePage("Sign in", "CRONWATCH_TOKEN is not set, so this development server made a token. The sign-in link is in the server log: open it once, or enter the token from it below, and this browser stays signed in.", base, true), 401) : html(messagePage("Sign in", "Enter your CRONWATCH_TOKEN and this browser stays signed in.", base, true), 401);
1837
+ if (token && method === "POST" && path === "/signin") {
1838
+ const sent = (await readBody(request)).token;
1839
+ if (sent === void 0 || !constantTimeEqual(sent, token)) return signInPage();
1840
+ return redirect(signInReturn(request.headers.get("referer"), publicOrigin, base), { "set-cookie": await signInCookie() });
1841
+ }
1689
1842
  if (token) {
1690
1843
  const query = wantsHtml && method === "GET" ? url.searchParams.get("token") : null;
1691
1844
  const sent = readCookie(request, COOKIE);
@@ -1693,17 +1846,12 @@ function createRoutes(cw, options = {}) {
1693
1846
  const cronSecretOk = isCheck && bearer !== null && cw.cronSecret !== null && constantTimeEqual(bearer, cw.cronSecret);
1694
1847
  const tokenOk = bearer !== null ? constantTimeEqual(bearer, token) : query !== null ? constantTimeEqual(query, token) : sent !== null && constantTimeEqual(sent, await expectedCookie(token));
1695
1848
  if (!cronSecretOk && !tokenOk) {
1696
- if (generated) {
1697
- 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, true), 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);
1698
- }
1699
- return wantsHtml ? html(messagePage("Sign in", `Open this page with ?token=<your CRONWATCH_TOKEN> once and it will stay signed in.`, base, true), 401) : api({ ok: false, error: "Unauthorized" }, 401);
1849
+ if (wantsHtml) return signInPage();
1850
+ return generated ? api({ ok: false, error: "Unauthorized: CRONWATCH_TOKEN is not set, so this development server made a token; it is in the server log" }, 401) : api({ ok: false, error: "Unauthorized" }, 401);
1700
1851
  }
1701
1852
  if (query !== null) {
1702
1853
  url.searchParams.delete("token");
1703
- const secure = publicOrigin.startsWith("https:") ? "; Secure" : "";
1704
- return redirect(url.pathname + (url.search || ""), {
1705
- "set-cookie": `${COOKIE}=${await expectedCookie(token)}; Path=${base || "/"}; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 30}${secure}`
1706
- });
1854
+ return redirect(url.pathname + (url.search || ""), { "set-cookie": await signInCookie() });
1707
1855
  }
1708
1856
  }
1709
1857
  const redirectBack = () => {
@@ -1754,6 +1902,9 @@ function createRoutes(cw, options = {}) {
1754
1902
  }
1755
1903
  if (parts[0] === "api") {
1756
1904
  const rest = parts.slice(1);
1905
+ if (method === "GET" && rest.length === 0) {
1906
+ return api({ ok: true, library: LIBRARY, language: LANGUAGE, version: VERSION, api: API_VERSION });
1907
+ }
1757
1908
  if (method === "GET" && rest[0] === "jobs" && rest.length === 1) {
1758
1909
  return api({ ok: true, jobs: await cw.jobs() });
1759
1910
  }
@@ -1781,9 +1932,13 @@ function createRoutes(cw, options = {}) {
1781
1932
  } catch (e) {
1782
1933
  return api({ ok: false, error: e.message }, 400);
1783
1934
  }
1784
- return api({ ok: true, state: await cw.silence(name, duration) });
1935
+ await cw.silence(name, duration);
1936
+ return api({ ok: true, job: await cw.jobSummary(name) });
1937
+ }
1938
+ if (rest[2] === "unsilence") {
1939
+ await cw.unsilence(name);
1940
+ return api({ ok: true, job: await cw.jobSummary(name) });
1785
1941
  }
1786
- if (rest[2] === "unsilence") return api({ ok: true, state: await cw.unsilence(name) });
1787
1942
  }
1788
1943
  if (rest[0] === "check" && rest.length === 1) {
1789
1944
  if (method === "GET" && bearer === null) {
@@ -1931,11 +2086,11 @@ function memory() {
1931
2086
  return true;
1932
2087
  },
1933
2088
  async prune(before) {
1934
- const newest = /* @__PURE__ */ new Map();
1935
- for (const run of runs.values()) newest.set(run.job, Math.max(newest.get(run.job) ?? -Infinity, run.startedAt));
2089
+ const newest2 = /* @__PURE__ */ new Map();
2090
+ for (const run of runs.values()) newest2.set(run.job, Math.max(newest2.get(run.job) ?? -Infinity, run.startedAt));
1936
2091
  let n = 0;
1937
2092
  for (const [id, run] of runs) {
1938
- if (run.status !== "running" && run.startedAt < before && run.startedAt < newest.get(run.job)) {
2093
+ if (run.status !== "running" && run.startedAt < before && run.startedAt < newest2.get(run.job)) {
1939
2094
  runs.delete(id);
1940
2095
  order.delete(id);
1941
2096
  n++;
@@ -1951,7 +2106,6 @@ var NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/;
1951
2106
  var TRIAGE_TIMEOUT_MS = 25e3;
1952
2107
  var CHANNEL_TIMEOUT_MS = 15e3;
1953
2108
  var PRUNE_INTERVAL_MS = 60 * 6e4;
1954
- var MAX_UNDELIVERED = 20;
1955
2109
  var RETRY_BUDGET_MS = 2e4;
1956
2110
  var STATE_ATTEMPTS = 10;
1957
2111
  var HISTORY_PAGE = BASELINE_WINDOW + 5;
@@ -1992,10 +2146,7 @@ function validateDefinition(def) {
1992
2146
  function sameState(a, b) {
1993
2147
  return JSON.stringify(a) === JSON.stringify(b);
1994
2148
  }
1995
- function alertKey(alert) {
1996
- return `${alert.type}|${alert.at}|${alert.run?.id ?? ""}`;
1997
- }
1998
- var CronWatch = class {
2149
+ var Cronwatch = class {
1999
2150
  store;
2000
2151
  alerts;
2001
2152
  triage;
@@ -2008,7 +2159,7 @@ var CronWatch = class {
2008
2159
  /** cronSecret was passed as null: handlers may run without a secret. */
2009
2160
  secretOptOut;
2010
2161
  redact;
2011
- /** "check": queue alerts for another process's check instead of sending them. See CronWatchOptions.deliver. */
2162
+ /** "check": queue alerts for another process's check instead of sending them. See CronwatchOptions.deliver. */
2012
2163
  deferDelivery;
2013
2164
  defaults;
2014
2165
  definitions = /* @__PURE__ */ new Map();
@@ -2027,12 +2178,15 @@ var CronWatch = class {
2027
2178
  usingDefaultStore = false;
2028
2179
  warnedNoSecret = false;
2029
2180
  warnedDeferredStart = false;
2181
+ /** Stored jobs read with a definition that was not a JSON object (see readJob). */
2182
+ unreadable = /* @__PURE__ */ new WeakSet();
2030
2183
  constructor(options = {}) {
2031
2184
  this.store = options.store ?? (this.usingDefaultStore = true, memory());
2032
2185
  this.alerts = options.alerts ?? [consoleChannel()];
2033
2186
  this.triage = options.triage;
2034
2187
  this.sources = options.sources ?? [];
2035
- const secret = options.cronSecret === void 0 ? readEnv("CRON_SECRET") : options.cronSecret;
2188
+ const given = secretOption(options.cronSecret, "cronSecret");
2189
+ const secret = options.cronSecret === void 0 ? readSecretEnv("CRON_SECRET") : given;
2036
2190
  this.cronSecret = secret ? secret : null;
2037
2191
  this.secretOptOut = options.cronSecret === null;
2038
2192
  this.retentionMs = parseDuration(options.retention ?? "30d", "retention");
@@ -2093,7 +2247,7 @@ var CronWatch = class {
2093
2247
  return outcome.result;
2094
2248
  },
2095
2249
  handler(fn, options) {
2096
- const own = options?.secret;
2250
+ const own = secretOption(options?.secret, "handler: secret");
2097
2251
  const secret = own === null ? null : own ? own : self.cronSecret;
2098
2252
  const optedOut = own === null || !own && self.secretOptOut;
2099
2253
  return async (request) => {
@@ -2145,7 +2299,7 @@ var CronWatch = class {
2145
2299
  if (!this.ready) {
2146
2300
  this.ready = (async () => {
2147
2301
  if (this.store.init) await this.store.init();
2148
- if (this.usingDefaultStore && readEnv("NODE_ENV") === "production") {
2302
+ if (this.usingDefaultStore && isProduction()) {
2149
2303
  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.");
2150
2304
  }
2151
2305
  })().catch((error) => {
@@ -2161,19 +2315,58 @@ var CronWatch = class {
2161
2315
  * that replaced it, never its own over it, and one forgotten since writes
2162
2316
  * its own. The writes of one name take turns, in the order they were asked
2163
2317
  * for, so one still under way cannot land after a later one; and a name
2164
- * declared again while its write was under way is still to be written.
2318
+ * declared again while its write was under way is still to be written. A
2319
+ * name is marked as written only while that same declaration stands, so a
2320
+ * forget that lands during the write (deleting the row after it) leaves
2321
+ * the name to be written again, as does one forgotten before it.
2322
+ *
2323
+ * With `confirm`, as a run starts, a name already written is read back:
2324
+ * another process may have forgotten the job since, and a job still
2325
+ * declared here comes back on its next run.
2165
2326
  */
2166
- async sync(definition) {
2327
+ async sync(definition, confirm = false) {
2167
2328
  await this.ensureReady();
2168
2329
  const name = definition.name;
2169
- if (this.synced.has(name)) return;
2330
+ if (this.synced.has(name)) {
2331
+ if (!confirm || await this.store.getJob(name)) return;
2332
+ this.synced.delete(name);
2333
+ }
2170
2334
  await this.serial(name, async () => {
2171
2335
  if (this.synced.has(name)) return;
2172
2336
  const standing = this.definitions.get(name) ?? definition;
2173
2337
  await this.store.upsertJob(toStored(standing), this.now());
2174
- if ((this.definitions.get(name) ?? standing) === standing) this.synced.add(name);
2338
+ if (this.definitions.get(name) === standing) this.synced.add(name);
2175
2339
  }, this.syncing);
2176
2340
  }
2341
+ /**
2342
+ * Every stored job, once each declaration has been written. A job declared
2343
+ * here that the store no longer has was forgotten by another process after
2344
+ * this one wrote it: it is written again, as its next run would, so it is
2345
+ * checked and shown while any process still declares it.
2346
+ */
2347
+ async storedJobs() {
2348
+ for (const definition of this.definitions.values()) await this.sync(definition);
2349
+ const jobs = await this.store.listJobs();
2350
+ const listed = new Set(jobs.map((job) => job.name));
2351
+ const missing = [...this.definitions.values()].filter((definition) => !listed.has(definition.name));
2352
+ if (missing.length === 0) return jobs.map((job) => this.readJob(job));
2353
+ for (const definition of missing) {
2354
+ if (this.definitions.get(definition.name) !== definition) continue;
2355
+ this.synced.delete(definition.name);
2356
+ await this.sync(definition);
2357
+ }
2358
+ return (await this.store.listJobs()).map((job) => this.readJob(job));
2359
+ }
2360
+ /** A stored job read leniently (readStoredJob); one whose definition is unreadable is remembered, so that evaluating it throws. */
2361
+ readJob(stored) {
2362
+ const { job, readable } = readStoredJob(stored);
2363
+ if (!readable) this.unreadable.add(job);
2364
+ return job;
2365
+ }
2366
+ /** Throws for a job whose stored definition was not a JSON object: reported, and shown as failing, while the others carry on. */
2367
+ evaluable(stored) {
2368
+ if (this.unreadable.has(stored)) throw new Error(`job "${stored.name}": its stored definition is not a JSON object`);
2369
+ }
2177
2370
  /**
2178
2371
  * Runs `fn` after every earlier state update for the same job has settled,
2179
2372
  * so two runs (or a run and a check) in this process never read and write
@@ -2249,7 +2442,7 @@ var CronWatch = class {
2249
2442
  };
2250
2443
  let recorded = false;
2251
2444
  try {
2252
- await this.sync(definition);
2445
+ await this.sync(definition, true);
2253
2446
  await this.store.insertRun({ ...run });
2254
2447
  recorded = true;
2255
2448
  } catch (e) {
@@ -2274,7 +2467,7 @@ var CronWatch = class {
2274
2467
  run.finishedAt = finishedAt;
2275
2468
  run.durationMs = runDuration(startedAt, finishedAt);
2276
2469
  run.metrics = recorder.metrics();
2277
- run.output = recorder.output() ?? (typeof result === "string" ? capOutput(result) : null);
2470
+ run.output = recorder.output() ?? (typeof result === "string" ? result : null);
2278
2471
  this.conclude(definition, run, result, error, threw, recorder.expectText() ?? (typeof result === "string" ? result : null));
2279
2472
  await started;
2280
2473
  try {
@@ -2287,12 +2480,13 @@ var CronWatch = class {
2287
2480
  }
2288
2481
  /**
2289
2482
  * Sets a finished run's status and error from how it ended, then redacts
2290
- * its output and error. Shared by execute() and RunHandle.finish().
2483
+ * its output and error and caps them, in that order. Shared by execute()
2484
+ * and RunHandle.finish().
2291
2485
  */
2292
2486
  conclude(definition, run, result, error, threw, expectText) {
2293
2487
  if (threw) {
2294
2488
  run.status = "failed";
2295
- run.error = errorMessage(error);
2489
+ run.error = describeError(error);
2296
2490
  } else if (result instanceof Response && result.status >= 400) {
2297
2491
  run.status = "failed";
2298
2492
  run.error = `HTTP ${result.status}${result.statusText ? ` ${result.statusText}` : ""}`;
@@ -2305,8 +2499,8 @@ var CronWatch = class {
2305
2499
  run.status = "ok";
2306
2500
  }
2307
2501
  }
2308
- if (run.output !== null) run.output = stripNul(this.redact(run.output));
2309
- if (run.error !== null) run.error = stripNul(this.redact(run.error));
2502
+ if (run.output !== null) run.output = redactAndCap(run.output, this.redact);
2503
+ if (run.error !== null) run.error = redactAndCap(run.error, this.redact);
2310
2504
  }
2311
2505
  /**
2312
2506
  * Writes a finished run and evaluates it. `recorded` says whether its
@@ -2401,7 +2595,7 @@ ${id}`;
2401
2595
  };
2402
2596
  let recorded = false;
2403
2597
  try {
2404
- await this.sync(definition);
2598
+ await this.sync(definition, true);
2405
2599
  await this.store.insertRun({ ...run });
2406
2600
  recorded = true;
2407
2601
  } catch (e) {
@@ -2501,14 +2695,15 @@ ${id}`;
2501
2695
  const result = typeof outcome === "string" ? outcome : failed ? void 0 : outcome?.result;
2502
2696
  const error = failed ? outcome.error : void 0;
2503
2697
  const finishedAt = self.now();
2504
- const added = recorder.output() ?? (typeof result === "string" ? capOutput(result) : null);
2698
+ const added = recorder.output() ?? (typeof result === "string" ? result : null);
2505
2699
  const run = {
2506
2700
  ...from,
2507
2701
  status: "running",
2508
2702
  finishedAt,
2509
2703
  durationMs: runDuration(from.startedAt, finishedAt),
2510
2704
  error: null,
2511
- output: joinOutput(from.output, added),
2705
+ // Capped by conclude(), after it is redacted.
2706
+ output: joinLines(from.output, added),
2512
2707
  metrics: { ...from.metrics, ...recorder.metrics() }
2513
2708
  };
2514
2709
  const expectText = joinLines(head, joinLines(from.output, recorder.expectText() ?? (typeof result === "string" ? result : null)));
@@ -2557,7 +2752,7 @@ ${id}`;
2557
2752
  self.report(new Error(`run ${id} of ${name} belongs to job "${stored.job}"; ignored`), `flushing ${name}`);
2558
2753
  return;
2559
2754
  }
2560
- const output = lines === null ? stored.output : joinOutput(stored.output, stripNul(self.redact(lines)));
2755
+ const output = lines === null ? stored.output : joinOutput(stored.output, redactAndCap(lines, self.redact));
2561
2756
  if (!await self.writeRunIf({ ...stored, output, metrics: { ...stored.metrics, ...metrics } }, ["running"])) return putBack();
2562
2757
  const text = taken.expectText();
2563
2758
  if (text !== null && (head === null || head.length < OUTPUT_CAP)) head = (joinLines(head, text) ?? "").slice(0, OUTPUT_CAP);
@@ -2582,13 +2777,23 @@ ${id}`;
2582
2777
  * alone and reported. A finished run is judged as if it had been wrapped
2583
2778
  * here (expect, failures, duration, budgets) and its output and error are
2584
2779
  * redacted the same way; one finishing after a check marked it timeout is
2585
- * judged only when it succeeded, as RunHandle.finish() does. Returns the
2586
- * alerts it sent.
2780
+ * judged only when it succeeded, as RunHandle.finish() does. A metric that
2781
+ * is not a finite number throws before anything is written, as
2782
+ * job.metric() does. Returns the alerts it sent.
2587
2783
  */
2588
2784
  async recordRun(input, options = {}) {
2589
2785
  const declared = this.definitions.get(input.job);
2590
2786
  if (!declared) throw new Error(`recordRun: job "${input.job}" is not declared; call job() first`);
2787
+ if (typeof input.id !== "string" || input.id.length === 0 || input.id.length > MAX_RUN_ID) {
2788
+ const got = typeof input.id === "string" ? `${input.id.length} characters` : typeof input.id;
2789
+ throw new Error(`recordRun: run ids must be 1 to ${MAX_RUN_ID} characters (got ${got}; job "${input.job}")`);
2790
+ }
2591
2791
  if (input.id.includes("\0")) throw new Error(`recordRun: run ids cannot contain a NUL character (job "${input.job}")`);
2792
+ for (const [metric, value] of Object.entries(input.metrics ?? {})) {
2793
+ if (typeof value !== "number" || !Number.isFinite(value)) {
2794
+ throw new Error(`recordRun: metric "${metric}" must be a finite number (job "${input.job}", run "${input.id}")`);
2795
+ }
2796
+ }
2592
2797
  await this.sync(declared);
2593
2798
  const run = { ...input, metrics: { ...input.metrics } };
2594
2799
  if (run.status === "ok") {
@@ -2598,8 +2803,8 @@ ${id}`;
2598
2803
  run.error = unmet;
2599
2804
  }
2600
2805
  }
2601
- if (run.output !== null) run.output = stripNul(this.redact(capOutput(run.output)));
2602
- if (run.error !== null) run.error = stripNul(this.redact(capOutput(run.error)));
2806
+ if (run.output !== null) run.output = redactAndCap(run.output, this.redact);
2807
+ if (run.error !== null) run.error = redactAndCap(run.error, this.redact);
2603
2808
  const evaluate = options.evaluate !== false;
2604
2809
  const definition = toStored(declared);
2605
2810
  const stored = await this.store.getRun(run.id);
@@ -2633,22 +2838,42 @@ ${id}`;
2633
2838
  }
2634
2839
  /**
2635
2840
  * Evaluate a finished run (ok, failed, or timed out by a check), already
2636
- * written, against the job's state and send what that produces. Never throws.
2841
+ * written, against the job's state and send what that produces. The
2842
+ * alerts are written with that state (see outbox()). Never throws.
2637
2843
  */
2638
2844
  async finishRun(definition, run, now) {
2639
- let drafts;
2845
+ let held;
2640
2846
  try {
2641
2847
  let history = null;
2642
- ({ result: drafts } = await this.updateState(run.job, async (previous) => {
2848
+ ({ result: held } = await this.updateState(run.job, async (previous) => {
2643
2849
  history ??= await this.history(run);
2644
- const settled = applySilence(previous, onRunFinish(definition, run, previous, history, now), now);
2645
- return { state: settled.state, result: settled.alerts };
2850
+ return this.outbox(applySilence(previous, onRunFinish(definition, run, previous, history, now), now), definition, now);
2646
2851
  }));
2647
2852
  } catch (e) {
2648
2853
  this.onError(e, `evaluating ${run.job}`);
2649
2854
  return [];
2650
2855
  }
2651
- return this.dispatch(drafts, definition, now);
2856
+ this.reportDropped(run.job, held.dropped);
2857
+ return this.dispatch(run.job, held.alerts, now);
2858
+ }
2859
+ /**
2860
+ * An evaluation as it is written: its drafts composed into alerts and held
2861
+ * in the same state (holdAlerts), so the write that opens a condition also
2862
+ * keeps its alerts, and a process that stops before sending them does not
2863
+ * lose them. Called inside updateState(), so it only computes.
2864
+ */
2865
+ outbox(settled, definition, now) {
2866
+ const alerts = settled.alerts.map((draft) => composeAlert(draft, definition, now));
2867
+ const { state, dropped } = holdAlerts(settled.state, alerts, this.now() + SEND_LEASE_MS, this.deferDelivery);
2868
+ return { state, result: { alerts, dropped } };
2869
+ }
2870
+ /** Reports alerts let go because a job's queue was full. */
2871
+ reportDropped(name, dropped) {
2872
+ if (dropped <= 0) return;
2873
+ this.report(
2874
+ new Error(`${dropped} undelivered alert${dropped === 1 ? "" : "s"} for ${name} dropped: only the newest ${MAX_UNDELIVERED} are kept for retry`),
2875
+ `alert queue for ${name}`
2876
+ );
2652
2877
  }
2653
2878
  /**
2654
2879
  * The runs before `run`, newest first, with up to BASELINE_WINDOW
@@ -2688,11 +2913,16 @@ ${id}`;
2688
2913
  }
2689
2914
  for (const definition of this.definitions.values()) await this.sync(definition);
2690
2915
  const now = this.now();
2691
- for (const run of await this.store.runningRuns()) {
2916
+ for (const listed of await this.store.runningRuns()) {
2692
2917
  try {
2693
- const declared = this.definitions.get(run.job);
2694
- const definition = declared ? toStored(declared) : (await this.store.getJob(run.job))?.definition;
2695
- if (!definition || !isStuck(definition, run, now)) continue;
2918
+ const declared = this.definitions.get(listed.job);
2919
+ const found = declared ? null : await this.store.getJob(listed.job);
2920
+ const stored = found ? this.readJob(found) : null;
2921
+ if (stored) this.evaluable(stored);
2922
+ const definition = declared ? toStored(declared) : stored?.definition;
2923
+ if (!definition || !isStuck(definition, listed, now)) continue;
2924
+ const run = await this.store.getRun(listed.id);
2925
+ if (!run || run.status !== "running" || run.job !== listed.job) continue;
2696
2926
  const timeout = timeoutMs(definition);
2697
2927
  run.status = "timeout";
2698
2928
  run.finishedAt = now;
@@ -2701,23 +2931,27 @@ ${id}`;
2701
2931
  if (!await this.writeRunIf(run, ["running"])) continue;
2702
2932
  alerts.push(...await this.finishRun(definition, run, now));
2703
2933
  } catch (e) {
2704
- this.onError(e, `checking ${run.job}`);
2934
+ this.onError(e, `checking ${listed.job}`);
2705
2935
  }
2706
2936
  }
2707
2937
  const jobs = [];
2708
2938
  const retries = { spentMs: 0 };
2709
- for (const stored of await this.store.listJobs()) {
2939
+ for (const stored of await this.storedJobs()) {
2710
2940
  try {
2941
+ this.evaluable(stored);
2711
2942
  const recent = await this.store.listRuns(stored.name, BASELINE_WINDOW);
2712
2943
  let nextExpectedAt = null;
2713
- const { state, result: drafts } = await this.updateState(stored.name, (previous) => {
2944
+ const { state, result: held } = await this.updateState(stored.name, (previous) => {
2714
2945
  const evaluation = onCheck(stored.definition, stored, recent[0] ?? null, previous, now);
2715
2946
  nextExpectedAt = evaluation.nextExpectedAt;
2716
2947
  const settled = applySilence(previous, evaluation, now);
2717
- return { state: settled.state, result: settled.alerts };
2948
+ const released = releaseSending(settled.state, this.now());
2949
+ const out = this.outbox({ state: released.state, alerts: settled.alerts }, stored.definition, now);
2950
+ return { state: out.state, result: { alerts: out.result.alerts, dropped: released.dropped + out.result.dropped } };
2718
2951
  });
2952
+ this.reportDropped(stored.name, held.dropped);
2719
2953
  alerts.push(...await this.retryUndelivered(stored.name, state, now, retries));
2720
- alerts.push(...await this.dispatch(drafts, stored.definition, now));
2954
+ alerts.push(...await this.dispatch(stored.name, held.alerts, now));
2721
2955
  jobs.push(summarize(stored, recent, state, nextExpectedAt, now));
2722
2956
  } catch (e) {
2723
2957
  this.onError(e, `checking ${stored.name}`);
@@ -2740,6 +2974,7 @@ ${id}`;
2740
2974
  let recent = [];
2741
2975
  try {
2742
2976
  recent = await this.store.listRuns(stored.name, Math.max(runs, BASELINE_WINDOW));
2977
+ this.evaluable(stored);
2743
2978
  const state = await this.readState(stored.name);
2744
2979
  const { nextExpectedAt } = onCheck(stored.definition, stored, recent[0] ?? null, state, now);
2745
2980
  return { job: summarize(stored, recent, state, nextExpectedAt, now), runs: recent.slice(0, runs) };
@@ -2761,19 +2996,20 @@ ${id}`;
2761
2996
  /** Every job's summary with its newest `limit` runs, read together. What the dashboard shows. */
2762
2997
  async jobsWithRuns(limit = 20) {
2763
2998
  await this.ensureReady();
2764
- for (const definition of this.definitions.values()) await this.sync(definition);
2999
+ const jobs = await this.storedJobs();
2765
3000
  const now = this.now();
2766
3001
  const out = [];
2767
- for (const stored of await this.store.listJobs()) out.push(await this.snapshot(stored, now, clampLimit(limit, 20, 0)));
3002
+ for (const stored of jobs) out.push(await this.snapshot(stored, now, clampLimit(limit, 20, 0)));
2768
3003
  return out;
2769
3004
  }
3005
+ /** A job's summary, or null for one the store does not have. One declared here and forgotten elsewhere is written again, as storedJobs() does. */
2770
3006
  async jobSummary(name) {
2771
3007
  await this.ensureReady();
2772
3008
  const definition = this.definitions.get(name);
2773
- if (definition) await this.sync(definition);
3009
+ if (definition) await this.sync(definition, true);
2774
3010
  const stored = await this.store.getJob(name);
2775
3011
  if (!stored) return null;
2776
- return (await this.snapshot(stored, this.now(), 0)).job;
3012
+ return (await this.snapshot(this.readJob(stored), this.now(), 0)).job;
2777
3013
  }
2778
3014
  /** A job's runs, newest first. `limit` is a whole number from 1 to 500. */
2779
3015
  async runs(name, limit = 50) {
@@ -2784,11 +3020,14 @@ ${id}`;
2784
3020
  await this.ensureReady();
2785
3021
  return this.store.getRun(id);
2786
3022
  }
2787
- /** Stop alerts for a job for a while. State keeps updating underneath. */
3023
+ /**
3024
+ * Stop alerts for a job for a while. State keeps updating underneath. The
3025
+ * end is a whole millisecond, held at 2^53 - 1 (see silenceEnd).
3026
+ */
2788
3027
  async silence(name, duration) {
2789
3028
  const ms = parseDuration(duration, "silence duration");
2790
3029
  return this.patchState(name, (state) => {
2791
- state.silencedUntil = this.now() + ms;
3030
+ state.silencedUntil = silenceEnd(this.now(), ms);
2792
3031
  });
2793
3032
  }
2794
3033
  async unsilence(name) {
@@ -2806,28 +3045,38 @@ ${id}`;
2806
3045
  });
2807
3046
  return state;
2808
3047
  }
2809
- /** Remove a job and its runs from the store. A job still declared in code comes back on its next run. */
3048
+ /**
3049
+ * Remove a job and its runs from the store. A job still declared in code
3050
+ * comes back: here on its next run, and in any other process that declares
3051
+ * it on its next run there, or at that process's next check or dashboard read.
3052
+ */
2810
3053
  async forget(name) {
2811
3054
  await this.ensureReady();
2812
3055
  this.definitions.delete(name);
2813
3056
  this.synced.delete(name);
2814
3057
  await this.store.deleteJob(name);
2815
3058
  }
2816
- /** The dashboard and JSON API as fetch-style handlers. See createRoutes(). */
3059
+ /**
3060
+ * The dashboard and JSON API as fetch-style handlers: the one way to mount
3061
+ * them. A Next.js app exports them from app/cronwatch/[[...path]]/route.ts:
3062
+ *
3063
+ * export const { GET, POST, DELETE } = cw.routes();
3064
+ */
2817
3065
  routes(options) {
2818
- return createRoutes(this, options);
3066
+ return buildRoutes(this, options);
2819
3067
  }
2820
3068
  /**
2821
3069
  * Check on an interval, for long-running servers. Default every minute.
2822
3070
  * Not for serverless functions or Cloudflare Workers, where nothing runs
2823
- * between requests: call check() from a cron there instead.
3071
+ * between requests: call check() from a cron there instead. (A job's
3072
+ * start() opens a run; this starts the checks.)
2824
3073
  */
2825
- start(every = "1m") {
3074
+ startChecking(every = "1m") {
2826
3075
  if (this.timer) return;
2827
3076
  const ms = Math.min(TIMER_MAX_MS, Math.max(5e3, parseDuration(every, "check interval")));
2828
3077
  if (this.deferDelivery && !this.warnedDeferredStart) {
2829
3078
  this.warnedDeferredStart = true;
2830
- 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.');
3079
+ 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.');
2831
3080
  }
2832
3081
  const tick = () => this.check().catch((e) => this.onError(e, "check"));
2833
3082
  this.timer = setInterval(tick, ms);
@@ -2838,38 +3087,52 @@ ${id}`;
2838
3087
  }, 1e3);
2839
3088
  if (typeof this.firstTick === "object" && "unref" in this.firstTick) this.firstTick.unref();
2840
3089
  }
3090
+ /**
3091
+ * startChecking(), under its former name.
3092
+ * @deprecated Renamed `startChecking`, since a job's `start()` opens a run.
3093
+ * This name still works through 1.x and goes in 2.0.
3094
+ */
3095
+ start(every = "1m") {
3096
+ this.startChecking(every);
3097
+ }
3098
+ /** Stop the checks startChecking() began. */
2841
3099
  stop() {
2842
3100
  if (this.timer) clearInterval(this.timer);
2843
3101
  if (this.firstTick) clearTimeout(this.firstTick);
2844
3102
  this.timer = null;
2845
3103
  this.firstTick = null;
2846
3104
  }
3105
+ /**
3106
+ * Stop the interval, wait for a check already under way (bounded by its
3107
+ * own channel, triage and retry timeouts; what it throws was reported to
3108
+ * whoever started it), then close the store, so that check neither writes
3109
+ * after the store is closed nor loses the alerts it would queue.
3110
+ */
2847
3111
  async close() {
2848
3112
  this.stop();
3113
+ if (this.checking) await this.checking.catch(() => {
3114
+ });
2849
3115
  if (this.store.close) await this.store.close();
2850
3116
  }
2851
3117
  /**
2852
- * Compose, triage and send each draft. The state was saved before this
2853
- * (updateState), so a slow channel holds up nothing else; afterwards only
2854
- * the delivery fields are written back, onto a fresh read of the state.
3118
+ * Triage and send each alert the outbox holds (see outbox()). The state,
3119
+ * with the alerts in it, was saved before this, so a slow channel holds up
3120
+ * nothing else; afterwards only the delivery fields are written back, onto
3121
+ * a fresh read of the state, and the alerts leave `sending`. Triage is made
3122
+ * here, never stored with the held alert: the write that opens a condition
3123
+ * cannot wait for it, and a retry triages an alert that has none. With
3124
+ * deliver: "check" the alerts were queued for a check elsewhere instead.
2855
3125
  */
2856
- async dispatch(drafts, definition, now) {
2857
- if (drafts.length === 0) return [];
2858
- const composed = [];
3126
+ async dispatch(name, alerts, now) {
3127
+ if (alerts.length === 0 || this.deferDelivery) return alerts;
2859
3128
  const delivered = [];
2860
3129
  const failed = [];
2861
- for (const draft of drafts) {
2862
- const alert = composeAlert(draft, definition, now);
2863
- if (this.deferDelivery) {
2864
- failed.push(alert);
2865
- } else {
2866
- if (this.triage && alert.type !== "recovered") await this.addTriage(alert, TRIAGE_TIMEOUT_MS);
2867
- (await this.deliver(alert) ? delivered : failed).push(alert);
2868
- }
2869
- composed.push(alert);
3130
+ for (const alert of alerts) {
3131
+ if (this.triage && alert.type !== "recovered") await this.addTriage(alert, TRIAGE_TIMEOUT_MS);
3132
+ (await this.deliver(alert) ? delivered : failed).push(alert);
2870
3133
  }
2871
- await this.recordDelivery(definition.name, delivered, failed, [], now);
2872
- return composed;
3134
+ await this.recordDelivery(name, delivered, failed, [], now);
3135
+ return alerts;
2873
3136
  }
2874
3137
  /**
2875
3138
  * Send the alerts that no channel accepted last time, once each, oldest
@@ -2898,28 +3161,18 @@ ${id}`;
2898
3161
  }
2899
3162
  /**
2900
3163
  * Mark delivered alerts done, drop stale ones, and keep failed ones for the
2901
- * next check. A failed alert replaces its stored copy, so a triage made on
2902
- * this attempt is kept. lastAlertAt moves only on a delivery.
3164
+ * next check, taking them all out of `sending` (recordSent). A failed alert
3165
+ * replaces its stored copy, so a triage made on this attempt is kept.
3166
+ * lastAlertAt moves only on a delivery. When this write fails, alerts
3167
+ * still in `sending` are retried once their lease runs out.
2903
3168
  */
2904
3169
  async recordDelivery(name, delivered, failed, dropped, now) {
2905
3170
  try {
2906
3171
  const { result: trimmed } = await this.updateState(name, (previous) => {
2907
- const state = normalizeState(previous, name);
2908
- const done = new Set([...delivered, ...dropped].map(alertKey));
2909
- const retried = new Map(failed.map((a) => [alertKey(a), a]));
2910
- const kept = state.undelivered.filter((a) => !done.has(alertKey(a))).map((a) => retried.get(alertKey(a)) ?? a);
2911
- const known = new Set(kept.map(alertKey));
2912
- kept.push(...failed.filter((a) => !known.has(alertKey(a))));
2913
- state.undelivered = kept.slice(-MAX_UNDELIVERED);
2914
- if (delivered.length > 0) state.lastAlertAt = now;
2915
- return { state, result: Math.max(0, kept.length - MAX_UNDELIVERED) };
3172
+ const { state, dropped: letGo } = recordSent(normalizeState(previous, name), delivered, failed, dropped, now);
3173
+ return { state, result: letGo };
2916
3174
  });
2917
- if (trimmed > 0) {
2918
- this.onError(
2919
- new Error(`${trimmed} undelivered alert${trimmed === 1 ? "" : "s"} for ${name} dropped: only the newest ${MAX_UNDELIVERED} are kept for retry`),
2920
- `alert queue for ${name}`
2921
- );
2922
- }
3175
+ this.reportDropped(name, trimmed);
2923
3176
  } catch (e) {
2924
3177
  this.onError(e, `recording alert delivery for ${name}`);
2925
3178
  }
@@ -2955,9 +3208,10 @@ ${id}`;
2955
3208
  }
2956
3209
  };
2957
3210
  var RESERVED_RUN_ID_PREFIX = "pgcron:";
3211
+ var MAX_RUN_ID = 200;
2958
3212
  function checkRunId(job, id, method) {
2959
- if (typeof id !== "string" || id.length === 0 || id.length > 200) {
2960
- throw new Error(`job "${job}": ${method}() needs a run id of 1 to 200 characters (got ${typeof id === "string" ? `${id.length} characters` : typeof id})`);
3213
+ if (typeof id !== "string" || id.length === 0 || id.length > MAX_RUN_ID) {
3214
+ throw new Error(`job "${job}": ${method}() needs a run id of 1 to ${MAX_RUN_ID} characters (got ${typeof id === "string" ? `${id.length} characters` : typeof id})`);
2961
3215
  }
2962
3216
  if (id.includes("\0")) throw new Error(`job "${job}": ${method}() cannot take a run id containing a NUL character`);
2963
3217
  if (id.startsWith(RESERVED_RUN_ID_PREFIX)) {
@@ -3018,12 +3272,13 @@ function custom(name, send) {
3018
3272
  await send(alert);
3019
3273
  } };
3020
3274
  }
3275
+ var CronWatch = Cronwatch;
3021
3276
 
3022
3277
  // src/index.ts
3023
3278
  function cronwatch(options = {}) {
3024
- return new CronWatch(options);
3279
+ return new Cronwatch(options);
3025
3280
  }
3026
3281
 
3027
- export { CronWatch, composeAlert, consoleChannel, createRoutes, cronwatch, custom, formatDuration, memory, nextFire, parseDuration, parseSchedule };
3282
+ export { CronWatch, Cronwatch, VERSION, composeAlert, consoleChannel, createRoutes, cronwatch, custom, formatDuration, memory, nextFire, parseDuration, parseSchedule };
3028
3283
  //# sourceMappingURL=index.js.map
3029
3284
  //# sourceMappingURL=index.js.map