@cronwatch/sdk 0.7.0 → 0.8.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.
- package/dist/anthropic.cjs +13 -2
- package/dist/anthropic.cjs.map +1 -1
- package/dist/anthropic.js +13 -2
- package/dist/anthropic.js.map +1 -1
- package/dist/bugsnag.cjs +8 -1
- package/dist/bugsnag.cjs.map +1 -1
- package/dist/bugsnag.js +8 -1
- package/dist/bugsnag.js.map +1 -1
- package/dist/d1.cjs +4 -1
- package/dist/d1.cjs.map +1 -1
- package/dist/d1.js +4 -1
- package/dist/d1.js.map +1 -1
- package/dist/datadog.cjs.map +1 -1
- package/dist/datadog.js.map +1 -1
- package/dist/discord.cjs.map +1 -1
- package/dist/discord.js.map +1 -1
- package/dist/honeybadger.cjs +8 -1
- package/dist/honeybadger.cjs.map +1 -1
- package/dist/honeybadger.js +8 -1
- package/dist/honeybadger.js.map +1 -1
- package/dist/index.cjs +69 -20
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +69 -20
- package/dist/index.js.map +1 -1
- package/dist/mailgun.cjs.map +1 -1
- package/dist/mailgun.js.map +1 -1
- package/dist/newrelic.cjs.map +1 -1
- package/dist/newrelic.js.map +1 -1
- package/dist/pg-cron.cjs +8 -1
- package/dist/pg-cron.cjs.map +1 -1
- package/dist/pg-cron.js +8 -1
- package/dist/pg-cron.js.map +1 -1
- package/dist/postgres.cjs +6 -1
- package/dist/postgres.cjs.map +1 -1
- package/dist/postgres.js +6 -1
- package/dist/postgres.js.map +1 -1
- package/dist/postmark.cjs.map +1 -1
- package/dist/postmark.js.map +1 -1
- package/dist/resend.cjs.map +1 -1
- package/dist/resend.js.map +1 -1
- package/dist/rollbar.cjs +8 -1
- package/dist/rollbar.cjs.map +1 -1
- package/dist/rollbar.js +8 -1
- package/dist/rollbar.js.map +1 -1
- package/dist/sendgrid.cjs.map +1 -1
- package/dist/sendgrid.js.map +1 -1
- package/dist/sentry.cjs +8 -1
- package/dist/sentry.cjs.map +1 -1
- package/dist/sentry.js +8 -1
- package/dist/sentry.js.map +1 -1
- package/dist/ses.cjs.map +1 -1
- package/dist/ses.js.map +1 -1
- package/dist/slack.cjs.map +1 -1
- package/dist/slack.js.map +1 -1
- package/dist/sqlite.cjs +4 -1
- package/dist/sqlite.cjs.map +1 -1
- package/dist/sqlite.js +4 -1
- package/dist/sqlite.js.map +1 -1
- package/dist/twilio.cjs.map +1 -1
- package/dist/twilio.js.map +1 -1
- package/dist/webhook.cjs.map +1 -1
- package/dist/webhook.js.map +1 -1
- package/package.json +1 -1
package/dist/mailgun.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/email.ts","../src/alerts/mailgun.ts"],"names":[],"mappings":"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","file":"mailgun.cjs","sourcesContent":["/**\n * Helpers shared by the provider channels. Only fetch and Web Crypto, so the\n * channels run on Node, Cloudflare Workers, Deno and Bun alike. Not an entry\n * point: each channel bundles its own copy.\n */\nimport type { Alert } from \"../types.js\";\n\nexport const TIMEOUT_MS = 10_000;\n\n/** Severity for trackers that have levels. Recovered is informational. */\nexport type Severity = \"error\" | \"warning\" | \"info\";\n\nexport function severity(type: Alert[\"type\"]): Severity {\n if (type === \"recovered\") return \"info\";\n if (type === \"slow\" || type === \"over_budget\") return \"warning\";\n return \"error\";\n}\n\n/** The scheme, host and port only. A URL's path or query can hold a credential. */\nexport function origin(url: string): string {\n try {\n return new URL(url).origin;\n } catch {\n return \"(invalid URL)\";\n }\n}\n\n/**\n * The URL, once it is one fetch can post to. Refused without quoting it:\n * fetch's own error for a URL it cannot parse (\"Failed to parse URL from ...\")\n * quotes the whole URL, and a webhook URL's path is its credential.\n */\nexport function postable(url: string): string {\n let protocol: string;\n try {\n protocol = new URL(url).protocol;\n } catch {\n throw new Error(\"only http and https URLs can be posted to, not this URL\");\n }\n if (protocol !== \"http:\" && protocol !== \"https:\") throw new Error(`only http and https URLs can be posted to, not ${protocol}`);\n return url;\n}\n\n/** How much of a provider's error body goes into the error message. */\nexport const ERROR_BODY_MAX = 200;\n\n/**\n * POSTs and throws on a non-2xx answer. The error names the provider and the\n * URL's origin, plus the start of the response body with every secret the\n * channel holds cut out, in case a provider echoes one back. Secrets are cut\n * from a prefix long enough to hold one that starts inside the first\n * ERROR_BODY_MAX characters, and only then is it cut to that length, so no\n * part of a secret survives at the edge. A redirect is refused rather than\n * followed: the credential headers would go with it to wherever it points.\n */\nexport async function post(provider: string, url: string, init: { headers: Record<string, string>; body: string }, secrets: (string | undefined)[] = []): Promise<Response> {\n const response = await fetch(postable(url), { method: \"POST\", headers: init.headers, body: init.body, redirect: \"error\", signal: AbortSignal.timeout(TIMEOUT_MS) });\n if (!response.ok) {\n let text = \"\";\n try {\n text = await response.text();\n } catch {\n // The status is enough.\n }\n throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : \"\"}`);\n }\n return response;\n}\n\n/** The start of an error body, secrets cut out first, then cut to ERROR_BODY_MAX on a code point. */\nexport function errorBody(text: string, secrets: (string | undefined)[] = []): string {\n const kept = secrets.filter((s): s is string => typeof s === \"string\" && s.length >= 4);\n const longest = kept.reduce((n, s) => Math.max(n, s.length), 0);\n let head = cut(text, ERROR_BODY_MAX + longest);\n for (const secret of kept) head = head.split(secret).join(\"[redacted]\");\n return cut(head, ERROR_BODY_MAX);\n}\n\n/** A credential with the spaces and newlines a paste leaves around it taken off. Anything not a string is empty. */\nexport function trimmed(value: unknown): string {\n return typeof value === \"string\" ? value.trim() : \"\";\n}\n\n/** Base64 of UTF-8, without Buffer. */\nexport function base64(text: string): string {\n let binary = \"\";\n for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);\n return btoa(binary);\n}\n\nexport function basicAuth(user: string, password: string): string {\n return `Basic ${base64(`${user}:${password}`)}`;\n}\n\nexport function hex(bytes: ArrayBuffer | Uint8Array): string {\n return Array.from(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nexport async function sha256Hex(text: string): Promise<string> {\n return hex(await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(text)));\n}\n\n/**\n * A stable 32 hex character id for one alert: the same job, type and time\n * always give the same id, so a provider that deduplicates on it drops a\n * resend of an alert it already took.\n */\nexport async function alertId(alert: Alert): Promise<string> {\n return (await sha256Hex(`${alert.job}\\n${alert.type}\\n${alert.at}`)).slice(0, 32);\n}\n\n/** The same id laid out as a UUID, for APIs that ask for one. */\nexport function asUuid(id: string): string {\n return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20, 32)}`;\n}\n\n/** Cuts to at most `max` UTF-16 units without splitting a surrogate pair. */\nexport function cut(text: string, max: number): string {\n if (text.length <= max) return text;\n let end = max;\n const code = text.charCodeAt(end - 1);\n if (code >= 0xd800 && code <= 0xdbff) end -= 1;\n return text.slice(0, end);\n}\n\n/** The run fields worth attaching to a tracker event. */\nexport function runSummary(alert: Alert): Record<string, unknown> | null {\n const run = alert.run;\n if (!run) return null;\n return { id: run.id, status: run.status, startedAt: new Date(run.startedAt).toISOString(), durationMs: run.durationMs, trigger: run.trigger };\n}\n\n/** Title, message, triage and link as one plain text block, the way every channel reads. */\nexport function plainText(alert: Alert, link: string | undefined): string {\n return [alert.title, \"\", alert.message, ...(alert.triage ? [\"\", `Triage: ${alert.triage}`] : []), ...(link ? [\"\", `Open: ${link}`] : [])].join(\"\\n\");\n}\n","/**\n * What every email channel sends: one subject, a plain text body and a small\n * HTML body, so an alert reads the same whichever provider carries it. Not an\n * entry point; resend, postmark, sendgrid, mailgun and ses each bundle it.\n */\nimport type { Alert } from \"../types.js\";\nimport { cut, plainText } from \"./shared.js\";\n\n/** The options every email channel takes. */\nexport interface EmailOptions {\n /** The sender, \"alerts@example.com\" or \"CronWatch <alerts@example.com>\". The provider must allow it. */\n from: string;\n /** One address or several. */\n to: string | string[];\n /** Put in front of the title in the subject, \"[prod]\" say. */\n subjectPrefix?: string;\n /** Link back to the job in your dashboard: (alert) => `https://app.example.com/cronwatch/jobs/${alert.job}`. */\n link?: (alert: Alert) => string;\n}\n\nexport interface Email {\n from: string;\n to: string[];\n subject: string;\n text: string;\n html: string;\n}\n\n/** Checks the shared options once, when the channel is made. */\nexport function recipients(name: string, options: EmailOptions): string[] {\n if (!options.from) throw new Error(`${name}() needs a from address`);\n const to = (Array.isArray(options.to) ? options.to : [options.to]).filter((a) => typeof a === \"string\" && a.trim() !== \"\");\n if (to.length === 0) throw new Error(`${name}() needs at least one to address`);\n return to.map((a) => a.trim());\n}\n\nexport function composeEmail(alert: Alert, options: EmailOptions, to: string[]): Email {\n const link = safeLink(options.link?.(alert));\n // One line: a newline in a subject is a header injection or a rejected send.\n const subject = cut(`${options.subjectPrefix ? `${options.subjectPrefix} ` : \"\"}${alert.title}`.replace(/[\\r\\n]+/g, \" \"), 250);\n return { from: options.from, to, subject, text: plainText(alert, link), html: html(alert, link) };\n}\n\n/** Escapes text for HTML content and double quoted attributes. */\nexport function escapeHtml(text: string): string {\n return text.replace(/&/g, \"&\").replace(/</g, \"<\").replace(/>/g, \">\").replace(/\"/g, \""\").replace(/'/g, \"'\");\n}\n\n/** Only http and https links are put in a mail; anything else is dropped. */\nfunction safeLink(link: string | undefined): string | undefined {\n if (!link) return undefined;\n return /^https?:\\/\\//i.test(link) ? link : undefined;\n}\n\nfunction html(alert: Alert, link: string | undefined): string {\n const parts = [\n `<!doctype html>`,\n `<html><body style=\"margin:0;padding:16px;font-family:Georgia,serif;color:#1d1b16;background:#ffffff\">`,\n `<p style=\"margin:0 0 12px;font-size:18px\"><strong>${escapeHtml(alert.title)}</strong></p>`,\n `<pre style=\"margin:0 0 12px;padding:12px;background:#f6f3ec;white-space:pre-wrap;word-break:break-word;font:13px/1.45 Menlo,Consolas,monospace\">${escapeHtml(alert.message)}</pre>`,\n ];\n if (alert.triage) parts.push(`<p style=\"margin:0 0 12px\"><em>Triage:</em> ${escapeHtml(alert.triage)}</p>`);\n if (link) parts.push(`<p style=\"margin:0\"><a href=\"${escapeHtml(link)}\">Open ${escapeHtml(alert.job)}</a></p>`);\n parts.push(`</body></html>`);\n return parts.join(\"\\n\");\n}\n\n/** Splits \"Name <a@b.c>\" into its parts; a bare address has no name. */\nexport function parseAddress(address: string): { email: string; name?: string } {\n const match = /^\\s*(.*?)\\s*<([^<>]+)>\\s*$/.exec(address);\n if (!match) return { email: address.trim() };\n const name = match[1]!.replace(/^\"(.*)\"$/, \"$1\");\n return name ? { email: match[2]!.trim(), name } : { email: match[2]!.trim() };\n}\n","/**\n * Mailgun. API reference: https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/messages/post-v3--domain-name--messages\n * POST https://api.mailgun.net/v3/<domain>/messages (api.eu.mailgun.net for\n * the EU region), form encoded, with basic auth \"api:<key>\".\n */\nimport type { AlertChannel } from \"../types.js\";\nimport { composeEmail, recipients, type EmailOptions } from \"./email.js\";\nimport { basicAuth, post, trimmed } from \"./shared.js\";\n\nexport interface MailgunOptions extends EmailOptions {\n /** A sending or account API key. */\n apiKey: string;\n /** The sending domain, \"mg.example.com\". */\n domain: string;\n /** \"eu\" for a domain in the EU region. Defaults to \"us\". */\n region?: \"us\" | \"eu\";\n}\n\n/** Sends alerts as email through Mailgun. */\nexport function mailgun(options: MailgunOptions): AlertChannel {\n // A pasted credential often carries a stray space or newline, which a header would refuse or send.\n const apiKey = trimmed(options.apiKey);\n if (!apiKey) throw new Error(\"mailgun() needs an apiKey\");\n if (!options.domain) throw new Error(\"mailgun() needs a domain\");\n const to = recipients(\"mailgun\", options);\n const host = options.region === \"eu\" ? \"https://api.eu.mailgun.net\" : \"https://api.mailgun.net\";\n const url = `${host}/v3/${encodeURIComponent(options.domain)}/messages`;\n return {\n name: \"mailgun\",\n async send(alert) {\n const email = composeEmail(alert, options, to);\n const form = new URLSearchParams();\n form.append(\"from\", email.from);\n for (const address of email.to) form.append(\"to\", address);\n form.append(\"subject\", email.subject);\n form.append(\"text\", email.text);\n form.append(\"html\", email.html);\n form.append(\"o:tag\", \"cronwatch\");\n await post(\"Mailgun\", url, {\n headers: { \"content-type\": \"application/x-www-form-urlencoded\", authorization: basicAuth(\"api\", apiKey) },\n body: form.toString(),\n }, [apiKey]);\n },\n };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/email.ts","../src/alerts/mailgun.ts"],"names":[],"mappings":"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","file":"mailgun.cjs","sourcesContent":["/**\n * Helpers shared by the provider channels. Only fetch and Web Crypto, so the\n * channels run on Node, Cloudflare Workers, Deno and Bun alike. Not an entry\n * point: each channel bundles its own copy.\n */\nimport { isoTime } from \"../duration.js\";\nimport type { Alert } from \"../types.js\";\n\nexport const TIMEOUT_MS = 10_000;\n\n/** Severity for trackers that have levels. Recovered is informational. */\nexport type Severity = \"error\" | \"warning\" | \"info\";\n\nexport function severity(type: Alert[\"type\"]): Severity {\n if (type === \"recovered\") return \"info\";\n if (type === \"slow\" || type === \"over_budget\") return \"warning\";\n return \"error\";\n}\n\n/** The scheme, host and port only. A URL's path or query can hold a credential. */\nexport function origin(url: string): string {\n try {\n return new URL(url).origin;\n } catch {\n return \"(invalid URL)\";\n }\n}\n\n/**\n * The URL, once it is one fetch can post to. Refused without quoting it:\n * fetch's own error for a URL it cannot parse (\"Failed to parse URL from ...\")\n * quotes the whole URL, and a webhook URL's path is its credential.\n */\nexport function postable(url: string): string {\n let protocol: string;\n try {\n protocol = new URL(url).protocol;\n } catch {\n throw new Error(\"only http and https URLs can be posted to, not this URL\");\n }\n if (protocol !== \"http:\" && protocol !== \"https:\") throw new Error(`only http and https URLs can be posted to, not ${protocol}`);\n return url;\n}\n\n/** How much of a provider's error body goes into the error message. */\nexport const ERROR_BODY_MAX = 200;\n\n/**\n * POSTs and throws on a non-2xx answer. The error names the provider and the\n * URL's origin, plus the start of the response body with every secret the\n * channel holds cut out, in case a provider echoes one back. Secrets are cut\n * from a prefix long enough to hold one that starts inside the first\n * ERROR_BODY_MAX characters, and only then is it cut to that length, so no\n * part of a secret survives at the edge. A redirect is refused rather than\n * followed: the credential headers would go with it to wherever it points.\n */\nexport async function post(provider: string, url: string, init: { headers: Record<string, string>; body: string }, secrets: (string | undefined)[] = []): Promise<Response> {\n const response = await fetch(postable(url), { method: \"POST\", headers: init.headers, body: init.body, redirect: \"error\", signal: AbortSignal.timeout(TIMEOUT_MS) });\n if (!response.ok) {\n let text = \"\";\n try {\n text = await response.text();\n } catch {\n // The status is enough.\n }\n throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : \"\"}`);\n }\n return response;\n}\n\n/** The start of an error body, secrets cut out first, then cut to ERROR_BODY_MAX on a code point. */\nexport function errorBody(text: string, secrets: (string | undefined)[] = []): string {\n const kept = secrets.filter((s): s is string => typeof s === \"string\" && s.length >= 4);\n const longest = kept.reduce((n, s) => Math.max(n, s.length), 0);\n let head = cut(text, ERROR_BODY_MAX + longest);\n for (const secret of kept) head = head.split(secret).join(\"[redacted]\");\n return cut(head, ERROR_BODY_MAX);\n}\n\n/** A credential with the spaces and newlines a paste leaves around it taken off. Anything not a string is empty. */\nexport function trimmed(value: unknown): string {\n return typeof value === \"string\" ? value.trim() : \"\";\n}\n\n/** Base64 of UTF-8, without Buffer. */\nexport function base64(text: string): string {\n let binary = \"\";\n for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);\n return btoa(binary);\n}\n\nexport function basicAuth(user: string, password: string): string {\n return `Basic ${base64(`${user}:${password}`)}`;\n}\n\nexport function hex(bytes: ArrayBuffer | Uint8Array): string {\n return Array.from(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nexport async function sha256Hex(text: string): Promise<string> {\n return hex(await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(text)));\n}\n\n/**\n * A stable 32 hex character id for one alert: the same job, type and time\n * always give the same id, so a provider that deduplicates on it drops a\n * resend of an alert it already took.\n */\nexport async function alertId(alert: Alert): Promise<string> {\n return (await sha256Hex(`${alert.job}\\n${alert.type}\\n${alert.at}`)).slice(0, 32);\n}\n\n/** The same id laid out as a UUID, for APIs that ask for one. */\nexport function asUuid(id: string): string {\n return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20, 32)}`;\n}\n\n/** Cuts to at most `max` UTF-16 units without splitting a surrogate pair. */\nexport function cut(text: string, max: number): string {\n if (text.length <= max) return text;\n let end = max;\n const code = text.charCodeAt(end - 1);\n if (code >= 0xd800 && code <= 0xdbff) end -= 1;\n return text.slice(0, end);\n}\n\n/** The run fields worth attaching to a tracker event. A start before the year 1 or after 9999 is null. */\nexport function runSummary(alert: Alert): Record<string, unknown> | null {\n const run = alert.run;\n if (!run) return null;\n return { id: run.id, status: run.status, startedAt: isoTime(run.startedAt), durationMs: run.durationMs, trigger: run.trigger };\n}\n\n/** Title, message, triage and link as one plain text block, the way every channel reads. */\nexport function plainText(alert: Alert, link: string | undefined): string {\n return [alert.title, \"\", alert.message, ...(alert.triage ? [\"\", `Triage: ${alert.triage}`] : []), ...(link ? [\"\", `Open: ${link}`] : [])].join(\"\\n\");\n}\n","/**\n * What every email channel sends: one subject, a plain text body and a small\n * HTML body, so an alert reads the same whichever provider carries it. Not an\n * entry point; resend, postmark, sendgrid, mailgun and ses each bundle it.\n */\nimport type { Alert } from \"../types.js\";\nimport { cut, plainText } from \"./shared.js\";\n\n/** The options every email channel takes. */\nexport interface EmailOptions {\n /** The sender, \"alerts@example.com\" or \"CronWatch <alerts@example.com>\". The provider must allow it. */\n from: string;\n /** One address or several. */\n to: string | string[];\n /** Put in front of the title in the subject, \"[prod]\" say. */\n subjectPrefix?: string;\n /** Link back to the job in your dashboard: (alert) => `https://app.example.com/cronwatch/jobs/${alert.job}`. */\n link?: (alert: Alert) => string;\n}\n\nexport interface Email {\n from: string;\n to: string[];\n subject: string;\n text: string;\n html: string;\n}\n\n/** Checks the shared options once, when the channel is made. */\nexport function recipients(name: string, options: EmailOptions): string[] {\n if (!options.from) throw new Error(`${name}() needs a from address`);\n const to = (Array.isArray(options.to) ? options.to : [options.to]).filter((a) => typeof a === \"string\" && a.trim() !== \"\");\n if (to.length === 0) throw new Error(`${name}() needs at least one to address`);\n return to.map((a) => a.trim());\n}\n\nexport function composeEmail(alert: Alert, options: EmailOptions, to: string[]): Email {\n const link = safeLink(options.link?.(alert));\n // One line: a newline in a subject is a header injection or a rejected send.\n const subject = cut(`${options.subjectPrefix ? `${options.subjectPrefix} ` : \"\"}${alert.title}`.replace(/[\\r\\n]+/g, \" \"), 250);\n return { from: options.from, to, subject, text: plainText(alert, link), html: html(alert, link) };\n}\n\n/** Escapes text for HTML content and double quoted attributes. */\nexport function escapeHtml(text: string): string {\n return text.replace(/&/g, \"&\").replace(/</g, \"<\").replace(/>/g, \">\").replace(/\"/g, \""\").replace(/'/g, \"'\");\n}\n\n/** Only http and https links are put in a mail; anything else is dropped. */\nfunction safeLink(link: string | undefined): string | undefined {\n if (!link) return undefined;\n return /^https?:\\/\\//i.test(link) ? link : undefined;\n}\n\nfunction html(alert: Alert, link: string | undefined): string {\n const parts = [\n `<!doctype html>`,\n `<html><body style=\"margin:0;padding:16px;font-family:Georgia,serif;color:#1d1b16;background:#ffffff\">`,\n `<p style=\"margin:0 0 12px;font-size:18px\"><strong>${escapeHtml(alert.title)}</strong></p>`,\n `<pre style=\"margin:0 0 12px;padding:12px;background:#f6f3ec;white-space:pre-wrap;word-break:break-word;font:13px/1.45 Menlo,Consolas,monospace\">${escapeHtml(alert.message)}</pre>`,\n ];\n if (alert.triage) parts.push(`<p style=\"margin:0 0 12px\"><em>Triage:</em> ${escapeHtml(alert.triage)}</p>`);\n if (link) parts.push(`<p style=\"margin:0\"><a href=\"${escapeHtml(link)}\">Open ${escapeHtml(alert.job)}</a></p>`);\n parts.push(`</body></html>`);\n return parts.join(\"\\n\");\n}\n\n/** Splits \"Name <a@b.c>\" into its parts; a bare address has no name. */\nexport function parseAddress(address: string): { email: string; name?: string } {\n const match = /^\\s*(.*?)\\s*<([^<>]+)>\\s*$/.exec(address);\n if (!match) return { email: address.trim() };\n const name = match[1]!.replace(/^\"(.*)\"$/, \"$1\");\n return name ? { email: match[2]!.trim(), name } : { email: match[2]!.trim() };\n}\n","/**\n * Mailgun. API reference: https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/messages/post-v3--domain-name--messages\n * POST https://api.mailgun.net/v3/<domain>/messages (api.eu.mailgun.net for\n * the EU region), form encoded, with basic auth \"api:<key>\".\n */\nimport type { AlertChannel } from \"../types.js\";\nimport { composeEmail, recipients, type EmailOptions } from \"./email.js\";\nimport { basicAuth, post, trimmed } from \"./shared.js\";\n\nexport interface MailgunOptions extends EmailOptions {\n /** A sending or account API key. */\n apiKey: string;\n /** The sending domain, \"mg.example.com\". */\n domain: string;\n /** \"eu\" for a domain in the EU region. Defaults to \"us\". */\n region?: \"us\" | \"eu\";\n}\n\n/** Sends alerts as email through Mailgun. */\nexport function mailgun(options: MailgunOptions): AlertChannel {\n // A pasted credential often carries a stray space or newline, which a header would refuse or send.\n const apiKey = trimmed(options.apiKey);\n if (!apiKey) throw new Error(\"mailgun() needs an apiKey\");\n if (!options.domain) throw new Error(\"mailgun() needs a domain\");\n const to = recipients(\"mailgun\", options);\n const host = options.region === \"eu\" ? \"https://api.eu.mailgun.net\" : \"https://api.mailgun.net\";\n const url = `${host}/v3/${encodeURIComponent(options.domain)}/messages`;\n return {\n name: \"mailgun\",\n async send(alert) {\n const email = composeEmail(alert, options, to);\n const form = new URLSearchParams();\n form.append(\"from\", email.from);\n for (const address of email.to) form.append(\"to\", address);\n form.append(\"subject\", email.subject);\n form.append(\"text\", email.text);\n form.append(\"html\", email.html);\n form.append(\"o:tag\", \"cronwatch\");\n await post(\"Mailgun\", url, {\n headers: { \"content-type\": \"application/x-www-form-urlencoded\", authorization: basicAuth(\"api\", apiKey) },\n body: form.toString(),\n }, [apiKey]);\n },\n };\n}\n"]}
|
package/dist/mailgun.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/email.ts","../src/alerts/mailgun.ts"],"names":[],"mappings":"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","file":"mailgun.js","sourcesContent":["/**\n * Helpers shared by the provider channels. Only fetch and Web Crypto, so the\n * channels run on Node, Cloudflare Workers, Deno and Bun alike. Not an entry\n * point: each channel bundles its own copy.\n */\nimport type { Alert } from \"../types.js\";\n\nexport const TIMEOUT_MS = 10_000;\n\n/** Severity for trackers that have levels. Recovered is informational. */\nexport type Severity = \"error\" | \"warning\" | \"info\";\n\nexport function severity(type: Alert[\"type\"]): Severity {\n if (type === \"recovered\") return \"info\";\n if (type === \"slow\" || type === \"over_budget\") return \"warning\";\n return \"error\";\n}\n\n/** The scheme, host and port only. A URL's path or query can hold a credential. */\nexport function origin(url: string): string {\n try {\n return new URL(url).origin;\n } catch {\n return \"(invalid URL)\";\n }\n}\n\n/**\n * The URL, once it is one fetch can post to. Refused without quoting it:\n * fetch's own error for a URL it cannot parse (\"Failed to parse URL from ...\")\n * quotes the whole URL, and a webhook URL's path is its credential.\n */\nexport function postable(url: string): string {\n let protocol: string;\n try {\n protocol = new URL(url).protocol;\n } catch {\n throw new Error(\"only http and https URLs can be posted to, not this URL\");\n }\n if (protocol !== \"http:\" && protocol !== \"https:\") throw new Error(`only http and https URLs can be posted to, not ${protocol}`);\n return url;\n}\n\n/** How much of a provider's error body goes into the error message. */\nexport const ERROR_BODY_MAX = 200;\n\n/**\n * POSTs and throws on a non-2xx answer. The error names the provider and the\n * URL's origin, plus the start of the response body with every secret the\n * channel holds cut out, in case a provider echoes one back. Secrets are cut\n * from a prefix long enough to hold one that starts inside the first\n * ERROR_BODY_MAX characters, and only then is it cut to that length, so no\n * part of a secret survives at the edge. A redirect is refused rather than\n * followed: the credential headers would go with it to wherever it points.\n */\nexport async function post(provider: string, url: string, init: { headers: Record<string, string>; body: string }, secrets: (string | undefined)[] = []): Promise<Response> {\n const response = await fetch(postable(url), { method: \"POST\", headers: init.headers, body: init.body, redirect: \"error\", signal: AbortSignal.timeout(TIMEOUT_MS) });\n if (!response.ok) {\n let text = \"\";\n try {\n text = await response.text();\n } catch {\n // The status is enough.\n }\n throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : \"\"}`);\n }\n return response;\n}\n\n/** The start of an error body, secrets cut out first, then cut to ERROR_BODY_MAX on a code point. */\nexport function errorBody(text: string, secrets: (string | undefined)[] = []): string {\n const kept = secrets.filter((s): s is string => typeof s === \"string\" && s.length >= 4);\n const longest = kept.reduce((n, s) => Math.max(n, s.length), 0);\n let head = cut(text, ERROR_BODY_MAX + longest);\n for (const secret of kept) head = head.split(secret).join(\"[redacted]\");\n return cut(head, ERROR_BODY_MAX);\n}\n\n/** A credential with the spaces and newlines a paste leaves around it taken off. Anything not a string is empty. */\nexport function trimmed(value: unknown): string {\n return typeof value === \"string\" ? value.trim() : \"\";\n}\n\n/** Base64 of UTF-8, without Buffer. */\nexport function base64(text: string): string {\n let binary = \"\";\n for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);\n return btoa(binary);\n}\n\nexport function basicAuth(user: string, password: string): string {\n return `Basic ${base64(`${user}:${password}`)}`;\n}\n\nexport function hex(bytes: ArrayBuffer | Uint8Array): string {\n return Array.from(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nexport async function sha256Hex(text: string): Promise<string> {\n return hex(await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(text)));\n}\n\n/**\n * A stable 32 hex character id for one alert: the same job, type and time\n * always give the same id, so a provider that deduplicates on it drops a\n * resend of an alert it already took.\n */\nexport async function alertId(alert: Alert): Promise<string> {\n return (await sha256Hex(`${alert.job}\\n${alert.type}\\n${alert.at}`)).slice(0, 32);\n}\n\n/** The same id laid out as a UUID, for APIs that ask for one. */\nexport function asUuid(id: string): string {\n return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20, 32)}`;\n}\n\n/** Cuts to at most `max` UTF-16 units without splitting a surrogate pair. */\nexport function cut(text: string, max: number): string {\n if (text.length <= max) return text;\n let end = max;\n const code = text.charCodeAt(end - 1);\n if (code >= 0xd800 && code <= 0xdbff) end -= 1;\n return text.slice(0, end);\n}\n\n/** The run fields worth attaching to a tracker event. */\nexport function runSummary(alert: Alert): Record<string, unknown> | null {\n const run = alert.run;\n if (!run) return null;\n return { id: run.id, status: run.status, startedAt: new Date(run.startedAt).toISOString(), durationMs: run.durationMs, trigger: run.trigger };\n}\n\n/** Title, message, triage and link as one plain text block, the way every channel reads. */\nexport function plainText(alert: Alert, link: string | undefined): string {\n return [alert.title, \"\", alert.message, ...(alert.triage ? [\"\", `Triage: ${alert.triage}`] : []), ...(link ? [\"\", `Open: ${link}`] : [])].join(\"\\n\");\n}\n","/**\n * What every email channel sends: one subject, a plain text body and a small\n * HTML body, so an alert reads the same whichever provider carries it. Not an\n * entry point; resend, postmark, sendgrid, mailgun and ses each bundle it.\n */\nimport type { Alert } from \"../types.js\";\nimport { cut, plainText } from \"./shared.js\";\n\n/** The options every email channel takes. */\nexport interface EmailOptions {\n /** The sender, \"alerts@example.com\" or \"CronWatch <alerts@example.com>\". The provider must allow it. */\n from: string;\n /** One address or several. */\n to: string | string[];\n /** Put in front of the title in the subject, \"[prod]\" say. */\n subjectPrefix?: string;\n /** Link back to the job in your dashboard: (alert) => `https://app.example.com/cronwatch/jobs/${alert.job}`. */\n link?: (alert: Alert) => string;\n}\n\nexport interface Email {\n from: string;\n to: string[];\n subject: string;\n text: string;\n html: string;\n}\n\n/** Checks the shared options once, when the channel is made. */\nexport function recipients(name: string, options: EmailOptions): string[] {\n if (!options.from) throw new Error(`${name}() needs a from address`);\n const to = (Array.isArray(options.to) ? options.to : [options.to]).filter((a) => typeof a === \"string\" && a.trim() !== \"\");\n if (to.length === 0) throw new Error(`${name}() needs at least one to address`);\n return to.map((a) => a.trim());\n}\n\nexport function composeEmail(alert: Alert, options: EmailOptions, to: string[]): Email {\n const link = safeLink(options.link?.(alert));\n // One line: a newline in a subject is a header injection or a rejected send.\n const subject = cut(`${options.subjectPrefix ? `${options.subjectPrefix} ` : \"\"}${alert.title}`.replace(/[\\r\\n]+/g, \" \"), 250);\n return { from: options.from, to, subject, text: plainText(alert, link), html: html(alert, link) };\n}\n\n/** Escapes text for HTML content and double quoted attributes. */\nexport function escapeHtml(text: string): string {\n return text.replace(/&/g, \"&\").replace(/</g, \"<\").replace(/>/g, \">\").replace(/\"/g, \""\").replace(/'/g, \"'\");\n}\n\n/** Only http and https links are put in a mail; anything else is dropped. */\nfunction safeLink(link: string | undefined): string | undefined {\n if (!link) return undefined;\n return /^https?:\\/\\//i.test(link) ? link : undefined;\n}\n\nfunction html(alert: Alert, link: string | undefined): string {\n const parts = [\n `<!doctype html>`,\n `<html><body style=\"margin:0;padding:16px;font-family:Georgia,serif;color:#1d1b16;background:#ffffff\">`,\n `<p style=\"margin:0 0 12px;font-size:18px\"><strong>${escapeHtml(alert.title)}</strong></p>`,\n `<pre style=\"margin:0 0 12px;padding:12px;background:#f6f3ec;white-space:pre-wrap;word-break:break-word;font:13px/1.45 Menlo,Consolas,monospace\">${escapeHtml(alert.message)}</pre>`,\n ];\n if (alert.triage) parts.push(`<p style=\"margin:0 0 12px\"><em>Triage:</em> ${escapeHtml(alert.triage)}</p>`);\n if (link) parts.push(`<p style=\"margin:0\"><a href=\"${escapeHtml(link)}\">Open ${escapeHtml(alert.job)}</a></p>`);\n parts.push(`</body></html>`);\n return parts.join(\"\\n\");\n}\n\n/** Splits \"Name <a@b.c>\" into its parts; a bare address has no name. */\nexport function parseAddress(address: string): { email: string; name?: string } {\n const match = /^\\s*(.*?)\\s*<([^<>]+)>\\s*$/.exec(address);\n if (!match) return { email: address.trim() };\n const name = match[1]!.replace(/^\"(.*)\"$/, \"$1\");\n return name ? { email: match[2]!.trim(), name } : { email: match[2]!.trim() };\n}\n","/**\n * Mailgun. API reference: https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/messages/post-v3--domain-name--messages\n * POST https://api.mailgun.net/v3/<domain>/messages (api.eu.mailgun.net for\n * the EU region), form encoded, with basic auth \"api:<key>\".\n */\nimport type { AlertChannel } from \"../types.js\";\nimport { composeEmail, recipients, type EmailOptions } from \"./email.js\";\nimport { basicAuth, post, trimmed } from \"./shared.js\";\n\nexport interface MailgunOptions extends EmailOptions {\n /** A sending or account API key. */\n apiKey: string;\n /** The sending domain, \"mg.example.com\". */\n domain: string;\n /** \"eu\" for a domain in the EU region. Defaults to \"us\". */\n region?: \"us\" | \"eu\";\n}\n\n/** Sends alerts as email through Mailgun. */\nexport function mailgun(options: MailgunOptions): AlertChannel {\n // A pasted credential often carries a stray space or newline, which a header would refuse or send.\n const apiKey = trimmed(options.apiKey);\n if (!apiKey) throw new Error(\"mailgun() needs an apiKey\");\n if (!options.domain) throw new Error(\"mailgun() needs a domain\");\n const to = recipients(\"mailgun\", options);\n const host = options.region === \"eu\" ? \"https://api.eu.mailgun.net\" : \"https://api.mailgun.net\";\n const url = `${host}/v3/${encodeURIComponent(options.domain)}/messages`;\n return {\n name: \"mailgun\",\n async send(alert) {\n const email = composeEmail(alert, options, to);\n const form = new URLSearchParams();\n form.append(\"from\", email.from);\n for (const address of email.to) form.append(\"to\", address);\n form.append(\"subject\", email.subject);\n form.append(\"text\", email.text);\n form.append(\"html\", email.html);\n form.append(\"o:tag\", \"cronwatch\");\n await post(\"Mailgun\", url, {\n headers: { \"content-type\": \"application/x-www-form-urlencoded\", authorization: basicAuth(\"api\", apiKey) },\n body: form.toString(),\n }, [apiKey]);\n },\n };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/email.ts","../src/alerts/mailgun.ts"],"names":[],"mappings":"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","file":"mailgun.js","sourcesContent":["/**\n * Helpers shared by the provider channels. Only fetch and Web Crypto, so the\n * channels run on Node, Cloudflare Workers, Deno and Bun alike. Not an entry\n * point: each channel bundles its own copy.\n */\nimport { isoTime } from \"../duration.js\";\nimport type { Alert } from \"../types.js\";\n\nexport const TIMEOUT_MS = 10_000;\n\n/** Severity for trackers that have levels. Recovered is informational. */\nexport type Severity = \"error\" | \"warning\" | \"info\";\n\nexport function severity(type: Alert[\"type\"]): Severity {\n if (type === \"recovered\") return \"info\";\n if (type === \"slow\" || type === \"over_budget\") return \"warning\";\n return \"error\";\n}\n\n/** The scheme, host and port only. A URL's path or query can hold a credential. */\nexport function origin(url: string): string {\n try {\n return new URL(url).origin;\n } catch {\n return \"(invalid URL)\";\n }\n}\n\n/**\n * The URL, once it is one fetch can post to. Refused without quoting it:\n * fetch's own error for a URL it cannot parse (\"Failed to parse URL from ...\")\n * quotes the whole URL, and a webhook URL's path is its credential.\n */\nexport function postable(url: string): string {\n let protocol: string;\n try {\n protocol = new URL(url).protocol;\n } catch {\n throw new Error(\"only http and https URLs can be posted to, not this URL\");\n }\n if (protocol !== \"http:\" && protocol !== \"https:\") throw new Error(`only http and https URLs can be posted to, not ${protocol}`);\n return url;\n}\n\n/** How much of a provider's error body goes into the error message. */\nexport const ERROR_BODY_MAX = 200;\n\n/**\n * POSTs and throws on a non-2xx answer. The error names the provider and the\n * URL's origin, plus the start of the response body with every secret the\n * channel holds cut out, in case a provider echoes one back. Secrets are cut\n * from a prefix long enough to hold one that starts inside the first\n * ERROR_BODY_MAX characters, and only then is it cut to that length, so no\n * part of a secret survives at the edge. A redirect is refused rather than\n * followed: the credential headers would go with it to wherever it points.\n */\nexport async function post(provider: string, url: string, init: { headers: Record<string, string>; body: string }, secrets: (string | undefined)[] = []): Promise<Response> {\n const response = await fetch(postable(url), { method: \"POST\", headers: init.headers, body: init.body, redirect: \"error\", signal: AbortSignal.timeout(TIMEOUT_MS) });\n if (!response.ok) {\n let text = \"\";\n try {\n text = await response.text();\n } catch {\n // The status is enough.\n }\n throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : \"\"}`);\n }\n return response;\n}\n\n/** The start of an error body, secrets cut out first, then cut to ERROR_BODY_MAX on a code point. */\nexport function errorBody(text: string, secrets: (string | undefined)[] = []): string {\n const kept = secrets.filter((s): s is string => typeof s === \"string\" && s.length >= 4);\n const longest = kept.reduce((n, s) => Math.max(n, s.length), 0);\n let head = cut(text, ERROR_BODY_MAX + longest);\n for (const secret of kept) head = head.split(secret).join(\"[redacted]\");\n return cut(head, ERROR_BODY_MAX);\n}\n\n/** A credential with the spaces and newlines a paste leaves around it taken off. Anything not a string is empty. */\nexport function trimmed(value: unknown): string {\n return typeof value === \"string\" ? value.trim() : \"\";\n}\n\n/** Base64 of UTF-8, without Buffer. */\nexport function base64(text: string): string {\n let binary = \"\";\n for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);\n return btoa(binary);\n}\n\nexport function basicAuth(user: string, password: string): string {\n return `Basic ${base64(`${user}:${password}`)}`;\n}\n\nexport function hex(bytes: ArrayBuffer | Uint8Array): string {\n return Array.from(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nexport async function sha256Hex(text: string): Promise<string> {\n return hex(await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(text)));\n}\n\n/**\n * A stable 32 hex character id for one alert: the same job, type and time\n * always give the same id, so a provider that deduplicates on it drops a\n * resend of an alert it already took.\n */\nexport async function alertId(alert: Alert): Promise<string> {\n return (await sha256Hex(`${alert.job}\\n${alert.type}\\n${alert.at}`)).slice(0, 32);\n}\n\n/** The same id laid out as a UUID, for APIs that ask for one. */\nexport function asUuid(id: string): string {\n return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20, 32)}`;\n}\n\n/** Cuts to at most `max` UTF-16 units without splitting a surrogate pair. */\nexport function cut(text: string, max: number): string {\n if (text.length <= max) return text;\n let end = max;\n const code = text.charCodeAt(end - 1);\n if (code >= 0xd800 && code <= 0xdbff) end -= 1;\n return text.slice(0, end);\n}\n\n/** The run fields worth attaching to a tracker event. A start before the year 1 or after 9999 is null. */\nexport function runSummary(alert: Alert): Record<string, unknown> | null {\n const run = alert.run;\n if (!run) return null;\n return { id: run.id, status: run.status, startedAt: isoTime(run.startedAt), durationMs: run.durationMs, trigger: run.trigger };\n}\n\n/** Title, message, triage and link as one plain text block, the way every channel reads. */\nexport function plainText(alert: Alert, link: string | undefined): string {\n return [alert.title, \"\", alert.message, ...(alert.triage ? [\"\", `Triage: ${alert.triage}`] : []), ...(link ? [\"\", `Open: ${link}`] : [])].join(\"\\n\");\n}\n","/**\n * What every email channel sends: one subject, a plain text body and a small\n * HTML body, so an alert reads the same whichever provider carries it. Not an\n * entry point; resend, postmark, sendgrid, mailgun and ses each bundle it.\n */\nimport type { Alert } from \"../types.js\";\nimport { cut, plainText } from \"./shared.js\";\n\n/** The options every email channel takes. */\nexport interface EmailOptions {\n /** The sender, \"alerts@example.com\" or \"CronWatch <alerts@example.com>\". The provider must allow it. */\n from: string;\n /** One address or several. */\n to: string | string[];\n /** Put in front of the title in the subject, \"[prod]\" say. */\n subjectPrefix?: string;\n /** Link back to the job in your dashboard: (alert) => `https://app.example.com/cronwatch/jobs/${alert.job}`. */\n link?: (alert: Alert) => string;\n}\n\nexport interface Email {\n from: string;\n to: string[];\n subject: string;\n text: string;\n html: string;\n}\n\n/** Checks the shared options once, when the channel is made. */\nexport function recipients(name: string, options: EmailOptions): string[] {\n if (!options.from) throw new Error(`${name}() needs a from address`);\n const to = (Array.isArray(options.to) ? options.to : [options.to]).filter((a) => typeof a === \"string\" && a.trim() !== \"\");\n if (to.length === 0) throw new Error(`${name}() needs at least one to address`);\n return to.map((a) => a.trim());\n}\n\nexport function composeEmail(alert: Alert, options: EmailOptions, to: string[]): Email {\n const link = safeLink(options.link?.(alert));\n // One line: a newline in a subject is a header injection or a rejected send.\n const subject = cut(`${options.subjectPrefix ? `${options.subjectPrefix} ` : \"\"}${alert.title}`.replace(/[\\r\\n]+/g, \" \"), 250);\n return { from: options.from, to, subject, text: plainText(alert, link), html: html(alert, link) };\n}\n\n/** Escapes text for HTML content and double quoted attributes. */\nexport function escapeHtml(text: string): string {\n return text.replace(/&/g, \"&\").replace(/</g, \"<\").replace(/>/g, \">\").replace(/\"/g, \""\").replace(/'/g, \"'\");\n}\n\n/** Only http and https links are put in a mail; anything else is dropped. */\nfunction safeLink(link: string | undefined): string | undefined {\n if (!link) return undefined;\n return /^https?:\\/\\//i.test(link) ? link : undefined;\n}\n\nfunction html(alert: Alert, link: string | undefined): string {\n const parts = [\n `<!doctype html>`,\n `<html><body style=\"margin:0;padding:16px;font-family:Georgia,serif;color:#1d1b16;background:#ffffff\">`,\n `<p style=\"margin:0 0 12px;font-size:18px\"><strong>${escapeHtml(alert.title)}</strong></p>`,\n `<pre style=\"margin:0 0 12px;padding:12px;background:#f6f3ec;white-space:pre-wrap;word-break:break-word;font:13px/1.45 Menlo,Consolas,monospace\">${escapeHtml(alert.message)}</pre>`,\n ];\n if (alert.triage) parts.push(`<p style=\"margin:0 0 12px\"><em>Triage:</em> ${escapeHtml(alert.triage)}</p>`);\n if (link) parts.push(`<p style=\"margin:0\"><a href=\"${escapeHtml(link)}\">Open ${escapeHtml(alert.job)}</a></p>`);\n parts.push(`</body></html>`);\n return parts.join(\"\\n\");\n}\n\n/** Splits \"Name <a@b.c>\" into its parts; a bare address has no name. */\nexport function parseAddress(address: string): { email: string; name?: string } {\n const match = /^\\s*(.*?)\\s*<([^<>]+)>\\s*$/.exec(address);\n if (!match) return { email: address.trim() };\n const name = match[1]!.replace(/^\"(.*)\"$/, \"$1\");\n return name ? { email: match[2]!.trim(), name } : { email: match[2]!.trim() };\n}\n","/**\n * Mailgun. API reference: https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/messages/post-v3--domain-name--messages\n * POST https://api.mailgun.net/v3/<domain>/messages (api.eu.mailgun.net for\n * the EU region), form encoded, with basic auth \"api:<key>\".\n */\nimport type { AlertChannel } from \"../types.js\";\nimport { composeEmail, recipients, type EmailOptions } from \"./email.js\";\nimport { basicAuth, post, trimmed } from \"./shared.js\";\n\nexport interface MailgunOptions extends EmailOptions {\n /** A sending or account API key. */\n apiKey: string;\n /** The sending domain, \"mg.example.com\". */\n domain: string;\n /** \"eu\" for a domain in the EU region. Defaults to \"us\". */\n region?: \"us\" | \"eu\";\n}\n\n/** Sends alerts as email through Mailgun. */\nexport function mailgun(options: MailgunOptions): AlertChannel {\n // A pasted credential often carries a stray space or newline, which a header would refuse or send.\n const apiKey = trimmed(options.apiKey);\n if (!apiKey) throw new Error(\"mailgun() needs an apiKey\");\n if (!options.domain) throw new Error(\"mailgun() needs a domain\");\n const to = recipients(\"mailgun\", options);\n const host = options.region === \"eu\" ? \"https://api.eu.mailgun.net\" : \"https://api.mailgun.net\";\n const url = `${host}/v3/${encodeURIComponent(options.domain)}/messages`;\n return {\n name: \"mailgun\",\n async send(alert) {\n const email = composeEmail(alert, options, to);\n const form = new URLSearchParams();\n form.append(\"from\", email.from);\n for (const address of email.to) form.append(\"to\", address);\n form.append(\"subject\", email.subject);\n form.append(\"text\", email.text);\n form.append(\"html\", email.html);\n form.append(\"o:tag\", \"cronwatch\");\n await post(\"Mailgun\", url, {\n headers: { \"content-type\": \"application/x-www-form-urlencoded\", authorization: basicAuth(\"api\", apiKey) },\n body: form.toString(),\n }, [apiKey]);\n },\n };\n}\n"]}
|
package/dist/newrelic.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/newrelic.ts"],"names":[],"mappings":"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","file":"newrelic.cjs","sourcesContent":["/**\n * Helpers shared by the provider channels. Only fetch and Web Crypto, so the\n * channels run on Node, Cloudflare Workers, Deno and Bun alike. Not an entry\n * point: each channel bundles its own copy.\n */\nimport type { Alert } from \"../types.js\";\n\nexport const TIMEOUT_MS = 10_000;\n\n/** Severity for trackers that have levels. Recovered is informational. */\nexport type Severity = \"error\" | \"warning\" | \"info\";\n\nexport function severity(type: Alert[\"type\"]): Severity {\n if (type === \"recovered\") return \"info\";\n if (type === \"slow\" || type === \"over_budget\") return \"warning\";\n return \"error\";\n}\n\n/** The scheme, host and port only. A URL's path or query can hold a credential. */\nexport function origin(url: string): string {\n try {\n return new URL(url).origin;\n } catch {\n return \"(invalid URL)\";\n }\n}\n\n/**\n * The URL, once it is one fetch can post to. Refused without quoting it:\n * fetch's own error for a URL it cannot parse (\"Failed to parse URL from ...\")\n * quotes the whole URL, and a webhook URL's path is its credential.\n */\nexport function postable(url: string): string {\n let protocol: string;\n try {\n protocol = new URL(url).protocol;\n } catch {\n throw new Error(\"only http and https URLs can be posted to, not this URL\");\n }\n if (protocol !== \"http:\" && protocol !== \"https:\") throw new Error(`only http and https URLs can be posted to, not ${protocol}`);\n return url;\n}\n\n/** How much of a provider's error body goes into the error message. */\nexport const ERROR_BODY_MAX = 200;\n\n/**\n * POSTs and throws on a non-2xx answer. The error names the provider and the\n * URL's origin, plus the start of the response body with every secret the\n * channel holds cut out, in case a provider echoes one back. Secrets are cut\n * from a prefix long enough to hold one that starts inside the first\n * ERROR_BODY_MAX characters, and only then is it cut to that length, so no\n * part of a secret survives at the edge. A redirect is refused rather than\n * followed: the credential headers would go with it to wherever it points.\n */\nexport async function post(provider: string, url: string, init: { headers: Record<string, string>; body: string }, secrets: (string | undefined)[] = []): Promise<Response> {\n const response = await fetch(postable(url), { method: \"POST\", headers: init.headers, body: init.body, redirect: \"error\", signal: AbortSignal.timeout(TIMEOUT_MS) });\n if (!response.ok) {\n let text = \"\";\n try {\n text = await response.text();\n } catch {\n // The status is enough.\n }\n throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : \"\"}`);\n }\n return response;\n}\n\n/** The start of an error body, secrets cut out first, then cut to ERROR_BODY_MAX on a code point. */\nexport function errorBody(text: string, secrets: (string | undefined)[] = []): string {\n const kept = secrets.filter((s): s is string => typeof s === \"string\" && s.length >= 4);\n const longest = kept.reduce((n, s) => Math.max(n, s.length), 0);\n let head = cut(text, ERROR_BODY_MAX + longest);\n for (const secret of kept) head = head.split(secret).join(\"[redacted]\");\n return cut(head, ERROR_BODY_MAX);\n}\n\n/** A credential with the spaces and newlines a paste leaves around it taken off. Anything not a string is empty. */\nexport function trimmed(value: unknown): string {\n return typeof value === \"string\" ? value.trim() : \"\";\n}\n\n/** Base64 of UTF-8, without Buffer. */\nexport function base64(text: string): string {\n let binary = \"\";\n for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);\n return btoa(binary);\n}\n\nexport function basicAuth(user: string, password: string): string {\n return `Basic ${base64(`${user}:${password}`)}`;\n}\n\nexport function hex(bytes: ArrayBuffer | Uint8Array): string {\n return Array.from(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nexport async function sha256Hex(text: string): Promise<string> {\n return hex(await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(text)));\n}\n\n/**\n * A stable 32 hex character id for one alert: the same job, type and time\n * always give the same id, so a provider that deduplicates on it drops a\n * resend of an alert it already took.\n */\nexport async function alertId(alert: Alert): Promise<string> {\n return (await sha256Hex(`${alert.job}\\n${alert.type}\\n${alert.at}`)).slice(0, 32);\n}\n\n/** The same id laid out as a UUID, for APIs that ask for one. */\nexport function asUuid(id: string): string {\n return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20, 32)}`;\n}\n\n/** Cuts to at most `max` UTF-16 units without splitting a surrogate pair. */\nexport function cut(text: string, max: number): string {\n if (text.length <= max) return text;\n let end = max;\n const code = text.charCodeAt(end - 1);\n if (code >= 0xd800 && code <= 0xdbff) end -= 1;\n return text.slice(0, end);\n}\n\n/** The run fields worth attaching to a tracker event. */\nexport function runSummary(alert: Alert): Record<string, unknown> | null {\n const run = alert.run;\n if (!run) return null;\n return { id: run.id, status: run.status, startedAt: new Date(run.startedAt).toISOString(), durationMs: run.durationMs, trigger: run.trigger };\n}\n\n/** Title, message, triage and link as one plain text block, the way every channel reads. */\nexport function plainText(alert: Alert, link: string | undefined): string {\n return [alert.title, \"\", alert.message, ...(alert.triage ? [\"\", `Triage: ${alert.triage}`] : []), ...(link ? [\"\", `Open: ${link}`] : [])].join(\"\\n\");\n}\n","/**\n * New Relic Event API. API reference: https://docs.newrelic.com/docs/data-apis/ingest-apis/event-api/introduction-event-api/\n * POST https://insights-collector.newrelic.com/v1/accounts/<id>/events\n * (insights-collector.eu01.nr-data.net for EU accounts) with Api-Key.\n * Each alert is one custom event of type CronWatchAlert, queryable with NRQL:\n * SELECT * FROM CronWatchAlert WHERE job = 'nightly'.\n */\nimport type { Alert, AlertChannel } from \"../types.js\";\nimport { cut, post, severity, trimmed } from \"./shared.js\";\n\nexport interface NewRelicOptions {\n /** The account id, the number in your New Relic URLs. */\n accountId: string | number;\n /** A license key (INGEST - LICENSE). */\n apiKey: string;\n /** \"eu\" for an account in the EU data center. Defaults to \"us\". */\n region?: \"us\" | \"eu\";\n /** Defaults to \"CronWatchAlert\". */\n eventType?: string;\n link?: (alert: Alert) => string;\n}\n\n/** Records alerts as New Relic custom events. */\nexport function newrelic(options: NewRelicOptions): AlertChannel {\n // A pasted credential often carries a stray space or newline, which a header would refuse or send.\n const apiKey = trimmed(options.apiKey);\n if (!apiKey) throw new Error(\"newrelic() needs an apiKey\");\n const account = String(options.accountId ?? \"\");\n if (!/^\\d+$/.test(account)) throw new Error(\"newrelic() needs a numeric accountId\");\n const host = options.region === \"eu\" ? \"https://insights-collector.eu01.nr-data.net\" : \"https://insights-collector.newrelic.com\";\n const url = `${host}/v1/accounts/${account}/events`;\n const eventType = options.eventType ?? \"CronWatchAlert\";\n return {\n name: \"newrelic\",\n async send(alert) {\n const link = options.link?.(alert);\n const run = alert.run;\n // Flat attributes only, strings under 4096 characters.\n const event: Record<string, string | number> = {\n eventType,\n timestamp: alert.at,\n job: cut(alert.job, 4095),\n alertType: alert.type,\n severity: severity(alert.type),\n title: cut(alert.title, 4095),\n message: cut(alert.message, 4095),\n };\n if (alert.triage) event.triage = cut(alert.triage, 4095);\n if (link) event.link = cut(link, 4095);\n if (run) {\n event.runId = run.id;\n event.runStatus = run.status;\n if (run.durationMs !== null) event.durationMs = run.durationMs;\n }\n await post(\"New Relic\", url, {\n headers: { \"content-type\": \"application/json\", \"api-key\": apiKey },\n body: JSON.stringify([event]),\n }, [apiKey]);\n },\n };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/newrelic.ts"],"names":[],"mappings":"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","file":"newrelic.cjs","sourcesContent":["/**\n * Helpers shared by the provider channels. Only fetch and Web Crypto, so the\n * channels run on Node, Cloudflare Workers, Deno and Bun alike. Not an entry\n * point: each channel bundles its own copy.\n */\nimport { isoTime } from \"../duration.js\";\nimport type { Alert } from \"../types.js\";\n\nexport const TIMEOUT_MS = 10_000;\n\n/** Severity for trackers that have levels. Recovered is informational. */\nexport type Severity = \"error\" | \"warning\" | \"info\";\n\nexport function severity(type: Alert[\"type\"]): Severity {\n if (type === \"recovered\") return \"info\";\n if (type === \"slow\" || type === \"over_budget\") return \"warning\";\n return \"error\";\n}\n\n/** The scheme, host and port only. A URL's path or query can hold a credential. */\nexport function origin(url: string): string {\n try {\n return new URL(url).origin;\n } catch {\n return \"(invalid URL)\";\n }\n}\n\n/**\n * The URL, once it is one fetch can post to. Refused without quoting it:\n * fetch's own error for a URL it cannot parse (\"Failed to parse URL from ...\")\n * quotes the whole URL, and a webhook URL's path is its credential.\n */\nexport function postable(url: string): string {\n let protocol: string;\n try {\n protocol = new URL(url).protocol;\n } catch {\n throw new Error(\"only http and https URLs can be posted to, not this URL\");\n }\n if (protocol !== \"http:\" && protocol !== \"https:\") throw new Error(`only http and https URLs can be posted to, not ${protocol}`);\n return url;\n}\n\n/** How much of a provider's error body goes into the error message. */\nexport const ERROR_BODY_MAX = 200;\n\n/**\n * POSTs and throws on a non-2xx answer. The error names the provider and the\n * URL's origin, plus the start of the response body with every secret the\n * channel holds cut out, in case a provider echoes one back. Secrets are cut\n * from a prefix long enough to hold one that starts inside the first\n * ERROR_BODY_MAX characters, and only then is it cut to that length, so no\n * part of a secret survives at the edge. A redirect is refused rather than\n * followed: the credential headers would go with it to wherever it points.\n */\nexport async function post(provider: string, url: string, init: { headers: Record<string, string>; body: string }, secrets: (string | undefined)[] = []): Promise<Response> {\n const response = await fetch(postable(url), { method: \"POST\", headers: init.headers, body: init.body, redirect: \"error\", signal: AbortSignal.timeout(TIMEOUT_MS) });\n if (!response.ok) {\n let text = \"\";\n try {\n text = await response.text();\n } catch {\n // The status is enough.\n }\n throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : \"\"}`);\n }\n return response;\n}\n\n/** The start of an error body, secrets cut out first, then cut to ERROR_BODY_MAX on a code point. */\nexport function errorBody(text: string, secrets: (string | undefined)[] = []): string {\n const kept = secrets.filter((s): s is string => typeof s === \"string\" && s.length >= 4);\n const longest = kept.reduce((n, s) => Math.max(n, s.length), 0);\n let head = cut(text, ERROR_BODY_MAX + longest);\n for (const secret of kept) head = head.split(secret).join(\"[redacted]\");\n return cut(head, ERROR_BODY_MAX);\n}\n\n/** A credential with the spaces and newlines a paste leaves around it taken off. Anything not a string is empty. */\nexport function trimmed(value: unknown): string {\n return typeof value === \"string\" ? value.trim() : \"\";\n}\n\n/** Base64 of UTF-8, without Buffer. */\nexport function base64(text: string): string {\n let binary = \"\";\n for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);\n return btoa(binary);\n}\n\nexport function basicAuth(user: string, password: string): string {\n return `Basic ${base64(`${user}:${password}`)}`;\n}\n\nexport function hex(bytes: ArrayBuffer | Uint8Array): string {\n return Array.from(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nexport async function sha256Hex(text: string): Promise<string> {\n return hex(await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(text)));\n}\n\n/**\n * A stable 32 hex character id for one alert: the same job, type and time\n * always give the same id, so a provider that deduplicates on it drops a\n * resend of an alert it already took.\n */\nexport async function alertId(alert: Alert): Promise<string> {\n return (await sha256Hex(`${alert.job}\\n${alert.type}\\n${alert.at}`)).slice(0, 32);\n}\n\n/** The same id laid out as a UUID, for APIs that ask for one. */\nexport function asUuid(id: string): string {\n return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20, 32)}`;\n}\n\n/** Cuts to at most `max` UTF-16 units without splitting a surrogate pair. */\nexport function cut(text: string, max: number): string {\n if (text.length <= max) return text;\n let end = max;\n const code = text.charCodeAt(end - 1);\n if (code >= 0xd800 && code <= 0xdbff) end -= 1;\n return text.slice(0, end);\n}\n\n/** The run fields worth attaching to a tracker event. A start before the year 1 or after 9999 is null. */\nexport function runSummary(alert: Alert): Record<string, unknown> | null {\n const run = alert.run;\n if (!run) return null;\n return { id: run.id, status: run.status, startedAt: isoTime(run.startedAt), durationMs: run.durationMs, trigger: run.trigger };\n}\n\n/** Title, message, triage and link as one plain text block, the way every channel reads. */\nexport function plainText(alert: Alert, link: string | undefined): string {\n return [alert.title, \"\", alert.message, ...(alert.triage ? [\"\", `Triage: ${alert.triage}`] : []), ...(link ? [\"\", `Open: ${link}`] : [])].join(\"\\n\");\n}\n","/**\n * New Relic Event API. API reference: https://docs.newrelic.com/docs/data-apis/ingest-apis/event-api/introduction-event-api/\n * POST https://insights-collector.newrelic.com/v1/accounts/<id>/events\n * (insights-collector.eu01.nr-data.net for EU accounts) with Api-Key.\n * Each alert is one custom event of type CronWatchAlert, queryable with NRQL:\n * SELECT * FROM CronWatchAlert WHERE job = 'nightly'.\n */\nimport type { Alert, AlertChannel } from \"../types.js\";\nimport { cut, post, severity, trimmed } from \"./shared.js\";\n\nexport interface NewRelicOptions {\n /** The account id, the number in your New Relic URLs. */\n accountId: string | number;\n /** A license key (INGEST - LICENSE). */\n apiKey: string;\n /** \"eu\" for an account in the EU data center. Defaults to \"us\". */\n region?: \"us\" | \"eu\";\n /** Defaults to \"CronWatchAlert\". */\n eventType?: string;\n link?: (alert: Alert) => string;\n}\n\n/** Records alerts as New Relic custom events. */\nexport function newrelic(options: NewRelicOptions): AlertChannel {\n // A pasted credential often carries a stray space or newline, which a header would refuse or send.\n const apiKey = trimmed(options.apiKey);\n if (!apiKey) throw new Error(\"newrelic() needs an apiKey\");\n const account = String(options.accountId ?? \"\");\n if (!/^\\d+$/.test(account)) throw new Error(\"newrelic() needs a numeric accountId\");\n const host = options.region === \"eu\" ? \"https://insights-collector.eu01.nr-data.net\" : \"https://insights-collector.newrelic.com\";\n const url = `${host}/v1/accounts/${account}/events`;\n const eventType = options.eventType ?? \"CronWatchAlert\";\n return {\n name: \"newrelic\",\n async send(alert) {\n const link = options.link?.(alert);\n const run = alert.run;\n // Flat attributes only, strings under 4096 characters.\n const event: Record<string, string | number> = {\n eventType,\n timestamp: alert.at,\n job: cut(alert.job, 4095),\n alertType: alert.type,\n severity: severity(alert.type),\n title: cut(alert.title, 4095),\n message: cut(alert.message, 4095),\n };\n if (alert.triage) event.triage = cut(alert.triage, 4095);\n if (link) event.link = cut(link, 4095);\n if (run) {\n event.runId = run.id;\n event.runStatus = run.status;\n if (run.durationMs !== null) event.durationMs = run.durationMs;\n }\n await post(\"New Relic\", url, {\n headers: { \"content-type\": \"application/json\", \"api-key\": apiKey },\n body: JSON.stringify([event]),\n }, [apiKey]);\n },\n };\n}\n"]}
|
package/dist/newrelic.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/newrelic.ts"],"names":[],"mappings":"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","file":"newrelic.js","sourcesContent":["/**\n * Helpers shared by the provider channels. Only fetch and Web Crypto, so the\n * channels run on Node, Cloudflare Workers, Deno and Bun alike. Not an entry\n * point: each channel bundles its own copy.\n */\nimport type { Alert } from \"../types.js\";\n\nexport const TIMEOUT_MS = 10_000;\n\n/** Severity for trackers that have levels. Recovered is informational. */\nexport type Severity = \"error\" | \"warning\" | \"info\";\n\nexport function severity(type: Alert[\"type\"]): Severity {\n if (type === \"recovered\") return \"info\";\n if (type === \"slow\" || type === \"over_budget\") return \"warning\";\n return \"error\";\n}\n\n/** The scheme, host and port only. A URL's path or query can hold a credential. */\nexport function origin(url: string): string {\n try {\n return new URL(url).origin;\n } catch {\n return \"(invalid URL)\";\n }\n}\n\n/**\n * The URL, once it is one fetch can post to. Refused without quoting it:\n * fetch's own error for a URL it cannot parse (\"Failed to parse URL from ...\")\n * quotes the whole URL, and a webhook URL's path is its credential.\n */\nexport function postable(url: string): string {\n let protocol: string;\n try {\n protocol = new URL(url).protocol;\n } catch {\n throw new Error(\"only http and https URLs can be posted to, not this URL\");\n }\n if (protocol !== \"http:\" && protocol !== \"https:\") throw new Error(`only http and https URLs can be posted to, not ${protocol}`);\n return url;\n}\n\n/** How much of a provider's error body goes into the error message. */\nexport const ERROR_BODY_MAX = 200;\n\n/**\n * POSTs and throws on a non-2xx answer. The error names the provider and the\n * URL's origin, plus the start of the response body with every secret the\n * channel holds cut out, in case a provider echoes one back. Secrets are cut\n * from a prefix long enough to hold one that starts inside the first\n * ERROR_BODY_MAX characters, and only then is it cut to that length, so no\n * part of a secret survives at the edge. A redirect is refused rather than\n * followed: the credential headers would go with it to wherever it points.\n */\nexport async function post(provider: string, url: string, init: { headers: Record<string, string>; body: string }, secrets: (string | undefined)[] = []): Promise<Response> {\n const response = await fetch(postable(url), { method: \"POST\", headers: init.headers, body: init.body, redirect: \"error\", signal: AbortSignal.timeout(TIMEOUT_MS) });\n if (!response.ok) {\n let text = \"\";\n try {\n text = await response.text();\n } catch {\n // The status is enough.\n }\n throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : \"\"}`);\n }\n return response;\n}\n\n/** The start of an error body, secrets cut out first, then cut to ERROR_BODY_MAX on a code point. */\nexport function errorBody(text: string, secrets: (string | undefined)[] = []): string {\n const kept = secrets.filter((s): s is string => typeof s === \"string\" && s.length >= 4);\n const longest = kept.reduce((n, s) => Math.max(n, s.length), 0);\n let head = cut(text, ERROR_BODY_MAX + longest);\n for (const secret of kept) head = head.split(secret).join(\"[redacted]\");\n return cut(head, ERROR_BODY_MAX);\n}\n\n/** A credential with the spaces and newlines a paste leaves around it taken off. Anything not a string is empty. */\nexport function trimmed(value: unknown): string {\n return typeof value === \"string\" ? value.trim() : \"\";\n}\n\n/** Base64 of UTF-8, without Buffer. */\nexport function base64(text: string): string {\n let binary = \"\";\n for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);\n return btoa(binary);\n}\n\nexport function basicAuth(user: string, password: string): string {\n return `Basic ${base64(`${user}:${password}`)}`;\n}\n\nexport function hex(bytes: ArrayBuffer | Uint8Array): string {\n return Array.from(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nexport async function sha256Hex(text: string): Promise<string> {\n return hex(await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(text)));\n}\n\n/**\n * A stable 32 hex character id for one alert: the same job, type and time\n * always give the same id, so a provider that deduplicates on it drops a\n * resend of an alert it already took.\n */\nexport async function alertId(alert: Alert): Promise<string> {\n return (await sha256Hex(`${alert.job}\\n${alert.type}\\n${alert.at}`)).slice(0, 32);\n}\n\n/** The same id laid out as a UUID, for APIs that ask for one. */\nexport function asUuid(id: string): string {\n return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20, 32)}`;\n}\n\n/** Cuts to at most `max` UTF-16 units without splitting a surrogate pair. */\nexport function cut(text: string, max: number): string {\n if (text.length <= max) return text;\n let end = max;\n const code = text.charCodeAt(end - 1);\n if (code >= 0xd800 && code <= 0xdbff) end -= 1;\n return text.slice(0, end);\n}\n\n/** The run fields worth attaching to a tracker event. */\nexport function runSummary(alert: Alert): Record<string, unknown> | null {\n const run = alert.run;\n if (!run) return null;\n return { id: run.id, status: run.status, startedAt: new Date(run.startedAt).toISOString(), durationMs: run.durationMs, trigger: run.trigger };\n}\n\n/** Title, message, triage and link as one plain text block, the way every channel reads. */\nexport function plainText(alert: Alert, link: string | undefined): string {\n return [alert.title, \"\", alert.message, ...(alert.triage ? [\"\", `Triage: ${alert.triage}`] : []), ...(link ? [\"\", `Open: ${link}`] : [])].join(\"\\n\");\n}\n","/**\n * New Relic Event API. API reference: https://docs.newrelic.com/docs/data-apis/ingest-apis/event-api/introduction-event-api/\n * POST https://insights-collector.newrelic.com/v1/accounts/<id>/events\n * (insights-collector.eu01.nr-data.net for EU accounts) with Api-Key.\n * Each alert is one custom event of type CronWatchAlert, queryable with NRQL:\n * SELECT * FROM CronWatchAlert WHERE job = 'nightly'.\n */\nimport type { Alert, AlertChannel } from \"../types.js\";\nimport { cut, post, severity, trimmed } from \"./shared.js\";\n\nexport interface NewRelicOptions {\n /** The account id, the number in your New Relic URLs. */\n accountId: string | number;\n /** A license key (INGEST - LICENSE). */\n apiKey: string;\n /** \"eu\" for an account in the EU data center. Defaults to \"us\". */\n region?: \"us\" | \"eu\";\n /** Defaults to \"CronWatchAlert\". */\n eventType?: string;\n link?: (alert: Alert) => string;\n}\n\n/** Records alerts as New Relic custom events. */\nexport function newrelic(options: NewRelicOptions): AlertChannel {\n // A pasted credential often carries a stray space or newline, which a header would refuse or send.\n const apiKey = trimmed(options.apiKey);\n if (!apiKey) throw new Error(\"newrelic() needs an apiKey\");\n const account = String(options.accountId ?? \"\");\n if (!/^\\d+$/.test(account)) throw new Error(\"newrelic() needs a numeric accountId\");\n const host = options.region === \"eu\" ? \"https://insights-collector.eu01.nr-data.net\" : \"https://insights-collector.newrelic.com\";\n const url = `${host}/v1/accounts/${account}/events`;\n const eventType = options.eventType ?? \"CronWatchAlert\";\n return {\n name: \"newrelic\",\n async send(alert) {\n const link = options.link?.(alert);\n const run = alert.run;\n // Flat attributes only, strings under 4096 characters.\n const event: Record<string, string | number> = {\n eventType,\n timestamp: alert.at,\n job: cut(alert.job, 4095),\n alertType: alert.type,\n severity: severity(alert.type),\n title: cut(alert.title, 4095),\n message: cut(alert.message, 4095),\n };\n if (alert.triage) event.triage = cut(alert.triage, 4095);\n if (link) event.link = cut(link, 4095);\n if (run) {\n event.runId = run.id;\n event.runStatus = run.status;\n if (run.durationMs !== null) event.durationMs = run.durationMs;\n }\n await post(\"New Relic\", url, {\n headers: { \"content-type\": \"application/json\", \"api-key\": apiKey },\n body: JSON.stringify([event]),\n }, [apiKey]);\n },\n };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/newrelic.ts"],"names":[],"mappings":"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","file":"newrelic.js","sourcesContent":["/**\n * Helpers shared by the provider channels. Only fetch and Web Crypto, so the\n * channels run on Node, Cloudflare Workers, Deno and Bun alike. Not an entry\n * point: each channel bundles its own copy.\n */\nimport { isoTime } from \"../duration.js\";\nimport type { Alert } from \"../types.js\";\n\nexport const TIMEOUT_MS = 10_000;\n\n/** Severity for trackers that have levels. Recovered is informational. */\nexport type Severity = \"error\" | \"warning\" | \"info\";\n\nexport function severity(type: Alert[\"type\"]): Severity {\n if (type === \"recovered\") return \"info\";\n if (type === \"slow\" || type === \"over_budget\") return \"warning\";\n return \"error\";\n}\n\n/** The scheme, host and port only. A URL's path or query can hold a credential. */\nexport function origin(url: string): string {\n try {\n return new URL(url).origin;\n } catch {\n return \"(invalid URL)\";\n }\n}\n\n/**\n * The URL, once it is one fetch can post to. Refused without quoting it:\n * fetch's own error for a URL it cannot parse (\"Failed to parse URL from ...\")\n * quotes the whole URL, and a webhook URL's path is its credential.\n */\nexport function postable(url: string): string {\n let protocol: string;\n try {\n protocol = new URL(url).protocol;\n } catch {\n throw new Error(\"only http and https URLs can be posted to, not this URL\");\n }\n if (protocol !== \"http:\" && protocol !== \"https:\") throw new Error(`only http and https URLs can be posted to, not ${protocol}`);\n return url;\n}\n\n/** How much of a provider's error body goes into the error message. */\nexport const ERROR_BODY_MAX = 200;\n\n/**\n * POSTs and throws on a non-2xx answer. The error names the provider and the\n * URL's origin, plus the start of the response body with every secret the\n * channel holds cut out, in case a provider echoes one back. Secrets are cut\n * from a prefix long enough to hold one that starts inside the first\n * ERROR_BODY_MAX characters, and only then is it cut to that length, so no\n * part of a secret survives at the edge. A redirect is refused rather than\n * followed: the credential headers would go with it to wherever it points.\n */\nexport async function post(provider: string, url: string, init: { headers: Record<string, string>; body: string }, secrets: (string | undefined)[] = []): Promise<Response> {\n const response = await fetch(postable(url), { method: \"POST\", headers: init.headers, body: init.body, redirect: \"error\", signal: AbortSignal.timeout(TIMEOUT_MS) });\n if (!response.ok) {\n let text = \"\";\n try {\n text = await response.text();\n } catch {\n // The status is enough.\n }\n throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : \"\"}`);\n }\n return response;\n}\n\n/** The start of an error body, secrets cut out first, then cut to ERROR_BODY_MAX on a code point. */\nexport function errorBody(text: string, secrets: (string | undefined)[] = []): string {\n const kept = secrets.filter((s): s is string => typeof s === \"string\" && s.length >= 4);\n const longest = kept.reduce((n, s) => Math.max(n, s.length), 0);\n let head = cut(text, ERROR_BODY_MAX + longest);\n for (const secret of kept) head = head.split(secret).join(\"[redacted]\");\n return cut(head, ERROR_BODY_MAX);\n}\n\n/** A credential with the spaces and newlines a paste leaves around it taken off. Anything not a string is empty. */\nexport function trimmed(value: unknown): string {\n return typeof value === \"string\" ? value.trim() : \"\";\n}\n\n/** Base64 of UTF-8, without Buffer. */\nexport function base64(text: string): string {\n let binary = \"\";\n for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);\n return btoa(binary);\n}\n\nexport function basicAuth(user: string, password: string): string {\n return `Basic ${base64(`${user}:${password}`)}`;\n}\n\nexport function hex(bytes: ArrayBuffer | Uint8Array): string {\n return Array.from(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nexport async function sha256Hex(text: string): Promise<string> {\n return hex(await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(text)));\n}\n\n/**\n * A stable 32 hex character id for one alert: the same job, type and time\n * always give the same id, so a provider that deduplicates on it drops a\n * resend of an alert it already took.\n */\nexport async function alertId(alert: Alert): Promise<string> {\n return (await sha256Hex(`${alert.job}\\n${alert.type}\\n${alert.at}`)).slice(0, 32);\n}\n\n/** The same id laid out as a UUID, for APIs that ask for one. */\nexport function asUuid(id: string): string {\n return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20, 32)}`;\n}\n\n/** Cuts to at most `max` UTF-16 units without splitting a surrogate pair. */\nexport function cut(text: string, max: number): string {\n if (text.length <= max) return text;\n let end = max;\n const code = text.charCodeAt(end - 1);\n if (code >= 0xd800 && code <= 0xdbff) end -= 1;\n return text.slice(0, end);\n}\n\n/** The run fields worth attaching to a tracker event. A start before the year 1 or after 9999 is null. */\nexport function runSummary(alert: Alert): Record<string, unknown> | null {\n const run = alert.run;\n if (!run) return null;\n return { id: run.id, status: run.status, startedAt: isoTime(run.startedAt), durationMs: run.durationMs, trigger: run.trigger };\n}\n\n/** Title, message, triage and link as one plain text block, the way every channel reads. */\nexport function plainText(alert: Alert, link: string | undefined): string {\n return [alert.title, \"\", alert.message, ...(alert.triage ? [\"\", `Triage: ${alert.triage}`] : []), ...(link ? [\"\", `Open: ${link}`] : [])].join(\"\\n\");\n}\n","/**\n * New Relic Event API. API reference: https://docs.newrelic.com/docs/data-apis/ingest-apis/event-api/introduction-event-api/\n * POST https://insights-collector.newrelic.com/v1/accounts/<id>/events\n * (insights-collector.eu01.nr-data.net for EU accounts) with Api-Key.\n * Each alert is one custom event of type CronWatchAlert, queryable with NRQL:\n * SELECT * FROM CronWatchAlert WHERE job = 'nightly'.\n */\nimport type { Alert, AlertChannel } from \"../types.js\";\nimport { cut, post, severity, trimmed } from \"./shared.js\";\n\nexport interface NewRelicOptions {\n /** The account id, the number in your New Relic URLs. */\n accountId: string | number;\n /** A license key (INGEST - LICENSE). */\n apiKey: string;\n /** \"eu\" for an account in the EU data center. Defaults to \"us\". */\n region?: \"us\" | \"eu\";\n /** Defaults to \"CronWatchAlert\". */\n eventType?: string;\n link?: (alert: Alert) => string;\n}\n\n/** Records alerts as New Relic custom events. */\nexport function newrelic(options: NewRelicOptions): AlertChannel {\n // A pasted credential often carries a stray space or newline, which a header would refuse or send.\n const apiKey = trimmed(options.apiKey);\n if (!apiKey) throw new Error(\"newrelic() needs an apiKey\");\n const account = String(options.accountId ?? \"\");\n if (!/^\\d+$/.test(account)) throw new Error(\"newrelic() needs a numeric accountId\");\n const host = options.region === \"eu\" ? \"https://insights-collector.eu01.nr-data.net\" : \"https://insights-collector.newrelic.com\";\n const url = `${host}/v1/accounts/${account}/events`;\n const eventType = options.eventType ?? \"CronWatchAlert\";\n return {\n name: \"newrelic\",\n async send(alert) {\n const link = options.link?.(alert);\n const run = alert.run;\n // Flat attributes only, strings under 4096 characters.\n const event: Record<string, string | number> = {\n eventType,\n timestamp: alert.at,\n job: cut(alert.job, 4095),\n alertType: alert.type,\n severity: severity(alert.type),\n title: cut(alert.title, 4095),\n message: cut(alert.message, 4095),\n };\n if (alert.triage) event.triage = cut(alert.triage, 4095);\n if (link) event.link = cut(link, 4095);\n if (run) {\n event.runId = run.id;\n event.runStatus = run.status;\n if (run.durationMs !== null) event.durationMs = run.durationMs;\n }\n await post(\"New Relic\", url, {\n headers: { \"content-type\": \"application/json\", \"api-key\": apiKey },\n body: JSON.stringify([event]),\n }, [apiKey]);\n },\n };\n}\n"]}
|
package/dist/pg-cron.cjs
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
// src/evaluate.ts
|
|
4
|
+
var MAX_DURATION_MS = Number.MAX_SAFE_INTEGER;
|
|
5
|
+
function runDuration(startedAt, finishedAt) {
|
|
6
|
+
const ms = finishedAt - startedAt;
|
|
7
|
+
return ms > 0 ? Math.min(ms, MAX_DURATION_MS) : 0;
|
|
8
|
+
}
|
|
9
|
+
|
|
3
10
|
// src/sources/pgcron.ts
|
|
4
11
|
var BACKFILL = 20;
|
|
5
12
|
var PAGE = 500;
|
|
@@ -43,7 +50,7 @@ function pgCronRun(row, job, idPrefix, fallbackAt = Date.now()) {
|
|
|
43
50
|
status,
|
|
44
51
|
startedAt,
|
|
45
52
|
finishedAt: end,
|
|
46
|
-
durationMs: end === null ? null :
|
|
53
|
+
durationMs: end === null ? null : runDuration(startedAt, end),
|
|
47
54
|
error: status === "failed" ? message ?? "pg_cron reported the run as failed" : null,
|
|
48
55
|
output: status === "ok" ? message : null,
|
|
49
56
|
metrics: {},
|
package/dist/pg-cron.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/sources/pgcron.ts"],"names":[],"mappings":";;;AA+CA,IAAM,QAAA,GAAW,EAAA;AAEjB,IAAM,IAAA,GAAO,GAAA;AACb,IAAM,SAAA,GAAY,EAAA;AAMX,IAAM,kBAAkB,EAAA,GAAK;AAEpC,IAAM,QAAA,GAAW,CAAA,wFAAA,CAAA;AAGjB,IAAM,WAAA,GAAc,CAAA,+CAAA,CAAA;AACpB,IAAM,OAAA,GAAU,CAAA,sEAAA,CAAA;AAEhB,IAAM,QAAA,GAAW,UAAU,OAAO;AAAA;AAAA;AAAA;AAAA,yBAAA,EAIP,IAAI,CAAA,CAAA;AAC/B,IAAM,UAAA,GAAa,CAAA,OAAA,EAAU,OAAO,CAAA,4EAAA,EAA+E,QAAQ,CAAA,CAAA;AAW3H,IAAM,cAAA,GAAiB,CAAC,MAAA,KAA0B,MAAA,KAAW,eAAe,MAAA,KAAW,QAAA;AAShF,SAAS,eAAe,QAAA,EAAiC;AAC9D,EAAA,MAAM,IAAA,GAAO,SAAS,IAAA,EAAK;AAC3B,EAAA,MAAM,OAAA,GAAU,qBAAA,CAAsB,IAAA,CAAK,IAAI,CAAA;AAC/C,EAAA,IAAI,SAAS,OAAO,CAAA,MAAA,EAAS,OAAO,OAAA,CAAQ,CAAC,CAAC,CAAC,CAAA,CAAA,CAAA;AAC/C,EAAA,IAAI,YAAA,CAAa,IAAA,CAAK,IAAI,CAAA,EAAG,OAAO,IAAA;AACpC,EAAA,MAAM,MAAA,GAAS,IAAA,CAAK,KAAA,CAAM,KAAK,CAAA;AAC/B,EAAA,IAAI,MAAA,CAAO,MAAA,GAAS,CAAA,IAAK,CAAC,MAAA,CAAO,CAAC,CAAA,CAAG,UAAA,CAAW,GAAG,CAAA,EAAG,MAAA,CAAO,MAAA,GAAS,CAAA;AACtE,EAAA,IAAI,OAAO,MAAA,KAAW,CAAA,IAAK,MAAA,CAAO,CAAC,EAAG,QAAA,CAAS,GAAG,CAAA,EAAG,MAAA,CAAO,CAAC,CAAA,GAAI,MAAA,CAAO,CAAC,CAAA,CAAG,OAAA,CAAQ,OAAO,GAAG,CAAA;AAC9F,EAAA,OAAO,MAAA,CAAO,KAAK,GAAG,CAAA;AACxB;AAGO,SAAS,cAAc,GAAA,EAAmD;AAC/E,EAAA,MAAM,OAAA,GAAA,CAAW,GAAA,CAAI,OAAA,IAAW,EAAA,EAAI,QAAQ,oBAAA,EAAsB,GAAG,CAAA,CAAE,OAAA,CAAQ,gBAAA,EAAkB,EAAE,CAAA,CAAE,KAAA,CAAM,GAAG,GAAG,CAAA;AACjH,EAAA,OAAO,OAAA,IAAW,CAAA,QAAA,EAAW,GAAA,CAAI,KAAK,CAAA,CAAA;AACxC;AASO,SAAS,UAAU,GAAA,EAAgB,GAAA,EAAa,UAAkB,UAAA,GAAqB,IAAA,CAAK,KAAI,EAAe;AACpH,EAAA,MAAM,UAAA,GAAa,GAAA,CAAI,QAAA,KAAa,IAAA,GAAO,IAAA,GAAO,IAAI,IAAA,CAAK,GAAA,CAAI,QAAQ,CAAA,CAAE,OAAA,EAAQ;AACjF,EAAA,MAAM,IAAA,GAAO,cAAA,CAAe,GAAA,CAAI,MAAM,CAAA;AACtC,EAAA,IAAI,GAAA,CAAI,UAAA,KAAe,IAAA,IAAQ,CAAC,MAAM,OAAO,IAAA;AAC7C,EAAA,MAAM,SAAA,GAAY,GAAA,CAAI,UAAA,KAAe,IAAA,GAAO,IAAI,IAAA,CAAK,GAAA,CAAI,UAAU,CAAA,CAAE,OAAA,EAAQ,GAAK,UAAA,IAAc,UAAA;AAChG,EAAA,MAAM,OAAA,GAAU,IAAI,cAAA,KAAmB,IAAA,GAAO,OAAO,GAAA,CAAI,cAAA,CAAe,MAAK,IAAK,IAAA;AAClF,EAAA,MAAM,MAAA,GAAwB,IAAI,MAAA,KAAW,WAAA,GAAc,OAAO,GAAA,CAAI,MAAA,KAAW,WAAW,QAAA,GAAW,SAAA;AACvG,EAAA,MAAM,MAAM,IAAA,GAAO,IAAA,CAAK,IAAI,SAAA,EAAW,UAAA,IAAc,SAAS,CAAA,GAAI,IAAA;AAClE,EAAA,OAAO;AAAA,IACL,EAAA,EAAI,CAAA,EAAG,QAAQ,CAAA,EAAG,IAAI,KAAK,CAAA,CAAA;AAAA,IAC3B,GAAA;AAAA,IACA,MAAA;AAAA,IACA,SAAA;AAAA,IACA,UAAA,EAAY,GAAA;AAAA,IACZ,UAAA,EAAY,GAAA,KAAQ,IAAA,GAAO,IAAA,GAAO,GAAA,GAAM,SAAA;AAAA,IACxC,KAAA,EAAO,MAAA,KAAW,QAAA,GAAY,OAAA,IAAW,oCAAA,GAAwC,IAAA;AAAA,IACjF,MAAA,EAAQ,MAAA,KAAW,IAAA,GAAO,OAAA,GAAU,IAAA;AAAA,IACpC,SAAS,EAAC;AAAA,IACV,OAAA,EAAS;AAAA,GACX;AACF;AAGA,SAAS,YAAY,UAAA,EAA0D;AAC7E,EAAA,MAAM,EAAE,aAAa,IAAA,EAAM,KAAA,EAAO,SAAS,WAAA,EAAa,MAAA,EAAQ,qBAAoB,GAAI,UAAA;AACxF,EAAA,MAAM,MAAkB,EAAC;AACzB,EAAA,IAAI,WAAA,KAAgB,MAAA,EAAW,GAAA,CAAI,WAAA,GAAc,WAAA;AACjD,EAAA,IAAI,IAAA,KAAS,MAAA,EAAW,GAAA,CAAI,IAAA,GAAO,IAAA;AACnC,EAAA,IAAI,KAAA,KAAU,MAAA,EAAW,GAAA,CAAI,KAAA,GAAQ,KAAA;AACrC,EAAA,IAAI,OAAA,KAAY,MAAA,EAAW,GAAA,CAAI,OAAA,GAAU,OAAA;AACzC,EAAA,IAAI,WAAA,KAAgB,MAAA,EAAW,GAAA,CAAI,WAAA,GAAc,WAAA;AACjD,EAAA,IAAI,MAAA,KAAW,MAAA,EAAW,GAAA,CAAI,MAAA,GAAS,MAAA;AACvC,EAAA,IAAI,mBAAA,KAAwB,MAAA,EAAW,GAAA,CAAI,mBAAA,GAAsB,mBAAA;AACjE,EAAA,OAAO,GAAA;AACT;AAEA,IAAM,QAAQ,CAAC,UAAA,KACb,IAAA,CAAK,SAAA,CAAU,YAAY,CAAC,EAAA,EAAI,CAAA,KAAgB,CAAA,YAAa,UAAU,OAAO,CAAA,KAAM,aAAa,MAAA,CAAO,CAAC,IAAI,CAAE,CAAA;AAe1G,SAAS,MAAA,CAAO,EAAA,EAAe,OAAA,GAAyB,EAAC,EAAW;AACzE,EAAA,MAAM,MAAA,GAAS,QAAQ,MAAA,IAAU,EAAA;AACjC,EAAA,MAAM,QAAA,GAAW,UAAU,MAAM,CAAA,CAAA;AAEjC,EAAA,MAAM,OAAA,uBAAc,GAAA,EAAoB;AAExC,EAAA,MAAM,MAAA,uBAAa,GAAA,EAAoB;AAEvC,EAAA,MAAM,OAAA,uBAAc,GAAA,EAAoB;AAExC,EAAA,MAAM,IAAA,uBAAW,GAAA,EAAoB;AAErC,EAAA,IAAI,KAAA,uBAAY,GAAA,EAAsD;AAEtE,EAAA,MAAM,QAAA,uBAAe,GAAA,EAAoB;AAEzC,EAAA,MAAM,OAAA,uBAAc,GAAA,EAAY;AAChC,EAAA,IAAI,OAAA,GAAU,KAAA;AACd,EAAA,MAAM,MAAA,uBAAa,GAAA,EAAY;AAE/B,EAAA,MAAM,QAAA,GAAW,CAAC,IAAA,EAAkB,GAAA,EAAa,OAAA,KAAoB;AACnE,IAAA,IAAI,MAAA,CAAO,GAAA,CAAI,GAAG,CAAA,EAAG;AACrB,IAAA,MAAA,CAAO,IAAI,GAAG,CAAA;AACd,IAAA,IAAA,CAAK,OAAA,CAAQ,IAAI,KAAA,CAAM,OAAO,GAAG,gBAAgB,CAAA;AAAA,EACnD,CAAA;AAEA,EAAA,MAAM,KAAA,GAAQ,CAAC,GAAA,KAA4B;AACzC,IAAA,MAAM,EAAE,MAAK,GAAI,OAAA;AACjB,IAAA,IAAI,IAAA,KAAS,QAAW,OAAO,IAAA;AAC/B,IAAA,IAAI,OAAO,IAAA,KAAS,UAAA,EAAY,OAAO,KAAK,GAAG,CAAA;AAC/C,IAAA,OAAO,IAAA,CAAK,IAAA,CAAK,CAAC,CAAA,KAAO,OAAO,CAAA,KAAM,QAAA,GAAW,CAAA,KAAM,GAAA,CAAI,KAAA,GAAQ,CAAA,KAAM,GAAA,CAAI,OAAQ,CAAA;AAAA,EACvF,CAAA;AAEA,EAAA,MAAM,OAAA,GAAU,OAAO,IAAA,KAAyC;AAC9D,IAAA,IAAI;AACF,MAAA,MAAM,EAAE,MAAK,GAAI,MAAM,GAAG,KAAA,CAAM,WAAA,EAAa,CAAC,IAAI,CAAC,CAAA;AACnD,MAAA,OAAQ,IAAA,CAAK,CAAC,CAAA,EAAG,OAAA,IAAyC,IAAA;AAAA,IAC5D,CAAA,CAAA,MAAQ;AACN,MAAA,OAAO,IAAA;AAAA,IACT;AAAA,EACF,CAAA;AAEA,EAAA,MAAM,OAAA,GAAU,CAAC,EAAA,KAA8B;AAC7C,IAAA,IAAI,CAAC,EAAA,CAAG,UAAA,CAAW,QAAQ,GAAG,OAAO,IAAA;AACrC,IAAA,MAAM,IAAI,MAAA,CAAO,EAAA,CAAG,KAAA,CAAM,QAAA,CAAS,MAAM,CAAC,CAAA;AAC1C,IAAA,OAAO,MAAA,CAAO,aAAA,CAAc,CAAC,CAAA,GAAI,CAAA,GAAI,IAAA;AAAA,EACvC,CAAA;AAGA,EAAA,MAAM,MAAA,GAAS,CAAC,IAAA,EAAkB,IAAA,EAAc,YAA8C,GAAA,KAAgB;AAC5G,IAAA,MAAM,IAAA,GAAO,YAAY,UAAU,CAAA;AACnC,IAAA,MAAM,IAAA,GAAmB,EAAE,GAAG,IAAA,EAAM,WAAA,EAAa,CAAA,EAAG,IAAA,CAAK,WAAA,IAAe,aAAa,CAAA,EAAA,EAAK,GAAG,CAAA,CAAA,CAAA,EAAI;AACjG,IAAA,IAAI;AACF,MAAA,IAAA,CAAK,GAAA,CAAI,MAAM,IAAI,CAAA;AACnB,MAAA,QAAA,CAAS,GAAA,CAAI,IAAA,EAAM,KAAA,CAAM,IAAI,CAAC,CAAA;AAC9B,MAAA,OAAA,CAAQ,IAAI,IAAI,CAAA;AAAA,IAClB,SAAS,CAAA,EAAG;AACV,MAAA,IAAA,CAAK,OAAA,CAAQ,CAAA,EAAG,CAAA,oBAAA,EAAuB,IAAI,CAAA,CAAE,CAAA;AAAA,IAC/C;AAAA,EACF,CAAA;AAEA,EAAA,OAAO;AAAA,IACL,IAAA,EAAM,SAAA;AAAA,IACN,MAAM,KAAK,IAAA,EAAM;AACf,MAAA,MAAM,GAAA,GAAM,KAAK,GAAA,EAAI;AACrB,MAAA,IAAI,WAAW,OAAA,CAAQ,QAAA;AACvB,MAAA,IAAI,CAAC,QAAA,EAAU;AACb,QAAA,MAAM,EAAA,GAAK,MAAM,OAAA,CAAQ,eAAe,CAAA;AACxC,QAAA,IAAI,EAAA,KAAO,IAAA,EAAM,QAAA,CAAS,IAAA,EAAM,MAAM,4GAA4G,CAAA;AAClJ,QAAA,QAAA,GAAW,OAAO,IAAA,IAAQ,gBAAA,CAAiB,IAAA,CAAK,EAAE,IAAI,KAAA,GAAQ,EAAA;AAAA,MAChE;AACA,MAAA,MAAM,MAAA,GAAS,MAAM,OAAA,CAAQ,cAAc,CAAA;AAC3C,MAAA,MAAM,YAAY,MAAA,KAAW,KAAA;AAC7B,MAAA,IAAI,CAAC,SAAA,EAAW,QAAA,CAAS,IAAA,EAAM,WAAW,0IAA0I,CAAA;AAEpL,MAAA,MAAM,EAAE,IAAA,EAAK,GAAI,MAAM,EAAA,CAAG,MAAM,QAAQ,CAAA;AACxC,MAAA,IAAI,IAAA,CAAK,WAAW,CAAA,EAAG;AACrB,QAAA,QAAA,CAAS,IAAA,EAAM,SAAS,iJAAiJ,CAAA;AAAA,MAC3K;AACA,MAAA,MAAM,GAAA,GAAO,IAAA,CAAqB,GAAA,CAAI,CAAC,CAAA,MAAO,EAAE,GAAG,CAAA,EAAG,KAAA,EAAO,MAAA,CAAO,CAAA,CAAE,KAAK,GAAE,CAAE,CAAA;AAC/E,MAAA,MAAM,IAAA,GAAO,GAAA,CAAI,MAAA,CAAO,KAAK,CAAA;AAG7B,MAAA,MAAM,KAAA,uBAAY,GAAA,EAAoB;AACtC,MAAA,MAAM,WAAA,uBAAkB,GAAA,EAAwB;AAChD,MAAA,MAAM,IAAA,uBAAW,GAAA,EAAY;AAC7B,MAAA,KAAA,MAAW,OAAO,IAAA,EAAM;AACtB,QAAA,IAAI,IAAA,GAAO,UAAU,OAAA,CAAQ,OAAA,GAAU,QAAQ,OAAA,CAAQ,GAAG,CAAA,GAAI,aAAA,CAAc,GAAG,CAAA,CAAA;AAC/E,QAAA,IAAI,IAAA,CAAK,IAAI,IAAI,CAAA,SAAU,CAAA,EAAG,IAAI,CAAA,CAAA,EAAI,GAAA,CAAI,KAAK,CAAA,CAAA;AAC/C,QAAA,IAAA,CAAK,IAAI,IAAI,CAAA;AACb,QAAA,MAAM,KAAA,GAAQ,OAAO,OAAA,CAAQ,OAAA,KAAY,UAAA,GAAa,OAAA,CAAQ,OAAA,CAAQ,GAAG,CAAA,GAAK,OAAA,CAAQ,OAAA,IAAW,EAAC;AAClG,QAAA,MAAM,WAAW,GAAA,CAAI,MAAA,IAAU,YAAY,cAAA,CAAe,GAAA,CAAI,QAAQ,CAAA,GAAI,IAAA;AAC1E,QAAA,IAAI,UAAA,GAAyB;AAAA,UAC3B,WAAA,EAAa,CAAA,YAAA,EAAe,GAAA,CAAI,KAAK,OAAO,GAAA,CAAI,QAAQ,CAAA,IAAA,EAAO,GAAA,CAAI,QAAQ,CAAA,EAAG,GAAA,CAAI,MAAA,GAAS,KAAK,WAAW,CAAA,CAAA;AAAA,UAC3G,IAAA,EAAM,CAAC,SAAS,CAAA;AAAA,UAChB,GAAG,KAAA;AAAA,UACH,GAAI,QAAA,GAAW,EAAE,QAAA,EAAU,QAAA,KAAa;AAAC,SAC3C;AACA,QAAA,IAAI;AACF,UAAA,IAAI,SAAS,GAAA,CAAI,IAAI,CAAA,KAAM,KAAA,CAAM,UAAU,CAAA,EAAG;AAC5C,YAAA,MAAM,GAAA,GAAM,MAAM,UAAU,CAAA;AAC5B,YAAA,IAAI;AACF,cAAA,IAAA,CAAK,GAAA,CAAI,MAAM,UAAU,CAAA;AAAA,YAC3B,SAAS,CAAA,EAAG;AACV,cAAA,IAAI,CAAC,UAAU,MAAM,CAAA;AAErB,cAAA,IAAA,CAAK,OAAA,CAAQ,IAAI,KAAA,CAAM,CAAA,YAAA,EAAe,GAAA,CAAI,KAAK,CAAA,EAAA,EAAM,CAAA,CAAY,OAAO,CAAA,gCAAA,CAAkC,CAAA,EAAG,gBAAgB,CAAA;AAC7H,cAAA,MAAM,EAAE,QAAA,EAAU,EAAA,EAAI,UAAU,EAAA,EAAI,GAAG,MAAK,GAAI,UAAA;AAChD,cAAA,UAAA,GAAa,IAAA;AACb,cAAA,IAAA,CAAK,GAAA,CAAI,MAAM,IAAI,CAAA;AAAA,YACrB;AACA,YAAA,QAAA,CAAS,GAAA,CAAI,MAAM,GAAG,CAAA;AAAA,UACxB;AACA,UAAA,KAAA,CAAM,GAAA,CAAI,GAAA,CAAI,KAAA,EAAO,IAAI,CAAA;AACzB,UAAA,WAAA,CAAY,GAAA,CAAI,GAAA,CAAI,KAAA,EAAO,UAAU,CAAA;AAAA,QACvC,SAAS,CAAA,EAAG;AACV,UAAA,IAAA,CAAK,OAAA,CAAQ,CAAA,EAAG,CAAA,oBAAA,EAAuB,GAAA,CAAI,KAAK,CAAA,CAAE,CAAA;AAAA,QACpD;AAAA,MACF;AAGA,MAAA,MAAM,KAAA,GAAQ,IAAI,GAAA,CAAI,KAAA,CAAM,QAAQ,CAAA;AACpC,MAAA,KAAA,MAAW,IAAA,IAAQ,KAAA,EAAO,OAAA,CAAQ,MAAA,CAAO,IAAI,CAAA;AAC7C,MAAA,KAAA,MAAW,CAAC,KAAA,EAAO,QAAQ,CAAA,IAAK,KAAA,EAAO;AACrC,QAAA,IAAI,KAAA,CAAM,GAAA,CAAI,QAAA,CAAS,IAAI,CAAA,EAAG;AAC9B,QAAA,MAAM,OAAA,GAAU,KAAA,CAAM,GAAA,CAAI,KAAK,CAAA;AAC/B,QAAA,MAAA,CAAO,IAAA,EAAM,SAAS,IAAA,EAAM,QAAA,CAAS,YAAY,OAAA,GAAU,CAAA,WAAA,EAAc,OAAO,CAAA,CAAA,GAAK,mBAAmB,CAAA;AAAA,MAC1G;AACA,MAAA,KAAA,GAAQ,IAAI,IAAI,CAAC,GAAG,KAAK,CAAA,CAAE,GAAA,CAAI,CAAC,CAAC,KAAA,EAAO,IAAI,MAAM,CAAC,KAAA,EAAO,EAAE,IAAA,EAAM,UAAA,EAAY,WAAA,CAAY,IAAI,KAAK,CAAA,EAAI,CAAC,CAAC,CAAA;AAEzG,MAAA,IAAI,CAAC,OAAA,IAAW,IAAA,CAAK,MAAA,GAAS,CAAA,EAAG;AAC/B,QAAA,OAAA,GAAU,IAAA;AACV,QAAA,IAAI;AACF,UAAA,MAAM,OAAA,GAAU,IAAI,GAAA,CAAI,GAAA,CAAI,IAAI,CAAC,CAAA,KAAM,CAAA,CAAE,KAAK,CAAC,CAAA;AAC/C,UAAA,KAAA,MAAW,MAAA,IAAU,MAAM,IAAA,CAAK,KAAA,CAAM,UAAS,EAAG;AAChD,YAAA,MAAM,MAAM,MAAA,CAAO,UAAA;AACnB,YAAA,IAAI,CAAC,OAAO,IAAA,CAAK,UAAA,CAAW,MAAM,CAAA,IAAK,KAAA,CAAM,IAAI,MAAA,CAAO,IAAI,KAAK,CAAC,GAAA,CAAI,YAAY,CAAA,CAAE,GAAA,CAAI,QAAQ,EAAC,EAAG,QAAA,CAAS,SAAS,CAAA,EAAG;AACzH,YAAA,MAAM,KAAA,GAAQ,wBAAA,CAAyB,IAAA,CAAK,GAAA,CAAI,eAAe,EAAE,CAAA;AACjE,YAAA,IAAI,CAAC,KAAA,EAAO;AACZ,YAAA,MAAM,KAAA,GAAQ,MAAA,CAAO,KAAA,CAAM,CAAC,CAAC,CAAA;AAC7B,YAAA,MAAM,OAAA,GAAU,KAAA,CAAM,GAAA,CAAI,KAAK,CAAA;AAC/B,YAAA,IAAI,CAAC,OAAA,CAAQ,GAAA,CAAI,KAAK,CAAA,SAAU,IAAA,EAAM,MAAA,CAAO,IAAA,EAAM,GAAA,EAAK,uBAAuB,CAAA;AAAA,iBAAA,IAEtE,WAAW,CAAC,MAAA,CAAO,KAAK,QAAA,CAAS,OAAA,CAAQ,MAAM,MAAA,CAAO,MAAM,CAAC,CAAA,SAAU,IAAA,EAAM,MAAA,CAAO,MAAM,GAAA,EAAK,CAAA,WAAA,EAAc,OAAO,CAAA,CAAE,CAAA;AAAA,UACjI;AAAA,QACF,SAAS,CAAA,EAAG;AACV,UAAA,IAAA,CAAK,OAAA,CAAQ,GAAG,gBAAgB,CAAA;AAAA,QAClC;AAAA,MACF;AACA,MAAA,IAAI,CAAC,SAAA,IAAa,KAAA,CAAM,IAAA,KAAS,CAAA,SAAU,EAAC;AAE5C,MAAA,MAAM,SAAkB,EAAC;AAEzB,MAAA,MAAM,MAAA,GAAS,OAAO,GAAA,EAAgB,QAAA,KAAqC;AACzE,QAAA,MAAM,KAAA,GAAQ,MAAA,CAAO,GAAA,CAAI,KAAK,CAAA;AAC9B,QAAA,MAAM,KAAA,GAAQ,MAAA,CAAO,GAAA,CAAI,KAAK,CAAA;AAC9B,QAAA,MAAM,OAAO,OAAA,CAAQ,GAAA,CAAI,KAAK,CAAA,IAAK,KAAA,CAAM,IAAI,KAAK,CAAA;AAClD,QAAA,IAAI,CAAC,IAAA,EAAM;AACT,UAAA,IAAA,CAAK,OAAO,KAAK,CAAA;AACjB,UAAA;AAAA,QACF;AACA,QAAA,IAAI,GAAA;AACJ,QAAA,IAAI,IAAI,UAAA,KAAe,IAAA,IAAQ,CAAC,cAAA,CAAe,GAAA,CAAI,MAAM,CAAA,EAAG;AAC1D,UAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,GAAA,CAAI,KAAK,CAAA,IAAK,GAAA;AACjC,UAAA,IAAI,GAAA,GAAM,QAAQ,eAAA,EAAiB;AACjC,YAAA,IAAA,CAAK,GAAA,CAAI,OAAO,KAAK,CAAA;AACrB,YAAA;AAAA,UACF;AACA,UAAA,GAAA,GAAM,SAAA,CAAU,EAAE,GAAG,GAAA,EAAK,UAAA,EAAY,IAAI,IAAA,CAAK,KAAK,CAAA,EAAE,EAAG,IAAA,EAAM,QAAQ,CAAA;AAAA,QACzE,CAAA,MAAO;AACL,UAAA,GAAA,GAAM,SAAA,CAAU,KAAK,IAAA,EAAM,QAAA,EAAU,OAAO,GAAA,CAAI,KAAK,KAAK,GAAG,CAAA;AAAA,QAC/D;AACA,QAAA,IAAA,CAAK,OAAO,KAAK,CAAA;AACjB,QAAA,IAAI,CAAC,GAAA,EAAK;AACV,QAAA,IAAI;AACF,UAAA,MAAA,CAAO,IAAA,CAAK,GAAI,MAAM,IAAA,CAAK,UAAU,GAAA,EAAK,EAAE,QAAA,EAAU,CAAE,CAAA;AAAA,QAC1D,SAAS,CAAA,EAAG;AACV,UAAA,IAAA,CAAK,OAAA,CAAQ,CAAA,EAAG,CAAA,oBAAA,EAAuB,KAAK,CAAA,CAAE,CAAA;AAC9C,UAAA;AAAA,QACF;AACA,QAAA,IAAI,IAAI,MAAA,KAAW,SAAA,EAAW,OAAA,CAAQ,GAAA,CAAI,OAAO,IAAI,CAAA;AAAA,aAChD,OAAA,CAAQ,OAAO,KAAK,CAAA;AACzB,QAAA,IAAI,GAAA,CAAI,SAAA,IAAa,MAAA,CAAO,GAAA,CAAI,KAAK,CAAA,IAAK,CAAA,QAAA,CAAA,EAAY,MAAA,CAAO,GAAA,CAAI,KAAA,EAAO,GAAA,CAAI,SAAS,CAAA;AAAA,MACvF,CAAA;AAGA,MAAA,KAAA,MAAW,CAAC,KAAA,EAAO,IAAI,CAAA,IAAK,KAAA,EAAO;AACjC,QAAA,IAAI,OAAA,CAAQ,GAAA,CAAI,KAAK,CAAA,EAAG;AACxB,QAAA,MAAM,IAAA,GAAA,CAAQ,MAAM,IAAA,CAAK,KAAA,CAAM,SAAS,IAAA,EAAM,QAAQ,CAAA,EAAG,MAAA,CAAO,CAAC,CAAA,KAAM,OAAA,CAAQ,CAAA,CAAE,EAAE,MAAM,IAAI,CAAA;AAC7F,QAAA,IAAI,IAAA,CAAK,SAAS,CAAA,EAAG;AACnB,UAAA,OAAA,CAAQ,GAAA,CAAI,KAAA,EAAO,IAAA,CAAK,GAAA,CAAI,GAAG,IAAA,CAAK,GAAA,CAAI,CAAC,CAAA,KAAM,OAAA,CAAQ,CAAA,CAAE,EAAE,CAAE,CAAC,CAAC,CAAA;AAC/D,UAAA,MAAA,CAAO,GAAA,CAAI,KAAA,EAAO,IAAA,CAAK,GAAA,CAAI,GAAG,IAAA,CAAK,GAAA,CAAI,CAAC,CAAA,KAAM,CAAA,CAAE,SAAS,CAAC,CAAC,CAAA;AAC3D,UAAA,KAAA,MAAW,KAAK,IAAA,EAAM,IAAI,CAAA,CAAE,MAAA,KAAW,aAAa,CAAA,CAAE,MAAA,KAAW,SAAA,EAAW,OAAA,CAAQ,IAAI,OAAA,CAAQ,CAAA,CAAE,EAAE,CAAA,EAAI,EAAE,GAAG,CAAA;AAC7G,UAAA;AAAA,QACF;AAGA,QAAA,MAAM,EAAE,IAAA,EAAM,MAAA,EAAO,GAAI,MAAM,GAAG,KAAA,CAAM,UAAA,EAAY,CAAC,KAAK,CAAC,CAAA;AAC3D,QAAA,MAAM,OAAA,GAAW,MAAA,CAAuB,KAAA,EAAM,CAAE,OAAA,EAAQ;AACxD,QAAA,IAAI,YAAA,GAAe,EAAA;AACnB,QAAA,OAAA,CAAQ,OAAA,CAAQ,CAAC,CAAA,EAAG,CAAA,KAAM;AAAE,UAAA,IAAI,cAAA,CAAe,CAAA,CAAE,MAAM,CAAA,EAAG,YAAA,GAAe,CAAA;AAAA,QAAG,CAAC,CAAA;AAC7E,QAAA,KAAA,MAAW,CAAC,CAAA,EAAG,GAAG,CAAA,IAAK,OAAA,CAAQ,SAAQ,EAAG;AAExC,UAAA,IAAI,MAAM,IAAA,CAAK,KAAA,CAAM,MAAA,CAAO,CAAA,EAAG,QAAQ,CAAA,EAAG,GAAA,CAAI,KAAK,CAAA,CAAE,CAAA,EAAG;AACxD,UAAA,MAAM,MAAA,CAAO,GAAA,EAAK,CAAA,IAAK,YAAY,CAAA;AAAA,QACrC;AACA,QAAA,OAAA,CAAQ,GAAA,CAAI,KAAA,EAAO,OAAA,CAAQ,MAAA,GAAS,CAAA,GAAI,MAAA,CAAO,OAAA,CAAQ,OAAA,CAAQ,MAAA,GAAS,CAAC,CAAA,CAAG,KAAK,IAAI,CAAC,CAAA;AAAA,MACxF;AAGA,MAAA,MAAM,OAAA,mBAAU,IAAI,GAAA,CAAI,CAAC,GAAG,MAAM,MAAA,EAAO,EAAG,GAAG,OAAO,CAAC,CAAA;AACvD,MAAA,KAAA,MAAW,GAAA,IAAO,MAAM,IAAA,CAAK,KAAA,CAAM,aAAY,EAAG;AAChD,QAAA,MAAM,EAAA,GAAK,OAAA,CAAQ,GAAA,CAAI,EAAE,CAAA;AACzB,QAAA,IAAI,EAAA,KAAO,IAAA,IAAQ,OAAA,CAAQ,GAAA,CAAI,GAAA,CAAI,GAAG,CAAA,EAAG,OAAA,CAAQ,GAAA,CAAI,EAAA,EAAI,GAAA,CAAI,GAAG,CAAA;AAAA,MAClE;AACA,MAAA,MAAM,IAAA,mBAAO,IAAI,GAAA,CAAI,CAAC,GAAG,OAAA,CAAQ,IAAA,EAAK,EAAG,GAAG,IAAA,CAAK,IAAA,EAAM,CAAC,CAAA;AACxD,MAAA,IAAI,QAAA,GAAW,KAAA;AACf,MAAA,KAAA,IAAS,IAAA,GAAO,CAAA,EAAG,IAAA,GAAO,SAAA,EAAW,IAAA,EAAA,EAAQ;AAC3C,QAAA,MAAM,MAAA,GAAS,CAAC,GAAG,KAAA,CAAM,MAAM,CAAA;AAC/B,QAAA,MAAM,EAAE,IAAA,EAAM,OAAA,EAAQ,GAAI,MAAM,GAAG,KAAA,CAAM,QAAA,EAAU,CAAC,MAAA,EAAQ,MAAA,CAAO,GAAA,CAAI,CAAC,CAAA,KAAM,OAAA,CAAQ,GAAA,CAAI,CAAC,CAAA,IAAK,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,CAAC,CAAA;AAC9G,QAAA,KAAA,MAAW,OAAO,OAAA,EAAwB;AACxC,UAAA,MAAM,KAAA,GAAQ,MAAA,CAAO,GAAA,CAAI,KAAK,CAAA;AAC9B,UAAA,MAAM,KAAA,GAAQ,MAAA,CAAO,GAAA,CAAI,KAAK,CAAA;AAC9B,UAAA,IAAA,CAAK,OAAO,KAAK,CAAA;AACjB,UAAA,MAAM,MAAA,CAAO,KAAK,IAAI,CAAA;AAEtB,UAAA,IAAI,KAAA,CAAM,GAAA,CAAI,KAAK,CAAA,IAAK,KAAA,IAAS,OAAA,CAAQ,GAAA,CAAI,KAAK,CAAA,IAAK,CAAA,CAAA,EAAI,OAAA,CAAQ,GAAA,CAAI,OAAO,KAAK,CAAA;AAAA,QACrF;AACA,QAAA,IAAI,OAAA,CAAQ,SAAS,IAAA,EAAM;AACzB,UAAA,QAAA,GAAW,IAAA;AACX,UAAA;AAAA,QACF;AAAA,MACF;AAEA,MAAA,IAAI,QAAA,EAAU;AACZ,QAAA,KAAA,MAAW,SAAS,IAAA,EAAM;AACxB,UAAA,OAAA,CAAQ,OAAO,KAAK,CAAA;AACpB,UAAA,IAAA,CAAK,OAAO,KAAK,CAAA;AAAA,QACnB;AAAA,MACF;AACA,MAAA,OAAO,MAAA;AAAA,IACT;AAAA,GACF;AACF","file":"pg-cron.cjs","sourcesContent":["import type { Source, SourceHost } from \"../client.js\";\nimport type { Alert, JobOptions, Run, StoredJobDefinition } from \"../types.js\";\n\n/**\n * Anything with a pg-style query(): a `pg` Pool or Client, or a wrapper\n * around another driver that answers with `{ rows }`.\n */\nexport interface Queryable {\n query(text: string, values?: unknown[]): Promise<{ rows: any[] }>;\n}\n\n/** A row of cron.job. */\nexport interface PgCronJob {\n jobid: number;\n jobname: string | null;\n schedule: string;\n database: string;\n username: string;\n active: boolean;\n}\n\nexport interface PgCronOptions {\n /**\n * Which jobs to watch: names or ids, or a function that picks them. Default\n * every job the role can see.\n */\n jobs?: (string | number)[] | ((job: PgCronJob) => boolean);\n /** Put before every job name, to keep them apart from your own (\"db:\"). Also keeps run ids apart. */\n prefix?: string;\n /**\n * The CronWatch name for a job. Default its jobname with anything other\n * than letters, digits, \".\", \"_\", \":\" and \"-\" turned into \"-\", or\n * \"pg_cron:<jobid>\" when it has none. The prefix goes in front either way.\n */\n jobName?: (job: PgCronJob) => string;\n /** Grace, timeout, maxDuration, expect and the rest, for every job or per job. The schedule always comes from pg_cron. */\n options?: Omit<JobOptions, \"schedule\" | \"timezone\"> | ((job: PgCronJob) => Omit<JobOptions, \"schedule\" | \"timezone\">);\n /**\n * The timezone pg_cron reads its cron expressions in. Default the server's\n * cron.timezone, read from pg_settings, which shows it only to roles with\n * pg_read_all_settings; UTC (pg_cron's default) is assumed when it cannot\n * be read.\n */\n timezone?: string;\n}\n\n/** How many of a job's newest runs are copied, without alerting, the first time it is seen. */\nconst BACKFILL = 20;\n/** Run details read per query, and the most read in one sync. */\nconst PAGE = 500;\nconst MAX_PAGES = 10;\n/**\n * How long a run pg_cron has queued but not started (no start_time yet) is\n * waited for. After that it is copied as running from when it was first\n * seen, so a run that never starts is marked stuck like any other.\n */\nexport const PG_CRON_HOLD_MS = 10 * 60_000;\n\nconst JOBS_SQL = `SELECT jobid, jobname, schedule, database, username, active FROM cron.job ORDER BY jobid`;\n// pg_settings has no row for a setting the role may not read, where\n// current_setting() raises an error that would abort the caller's transaction.\nconst SETTING_SQL = `SELECT setting FROM pg_settings WHERE name = $1`;\nconst COLUMNS = `d.runid, d.jobid, d.status, d.return_message, d.start_time, d.end_time`;\n// Every tracked job's runs after its cursor, and any run still open here, whatever its job.\nconst RUNS_SQL = `SELECT ${COLUMNS}\n FROM cron.job_run_details d\n LEFT JOIN unnest($1::bigint[], $2::bigint[]) AS c(jobid, after) ON d.jobid = c.jobid\n WHERE d.runid > c.after OR d.runid = ANY($3::bigint[])\n ORDER BY d.runid LIMIT ${PAGE}`;\nconst NEWEST_SQL = `SELECT ${COLUMNS} FROM cron.job_run_details d WHERE d.jobid = $1 ORDER BY d.runid DESC LIMIT ${BACKFILL}`;\n\ninterface DetailRow {\n runid: number | string;\n jobid: number | string;\n status: string | null;\n return_message: string | null;\n start_time: Date | string | null;\n end_time: Date | string | null;\n}\n\nconst finishedStatus = (status: string | null) => status === \"succeeded\" || status === \"failed\";\n\n/**\n * pg_cron takes a cron expression, with \"$\" for the last day of the month,\n * or \"N seconds\" for 1 to 59 seconds. Returns the CronWatch schedule, or\n * null for one that has no cadence to watch. pg_cron reads only the first\n * five fields of an expression and ignores the rest, so only those are kept\n * (a sixth would otherwise be read as seconds).\n */\nexport function pgCronSchedule(schedule: string): string | null {\n const text = schedule.trim();\n const seconds = /^(\\d+)\\s*seconds?$/i.exec(text);\n if (seconds) return `every ${Number(seconds[1])}s`;\n if (/^@reboot$/i.test(text)) return null;\n const fields = text.split(/\\s+/);\n if (fields.length > 5 && !fields[0]!.startsWith(\"@\")) fields.length = 5;\n if (fields.length === 5 && fields[2]!.includes(\"$\")) fields[2] = fields[2]!.replace(/\\$/g, \"L\");\n return fields.join(\" \");\n}\n\n/** The default CronWatch name for a pg_cron job, before the prefix. */\nexport function pgCronJobName(job: Pick<PgCronJob, \"jobid\" | \"jobname\">): string {\n const cleaned = (job.jobname ?? \"\").replace(/[^A-Za-z0-9._:-]+/g, \"-\").replace(/^[^A-Za-z0-9]+/, \"\").slice(0, 100);\n return cleaned || `pg_cron:${job.jobid}`;\n}\n\n/**\n * A row of cron.job_run_details as a CronWatch run, or null for one that has\n * not started (no start_time, not finished). A finished row with no\n * start_time (pg_cron writes these for runs a server restart cut off,\n * \"server restarted\") starts at its end_time, else at `fallbackAt` (the\n * reader passes the job's newest run's start, or now).\n */\nexport function pgCronRun(row: DetailRow, job: string, idPrefix: string, fallbackAt: number = Date.now()): Run | null {\n const finishedAt = row.end_time === null ? null : new Date(row.end_time).getTime();\n const done = finishedStatus(row.status);\n if (row.start_time === null && !done) return null;\n const startedAt = row.start_time !== null ? new Date(row.start_time).getTime() : (finishedAt ?? fallbackAt);\n const message = row.return_message === null ? null : row.return_message.trim() || null;\n const status: Run[\"status\"] = row.status === \"succeeded\" ? \"ok\" : row.status === \"failed\" ? \"failed\" : \"running\";\n const end = done ? Math.max(startedAt, finishedAt ?? startedAt) : null;\n return {\n id: `${idPrefix}${row.runid}`,\n job,\n status,\n startedAt,\n finishedAt: end,\n durationMs: end === null ? null : end - startedAt,\n error: status === \"failed\" ? (message ?? \"pg_cron reported the run as failed\") : null,\n output: status === \"ok\" ? message : null,\n metrics: {},\n trigger: \"pg_cron\",\n };\n}\n\n/** The options of a stored definition that can be declared again, without its schedule. */\nfunction unscheduled(definition: StoredJobDefinition | JobOptions): JobOptions {\n const { description, tags, grace, timeout, maxDuration, budget, failuresBeforeAlert } = definition;\n const out: JobOptions = {};\n if (description !== undefined) out.description = description;\n if (tags !== undefined) out.tags = tags;\n if (grace !== undefined) out.grace = grace;\n if (timeout !== undefined) out.timeout = timeout;\n if (maxDuration !== undefined) out.maxDuration = maxDuration;\n if (budget !== undefined) out.budget = budget;\n if (failuresBeforeAlert !== undefined) out.failuresBeforeAlert = failuresBeforeAlert;\n return out;\n}\n\nconst keyOf = (definition: JobOptions) =>\n JSON.stringify(definition, (_k, v: unknown) => (v instanceof RegExp || typeof v === \"function\" ? String(v) : v));\n\n/**\n * Watches pg_cron jobs, which run inside Postgres where nothing can wrap\n * them. As a source, on every check it reads cron.job and declares each job\n * with its schedule, then copies new rows of cron.job_run_details in as runs\n * (ids \"pgcron:<runid>\"), so the usual evaluation raises missed, failed,\n * stuck and slow alerts.\n *\n * A job that is renamed, unscheduled or no longer picked keeps its old name's\n * runs and history, and that name is declared again without a schedule, so\n * it is never reported missed. Its description says why.\n *\n * cronwatch({ store: postgres({ pool }), sources: [pgCron(pool)] }).start();\n */\nexport function pgCron(db: Queryable, options: PgCronOptions = {}): Source {\n const prefix = options.prefix ?? \"\";\n const idPrefix = `pgcron:${prefix}`;\n /** The newest runid read for each jobid, once known. */\n const cursors = new Map<number, number>();\n /** The start of the newest run copied for each jobid: where a restart row with no times is put. */\n const lastAt = new Map<number, number>();\n /** Runs copied while still going, by runid, with their job: read again until they finish, even once a check marks them timeout. */\n const pending = new Map<number, string>();\n /** Runs read before they started, by runid, with when they were first seen. */\n const held = new Map<number, number>();\n /** Each job's name and definition as last declared, by jobid. */\n let known = new Map<number, { name: string; definition: JobOptions }>();\n /** The last definition declared for each name, so an unchanged job is not declared again. */\n const declared = new Map<string, string>();\n /** Names declared again without a schedule by retire(), whose open runs are still read. */\n const retired = new Set<string>();\n let scanned = false;\n const warned = new Set<string>();\n\n const warnOnce = (host: SourceHost, key: string, message: string) => {\n if (warned.has(key)) return;\n warned.add(key);\n host.onError(new Error(message), \"source pg_cron\");\n };\n\n const picks = (job: PgCronJob): boolean => {\n const { jobs } = options;\n if (jobs === undefined) return true;\n if (typeof jobs === \"function\") return jobs(job);\n return jobs.some((j) => (typeof j === \"number\" ? j === job.jobid : j === job.jobname));\n };\n\n const setting = async (name: string): Promise<string | null> => {\n try {\n const { rows } = await db.query(SETTING_SQL, [name]);\n return (rows[0]?.setting as string | null | undefined) ?? null;\n } catch {\n return null;\n }\n };\n\n const runIdOf = (id: string): number | null => {\n if (!id.startsWith(idPrefix)) return null;\n const n = Number(id.slice(idPrefix.length));\n return Number.isSafeInteger(n) ? n : null;\n };\n\n /** Declares a name this source no longer uses for any job again, without its schedule. */\n const retire = (host: SourceHost, name: string, definition: StoredJobDefinition | JobOptions, why: string) => {\n const base = unscheduled(definition);\n const next: JobOptions = { ...base, description: `${base.description ?? \"pg_cron job\"} (${why})` };\n try {\n host.job(name, next);\n declared.set(name, keyOf(next));\n retired.add(name);\n } catch (e) {\n host.onError(e, `source pg_cron: job ${name}`);\n }\n };\n\n return {\n name: \"pg_cron\",\n async sync(host) {\n const now = host.now();\n let timezone = options.timezone;\n if (!timezone) {\n const tz = await setting(\"cron.timezone\");\n if (tz === null) warnOnce(host, \"tz\", \"could not read cron.timezone; assuming UTC. Grant pg_read_all_settings or pass pgCron(pool, { timezone }).\");\n timezone = tz === null || /^(gmt|utc|z)$/i.test(tz) ? \"UTC\" : tz;\n }\n const logRun = await setting(\"cron.log_run\");\n const recording = logRun !== \"off\";\n if (!recording) warnOnce(host, \"log_run\", \"cron.log_run is off, so pg_cron records no runs: jobs are watched without their schedules and no run can fail. Turn it on to watch them.\");\n\n const { rows } = await db.query(JOBS_SQL);\n if (rows.length === 0) {\n warnOnce(host, \"empty\", \"cron.job shows no jobs. pg_cron's row level security shows a role only the jobs it scheduled: connect as that role, or give this one BYPASSRLS.\");\n }\n const all = (rows as PgCronJob[]).map((r) => ({ ...r, jobid: Number(r.jobid) }));\n const jobs = all.filter(picks);\n\n // Declare each job. A paused one (active = false) keeps its failures but loses its schedule, so it is not missed.\n const names = new Map<number, string>();\n const definitions = new Map<number, JobOptions>();\n const used = new Set<string>();\n for (const job of jobs) {\n let name = prefix + (options.jobName ? options.jobName(job) : pgCronJobName(job));\n if (used.has(name)) name = `${name}:${job.jobid}`;\n used.add(name);\n const extra = typeof options.options === \"function\" ? options.options(job) : (options.options ?? {});\n const schedule = job.active && recording ? pgCronSchedule(job.schedule) : null;\n let definition: JobOptions = {\n description: `pg_cron job ${job.jobid} in ${job.database} as ${job.username}${job.active ? \"\" : \" (paused)\"}`,\n tags: [\"pg_cron\"],\n ...extra,\n ...(schedule ? { schedule, timezone } : {}),\n };\n try {\n if (declared.get(name) !== keyOf(definition)) {\n const key = keyOf(definition);\n try {\n host.job(name, definition);\n } catch (e) {\n if (!schedule) throw e;\n // A schedule CronWatch cannot read: watch the runs, not the cadence.\n host.onError(new Error(`pg_cron job ${job.jobid}: ${(e as Error).message}; watching it without a schedule`), \"source pg_cron\");\n const { schedule: _s, timezone: _t, ...rest } = definition;\n definition = rest;\n host.job(name, rest);\n }\n declared.set(name, key);\n }\n names.set(job.jobid, name);\n definitions.set(job.jobid, definition);\n } catch (e) {\n host.onError(e, `source pg_cron: job ${job.jobid}`);\n }\n }\n\n // A name this source used for a job that has since been renamed, unscheduled or dropped from `jobs`.\n const inUse = new Set(names.values());\n for (const name of inUse) retired.delete(name);\n for (const [jobid, previous] of known) {\n if (inUse.has(previous.name)) continue;\n const renamed = names.get(jobid);\n retire(host, previous.name, previous.definition, renamed ? `renamed to ${renamed}` : \"no longer watched\");\n }\n known = new Map([...names].map(([jobid, name]) => [jobid, { name, definition: definitions.get(jobid)! }]));\n // Once per process, the same for names left scheduled in the store while no process was watching.\n if (!scanned && rows.length > 0) {\n scanned = true;\n try {\n const visible = new Set(all.map((j) => j.jobid));\n for (const stored of await host.store.listJobs()) {\n const def = stored.definition;\n if (!stored.name.startsWith(prefix) || inUse.has(stored.name) || !def.schedule || !(def.tags ?? []).includes(\"pg_cron\")) continue;\n const match = /^pg_cron job (\\d+) in /.exec(def.description ?? \"\");\n if (!match) continue;\n const jobid = Number(match[1]);\n const current = names.get(jobid);\n if (!visible.has(jobid)) retire(host, stored.name, def, \"no longer in cron.job\");\n // Another pg_cron source's name for the same job ends the same way: that one is left alone.\n else if (current && !stored.name.endsWith(current.slice(prefix.length))) retire(host, stored.name, def, `renamed to ${current}`);\n }\n } catch (e) {\n host.onError(e, \"source pg_cron\");\n }\n }\n if (!recording || names.size === 0) return [];\n\n const alerts: Alert[] = [];\n /** Copies one row. A row that cannot be recorded is reported and skipped; it never stops the others. */\n const record = async (row: DetailRow, evaluate: boolean): Promise<void> => {\n const runid = Number(row.runid);\n const jobid = Number(row.jobid);\n const name = pending.get(runid) ?? names.get(jobid);\n if (!name) {\n held.delete(runid);\n return;\n }\n let run: Run | null;\n if (row.start_time === null && !finishedStatus(row.status)) {\n const since = held.get(runid) ?? now;\n if (now - since < PG_CRON_HOLD_MS) {\n held.set(runid, since);\n return;\n }\n run = pgCronRun({ ...row, start_time: new Date(since) }, name, idPrefix);\n } else {\n run = pgCronRun(row, name, idPrefix, lastAt.get(jobid) ?? now);\n }\n held.delete(runid);\n if (!run) return;\n try {\n alerts.push(...(await host.recordRun(run, { evaluate })));\n } catch (e) {\n host.onError(e, `source pg_cron: run ${runid}`);\n return;\n }\n if (run.status === \"running\") pending.set(runid, name);\n else pending.delete(runid);\n if (run.startedAt > (lastAt.get(jobid) ?? -Infinity)) lastAt.set(jobid, run.startedAt);\n };\n\n // Where each job left off. Found from the store the first time, so a restart carries on.\n for (const [jobid, name] of names) {\n if (cursors.has(jobid)) continue;\n const ours = (await host.store.listRuns(name, BACKFILL)).filter((r) => runIdOf(r.id) !== null);\n if (ours.length > 0) {\n cursors.set(jobid, Math.max(...ours.map((r) => runIdOf(r.id)!)));\n lastAt.set(jobid, Math.max(...ours.map((r) => r.startedAt)));\n for (const r of ours) if (r.status === \"running\" || r.status === \"timeout\") pending.set(runIdOf(r.id)!, r.job);\n continue;\n }\n // First sight: copy recent history quietly, and judge only from the newest finished run on.\n // The cursor goes to the newest row read, whatever is held, so history is never judged later.\n const { rows: newest } = await db.query(NEWEST_SQL, [jobid]);\n const ordered = (newest as DetailRow[]).slice().reverse();\n let lastFinished = -1;\n ordered.forEach((r, i) => { if (finishedStatus(r.status)) lastFinished = i; });\n for (const [i, row] of ordered.entries()) {\n // Already copied under another name (the job was renamed while no process watched): left there.\n if (await host.store.getRun(`${idPrefix}${row.runid}`)) continue;\n await record(row, i >= lastFinished);\n }\n cursors.set(jobid, ordered.length > 0 ? Number(ordered[ordered.length - 1]!.runid) : 0);\n }\n\n // New runs, runs copied while still going (or since marked timeout), and runs not yet started.\n const watched = new Set([...names.values(), ...retired]);\n for (const run of await host.store.runningRuns()) {\n const id = runIdOf(run.id);\n if (id !== null && watched.has(run.job)) pending.set(id, run.job);\n }\n const open = new Set([...pending.keys(), ...held.keys()]);\n let complete = false;\n for (let page = 0; page < MAX_PAGES; page++) {\n const jobids = [...names.keys()];\n const { rows: details } = await db.query(RUNS_SQL, [jobids, jobids.map((j) => cursors.get(j) ?? 0), [...open]]);\n for (const row of details as DetailRow[]) {\n const jobid = Number(row.jobid);\n const runid = Number(row.runid);\n open.delete(runid);\n await record(row, true);\n // Held or not, the cursor moves on: a held run is read again by its runid.\n if (names.has(jobid) && runid > (cursors.get(jobid) ?? 0)) cursors.set(jobid, runid);\n }\n if (details.length < PAGE) {\n complete = true;\n break;\n }\n }\n // Every row was read and these were not among them: pg_cron no longer has them.\n if (complete) {\n for (const runid of open) {\n pending.delete(runid);\n held.delete(runid);\n }\n }\n return alerts;\n },\n };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/evaluate.ts","../src/sources/pgcron.ts"],"names":[],"mappings":"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","file":"pg-cron.cjs","sourcesContent":["/**\n * Pure decisions about a job's health. Each function takes the current state\n * and returns the new state plus the alerts that should go out. Nothing here\n * touches a store or a network, which is what makes it testable.\n */\nimport { formatDuration } from \"./duration.js\";\nimport { parseDuration } from \"./duration.js\";\nimport { expectation, nextFire, parseSchedule } from \"./schedule.js\";\nimport { median, percentile } from \"./stats.js\";\nimport type {\n AlertDraft,\n BudgetBreach,\n Condition,\n JobDefinition,\n JobHealth,\n JobState,\n JobSummary,\n Run,\n StoredJob,\n StoredJobDefinition,\n} from \"./types.js\";\n\nexport type { AlertDraft, BudgetBreach };\n\nexport interface Evaluation {\n state: JobState;\n alerts: AlertDraft[];\n}\n\nexport const DEFAULT_GRACE_MS = 10 * 60_000;\nexport const DEFAULT_TIMEOUT_MS = 60 * 60_000;\n/** Runs faster than this are never called slow, whatever the baseline says. */\nexport const SLOW_FLOOR_MS = 10_000;\n/** How many earlier runs a baseline needs before it is trusted. */\nexport const BASELINE_MIN_RUNS = 5;\n/** How many successful runs a baseline looks at, and how many runs a summary covers. */\nexport const BASELINE_WINDOW = 20;\n\n/** The longest duration written: the largest integer JavaScript holds exactly, which every port and store reads back unchanged. */\nexport const MAX_DURATION_MS = Number.MAX_SAFE_INTEGER;\n\n/**\n * How long a run took, from `startedAt` to `finishedAt`: 0 when it started\n * later, and never more than MAX_DURATION_MS. A foreign row's start near a\n * 64-bit limit must not make a duration no store can write.\n */\nexport function runDuration(startedAt: number, finishedAt: number): number {\n const ms = finishedAt - startedAt;\n return ms > 0 ? Math.min(ms, MAX_DURATION_MS) : 0;\n}\n\n/**\n * The version a stored state counts as for compareAndSetState: its\n * `version` when that is a whole number from 0 to MAX_DURATION_MS (2^53 - 1),\n * else 0, as when it is absent. The SQL stores read it the same way, so a\n * foreign row's `1.5`, `\"x\"` or `-1` is written over by the next update\n * instead of refusing every compare-and-set of its job for good.\n */\nexport function stateVersion(state: Pick<JobState, \"version\"> | null | undefined): number {\n const version: unknown = state?.version;\n return typeof version === \"number\" && Number.isSafeInteger(version) && version >= 0 ? version + 0 : 0;\n}\n\nexport function emptyState(job: string): JobState {\n return { job, open: {}, consecutiveFailures: 0, silencedUntil: null, lastAlertAt: null, pendingRecovery: [], undelivered: [] };\n}\n\n/** A stored state with every field present, or a fresh one. State written by an older version lacks the newer fields. */\nexport function normalizeState(state: JobState | null, job: string): JobState {\n if (!state) return emptyState(job);\n return {\n ...emptyState(job),\n ...state,\n open: { ...state.open },\n pendingRecovery: [...(state.pendingRecovery ?? [])],\n undelivered: [...(state.undelivered ?? [])],\n };\n}\n\nfunction cloneState(state: JobState): JobState {\n return normalizeState(state, state.job);\n}\n\nfunction openCondition(state: JobState, condition: Condition, now: number): boolean {\n if (state.open[condition] !== undefined) return false;\n state.open[condition] = now;\n return true;\n}\n\n/**\n * Every open condition has alerted, so closing one owes a recovered message.\n * It is remembered until a successful run leaves nothing open and sends it.\n */\nfunction closeCondition(state: JobState, condition: Condition): boolean {\n if (state.open[condition] === undefined) return false;\n delete state.open[condition];\n const pending = (state.pendingRecovery ??= []);\n if (!pending.includes(condition)) pending.push(condition);\n return true;\n}\n\nexport function openConditions(state: JobState): Condition[] {\n return Object.keys(state.open) as Condition[];\n}\n\nexport function graceMs(def: Pick<JobDefinition, \"grace\">): number {\n return def.grace === undefined ? DEFAULT_GRACE_MS : parseDuration(def.grace, \"grace\");\n}\n\nexport function timeoutMs(def: Pick<JobDefinition, \"timeout\">): number {\n return def.timeout === undefined ? DEFAULT_TIMEOUT_MS : parseDuration(def.timeout, \"timeout\");\n}\n\n/** Slow threshold for a successful run, or null when there is nothing to compare against yet. */\nexport function slowThreshold(def: Pick<JobDefinition, \"maxDuration\">, history: Run[]): { thresholdMs: number; basis: string } | null {\n if (def.maxDuration !== undefined) {\n return { thresholdMs: parseDuration(def.maxDuration, \"maxDuration\"), basis: \"maxDuration\" };\n }\n const durations = history\n .filter((r) => r.status === \"ok\" && r.durationMs !== null)\n .slice(0, BASELINE_WINDOW)\n .map((r) => r.durationMs!);\n if (durations.length < BASELINE_MIN_RUNS) return null;\n const p95 = percentile(durations, 95)!;\n return { thresholdMs: Math.max(2 * p95, SLOW_FLOOR_MS), basis: `twice the p95 of the last ${durations.length} runs (${formatDuration(p95)})` };\n}\n\nexport function budgetBreaches(def: Pick<JobDefinition, \"budget\">, run: Run, history: Run[]): BudgetBreach[] {\n const breaches: BudgetBreach[] = [];\n for (const [metric, value] of Object.entries(run.metrics)) {\n const ceiling = def.budget?.[metric];\n if (ceiling !== undefined) {\n if (value > ceiling) breaches.push({ metric, value, limit: ceiling, basis: \"budget\" });\n continue;\n }\n const past = history\n .filter((r) => r.status === \"ok\" && typeof r.metrics[metric] === \"number\")\n .slice(0, BASELINE_WINDOW)\n .map((r) => r.metrics[metric]!);\n if (past.length < BASELINE_MIN_RUNS) continue;\n const usual = median(past)!;\n if (usual > 0 && value > 3 * usual) {\n breaches.push({ metric, value, limit: 3 * usual, basis: `three times the usual ${formatNumber(usual)}` });\n }\n }\n return breaches;\n}\n\n/** Whether `history` (newest first) holds a full baseline window of successful runs. */\nexport function hasFullBaseline(history: Run[]): boolean {\n return history.filter((r) => r.status === \"ok\").length >= BASELINE_WINDOW;\n}\n\nexport function formatNumber(n: number): string {\n if (Number.isInteger(n)) return n.toLocaleString(\"en-US\");\n return n.toLocaleString(\"en-US\", { maximumFractionDigits: 4 });\n}\n\n/**\n * Called when a run starts. Missed and stuck are about the absence of a run,\n * so a run starting closes them without an alert; the recovered message\n * waits for a successful finish.\n */\nexport function onRunStart(state: JobState): JobState {\n const next = cloneState(state);\n closeCondition(next, \"missed\");\n closeCondition(next, \"stuck\");\n return next;\n}\n\n/**\n * Called when a run finishes with status ok, failed or timeout. `history` is\n * the job's earlier runs, newest first, not including this one.\n */\nexport function onRunFinish(\n def: JobDefinition | StoredJobDefinition,\n run: Run,\n state: JobState,\n history: Run[],\n now: number,\n): Evaluation {\n const next = cloneState(state);\n const alerts: AlertDraft[] = [];\n\n if (run.status === \"ok\") {\n next.consecutiveFailures = 0;\n closeCondition(next, \"missed\");\n closeCondition(next, \"stuck\");\n closeCondition(next, \"failed\");\n\n const slow = slowThreshold(def, history);\n if (slow && run.durationMs !== null && run.durationMs > slow.thresholdMs) {\n if (openCondition(next, \"slow\", now)) {\n alerts.push({ type: \"slow\", run, details: { durationMs: run.durationMs, thresholdMs: slow.thresholdMs, basis: slow.basis } });\n }\n } else {\n closeCondition(next, \"slow\");\n }\n\n const breaches = budgetBreaches(def, run, history);\n if (breaches.length > 0) {\n if (openCondition(next, \"over_budget\", now)) {\n alerts.push({ type: \"over_budget\", run, details: { breaches } });\n }\n } else {\n closeCondition(next, \"over_budget\");\n }\n\n const pending = next.pendingRecovery ?? [];\n if (pending.length > 0 && openConditions(next).length === 0) {\n alerts.push({ type: \"recovered\", run, details: { after: [...pending] } });\n next.pendingRecovery = [];\n }\n return { state: next, alerts };\n }\n\n // failed or timeout\n next.consecutiveFailures += 1;\n closeCondition(next, \"missed\");\n const threshold = Math.max(1, def.failuresBeforeAlert ?? 1);\n const condition = run.status === \"timeout\" ? \"stuck\" : \"failed\";\n if (next.consecutiveFailures >= threshold) {\n if (openCondition(next, condition, now)) {\n alerts.push({ type: condition, run, details: { consecutiveFailures: next.consecutiveFailures, threshold } });\n }\n }\n return { state: next, alerts };\n}\n\n/**\n * Called by check(). Decides whether the schedule has been missed: the run\n * the schedule wants next (see expectation()) has not started and its grace\n * has run out. `lastRun` is the most recent run of any status. A job with no\n * schedule is never missed, and one whose schedule was removed while missed\n * was open gets a recovered alert (reason \"unscheduled\") for missed alone.\n */\nexport function onCheck(\n def: StoredJobDefinition,\n stored: StoredJob,\n lastRun: Run | null,\n state: JobState,\n now: number,\n): Evaluation & { nextExpectedAt: number | null; dueAt: number | null } {\n const next = cloneState(state);\n const alerts: AlertDraft[] = [];\n if (!def.schedule) {\n const since = next.open.missed;\n if (since !== undefined) {\n // The schedule went away while missed was open (the job was declared\n // again without one, or a source retired it), so nothing is due any\n // more. Missed closes now with a recovery of its own; other open\n // conditions keep their own rules. Missed is taken out of the pending\n // recovery too, so the next successful run does not name it again.\n delete next.open.missed;\n next.pendingRecovery = (next.pendingRecovery ?? []).filter((c) => c !== \"missed\");\n alerts.push({ type: \"recovered\", run: lastRun, details: { after: [\"missed\"], reason: \"unscheduled\", since } });\n }\n return { state: next, alerts, nextExpectedAt: null, dueAt: null };\n }\n\n const parsed = parseSchedule(def.schedule, def.timezone);\n const grace = graceMs(def);\n const lastRunAt = lastRun?.startedAt ?? null;\n const exp = expectation(parsed, lastRunAt, stored.createdAt, grace);\n const nextExpectedAt = parsed.kind === \"interval\" ? nextFire(parsed, stored.createdAt, lastRunAt) : nextFire(parsed, now, null);\n if (!exp) return { state: next, alerts, nextExpectedAt, dueAt: null };\n\n // An interval's next run is due a period after the last one started. If that\n // run is still going, the job is busy, not late; stuck covers one that never ends.\n if (parsed.kind === \"interval\" && lastRun?.status === \"running\") {\n return { state: next, alerts, nextExpectedAt, dueAt: exp.dueAt };\n }\n\n if (now > exp.deadline) {\n if (openCondition(next, \"missed\", now)) {\n alerts.push({ type: \"missed\", run: lastRun, details: { dueAt: exp.dueAt, deadline: exp.deadline, graceMs: grace, lastRunAt } });\n }\n } else {\n // A run has started since it opened, or the grace was widened.\n closeCondition(next, \"missed\");\n }\n return { state: next, alerts, nextExpectedAt, dueAt: exp.dueAt };\n}\n\n/** Whether a running run has gone on longer than the job's timeout. */\nexport function isStuck(def: Pick<JobDefinition, \"timeout\">, run: Run, now: number): boolean {\n return run.status === \"running\" && now - run.startedAt > timeoutMs(def);\n}\n\n/**\n * While a job is silenced nothing new is recorded as an incident: conditions\n * may close (so a job that recovered during the silence shows as healthy) but\n * none may open, so the first problem after the silence ends alerts normally.\n */\nexport function muteOpens(previous: JobState, next: JobState): JobState {\n const muted = cloneState(next);\n for (const condition of openConditions(muted)) {\n if (previous.open[condition] === undefined) delete muted.open[condition];\n }\n return muted;\n}\n\nexport function isSilenced(state: JobState, now: number): boolean {\n return state.silencedUntil !== null && state.silencedUntil > now;\n}\n\n/**\n * An evaluation as it is saved and sent: while the job was silenced when it\n * began, nothing opens and nothing is sent.\n */\nexport function applySilence(previous: JobState, evaluation: Evaluation, now: number): Evaluation {\n if (!isSilenced(previous, now)) return evaluation;\n return { state: muteOpens(previous, evaluation.state), alerts: [] };\n}\n\n/**\n * Whether an alert waiting to be retried no longer describes the job, so it\n * is dropped rather than sent late. An alert for a condition is stale once\n * that condition has closed, or has closed and opened again (it opened at a\n * time other than the alert's). A recovery is stale when any condition it\n * names is open again; while they all stay closed it is kept.\n */\nexport function staleAlert(alert: AlertDraft & { at: number }, state: JobState): boolean {\n if (alert.type === \"recovered\") return alert.details.after.some((condition) => state.open[condition] !== undefined);\n return state.open[alert.type] !== alert.at;\n}\n\n/** How a job looks at a glance. Silence wins, then stuck, failing and late. */\nexport function jobHealth(def: Pick<JobDefinition, \"timeout\">, lastRun: Run | null, state: JobState, now: number): JobHealth {\n const open = openConditions(state);\n if (isSilenced(state, now)) return \"silenced\";\n if (open.includes(\"stuck\") || (lastRun && isStuck(def, lastRun, now))) return \"stuck\";\n if (open.includes(\"failed\") || lastRun?.status === \"failed\" || lastRun?.status === \"timeout\") return \"failing\";\n if (open.includes(\"missed\")) return \"late\";\n if (!lastRun) return \"never_ran\";\n return \"healthy\";\n}\n\n/**\n * A job's summary from its most recent runs (newest first; the first\n * BASELINE_WINDOW are used) and its state. Stats cover runs of any status;\n * the percentiles are over the successful ones among them.\n */\nexport function summarize(stored: StoredJob, recent: Run[], state: JobState, nextExpectedAt: number | null, now: number): JobSummary {\n return summary(stored, recent, state, nextExpectedAt, (lastRun) => jobHealth(stored.definition, lastRun, state, now));\n}\n\n/**\n * The summary of a job that could not be evaluated, say because its stored\n * schedule no longer parses. It reads nothing from the definition. The job\n * shows as failing (or silenced, while it is), since it needs a look, and\n * nothing is known about when it is next due.\n */\nexport function unevaluableSummary(stored: StoredJob, recent: Run[], state: JobState, now: number): JobSummary {\n return summary(stored, recent, state, null, () => (isSilenced(state, now) ? \"silenced\" : \"failing\"));\n}\n\nfunction summary(stored: StoredJob, recent: Run[], state: JobState, nextExpectedAt: number | null, health: (lastRun: Run | null) => JobHealth): JobSummary {\n const window = recent.slice(0, BASELINE_WINDOW);\n const lastRun = window[0] ?? null;\n const finished = window.filter((r) => r.status !== \"running\");\n const okDurations = window.filter((r) => r.status === \"ok\" && r.durationMs !== null).map((r) => r.durationMs!);\n return {\n name: stored.name,\n definition: stored.definition,\n health: health(lastRun),\n open: openConditions(state),\n lastRun,\n nextExpectedAt,\n consecutiveFailures: state.consecutiveFailures,\n silencedUntil: state.silencedUntil,\n stats: {\n runs: finished.length,\n okRate: finished.length === 0 ? 1 : finished.filter((r) => r.status === \"ok\").length / finished.length,\n p50Ms: percentile(okDurations, 50),\n p95Ms: percentile(okDurations, 95),\n },\n };\n}\n","import type { Source, SourceHost } from \"../client.js\";\nimport { runDuration } from \"../evaluate.js\";\nimport type { Alert, JobOptions, Run, StoredJobDefinition } from \"../types.js\";\n\n/**\n * Anything with a pg-style query(): a `pg` Pool or Client, or a wrapper\n * around another driver that answers with `{ rows }`.\n */\nexport interface Queryable {\n query(text: string, values?: unknown[]): Promise<{ rows: any[] }>;\n}\n\n/** A row of cron.job. */\nexport interface PgCronJob {\n jobid: number;\n jobname: string | null;\n schedule: string;\n database: string;\n username: string;\n active: boolean;\n}\n\nexport interface PgCronOptions {\n /**\n * Which jobs to watch: names or ids, or a function that picks them. Default\n * every job the role can see.\n */\n jobs?: (string | number)[] | ((job: PgCronJob) => boolean);\n /** Put before every job name, to keep them apart from your own (\"db:\"). Also keeps run ids apart. */\n prefix?: string;\n /**\n * The CronWatch name for a job. Default its jobname with anything other\n * than letters, digits, \".\", \"_\", \":\" and \"-\" turned into \"-\", or\n * \"pg_cron:<jobid>\" when it has none. The prefix goes in front either way.\n */\n jobName?: (job: PgCronJob) => string;\n /** Grace, timeout, maxDuration, expect and the rest, for every job or per job. The schedule always comes from pg_cron. */\n options?: Omit<JobOptions, \"schedule\" | \"timezone\"> | ((job: PgCronJob) => Omit<JobOptions, \"schedule\" | \"timezone\">);\n /**\n * The timezone pg_cron reads its cron expressions in. Default the server's\n * cron.timezone, read from pg_settings, which shows it only to roles with\n * pg_read_all_settings; UTC (pg_cron's default) is assumed when it cannot\n * be read.\n */\n timezone?: string;\n}\n\n/** How many of a job's newest runs are copied, without alerting, the first time it is seen. */\nconst BACKFILL = 20;\n/** Run details read per query, and the most read in one sync. */\nconst PAGE = 500;\nconst MAX_PAGES = 10;\n/**\n * How long a run pg_cron has queued but not started (no start_time yet) is\n * waited for. After that it is copied as running from when it was first\n * seen, so a run that never starts is marked stuck like any other.\n */\nexport const PG_CRON_HOLD_MS = 10 * 60_000;\n\nconst JOBS_SQL = `SELECT jobid, jobname, schedule, database, username, active FROM cron.job ORDER BY jobid`;\n// pg_settings has no row for a setting the role may not read, where\n// current_setting() raises an error that would abort the caller's transaction.\nconst SETTING_SQL = `SELECT setting FROM pg_settings WHERE name = $1`;\nconst COLUMNS = `d.runid, d.jobid, d.status, d.return_message, d.start_time, d.end_time`;\n// Every tracked job's runs after its cursor, and any run still open here, whatever its job.\nconst RUNS_SQL = `SELECT ${COLUMNS}\n FROM cron.job_run_details d\n LEFT JOIN unnest($1::bigint[], $2::bigint[]) AS c(jobid, after) ON d.jobid = c.jobid\n WHERE d.runid > c.after OR d.runid = ANY($3::bigint[])\n ORDER BY d.runid LIMIT ${PAGE}`;\nconst NEWEST_SQL = `SELECT ${COLUMNS} FROM cron.job_run_details d WHERE d.jobid = $1 ORDER BY d.runid DESC LIMIT ${BACKFILL}`;\n\ninterface DetailRow {\n runid: number | string;\n jobid: number | string;\n status: string | null;\n return_message: string | null;\n start_time: Date | string | null;\n end_time: Date | string | null;\n}\n\nconst finishedStatus = (status: string | null) => status === \"succeeded\" || status === \"failed\";\n\n/**\n * pg_cron takes a cron expression, with \"$\" for the last day of the month,\n * or \"N seconds\" for 1 to 59 seconds. Returns the CronWatch schedule, or\n * null for one that has no cadence to watch. pg_cron reads only the first\n * five fields of an expression and ignores the rest, so only those are kept\n * (a sixth would otherwise be read as seconds).\n */\nexport function pgCronSchedule(schedule: string): string | null {\n const text = schedule.trim();\n const seconds = /^(\\d+)\\s*seconds?$/i.exec(text);\n if (seconds) return `every ${Number(seconds[1])}s`;\n if (/^@reboot$/i.test(text)) return null;\n const fields = text.split(/\\s+/);\n if (fields.length > 5 && !fields[0]!.startsWith(\"@\")) fields.length = 5;\n if (fields.length === 5 && fields[2]!.includes(\"$\")) fields[2] = fields[2]!.replace(/\\$/g, \"L\");\n return fields.join(\" \");\n}\n\n/** The default CronWatch name for a pg_cron job, before the prefix. */\nexport function pgCronJobName(job: Pick<PgCronJob, \"jobid\" | \"jobname\">): string {\n const cleaned = (job.jobname ?? \"\").replace(/[^A-Za-z0-9._:-]+/g, \"-\").replace(/^[^A-Za-z0-9]+/, \"\").slice(0, 100);\n return cleaned || `pg_cron:${job.jobid}`;\n}\n\n/**\n * A row of cron.job_run_details as a CronWatch run, or null for one that has\n * not started (no start_time, not finished). A finished row with no\n * start_time (pg_cron writes these for runs a server restart cut off,\n * \"server restarted\") starts at its end_time, else at `fallbackAt` (the\n * reader passes the job's newest run's start, or now).\n */\nexport function pgCronRun(row: DetailRow, job: string, idPrefix: string, fallbackAt: number = Date.now()): Run | null {\n const finishedAt = row.end_time === null ? null : new Date(row.end_time).getTime();\n const done = finishedStatus(row.status);\n if (row.start_time === null && !done) return null;\n const startedAt = row.start_time !== null ? new Date(row.start_time).getTime() : (finishedAt ?? fallbackAt);\n const message = row.return_message === null ? null : row.return_message.trim() || null;\n const status: Run[\"status\"] = row.status === \"succeeded\" ? \"ok\" : row.status === \"failed\" ? \"failed\" : \"running\";\n const end = done ? Math.max(startedAt, finishedAt ?? startedAt) : null;\n return {\n id: `${idPrefix}${row.runid}`,\n job,\n status,\n startedAt,\n finishedAt: end,\n durationMs: end === null ? null : runDuration(startedAt, end),\n error: status === \"failed\" ? (message ?? \"pg_cron reported the run as failed\") : null,\n output: status === \"ok\" ? message : null,\n metrics: {},\n trigger: \"pg_cron\",\n };\n}\n\n/** The options of a stored definition that can be declared again, without its schedule. */\nfunction unscheduled(definition: StoredJobDefinition | JobOptions): JobOptions {\n const { description, tags, grace, timeout, maxDuration, budget, failuresBeforeAlert } = definition;\n const out: JobOptions = {};\n if (description !== undefined) out.description = description;\n if (tags !== undefined) out.tags = tags;\n if (grace !== undefined) out.grace = grace;\n if (timeout !== undefined) out.timeout = timeout;\n if (maxDuration !== undefined) out.maxDuration = maxDuration;\n if (budget !== undefined) out.budget = budget;\n if (failuresBeforeAlert !== undefined) out.failuresBeforeAlert = failuresBeforeAlert;\n return out;\n}\n\nconst keyOf = (definition: JobOptions) =>\n JSON.stringify(definition, (_k, v: unknown) => (v instanceof RegExp || typeof v === \"function\" ? String(v) : v));\n\n/**\n * Watches pg_cron jobs, which run inside Postgres where nothing can wrap\n * them. As a source, on every check it reads cron.job and declares each job\n * with its schedule, then copies new rows of cron.job_run_details in as runs\n * (ids \"pgcron:<runid>\"), so the usual evaluation raises missed, failed,\n * stuck and slow alerts.\n *\n * A job that is renamed, unscheduled or no longer picked keeps its old name's\n * runs and history, and that name is declared again without a schedule, so\n * it is never reported missed. Its description says why.\n *\n * cronwatch({ store: postgres({ pool }), sources: [pgCron(pool)] }).start();\n */\nexport function pgCron(db: Queryable, options: PgCronOptions = {}): Source {\n const prefix = options.prefix ?? \"\";\n const idPrefix = `pgcron:${prefix}`;\n /** The newest runid read for each jobid, once known. */\n const cursors = new Map<number, number>();\n /** The start of the newest run copied for each jobid: where a restart row with no times is put. */\n const lastAt = new Map<number, number>();\n /** Runs copied while still going, by runid, with their job: read again until they finish, even once a check marks them timeout. */\n const pending = new Map<number, string>();\n /** Runs read before they started, by runid, with when they were first seen. */\n const held = new Map<number, number>();\n /** Each job's name and definition as last declared, by jobid. */\n let known = new Map<number, { name: string; definition: JobOptions }>();\n /** The last definition declared for each name, so an unchanged job is not declared again. */\n const declared = new Map<string, string>();\n /** Names declared again without a schedule by retire(), whose open runs are still read. */\n const retired = new Set<string>();\n let scanned = false;\n const warned = new Set<string>();\n\n const warnOnce = (host: SourceHost, key: string, message: string) => {\n if (warned.has(key)) return;\n warned.add(key);\n host.onError(new Error(message), \"source pg_cron\");\n };\n\n const picks = (job: PgCronJob): boolean => {\n const { jobs } = options;\n if (jobs === undefined) return true;\n if (typeof jobs === \"function\") return jobs(job);\n return jobs.some((j) => (typeof j === \"number\" ? j === job.jobid : j === job.jobname));\n };\n\n const setting = async (name: string): Promise<string | null> => {\n try {\n const { rows } = await db.query(SETTING_SQL, [name]);\n return (rows[0]?.setting as string | null | undefined) ?? null;\n } catch {\n return null;\n }\n };\n\n const runIdOf = (id: string): number | null => {\n if (!id.startsWith(idPrefix)) return null;\n const n = Number(id.slice(idPrefix.length));\n return Number.isSafeInteger(n) ? n : null;\n };\n\n /** Declares a name this source no longer uses for any job again, without its schedule. */\n const retire = (host: SourceHost, name: string, definition: StoredJobDefinition | JobOptions, why: string) => {\n const base = unscheduled(definition);\n const next: JobOptions = { ...base, description: `${base.description ?? \"pg_cron job\"} (${why})` };\n try {\n host.job(name, next);\n declared.set(name, keyOf(next));\n retired.add(name);\n } catch (e) {\n host.onError(e, `source pg_cron: job ${name}`);\n }\n };\n\n return {\n name: \"pg_cron\",\n async sync(host) {\n const now = host.now();\n let timezone = options.timezone;\n if (!timezone) {\n const tz = await setting(\"cron.timezone\");\n if (tz === null) warnOnce(host, \"tz\", \"could not read cron.timezone; assuming UTC. Grant pg_read_all_settings or pass pgCron(pool, { timezone }).\");\n timezone = tz === null || /^(gmt|utc|z)$/i.test(tz) ? \"UTC\" : tz;\n }\n const logRun = await setting(\"cron.log_run\");\n const recording = logRun !== \"off\";\n if (!recording) warnOnce(host, \"log_run\", \"cron.log_run is off, so pg_cron records no runs: jobs are watched without their schedules and no run can fail. Turn it on to watch them.\");\n\n const { rows } = await db.query(JOBS_SQL);\n if (rows.length === 0) {\n warnOnce(host, \"empty\", \"cron.job shows no jobs. pg_cron's row level security shows a role only the jobs it scheduled: connect as that role, or give this one BYPASSRLS.\");\n }\n const all = (rows as PgCronJob[]).map((r) => ({ ...r, jobid: Number(r.jobid) }));\n const jobs = all.filter(picks);\n\n // Declare each job. A paused one (active = false) keeps its failures but loses its schedule, so it is not missed.\n const names = new Map<number, string>();\n const definitions = new Map<number, JobOptions>();\n const used = new Set<string>();\n for (const job of jobs) {\n let name = prefix + (options.jobName ? options.jobName(job) : pgCronJobName(job));\n if (used.has(name)) name = `${name}:${job.jobid}`;\n used.add(name);\n const extra = typeof options.options === \"function\" ? options.options(job) : (options.options ?? {});\n const schedule = job.active && recording ? pgCronSchedule(job.schedule) : null;\n let definition: JobOptions = {\n description: `pg_cron job ${job.jobid} in ${job.database} as ${job.username}${job.active ? \"\" : \" (paused)\"}`,\n tags: [\"pg_cron\"],\n ...extra,\n ...(schedule ? { schedule, timezone } : {}),\n };\n try {\n if (declared.get(name) !== keyOf(definition)) {\n const key = keyOf(definition);\n try {\n host.job(name, definition);\n } catch (e) {\n if (!schedule) throw e;\n // A schedule CronWatch cannot read: watch the runs, not the cadence.\n host.onError(new Error(`pg_cron job ${job.jobid}: ${(e as Error).message}; watching it without a schedule`), \"source pg_cron\");\n const { schedule: _s, timezone: _t, ...rest } = definition;\n definition = rest;\n host.job(name, rest);\n }\n declared.set(name, key);\n }\n names.set(job.jobid, name);\n definitions.set(job.jobid, definition);\n } catch (e) {\n host.onError(e, `source pg_cron: job ${job.jobid}`);\n }\n }\n\n // A name this source used for a job that has since been renamed, unscheduled or dropped from `jobs`.\n const inUse = new Set(names.values());\n for (const name of inUse) retired.delete(name);\n for (const [jobid, previous] of known) {\n if (inUse.has(previous.name)) continue;\n const renamed = names.get(jobid);\n retire(host, previous.name, previous.definition, renamed ? `renamed to ${renamed}` : \"no longer watched\");\n }\n known = new Map([...names].map(([jobid, name]) => [jobid, { name, definition: definitions.get(jobid)! }]));\n // Once per process, the same for names left scheduled in the store while no process was watching.\n if (!scanned && rows.length > 0) {\n scanned = true;\n try {\n const visible = new Set(all.map((j) => j.jobid));\n for (const stored of await host.store.listJobs()) {\n const def = stored.definition;\n if (!stored.name.startsWith(prefix) || inUse.has(stored.name) || !def.schedule || !(def.tags ?? []).includes(\"pg_cron\")) continue;\n const match = /^pg_cron job (\\d+) in /.exec(def.description ?? \"\");\n if (!match) continue;\n const jobid = Number(match[1]);\n const current = names.get(jobid);\n if (!visible.has(jobid)) retire(host, stored.name, def, \"no longer in cron.job\");\n // Another pg_cron source's name for the same job ends the same way: that one is left alone.\n else if (current && !stored.name.endsWith(current.slice(prefix.length))) retire(host, stored.name, def, `renamed to ${current}`);\n }\n } catch (e) {\n host.onError(e, \"source pg_cron\");\n }\n }\n if (!recording || names.size === 0) return [];\n\n const alerts: Alert[] = [];\n /** Copies one row. A row that cannot be recorded is reported and skipped; it never stops the others. */\n const record = async (row: DetailRow, evaluate: boolean): Promise<void> => {\n const runid = Number(row.runid);\n const jobid = Number(row.jobid);\n const name = pending.get(runid) ?? names.get(jobid);\n if (!name) {\n held.delete(runid);\n return;\n }\n let run: Run | null;\n if (row.start_time === null && !finishedStatus(row.status)) {\n const since = held.get(runid) ?? now;\n if (now - since < PG_CRON_HOLD_MS) {\n held.set(runid, since);\n return;\n }\n run = pgCronRun({ ...row, start_time: new Date(since) }, name, idPrefix);\n } else {\n run = pgCronRun(row, name, idPrefix, lastAt.get(jobid) ?? now);\n }\n held.delete(runid);\n if (!run) return;\n try {\n alerts.push(...(await host.recordRun(run, { evaluate })));\n } catch (e) {\n host.onError(e, `source pg_cron: run ${runid}`);\n return;\n }\n if (run.status === \"running\") pending.set(runid, name);\n else pending.delete(runid);\n if (run.startedAt > (lastAt.get(jobid) ?? -Infinity)) lastAt.set(jobid, run.startedAt);\n };\n\n // Where each job left off. Found from the store the first time, so a restart carries on.\n for (const [jobid, name] of names) {\n if (cursors.has(jobid)) continue;\n const ours = (await host.store.listRuns(name, BACKFILL)).filter((r) => runIdOf(r.id) !== null);\n if (ours.length > 0) {\n cursors.set(jobid, Math.max(...ours.map((r) => runIdOf(r.id)!)));\n lastAt.set(jobid, Math.max(...ours.map((r) => r.startedAt)));\n for (const r of ours) if (r.status === \"running\" || r.status === \"timeout\") pending.set(runIdOf(r.id)!, r.job);\n continue;\n }\n // First sight: copy recent history quietly, and judge only from the newest finished run on.\n // The cursor goes to the newest row read, whatever is held, so history is never judged later.\n const { rows: newest } = await db.query(NEWEST_SQL, [jobid]);\n const ordered = (newest as DetailRow[]).slice().reverse();\n let lastFinished = -1;\n ordered.forEach((r, i) => { if (finishedStatus(r.status)) lastFinished = i; });\n for (const [i, row] of ordered.entries()) {\n // Already copied under another name (the job was renamed while no process watched): left there.\n if (await host.store.getRun(`${idPrefix}${row.runid}`)) continue;\n await record(row, i >= lastFinished);\n }\n cursors.set(jobid, ordered.length > 0 ? Number(ordered[ordered.length - 1]!.runid) : 0);\n }\n\n // New runs, runs copied while still going (or since marked timeout), and runs not yet started.\n const watched = new Set([...names.values(), ...retired]);\n for (const run of await host.store.runningRuns()) {\n const id = runIdOf(run.id);\n if (id !== null && watched.has(run.job)) pending.set(id, run.job);\n }\n const open = new Set([...pending.keys(), ...held.keys()]);\n let complete = false;\n for (let page = 0; page < MAX_PAGES; page++) {\n const jobids = [...names.keys()];\n const { rows: details } = await db.query(RUNS_SQL, [jobids, jobids.map((j) => cursors.get(j) ?? 0), [...open]]);\n for (const row of details as DetailRow[]) {\n const jobid = Number(row.jobid);\n const runid = Number(row.runid);\n open.delete(runid);\n await record(row, true);\n // Held or not, the cursor moves on: a held run is read again by its runid.\n if (names.has(jobid) && runid > (cursors.get(jobid) ?? 0)) cursors.set(jobid, runid);\n }\n if (details.length < PAGE) {\n complete = true;\n break;\n }\n }\n // Every row was read and these were not among them: pg_cron no longer has them.\n if (complete) {\n for (const runid of open) {\n pending.delete(runid);\n held.delete(runid);\n }\n }\n return alerts;\n },\n };\n}\n"]}
|
package/dist/pg-cron.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
// src/evaluate.ts
|
|
2
|
+
var MAX_DURATION_MS = Number.MAX_SAFE_INTEGER;
|
|
3
|
+
function runDuration(startedAt, finishedAt) {
|
|
4
|
+
const ms = finishedAt - startedAt;
|
|
5
|
+
return ms > 0 ? Math.min(ms, MAX_DURATION_MS) : 0;
|
|
6
|
+
}
|
|
7
|
+
|
|
1
8
|
// src/sources/pgcron.ts
|
|
2
9
|
var BACKFILL = 20;
|
|
3
10
|
var PAGE = 500;
|
|
@@ -41,7 +48,7 @@ function pgCronRun(row, job, idPrefix, fallbackAt = Date.now()) {
|
|
|
41
48
|
status,
|
|
42
49
|
startedAt,
|
|
43
50
|
finishedAt: end,
|
|
44
|
-
durationMs: end === null ? null :
|
|
51
|
+
durationMs: end === null ? null : runDuration(startedAt, end),
|
|
45
52
|
error: status === "failed" ? message ?? "pg_cron reported the run as failed" : null,
|
|
46
53
|
output: status === "ok" ? message : null,
|
|
47
54
|
metrics: {},
|