agentic-notify 0.0.0-stage → 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 +98 -2
- package/dist/chunk-6CKFBZOF.js +161 -0
- package/dist/chunk-DEPJ3SHO.js +86 -0
- package/dist/chunk-FJPEUDTG.js +64 -0
- package/dist/chunk-FMU2ED2H.js +485 -0
- package/dist/chunk-GJFAXI2K.js +104 -0
- package/dist/chunk-KJKM56FT.js +31 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +227 -0
- package/dist/d1-CUcNko0m.d.ts +106 -0
- package/dist/email-UqtllxjF.d.ts +26 -0
- package/dist/gdpr-9I4eV86m.d.ts +9 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.js +46 -0
- package/dist/mcp-bin.d.ts +1 -0
- package/dist/mcp-bin.js +19 -0
- package/dist/mcp.d.ts +11 -0
- package/dist/mcp.js +13 -0
- package/dist/sdk.d.ts +110 -0
- package/dist/sdk.js +8 -0
- package/dist/testing.d.ts +13 -0
- package/dist/testing.js +155 -0
- package/dist/types-Dkst26cb.d.ts +69 -0
- package/dist/types-GuAgyMOc.d.ts +217 -0
- package/dist/worker.d.ts +61 -0
- package/dist/worker.js +170 -0
- package/package.json +85 -4
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import {
|
|
2
|
+
canonicalJson,
|
|
3
|
+
clampLimit,
|
|
4
|
+
decodeCursor,
|
|
5
|
+
encodeCursor
|
|
6
|
+
} from "./chunk-GJFAXI2K.js";
|
|
7
|
+
|
|
8
|
+
// package.json
|
|
9
|
+
var package_default = {
|
|
10
|
+
name: "agentic-notify",
|
|
11
|
+
version: "0.1.0",
|
|
12
|
+
description: "Notifications for humans and agents: in-app inbox, webhook and email delivery, with SDK, CLI, MCP server and Worker API.",
|
|
13
|
+
keywords: [
|
|
14
|
+
"notifications",
|
|
15
|
+
"inbox",
|
|
16
|
+
"webhook",
|
|
17
|
+
"agents",
|
|
18
|
+
"mcp",
|
|
19
|
+
"cli",
|
|
20
|
+
"cloudflare-workers",
|
|
21
|
+
"typescript"
|
|
22
|
+
],
|
|
23
|
+
license: "MIT",
|
|
24
|
+
author: "Pooria Arab",
|
|
25
|
+
repository: {
|
|
26
|
+
type: "git",
|
|
27
|
+
url: "git+https://github.com/pooriaarab/agentic-notify.git"
|
|
28
|
+
},
|
|
29
|
+
type: "module",
|
|
30
|
+
private: false,
|
|
31
|
+
engines: {
|
|
32
|
+
node: ">=20"
|
|
33
|
+
},
|
|
34
|
+
files: [
|
|
35
|
+
"dist"
|
|
36
|
+
],
|
|
37
|
+
publishConfig: {
|
|
38
|
+
access: "public"
|
|
39
|
+
},
|
|
40
|
+
mcpName: "io.github.pooriaarab/agentic-notify",
|
|
41
|
+
main: "./dist/index.js",
|
|
42
|
+
types: "./dist/index.d.ts",
|
|
43
|
+
exports: {
|
|
44
|
+
".": {
|
|
45
|
+
types: "./dist/index.d.ts",
|
|
46
|
+
import: "./dist/index.js"
|
|
47
|
+
},
|
|
48
|
+
"./testing": {
|
|
49
|
+
types: "./dist/testing.d.ts",
|
|
50
|
+
import: "./dist/testing.js"
|
|
51
|
+
},
|
|
52
|
+
"./sdk": {
|
|
53
|
+
types: "./dist/sdk.d.ts",
|
|
54
|
+
import: "./dist/sdk.js"
|
|
55
|
+
},
|
|
56
|
+
"./worker": {
|
|
57
|
+
types: "./dist/worker.d.ts",
|
|
58
|
+
import: "./dist/worker.js"
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
bin: {
|
|
62
|
+
"agentic-notify": "./dist/cli.js",
|
|
63
|
+
"agentic-notify-mcp": "./dist/mcp-bin.js"
|
|
64
|
+
},
|
|
65
|
+
scripts: {
|
|
66
|
+
build: "tsup",
|
|
67
|
+
typecheck: "tsc --noEmit",
|
|
68
|
+
test: "vitest run",
|
|
69
|
+
clean: "rm -rf dist",
|
|
70
|
+
prepack: "npm run build",
|
|
71
|
+
"ci:local": "npm run build && npm run typecheck && npm run check:worker && npm test",
|
|
72
|
+
"check:worker": "esbuild src/worker.ts --bundle --platform=neutral --format=esm --log-level=warning --outfile=node_modules/.cache/worker-check.js"
|
|
73
|
+
},
|
|
74
|
+
dependencies: {
|
|
75
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
76
|
+
hono: "^4.13.14",
|
|
77
|
+
zod: "^3.25.0"
|
|
78
|
+
},
|
|
79
|
+
devDependencies: {
|
|
80
|
+
"@types/node": "^22.0.0",
|
|
81
|
+
esbuild: "^0.28.2",
|
|
82
|
+
miniflare: "^4.20260730.0",
|
|
83
|
+
tsup: "^8.0.0",
|
|
84
|
+
typescript: "^5.6.0",
|
|
85
|
+
vitest: "^3.0.0"
|
|
86
|
+
},
|
|
87
|
+
peerDependencies: {
|
|
88
|
+
vitest: ">=3"
|
|
89
|
+
},
|
|
90
|
+
peerDependenciesMeta: {
|
|
91
|
+
vitest: {
|
|
92
|
+
optional: true
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// src/version.ts
|
|
98
|
+
if (typeof package_default.version !== "string" || package_default.version.length === 0) {
|
|
99
|
+
throw new Error("package.json carries no version string");
|
|
100
|
+
}
|
|
101
|
+
var VERSION = package_default.version;
|
|
102
|
+
|
|
103
|
+
// src/core/types.ts
|
|
104
|
+
import { z } from "zod";
|
|
105
|
+
var CHANNELS = ["inbox", "webhook", "email"];
|
|
106
|
+
var id = z.string().min(1).max(200);
|
|
107
|
+
var recipientSchema = z.object({ kind: z.enum(["human", "agent"]), id }).strict();
|
|
108
|
+
var typeNameSchema = z.string().regex(/^[a-z][a-z0-9_.-]{0,63}$/);
|
|
109
|
+
var resourceRefSchema = z.object({
|
|
110
|
+
kind: z.string().min(1).max(100),
|
|
111
|
+
id,
|
|
112
|
+
url: z.string().max(2e3).refine((u) => /^https?:\/\//i.test(u) && URL.canParse(u), "url must be http or https").optional()
|
|
113
|
+
}).strict();
|
|
114
|
+
var actorSchema = z.object({ kind: z.enum(["human", "agent"]), id }).strict();
|
|
115
|
+
var notificationSchema = z.object({
|
|
116
|
+
id,
|
|
117
|
+
recipient: recipientSchema,
|
|
118
|
+
type: typeNameSchema,
|
|
119
|
+
resource: resourceRefSchema,
|
|
120
|
+
actor: actorSchema,
|
|
121
|
+
body: z.string().max(2e3),
|
|
122
|
+
createdAt: z.number().int().nonnegative(),
|
|
123
|
+
readAt: z.number().int().nonnegative().nullable()
|
|
124
|
+
}).strict();
|
|
125
|
+
var sendInputSchema = z.object({
|
|
126
|
+
recipient: recipientSchema,
|
|
127
|
+
type: typeNameSchema,
|
|
128
|
+
resource: resourceRefSchema,
|
|
129
|
+
actor: actorSchema,
|
|
130
|
+
body: z.string().max(2e3)
|
|
131
|
+
}).strict();
|
|
132
|
+
var preferencesSchema = z.object({
|
|
133
|
+
channels: z.record(typeNameSchema, z.array(z.enum(CHANNELS)).max(CHANNELS.length).refine((list) => new Set(list).size === list.length, "channels must not repeat")),
|
|
134
|
+
muted: z.boolean(),
|
|
135
|
+
mutedTypes: z.array(typeNameSchema).max(200)
|
|
136
|
+
}).strict();
|
|
137
|
+
var DEFAULT_CHANNELS = ["inbox"];
|
|
138
|
+
function defaultPreferences() {
|
|
139
|
+
return { channels: {}, muted: false, mutedTypes: [] };
|
|
140
|
+
}
|
|
141
|
+
function recipientKey(recipient) {
|
|
142
|
+
return `${recipient.kind}:${recipient.id}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// src/core/service.ts
|
|
146
|
+
import { z as z2 } from "zod";
|
|
147
|
+
|
|
148
|
+
// src/core/gdpr.ts
|
|
149
|
+
var NOTES = "notifications";
|
|
150
|
+
var PREFS = "preferences";
|
|
151
|
+
async function* notificationDocs(store, recipient, ordered = true) {
|
|
152
|
+
let cursor;
|
|
153
|
+
do {
|
|
154
|
+
const page = await store.query(NOTES, {
|
|
155
|
+
where: { recipientKey: recipientKey(recipient) },
|
|
156
|
+
// Erase needs no order. Paging by id keeps it working when a doc has a bad createdAt.
|
|
157
|
+
...ordered ? { orderBy: { field: "createdAt", direction: "asc" } } : {},
|
|
158
|
+
limit: 200,
|
|
159
|
+
cursor
|
|
160
|
+
});
|
|
161
|
+
yield* page.items;
|
|
162
|
+
cursor = page.nextCursor ?? void 0;
|
|
163
|
+
} while (cursor);
|
|
164
|
+
}
|
|
165
|
+
async function exportRecipient(store, recipient) {
|
|
166
|
+
const notifications = [];
|
|
167
|
+
for await (const item of notificationDocs(store, recipient, false)) {
|
|
168
|
+
const { recipientKey: _drop, ...rest } = item.doc;
|
|
169
|
+
notifications.push(notificationSchema.parse(rest));
|
|
170
|
+
}
|
|
171
|
+
notifications.sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : 1));
|
|
172
|
+
const prefs = await store.get(PREFS, recipientKey(recipient));
|
|
173
|
+
return { recipient, preferences: prefs ? preferencesSchema.parse(prefs) : null, notifications };
|
|
174
|
+
}
|
|
175
|
+
async function eraseRecipient(store, recipient) {
|
|
176
|
+
let removed = 0;
|
|
177
|
+
for (let pass = 0; ; pass++) {
|
|
178
|
+
if (pass >= 20) throw new Error("erase did not finish: deletes had no effect");
|
|
179
|
+
const ids = [];
|
|
180
|
+
for await (const item of notificationDocs(store, recipient, false)) ids.push(item.id);
|
|
181
|
+
if (ids.length === 0) break;
|
|
182
|
+
for (const id2 of ids) await store.delete(NOTES, id2);
|
|
183
|
+
removed += ids.length;
|
|
184
|
+
}
|
|
185
|
+
await store.delete(PREFS, recipientKey(recipient));
|
|
186
|
+
return removed;
|
|
187
|
+
}
|
|
188
|
+
async function sweepBefore(store, cutoff) {
|
|
189
|
+
if (!Number.isFinite(cutoff)) throw new RangeError("cutoff must be a finite number");
|
|
190
|
+
const limit = 200;
|
|
191
|
+
let removed = 0;
|
|
192
|
+
const gone = /* @__PURE__ */ new Set();
|
|
193
|
+
for (; ; ) {
|
|
194
|
+
const page = await store.query(NOTES, { orderBy: { field: "createdAt", direction: "asc" }, limit });
|
|
195
|
+
const old = page.items.filter((i) => typeof i.doc.createdAt === "number" && i.doc.createdAt < cutoff);
|
|
196
|
+
if (old.some((i) => gone.has(i.id))) throw new Error("retention sweep did not finish: deletes had no effect");
|
|
197
|
+
for (const item of old) {
|
|
198
|
+
await store.delete(NOTES, item.id);
|
|
199
|
+
gone.add(item.id);
|
|
200
|
+
}
|
|
201
|
+
removed += old.length;
|
|
202
|
+
if (old.length > 0) continue;
|
|
203
|
+
if (page.items.length < limit || page.items.some((i) => typeof i.doc.createdAt === "number")) return removed;
|
|
204
|
+
throw new Error("retention sweep blocked: a full page of notifications has no usable createdAt");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// src/core/service.ts
|
|
209
|
+
var NotFoundError = class extends Error {
|
|
210
|
+
code = "NOT_FOUND";
|
|
211
|
+
constructor(message = "notification not found") {
|
|
212
|
+
super(message);
|
|
213
|
+
this.name = "NotFoundError";
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
function toDoc(n) {
|
|
217
|
+
return { ...n, recipientKey: recipientKey(n.recipient) };
|
|
218
|
+
}
|
|
219
|
+
function fromDoc(doc) {
|
|
220
|
+
const { recipientKey: _drop, ...rest } = doc;
|
|
221
|
+
return notificationSchema.parse(rest);
|
|
222
|
+
}
|
|
223
|
+
function createNotifier(options) {
|
|
224
|
+
const { store } = options;
|
|
225
|
+
const now = options.now ?? (() => Date.now());
|
|
226
|
+
const newId = options.newId ?? (() => crypto.randomUUID());
|
|
227
|
+
if (options.retentionMs !== void 0 && !(Number.isInteger(options.retentionMs) && options.retentionMs > 0)) {
|
|
228
|
+
throw new RangeError("retentionMs must be a positive integer");
|
|
229
|
+
}
|
|
230
|
+
async function getPreferences(recipient) {
|
|
231
|
+
const r = recipientSchema.parse(recipient);
|
|
232
|
+
const doc = await store.get(PREFS, recipientKey(r));
|
|
233
|
+
return doc ? preferencesSchema.parse(doc) : defaultPreferences();
|
|
234
|
+
}
|
|
235
|
+
async function setPreferences(recipient, prefs) {
|
|
236
|
+
const r = recipientSchema.parse(recipient);
|
|
237
|
+
const valid = preferencesSchema.parse(prefs);
|
|
238
|
+
await store.put(PREFS, recipientKey(r), valid);
|
|
239
|
+
return valid;
|
|
240
|
+
}
|
|
241
|
+
async function send(input) {
|
|
242
|
+
const valid = sendInputSchema.parse(input);
|
|
243
|
+
const prefs = await getPreferences(valid.recipient);
|
|
244
|
+
if (prefs.muted || prefs.mutedTypes.includes(valid.type)) {
|
|
245
|
+
return { notification: null, stored: false, channels: [], muted: true };
|
|
246
|
+
}
|
|
247
|
+
const own = Object.hasOwn(prefs.channels, valid.type) ? prefs.channels[valid.type] : void 0;
|
|
248
|
+
const channels = [...own ?? DEFAULT_CHANNELS];
|
|
249
|
+
const notification = notificationSchema.parse({
|
|
250
|
+
...valid,
|
|
251
|
+
id: newId(),
|
|
252
|
+
createdAt: now(),
|
|
253
|
+
readAt: null
|
|
254
|
+
});
|
|
255
|
+
if (!channels.includes("inbox")) return { notification, stored: false, channels, muted: false };
|
|
256
|
+
if (!await store.create(NOTES, notification.id, toDoc(notification))) {
|
|
257
|
+
throw new Error("notification id collision");
|
|
258
|
+
}
|
|
259
|
+
return { notification, stored: true, channels, muted: false };
|
|
260
|
+
}
|
|
261
|
+
async function list(recipient, opts = {}) {
|
|
262
|
+
const r = recipientSchema.parse(recipient);
|
|
263
|
+
const where = { recipientKey: recipientKey(r) };
|
|
264
|
+
if (opts.unreadOnly) where.readAt = null;
|
|
265
|
+
const page = await store.query(NOTES, {
|
|
266
|
+
where,
|
|
267
|
+
orderBy: { field: "createdAt", direction: "desc" },
|
|
268
|
+
limit: opts.limit,
|
|
269
|
+
cursor: opts.cursor
|
|
270
|
+
});
|
|
271
|
+
return { items: page.items.map((i) => fromDoc(i.doc)), nextCursor: page.nextCursor };
|
|
272
|
+
}
|
|
273
|
+
async function load(recipient, id2) {
|
|
274
|
+
const r = recipientSchema.parse(recipient);
|
|
275
|
+
const doc = await store.get(NOTES, id2);
|
|
276
|
+
if (!doc || doc.recipientKey !== recipientKey(r)) throw new NotFoundError();
|
|
277
|
+
return doc;
|
|
278
|
+
}
|
|
279
|
+
async function get(recipient, id2) {
|
|
280
|
+
return fromDoc(await load(recipient, id2));
|
|
281
|
+
}
|
|
282
|
+
async function markReadOnce(recipient, id2) {
|
|
283
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
284
|
+
const doc = await load(recipient, id2);
|
|
285
|
+
const n = fromDoc(doc);
|
|
286
|
+
if (n.readAt !== null) return { notification: n, won: false };
|
|
287
|
+
const next = notificationSchema.parse({ ...n, readAt: now() });
|
|
288
|
+
if (await store.replaceIf(NOTES, id2, doc, toDoc(next))) return { notification: next, won: true };
|
|
289
|
+
}
|
|
290
|
+
throw new Error("could not mark read: too much contention");
|
|
291
|
+
}
|
|
292
|
+
async function markRead(recipient, id2) {
|
|
293
|
+
return (await markReadOnce(recipient, id2)).notification;
|
|
294
|
+
}
|
|
295
|
+
async function markAllRead(recipient) {
|
|
296
|
+
let count = 0;
|
|
297
|
+
const tried = /* @__PURE__ */ new Set();
|
|
298
|
+
for (; ; ) {
|
|
299
|
+
const page = await list(recipient, { unreadOnly: true, limit: 100 });
|
|
300
|
+
const fresh = page.items.filter((n) => !tried.has(n.id));
|
|
301
|
+
if (fresh.length === 0) return count;
|
|
302
|
+
for (const n of fresh) {
|
|
303
|
+
tried.add(n.id);
|
|
304
|
+
if ((await markReadOnce(recipient, n.id)).won) count++;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
async function unreadCount(recipient) {
|
|
309
|
+
let count = 0;
|
|
310
|
+
let cursor;
|
|
311
|
+
do {
|
|
312
|
+
const page = await list(recipient, { unreadOnly: true, limit: 200, cursor });
|
|
313
|
+
count += page.items.length;
|
|
314
|
+
cursor = page.nextCursor ?? void 0;
|
|
315
|
+
} while (cursor);
|
|
316
|
+
return count;
|
|
317
|
+
}
|
|
318
|
+
async function digest(recipient, since = 0) {
|
|
319
|
+
since = z2.number().int().nonnegative().parse(since);
|
|
320
|
+
const groups = /* @__PURE__ */ new Map();
|
|
321
|
+
let total = 0;
|
|
322
|
+
let cursor;
|
|
323
|
+
do {
|
|
324
|
+
const page = await list(recipient, { unreadOnly: true, limit: 200, cursor });
|
|
325
|
+
for (const n of page.items) {
|
|
326
|
+
if (n.createdAt < since) continue;
|
|
327
|
+
total++;
|
|
328
|
+
const g = groups.get(n.type);
|
|
329
|
+
if (!g) groups.set(n.type, { count: 1, latest: n });
|
|
330
|
+
else g.count++;
|
|
331
|
+
}
|
|
332
|
+
cursor = page.nextCursor ?? void 0;
|
|
333
|
+
} while (cursor);
|
|
334
|
+
return {
|
|
335
|
+
total,
|
|
336
|
+
groups: [...groups].map(([type, g]) => ({ type, ...g })).sort((a, b) => b.latest.createdAt - a.latest.createdAt)
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
async function exportData(recipient) {
|
|
340
|
+
return exportRecipient(store, recipientSchema.parse(recipient));
|
|
341
|
+
}
|
|
342
|
+
async function erase(recipient) {
|
|
343
|
+
return { notifications: await eraseRecipient(store, recipientSchema.parse(recipient)) };
|
|
344
|
+
}
|
|
345
|
+
async function sweep() {
|
|
346
|
+
if (options.retentionMs === void 0) return 0;
|
|
347
|
+
return sweepBefore(store, now() - options.retentionMs);
|
|
348
|
+
}
|
|
349
|
+
function parseType(type) {
|
|
350
|
+
return typeNameSchema.parse(type);
|
|
351
|
+
}
|
|
352
|
+
return {
|
|
353
|
+
send,
|
|
354
|
+
list,
|
|
355
|
+
get,
|
|
356
|
+
markRead,
|
|
357
|
+
markAllRead,
|
|
358
|
+
unreadCount,
|
|
359
|
+
digest,
|
|
360
|
+
getPreferences,
|
|
361
|
+
setPreferences,
|
|
362
|
+
parseType,
|
|
363
|
+
exportData,
|
|
364
|
+
erase,
|
|
365
|
+
sweep
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// src/adapters/d1.ts
|
|
370
|
+
var FIELD = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
|
371
|
+
var SCHEMA = `CREATE TABLE IF NOT EXISTS notify_docs (
|
|
372
|
+
collection TEXT NOT NULL,
|
|
373
|
+
id TEXT NOT NULL,
|
|
374
|
+
doc TEXT NOT NULL,
|
|
375
|
+
PRIMARY KEY (collection, id)
|
|
376
|
+
)`;
|
|
377
|
+
function field(name) {
|
|
378
|
+
if (!FIELD.test(name)) throw new RangeError("bad field name");
|
|
379
|
+
return `json_extract(doc, '$.${name}')`;
|
|
380
|
+
}
|
|
381
|
+
function bindable(v) {
|
|
382
|
+
return typeof v === "boolean" ? v ? 1 : 0 : v;
|
|
383
|
+
}
|
|
384
|
+
var TYPES = {
|
|
385
|
+
number: ["integer", "real"],
|
|
386
|
+
string: ["text"],
|
|
387
|
+
boolean: ["true", "false"]
|
|
388
|
+
};
|
|
389
|
+
var INDEXES = [
|
|
390
|
+
`CREATE INDEX IF NOT EXISTS notify_by_recipient ON notify_docs (collection, json_extract(doc, '$.recipientKey'), json_extract(doc, '$.createdAt'), id)`,
|
|
391
|
+
`CREATE INDEX IF NOT EXISTS notify_by_created ON notify_docs (collection, json_extract(doc, '$.createdAt'), id)`
|
|
392
|
+
];
|
|
393
|
+
async function migrateD1(db) {
|
|
394
|
+
for (const sql of [SCHEMA, ...INDEXES]) await db.prepare(sql).bind().run();
|
|
395
|
+
}
|
|
396
|
+
var D1Store = class {
|
|
397
|
+
constructor(db) {
|
|
398
|
+
this.db = db;
|
|
399
|
+
}
|
|
400
|
+
db;
|
|
401
|
+
async get(collection, id2) {
|
|
402
|
+
const row = await this.db.prepare("SELECT doc FROM notify_docs WHERE collection = ? AND id = ?").bind(collection, id2).first();
|
|
403
|
+
return row ? JSON.parse(row.doc) : null;
|
|
404
|
+
}
|
|
405
|
+
async put(collection, id2, doc) {
|
|
406
|
+
await this.db.prepare("INSERT OR REPLACE INTO notify_docs (collection, id, doc) VALUES (?, ?, ?)").bind(collection, id2, canonicalJson(doc)).run();
|
|
407
|
+
}
|
|
408
|
+
async create(collection, id2, doc) {
|
|
409
|
+
const res = await this.db.prepare("INSERT OR IGNORE INTO notify_docs (collection, id, doc) VALUES (?, ?, ?)").bind(collection, id2, canonicalJson(doc)).run();
|
|
410
|
+
return (res.meta.changes ?? 0) > 0;
|
|
411
|
+
}
|
|
412
|
+
async replaceIf(collection, id2, expected, next) {
|
|
413
|
+
const res = await this.db.prepare("UPDATE notify_docs SET doc = ? WHERE collection = ? AND id = ? AND doc = ?").bind(canonicalJson(next), collection, id2, canonicalJson(expected)).run();
|
|
414
|
+
return (res.meta.changes ?? 0) > 0;
|
|
415
|
+
}
|
|
416
|
+
async delete(collection, id2) {
|
|
417
|
+
await this.db.prepare("DELETE FROM notify_docs WHERE collection = ? AND id = ?").bind(collection, id2).run();
|
|
418
|
+
}
|
|
419
|
+
async query(collection, query = {}) {
|
|
420
|
+
const limit = clampLimit(query.limit);
|
|
421
|
+
const desc = query.orderBy?.direction === "desc";
|
|
422
|
+
const key = query.orderBy ? field(query.orderBy.field) : "id";
|
|
423
|
+
const conds = ["collection = ?"];
|
|
424
|
+
const args = [collection];
|
|
425
|
+
for (const [name, value] of Object.entries(query.where ?? {})) {
|
|
426
|
+
if (value === null) conds.push(`${field(name)} IS NULL`);
|
|
427
|
+
else {
|
|
428
|
+
const types = TYPES[typeof value] ?? [];
|
|
429
|
+
conds.push(`${field(name)} = ? AND json_type(doc, '$.${name}') IN (${types.map(() => "?").join(",")})`);
|
|
430
|
+
args.push(bindable(value), ...types);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (query.cursor !== void 0) {
|
|
434
|
+
const [cv, cid] = decodeCursor(query.cursor);
|
|
435
|
+
if (query.orderBy && cv !== null) {
|
|
436
|
+
const allowed = TYPES[typeof cv] ?? [];
|
|
437
|
+
const marks = allowed.map(() => "?").join(",");
|
|
438
|
+
const bad = await this.db.prepare(
|
|
439
|
+
`SELECT 1 AS x FROM notify_docs WHERE ${conds.join(" AND ")} AND ${key} IS NOT NULL AND json_type(doc, '$.${query.orderBy.field}') NOT IN (${marks}) LIMIT 1`
|
|
440
|
+
).bind(...args, ...allowed).first();
|
|
441
|
+
if (bad) throw new RangeError("bad cursor");
|
|
442
|
+
}
|
|
443
|
+
const idCmp = desc ? "<" : ">";
|
|
444
|
+
if (!query.orderBy) {
|
|
445
|
+
conds.push(`id ${idCmp} ?`);
|
|
446
|
+
args.push(cid);
|
|
447
|
+
} else if (cv === null) {
|
|
448
|
+
conds.push(desc ? `(${key} IS NULL AND id < ?)` : `(${key} IS NOT NULL OR id > ?)`);
|
|
449
|
+
args.push(cid);
|
|
450
|
+
} else if (desc) {
|
|
451
|
+
conds.push(`(${key} < ? OR ${key} IS NULL OR (${key} = ? AND id < ?))`);
|
|
452
|
+
args.push(bindable(cv), bindable(cv), cid);
|
|
453
|
+
} else {
|
|
454
|
+
conds.push(`(${key} > ? OR (${key} = ? AND id > ?))`);
|
|
455
|
+
args.push(bindable(cv), bindable(cv), cid);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const dir = desc ? "DESC" : "ASC";
|
|
459
|
+
const sql = `SELECT id, doc FROM notify_docs WHERE ${conds.join(" AND ")} ORDER BY ${key} ${dir}, id ${dir} LIMIT ?`;
|
|
460
|
+
const { results } = await this.db.prepare(sql).bind(...args, limit + 1).all();
|
|
461
|
+
const rows = results.slice(0, limit).map((r) => ({ id: r.id, doc: JSON.parse(r.doc) }));
|
|
462
|
+
const last = rows[rows.length - 1];
|
|
463
|
+
const lastKey = last && query.orderBy ? last.doc[query.orderBy.field] ?? null : last?.id ?? null;
|
|
464
|
+
return {
|
|
465
|
+
items: rows,
|
|
466
|
+
nextCursor: results.length > limit && last ? encodeCursor(lastKey, last.id) : null
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
export {
|
|
472
|
+
VERSION,
|
|
473
|
+
CHANNELS,
|
|
474
|
+
recipientSchema,
|
|
475
|
+
typeNameSchema,
|
|
476
|
+
resourceRefSchema,
|
|
477
|
+
preferencesSchema,
|
|
478
|
+
DEFAULT_CHANNELS,
|
|
479
|
+
defaultPreferences,
|
|
480
|
+
recipientKey,
|
|
481
|
+
NotFoundError,
|
|
482
|
+
createNotifier,
|
|
483
|
+
migrateD1,
|
|
484
|
+
D1Store
|
|
485
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// src/core/store.ts
|
|
2
|
+
var MAX_LIMIT = 200;
|
|
3
|
+
var DEFAULT_LIMIT = 50;
|
|
4
|
+
function clampLimit(limit) {
|
|
5
|
+
if (limit === void 0) return DEFAULT_LIMIT;
|
|
6
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
7
|
+
throw new RangeError("limit must be an integer of 1 or more");
|
|
8
|
+
}
|
|
9
|
+
return Math.min(limit, MAX_LIMIT);
|
|
10
|
+
}
|
|
11
|
+
function encodeCursor(value, id) {
|
|
12
|
+
return btoa(unescape(encodeURIComponent(JSON.stringify([value, id]))));
|
|
13
|
+
}
|
|
14
|
+
function decodeCursor(cursor) {
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(decodeURIComponent(escape(atob(cursor))));
|
|
17
|
+
if (Array.isArray(parsed) && parsed.length === 2 && typeof parsed[1] === "string" && (parsed[0] === null || ["string", "number", "boolean"].includes(typeof parsed[0]))) {
|
|
18
|
+
return [parsed[0], parsed[1]];
|
|
19
|
+
}
|
|
20
|
+
} catch {
|
|
21
|
+
}
|
|
22
|
+
throw new RangeError("bad cursor");
|
|
23
|
+
}
|
|
24
|
+
function compare(a, b) {
|
|
25
|
+
if (a === b) return 0;
|
|
26
|
+
if (a === null) return -1;
|
|
27
|
+
if (b === null) return 1;
|
|
28
|
+
return a < b ? -1 : 1;
|
|
29
|
+
}
|
|
30
|
+
function canonicalJson(value) {
|
|
31
|
+
return JSON.stringify(
|
|
32
|
+
value,
|
|
33
|
+
(_k, v) => v && typeof v === "object" && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : v
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
function sameDoc(a, b) {
|
|
37
|
+
return canonicalJson(a) === canonicalJson(b);
|
|
38
|
+
}
|
|
39
|
+
var MemoryStore = class {
|
|
40
|
+
data = /* @__PURE__ */ new Map();
|
|
41
|
+
coll(name) {
|
|
42
|
+
let c = this.data.get(name);
|
|
43
|
+
if (!c) {
|
|
44
|
+
c = /* @__PURE__ */ new Map();
|
|
45
|
+
this.data.set(name, c);
|
|
46
|
+
}
|
|
47
|
+
return c;
|
|
48
|
+
}
|
|
49
|
+
async get(collection, id) {
|
|
50
|
+
const doc = this.coll(collection).get(id);
|
|
51
|
+
return doc ? structuredClone(doc) : null;
|
|
52
|
+
}
|
|
53
|
+
async put(collection, id, doc) {
|
|
54
|
+
this.coll(collection).set(id, structuredClone(doc));
|
|
55
|
+
}
|
|
56
|
+
async create(collection, id, doc) {
|
|
57
|
+
const c = this.coll(collection);
|
|
58
|
+
if (c.has(id)) return false;
|
|
59
|
+
c.set(id, structuredClone(doc));
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
async replaceIf(collection, id, expected, next) {
|
|
63
|
+
const c = this.coll(collection);
|
|
64
|
+
const current = c.get(id);
|
|
65
|
+
if (!current || !sameDoc(current, expected)) return false;
|
|
66
|
+
c.set(id, structuredClone(next));
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
async delete(collection, id) {
|
|
70
|
+
this.coll(collection).delete(id);
|
|
71
|
+
}
|
|
72
|
+
async query(collection, query = {}) {
|
|
73
|
+
const limit = clampLimit(query.limit);
|
|
74
|
+
const field = query.orderBy?.field;
|
|
75
|
+
const dir = query.orderBy?.direction === "desc" ? -1 : 1;
|
|
76
|
+
const where = Object.entries(query.where ?? {});
|
|
77
|
+
const key = (id, doc) => field ? doc[field] ?? null : id;
|
|
78
|
+
let rows = [...this.coll(collection)].filter(([, doc]) => where.every(([k, v]) => (doc[k] ?? null) === v)).sort(([ia, a], [ib, b]) => dir * (compare(key(ia, a), key(ib, b)) || compare(ia, ib)));
|
|
79
|
+
if (query.cursor !== void 0) {
|
|
80
|
+
const [cv, cid] = decodeCursor(query.cursor);
|
|
81
|
+
const kind = (v) => v === null ? "null" : typeof v;
|
|
82
|
+
if (rows.some(([id, doc]) => key(id, doc) !== null && cv !== null && kind(key(id, doc)) !== kind(cv))) {
|
|
83
|
+
throw new RangeError("bad cursor");
|
|
84
|
+
}
|
|
85
|
+
rows = rows.filter(([id, doc]) => dir * (compare(key(id, doc), cv) || compare(id, cid)) > 0);
|
|
86
|
+
}
|
|
87
|
+
const slice = rows.slice(0, limit + 1);
|
|
88
|
+
const hasMore = slice.length > limit;
|
|
89
|
+
const items = slice.slice(0, limit).map(([id, doc]) => ({ id, doc: structuredClone(doc) }));
|
|
90
|
+
const last = items[items.length - 1];
|
|
91
|
+
return {
|
|
92
|
+
items,
|
|
93
|
+
nextCursor: hasMore && last ? encodeCursor(key(last.id, last.doc), last.id) : null
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export {
|
|
99
|
+
clampLimit,
|
|
100
|
+
encodeCursor,
|
|
101
|
+
decodeCursor,
|
|
102
|
+
canonicalJson,
|
|
103
|
+
MemoryStore
|
|
104
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// src/delivery/email.ts
|
|
2
|
+
var TestEmailAdapter = class {
|
|
3
|
+
sent = [];
|
|
4
|
+
failNext = 0;
|
|
5
|
+
async send(message) {
|
|
6
|
+
if (this.failNext > 0) {
|
|
7
|
+
this.failNext--;
|
|
8
|
+
throw new Error("test adapter forced failure");
|
|
9
|
+
}
|
|
10
|
+
this.sent.push({ ...message });
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
var CONTROL = /[\x00-\x1f\x7f]/;
|
|
14
|
+
var CONTROL_G = /[\x00-\x1f\x7f]+/g;
|
|
15
|
+
function createEmailChannel(options) {
|
|
16
|
+
return {
|
|
17
|
+
async deliver(n) {
|
|
18
|
+
const to = await options.resolveAddress(n.recipient);
|
|
19
|
+
if (!to) return "skipped";
|
|
20
|
+
if (CONTROL.test(to)) throw new Error("address holds control characters");
|
|
21
|
+
const subject = `${n.type.replace(/[_.-]+/g, " ")}: ${n.resource.kind} ${n.resource.id}`.replace(CONTROL_G, " ");
|
|
22
|
+
await options.port.send({ to, subject, text: n.body });
|
|
23
|
+
return "sent";
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export {
|
|
29
|
+
TestEmailAdapter,
|
|
30
|
+
createEmailChannel
|
|
31
|
+
};
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
interface CliIo {
|
|
3
|
+
out(line: string): void;
|
|
4
|
+
err(line: string): void;
|
|
5
|
+
env: Record<string, string | undefined>;
|
|
6
|
+
fetch?: typeof fetch;
|
|
7
|
+
}
|
|
8
|
+
declare function runCli(argv: string[], io?: CliIo): Promise<number>;
|
|
9
|
+
|
|
10
|
+
export { type CliIo, runCli };
|