@cronwatch/sdk 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +8 -2
  2. package/dist/anthropic.d.cts +1 -1
  3. package/dist/anthropic.d.ts +1 -1
  4. package/dist/bugsnag.cjs +108 -0
  5. package/dist/bugsnag.cjs.map +1 -0
  6. package/dist/bugsnag.d.cts +26 -0
  7. package/dist/bugsnag.d.ts +26 -0
  8. package/dist/bugsnag.js +106 -0
  9. package/dist/bugsnag.js.map +1 -0
  10. package/dist/client-B7bqnkI5.d.cts +450 -0
  11. package/dist/client-pmqH3apS.d.ts +450 -0
  12. package/dist/d1.cjs +214 -0
  13. package/dist/d1.cjs.map +1 -0
  14. package/dist/d1.d.cts +42 -0
  15. package/dist/d1.d.ts +42 -0
  16. package/dist/d1.js +212 -0
  17. package/dist/d1.js.map +1 -0
  18. package/dist/datadog.cjs +95 -0
  19. package/dist/datadog.cjs.map +1 -0
  20. package/dist/datadog.d.cts +24 -0
  21. package/dist/datadog.d.ts +24 -0
  22. package/dist/datadog.js +93 -0
  23. package/dist/datadog.js.map +1 -0
  24. package/dist/discord.cjs +2 -0
  25. package/dist/discord.cjs.map +1 -1
  26. package/dist/discord.d.cts +1 -1
  27. package/dist/discord.d.ts +1 -1
  28. package/dist/discord.js +2 -0
  29. package/dist/discord.js.map +1 -1
  30. package/dist/email-BJ-z_epD.d.cts +21 -0
  31. package/dist/email-_H0faCh3.d.ts +21 -0
  32. package/dist/honeybadger.cjs +101 -0
  33. package/dist/honeybadger.cjs.map +1 -0
  34. package/dist/honeybadger.d.cts +23 -0
  35. package/dist/honeybadger.d.ts +23 -0
  36. package/dist/honeybadger.js +99 -0
  37. package/dist/honeybadger.js.map +1 -0
  38. package/dist/index.cjs +462 -45
  39. package/dist/index.cjs.map +1 -1
  40. package/dist/index.d.cts +5 -260
  41. package/dist/index.d.ts +5 -260
  42. package/dist/index.js +462 -45
  43. package/dist/index.js.map +1 -1
  44. package/dist/mailgun.cjs +115 -0
  45. package/dist/mailgun.cjs.map +1 -0
  46. package/dist/mailgun.d.cts +21 -0
  47. package/dist/mailgun.d.ts +21 -0
  48. package/dist/mailgun.js +113 -0
  49. package/dist/mailgun.js.map +1 -0
  50. package/dist/newrelic.cjs +88 -0
  51. package/dist/newrelic.cjs.map +1 -0
  52. package/dist/newrelic.d.cts +25 -0
  53. package/dist/newrelic.d.ts +25 -0
  54. package/dist/newrelic.js +86 -0
  55. package/dist/newrelic.js.map +1 -0
  56. package/dist/node.cjs +191 -0
  57. package/dist/node.cjs.map +1 -0
  58. package/dist/node.d.cts +97 -0
  59. package/dist/node.d.ts +97 -0
  60. package/dist/node.js +186 -0
  61. package/dist/node.js.map +1 -0
  62. package/dist/pg-cron.cjs +286 -0
  63. package/dist/pg-cron.cjs.map +1 -0
  64. package/dist/pg-cron.d.cts +93 -0
  65. package/dist/pg-cron.d.ts +93 -0
  66. package/dist/pg-cron.js +280 -0
  67. package/dist/pg-cron.js.map +1 -0
  68. package/dist/postgres.cjs +20 -0
  69. package/dist/postgres.cjs.map +1 -1
  70. package/dist/postgres.d.cts +1 -1
  71. package/dist/postgres.d.ts +1 -1
  72. package/dist/postgres.js +20 -0
  73. package/dist/postgres.js.map +1 -1
  74. package/dist/postmark.cjs +106 -0
  75. package/dist/postmark.cjs.map +1 -0
  76. package/dist/postmark.d.cts +18 -0
  77. package/dist/postmark.d.ts +18 -0
  78. package/dist/postmark.js +104 -0
  79. package/dist/postmark.js.map +1 -0
  80. package/dist/resend.cjs +114 -0
  81. package/dist/resend.cjs.map +1 -0
  82. package/dist/resend.d.cts +16 -0
  83. package/dist/resend.d.ts +16 -0
  84. package/dist/resend.js +112 -0
  85. package/dist/resend.js.map +1 -0
  86. package/dist/rollbar.cjs +108 -0
  87. package/dist/rollbar.cjs.map +1 -0
  88. package/dist/rollbar.d.cts +20 -0
  89. package/dist/rollbar.d.ts +20 -0
  90. package/dist/rollbar.js +106 -0
  91. package/dist/rollbar.js.map +1 -0
  92. package/dist/sendgrid.cjs +114 -0
  93. package/dist/sendgrid.cjs.map +1 -0
  94. package/dist/sendgrid.d.cts +19 -0
  95. package/dist/sendgrid.d.ts +19 -0
  96. package/dist/sendgrid.js +112 -0
  97. package/dist/sendgrid.js.map +1 -0
  98. package/dist/sentry.cjs +130 -0
  99. package/dist/sentry.cjs.map +1 -0
  100. package/dist/sentry.d.cts +28 -0
  101. package/dist/sentry.d.ts +28 -0
  102. package/dist/sentry.js +127 -0
  103. package/dist/sentry.js.map +1 -0
  104. package/dist/ses.cjs +170 -0
  105. package/dist/ses.cjs.map +1 -0
  106. package/dist/ses.d.cts +25 -0
  107. package/dist/ses.d.ts +25 -0
  108. package/dist/ses.js +168 -0
  109. package/dist/ses.js.map +1 -0
  110. package/dist/slack.cjs +2 -0
  111. package/dist/slack.cjs.map +1 -1
  112. package/dist/slack.d.cts +1 -1
  113. package/dist/slack.d.ts +1 -1
  114. package/dist/slack.js +2 -0
  115. package/dist/slack.js.map +1 -1
  116. package/dist/sqlite.cjs +18 -0
  117. package/dist/sqlite.cjs.map +1 -1
  118. package/dist/sqlite.d.cts +1 -1
  119. package/dist/sqlite.d.ts +1 -1
  120. package/dist/sqlite.js +18 -0
  121. package/dist/sqlite.js.map +1 -1
  122. package/dist/twilio.cjs +168 -0
  123. package/dist/twilio.cjs.map +1 -0
  124. package/dist/twilio.d.cts +52 -0
  125. package/dist/twilio.d.ts +52 -0
  126. package/dist/twilio.js +162 -0
  127. package/dist/twilio.js.map +1 -0
  128. package/dist/{types-DUt2apl0.d.cts → types-Ddq1MUPL.d.cts} +29 -2
  129. package/dist/{types-DUt2apl0.d.ts → types-Ddq1MUPL.d.ts} +29 -2
  130. package/dist/webhook.cjs +11 -5
  131. package/dist/webhook.cjs.map +1 -1
  132. package/dist/webhook.d.cts +6 -3
  133. package/dist/webhook.d.ts +6 -3
  134. package/dist/webhook.js +11 -6
  135. package/dist/webhook.js.map +1 -1
  136. package/package.json +208 -2
package/dist/index.cjs CHANGED
@@ -1,7 +1,6 @@
1
1
  'use strict';
2
2
 
3
3
  var croner = require('croner');
4
- var crypto$1 = require('crypto');
5
4
 
6
5
  // src/duration.ts
7
6
  var UNIT_MS = {
@@ -55,6 +54,16 @@ function formatRelative(at, now) {
55
54
  if (abs < 5e3) return text;
56
55
  return diff < 0 ? `${text} ago` : `in ${text}`;
57
56
  }
57
+
58
+ // src/env.ts
59
+ function readEnv(name) {
60
+ return typeof process === "undefined" || !process.env ? void 0 : process.env[name];
61
+ }
62
+ function isDevelopment() {
63
+ if (typeof process === "undefined" || !process.env) return false;
64
+ const mode = process.env.NODE_ENV;
65
+ return mode === "development" || mode === "test";
66
+ }
58
67
  var cache = /* @__PURE__ */ new Map();
59
68
  var crons = /* @__PURE__ */ new WeakMap();
60
69
  var EARLY_SLACK_MS = 6e4;
@@ -307,7 +316,15 @@ function onRunFinish(def, run, state, history, now) {
307
316
  function onCheck(def, stored, lastRun, state, now) {
308
317
  const next = cloneState(state);
309
318
  const alerts = [];
310
- if (!def.schedule) return { state: next, alerts, nextExpectedAt: null, dueAt: null };
319
+ if (!def.schedule) {
320
+ const since = next.open.missed;
321
+ if (since !== void 0) {
322
+ delete next.open.missed;
323
+ next.pendingRecovery = (next.pendingRecovery ?? []).filter((c) => c !== "missed");
324
+ alerts.push({ type: "recovered", run: lastRun, details: { after: ["missed"], reason: "unscheduled", since } });
325
+ }
326
+ return { state: next, alerts, nextExpectedAt: null, dueAt: null };
327
+ }
311
328
  const parsed = parseSchedule(def.schedule, def.timezone);
312
329
  const grace = graceMs(def);
313
330
  const lastRunAt = lastRun?.startedAt ?? null;
@@ -457,6 +474,12 @@ ${out}`);
457
474
  break;
458
475
  }
459
476
  case "recovered": {
477
+ if (draft.details.reason === "unscheduled") {
478
+ title = `${name} is no longer scheduled`;
479
+ const since = draft.details.since;
480
+ lines.push(`${since === void 0 ? "" : `Missed since ${when(since, now)}. `}It has no schedule now, so nothing is due; the missed alert is closed.`);
481
+ break;
482
+ }
460
483
  title = `${name} recovered`;
461
484
  const after = draft.details.after.map((c) => c.replace("_", " ")).join(", ");
462
485
  lines.push(`A run ${run ? when(run.startedAt, now) : "just now"} succeeded${after ? ` after: ${after}` : ""}.`);
@@ -818,15 +841,17 @@ function messagePage(title, message, base) {
818
841
 
819
842
  // src/routes/index.ts
820
843
  var COOKIE = "cronwatch_token";
821
- function cookieValue(token) {
822
- return crypto$1.createHash("sha256").update(`cronwatch-cookie:${token}`).digest("hex");
844
+ async function cookieValue(token) {
845
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`cronwatch-cookie:${token}`));
846
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
823
847
  }
824
848
  var DEFAULT_RUNS = 20;
825
849
  var MAX_RUNS = 500;
826
850
  var CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
827
851
  var SECURITY_HEADERS = { "x-content-type-options": "nosniff", "referrer-policy": "same-origin", "x-robots-tag": "noindex" };
828
852
  function developmentToken() {
829
- return crypto$1.randomBytes(32).toString("base64url");
853
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
854
+ return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
830
855
  }
831
856
  function developmentSignInLine(origin, base, token) {
832
857
  return `[cronwatch] CRONWATCH_TOKEN is not set, so this development server made a token for the dashboard. Sign in: ${origin}${base}/?token=${token}`;
@@ -847,6 +872,34 @@ function readCookie(request, name) {
847
872
  }
848
873
  return null;
849
874
  }
875
+ function firstValue(value) {
876
+ const first = value?.split(",")[0]?.trim();
877
+ return first ? first : null;
878
+ }
879
+ function configuredOrigin(value) {
880
+ if (value === void 0 || value === "") return null;
881
+ let url;
882
+ try {
883
+ url = new URL(value);
884
+ } catch {
885
+ throw new Error(`routes: origin must be an absolute URL such as "https://app.example.com", got ${JSON.stringify(value)}`);
886
+ }
887
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`routes: origin must be http or https, got ${JSON.stringify(value)}`);
888
+ return url.origin;
889
+ }
890
+ function forwardedOrigin(request, url) {
891
+ const proto = firstValue(request.headers.get("x-forwarded-proto"))?.toLowerCase() ?? null;
892
+ const host = firstValue(request.headers.get("x-forwarded-host"));
893
+ if (proto === null && host === null) return url.origin;
894
+ if (proto !== null && proto !== "http" && proto !== "https") return url.origin;
895
+ try {
896
+ const built = new URL(`${proto ?? url.protocol.slice(0, -1)}://${host ?? url.host}`);
897
+ if (built.pathname !== "/" || built.username || built.password || built.search || built.hash) return url.origin;
898
+ return built.origin;
899
+ } catch {
900
+ return url.origin;
901
+ }
902
+ }
850
903
  function stripBase(pathname, base) {
851
904
  let path = pathname.startsWith(base) ? pathname.slice(base.length) : pathname;
852
905
  if (path === "") path = "/";
@@ -871,9 +924,9 @@ async function readBody(request) {
871
924
  }
872
925
  return {};
873
926
  }
874
- function crossSite(request, url) {
927
+ function crossSite(request, publicOrigin) {
875
928
  const origin = request.headers.get("origin");
876
- if (origin !== null && origin !== url.origin) return true;
929
+ if (origin !== null && origin !== publicOrigin) return true;
877
930
  const site = request.headers.get("sec-fetch-site");
878
931
  return site !== null && site !== "same-origin" && site !== "none";
879
932
  }
@@ -890,31 +943,37 @@ function runsLimit(value) {
890
943
  }
891
944
  function createRoutes(cw, options = {}) {
892
945
  const optedOut = options.token === null;
893
- const configured = optedOut ? null : options.token || process.env.CRONWATCH_TOKEN || null;
946
+ const configured = optedOut ? null : options.token || readEnv("CRONWATCH_TOKEN") || null;
894
947
  const base = (options.basePath ?? "/cronwatch").replace(/\/+$/, "");
895
- const developing = process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test";
948
+ const developing = isDevelopment();
949
+ const fixedOrigin = configuredOrigin(options.origin);
950
+ const trustProxy = options.trustProxy === true;
951
+ const originOf = (request, url) => fixedOrigin ?? (trustProxy ? forwardedOrigin(request, url) : url.origin);
896
952
  const generated = !configured && !optedOut && developing;
897
953
  const token = generated ? developmentToken() : configured;
898
954
  let announced = false;
955
+ let cookie = null;
956
+ const expectedCookie = (value) => cookie ??= cookieValue(value);
899
957
  const serve = async (request, url, path, wantsHtml) => {
900
958
  const method = request.method.toUpperCase();
959
+ const publicOrigin = originOf(request, url);
901
960
  if (generated && !announced) {
902
961
  announced = true;
903
- console.info(developmentSignInLine(url.origin, base, token));
962
+ console.info(developmentSignInLine(publicOrigin, base, token));
904
963
  }
905
964
  if (!token && !optedOut) {
906
965
  return wantsHtml ? html(messagePage("CronWatch routes are locked", "Set CRONWATCH_TOKEN (or pass token to cw.routes()), or pass token: null to serve them open behind your own auth.", base), 503) : api({ ok: false, error: "CRONWATCH_TOKEN is not set" }, 503);
907
966
  }
908
- if (method !== "GET" && method !== "HEAD" && crossSite(request, url)) {
967
+ if (method !== "GET" && method !== "HEAD" && crossSite(request, publicOrigin)) {
909
968
  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);
910
969
  }
911
970
  const bearer = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") ?? null;
912
971
  if (token) {
913
972
  const query = wantsHtml && method === "GET" ? url.searchParams.get("token") : null;
914
- const cookie = readCookie(request, COOKIE);
973
+ const sent = readCookie(request, COOKIE);
915
974
  const isCheck = path === "/api/check";
916
975
  const cronSecretOk = isCheck && bearer !== null && cw.cronSecret !== null && constantTimeEqual(bearer, cw.cronSecret);
917
- const tokenOk = bearer !== null ? constantTimeEqual(bearer, token) : query !== null ? constantTimeEqual(query, token) : cookie !== null && constantTimeEqual(cookie, cookieValue(token));
976
+ const tokenOk = bearer !== null ? constantTimeEqual(bearer, token) : query !== null ? constantTimeEqual(query, token) : sent !== null && constantTimeEqual(sent, await expectedCookie(token));
918
977
  if (!cronSecretOk && !tokenOk) {
919
978
  if (generated) {
920
979
  return wantsHtml ? html(messagePage("Sign in", "CRONWATCH_TOKEN is not set, so this development server made a token. The sign-in link is in the server log: open it once and this browser stays signed in.", base), 401) : api({ ok: false, error: "Unauthorized: CRONWATCH_TOKEN is not set, so this development server made a token; it is in the server log" }, 401);
@@ -923,15 +982,15 @@ function createRoutes(cw, options = {}) {
923
982
  }
924
983
  if (query !== null) {
925
984
  url.searchParams.delete("token");
926
- const secure = url.protocol === "https:" ? "; Secure" : "";
985
+ const secure = publicOrigin.startsWith("https:") ? "; Secure" : "";
927
986
  return redirect(url.pathname + (url.search || ""), {
928
- "set-cookie": `${COOKIE}=${cookieValue(token)}; Path=${base || "/"}; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 30}${secure}`
987
+ "set-cookie": `${COOKIE}=${await expectedCookie(token)}; Path=${base || "/"}; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 30}${secure}`
929
988
  });
930
989
  }
931
990
  }
932
991
  const redirectBack = () => {
933
992
  const referer = request.headers.get("referer") ?? "";
934
- return redirect(referer.startsWith(url.origin + "/") ? referer : `${base}/`);
993
+ return redirect(referer.startsWith(publicOrigin + "/") ? referer : `${base}/`);
935
994
  };
936
995
  const decoded = path.split("/").filter(Boolean).map(safeDecode);
937
996
  if (decoded.some((part) => part === null)) {
@@ -1092,6 +1151,7 @@ function memory() {
1092
1151
  }
1093
1152
  },
1094
1153
  async insertRun(run) {
1154
+ if (runs.has(run.id)) throw new Error(`run ${run.id} already exists`);
1095
1155
  runs.set(run.id, clone(run));
1096
1156
  order.set(run.id, ++seq);
1097
1157
  },
@@ -1101,6 +1161,13 @@ function memory() {
1101
1161
  const { status, finishedAt, durationMs, error, output, metrics } = clone(run);
1102
1162
  runs.set(run.id, { ...existing, status, finishedAt, durationMs, error, output, metrics });
1103
1163
  },
1164
+ async updateRunIf(run, fromStatuses) {
1165
+ const existing = runs.get(run.id);
1166
+ if (!existing || !fromStatuses.includes(existing.status)) return false;
1167
+ const { status, finishedAt, durationMs, error, output, metrics } = clone(run);
1168
+ runs.set(run.id, { ...existing, status, finishedAt, durationMs, error, output, metrics });
1169
+ return true;
1170
+ },
1104
1171
  async getRun(id) {
1105
1172
  const r = runs.get(id);
1106
1173
  return r ? clone(r) : null;
@@ -1196,6 +1263,7 @@ var CronWatch = class {
1196
1263
  store;
1197
1264
  alerts;
1198
1265
  triage;
1266
+ sources;
1199
1267
  /** The secret handler() requests must carry, or null when none is set. */
1200
1268
  cronSecret;
1201
1269
  retentionMs;
@@ -1211,6 +1279,8 @@ var CronWatch = class {
1211
1279
  synced = /* @__PURE__ */ new Set();
1212
1280
  /** The tail of each job's queue of state updates. See serial(). */
1213
1281
  queues = /* @__PURE__ */ new Map();
1282
+ /** start() calls with an id still in flight, so two at once in this process record one run. */
1283
+ starting = /* @__PURE__ */ new Map();
1214
1284
  ready = null;
1215
1285
  checking = null;
1216
1286
  lastPruneAt = 0;
@@ -1223,7 +1293,8 @@ var CronWatch = class {
1223
1293
  this.store = options.store ?? (this.usingDefaultStore = true, memory());
1224
1294
  this.alerts = options.alerts ?? [consoleChannel()];
1225
1295
  this.triage = options.triage;
1226
- const secret = options.cronSecret === void 0 ? process.env.CRON_SECRET : options.cronSecret;
1296
+ this.sources = options.sources ?? [];
1297
+ const secret = options.cronSecret === void 0 ? readEnv("CRON_SECRET") : options.cronSecret;
1227
1298
  this.cronSecret = secret ? secret : null;
1228
1299
  this.secretOptOut = options.cronSecret === null;
1229
1300
  this.retentionMs = parseDuration(options.retention ?? "30d", "retention");
@@ -1267,6 +1338,12 @@ var CronWatch = class {
1267
1338
  definedJobs() {
1268
1339
  return [...this.definitions.values()];
1269
1340
  }
1341
+ /** A handle on a run started elsewhere, as job(name).resume(runId). The job must be declared in this process. */
1342
+ resumeRun(name, runId) {
1343
+ const definition = this.definitions.get(name);
1344
+ if (!definition) return Promise.reject(new Error(`resumeRun: job "${name}" is not declared; call job() first`));
1345
+ return this.resumeHandle(definition, runId);
1346
+ }
1270
1347
  handle(definition) {
1271
1348
  const self = this;
1272
1349
  return {
@@ -1305,6 +1382,12 @@ var CronWatch = class {
1305
1382
  };
1306
1383
  return json(body, outcome.run.status === "ok" ? 200 : 500);
1307
1384
  };
1385
+ },
1386
+ start(options) {
1387
+ return self.startRun(definition, options ?? {});
1388
+ },
1389
+ resume(runId) {
1390
+ return self.resumeHandle(definition, runId);
1308
1391
  }
1309
1392
  };
1310
1393
  }
@@ -1324,7 +1407,7 @@ var CronWatch = class {
1324
1407
  if (!this.ready) {
1325
1408
  this.ready = (async () => {
1326
1409
  if (this.store.init) await this.store.init();
1327
- if (this.usingDefaultStore && process.env.NODE_ENV === "production") {
1410
+ if (this.usingDefaultStore && readEnv("NODE_ENV") === "production") {
1328
1411
  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.");
1329
1412
  }
1330
1413
  })().catch((error) => {
@@ -1441,6 +1524,21 @@ var CronWatch = class {
1441
1524
  run.durationMs = Math.max(0, finishedAt - startedAt);
1442
1525
  run.metrics = recorder.metrics();
1443
1526
  run.output = recorder.output() ?? (typeof result === "string" ? capOutput(result) : null);
1527
+ this.conclude(definition, run, result, error, threw, recorder.expectText() ?? (typeof result === "string" ? result : null));
1528
+ await started;
1529
+ try {
1530
+ const ignored = await this.recordFinish(definition, run, recorded, finishedAt);
1531
+ if (ignored) this.report(new Error(`run ${run.id} of ${name} ${ignored}; ignored`), `finishing ${name}`);
1532
+ } catch (e) {
1533
+ this.onError(e, `recording ${name}`);
1534
+ }
1535
+ return { run, result, error, threw };
1536
+ }
1537
+ /**
1538
+ * Sets a finished run's status and error from how it ended, then redacts
1539
+ * its output and error. Shared by execute() and RunHandle.finish().
1540
+ */
1541
+ conclude(definition, run, result, error, threw, expectText) {
1444
1542
  if (threw) {
1445
1543
  run.status = "failed";
1446
1544
  run.error = errorMessage(error);
@@ -1448,7 +1546,6 @@ var CronWatch = class {
1448
1546
  run.status = "failed";
1449
1547
  run.error = `HTTP ${result.status}${result.statusText ? ` ${result.statusText}` : ""}`;
1450
1548
  } else {
1451
- const expectText = recorder.expectText() ?? (typeof result === "string" ? result : null);
1452
1549
  const unmet = checkExpectation(definition.expect, expectText);
1453
1550
  if (unmet) {
1454
1551
  run.status = "failed";
@@ -1459,44 +1556,336 @@ var CronWatch = class {
1459
1556
  }
1460
1557
  if (run.output !== null) run.output = stripNul(this.redact(run.output));
1461
1558
  if (run.error !== null) run.error = stripNul(this.redact(run.error));
1462
- await started;
1559
+ }
1560
+ /**
1561
+ * Writes a finished run and evaluates it. `recorded` says whether its
1562
+ * start was written; if not, it is inserted now. Returns why nothing was
1563
+ * recorded (another process finished the run first, say), or null. Throws
1564
+ * when the store does, so a handle can be finished again. Shared by
1565
+ * execute() and RunHandle.finish().
1566
+ */
1567
+ async recordFinish(definition, run, recorded, finishedAt) {
1463
1568
  if (!recorded) {
1569
+ await this.sync(definition);
1464
1570
  try {
1465
- await this.sync(definition);
1466
1571
  await this.store.insertRun(run);
1467
- recorded = true;
1572
+ await this.finishRun(toStored(definition), run, finishedAt);
1573
+ return null;
1468
1574
  } catch (e) {
1469
- this.onError(e, `recording ${name}`);
1575
+ const stored = await this.store.getRun(run.id).catch(() => null);
1576
+ if (!stored) throw e;
1577
+ if (stored.job !== run.job) return `belongs to job "${stored.job}"`;
1470
1578
  }
1471
- if (recorded) await this.finishRun(toStored(definition), run, finishedAt, false);
1472
- } else if (await this.markedTimedOut(run)) {
1579
+ }
1580
+ const claim = await this.claimFinish(run);
1581
+ if ("ignored" in claim) return claim.ignored;
1582
+ if (!claim.lateAfterTimeout || run.status === "ok") await this.finishRun(toStored(definition), run, finishedAt);
1583
+ return null;
1584
+ }
1585
+ /** A conditional write (Store.updateRunIf), or for a store without one, a read then a plain write. */
1586
+ async writeRunIf(run, fromStatuses) {
1587
+ if (typeof this.store.updateRunIf === "function") return this.store.updateRunIf(run, fromStatuses);
1588
+ const stored = await this.store.getRun(run.id);
1589
+ if (!stored || !fromStatuses.includes(stored.status)) return false;
1590
+ await this.store.updateRun(run);
1591
+ return true;
1592
+ }
1593
+ /**
1594
+ * Writes a finished run over its stored row, only while that row is still
1595
+ * running, or else still marked timeout by a check. Only the process whose
1596
+ * write lands goes on to evaluate the run; for the others it returns why
1597
+ * nothing was written. `lateAfterTimeout` means a check already counted
1598
+ * the run as a stuck failure: a late failure must not count twice, while
1599
+ * a late success still closes stuck and recovers. Throws when the store does.
1600
+ */
1601
+ async claimFinish(run) {
1602
+ if (await this.writeRunIf(run, ["running"])) return { lateAfterTimeout: false };
1603
+ if (await this.writeRunIf(run, ["timeout"])) return { lateAfterTimeout: true };
1604
+ const stored = await this.store.getRun(run.id);
1605
+ return { ignored: stored ? `was already finished as ${stored.status}` : "was not found" };
1606
+ }
1607
+ /** job.start(): records a running run and returns a handle to finish it. See JobHandle.start. */
1608
+ async startRun(definition, options) {
1609
+ const id = options.id;
1610
+ if (id === void 0) return this.recordStart(definition, options.trigger);
1611
+ checkRunId(definition.name, id, "start");
1612
+ const key = `${definition.name}
1613
+ ${id}`;
1614
+ const inFlight = this.starting.get(key);
1615
+ if (inFlight) return inFlight;
1616
+ const started = this.recordStart(definition, options.trigger, id).finally(() => {
1617
+ if (this.starting.get(key) === started) this.starting.delete(key);
1618
+ });
1619
+ this.starting.set(key, started);
1620
+ return started;
1621
+ }
1622
+ /**
1623
+ * The start of execute() without the function: the run is inserted and
1624
+ * missed and stuck close (onRunStart). A store that fails is reported and
1625
+ * the handle inserts the finished run instead, as execute() does.
1626
+ */
1627
+ async recordStart(definition, trigger = "start", id) {
1628
+ const name = definition.name;
1629
+ if (id !== void 0) {
1630
+ let stored = null;
1473
1631
  try {
1474
- await this.store.updateRun(run);
1632
+ await this.ensureReady();
1633
+ stored = await this.store.getRun(id);
1475
1634
  } catch (e) {
1476
1635
  this.onError(e, `recording ${name}`);
1477
1636
  }
1478
- } else {
1479
- await this.finishRun(toStored(definition), run, finishedAt, true);
1637
+ if (stored) return this.existingHandle(definition, stored);
1480
1638
  }
1481
- return { run, result, error, threw };
1639
+ const run = {
1640
+ id: id ?? crypto.randomUUID(),
1641
+ job: name,
1642
+ status: "running",
1643
+ startedAt: this.now(),
1644
+ finishedAt: null,
1645
+ durationMs: null,
1646
+ error: null,
1647
+ output: null,
1648
+ metrics: {},
1649
+ trigger
1650
+ };
1651
+ let recorded = false;
1652
+ try {
1653
+ await this.sync(definition);
1654
+ await this.store.insertRun({ ...run });
1655
+ recorded = true;
1656
+ } catch (e) {
1657
+ const stored = id === void 0 ? null : await this.store.getRun(id).catch(() => null);
1658
+ if (stored) return this.existingHandle(definition, stored);
1659
+ this.onError(e, `recording ${name}`);
1660
+ }
1661
+ if (recorded) {
1662
+ await this.updateState(name, (before) => ({ state: onRunStart(before), result: void 0 })).catch((e) => this.onError(e, `starting ${name}`));
1663
+ }
1664
+ return this.runHandle(definition, run.id, run, recorded, null);
1482
1665
  }
1483
- /** Whether a check already marked this run as timed out, for a failure that finished late. */
1484
- async markedTimedOut(run) {
1485
- if (run.status === "ok") return false;
1666
+ /** job.resume() and cw.resumeRun(). A store that cannot be read is reported, and finish() reads it again. */
1667
+ async resumeHandle(definition, runId) {
1668
+ checkRunId(definition.name, runId, "resume");
1669
+ let stored;
1486
1670
  try {
1487
- return (await this.store.getRun(run.id))?.status === "timeout";
1488
- } catch {
1489
- return false;
1671
+ await this.ensureReady();
1672
+ stored = await this.store.getRun(runId);
1673
+ } catch (e) {
1674
+ this.onError(e, `resuming ${definition.name}`);
1675
+ return this.runHandle(definition, runId, null, true, null);
1676
+ }
1677
+ if (!stored) return this.runHandle(definition, runId, null, true, "was not found");
1678
+ return this.existingHandle(definition, stored);
1679
+ }
1680
+ /** A handle on a stored run. One still running, or marked timeout by a check, can be finished. */
1681
+ existingHandle(definition, stored) {
1682
+ if (stored.job !== definition.name) {
1683
+ throw new Error(`run "${stored.id}" belongs to job "${stored.job}", not "${definition.name}"`);
1684
+ }
1685
+ const finished = stored.status === "ok" || stored.status === "failed";
1686
+ return this.runHandle(definition, stored.id, stored, true, finished ? `already finished as ${stored.status}` : null);
1687
+ }
1688
+ /**
1689
+ * The handle itself. `base` is the run as last known here, `recorded`
1690
+ * whether its start is in the store, and `inactive` why finish() has
1691
+ * nothing to do, or null. Lines and metrics wait in the handle until
1692
+ * flush() or finish() merges them onto a fresh read of the stored run.
1693
+ */
1694
+ runHandle(definition, id, base, recorded, inactive) {
1695
+ const self = this;
1696
+ const name = definition.name;
1697
+ const fresh = () => createRecorder({ id, job: name, startedAt: base?.startedAt ?? 0 });
1698
+ let recorder = fresh();
1699
+ let finished = inactive !== null;
1700
+ let finishCalled = false;
1701
+ let head = null;
1702
+ let queue = Promise.resolve();
1703
+ const inTurn = (fn) => {
1704
+ const result = queue.then(fn);
1705
+ queue = result.catch(() => {
1706
+ });
1707
+ return result;
1708
+ };
1709
+ const ignored = (why) => self.report(new Error(`run ${id} of ${name} ${why}; ignored`), `finishing ${name}`);
1710
+ const finish = (outcome) => {
1711
+ if (finishCalled) {
1712
+ ignored("was already finished by this handle");
1713
+ return Promise.resolve(null);
1714
+ }
1715
+ finishCalled = true;
1716
+ const wasInactive = finished;
1717
+ finished = true;
1718
+ const retryable = (error) => {
1719
+ finishCalled = false;
1720
+ finished = false;
1721
+ self.report(error, `finishing ${name}`);
1722
+ return null;
1723
+ };
1724
+ return inTurn(async () => {
1725
+ if (wasInactive) {
1726
+ ignored(inactive);
1727
+ return null;
1728
+ }
1729
+ let from = base;
1730
+ if (recorded) {
1731
+ try {
1732
+ from = await self.store.getRun(id) ?? base;
1733
+ } catch (e) {
1734
+ return retryable(e);
1735
+ }
1736
+ }
1737
+ if (!from) {
1738
+ ignored("was not found");
1739
+ return null;
1740
+ }
1741
+ if (from.job !== name) {
1742
+ ignored(`belongs to job "${from.job}"`);
1743
+ return null;
1744
+ }
1745
+ if (from.status === "ok" || from.status === "failed") {
1746
+ ignored(`was already finished as ${from.status}`);
1747
+ return null;
1748
+ }
1749
+ const failed = typeof outcome === "object" && outcome !== null && "error" in outcome;
1750
+ const result = typeof outcome === "string" ? outcome : failed ? void 0 : outcome?.result;
1751
+ const error = failed ? outcome.error : void 0;
1752
+ const finishedAt = self.now();
1753
+ const added = recorder.output() ?? (typeof result === "string" ? capOutput(result) : null);
1754
+ const run = {
1755
+ ...from,
1756
+ status: "running",
1757
+ finishedAt,
1758
+ durationMs: Math.max(0, finishedAt - from.startedAt),
1759
+ error: null,
1760
+ output: joinOutput(from.output, added),
1761
+ metrics: { ...from.metrics, ...recorder.metrics() }
1762
+ };
1763
+ const expectText = joinLines(head, joinLines(from.output, recorder.expectText() ?? (typeof result === "string" ? result : null)));
1764
+ self.conclude(definition, run, result, error, failed, expectText);
1765
+ let why;
1766
+ try {
1767
+ why = await self.recordFinish(definition, run, recorded, finishedAt);
1768
+ } catch (e) {
1769
+ return retryable(e);
1770
+ }
1771
+ if (why) {
1772
+ ignored(why);
1773
+ return null;
1774
+ }
1775
+ return run;
1776
+ });
1777
+ };
1778
+ return {
1779
+ id,
1780
+ job: name,
1781
+ startedAt: base?.startedAt ?? null,
1782
+ get active() {
1783
+ return !finished;
1784
+ },
1785
+ log: (...parts) => recorder.context.log(...parts),
1786
+ metric: (metric, value) => recorder.context.metric(metric, value),
1787
+ metrics: (values) => recorder.context.metrics(values),
1788
+ flush: () => inTurn(async () => {
1789
+ if (finished || !recorded) return;
1790
+ const lines = recorder.output();
1791
+ const metrics = recorder.metrics();
1792
+ if (lines === null && Object.keys(metrics).length === 0) return;
1793
+ const taken = recorder;
1794
+ recorder = fresh();
1795
+ const putBack = () => {
1796
+ const later = recorder;
1797
+ recorder = fresh();
1798
+ for (const text of [taken.expectText(), later.expectText()]) if (text !== null) recorder.context.log(text);
1799
+ recorder.context.metrics({ ...taken.metrics(), ...later.metrics() });
1800
+ };
1801
+ try {
1802
+ const stored = await self.store.getRun(id);
1803
+ if (!stored || stored.status !== "running") return putBack();
1804
+ if (stored.job !== name) {
1805
+ putBack();
1806
+ self.report(new Error(`run ${id} of ${name} belongs to job "${stored.job}"; ignored`), `flushing ${name}`);
1807
+ return;
1808
+ }
1809
+ const output = lines === null ? stored.output : joinOutput(stored.output, stripNul(self.redact(lines)));
1810
+ if (!await self.writeRunIf({ ...stored, output, metrics: { ...stored.metrics, ...metrics } }, ["running"])) return putBack();
1811
+ const text = taken.expectText();
1812
+ if (text !== null && (head === null || head.length < OUTPUT_CAP)) head = (joinLines(head, text) ?? "").slice(0, OUTPUT_CAP);
1813
+ } catch (e) {
1814
+ putBack();
1815
+ self.report(e, `flushing ${name}`);
1816
+ }
1817
+ }),
1818
+ finish,
1819
+ fail: (error) => finish({ error })
1820
+ };
1821
+ }
1822
+ /**
1823
+ * Record a run that happened outside this process, for a Source. Its job
1824
+ * must be declared with job() first. Runs are keyed by id: a new one is
1825
+ * inserted, a stored one still running (or marked timeout by a check) is
1826
+ * finished when this one is not running, and anything else is left alone,
1827
+ * so recording the same run twice changes nothing. Finishing is
1828
+ * conditional (see Store.updateRunIf): when two processes record the same
1829
+ * finish, only the one whose write lands evaluates it, and the other
1830
+ * reports it as already finished. A stored run of another job is left
1831
+ * alone and reported. A finished run is judged as if it had been wrapped
1832
+ * here (expect, failures, duration, budgets) and its output and error are
1833
+ * redacted the same way; one finishing after a check marked it timeout is
1834
+ * judged only when it succeeded, as RunHandle.finish() does. Returns the
1835
+ * alerts it sent.
1836
+ */
1837
+ async recordRun(input, options = {}) {
1838
+ const declared = this.definitions.get(input.job);
1839
+ if (!declared) throw new Error(`recordRun: job "${input.job}" is not declared; call job() first`);
1840
+ await this.sync(declared);
1841
+ const run = { ...input, metrics: { ...input.metrics } };
1842
+ if (run.status === "ok") {
1843
+ const unmet = checkExpectation(declared.expect, run.output);
1844
+ if (unmet) {
1845
+ run.status = "failed";
1846
+ run.error = unmet;
1847
+ }
1848
+ }
1849
+ if (run.output !== null) run.output = stripNul(this.redact(capOutput(run.output)));
1850
+ if (run.error !== null) run.error = stripNul(this.redact(capOutput(run.error)));
1851
+ const evaluate = options.evaluate !== false;
1852
+ const definition = toStored(declared);
1853
+ const stored = await this.store.getRun(run.id);
1854
+ if (stored) return this.recordOver(definition, stored, run, evaluate);
1855
+ try {
1856
+ await this.store.insertRun(run);
1857
+ } catch (e) {
1858
+ const again = await this.store.getRun(run.id).catch(() => null);
1859
+ if (again) return this.recordOver(definition, again, run, evaluate);
1860
+ throw e;
1490
1861
  }
1862
+ if (!evaluate) return [];
1863
+ await this.updateState(run.job, (before) => ({ state: onRunStart(before), result: void 0 }));
1864
+ if (run.status === "running") return [];
1865
+ return this.finishRun(definition, run, this.now());
1866
+ }
1867
+ /** recordRun() for a run already stored. */
1868
+ async recordOver(definition, stored, run, evaluate) {
1869
+ if (stored.job !== run.job) {
1870
+ this.report(new Error(`run ${run.id} of ${run.job} belongs to job "${stored.job}"; ignored`), `recording ${run.job}`);
1871
+ return [];
1872
+ }
1873
+ if (stored.status !== "running" && stored.status !== "timeout" || run.status === "running") return [];
1874
+ const claim = await this.claimFinish(run);
1875
+ if ("ignored" in claim) {
1876
+ this.report(new Error(`run ${run.id} of ${run.job} ${claim.ignored}; ignored`), `recording ${run.job}`);
1877
+ return [];
1878
+ }
1879
+ if (!evaluate || claim.lateAfterTimeout && run.status !== "ok") return [];
1880
+ return this.finishRun(definition, run, this.now());
1491
1881
  }
1492
1882
  /**
1493
- * Record a finished run (ok, failed, or timed out by a check), evaluate it
1494
- * against the job's state and send what that produces. Never throws.
1883
+ * Evaluate a finished run (ok, failed, or timed out by a check), already
1884
+ * written, against the job's state and send what that produces. Never throws.
1495
1885
  */
1496
- async finishRun(definition, run, now, write) {
1886
+ async finishRun(definition, run, now) {
1497
1887
  let drafts;
1498
1888
  try {
1499
- if (write) await this.store.updateRun(run);
1500
1889
  let history = null;
1501
1890
  ({ result: drafts } = await this.updateState(run.job, async (previous) => {
1502
1891
  history ??= await this.history(run);
@@ -1537,9 +1926,16 @@ var CronWatch = class {
1537
1926
  }
1538
1927
  async runCheck() {
1539
1928
  await this.ensureReady();
1929
+ const alerts = [];
1930
+ for (const source of this.sources) {
1931
+ try {
1932
+ alerts.push(...await source.sync(this) ?? []);
1933
+ } catch (e) {
1934
+ this.onError(e, `source ${source.name}`);
1935
+ }
1936
+ }
1540
1937
  for (const definition of this.definitions.values()) await this.sync(definition);
1541
1938
  const now = this.now();
1542
- const alerts = [];
1543
1939
  for (const run of await this.store.runningRuns()) {
1544
1940
  try {
1545
1941
  const declared = this.definitions.get(run.job);
@@ -1550,7 +1946,8 @@ var CronWatch = class {
1550
1946
  run.finishedAt = now;
1551
1947
  run.durationMs = now - run.startedAt;
1552
1948
  run.error = `Still running after ${formatDuration(timeout)}; marked as timed out`;
1553
- alerts.push(...await this.finishRun(definition, run, now, true));
1949
+ if (!await this.writeRunIf(run, ["running"])) continue;
1950
+ alerts.push(...await this.finishRun(definition, run, now));
1554
1951
  } catch (e) {
1555
1952
  this.onError(e, `checking ${run.job}`);
1556
1953
  }
@@ -1668,7 +2065,11 @@ var CronWatch = class {
1668
2065
  routes(options) {
1669
2066
  return createRoutes(this, options);
1670
2067
  }
1671
- /** Check on an interval, for long-running servers. Default every minute. */
2068
+ /**
2069
+ * Check on an interval, for long-running servers. Default every minute.
2070
+ * Not for serverless functions or Cloudflare Workers, where nothing runs
2071
+ * between requests: call check() from a cron there instead.
2072
+ */
1672
2073
  start(every = "1m") {
1673
2074
  if (this.timer) return;
1674
2075
  const ms = Math.max(5e3, parseDuration(every, "check interval"));
@@ -1777,7 +2178,7 @@ var CronWatch = class {
1777
2178
  const results = await Promise.all(
1778
2179
  this.alerts.map(async (channel) => {
1779
2180
  try {
1780
- await withTimeout(channel.send(alert), CHANNEL_TIMEOUT_MS);
2181
+ await withTimeout(channel.send(alert, { onError: (e) => this.report(e, `alert channel ${channel.name}`) }), CHANNEL_TIMEOUT_MS);
1781
2182
  return true;
1782
2183
  } catch (e) {
1783
2184
  this.onError(e, `alert channel ${channel.name}`);
@@ -1801,8 +2202,24 @@ var CronWatch = class {
1801
2202
  }
1802
2203
  }
1803
2204
  };
1804
- function isDevelopment() {
1805
- return process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test";
2205
+ var RESERVED_RUN_ID_PREFIX = "pgcron:";
2206
+ function checkRunId(job, id, method) {
2207
+ if (typeof id !== "string" || id.length === 0 || id.length > 200) {
2208
+ throw new Error(`job "${job}": ${method}() needs a run id of 1 to 200 characters (got ${typeof id === "string" ? `${id.length} characters` : typeof id})`);
2209
+ }
2210
+ if (id.startsWith(RESERVED_RUN_ID_PREFIX)) {
2211
+ throw new Error(`job "${job}": ${method}() cannot take a run id starting with "${RESERVED_RUN_ID_PREFIX}", which the pg_cron source uses for its runs`);
2212
+ }
2213
+ }
2214
+ function joinLines(before, after) {
2215
+ if (before === null || before === "") return after;
2216
+ if (after === null) return before;
2217
+ return `${before}
2218
+ ${after}`;
2219
+ }
2220
+ function joinOutput(before, after) {
2221
+ const joined = joinLines(before, after);
2222
+ return joined === null ? null : capOutput(joined);
1806
2223
  }
1807
2224
  function clampLimit(limit, fallback, min) {
1808
2225
  const n = typeof limit === "number" && Number.isFinite(limit) ? Math.trunc(limit) : fallback;