easy-ping 0.3.0 → 0.5.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 (127) hide show
  1. package/README.md +174 -82
  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 +4 -4
  7. package/dist/adapters/drizzle.cjs.map +1 -1
  8. package/dist/adapters/drizzle.d.cts +35 -1
  9. package/dist/adapters/drizzle.d.ts +35 -1
  10. package/dist/adapters/drizzle.js +5 -5
  11. package/dist/adapters/drizzle.js.map +1 -1
  12. package/dist/adapters/mongodb.cjs +8 -7
  13. package/dist/adapters/mongodb.cjs.map +1 -1
  14. package/dist/adapters/mongodb.d.cts +4 -17
  15. package/dist/adapters/mongodb.d.ts +4 -17
  16. package/dist/adapters/mongodb.js +7 -7
  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 +19 -11
  25. package/dist/adapters/postgres.cjs.map +1 -1
  26. package/dist/adapters/postgres.d.cts +9 -54
  27. package/dist/adapters/postgres.d.ts +9 -54
  28. package/dist/adapters/postgres.js +6 -41
  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-OZSOIX23.js → chunk-3VOQOGDR.js} +54 -12
  43. package/dist/chunk-3VOQOGDR.js.map +1 -0
  44. package/dist/{chunk-NYDVNMKG.js → chunk-AEYYTE5I.js} +4 -3
  45. package/dist/chunk-AEYYTE5I.js.map +1 -0
  46. package/dist/chunk-BECMTNK7.js +315 -0
  47. package/dist/chunk-BECMTNK7.js.map +1 -0
  48. package/dist/chunk-CK5UPVYP.js +48 -0
  49. package/dist/chunk-CK5UPVYP.js.map +1 -0
  50. package/dist/{chunk-BV3U542P.js → chunk-E4IZ2CQC.js} +2 -2
  51. package/dist/chunk-E4IZ2CQC.js.map +1 -0
  52. package/dist/chunk-GJNLOQJS.js +226 -0
  53. package/dist/chunk-GJNLOQJS.js.map +1 -0
  54. package/dist/chunk-HY47TQMY.js +52 -0
  55. package/dist/chunk-HY47TQMY.js.map +1 -0
  56. package/dist/chunk-M4R77BJD.js +64 -0
  57. package/dist/chunk-M4R77BJD.js.map +1 -0
  58. package/dist/{chunk-RBDW4S6F.js → chunk-XIDVLYNQ.js} +14 -4
  59. package/dist/chunk-XIDVLYNQ.js.map +1 -0
  60. package/dist/client.cjs.map +1 -1
  61. package/dist/client.js.map +1 -1
  62. package/dist/index.cjs +362 -64
  63. package/dist/index.cjs.map +1 -1
  64. package/dist/index.d.cts +56 -37
  65. package/dist/index.d.ts +56 -37
  66. package/dist/index.js +120 -359
  67. package/dist/index.js.map +1 -1
  68. package/dist/node.cjs +27 -6
  69. package/dist/node.cjs.map +1 -1
  70. package/dist/node.d.cts +8 -2
  71. package/dist/node.d.ts +8 -2
  72. package/dist/node.js +26 -6
  73. package/dist/node.js.map +1 -1
  74. package/dist/{plugin-CmPOFVmn.d.ts → plugin-CHd2ZiGC.d.cts} +37 -25
  75. package/dist/{plugin-f5KaFWSN.d.cts → plugin-Cg9usuUi.d.ts} +37 -25
  76. package/dist/plugins/digests.cjs +1 -4
  77. package/dist/plugins/digests.cjs.map +1 -1
  78. package/dist/plugins/digests.d.cts +6 -31
  79. package/dist/plugins/digests.d.ts +6 -31
  80. package/dist/plugins/digests.js +1 -4
  81. package/dist/plugins/digests.js.map +1 -1
  82. package/dist/plugins/preferences-client.cjs.map +1 -1
  83. package/dist/plugins/preferences-client.d.cts +2 -2
  84. package/dist/plugins/preferences-client.d.ts +2 -2
  85. package/dist/plugins/preferences-client.js +1 -1
  86. package/dist/plugins/preferences-react.cjs.map +1 -1
  87. package/dist/plugins/preferences-react.js.map +1 -1
  88. package/dist/plugins/preferences.cjs +101 -17
  89. package/dist/plugins/preferences.cjs.map +1 -1
  90. package/dist/plugins/preferences.d.cts +4 -2
  91. package/dist/plugins/preferences.d.ts +4 -2
  92. package/dist/plugins/preferences.js +36 -9
  93. package/dist/plugins/preferences.js.map +1 -1
  94. package/dist/plugins/push.cjs +198 -40
  95. package/dist/plugins/push.cjs.map +1 -1
  96. package/dist/plugins/push.d.cts +20 -18
  97. package/dist/plugins/push.d.ts +20 -18
  98. package/dist/plugins/push.js +141 -41
  99. package/dist/plugins/push.js.map +1 -1
  100. package/dist/providers/web-push.cjs +37 -11
  101. package/dist/providers/web-push.cjs.map +1 -1
  102. package/dist/providers/web-push.d.cts +10 -11
  103. package/dist/providers/web-push.d.ts +10 -11
  104. package/dist/providers/web-push.js +37 -12
  105. package/dist/providers/web-push.js.map +1 -1
  106. package/dist/react.cjs.map +1 -1
  107. package/dist/react.d.cts +1 -4
  108. package/dist/react.d.ts +1 -4
  109. package/dist/react.js.map +1 -1
  110. package/dist/schema.cjs +121 -2
  111. package/dist/schema.cjs.map +1 -1
  112. package/dist/schema.d.cts +25 -14
  113. package/dist/schema.d.ts +25 -14
  114. package/dist/schema.js +4 -2
  115. package/dist/testing.cjs +3 -19
  116. package/dist/testing.cjs.map +1 -1
  117. package/dist/testing.d.cts +4 -22
  118. package/dist/testing.d.ts +4 -22
  119. package/dist/testing.js +3 -19
  120. package/dist/testing.js.map +1 -1
  121. package/package.json +12 -1
  122. package/dist/chunk-BV3U542P.js.map +0 -1
  123. package/dist/chunk-NYDVNMKG.js.map +0 -1
  124. package/dist/chunk-OZSOIX23.js.map +0 -1
  125. package/dist/chunk-RBDW4S6F.js.map +0 -1
  126. package/dist/chunk-W3BMEPPQ.js +0 -50
  127. package/dist/chunk-W3BMEPPQ.js.map +0 -1
package/README.md CHANGED
@@ -1,20 +1,65 @@
1
+ <div align="center">
2
+
1
3
  # easy-ping
2
4
 
3
- **Own your notifications.** Your database, your users, no per-notification pricing.
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)
4
13
 
5
- A framework-agnostic, type-safe, self-hosted notifications library for TypeScript — in-app inbox, transactional email, and a plugin system for the rest.
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)
6
15
 
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.
16
+ </div>
8
17
 
9
18
  ---
10
19
 
11
- ## Why
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.
12
33
 
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.
34
+ ---
35
+
36
+ ## Why
14
37
 
15
- easy-ping runs **inside your app**, stores notifications in **your database**, and never charges per send.
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.
16
41
 
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.
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`. Realtime and batching are not built, and the Resend provider has only been exercised
62
+ > against a stub. Minor versions may still move APIs before 1.0.
18
63
 
19
64
  ---
20
65
 
@@ -23,64 +68,42 @@ easy-ping runs **inside your app**, stores notifications in **your database**, a
23
68
  ### 1. Install
24
69
 
25
70
  ```bash
26
- pnpm add easy-ping drizzle-orm postgres zod
71
+ pnpm add easy-ping pg zod
27
72
  ```
28
73
 
29
74
  ### 2. Create the tables
30
75
 
31
- ```ts
32
- // db/schema.ts
33
- import { createSchema } from "easy-ping/adapters/drizzle";
76
+ No ORM required. The whole database contract is one function: run a parameterised statement,
77
+ return rows.
34
78
 
35
- export const { notification, notificationDelivery, notificationPreference } = createSchema();
36
- ```
79
+ ```ts
80
+ import { createPostgresTables, pgTransaction, postgresAdapter } from "easy-ping/adapters/postgres";
81
+ import { Pool } from "pg";
37
82
 
38
- Push them with `drizzle-kit`, or generate raw SQL:
83
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL });
84
+ const query = async (text, params) => (await pool.query(text, params)).rows;
39
85
 
40
- ```ts
41
- import { coreSchema, renderPostgresDdl } from "easy-ping/schema";
86
+ // Renders the DDL from the installed version's own schema, so the tables
87
+ // cannot drift from the package. Every statement is IF NOT EXISTS.
88
+ await createPostgresTables(query);
42
89
 
43
- for (const statement of renderPostgresDdl(coreSchema)) await sql.unsafe(statement);
90
+ export const database = postgresAdapter(query, { transaction: pgTransaction(pool) });
44
91
  ```
45
92
 
46
- <details>
47
- <summary>Without an ORM (plain pg, postgres.js, Kysely…)</summary>
93
+ Anything that can run a statement works: `pg`, `postgres.js`, Kysely, Neon or PlanetScale's
94
+ serverless drivers, or Prisma's `$queryRawUnsafe`.
48
95
 
49
- ```bash
50
- pnpm add easy-ping pg zod
51
- ```
96
+ <details>
97
+ <summary>Already on Drizzle</summary>
52
98
 
53
99
  ```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 });
100
+ // db/schema.ts
101
+ import { createSchema } from "easy-ping/adapters/drizzle";
58
102
 
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
- );
103
+ export const { notification, notificationDelivery, notificationPreference } = createSchema();
79
104
  ```
80
105
 
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`.
106
+ Push it with `drizzle-kit`, then `drizzleAdapter(db)` in place of `postgresAdapter`.
84
107
 
85
108
  </details>
86
109
 
@@ -98,34 +121,91 @@ const client = new MongoClient(process.env.MONGO_URL!);
98
121
  await client.connect();
99
122
  const db = client.db("app");
100
123
 
101
- // There are no tables to create, only indexes. Run once at startup.
124
+ // No tables, only indexes. Run once at startup.
102
125
  await createMongoIndexes(db);
103
126
 
104
127
  // Pass the client too: it is what makes a notification and its deliveries
105
128
  // land together, which needs a replica set.
106
- const database = mongoAdapter(db, { client });
129
+ export const database = mongoAdapter(db, { client });
107
130
  ```
108
131
 
109
132
  Everything after this point is identical.
110
133
 
111
134
  </details>
112
135
 
136
+ <details>
137
+ <summary>On MySQL instead</summary>
138
+
139
+ ```bash
140
+ pnpm add easy-ping mysql2 zod
141
+ ```
142
+
143
+ ```ts
144
+ import { createMysqlTables, mysql2Query, mysqlAdapter, mysqlTransaction } from "easy-ping/adapters/mysql";
145
+ import mysql from "mysql2/promise";
146
+
147
+ // timezone "Z": the adapter writes DATETIME columns as UTC and must read them back unshifted.
148
+ const pool = mysql.createPool({ uri: process.env.DATABASE_URL, timezone: "Z" });
149
+ const query = mysql2Query(pool);
150
+
151
+ await createMysqlTables(query); // CREATE TABLE IF NOT EXISTS, safe on every boot
152
+
153
+ // With a transaction, a claim uses FOR UPDATE SKIP LOCKED (MySQL 8+). Without
154
+ // one it is a single lock-free UPDATE that still never double-claims a row.
155
+ export const database = mysqlAdapter(query, { transaction: mysqlTransaction(pool) });
156
+ ```
157
+
158
+ MySQL 8 or MariaDB. Any driver works: the contract is `(text, params) => Promise<{ rows, affectedRows }>`.
159
+
160
+ </details>
161
+
162
+ <details>
163
+ <summary>On SQLite instead</summary>
164
+
165
+ ```ts
166
+ import { createSqliteTables, sqliteAdapter, sqliteQuery, sqliteTransaction } from "easy-ping/adapters/sqlite";
167
+ import { DatabaseSync } from "node:sqlite"; // Node 22.13+; better-sqlite3 works the same way
168
+
169
+ const db = new DatabaseSync("./app.db");
170
+ const query = sqliteQuery(db);
171
+
172
+ await createSqliteTables(query);
173
+
174
+ export const database = sqliteAdapter(query, { transaction: sqliteTransaction(db) });
175
+ ```
176
+
177
+ 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.
178
+
179
+ </details>
180
+
181
+ <details>
182
+ <summary>Adding a plugin's tables</summary>
183
+
184
+ Plugins own their own tables and export the declaration, so no plugin instance is needed:
185
+
186
+ ```ts
187
+ import { pushSchema } from "easy-ping/plugins/push";
188
+
189
+ await createPostgresTables(query, { plugins: [pushSchema] });
190
+ ```
191
+
192
+ </details>
193
+
113
194
  ### 3. Configure
114
195
 
115
196
  ```ts
116
197
  // notify.ts
117
- import { defineNotification, easyPing } from "easy-ping";
118
- import { drizzleAdapter } from "easy-ping/adapters/drizzle";
198
+ import { defineNotification, easyPing, escapeHtml } from "easy-ping";
119
199
  import { resend } from "easy-ping/providers/resend";
120
200
  import { after } from "next/server";
121
- import { inArray } from "drizzle-orm";
122
201
  import { z } from "zod";
123
202
  import { auth } from "./auth";
124
- import { db, users } from "./db";
203
+ import { database, pool } from "./db"; // from step 2
125
204
 
126
205
  export const notify = easyPing({
127
- database: drizzleAdapter(db),
206
+ database,
128
207
 
208
+ // Both at least 16 characters; `openssl rand -base64 32` is the easy way.
129
209
  secret: process.env.NOTIFY_SECRET!,
130
210
  cron: { secret: process.env.NOTIFY_CRON_SECRET! },
131
211
 
@@ -135,14 +215,19 @@ export const notify = easyPing({
135
215
  (await auth.api.getSession({ headers: request.headers }))?.user.id ?? null,
136
216
  },
137
217
 
138
- // Batched — one call per send, never one per recipient.
139
- getRecipients: async (userIds) =>
140
- (await db.select().from(users).where(inArray(users.id, [...userIds]))).map((u) => ({
218
+ // Batched: one call per send, never one per recipient.
219
+ getRecipients: async (userIds) => {
220
+ const { rows } = await pool.query(
221
+ "select id, email, timezone, locale from users where id = any($1)",
222
+ [userIds],
223
+ );
224
+ return rows.map((u) => ({
141
225
  userId: u.id,
142
226
  email: u.email,
143
227
  timezone: u.timezone,
144
228
  locale: u.locale,
145
- })),
229
+ }));
230
+ },
146
231
 
147
232
  channels: {
148
233
  inApp: { enabled: true },
@@ -157,7 +242,9 @@ export const notify = easyPing({
157
242
  channels: ["inApp", "email"],
158
243
  email: {
159
244
  subject: (p) => `${p.authorName} replied to you`,
160
- template: (p) => `<p>${p.authorName} replied. <a href="/c/${p.commentId}">View</a></p>`,
245
+ // Anything a user typed goes through escapeHtml, or their markup ships from your domain.
246
+ template: (p) =>
247
+ `<p>${escapeHtml(p.authorName)} replied. <a href="/c/${encodeURIComponent(p.commentId)}">View</a></p>`,
161
248
  },
162
249
  }),
163
250
  },
@@ -167,7 +254,9 @@ export const notify = easyPing({
167
254
  ### 4. Mount the endpoints
168
255
 
169
256
  ```ts
170
- // app/api/notifications/[...notify]/route.ts
257
+ // app/api/notifications/[[...notify]]/route.ts
258
+ // Double brackets: Next's required catch-all would not match the bare
259
+ // /api/notifications path, which is where the feed lives.
171
260
  import { notify } from "@/notify";
172
261
 
173
262
  export const { GET, POST } = notify.handler;
@@ -315,8 +404,16 @@ Defaults to the last 24 hours, capped at 1000 rows. Wire it to an admin page or
315
404
 
316
405
  - **`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.
317
406
  - **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.
318
- - **`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.
319
- - **In-app payloads are served to the browser verbatim.** Never put anything in `payload` the recipient shouldn't read.
407
+ - **`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).
408
+ - **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.
409
+ - **Secrets must be at least 16 characters** and not a placeholder; startup throws otherwise. A short HMAC key makes every unsubscribe link forgeable offline.
410
+ - **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.
411
+ - **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.
412
+ - **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.
413
+ - **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`.
414
+ - **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.
415
+ - **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.
416
+ - **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.
320
417
 
321
418
  ---
322
419
 
@@ -328,6 +425,8 @@ Defaults to the last 24 hours, capped at 1000 rows. Wire it to an admin page or
328
425
  | ✅ Postgres through any driver — no ORM needed | `pg`, `postgres.js`, Kysely, Neon… |
329
426
  | ✅ Postgres via Drizzle, for those already on it | same conformance suite |
330
427
  | ✅ MongoDB | same conformance suite |
428
+ | ✅ MySQL 8 / MariaDB | `SKIP LOCKED` with a transaction, lock-free `UPDATE` without |
429
+ | ✅ SQLite | `node:sqlite` or better-sqlite3; one writer, so the claim is one `UPDATE` |
331
430
  | ✅ Delivery runner, all four modes, retry + backoff | |
332
431
  | ✅ Resend provider | |
333
432
  | ✅ Route handler, session scoping, cron | |
@@ -337,10 +436,10 @@ Defaults to the last 24 hours, capped at 1000 rows. Wire it to an admin page or
337
436
  | ✅ push plugin + web-push provider | VAPID + RFC 8291, no node:crypto |
338
437
  | ✅ push verified against a live push service | Mozilla autopush, plus a cross-check against `http_ece` |
339
438
  | ✅ scoped plugin storage, so plugins own their tables | |
340
- | ✅ additive schema migrations for the raw-SQL path | `planPostgresMigration()` |
439
+ | ✅ additive schema migrations for the raw-SQL path | `planPostgresMigration()`; MySQL and SQLite are bootstrap-only for now |
341
440
  | ✅ failed deliveries reachable from the instance | `notify.getFailedDeliveries()` |
342
441
  | ⬜ realtime, batching | |
343
- | ⬜ Prisma / Kysely adapters, Vue / Svelte bindings | |
442
+ | ⬜ Prisma adapter, Vue / Svelte bindings | Kysely already works through `postgresAdapter` |
344
443
 
345
444
  ---
346
445
 
@@ -356,7 +455,7 @@ for (const testCase of adapterConformanceCases) {
356
455
  }
357
456
  ```
358
457
 
359
- 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.
458
+ 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.
360
459
 
361
460
  Declare your dialect on the adapter so the plugin store writes what your driver expects:
362
461
 
@@ -371,11 +470,11 @@ serializesJson: boolean // json as a string, or natively
371
470
 
372
471
  ```bash
373
472
  pnpm install
374
- docker compose up -d # Postgres on :54329, MongoDB on :27019
473
+ docker compose up -d # Postgres on :54329, MongoDB on :27019, MySQL on :33069
375
474
  pnpm test
376
475
  ```
377
476
 
378
- Mongo runs as a single-node replica set, because that is the only way it offers transactions.
477
+ 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.
379
478
 
380
479
  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.
381
480
 
@@ -393,23 +492,16 @@ RESEND_API_KEY=re_... RESEND_FROM="Acme <hi@acme.dev>" pnpm --filter easy-ping
393
492
 
394
493
  ## Releasing
395
494
 
396
- Nothing is on npm yet. The name `easy-ping` is unclaimed.
397
-
398
- 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.
399
-
400
- The first release is manual:
495
+ Every release is automatic. Open a PR with a changeset:
401
496
 
402
497
  ```bash
403
- # 1. bump off 0.0.0
404
- pnpm changeset # choose minor -> 0.1.0
405
- pnpm changeset version
406
-
407
- # 2. publish once, by hand
408
- npm login
409
- pnpm --filter easy-ping publish --access public
498
+ pnpm changeset # describe the change, pick patch/minor/major
410
499
  ```
411
500
 
412
- 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.
501
+ On merge to `main`, the Release workflow opens a version PR. Merging *that* publishes to npm via
502
+ [trusted publishing](https://docs.npmjs.com/trusted-publishers/), tags the commit and opens a
503
+ GitHub Release from the changelog. No npm token is stored anywhere; the workflow mints a
504
+ short-lived OIDC token instead.
413
505
 
414
506
  ## License
415
507
 
@@ -0,0 +1,17 @@
1
+ type Row = Record<string, unknown>;
2
+ type SqlResult = {
3
+ rows: readonly Row[];
4
+ /** Rows an INSERT/UPDATE/DELETE touched; drivers without RETURNING report through this. */
5
+ affectedRows: number;
6
+ };
7
+ /** The whole driver contract for the MySQL and SQLite adapters. */
8
+ type SqlExec = (text: string, params: readonly unknown[]) => Promise<SqlResult>;
9
+
10
+ type SqlAdapterOptions = {
11
+ /** Table-name prefix. Must match the instance's `tablePrefix`. */
12
+ prefix?: string;
13
+ /** Makes createNotifications atomic and, on MySQL, lets a claim use FOR UPDATE SKIP LOCKED. */
14
+ transaction?: <T>(fn: (exec: SqlExec) => Promise<T>) => Promise<T>;
15
+ };
16
+
17
+ export type { SqlAdapterOptions as S, SqlExec as a, SqlResult as b };
@@ -0,0 +1,17 @@
1
+ type Row = Record<string, unknown>;
2
+ type SqlResult = {
3
+ rows: readonly Row[];
4
+ /** Rows an INSERT/UPDATE/DELETE touched; drivers without RETURNING report through this. */
5
+ affectedRows: number;
6
+ };
7
+ /** The whole driver contract for the MySQL and SQLite adapters. */
8
+ type SqlExec = (text: string, params: readonly unknown[]) => Promise<SqlResult>;
9
+
10
+ type SqlAdapterOptions = {
11
+ /** Table-name prefix. Must match the instance's `tablePrefix`. */
12
+ prefix?: string;
13
+ /** Makes createNotifications atomic and, on MySQL, lets a claim use FOR UPDATE SKIP LOCKED. */
14
+ transaction?: <T>(fn: (exec: SqlExec) => Promise<T>) => Promise<T>;
15
+ };
16
+
17
+ export type { SqlAdapterOptions as S, SqlExec as a, SqlResult as b };
@@ -41,11 +41,7 @@ type DeliveryRecord = {
41
41
  };
42
42
 
43
43
  type Scalar = string | number | boolean | Date | null;
44
- /**
45
- * One operator per condition. Operators are tagged objects so a bare value is
46
- * always an equality test; json columns therefore cannot be filtered, since an
47
- * object value would be ambiguous.
48
- */
44
+ /** Operators are tagged objects so a bare value is always equality; json columns cannot be filtered. */
49
45
  type WhereCondition = Scalar | {
50
46
  in: readonly (string | number)[];
51
47
  } | {
@@ -70,13 +66,7 @@ type QueryOptions = {
70
66
  type UpsertOptions = {
71
67
  onConflict: readonly string[];
72
68
  };
73
- /**
74
- * Table access for plugins, scoped to the tables that plugin declared.
75
- *
76
- * Plugins could always declare tables via schema() but had no way to read
77
- * them, so preferences had to bolt its queries onto the core adapter. That
78
- * does not generalise to digests or push.
79
- */
69
+ /** Table access for plugins, scoped to the tables the plugin declared. */
80
70
  type PluginStore = {
81
71
  find<T = Record<string, unknown>>(table: string, where?: WhereClause, options?: QueryOptions): Promise<T[]>;
82
72
  insert(table: string, rows: readonly Record<string, unknown>[]): Promise<number>;
@@ -138,12 +128,7 @@ type DeliveryOutcome = {
138
128
  error: string;
139
129
  retryable: boolean;
140
130
  }
141
- /**
142
- * Nothing to do, and nothing wrong. A channel whose preconditions are
143
- * simply absent — push with no registered device, say — must not land in
144
- * `getFailedDeliveries`, or the one view meant to surface real breakage
145
- * fills up with people who never opted in. Terminal: never retried.
146
- */
131
+ /** Nothing to do and nothing wrong (push with no device, say). Terminal; kept out of getFailedDeliveries. */
147
132
  | {
148
133
  result: "skipped";
149
134
  reason: string;
@@ -167,16 +152,9 @@ type FeedPage = {
167
152
  };
168
153
  type DatabaseAdapter = {
169
154
  readonly name: string;
170
- /**
171
- * How the adapter names stored fields. SQL adapters use snake_case columns;
172
- * document stores keep the declared camelCase. The plugin store translates
173
- * accordingly rather than assuming one of them.
174
- */
155
+ /** snake_case columns (SQL) or the declared camelCase (document stores); the store translates. */
175
156
  readonly naming?: "snake_case" | "preserve";
176
- /**
177
- * True when json values must reach the driver as strings. postgres-js cannot
178
- * bind a plain object; Mongo stores one natively.
179
- */
157
+ /** True when json must reach the driver as a string. */
180
158
  readonly serializesJson?: boolean;
181
159
  /** One transaction. A duplicate (userId, dedupeKey) is reported, not thrown. */
182
160
  createNotifications(rows: readonly InsertNotification[]): Promise<{
@@ -196,11 +174,7 @@ type DatabaseAdapter = {
196
174
  since: Date;
197
175
  limit: number;
198
176
  }): Promise<readonly DeliveryRecord[]>;
199
- /**
200
- * Generic table access for plugin-declared tables. Reached only through a
201
- * PluginStore, which validates the table and every column against the
202
- * plugin's own schema() before anything gets here.
203
- */
177
+ /** Plugin-table access. Reached only through a PluginStore, which validated table and columns. */
204
178
  queryTable(table: string, where: WhereClause, options: QueryOptions): Promise<Record<string, unknown>[]>;
205
179
  insertRows(table: string, rows: readonly Record<string, unknown>[], onConflict?: readonly string[]): Promise<number>;
206
180
  updateRows(table: string, where: WhereClause, set: Record<string, unknown>): Promise<number>;
@@ -41,11 +41,7 @@ type DeliveryRecord = {
41
41
  };
42
42
 
43
43
  type Scalar = string | number | boolean | Date | null;
44
- /**
45
- * One operator per condition. Operators are tagged objects so a bare value is
46
- * always an equality test; json columns therefore cannot be filtered, since an
47
- * object value would be ambiguous.
48
- */
44
+ /** Operators are tagged objects so a bare value is always equality; json columns cannot be filtered. */
49
45
  type WhereCondition = Scalar | {
50
46
  in: readonly (string | number)[];
51
47
  } | {
@@ -70,13 +66,7 @@ type QueryOptions = {
70
66
  type UpsertOptions = {
71
67
  onConflict: readonly string[];
72
68
  };
73
- /**
74
- * Table access for plugins, scoped to the tables that plugin declared.
75
- *
76
- * Plugins could always declare tables via schema() but had no way to read
77
- * them, so preferences had to bolt its queries onto the core adapter. That
78
- * does not generalise to digests or push.
79
- */
69
+ /** Table access for plugins, scoped to the tables the plugin declared. */
80
70
  type PluginStore = {
81
71
  find<T = Record<string, unknown>>(table: string, where?: WhereClause, options?: QueryOptions): Promise<T[]>;
82
72
  insert(table: string, rows: readonly Record<string, unknown>[]): Promise<number>;
@@ -138,12 +128,7 @@ type DeliveryOutcome = {
138
128
  error: string;
139
129
  retryable: boolean;
140
130
  }
141
- /**
142
- * Nothing to do, and nothing wrong. A channel whose preconditions are
143
- * simply absent — push with no registered device, say — must not land in
144
- * `getFailedDeliveries`, or the one view meant to surface real breakage
145
- * fills up with people who never opted in. Terminal: never retried.
146
- */
131
+ /** Nothing to do and nothing wrong (push with no device, say). Terminal; kept out of getFailedDeliveries. */
147
132
  | {
148
133
  result: "skipped";
149
134
  reason: string;
@@ -167,16 +152,9 @@ type FeedPage = {
167
152
  };
168
153
  type DatabaseAdapter = {
169
154
  readonly name: string;
170
- /**
171
- * How the adapter names stored fields. SQL adapters use snake_case columns;
172
- * document stores keep the declared camelCase. The plugin store translates
173
- * accordingly rather than assuming one of them.
174
- */
155
+ /** snake_case columns (SQL) or the declared camelCase (document stores); the store translates. */
175
156
  readonly naming?: "snake_case" | "preserve";
176
- /**
177
- * True when json values must reach the driver as strings. postgres-js cannot
178
- * bind a plain object; Mongo stores one natively.
179
- */
157
+ /** True when json must reach the driver as a string. */
180
158
  readonly serializesJson?: boolean;
181
159
  /** One transaction. A duplicate (userId, dedupeKey) is reported, not thrown. */
182
160
  createNotifications(rows: readonly InsertNotification[]): Promise<{
@@ -196,11 +174,7 @@ type DatabaseAdapter = {
196
174
  since: Date;
197
175
  limit: number;
198
176
  }): Promise<readonly DeliveryRecord[]>;
199
- /**
200
- * Generic table access for plugin-declared tables. Reached only through a
201
- * PluginStore, which validates the table and every column against the
202
- * plugin's own schema() before anything gets here.
203
- */
177
+ /** Plugin-table access. Reached only through a PluginStore, which validated table and columns. */
204
178
  queryTable(table: string, where: WhereClause, options: QueryOptions): Promise<Record<string, unknown>[]>;
205
179
  insertRows(table: string, rows: readonly Record<string, unknown>[], onConflict?: readonly string[]): Promise<number>;
206
180
  updateRows(table: string, where: WhereClause, set: Record<string, unknown>): Promise<number>;
@@ -30,7 +30,7 @@ var toSnakeCase = (value) => value.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}
30
30
 
31
31
  // src/core/store.ts
32
32
  var OPERATORS = ["in", "lt", "lte", "gt", "gte", "not"];
33
- var isOperator = (value) => typeof value === "object" && value !== null && !(value instanceof Date) && OPERATORS.some((op) => op in value);
33
+ var isOperator = (value) => typeof value === "object" && value !== null && !(value instanceof Date) && OPERATORS.some((op) => Object.hasOwn(value, op));
34
34
 
35
35
  // src/adapters/drizzle/adapter.ts
36
36
  function toRows(result) {
@@ -397,8 +397,7 @@ function createSchema(prefix = "") {
397
397
  pgCore.index(name("notification_feed_idx")).on(table.userId, table.createdAt.desc()),
398
398
  pgCore.index(name("notification_unseen_idx")).on(table.userId).where(drizzleOrm.sql`${table.seenAt} is null`),
399
399
  pgCore.index(name("notification_group_idx")).on(table.userId, table.groupKey),
400
- // Postgres treats NULLs as distinct here, so unlimited rows may carry no
401
- // dedupe key while a present one is enforced unique per user.
400
+ // NULLs are distinct here: unlimited rows without a dedupe key.
402
401
  pgCore.uniqueIndex(name("notification_dedupe_idx")).on(table.userId, table.dedupeKey)
403
402
  ]
404
403
  );
@@ -429,7 +428,8 @@ function createSchema(prefix = "") {
429
428
  type: pgCore.text("type").notNull(),
430
429
  channel: pgCore.text("channel").$type().notNull(),
431
430
  enabled: pgCore.boolean("enabled").notNull().default(true),
432
- frequency: pgCore.text("frequency").$type().notNull().default("instant")
431
+ frequency: pgCore.text("frequency").$type().notNull().default("instant"),
432
+ updatedAt: pgCore.timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
433
433
  },
434
434
  (table) => [pgCore.primaryKey({ columns: [table.userId, table.type, table.channel] })]
435
435
  );