@marktiderman/genesis-notifications 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +251 -2
  3. package/dist/blocks.d.ts +10 -0
  4. package/dist/blocks.js +35 -0
  5. package/dist/blocks.js.map +1 -0
  6. package/dist/chunk-2BG336II.js +124 -0
  7. package/dist/chunk-2BG336II.js.map +1 -0
  8. package/dist/chunk-7PHA3WVG.js +51 -0
  9. package/dist/chunk-7PHA3WVG.js.map +1 -0
  10. package/dist/chunk-EWZB35V6.js +113 -0
  11. package/dist/chunk-EWZB35V6.js.map +1 -0
  12. package/dist/chunk-UXNWI7OS.js +118 -0
  13. package/dist/chunk-UXNWI7OS.js.map +1 -0
  14. package/dist/email-render-CID_pXDy.d.ts +53 -0
  15. package/dist/email.d.ts +46 -0
  16. package/dist/email.js +64 -0
  17. package/dist/email.js.map +1 -0
  18. package/dist/index.d.ts +26 -0
  19. package/dist/index.js +151 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/native.d.ts +92 -0
  22. package/dist/native.js +170 -0
  23. package/dist/native.js.map +1 -0
  24. package/dist/react.d.ts +98 -0
  25. package/dist/react.js +4 -0
  26. package/dist/react.js.map +1 -0
  27. package/dist/runner.d.ts +174 -0
  28. package/dist/runner.js +3 -0
  29. package/dist/runner.js.map +1 -0
  30. package/dist/server.d.ts +658 -0
  31. package/dist/server.js +995 -0
  32. package/dist/server.js.map +1 -0
  33. package/dist/supabase.d.ts +118 -0
  34. package/dist/supabase.js +3 -0
  35. package/dist/supabase.js.map +1 -0
  36. package/dist/types-BilguCPW.d.ts +332 -0
  37. package/dist/web.d.ts +79 -0
  38. package/dist/web.js +233 -0
  39. package/dist/web.js.map +1 -0
  40. package/functions/genesis-notifications/index.ts +60 -0
  41. package/functions/genesis-notifications/notifications.ts +18 -0
  42. package/migrations/0001_genesis_notifications.sql +2057 -0
  43. package/migrations/tests/0001_genesis_notifications.test.sql +473 -0
  44. package/package.json +191 -3
  45. package/src/blocks.ts +42 -0
  46. package/src/core/index.ts +43 -0
  47. package/src/core/registry.ts +354 -0
  48. package/src/core/types.ts +90 -0
  49. package/src/core/validate.ts +73 -0
  50. package/src/email/index.tsx +142 -0
  51. package/src/index.ts +6 -0
  52. package/src/native/index.tsx +283 -0
  53. package/src/react/index.tsx +226 -0
  54. package/src/runner/index.ts +299 -0
  55. package/src/server/crypto.ts +44 -0
  56. package/src/server/dns.ts +106 -0
  57. package/src/server/email-render.ts +90 -0
  58. package/src/server/errors.ts +61 -0
  59. package/src/server/expo.ts +133 -0
  60. package/src/server/handler.ts +127 -0
  61. package/src/server/index.ts +57 -0
  62. package/src/server/notify.ts +100 -0
  63. package/src/server/rate.ts +47 -0
  64. package/src/server/resend.ts +143 -0
  65. package/src/server/runner.ts +524 -0
  66. package/src/server/secret-links.ts +53 -0
  67. package/src/server/store.ts +150 -0
  68. package/src/server/unsubscribe.ts +78 -0
  69. package/src/server/webhook.ts +55 -0
  70. package/src/supabase/index.ts +210 -0
  71. package/src/web/index.tsx +428 -0
@@ -0,0 +1,113 @@
1
+ // src/supabase/index.ts
2
+ var NOTIFICATIONS_PACKAGE = "@marktiderman/genesis-notifications";
3
+ var NOTIFICATIONS_MIGRATIONS = [
4
+ { package: NOTIFICATIONS_PACKAGE, file: "migrations/0001_genesis_notifications.sql", test: "migrations/tests/0001_genesis_notifications.test.sql" }
5
+ ];
6
+ var NotificationsClientError = class extends Error {
7
+ constructor(operation, code) {
8
+ super(`${operation} failed${code ? ` (${code})` : ""}`);
9
+ this.operation = operation;
10
+ this.code = code;
11
+ this.name = "NotificationsClientError";
12
+ }
13
+ operation;
14
+ code;
15
+ };
16
+ function choicesOf(row) {
17
+ const object = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : {};
18
+ const out = {};
19
+ for (const [category, chosen] of Object.entries(object(row?.channels))) {
20
+ const cell = {};
21
+ for (const [ch, on] of Object.entries(object(chosen))) if (typeof on === "boolean" && (ch === "in_app" || ch === "push" || ch === "email")) cell[ch] = on;
22
+ out[category] = cell;
23
+ }
24
+ for (const [category, on] of Object.entries(object(row?.categories))) {
25
+ if (on !== false) continue;
26
+ const cell = { ...out[category] };
27
+ cell.push ??= false;
28
+ cell.email ??= false;
29
+ out[category] = cell;
30
+ }
31
+ return out;
32
+ }
33
+ var FEED_COLUMNS = "id, type, category, tenant_id, data, thread_key, created_at, seen_at, read_at, archived_at";
34
+ var LOG_COLUMNS = "id, notification_id, tenant_id, type, channel, status, attempts, error, skip_reason, recipient_masked, created_at, sent_at";
35
+ function notificationsClient(client) {
36
+ const must = (operation, result) => {
37
+ if (result.error) throw new NotificationsClientError(operation, result.error.code ?? null);
38
+ return result;
39
+ };
40
+ const table = (name) => client.from(name);
41
+ return {
42
+ async feed({ filter = "all", limit = 50, before } = {}) {
43
+ let q = table("notifications").select(FEED_COLUMNS).eq("in_feed", true);
44
+ q = filter === "archived" ? q.not("archived_at", "is", null) : q.is("archived_at", null);
45
+ if (filter === "unread") q = q.is("read_at", null);
46
+ if (before) q = q.lt("created_at", before);
47
+ const { data } = must("notifications", await q.order("created_at", { ascending: false }).limit(Math.min(Math.max(1, limit), 200)));
48
+ return data ?? [];
49
+ },
50
+ async unreadCount() {
51
+ const { count } = must(
52
+ "notifications",
53
+ await table("notifications").select("id", { count: "exact", head: true }).eq("in_feed", true).is("read_at", null).is("archived_at", null)
54
+ );
55
+ return count ?? 0;
56
+ },
57
+ async mark(ids, state) {
58
+ if (!ids.length) return 0;
59
+ const { data } = must("genesis_notifications_mark", await client.rpc("genesis_notifications_mark", { p_ids: ids, p_state: state }));
60
+ return Number(data ?? 0);
61
+ },
62
+ async markAll(state) {
63
+ const { data } = must("genesis_notifications_mark_all", await client.rpc("genesis_notifications_mark_all", { p_state: state }));
64
+ return Number(data ?? 0);
65
+ },
66
+ async choices(userId) {
67
+ const { data } = must("notification_preferences", await table("notification_preferences").select("channels, categories").eq("user_id", userId).maybeSingle());
68
+ return choicesOf(data);
69
+ },
70
+ async setChoice(category, channel, on) {
71
+ const { data } = must("genesis_notifications_set_preference", await client.rpc("genesis_notifications_set_preference", { p_category: category, p_channel: channel, p_on: on }));
72
+ return data ?? {};
73
+ },
74
+ subscribe(userId, onChange) {
75
+ if (typeof client.channel !== "function") {
76
+ console.warn("[genesis-notifications] this Supabase client has no realtime (client.channel); the feed won't update live.");
77
+ return () => {
78
+ };
79
+ }
80
+ const channel = client.channel(`user:${userId}`, { config: { private: true } });
81
+ channel.on("broadcast", { event: "notification" }, () => onChange()).subscribe();
82
+ return () => {
83
+ client.removeChannel(channel);
84
+ };
85
+ },
86
+ async deliveryLog({ tenantId, status, limit = 100 } = {}) {
87
+ let q = table("notification_deliveries").select(LOG_COLUMNS);
88
+ if (tenantId !== void 0) q = tenantId === null ? q.is("tenant_id", null) : q.eq("tenant_id", tenantId);
89
+ if (status) q = q.eq("status", status);
90
+ const { data } = must("notification_deliveries", await q.order("created_at", { ascending: false }).limit(Math.min(Math.max(1, limit), 500)));
91
+ return data ?? [];
92
+ },
93
+ async suppressions({ limit = 200 } = {}) {
94
+ const { data } = must(
95
+ "notification_suppressions",
96
+ await table("notification_suppressions").select("email_hash, category, reason, email_masked, created_at").order("created_at", { ascending: false }).limit(Math.min(Math.max(1, limit), 1e3))
97
+ );
98
+ return data ?? [];
99
+ },
100
+ async removeSuppression(emailHash, category = "*") {
101
+ const { data } = must("genesis_notifications_remove_suppression", await client.rpc("genesis_notifications_remove_suppression", { p_email_hash: emailHash, p_category: category }));
102
+ return Boolean(data);
103
+ },
104
+ async sendTest(tenantId = null) {
105
+ const { data } = must("genesis_notifications_send_test", await client.rpc("genesis_notifications_send_test", { p_tenant: tenantId }));
106
+ return data ?? null;
107
+ }
108
+ };
109
+ }
110
+
111
+ export { FEED_COLUMNS, NOTIFICATIONS_MIGRATIONS, NOTIFICATIONS_PACKAGE, NotificationsClientError, choicesOf, notificationsClient };
112
+ //# sourceMappingURL=chunk-EWZB35V6.js.map
113
+ //# sourceMappingURL=chunk-EWZB35V6.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/supabase/index.ts"],"names":[],"mappings":"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","file":"chunk-EWZB35V6.js","sourcesContent":["/**\n * `@marktiderman/genesis-notifications/supabase`: the migration this package\n * ships (`migrations/0001_genesis_notifications.sql`, with its pgTAP test),\n * and `notificationsClient(supabase)`, the browser and phone side over it:\n * the person's feed, unread count, marks and preferences under row level\n * security, the realtime nudge on `user:<id>`, and the admin views.\n *\n * @packageDocumentation\n */\nimport type { Channel, ChannelChoices, NotificationRow } from \"../core/index\";\n\nexport const NOTIFICATIONS_PACKAGE = \"@marktiderman/genesis-notifications\";\n\n/** The migrations to copy, in order, with their tests. */\nexport const NOTIFICATIONS_MIGRATIONS = [\n { package: NOTIFICATIONS_PACKAGE, file: \"migrations/0001_genesis_notifications.sql\", test: \"migrations/tests/0001_genesis_notifications.test.sql\" },\n] as const;\n\ntype Result = { data: unknown; error: { message?: string; code?: string | null } | null; count?: number | null };\n\ninterface Query extends PromiseLike<Result> {\n select(columns: string, options?: { count?: \"exact\"; head?: boolean }): Query;\n eq(column: string, value: unknown): Query;\n is(column: string, value: null): Query;\n not(column: string, op: string, value: unknown): Query;\n lt(column: string, value: unknown): Query;\n order(column: string, options?: { ascending?: boolean }): Query;\n limit(n: number): Query;\n maybeSingle(): PromiseLike<Result>;\n}\n\ninterface Channel_ {\n on(type: \"broadcast\", filter: { event: string }, callback: (payload: unknown) => void): Channel_;\n subscribe(callback?: (status: string) => void): Channel_;\n}\n\n/** The parts of a Supabase client used here; a `SupabaseClient` fits, typed by your Database or not. */\nexport interface NotificationsSupabaseLike {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n rpc: (fn: any, args?: any) => PromiseLike<Result>;\n from(table: string): unknown;\n channel(name: string, options?: { config?: { private?: boolean } }): unknown;\n removeChannel(channel: unknown): unknown;\n realtime?: { setAuth?: (token?: string | null) => unknown };\n}\n\n/** A database refusal, with its SQLSTATE. */\nexport class NotificationsClientError extends Error {\n constructor(\n readonly operation: string,\n readonly code: string | null,\n ) {\n super(`${operation} failed${code ? ` (${code})` : \"\"}`);\n this.name = \"NotificationsClientError\";\n }\n}\n\nexport type FeedFilter = \"all\" | \"unread\" | \"archived\";\n\n/** One row of the admin's delivery log: no content, a masked address. */\nexport interface DeliveryLogRow {\n id: string;\n notification_id: string;\n tenant_id: string | null;\n type: string;\n channel: \"push\" | \"email\";\n status: string;\n attempts: number;\n error: string | null;\n skip_reason: string | null;\n recipient_masked: string | null;\n created_at: string;\n sent_at: string | null;\n}\n\nexport interface SuppressionRow {\n email_hash: string;\n category: string;\n reason: \"bounce\" | \"complaint\" | \"unsubscribe\" | \"manual\";\n email_masked: string | null;\n created_at: string;\n}\n\nexport interface NotificationsClient {\n feed(options?: { filter?: FeedFilter; limit?: number; before?: string }): Promise<NotificationRow[]>;\n unreadCount(): Promise<number>;\n mark(ids: readonly string[], state: \"seen\" | \"read\" | \"unread\" | \"archived\" | \"unarchived\"): Promise<number>;\n markAll(state: \"seen\" | \"read\"): Promise<number>;\n /** The signed-in person's choices (category → channel → on); `{}` before they chose. */\n choices(userId: string): Promise<ChannelChoices>;\n /** One category and channel; `null` returns it to the default. Resolves the person's choices after. */\n setChoice(category: string, channel: Channel, on: boolean | null): Promise<ChannelChoices>;\n /** Calls `onChange` whenever the person's feed changes (a broadcast on `user:<id>`). Returns the stop. */\n subscribe(userId: string, onChange: () => void): () => void;\n /** Admin: the delivery log (row level security shows an admin their tenants'). */\n deliveryLog(options?: { tenantId?: string | null; status?: string; limit?: number }): Promise<DeliveryLogRow[]>;\n /** Admin: the suppression list (platform admins). */\n suppressions(options?: { limit?: number }): Promise<SuppressionRow[]>;\n removeSuppression(emailHash: string, category?: string): Promise<boolean>;\n /** Admin: a test notification to yourself, on every channel you have. */\n sendTest(tenantId?: string | null): Promise<string | null>;\n}\n\n/**\n * A preferences row as category → channel → on: `channels`, with the push\n * and email of a category switched off as a whole (migration 0005's\n * `categories`) filled in where no channel was chosen, as the server reads it.\n */\nexport function choicesOf(row: { channels?: unknown; categories?: unknown } | null): ChannelChoices {\n const object = (v: unknown): Record<string, unknown> => (v && typeof v === \"object\" && !Array.isArray(v) ? (v as Record<string, unknown>) : {});\n const out: ChannelChoices = {};\n for (const [category, chosen] of Object.entries(object(row?.channels))) {\n const cell: Partial<Record<Channel, boolean>> = {};\n for (const [ch, on] of Object.entries(object(chosen))) if (typeof on === \"boolean\" && (ch === \"in_app\" || ch === \"push\" || ch === \"email\")) cell[ch] = on;\n out[category] = cell;\n }\n for (const [category, on] of Object.entries(object(row?.categories))) {\n if (on !== false) continue;\n const cell = { ...out[category] };\n cell.push ??= false;\n cell.email ??= false;\n out[category] = cell;\n }\n return out;\n}\n\nexport const FEED_COLUMNS = \"id, type, category, tenant_id, data, thread_key, created_at, seen_at, read_at, archived_at\";\nconst LOG_COLUMNS = \"id, notification_id, tenant_id, type, channel, status, attempts, error, skip_reason, recipient_masked, created_at, sent_at\";\n\n/**\n * The browser and phone side of the notifier, under the signed-in person's\n * row level security.\n *\n * @stability Beta\n */\nexport function notificationsClient(client: NotificationsSupabaseLike): NotificationsClient {\n const must = (operation: string, result: Result) => {\n if (result.error) throw new NotificationsClientError(operation, result.error.code ?? null);\n return result;\n };\n const table = (name: string) => client.from(name) as Query;\n return {\n async feed({ filter = \"all\", limit = 50, before } = {}) {\n let q = table(\"notifications\").select(FEED_COLUMNS).eq(\"in_feed\", true);\n q = filter === \"archived\" ? q.not(\"archived_at\", \"is\", null) : q.is(\"archived_at\", null);\n if (filter === \"unread\") q = q.is(\"read_at\", null);\n if (before) q = q.lt(\"created_at\", before);\n const { data } = must(\"notifications\", await q.order(\"created_at\", { ascending: false }).limit(Math.min(Math.max(1, limit), 200)));\n return (data as NotificationRow[] | null) ?? [];\n },\n async unreadCount() {\n const { count } = must(\n \"notifications\",\n await table(\"notifications\").select(\"id\", { count: \"exact\", head: true }).eq(\"in_feed\", true).is(\"read_at\", null).is(\"archived_at\", null),\n );\n return count ?? 0;\n },\n async mark(ids, state) {\n if (!ids.length) return 0;\n const { data } = must(\"genesis_notifications_mark\", await client.rpc(\"genesis_notifications_mark\", { p_ids: ids, p_state: state }));\n return Number(data ?? 0);\n },\n async markAll(state) {\n const { data } = must(\"genesis_notifications_mark_all\", await client.rpc(\"genesis_notifications_mark_all\", { p_state: state }));\n return Number(data ?? 0);\n },\n async choices(userId) {\n const { data } = must(\"notification_preferences\", await table(\"notification_preferences\").select(\"channels, categories\").eq(\"user_id\", userId).maybeSingle());\n return choicesOf(data as { channels?: unknown; categories?: unknown } | null);\n },\n async setChoice(category, channel, on) {\n const { data } = must(\"genesis_notifications_set_preference\", await client.rpc(\"genesis_notifications_set_preference\", { p_category: category, p_channel: channel, p_on: on }));\n return (data as ChannelChoices | null) ?? {};\n },\n subscribe(userId, onChange) {\n if (typeof client.channel !== \"function\") {\n // Loud, not fatal: without realtime the feed still refetches on focus and pull to refresh.\n console.warn(\"[genesis-notifications] this Supabase client has no realtime (client.channel); the feed won't update live.\");\n return () => {};\n }\n const channel = client.channel(`user:${userId}`, { config: { private: true } }) as Channel_;\n channel.on(\"broadcast\", { event: \"notification\" }, () => onChange()).subscribe();\n return () => {\n client.removeChannel(channel);\n };\n },\n async deliveryLog({ tenantId, status, limit = 100 } = {}) {\n let q = table(\"notification_deliveries\").select(LOG_COLUMNS);\n if (tenantId !== undefined) q = tenantId === null ? q.is(\"tenant_id\", null) : q.eq(\"tenant_id\", tenantId);\n if (status) q = q.eq(\"status\", status);\n const { data } = must(\"notification_deliveries\", await q.order(\"created_at\", { ascending: false }).limit(Math.min(Math.max(1, limit), 500)));\n return (data as DeliveryLogRow[] | null) ?? [];\n },\n async suppressions({ limit = 200 } = {}) {\n const { data } = must(\n \"notification_suppressions\",\n await table(\"notification_suppressions\").select(\"email_hash, category, reason, email_masked, created_at\").order(\"created_at\", { ascending: false }).limit(Math.min(Math.max(1, limit), 1000)),\n );\n return (data as SuppressionRow[] | null) ?? [];\n },\n async removeSuppression(emailHash, category = \"*\") {\n const { data } = must(\"genesis_notifications_remove_suppression\", await client.rpc(\"genesis_notifications_remove_suppression\", { p_email_hash: emailHash, p_category: category }));\n return Boolean(data);\n },\n async sendTest(tenantId = null) {\n const { data } = must(\"genesis_notifications_send_test\", await client.rpc(\"genesis_notifications_send_test\", { p_tenant: tenantId }));\n return (data as string | null) ?? null;\n },\n };\n}\n"]}
@@ -0,0 +1,118 @@
1
+ // src/runner/index.ts
2
+ var DEFAULT_MAX_ATTEMPTS = 8;
3
+ var DEFAULT_MAX_DEFERRALS = 24;
4
+ var DEFAULT_BATCH_SIZE = 25;
5
+ function defaultBackoff(attempt, random) {
6
+ const nominal = Math.min(24 * 3600, 300 * 4 ** Math.max(0, attempt - 1));
7
+ const r = typeof random === "function" ? random() : Math.random();
8
+ return Math.round(nominal * (0.8 + 0.4 * Math.min(1, Math.max(0, r))));
9
+ }
10
+ function jobOutcome(result, attempt, options = {}) {
11
+ if (result.ok) {
12
+ if (result.notSent) return { status: "skipped", reason: result.notSent.slice(0, 500) };
13
+ return result.alreadyThere ? { status: "sent", alreadyThere: true } : { status: "sent" };
14
+ }
15
+ const http = result.status !== void 0 ? { httpStatus: result.status } : {};
16
+ if (result.disable) return { status: "failed", error: result.error, ...http, disable: true };
17
+ if (!result.retryable) return { status: "failed", error: result.error, ...http };
18
+ const now = options.now ?? Date.now();
19
+ if (result.retryAfterSeconds !== void 0 && (options.deferrals ?? 0) < (options.maxDeferrals ?? DEFAULT_MAX_DEFERRALS)) {
20
+ const seconds2 = Math.max(1, Math.ceil(result.retryAfterSeconds));
21
+ return { status: "retry", error: result.error, retryAt: new Date(now + seconds2 * 1e3), retryAfterSeconds: seconds2, counted: false, ...http };
22
+ }
23
+ const max = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
24
+ if (attempt >= max) return { status: "failed", error: `${result.error} (gave up after ${attempt} attempts)`.slice(0, 500), ...http };
25
+ const seconds = Math.max(1, Math.ceil((options.backoff ?? defaultBackoff)(attempt)));
26
+ return { status: "retry", error: result.error, retryAt: new Date(now + seconds * 1e3), retryAfterSeconds: seconds, counted: true, ...http };
27
+ }
28
+ function failureOf(error) {
29
+ const e = error;
30
+ if (e && typeof e === "object" && typeof e.retryable === "boolean" && typeof e.message === "string") {
31
+ return {
32
+ ok: false,
33
+ retryable: e.disable === true ? false : e.retryable,
34
+ error: e.message.slice(0, 500),
35
+ ...typeof e.retryAfterSeconds === "number" ? { retryAfterSeconds: e.retryAfterSeconds } : {},
36
+ ...typeof e.status === "number" ? { status: e.status } : {},
37
+ ...e.disable === true ? { disable: true } : {}
38
+ };
39
+ }
40
+ const timeout = e && typeof e === "object" && (e.name === "TimeoutError" || e.name === "AbortError");
41
+ return { ok: false, retryable: true, error: timeout ? "Timed out" : "Unexpected error" };
42
+ }
43
+ function createRunner(options) {
44
+ const log = (message) => {
45
+ try {
46
+ options.log?.(message);
47
+ } catch {
48
+ }
49
+ };
50
+ const label = (job) => options.label?.(job) ?? `Job ${job.id}`;
51
+ const recordLabel = (job) => options.recordLabel?.(job) ?? `job ${job.id}`;
52
+ const now = () => options.now?.() ?? Date.now();
53
+ return {
54
+ async run(runOptions = {}) {
55
+ const report = { claimed: 0, sent: 0, filtered: 0, skipped: 0, retrying: 0, deferred: 0, failed: 0, stale: 0, unrecorded: 0 };
56
+ const limit = runOptions.limit ?? options.batchSize ?? DEFAULT_BATCH_SIZE;
57
+ const claimed = await options.claim({ limit, filter: runOptions.filter });
58
+ report.claimed = claimed.length;
59
+ const groups = /* @__PURE__ */ new Map();
60
+ claimed.forEach((job, i) => {
61
+ const key = options.group ? options.group(job) : `\0${i}`;
62
+ const list = groups.get(key) ?? [];
63
+ list.push(job);
64
+ groups.set(key, list);
65
+ });
66
+ await Promise.all(
67
+ [...groups.values()].map(async (group) => {
68
+ let results;
69
+ try {
70
+ results = await options.send(group, { signal: runOptions.signal });
71
+ if (results.length !== group.length) throw new Error("send answered the wrong number of results");
72
+ } catch (error) {
73
+ const failure = failureOf(error);
74
+ results = group.map(() => failure);
75
+ }
76
+ const at = now();
77
+ await Promise.all(
78
+ group.map(async (job, i) => {
79
+ const result = results[i];
80
+ const outcome = result === "filtered" ? { status: "filtered" } : jobOutcome(result, job.attempt, {
81
+ now: at,
82
+ maxAttempts: options.maxAttempts,
83
+ backoff: options.backoff,
84
+ deferrals: job.deferrals,
85
+ maxDeferrals: options.maxDeferrals
86
+ });
87
+ if (outcome.status === "failed" || outcome.status === "retry") {
88
+ log(`${label(job)} ${outcome.status === "failed" ? "failed" : "will be retried"}: ${outcome.error}`);
89
+ } else if (outcome.status === "skipped") {
90
+ log(`${label(job)} skipped: ${outcome.reason}`);
91
+ }
92
+ let recorded;
93
+ try {
94
+ recorded = await options.record(job, outcome);
95
+ } catch {
96
+ report.unrecorded++;
97
+ log(`Recording ${recordLabel(job)} failed`);
98
+ return;
99
+ }
100
+ if (recorded === false) report.stale++;
101
+ else if (outcome.status === "sent") report.sent++;
102
+ else if (outcome.status === "filtered") report.filtered++;
103
+ else if (outcome.status === "skipped") report.skipped++;
104
+ else if (outcome.status === "failed") report.failed++;
105
+ else if (outcome.counted) report.retrying++;
106
+ else report.deferred++;
107
+ })
108
+ );
109
+ })
110
+ );
111
+ return report;
112
+ }
113
+ };
114
+ }
115
+
116
+ export { DEFAULT_BATCH_SIZE, DEFAULT_MAX_ATTEMPTS, DEFAULT_MAX_DEFERRALS, createRunner, defaultBackoff, failureOf, jobOutcome };
117
+ //# sourceMappingURL=chunk-UXNWI7OS.js.map
118
+ //# sourceMappingURL=chunk-UXNWI7OS.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/runner/index.ts"],"names":["seconds"],"mappings":";AAwJO,IAAM,oBAAA,GAAuB;AAC7B,IAAM,qBAAA,GAAwB;AAC9B,IAAM,kBAAA,GAAqB;AAO3B,SAAS,cAAA,CAAe,SAAiB,MAAA,EAA+B;AAC7E,EAAA,MAAM,OAAA,GAAU,IAAA,CAAK,GAAA,CAAI,EAAA,GAAK,IAAA,EAAM,GAAA,GAAM,CAAA,IAAK,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,OAAA,GAAU,CAAC,CAAC,CAAA;AAEvE,EAAA,MAAM,IAAI,OAAO,MAAA,KAAW,aAAa,MAAA,EAAO,GAAI,KAAK,MAAA,EAAO;AAChE,EAAA,OAAO,IAAA,CAAK,KAAA,CAAM,OAAA,IAAW,GAAA,GAAM,GAAA,GAAM,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,CAAC,CAAC,CAAA,CAAE,CAAA;AACvE;AAGO,SAAS,UAAA,CACd,MAAA,EACA,OAAA,EACA,OAAA,GAMI,EAAC,EACO;AACZ,EAAA,IAAI,OAAO,EAAA,EAAI;AACb,IAAA,IAAI,MAAA,CAAO,OAAA,EAAS,OAAO,EAAE,MAAA,EAAQ,SAAA,EAAW,MAAA,EAAQ,MAAA,CAAO,OAAA,CAAQ,KAAA,CAAM,CAAA,EAAG,GAAG,CAAA,EAAE;AACrF,IAAA,OAAO,MAAA,CAAO,YAAA,GAAe,EAAE,MAAA,EAAQ,MAAA,EAAQ,cAAc,IAAA,EAAK,GAAI,EAAE,MAAA,EAAQ,MAAA,EAAO;AAAA,EACzF;AACA,EAAA,MAAM,IAAA,GAAO,OAAO,MAAA,KAAW,MAAA,GAAY,EAAE,UAAA,EAAY,MAAA,CAAO,MAAA,EAAO,GAAI,EAAC;AAC5E,EAAA,IAAI,MAAA,CAAO,OAAA,EAAS,OAAO,EAAE,MAAA,EAAQ,QAAA,EAAU,KAAA,EAAO,MAAA,CAAO,KAAA,EAAO,GAAG,IAAA,EAAM,OAAA,EAAS,IAAA,EAAK;AAC3F,EAAA,IAAI,CAAC,MAAA,CAAO,SAAA,EAAW,OAAO,EAAE,MAAA,EAAQ,QAAA,EAAU,KAAA,EAAO,MAAA,CAAO,KAAA,EAAO,GAAG,IAAA,EAAK;AAC/E,EAAA,MAAM,GAAA,GAAM,OAAA,CAAQ,GAAA,IAAO,IAAA,CAAK,GAAA,EAAI;AACpC,EAAA,IAAI,MAAA,CAAO,sBAAsB,MAAA,IAAA,CAAc,OAAA,CAAQ,aAAa,CAAA,KAAM,OAAA,CAAQ,gBAAgB,qBAAA,CAAA,EAAwB;AACxH,IAAA,MAAMA,QAAAA,GAAU,KAAK,GAAA,CAAI,CAAA,EAAG,KAAK,IAAA,CAAK,MAAA,CAAO,iBAAiB,CAAC,CAAA;AAC/D,IAAA,OAAO,EAAE,MAAA,EAAQ,OAAA,EAAS,OAAO,MAAA,CAAO,KAAA,EAAO,SAAS,IAAI,IAAA,CAAK,GAAA,GAAMA,QAAAA,GAAU,GAAI,CAAA,EAAG,iBAAA,EAAmBA,UAAS,OAAA,EAAS,KAAA,EAAO,GAAG,IAAA,EAAK;AAAA,EAC9I;AACA,EAAA,MAAM,GAAA,GAAM,QAAQ,WAAA,IAAe,oBAAA;AACnC,EAAA,IAAI,WAAW,GAAA,EAAK,OAAO,EAAE,MAAA,EAAQ,QAAA,EAAU,OAAO,CAAA,EAAG,MAAA,CAAO,KAAK,CAAA,gBAAA,EAAmB,OAAO,CAAA,UAAA,CAAA,CAAa,KAAA,CAAM,GAAG,GAAG,CAAA,EAAG,GAAG,IAAA,EAAK;AACnI,EAAA,MAAM,OAAA,GAAU,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,IAAA,CAAK,IAAA,CAAA,CAAM,OAAA,CAAQ,OAAA,IAAW,cAAA,EAAgB,OAAO,CAAC,CAAC,CAAA;AACnF,EAAA,OAAO,EAAE,MAAA,EAAQ,OAAA,EAAS,OAAO,MAAA,CAAO,KAAA,EAAO,SAAS,IAAI,IAAA,CAAK,GAAA,GAAM,OAAA,GAAU,GAAI,CAAA,EAAG,iBAAA,EAAmB,SAAS,OAAA,EAAS,IAAA,EAAM,GAAG,IAAA,EAAK;AAC7I;AAGO,SAAS,UAAU,KAAA,EAA4B;AACpD,EAAA,MAAM,CAAA,GAAI,KAAA;AACV,EAAA,IAAI,CAAA,IAAK,OAAO,CAAA,KAAM,QAAA,IAAY,OAAO,CAAA,CAAE,SAAA,KAAc,SAAA,IAAa,OAAO,CAAA,CAAE,OAAA,KAAY,QAAA,EAAU;AACnG,IAAA,OAAO;AAAA,MACL,EAAA,EAAI,KAAA;AAAA,MACJ,SAAA,EAAW,CAAA,CAAE,OAAA,KAAY,IAAA,GAAO,QAAQ,CAAA,CAAE,SAAA;AAAA,MAC1C,KAAA,EAAO,CAAA,CAAE,OAAA,CAAQ,KAAA,CAAM,GAAG,GAAG,CAAA;AAAA,MAC7B,GAAI,OAAO,CAAA,CAAE,iBAAA,KAAsB,QAAA,GAAW,EAAE,iBAAA,EAAmB,CAAA,CAAE,iBAAA,EAAkB,GAAI,EAAC;AAAA,MAC5F,GAAI,OAAO,CAAA,CAAE,MAAA,KAAW,QAAA,GAAW,EAAE,MAAA,EAAQ,CAAA,CAAE,MAAA,EAAO,GAAI,EAAC;AAAA,MAC3D,GAAI,EAAE,OAAA,KAAY,IAAA,GAAO,EAAE,OAAA,EAAS,IAAA,KAAkB;AAAC,KACzD;AAAA,EACF;AACA,EAAA,MAAM,OAAA,GAAU,KAAK,OAAO,CAAA,KAAM,aAAa,CAAA,CAAE,IAAA,KAAS,cAAA,IAAkB,CAAA,CAAE,IAAA,KAAS,YAAA,CAAA;AACvF,EAAA,OAAO,EAAE,IAAI,KAAA,EAAO,SAAA,EAAW,MAAM,KAAA,EAAO,OAAA,GAAU,cAAc,kBAAA,EAAmB;AACzF;AAOO,SAAS,aAAsD,OAAA,EAAmD;AACvH,EAAA,MAAM,GAAA,GAAM,CAAC,OAAA,KAA0B;AACrC,IAAA,IAAI;AACF,MAAA,OAAA,CAAQ,MAAM,OAAO,CAAA;AAAA,IACvB,CAAA,CAAA,MAAQ;AAAA,IAER;AAAA,EACF,CAAA;AACA,EAAA,MAAM,KAAA,GAAQ,CAAC,GAAA,KAAW,OAAA,CAAQ,QAAQ,GAAG,CAAA,IAAK,CAAA,IAAA,EAAO,GAAA,CAAI,EAAE,CAAA,CAAA;AAC/D,EAAA,MAAM,WAAA,GAAc,CAAC,GAAA,KAAW,OAAA,CAAQ,cAAc,GAAG,CAAA,IAAK,CAAA,IAAA,EAAO,GAAA,CAAI,EAAE,CAAA,CAAA;AAC3E,EAAA,MAAM,MAAM,MAAM,OAAA,CAAQ,GAAA,IAAM,IAAK,KAAK,GAAA,EAAI;AAE9C,EAAA,OAAO;AAAA,IACL,MAAM,GAAA,CAAI,UAAA,GAAiC,EAAC,EAAuB;AACjE,MAAA,MAAM,SAAoB,EAAE,OAAA,EAAS,GAAG,IAAA,EAAM,CAAA,EAAG,UAAU,CAAA,EAAG,OAAA,EAAS,GAAG,QAAA,EAAU,CAAA,EAAG,UAAU,CAAA,EAAG,MAAA,EAAQ,GAAG,KAAA,EAAO,CAAA,EAAG,YAAY,CAAA,EAAE;AACvI,MAAA,MAAM,KAAA,GAAQ,UAAA,CAAW,KAAA,IAAS,OAAA,CAAQ,SAAA,IAAa,kBAAA;AACvD,MAAA,MAAM,OAAA,GAAU,MAAM,OAAA,CAAQ,KAAA,CAAM,EAAE,KAAA,EAAO,MAAA,EAAQ,UAAA,CAAW,MAAA,EAAQ,CAAA;AACxE,MAAA,MAAA,CAAO,UAAU,OAAA,CAAQ,MAAA;AAEzB,MAAA,MAAM,MAAA,uBAAa,GAAA,EAAiB;AACpC,MAAA,OAAA,CAAQ,OAAA,CAAQ,CAAC,GAAA,EAAK,CAAA,KAAM;AAC1B,QAAA,MAAM,GAAA,GAAM,QAAQ,KAAA,GAAQ,OAAA,CAAQ,MAAM,GAAG,CAAA,GAAI,KAAS,CAAC,CAAA,CAAA;AAC3D,QAAA,MAAM,IAAA,GAAO,MAAA,CAAO,GAAA,CAAI,GAAG,KAAK,EAAC;AACjC,QAAA,IAAA,CAAK,KAAK,GAAG,CAAA;AACb,QAAA,MAAA,CAAO,GAAA,CAAI,KAAK,IAAI,CAAA;AAAA,MACtB,CAAC,CAAA;AAED,MAAA,MAAM,OAAA,CAAQ,GAAA;AAAA,QACZ,CAAC,GAAG,MAAA,CAAO,MAAA,EAAQ,CAAA,CAAE,GAAA,CAAI,OAAO,KAAA,KAAU;AACxC,UAAA,IAAI,OAAA;AACJ,UAAA,IAAI;AACF,YAAA,OAAA,GAAU,MAAM,QAAQ,IAAA,CAAK,KAAA,EAAO,EAAE,MAAA,EAAQ,UAAA,CAAW,QAAQ,CAAA;AACjE,YAAA,IAAI,QAAQ,MAAA,KAAW,KAAA,CAAM,QAAQ,MAAM,IAAI,MAAM,2CAA2C,CAAA;AAAA,UAClG,SAAS,KAAA,EAAO;AACd,YAAA,MAAM,OAAA,GAAU,UAAU,KAAK,CAAA;AAC/B,YAAA,OAAA,GAAU,KAAA,CAAM,GAAA,CAAI,MAAM,OAAO,CAAA;AAAA,UACnC;AACA,UAAA,MAAM,KAAK,GAAA,EAAI;AACf,UAAA,MAAM,OAAA,CAAQ,GAAA;AAAA,YACZ,KAAA,CAAM,GAAA,CAAI,OAAO,GAAA,EAAK,CAAA,KAAM;AAC1B,cAAA,MAAM,MAAA,GAAS,QAAQ,CAAC,CAAA;AACxB,cAAA,MAAM,OAAA,GACJ,MAAA,KAAW,UAAA,GACP,EAAE,MAAA,EAAQ,YAAW,GACrB,UAAA,CAAW,MAAA,EAAQ,GAAA,CAAI,OAAA,EAAS;AAAA,gBAC9B,GAAA,EAAK,EAAA;AAAA,gBACL,aAAa,OAAA,CAAQ,WAAA;AAAA,gBACrB,SAAS,OAAA,CAAQ,OAAA;AAAA,gBACjB,WAAW,GAAA,CAAI,SAAA;AAAA,gBACf,cAAc,OAAA,CAAQ;AAAA,eACvB,CAAA;AACP,cAAA,IAAI,OAAA,CAAQ,MAAA,KAAW,QAAA,IAAY,OAAA,CAAQ,WAAW,OAAA,EAAS;AAC7D,gBAAA,GAAA,CAAI,CAAA,EAAG,KAAA,CAAM,GAAG,CAAC,CAAA,CAAA,EAAI,OAAA,CAAQ,MAAA,KAAW,QAAA,GAAW,QAAA,GAAW,iBAAiB,CAAA,EAAA,EAAK,OAAA,CAAQ,KAAK,CAAA,CAAE,CAAA;AAAA,cACrG,CAAA,MAAA,IAAW,OAAA,CAAQ,MAAA,KAAW,SAAA,EAAW;AACvC,gBAAA,GAAA,CAAI,GAAG,KAAA,CAAM,GAAG,CAAC,CAAA,UAAA,EAAa,OAAA,CAAQ,MAAM,CAAA,CAAE,CAAA;AAAA,cAChD;AACA,cAAA,IAAI,QAAA;AACJ,cAAA,IAAI;AACF,gBAAA,QAAA,GAAW,MAAM,OAAA,CAAQ,MAAA,CAAO,GAAA,EAAK,OAAO,CAAA;AAAA,cAC9C,CAAA,CAAA,MAAQ;AACN,gBAAA,MAAA,CAAO,UAAA,EAAA;AACP,gBAAA,GAAA,CAAI,CAAA,UAAA,EAAa,WAAA,CAAY,GAAG,CAAC,CAAA,OAAA,CAAS,CAAA;AAC1C,gBAAA;AAAA,cACF;AACA,cAAA,IAAI,QAAA,KAAa,OAAO,MAAA,CAAO,KAAA,EAAA;AAAA,mBAAA,IACtB,OAAA,CAAQ,MAAA,KAAW,MAAA,EAAQ,MAAA,CAAO,IAAA,EAAA;AAAA,mBAAA,IAClC,OAAA,CAAQ,MAAA,KAAW,UAAA,EAAY,MAAA,CAAO,QAAA,EAAA;AAAA,mBAAA,IACtC,OAAA,CAAQ,MAAA,KAAW,SAAA,EAAW,MAAA,CAAO,OAAA,EAAA;AAAA,mBAAA,IACrC,OAAA,CAAQ,MAAA,KAAW,QAAA,EAAU,MAAA,CAAO,MAAA,EAAA;AAAA,mBAAA,IACpC,OAAA,CAAQ,SAAS,MAAA,CAAO,QAAA,EAAA;AAAA,mBAC5B,MAAA,CAAO,QAAA,EAAA;AAAA,YACd,CAAC;AAAA,WACH;AAAA,QACF,CAAC;AAAA,OACH;AACA,MAAA,OAAO,MAAA;AAAA,IACT;AAAA,GACF;AACF","file":"chunk-UXNWI7OS.js","sourcesContent":["/**\n * `@marktiderman/genesis-notifications/runner`: the one outbox runner. Claim a\n * batch of due jobs, send them (grouped, so a destination that takes many at\n * once gets one request), and record what happened. No dependencies.\n *\n * The store is the caller's (a SQL outbox claimed with SKIP LOCKED, a\n * queue): `claim` hands over leased jobs and `record` takes back outcomes.\n * The runner owns the rest: turning each send result into an outcome with a\n * retry time, jittered backoff, the deferral cap, and never throwing for one\n * job's failure. The notifier's push and email channels run on it, and so do\n * genesis-forms' destinations (Sheets, webhooks), each with its own claim.\n *\n * Outcomes:\n * - `sent`: delivered (or already there, on a redelivery: `alreadyThere`).\n * - `filtered`: the sender decided it doesn't apply; nothing sent, nothing to retry.\n * - `skipped`: deliberately not sent (`notSent`: a reason safe to store); never retried.\n * - `retry`: a retryable failure. With the service's own Retry-After it is\n * deferred without counting an attempt (`counted: false`), until\n * `maxDeferrals`; otherwise it backs off by attempt (`counted: true`), until\n * `maxAttempts`.\n * - `failed`: permanent, or out of attempts. With `disable: true` the receiver\n * asked to be switched off (a 410 Gone): stop sending to it.\n *\n * It never logs content, addresses or secrets: only what `label` gives and\n * the senders' safe error messages.\n *\n * @packageDocumentation\n */\n\n/** One claimed job. Extend it with what your sender needs. */\nexport interface RunnerJob {\n /** Stable across retries (an idempotency key is made from it). */\n id: string;\n /** This attempt's number, from 1. */\n attempt: number;\n /** Deferrals so far (outcomes with `counted: false`); pass it back so `maxDeferrals` holds. Default 0. */\n deferrals?: number | undefined;\n /** An earlier attempt may have gone through (its lease ran out): senders that can look first do. */\n redelivery?: boolean | undefined;\n}\n\n/** What a sender says about one job. */\nexport type JobResult =\n | {\n ok: true;\n /** Deliberately sent nothing; the reason, safe to store. Recorded as `skipped`. */\n notSent?: string | undefined;\n /** It was already there (a redelivery), so nothing was sent this time. */\n alreadyThere?: boolean | undefined;\n }\n | JobFailure;\n\nexport interface JobFailure {\n ok: false;\n /** The same request may succeed later (rate limited, the service down, a timeout). */\n retryable: boolean;\n /** The service said when to come back: retry after this many seconds, without counting an attempt. */\n retryAfterSeconds?: number | undefined;\n status?: number | undefined;\n /** The receiver asked to be switched off. Always with `retryable: false`. */\n disable?: true | undefined;\n /** Safe to store and log: never secrets, content or addresses. */\n error: string;\n}\n\nexport type JobOutcome =\n | { status: \"sent\"; alreadyThere?: boolean }\n | { status: \"filtered\" }\n | { status: \"skipped\"; reason: string }\n | {\n status: \"retry\";\n error: string;\n retryAt: Date;\n retryAfterSeconds: number;\n /** Whether this attempt counts toward `maxAttempts`. False when the service said when to come back. */\n counted: boolean;\n httpStatus?: number;\n }\n | { status: \"failed\"; error: string; httpStatus?: number; disable?: true };\n\nexport interface RunReport {\n claimed: number;\n sent: number;\n filtered: number;\n skipped: number;\n /** Retryable failures, backed off by attempt. */\n retrying: number;\n /** Retryable failures the service said when to retry; no attempt used. */\n deferred: number;\n failed: number;\n /** Recorded after the claim was lost (`record` resolved false). */\n stale: number;\n /** `record` threw. */\n unrecorded: number;\n}\n\nexport interface ClaimRequest<Filter> {\n limit: number;\n filter?: Filter | undefined;\n}\n\nexport interface SendContext {\n signal?: AbortSignal | undefined;\n}\n\nexport interface RunnerOptions<J extends RunnerJob, Filter = undefined> {\n /** Claims up to `limit` due jobs, each leased so no other runner sends it meanwhile. */\n claim(request: ClaimRequest<Filter>): Promise<readonly J[]>;\n /**\n * Sends one group of jobs (those with the same `group` key) and resolves one\n * result per job, in order, or `\"filtered\"`. A throw fails the whole group\n * with a retryable error.\n */\n send(group: readonly J[], context: SendContext): Promise<readonly (JobResult | \"filtered\")[]>;\n /** Jobs with the same key are sent together. Default: each job alone. */\n group?: ((job: J) => string) | undefined;\n /**\n * Records an outcome. Resolve `false` when the claim was no longer held: it\n * is counted as stale. A rejection is counted as unrecorded; the lease runs\n * out and the job comes back on its own.\n */\n record(job: J, outcome: JobOutcome): Promise<void | boolean>;\n now?: (() => number) | undefined;\n /** Safe lines (ids, kinds, short reasons). Default: nothing is logged. */\n log?: ((message: string) => void) | undefined;\n /** How a job is named in a log line, capitalised (\"Delivery d1 (email)\"). Default `Job <id>`. */\n label?: ((job: J) => string) | undefined;\n /** How a job is named when recording it failed (\"delivery d1\"). Default `job <id>`. */\n recordLabel?: ((job: J) => string) | undefined;\n /** Jobs claimed per run. Default 25. */\n batchSize?: number | undefined;\n /** Counted attempts before a retryable failure is given up. Default 8. */\n maxAttempts?: number | undefined;\n /** Deferrals allowed before a further one counts as an attempt. Default 24. */\n maxDeferrals?: number | undefined;\n /** Seconds to wait after a counted failed attempt. Default `defaultBackoff`. */\n backoff?: ((attempt: number) => number) | undefined;\n}\n\nexport interface RunOptions<Filter> {\n filter?: Filter | undefined;\n /** Overrides `batchSize` for this run. */\n limit?: number | undefined;\n /** Aborts the sends in flight. */\n signal?: AbortSignal | undefined;\n}\n\nexport interface Runner<Filter = undefined> {\n /** One claim, its sends and their records. Never rejects for a job's failure; a failing `claim` rejects. */\n run(options?: RunOptions<Filter>): Promise<RunReport>;\n}\n\nexport const DEFAULT_MAX_ATTEMPTS = 8;\nexport const DEFAULT_MAX_DEFERRALS = 24;\nexport const DEFAULT_BATCH_SIZE = 25;\n\n/**\n * 5 minutes, quadrupling each attempt, at most 24 hours: 5m, 20m, 80m, 5h20m,\n * 21h20m, then 24h; about 3 days across 8 attempts. Each wait is jittered by\n * up to ±20%, so jobs that failed together don't all come back at once.\n */\nexport function defaultBackoff(attempt: number, random?: () => number): number {\n const nominal = Math.min(24 * 3600, 300 * 4 ** Math.max(0, attempt - 1));\n // A function only: `[1, 2].map(defaultBackoff)` passes an index here.\n const r = typeof random === \"function\" ? random() : Math.random();\n return Math.round(nominal * (0.8 + 0.4 * Math.min(1, Math.max(0, r))));\n}\n\n/** Turns a send result into an outcome for a job on its `attempt`. */\nexport function jobOutcome(\n result: JobResult,\n attempt: number,\n options: {\n now?: number | undefined;\n maxAttempts?: number | undefined;\n backoff?: ((attempt: number) => number) | undefined;\n deferrals?: number | undefined;\n maxDeferrals?: number | undefined;\n } = {},\n): JobOutcome {\n if (result.ok) {\n if (result.notSent) return { status: \"skipped\", reason: result.notSent.slice(0, 500) };\n return result.alreadyThere ? { status: \"sent\", alreadyThere: true } : { status: \"sent\" };\n }\n const http = result.status !== undefined ? { httpStatus: result.status } : {};\n if (result.disable) return { status: \"failed\", error: result.error, ...http, disable: true };\n if (!result.retryable) return { status: \"failed\", error: result.error, ...http };\n const now = options.now ?? Date.now();\n if (result.retryAfterSeconds !== undefined && (options.deferrals ?? 0) < (options.maxDeferrals ?? DEFAULT_MAX_DEFERRALS)) {\n const seconds = Math.max(1, Math.ceil(result.retryAfterSeconds));\n return { status: \"retry\", error: result.error, retryAt: new Date(now + seconds * 1000), retryAfterSeconds: seconds, counted: false, ...http };\n }\n const max = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;\n if (attempt >= max) return { status: \"failed\", error: `${result.error} (gave up after ${attempt} attempts)`.slice(0, 500), ...http };\n const seconds = Math.max(1, Math.ceil((options.backoff ?? defaultBackoff)(attempt)));\n return { status: \"retry\", error: result.error, retryAt: new Date(now + seconds * 1000), retryAfterSeconds: seconds, counted: true, ...http };\n}\n\n/** A thrown error as a retryable failure with a safe message. */\nexport function failureOf(error: unknown): JobFailure {\n const e = error as { name?: unknown; message?: unknown; retryable?: unknown; retryAfterSeconds?: unknown; status?: unknown; disable?: unknown } | null;\n if (e && typeof e === \"object\" && typeof e.retryable === \"boolean\" && typeof e.message === \"string\") {\n return {\n ok: false,\n retryable: e.disable === true ? false : e.retryable,\n error: e.message.slice(0, 500),\n ...(typeof e.retryAfterSeconds === \"number\" ? { retryAfterSeconds: e.retryAfterSeconds } : {}),\n ...(typeof e.status === \"number\" ? { status: e.status } : {}),\n ...(e.disable === true ? { disable: true as const } : {}),\n };\n }\n const timeout = e && typeof e === \"object\" && (e.name === \"TimeoutError\" || e.name === \"AbortError\");\n return { ok: false, retryable: true, error: timeout ? \"Timed out\" : \"Unexpected error\" };\n}\n\n/**\n * The runner.\n *\n * @stability Beta\n */\nexport function createRunner<J extends RunnerJob, Filter = undefined>(options: RunnerOptions<J, Filter>): Runner<Filter> {\n const log = (message: string): void => {\n try {\n options.log?.(message);\n } catch {\n // A logger that throws never stops an outcome being recorded.\n }\n };\n const label = (job: J) => options.label?.(job) ?? `Job ${job.id}`;\n const recordLabel = (job: J) => options.recordLabel?.(job) ?? `job ${job.id}`;\n const now = () => options.now?.() ?? Date.now();\n\n return {\n async run(runOptions: RunOptions<Filter> = {}): Promise<RunReport> {\n const report: RunReport = { claimed: 0, sent: 0, filtered: 0, skipped: 0, retrying: 0, deferred: 0, failed: 0, stale: 0, unrecorded: 0 };\n const limit = runOptions.limit ?? options.batchSize ?? DEFAULT_BATCH_SIZE;\n const claimed = await options.claim({ limit, filter: runOptions.filter });\n report.claimed = claimed.length;\n\n const groups = new Map<string, J[]>();\n claimed.forEach((job, i) => {\n const key = options.group ? options.group(job) : `\\u0000${i}`;\n const list = groups.get(key) ?? [];\n list.push(job);\n groups.set(key, list);\n });\n\n await Promise.all(\n [...groups.values()].map(async (group) => {\n let results: readonly (JobResult | \"filtered\")[];\n try {\n results = await options.send(group, { signal: runOptions.signal });\n if (results.length !== group.length) throw new Error(\"send answered the wrong number of results\");\n } catch (error) {\n const failure = failureOf(error);\n results = group.map(() => failure);\n }\n const at = now();\n await Promise.all(\n group.map(async (job, i) => {\n const result = results[i]!;\n const outcome: JobOutcome =\n result === \"filtered\"\n ? { status: \"filtered\" }\n : jobOutcome(result, job.attempt, {\n now: at,\n maxAttempts: options.maxAttempts,\n backoff: options.backoff,\n deferrals: job.deferrals,\n maxDeferrals: options.maxDeferrals,\n });\n if (outcome.status === \"failed\" || outcome.status === \"retry\") {\n log(`${label(job)} ${outcome.status === \"failed\" ? \"failed\" : \"will be retried\"}: ${outcome.error}`);\n } else if (outcome.status === \"skipped\") {\n log(`${label(job)} skipped: ${outcome.reason}`);\n }\n let recorded: void | boolean;\n try {\n recorded = await options.record(job, outcome);\n } catch {\n report.unrecorded++;\n log(`Recording ${recordLabel(job)} failed`);\n return;\n }\n if (recorded === false) report.stale++;\n else if (outcome.status === \"sent\") report.sent++;\n else if (outcome.status === \"filtered\") report.filtered++;\n else if (outcome.status === \"skipped\") report.skipped++;\n else if (outcome.status === \"failed\") report.failed++;\n else if (outcome.counted) report.retrying++;\n else report.deferred++;\n }),\n );\n }),\n );\n return report;\n },\n };\n}\n"]}
@@ -0,0 +1,53 @@
1
+ import { t as RenderedText, j as NotificationBrand } from './types-BilguCPW.js';
2
+
3
+ /**
4
+ * What an email says, and the checks every one passes: subjects and names on
5
+ * one line, addresses well formed, every value escaped in the default body.
6
+ */
7
+
8
+ interface EmailContent {
9
+ subject: string;
10
+ html: string;
11
+ text: string;
12
+ }
13
+ interface EmailTemplateContext {
14
+ type: string;
15
+ category: string;
16
+ /** The notification's `data`. */
17
+ data: Record<string, unknown>;
18
+ /** What the registry's render says (title, body, url): a starting point. */
19
+ text: RenderedText;
20
+ recipient: {
21
+ email: string;
22
+ name: string | null;
23
+ };
24
+ /** The caller's brand over the tenant's default. */
25
+ brand: NotificationBrand;
26
+ tenantId: string | null;
27
+ transactional: boolean;
28
+ /**
29
+ * Put this where the secret link goes (`href={secretLink}`): the runner
30
+ * swaps in the real link at send time, so the stored render never holds it.
31
+ * Null when the type has none.
32
+ */
33
+ secretLink: string | null;
34
+ /** The signed one-click unsubscribe link, for the footer. Null for transactional mail. */
35
+ unsubscribeUrl: string | null;
36
+ /** The app's absolute link for `text.url`, when `appUrl` is set. */
37
+ url: string | null;
38
+ }
39
+ /** A type's email: React Email (`@marktiderman/genesis-notifications/email`), or any HTML. */
40
+ type EmailTemplate = (context: EmailTemplateContext) => EmailContent | Promise<EmailContent>;
41
+ declare function escapeHtml(text: string): string;
42
+ /** A header value on one line: control characters and line separators become spaces; cut to `max`. */
43
+ declare function headerSafe(text: string, max?: number): string;
44
+ /** A bare address safe as a header value. */
45
+ declare function isSafeAddress(value: unknown): value is string;
46
+ /** A display name with nothing that could break out of the From header (or need quoting): no quotes, brackets, ":", ";", "@", "," or "\\". */
47
+ declare function displayName(name: string): string;
48
+ /** The default email when a type has no template: its title, body and a button, every value escaped. */
49
+ declare const defaultEmailTemplate: EmailTemplate;
50
+ /** JSON with sorted keys: the same bytes however a database reordered them. */
51
+ declare function canonicalJson(value: unknown): string;
52
+
53
+ export { type EmailTemplateContext as E, type EmailTemplate as a, type EmailContent as b, canonicalJson as c, defaultEmailTemplate as d, displayName as e, escapeHtml as f, headerSafe as h, isSafeAddress as i };
@@ -0,0 +1,46 @@
1
+ import * as react from 'react';
2
+ import { ReactNode, ReactElement } from 'react';
3
+ import { E as EmailTemplateContext, a as EmailTemplate } from './email-render-CID_pXDy.js';
4
+ export { b as EmailContent } from './email-render-CID_pXDy.js';
5
+ import './types-BilguCPW.js';
6
+
7
+ interface BrandLayoutProps {
8
+ ctx: EmailTemplateContext;
9
+ /** The inbox preview line. */
10
+ preview?: string;
11
+ /** Default `ctx.text.title`. */
12
+ heading?: string;
13
+ /** Words before the unsubscribe link. Default "Don't want these emails?". */
14
+ unsubscribeLabel?: string;
15
+ children?: ReactNode;
16
+ }
17
+ /**
18
+ * The brand frame every notification email shares.
19
+ *
20
+ * @stability Beta
21
+ */
22
+ declare function BrandLayout({ ctx, preview, heading, unsubscribeLabel, children }: BrandLayoutProps): react.JSX.Element;
23
+ /** A paragraph in the brand's type. */
24
+ declare function EmailText({ children }: {
25
+ children?: ReactNode;
26
+ }): react.JSX.Element;
27
+ /** A button in the brand's accent. */
28
+ declare function EmailButton({ ctx, href, children }: {
29
+ ctx: EmailTemplateContext;
30
+ href: string;
31
+ children?: ReactNode;
32
+ }): react.JSX.Element;
33
+ /**
34
+ * Turn a React Email component into the runner's `EmailTemplate`: HTML and a
35
+ * plain-text part from the same element.
36
+ *
37
+ * @stability Beta
38
+ */
39
+ declare function reactEmailTemplate(build: (ctx: EmailTemplateContext) => {
40
+ subject: string;
41
+ body: ReactElement;
42
+ }): EmailTemplate;
43
+ /** The default template, on the brand layout: the registry's title and body, and a button to the link. */
44
+ declare const brandedDefaultTemplate: EmailTemplate;
45
+
46
+ export { BrandLayout, type BrandLayoutProps, EmailButton, EmailTemplate, EmailTemplateContext, EmailText, brandedDefaultTemplate, reactEmailTemplate };
package/dist/email.js ADDED
@@ -0,0 +1,64 @@
1
+ import { Html, Head, Preview, Body, Container, Section, Img, Text, Heading, Hr, Link, Button, render } from '@react-email/components';
2
+ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
3
+
4
+ // src/email/index.tsx
5
+ var SAFE_COLOR = /^#[0-9a-fA-F]{3,8}$/;
6
+ function accentOf(ctx) {
7
+ return typeof ctx.brand.color === "string" && SAFE_COLOR.test(ctx.brand.color) ? ctx.brand.color : "#111827";
8
+ }
9
+ function httpsOrNull(url) {
10
+ if (typeof url !== "string") return null;
11
+ try {
12
+ return new URL(url).protocol === "https:" ? url : null;
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+ function BrandLayout({ ctx, preview, heading, unsubscribeLabel = "Don't want these emails?", children }) {
18
+ const accent = accentOf(ctx);
19
+ const logo = httpsOrNull(ctx.brand.logoUrl);
20
+ const site = httpsOrNull(ctx.brand.url);
21
+ return /* @__PURE__ */ jsxs(Html, { lang: "en", children: [
22
+ /* @__PURE__ */ jsx(Head, {}),
23
+ preview ? /* @__PURE__ */ jsx(Preview, { children: preview }) : null,
24
+ /* @__PURE__ */ jsx(Body, { style: { backgroundColor: "#f6f7f9", margin: 0, padding: "24px 0", fontFamily: "-apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif" }, children: /* @__PURE__ */ jsxs(Container, { style: { backgroundColor: "#ffffff", borderRadius: 8, padding: 24, maxWidth: 560 }, children: [
25
+ /* @__PURE__ */ jsx(Section, { style: { marginBottom: 16 }, children: logo ? /* @__PURE__ */ jsx(Img, { src: logo, alt: ctx.brand.name ?? "", height: 32 }) : ctx.brand.name ? /* @__PURE__ */ jsx(Text, { style: { margin: 0, fontWeight: 600, fontSize: 16, color: accent }, children: ctx.brand.name }) : null }),
26
+ /* @__PURE__ */ jsx(Heading, { as: "h1", style: { fontSize: 20, lineHeight: "28px", margin: "0 0 12px", color: "#111827" }, children: heading ?? ctx.text.title }),
27
+ children,
28
+ /* @__PURE__ */ jsx(Hr, { style: { borderColor: "#e5e7eb", margin: "24px 0 12px" } }),
29
+ /* @__PURE__ */ jsxs(Text, { style: { fontSize: 12, color: "#6b7280", margin: 0 }, children: [
30
+ site && ctx.brand.name ? /* @__PURE__ */ jsx(Link, { href: site, style: { color: "#6b7280" }, children: ctx.brand.name }) : ctx.brand.name ?? null,
31
+ ctx.unsubscribeUrl ? /* @__PURE__ */ jsxs(Fragment, { children: [
32
+ ctx.brand.name ? " \xB7 " : null,
33
+ unsubscribeLabel,
34
+ " ",
35
+ /* @__PURE__ */ jsx(Link, { href: ctx.unsubscribeUrl, style: { color: "#6b7280", textDecoration: "underline" }, children: "Unsubscribe" })
36
+ ] }) : null
37
+ ] })
38
+ ] }) })
39
+ ] });
40
+ }
41
+ function EmailText({ children }) {
42
+ return /* @__PURE__ */ jsx(Text, { style: { fontSize: 15, lineHeight: "24px", color: "#111827", margin: "0 0 16px", whiteSpace: "pre-wrap" }, children });
43
+ }
44
+ function EmailButton({ ctx, href, children }) {
45
+ return /* @__PURE__ */ jsx(Button, { href, style: { backgroundColor: accentOf(ctx), color: "#ffffff", borderRadius: 6, padding: "10px 16px", fontSize: 15, textDecoration: "none" }, children });
46
+ }
47
+ function reactEmailTemplate(build) {
48
+ return async (ctx) => {
49
+ const { subject, body } = build(ctx);
50
+ const [html, text] = await Promise.all([render(body), render(body, { plainText: true })]);
51
+ return { subject, html, text };
52
+ };
53
+ }
54
+ var brandedDefaultTemplate = reactEmailTemplate((ctx) => ({
55
+ subject: ctx.text.title,
56
+ body: /* @__PURE__ */ jsxs(BrandLayout, { ctx, preview: ctx.text.body, children: [
57
+ ctx.text.body ? /* @__PURE__ */ jsx(EmailText, { children: ctx.text.body }) : null,
58
+ ctx.secretLink ?? ctx.url ? /* @__PURE__ */ jsx(EmailButton, { ctx, href: ctx.secretLink ?? ctx.url, children: "Open" }) : null
59
+ ] })
60
+ }));
61
+
62
+ export { BrandLayout, EmailButton, EmailText, brandedDefaultTemplate, reactEmailTemplate };
63
+ //# sourceMappingURL=email.js.map
64
+ //# sourceMappingURL=email.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/email/index.tsx"],"names":[],"mappings":"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","file":"email.js","sourcesContent":["/**\n * `@marktiderman/genesis-notifications/email`: React Email templates for the\n * notifier. `BrandLayout` wraps a message in the tenant's brand (logo or\n * name, accent colour, footer with the unsubscribe link when there is one);\n * `reactEmailTemplate` turns a component into an `EmailTemplate` the runner\n * takes. Server-only; needs `@react-email/components` (an optional peer).\n *\n * ```tsx\n * const templates = {\n * \"chat.message\": reactEmailTemplate((ctx) => ({\n * subject: `${ctx.text.title} sent you a message`,\n * body: (\n * <BrandLayout ctx={ctx} preview={ctx.text.body}>\n * <EmailText>{ctx.text.body}</EmailText>\n * {ctx.url ? <EmailButton ctx={ctx} href={ctx.url}>Reply</EmailButton> : null}\n * </BrandLayout>\n * ),\n * })),\n * };\n * ```\n *\n * @packageDocumentation\n */\nimport type { ReactElement, ReactNode } from \"react\";\nimport { Body, Button, Container, Head, Heading, Hr, Html, Img, Link, Preview, Section, Text, render } from \"@react-email/components\";\nimport type { EmailContent, EmailTemplate, EmailTemplateContext } from \"../server/email-render\";\n\nconst SAFE_COLOR = /^#[0-9a-fA-F]{3,8}$/;\n\nfunction accentOf(ctx: EmailTemplateContext): string {\n return typeof ctx.brand.color === \"string\" && SAFE_COLOR.test(ctx.brand.color) ? ctx.brand.color : \"#111827\";\n}\n\nfunction httpsOrNull(url: unknown): string | null {\n if (typeof url !== \"string\") return null;\n try {\n return new URL(url).protocol === \"https:\" ? url : null;\n } catch {\n return null;\n }\n}\n\nexport interface BrandLayoutProps {\n ctx: EmailTemplateContext;\n /** The inbox preview line. */\n preview?: string;\n /** Default `ctx.text.title`. */\n heading?: string;\n /** Words before the unsubscribe link. Default \"Don't want these emails?\". */\n unsubscribeLabel?: string;\n children?: ReactNode;\n}\n\n/**\n * The brand frame every notification email shares.\n *\n * @stability Beta\n */\nexport function BrandLayout({ ctx, preview, heading, unsubscribeLabel = \"Don't want these emails?\", children }: BrandLayoutProps) {\n const accent = accentOf(ctx);\n const logo = httpsOrNull(ctx.brand.logoUrl);\n const site = httpsOrNull(ctx.brand.url);\n return (\n <Html lang=\"en\">\n <Head />\n {preview ? <Preview>{preview}</Preview> : null}\n <Body style={{ backgroundColor: \"#f6f7f9\", margin: 0, padding: \"24px 0\", fontFamily: \"-apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif\" }}>\n <Container style={{ backgroundColor: \"#ffffff\", borderRadius: 8, padding: 24, maxWidth: 560 }}>\n <Section style={{ marginBottom: 16 }}>\n {logo ? (\n <Img src={logo} alt={ctx.brand.name ?? \"\"} height={32} />\n ) : ctx.brand.name ? (\n <Text style={{ margin: 0, fontWeight: 600, fontSize: 16, color: accent }}>{ctx.brand.name}</Text>\n ) : null}\n </Section>\n <Heading as=\"h1\" style={{ fontSize: 20, lineHeight: \"28px\", margin: \"0 0 12px\", color: \"#111827\" }}>\n {heading ?? ctx.text.title}\n </Heading>\n {children}\n <Hr style={{ borderColor: \"#e5e7eb\", margin: \"24px 0 12px\" }} />\n <Text style={{ fontSize: 12, color: \"#6b7280\", margin: 0 }}>\n {site && ctx.brand.name ? <Link href={site} style={{ color: \"#6b7280\" }}>{ctx.brand.name}</Link> : ctx.brand.name ?? null}\n {ctx.unsubscribeUrl ? (\n <>\n {ctx.brand.name ? \" · \" : null}\n {unsubscribeLabel}{\" \"}\n <Link href={ctx.unsubscribeUrl} style={{ color: \"#6b7280\", textDecoration: \"underline\" }}>\n Unsubscribe\n </Link>\n </>\n ) : null}\n </Text>\n </Container>\n </Body>\n </Html>\n );\n}\n\n/** A paragraph in the brand's type. */\nexport function EmailText({ children }: { children?: ReactNode }) {\n return <Text style={{ fontSize: 15, lineHeight: \"24px\", color: \"#111827\", margin: \"0 0 16px\", whiteSpace: \"pre-wrap\" }}>{children}</Text>;\n}\n\n/** A button in the brand's accent. */\nexport function EmailButton({ ctx, href, children }: { ctx: EmailTemplateContext; href: string; children?: ReactNode }) {\n return (\n <Button href={href} style={{ backgroundColor: accentOf(ctx), color: \"#ffffff\", borderRadius: 6, padding: \"10px 16px\", fontSize: 15, textDecoration: \"none\" }}>\n {children}\n </Button>\n );\n}\n\n/**\n * Turn a React Email component into the runner's `EmailTemplate`: HTML and a\n * plain-text part from the same element.\n *\n * @stability Beta\n */\nexport function reactEmailTemplate(build: (ctx: EmailTemplateContext) => { subject: string; body: ReactElement }): EmailTemplate {\n return async (ctx): Promise<EmailContent> => {\n const { subject, body } = build(ctx);\n const [html, text] = await Promise.all([render(body), render(body, { plainText: true })]);\n return { subject, html, text };\n };\n}\n\n/** The default template, on the brand layout: the registry's title and body, and a button to the link. */\nexport const brandedDefaultTemplate: EmailTemplate = reactEmailTemplate((ctx) => ({\n subject: ctx.text.title,\n body: (\n <BrandLayout ctx={ctx} preview={ctx.text.body}>\n {ctx.text.body ? <EmailText>{ctx.text.body}</EmailText> : null}\n {ctx.secretLink ?? ctx.url ? (\n <EmailButton ctx={ctx} href={(ctx.secretLink ?? ctx.url)!}>\n Open\n </EmailButton>\n ) : null}\n </BrandLayout>\n ),\n}));\n\nexport type { EmailContent, EmailTemplate, EmailTemplateContext };\n"]}
@@ -0,0 +1,26 @@
1
+ import { N as NotificationData, R as Recipient } from './types-BilguCPW.js';
2
+ export { C as CHANNELS, a as Channel, b as ChannelChoices, c as ChannelImportance, D as DELIVERY_STATUSES, d as DeliveryStatus, E as EmailRecipient, G as GENESIS_TEST_CATEGORY, e as GENESIS_TEST_TYPE, I as IOS_ID_PATTERN, K as KEY_PATTERN, M as MAX_EMAIL_DELAY_SECONDS, f as MESSAGE_ACTIONS, g as MatrixRow, h as NotificationActionDef, i as NotificationActionSetDef, j as NotificationBrand, k as NotificationCategoryDef, l as NotificationRegistry, m as NotificationRegistryError, n as NotificationRow, o as NotificationTypeDef, p as NotificationTypeRow, q as NotificationsInput, r as NotifyInput, s as NotifyResult, t as RenderedText, U as UserRecipient, u as defineNotifications } from './types-BilguCPW.js';
3
+
4
+ /**
5
+ * Checks `notify()` runs before anything reaches the database (which checks
6
+ * the same again): recipients are well formed, and `data` holds no secret.
7
+ */
8
+
9
+ /** `data` held something that looks like a secret: build such links at send time instead. */
10
+ declare class SecretInDataError extends Error {
11
+ readonly path: string;
12
+ constructor(path: string);
13
+ }
14
+ /** Throws a `SecretInDataError` naming the first key (or link) that looks like a secret. */
15
+ declare function assertNoSecrets(data: NotificationData | undefined, path?: string): void;
16
+ /** The recipients as `genesis_notify` takes them; throws on a malformed one. */
17
+ declare function recipientsToJson(recipients: readonly Recipient[]): ({
18
+ user_id: string;
19
+ badge?: number;
20
+ } | {
21
+ email: string;
22
+ name?: string;
23
+ ref?: string;
24
+ })[];
25
+
26
+ export { NotificationData, Recipient, SecretInDataError, assertNoSecrets, recipientsToJson };