@cronwatch/sdk 0.12.4 → 0.13.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/README.md +3 -3
- package/dist/anthropic.cjs +2 -2
- package/dist/anthropic.cjs.map +1 -1
- package/dist/anthropic.d.cts +1 -1
- package/dist/anthropic.d.ts +1 -1
- package/dist/anthropic.js +2 -2
- package/dist/anthropic.js.map +1 -1
- package/dist/bugsnag.cjs +7 -0
- package/dist/bugsnag.cjs.map +1 -1
- package/dist/bugsnag.d.cts +1 -1
- package/dist/bugsnag.d.ts +1 -1
- package/dist/bugsnag.js +7 -0
- package/dist/bugsnag.js.map +1 -1
- package/dist/{client-BkPMRkDq.d.cts → client-DAJH7YiG.d.ts} +41 -20
- package/dist/{client-DySsV5T0.d.ts → client-z1LdR_m2.d.cts} +41 -20
- package/dist/d1.cjs +1 -1
- package/dist/d1.cjs.map +1 -1
- package/dist/d1.d.cts +4 -4
- package/dist/d1.d.ts +4 -4
- package/dist/d1.js +1 -1
- package/dist/d1.js.map +1 -1
- package/dist/datadog.cjs +7 -0
- package/dist/datadog.cjs.map +1 -1
- package/dist/datadog.d.cts +2 -2
- package/dist/datadog.d.ts +2 -2
- package/dist/datadog.js +7 -0
- package/dist/datadog.js.map +1 -1
- package/dist/discord.cjs +25 -1
- package/dist/discord.cjs.map +1 -1
- package/dist/discord.d.cts +5 -5
- package/dist/discord.d.ts +5 -5
- package/dist/discord.js +25 -1
- package/dist/discord.js.map +1 -1
- package/dist/{email-CD6NO8CV.d.cts → email-CCUzlLe9.d.cts} +3 -3
- package/dist/{email-Bx3ehK0O.d.ts → email-CFtDqlvZ.d.ts} +3 -3
- package/dist/honeybadger.cjs +7 -0
- package/dist/honeybadger.cjs.map +1 -1
- package/dist/honeybadger.d.cts +1 -1
- package/dist/honeybadger.d.ts +1 -1
- package/dist/honeybadger.js +7 -0
- package/dist/honeybadger.js.map +1 -1
- package/dist/index.cjs +206 -58
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -5
- package/dist/index.d.ts +5 -5
- package/dist/index.js +206 -58
- package/dist/index.js.map +1 -1
- package/dist/mailgun.cjs +7 -0
- package/dist/mailgun.cjs.map +1 -1
- package/dist/mailgun.d.cts +2 -2
- package/dist/mailgun.d.ts +2 -2
- package/dist/mailgun.js +7 -0
- package/dist/mailgun.js.map +1 -1
- package/dist/newrelic.cjs +7 -0
- package/dist/newrelic.cjs.map +1 -1
- package/dist/newrelic.d.cts +1 -1
- package/dist/newrelic.d.ts +1 -1
- package/dist/newrelic.js +7 -0
- package/dist/newrelic.js.map +1 -1
- package/dist/node.cjs.map +1 -1
- package/dist/node.d.cts +3 -3
- package/dist/node.d.ts +3 -3
- package/dist/node.js.map +1 -1
- package/dist/pg-cron.cjs.map +1 -1
- package/dist/pg-cron.d.cts +10 -10
- package/dist/pg-cron.d.ts +10 -10
- package/dist/pg-cron.js.map +1 -1
- package/dist/postgres.cjs +8 -3
- package/dist/postgres.cjs.map +1 -1
- package/dist/postgres.d.cts +3 -3
- package/dist/postgres.d.ts +3 -3
- package/dist/postgres.js +8 -3
- package/dist/postgres.js.map +1 -1
- package/dist/postmark.cjs +7 -0
- package/dist/postmark.cjs.map +1 -1
- package/dist/postmark.d.cts +2 -2
- package/dist/postmark.d.ts +2 -2
- package/dist/postmark.js +7 -0
- package/dist/postmark.js.map +1 -1
- package/dist/resend.cjs +7 -0
- package/dist/resend.cjs.map +1 -1
- package/dist/resend.d.cts +2 -2
- package/dist/resend.d.ts +2 -2
- package/dist/resend.js +7 -0
- package/dist/resend.js.map +1 -1
- package/dist/rollbar.cjs +7 -0
- package/dist/rollbar.cjs.map +1 -1
- package/dist/rollbar.d.cts +1 -1
- package/dist/rollbar.d.ts +1 -1
- package/dist/rollbar.js +7 -0
- package/dist/rollbar.js.map +1 -1
- package/dist/sendgrid.cjs +7 -0
- package/dist/sendgrid.cjs.map +1 -1
- package/dist/sendgrid.d.cts +2 -2
- package/dist/sendgrid.d.ts +2 -2
- package/dist/sendgrid.js +7 -0
- package/dist/sendgrid.js.map +1 -1
- package/dist/sentry.cjs +7 -0
- package/dist/sentry.cjs.map +1 -1
- package/dist/sentry.d.cts +2 -2
- package/dist/sentry.d.ts +2 -2
- package/dist/sentry.js +7 -0
- package/dist/sentry.js.map +1 -1
- package/dist/ses.cjs +7 -0
- package/dist/ses.cjs.map +1 -1
- package/dist/ses.d.cts +2 -2
- package/dist/ses.d.ts +2 -2
- package/dist/ses.js +7 -0
- package/dist/ses.js.map +1 -1
- package/dist/slack.cjs +32 -1
- package/dist/slack.cjs.map +1 -1
- package/dist/slack.d.cts +1 -1
- package/dist/slack.d.ts +1 -1
- package/dist/slack.js +32 -1
- package/dist/slack.js.map +1 -1
- package/dist/sqlite.cjs +1 -1
- package/dist/sqlite.cjs.map +1 -1
- package/dist/sqlite.d.cts +2 -2
- package/dist/sqlite.d.ts +2 -2
- package/dist/sqlite.js +1 -1
- package/dist/sqlite.js.map +1 -1
- package/dist/twilio.cjs +7 -0
- package/dist/twilio.cjs.map +1 -1
- package/dist/twilio.d.cts +5 -5
- package/dist/twilio.d.ts +5 -5
- package/dist/twilio.js +7 -0
- package/dist/twilio.js.map +1 -1
- package/dist/{types-Bm972Uqj.d.cts → types-D6t0Z846.d.cts} +15 -7
- package/dist/{types-Bm972Uqj.d.ts → types-D6t0Z846.d.ts} +15 -7
- package/dist/webhook.cjs +11 -1
- package/dist/webhook.cjs.map +1 -1
- package/dist/webhook.d.cts +4 -3
- package/dist/webhook.d.ts +4 -3
- package/dist/webhook.js +11 -1
- package/dist/webhook.js.map +1 -1
- package/package.json +2 -2
package/dist/slack.cjs
CHANGED
|
@@ -11,6 +11,36 @@ function postable(url) {
|
|
|
11
11
|
if (protocol !== "http:" && protocol !== "https:") throw new Error(`only http and https URLs can be posted to, not ${protocol}`);
|
|
12
12
|
return url;
|
|
13
13
|
}
|
|
14
|
+
var ERROR_BODY_MAX = 200;
|
|
15
|
+
async function drain(response) {
|
|
16
|
+
try {
|
|
17
|
+
await response.body?.cancel();
|
|
18
|
+
} catch {
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function urlSecrets(url) {
|
|
22
|
+
try {
|
|
23
|
+
const parsed = new URL(url);
|
|
24
|
+
const last = parsed.pathname.split("/").pop() ?? "";
|
|
25
|
+
return [parsed.pathname + parsed.search, last].filter((part) => part.length > 0 && part !== "/");
|
|
26
|
+
} catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function errorBody(text, secrets = []) {
|
|
31
|
+
const kept = secrets.filter((s) => typeof s === "string" && s.length >= 4);
|
|
32
|
+
const longest = kept.reduce((n, s) => Math.max(n, s.length), 0);
|
|
33
|
+
let head = cut(text, ERROR_BODY_MAX + longest);
|
|
34
|
+
for (const secret of kept) head = head.split(secret).join("[redacted]");
|
|
35
|
+
return cut(head, ERROR_BODY_MAX);
|
|
36
|
+
}
|
|
37
|
+
function cut(text, max) {
|
|
38
|
+
if (text.length <= max) return text;
|
|
39
|
+
let end = max;
|
|
40
|
+
const code = text.charCodeAt(end - 1);
|
|
41
|
+
if (code >= 55296 && code <= 56319) end -= 1;
|
|
42
|
+
return text.slice(0, end);
|
|
43
|
+
}
|
|
14
44
|
|
|
15
45
|
// src/alerts/slack.ts
|
|
16
46
|
var EMOJI = {
|
|
@@ -49,7 +79,8 @@ ${alert.message}`),
|
|
|
49
79
|
]
|
|
50
80
|
})
|
|
51
81
|
});
|
|
52
|
-
if (!response.ok) throw new Error(`Slack webhook answered ${response.status}: ${(await response.text()
|
|
82
|
+
if (!response.ok) throw new Error(`Slack webhook answered ${response.status}: ${errorBody(await response.text(), urlSecrets(options.webhookUrl))}`);
|
|
83
|
+
await drain(response);
|
|
53
84
|
}
|
|
54
85
|
};
|
|
55
86
|
}
|
package/dist/slack.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/slack.ts"],"names":[],"mappings":"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","file":"slack.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\" || type === \"under_floor\") 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","import type { Alert, AlertChannel } from \"../types.js\";\nimport { postable } from \"./shared.js\";\n\nexport interface SlackOptions {\n /** An incoming webhook URL from api.slack.com/messaging/webhooks. */\n webhookUrl: 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\nconst EMOJI: Record<Alert[\"type\"], string> = {\n missed: \":hourglass_flowing_sand:\",\n failed: \":x:\",\n stuck: \":no_entry:\",\n slow: \":turtle:\",\n over_budget: \":moneybag:\",\n under_floor: \":chart_with_downwards_trend:\",\n recovered: \":white_check_mark:\",\n};\n\nconst TIMEOUT_MS = 10_000;\n\n/** Sends alerts to a Slack channel through an incoming webhook. */\nexport function slack(options: SlackOptions): AlertChannel {\n if (!options.webhookUrl) throw new Error(\"slack() needs a webhookUrl\");\n return {\n name: \"slack\",\n async send(alert) {\n const url = options.link?.(alert);\n const title = `${EMOJI[alert.type]} *${escape(alert.title)}*${url ? ` (<${url}|open>)` : \"\"}`;\n const body = codeBlockSafe(escape(alert.message)).slice(0, 2900);\n const response = await fetch(postable(options.webhookUrl), {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n // Refused, not followed: a webhook URL is its own credential.\n redirect: \"error\",\n signal: AbortSignal.timeout(TIMEOUT_MS),\n body: JSON.stringify({\n // The notification fallback is parsed as mrkdwn too, so it is escaped like the blocks.\n text: escape(`${alert.title}\\n${alert.message}`),\n blocks: [\n { type: \"section\", text: { type: \"mrkdwn\", text: title } },\n { type: \"section\", text: { type: \"mrkdwn\", text: \"```\" + body + \"```\" } },\n // Its own block, so a long diagnosis cannot push a block past Slack's 3000 character limit.\n ...(alert.triage ? [{ type: \"section\", text: { type: \"mrkdwn\", text: `_Triage:_ ${escape(alert.triage)}`.slice(0, 3000) } }] : []),\n ],\n }),\n });\n if (!response.ok) throw new Error(`Slack webhook answered ${response.status}: ${(await response.text()).slice(0, 200)}`);\n },\n };\n}\n\n/** Slack's three control characters. Escaping < and > also stops <!channel> and <url|links>. */\nfunction escape(text: string): string {\n return text.replace(/&/g, \"&\").replace(/</g, \"<\").replace(/>/g, \">\");\n}\n\n/** Breaks up ``` so text inside a code block cannot close it. */\nfunction codeBlockSafe(text: string): string {\n return text.replace(/```/g, \"```\");\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/slack.ts"],"names":[],"mappings":"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","file":"slack.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\" || type === \"under_floor\") 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 await drain(response);\n return response;\n}\n\n/**\n * Lets go of a successful answer's body, which nothing reads, so its\n * connection goes back to the pool now rather than when it is collected.\n */\nexport async function drain(response: Response): Promise<void> {\n try {\n await response.body?.cancel();\n } catch {\n // Already read, or the connection is gone: nothing to let go of.\n }\n}\n\n/**\n * The parts of a webhook URL that are its credential, to cut from an error\n * body that echoes the request: its path and query together, then the last\n * segment of its path (Slack's and Discord's token) on its own.\n */\nexport function urlSecrets(url: string): string[] {\n try {\n const parsed = new URL(url);\n const last = parsed.pathname.split(\"/\").pop() ?? \"\";\n return [parsed.pathname + parsed.search, last].filter((part) => part.length > 0 && part !== \"/\");\n } catch {\n return [];\n }\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","import type { Alert, AlertChannel } from \"../types.js\";\nimport { drain, errorBody, postable, urlSecrets } from \"./shared.js\";\n\nexport interface SlackOptions {\n /** An incoming webhook URL from api.slack.com/messaging/webhooks. */\n webhookUrl: 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\nconst EMOJI: Record<Alert[\"type\"], string> = {\n missed: \":hourglass_flowing_sand:\",\n failed: \":x:\",\n stuck: \":no_entry:\",\n slow: \":turtle:\",\n over_budget: \":moneybag:\",\n under_floor: \":chart_with_downwards_trend:\",\n recovered: \":white_check_mark:\",\n};\n\nconst TIMEOUT_MS = 10_000;\n\n/** Sends alerts to a Slack channel through an incoming webhook. */\nexport function slack(options: SlackOptions): AlertChannel {\n if (!options.webhookUrl) throw new Error(\"slack() needs a webhookUrl\");\n return {\n name: \"slack\",\n async send(alert) {\n const url = options.link?.(alert);\n const title = `${EMOJI[alert.type]} *${escape(alert.title)}*${url ? ` (<${url}|open>)` : \"\"}`;\n const body = codeBlockSafe(escape(alert.message)).slice(0, 2900);\n const response = await fetch(postable(options.webhookUrl), {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n // Refused, not followed: a webhook URL is its own credential.\n redirect: \"error\",\n signal: AbortSignal.timeout(TIMEOUT_MS),\n body: JSON.stringify({\n // The notification fallback is parsed as mrkdwn too, so it is escaped like the blocks.\n text: escape(`${alert.title}\\n${alert.message}`),\n blocks: [\n { type: \"section\", text: { type: \"mrkdwn\", text: title } },\n { type: \"section\", text: { type: \"mrkdwn\", text: \"```\" + body + \"```\" } },\n // Its own block, so a long diagnosis cannot push a block past Slack's 3000 character limit.\n ...(alert.triage ? [{ type: \"section\", text: { type: \"mrkdwn\", text: `_Triage:_ ${escape(alert.triage)}`.slice(0, 3000) } }] : []),\n ],\n }),\n });\n // The start of the answer, with the URL's credential cut out in case a proxy echoes the request.\n if (!response.ok) throw new Error(`Slack webhook answered ${response.status}: ${errorBody(await response.text(), urlSecrets(options.webhookUrl))}`);\n await drain(response);\n },\n };\n}\n\n/** Slack's three control characters. Escaping < and > also stops <!channel> and <url|links>. */\nfunction escape(text: string): string {\n return text.replace(/&/g, \"&\").replace(/</g, \"<\").replace(/>/g, \">\");\n}\n\n/** Breaks up ``` so text inside a code block cannot close it. */\nfunction codeBlockSafe(text: string): string {\n return text.replace(/```/g, \"```\");\n}\n"]}
|
package/dist/slack.d.cts
CHANGED
package/dist/slack.d.ts
CHANGED
package/dist/slack.js
CHANGED
|
@@ -9,6 +9,36 @@ function postable(url) {
|
|
|
9
9
|
if (protocol !== "http:" && protocol !== "https:") throw new Error(`only http and https URLs can be posted to, not ${protocol}`);
|
|
10
10
|
return url;
|
|
11
11
|
}
|
|
12
|
+
var ERROR_BODY_MAX = 200;
|
|
13
|
+
async function drain(response) {
|
|
14
|
+
try {
|
|
15
|
+
await response.body?.cancel();
|
|
16
|
+
} catch {
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function urlSecrets(url) {
|
|
20
|
+
try {
|
|
21
|
+
const parsed = new URL(url);
|
|
22
|
+
const last = parsed.pathname.split("/").pop() ?? "";
|
|
23
|
+
return [parsed.pathname + parsed.search, last].filter((part) => part.length > 0 && part !== "/");
|
|
24
|
+
} catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function errorBody(text, secrets = []) {
|
|
29
|
+
const kept = secrets.filter((s) => typeof s === "string" && s.length >= 4);
|
|
30
|
+
const longest = kept.reduce((n, s) => Math.max(n, s.length), 0);
|
|
31
|
+
let head = cut(text, ERROR_BODY_MAX + longest);
|
|
32
|
+
for (const secret of kept) head = head.split(secret).join("[redacted]");
|
|
33
|
+
return cut(head, ERROR_BODY_MAX);
|
|
34
|
+
}
|
|
35
|
+
function cut(text, max) {
|
|
36
|
+
if (text.length <= max) return text;
|
|
37
|
+
let end = max;
|
|
38
|
+
const code = text.charCodeAt(end - 1);
|
|
39
|
+
if (code >= 55296 && code <= 56319) end -= 1;
|
|
40
|
+
return text.slice(0, end);
|
|
41
|
+
}
|
|
12
42
|
|
|
13
43
|
// src/alerts/slack.ts
|
|
14
44
|
var EMOJI = {
|
|
@@ -47,7 +77,8 @@ ${alert.message}`),
|
|
|
47
77
|
]
|
|
48
78
|
})
|
|
49
79
|
});
|
|
50
|
-
if (!response.ok) throw new Error(`Slack webhook answered ${response.status}: ${(await response.text()
|
|
80
|
+
if (!response.ok) throw new Error(`Slack webhook answered ${response.status}: ${errorBody(await response.text(), urlSecrets(options.webhookUrl))}`);
|
|
81
|
+
await drain(response);
|
|
51
82
|
}
|
|
52
83
|
};
|
|
53
84
|
}
|
package/dist/slack.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/slack.ts"],"names":[],"mappings":"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","file":"slack.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\" || type === \"under_floor\") 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","import type { Alert, AlertChannel } from \"../types.js\";\nimport { postable } from \"./shared.js\";\n\nexport interface SlackOptions {\n /** An incoming webhook URL from api.slack.com/messaging/webhooks. */\n webhookUrl: 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\nconst EMOJI: Record<Alert[\"type\"], string> = {\n missed: \":hourglass_flowing_sand:\",\n failed: \":x:\",\n stuck: \":no_entry:\",\n slow: \":turtle:\",\n over_budget: \":moneybag:\",\n under_floor: \":chart_with_downwards_trend:\",\n recovered: \":white_check_mark:\",\n};\n\nconst TIMEOUT_MS = 10_000;\n\n/** Sends alerts to a Slack channel through an incoming webhook. */\nexport function slack(options: SlackOptions): AlertChannel {\n if (!options.webhookUrl) throw new Error(\"slack() needs a webhookUrl\");\n return {\n name: \"slack\",\n async send(alert) {\n const url = options.link?.(alert);\n const title = `${EMOJI[alert.type]} *${escape(alert.title)}*${url ? ` (<${url}|open>)` : \"\"}`;\n const body = codeBlockSafe(escape(alert.message)).slice(0, 2900);\n const response = await fetch(postable(options.webhookUrl), {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n // Refused, not followed: a webhook URL is its own credential.\n redirect: \"error\",\n signal: AbortSignal.timeout(TIMEOUT_MS),\n body: JSON.stringify({\n // The notification fallback is parsed as mrkdwn too, so it is escaped like the blocks.\n text: escape(`${alert.title}\\n${alert.message}`),\n blocks: [\n { type: \"section\", text: { type: \"mrkdwn\", text: title } },\n { type: \"section\", text: { type: \"mrkdwn\", text: \"```\" + body + \"```\" } },\n // Its own block, so a long diagnosis cannot push a block past Slack's 3000 character limit.\n ...(alert.triage ? [{ type: \"section\", text: { type: \"mrkdwn\", text: `_Triage:_ ${escape(alert.triage)}`.slice(0, 3000) } }] : []),\n ],\n }),\n });\n if (!response.ok) throw new Error(`Slack webhook answered ${response.status}: ${(await response.text()).slice(0, 200)}`);\n },\n };\n}\n\n/** Slack's three control characters. Escaping < and > also stops <!channel> and <url|links>. */\nfunction escape(text: string): string {\n return text.replace(/&/g, \"&\").replace(/</g, \"<\").replace(/>/g, \">\");\n}\n\n/** Breaks up ``` so text inside a code block cannot close it. */\nfunction codeBlockSafe(text: string): string {\n return text.replace(/```/g, \"```\");\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/slack.ts"],"names":[],"mappings":"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","file":"slack.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\" || type === \"under_floor\") 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 await drain(response);\n return response;\n}\n\n/**\n * Lets go of a successful answer's body, which nothing reads, so its\n * connection goes back to the pool now rather than when it is collected.\n */\nexport async function drain(response: Response): Promise<void> {\n try {\n await response.body?.cancel();\n } catch {\n // Already read, or the connection is gone: nothing to let go of.\n }\n}\n\n/**\n * The parts of a webhook URL that are its credential, to cut from an error\n * body that echoes the request: its path and query together, then the last\n * segment of its path (Slack's and Discord's token) on its own.\n */\nexport function urlSecrets(url: string): string[] {\n try {\n const parsed = new URL(url);\n const last = parsed.pathname.split(\"/\").pop() ?? \"\";\n return [parsed.pathname + parsed.search, last].filter((part) => part.length > 0 && part !== \"/\");\n } catch {\n return [];\n }\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","import type { Alert, AlertChannel } from \"../types.js\";\nimport { drain, errorBody, postable, urlSecrets } from \"./shared.js\";\n\nexport interface SlackOptions {\n /** An incoming webhook URL from api.slack.com/messaging/webhooks. */\n webhookUrl: 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\nconst EMOJI: Record<Alert[\"type\"], string> = {\n missed: \":hourglass_flowing_sand:\",\n failed: \":x:\",\n stuck: \":no_entry:\",\n slow: \":turtle:\",\n over_budget: \":moneybag:\",\n under_floor: \":chart_with_downwards_trend:\",\n recovered: \":white_check_mark:\",\n};\n\nconst TIMEOUT_MS = 10_000;\n\n/** Sends alerts to a Slack channel through an incoming webhook. */\nexport function slack(options: SlackOptions): AlertChannel {\n if (!options.webhookUrl) throw new Error(\"slack() needs a webhookUrl\");\n return {\n name: \"slack\",\n async send(alert) {\n const url = options.link?.(alert);\n const title = `${EMOJI[alert.type]} *${escape(alert.title)}*${url ? ` (<${url}|open>)` : \"\"}`;\n const body = codeBlockSafe(escape(alert.message)).slice(0, 2900);\n const response = await fetch(postable(options.webhookUrl), {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n // Refused, not followed: a webhook URL is its own credential.\n redirect: \"error\",\n signal: AbortSignal.timeout(TIMEOUT_MS),\n body: JSON.stringify({\n // The notification fallback is parsed as mrkdwn too, so it is escaped like the blocks.\n text: escape(`${alert.title}\\n${alert.message}`),\n blocks: [\n { type: \"section\", text: { type: \"mrkdwn\", text: title } },\n { type: \"section\", text: { type: \"mrkdwn\", text: \"```\" + body + \"```\" } },\n // Its own block, so a long diagnosis cannot push a block past Slack's 3000 character limit.\n ...(alert.triage ? [{ type: \"section\", text: { type: \"mrkdwn\", text: `_Triage:_ ${escape(alert.triage)}`.slice(0, 3000) } }] : []),\n ],\n }),\n });\n // The start of the answer, with the URL's credential cut out in case a proxy echoes the request.\n if (!response.ok) throw new Error(`Slack webhook answered ${response.status}: ${errorBody(await response.text(), urlSecrets(options.webhookUrl))}`);\n await drain(response);\n },\n };\n}\n\n/** Slack's three control characters. Escaping < and > also stops <!channel> and <url|links>. */\nfunction escape(text: string): string {\n return text.replace(/&/g, \"&\").replace(/</g, \"<\").replace(/>/g, \">\");\n}\n\n/** Breaks up ``` so text inside a code block cannot close it. */\nfunction codeBlockSafe(text: string): string {\n return text.replace(/```/g, \"```\");\n}\n"]}
|
package/dist/sqlite.cjs
CHANGED
|
@@ -44,7 +44,7 @@ var MAX_PREFIX = 63 - "runs_job_started".length;
|
|
|
44
44
|
function tablePrefix(prefix = DEFAULT_PREFIX) {
|
|
45
45
|
if (!/^[a-z_][a-z0-9_]*$/.test(prefix) || prefix.length > MAX_PREFIX) {
|
|
46
46
|
throw new Error(
|
|
47
|
-
`cronwatch: invalid table prefix ${JSON.stringify(prefix)}. Use lowercase letters, digits and underscores, not starting with a digit, at most ${MAX_PREFIX} characters.`
|
|
47
|
+
`cronwatch: invalid table prefix ${JSON.stringify(prefix)}. Use lowercase letters, digits, and underscores, not starting with a digit, at most ${MAX_PREFIX} characters.`
|
|
48
48
|
);
|
|
49
49
|
}
|
|
50
50
|
return prefix;
|
package/dist/sqlite.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/stores/busy.ts","../src/output.ts","../src/stores/sql.ts","../src/stores/sqlite.ts"],"names":["text","json","mkdirSync","path","closeSync","openSync","chmodSync","Database"],"mappings":"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","file":"sqlite.cjs","sourcesContent":["/** How long opening SQLite keeps retrying a busy database before it gives up. */\nexport const BUSY_RETRY_MS = 2_000;\n\nfunction sleepSync(ms: number): void {\n Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);\n}\n\n/**\n * Runs `fn`, retrying while SQLite answers SQLITE_BUSY (or SQLITE_LOCKED),\n * with a short growing pause, for up to `budgetMs` in all. Opening a\n * better-sqlite3 database is synchronous, so the pause is too.\n */\nexport function retryBusy<T>(fn: () => T, budgetMs = BUSY_RETRY_MS, sleep: (ms: number) => void = sleepSync): T {\n let waited = 0;\n for (let attempt = 0; ; attempt++) {\n try {\n return fn();\n } catch (e) {\n const code = (e as { code?: unknown } | null)?.code;\n const busy = typeof code === \"string\" && (code.startsWith(\"SQLITE_BUSY\") || code.startsWith(\"SQLITE_LOCKED\"));\n if (!busy || waited >= budgetMs) throw e;\n const pause = Math.min(10 * 2 ** attempt, 200, budgetMs - waited);\n sleep(pause);\n waited += pause;\n }\n }\n}\n","/** Output is capped so a chatty job cannot fill the store. The tail is kept. */\nexport const OUTPUT_CAP = 16 * 1024;\n\n/**\n * NUL characters are removed first, since Postgres refuses them in TEXT and\n * JSONB and the whole run row would be lost. The cap then applies to what is\n * left.\n */\nexport function capOutput(text: string): string {\n const clean = stripNul(text);\n if (clean.length <= OUTPUT_CAP) return clean;\n return TRIMMED + clean.slice(clean.length - OUTPUT_CAP);\n}\n\nconst TRIMMED = \"[earlier output trimmed]\\n\";\n\n/**\n * How much text before the kept tail redaction reads, and never keeps: three\n * times the longest secret a default pattern can match (a PEM key's 16 KB body\n * with its header and footer, under OUTPUT_CAP + 1024), since a replacement\n * grows what it replaces at most threefold.\n */\nexport const REDACT_EDGE = 3 * (OUTPUT_CAP + 1024);\n\n/**\n * Output or an error as it is stored: redacted, then capped like capOutput,\n * so the cut cannot fall inside a secret and keep what follows its label.\n * Text of at most OUTPUT_CAP + REDACT_EDGE is redacted whole. Longer text is\n * cut to that many units from its end first, and after redacting, the first\n * REDACT_EDGE units are never kept: a secret whose label fell before that cut\n * is left out with them. NULs go before and after `redact`.\n */\nexport function redactAndCap(text: string, redact: (text: string) => string): string {\n const clean = stripNul(text);\n const from = clean.length - (OUTPUT_CAP + REDACT_EDGE);\n if (from <= 0) return capOutput(redact(clean));\n const redacted = stripNul(redact(clean.slice(from)));\n return TRIMMED + redacted.slice(Math.max(redacted.length - OUTPUT_CAP, REDACT_EDGE));\n}\n\n/** Removes every U+0000. */\nexport function stripNul(text: string): string {\n return text.includes(\"\\u0000\") ? text.replace(/\\u0000/g, \"\") : text;\n}\n\n/**\n * Removes every U+0000 from JSON text, keys and strings alike, by dropping\n * each \\u0000 escape (a NUL can appear in JSON no other way). Escapes are\n * read left to right in pairs, so an escaped backslash followed by \"u0000\"\n * is left as it is.\n */\nexport function stripJsonNul(json: string): string {\n return json.includes(\"\\\\u0000\") ? json.replace(/\\\\(u0000|[\\s\\S])/g, (escape, next: string) => (next === \"u0000\" ? \"\" : escape)) : json;\n}\n\n/** \"Name: message\" and the first five stack frames, capped like output. */\nexport function errorMessage(error: unknown): string {\n return capOutput(describeError(error));\n}\n\n/** \"Name: message\" and the first five stack frames, not capped: see redactAndCap. */\nexport function describeError(error: unknown): string {\n if (error instanceof Error) {\n // The stack repeats the header (over several lines when the message has\n // newlines), so take only its frames.\n const frames = (error.stack ?? \"\").split(\"\\n\").filter((line) => /^\\s+at /.test(line)).slice(0, 5);\n return `${error.name}: ${error.message}${frames.length ? `\\n${frames.join(\"\\n\")}` : \"\"}`;\n }\n if (typeof error === \"string\") return error;\n try {\n return JSON.stringify(error) ?? String(error);\n } catch {\n return String(error);\n }\n}\n\nconst REDACTED = \"[redacted]\";\n\n// Bounded quantifiers throughout, so a long line cannot make these backtrack.\n// They apply in this order, each to the text the ones before it left.\nconst SECRET_PATTERNS: [RegExp, string | ((match: string, ...groups: string[]) => string)][] = [\n // A PEM private key, header to footer. Without a footer (the output was\n // trimmed) it runs to the end of the base64 body. A \"-\" that starts five\n // dashes ends the body, so the footer is never swallowed into it.\n [\n /-----BEGIN (?:[A-Z0-9]{1,20} ){0,3}PRIVATE KEY-----(?:[A-Za-z0-9+/=\\s,:]|-(?!----)){0,16384}(?:-----END (?:[A-Z0-9]{1,20} ){0,3}PRIVATE KEY-----)?/g,\n REDACTED,\n ],\n // password=..., API_KEY: ..., \"client_secret\": \"...\", TOKEN='...', token=...,\n // :password=>\"...\" (but not max_tokens: 800). A quoted value is blanked to\n // its closing quote, spaces and all, and keeps its quotes.\n [\n /\\b([A-Za-z0-9_-]{0,40}(?:secret|token|passw(?:or)?d|pwd|api[_-]?key|access[_-]?key|private[_-]?key|credential)[A-Za-z0-9_-]{0,40}(?<![Tt][Oo][Kk][Ee][Nn][Ss])\"?\\s{0,3}(?:=>|[=:])\\s{0,3})(?:(\")[^\"\\n]{1,4096}\"|(')[^'\\n]{1,4096}'|[\"']?[^\\s\"',;&]{1,4096})/gi,\n (_match, name: string, double?: string, single?: string) => {\n const quote = double ?? single ?? \"\";\n return `${name}${quote}${REDACTED}${quote}`;\n },\n ],\n // Authorization: Basic <base64> and Authorization: Token <token>, also as a JSON or hash entry.\n [/\\b((?:proxy-)?authorization[\"']?\\s{0,3}(?:=>|[=:])\\s{0,3}[\"']?\\s{0,3}(?:basic|token)\\s{1,3})[A-Za-z0-9._~+/=:-]{1,4096}/gi, `$1${REDACTED}`],\n // Credentials inside a URL: postgres://user:password@host. The password\n // runs to the last \"@\" before a \"/\" or a space, so one that contains \"@\"\n // is blanked whole.\n [/(\\b[a-z][a-z0-9+.-]{0,30}:\\/\\/[^\\s/:@]{0,256}:)[^\\s/]{1,256}@/gi, `$1${REDACTED}@`],\n // Authorization: Bearer <token>\n [/\\b(Bearer\\s{1,3})[A-Za-z0-9._~+/=-]{8,4096}/g, `$1${REDACTED}`],\n // A bare JWT: three base64url segments, the first starting eyJ.\n [/\\beyJ[A-Za-z0-9_-]{4,4096}\\.[A-Za-z0-9_-]{4,4096}\\.[A-Za-z0-9_-]{0,4096}/g, REDACTED],\n // Incoming webhook URLs carry their secret in the path.\n [/(\\bhooks\\.slack\\.com\\/(?:services|workflows|triggers)\\/)[A-Za-z0-9/_-]{1,255}/gi, `$1${REDACTED}`],\n [/(\\bdiscord(?:app)?\\.com\\/api\\/(?:v\\d{1,2}\\/)?webhooks\\/)[A-Za-z0-9/_-]{1,255}/gi, `$1${REDACTED}`],\n // Well-known token shapes: AWS, GitHub, Slack, Stripe, Anthropic, OpenAI and Google style keys.\n [/\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b/g, REDACTED],\n [/\\b(?:gh[pousr]_[A-Za-z0-9]{30,255}|github_pat_[A-Za-z0-9_]{20,255})\\b/g, REDACTED],\n [/\\bxox[abposr]-[A-Za-z0-9-]{10,255}/g, REDACTED],\n [/\\b[rsp]k_(?:live|test)_[A-Za-z0-9]{10,255}\\b/g, REDACTED],\n [/\\bwhsec_[A-Za-z0-9+/=]{16,255}/g, REDACTED],\n [/\\bsk-[A-Za-z0-9_-]{20,255}/g, REDACTED],\n [/\\bAIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])/g, REDACTED],\n];\n\n/**\n * The default `redact`: blanks values that look like secrets (key=value pairs\n * with secret-ish names, Authorization headers, URL credentials, bearer\n * tokens, JWTs, PEM private keys, webhook URLs and well-known token formats)\n * before output or an error is stored, shown or sent anywhere.\n */\nexport function redactSecrets(text: string): string {\n let out = text;\n for (const [pattern, replacement] of SECRET_PATTERNS) out = out.replace(pattern, replacement as string);\n return out;\n}\n","import { stripJsonNul, stripNul } from \"../output.js\";\nimport type { JobState, Run, StoredJob, StoredJobDefinition } from \"../types.js\";\n\n/**\n * The schema, statements and row mapping shared by the SQLite and Postgres\n * stores. Imports no driver, so either entry point can pull it in alone.\n * Statements are written with `?` placeholders; Postgres numbers them.\n */\n\nexport type Dialect = \"sqlite\" | \"postgres\";\n\nexport const DEFAULT_PREFIX = \"cronwatch_\";\n\n// Postgres truncates identifiers past 63 bytes; the longest name we build is the prefix plus \"runs_job_started\".\nconst MAX_PREFIX = 63 - \"runs_job_started\".length;\n\n/**\n * Table names are built from the prefix, so it must be a plain lowercase\n * identifier. Uppercase is refused rather than folded: Postgres lowercases\n * unquoted names, so \"Monitoring_\" would quietly become \"monitoring_\".\n */\nexport function tablePrefix(prefix: string = DEFAULT_PREFIX): string {\n if (!/^[a-z_][a-z0-9_]*$/.test(prefix) || prefix.length > MAX_PREFIX) {\n throw new Error(\n `cronwatch: invalid table prefix ${JSON.stringify(prefix)}. Use lowercase letters, digits and underscores, ` +\n `not starting with a digit, at most ${MAX_PREFIX} characters.`,\n );\n }\n return prefix;\n}\n\nexport function schema(dialect: Dialect, p: string): string {\n const pg = dialect === \"postgres\";\n const int = pg ? \"BIGINT\" : \"INTEGER\";\n const json = pg ? \"JSONB\" : \"TEXT\";\n return `\n CREATE TABLE IF NOT EXISTS ${p}jobs (\n name TEXT PRIMARY KEY,\n definition ${json} NOT NULL,\n created_at ${int} NOT NULL,\n updated_at ${int} NOT NULL\n );\n CREATE TABLE IF NOT EXISTS ${p}runs (${pg ? \"\\n seq BIGSERIAL,\" : \"\"}\n id TEXT PRIMARY KEY,\n job TEXT NOT NULL,\n status TEXT NOT NULL,\n started_at ${int} NOT NULL,\n finished_at ${int},\n duration_ms ${int},\n error TEXT,\n output TEXT,\n metrics ${json} NOT NULL DEFAULT '{}',\n trigger TEXT NOT NULL DEFAULT 'run'\n );\n CREATE INDEX IF NOT EXISTS ${p}runs_job_started ON ${p}runs (job, started_at DESC);\n CREATE INDEX IF NOT EXISTS ${p}runs_running ON ${p}runs (status) WHERE status = 'running';\n CREATE TABLE IF NOT EXISTS ${p}state (\n job TEXT PRIMARY KEY,\n state ${json} NOT NULL\n );\n `;\n}\n\nexport function statements(dialect: Dialect, p: string) {\n const pg = dialect === \"postgres\";\n // Insertion order, to break ties between runs that started in the same millisecond.\n const seq = pg ? \"seq\" : \"rowid\";\n // Byte order on both, so names sort the same whatever the database's collation.\n const byName = pg ? `name COLLATE \"C\"` : \"name\";\n // The version inside a state's JSON, as stateVersion() reads it: a whole\n // number from 0 to 2^53 - 1, else 0 (none, or a foreign row's 1.5 or \"x\",\n // which must neither fail the statement nor refuse every write for good;\n // on SQLite, also text that is not JSON).\n // Each CASE tests the JSON type before any cast.\n const version = (column: string) => {\n if (pg) {\n const v = `(${column}->>'version')::numeric`;\n return `CASE WHEN jsonb_typeof(${column}->'version') <> 'number' THEN 0 WHEN ${v} % 1 = 0 AND ${v} BETWEEN 0 AND 9007199254740991 THEN ${v}::bigint ELSE 0 END`;\n }\n const v = `json_extract(${column}, '$.version')`;\n // Text that is not JSON at all (SQLite holds any) counts as 0 too, before json_type could fail on it.\n return `CASE WHEN NOT json_valid(${column}) THEN 0 WHEN json_type(${column}, '$.version') NOT IN ('integer', 'real') THEN 0 WHEN ${v} = CAST(${v} AS INTEGER) AND ${v} BETWEEN 0 AND 9007199254740991 THEN CAST(${v} AS INTEGER) ELSE 0 END`;\n };\n const sql = {\n upsertJob: `INSERT INTO ${p}jobs (name, definition, created_at, updated_at) VALUES (?, ?, ?, ?)\n ON CONFLICT (name) DO UPDATE SET definition = excluded.definition, updated_at = excluded.updated_at`,\n getJob: `SELECT * FROM ${p}jobs WHERE name = ?`,\n listJobs: `SELECT * FROM ${p}jobs ORDER BY ${byName}`,\n deleteRuns: `DELETE FROM ${p}runs WHERE job = ?`,\n deleteState: `DELETE FROM ${p}state WHERE job = ?`,\n deleteJob: `DELETE FROM ${p}jobs WHERE name = ?`,\n insertRun: `INSERT INTO ${p}runs (id, job, status, started_at, finished_at, duration_ms, error, output, metrics, trigger)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n updateRun: `UPDATE ${p}runs SET status = ?, finished_at = ?, duration_ms = ?, error = ?, output = ?, metrics = ? WHERE id = ?`,\n getRun: `SELECT * FROM ${p}runs WHERE id = ?`,\n listRuns: `SELECT * FROM ${p}runs WHERE job = ? ORDER BY started_at DESC, ${seq} DESC LIMIT ?`,\n runningRuns: `SELECT * FROM ${p}runs WHERE status = 'running' ORDER BY started_at, ${seq}`,\n getState: `SELECT state FROM ${p}state WHERE job = ?`,\n setState: `INSERT INTO ${p}state (job, state) VALUES (?, ?) ON CONFLICT (job) DO UPDATE SET state = excluded.state`,\n // compareAndSetState. Expecting version 0 also matches a missing row, so\n // that case inserts; any other version must find its row.\n casInsert: `INSERT INTO ${p}state (job, state) VALUES (?, ?)\n ON CONFLICT (job) DO UPDATE SET state = excluded.state WHERE ${version(`${p}state.state`)} = 0`,\n casUpdate: `UPDATE ${p}state SET state = ? WHERE job = ? AND ${version(\"state\")} = ?`,\n // Each job's newest run is kept whatever its age: without it, a job that\n // runs less often than the retention looks like it never ran.\n prune: `DELETE FROM ${p}runs WHERE status <> 'running' AND started_at < ?\n AND started_at < (SELECT MAX(r.started_at) FROM ${p}runs r WHERE r.job = ${p}runs.job)`,\n };\n if (pg) {\n for (const key of Object.keys(sql) as (keyof typeof sql)[]) {\n let n = 0;\n sql[key] = sql[key].replace(/\\?/g, () => `$${++n}`);\n }\n }\n return sql;\n}\n\n/**\n * updateRunIf: the update above, only while the stored status is one of\n * `count` statuses. Built per count, since the list is bound value by value.\n */\nexport function updateRunIfSql(dialect: Dialect, p: string, count: number): string {\n const text = `UPDATE ${p}runs SET status = ?, finished_at = ?, duration_ms = ?, error = ?, output = ?, metrics = ? WHERE id = ? AND status IN (${Array.from({ length: count }, () => \"?\").join(\", \")})`;\n if (dialect !== \"postgres\") return text;\n let n = 0;\n return text.replace(/\\?/g, () => `$${++n}`);\n}\n\n/**\n * Postgres refuses U+0000 in TEXT and JSONB, and a refused write loses the\n * whole row, so every dialect writes text without it: a run's trigger,\n * output, error and metric names, and every key and string of a definition\n * and a state. Identifiers (a job's name, a run's id) are written as given;\n * the client refuses one with a NUL before it gets here.\n */\nconst text = (value: string | null) => (value === null ? null : stripNul(value));\nconst jsonText = (value: unknown) => stripJsonNul(JSON.stringify(value));\n\n// Parameters in statement order, so both drivers bind the same values.\nexport const params = {\n upsertJob: (definition: StoredJobDefinition, now: number) => [definition.name, jsonText(definition), now, now],\n insertRun: (run: Run) => [\n run.id, run.job, run.status, run.startedAt, run.finishedAt, run.durationMs, text(run.error), text(run.output), jsonText(run.metrics), stripNul(run.trigger),\n ],\n updateRun: (run: Run) => [run.status, run.finishedAt, run.durationMs, text(run.error), text(run.output), jsonText(run.metrics), run.id],\n updateRunIf: (run: Run, fromStatuses: Run[\"status\"][]) => [\n run.status, run.finishedAt, run.durationMs, text(run.error), text(run.output), jsonText(run.metrics), run.id, ...fromStatuses,\n ],\n setState: (state: JobState) => [state.job, jsonText(state)],\n casInsert: (state: JobState) => [state.job, jsonText(state)],\n casUpdate: (state: JobState, expectedVersion: number) => [jsonText(state), state.job, expectedVersion],\n};\n\n// SQLite hands back JSON as TEXT and Postgres as parsed JSONB; Postgres returns BIGINT as a string.\ntype Json<T> = T | string;\ntype Int = number | string;\n\nexport interface JobRow { name: string; definition: Json<StoredJobDefinition>; created_at: Int; updated_at: Int }\nexport interface RunRow {\n id: string; job: string; status: Run[\"status\"]; started_at: Int; finished_at: Int | null;\n duration_ms: Int | null; error: string | null; output: string | null; metrics: Json<Record<string, number>> | null; trigger: string;\n}\nexport interface StateRow { state: Json<JobState> }\n\n/**\n * Rows are read leniently: a foreign, hand-edited or damaged row (SQLite\n * keeps whatever type it is given, in any column) must affect only its own\n * job, never every read. JSON text that does not parse reads as null, which\n * the client takes as no state, or as an unreadable definition it reports.\n */\nconst json = (v: unknown): unknown => {\n if (typeof v !== \"string\") return v;\n try {\n return JSON.parse(v) as unknown;\n } catch {\n return null;\n }\n};\n/** A time, or a count of milliseconds, as a column holds it (a string from Postgres's BIGINT): NaN when it is not a number. */\nconst number = (v: unknown): number => (typeof v === \"number\" ? v : typeof v === \"string\" && v.trim() !== \"\" ? Number(v) : Number.NaN);\n/** A time that must be there: one that is not a finite number reads as 0. */\nconst time = (v: unknown): number => {\n const n = number(v);\n return Number.isFinite(n) ? n : 0;\n};\n/** A time or duration that may be absent: one that is not a finite number reads as null. */\nconst maybeTime = (v: unknown): number | null => {\n const n = number(v);\n return Number.isFinite(n) ? n : null;\n};\nconst textOrNull = (v: unknown): string | null => (typeof v === \"string\" ? v : null);\nconst isObject = (v: unknown): v is Record<string, number> => typeof v === \"object\" && v !== null && !Array.isArray(v);\n\n/** A job's row. Its definition is as stored, or null when its text does not parse; the client reads the rest (see readStoredJob). */\nexport function rowToJob(r: JobRow): StoredJob {\n return { name: r.name, definition: json(r.definition) as StoredJobDefinition, createdAt: time(r.created_at), updatedAt: time(r.updated_at) };\n}\n\n/**\n * A run's row. A start that is not a finite number reads as 0, a finish or\n * duration as null; an error or output that is not text as null; metrics\n * that do not parse to an object as {}; a trigger that is not text as \"run\".\n */\nexport function rowToRun(r: RunRow): Run {\n const metrics = json(r.metrics);\n return {\n id: r.id, job: r.job, status: r.status, startedAt: time(r.started_at), finishedAt: maybeTime(r.finished_at),\n durationMs: maybeTime(r.duration_ms), error: textOrNull(r.error), output: textOrNull(r.output),\n metrics: isObject(metrics) ? metrics : {}, trigger: typeof r.trigger === \"string\" ? r.trigger : \"run\",\n };\n}\n\n/** A state's row, or null when its text does not parse (normalizeState reads anything else that is not an object as no state). */\nexport function rowToState(r: StateRow): JobState {\n return json(r.state) as JobState;\n}\n","import Database from \"better-sqlite3\";\nimport { chmodSync, closeSync, mkdirSync, openSync } from \"node:fs\";\nimport path from \"node:path\";\nimport type { Store, StoredJob, StoredJobDefinition } from \"../types.js\";\nimport { retryBusy } from \"./busy.js\";\nimport { params, rowToJob, rowToRun, rowToState, schema, statements, tablePrefix, updateRunIfSql, type JobRow, type RunRow, type StateRow } from \"./sql.js\";\n\nexport interface SqliteOptions {\n /** File path. The directory is created if missing. \":memory:\" works too. */\n path?: string;\n /** Bring your own open better-sqlite3 database instead. */\n database?: Database.Database;\n /** Table name prefix: lowercase letters, digits and underscores. Default \"cronwatch_\". */\n prefix?: string;\n}\n\n/**\n * Keeps everything in one SQLite file through better-sqlite3. The right\n * choice for a single server. WAL mode, so the app's reads never block on a\n * run being written.\n */\nexport function sqlite(options: SqliteOptions = {}): Store {\n const p = tablePrefix(options.prefix);\n const sql = statements(\"sqlite\", p);\n let db: Database.Database | null = options.database ?? null;\n const file = options.path ?? \"./data/cronwatch.db\";\n const cache = new Map<string, Database.Statement>();\n\n const open = (): Database.Database => {\n if (db) return db;\n const onDisk = file !== \":memory:\" && file !== \"\";\n if (onDisk) {\n mkdirSync(path.dirname(file), { recursive: true });\n // Create the file private before SQLite opens it. SQLite gives the -wal and -shm files the\n // main file's mode, so the whole set stays 0600; the chmods cover files left by an earlier open.\n try {\n closeSync(openSync(file, \"a\", 0o600));\n for (const f of [file, `${file}-wal`, `${file}-shm`]) {\n try { chmodSync(f, 0o600); } catch { /* the sidecar may not exist yet */ }\n }\n } catch { /* best effort: not every filesystem cares */ }\n }\n // No busy handler until WAL is on: switching journal mode can answer\n // SQLITE_BUSY at once while another process is doing the same on a new\n // file, so that is retried here. The database is kept only once every\n // pragma has gone through; a failed open is tried afresh next time.\n const opened = new Database(file, { timeout: 0 });\n try {\n retryBusy(() => opened.pragma(\"journal_mode = WAL\"));\n opened.pragma(\"busy_timeout = 5000\");\n opened.pragma(\"synchronous = NORMAL\");\n } catch (e) {\n opened.close();\n throw e;\n }\n db = opened;\n return db;\n };\n\n // Each statement is prepared once per open database.\n const stmt = (text: string): Database.Statement => {\n let s = cache.get(text);\n if (!s) {\n s = open().prepare(text);\n cache.set(text, s);\n }\n return s;\n };\n\n const forget = (name: string) => {\n stmt(sql.deleteRuns).run(name);\n stmt(sql.deleteState).run(name);\n stmt(sql.deleteJob).run(name);\n };\n\n return {\n async init() {\n open().exec(schema(\"sqlite\", p));\n },\n async upsertJob(definition: StoredJobDefinition, now: number) {\n stmt(sql.upsertJob).run(...params.upsertJob(definition, now));\n },\n async getJob(name) {\n const r = stmt(sql.getJob).get(name) as JobRow | undefined;\n return r ? rowToJob(r) : null;\n },\n async listJobs(): Promise<StoredJob[]> {\n return (stmt(sql.listJobs).all() as JobRow[]).map(rowToJob);\n },\n async deleteJob(name) {\n open().transaction(forget)(name);\n },\n async insertRun(run) {\n stmt(sql.insertRun).run(...params.insertRun(run));\n },\n async updateRun(run) {\n stmt(sql.updateRun).run(...params.updateRun(run));\n },\n async updateRunIf(run, fromStatuses) {\n if (fromStatuses.length === 0) return false;\n return stmt(updateRunIfSql(\"sqlite\", p, fromStatuses.length)).run(...params.updateRunIf(run, fromStatuses)).changes > 0;\n },\n async getRun(id) {\n const r = stmt(sql.getRun).get(id) as RunRow | undefined;\n return r ? rowToRun(r) : null;\n },\n async listRuns(job, limit) {\n return (stmt(sql.listRuns).all(job, limit) as RunRow[]).map(rowToRun);\n },\n async lastRun(job) {\n const r = stmt(sql.listRuns).get(job, 1) as RunRow | undefined;\n return r ? rowToRun(r) : null;\n },\n async runningRuns() {\n return (stmt(sql.runningRuns).all() as RunRow[]).map(rowToRun);\n },\n async getState(job) {\n const r = stmt(sql.getState).get(job) as StateRow | undefined;\n return r ? rowToState(r) : null;\n },\n async setState(state) {\n stmt(sql.setState).run(...params.setState(state));\n },\n async compareAndSetState(state, expectedVersion) {\n const result = expectedVersion === 0\n ? stmt(sql.casInsert).run(...params.casInsert(state))\n : stmt(sql.casUpdate).run(...params.casUpdate(state, expectedVersion));\n return result.changes > 0;\n },\n async prune(before) {\n return stmt(sql.prune).run(before).changes;\n },\n async close() {\n cache.clear();\n if (db && !options.database) {\n db.close();\n db = null;\n }\n },\n };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/stores/busy.ts","../src/output.ts","../src/stores/sql.ts","../src/stores/sqlite.ts"],"names":["text","json","mkdirSync","path","closeSync","openSync","chmodSync","Database"],"mappings":"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","file":"sqlite.cjs","sourcesContent":["/** How long opening SQLite keeps retrying a busy database before it gives up. */\nexport const BUSY_RETRY_MS = 2_000;\n\nfunction sleepSync(ms: number): void {\n Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);\n}\n\n/**\n * Runs `fn`, retrying while SQLite answers SQLITE_BUSY (or SQLITE_LOCKED),\n * with a short growing pause, for up to `budgetMs` in all. Opening a\n * better-sqlite3 database is synchronous, so the pause is too.\n */\nexport function retryBusy<T>(fn: () => T, budgetMs = BUSY_RETRY_MS, sleep: (ms: number) => void = sleepSync): T {\n let waited = 0;\n for (let attempt = 0; ; attempt++) {\n try {\n return fn();\n } catch (e) {\n const code = (e as { code?: unknown } | null)?.code;\n const busy = typeof code === \"string\" && (code.startsWith(\"SQLITE_BUSY\") || code.startsWith(\"SQLITE_LOCKED\"));\n if (!busy || waited >= budgetMs) throw e;\n const pause = Math.min(10 * 2 ** attempt, 200, budgetMs - waited);\n sleep(pause);\n waited += pause;\n }\n }\n}\n","/** Output is capped so a chatty job cannot fill the store. The tail is kept. */\nexport const OUTPUT_CAP = 16 * 1024;\n\n/**\n * NUL characters are removed first, since Postgres refuses them in TEXT and\n * JSONB and the whole run row would be lost. The cap then applies to what is\n * left.\n */\nexport function capOutput(text: string): string {\n const clean = stripNul(text);\n if (clean.length <= OUTPUT_CAP) return clean;\n return TRIMMED + clean.slice(clean.length - OUTPUT_CAP);\n}\n\nconst TRIMMED = \"[earlier output trimmed]\\n\";\n\n/**\n * How much text before the kept tail redaction reads, and never keeps: three\n * times the longest secret a default pattern can match (a PEM key's 16 KB body\n * with its header and footer, under OUTPUT_CAP + 1024), since a replacement\n * grows what it replaces at most threefold.\n */\nexport const REDACT_EDGE = 3 * (OUTPUT_CAP + 1024);\n\n/**\n * Output or an error as it is stored: redacted, then capped like capOutput,\n * so the cut cannot fall inside a secret and keep what follows its label.\n * Text of at most OUTPUT_CAP + REDACT_EDGE is redacted whole. Longer text is\n * cut to that many units from its end first, and after redacting, the first\n * REDACT_EDGE units are never kept: a secret whose label fell before that cut\n * is left out with them. NULs go before and after `redact`.\n */\nexport function redactAndCap(text: string, redact: (text: string) => string): string {\n const clean = stripNul(text);\n const from = clean.length - (OUTPUT_CAP + REDACT_EDGE);\n if (from <= 0) return capOutput(redact(clean));\n const redacted = stripNul(redact(clean.slice(from)));\n return TRIMMED + redacted.slice(Math.max(redacted.length - OUTPUT_CAP, REDACT_EDGE));\n}\n\n/** Removes every U+0000. */\nexport function stripNul(text: string): string {\n return text.includes(\"\\u0000\") ? text.replace(/\\u0000/g, \"\") : text;\n}\n\n/**\n * Removes every U+0000 from JSON text, keys and strings alike, by dropping\n * each \\u0000 escape (a NUL can appear in JSON no other way). Escapes are\n * read left to right in pairs, so an escaped backslash followed by \"u0000\"\n * is left as it is.\n */\nexport function stripJsonNul(json: string): string {\n return json.includes(\"\\\\u0000\") ? json.replace(/\\\\(u0000|[\\s\\S])/g, (escape, next: string) => (next === \"u0000\" ? \"\" : escape)) : json;\n}\n\n/** \"Name: message\" and the first five stack frames, capped like output. */\nexport function errorMessage(error: unknown): string {\n return capOutput(describeError(error));\n}\n\n/** \"Name: message\" and the first five stack frames, not capped: see redactAndCap. */\nexport function describeError(error: unknown): string {\n if (error instanceof Error) {\n // The stack repeats the header (over several lines when the message has\n // newlines), so take only its frames.\n const frames = (error.stack ?? \"\").split(\"\\n\").filter((line) => /^\\s+at /.test(line)).slice(0, 5);\n return `${error.name}: ${error.message}${frames.length ? `\\n${frames.join(\"\\n\")}` : \"\"}`;\n }\n if (typeof error === \"string\") return error;\n try {\n return JSON.stringify(error) ?? String(error);\n } catch {\n return String(error);\n }\n}\n\nconst REDACTED = \"[redacted]\";\n\n// Bounded quantifiers throughout, so a long line cannot make these backtrack.\n// They apply in this order, each to the text the ones before it left.\nconst SECRET_PATTERNS: [RegExp, string | ((match: string, ...groups: string[]) => string)][] = [\n // A PEM private key, header to footer. Without a footer (the output was\n // trimmed) it runs to the end of the base64 body. A \"-\" that starts five\n // dashes ends the body, so the footer is never swallowed into it.\n [\n /-----BEGIN (?:[A-Z0-9]{1,20} ){0,3}PRIVATE KEY-----(?:[A-Za-z0-9+/=\\s,:]|-(?!----)){0,16384}(?:-----END (?:[A-Z0-9]{1,20} ){0,3}PRIVATE KEY-----)?/g,\n REDACTED,\n ],\n // password=..., API_KEY: ..., \"client_secret\": \"...\", TOKEN='...', token=...,\n // :password=>\"...\" (but not max_tokens: 800). A quoted value is blanked to\n // its closing quote, spaces and all, and keeps its quotes.\n [\n /\\b([A-Za-z0-9_-]{0,40}(?:secret|token|passw(?:or)?d|pwd|api[_-]?key|access[_-]?key|private[_-]?key|credential)[A-Za-z0-9_-]{0,40}(?<![Tt][Oo][Kk][Ee][Nn][Ss])\"?\\s{0,3}(?:=>|[=:])\\s{0,3})(?:(\")[^\"\\n]{1,4096}\"|(')[^'\\n]{1,4096}'|[\"']?[^\\s\"',;&]{1,4096})/gi,\n (_match, name: string, double?: string, single?: string) => {\n const quote = double ?? single ?? \"\";\n return `${name}${quote}${REDACTED}${quote}`;\n },\n ],\n // Authorization: Basic <base64> and Authorization: Token <token>, also as a JSON or hash entry.\n [/\\b((?:proxy-)?authorization[\"']?\\s{0,3}(?:=>|[=:])\\s{0,3}[\"']?\\s{0,3}(?:basic|token)\\s{1,3})[A-Za-z0-9._~+/=:-]{1,4096}/gi, `$1${REDACTED}`],\n // Credentials inside a URL: postgres://user:password@host. The password\n // runs to the last \"@\" before a \"/\" or a space, so one that contains \"@\"\n // is blanked whole.\n [/(\\b[a-z][a-z0-9+.-]{0,30}:\\/\\/[^\\s/:@]{0,256}:)[^\\s/]{1,256}@/gi, `$1${REDACTED}@`],\n // Authorization: Bearer <token>\n [/\\b(Bearer\\s{1,3})[A-Za-z0-9._~+/=-]{8,4096}/g, `$1${REDACTED}`],\n // A bare JWT: three base64url segments, the first starting eyJ.\n [/\\beyJ[A-Za-z0-9_-]{4,4096}\\.[A-Za-z0-9_-]{4,4096}\\.[A-Za-z0-9_-]{0,4096}/g, REDACTED],\n // Incoming webhook URLs carry their secret in the path.\n [/(\\bhooks\\.slack\\.com\\/(?:services|workflows|triggers)\\/)[A-Za-z0-9/_-]{1,255}/gi, `$1${REDACTED}`],\n [/(\\bdiscord(?:app)?\\.com\\/api\\/(?:v\\d{1,2}\\/)?webhooks\\/)[A-Za-z0-9/_-]{1,255}/gi, `$1${REDACTED}`],\n // Well-known token shapes: AWS, GitHub, Slack, Stripe, Anthropic, OpenAI, and Google style keys.\n [/\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b/g, REDACTED],\n [/\\b(?:gh[pousr]_[A-Za-z0-9]{30,255}|github_pat_[A-Za-z0-9_]{20,255})\\b/g, REDACTED],\n [/\\bxox[abposr]-[A-Za-z0-9-]{10,255}/g, REDACTED],\n [/\\b[rsp]k_(?:live|test)_[A-Za-z0-9]{10,255}\\b/g, REDACTED],\n [/\\bwhsec_[A-Za-z0-9+/=]{16,255}/g, REDACTED],\n [/\\bsk-[A-Za-z0-9_-]{20,255}/g, REDACTED],\n [/\\bAIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])/g, REDACTED],\n];\n\n/**\n * The default `redact`: blanks values that look like secrets (key=value pairs\n * with secret-ish names, Authorization headers, URL credentials, bearer\n * tokens, JWTs, PEM private keys, webhook URLs, and well-known token formats)\n * before output or an error is stored, shown, or sent anywhere.\n */\nexport function redactSecrets(text: string): string {\n let out = text;\n for (const [pattern, replacement] of SECRET_PATTERNS) out = out.replace(pattern, replacement as string);\n return out;\n}\n","import { stripJsonNul, stripNul } from \"../output.js\";\nimport type { JobState, Run, StoredJob, StoredJobDefinition } from \"../types.js\";\n\n/**\n * The schema, statements, and row mapping shared by the SQLite and Postgres\n * stores. Imports no driver, so either entry point can pull it in alone.\n * Statements are written with `?` placeholders; Postgres numbers them.\n */\n\nexport type Dialect = \"sqlite\" | \"postgres\";\n\nexport const DEFAULT_PREFIX = \"cronwatch_\";\n\n// Postgres truncates identifiers past 63 bytes; the longest name we build is the prefix plus \"runs_job_started\".\nconst MAX_PREFIX = 63 - \"runs_job_started\".length;\n\n/**\n * Table names are built from the prefix, so it must be a plain lowercase\n * identifier. Uppercase is refused rather than folded: Postgres lowercases\n * unquoted names, so \"Monitoring_\" would quietly become \"monitoring_\".\n */\nexport function tablePrefix(prefix: string = DEFAULT_PREFIX): string {\n if (!/^[a-z_][a-z0-9_]*$/.test(prefix) || prefix.length > MAX_PREFIX) {\n throw new Error(\n `cronwatch: invalid table prefix ${JSON.stringify(prefix)}. Use lowercase letters, digits, and underscores, ` +\n `not starting with a digit, at most ${MAX_PREFIX} characters.`,\n );\n }\n return prefix;\n}\n\nexport function schema(dialect: Dialect, p: string): string {\n const pg = dialect === \"postgres\";\n const int = pg ? \"BIGINT\" : \"INTEGER\";\n const json = pg ? \"JSONB\" : \"TEXT\";\n return `\n CREATE TABLE IF NOT EXISTS ${p}jobs (\n name TEXT PRIMARY KEY,\n definition ${json} NOT NULL,\n created_at ${int} NOT NULL,\n updated_at ${int} NOT NULL\n );\n CREATE TABLE IF NOT EXISTS ${p}runs (${pg ? \"\\n seq BIGSERIAL,\" : \"\"}\n id TEXT PRIMARY KEY,\n job TEXT NOT NULL,\n status TEXT NOT NULL,\n started_at ${int} NOT NULL,\n finished_at ${int},\n duration_ms ${int},\n error TEXT,\n output TEXT,\n metrics ${json} NOT NULL DEFAULT '{}',\n trigger TEXT NOT NULL DEFAULT 'run'\n );\n CREATE INDEX IF NOT EXISTS ${p}runs_job_started ON ${p}runs (job, started_at DESC);\n CREATE INDEX IF NOT EXISTS ${p}runs_running ON ${p}runs (status) WHERE status = 'running';\n CREATE TABLE IF NOT EXISTS ${p}state (\n job TEXT PRIMARY KEY,\n state ${json} NOT NULL\n );\n `;\n}\n\nexport function statements(dialect: Dialect, p: string) {\n const pg = dialect === \"postgres\";\n // Insertion order, to break ties between runs that started in the same millisecond.\n const seq = pg ? \"seq\" : \"rowid\";\n // Byte order on both, so names sort the same whatever the database's collation.\n const byName = pg ? `name COLLATE \"C\"` : \"name\";\n // The version inside a state's JSON, as stateVersion() reads it: a whole\n // number from 0 to 2^53 - 1, else 0 (none, or a foreign row's 1.5 or \"x\",\n // which must neither fail the statement nor refuse every write for good;\n // on SQLite, also text that is not JSON).\n // Each CASE tests the JSON type before any cast.\n const version = (column: string) => {\n if (pg) {\n const v = `(${column}->>'version')::numeric`;\n return `CASE WHEN jsonb_typeof(${column}->'version') <> 'number' THEN 0 WHEN ${v} % 1 = 0 AND ${v} BETWEEN 0 AND 9007199254740991 THEN ${v}::bigint ELSE 0 END`;\n }\n const v = `json_extract(${column}, '$.version')`;\n // Text that is not JSON at all (SQLite holds any) counts as 0 too, before json_type could fail on it.\n return `CASE WHEN NOT json_valid(${column}) THEN 0 WHEN json_type(${column}, '$.version') NOT IN ('integer', 'real') THEN 0 WHEN ${v} = CAST(${v} AS INTEGER) AND ${v} BETWEEN 0 AND 9007199254740991 THEN CAST(${v} AS INTEGER) ELSE 0 END`;\n };\n const sql = {\n upsertJob: `INSERT INTO ${p}jobs (name, definition, created_at, updated_at) VALUES (?, ?, ?, ?)\n ON CONFLICT (name) DO UPDATE SET definition = excluded.definition, updated_at = excluded.updated_at`,\n getJob: `SELECT * FROM ${p}jobs WHERE name = ?`,\n listJobs: `SELECT * FROM ${p}jobs ORDER BY ${byName}`,\n deleteRuns: `DELETE FROM ${p}runs WHERE job = ?`,\n deleteState: `DELETE FROM ${p}state WHERE job = ?`,\n deleteJob: `DELETE FROM ${p}jobs WHERE name = ?`,\n insertRun: `INSERT INTO ${p}runs (id, job, status, started_at, finished_at, duration_ms, error, output, metrics, trigger)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n updateRun: `UPDATE ${p}runs SET status = ?, finished_at = ?, duration_ms = ?, error = ?, output = ?, metrics = ? WHERE id = ?`,\n getRun: `SELECT * FROM ${p}runs WHERE id = ?`,\n listRuns: `SELECT * FROM ${p}runs WHERE job = ? ORDER BY started_at DESC, ${seq} DESC LIMIT ?`,\n runningRuns: `SELECT * FROM ${p}runs WHERE status = 'running' ORDER BY started_at, ${seq}`,\n getState: `SELECT state FROM ${p}state WHERE job = ?`,\n setState: `INSERT INTO ${p}state (job, state) VALUES (?, ?) ON CONFLICT (job) DO UPDATE SET state = excluded.state`,\n // compareAndSetState. Expecting version 0 also matches a missing row, so\n // that case inserts; any other version must find its row.\n casInsert: `INSERT INTO ${p}state (job, state) VALUES (?, ?)\n ON CONFLICT (job) DO UPDATE SET state = excluded.state WHERE ${version(`${p}state.state`)} = 0`,\n casUpdate: `UPDATE ${p}state SET state = ? WHERE job = ? AND ${version(\"state\")} = ?`,\n // Each job's newest run is kept whatever its age: without it, a job that\n // runs less often than the retention looks like it never ran.\n prune: `DELETE FROM ${p}runs WHERE status <> 'running' AND started_at < ?\n AND started_at < (SELECT MAX(r.started_at) FROM ${p}runs r WHERE r.job = ${p}runs.job)`,\n };\n if (pg) {\n for (const key of Object.keys(sql) as (keyof typeof sql)[]) {\n let n = 0;\n sql[key] = sql[key].replace(/\\?/g, () => `$${++n}`);\n }\n }\n return sql;\n}\n\n/**\n * updateRunIf: the update above, only while the stored status is one of\n * `count` statuses. Built per count, since the list is bound value by value.\n */\nexport function updateRunIfSql(dialect: Dialect, p: string, count: number): string {\n const text = `UPDATE ${p}runs SET status = ?, finished_at = ?, duration_ms = ?, error = ?, output = ?, metrics = ? WHERE id = ? AND status IN (${Array.from({ length: count }, () => \"?\").join(\", \")})`;\n if (dialect !== \"postgres\") return text;\n let n = 0;\n return text.replace(/\\?/g, () => `$${++n}`);\n}\n\n/**\n * Postgres refuses U+0000 in TEXT and JSONB, and a refused write loses the\n * whole row, so every dialect writes text without it: a run's trigger,\n * output, error, and metric names, and every key and string of a definition\n * and a state. Identifiers (a job's name, a run's id) are written as given;\n * the client refuses one with a NUL before it gets here.\n */\nconst text = (value: string | null) => (value === null ? null : stripNul(value));\nconst jsonText = (value: unknown) => stripJsonNul(JSON.stringify(value));\n\n// Parameters in statement order, so both drivers bind the same values.\nexport const params = {\n upsertJob: (definition: StoredJobDefinition, now: number) => [definition.name, jsonText(definition), now, now],\n insertRun: (run: Run) => [\n run.id, run.job, run.status, run.startedAt, run.finishedAt, run.durationMs, text(run.error), text(run.output), jsonText(run.metrics), stripNul(run.trigger),\n ],\n updateRun: (run: Run) => [run.status, run.finishedAt, run.durationMs, text(run.error), text(run.output), jsonText(run.metrics), run.id],\n updateRunIf: (run: Run, fromStatuses: Run[\"status\"][]) => [\n run.status, run.finishedAt, run.durationMs, text(run.error), text(run.output), jsonText(run.metrics), run.id, ...fromStatuses,\n ],\n setState: (state: JobState) => [state.job, jsonText(state)],\n casInsert: (state: JobState) => [state.job, jsonText(state)],\n casUpdate: (state: JobState, expectedVersion: number) => [jsonText(state), state.job, expectedVersion],\n};\n\n// SQLite hands back JSON as TEXT and Postgres as parsed JSONB; Postgres returns BIGINT as a string.\ntype Json<T> = T | string;\ntype Int = number | string;\n\nexport interface JobRow { name: string; definition: Json<StoredJobDefinition>; created_at: Int; updated_at: Int }\nexport interface RunRow {\n id: string; job: string; status: Run[\"status\"]; started_at: Int; finished_at: Int | null;\n duration_ms: Int | null; error: string | null; output: string | null; metrics: Json<Record<string, number>> | null; trigger: string;\n}\nexport interface StateRow { state: Json<JobState> }\n\n/**\n * Rows are read leniently: a foreign, hand-edited, or damaged row (SQLite\n * keeps whatever type it is given, in any column) must affect only its own\n * job, never every read. JSON text that does not parse reads as null, which\n * the client takes as no state, or as an unreadable definition it reports.\n */\nconst json = (v: unknown): unknown => {\n if (typeof v !== \"string\") return v;\n try {\n return JSON.parse(v) as unknown;\n } catch {\n return null;\n }\n};\n/** A time, or a count of milliseconds, as a column holds it (a string from Postgres's BIGINT): NaN when it is not a number. */\nconst number = (v: unknown): number => (typeof v === \"number\" ? v : typeof v === \"string\" && v.trim() !== \"\" ? Number(v) : Number.NaN);\n/** A time that must be there: one that is not a finite number reads as 0. */\nconst time = (v: unknown): number => {\n const n = number(v);\n return Number.isFinite(n) ? n : 0;\n};\n/** A time or duration that may be absent: one that is not a finite number reads as null. */\nconst maybeTime = (v: unknown): number | null => {\n const n = number(v);\n return Number.isFinite(n) ? n : null;\n};\nconst textOrNull = (v: unknown): string | null => (typeof v === \"string\" ? v : null);\nconst isObject = (v: unknown): v is Record<string, number> => typeof v === \"object\" && v !== null && !Array.isArray(v);\n\n/** A job's row. Its definition is as stored, or null when its text does not parse; the client reads the rest (see readStoredJob). */\nexport function rowToJob(r: JobRow): StoredJob {\n return { name: r.name, definition: json(r.definition) as StoredJobDefinition, createdAt: time(r.created_at), updatedAt: time(r.updated_at) };\n}\n\n/**\n * A run's row. A start that is not a finite number reads as 0, a finish or\n * duration as null; an error or output that is not text as null; metrics\n * that do not parse to an object as {}; a trigger that is not text as \"run\".\n */\nexport function rowToRun(r: RunRow): Run {\n const metrics = json(r.metrics);\n return {\n id: r.id, job: r.job, status: r.status, startedAt: time(r.started_at), finishedAt: maybeTime(r.finished_at),\n durationMs: maybeTime(r.duration_ms), error: textOrNull(r.error), output: textOrNull(r.output),\n metrics: isObject(metrics) ? metrics : {}, trigger: typeof r.trigger === \"string\" ? r.trigger : \"run\",\n };\n}\n\n/** A state's row, or null when its text does not parse (normalizeState reads anything else that is not an object as no state). */\nexport function rowToState(r: StateRow): JobState {\n return json(r.state) as JobState;\n}\n","import Database from \"better-sqlite3\";\nimport { chmodSync, closeSync, mkdirSync, openSync } from \"node:fs\";\nimport path from \"node:path\";\nimport type { Store, StoredJob, StoredJobDefinition } from \"../types.js\";\nimport { retryBusy } from \"./busy.js\";\nimport { params, rowToJob, rowToRun, rowToState, schema, statements, tablePrefix, updateRunIfSql, type JobRow, type RunRow, type StateRow } from \"./sql.js\";\n\nexport interface SqliteOptions {\n /** File path. The directory is created if missing. \":memory:\" works too. */\n path?: string;\n /** Bring your own open better-sqlite3 database instead. */\n database?: Database.Database;\n /** Table name prefix: lowercase letters, digits, and underscores. Default \"cronwatch_\". */\n prefix?: string;\n}\n\n/**\n * Keeps everything in one SQLite file through better-sqlite3. The right\n * choice for a single server. WAL mode, so the app's reads never block on a\n * run being written.\n */\nexport function sqlite(options: SqliteOptions = {}): Store {\n const p = tablePrefix(options.prefix);\n const sql = statements(\"sqlite\", p);\n let db: Database.Database | null = options.database ?? null;\n const file = options.path ?? \"./data/cronwatch.db\";\n const cache = new Map<string, Database.Statement>();\n\n const open = (): Database.Database => {\n if (db) return db;\n const onDisk = file !== \":memory:\" && file !== \"\";\n if (onDisk) {\n mkdirSync(path.dirname(file), { recursive: true });\n // Create the file private before SQLite opens it. SQLite gives the -wal and -shm files the\n // main file's mode, so the whole set stays 0600; the chmods cover files left by an earlier open.\n try {\n closeSync(openSync(file, \"a\", 0o600));\n for (const f of [file, `${file}-wal`, `${file}-shm`]) {\n try { chmodSync(f, 0o600); } catch { /* the sidecar may not exist yet */ }\n }\n } catch { /* best effort: not every filesystem cares */ }\n }\n // No busy handler until WAL is on: switching journal mode can answer\n // SQLITE_BUSY at once while another process is doing the same on a new\n // file, so that is retried here. The database is kept only once every\n // pragma has gone through; a failed open is tried afresh next time.\n const opened = new Database(file, { timeout: 0 });\n try {\n retryBusy(() => opened.pragma(\"journal_mode = WAL\"));\n opened.pragma(\"busy_timeout = 5000\");\n opened.pragma(\"synchronous = NORMAL\");\n } catch (e) {\n opened.close();\n throw e;\n }\n db = opened;\n return db;\n };\n\n // Each statement is prepared once per open database.\n const stmt = (text: string): Database.Statement => {\n let s = cache.get(text);\n if (!s) {\n s = open().prepare(text);\n cache.set(text, s);\n }\n return s;\n };\n\n const forget = (name: string) => {\n stmt(sql.deleteRuns).run(name);\n stmt(sql.deleteState).run(name);\n stmt(sql.deleteJob).run(name);\n };\n\n return {\n async init() {\n open().exec(schema(\"sqlite\", p));\n },\n async upsertJob(definition: StoredJobDefinition, now: number) {\n stmt(sql.upsertJob).run(...params.upsertJob(definition, now));\n },\n async getJob(name) {\n const r = stmt(sql.getJob).get(name) as JobRow | undefined;\n return r ? rowToJob(r) : null;\n },\n async listJobs(): Promise<StoredJob[]> {\n return (stmt(sql.listJobs).all() as JobRow[]).map(rowToJob);\n },\n async deleteJob(name) {\n open().transaction(forget)(name);\n },\n async insertRun(run) {\n stmt(sql.insertRun).run(...params.insertRun(run));\n },\n async updateRun(run) {\n stmt(sql.updateRun).run(...params.updateRun(run));\n },\n async updateRunIf(run, fromStatuses) {\n if (fromStatuses.length === 0) return false;\n return stmt(updateRunIfSql(\"sqlite\", p, fromStatuses.length)).run(...params.updateRunIf(run, fromStatuses)).changes > 0;\n },\n async getRun(id) {\n const r = stmt(sql.getRun).get(id) as RunRow | undefined;\n return r ? rowToRun(r) : null;\n },\n async listRuns(job, limit) {\n return (stmt(sql.listRuns).all(job, limit) as RunRow[]).map(rowToRun);\n },\n async lastRun(job) {\n const r = stmt(sql.listRuns).get(job, 1) as RunRow | undefined;\n return r ? rowToRun(r) : null;\n },\n async runningRuns() {\n return (stmt(sql.runningRuns).all() as RunRow[]).map(rowToRun);\n },\n async getState(job) {\n const r = stmt(sql.getState).get(job) as StateRow | undefined;\n return r ? rowToState(r) : null;\n },\n async setState(state) {\n stmt(sql.setState).run(...params.setState(state));\n },\n async compareAndSetState(state, expectedVersion) {\n const result = expectedVersion === 0\n ? stmt(sql.casInsert).run(...params.casInsert(state))\n : stmt(sql.casUpdate).run(...params.casUpdate(state, expectedVersion));\n return result.changes > 0;\n },\n async prune(before) {\n return stmt(sql.prune).run(before).changes;\n },\n async close() {\n cache.clear();\n if (db && !options.database) {\n db.close();\n db = null;\n }\n },\n };\n}\n"]}
|
package/dist/sqlite.d.cts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import Database from 'better-sqlite3';
|
|
2
|
-
import { S as Store } from './types-
|
|
2
|
+
import { S as Store } from './types-D6t0Z846.cjs';
|
|
3
3
|
|
|
4
4
|
interface SqliteOptions {
|
|
5
5
|
/** File path. The directory is created if missing. ":memory:" works too. */
|
|
6
6
|
path?: string;
|
|
7
7
|
/** Bring your own open better-sqlite3 database instead. */
|
|
8
8
|
database?: Database.Database;
|
|
9
|
-
/** Table name prefix: lowercase letters, digits and underscores. Default "cronwatch_". */
|
|
9
|
+
/** Table name prefix: lowercase letters, digits, and underscores. Default "cronwatch_". */
|
|
10
10
|
prefix?: string;
|
|
11
11
|
}
|
|
12
12
|
/**
|
package/dist/sqlite.d.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import Database from 'better-sqlite3';
|
|
2
|
-
import { S as Store } from './types-
|
|
2
|
+
import { S as Store } from './types-D6t0Z846.js';
|
|
3
3
|
|
|
4
4
|
interface SqliteOptions {
|
|
5
5
|
/** File path. The directory is created if missing. ":memory:" works too. */
|
|
6
6
|
path?: string;
|
|
7
7
|
/** Bring your own open better-sqlite3 database instead. */
|
|
8
8
|
database?: Database.Database;
|
|
9
|
-
/** Table name prefix: lowercase letters, digits and underscores. Default "cronwatch_". */
|
|
9
|
+
/** Table name prefix: lowercase letters, digits, and underscores. Default "cronwatch_". */
|
|
10
10
|
prefix?: string;
|
|
11
11
|
}
|
|
12
12
|
/**
|
package/dist/sqlite.js
CHANGED
|
@@ -37,7 +37,7 @@ var MAX_PREFIX = 63 - "runs_job_started".length;
|
|
|
37
37
|
function tablePrefix(prefix = DEFAULT_PREFIX) {
|
|
38
38
|
if (!/^[a-z_][a-z0-9_]*$/.test(prefix) || prefix.length > MAX_PREFIX) {
|
|
39
39
|
throw new Error(
|
|
40
|
-
`cronwatch: invalid table prefix ${JSON.stringify(prefix)}. Use lowercase letters, digits and underscores, not starting with a digit, at most ${MAX_PREFIX} characters.`
|
|
40
|
+
`cronwatch: invalid table prefix ${JSON.stringify(prefix)}. Use lowercase letters, digits, and underscores, not starting with a digit, at most ${MAX_PREFIX} characters.`
|
|
41
41
|
);
|
|
42
42
|
}
|
|
43
43
|
return prefix;
|