easy-ping 0.4.0 → 0.6.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 (140) hide show
  1. package/README.md +548 -427
  2. package/dist/adapter-B7R4rXtJ.d.cts +17 -0
  3. package/dist/adapter-B7R4rXtJ.d.ts +17 -0
  4. package/dist/{adapter-BfLe2J0t.d.cts → adapter-D3lTy-4E.d.cts} +6 -32
  5. package/dist/{adapter-BfLe2J0t.d.ts → adapter-D3lTy-4E.d.ts} +6 -32
  6. package/dist/adapters/drizzle.cjs +1 -2
  7. package/dist/adapters/drizzle.cjs.map +1 -1
  8. package/dist/adapters/drizzle.d.cts +1 -1
  9. package/dist/adapters/drizzle.d.ts +1 -1
  10. package/dist/adapters/drizzle.js +3 -4
  11. package/dist/adapters/drizzle.js.map +1 -1
  12. package/dist/adapters/mongodb.cjs +144 -5
  13. package/dist/adapters/mongodb.cjs.map +1 -1
  14. package/dist/adapters/mongodb.d.cts +43 -18
  15. package/dist/adapters/mongodb.d.ts +43 -18
  16. package/dist/adapters/mongodb.js +77 -8
  17. package/dist/adapters/mongodb.js.map +1 -1
  18. package/dist/adapters/mysql.cjs +605 -0
  19. package/dist/adapters/mysql.cjs.map +1 -0
  20. package/dist/adapters/mysql.d.cts +38 -0
  21. package/dist/adapters/mysql.d.ts +38 -0
  22. package/dist/adapters/mysql.js +51 -0
  23. package/dist/adapters/mysql.js.map +1 -0
  24. package/dist/adapters/postgres.cjs +162 -9
  25. package/dist/adapters/postgres.cjs.map +1 -1
  26. package/dist/adapters/postgres.d.cts +44 -54
  27. package/dist/adapters/postgres.d.ts +44 -54
  28. package/dist/adapters/postgres.js +82 -42
  29. package/dist/adapters/postgres.js.map +1 -1
  30. package/dist/adapters/sqlite.cjs +593 -0
  31. package/dist/adapters/sqlite.cjs.map +1 -0
  32. package/dist/adapters/sqlite.d.cts +37 -0
  33. package/dist/adapters/sqlite.d.ts +37 -0
  34. package/dist/adapters/sqlite.js +53 -0
  35. package/dist/adapters/sqlite.js.map +1 -0
  36. package/dist/browser.cjs.map +1 -1
  37. package/dist/browser.d.cts +2 -14
  38. package/dist/browser.d.ts +2 -14
  39. package/dist/browser.js.map +1 -1
  40. package/dist/chunk-2EYKPF5A.js +393 -0
  41. package/dist/chunk-2EYKPF5A.js.map +1 -0
  42. package/dist/{chunk-3Y2MBUAW.js → chunk-3VOQOGDR.js} +4 -3
  43. package/dist/chunk-3VOQOGDR.js.map +1 -0
  44. package/dist/{chunk-PSJHXZPR.js → chunk-AEYYTE5I.js} +2 -2
  45. package/dist/chunk-AEYYTE5I.js.map +1 -0
  46. package/dist/chunk-CK5UPVYP.js +48 -0
  47. package/dist/chunk-CK5UPVYP.js.map +1 -0
  48. package/dist/{chunk-W6I3B2CI.js → chunk-E4IZ2CQC.js} +2 -2
  49. package/dist/{chunk-W6I3B2CI.js.map → chunk-E4IZ2CQC.js.map} +1 -1
  50. package/dist/chunk-HY47TQMY.js +52 -0
  51. package/dist/chunk-HY47TQMY.js.map +1 -0
  52. package/dist/{chunk-CYKAM7LE.js → chunk-JKTMUMQK.js} +31 -6
  53. package/dist/chunk-JKTMUMQK.js.map +1 -0
  54. package/dist/chunk-M4R77BJD.js +64 -0
  55. package/dist/chunk-M4R77BJD.js.map +1 -0
  56. package/dist/chunk-ORZKH224.js +75 -0
  57. package/dist/chunk-ORZKH224.js.map +1 -0
  58. package/dist/{chunk-DG3ZL5NP.js → chunk-USQ6CQTJ.js} +88 -3
  59. package/dist/chunk-USQ6CQTJ.js.map +1 -0
  60. package/dist/{chunk-YEMRRMJQ.js → chunk-XIDVLYNQ.js} +14 -4
  61. package/dist/chunk-XIDVLYNQ.js.map +1 -0
  62. package/dist/client.cjs +288 -17
  63. package/dist/client.cjs.map +1 -1
  64. package/dist/client.d.cts +53 -1
  65. package/dist/client.d.ts +53 -1
  66. package/dist/client.js +288 -17
  67. package/dist/client.js.map +1 -1
  68. package/dist/index.cjs +239 -9
  69. package/dist/index.cjs.map +1 -1
  70. package/dist/index.d.cts +53 -57
  71. package/dist/index.d.ts +53 -57
  72. package/dist/index.js +60 -13
  73. package/dist/index.js.map +1 -1
  74. package/dist/node.cjs +26 -5
  75. package/dist/node.cjs.map +1 -1
  76. package/dist/node.d.cts +1 -1
  77. package/dist/node.d.ts +1 -1
  78. package/dist/node.js +28 -6
  79. package/dist/node.js.map +1 -1
  80. package/dist/{plugin-C9Hoxus2.d.ts → plugin-CHd2ZiGC.d.cts} +5 -21
  81. package/dist/{plugin-CiMsGlQe.d.cts → plugin-Cg9usuUi.d.ts} +5 -21
  82. package/dist/plugins/digests.cjs +1 -4
  83. package/dist/plugins/digests.cjs.map +1 -1
  84. package/dist/plugins/digests.d.cts +6 -31
  85. package/dist/plugins/digests.d.ts +6 -31
  86. package/dist/plugins/digests.js +1 -4
  87. package/dist/plugins/digests.js.map +1 -1
  88. package/dist/plugins/preferences-client.cjs.map +1 -1
  89. package/dist/plugins/preferences-client.d.cts +2 -2
  90. package/dist/plugins/preferences-client.d.ts +2 -2
  91. package/dist/plugins/preferences-client.js +1 -1
  92. package/dist/plugins/preferences-react.cjs.map +1 -1
  93. package/dist/plugins/preferences-react.js.map +1 -1
  94. package/dist/plugins/preferences.cjs +1 -2
  95. package/dist/plugins/preferences.cjs.map +1 -1
  96. package/dist/plugins/preferences.d.cts +2 -2
  97. package/dist/plugins/preferences.d.ts +2 -2
  98. package/dist/plugins/preferences.js +3 -3
  99. package/dist/plugins/preferences.js.map +1 -1
  100. package/dist/plugins/push.cjs +1 -2
  101. package/dist/plugins/push.cjs.map +1 -1
  102. package/dist/plugins/push.d.cts +6 -26
  103. package/dist/plugins/push.d.ts +6 -26
  104. package/dist/plugins/push.js +4 -4
  105. package/dist/plugins/push.js.map +1 -1
  106. package/dist/providers/web-push.cjs.map +1 -1
  107. package/dist/providers/web-push.d.cts +3 -10
  108. package/dist/providers/web-push.d.ts +3 -10
  109. package/dist/providers/web-push.js.map +1 -1
  110. package/dist/react.cjs +311 -19
  111. package/dist/react.cjs.map +1 -1
  112. package/dist/react.d.cts +53 -4
  113. package/dist/react.d.ts +53 -4
  114. package/dist/react.js +311 -19
  115. package/dist/react.js.map +1 -1
  116. package/dist/schema.cjs +119 -1
  117. package/dist/schema.cjs.map +1 -1
  118. package/dist/schema.d.cts +20 -14
  119. package/dist/schema.d.ts +20 -14
  120. package/dist/schema.js +4 -2
  121. package/dist/signals-kPmOlKDX.d.cts +27 -0
  122. package/dist/signals-kPmOlKDX.d.ts +27 -0
  123. package/dist/sw.cjs +39 -0
  124. package/dist/sw.cjs.map +1 -0
  125. package/dist/sw.d.cts +52 -0
  126. package/dist/sw.d.ts +52 -0
  127. package/dist/sw.js +36 -0
  128. package/dist/sw.js.map +1 -0
  129. package/dist/testing.cjs +3 -19
  130. package/dist/testing.cjs.map +1 -1
  131. package/dist/testing.d.cts +4 -22
  132. package/dist/testing.d.ts +4 -22
  133. package/dist/testing.js +3 -19
  134. package/dist/testing.js.map +1 -1
  135. package/package.json +17 -1
  136. package/dist/chunk-3Y2MBUAW.js.map +0 -1
  137. package/dist/chunk-CYKAM7LE.js.map +0 -1
  138. package/dist/chunk-DG3ZL5NP.js.map +0 -1
  139. package/dist/chunk-PSJHXZPR.js.map +0 -1
  140. package/dist/chunk-YEMRRMJQ.js.map +0 -1
package/README.md CHANGED
@@ -1,427 +1,548 @@
1
- # easy-ping
2
-
3
- **Own your notifications.** Your database, your users, no per-notification pricing.
4
-
5
- A framework-agnostic, type-safe, self-hosted notifications library for TypeScript — in-app inbox, transactional email, and a plugin system for the rest.
6
-
7
- > **Status: pre-release (v0.0.0).** The core pipeline, both adapters (Postgres and MongoDB), the React client, and the preferences, digests and push plugins all work and are covered by tests against real databases. Web push is verified end to end against Mozilla's production push service and cross-checked against `http_ece`. Realtime and batching are not built, and the Resend provider has still only been exercised against a stub. APIs may still move.
8
-
9
- ---
10
-
11
- ## Why
12
-
13
- Every app past the weekend-project stage needs a notification bell, transactional email, and user preferences. The options are a platform you deploy (Novu), a SaaS you rent per notification (Knock, Courier), a workflow platform plus your own table (Inngest + Resend), or hand-rolling it badly.
14
-
15
- easy-ping runs **inside your app**, stores notifications in **your database**, and never charges per send.
16
-
17
- **Scale target:** thousands to low-millions of notifications per month. Not Slack-scale fan-out. Every "no queue required" decision below follows from that.
18
-
19
- ---
20
-
21
- ## Quickstart
22
-
23
- ### 1. Install
24
-
25
- ```bash
26
- pnpm add easy-ping drizzle-orm postgres zod
27
- ```
28
-
29
- ### 2. Create the tables
30
-
31
- ```ts
32
- // db/schema.ts
33
- import { createSchema } from "easy-ping/adapters/drizzle";
34
-
35
- export const { notification, notificationDelivery, notificationPreference } = createSchema();
36
- ```
37
-
38
- Push them with `drizzle-kit`, or generate raw SQL:
39
-
40
- ```ts
41
- import { coreSchema, renderPostgresDdl } from "easy-ping/schema";
42
-
43
- for (const statement of renderPostgresDdl(coreSchema)) await sql.unsafe(statement);
44
- ```
45
-
46
- <details>
47
- <summary>Without an ORM (plain pg, postgres.js, Kysely…)</summary>
48
-
49
- ```bash
50
- pnpm add easy-ping pg zod
51
- ```
52
-
53
- ```ts
54
- import { postgresAdapter } from "easy-ping/adapters/postgres";
55
- import { Pool } from "pg";
56
-
57
- const pool = new Pool({ connectionString: process.env.DATABASE_URL });
58
-
59
- const database = postgresAdapter(
60
- async (text, params) => (await pool.query(text, params as unknown[])).rows,
61
- {
62
- // Optional, but it is what makes createNotifications atomic.
63
- transaction: async (fn) => {
64
- const client = await pool.connect();
65
- try {
66
- await client.query("BEGIN");
67
- const result = await fn(async (t, p) => (await client.query(t, p as unknown[])).rows);
68
- await client.query("COMMIT");
69
- return result;
70
- } catch (error) {
71
- await client.query("ROLLBACK");
72
- throw error;
73
- } finally {
74
- client.release();
75
- }
76
- },
77
- },
78
- );
79
- ```
80
-
81
- One function — run a parameterised statement, return rows — is the entire contract.
82
- Anything that can do that works: `pg`, `postgres.js`, Kysely, Neon or PlanetScale's
83
- serverless drivers, or Prisma's `$queryRawUnsafe`.
84
-
85
- </details>
86
-
87
- <details>
88
- <summary>On MongoDB instead</summary>
89
-
90
- ```bash
91
- pnpm add easy-ping mongodb zod
92
- ```
93
-
94
- ```ts
95
- import { createMongoIndexes, mongoAdapter } from "easy-ping/adapters/mongodb";
96
-
97
- const client = new MongoClient(process.env.MONGO_URL!);
98
- await client.connect();
99
- const db = client.db("app");
100
-
101
- // There are no tables to create, only indexes. Run once at startup.
102
- await createMongoIndexes(db);
103
-
104
- // Pass the client too: it is what makes a notification and its deliveries
105
- // land together, which needs a replica set.
106
- const database = mongoAdapter(db, { client });
107
- ```
108
-
109
- Everything after this point is identical.
110
-
111
- </details>
112
-
113
- ### 3. Configure
114
-
115
- ```ts
116
- // notify.ts
117
- import { defineNotification, easyPing, escapeHtml } from "easy-ping";
118
- import { drizzleAdapter } from "easy-ping/adapters/drizzle";
119
- import { resend } from "easy-ping/providers/resend";
120
- import { after } from "next/server";
121
- import { inArray } from "drizzle-orm";
122
- import { z } from "zod";
123
- import { auth } from "./auth";
124
- import { db, users } from "./db";
125
-
126
- export const notify = easyPing({
127
- database: drizzleAdapter(db),
128
-
129
- // Both at least 16 characters; `openssl rand -base64 32` is the easy way.
130
- secret: process.env.NOTIFY_SECRET!,
131
- cron: { secret: process.env.NOTIFY_CRON_SECRET! },
132
-
133
- // Required. The mounted endpoints serve a user's private inbox.
134
- session: {
135
- getUserId: async (request) =>
136
- (await auth.api.getSession({ headers: request.headers }))?.user.id ?? null,
137
- },
138
-
139
- // Batched — one call per send, never one per recipient.
140
- getRecipients: async (userIds) =>
141
- (await db.select().from(users).where(inArray(users.id, [...userIds]))).map((u) => ({
142
- userId: u.id,
143
- email: u.email,
144
- timezone: u.timezone,
145
- locale: u.locale,
146
- })),
147
-
148
- channels: {
149
- inApp: { enabled: true },
150
- email: { provider: resend({ apiKey: process.env.RESEND_API_KEY!, from: "Acme <hi@acme.dev>" }) },
151
- },
152
-
153
- delivery: { mode: "deferred", waitUntil: after },
154
-
155
- notifications: {
156
- commentReply: defineNotification({
157
- schema: z.object({ authorName: z.string(), commentId: z.string() }),
158
- channels: ["inApp", "email"],
159
- email: {
160
- subject: (p) => `${p.authorName} replied to you`,
161
- // Anything a user typed goes through escapeHtml, or their markup ships from your domain.
162
- template: (p) =>
163
- `<p>${escapeHtml(p.authorName)} replied. <a href="/c/${encodeURIComponent(p.commentId)}">View</a></p>`,
164
- },
165
- }),
166
- },
167
- });
168
- ```
169
-
170
- ### 4. Mount the endpoints
171
-
172
- ```ts
173
- // app/api/notifications/[...notify]/route.ts
174
- import { notify } from "@/notify";
175
-
176
- export const { GET, POST } = notify.handler;
177
- ```
178
-
179
- ### 5. Send
180
-
181
- ```ts
182
- await notify.send("commentReply", {
183
- to: threadOwnerId,
184
- payload: { authorName: "Dana", commentId: "c_123" }, // typed against the schema
185
- dedupeKey: `commentReply:c_123:${threadOwnerId}`, // optional, makes retries safe
186
- });
187
- ```
188
-
189
- ### 6. Render the bell
190
-
191
- ```tsx
192
- "use client";
193
- import { useNotifications } from "easy-ping/react";
194
-
195
- export function Bell() {
196
- const { notifications, unseenCount, markAsRead, markSeen } = useNotifications();
197
-
198
- return (
199
- <button onClick={() => markSeen()}>
200
- {unseenCount}
201
- {notifications.map((n) => (
202
- <div key={n.id} onClick={() => markAsRead(n.id)}>
203
- {n.type} {n.readAt ? "" : "•"}
204
- </div>
205
- ))}
206
- </button>
207
- );
208
- }
209
- ```
210
-
211
- ### 7. Wire the cron
212
-
213
- ```
214
- POST /api/notifications/cron
215
- Authorization: Bearer $NOTIFY_CRON_SECRET
216
- ```
217
-
218
- Every 1–5 minutes, from Vercel Cron, GitHub Actions, or anything else. This is the durability floor beneath every delivery mode — **required** for `cron` and `deferred`.
219
-
220
- ---
221
-
222
- ## How delivery works
223
-
224
- `send()` validates, resolves recipients, runs hooks, and writes the notification and delivery rows in one transaction. **Committing those rows is the only thing it has to do** — a 200–800ms provider round trip has no business on a comment POST.
225
-
226
- What happens next is `delivery.mode`'s job. In `deferred`, `worker` and `cron`, `send()` returns as soon as the rows are committed and never waits on a provider. `inline` is the exception: it awaits that send's own deliveries before resolving, which is the tradeoff you accept for the simplest possible setup.
227
-
228
- Delivery then happens according to `delivery.mode`:
229
-
230
- | mode | delivery happens | latency | needs cron |
231
- | --- | --- | --- | --- |
232
- | `inline` | before `send()` resolves | in-request | recommended |
233
- | `deferred` | after the response, via `waitUntil` | ~1s | **yes** |
234
- | `worker` | in-process poller | ~1s | optional |
235
- | `cron` | when the sweep runs | up to the interval | **yes** |
236
-
237
- Modes are **additive**. `deferred` and `worker` are latency optimisations layered over the cron sweep — the rows are already committed, so a missing platform primitive or a crashed process costs latency, never a notification.
238
-
239
- ```ts
240
- const worker = notify.startWorker({ intervalMs: 1000 });
241
- process.on("SIGTERM", () => worker.stop()); // drains in-flight work, releases leases
242
- ```
243
-
244
- **Delivery is at-least-once.** Providers receive an idempotency key derived from the delivery id. Retries use exponential backoff with jitter (30s → 2m → 8m → 32m, five attempts), floored by your cron interval. Non-retryable failures — a revoked API key, an invalid recipient — fail immediately rather than burning all five attempts.
245
-
246
- **Not for OTP or 2FA codes.** Use your auth library's own sender. A retry-and-sweep model is wrong for a 60-second TTL.
247
-
248
- ---
249
-
250
- ## Channels
251
-
252
- | channel | state |
253
- | --- | --- |
254
- | `inApp` | ✅ built in, on by default, needs no provider |
255
- | `email` | ✅ Resend provider; the interface is open for others |
256
- | `push` | ✅ push plugin + `webPush()` — VAPID and aes128gcm on Web Crypto, so it runs on edge too |
257
- | `sms` | ⬜ not implemented |
258
- | `slack` | ⬜ not implemented |
259
-
260
- A channel is usable when core carries it (`inApp`, `email`) or a plugin declares it and can `deliver` it. That is how push works, and how sms and slack will.
261
-
262
- Declaring a channel nothing can carry **warns at startup** and reports `skipped: "channel-unavailable"` — deliberately distinct from `"no-channels"`, so a missing provider never looks like a user opt-out.
263
-
264
- ## Upgrading
265
-
266
- A later version may add a column. How you pick it up depends on how you created the tables:
267
-
268
- | you bootstrapped with | to upgrade |
269
- | --- | --- |
270
- | `createSchema()` + drizzle-kit | `drizzle-kit` diffs it for you — nothing to do here |
271
- | `renderPostgresDdl()` | `planPostgresMigration()` — see below |
272
- | `createMongoIndexes()` | rerun it; `createIndex` is idempotent and additive |
273
-
274
- **`renderPostgresDdl` cannot upgrade you.** It emits `CREATE TABLE IF NOT EXISTS`, which is correct exactly once and a silent no-op afterwards. Rerunning it on an existing database does nothing at all.
275
-
276
- ```ts
277
- import { INTROSPECT_SQL, planPostgresMigration, coreSchema } from "easy-ping/schema";
278
-
279
- const plan = await planPostgresMigration(
280
- async () =>
281
- (await sql.unsafe(INTROSPECT_SQL)).map((row) => ({
282
- table: row.table_name,
283
- column: row.column_name,
284
- type: row.data_type,
285
- nullable: row.is_nullable === "YES",
286
- })),
287
- coreSchema,
288
- );
289
-
290
- for (const statement of plan.statements) await sql.unsafe(statement);
291
- if (plan.unsupported.length) console.warn(plan.unsupported);
292
- ```
293
-
294
- **Additive only, deliberately.** It adds missing columns and indexes, and creates tables that do not exist. It never drops a column, never changes a type, and never touches a column it did not declare — those land in `unsupported` as a message for a human, because they are destructive and context-dependent.
295
-
296
- One behaviour worth knowing: a **required** column with no default is added **nullable**, because `NOT NULL` would abort against existing rows. That is reported in `unsupported`; backfill it and `SET NOT NULL` yourself.
297
-
298
- Run the plugins' schemas the same way — `push({...}).schema`, `preferences().schema`.
299
-
300
- ---
301
-
302
- ## When something fails
303
-
304
- Delivery is at-least-once with five attempts and backoff, which means failures are quiet by design. To see them:
305
-
306
- ```ts
307
- const failed = await notify.getFailedDeliveries({ since: new Date(Date.now() - 86_400_000) });
308
- // [{ id, notificationId, channel, attempts, lastError, updatedAt, ... }]
309
- ```
310
-
311
- Defaults to the last 24 hours, capped at 1000 rows. Wire it to an admin page or an alert — otherwise the retry machinery is a black box, and a revoked API key looks exactly like nothing happening.
312
-
313
- `notify.healthCheck()` reports the delivery mode and warns when a mode needs a cron you have not mounted.
314
-
315
- ---
316
-
317
- ## Security notes
318
-
319
- - **`session.getUserId` is mandatory.** There is no default and no dev bypass — an insecure default ships, a startup crash doesn't. Returning `null` yields 401; *throwing* yields 500, because a broken session store and an absent session are different bugs.
320
- - **Every query is scoped server-side.** No route accepts a user id from the client. Marking someone else's notification read returns 404, not 403 — a 403 confirms the row exists.
321
- - **`cron.secret` is required** for modes that rely on the sweep. Unauthenticated, that endpoint is a free flush-everything trigger against your email provider. Compared in constant time. Plugin machine routes (`/push/prune`, `/digests/cron`) accept it too unless you set `machineSecret`, which keeps the scheduler's credential to `/cron` alone. One call drains at most `cron.maxSweeps` sweeps (default 50).
322
- - **Plugins never see the secret.** They get `ctx.sign()`, which mints tokens only for the purposes their own signed routes declare; keys are derived per purpose with HKDF, so an unsubscribe key signs nothing else. `notify.listRoutes()` shows every mounted route with its auth scope, and a `custom`-scoped route is named in a startup warning with its justification.
323
- - **Secrets must be at least 16 characters** and not a placeholder; startup throws otherwise. A short HMAC key makes every unsubscribe link forgeable offline.
324
- - **CSRF.** Every POST must be `application/json` (415 otherwise) and, when the browser sends an `Origin`, it must match the request host or an entry in `trustedOrigins` (403 otherwise). This holds even if your session cookie is `SameSite=None`. Set `trustedOrigins: ["https://app.example.com", "*.example.com"]` when the API lives on a different origin from the page.
325
- - **Push endpoints are validated at registration.** Public https hosts only, well-formed keys, one owner per endpoint for life (409 if another account registered it), at most `maxDevicesPerUser` rows per user. Pin `allowedEndpointHosts` to the push services you expect if you want the SSRF surface closed entirely.
326
- - **Email templates are sent as-is.** Run every payload field a user could have typed through `escapeHtml`, or use a templating library that escapes by default. The quickstart shows the pattern.
327
- - **In-app payloads are served to the browser verbatim.** Never put anything in `payload` the recipient shouldn't read, and never render it with `innerHTML`.
328
- - **Request bodies are capped at 64 KiB** (`maxBodyBytes`), responses carry `Cache-Control: private, no-store`, and a thrown adapter error is a logged 500 rather than a stack trace or a crashed process.
329
- - **Rate limiting.** `rateLimit: { max, windowMs }` is an in-process fixed-window limiter keyed by the client address from `cf-connecting-ip` / `x-real-ip` / `x-forwarded-for` (or your own `key`), applied to every route including `/cron` and `/unsubscribe`. It is per process, not global: enough to blunt secret guessing and poll storms, not a quota. `onRequest` runs before it if you have a shared limiter of your own. The inbox is polled by every open tab, so size limits with that in mind.
330
- - **Unsubscribe tokens** travel in the URL, so they will appear in access logs. They are valid for 30 days by default, capped at 90. A token is refused if the user changed that preference after it was issued, so an old link cannot undo a newer decision; clicking the same link twice is still a 200. `last_error` stores provider messages with email addresses redacted.
331
-
332
- ---
333
-
334
- ## Status
335
-
336
- | | |
337
- | --- | --- |
338
- | ✅ Core `send()` pipeline, hooks, dedupe | |
339
- | ✅ Postgres through any driver — no ORM needed | `pg`, `postgres.js`, Kysely, Neon… |
340
- | ✅ Postgres via Drizzle, for those already on it | same conformance suite |
341
- | ✅ MongoDB | same conformance suite |
342
- | ✅ Delivery runner, all four modes, retry + backoff | |
343
- | ✅ Resend provider | |
344
- | ✅ Route handler, session scoping, cron | |
345
- | ✅ React client, polling, optimistic updates | |
346
- | ✅ preferences plugin + headless `usePreferences` | the wedge |
347
- | ✅ digests plugin, timezone-aware | |
348
- | ✅ push plugin + web-push provider | VAPID + RFC 8291, no node:crypto |
349
- | ✅ push verified against a live push service | Mozilla autopush, plus a cross-check against `http_ece` |
350
- | ✅ scoped plugin storage, so plugins own their tables | |
351
- | ✅ additive schema migrations for the raw-SQL path | `planPostgresMigration()` |
352
- | ✅ failed deliveries reachable from the instance | `notify.getFailedDeliveries()` |
353
- | ⬜ realtime, batching | |
354
- | ⬜ Prisma / Kysely adapters, Vue / Svelte bindings | |
355
-
356
- ---
357
-
358
- ## Writing an adapter
359
-
360
- The one operation with no equivalent in other libraries is atomic claiming — without it, two concurrent sweeps send the same email twice. Verify yours:
361
-
362
- ```ts
363
- import { adapterConformanceCases } from "easy-ping/testing";
364
-
365
- for (const testCase of adapterConformanceCases) {
366
- it(testCase.name, () => testCase.run({ adapter, reset, setAttempts, lockRow }));
367
- }
368
- ```
369
-
370
- Thirteen cases. The one that matters asserts a row locked by another transaction is *skipped*, not waited on; it is tagged `requires: "rowLock"`, and a store whose claim is a single atomic update filters it out rather than faking it.
371
-
372
- Declare your dialect on the adapter so the plugin store writes what your driver expects:
373
-
374
- ```ts
375
- naming: "snake_case" | "preserve" // columns, or the declared field names
376
- serializesJson: boolean // json as a string, or natively
377
- ```
378
-
379
- ---
380
-
381
- ## Development
382
-
383
- ```bash
384
- pnpm install
385
- docker compose up -d # Postgres on :54329, MongoDB on :27019
386
- pnpm test
387
- ```
388
-
389
- Mongo runs as a single-node replica set, because that is the only way it offers transactions.
390
-
391
- Database tests skip locally when a database is unreachable, and **fail** in CI — a green build that ran none of them is worse than a red one.
392
-
393
- The push crypto is checked two ways. `web-push-reference.test.ts` decrypts our output with `http_ece` — the library `web-push` npm uses — because a decryptor written from the same RFC would share any misreading and agree with itself. `web-push-live.test.ts` then sends through Mozilla's production push service for real; it is opt-in so CI never goes red because someone else's service is having a bad afternoon:
394
-
395
- ```bash
396
- EASY_PING_LIVE_PUSH=1 pnpm --filter easy-ping test web-push-live
397
- ```
398
-
399
- The Resend provider is checked against Resend's real API too: the rejection paths need no credentials — a bogus key coming back as a structured 401 rather than a 400 is what proves the request shape is right. The delivery leg needs your own key:
400
-
401
- ```bash
402
- RESEND_API_KEY=re_... RESEND_FROM="Acme <hi@acme.dev>" pnpm --filter easy-ping test resend-live
403
- ```
404
-
405
- ## Releasing
406
-
407
- Nothing is on npm yet. The name `easy-ping` is unclaimed.
408
-
409
- The Release workflow only maintains the version PR; it does **not** publish. npm trusted publishing (OIDC) cannot create a package that does not exist — a trusted publisher is configured against an existing package, so the first `PUT` is rejected as `E404`, which reads like "name taken" and is not.
410
-
411
- The first release is manual:
412
-
413
- ```bash
414
- # 1. bump off 0.0.0
415
- pnpm changeset # choose minor -> 0.1.0
416
- pnpm changeset version
417
-
418
- # 2. publish once, by hand
419
- npm login
420
- pnpm --filter easy-ping publish --access public
421
- ```
422
-
423
- Then enable trusted publishing on npmjs.com for this repo and this workflow, and re-add `publish: pnpm changeset publish` to `.github/workflows/release.yml`. Every release after that is automatic.
424
-
425
- ## License
426
-
427
- MIT
1
+ <div align="center">
2
+
3
+ # easy-ping
4
+
5
+ **Own your notifications.**
6
+ In-app inbox, transactional email and web push — running inside your app, stored in your
7
+ database, with no per-notification bill.
8
+
9
+ [![npm](https://img.shields.io/npm/v/easy-ping?color=%23e0362a&label=npm)](https://www.npmjs.com/package/easy-ping)
10
+ [![install size](https://packagephobia.com/badge?p=easy-ping)](https://packagephobia.com/result?p=easy-ping)
11
+ [![node](https://img.shields.io/node/v/easy-ping)](https://www.npmjs.com/package/easy-ping)
12
+ [![license](https://img.shields.io/npm/l/easy-ping?color=blue)](./LICENSE)
13
+
14
+ [Documentation](https://easy-notify-website.vercel.app) · [Quickstart](https://easy-notify-website.vercel.app/docs/quickstart) · [Changelog](https://easy-notify-website.vercel.app/docs/changelog) · [AI context](https://easy-notify-website.vercel.app/docs/ai-assistant)
15
+
16
+ </div>
17
+
18
+ ---
19
+
20
+ ```ts
21
+ await notify.send("commentReply", {
22
+ to: threadOwnerId,
23
+ payload: { authorName: "Dana", commentId: "c_123" }, // typed from your schema
24
+ dedupeKey: `commentReply:c_123:${threadOwnerId}`, // optional, makes retries safe
25
+ });
26
+ ```
27
+
28
+ ```tsx
29
+ const { notifications, unseenCount, markSeen, markAsRead } = useNotifications();
30
+ ```
31
+
32
+ One config file, one `send()`, one hook. No queue, no worker service, no vendor.
33
+
34
+ ---
35
+
36
+ ## Why
37
+
38
+ Every app past the weekend-project stage needs a notification bell, transactional email and user
39
+ preferences. The alternatives are a platform you deploy, a SaaS you rent per notification, or
40
+ hand-rolling it badly.
41
+
42
+ | | easy-ping | Novu (self-hosted) | Knock · Courier |
43
+ | --- | --- | --- | --- |
44
+ | **What you deploy** | nothing, it is a dependency | four services | nothing, it is their cloud |
45
+ | **Infrastructure it adds** | none | MongoDB, Redis, S3 | none |
46
+ | **Where notifications live** | your Postgres or MongoDB | Novu's MongoDB | theirs |
47
+ | **Cost per notification** | none | none, you pay for servers | metered per send |
48
+ | **License** | MIT, all of it | MIT core, commercial modules | proprietary |
49
+ | **Visual workflow editor** | **no**, a notification is code | yes | yes |
50
+ | **Channels out of the box** | **in-app, email, web push** | dozens | dozens |
51
+
52
+ The last two rows are the trade. If you need a workflow editor a non-developer can edit, use one
53
+ of the others — they are good tools solving a bigger problem.
54
+
55
+ **Scale target:** thousands to low-millions of notifications per month. Not Slack-scale fan-out.
56
+ Every "no queue required" decision below follows from that.
57
+
58
+ > **Status: published, pre-1.0.** The core pipeline, all three adapters, the React client and the
59
+ > preferences, digests and push plugins are covered by tests against real databases. Web push is
60
+ > verified end to end against Mozilla's production push service and cross-checked against
61
+ > `http_ece`. The bell updates over a server-sent event stream with polling as the fallback;
62
+ > batching is not built, and the Resend provider has only been exercised against a stub. Minor
63
+ > versions may still move APIs before 1.0.
64
+
65
+ ---
66
+
67
+ ## Quickstart
68
+
69
+ ### 1. Install
70
+
71
+ ```bash
72
+ pnpm add easy-ping pg zod
73
+ ```
74
+
75
+ ### 2. Create the tables
76
+
77
+ No ORM required. The whole database contract is one function: run a parameterised statement,
78
+ return rows.
79
+
80
+ ```ts
81
+ import { createPostgresTables, pgTransaction, postgresAdapter } from "easy-ping/adapters/postgres";
82
+ import { Pool } from "pg";
83
+
84
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL });
85
+ const query = async (text, params) => (await pool.query(text, params)).rows;
86
+
87
+ // Renders the DDL from the installed version's own schema, so the tables
88
+ // cannot drift from the package. Every statement is IF NOT EXISTS.
89
+ await createPostgresTables(query);
90
+
91
+ export const database = postgresAdapter(query, { transaction: pgTransaction(pool) });
92
+ ```
93
+
94
+ Anything that can run a statement works: `pg`, `postgres.js`, Kysely, Neon or PlanetScale's
95
+ serverless drivers, or Prisma's `$queryRawUnsafe`.
96
+
97
+ <details>
98
+ <summary>Already on Drizzle</summary>
99
+
100
+ ```ts
101
+ // db/schema.ts
102
+ import { createSchema } from "easy-ping/adapters/drizzle";
103
+
104
+ export const { notification, notificationDelivery, notificationPreference } = createSchema();
105
+ ```
106
+
107
+ Push it with `drizzle-kit`, then `drizzleAdapter(db)` in place of `postgresAdapter`.
108
+
109
+ </details>
110
+
111
+ <details>
112
+ <summary>On MongoDB instead</summary>
113
+
114
+ ```bash
115
+ pnpm add easy-ping mongodb zod
116
+ ```
117
+
118
+ ```ts
119
+ import { createMongoIndexes, mongoAdapter } from "easy-ping/adapters/mongodb";
120
+
121
+ const client = new MongoClient(process.env.MONGO_URL!);
122
+ await client.connect();
123
+ const db = client.db("app");
124
+
125
+ // No tables, only indexes. Run once at startup.
126
+ await createMongoIndexes(db);
127
+
128
+ // Pass the client too: it is what makes a notification and its deliveries
129
+ // land together, which needs a replica set.
130
+ export const database = mongoAdapter(db, { client });
131
+ ```
132
+
133
+ Everything after this point is identical.
134
+
135
+ </details>
136
+
137
+ <details>
138
+ <summary>On MySQL instead</summary>
139
+
140
+ ```bash
141
+ pnpm add easy-ping mysql2 zod
142
+ ```
143
+
144
+ ```ts
145
+ import { createMysqlTables, mysql2Query, mysqlAdapter, mysqlTransaction } from "easy-ping/adapters/mysql";
146
+ import mysql from "mysql2/promise";
147
+
148
+ // timezone "Z": the adapter writes DATETIME columns as UTC and must read them back unshifted.
149
+ const pool = mysql.createPool({ uri: process.env.DATABASE_URL, timezone: "Z" });
150
+ const query = mysql2Query(pool);
151
+
152
+ await createMysqlTables(query); // CREATE TABLE IF NOT EXISTS, safe on every boot
153
+
154
+ // With a transaction, a claim uses FOR UPDATE SKIP LOCKED (MySQL 8+). Without
155
+ // one it is a single lock-free UPDATE that still never double-claims a row.
156
+ export const database = mysqlAdapter(query, { transaction: mysqlTransaction(pool) });
157
+ ```
158
+
159
+ MySQL 8 or MariaDB. Any driver works: the contract is `(text, params) => Promise<{ rows, affectedRows }>`.
160
+
161
+ </details>
162
+
163
+ <details>
164
+ <summary>On SQLite instead</summary>
165
+
166
+ ```ts
167
+ import { createSqliteTables, sqliteAdapter, sqliteQuery, sqliteTransaction } from "easy-ping/adapters/sqlite";
168
+ import { DatabaseSync } from "node:sqlite"; // Node 22.13+; better-sqlite3 works the same way
169
+
170
+ const db = new DatabaseSync("./app.db");
171
+ const query = sqliteQuery(db);
172
+
173
+ await createSqliteTables(query);
174
+
175
+ export const database = sqliteAdapter(query, { transaction: sqliteTransaction(db) });
176
+ ```
177
+
178
+ SQLite has one writer at a time, so the claim is a plain `UPDATE … WHERE id IN (SELECT … LIMIT ?)`; two sweeps cannot interleave. Dates are ISO text, booleans 0/1, JSON text.
179
+
180
+ </details>
181
+
182
+ <details>
183
+ <summary>Adding a plugin's tables</summary>
184
+
185
+ Plugins own their own tables and export the declaration, so no plugin instance is needed:
186
+
187
+ ```ts
188
+ import { pushSchema } from "easy-ping/plugins/push";
189
+
190
+ await createPostgresTables(query, { plugins: [pushSchema] });
191
+ ```
192
+
193
+ </details>
194
+
195
+ ### 3. Configure
196
+
197
+ ```ts
198
+ // notify.ts
199
+ import { defineNotification, easyPing, escapeHtml } from "easy-ping";
200
+ import { resend } from "easy-ping/providers/resend";
201
+ import { after } from "next/server";
202
+ import { z } from "zod";
203
+ import { auth } from "./auth";
204
+ import { database, pool } from "./db"; // from step 2
205
+
206
+ export const notify = easyPing({
207
+ database,
208
+
209
+ // Both at least 16 characters; `openssl rand -base64 32` is the easy way.
210
+ secret: process.env.NOTIFY_SECRET!,
211
+ cron: { secret: process.env.NOTIFY_CRON_SECRET! },
212
+
213
+ // Required. The mounted endpoints serve a user's private inbox.
214
+ session: {
215
+ getUserId: async (request) =>
216
+ (await auth.api.getSession({ headers: request.headers }))?.user.id ?? null,
217
+ },
218
+
219
+ // Batched: one call per send, never one per recipient.
220
+ getRecipients: async (userIds) => {
221
+ const { rows } = await pool.query(
222
+ "select id, email, timezone, locale from users where id = any($1)",
223
+ [userIds],
224
+ );
225
+ return rows.map((u) => ({
226
+ userId: u.id,
227
+ email: u.email,
228
+ timezone: u.timezone,
229
+ locale: u.locale,
230
+ }));
231
+ },
232
+
233
+ channels: {
234
+ inApp: { enabled: true },
235
+ email: { provider: resend({ apiKey: process.env.RESEND_API_KEY!, from: "Acme <hi@acme.dev>" }) },
236
+ },
237
+
238
+ delivery: { mode: "deferred", waitUntil: after },
239
+
240
+ notifications: {
241
+ commentReply: defineNotification({
242
+ schema: z.object({ authorName: z.string(), commentId: z.string() }),
243
+ channels: ["inApp", "email"],
244
+ email: {
245
+ subject: (p) => `${p.authorName} replied to you`,
246
+ // Anything a user typed goes through escapeHtml, or their markup ships from your domain.
247
+ template: (p) =>
248
+ `<p>${escapeHtml(p.authorName)} replied. <a href="/c/${encodeURIComponent(p.commentId)}">View</a></p>`,
249
+ },
250
+ }),
251
+ },
252
+ });
253
+ ```
254
+
255
+ ### 4. Mount the endpoints
256
+
257
+ ```ts
258
+ // app/api/notifications/[[...notify]]/route.ts
259
+ // Double brackets: Next's required catch-all would not match the bare
260
+ // /api/notifications path, which is where the feed lives.
261
+ import { notify } from "@/notify";
262
+
263
+ export const { GET, POST } = notify.handler;
264
+ ```
265
+
266
+ ### 5. Send
267
+
268
+ ```ts
269
+ await notify.send("commentReply", {
270
+ to: threadOwnerId,
271
+ payload: { authorName: "Dana", commentId: "c_123" }, // typed against the schema
272
+ dedupeKey: `commentReply:c_123:${threadOwnerId}`, // optional, makes retries safe
273
+ });
274
+ ```
275
+
276
+ ### 6. Render the bell
277
+
278
+ ```tsx
279
+ "use client";
280
+ import { useNotifications } from "easy-ping/react";
281
+
282
+ export function Bell() {
283
+ const { notifications, unseenCount, markAsRead, markSeen } = useNotifications();
284
+
285
+ return (
286
+ <button onClick={() => markSeen()}>
287
+ {unseenCount}
288
+ {notifications.map((n) => (
289
+ <div key={n.id} onClick={() => markAsRead(n.id)}>
290
+ {n.type} {n.readAt ? "" : "•"}
291
+ </div>
292
+ ))}
293
+ </button>
294
+ );
295
+ }
296
+ ```
297
+
298
+ The hook is headless and keeps itself fresh without a socket server: one tab per browser holds a
299
+ `GET /events` stream and the others mirror it over `BroadcastChannel`, so ten open tabs cost one
300
+ connection and zero idle queries. `send()`, `/read` and `/seen` push a `changed` event, the tab
301
+ refreshes, and where a stream cannot live (a serverless host that cuts it, a hardened proxy) the
302
+ client notices and falls back to polling that slows down while the user is idle. Nothing to
303
+ configure; `transport: "poll"` switches the stream off if you want the old behaviour.
304
+
305
+ Two optional extras when you want the bell fresh with no dedicated request at all:
306
+
307
+ ```ts
308
+ // Piggyback: any response from your own API can carry the inbox version.
309
+ return Response.json(data, { headers: notify.inboxHeaders(userId) });
310
+ // ...and the client refreshes only when that number moves.
311
+ const fetchWithBell = client.instrument(fetch);
312
+
313
+ // Push relay: if you already run the push plugin, the service worker wakes the bell too.
314
+ import { handlePush } from "easy-ping/sw";
315
+ self.addEventListener("push", (event) => handlePush(event));
316
+ ```
317
+
318
+ ### 7. Wire the cron
319
+
320
+ ```
321
+ POST /api/notifications/cron
322
+ Authorization: Bearer $NOTIFY_CRON_SECRET
323
+ ```
324
+
325
+ Every 1–5 minutes, from Vercel Cron, GitHub Actions, or anything else. This is the durability floor beneath every delivery mode — **required** for `cron` and `deferred`.
326
+
327
+ ---
328
+
329
+ ## How delivery works
330
+
331
+ `send()` validates, resolves recipients, runs hooks, and writes the notification and delivery rows in one transaction. **Committing those rows is the only thing it has to do** — a 200–800ms provider round trip has no business on a comment POST.
332
+
333
+ What happens next is `delivery.mode`'s job. In `deferred`, `worker` and `cron`, `send()` returns as soon as the rows are committed and never waits on a provider. `inline` is the exception: it awaits that send's own deliveries before resolving, which is the tradeoff you accept for the simplest possible setup.
334
+
335
+ Delivery then happens according to `delivery.mode`:
336
+
337
+ | mode | delivery happens | latency | needs cron |
338
+ | --- | --- | --- | --- |
339
+ | `inline` | before `send()` resolves | in-request | recommended |
340
+ | `deferred` | after the response, via `waitUntil` | ~1s | **yes** |
341
+ | `worker` | in-process loop, woken by `send()` | ~instant in-process, ≤10s across replicas | optional |
342
+ | `cron` | when the sweep runs | up to the interval | **yes** |
343
+
344
+ Modes are **additive**. `deferred` and `worker` are latency optimisations layered over the cron sweep — the rows are already committed, so a missing platform primitive or a crashed process costs latency, never a notification.
345
+
346
+ ```ts
347
+ const worker = notify.startWorker(); // idles 10s between sweeps; a send() in this process wakes it at once
348
+ process.on("SIGTERM", () => worker.stop()); // drains in-flight work, releases leases
349
+ ```
350
+
351
+ On `cron` and `deferred` hosts that see traffic but no scheduler for minutes at a time,
352
+ `delivery.sweepOnRequest: true` runs a small bounded sweep after any request, at most every 5 s,
353
+ so a free-tier deployment delivers within seconds of the next page load instead of the next cron.
354
+
355
+ **Across replicas**, wake-ups travel through `signals`: a publish says "something changed, go
356
+ look" and carries no payload, so losing one costs latency, never a notification.
357
+
358
+ ```ts
359
+ import { postgresSignals } from "easy-ping/adapters/postgres"; // LISTEN/NOTIFY, one channel
360
+ import { mongoSignals } from "easy-ping/adapters/mongodb"; // change stream on a capped collection
361
+
362
+ easyPing({ ..., signals: postgresSignals(sql) });
363
+ ```
364
+
365
+ Without one, each process uses in-memory signals and the event stream falls back to a cheap
366
+ fingerprint probe every 30 s (`events.probeIntervalMs`), which is exactly right on SQLite or a
367
+ single replica.
368
+
369
+ **Delivery is at-least-once.** Providers receive an idempotency key derived from the delivery id. Retries use exponential backoff with jitter (30s → 2m → 8m → 32m, five attempts), floored by your cron interval. Non-retryable failures — a revoked API key, an invalid recipient — fail immediately rather than burning all five attempts.
370
+
371
+ **Not for OTP or 2FA codes.** Use your auth library's own sender. A retry-and-sweep model is wrong for a 60-second TTL.
372
+
373
+ ---
374
+
375
+ ## Channels
376
+
377
+ | channel | state |
378
+ | --- | --- |
379
+ | `inApp` | ✅ built in, on by default, needs no provider |
380
+ | `email` | ✅ Resend provider; the interface is open for others |
381
+ | `push` | ✅ push plugin + `webPush()` — VAPID and aes128gcm on Web Crypto, so it runs on edge too |
382
+ | `sms` | ⬜ not implemented |
383
+ | `slack` | ⬜ not implemented |
384
+
385
+ A channel is usable when core carries it (`inApp`, `email`) or a plugin declares it and can `deliver` it. That is how push works, and how sms and slack will.
386
+
387
+ Declaring a channel nothing can carry **warns at startup** and reports `skipped: "channel-unavailable"` — deliberately distinct from `"no-channels"`, so a missing provider never looks like a user opt-out.
388
+
389
+ ## Upgrading
390
+
391
+ A later version may add a column. How you pick it up depends on how you created the tables:
392
+
393
+ | you bootstrapped with | to upgrade |
394
+ | --- | --- |
395
+ | `createSchema()` + drizzle-kit | `drizzle-kit` diffs it for you — nothing to do here |
396
+ | `renderPostgresDdl()` | `planPostgresMigration()` — see below |
397
+ | `createMongoIndexes()` | rerun it; `createIndex` is idempotent and additive |
398
+
399
+ **`renderPostgresDdl` cannot upgrade you.** It emits `CREATE TABLE IF NOT EXISTS`, which is correct exactly once and a silent no-op afterwards. Rerunning it on an existing database does nothing at all.
400
+
401
+ ```ts
402
+ import { INTROSPECT_SQL, planPostgresMigration, coreSchema } from "easy-ping/schema";
403
+
404
+ const plan = await planPostgresMigration(
405
+ async () =>
406
+ (await sql.unsafe(INTROSPECT_SQL)).map((row) => ({
407
+ table: row.table_name,
408
+ column: row.column_name,
409
+ type: row.data_type,
410
+ nullable: row.is_nullable === "YES",
411
+ })),
412
+ coreSchema,
413
+ );
414
+
415
+ for (const statement of plan.statements) await sql.unsafe(statement);
416
+ if (plan.unsupported.length) console.warn(plan.unsupported);
417
+ ```
418
+
419
+ **Additive only, deliberately.** It adds missing columns and indexes, and creates tables that do not exist. It never drops a column, never changes a type, and never touches a column it did not declare — those land in `unsupported` as a message for a human, because they are destructive and context-dependent.
420
+
421
+ One behaviour worth knowing: a **required** column with no default is added **nullable**, because `NOT NULL` would abort against existing rows. That is reported in `unsupported`; backfill it and `SET NOT NULL` yourself.
422
+
423
+ Run the plugins' schemas the same way — `push({...}).schema`, `preferences().schema`.
424
+
425
+ ---
426
+
427
+ ## When something fails
428
+
429
+ Delivery is at-least-once with five attempts and backoff, which means failures are quiet by design. To see them:
430
+
431
+ ```ts
432
+ const failed = await notify.getFailedDeliveries({ since: new Date(Date.now() - 86_400_000) });
433
+ // [{ id, notificationId, channel, attempts, lastError, updatedAt, ... }]
434
+ ```
435
+
436
+ Defaults to the last 24 hours, capped at 1000 rows. Wire it to an admin page or an alert — otherwise the retry machinery is a black box, and a revoked API key looks exactly like nothing happening.
437
+
438
+ `notify.healthCheck()` reports the delivery mode and warns when a mode needs a cron you have not mounted.
439
+
440
+ ---
441
+
442
+ ## Security notes
443
+
444
+ - **`session.getUserId` is mandatory.** There is no default and no dev bypass — an insecure default ships, a startup crash doesn't. Returning `null` yields 401; *throwing* yields 500, because a broken session store and an absent session are different bugs.
445
+ - **Every query is scoped server-side.** No route accepts a user id from the client. Marking someone else's notification read returns 404, not 403 — a 403 confirms the row exists.
446
+ - **`cron.secret` is required** for modes that rely on the sweep. Unauthenticated, that endpoint is a free flush-everything trigger against your email provider. Compared in constant time. Plugin machine routes (`/push/prune`, `/digests/cron`) accept it too unless you set `machineSecret`, which keeps the scheduler's credential to `/cron` alone. One call drains at most `cron.maxSweeps` sweeps (default 50).
447
+ - **Plugins never see the secret.** They get `ctx.sign()`, which mints tokens only for the purposes their own signed routes declare; keys are derived per purpose with HKDF, so an unsubscribe key signs nothing else. `notify.listRoutes()` shows every mounted route with its auth scope, and a `custom`-scoped route is named in a startup warning with its justification.
448
+ - **Secrets must be at least 16 characters** and not a placeholder; startup throws otherwise. A short HMAC key makes every unsubscribe link forgeable offline.
449
+ - **CSRF.** Every POST must be `application/json` (415 otherwise) and, when the browser sends an `Origin`, it must match the request host or an entry in `trustedOrigins` (403 otherwise). This holds even if your session cookie is `SameSite=None`. Set `trustedOrigins: ["https://app.example.com", "*.example.com"]` when the API lives on a different origin from the page.
450
+ - **Push endpoints are validated at registration.** Public https hosts only, well-formed keys, one owner per endpoint for life (409 if another account registered it), at most `maxDevicesPerUser` rows per user. Pin `allowedEndpointHosts` to the push services you expect if you want the SSRF surface closed entirely.
451
+ - **Email templates are sent as-is.** Run every payload field a user could have typed through `escapeHtml`, or use a templating library that escapes by default. The quickstart shows the pattern.
452
+ - **In-app payloads are served to the browser verbatim.** Never put anything in `payload` the recipient shouldn't read, and never render it with `innerHTML`.
453
+ - **Request bodies are capped at 64 KiB** (`maxBodyBytes`), responses carry `Cache-Control: private, no-store`, and a thrown adapter error is a logged 500 rather than a stack trace or a crashed process.
454
+ - **Rate limiting.** `rateLimit: { max, windowMs }` is an in-process fixed-window limiter keyed by the client address from `cf-connecting-ip` / `x-real-ip` / `x-forwarded-for` (or your own `key`), applied to every route including `/cron` and `/unsubscribe`. It is per process, not global: enough to blunt secret guessing and refresh storms, not a quota. `onRequest` runs before it if you have a shared limiter of your own. Each browser holds one `GET /events` stream and refreshes the feed on every `changed`, so size limits with that in mind.
455
+ - **Unsubscribe tokens** travel in the URL, so they will appear in access logs. They are valid for 30 days by default, capped at 90. A token is refused if the user changed that preference after it was issued, so an old link cannot undo a newer decision; clicking the same link twice is still a 200. `last_error` stores provider messages with email addresses redacted.
456
+
457
+ ---
458
+
459
+ ## Status
460
+
461
+ | | |
462
+ | --- | --- |
463
+ | ✅ Core `send()` pipeline, hooks, dedupe | |
464
+ | ✅ Postgres through any driver — no ORM needed | `pg`, `postgres.js`, Kysely, Neon… |
465
+ | ✅ Postgres via Drizzle, for those already on it | same conformance suite |
466
+ | ✅ MongoDB | same conformance suite |
467
+ | ✅ MySQL 8 / MariaDB | `SKIP LOCKED` with a transaction, lock-free `UPDATE` without |
468
+ | ✅ SQLite | `node:sqlite` or better-sqlite3; one writer, so the claim is one `UPDATE` |
469
+ | ✅ Delivery runner, all four modes, retry + backoff | |
470
+ | ✅ Resend provider | |
471
+ | ✅ Route handler, session scoping, cron | |
472
+ | ✅ React client, optimistic updates, one event stream per browser with polling fallback | `GET /events`, `navigator.locks` leader, `BroadcastChannel` mirror |
473
+ | ✅ wake-ups: worker woken by `send()`, LISTEN/NOTIFY and change-stream signals, request-driven sweep | `signals`, `delivery.sweepOnRequest` |
474
+ | ✅ preferences plugin + headless `usePreferences` | the wedge |
475
+ | ✅ digests plugin, timezone-aware | |
476
+ | ✅ push plugin + web-push provider | VAPID + RFC 8291, no node:crypto |
477
+ | ✅ push verified against a live push service | Mozilla autopush, plus a cross-check against `http_ece` |
478
+ | ✅ scoped plugin storage, so plugins own their tables | |
479
+ | ✅ additive schema migrations for the raw-SQL path | `planPostgresMigration()`; MySQL and SQLite are bootstrap-only for now |
480
+ | ✅ failed deliveries reachable from the instance | `notify.getFailedDeliveries()` |
481
+ | ⬜ batching | |
482
+ | ⬜ Prisma adapter, Vue / Svelte bindings | Kysely already works through `postgresAdapter` |
483
+
484
+ ---
485
+
486
+ ## Writing an adapter
487
+
488
+ The one operation with no equivalent in other libraries is atomic claiming — without it, two concurrent sweeps send the same email twice. Verify yours:
489
+
490
+ ```ts
491
+ import { adapterConformanceCases } from "easy-ping/testing";
492
+
493
+ for (const testCase of adapterConformanceCases) {
494
+ it(testCase.name, () => testCase.run({ adapter, reset, setAttempts, lockRow }));
495
+ }
496
+ ```
497
+
498
+ Fifteen cases, run against six backends in this repo (two Postgres paths, MongoDB, two MySQL paths, SQLite). The one that matters asserts a row locked by another transaction is *skipped*, not waited on; it is tagged `requires: "rowLock"`, and a store whose claim is a single atomic update filters it out rather than faking it.
499
+
500
+ Declare your dialect on the adapter so the plugin store writes what your driver expects:
501
+
502
+ ```ts
503
+ naming: "snake_case" | "preserve" // columns, or the declared field names
504
+ serializesJson: boolean // json as a string, or natively
505
+ ```
506
+
507
+ ---
508
+
509
+ ## Development
510
+
511
+ ```bash
512
+ pnpm install
513
+ docker compose up -d # Postgres on :54329, MongoDB on :27019, MySQL on :33069
514
+ pnpm test
515
+ ```
516
+
517
+ Mongo runs as a single-node replica set, because that is the only way it offers transactions. SQLite needs nothing running: the suite uses Node's own `node:sqlite` in memory.
518
+
519
+ Database tests skip locally when a database is unreachable, and **fail** in CI — a green build that ran none of them is worse than a red one.
520
+
521
+ The push crypto is checked two ways. `web-push-reference.test.ts` decrypts our output with `http_ece` — the library `web-push` npm uses — because a decryptor written from the same RFC would share any misreading and agree with itself. `web-push-live.test.ts` then sends through Mozilla's production push service for real; it is opt-in so CI never goes red because someone else's service is having a bad afternoon:
522
+
523
+ ```bash
524
+ EASY_PING_LIVE_PUSH=1 pnpm --filter easy-ping test web-push-live
525
+ ```
526
+
527
+ The Resend provider is checked against Resend's real API too: the rejection paths need no credentials — a bogus key coming back as a structured 401 rather than a 400 is what proves the request shape is right. The delivery leg needs your own key:
528
+
529
+ ```bash
530
+ RESEND_API_KEY=re_... RESEND_FROM="Acme <hi@acme.dev>" pnpm --filter easy-ping test resend-live
531
+ ```
532
+
533
+ ## Releasing
534
+
535
+ Every release is automatic. Open a PR with a changeset:
536
+
537
+ ```bash
538
+ pnpm changeset # describe the change, pick patch/minor/major
539
+ ```
540
+
541
+ On merge to `main`, the Release workflow opens a version PR. Merging *that* publishes to npm via
542
+ [trusted publishing](https://docs.npmjs.com/trusted-publishers/), tags the commit and opens a
543
+ GitHub Release from the changelog. No npm token is stored anywhere; the workflow mints a
544
+ short-lived OIDC token instead.
545
+
546
+ ## License
547
+
548
+ MIT