@cronwatch/sdk 0.8.0 → 0.10.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 (73) hide show
  1. package/dist/anthropic.d.cts +1 -1
  2. package/dist/anthropic.d.ts +1 -1
  3. package/dist/bugsnag.d.cts +1 -1
  4. package/dist/bugsnag.d.ts +1 -1
  5. package/dist/{client-B5hM4l-D.d.ts → client-BcIjVBiZ.d.ts} +79 -13
  6. package/dist/{client-Di5HAWQz.d.cts → client-DUoSea7X.d.cts} +79 -13
  7. package/dist/d1.cjs +29 -19
  8. package/dist/d1.cjs.map +1 -1
  9. package/dist/d1.d.cts +1 -1
  10. package/dist/d1.d.ts +1 -1
  11. package/dist/d1.js +29 -19
  12. package/dist/d1.js.map +1 -1
  13. package/dist/datadog.d.cts +1 -1
  14. package/dist/datadog.d.ts +1 -1
  15. package/dist/discord.cjs +17 -2
  16. package/dist/discord.cjs.map +1 -1
  17. package/dist/discord.d.cts +11 -2
  18. package/dist/discord.d.ts +11 -2
  19. package/dist/discord.js +16 -3
  20. package/dist/discord.js.map +1 -1
  21. package/dist/{email-DX6gkd5H.d.cts → email-JZs3vxwq.d.cts} +1 -1
  22. package/dist/{email-Ciq7GRn4.d.ts → email-VrGsVz43.d.ts} +1 -1
  23. package/dist/honeybadger.d.cts +1 -1
  24. package/dist/honeybadger.d.ts +1 -1
  25. package/dist/index.cjs +253 -99
  26. package/dist/index.cjs.map +1 -1
  27. package/dist/index.d.cts +4 -4
  28. package/dist/index.d.ts +4 -4
  29. package/dist/index.js +253 -99
  30. package/dist/index.js.map +1 -1
  31. package/dist/mailgun.d.cts +2 -2
  32. package/dist/mailgun.d.ts +2 -2
  33. package/dist/newrelic.d.cts +1 -1
  34. package/dist/newrelic.d.ts +1 -1
  35. package/dist/pg-cron.cjs +47 -5
  36. package/dist/pg-cron.cjs.map +1 -1
  37. package/dist/pg-cron.d.cts +5 -2
  38. package/dist/pg-cron.d.ts +5 -2
  39. package/dist/pg-cron.js +47 -5
  40. package/dist/pg-cron.js.map +1 -1
  41. package/dist/postgres.cjs +22 -14
  42. package/dist/postgres.cjs.map +1 -1
  43. package/dist/postgres.d.cts +1 -1
  44. package/dist/postgres.d.ts +1 -1
  45. package/dist/postgres.js +22 -14
  46. package/dist/postgres.js.map +1 -1
  47. package/dist/postmark.d.cts +2 -2
  48. package/dist/postmark.d.ts +2 -2
  49. package/dist/resend.d.cts +2 -2
  50. package/dist/resend.d.ts +2 -2
  51. package/dist/rollbar.d.cts +1 -1
  52. package/dist/rollbar.d.ts +1 -1
  53. package/dist/sendgrid.d.cts +2 -2
  54. package/dist/sendgrid.d.ts +2 -2
  55. package/dist/sentry.d.cts +1 -1
  56. package/dist/sentry.d.ts +1 -1
  57. package/dist/ses.d.cts +2 -2
  58. package/dist/ses.d.ts +2 -2
  59. package/dist/slack.d.cts +1 -1
  60. package/dist/slack.d.ts +1 -1
  61. package/dist/sqlite.cjs +26 -18
  62. package/dist/sqlite.cjs.map +1 -1
  63. package/dist/sqlite.d.cts +1 -1
  64. package/dist/sqlite.d.ts +1 -1
  65. package/dist/sqlite.js +26 -18
  66. package/dist/sqlite.js.map +1 -1
  67. package/dist/twilio.d.cts +1 -1
  68. package/dist/twilio.d.ts +1 -1
  69. package/dist/{types-BjBbCGeJ.d.cts → types-DyWCxlSi.d.cts} +20 -1
  70. package/dist/{types-BjBbCGeJ.d.ts → types-DyWCxlSi.d.ts} +20 -1
  71. package/dist/webhook.d.cts +1 -1
  72. package/dist/webhook.d.ts +1 -1
  73. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -268,23 +268,81 @@ function runDuration(startedAt, finishedAt) {
268
268
  const ms = finishedAt - startedAt;
269
269
  return ms > 0 ? Math.min(ms, MAX_DURATION_MS) : 0;
270
270
  }
271
+ function silenceEnd(now, ms) {
272
+ return Math.min(now + Math.floor(Math.min(ms, MAX_DURATION_MS)), MAX_DURATION_MS);
273
+ }
271
274
  function stateVersion(state) {
272
275
  const version = state?.version;
273
276
  return typeof version === "number" && Number.isSafeInteger(version) && version >= 0 ? version + 0 : 0;
274
277
  }
278
+ function failureCount(state) {
279
+ const count = state?.consecutiveFailures;
280
+ if (typeof count !== "number" || !Number.isInteger(count) || count <= 0) return 0;
281
+ return Math.min(count, MAX_DURATION_MS);
282
+ }
275
283
  function emptyState(job) {
276
284
  return { job, open: {}, consecutiveFailures: 0, silencedUntil: null, lastAlertAt: null, pendingRecovery: [], undelivered: [] };
277
285
  }
278
286
  function normalizeState(state, job) {
279
287
  if (!state) return emptyState(job);
288
+ const { sending, ...rest } = state;
280
289
  return {
281
290
  ...emptyState(job),
282
- ...state,
291
+ ...rest,
283
292
  open: { ...state.open },
293
+ consecutiveFailures: failureCount(state),
284
294
  pendingRecovery: [...state.pendingRecovery ?? []],
285
- undelivered: [...state.undelivered ?? []]
295
+ undelivered: [...state.undelivered ?? []],
296
+ ...Array.isArray(sending) && sending.length > 0 ? { sending: [...sending] } : {}
286
297
  };
287
298
  }
299
+ var MAX_UNDELIVERED = 20;
300
+ var SEND_LEASE_MS = 5 * 6e4;
301
+ function alertKey(alert) {
302
+ return `${alert.type}|${alert.at}|${alert.run?.id ?? ""}`;
303
+ }
304
+ function newest(list, max) {
305
+ return { kept: list.slice(-max), dropped: Math.max(0, list.length - max) };
306
+ }
307
+ function queueUndelivered(state, alerts) {
308
+ const next = cloneState(state);
309
+ const byKey = new Map(alerts.map((alert) => [alertKey(alert), alert]));
310
+ const queue = next.undelivered.map((alert) => byKey.get(alertKey(alert)) ?? alert);
311
+ const known = new Set(queue.map(alertKey));
312
+ queue.push(...alerts.filter((alert) => !known.has(alertKey(alert))));
313
+ const { kept, dropped } = newest(queue, MAX_UNDELIVERED);
314
+ next.undelivered = kept;
315
+ return { state: next, dropped };
316
+ }
317
+ function holdAlerts(state, alerts, until, deferred) {
318
+ if (alerts.length === 0) return { state, dropped: 0 };
319
+ if (deferred) return queueUndelivered(state, alerts);
320
+ const next = cloneState(state);
321
+ const { kept, dropped } = newest([...next.sending ?? [], ...alerts.map((alert) => ({ until, alert }))], MAX_UNDELIVERED);
322
+ next.sending = kept;
323
+ return { state: next, dropped };
324
+ }
325
+ function releaseSending(state, now) {
326
+ const sending = state.sending ?? [];
327
+ const lapsed = sending.filter((entry) => !(typeof entry?.until === "number" && entry.until > now));
328
+ if (lapsed.length === 0) return { state, dropped: 0 };
329
+ const held = sending.filter((entry) => !lapsed.includes(entry));
330
+ return queueUndelivered(
331
+ { ...state, sending: held },
332
+ lapsed.filter((entry) => typeof entry?.alert === "object" && entry.alert !== null).map((entry) => entry.alert)
333
+ );
334
+ }
335
+ function recordSent(state, delivered, failed, stale, now) {
336
+ const next = cloneState(state);
337
+ const done = new Set([...delivered, ...stale].map(alertKey));
338
+ next.undelivered = next.undelivered.filter((alert) => !done.has(alertKey(alert)));
339
+ const sent = new Set([...delivered, ...failed, ...stale].map(alertKey));
340
+ const held = (next.sending ?? []).filter((entry) => !(typeof entry?.alert === "object" && entry.alert !== null && sent.has(alertKey(entry.alert))));
341
+ if (held.length > 0) next.sending = held;
342
+ else delete next.sending;
343
+ if (delivered.length > 0) next.lastAlertAt = now;
344
+ return queueUndelivered(next, failed);
345
+ }
288
346
  function cloneState(state) {
289
347
  return normalizeState(state, state.job);
290
348
  }
@@ -379,7 +437,7 @@ function onRunFinish(def, run, state, history, now) {
379
437
  }
380
438
  return { state: next, alerts };
381
439
  }
382
- next.consecutiveFailures += 1;
440
+ next.consecutiveFailures = Math.min(next.consecutiveFailures + 1, MAX_DURATION_MS);
383
441
  closeCondition(next, "missed");
384
442
  const threshold = Math.max(1, def.failuresBeforeAlert ?? 1);
385
443
  const condition = run.status === "timeout" ? "stuck" : "failed";
@@ -588,13 +646,22 @@ var OUTPUT_CAP = 16 * 1024;
588
646
  function capOutput(text) {
589
647
  const clean = stripNul(text);
590
648
  if (clean.length <= OUTPUT_CAP) return clean;
591
- return "[earlier output trimmed]\n" + clean.slice(clean.length - OUTPUT_CAP);
649
+ return TRIMMED + clean.slice(clean.length - OUTPUT_CAP);
650
+ }
651
+ var TRIMMED = "[earlier output trimmed]\n";
652
+ var REDACT_EDGE = 3 * (OUTPUT_CAP + 1024);
653
+ function redactAndCap(text, redact) {
654
+ const clean = stripNul(text);
655
+ const from = clean.length - (OUTPUT_CAP + REDACT_EDGE);
656
+ if (from <= 0) return capOutput(redact(clean));
657
+ const redacted = stripNul(redact(clean.slice(from)));
658
+ return TRIMMED + redacted.slice(Math.max(redacted.length - OUTPUT_CAP, REDACT_EDGE));
592
659
  }
593
660
  function stripNul(text) {
594
661
  return text.includes("\0") ? text.replace(/\u0000/g, "") : text;
595
662
  }
596
- function errorMessage(error) {
597
- return capOutput(describeError(error));
663
+ function stripJsonNul(json2) {
664
+ return json2.includes("\\u0000") ? json2.replace(/\\(u0000|[\s\S])/g, (escape, next) => next === "u0000" ? "" : escape) : json2;
598
665
  }
599
666
  function describeError(error) {
600
667
  if (error instanceof Error) {
@@ -704,7 +771,7 @@ function createRecorder(run) {
704
771
  };
705
772
  return {
706
773
  context,
707
- output: () => lines.length === 0 ? null : capOutput(lines.join("\n")),
774
+ output: () => lines.length === 0 ? null : lines.join("\n"),
708
775
  expectText: () => {
709
776
  if (lines.length === 0) return null;
710
777
  const all = lines.join("\n");
@@ -1445,7 +1512,7 @@ function jobPage(job, runs, now, base, complete = true) {
1445
1512
  run.error ? `<details class="out error" open><summary>error</summary><pre>${h(run.error)}</pre></details>` : "",
1446
1513
  run.output ? `<details class="out"${run.status === "ok" ? "" : " open"}><summary>output</summary><pre>${h(run.output)}</pre></details>` : ""
1447
1514
  ].join("");
1448
- 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("");
1515
+ 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("");
1449
1516
  return `<tr${detail ? ` class="has-detail"` : ""}>
1450
1517
  <td class="nowrap">${runState(run)}</td>
1451
1518
  <td class="nowrap">${h(when2(run.startedAt, now))} <span class="muted">UTC</span><span class="sub">${stamp(run.startedAt, now)}</span></td>
@@ -1845,13 +1912,21 @@ function memory() {
1845
1912
  const states = /* @__PURE__ */ new Map();
1846
1913
  let seq = 0;
1847
1914
  const clone = (v) => JSON.parse(JSON.stringify(v));
1915
+ const kept = (v) => JSON.parse(stripJsonNul(JSON.stringify(v)));
1916
+ const keptRun = (run) => ({
1917
+ ...clone(run),
1918
+ trigger: stripNul(run.trigger),
1919
+ output: run.output === null ? null : stripNul(run.output),
1920
+ error: run.error === null ? null : stripNul(run.error),
1921
+ metrics: kept(run.metrics)
1922
+ });
1848
1923
  const byName = (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
1849
1924
  return {
1850
1925
  async upsertJob(definition, now) {
1851
1926
  const existing = jobs.get(definition.name);
1852
1927
  jobs.set(definition.name, {
1853
1928
  name: definition.name,
1854
- definition: clone(definition),
1929
+ definition: kept(definition),
1855
1930
  createdAt: existing?.createdAt ?? now,
1856
1931
  updatedAt: now
1857
1932
  });
@@ -1873,19 +1948,19 @@ function memory() {
1873
1948
  },
1874
1949
  async insertRun(run) {
1875
1950
  if (runs.has(run.id)) throw new Error(`run ${run.id} already exists`);
1876
- runs.set(run.id, clone(run));
1951
+ runs.set(run.id, keptRun(run));
1877
1952
  order.set(run.id, ++seq);
1878
1953
  },
1879
1954
  async updateRun(run) {
1880
1955
  const existing = runs.get(run.id);
1881
1956
  if (!existing) return;
1882
- const { status, finishedAt, durationMs, error, output, metrics } = clone(run);
1957
+ const { status, finishedAt, durationMs, error, output, metrics } = keptRun(run);
1883
1958
  runs.set(run.id, { ...existing, status, finishedAt, durationMs, error, output, metrics });
1884
1959
  },
1885
1960
  async updateRunIf(run, fromStatuses) {
1886
1961
  const existing = runs.get(run.id);
1887
1962
  if (!existing || !fromStatuses.includes(existing.status)) return false;
1888
- const { status, finishedAt, durationMs, error, output, metrics } = clone(run);
1963
+ const { status, finishedAt, durationMs, error, output, metrics } = keptRun(run);
1889
1964
  runs.set(run.id, { ...existing, status, finishedAt, durationMs, error, output, metrics });
1890
1965
  return true;
1891
1966
  },
@@ -1908,19 +1983,19 @@ function memory() {
1908
1983
  return s ? clone(s) : null;
1909
1984
  },
1910
1985
  async setState(state) {
1911
- states.set(state.job, clone(state));
1986
+ states.set(state.job, kept(state));
1912
1987
  },
1913
1988
  async compareAndSetState(state, expectedVersion) {
1914
1989
  if (stateVersion(states.get(state.job)) !== expectedVersion) return false;
1915
- states.set(state.job, clone(state));
1990
+ states.set(state.job, kept(state));
1916
1991
  return true;
1917
1992
  },
1918
1993
  async prune(before) {
1919
- const newest = /* @__PURE__ */ new Map();
1920
- for (const run of runs.values()) newest.set(run.job, Math.max(newest.get(run.job) ?? -Infinity, run.startedAt));
1994
+ const newest2 = /* @__PURE__ */ new Map();
1995
+ for (const run of runs.values()) newest2.set(run.job, Math.max(newest2.get(run.job) ?? -Infinity, run.startedAt));
1921
1996
  let n = 0;
1922
1997
  for (const [id, run] of runs) {
1923
- if (run.status !== "running" && run.startedAt < before && run.startedAt < newest.get(run.job)) {
1998
+ if (run.status !== "running" && run.startedAt < before && run.startedAt < newest2.get(run.job)) {
1924
1999
  runs.delete(id);
1925
2000
  order.delete(id);
1926
2001
  n++;
@@ -1936,7 +2011,6 @@ var NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/;
1936
2011
  var TRIAGE_TIMEOUT_MS = 25e3;
1937
2012
  var CHANNEL_TIMEOUT_MS = 15e3;
1938
2013
  var PRUNE_INTERVAL_MS = 60 * 6e4;
1939
- var MAX_UNDELIVERED = 20;
1940
2014
  var RETRY_BUDGET_MS = 2e4;
1941
2015
  var STATE_ATTEMPTS = 10;
1942
2016
  var HISTORY_PAGE = BASELINE_WINDOW + 5;
@@ -1977,9 +2051,6 @@ function validateDefinition(def) {
1977
2051
  function sameState(a, b) {
1978
2052
  return JSON.stringify(a) === JSON.stringify(b);
1979
2053
  }
1980
- function alertKey(alert) {
1981
- return `${alert.type}|${alert.at}|${alert.run?.id ?? ""}`;
1982
- }
1983
2054
  var CronWatch = class {
1984
2055
  store;
1985
2056
  alerts;
@@ -2000,6 +2071,8 @@ var CronWatch = class {
2000
2071
  synced = /* @__PURE__ */ new Set();
2001
2072
  /** The tail of each job's queue of state updates. See serial(). */
2002
2073
  queues = /* @__PURE__ */ new Map();
2074
+ /** The tail of each job's queue of declaration writes. See sync(). */
2075
+ syncing = /* @__PURE__ */ new Map();
2003
2076
  /** start() calls with an id still in flight, so two at once in this process record one run. */
2004
2077
  starting = /* @__PURE__ */ new Map();
2005
2078
  ready = null;
@@ -2138,26 +2211,69 @@ var CronWatch = class {
2138
2211
  }
2139
2212
  await this.ready;
2140
2213
  }
2141
- async sync(definition) {
2214
+ /**
2215
+ * Writes the declaration of `definition`'s name as it stands, unless the
2216
+ * store has it. A handle kept from an earlier declaration writes the one
2217
+ * that replaced it, never its own over it, and one forgotten since writes
2218
+ * its own. The writes of one name take turns, in the order they were asked
2219
+ * for, so one still under way cannot land after a later one; and a name
2220
+ * declared again while its write was under way is still to be written. A
2221
+ * name is marked as written only while that same declaration stands, so a
2222
+ * forget that lands during the write (deleting the row after it) leaves
2223
+ * the name to be written again, as does one forgotten before it.
2224
+ *
2225
+ * With `confirm`, as a run starts, a name already written is read back:
2226
+ * another process may have forgotten the job since, and a job still
2227
+ * declared here comes back on its next run.
2228
+ */
2229
+ async sync(definition, confirm = false) {
2142
2230
  await this.ensureReady();
2143
- if (this.synced.has(definition.name)) return;
2144
- await this.store.upsertJob(toStored(definition), this.now());
2145
- this.synced.add(definition.name);
2231
+ const name = definition.name;
2232
+ if (this.synced.has(name)) {
2233
+ if (!confirm || await this.store.getJob(name)) return;
2234
+ this.synced.delete(name);
2235
+ }
2236
+ await this.serial(name, async () => {
2237
+ if (this.synced.has(name)) return;
2238
+ const standing = this.definitions.get(name) ?? definition;
2239
+ await this.store.upsertJob(toStored(standing), this.now());
2240
+ if (this.definitions.get(name) === standing) this.synced.add(name);
2241
+ }, this.syncing);
2242
+ }
2243
+ /**
2244
+ * Every stored job, once each declaration has been written. A job declared
2245
+ * here that the store no longer has was forgotten by another process after
2246
+ * this one wrote it: it is written again, as its next run would, so it is
2247
+ * checked and shown while any process still declares it.
2248
+ */
2249
+ async storedJobs() {
2250
+ for (const definition of this.definitions.values()) await this.sync(definition);
2251
+ const jobs = await this.store.listJobs();
2252
+ const listed = new Set(jobs.map((job) => job.name));
2253
+ const missing = [...this.definitions.values()].filter((definition) => !listed.has(definition.name));
2254
+ if (missing.length === 0) return jobs;
2255
+ for (const definition of missing) {
2256
+ if (this.definitions.get(definition.name) !== definition) continue;
2257
+ this.synced.delete(definition.name);
2258
+ await this.sync(definition);
2259
+ }
2260
+ return this.store.listJobs();
2146
2261
  }
2147
2262
  /**
2148
2263
  * Runs `fn` after every earlier state update for the same job has settled,
2149
2264
  * so two runs (or a run and a check) in this process never read and write
2150
2265
  * the job's state over each other. Other processes are coordinated by
2151
- * updateState() instead.
2266
+ * updateState() instead. sync() queues a job's declaration writes the same
2267
+ * way, in `syncing`.
2152
2268
  */
2153
- serial(job, fn) {
2154
- const result = (this.queues.get(job) ?? Promise.resolve()).then(fn);
2269
+ serial(job, fn, queues = this.queues) {
2270
+ const result = (queues.get(job) ?? Promise.resolve()).then(fn);
2155
2271
  const tail2 = result.then(() => {
2156
2272
  }, () => {
2157
2273
  });
2158
- this.queues.set(job, tail2);
2274
+ queues.set(job, tail2);
2159
2275
  void tail2.then(() => {
2160
- if (this.queues.get(job) === tail2) this.queues.delete(job);
2276
+ if (queues.get(job) === tail2) queues.delete(job);
2161
2277
  });
2162
2278
  return result;
2163
2279
  }
@@ -2218,7 +2334,7 @@ var CronWatch = class {
2218
2334
  };
2219
2335
  let recorded = false;
2220
2336
  try {
2221
- await this.sync(definition);
2337
+ await this.sync(definition, true);
2222
2338
  await this.store.insertRun({ ...run });
2223
2339
  recorded = true;
2224
2340
  } catch (e) {
@@ -2243,7 +2359,7 @@ var CronWatch = class {
2243
2359
  run.finishedAt = finishedAt;
2244
2360
  run.durationMs = runDuration(startedAt, finishedAt);
2245
2361
  run.metrics = recorder.metrics();
2246
- run.output = recorder.output() ?? (typeof result === "string" ? capOutput(result) : null);
2362
+ run.output = recorder.output() ?? (typeof result === "string" ? result : null);
2247
2363
  this.conclude(definition, run, result, error, threw, recorder.expectText() ?? (typeof result === "string" ? result : null));
2248
2364
  await started;
2249
2365
  try {
@@ -2256,12 +2372,13 @@ var CronWatch = class {
2256
2372
  }
2257
2373
  /**
2258
2374
  * Sets a finished run's status and error from how it ended, then redacts
2259
- * its output and error. Shared by execute() and RunHandle.finish().
2375
+ * its output and error and caps them, in that order. Shared by execute()
2376
+ * and RunHandle.finish().
2260
2377
  */
2261
2378
  conclude(definition, run, result, error, threw, expectText) {
2262
2379
  if (threw) {
2263
2380
  run.status = "failed";
2264
- run.error = errorMessage(error);
2381
+ run.error = describeError(error);
2265
2382
  } else if (result instanceof Response && result.status >= 400) {
2266
2383
  run.status = "failed";
2267
2384
  run.error = `HTTP ${result.status}${result.statusText ? ` ${result.statusText}` : ""}`;
@@ -2274,8 +2391,8 @@ var CronWatch = class {
2274
2391
  run.status = "ok";
2275
2392
  }
2276
2393
  }
2277
- if (run.output !== null) run.output = stripNul(this.redact(run.output));
2278
- if (run.error !== null) run.error = stripNul(this.redact(run.error));
2394
+ if (run.output !== null) run.output = redactAndCap(run.output, this.redact);
2395
+ if (run.error !== null) run.error = redactAndCap(run.error, this.redact);
2279
2396
  }
2280
2397
  /**
2281
2398
  * Writes a finished run and evaluates it. `recorded` says whether its
@@ -2370,7 +2487,7 @@ ${id}`;
2370
2487
  };
2371
2488
  let recorded = false;
2372
2489
  try {
2373
- await this.sync(definition);
2490
+ await this.sync(definition, true);
2374
2491
  await this.store.insertRun({ ...run });
2375
2492
  recorded = true;
2376
2493
  } catch (e) {
@@ -2470,14 +2587,15 @@ ${id}`;
2470
2587
  const result = typeof outcome === "string" ? outcome : failed ? void 0 : outcome?.result;
2471
2588
  const error = failed ? outcome.error : void 0;
2472
2589
  const finishedAt = self.now();
2473
- const added = recorder.output() ?? (typeof result === "string" ? capOutput(result) : null);
2590
+ const added = recorder.output() ?? (typeof result === "string" ? result : null);
2474
2591
  const run = {
2475
2592
  ...from,
2476
2593
  status: "running",
2477
2594
  finishedAt,
2478
2595
  durationMs: runDuration(from.startedAt, finishedAt),
2479
2596
  error: null,
2480
- output: joinOutput(from.output, added),
2597
+ // Capped by conclude(), after it is redacted.
2598
+ output: joinLines(from.output, added),
2481
2599
  metrics: { ...from.metrics, ...recorder.metrics() }
2482
2600
  };
2483
2601
  const expectText = joinLines(head, joinLines(from.output, recorder.expectText() ?? (typeof result === "string" ? result : null)));
@@ -2526,7 +2644,7 @@ ${id}`;
2526
2644
  self.report(new Error(`run ${id} of ${name} belongs to job "${stored.job}"; ignored`), `flushing ${name}`);
2527
2645
  return;
2528
2646
  }
2529
- const output = lines === null ? stored.output : joinOutput(stored.output, stripNul(self.redact(lines)));
2647
+ const output = lines === null ? stored.output : joinOutput(stored.output, redactAndCap(lines, self.redact));
2530
2648
  if (!await self.writeRunIf({ ...stored, output, metrics: { ...stored.metrics, ...metrics } }, ["running"])) return putBack();
2531
2649
  const text = taken.expectText();
2532
2650
  if (text !== null && (head === null || head.length < OUTPUT_CAP)) head = (joinLines(head, text) ?? "").slice(0, OUTPUT_CAP);
@@ -2551,12 +2669,19 @@ ${id}`;
2551
2669
  * alone and reported. A finished run is judged as if it had been wrapped
2552
2670
  * here (expect, failures, duration, budgets) and its output and error are
2553
2671
  * redacted the same way; one finishing after a check marked it timeout is
2554
- * judged only when it succeeded, as RunHandle.finish() does. Returns the
2555
- * alerts it sent.
2672
+ * judged only when it succeeded, as RunHandle.finish() does. A metric that
2673
+ * is not a finite number throws before anything is written, as
2674
+ * job.metric() does. Returns the alerts it sent.
2556
2675
  */
2557
2676
  async recordRun(input, options = {}) {
2558
2677
  const declared = this.definitions.get(input.job);
2559
2678
  if (!declared) throw new Error(`recordRun: job "${input.job}" is not declared; call job() first`);
2679
+ if (input.id.includes("\0")) throw new Error(`recordRun: run ids cannot contain a NUL character (job "${input.job}")`);
2680
+ for (const [metric, value] of Object.entries(input.metrics ?? {})) {
2681
+ if (typeof value !== "number" || !Number.isFinite(value)) {
2682
+ throw new Error(`recordRun: metric "${metric}" must be a finite number (job "${input.job}", run "${input.id}")`);
2683
+ }
2684
+ }
2560
2685
  await this.sync(declared);
2561
2686
  const run = { ...input, metrics: { ...input.metrics } };
2562
2687
  if (run.status === "ok") {
@@ -2566,8 +2691,8 @@ ${id}`;
2566
2691
  run.error = unmet;
2567
2692
  }
2568
2693
  }
2569
- if (run.output !== null) run.output = stripNul(this.redact(capOutput(run.output)));
2570
- if (run.error !== null) run.error = stripNul(this.redact(capOutput(run.error)));
2694
+ if (run.output !== null) run.output = redactAndCap(run.output, this.redact);
2695
+ if (run.error !== null) run.error = redactAndCap(run.error, this.redact);
2571
2696
  const evaluate = options.evaluate !== false;
2572
2697
  const definition = toStored(declared);
2573
2698
  const stored = await this.store.getRun(run.id);
@@ -2601,22 +2726,42 @@ ${id}`;
2601
2726
  }
2602
2727
  /**
2603
2728
  * Evaluate a finished run (ok, failed, or timed out by a check), already
2604
- * written, against the job's state and send what that produces. Never throws.
2729
+ * written, against the job's state and send what that produces. The
2730
+ * alerts are written with that state (see outbox()). Never throws.
2605
2731
  */
2606
2732
  async finishRun(definition, run, now) {
2607
- let drafts;
2733
+ let held;
2608
2734
  try {
2609
2735
  let history = null;
2610
- ({ result: drafts } = await this.updateState(run.job, async (previous) => {
2736
+ ({ result: held } = await this.updateState(run.job, async (previous) => {
2611
2737
  history ??= await this.history(run);
2612
- const settled = applySilence(previous, onRunFinish(definition, run, previous, history, now), now);
2613
- return { state: settled.state, result: settled.alerts };
2738
+ return this.outbox(applySilence(previous, onRunFinish(definition, run, previous, history, now), now), definition, now);
2614
2739
  }));
2615
2740
  } catch (e) {
2616
2741
  this.onError(e, `evaluating ${run.job}`);
2617
2742
  return [];
2618
2743
  }
2619
- return this.dispatch(drafts, definition, now);
2744
+ this.reportDropped(run.job, held.dropped);
2745
+ return this.dispatch(run.job, held.alerts, now);
2746
+ }
2747
+ /**
2748
+ * An evaluation as it is written: its drafts composed into alerts and held
2749
+ * in the same state (holdAlerts), so the write that opens a condition also
2750
+ * keeps its alerts, and a process that stops before sending them does not
2751
+ * lose them. Called inside updateState(), so it only computes.
2752
+ */
2753
+ outbox(settled, definition, now) {
2754
+ const alerts = settled.alerts.map((draft) => composeAlert(draft, definition, now));
2755
+ const { state, dropped } = holdAlerts(settled.state, alerts, this.now() + SEND_LEASE_MS, this.deferDelivery);
2756
+ return { state, result: { alerts, dropped } };
2757
+ }
2758
+ /** Reports alerts let go because a job's queue was full. */
2759
+ reportDropped(name, dropped) {
2760
+ if (dropped <= 0) return;
2761
+ this.report(
2762
+ new Error(`${dropped} undelivered alert${dropped === 1 ? "" : "s"} for ${name} dropped: only the newest ${MAX_UNDELIVERED} are kept for retry`),
2763
+ `alert queue for ${name}`
2764
+ );
2620
2765
  }
2621
2766
  /**
2622
2767
  * The runs before `run`, newest first, with up to BASELINE_WINDOW
@@ -2656,11 +2801,13 @@ ${id}`;
2656
2801
  }
2657
2802
  for (const definition of this.definitions.values()) await this.sync(definition);
2658
2803
  const now = this.now();
2659
- for (const run of await this.store.runningRuns()) {
2804
+ for (const listed of await this.store.runningRuns()) {
2660
2805
  try {
2661
- const declared = this.definitions.get(run.job);
2662
- const definition = declared ? toStored(declared) : (await this.store.getJob(run.job))?.definition;
2663
- if (!definition || !isStuck(definition, run, now)) continue;
2806
+ const declared = this.definitions.get(listed.job);
2807
+ const definition = declared ? toStored(declared) : (await this.store.getJob(listed.job))?.definition;
2808
+ if (!definition || !isStuck(definition, listed, now)) continue;
2809
+ const run = await this.store.getRun(listed.id);
2810
+ if (!run || run.status !== "running" || run.job !== listed.job) continue;
2664
2811
  const timeout = timeoutMs(definition);
2665
2812
  run.status = "timeout";
2666
2813
  run.finishedAt = now;
@@ -2669,23 +2816,26 @@ ${id}`;
2669
2816
  if (!await this.writeRunIf(run, ["running"])) continue;
2670
2817
  alerts.push(...await this.finishRun(definition, run, now));
2671
2818
  } catch (e) {
2672
- this.onError(e, `checking ${run.job}`);
2819
+ this.onError(e, `checking ${listed.job}`);
2673
2820
  }
2674
2821
  }
2675
2822
  const jobs = [];
2676
2823
  const retries = { spentMs: 0 };
2677
- for (const stored of await this.store.listJobs()) {
2824
+ for (const stored of await this.storedJobs()) {
2678
2825
  try {
2679
2826
  const recent = await this.store.listRuns(stored.name, BASELINE_WINDOW);
2680
2827
  let nextExpectedAt = null;
2681
- const { state, result: drafts } = await this.updateState(stored.name, (previous) => {
2828
+ const { state, result: held } = await this.updateState(stored.name, (previous) => {
2682
2829
  const evaluation = onCheck(stored.definition, stored, recent[0] ?? null, previous, now);
2683
2830
  nextExpectedAt = evaluation.nextExpectedAt;
2684
2831
  const settled = applySilence(previous, evaluation, now);
2685
- return { state: settled.state, result: settled.alerts };
2832
+ const released = releaseSending(settled.state, this.now());
2833
+ const out = this.outbox({ state: released.state, alerts: settled.alerts }, stored.definition, now);
2834
+ return { state: out.state, result: { alerts: out.result.alerts, dropped: released.dropped + out.result.dropped } };
2686
2835
  });
2836
+ this.reportDropped(stored.name, held.dropped);
2687
2837
  alerts.push(...await this.retryUndelivered(stored.name, state, now, retries));
2688
- alerts.push(...await this.dispatch(drafts, stored.definition, now));
2838
+ alerts.push(...await this.dispatch(stored.name, held.alerts, now));
2689
2839
  jobs.push(summarize(stored, recent, state, nextExpectedAt, now));
2690
2840
  } catch (e) {
2691
2841
  this.onError(e, `checking ${stored.name}`);
@@ -2729,16 +2879,17 @@ ${id}`;
2729
2879
  /** Every job's summary with its newest `limit` runs, read together. What the dashboard shows. */
2730
2880
  async jobsWithRuns(limit = 20) {
2731
2881
  await this.ensureReady();
2732
- for (const definition of this.definitions.values()) await this.sync(definition);
2882
+ const jobs = await this.storedJobs();
2733
2883
  const now = this.now();
2734
2884
  const out = [];
2735
- for (const stored of await this.store.listJobs()) out.push(await this.snapshot(stored, now, clampLimit(limit, 20, 0)));
2885
+ for (const stored of jobs) out.push(await this.snapshot(stored, now, clampLimit(limit, 20, 0)));
2736
2886
  return out;
2737
2887
  }
2888
+ /** 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. */
2738
2889
  async jobSummary(name) {
2739
2890
  await this.ensureReady();
2740
2891
  const definition = this.definitions.get(name);
2741
- if (definition) await this.sync(definition);
2892
+ if (definition) await this.sync(definition, true);
2742
2893
  const stored = await this.store.getJob(name);
2743
2894
  if (!stored) return null;
2744
2895
  return (await this.snapshot(stored, this.now(), 0)).job;
@@ -2752,11 +2903,14 @@ ${id}`;
2752
2903
  await this.ensureReady();
2753
2904
  return this.store.getRun(id);
2754
2905
  }
2755
- /** Stop alerts for a job for a while. State keeps updating underneath. */
2906
+ /**
2907
+ * Stop alerts for a job for a while. State keeps updating underneath. The
2908
+ * end is a whole millisecond, held at 2^53 - 1 (see silenceEnd).
2909
+ */
2756
2910
  async silence(name, duration) {
2757
2911
  const ms = parseDuration(duration, "silence duration");
2758
2912
  return this.patchState(name, (state) => {
2759
- state.silencedUntil = this.now() + ms;
2913
+ state.silencedUntil = silenceEnd(this.now(), ms);
2760
2914
  });
2761
2915
  }
2762
2916
  async unsilence(name) {
@@ -2774,7 +2928,11 @@ ${id}`;
2774
2928
  });
2775
2929
  return state;
2776
2930
  }
2777
- /** Remove a job and its runs from the store. A job still declared in code comes back on its next run. */
2931
+ /**
2932
+ * Remove a job and its runs from the store. A job still declared in code
2933
+ * comes back: here on its next run, and in any other process that declares
2934
+ * it on its next run there, or at that process's next check or dashboard read.
2935
+ */
2778
2936
  async forget(name) {
2779
2937
  await this.ensureReady();
2780
2938
  this.definitions.delete(name);
@@ -2812,32 +2970,37 @@ ${id}`;
2812
2970
  this.timer = null;
2813
2971
  this.firstTick = null;
2814
2972
  }
2973
+ /**
2974
+ * Stop the interval, wait for a check already under way (bounded by its
2975
+ * own channel, triage and retry timeouts; what it throws was reported to
2976
+ * whoever started it), then close the store, so that check neither writes
2977
+ * after the store is closed nor loses the alerts it would queue.
2978
+ */
2815
2979
  async close() {
2816
2980
  this.stop();
2981
+ if (this.checking) await this.checking.catch(() => {
2982
+ });
2817
2983
  if (this.store.close) await this.store.close();
2818
2984
  }
2819
2985
  /**
2820
- * Compose, triage and send each draft. The state was saved before this
2821
- * (updateState), so a slow channel holds up nothing else; afterwards only
2822
- * the delivery fields are written back, onto a fresh read of the state.
2986
+ * Triage and send each alert the outbox holds (see outbox()). The state,
2987
+ * with the alerts in it, was saved before this, so a slow channel holds up
2988
+ * nothing else; afterwards only the delivery fields are written back, onto
2989
+ * a fresh read of the state, and the alerts leave `sending`. Triage is made
2990
+ * here, never stored with the held alert: the write that opens a condition
2991
+ * cannot wait for it, and a retry triages an alert that has none. With
2992
+ * deliver: "check" the alerts were queued for a check elsewhere instead.
2823
2993
  */
2824
- async dispatch(drafts, definition, now) {
2825
- if (drafts.length === 0) return [];
2826
- const composed = [];
2994
+ async dispatch(name, alerts, now) {
2995
+ if (alerts.length === 0 || this.deferDelivery) return alerts;
2827
2996
  const delivered = [];
2828
2997
  const failed = [];
2829
- for (const draft of drafts) {
2830
- const alert = composeAlert(draft, definition, now);
2831
- if (this.deferDelivery) {
2832
- failed.push(alert);
2833
- } else {
2834
- if (this.triage && alert.type !== "recovered") await this.addTriage(alert, TRIAGE_TIMEOUT_MS);
2835
- (await this.deliver(alert) ? delivered : failed).push(alert);
2836
- }
2837
- composed.push(alert);
2998
+ for (const alert of alerts) {
2999
+ if (this.triage && alert.type !== "recovered") await this.addTriage(alert, TRIAGE_TIMEOUT_MS);
3000
+ (await this.deliver(alert) ? delivered : failed).push(alert);
2838
3001
  }
2839
- await this.recordDelivery(definition.name, delivered, failed, [], now);
2840
- return composed;
3002
+ await this.recordDelivery(name, delivered, failed, [], now);
3003
+ return alerts;
2841
3004
  }
2842
3005
  /**
2843
3006
  * Send the alerts that no channel accepted last time, once each, oldest
@@ -2866,28 +3029,18 @@ ${id}`;
2866
3029
  }
2867
3030
  /**
2868
3031
  * Mark delivered alerts done, drop stale ones, and keep failed ones for the
2869
- * next check. A failed alert replaces its stored copy, so a triage made on
2870
- * this attempt is kept. lastAlertAt moves only on a delivery.
3032
+ * next check, taking them all out of `sending` (recordSent). A failed alert
3033
+ * replaces its stored copy, so a triage made on this attempt is kept.
3034
+ * lastAlertAt moves only on a delivery. When this write fails, alerts
3035
+ * still in `sending` are retried once their lease runs out.
2871
3036
  */
2872
3037
  async recordDelivery(name, delivered, failed, dropped, now) {
2873
3038
  try {
2874
3039
  const { result: trimmed } = await this.updateState(name, (previous) => {
2875
- const state = normalizeState(previous, name);
2876
- const done = new Set([...delivered, ...dropped].map(alertKey));
2877
- const retried = new Map(failed.map((a) => [alertKey(a), a]));
2878
- const kept = state.undelivered.filter((a) => !done.has(alertKey(a))).map((a) => retried.get(alertKey(a)) ?? a);
2879
- const known = new Set(kept.map(alertKey));
2880
- kept.push(...failed.filter((a) => !known.has(alertKey(a))));
2881
- state.undelivered = kept.slice(-MAX_UNDELIVERED);
2882
- if (delivered.length > 0) state.lastAlertAt = now;
2883
- return { state, result: Math.max(0, kept.length - MAX_UNDELIVERED) };
3040
+ const { state, dropped: letGo } = recordSent(normalizeState(previous, name), delivered, failed, dropped, now);
3041
+ return { state, result: letGo };
2884
3042
  });
2885
- if (trimmed > 0) {
2886
- this.onError(
2887
- new Error(`${trimmed} undelivered alert${trimmed === 1 ? "" : "s"} for ${name} dropped: only the newest ${MAX_UNDELIVERED} are kept for retry`),
2888
- `alert queue for ${name}`
2889
- );
2890
- }
3043
+ this.reportDropped(name, trimmed);
2891
3044
  } catch (e) {
2892
3045
  this.onError(e, `recording alert delivery for ${name}`);
2893
3046
  }
@@ -2927,6 +3080,7 @@ function checkRunId(job, id, method) {
2927
3080
  if (typeof id !== "string" || id.length === 0 || id.length > 200) {
2928
3081
  throw new Error(`job "${job}": ${method}() needs a run id of 1 to 200 characters (got ${typeof id === "string" ? `${id.length} characters` : typeof id})`);
2929
3082
  }
3083
+ if (id.includes("\0")) throw new Error(`job "${job}": ${method}() cannot take a run id containing a NUL character`);
2930
3084
  if (id.startsWith(RESERVED_RUN_ID_PREFIX)) {
2931
3085
  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`);
2932
3086
  }