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