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