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.
- package/README.md +159 -78
- package/dist/adapter-B7R4rXtJ.d.cts +17 -0
- package/dist/adapter-B7R4rXtJ.d.ts +17 -0
- package/dist/{adapter-BfLe2J0t.d.cts → adapter-D3lTy-4E.d.cts} +6 -32
- package/dist/{adapter-BfLe2J0t.d.ts → adapter-D3lTy-4E.d.ts} +6 -32
- package/dist/adapters/drizzle.cjs +1 -2
- package/dist/adapters/drizzle.cjs.map +1 -1
- package/dist/adapters/drizzle.d.cts +1 -1
- package/dist/adapters/drizzle.d.ts +1 -1
- package/dist/adapters/drizzle.js +3 -4
- package/dist/adapters/drizzle.js.map +1 -1
- package/dist/adapters/mongodb.cjs +2 -5
- package/dist/adapters/mongodb.cjs.map +1 -1
- package/dist/adapters/mongodb.d.cts +4 -17
- package/dist/adapters/mongodb.d.ts +4 -17
- package/dist/adapters/mongodb.js +4 -7
- package/dist/adapters/mongodb.js.map +1 -1
- package/dist/adapters/mysql.cjs +605 -0
- package/dist/adapters/mysql.cjs.map +1 -0
- package/dist/adapters/mysql.d.cts +38 -0
- package/dist/adapters/mysql.d.ts +38 -0
- package/dist/adapters/mysql.js +51 -0
- package/dist/adapters/mysql.js.map +1 -0
- package/dist/adapters/postgres.cjs +16 -9
- package/dist/adapters/postgres.cjs.map +1 -1
- package/dist/adapters/postgres.d.cts +9 -54
- package/dist/adapters/postgres.d.ts +9 -54
- package/dist/adapters/postgres.js +6 -41
- package/dist/adapters/postgres.js.map +1 -1
- package/dist/adapters/sqlite.cjs +593 -0
- package/dist/adapters/sqlite.cjs.map +1 -0
- package/dist/adapters/sqlite.d.cts +37 -0
- package/dist/adapters/sqlite.d.ts +37 -0
- package/dist/adapters/sqlite.js +53 -0
- package/dist/adapters/sqlite.js.map +1 -0
- package/dist/browser.cjs.map +1 -1
- package/dist/browser.d.cts +2 -14
- package/dist/browser.d.ts +2 -14
- package/dist/browser.js.map +1 -1
- package/dist/chunk-2EYKPF5A.js +393 -0
- package/dist/chunk-2EYKPF5A.js.map +1 -0
- package/dist/{chunk-3Y2MBUAW.js → chunk-3VOQOGDR.js} +4 -3
- package/dist/chunk-3VOQOGDR.js.map +1 -0
- package/dist/{chunk-PSJHXZPR.js → chunk-AEYYTE5I.js} +2 -2
- package/dist/chunk-AEYYTE5I.js.map +1 -0
- package/dist/{chunk-DG3ZL5NP.js → chunk-BECMTNK7.js} +2 -2
- package/dist/chunk-BECMTNK7.js.map +1 -0
- package/dist/chunk-CK5UPVYP.js +48 -0
- package/dist/chunk-CK5UPVYP.js.map +1 -0
- package/dist/{chunk-W6I3B2CI.js → chunk-E4IZ2CQC.js} +2 -2
- package/dist/{chunk-W6I3B2CI.js.map → chunk-E4IZ2CQC.js.map} +1 -1
- package/dist/{chunk-CYKAM7LE.js → chunk-GJNLOQJS.js} +2 -2
- package/dist/chunk-GJNLOQJS.js.map +1 -0
- package/dist/chunk-HY47TQMY.js +52 -0
- package/dist/chunk-HY47TQMY.js.map +1 -0
- package/dist/chunk-M4R77BJD.js +64 -0
- package/dist/chunk-M4R77BJD.js.map +1 -0
- package/dist/{chunk-YEMRRMJQ.js → chunk-XIDVLYNQ.js} +14 -4
- package/dist/chunk-XIDVLYNQ.js.map +1 -0
- package/dist/client.cjs.map +1 -1
- package/dist/client.js.map +1 -1
- package/dist/index.cjs +2 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +15 -60
- package/dist/index.d.ts +15 -60
- package/dist/index.js +9 -10
- package/dist/index.js.map +1 -1
- package/dist/node.cjs.map +1 -1
- package/dist/node.js +1 -1
- package/dist/node.js.map +1 -1
- package/dist/{plugin-C9Hoxus2.d.ts → plugin-CHd2ZiGC.d.cts} +5 -21
- package/dist/{plugin-CiMsGlQe.d.cts → plugin-Cg9usuUi.d.ts} +5 -21
- package/dist/plugins/digests.cjs +1 -4
- package/dist/plugins/digests.cjs.map +1 -1
- package/dist/plugins/digests.d.cts +6 -31
- package/dist/plugins/digests.d.ts +6 -31
- package/dist/plugins/digests.js +1 -4
- package/dist/plugins/digests.js.map +1 -1
- package/dist/plugins/preferences-client.cjs.map +1 -1
- package/dist/plugins/preferences-client.d.cts +2 -2
- package/dist/plugins/preferences-client.d.ts +2 -2
- package/dist/plugins/preferences-client.js +1 -1
- package/dist/plugins/preferences-react.cjs.map +1 -1
- package/dist/plugins/preferences-react.js.map +1 -1
- package/dist/plugins/preferences.cjs +1 -2
- package/dist/plugins/preferences.cjs.map +1 -1
- package/dist/plugins/preferences.d.cts +2 -2
- package/dist/plugins/preferences.d.ts +2 -2
- package/dist/plugins/preferences.js +2 -3
- package/dist/plugins/preferences.js.map +1 -1
- package/dist/plugins/push.cjs +1 -2
- package/dist/plugins/push.cjs.map +1 -1
- package/dist/plugins/push.d.cts +6 -26
- package/dist/plugins/push.d.ts +6 -26
- package/dist/plugins/push.js +3 -4
- package/dist/plugins/push.js.map +1 -1
- package/dist/providers/web-push.cjs.map +1 -1
- package/dist/providers/web-push.d.cts +3 -10
- package/dist/providers/web-push.d.ts +3 -10
- package/dist/providers/web-push.js.map +1 -1
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +1 -4
- package/dist/react.d.ts +1 -4
- package/dist/react.js.map +1 -1
- package/dist/schema.cjs +119 -1
- package/dist/schema.cjs.map +1 -1
- package/dist/schema.d.cts +20 -14
- package/dist/schema.d.ts +20 -14
- package/dist/schema.js +4 -2
- package/dist/testing.cjs +3 -19
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +4 -22
- package/dist/testing.d.ts +4 -22
- package/dist/testing.js +3 -19
- package/dist/testing.js.map +1 -1
- package/package.json +12 -1
- package/dist/chunk-3Y2MBUAW.js.map +0 -1
- package/dist/chunk-CYKAM7LE.js.map +0 -1
- package/dist/chunk-DG3ZL5NP.js.map +0 -1
- package/dist/chunk-PSJHXZPR.js.map +0 -1
- 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.**
|
|
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)
|
|
4
13
|
|
|
5
|
-
|
|
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
|
-
>
|
|
16
|
+
</div>
|
|
8
17
|
|
|
9
18
|
---
|
|
10
19
|
|
|
11
|
-
|
|
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
|
-
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Why
|
|
14
37
|
|
|
15
|
-
|
|
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
|
-
|
|
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
|
|
71
|
+
pnpm add easy-ping pg zod
|
|
27
72
|
```
|
|
28
73
|
|
|
29
74
|
### 2. Create the tables
|
|
30
75
|
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
|
|
79
|
+
```ts
|
|
80
|
+
import { createPostgresTables, pgTransaction, postgresAdapter } from "easy-ping/adapters/postgres";
|
|
81
|
+
import { Pool } from "pg";
|
|
37
82
|
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
90
|
+
export const database = postgresAdapter(query, { transaction: pgTransaction(pool) });
|
|
44
91
|
```
|
|
45
92
|
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
```
|
|
96
|
+
<details>
|
|
97
|
+
<summary>Already on Drizzle</summary>
|
|
52
98
|
|
|
53
99
|
```ts
|
|
54
|
-
|
|
55
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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 {
|
|
203
|
+
import { database, pool } from "./db"; // from step 2
|
|
125
204
|
|
|
126
205
|
export const notify = easyPing({
|
|
127
|
-
database
|
|
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
|
|
140
|
-
getRecipients: async (userIds) =>
|
|
141
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
);
|