@mpgd/adapter-devvit 0.12.1 → 0.13.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.
@@ -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;