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