easy-ping 0.4.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 (121) hide show
  1. package/README.md +159 -78
  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 +2 -5
  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 +4 -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 +16 -9
  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-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-DG3ZL5NP.js → chunk-BECMTNK7.js} +2 -2
  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-W6I3B2CI.js → chunk-E4IZ2CQC.js} +2 -2
  51. package/dist/{chunk-W6I3B2CI.js.map → chunk-E4IZ2CQC.js.map} +1 -1
  52. package/dist/{chunk-CYKAM7LE.js → chunk-GJNLOQJS.js} +2 -2
  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-YEMRRMJQ.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 +2 -2
  63. package/dist/index.cjs.map +1 -1
  64. package/dist/index.d.cts +15 -60
  65. package/dist/index.d.ts +15 -60
  66. package/dist/index.js +9 -10
  67. package/dist/index.js.map +1 -1
  68. package/dist/node.cjs.map +1 -1
  69. package/dist/node.js +1 -1
  70. package/dist/node.js.map +1 -1
  71. package/dist/{plugin-C9Hoxus2.d.ts → plugin-CHd2ZiGC.d.cts} +5 -21
  72. package/dist/{plugin-CiMsGlQe.d.cts → plugin-Cg9usuUi.d.ts} +5 -21
  73. package/dist/plugins/digests.cjs +1 -4
  74. package/dist/plugins/digests.cjs.map +1 -1
  75. package/dist/plugins/digests.d.cts +6 -31
  76. package/dist/plugins/digests.d.ts +6 -31
  77. package/dist/plugins/digests.js +1 -4
  78. package/dist/plugins/digests.js.map +1 -1
  79. package/dist/plugins/preferences-client.cjs.map +1 -1
  80. package/dist/plugins/preferences-client.d.cts +2 -2
  81. package/dist/plugins/preferences-client.d.ts +2 -2
  82. package/dist/plugins/preferences-client.js +1 -1
  83. package/dist/plugins/preferences-react.cjs.map +1 -1
  84. package/dist/plugins/preferences-react.js.map +1 -1
  85. package/dist/plugins/preferences.cjs +1 -2
  86. package/dist/plugins/preferences.cjs.map +1 -1
  87. package/dist/plugins/preferences.d.cts +2 -2
  88. package/dist/plugins/preferences.d.ts +2 -2
  89. package/dist/plugins/preferences.js +2 -3
  90. package/dist/plugins/preferences.js.map +1 -1
  91. package/dist/plugins/push.cjs +1 -2
  92. package/dist/plugins/push.cjs.map +1 -1
  93. package/dist/plugins/push.d.cts +6 -26
  94. package/dist/plugins/push.d.ts +6 -26
  95. package/dist/plugins/push.js +3 -4
  96. package/dist/plugins/push.js.map +1 -1
  97. package/dist/providers/web-push.cjs.map +1 -1
  98. package/dist/providers/web-push.d.cts +3 -10
  99. package/dist/providers/web-push.d.ts +3 -10
  100. package/dist/providers/web-push.js.map +1 -1
  101. package/dist/react.cjs.map +1 -1
  102. package/dist/react.d.cts +1 -4
  103. package/dist/react.d.ts +1 -4
  104. package/dist/react.js.map +1 -1
  105. package/dist/schema.cjs +119 -1
  106. package/dist/schema.cjs.map +1 -1
  107. package/dist/schema.d.cts +20 -14
  108. package/dist/schema.d.ts +20 -14
  109. package/dist/schema.js +4 -2
  110. package/dist/testing.cjs +3 -19
  111. package/dist/testing.cjs.map +1 -1
  112. package/dist/testing.d.cts +4 -22
  113. package/dist/testing.d.ts +4 -22
  114. package/dist/testing.js +3 -19
  115. package/dist/testing.js.map +1 -1
  116. package/package.json +12 -1
  117. package/dist/chunk-3Y2MBUAW.js.map +0 -1
  118. package/dist/chunk-CYKAM7LE.js.map +0 -1
  119. package/dist/chunk-DG3ZL5NP.js.map +0 -1
  120. package/dist/chunk-PSJHXZPR.js.map +0 -1
  121. package/dist/chunk-YEMRRMJQ.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,33 +121,89 @@ 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
198
  import { defineNotification, easyPing, escapeHtml } from "easy-ping";
118
- import { drizzleAdapter } from "easy-ping/adapters/drizzle";
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
 
129
208
  // Both at least 16 characters; `openssl rand -base64 32` is the easy way.
130
209
  secret: process.env.NOTIFY_SECRET!,
@@ -136,14 +215,19 @@ export const notify = easyPing({
136
215
  (await auth.api.getSession({ headers: request.headers }))?.user.id ?? null,
137
216
  },
138
217
 
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) => ({
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) => ({
142
225
  userId: u.id,
143
226
  email: u.email,
144
227
  timezone: u.timezone,
145
228
  locale: u.locale,
146
- })),
229
+ }));
230
+ },
147
231
 
148
232
  channels: {
149
233
  inApp: { enabled: true },
@@ -170,7 +254,9 @@ export const notify = easyPing({
170
254
  ### 4. Mount the endpoints
171
255
 
172
256
  ```ts
173
- // 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.
174
260
  import { notify } from "@/notify";
175
261
 
176
262
  export const { GET, POST } = notify.handler;
@@ -339,6 +425,8 @@ Defaults to the last 24 hours, capped at 1000 rows. Wire it to an admin page or
339
425
  | ✅ Postgres through any driver — no ORM needed | `pg`, `postgres.js`, Kysely, Neon… |
340
426
  | ✅ Postgres via Drizzle, for those already on it | same conformance suite |
341
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` |
342
430
  | ✅ Delivery runner, all four modes, retry + backoff | |
343
431
  | ✅ Resend provider | |
344
432
  | ✅ Route handler, session scoping, cron | |
@@ -348,10 +436,10 @@ Defaults to the last 24 hours, capped at 1000 rows. Wire it to an admin page or
348
436
  | ✅ push plugin + web-push provider | VAPID + RFC 8291, no node:crypto |
349
437
  | ✅ push verified against a live push service | Mozilla autopush, plus a cross-check against `http_ece` |
350
438
  | ✅ scoped plugin storage, so plugins own their tables | |
351
- | ✅ 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 |
352
440
  | ✅ failed deliveries reachable from the instance | `notify.getFailedDeliveries()` |
353
441
  | ⬜ realtime, batching | |
354
- | ⬜ Prisma / Kysely adapters, Vue / Svelte bindings | |
442
+ | ⬜ Prisma adapter, Vue / Svelte bindings | Kysely already works through `postgresAdapter` |
355
443
 
356
444
  ---
357
445
 
@@ -367,7 +455,7 @@ for (const testCase of adapterConformanceCases) {
367
455
  }
368
456
  ```
369
457
 
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.
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.
371
459
 
372
460
  Declare your dialect on the adapter so the plugin store writes what your driver expects:
373
461
 
@@ -382,11 +470,11 @@ serializesJson: boolean // json as a string, or natively
382
470
 
383
471
  ```bash
384
472
  pnpm install
385
- docker compose up -d # Postgres on :54329, MongoDB on :27019
473
+ docker compose up -d # Postgres on :54329, MongoDB on :27019, MySQL on :33069
386
474
  pnpm test
387
475
  ```
388
476
 
389
- 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.
390
478
 
391
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.
392
480
 
@@ -404,23 +492,16 @@ RESEND_API_KEY=re_... RESEND_FROM="Acme <hi@acme.dev>" pnpm --filter easy-ping
404
492
 
405
493
  ## Releasing
406
494
 
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:
495
+ Every release is automatic. Open a PR with a changeset:
412
496
 
413
497
  ```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
498
+ pnpm changeset # describe the change, pick patch/minor/major
421
499
  ```
422
500
 
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.
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.
424
505
 
425
506
  ## License
426
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>;
@@ -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
  );