easy-ping 0.3.0 → 0.4.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 (79) hide show
  1. package/README.md +15 -4
  2. package/dist/adapters/drizzle.cjs +3 -2
  3. package/dist/adapters/drizzle.cjs.map +1 -1
  4. package/dist/adapters/drizzle.d.cts +34 -0
  5. package/dist/adapters/drizzle.d.ts +34 -0
  6. package/dist/adapters/drizzle.js +4 -3
  7. package/dist/adapters/drizzle.js.map +1 -1
  8. package/dist/adapters/mongodb.cjs +6 -2
  9. package/dist/adapters/mongodb.cjs.map +1 -1
  10. package/dist/adapters/mongodb.d.cts +1 -1
  11. package/dist/adapters/mongodb.d.ts +1 -1
  12. package/dist/adapters/mongodb.js +5 -2
  13. package/dist/adapters/mongodb.js.map +1 -1
  14. package/dist/adapters/postgres.cjs +3 -2
  15. package/dist/adapters/postgres.cjs.map +1 -1
  16. package/dist/adapters/postgres.d.cts +1 -1
  17. package/dist/adapters/postgres.d.ts +1 -1
  18. package/dist/adapters/postgres.js +3 -3
  19. package/dist/{chunk-OZSOIX23.js → chunk-3Y2MBUAW.js} +53 -12
  20. package/dist/chunk-3Y2MBUAW.js.map +1 -0
  21. package/dist/chunk-CYKAM7LE.js +226 -0
  22. package/dist/chunk-CYKAM7LE.js.map +1 -0
  23. package/dist/chunk-DG3ZL5NP.js +315 -0
  24. package/dist/chunk-DG3ZL5NP.js.map +1 -0
  25. package/dist/{chunk-NYDVNMKG.js → chunk-PSJHXZPR.js} +4 -3
  26. package/dist/chunk-PSJHXZPR.js.map +1 -0
  27. package/dist/{chunk-BV3U542P.js → chunk-W6I3B2CI.js} +2 -2
  28. package/dist/chunk-W6I3B2CI.js.map +1 -0
  29. package/dist/{chunk-RBDW4S6F.js → chunk-YEMRRMJQ.js} +3 -3
  30. package/dist/{chunk-RBDW4S6F.js.map → chunk-YEMRRMJQ.js.map} +1 -1
  31. package/dist/index.cjs +360 -62
  32. package/dist/index.cjs.map +1 -1
  33. package/dist/index.d.cts +84 -20
  34. package/dist/index.d.ts +84 -20
  35. package/dist/index.js +119 -357
  36. package/dist/index.js.map +1 -1
  37. package/dist/node.cjs +27 -6
  38. package/dist/node.cjs.map +1 -1
  39. package/dist/node.d.cts +8 -2
  40. package/dist/node.d.ts +8 -2
  41. package/dist/node.js +26 -6
  42. package/dist/node.js.map +1 -1
  43. package/dist/{plugin-CmPOFVmn.d.ts → plugin-C9Hoxus2.d.ts} +37 -9
  44. package/dist/{plugin-f5KaFWSN.d.cts → plugin-CiMsGlQe.d.cts} +37 -9
  45. package/dist/plugins/digests.d.cts +1 -1
  46. package/dist/plugins/digests.d.ts +1 -1
  47. package/dist/plugins/preferences-client.cjs.map +1 -1
  48. package/dist/plugins/preferences-client.d.cts +1 -1
  49. package/dist/plugins/preferences-client.d.ts +1 -1
  50. package/dist/plugins/preferences-client.js +1 -1
  51. package/dist/plugins/preferences.cjs +100 -15
  52. package/dist/plugins/preferences.cjs.map +1 -1
  53. package/dist/plugins/preferences.d.cts +3 -1
  54. package/dist/plugins/preferences.d.ts +3 -1
  55. package/dist/plugins/preferences.js +35 -7
  56. package/dist/plugins/preferences.js.map +1 -1
  57. package/dist/plugins/push.cjs +197 -38
  58. package/dist/plugins/push.cjs.map +1 -1
  59. package/dist/plugins/push.d.cts +32 -10
  60. package/dist/plugins/push.d.ts +32 -10
  61. package/dist/plugins/push.js +140 -39
  62. package/dist/plugins/push.js.map +1 -1
  63. package/dist/providers/web-push.cjs +37 -11
  64. package/dist/providers/web-push.cjs.map +1 -1
  65. package/dist/providers/web-push.d.cts +8 -2
  66. package/dist/providers/web-push.d.ts +8 -2
  67. package/dist/providers/web-push.js +37 -12
  68. package/dist/providers/web-push.js.map +1 -1
  69. package/dist/schema.cjs +2 -1
  70. package/dist/schema.cjs.map +1 -1
  71. package/dist/schema.d.cts +6 -1
  72. package/dist/schema.d.ts +6 -1
  73. package/dist/schema.js +2 -2
  74. package/package.json +1 -1
  75. package/dist/chunk-BV3U542P.js.map +0 -1
  76. package/dist/chunk-NYDVNMKG.js.map +0 -1
  77. package/dist/chunk-OZSOIX23.js.map +0 -1
  78. package/dist/chunk-W3BMEPPQ.js +0 -50
  79. package/dist/chunk-W3BMEPPQ.js.map +0 -1
@@ -1,4 +1,4 @@
1
- import { toSnakeCase } from './chunk-NYDVNMKG.js';
1
+ import { toSnakeCase } from './chunk-PSJHXZPR.js';
2
2
 
3
3
  // src/core/errors.ts
4
4
  var EasyPingError = class extends Error {
@@ -28,7 +28,32 @@ var consoleLogger = {
28
28
 
29
29
  // src/core/store.ts
30
30
  var OPERATORS = ["in", "lt", "lte", "gt", "gte", "not"];
31
- var isOperator = (value) => typeof value === "object" && value !== null && !(value instanceof Date) && OPERATORS.some((op) => op in value);
31
+ var isOperator = (value) => typeof value === "object" && value !== null && !(value instanceof Date) && OPERATORS.some((op) => Object.hasOwn(value, op));
32
+ var isScalar = (value) => value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value instanceof Date;
33
+ function assertCondition(pluginId, field, condition) {
34
+ if (isScalar(condition)) return;
35
+ if (isOperator(condition)) {
36
+ const operator = condition;
37
+ const keys = Object.keys(operator);
38
+ const [op] = keys;
39
+ if (keys.length !== 1 || op === void 0) {
40
+ throw new ConfigError(`plugin "${pluginId}" gave field "${field}" more than one operator.`);
41
+ }
42
+ const payload = operator[op];
43
+ const valid = op === "in" ? Array.isArray(payload) && payload.every((item) => typeof item === "string" || typeof item === "number") : isScalar(payload);
44
+ if (valid) return;
45
+ }
46
+ throw new ConfigError(
47
+ `plugin "${pluginId}" passed a non-scalar value for field "${field}"; validate request bodies before querying with them.`
48
+ );
49
+ }
50
+ function assertValue(pluginId, field, spec, value) {
51
+ if (value === void 0 || isScalar(value)) return;
52
+ if (spec.type === "json" && typeof value === "object") return;
53
+ throw new ConfigError(
54
+ `plugin "${pluginId}" passed a non-scalar value for ${spec.type} field "${field}".`
55
+ );
56
+ }
32
57
  function createPluginStore(pluginId, schema, storage, prefix) {
33
58
  const byTableName = /* @__PURE__ */ new Map();
34
59
  for (const declaration of Object.values(schema ?? {})) {
@@ -45,7 +70,7 @@ function createPluginStore(pluginId, schema, storage, prefix) {
45
70
  }
46
71
  function checkFields(table, fields, context) {
47
72
  for (const field of fields) {
48
- const spec = table.fields[field];
73
+ const spec = Object.hasOwn(table.fields, field) ? table.fields[field] : void 0;
49
74
  if (!spec) {
50
75
  throw new ConfigError(
51
76
  `plugin "${pluginId}" used unknown field "${field}" on "${table.tableName}" (${context}).`
@@ -58,6 +83,19 @@ function createPluginStore(pluginId, schema, storage, prefix) {
58
83
  }
59
84
  }
60
85
  }
86
+ function checkWhere(table, where) {
87
+ checkFields(table, Object.keys(where), "where");
88
+ for (const [field, condition] of Object.entries(where)) {
89
+ assertCondition(pluginId, field, condition);
90
+ }
91
+ }
92
+ function checkRow(table, row, context) {
93
+ checkFields(table, Object.keys(row), context);
94
+ for (const [field, value] of Object.entries(row)) {
95
+ const spec = table.fields[field];
96
+ if (spec) assertValue(pluginId, field, spec, value);
97
+ }
98
+ }
61
99
  const qualified = (table) => prefix + table.tableName;
62
100
  const column = (field) => storage.naming === "preserve" ? field : toSnakeCase(field);
63
101
  const serialize = (table, row) => {
@@ -71,21 +109,24 @@ function createPluginStore(pluginId, schema, storage, prefix) {
71
109
  return {
72
110
  async find(table, where = {}, options = {}) {
73
111
  const declaration = resolve(table);
74
- checkFields(declaration, Object.keys(where), "where");
112
+ checkWhere(declaration, where);
75
113
  if (options.orderBy) checkFields(declaration, [options.orderBy.field], "orderBy");
76
114
  const rows = await storage.queryTable(qualified(declaration), where, options);
77
115
  return rows.map((row) => {
78
116
  const mapped = {};
79
117
  for (const field of Object.keys(declaration.fields)) {
80
118
  const value = row[column(field)];
81
- mapped[field] = declaration.fields[field]?.type === "json" && typeof value === "string" ? JSON.parse(value) : value;
119
+ const type = declaration.fields[field]?.type;
120
+ if (type === "json" && typeof value === "string") mapped[field] = JSON.parse(value);
121
+ else if (type === "date" && typeof value === "string") mapped[field] = new Date(value);
122
+ else mapped[field] = value;
82
123
  }
83
124
  return mapped;
84
125
  });
85
126
  },
86
127
  async insert(table, rows) {
87
128
  const declaration = resolve(table);
88
- for (const row of rows) checkFields(declaration, Object.keys(row), "insert");
129
+ for (const row of rows) checkRow(declaration, row, "insert");
89
130
  return storage.insertRows(
90
131
  qualified(declaration),
91
132
  rows.map((row) => serialize(declaration, row))
@@ -93,7 +134,7 @@ function createPluginStore(pluginId, schema, storage, prefix) {
93
134
  },
94
135
  async upsert(table, rows, options) {
95
136
  const declaration = resolve(table);
96
- for (const row of rows) checkFields(declaration, Object.keys(row), "upsert");
137
+ for (const row of rows) checkRow(declaration, row, "upsert");
97
138
  checkFields(declaration, options.onConflict, "onConflict");
98
139
  return storage.insertRows(
99
140
  qualified(declaration),
@@ -103,18 +144,18 @@ function createPluginStore(pluginId, schema, storage, prefix) {
103
144
  },
104
145
  async update(table, where, set) {
105
146
  const declaration = resolve(table);
106
- checkFields(declaration, Object.keys(where), "where");
107
- checkFields(declaration, Object.keys(set), "update");
147
+ checkWhere(declaration, where);
148
+ checkRow(declaration, set, "update");
108
149
  return storage.updateRows(qualified(declaration), where, serialize(declaration, set));
109
150
  },
110
151
  async remove(table, where) {
111
152
  const declaration = resolve(table);
112
- checkFields(declaration, Object.keys(where), "where");
153
+ checkWhere(declaration, where);
113
154
  return storage.deleteRows(qualified(declaration), where);
114
155
  }
115
156
  };
116
157
  }
117
158
 
118
159
  export { ConfigError, EasyPingError, ValidationError, consoleLogger, createPluginStore, isOperator };
119
- //# sourceMappingURL=chunk-OZSOIX23.js.map
120
- //# sourceMappingURL=chunk-OZSOIX23.js.map
160
+ //# sourceMappingURL=chunk-3Y2MBUAW.js.map
161
+ //# sourceMappingURL=chunk-3Y2MBUAW.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/core/errors.ts","../src/core/store.ts"],"names":[],"mappings":"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","file":"chunk-3Y2MBUAW.js","sourcesContent":["export class EasyPingError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"EasyPingError\";\n }\n}\n\n/** Thrown when a payload fails its Standard Schema. Developer error. */\nexport class ValidationError extends EasyPingError {\n readonly issues: readonly { message: string; path?: string }[];\n\n constructor(type: string, issues: readonly { message: string; path?: string }[]) {\n super(`Payload for \"${type}\" failed validation: ${issues.map((i) => i.message).join(\"; \")}`);\n this.name = \"ValidationError\";\n this.issues = issues;\n }\n}\n\n/** Thrown at startup for a malformed config. Never at send time. */\nexport class ConfigError extends EasyPingError {\n constructor(message: string) {\n super(message);\n this.name = \"ConfigError\";\n }\n}\n\nexport type Logger = {\n warn: (message: string, meta?: Record<string, unknown>) => void;\n error: (message: string, meta?: Record<string, unknown>) => void;\n};\n\nexport const consoleLogger: Logger = {\n warn: (message, meta) => console.warn(`[easy-ping] ${message}`, meta ?? \"\"),\n error: (message, meta) => console.error(`[easy-ping] ${message}`, meta ?? \"\"),\n};\n","import { toSnakeCase } from \"../schema/declaration\";\nimport { ConfigError } from \"./errors\";\nimport type { FieldDeclaration, SchemaDeclaration, TableDeclaration } from \"./plugin\";\n\nexport type Scalar = string | number | boolean | Date | null;\n\n/**\n * One operator per condition. Operators are tagged objects so a bare value is\n * always an equality test; json columns therefore cannot be filtered, since an\n * object value would be ambiguous.\n */\nexport type WhereCondition =\n | Scalar\n | { in: readonly (string | number)[] }\n | { lt: Date | number }\n | { lte: Date | number }\n | { gt: Date | number }\n | { gte: Date | number }\n | { not: Scalar };\n\nexport type WhereClause = Record<string, WhereCondition>;\n\nexport type QueryOptions = {\n limit?: number;\n orderBy?: { field: string; direction: \"asc\" | \"desc\" };\n};\n\nexport type UpsertOptions = { onConflict: readonly string[] };\n\n/**\n * Table access for plugins, scoped to the tables that plugin declared.\n *\n * Plugins could always declare tables via schema() but had no way to read\n * them, so preferences had to bolt its queries onto the core adapter. That\n * does not generalise to digests or push.\n */\nexport type PluginStore = {\n find<T = Record<string, unknown>>(\n table: string,\n where?: WhereClause,\n options?: QueryOptions,\n ): Promise<T[]>;\n insert(table: string, rows: readonly Record<string, unknown>[]): Promise<number>;\n upsert(\n table: string,\n rows: readonly Record<string, unknown>[],\n options: UpsertOptions,\n ): Promise<number>;\n update(table: string, where: WhereClause, set: Record<string, unknown>): Promise<number>;\n remove(table: string, where: WhereClause): Promise<number>;\n};\n\nexport type TableStorage = {\n readonly naming?: \"snake_case\" | \"preserve\";\n readonly serializesJson?: boolean;\n\n queryTable(\n table: string,\n where: WhereClause,\n options: QueryOptions,\n ): Promise<Record<string, unknown>[]>;\n insertRows(\n table: string,\n rows: readonly Record<string, unknown>[],\n onConflict?: readonly string[],\n ): Promise<number>;\n updateRows(table: string, where: WhereClause, set: Record<string, unknown>): Promise<number>;\n deleteRows(table: string, where: WhereClause): Promise<number>;\n};\n\nconst OPERATORS = [\"in\", \"lt\", \"lte\", \"gt\", \"gte\", \"not\"] as const;\n\nexport const isOperator = (value: unknown): boolean =>\n typeof value === \"object\" &&\n value !== null &&\n !(value instanceof Date) &&\n OPERATORS.some((op) => Object.hasOwn(value, op));\n\nconst isScalar = (value: unknown): value is Scalar =>\n value === null ||\n typeof value === \"string\" ||\n typeof value === \"number\" ||\n typeof value === \"boolean\" ||\n value instanceof Date;\n\n/**\n * Types say a condition is a scalar or an operator, but a route that forwards\n * a JSON body has no such guarantee: an object with a `$ne` key is a Mongo\n * operator once it reaches the driver. Refuse anything else at the boundary.\n */\nfunction assertCondition(pluginId: string, field: string, condition: unknown): void {\n if (isScalar(condition)) return;\n\n if (isOperator(condition)) {\n const operator = condition as Record<string, unknown>;\n const keys = Object.keys(operator);\n const [op] = keys;\n if (keys.length !== 1 || op === undefined) {\n throw new ConfigError(`plugin \"${pluginId}\" gave field \"${field}\" more than one operator.`);\n }\n const payload = operator[op];\n const valid =\n op === \"in\"\n ? Array.isArray(payload) &&\n payload.every((item) => typeof item === \"string\" || typeof item === \"number\")\n : isScalar(payload);\n if (valid) return;\n }\n\n throw new ConfigError(\n `plugin \"${pluginId}\" passed a non-scalar value for field \"${field}\"; ` +\n \"validate request bodies before querying with them.\",\n );\n}\n\nfunction assertValue(pluginId: string, field: string, spec: FieldDeclaration, value: unknown) {\n if (value === undefined || isScalar(value)) return;\n if (spec.type === \"json\" && typeof value === \"object\") return;\n throw new ConfigError(\n `plugin \"${pluginId}\" passed a non-scalar value for ${spec.type} field \"${field}\".`,\n );\n}\n\n/**\n * Every table and column a plugin touches is checked against its own\n * declaration. Without this a plugin could read the notification table, and\n * unvalidated identifiers would reach the SQL builder.\n */\nexport function createPluginStore(\n pluginId: string,\n schema: SchemaDeclaration | undefined,\n storage: TableStorage,\n prefix: string,\n): PluginStore {\n const byTableName = new Map<string, TableDeclaration>();\n for (const declaration of Object.values(schema ?? {})) {\n byTableName.set(declaration.tableName, declaration);\n }\n\n function resolve(table: string): TableDeclaration {\n const declaration = byTableName.get(table);\n if (!declaration) {\n throw new ConfigError(\n `plugin \"${pluginId}\" accessed table \"${table}\", which it does not declare in schema().`,\n );\n }\n return declaration;\n }\n\n function checkFields(table: TableDeclaration, fields: Iterable<string>, context: string) {\n for (const field of fields) {\n // hasOwn, not a lookup: `constructor` and `__proto__` are not columns.\n const spec = Object.hasOwn(table.fields, field) ? table.fields[field] : undefined;\n if (!spec) {\n throw new ConfigError(\n `plugin \"${pluginId}\" used unknown field \"${field}\" on \"${table.tableName}\" (${context}).`,\n );\n }\n if (context === \"where\" && spec.type === \"json\") {\n throw new ConfigError(\n `plugin \"${pluginId}\" cannot filter on json field \"${field}\"; ` +\n \"a bare object value is indistinguishable from an operator.\",\n );\n }\n }\n }\n\n function checkWhere(table: TableDeclaration, where: WhereClause) {\n checkFields(table, Object.keys(where), \"where\");\n for (const [field, condition] of Object.entries(where)) {\n assertCondition(pluginId, field, condition);\n }\n }\n\n function checkRow(table: TableDeclaration, row: Record<string, unknown>, context: string) {\n checkFields(table, Object.keys(row), context);\n for (const [field, value] of Object.entries(row)) {\n const spec = table.fields[field];\n if (spec) assertValue(pluginId, field, spec, value);\n }\n }\n\n const qualified = (table: TableDeclaration) => prefix + table.tableName;\n\n // A document store keeps the declared names; a SQL adapter wants columns.\n const column = (field: string) => (storage.naming === \"preserve\" ? field : toSnakeCase(field));\n\n /**\n * json columns must be handed to the driver as text; postgres-js cannot bind\n * a plain object and the insert fails outright.\n */\n const serialize = (table: TableDeclaration, row: Record<string, unknown>) => {\n if (storage.serializesJson === false) return row;\n\n const out: Record<string, unknown> = {};\n for (const [field, value] of Object.entries(row)) {\n out[field] =\n table.fields[field]?.type === \"json\" && value !== null && value !== undefined\n ? JSON.stringify(value)\n : value;\n }\n return out;\n };\n\n return {\n async find(table, where = {}, options = {}) {\n const declaration = resolve(table);\n checkWhere(declaration, where);\n if (options.orderBy) checkFields(declaration, [options.orderBy.field], \"orderBy\");\n\n const rows = await storage.queryTable(qualified(declaration), where, options);\n\n // SELECT * returns snake_case columns; plugins declare camelCase fields\n // and type their reads that way. Without this every property is\n // undefined at runtime while typechecking perfectly.\n return rows.map((row) => {\n const mapped: Record<string, unknown> = {};\n for (const field of Object.keys(declaration.fields)) {\n const value = row[column(field)];\n const type = declaration.fields[field]?.type;\n // Drivers differ: some hand back parsed jsonb, some raw text; some\n // return timestamptz as a Date, postgres.js through Drizzle as text.\n if (type === \"json\" && typeof value === \"string\") mapped[field] = JSON.parse(value);\n else if (type === \"date\" && typeof value === \"string\") mapped[field] = new Date(value);\n else mapped[field] = value;\n }\n return mapped;\n }) as never;\n },\n\n async insert(table, rows) {\n const declaration = resolve(table);\n for (const row of rows) checkRow(declaration, row, \"insert\");\n return storage.insertRows(\n qualified(declaration),\n rows.map((row) => serialize(declaration, row)),\n );\n },\n\n async upsert(table, rows, options) {\n const declaration = resolve(table);\n for (const row of rows) checkRow(declaration, row, \"upsert\");\n checkFields(declaration, options.onConflict, \"onConflict\");\n return storage.insertRows(\n qualified(declaration),\n rows.map((row) => serialize(declaration, row)),\n options.onConflict,\n );\n },\n\n async update(table, where, set) {\n const declaration = resolve(table);\n checkWhere(declaration, where);\n checkRow(declaration, set, \"update\");\n return storage.updateRows(qualified(declaration), where, serialize(declaration, set));\n },\n\n async remove(table, where) {\n const declaration = resolve(table);\n checkWhere(declaration, where);\n return storage.deleteRows(qualified(declaration), where);\n },\n };\n}\n"]}
@@ -0,0 +1,226 @@
1
+ // src/core/backoff.ts
2
+ var SCHEDULE_MS = [3e4, 12e4, 48e4, 192e4];
3
+ function backoffMs(attempt, strategy = "exponential") {
4
+ if (typeof strategy === "function") return Math.max(0, strategy(attempt));
5
+ const index = Math.min(Math.max(attempt, 1), SCHEDULE_MS.length) - 1;
6
+ const base = SCHEDULE_MS[index] ?? SCHEDULE_MS[SCHEDULE_MS.length - 1] ?? 3e4;
7
+ return Math.round(base * (0.8 + Math.random() * 0.4));
8
+ }
9
+ var nextAttemptAt = (attempt, strategy, now = /* @__PURE__ */ new Date()) => new Date(now.getTime() + backoffMs(attempt, strategy));
10
+
11
+ // src/core/runner.ts
12
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
13
+ async function withTimeout(run, ms, label) {
14
+ const controller = new AbortController();
15
+ let timer;
16
+ const timeout = new Promise((_, reject) => {
17
+ timer = setTimeout(() => {
18
+ controller.abort();
19
+ reject(new Error(`${label} timed out after ${ms}ms`));
20
+ }, ms);
21
+ });
22
+ try {
23
+ return await Promise.race([run(controller.signal), timeout]);
24
+ } finally {
25
+ clearTimeout(timer);
26
+ }
27
+ }
28
+ var redactErrorMessage = (text) => text.replace(/[^\s"'<>()[\]{},;]+@[^\s"'<>()[\]{},;]+/g, "[redacted-email]");
29
+ var message = (error) => redactErrorMessage(error instanceof Error ? error.message : String(error));
30
+ function createRunner(deps) {
31
+ async function deliverOne(delivery, recipient) {
32
+ if (!recipient) {
33
+ return { result: "failed", error: "recipient could not be resolved", retryable: false };
34
+ }
35
+ if (delivery.channel === "inApp") {
36
+ return { result: "sent" };
37
+ }
38
+ if (delivery.channel === "email") {
39
+ const provider = deps.channels.email?.provider;
40
+ if (!provider) {
41
+ return { result: "failed", error: "no email provider configured", retryable: false };
42
+ }
43
+ const to = recipient.email;
44
+ if (!to) {
45
+ return { result: "failed", error: "recipient has no email address", retryable: false };
46
+ }
47
+ const email = deps.definitions[delivery.notification.type]?.email;
48
+ if (!email) {
49
+ return {
50
+ result: "failed",
51
+ error: `notification type "${delivery.notification.type}" has no email template`,
52
+ retryable: false
53
+ };
54
+ }
55
+ try {
56
+ const payload = delivery.notification.payload;
57
+ const html = await email.template(payload);
58
+ await withTimeout(
59
+ (signal) => provider.send({
60
+ to,
61
+ subject: email.subject(payload),
62
+ html,
63
+ // Delivery is at-least-once; the provider dedupes on this.
64
+ idempotencyKey: delivery.id,
65
+ signal
66
+ }),
67
+ provider.timeoutMs ?? 3e4,
68
+ `${provider.name} send`
69
+ );
70
+ return { result: "sent" };
71
+ } catch (error) {
72
+ return {
73
+ result: "failed",
74
+ error: message(error),
75
+ retryable: provider.isRetryable(error)
76
+ };
77
+ }
78
+ }
79
+ const owner = deps.plugins.find(
80
+ (plugin) => plugin.channels?.includes(delivery.channel) && plugin.hooks?.deliver
81
+ );
82
+ if (!owner?.hooks?.deliver) {
83
+ return {
84
+ result: "failed",
85
+ error: `no plugin delivers channel "${delivery.channel}"`,
86
+ retryable: false
87
+ };
88
+ }
89
+ try {
90
+ return await withTimeout(
91
+ (signal) => Promise.resolve(
92
+ // biome-ignore lint/style/noNonNullAssertion: guarded directly above
93
+ owner.hooks.deliver({
94
+ channel: delivery.channel,
95
+ deliveryId: delivery.id,
96
+ attempt: delivery.attempts + 1,
97
+ recipient,
98
+ notification: {
99
+ id: delivery.notificationId,
100
+ userId: delivery.notification.userId,
101
+ type: delivery.notification.type,
102
+ payload: delivery.notification.payload
103
+ },
104
+ signal
105
+ })
106
+ ),
107
+ 3e4,
108
+ `plugin "${owner.id}" deliver`
109
+ );
110
+ } catch (error) {
111
+ return { result: "failed", error: message(error), retryable: true };
112
+ }
113
+ }
114
+ async function notifyAfterDeliver(delivery, outcome) {
115
+ const isTerminal = outcome.result !== "failed" || !outcome.retryable || delivery.attempts + 1 >= delivery.maxAttempts;
116
+ for (const plugin of deps.plugins) {
117
+ const hook = plugin.hooks?.afterDeliver;
118
+ if (!hook) continue;
119
+ try {
120
+ await hook({
121
+ deliveryId: delivery.id,
122
+ notificationId: delivery.notificationId,
123
+ userId: delivery.notification.userId,
124
+ channel: delivery.channel,
125
+ outcome,
126
+ attempt: delivery.attempts + 1,
127
+ isTerminal
128
+ });
129
+ } catch (error) {
130
+ deps.logger.error(`plugin "${plugin.id}" afterDeliver threw; ignoring`, {
131
+ deliveryId: delivery.id,
132
+ error
133
+ });
134
+ }
135
+ }
136
+ }
137
+ async function runOnce(options = {}) {
138
+ const claimToken = crypto.randomUUID();
139
+ const claimed = await deps.adapter.claimPendingDeliveries({
140
+ limit: options.limit ?? deps.batchSize,
141
+ leaseMs: deps.leaseMs,
142
+ claimToken,
143
+ ...options.channels ? { channels: options.channels } : {},
144
+ ...options.ids ? { ids: options.ids } : {}
145
+ });
146
+ if (claimed.length === 0) return { claimed: 0, sent: 0, failed: 0, skipped: 0 };
147
+ const userIds = [...new Set(claimed.map((row) => row.notification.userId))];
148
+ const recipients = await deps.getRecipients(userIds);
149
+ const byUserId = new Map(recipients.map((recipient) => [recipient.userId, recipient]));
150
+ const releases = [];
151
+ let sent = 0;
152
+ let failed = 0;
153
+ let skipped = 0;
154
+ for (const delivery of claimed) {
155
+ const outcome = await deliverOne(delivery, byUserId.get(delivery.notification.userId));
156
+ if (outcome.result === "sent") sent += 1;
157
+ else if (outcome.result === "skipped") skipped += 1;
158
+ else failed += 1;
159
+ releases.push({
160
+ id: delivery.id,
161
+ claimToken,
162
+ outcome,
163
+ ...outcome.result === "failed" && outcome.retryable ? { nextAttemptAt: nextAttemptAt(delivery.attempts + 1, deps.backoff) } : {}
164
+ });
165
+ await notifyAfterDeliver(delivery, outcome);
166
+ }
167
+ await deps.adapter.releaseDeliveries(releases);
168
+ return { claimed: claimed.length, sent, failed, skipped };
169
+ }
170
+ async function drain(options = {}) {
171
+ const maxSweeps = options.maxSweeps ?? 50;
172
+ const total = { claimed: 0, sent: 0, failed: 0, skipped: 0 };
173
+ for (let sweep = 0; sweep < maxSweeps; sweep += 1) {
174
+ const result = await runOnce(options.ids ? { ids: options.ids } : {});
175
+ total.claimed += result.claimed;
176
+ total.sent += result.sent;
177
+ total.failed += result.failed;
178
+ total.skipped += result.skipped;
179
+ if (result.claimed === 0) break;
180
+ }
181
+ return total;
182
+ }
183
+ let warnedMissingWaitUntil = false;
184
+ function scheduleDeferred(waitUntil, ids) {
185
+ if (waitUntil) {
186
+ waitUntil(
187
+ drain({ maxSweeps: 10, ...ids ? { ids } : {} }).catch(
188
+ (error) => deps.logger.error("deferred sweep failed", { error })
189
+ )
190
+ );
191
+ return;
192
+ }
193
+ if (!warnedMissingWaitUntil) {
194
+ warnedMissingWaitUntil = true;
195
+ deps.logger.warn(
196
+ "delivery.mode is 'deferred' but no delivery.waitUntil was supplied \u2014 falling back to the cron sweep. Notifications are not lost, but they will not be delivered until POST /cron runs."
197
+ );
198
+ }
199
+ }
200
+ function startWorker(options = {}) {
201
+ const intervalMs = options.intervalMs ?? 1e3;
202
+ let stopped = false;
203
+ const loop = (async () => {
204
+ while (!stopped) {
205
+ try {
206
+ const result = await runOnce({ limit: options.batchSize ?? deps.batchSize });
207
+ if (result.claimed === 0) await sleep(intervalMs);
208
+ } catch (error) {
209
+ deps.logger.error("worker sweep failed", { error });
210
+ await sleep(intervalMs);
211
+ }
212
+ }
213
+ })();
214
+ return {
215
+ stop: async () => {
216
+ stopped = true;
217
+ await loop;
218
+ }
219
+ };
220
+ }
221
+ return { runOnce, drain, scheduleDeferred, startWorker };
222
+ }
223
+
224
+ export { createRunner, redactErrorMessage };
225
+ //# sourceMappingURL=chunk-CYKAM7LE.js.map
226
+ //# sourceMappingURL=chunk-CYKAM7LE.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/core/backoff.ts","../src/core/runner.ts"],"names":[],"mappings":"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","file":"chunk-CYKAM7LE.js","sourcesContent":["/**\n * Exponential backoff with ±20% jitter. RFC 0001 §4.\n *\n * These are minimums, not promises — the sweep interval floors them. Under an\n * hourly cron a 30s backoff is an hour.\n */\nconst SCHEDULE_MS = [30_000, 120_000, 480_000, 1_920_000] as const;\n\nexport type Backoff = \"exponential\" | ((attempt: number) => number);\n\nexport function backoffMs(attempt: number, strategy: Backoff = \"exponential\"): number {\n if (typeof strategy === \"function\") return Math.max(0, strategy(attempt));\n\n const index = Math.min(Math.max(attempt, 1), SCHEDULE_MS.length) - 1;\n const base = SCHEDULE_MS[index] ?? SCHEDULE_MS[SCHEDULE_MS.length - 1] ?? 30_000;\n return Math.round(base * (0.8 + Math.random() * 0.4));\n}\n\nexport const nextAttemptAt = (attempt: number, strategy?: Backoff, now = new Date()): Date =>\n new Date(now.getTime() + backoffMs(attempt, strategy));\n","import type { ClaimedDelivery, DatabaseAdapter, DeliveryOutcome, DeliveryRelease } from \"./adapter\";\nimport { type Backoff, nextAttemptAt } from \"./backoff\";\nimport type { ChannelsConfig, Worker } from \"./config\";\nimport type { NotificationDefinitions } from \"./definition\";\nimport type { Logger } from \"./errors\";\nimport type { AnyPlugin } from \"./plugin\";\nimport type { Channel, Recipient } from \"./types\";\n\nexport type RunnerDeps = {\n adapter: DatabaseAdapter;\n definitions: NotificationDefinitions;\n channels: ChannelsConfig;\n plugins: readonly AnyPlugin[];\n getRecipients: (userIds: readonly string[]) => Promise<readonly Recipient[]>;\n logger: Logger;\n leaseMs: number;\n batchSize: number;\n backoff: Backoff;\n};\n\nexport type SweepResult = {\n claimed: number;\n sent: number;\n failed: number;\n /** Deliveries that had nothing to do. Not errors; see DeliveryOutcome. */\n skipped: number;\n};\n\nconst sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));\n\nasync function withTimeout<T>(\n run: (signal: AbortSignal) => Promise<T>,\n ms: number,\n label: string,\n): Promise<T> {\n const controller = new AbortController();\n let timer: ReturnType<typeof setTimeout> | undefined;\n\n const timeout = new Promise<never>((_, reject) => {\n timer = setTimeout(() => {\n // Abort first: racing a promise leaves the original request running.\n controller.abort();\n reject(new Error(`${label} timed out after ${ms}ms`));\n }, ms);\n });\n\n try {\n return await Promise.race([run(controller.signal), timeout]);\n } finally {\n clearTimeout(timer);\n }\n}\n\n/**\n * What gets persisted to `last_error`. Provider responses quote the offending\n * field, which for a bad recipient is the address itself; that column outlives\n * the notification and is read by admin tooling, so addresses do not go in.\n */\nexport const redactErrorMessage = (text: string) =>\n text.replace(/[^\\s\"'<>()[\\]{},;]+@[^\\s\"'<>()[\\]{},;]+/g, \"[redacted-email]\");\n\nconst message = (error: unknown) =>\n redactErrorMessage(error instanceof Error ? error.message : String(error));\n\nexport function createRunner(deps: RunnerDeps) {\n async function deliverOne(\n delivery: ClaimedDelivery,\n recipient: Recipient | undefined,\n ): Promise<DeliveryOutcome> {\n if (!recipient) {\n return { result: \"failed\", error: \"recipient could not be resolved\", retryable: false };\n }\n\n if (delivery.channel === \"inApp\") {\n // The notification row is the delivery. Nothing to transmit.\n return { result: \"sent\" };\n }\n\n if (delivery.channel === \"email\") {\n const provider = deps.channels.email?.provider;\n if (!provider) {\n return { result: \"failed\", error: \"no email provider configured\", retryable: false };\n }\n // Captured before the closure below: narrowing does not survive into it.\n const to = recipient.email;\n if (!to) {\n return { result: \"failed\", error: \"recipient has no email address\", retryable: false };\n }\n\n const email = deps.definitions[delivery.notification.type]?.email;\n if (!email) {\n return {\n result: \"failed\",\n error: `notification type \"${delivery.notification.type}\" has no email template`,\n retryable: false,\n };\n }\n\n try {\n const payload = delivery.notification.payload as never;\n const html = await email.template(payload);\n\n await withTimeout(\n (signal) =>\n provider.send({\n to,\n subject: email.subject(payload),\n html,\n // Delivery is at-least-once; the provider dedupes on this.\n idempotencyKey: delivery.id,\n signal,\n }),\n provider.timeoutMs ?? 30_000,\n `${provider.name} send`,\n );\n\n return { result: \"sent\" };\n } catch (error) {\n return {\n result: \"failed\",\n error: message(error),\n retryable: provider.isRetryable(error),\n };\n }\n }\n\n const owner = deps.plugins.find(\n (plugin) => plugin.channels?.includes(delivery.channel) && plugin.hooks?.deliver,\n );\n if (!owner?.hooks?.deliver) {\n return {\n result: \"failed\",\n error: `no plugin delivers channel \"${delivery.channel}\"`,\n retryable: false,\n };\n }\n\n try {\n return await withTimeout(\n (signal) =>\n Promise.resolve(\n // biome-ignore lint/style/noNonNullAssertion: guarded directly above\n owner.hooks!.deliver!({\n channel: delivery.channel,\n deliveryId: delivery.id,\n attempt: delivery.attempts + 1,\n recipient,\n notification: {\n id: delivery.notificationId,\n userId: delivery.notification.userId,\n type: delivery.notification.type,\n payload: delivery.notification.payload,\n },\n signal,\n }),\n ),\n 30_000,\n `plugin \"${owner.id}\" deliver`,\n );\n } catch (error) {\n return { result: \"failed\", error: message(error), retryable: true };\n }\n }\n\n async function notifyAfterDeliver(delivery: ClaimedDelivery, outcome: DeliveryOutcome) {\n const isTerminal =\n outcome.result !== \"failed\" ||\n !outcome.retryable ||\n delivery.attempts + 1 >= delivery.maxAttempts;\n\n for (const plugin of deps.plugins) {\n const hook = plugin.hooks?.afterDeliver;\n if (!hook) continue;\n try {\n await hook({\n deliveryId: delivery.id,\n notificationId: delivery.notificationId,\n userId: delivery.notification.userId,\n channel: delivery.channel,\n outcome,\n attempt: delivery.attempts + 1,\n isTerminal,\n });\n } catch (error) {\n // Fail open. The message is already sent; a broken analytics hook must\n // not mark it failed and trigger a retry. RFC 0004 §5.\n deps.logger.error(`plugin \"${plugin.id}\" afterDeliver threw; ignoring`, {\n deliveryId: delivery.id,\n error,\n });\n }\n }\n }\n\n async function runOnce(\n options: { limit?: number; channels?: readonly Channel[]; ids?: readonly string[] } = {},\n ): Promise<SweepResult> {\n const claimToken = crypto.randomUUID();\n\n const claimed = await deps.adapter.claimPendingDeliveries({\n limit: options.limit ?? deps.batchSize,\n leaseMs: deps.leaseMs,\n claimToken,\n ...(options.channels ? { channels: options.channels } : {}),\n ...(options.ids ? { ids: options.ids } : {}),\n });\n\n if (claimed.length === 0) return { claimed: 0, sent: 0, failed: 0, skipped: 0 };\n\n const userIds = [...new Set(claimed.map((row) => row.notification.userId))];\n const recipients = await deps.getRecipients(userIds);\n const byUserId = new Map(recipients.map((recipient) => [recipient.userId, recipient]));\n\n const releases: DeliveryRelease[] = [];\n let sent = 0;\n let failed = 0;\n let skipped = 0;\n\n for (const delivery of claimed) {\n const outcome = await deliverOne(delivery, byUserId.get(delivery.notification.userId));\n\n if (outcome.result === \"sent\") sent += 1;\n else if (outcome.result === \"skipped\") skipped += 1;\n else failed += 1;\n\n releases.push({\n id: delivery.id,\n claimToken,\n outcome,\n ...(outcome.result === \"failed\" && outcome.retryable\n ? { nextAttemptAt: nextAttemptAt(delivery.attempts + 1, deps.backoff) }\n : {}),\n });\n\n await notifyAfterDeliver(delivery, outcome);\n }\n\n await deps.adapter.releaseDeliveries(releases);\n return { claimed: claimed.length, sent, failed, skipped };\n }\n\n /** Drains until empty. Pass `ids` to bound it to one send. */\n async function drain(\n options: { maxSweeps?: number; ids?: readonly string[] } = {},\n ): Promise<SweepResult> {\n const maxSweeps = options.maxSweeps ?? 50;\n const total: SweepResult = { claimed: 0, sent: 0, failed: 0, skipped: 0 };\n\n for (let sweep = 0; sweep < maxSweeps; sweep += 1) {\n const result = await runOnce(options.ids ? { ids: options.ids } : {});\n total.claimed += result.claimed;\n total.sent += result.sent;\n total.failed += result.failed;\n total.skipped += result.skipped;\n if (result.claimed === 0) break;\n }\n\n return total;\n }\n\n let warnedMissingWaitUntil = false;\n\n function scheduleDeferred(\n waitUntil?: (promise: Promise<unknown>) => void,\n ids?: readonly string[],\n ): void {\n if (waitUntil) {\n waitUntil(\n drain({ maxSweeps: 10, ...(ids ? { ids } : {}) }).catch((error) =>\n deps.logger.error(\"deferred sweep failed\", { error }),\n ),\n );\n return;\n }\n\n if (!warnedMissingWaitUntil) {\n warnedMissingWaitUntil = true;\n deps.logger.warn(\n \"delivery.mode is 'deferred' but no delivery.waitUntil was supplied — \" +\n \"falling back to the cron sweep. Notifications are not lost, but they \" +\n \"will not be delivered until POST /cron runs.\",\n );\n }\n }\n\n function startWorker(options: { intervalMs?: number; batchSize?: number } = {}): Worker {\n const intervalMs = options.intervalMs ?? 1_000;\n let stopped = false;\n\n const loop = (async () => {\n while (!stopped) {\n try {\n const result = await runOnce({ limit: options.batchSize ?? deps.batchSize });\n // Only idle when there was nothing to do; otherwise keep draining.\n if (result.claimed === 0) await sleep(intervalMs);\n } catch (error) {\n deps.logger.error(\"worker sweep failed\", { error });\n await sleep(intervalMs);\n }\n }\n })();\n\n return {\n stop: async () => {\n stopped = true;\n // Awaiting the loop lets the in-flight sweep finish and release its\n // leases, rather than leaving rows claimed until they expire.\n await loop;\n },\n };\n }\n\n return { runOnce, drain, scheduleDeferred, startWorker };\n}\n\nexport type Runner = ReturnType<typeof createRunner>;\n"]}