easy-ping 0.5.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.
- package/README.md +548 -508
- package/dist/adapters/mongodb.cjs +142 -0
- package/dist/adapters/mongodb.cjs.map +1 -1
- package/dist/adapters/mongodb.d.cts +39 -1
- package/dist/adapters/mongodb.d.ts +39 -1
- package/dist/adapters/mongodb.js +73 -1
- package/dist/adapters/mongodb.js.map +1 -1
- package/dist/adapters/postgres.cjs +146 -0
- package/dist/adapters/postgres.cjs.map +1 -1
- package/dist/adapters/postgres.d.cts +36 -1
- package/dist/adapters/postgres.d.ts +36 -1
- package/dist/adapters/postgres.js +78 -3
- package/dist/adapters/postgres.js.map +1 -1
- package/dist/adapters/sqlite.js +1 -1
- package/dist/{chunk-GJNLOQJS.js → chunk-JKTMUMQK.js} +31 -6
- package/dist/chunk-JKTMUMQK.js.map +1 -0
- package/dist/chunk-ORZKH224.js +75 -0
- package/dist/chunk-ORZKH224.js.map +1 -0
- package/dist/{chunk-BECMTNK7.js → chunk-USQ6CQTJ.js} +88 -3
- package/dist/chunk-USQ6CQTJ.js.map +1 -0
- package/dist/client.cjs +288 -17
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +53 -1
- package/dist/client.d.ts +53 -1
- package/dist/client.js +288 -17
- package/dist/client.js.map +1 -1
- package/dist/index.cjs +237 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +42 -1
- package/dist/index.d.ts +42 -1
- package/dist/index.js +55 -7
- package/dist/index.js.map +1 -1
- package/dist/node.cjs +26 -5
- package/dist/node.cjs.map +1 -1
- package/dist/node.d.cts +1 -1
- package/dist/node.d.ts +1 -1
- package/dist/node.js +28 -6
- package/dist/node.js.map +1 -1
- package/dist/plugins/preferences.cjs.map +1 -1
- package/dist/plugins/preferences.js +2 -1
- package/dist/plugins/preferences.js.map +1 -1
- package/dist/plugins/push.cjs.map +1 -1
- package/dist/plugins/push.js +3 -2
- package/dist/plugins/push.js.map +1 -1
- package/dist/react.cjs +311 -19
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +52 -0
- package/dist/react.d.ts +52 -0
- package/dist/react.js +311 -19
- package/dist/react.js.map +1 -1
- package/dist/schema.js +1 -1
- package/dist/signals-kPmOlKDX.d.cts +27 -0
- package/dist/signals-kPmOlKDX.d.ts +27 -0
- package/dist/sw.cjs +39 -0
- package/dist/sw.cjs.map +1 -0
- package/dist/sw.d.cts +52 -0
- package/dist/sw.d.ts +52 -0
- package/dist/sw.js +36 -0
- package/dist/sw.js.map +1 -0
- package/package.json +6 -1
- package/dist/chunk-BECMTNK7.js.map +0 -1
- package/dist/chunk-GJNLOQJS.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,508 +1,548 @@
|
|
|
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
|
-
[](https://www.npmjs.com/package/easy-ping)
|
|
10
|
-
[](https://packagephobia.com/result?p=easy-ping)
|
|
11
|
-
[](https://www.npmjs.com/package/easy-ping)
|
|
12
|
-
[](./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`.
|
|
62
|
-
>
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
import {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
<
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
<
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
<
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
import
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
<
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
import {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
<
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
import {
|
|
200
|
-
import {
|
|
201
|
-
import {
|
|
202
|
-
import {
|
|
203
|
-
import {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
{
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
|
339
|
-
|
|
|
340
|
-
| `
|
|
341
|
-
| `
|
|
342
|
-
| `
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
`
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
+
[](https://www.npmjs.com/package/easy-ping)
|
|
10
|
+
[](https://packagephobia.com/result?p=easy-ping)
|
|
11
|
+
[](https://www.npmjs.com/package/easy-ping)
|
|
12
|
+
[](./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
|