@vex-chat/spire 1.10.4 → 1.11.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/dist/ClientManager.d.ts +1 -1
- package/dist/ClientManager.js +12 -2
- package/dist/ClientManager.js.map +1 -1
- package/dist/Database.d.ts +31 -0
- package/dist/Database.js +83 -0
- package/dist/Database.js.map +1 -1
- package/dist/NotificationService.d.ts +35 -0
- package/dist/NotificationService.js +386 -0
- package/dist/NotificationService.js.map +1 -0
- package/dist/Spire.d.ts +1 -0
- package/dist/Spire.js +61 -39
- package/dist/Spire.js.map +1 -1
- package/dist/db/schema.d.ts +16 -0
- package/dist/migrations/2026-05-10_notification-subscriptions.d.ts +8 -0
- package/dist/migrations/2026-05-10_notification-subscriptions.js +46 -0
- package/dist/migrations/2026-05-10_notification-subscriptions.js.map +1 -0
- package/dist/migrations/2026-05-15_notification-subscription-unique-key.d.ts +8 -0
- package/dist/migrations/2026-05-15_notification-subscription-unique-key.js +28 -0
- package/dist/migrations/2026-05-15_notification-subscription-unique-key.js.map +1 -0
- package/package.json +5 -5
- package/src/ClientManager.ts +20 -2
- package/src/Database.ts +145 -0
- package/src/NotificationService.ts +535 -0
- package/src/Spire.ts +83 -40
- package/src/__tests__/Database.spec.ts +124 -0
- package/src/__tests__/clientManagerReceipt.spec.ts +106 -0
- package/src/__tests__/notifyFanout.spec.ts +397 -32
- package/src/db/schema.ts +21 -1
- package/src/migrations/2026-05-10_notification-subscriptions.ts +52 -0
- package/src/migrations/2026-05-15_notification-subscription-unique-key.ts +33 -0
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { ClientManager } from "../ClientManager.ts";
|
|
8
|
+
import type { Database, NotificationSubscription } from "../Database.ts";
|
|
8
9
|
import type { BaseMsg } from "@vex-chat/types";
|
|
9
10
|
|
|
10
|
-
import { describe, expect, it, vi } from "vitest";
|
|
11
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
11
12
|
|
|
12
|
-
import {
|
|
13
|
+
import { NotificationService } from "../NotificationService.ts";
|
|
13
14
|
|
|
14
15
|
interface FakeClient {
|
|
15
16
|
getDeviceID: () => null | string;
|
|
@@ -18,21 +19,44 @@ interface FakeClient {
|
|
|
18
19
|
send: (msg: BaseMsg) => void;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
22
|
+
const subscription: NotificationSubscription = {
|
|
23
|
+
channel: "expo",
|
|
24
|
+
createdAt: "2026-05-12T00:00:00.000Z",
|
|
25
|
+
deviceID: "device-b",
|
|
26
|
+
enabled: true,
|
|
27
|
+
events: ["mail"],
|
|
28
|
+
platform: "android",
|
|
29
|
+
subscriptionID: "sub-b",
|
|
30
|
+
token: "ExponentPushToken[test]",
|
|
31
|
+
updatedAt: "2026-05-12T00:00:00.000Z",
|
|
32
|
+
userID: "user-b",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function createSpireHarness(
|
|
36
|
+
clients: FakeClient[],
|
|
37
|
+
subscriptions: NotificationSubscription[] = [],
|
|
38
|
+
removeNotificationSubscription = vi.fn(() => Promise.resolve(true)),
|
|
39
|
+
) {
|
|
40
|
+
const retrieveNotificationSubscriptions = vi.fn(() =>
|
|
41
|
+
Promise.resolve(subscriptions),
|
|
42
|
+
);
|
|
43
|
+
const db = {
|
|
44
|
+
removeNotificationSubscription,
|
|
45
|
+
retrieveNotificationSubscriptions,
|
|
46
|
+
} as unknown as Database;
|
|
47
|
+
const managers = clients as unknown as ClientManager[];
|
|
48
|
+
const removeClient = (client: ClientManager) => {
|
|
49
|
+
const idx = managers.indexOf(client);
|
|
50
|
+
if (idx >= 0) managers.splice(idx, 1);
|
|
51
|
+
};
|
|
52
|
+
const notifications = new NotificationService(db, managers, removeClient);
|
|
53
|
+
return {
|
|
54
|
+
clients: managers,
|
|
55
|
+
db,
|
|
56
|
+
notifications,
|
|
57
|
+
removeNotificationSubscription,
|
|
58
|
+
retrieveNotificationSubscriptions,
|
|
33
59
|
};
|
|
34
|
-
spire.clients = clients as unknown as ClientManager[];
|
|
35
|
-
return spire;
|
|
36
60
|
}
|
|
37
61
|
|
|
38
62
|
function fakeClient(overrides: Partial<FakeClient> = {}): FakeClient {
|
|
@@ -45,7 +69,20 @@ function fakeClient(overrides: Partial<FakeClient> = {}): FakeClient {
|
|
|
45
69
|
};
|
|
46
70
|
}
|
|
47
71
|
|
|
72
|
+
function pendingReceiptCount(service: NotificationService): number {
|
|
73
|
+
return (
|
|
74
|
+
service as unknown as {
|
|
75
|
+
pendingReceipts: Map<string, unknown>;
|
|
76
|
+
}
|
|
77
|
+
).pendingReceipts.size;
|
|
78
|
+
}
|
|
79
|
+
|
|
48
80
|
describe("Spire notify fanout", () => {
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
vi.restoreAllMocks();
|
|
83
|
+
vi.useRealTimers();
|
|
84
|
+
});
|
|
85
|
+
|
|
49
86
|
it("continues device fanout after pruning a stale client", () => {
|
|
50
87
|
const stale = fakeClient({
|
|
51
88
|
getDeviceID: vi.fn(() => null),
|
|
@@ -56,19 +93,23 @@ describe("Spire notify fanout", () => {
|
|
|
56
93
|
const other = fakeClient({
|
|
57
94
|
getDeviceID: vi.fn(() => "device-c"),
|
|
58
95
|
});
|
|
59
|
-
const
|
|
96
|
+
const { clients, notifications } = createSpireHarness([
|
|
97
|
+
stale,
|
|
98
|
+
recipient,
|
|
99
|
+
other,
|
|
100
|
+
]);
|
|
60
101
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
|
|
66
|
-
"
|
|
67
|
-
);
|
|
102
|
+
notifications.notify({
|
|
103
|
+
data: null,
|
|
104
|
+
deviceID: "device-b",
|
|
105
|
+
event: "mail",
|
|
106
|
+
transmissionID: "00000000-0000-0000-0000-000000000001",
|
|
107
|
+
userID: "user-b",
|
|
108
|
+
});
|
|
68
109
|
|
|
69
110
|
expect(recipient.send).toHaveBeenCalledTimes(1);
|
|
70
111
|
expect(other.send).not.toHaveBeenCalled();
|
|
71
|
-
expect(
|
|
112
|
+
expect(clients).toEqual([recipient, other]);
|
|
72
113
|
});
|
|
73
114
|
|
|
74
115
|
it("continues user fanout after a client inspection throws", () => {
|
|
@@ -83,16 +124,340 @@ describe("Spire notify fanout", () => {
|
|
|
83
124
|
const other = fakeClient({
|
|
84
125
|
getUserID: vi.fn(() => "user-c"),
|
|
85
126
|
});
|
|
86
|
-
const
|
|
127
|
+
const { clients, notifications } = createSpireHarness([
|
|
128
|
+
broken,
|
|
129
|
+
recipient,
|
|
130
|
+
other,
|
|
131
|
+
]);
|
|
87
132
|
|
|
88
|
-
|
|
89
|
-
"
|
|
90
|
-
"
|
|
91
|
-
"
|
|
92
|
-
);
|
|
133
|
+
notifications.notify({
|
|
134
|
+
event: "device_pending_enrollment",
|
|
135
|
+
transmissionID: "00000000-0000-0000-0000-000000000002",
|
|
136
|
+
userID: "user-b",
|
|
137
|
+
});
|
|
93
138
|
|
|
94
139
|
expect(recipient.send).toHaveBeenCalledTimes(1);
|
|
95
140
|
expect(other.send).not.toHaveBeenCalled();
|
|
96
|
-
expect(
|
|
141
|
+
expect(clients).toEqual([recipient, other]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("uses headless Expo pushes for sender-owned mail while keeping websocket fanout", async () => {
|
|
145
|
+
const recipient = fakeClient({
|
|
146
|
+
getDeviceID: vi.fn(() => "device-b"),
|
|
147
|
+
});
|
|
148
|
+
const fetchMock = vi.fn().mockResolvedValueOnce({
|
|
149
|
+
json: () =>
|
|
150
|
+
Promise.resolve({
|
|
151
|
+
data: [{ id: "receipt-a", status: "ok" }],
|
|
152
|
+
}),
|
|
153
|
+
ok: true,
|
|
154
|
+
});
|
|
155
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
156
|
+
|
|
157
|
+
const { notifications, retrieveNotificationSubscriptions } =
|
|
158
|
+
createSpireHarness([recipient], [subscription]);
|
|
159
|
+
|
|
160
|
+
notifications.notify({
|
|
161
|
+
deviceID: "device-b",
|
|
162
|
+
event: "mail",
|
|
163
|
+
headlessPushUserID: "user-b",
|
|
164
|
+
transmissionID: "00000000-0000-0000-0000-000000000007",
|
|
165
|
+
userID: "user-b",
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(recipient.send).toHaveBeenCalledTimes(1);
|
|
169
|
+
await vi.waitFor(() => {
|
|
170
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
171
|
+
});
|
|
172
|
+
expect(retrieveNotificationSubscriptions).toHaveBeenCalledWith({
|
|
173
|
+
deviceID: "device-b",
|
|
174
|
+
event: "mail",
|
|
175
|
+
userID: "user-b",
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const init = fetchMock.mock.calls[0]?.[1] as
|
|
179
|
+
| undefined
|
|
180
|
+
| { body?: unknown };
|
|
181
|
+
const messages = JSON.parse(String(init?.body)) as Array<{
|
|
182
|
+
_contentAvailable?: boolean;
|
|
183
|
+
body?: string;
|
|
184
|
+
channelId?: string;
|
|
185
|
+
data?: Record<string, unknown>;
|
|
186
|
+
tag?: string;
|
|
187
|
+
title?: string;
|
|
188
|
+
}>;
|
|
189
|
+
expect(messages[0]?._contentAvailable).toBe(true);
|
|
190
|
+
expect(messages[0]).not.toHaveProperty("body");
|
|
191
|
+
expect(messages[0]).not.toHaveProperty("channelId");
|
|
192
|
+
expect(messages[0]).not.toHaveProperty("tag");
|
|
193
|
+
expect(messages[0]).not.toHaveProperty("title");
|
|
194
|
+
expect(messages[0]?.data).toMatchObject({
|
|
195
|
+
deviceID: "device-b",
|
|
196
|
+
event: "mail",
|
|
197
|
+
headless: true,
|
|
198
|
+
transmissionID: "00000000-0000-0000-0000-000000000007",
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("checks Expo receipts and removes unregistered devices", async () => {
|
|
203
|
+
vi.useFakeTimers();
|
|
204
|
+
const fetchMock = vi
|
|
205
|
+
.fn()
|
|
206
|
+
.mockResolvedValueOnce({
|
|
207
|
+
json: () =>
|
|
208
|
+
Promise.resolve({
|
|
209
|
+
data: [{ id: "receipt-a", status: "ok" }],
|
|
210
|
+
}),
|
|
211
|
+
ok: true,
|
|
212
|
+
})
|
|
213
|
+
.mockResolvedValueOnce({
|
|
214
|
+
json: () =>
|
|
215
|
+
Promise.resolve({
|
|
216
|
+
data: {
|
|
217
|
+
"receipt-a": {
|
|
218
|
+
details: { error: "DeviceNotRegistered" },
|
|
219
|
+
message: "device is not registered",
|
|
220
|
+
status: "error",
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
}),
|
|
224
|
+
ok: true,
|
|
225
|
+
});
|
|
226
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
227
|
+
|
|
228
|
+
const { db, removeNotificationSubscription } = createSpireHarness(
|
|
229
|
+
[],
|
|
230
|
+
[subscription],
|
|
231
|
+
);
|
|
232
|
+
const service = new NotificationService(db, [], () => {}, {
|
|
233
|
+
receiptDelayMs: 1,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
service.notify({
|
|
237
|
+
deviceID: subscription.deviceID,
|
|
238
|
+
event: "mail",
|
|
239
|
+
transmissionID: "00000000-0000-0000-0000-000000000003",
|
|
240
|
+
userID: subscription.userID,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
244
|
+
|
|
245
|
+
await vi.waitFor(() => {
|
|
246
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
247
|
+
});
|
|
248
|
+
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
|
249
|
+
"https://exp.host/--/api/v2/push/getReceipts",
|
|
250
|
+
);
|
|
251
|
+
expect(removeNotificationSubscription).toHaveBeenCalledWith({
|
|
252
|
+
deviceID: subscription.deviceID,
|
|
253
|
+
subscriptionID: subscription.subscriptionID,
|
|
254
|
+
userID: subscription.userID,
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("does not leave stale pending receipts after receipt lookup failure", async () => {
|
|
259
|
+
vi.useFakeTimers();
|
|
260
|
+
const fetchMock = vi
|
|
261
|
+
.fn()
|
|
262
|
+
.mockResolvedValueOnce({
|
|
263
|
+
json: () =>
|
|
264
|
+
Promise.resolve({
|
|
265
|
+
data: [{ id: "receipt-a", status: "ok" }],
|
|
266
|
+
}),
|
|
267
|
+
ok: true,
|
|
268
|
+
})
|
|
269
|
+
.mockResolvedValueOnce({
|
|
270
|
+
ok: false,
|
|
271
|
+
status: 503,
|
|
272
|
+
});
|
|
273
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
274
|
+
|
|
275
|
+
const { db } = createSpireHarness([], [subscription]);
|
|
276
|
+
const service = new NotificationService(db, [], () => {}, {
|
|
277
|
+
receiptDelayMs: 1,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
service.notify({
|
|
281
|
+
deviceID: subscription.deviceID,
|
|
282
|
+
event: "mail",
|
|
283
|
+
transmissionID: "00000000-0000-0000-0000-000000000004",
|
|
284
|
+
userID: subscription.userID,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
288
|
+
|
|
289
|
+
await vi.waitFor(() => {
|
|
290
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
291
|
+
expect(pendingReceiptCount(service)).toBe(0);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("does not leave stale pending receipts after receipt fetch rejection", async () => {
|
|
296
|
+
vi.useFakeTimers();
|
|
297
|
+
const fetchMock = vi
|
|
298
|
+
.fn()
|
|
299
|
+
.mockResolvedValueOnce({
|
|
300
|
+
json: () =>
|
|
301
|
+
Promise.resolve({
|
|
302
|
+
data: [{ id: "receipt-a", status: "ok" }],
|
|
303
|
+
}),
|
|
304
|
+
ok: true,
|
|
305
|
+
})
|
|
306
|
+
.mockRejectedValueOnce(new Error("network unavailable"));
|
|
307
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
308
|
+
|
|
309
|
+
const { db } = createSpireHarness([], [subscription]);
|
|
310
|
+
const service = new NotificationService(db, [], () => {}, {
|
|
311
|
+
receiptDelayMs: 1,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
service.notify({
|
|
315
|
+
deviceID: subscription.deviceID,
|
|
316
|
+
event: "mail",
|
|
317
|
+
transmissionID: "00000000-0000-0000-0000-000000000008",
|
|
318
|
+
userID: subscription.userID,
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
322
|
+
|
|
323
|
+
await vi.waitFor(() => {
|
|
324
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
325
|
+
expect(pendingReceiptCount(service)).toBe(0);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("does not leave stale pending receipts after receipt lookup timeout", async () => {
|
|
330
|
+
vi.useFakeTimers();
|
|
331
|
+
const fetchMock = vi
|
|
332
|
+
.fn()
|
|
333
|
+
.mockResolvedValueOnce({
|
|
334
|
+
json: () =>
|
|
335
|
+
Promise.resolve({
|
|
336
|
+
data: [{ id: "receipt-a", status: "ok" }],
|
|
337
|
+
}),
|
|
338
|
+
ok: true,
|
|
339
|
+
})
|
|
340
|
+
.mockImplementationOnce((_input: string, init?: RequestInit) => {
|
|
341
|
+
return new Promise<Response>((_resolve, reject) => {
|
|
342
|
+
const abortError = new Error("aborted");
|
|
343
|
+
abortError.name = "AbortError";
|
|
344
|
+
const signal = init?.signal;
|
|
345
|
+
if (signal?.aborted) {
|
|
346
|
+
reject(abortError);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
signal?.addEventListener(
|
|
350
|
+
"abort",
|
|
351
|
+
() => {
|
|
352
|
+
reject(abortError);
|
|
353
|
+
},
|
|
354
|
+
{ once: true },
|
|
355
|
+
);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
359
|
+
|
|
360
|
+
const { db } = createSpireHarness([], [subscription]);
|
|
361
|
+
const service = new NotificationService(db, [], () => {}, {
|
|
362
|
+
receiptDelayMs: 1,
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
service.notify({
|
|
366
|
+
deviceID: subscription.deviceID,
|
|
367
|
+
event: "mail",
|
|
368
|
+
transmissionID: "00000000-0000-0000-0000-000000000009",
|
|
369
|
+
userID: subscription.userID,
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
373
|
+
await vi.waitFor(() => {
|
|
374
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
375
|
+
});
|
|
376
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
377
|
+
|
|
378
|
+
await vi.waitFor(() => {
|
|
379
|
+
expect(pendingReceiptCount(service)).toBe(0);
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("sends Android Expo pushes on the mobile push channel", async () => {
|
|
384
|
+
const fetchMock = vi.fn().mockResolvedValueOnce({
|
|
385
|
+
json: () =>
|
|
386
|
+
Promise.resolve({
|
|
387
|
+
data: [{ id: "receipt-a", status: "ok" }],
|
|
388
|
+
}),
|
|
389
|
+
ok: true,
|
|
390
|
+
});
|
|
391
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
392
|
+
|
|
393
|
+
const { db } = createSpireHarness([], [subscription]);
|
|
394
|
+
const service = new NotificationService(db, [], () => {});
|
|
395
|
+
|
|
396
|
+
await service["notifyPush"]({
|
|
397
|
+
deviceID: subscription.deviceID,
|
|
398
|
+
event: "mail",
|
|
399
|
+
transmissionID: "00000000-0000-0000-0000-000000000006",
|
|
400
|
+
userID: subscription.userID,
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
const init = fetchMock.mock.calls[0]?.[1] as
|
|
404
|
+
| undefined
|
|
405
|
+
| { body?: unknown };
|
|
406
|
+
const messages = JSON.parse(String(init?.body)) as Array<{
|
|
407
|
+
channelId?: string;
|
|
408
|
+
collapseId?: string;
|
|
409
|
+
data?: Record<string, unknown>;
|
|
410
|
+
priority?: string;
|
|
411
|
+
tag?: string;
|
|
412
|
+
title?: string;
|
|
413
|
+
}>;
|
|
414
|
+
expect(messages[0]?.collapseId).toBe("vex-message-summary");
|
|
415
|
+
expect(messages[0]?.channelId).toBe("vex-push-messages-v2");
|
|
416
|
+
expect(messages[0]?.priority).toBe("high");
|
|
417
|
+
expect(messages[0]?.tag).toBe("vex-message-summary");
|
|
418
|
+
expect(messages[0]?.title).toBe("New Message");
|
|
419
|
+
expect(messages[0]).not.toHaveProperty("body");
|
|
420
|
+
expect(messages[0]?.data).toMatchObject({
|
|
421
|
+
event: "mail",
|
|
422
|
+
title: "New Message",
|
|
423
|
+
transmissionID: "00000000-0000-0000-0000-000000000006",
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("awaits ticket error cleanup so rejection stays on notifyPush", async () => {
|
|
428
|
+
const cleanupError = new Error("database unavailable");
|
|
429
|
+
const removeNotificationSubscription = vi.fn(() =>
|
|
430
|
+
Promise.reject(cleanupError),
|
|
431
|
+
);
|
|
432
|
+
const fetchMock = vi.fn().mockResolvedValueOnce({
|
|
433
|
+
json: () =>
|
|
434
|
+
Promise.resolve({
|
|
435
|
+
data: [
|
|
436
|
+
{
|
|
437
|
+
details: { error: "DeviceNotRegistered" },
|
|
438
|
+
message: "device is not registered",
|
|
439
|
+
status: "error",
|
|
440
|
+
},
|
|
441
|
+
],
|
|
442
|
+
}),
|
|
443
|
+
ok: true,
|
|
444
|
+
});
|
|
445
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
446
|
+
|
|
447
|
+
const { db } = createSpireHarness(
|
|
448
|
+
[],
|
|
449
|
+
[subscription],
|
|
450
|
+
removeNotificationSubscription,
|
|
451
|
+
);
|
|
452
|
+
const service = new NotificationService(db, [], () => {});
|
|
453
|
+
|
|
454
|
+
await expect(
|
|
455
|
+
service["notifyPush"]({
|
|
456
|
+
deviceID: subscription.deviceID,
|
|
457
|
+
event: "mail",
|
|
458
|
+
transmissionID: "00000000-0000-0000-0000-000000000005",
|
|
459
|
+
userID: subscription.userID,
|
|
460
|
+
}),
|
|
461
|
+
).rejects.toThrow(cleanupError);
|
|
97
462
|
});
|
|
98
463
|
});
|
package/src/db/schema.ts
CHANGED
|
@@ -90,8 +90,10 @@ export type NewFile = Insertable<FilesTable>;
|
|
|
90
90
|
|
|
91
91
|
export type NewInvite = Insertable<InvitesTable>;
|
|
92
92
|
export type NewMail = Insertable<MailTable>;
|
|
93
|
-
export type
|
|
93
|
+
export type NewNotificationSubscription =
|
|
94
|
+
Insertable<NotificationSubscriptionsTable>;
|
|
94
95
|
|
|
96
|
+
export type NewOneTimeKey = Insertable<OneTimeKeysTable>;
|
|
95
97
|
export type NewPasskey = Insertable<PasskeysTable>;
|
|
96
98
|
export type NewPermission = Insertable<PermissionsTable>;
|
|
97
99
|
export type NewPreKey = Insertable<PreKeysTable>;
|
|
@@ -99,6 +101,23 @@ export type NewPreKey = Insertable<PreKeysTable>;
|
|
|
99
101
|
export type NewServer = Insertable<ServersTable>;
|
|
100
102
|
export type NewServiceMetric = Insertable<ServiceMetricsTable>;
|
|
101
103
|
export type NewUser = Insertable<UsersTable>;
|
|
104
|
+
export type NotificationSubscriptionRow =
|
|
105
|
+
Selectable<NotificationSubscriptionsTable>;
|
|
106
|
+
|
|
107
|
+
export interface NotificationSubscriptionsTable {
|
|
108
|
+
channel: string;
|
|
109
|
+
createdAt: string;
|
|
110
|
+
deviceID: string;
|
|
111
|
+
enabled: number;
|
|
112
|
+
events: string;
|
|
113
|
+
platform: null | string;
|
|
114
|
+
subscriptionID: string;
|
|
115
|
+
token: string;
|
|
116
|
+
updatedAt: string;
|
|
117
|
+
userID: string;
|
|
118
|
+
}
|
|
119
|
+
export type NotificationSubscriptionUpdate =
|
|
120
|
+
Updateable<NotificationSubscriptionsTable>;
|
|
102
121
|
export type OneTimeKeyRow = Selectable<OneTimeKeysTable>;
|
|
103
122
|
|
|
104
123
|
export interface OneTimeKeysTable {
|
|
@@ -153,6 +172,7 @@ export interface ServerDatabase {
|
|
|
153
172
|
files: FilesTable;
|
|
154
173
|
invites: InvitesTable;
|
|
155
174
|
mail: MailTable;
|
|
175
|
+
notification_subscriptions: NotificationSubscriptionsTable;
|
|
156
176
|
oneTimeKeys: OneTimeKeysTable;
|
|
157
177
|
passkeys: PasskeysTable;
|
|
158
178
|
permissions: PermissionsTable;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2020-2026 Vex Heavy Industries LLC
|
|
3
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
4
|
+
* Commercial licenses available at vex.wtf
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Kysely } from "kysely";
|
|
8
|
+
|
|
9
|
+
export async function down(db: Kysely<unknown>): Promise<void> {
|
|
10
|
+
await db.schema
|
|
11
|
+
.dropTable("notification_subscriptions")
|
|
12
|
+
.ifExists()
|
|
13
|
+
.execute();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function up(db: Kysely<unknown>): Promise<void> {
|
|
17
|
+
await db.schema
|
|
18
|
+
.createTable("notification_subscriptions")
|
|
19
|
+
.ifNotExists()
|
|
20
|
+
.addColumn("subscriptionID", "varchar(255)", (cb) => cb.primaryKey())
|
|
21
|
+
.addColumn("userID", "varchar(255)", (cb) => cb.notNull())
|
|
22
|
+
.addColumn("deviceID", "varchar(255)", (cb) => cb.notNull())
|
|
23
|
+
.addColumn("channel", "varchar(32)", (cb) => cb.notNull())
|
|
24
|
+
.addColumn("token", "text", (cb) => cb.notNull())
|
|
25
|
+
.addColumn("platform", "varchar(32)")
|
|
26
|
+
.addColumn("events", "text", (cb) => cb.notNull())
|
|
27
|
+
.addColumn("enabled", "integer", (cb) => cb.notNull().defaultTo(1))
|
|
28
|
+
.addColumn("createdAt", "text", (cb) => cb.notNull())
|
|
29
|
+
.addColumn("updatedAt", "text", (cb) => cb.notNull())
|
|
30
|
+
.execute();
|
|
31
|
+
|
|
32
|
+
await db.schema
|
|
33
|
+
.createIndex("notification_subscriptions_device_idx")
|
|
34
|
+
.ifNotExists()
|
|
35
|
+
.on("notification_subscriptions")
|
|
36
|
+
.columns(["deviceID", "channel"])
|
|
37
|
+
.execute();
|
|
38
|
+
|
|
39
|
+
await db.schema
|
|
40
|
+
.createIndex("notification_subscriptions_user_idx")
|
|
41
|
+
.ifNotExists()
|
|
42
|
+
.on("notification_subscriptions")
|
|
43
|
+
.columns(["userID", "channel"])
|
|
44
|
+
.execute();
|
|
45
|
+
|
|
46
|
+
await db.schema
|
|
47
|
+
.createIndex("notification_subscriptions_token_idx")
|
|
48
|
+
.ifNotExists()
|
|
49
|
+
.on("notification_subscriptions")
|
|
50
|
+
.columns(["channel", "token"])
|
|
51
|
+
.execute();
|
|
52
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2020-2026 Vex Heavy Industries LLC
|
|
3
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
4
|
+
* Commercial licenses available at vex.wtf
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { type Kysely, sql } from "kysely";
|
|
8
|
+
|
|
9
|
+
const UNIQUE_INDEX_NAME =
|
|
10
|
+
"notification_subscriptions_channel_device_token_unique_idx";
|
|
11
|
+
|
|
12
|
+
export async function down(db: Kysely<unknown>): Promise<void> {
|
|
13
|
+
await db.schema.dropIndex(UNIQUE_INDEX_NAME).ifExists().execute();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function up(db: Kysely<unknown>): Promise<void> {
|
|
17
|
+
await sql`
|
|
18
|
+
DELETE FROM notification_subscriptions
|
|
19
|
+
WHERE rowid NOT IN (
|
|
20
|
+
SELECT MAX(rowid)
|
|
21
|
+
FROM notification_subscriptions
|
|
22
|
+
GROUP BY channel, deviceID, token
|
|
23
|
+
)
|
|
24
|
+
`.execute(db);
|
|
25
|
+
|
|
26
|
+
await db.schema
|
|
27
|
+
.createIndex(UNIQUE_INDEX_NAME)
|
|
28
|
+
.ifNotExists()
|
|
29
|
+
.on("notification_subscriptions")
|
|
30
|
+
.columns(["channel", "deviceID", "token"])
|
|
31
|
+
.unique()
|
|
32
|
+
.execute();
|
|
33
|
+
}
|