easy-ping 0.1.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/LICENSE +21 -0
- package/README.md +374 -0
- package/dist/adapter-f0OR2DhY.d.cts +200 -0
- package/dist/adapter-f0OR2DhY.d.ts +200 -0
- package/dist/adapters/drizzle.cjs +440 -0
- package/dist/adapters/drizzle.cjs.map +1 -0
- package/dist/adapters/drizzle.d.cts +991 -0
- package/dist/adapters/drizzle.d.ts +991 -0
- package/dist/adapters/drizzle.js +409 -0
- package/dist/adapters/drizzle.js.map +1 -0
- package/dist/adapters/mongodb.cjs +458 -0
- package/dist/adapters/mongodb.cjs.map +1 -0
- package/dist/adapters/mongodb.d.cts +32 -0
- package/dist/adapters/mongodb.d.ts +32 -0
- package/dist/adapters/mongodb.js +376 -0
- package/dist/adapters/mongodb.js.map +1 -0
- package/dist/browser.cjs +96 -0
- package/dist/browser.cjs.map +1 -0
- package/dist/browser.d.cts +45 -0
- package/dist/browser.d.ts +45 -0
- package/dist/browser.js +89 -0
- package/dist/browser.js.map +1 -0
- package/dist/chunk-BV3U542P.js +11 -0
- package/dist/chunk-BV3U542P.js.map +1 -0
- package/dist/chunk-NYDVNMKG.js +62 -0
- package/dist/chunk-NYDVNMKG.js.map +1 -0
- package/dist/chunk-OZSOIX23.js +120 -0
- package/dist/chunk-OZSOIX23.js.map +1 -0
- package/dist/chunk-PTGHMCCG.js +23 -0
- package/dist/chunk-PTGHMCCG.js.map +1 -0
- package/dist/chunk-W3BMEPPQ.js +50 -0
- package/dist/chunk-W3BMEPPQ.js.map +1 -0
- package/dist/client.cjs +160 -0
- package/dist/client.cjs.map +1 -0
- package/dist/client.d.cts +42 -0
- package/dist/client.d.ts +42 -0
- package/dist/client.js +158 -0
- package/dist/client.js.map +1 -0
- package/dist/index.cjs +920 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +203 -0
- package/dist/index.d.ts +203 -0
- package/dist/index.js +723 -0
- package/dist/index.js.map +1 -0
- package/dist/node.cjs +49 -0
- package/dist/node.cjs.map +1 -0
- package/dist/node.d.cts +8 -0
- package/dist/node.d.ts +8 -0
- package/dist/node.js +46 -0
- package/dist/node.js.map +1 -0
- package/dist/plugin-Bqj9xAOS.d.cts +217 -0
- package/dist/plugin-bTJKmnlq.d.ts +217 -0
- package/dist/plugins/digests.cjs +206 -0
- package/dist/plugins/digests.cjs.map +1 -0
- package/dist/plugins/digests.d.cts +68 -0
- package/dist/plugins/digests.d.ts +68 -0
- package/dist/plugins/digests.js +201 -0
- package/dist/plugins/digests.js.map +1 -0
- package/dist/plugins/preferences-client.cjs +23 -0
- package/dist/plugins/preferences-client.cjs.map +1 -0
- package/dist/plugins/preferences-client.d.cts +29 -0
- package/dist/plugins/preferences-client.d.ts +29 -0
- package/dist/plugins/preferences-client.js +18 -0
- package/dist/plugins/preferences-client.js.map +1 -0
- package/dist/plugins/preferences-react.cjs +125 -0
- package/dist/plugins/preferences-react.cjs.map +1 -0
- package/dist/plugins/preferences-react.d.cts +40 -0
- package/dist/plugins/preferences-react.d.ts +40 -0
- package/dist/plugins/preferences-react.js +101 -0
- package/dist/plugins/preferences-react.js.map +1 -0
- package/dist/plugins/preferences.cjs +147 -0
- package/dist/plugins/preferences.cjs.map +1 -0
- package/dist/plugins/preferences.d.cts +22 -0
- package/dist/plugins/preferences.d.ts +22 -0
- package/dist/plugins/preferences.js +119 -0
- package/dist/plugins/preferences.js.map +1 -0
- package/dist/plugins/push.cjs +146 -0
- package/dist/plugins/push.cjs.map +1 -0
- package/dist/plugins/push.d.cts +60 -0
- package/dist/plugins/push.d.ts +60 -0
- package/dist/plugins/push.js +144 -0
- package/dist/plugins/push.js.map +1 -0
- package/dist/provider-CA45WVqt.d.cts +23 -0
- package/dist/provider-CA45WVqt.d.ts +23 -0
- package/dist/providers/resend.cjs +69 -0
- package/dist/providers/resend.cjs.map +1 -0
- package/dist/providers/resend.d.cts +20 -0
- package/dist/providers/resend.d.ts +20 -0
- package/dist/providers/resend.js +66 -0
- package/dist/providers/resend.js.map +1 -0
- package/dist/providers/web-push.cjs +211 -0
- package/dist/providers/web-push.cjs.map +1 -0
- package/dist/providers/web-push.d.cts +33 -0
- package/dist/providers/web-push.d.ts +33 -0
- package/dist/providers/web-push.js +193 -0
- package/dist/providers/web-push.js.map +1 -0
- package/dist/react.cjs +218 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +57 -0
- package/dist/react.d.ts +57 -0
- package/dist/react.js +193 -0
- package/dist/react.js.map +1 -0
- package/dist/schema.cjs +263 -0
- package/dist/schema.cjs.map +1 -0
- package/dist/schema.d.cts +180 -0
- package/dist/schema.d.ts +180 -0
- package/dist/schema.js +200 -0
- package/dist/schema.js.map +1 -0
- package/dist/testing.cjs +342 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +38 -0
- package/dist/testing.d.ts +38 -0
- package/dist/testing.js +340 -0
- package/dist/testing.js.map +1 -0
- package/package.json +168 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Teklu Moges and easy-ping contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
# easy-ping
|
|
2
|
+
|
|
3
|
+
**Own your notifications.** Your database, your users, no per-notification pricing.
|
|
4
|
+
|
|
5
|
+
A framework-agnostic, type-safe, self-hosted notifications library for TypeScript — in-app inbox, transactional email, and a plugin system for the rest.
|
|
6
|
+
|
|
7
|
+
> **Status: pre-release (v0.0.0).** The core pipeline, both adapters (Postgres and MongoDB), the React client, and the preferences, digests and push plugins all work and are covered by tests against real databases. Web push is verified end to end against Mozilla's production push service and cross-checked against `http_ece`. Realtime and batching are not built, and the Resend provider has still only been exercised against a stub. APIs may still move.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Why
|
|
12
|
+
|
|
13
|
+
Every app past the weekend-project stage needs a notification bell, transactional email, and user preferences. The options are a platform you deploy (Novu), a SaaS you rent per notification (Knock, Courier), a workflow platform plus your own table (Inngest + Resend), or hand-rolling it badly.
|
|
14
|
+
|
|
15
|
+
easy-ping runs **inside your app**, stores notifications in **your database**, and never charges per send.
|
|
16
|
+
|
|
17
|
+
**Scale target:** thousands to low-millions of notifications per month. Not Slack-scale fan-out. Every "no queue required" decision below follows from that.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Quickstart
|
|
22
|
+
|
|
23
|
+
### 1. Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pnpm add easy-ping drizzle-orm postgres zod
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### 2. Create the tables
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
// db/schema.ts
|
|
33
|
+
import { createSchema } from "easy-ping/adapters/drizzle";
|
|
34
|
+
|
|
35
|
+
export const { notification, notificationDelivery, notificationPreference } = createSchema();
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Push them with `drizzle-kit`, or generate raw SQL:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { coreSchema, renderPostgresDdl } from "easy-ping/schema";
|
|
42
|
+
|
|
43
|
+
for (const statement of renderPostgresDdl(coreSchema)) await sql.unsafe(statement);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
<details>
|
|
47
|
+
<summary>On MongoDB instead</summary>
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pnpm add easy-ping mongodb zod
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { createMongoIndexes, mongoAdapter } from "easy-ping/adapters/mongodb";
|
|
55
|
+
|
|
56
|
+
const client = new MongoClient(process.env.MONGO_URL!);
|
|
57
|
+
await client.connect();
|
|
58
|
+
const db = client.db("app");
|
|
59
|
+
|
|
60
|
+
// There are no tables to create, only indexes. Run once at startup.
|
|
61
|
+
await createMongoIndexes(db);
|
|
62
|
+
|
|
63
|
+
// Pass the client too: it is what makes a notification and its deliveries
|
|
64
|
+
// land together, which needs a replica set.
|
|
65
|
+
const database = mongoAdapter(db, { client });
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Everything after this point is identical.
|
|
69
|
+
|
|
70
|
+
</details>
|
|
71
|
+
|
|
72
|
+
### 3. Configure
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
// notify.ts
|
|
76
|
+
import { defineNotification, easyPing } from "easy-ping";
|
|
77
|
+
import { drizzleAdapter } from "easy-ping/adapters/drizzle";
|
|
78
|
+
import { resend } from "easy-ping/providers/resend";
|
|
79
|
+
import { after } from "next/server";
|
|
80
|
+
import { inArray } from "drizzle-orm";
|
|
81
|
+
import { z } from "zod";
|
|
82
|
+
import { auth } from "./auth";
|
|
83
|
+
import { db, users } from "./db";
|
|
84
|
+
|
|
85
|
+
export const notify = easyPing({
|
|
86
|
+
database: drizzleAdapter(db),
|
|
87
|
+
|
|
88
|
+
secret: process.env.NOTIFY_SECRET!,
|
|
89
|
+
cron: { secret: process.env.NOTIFY_CRON_SECRET! },
|
|
90
|
+
|
|
91
|
+
// Required. The mounted endpoints serve a user's private inbox.
|
|
92
|
+
session: {
|
|
93
|
+
getUserId: async (request) =>
|
|
94
|
+
(await auth.api.getSession({ headers: request.headers }))?.user.id ?? null,
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
// Batched — one call per send, never one per recipient.
|
|
98
|
+
getRecipients: async (userIds) =>
|
|
99
|
+
(await db.select().from(users).where(inArray(users.id, [...userIds]))).map((u) => ({
|
|
100
|
+
userId: u.id,
|
|
101
|
+
email: u.email,
|
|
102
|
+
timezone: u.timezone,
|
|
103
|
+
locale: u.locale,
|
|
104
|
+
})),
|
|
105
|
+
|
|
106
|
+
channels: {
|
|
107
|
+
inApp: { enabled: true },
|
|
108
|
+
email: { provider: resend({ apiKey: process.env.RESEND_API_KEY!, from: "Acme <hi@acme.dev>" }) },
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
delivery: { mode: "deferred", waitUntil: after },
|
|
112
|
+
|
|
113
|
+
notifications: {
|
|
114
|
+
commentReply: defineNotification({
|
|
115
|
+
schema: z.object({ authorName: z.string(), commentId: z.string() }),
|
|
116
|
+
channels: ["inApp", "email"],
|
|
117
|
+
email: {
|
|
118
|
+
subject: (p) => `${p.authorName} replied to you`,
|
|
119
|
+
template: (p) => `<p>${p.authorName} replied. <a href="/c/${p.commentId}">View</a></p>`,
|
|
120
|
+
},
|
|
121
|
+
}),
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### 4. Mount the endpoints
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
// app/api/notifications/[...notify]/route.ts
|
|
130
|
+
import { notify } from "@/notify";
|
|
131
|
+
|
|
132
|
+
export const { GET, POST } = notify.handler;
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### 5. Send
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
await notify.send("commentReply", {
|
|
139
|
+
to: threadOwnerId,
|
|
140
|
+
payload: { authorName: "Dana", commentId: "c_123" }, // typed against the schema
|
|
141
|
+
dedupeKey: `commentReply:c_123:${threadOwnerId}`, // optional, makes retries safe
|
|
142
|
+
});
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### 6. Render the bell
|
|
146
|
+
|
|
147
|
+
```tsx
|
|
148
|
+
"use client";
|
|
149
|
+
import { useNotifications } from "easy-ping/react";
|
|
150
|
+
|
|
151
|
+
export function Bell() {
|
|
152
|
+
const { notifications, unseenCount, markAsRead, markSeen } = useNotifications();
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<button onClick={() => markSeen()}>
|
|
156
|
+
{unseenCount}
|
|
157
|
+
{notifications.map((n) => (
|
|
158
|
+
<div key={n.id} onClick={() => markAsRead(n.id)}>
|
|
159
|
+
{n.type} {n.readAt ? "" : "•"}
|
|
160
|
+
</div>
|
|
161
|
+
))}
|
|
162
|
+
</button>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### 7. Wire the cron
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
POST /api/notifications/cron
|
|
171
|
+
Authorization: Bearer $NOTIFY_CRON_SECRET
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
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`.
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## How delivery works
|
|
179
|
+
|
|
180
|
+
`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.
|
|
181
|
+
|
|
182
|
+
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.
|
|
183
|
+
|
|
184
|
+
Delivery then happens according to `delivery.mode`:
|
|
185
|
+
|
|
186
|
+
| mode | delivery happens | latency | needs cron |
|
|
187
|
+
| --- | --- | --- | --- |
|
|
188
|
+
| `inline` | before `send()` resolves | in-request | recommended |
|
|
189
|
+
| `deferred` | after the response, via `waitUntil` | ~1s | **yes** |
|
|
190
|
+
| `worker` | in-process poller | ~1s | optional |
|
|
191
|
+
| `cron` | when the sweep runs | up to the interval | **yes** |
|
|
192
|
+
|
|
193
|
+
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.
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
const worker = notify.startWorker({ intervalMs: 1000 });
|
|
197
|
+
process.on("SIGTERM", () => worker.stop()); // drains in-flight work, releases leases
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
**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.
|
|
201
|
+
|
|
202
|
+
**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.
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Channels
|
|
207
|
+
|
|
208
|
+
| channel | state |
|
|
209
|
+
| --- | --- |
|
|
210
|
+
| `inApp` | ✅ built in, on by default, needs no provider |
|
|
211
|
+
| `email` | ✅ Resend provider; the interface is open for others |
|
|
212
|
+
| `push` | ✅ push plugin + `webPush()` — VAPID and aes128gcm on Web Crypto, so it runs on edge too |
|
|
213
|
+
| `sms` | ⬜ not implemented |
|
|
214
|
+
| `slack` | ⬜ not implemented |
|
|
215
|
+
|
|
216
|
+
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.
|
|
217
|
+
|
|
218
|
+
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.
|
|
219
|
+
|
|
220
|
+
## Upgrading
|
|
221
|
+
|
|
222
|
+
A later version may add a column. How you pick it up depends on how you created the tables:
|
|
223
|
+
|
|
224
|
+
| you bootstrapped with | to upgrade |
|
|
225
|
+
| --- | --- |
|
|
226
|
+
| `createSchema()` + drizzle-kit | `drizzle-kit` diffs it for you — nothing to do here |
|
|
227
|
+
| `renderPostgresDdl()` | `planPostgresMigration()` — see below |
|
|
228
|
+
| `createMongoIndexes()` | rerun it; `createIndex` is idempotent and additive |
|
|
229
|
+
|
|
230
|
+
**`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.
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
import { INTROSPECT_SQL, planPostgresMigration, coreSchema } from "easy-ping/schema";
|
|
234
|
+
|
|
235
|
+
const plan = await planPostgresMigration(
|
|
236
|
+
async () =>
|
|
237
|
+
(await sql.unsafe(INTROSPECT_SQL)).map((row) => ({
|
|
238
|
+
table: row.table_name,
|
|
239
|
+
column: row.column_name,
|
|
240
|
+
type: row.data_type,
|
|
241
|
+
nullable: row.is_nullable === "YES",
|
|
242
|
+
})),
|
|
243
|
+
coreSchema,
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
for (const statement of plan.statements) await sql.unsafe(statement);
|
|
247
|
+
if (plan.unsupported.length) console.warn(plan.unsupported);
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
**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.
|
|
251
|
+
|
|
252
|
+
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.
|
|
253
|
+
|
|
254
|
+
Run the plugins' schemas the same way — `push({...}).schema`, `preferences().schema`.
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## When something fails
|
|
259
|
+
|
|
260
|
+
Delivery is at-least-once with five attempts and backoff, which means failures are quiet by design. To see them:
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
const failed = await notify.getFailedDeliveries({ since: new Date(Date.now() - 86_400_000) });
|
|
264
|
+
// [{ id, notificationId, channel, attempts, lastError, updatedAt, ... }]
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
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.
|
|
268
|
+
|
|
269
|
+
`notify.healthCheck()` reports the delivery mode and warns when a mode needs a cron you have not mounted.
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
## Security notes
|
|
274
|
+
|
|
275
|
+
- **`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.
|
|
276
|
+
- **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.
|
|
277
|
+
- **`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.
|
|
278
|
+
- **In-app payloads are served to the browser verbatim.** Never put anything in `payload` the recipient shouldn't read.
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## Status
|
|
283
|
+
|
|
284
|
+
| | |
|
|
285
|
+
| --- | --- |
|
|
286
|
+
| ✅ Core `send()` pipeline, hooks, dedupe | |
|
|
287
|
+
| ✅ Postgres via Drizzle, with a conformance suite | |
|
|
288
|
+
| ✅ MongoDB, same conformance suite | |
|
|
289
|
+
| ✅ Delivery runner, all four modes, retry + backoff | |
|
|
290
|
+
| ✅ Resend provider | |
|
|
291
|
+
| ✅ Route handler, session scoping, cron | |
|
|
292
|
+
| ✅ React client, polling, optimistic updates | |
|
|
293
|
+
| ✅ preferences plugin + headless `usePreferences` | the wedge |
|
|
294
|
+
| ✅ digests plugin, timezone-aware | |
|
|
295
|
+
| ✅ push plugin + web-push provider | VAPID + RFC 8291, no node:crypto |
|
|
296
|
+
| ✅ push verified against a live push service | Mozilla autopush, plus a cross-check against `http_ece` |
|
|
297
|
+
| ✅ scoped plugin storage, so plugins own their tables | |
|
|
298
|
+
| ✅ additive schema migrations for the raw-SQL path | `planPostgresMigration()` |
|
|
299
|
+
| ✅ failed deliveries reachable from the instance | `notify.getFailedDeliveries()` |
|
|
300
|
+
| ⬜ realtime, batching | |
|
|
301
|
+
| ⬜ Prisma / Kysely adapters, Vue / Svelte bindings | |
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
## Writing an adapter
|
|
306
|
+
|
|
307
|
+
The one operation with no equivalent in other libraries is atomic claiming — without it, two concurrent sweeps send the same email twice. Verify yours:
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
import { adapterConformanceCases } from "easy-ping/testing";
|
|
311
|
+
|
|
312
|
+
for (const testCase of adapterConformanceCases) {
|
|
313
|
+
it(testCase.name, () => testCase.run({ adapter, reset, setAttempts, lockRow }));
|
|
314
|
+
}
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
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.
|
|
318
|
+
|
|
319
|
+
Declare your dialect on the adapter so the plugin store writes what your driver expects:
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
naming: "snake_case" | "preserve" // columns, or the declared field names
|
|
323
|
+
serializesJson: boolean // json as a string, or natively
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
## Development
|
|
329
|
+
|
|
330
|
+
```bash
|
|
331
|
+
pnpm install
|
|
332
|
+
docker compose up -d # Postgres on :54329, MongoDB on :27019
|
|
333
|
+
pnpm test
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Mongo runs as a single-node replica set, because that is the only way it offers transactions.
|
|
337
|
+
|
|
338
|
+
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.
|
|
339
|
+
|
|
340
|
+
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:
|
|
341
|
+
|
|
342
|
+
```bash
|
|
343
|
+
EASY_PING_LIVE_PUSH=1 pnpm --filter easy-ping test web-push-live
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
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:
|
|
347
|
+
|
|
348
|
+
```bash
|
|
349
|
+
RESEND_API_KEY=re_... RESEND_FROM="Acme <hi@acme.dev>" pnpm --filter easy-ping test resend-live
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
## Releasing
|
|
353
|
+
|
|
354
|
+
Nothing is on npm yet. The name `easy-ping` is unclaimed.
|
|
355
|
+
|
|
356
|
+
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.
|
|
357
|
+
|
|
358
|
+
The first release is manual:
|
|
359
|
+
|
|
360
|
+
```bash
|
|
361
|
+
# 1. bump off 0.0.0
|
|
362
|
+
pnpm changeset # choose minor -> 0.1.0
|
|
363
|
+
pnpm changeset version
|
|
364
|
+
|
|
365
|
+
# 2. publish once, by hand
|
|
366
|
+
npm login
|
|
367
|
+
pnpm --filter easy-ping publish --access public
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
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.
|
|
371
|
+
|
|
372
|
+
## License
|
|
373
|
+
|
|
374
|
+
MIT
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
type Channel = "inApp" | "email" | "push" | "sms" | "slack";
|
|
2
|
+
type DeliveryStatus = "pending" | "claimed" | "sent" | "failed" | "skipped";
|
|
3
|
+
type DeliveryMode = "inline" | "deferred" | "worker" | "cron";
|
|
4
|
+
type Frequency = "instant" | "daily" | "weekly" | "off";
|
|
5
|
+
type SkipReason = "deduped" | "no-channels" | "no-recipient"
|
|
6
|
+
/** Every declared channel lacks a provider — a misconfiguration, not a preference. */
|
|
7
|
+
| "channel-unavailable";
|
|
8
|
+
type Recipient = {
|
|
9
|
+
userId: string;
|
|
10
|
+
email?: string | undefined;
|
|
11
|
+
phone?: string | undefined;
|
|
12
|
+
pushTokens?: readonly string[] | undefined;
|
|
13
|
+
timezone: string;
|
|
14
|
+
locale: string;
|
|
15
|
+
};
|
|
16
|
+
type NotificationRecord = {
|
|
17
|
+
id: string;
|
|
18
|
+
userId: string;
|
|
19
|
+
type: string;
|
|
20
|
+
payload: unknown;
|
|
21
|
+
actorId: string | null;
|
|
22
|
+
groupKey: string | null;
|
|
23
|
+
dedupeKey: string | null;
|
|
24
|
+
seenAt: Date | null;
|
|
25
|
+
readAt: Date | null;
|
|
26
|
+
archivedAt: Date | null;
|
|
27
|
+
createdAt: Date;
|
|
28
|
+
};
|
|
29
|
+
type DeliveryRecord = {
|
|
30
|
+
id: string;
|
|
31
|
+
notificationId: string;
|
|
32
|
+
channel: Channel;
|
|
33
|
+
status: DeliveryStatus;
|
|
34
|
+
attempts: number;
|
|
35
|
+
maxAttempts: number;
|
|
36
|
+
notBefore: Date;
|
|
37
|
+
claimedAt: Date | null;
|
|
38
|
+
claimedBy: string | null;
|
|
39
|
+
lastError: string | null;
|
|
40
|
+
updatedAt: Date;
|
|
41
|
+
};
|
|
42
|
+
|
|
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
|
+
*/
|
|
49
|
+
type WhereCondition = Scalar | {
|
|
50
|
+
in: readonly (string | number)[];
|
|
51
|
+
} | {
|
|
52
|
+
lt: Date | number;
|
|
53
|
+
} | {
|
|
54
|
+
lte: Date | number;
|
|
55
|
+
} | {
|
|
56
|
+
gt: Date | number;
|
|
57
|
+
} | {
|
|
58
|
+
gte: Date | number;
|
|
59
|
+
} | {
|
|
60
|
+
not: Scalar;
|
|
61
|
+
};
|
|
62
|
+
type WhereClause = Record<string, WhereCondition>;
|
|
63
|
+
type QueryOptions = {
|
|
64
|
+
limit?: number;
|
|
65
|
+
orderBy?: {
|
|
66
|
+
field: string;
|
|
67
|
+
direction: "asc" | "desc";
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
type UpsertOptions = {
|
|
71
|
+
onConflict: readonly string[];
|
|
72
|
+
};
|
|
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
|
+
*/
|
|
80
|
+
type PluginStore = {
|
|
81
|
+
find<T = Record<string, unknown>>(table: string, where?: WhereClause, options?: QueryOptions): Promise<T[]>;
|
|
82
|
+
insert(table: string, rows: readonly Record<string, unknown>[]): Promise<number>;
|
|
83
|
+
upsert(table: string, rows: readonly Record<string, unknown>[], options: UpsertOptions): Promise<number>;
|
|
84
|
+
update(table: string, where: WhereClause, set: Record<string, unknown>): Promise<number>;
|
|
85
|
+
remove(table: string, where: WhereClause): Promise<number>;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
type PreferenceRecord = {
|
|
89
|
+
userId: string;
|
|
90
|
+
type: string;
|
|
91
|
+
channel: Channel;
|
|
92
|
+
enabled: boolean;
|
|
93
|
+
frequency: Frequency;
|
|
94
|
+
};
|
|
95
|
+
type InsertNotification = {
|
|
96
|
+
id: string;
|
|
97
|
+
userId: string;
|
|
98
|
+
type: string;
|
|
99
|
+
payload: unknown;
|
|
100
|
+
actorId?: string | undefined;
|
|
101
|
+
groupKey?: string | undefined;
|
|
102
|
+
dedupeKey?: string | undefined;
|
|
103
|
+
deliveries: readonly InsertDelivery[];
|
|
104
|
+
};
|
|
105
|
+
type InsertDelivery = {
|
|
106
|
+
id: string;
|
|
107
|
+
channel: Channel;
|
|
108
|
+
maxAttempts: number;
|
|
109
|
+
notBefore: Date;
|
|
110
|
+
};
|
|
111
|
+
type ClaimArgs = {
|
|
112
|
+
limit: number;
|
|
113
|
+
leaseMs: number;
|
|
114
|
+
/** Unique per call, not per worker. See RFC 0003 §4. */
|
|
115
|
+
claimToken: string;
|
|
116
|
+
channels?: readonly Channel[] | undefined;
|
|
117
|
+
/** Scopes the claim to one send; without it inline/deferred flush the whole table. */
|
|
118
|
+
ids?: readonly string[] | undefined;
|
|
119
|
+
now?: Date | undefined;
|
|
120
|
+
};
|
|
121
|
+
type ClaimedDelivery = {
|
|
122
|
+
id: string;
|
|
123
|
+
notificationId: string;
|
|
124
|
+
channel: Channel;
|
|
125
|
+
attempts: number;
|
|
126
|
+
maxAttempts: number;
|
|
127
|
+
notification: {
|
|
128
|
+
userId: string;
|
|
129
|
+
type: string;
|
|
130
|
+
payload: unknown;
|
|
131
|
+
actorId: string | null;
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
type DeliveryOutcome = {
|
|
135
|
+
result: "sent";
|
|
136
|
+
} | {
|
|
137
|
+
result: "failed";
|
|
138
|
+
error: string;
|
|
139
|
+
retryable: boolean;
|
|
140
|
+
};
|
|
141
|
+
type DeliveryRelease = {
|
|
142
|
+
id: string;
|
|
143
|
+
claimToken: string;
|
|
144
|
+
outcome: DeliveryOutcome;
|
|
145
|
+
/** Applied when a retryable failure re-arms the row. */
|
|
146
|
+
nextAttemptAt?: Date | undefined;
|
|
147
|
+
};
|
|
148
|
+
type FeedQuery = {
|
|
149
|
+
userId: string;
|
|
150
|
+
limit: number;
|
|
151
|
+
cursor?: string | undefined;
|
|
152
|
+
unreadOnly?: boolean | undefined;
|
|
153
|
+
};
|
|
154
|
+
type FeedPage = {
|
|
155
|
+
notifications: readonly NotificationRecord[];
|
|
156
|
+
nextCursor: string | null;
|
|
157
|
+
};
|
|
158
|
+
type DatabaseAdapter = {
|
|
159
|
+
readonly name: string;
|
|
160
|
+
/**
|
|
161
|
+
* How the adapter names stored fields. SQL adapters use snake_case columns;
|
|
162
|
+
* document stores keep the declared camelCase. The plugin store translates
|
|
163
|
+
* accordingly rather than assuming one of them.
|
|
164
|
+
*/
|
|
165
|
+
readonly naming?: "snake_case" | "preserve";
|
|
166
|
+
/**
|
|
167
|
+
* True when json values must reach the driver as strings. postgres-js cannot
|
|
168
|
+
* bind a plain object; Mongo stores one natively.
|
|
169
|
+
*/
|
|
170
|
+
readonly serializesJson?: boolean;
|
|
171
|
+
/** One transaction. A duplicate (userId, dedupeKey) is reported, not thrown. */
|
|
172
|
+
createNotifications(rows: readonly InsertNotification[]): Promise<{
|
|
173
|
+
created: readonly string[];
|
|
174
|
+
deduped: readonly string[];
|
|
175
|
+
}>;
|
|
176
|
+
/** Atomic lease: a returned row stays invisible to other callers until it expires. */
|
|
177
|
+
claimPendingDeliveries(args: ClaimArgs): Promise<readonly ClaimedDelivery[]>;
|
|
178
|
+
/** Writes terminal state even when claimToken is stale. See RFC 0003 §6. */
|
|
179
|
+
releaseDeliveries(releases: readonly DeliveryRelease[]): Promise<void>;
|
|
180
|
+
listNotifications(query: FeedQuery): Promise<FeedPage>;
|
|
181
|
+
countUnseen(userId: string): Promise<number>;
|
|
182
|
+
markSeen(userId: string, before: Date): Promise<void>;
|
|
183
|
+
markRead(userId: string, notificationIds: readonly string[]): Promise<number>;
|
|
184
|
+
markAllRead(userId: string): Promise<number>;
|
|
185
|
+
getFailedDeliveries(args: {
|
|
186
|
+
since: Date;
|
|
187
|
+
limit: number;
|
|
188
|
+
}): Promise<readonly DeliveryRecord[]>;
|
|
189
|
+
/**
|
|
190
|
+
* Generic table access for plugin-declared tables. Reached only through a
|
|
191
|
+
* PluginStore, which validates the table and every column against the
|
|
192
|
+
* plugin's own schema() before anything gets here.
|
|
193
|
+
*/
|
|
194
|
+
queryTable(table: string, where: WhereClause, options: QueryOptions): Promise<Record<string, unknown>[]>;
|
|
195
|
+
insertRows(table: string, rows: readonly Record<string, unknown>[], onConflict?: readonly string[]): Promise<number>;
|
|
196
|
+
updateRows(table: string, where: WhereClause, set: Record<string, unknown>): Promise<number>;
|
|
197
|
+
deleteRows(table: string, where: WhereClause): Promise<number>;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export type { Channel as C, DatabaseAdapter as D, Frequency as F, InsertDelivery as I, NotificationRecord as N, PreferenceRecord as P, Recipient as R, SkipReason as S, DeliveryStatus as a, DeliveryMode as b, DeliveryRecord as c, ClaimArgs as d, ClaimedDelivery as e, DeliveryOutcome as f, DeliveryRelease as g, FeedPage as h, FeedQuery as i, InsertNotification as j, PluginStore as k };
|