@mpgd/adapter-devvit 0.12.1 → 0.13.1
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 +55 -3
- package/dist/index.d.ts +36 -0
- package/dist/index.js +167 -8
- package/dist/notifications-client.d.ts +44 -0
- package/dist/notifications-client.js +141 -0
- package/dist/notifications-contract.d.ts +71 -0
- package/dist/notifications-contract.js +109 -0
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.js +3 -0
- package/dist/server/notifications-bridge.d.ts +103 -0
- package/dist/server/notifications-bridge.js +262 -0
- package/dist/server/notifications-delivery.d.ts +257 -0
- package/dist/server/notifications-delivery.js +431 -0
- package/dist/server/redis-notification-store.d.ts +130 -0
- package/dist/server/redis-notification-store.js +1058 -0
- package/dist/web.d.ts +10 -0
- package/dist/web.js +15 -0
- package/package.json +4 -4
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import { createNotificationProviderRegistry, createNotificationService, createNotificationServiceCatalog, } from '@mpgd/game-services/notifications';
|
|
2
|
+
import { referenceNotificationProviders, } from '@mpgd/platform/notifications';
|
|
3
|
+
import { devvitNotificationBodyMaxLength, devvitNotificationLinkPattern, devvitNotificationProviderId, devvitNotificationsPerAppPerDay, devvitNotificationsPerUserPerDay, devvitNotificationTarget, devvitNotificationTextLength, devvitNotificationTitleMaxLength, devvitUserIdPattern, } from '../notifications-contract.js';
|
|
4
|
+
import { createDevvitRedisNotificationStore, } from './redis-notification-store.js';
|
|
5
|
+
/** Provider codes of the answers this provider gives without calling `enqueue`. */
|
|
6
|
+
export const devvitNotificationProviderCodes = Object.freeze({
|
|
7
|
+
deviceRecipient: 'DEVVIT_DEVICE_RECIPIENT',
|
|
8
|
+
recipientInvalid: 'DEVVIT_RECIPIENT_INVALID',
|
|
9
|
+
linkInvalid: 'DEVVIT_LINK_INVALID',
|
|
10
|
+
textMissing: 'DEVVIT_TEXT_MISSING',
|
|
11
|
+
titleTooLong: 'DEVVIT_TITLE_TOO_LONG',
|
|
12
|
+
bodyTooLong: 'DEVVIT_BODY_TOO_LONG',
|
|
13
|
+
templateSyntax: 'DEVVIT_TEMPLATE_SYNTAX',
|
|
14
|
+
enqueueFailed: 'DEVVIT_ENQUEUE_FAILED',
|
|
15
|
+
enqueueRejected: 'DEVVIT_ENQUEUE_REJECTED',
|
|
16
|
+
enqueueUnanswered: 'DEVVIT_ENQUEUE_UNANSWERED',
|
|
17
|
+
enqueueUnclassified: 'DEVVIT_ENQUEUE_UNCLASSIFIED',
|
|
18
|
+
});
|
|
19
|
+
/**
|
|
20
|
+
* Whether a message reads as a rate limit: per user (2 a day) or per app
|
|
21
|
+
* (25,000 a day). A limit counts only next to a word about sending and
|
|
22
|
+
* never next to one about the text, so "character limit exceeded" stays a
|
|
23
|
+
* configuration refusal.
|
|
24
|
+
*/
|
|
25
|
+
function readsAsRateLimit(message) {
|
|
26
|
+
if (/rate[ -]?limit|too many (?:requests|notifications)|quota|throttl/iu.test(message)) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
// A word about size or about the text marks a length or content refusal,
|
|
30
|
+
// whatever period it names. "Message" may count sends ("daily message
|
|
31
|
+
// limit"), so it marks one only when the message names no period.
|
|
32
|
+
if (/\b(?:characters?|length|long|size|bytes?|title|body|link|text|content|payload|template)\b/iu.test(message)) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
if (!dailyLimitWords.test(message) && /\bmessages?\b/iu.test(message)) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
// "Daily send limit", "per-user limits": the limit names what it counts.
|
|
39
|
+
if (adjacentCountedLimit.test(message)) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
return /\blimits?\b/iu.test(message)
|
|
43
|
+
&& /\b(?:exceed(?:s|ed)?|reach(?:es|ed)?|hit)\b/iu.test(message)
|
|
44
|
+
&& (dailyLimitWords.test(message) || /\b(?:notifications?|user|app|send(?:s|ing)?)\b/iu.test(message));
|
|
45
|
+
}
|
|
46
|
+
/** The words that name a daily period, once: Devvit's limits are per user per day and per app per day. */
|
|
47
|
+
const dailyPeriodSource = String.raw `daily|per[- ]?(?:day|user|app)|today'?s?`;
|
|
48
|
+
const dailyLimitWords = new RegExp(String.raw `\b(?:${dailyPeriodSource})\b`, 'iu');
|
|
49
|
+
const adjacentCountedLimit = new RegExp(String.raw `\b(?:${dailyPeriodSource}|send(?:ing)?|notifications?) limits?\b`, 'iu');
|
|
50
|
+
/**
|
|
51
|
+
* A message that reads as this user's refusal: not opted in, opted out, no
|
|
52
|
+
* such user or account. It counts only for an error that names this
|
|
53
|
+
* recipient.
|
|
54
|
+
*/
|
|
55
|
+
function readsAsUserRefusal(message) {
|
|
56
|
+
// A message about the link, the post, or the comment is not the user's.
|
|
57
|
+
if (/\b(?:perma)?(?:link|post|comment)(?:s|_?ids?)?\b|\bt[13]_/iu.test(message)) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return /\bopt(?:ed|ing)?[ -]?(?:in|out)\b|\bnot subscribed\b|\bnotifications? (?:are )?disabled\b/iu.test(message)
|
|
61
|
+
|| /\b(?:user|account|recipient)(?:\s+(?:with\s+)?(?:id\s+)?t2_\w+)?\s+(?:is\s+|was\s+|has\s+been\s+)?(?:not found|does not exist|deleted|suspended|banned)\b/iu
|
|
62
|
+
.test(message);
|
|
63
|
+
}
|
|
64
|
+
/** The reason a report names: only the two reasons this provider answers. */
|
|
65
|
+
function reasonOf(answer) {
|
|
66
|
+
if (answer.status !== 'rejected') {
|
|
67
|
+
return {};
|
|
68
|
+
}
|
|
69
|
+
if (answer.reason === 'recipient' || answer.reason === 'configuration') {
|
|
70
|
+
return { reason: answer.reason };
|
|
71
|
+
}
|
|
72
|
+
return {};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The Devvit delivery provider (`{ id: 'devvit', send, readPermission }`).
|
|
76
|
+
* One recipient per `enqueue` call in the first wave (RFC "Open questions",
|
|
77
|
+
* bulk sends).
|
|
78
|
+
*
|
|
79
|
+
* - **What it sends.** The gate's title and body as they are, with the
|
|
80
|
+
* send's `link` (else the mapping's) and no template data. Every Mustache
|
|
81
|
+
* tag starts with `{{`, so a filled text that still holds one (from
|
|
82
|
+
* template data such as a player name, or a catalog brace outside a
|
|
83
|
+
* `{{name}}` placeholder) is refused (`rejected`, `configuration`, provider
|
|
84
|
+
* code `DEVVIT_TEMPLATE_SYNTAX`) before anything is sent.
|
|
85
|
+
* - **Limits it checks first.** A missing or blank title or body, a title over 60 or a body over 100 code
|
|
86
|
+
* points of the filled text (see `devvitNotificationTextLength`), a link
|
|
87
|
+
* that is not a `t3_` or `t1_` id, and a recipient that is not a `t2_` id
|
|
88
|
+
* are `rejected` with reason `configuration` (the recipient: `recipient`),
|
|
89
|
+
* because the same send would fail again.
|
|
90
|
+
* - **Answers.** `successCount` above 0 is `accepted`. Otherwise an error for
|
|
91
|
+
* this recipient (or for no named user) is a failure Devvit counted:
|
|
92
|
+
* a message that reads as a rate limit is `rate-limited` (deferred, for
|
|
93
|
+
* at most a day), one that reads as the user's refusal (not opted in, no
|
|
94
|
+
* such user) in an error that names this recipient is `rejected` with
|
|
95
|
+
* reason `recipient`. Any other error is `ambiguous` (provider code
|
|
96
|
+
* `DEVVIT_ENQUEUE_UNCLASSIFIED`): Devvit documents no error messages, so
|
|
97
|
+
* an unknown one is neither proof of a setup error nor of a refusal. The
|
|
98
|
+
* service reports it to the operator and replays it after each lease,
|
|
99
|
+
* which doubles from the claim lease up to an hour, for at most 24 hours
|
|
100
|
+
* after the first attempt (`notificationAmbiguousReplayHours`): with the
|
|
101
|
+
* default 300-second lease, at most 27 calls. A call that throws,
|
|
102
|
+
* or an answer that counts neither a success nor a failure, is
|
|
103
|
+
* `ambiguous` too: Devvit may have queued it, and `enqueue` takes no
|
|
104
|
+
* idempotency key, so a replay may show twice.
|
|
105
|
+
* `classifyEnqueueError` overrides each default, and `onEnqueueError`
|
|
106
|
+
* sees every failure with Devvit's message.
|
|
107
|
+
* - **Opt-in at send time.** `readPermission` reads `isOptedIn`, since no
|
|
108
|
+
* opt-out event exists and opt-out may move into Reddit settings: opted in
|
|
109
|
+
* is `granted`, otherwise `prompt`.
|
|
110
|
+
*/
|
|
111
|
+
export function createDevvitNotificationDeliveryProvider(input) {
|
|
112
|
+
const userIdOf = input.userIdOf ?? ((player) => player);
|
|
113
|
+
const rejected = (reason, providerCode) => ({ status: 'rejected', reason, providerCode });
|
|
114
|
+
return {
|
|
115
|
+
id: devvitNotificationProviderId,
|
|
116
|
+
async send(request) {
|
|
117
|
+
if (request.recipient.kind !== 'platform-user') {
|
|
118
|
+
return rejected('configuration', devvitNotificationProviderCodes.deviceRecipient);
|
|
119
|
+
}
|
|
120
|
+
const userId = request.recipient.id;
|
|
121
|
+
if (!devvitUserIdPattern.test(userId)) {
|
|
122
|
+
return rejected('recipient', devvitNotificationProviderCodes.recipientInvalid);
|
|
123
|
+
}
|
|
124
|
+
const link = request.link;
|
|
125
|
+
if (link === undefined || !devvitNotificationLinkPattern.test(link)) {
|
|
126
|
+
return rejected('configuration', devvitNotificationProviderCodes.linkInvalid);
|
|
127
|
+
}
|
|
128
|
+
const { title, body } = request.content;
|
|
129
|
+
if (title === undefined || body === undefined || title.trim() === '' || body.trim() === '') {
|
|
130
|
+
// A host template or an empty text: Devvit sends the title and body it is given.
|
|
131
|
+
return rejected('configuration', devvitNotificationProviderCodes.textMissing);
|
|
132
|
+
}
|
|
133
|
+
if (devvitNotificationTextLength(title) > devvitNotificationTitleMaxLength) {
|
|
134
|
+
return rejected('configuration', devvitNotificationProviderCodes.titleTooLong);
|
|
135
|
+
}
|
|
136
|
+
if (devvitNotificationTextLength(body) > devvitNotificationBodyMaxLength) {
|
|
137
|
+
return rejected('configuration', devvitNotificationProviderCodes.bodyTooLong);
|
|
138
|
+
}
|
|
139
|
+
if (title.includes('{{') || body.includes('{{')) {
|
|
140
|
+
// From template data or from a catalog brace outside a `{{name}}`
|
|
141
|
+
// placeholder: either way the operator should see it.
|
|
142
|
+
return rejected('configuration', devvitNotificationProviderCodes.templateSyntax);
|
|
143
|
+
}
|
|
144
|
+
const report = (source, message, answer) => {
|
|
145
|
+
try {
|
|
146
|
+
input.onEnqueueError?.({
|
|
147
|
+
sendKey: request.sendKey,
|
|
148
|
+
source,
|
|
149
|
+
message,
|
|
150
|
+
answer: answer.status,
|
|
151
|
+
...reasonOf(answer),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// A failing log never changes the answer.
|
|
156
|
+
}
|
|
157
|
+
return answer;
|
|
158
|
+
};
|
|
159
|
+
const failure = (source, message, fallback) => {
|
|
160
|
+
let kind = fallback;
|
|
161
|
+
let note = '';
|
|
162
|
+
try {
|
|
163
|
+
kind = input.classifyEnqueueError?.(message, source) ?? fallback;
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
// A failing classifier keeps the default answer; the report says so.
|
|
167
|
+
note = ` (classifyEnqueueError threw: ${error instanceof Error ? error.message : String(error)})`;
|
|
168
|
+
}
|
|
169
|
+
// The provider code follows the final answer, after any override; a
|
|
170
|
+
// thrown call keeps DEVVIT_ENQUEUE_FAILED, which names its source.
|
|
171
|
+
let providerCode = devvitNotificationProviderCodes.enqueueRejected;
|
|
172
|
+
if (source === 'thrown') {
|
|
173
|
+
providerCode = devvitNotificationProviderCodes.enqueueFailed;
|
|
174
|
+
}
|
|
175
|
+
else if (kind === 'ambiguous') {
|
|
176
|
+
providerCode = devvitNotificationProviderCodes.enqueueUnclassified;
|
|
177
|
+
}
|
|
178
|
+
let answer;
|
|
179
|
+
if (kind === 'rate-limited') {
|
|
180
|
+
// A daily limit lifts no sooner than an hour from now; any other
|
|
181
|
+
// waits the service's default first delay.
|
|
182
|
+
answer = dailyLimitWords.test(message)
|
|
183
|
+
? { status: 'rate-limited', retryAfterSeconds: 3600 }
|
|
184
|
+
: { status: 'rate-limited' };
|
|
185
|
+
}
|
|
186
|
+
else if (kind === 'ambiguous') {
|
|
187
|
+
answer = { status: 'ambiguous', providerCode };
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
answer = rejected(kind === 'recipient' ? 'recipient' : 'configuration', providerCode);
|
|
191
|
+
}
|
|
192
|
+
return report(source, `${message}${note}`, answer);
|
|
193
|
+
};
|
|
194
|
+
let response;
|
|
195
|
+
try {
|
|
196
|
+
response = await input.notifications.enqueue({
|
|
197
|
+
title,
|
|
198
|
+
body,
|
|
199
|
+
recipients: [{
|
|
200
|
+
userId: userId,
|
|
201
|
+
link: link,
|
|
202
|
+
data: {},
|
|
203
|
+
}],
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
return failure('thrown', error instanceof Error ? error.message : String(error), 'ambiguous');
|
|
208
|
+
}
|
|
209
|
+
if (typeof response?.successCount === 'number' && response.successCount > 0) {
|
|
210
|
+
return { status: 'accepted' };
|
|
211
|
+
}
|
|
212
|
+
const errors = Array.isArray(response?.errors) ? response.errors : [];
|
|
213
|
+
const ours = errors.find((error) => error?.userId === userId)
|
|
214
|
+
?? errors.find((error) => error?.userId === undefined);
|
|
215
|
+
const failed = typeof response?.failureCount === 'number' && response.failureCount > 0;
|
|
216
|
+
if (ours === undefined && !failed) {
|
|
217
|
+
// Neither queued nor refused: Devvit may still have queued it. No
|
|
218
|
+
// error exists to classify, so the answer stays ambiguous.
|
|
219
|
+
return report('answer', '(no success and no error in the answer)', {
|
|
220
|
+
status: 'ambiguous',
|
|
221
|
+
providerCode: devvitNotificationProviderCodes.enqueueUnanswered,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
const message = typeof ours?.message === 'string' ? ours.message : '';
|
|
225
|
+
let fallback = 'ambiguous';
|
|
226
|
+
if (readsAsRateLimit(message)) {
|
|
227
|
+
fallback = 'rate-limited';
|
|
228
|
+
}
|
|
229
|
+
else if (ours?.userId === userId && readsAsUserRefusal(message)) {
|
|
230
|
+
// Only an error that names this recipient proves a refusal of the user.
|
|
231
|
+
fallback = 'recipient';
|
|
232
|
+
}
|
|
233
|
+
return failure('answer', message, fallback);
|
|
234
|
+
},
|
|
235
|
+
async readPermission({ player }) {
|
|
236
|
+
const userId = userIdOf(player);
|
|
237
|
+
if (userId === undefined || !devvitUserIdPattern.test(userId)) {
|
|
238
|
+
return 'unknown';
|
|
239
|
+
}
|
|
240
|
+
return (await input.notifications.isOptedIn(userId)) ? 'granted' : 'prompt';
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* The Devvit registry entry: the reference `devvit` entry of
|
|
246
|
+
* `@mpgd/platform/notifications`, whose values this provider owns. 2 sends
|
|
247
|
+
* per user per day is a provider limit (a day-long window suppresses), and
|
|
248
|
+
* 25,000 per app per day is the `rate` the scheduler keeps to. A fresh copy
|
|
249
|
+
* on every call.
|
|
250
|
+
*/
|
|
251
|
+
export function createDevvitNotificationProviderEntry() {
|
|
252
|
+
const entry = structuredClone(referenceNotificationProviders.devvit);
|
|
253
|
+
return {
|
|
254
|
+
...entry,
|
|
255
|
+
limits: [{ count: devvitNotificationsPerUserPerDay, windowSeconds: 86_400 }],
|
|
256
|
+
rate: { count: devvitNotificationsPerAppPerDay, windowSeconds: 86_400 },
|
|
257
|
+
mapping: {
|
|
258
|
+
...entry.mapping,
|
|
259
|
+
title: { required: true, maxLength: devvitNotificationTitleMaxLength },
|
|
260
|
+
body: { required: true, maxLength: devvitNotificationBodyMaxLength },
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* The notifications v2 service as the Devvit server runs it: the Redis
|
|
266
|
+
* store, the Devvit provider for the `reddit` target, and the catalog
|
|
267
|
+
* checked against the Devvit entry (title ≤ 60 and body ≤ 100 code points,
|
|
268
|
+
* an optional `t3_`/`t1_` default link). A player is a Reddit user id
|
|
269
|
+
* (`context.userId`), which is also the recipient; any other player id has
|
|
270
|
+
* no Devvit recipient (`unavailable: no-endpoint`).
|
|
271
|
+
*
|
|
272
|
+
* Players' own calls take a `devvitNotificationSession(userId)`. Server
|
|
273
|
+
* reminders (`setReminder`) fire through the `dispatch` scheduler task
|
|
274
|
+
* (`runDevvitNotificationTask`). The scheduler pushes no consent notices
|
|
275
|
+
* through Devvit: its entry fixes the title and body per topic, so it does
|
|
276
|
+
* not carry notices (`notificationProviderCarriesNotices`).
|
|
277
|
+
*/
|
|
278
|
+
export function createDevvitNotificationService(input) {
|
|
279
|
+
const { redis, notifications, catalog: catalogFile, deployment, store: givenStore, storeOptions, classifyEnqueueError, ...options } = input;
|
|
280
|
+
if (givenStore !== undefined && (redis !== undefined || storeOptions !== undefined)) {
|
|
281
|
+
throw new TypeError('Pass either a notification store or Redis with its store options, not both.');
|
|
282
|
+
}
|
|
283
|
+
let store;
|
|
284
|
+
if (givenStore !== undefined) {
|
|
285
|
+
store = givenStore;
|
|
286
|
+
}
|
|
287
|
+
else if (redis === undefined) {
|
|
288
|
+
throw new TypeError('The Devvit notifications service needs Redis or a notification store.');
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
// A scheduled purge runs in a time-bounded Devvit task: bound each call.
|
|
292
|
+
store = createDevvitRedisNotificationStore(redis, {
|
|
293
|
+
...storeOptions,
|
|
294
|
+
purgeLimit: storeOptions?.purgeLimit ?? devvitNotificationPurgeBatch,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
const registry = createNotificationProviderRegistry([
|
|
298
|
+
{
|
|
299
|
+
entry: createDevvitNotificationProviderEntry(),
|
|
300
|
+
delivery: createDevvitNotificationDeliveryProvider({
|
|
301
|
+
notifications,
|
|
302
|
+
...(classifyEnqueueError === undefined ? {} : { classifyEnqueueError }),
|
|
303
|
+
onEnqueueError: (report) => {
|
|
304
|
+
// Routine answers (a rate limit, a user's refusal) are not errors.
|
|
305
|
+
if (report.answer !== 'ambiguous' && report.reason !== 'configuration') {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
options.onError?.(new Error(`Devvit enqueue failed (${report.source}): ${report.message}`), {
|
|
309
|
+
operation: 'devvit.enqueue',
|
|
310
|
+
sendKey: report.sendKey,
|
|
311
|
+
provider: devvitNotificationProviderId,
|
|
312
|
+
});
|
|
313
|
+
},
|
|
314
|
+
}),
|
|
315
|
+
targets: [devvitNotificationTarget],
|
|
316
|
+
},
|
|
317
|
+
]);
|
|
318
|
+
const catalog = createNotificationServiceCatalog(catalogFile, {
|
|
319
|
+
registry,
|
|
320
|
+
...(deployment === undefined ? {} : { deployment }),
|
|
321
|
+
});
|
|
322
|
+
const service = createNotificationService({
|
|
323
|
+
...options,
|
|
324
|
+
store,
|
|
325
|
+
registry,
|
|
326
|
+
catalog,
|
|
327
|
+
...(deployment === undefined ? {} : { deployment }),
|
|
328
|
+
resolvePlatformRecipient: ({ provider, player }) => provider === devvitNotificationProviderId && devvitUserIdPattern.test(player)
|
|
329
|
+
? player
|
|
330
|
+
: undefined,
|
|
331
|
+
});
|
|
332
|
+
return { service, store, registry, catalog };
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* The notifications session of a Devvit request's logged-in user. A Devvit
|
|
336
|
+
* player has no game-services session: the user id is the player, and one
|
|
337
|
+
* session and installation per user, which no endpoint uses because Devvit
|
|
338
|
+
* registers no device address.
|
|
339
|
+
*/
|
|
340
|
+
export function devvitNotificationSession(userId) {
|
|
341
|
+
if (!devvitUserIdPattern.test(userId)) {
|
|
342
|
+
throw new TypeError('A Devvit notifications session needs a t2_ Reddit user id.');
|
|
343
|
+
}
|
|
344
|
+
return { player: userId, sessionId: `devvit:${userId}`, installationId: `devvit:${userId}` };
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* The Devvit scheduler tasks that keep the service's own work moving:
|
|
348
|
+
* `dispatch` runs the service's scheduler (`runScheduler`) every minute,
|
|
349
|
+
* which turns due server reminders into sends and replays due rows
|
|
350
|
+
* (deferred sends, rate-limited ones, and ambiguous answers once their
|
|
351
|
+
* lease ends), and `purge` forgets old keys,
|
|
352
|
+
* history, and dead endpoints every five minutes. They run, and use two of
|
|
353
|
+
* the installation's recurring tasks, even while the game has no
|
|
354
|
+
* notifications service; such a run does no work. A game declares both in
|
|
355
|
+
* `devvit.json` (`scheduler.tasks`, each with its `endpoint` and `cron`)
|
|
356
|
+
* and routes each endpoint to `runDevvitNotificationTask`.
|
|
357
|
+
*
|
|
358
|
+
* Devvit scheduler: <https://developers.reddit.com/docs/versioned_docs/version-0.14/capabilities/server/scheduler>
|
|
359
|
+
* (a recurring task is a `devvit.json` `scheduler.tasks` entry whose
|
|
360
|
+
* endpoint matches `/internal/.+`, answered with a `TaskResponse`, `{}`).
|
|
361
|
+
*/
|
|
362
|
+
export const devvitNotificationSchedulerTasks = Object.freeze({
|
|
363
|
+
dispatch: Object.freeze({
|
|
364
|
+
name: 'mpgd-notifications-dispatch',
|
|
365
|
+
endpoint: '/internal/scheduler/mpgd-notifications-dispatch',
|
|
366
|
+
cron: '* * * * *',
|
|
367
|
+
}),
|
|
368
|
+
purge: Object.freeze({
|
|
369
|
+
name: 'mpgd-notifications-purge',
|
|
370
|
+
endpoint: '/internal/scheduler/mpgd-notifications-purge',
|
|
371
|
+
cron: '*/5 * * * *',
|
|
372
|
+
}),
|
|
373
|
+
});
|
|
374
|
+
/**
|
|
375
|
+
* Due schedule entries, and due sends, one scheduler round of a dispatch
|
|
376
|
+
* task takes at most (`runScheduler`'s `limit`).
|
|
377
|
+
*/
|
|
378
|
+
export const devvitNotificationDispatchBatch = 10;
|
|
379
|
+
/**
|
|
380
|
+
* Rows of each kind one purge task deletes at most (the Redis store's
|
|
381
|
+
* `purgeLimit`). Every five minutes that is 57,600 a day of each kind,
|
|
382
|
+
* above the 25,000 sends a day Devvit allows an app.
|
|
383
|
+
*/
|
|
384
|
+
export const devvitNotificationPurgeBatch = 200;
|
|
385
|
+
/**
|
|
386
|
+
* A dispatch task starts no scheduler round after this long
|
|
387
|
+
* (`runScheduler`'s `budgetMs`), so a run lasts the budget plus at most one
|
|
388
|
+
* round. Devvit documents no time limit for a scheduler task; this keeps a
|
|
389
|
+
* run well inside the next minute's.
|
|
390
|
+
*/
|
|
391
|
+
export const devvitNotificationDispatchBudgetMs = 20_000;
|
|
392
|
+
/** The task an internal scheduler endpoint names, or `undefined` for any other path. */
|
|
393
|
+
export function devvitNotificationTaskForEndpoint(pathname) {
|
|
394
|
+
for (const [task, declared] of Object.entries(devvitNotificationSchedulerTasks)) {
|
|
395
|
+
if (declared.endpoint === pathname) {
|
|
396
|
+
return task;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return undefined;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Runs one scheduler task.
|
|
403
|
+
*
|
|
404
|
+
* `dispatch` is one `runScheduler` run, bounded by time: rounds of at most
|
|
405
|
+
* `dispatchLimit` (10, a positive integer) due schedule entries and due
|
|
406
|
+
* sends, which go on while a round filled its batch and got somewhere, and
|
|
407
|
+
* start no round after `budgetMs` (20 seconds), so a run lasts the budget
|
|
408
|
+
* plus at most one round; the sends of a run are not otherwise capped. Due
|
|
409
|
+
* rows count against the Devvit entry's `rate`, 25,000 a day, a daily cap
|
|
410
|
+
* rather than a spread. One failing send never stops the others.
|
|
411
|
+
*
|
|
412
|
+
* `purge` deletes at most the store's `purgeLimit` rows of each kind (200
|
|
413
|
+
* for the store `createDevvitNotificationService` builds). A backlog
|
|
414
|
+
* drains over the following runs.
|
|
415
|
+
*/
|
|
416
|
+
export async function runDevvitNotificationTask(service, task, options = {}) {
|
|
417
|
+
if (task === 'dispatch') {
|
|
418
|
+
const limit = options.dispatchLimit ?? devvitNotificationDispatchBatch;
|
|
419
|
+
const budgetMs = options.budgetMs ?? devvitNotificationDispatchBudgetMs;
|
|
420
|
+
if (!Number.isSafeInteger(limit) || limit < 1) {
|
|
421
|
+
throw new RangeError('dispatchLimit must be a positive integer');
|
|
422
|
+
}
|
|
423
|
+
if (!Number.isFinite(budgetMs) || budgetMs <= 0) {
|
|
424
|
+
throw new RangeError('budgetMs must be a positive number of milliseconds');
|
|
425
|
+
}
|
|
426
|
+
const run = await service.runScheduler({ limit, budgetMs });
|
|
427
|
+
return { task, touched: run.results.length, failed: run.failed };
|
|
428
|
+
}
|
|
429
|
+
const counts = await service.purge();
|
|
430
|
+
return { task, ...counts };
|
|
431
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { type NotificationStore } from '@mpgd/game-services/notifications';
|
|
2
|
+
/**
|
|
3
|
+
* A `NotificationStore` over Devvit Redis (notifications v2 RFC, "Delivery
|
|
4
|
+
* providers", Devvit: "The same delivery module and policy gate run there
|
|
5
|
+
* with a Redis-backed ledger"). The notifications service of
|
|
6
|
+
* `@mpgd/game-services/notifications` keeps every rule; this store keeps the
|
|
7
|
+
* rows and answers each atomic step with one WATCH/MULTI/EXEC transaction.
|
|
8
|
+
*
|
|
9
|
+
* - **Sends** (the claim/lease ledger) are one JSON value per key with a
|
|
10
|
+
* version token. `updateSend` writes only while the stored version is the
|
|
11
|
+
* one the caller read, inside a transaction that watches the key, so a
|
|
12
|
+
* holder whose lease another worker took over cannot write its answer
|
|
13
|
+
* back. The due, in-flight, and retention indexes are sorted sets written
|
|
14
|
+
* in the same transaction as the value, so they never disagree with it.
|
|
15
|
+
* - **Consent records** are a sorted set per player scored by the record's
|
|
16
|
+
* millisecond; an append watches it, so parallel appends each take their
|
|
17
|
+
* own millisecond. `pruneConsent` deletes records and the notices given
|
|
18
|
+
* about them in one transaction over the player's two keys.
|
|
19
|
+
* - **Device endpoints** are one JSON value per provider and installation,
|
|
20
|
+
* with an address pointer per provider and address (the uniqueness check)
|
|
21
|
+
* and sorted-set indexes per player, session, and installation, all
|
|
22
|
+
* written in the endpoint's own transaction. A call reads and watches only
|
|
23
|
+
* the keys of the endpoints it touches, so reads and contention scale with
|
|
24
|
+
* one player's endpoints. `purge` reads, per cutoff, only the endpoints
|
|
25
|
+
* past it from a time index. The address pointer's key holds a hash of
|
|
26
|
+
* the address, so a device token never appears in a key name. Updates
|
|
27
|
+
* to several endpoints (a session's) commit one endpoint at a time. A
|
|
28
|
+
* Devvit server serves only platform-user
|
|
29
|
+
* providers, so it stores none in practice.
|
|
30
|
+
* - **History** is a hash per player (one field per send and provider, so a
|
|
31
|
+
* repeat is ignored) with sorted sets by time: per player, for all
|
|
32
|
+
* players (purge), and per provider. `countHistory` counts the provider's
|
|
33
|
+
* set by rank (`@devvit/redis` has no ZCOUNT): a few reads, not a
|
|
34
|
+
* snapshot, which the scheduler's approximate pacing allows.
|
|
35
|
+
* - **Schedule entries** (server reminders, and notice checks for a
|
|
36
|
+
* provider that carries notices) are one JSON
|
|
37
|
+
* value per player and id with a version token, written as sends are.
|
|
38
|
+
* The due index is one sorted set by `dueAt` whose member encodes the
|
|
39
|
+
* player and id with fixed-width code points, so Redis's byte order of
|
|
40
|
+
* equal scores is code-point order by player, then id. A sorted set per
|
|
41
|
+
* player holds the ids of pending reminders, so `countPendingReminders`
|
|
42
|
+
* is one ZCARD.
|
|
43
|
+
* - **Times.** A time the service passes to a schedule step, a due or
|
|
44
|
+
* history listing, a history write or count, a pruning, or a purge is
|
|
45
|
+
* refused with a `TypeError` when it does not parse, before anything is
|
|
46
|
+
* read or written, as in the in-memory store. Other writes take the
|
|
47
|
+
* service's times as given.
|
|
48
|
+
* - **Order.** Lists come sorted (by time, then by key or installation),
|
|
49
|
+
* not in insertion order as in the in-memory store; the interface promises
|
|
50
|
+
* only "oldest first" and "earliest first", and the service sorts where
|
|
51
|
+
* it needs more.
|
|
52
|
+
* - **Purge** deletes every row past its cutoff, as the interface says; a
|
|
53
|
+
* deployment that bounds each call sets `purgeLimit`, and the next
|
|
54
|
+
* scheduled purge continues.
|
|
55
|
+
*
|
|
56
|
+
* The Devvit Redis client (`@devvit/redis` `TxClient.exec()`) resolves an
|
|
57
|
+
* aborted transaction as an empty array, never `null`, so a result shorter
|
|
58
|
+
* than the commands queued would be a conflict in
|
|
59
|
+
* `createDevvitRedisPostOperationStore`. Here every transaction ends with a
|
|
60
|
+
* commit marker whose reply always maps, so only an empty or all-nil result
|
|
61
|
+
* is an abort, and a committed write is never retried as if it were lost.
|
|
62
|
+
* Devvit allows 20 concurrent transactions per installation by default.
|
|
63
|
+
*
|
|
64
|
+
* Devvit Redis: <https://developers.reddit.com/docs/versioned_docs/version-0.14/capabilities/server/redis>
|
|
65
|
+
* (transactions: WATCH, MULTI, EXEC, DISCARD, UNWATCH).
|
|
66
|
+
*/
|
|
67
|
+
/** The member and score of a sorted-set entry, as `@devvit/redis` reads and writes them. */
|
|
68
|
+
export interface DevvitNotificationRedisMember {
|
|
69
|
+
readonly member: string;
|
|
70
|
+
readonly score: number;
|
|
71
|
+
}
|
|
72
|
+
/** The commands this store queues inside a transaction (`@devvit/redis` `TxClientLike`). */
|
|
73
|
+
export interface DevvitNotificationRedisTransaction {
|
|
74
|
+
multi(): Promise<unknown>;
|
|
75
|
+
exec(): Promise<readonly unknown[] | null>;
|
|
76
|
+
discard(): Promise<unknown>;
|
|
77
|
+
unwatch(): Promise<unknown>;
|
|
78
|
+
set(key: string, value: string): Promise<unknown>;
|
|
79
|
+
del(...keys: string[]): Promise<unknown>;
|
|
80
|
+
hSet(key: string, fieldValues: Record<string, string>): Promise<unknown>;
|
|
81
|
+
hDel(key: string, fields: string[]): Promise<unknown>;
|
|
82
|
+
zAdd(key: string, ...members: DevvitNotificationRedisMember[]): Promise<unknown>;
|
|
83
|
+
zRem(key: string, members: string[]): Promise<unknown>;
|
|
84
|
+
}
|
|
85
|
+
/** The subset of the Devvit server Redis client (`redis` from `@devvit/web/server`) this store reads. */
|
|
86
|
+
export interface DevvitNotificationRedis {
|
|
87
|
+
get(key: string): Promise<string | undefined>;
|
|
88
|
+
mGet(keys: string[]): Promise<(string | null)[]>;
|
|
89
|
+
hGet(key: string, field: string): Promise<string | undefined>;
|
|
90
|
+
hGetAll(key: string): Promise<Record<string, string>>;
|
|
91
|
+
hMGet(key: string, fields: string[]): Promise<(string | null)[]>;
|
|
92
|
+
hSet(key: string, fieldValues: Record<string, string>): Promise<unknown>;
|
|
93
|
+
zRange(key: string, start: number | string, stop: number | string, options?: {
|
|
94
|
+
readonly by: 'score' | 'lex' | 'rank';
|
|
95
|
+
readonly limit?: {
|
|
96
|
+
readonly offset: number;
|
|
97
|
+
readonly count: number;
|
|
98
|
+
};
|
|
99
|
+
}): Promise<{
|
|
100
|
+
member: string;
|
|
101
|
+
score: number;
|
|
102
|
+
}[]>;
|
|
103
|
+
zRank(key: string, member: string): Promise<number | undefined>;
|
|
104
|
+
zCard(key: string): Promise<number>;
|
|
105
|
+
watch(...keys: string[]): Promise<DevvitNotificationRedisTransaction>;
|
|
106
|
+
}
|
|
107
|
+
export interface CreateDevvitRedisNotificationStoreOptions {
|
|
108
|
+
/** Prefix of every key; generated games pass their game name. Default `mpgd:notifications`. */
|
|
109
|
+
readonly namespace?: string;
|
|
110
|
+
/** Attempts of one atomic step before it fails as contended; 16 by default, at most 64. */
|
|
111
|
+
readonly transactionAttempts?: number;
|
|
112
|
+
/** A fresh send version token; random by default. */
|
|
113
|
+
readonly newVersion?: () => string;
|
|
114
|
+
/**
|
|
115
|
+
* Waits before an aborted step is planned again, `attempt` from 1; a
|
|
116
|
+
* short jittered backoff by default, so contending writers spread out
|
|
117
|
+
* instead of retrying in lockstep.
|
|
118
|
+
*/
|
|
119
|
+
readonly backoff?: (attempt: number) => Promise<void>;
|
|
120
|
+
/**
|
|
121
|
+
* Sends, history rows, and endpoints (per cutoff) one `purge` call deletes at most, each; unbounded
|
|
122
|
+
* by default, as `NotificationStore.purge` deletes every row past its
|
|
123
|
+
* cutoff. Each row costs a few Redis round trips, so a deployment whose
|
|
124
|
+
* purge runs in a time-bounded Devvit request or scheduler job sets it
|
|
125
|
+
* (say 500) and runs the purge again while a count equals it.
|
|
126
|
+
*/
|
|
127
|
+
readonly purgeLimit?: number | undefined;
|
|
128
|
+
}
|
|
129
|
+
export declare const defaultDevvitNotificationNamespace = "mpgd:notifications";
|
|
130
|
+
export declare function createDevvitRedisNotificationStore(redis: DevvitNotificationRedis, options?: CreateDevvitRedisNotificationStoreOptions): NotificationStore;
|