@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.
@@ -0,0 +1,1058 @@
1
+ import { formatNotificationInstant, notificationSendDueAt, } from '@mpgd/game-services/notifications';
2
+ import { createHash } from 'node:crypto';
3
+ export const defaultDevvitNotificationNamespace = 'mpgd:notifications';
4
+ const defaultTransactionAttempts = 16;
5
+ const maximumTransactionAttempts = 64;
6
+ const defaultPurgeLimit = Number.POSITIVE_INFINITY;
7
+ /** The commit marker's value; its SET replies `OK`, which `@devvit/redis` always maps. */
8
+ const commitMarkerValue = '1';
9
+ /** Sorted-set page size; `@devvit/redis` reads at most 1,000 members per score or lex range. */
10
+ const pageSize = 500;
11
+ export function createDevvitRedisNotificationStore(redis, options = {}) {
12
+ const namespace = options.namespace ?? defaultDevvitNotificationNamespace;
13
+ if (namespace.length === 0) {
14
+ throw new TypeError('The Devvit notification store namespace must not be empty.');
15
+ }
16
+ const attempts = options.transactionAttempts ?? defaultTransactionAttempts;
17
+ if (!Number.isSafeInteger(attempts) || attempts < 1 || attempts > maximumTransactionAttempts) {
18
+ throw new TypeError(`transactionAttempts must be a safe integer from 1 to ${String(maximumTransactionAttempts)}.`);
19
+ }
20
+ const newVersion = options.newVersion ?? (() => globalThis.crypto.randomUUID());
21
+ const backoff = options.backoff ?? defaultBackoff;
22
+ const purgeLimit = options.purgeLimit ?? defaultPurgeLimit;
23
+ if (purgeLimit !== defaultPurgeLimit && (!Number.isSafeInteger(purgeLimit) || purgeLimit < 1)) {
24
+ throw new TypeError('purgeLimit must be a positive safe integer.');
25
+ }
26
+ const enc = encodeURIComponent;
27
+ const keys = {
28
+ consent: (player) => `${namespace}:consent:${enc(player)}`,
29
+ endpoint: (provider, installationId) => `${namespace}:endpoint:${enc(provider)}:${enc(installationId)}`,
30
+ // The address is hashed: a device token never appears in a key name.
31
+ endpointAddress: (provider, value) => `${namespace}:endpoint-address:${enc(provider)}:${createHash('sha256').update(value).digest('hex')}`,
32
+ endpointsByPlayer: (player) => `${namespace}:endpoints-player:${enc(player)}`,
33
+ endpointsBySession: (sessionId) => `${namespace}:endpoints-session:${enc(sessionId)}`,
34
+ endpointsByInstallation: (installationId) => `${namespace}:endpoints-installation:${enc(installationId)}`,
35
+ endpointsRegistered: `${namespace}:endpoints-registered`,
36
+ endpointsInvalidated: `${namespace}:endpoints-invalidated`,
37
+ endpointsSessionEnd: `${namespace}:endpoints-session-end`,
38
+ registrations: `${namespace}:registrations`,
39
+ notices: (player) => `${namespace}:notices:${enc(player)}`,
40
+ send: (key) => `${namespace}:send:${enc(key)}`,
41
+ due: `${namespace}:sends:due`,
42
+ changed: `${namespace}:sends:changed`,
43
+ inFlight: (player) => `${namespace}:sends:in-flight:${enc(player)}`,
44
+ history: (player) => `${namespace}:history:${enc(player)}`,
45
+ historyAt: (player) => `${namespace}:history-at:${enc(player)}`,
46
+ historyAll: `${namespace}:history-all`,
47
+ historyByProvider: (provider) => `${namespace}:history-provider:${enc(provider)}`,
48
+ schedule: (player, id) => `${namespace}:schedule:${enc(player)}:${enc(id)}`,
49
+ scheduleDue: `${namespace}:schedule-due`,
50
+ pendingReminders: (player) => `${namespace}:schedule-pending:${enc(player)}`,
51
+ commitMarker: `${namespace}:commit`,
52
+ };
53
+ /**
54
+ * Runs one atomic step: watches `watched`, lets `plan` read and decide,
55
+ * and queues its commands in one MULTI/EXEC. A plan without commands only
56
+ * unwatches. A transaction that another writer aborted is planned again.
57
+ *
58
+ * `@devvit/redis` drops from the EXEC result every reply it cannot map,
59
+ * which may include a protobuf zero (a ZREM or HSET that changed nothing),
60
+ * and resolves an aborted transaction as an empty array. So each
61
+ * transaction ends with a SET of the namespace's commit marker, whose `OK`
62
+ * always maps: a committed EXEC answers at least that reply, and only an
63
+ * empty, `null`, or all-nil result is an abort.
64
+ */
65
+ async function transact(watched, plan) {
66
+ return transactPrepared(async () => ({ watched, plan }));
67
+ }
68
+ /**
69
+ * As `transact`, with the watched keys chosen from a read before WATCH:
70
+ * `prepare` reads what decides them, and a plan that finds that read
71
+ * outdated once the keys are watched answers `retry`.
72
+ */
73
+ async function transactPrepared(prepare) {
74
+ let lastWatched = [];
75
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
76
+ if (attempt > 0) {
77
+ await backoff(attempt);
78
+ }
79
+ const { watched, plan } = await prepare();
80
+ lastWatched = watched;
81
+ const transaction = await redis.watch(...watched);
82
+ let multiStarted = false;
83
+ try {
84
+ const planned = await plan();
85
+ if ('retry' in planned) {
86
+ await transaction.unwatch();
87
+ continue;
88
+ }
89
+ const { commands, result } = planned;
90
+ if (commands.length === 0) {
91
+ await transaction.unwatch();
92
+ return result;
93
+ }
94
+ await transaction.multi();
95
+ multiStarted = true;
96
+ for (const command of commands) {
97
+ await command(transaction);
98
+ }
99
+ await transaction.set(keys.commitMarker, commitMarkerValue);
100
+ const results = await transaction.exec();
101
+ if (results !== null && !Array.isArray(results)) {
102
+ throw new Error('Devvit Redis transaction returned an unsupported response.');
103
+ }
104
+ // An aborted WATCH transaction (another writer changed a watched
105
+ // key) answers no reply; one that answers only nil is read the same
106
+ // way, since no command queued here replies nil when it commits.
107
+ if (results === null || results.every((reply) => reply === null || reply === undefined)) {
108
+ continue;
109
+ }
110
+ return result;
111
+ }
112
+ catch (error) {
113
+ await bestEffortReset(transaction, multiStarted);
114
+ throw error;
115
+ }
116
+ }
117
+ throw new Error(`Devvit Redis notification store: contention exceeded ${String(attempts)} attempts on ${String(lastWatched.length)} watched key(s).`);
118
+ }
119
+ /** The members of a sorted set in score order, read in pages, up to `limit`. */
120
+ async function scoreRange(key, min, max, limit = Number.POSITIVE_INFINITY) {
121
+ const members = [];
122
+ for (let offset = 0; members.length < limit; offset += pageSize) {
123
+ const count = Math.min(pageSize, limit - members.length);
124
+ const page = await redis.zRange(key, min, max, {
125
+ by: 'score',
126
+ limit: { offset, count },
127
+ });
128
+ for (const entry of page) {
129
+ members.push(entry.member);
130
+ }
131
+ if (page.length < count) {
132
+ break;
133
+ }
134
+ }
135
+ return members;
136
+ }
137
+ const endpointMember = (provider, installationId) => JSON.stringify([provider, installationId]);
138
+ async function readEndpoint(provider, installationId) {
139
+ const key = keys.endpoint(provider, installationId);
140
+ const value = await redis.get(key);
141
+ return isMissing(value) ? undefined : parse(value, key);
142
+ }
143
+ /**
144
+ * The commands that move an endpoint from `before` to `after` (either may
145
+ * be absent) with its address pointer and indexes. `ownsOldAddress` says
146
+ * whether `before`'s address pointer still names this installation, so a
147
+ * pointer another installation took is never deleted; `writeAddress`
148
+ * false keeps the pointer as it is (an update that keeps the address).
149
+ */
150
+ function endpointCommands(before, after, ownsOldAddress, writeAddress = true) {
151
+ const subject = after ?? before;
152
+ if (subject === undefined) {
153
+ return [];
154
+ }
155
+ const { provider, installationId } = subject;
156
+ const member = endpointMember(provider, installationId);
157
+ const commands = [];
158
+ const valueKey = keys.endpoint(provider, installationId);
159
+ if (after === undefined) {
160
+ commands.push((transaction) => transaction.del(valueKey));
161
+ }
162
+ else {
163
+ const value = JSON.stringify(after);
164
+ commands.push((transaction) => transaction.set(valueKey, value));
165
+ }
166
+ if (before !== undefined
167
+ && ownsOldAddress
168
+ && (after === undefined || before.address.value !== after.address.value)) {
169
+ const oldAddress = keys.endpointAddress(provider, before.address.value);
170
+ commands.push((transaction) => transaction.del(oldAddress));
171
+ }
172
+ if (after !== undefined && writeAddress) {
173
+ const address = keys.endpointAddress(provider, after.address.value);
174
+ commands.push((transaction) => transaction.set(address, installationId));
175
+ }
176
+ const moved = (field, key) => {
177
+ if (before !== undefined && (after === undefined || before[field] !== after[field])) {
178
+ const old = key(before[field]);
179
+ commands.push((transaction) => transaction.zRem(old, [member]));
180
+ }
181
+ if (after !== undefined) {
182
+ const next = key(after[field]);
183
+ commands.push((transaction) => transaction.zAdd(next, { member, score: 0 }));
184
+ }
185
+ };
186
+ moved('player', keys.endpointsByPlayer);
187
+ moved('sessionId', keys.endpointsBySession);
188
+ const byInstallation = keys.endpointsByInstallation(installationId);
189
+ if (after === undefined) {
190
+ commands.push((transaction) => transaction.zRem(byInstallation, [provider]));
191
+ }
192
+ else {
193
+ commands.push((transaction) => transaction.zAdd(byInstallation, { member: provider, score: 0 }));
194
+ }
195
+ // The purge indexes, one per cutoff, scored by the time it compares.
196
+ const timed = (key, at) => {
197
+ if (at === undefined) {
198
+ commands.push((transaction) => transaction.zRem(key, [member]));
199
+ }
200
+ else {
201
+ const score = Date.parse(at);
202
+ commands.push((transaction) => transaction.zAdd(key, { member, score }));
203
+ }
204
+ };
205
+ timed(keys.endpointsRegistered, after?.lastRegisteredAt);
206
+ timed(keys.endpointsInvalidated, after?.status === 'invalid' ? after.invalidatedAt : undefined);
207
+ timed(keys.endpointsSessionEnd, after?.sessionExpiresAt);
208
+ return commands;
209
+ }
210
+ /**
211
+ * Removes one endpoint, atomically, while `matches` holds for it; answers
212
+ * whether it did. An index entry that names an endpoint whose value is
213
+ * gone is dropped from `prune` (`[key, member]` pairs); one whose endpoint
214
+ * exists but does not match is rewritten by `reindex` (a time index's
215
+ * caller rescores a drifted entry).
216
+ */
217
+ async function removeEndpointIf(provider, installationId, matches, prune = [], reindex = () => []) {
218
+ return transactPrepared(async () => {
219
+ const seen = await readEndpoint(provider, installationId);
220
+ const valueKey = keys.endpoint(provider, installationId);
221
+ return {
222
+ watched: seen === undefined
223
+ ? [valueKey]
224
+ : [valueKey, keys.endpointAddress(provider, seen.address.value)],
225
+ plan: async () => {
226
+ const endpoint = await readEndpoint(provider, installationId);
227
+ if (JSON.stringify(endpoint) !== JSON.stringify(seen)) {
228
+ return { retry: true };
229
+ }
230
+ if (endpoint === undefined) {
231
+ return {
232
+ commands: prune.map(([key, member]) => (transaction) => transaction.zRem(key, [member])),
233
+ result: false,
234
+ };
235
+ }
236
+ if (!matches(endpoint)) {
237
+ // An index entry that drifted from its endpoint is rewritten, so a
238
+ // purge never reads it again before its time.
239
+ return { commands: reindex(endpoint), result: false };
240
+ }
241
+ const owner = await redis.get(keys.endpointAddress(provider, endpoint.address.value));
242
+ return {
243
+ commands: endpointCommands(endpoint, undefined, owner === installationId),
244
+ result: true,
245
+ };
246
+ },
247
+ };
248
+ });
249
+ }
250
+ /** Every endpoint an index names, as `[provider, installationId]`. */
251
+ async function indexedEndpoints(key) {
252
+ const members = await redis.zRange(key, 0, -1, { by: 'rank' });
253
+ return members.flatMap((entry) => {
254
+ const named = tryParse(entry.member);
255
+ return Array.isArray(named) && typeof named[0] === 'string' && typeof named[1] === 'string'
256
+ ? [[named[0], named[1]]]
257
+ : [];
258
+ });
259
+ }
260
+ /** The index commands that follow a send from `before` to `after` (either may be absent). */
261
+ function sendIndexCommands(key, before, after) {
262
+ const commands = [];
263
+ const due = after === undefined ? undefined : notificationSendDueAt(after);
264
+ if (due === undefined) {
265
+ if (before !== undefined) {
266
+ commands.push((transaction) => transaction.zRem(keys.due, [key]));
267
+ }
268
+ }
269
+ else {
270
+ commands.push((transaction) => transaction.zAdd(keys.due, { member: key, score: Date.parse(due) }));
271
+ }
272
+ if (after === undefined) {
273
+ commands.push((transaction) => transaction.zRem(keys.changed, [key]));
274
+ }
275
+ else {
276
+ commands.push((transaction) => transaction.zAdd(keys.changed, { member: key, score: Date.parse(after.changedAt) }));
277
+ }
278
+ const flying = (send) => send !== undefined && send.rows.some((row) => row.state.status === 'in-flight');
279
+ if (before !== undefined && flying(before) && !flying(after)) {
280
+ commands.push((transaction) => transaction.zRem(keys.inFlight(before.request.player), [key]));
281
+ }
282
+ if (after !== undefined && flying(after)) {
283
+ commands.push((transaction) => transaction.zAdd(keys.inFlight(after.request.player), { member: key, score: 0 }));
284
+ }
285
+ return commands;
286
+ }
287
+ /** A stored send value, or `'unreadable'` for one that is not a send. */
288
+ function readSendValue(raw) {
289
+ const value = tryParse(raw);
290
+ const send = typeof value === 'object' && value !== null ? value.send : undefined;
291
+ return typeof value?.version === 'string'
292
+ && typeof send === 'object' && send !== null
293
+ && typeof send.key === 'string'
294
+ && typeof send.changedAt === 'string'
295
+ && typeof send.request?.player === 'string'
296
+ && Array.isArray(send.rows)
297
+ && send.rows.every((row) => {
298
+ const candidate = row;
299
+ return typeof candidate === 'object' && candidate !== null
300
+ && typeof candidate.key === 'string'
301
+ && typeof candidate.provider === 'string'
302
+ && typeof candidate.state?.status === 'string';
303
+ })
304
+ ? value
305
+ : 'unreadable';
306
+ }
307
+ async function readSend(key) {
308
+ const value = await redis.get(keys.send(key));
309
+ if (isMissing(value)) {
310
+ return undefined;
311
+ }
312
+ const stored = readSendValue(value);
313
+ if (stored === 'unreadable') {
314
+ throw new Error(`Devvit Redis notification store: ${keys.send(key)} holds a value that is not a send.`);
315
+ }
316
+ return stored;
317
+ }
318
+ async function readSchedule(player, id) {
319
+ const key = keys.schedule(player, id);
320
+ const value = await redis.get(key);
321
+ if (isMissing(value)) {
322
+ return undefined;
323
+ }
324
+ const stored = tryParse(value);
325
+ if (typeof stored !== 'object' || stored === null
326
+ || typeof stored.version !== 'string'
327
+ || typeof stored.entry !== 'object' || stored.entry === null
328
+ || typeof stored.entry.item !== 'object' || stored.entry.item === null) {
329
+ throw new Error(`Devvit Redis notification store: ${key} holds a value that is not a schedule entry.`);
330
+ }
331
+ return stored;
332
+ }
333
+ /**
334
+ * The value and index commands that store `after` under `player` and `id`
335
+ * (whatever it names) with a new version, or delete the entry.
336
+ */
337
+ function scheduleCommands(player, id, after) {
338
+ const valueKey = keys.schedule(player, id);
339
+ const member = scheduleMember(player, id);
340
+ if (after === undefined) {
341
+ return {
342
+ commands: [
343
+ (transaction) => transaction.del(valueKey),
344
+ (transaction) => transaction.zRem(keys.scheduleDue, [member]),
345
+ (transaction) => transaction.zRem(keys.pendingReminders(player), [id]),
346
+ ],
347
+ version: undefined,
348
+ };
349
+ }
350
+ const { dueAt, ...rest } = after;
351
+ const entry = {
352
+ ...rest,
353
+ player,
354
+ id,
355
+ ...(dueAt === undefined ? {} : { dueAt }),
356
+ };
357
+ const version = newVersion();
358
+ const value = JSON.stringify({ entry, version });
359
+ const score = dueAt === undefined ? undefined : instantOf(dueAt);
360
+ const pending = id.startsWith('reminder:') && entry.item.kind === 'reminder' && entry.item.firedAt === undefined;
361
+ return {
362
+ commands: [
363
+ (transaction) => transaction.set(valueKey, value),
364
+ score === undefined
365
+ ? (transaction) => transaction.zRem(keys.scheduleDue, [member])
366
+ : (transaction) => transaction.zAdd(keys.scheduleDue, { member, score }),
367
+ pending
368
+ ? (transaction) => transaction.zAdd(keys.pendingReminders(player), { member: id, score: 0 })
369
+ : (transaction) => transaction.zRem(keys.pendingReminders(player), [id]),
370
+ ],
371
+ version,
372
+ };
373
+ }
374
+ /** The rank of the first member at or after `min` (a score bound), the set's size when none is. */
375
+ async function rankFrom(key, min) {
376
+ const [first] = await redis.zRange(key, min, '+inf', {
377
+ by: 'score',
378
+ limit: { offset: 0, count: 1 },
379
+ });
380
+ return first === undefined ? redis.zCard(key) : redis.zRank(key, first.member);
381
+ }
382
+ /**
383
+ * How many members score from `min` to `max`, both inclusive, by rank.
384
+ * The start rank is read again after the end rank: a write below `min`
385
+ * between the reads moves both, so differing start ranks, or a member
386
+ * removed before its rank was read, make the count read again. After a
387
+ * few tries the members are counted in pages. A write in or above the
388
+ * range during the reads (a send delivered just now), or two opposite
389
+ * writes below it, may still be counted or not: pacing is approximate by
390
+ * contract.
391
+ */
392
+ async function countScores(key, min, max) {
393
+ for (let attempt = 0; attempt < 4; attempt += 1) {
394
+ const start = await rankFrom(key, String(min));
395
+ const end = await rankFrom(key, `(${String(max)}`);
396
+ const again = await rankFrom(key, String(min));
397
+ if (start !== undefined && end !== undefined && again === start && end >= start) {
398
+ return end - start;
399
+ }
400
+ }
401
+ return (await scoreRange(key, String(min), String(max))).length;
402
+ }
403
+ return {
404
+ async appendConsent({ player, record, at }) {
405
+ const key = keys.consent(player);
406
+ return transact([key], async () => {
407
+ const [latest] = await redis.zRange(key, -1, -1, { by: 'rank' });
408
+ let millis = Date.parse(at);
409
+ if (latest !== undefined) {
410
+ millis = Math.max(millis, latest.score + 1);
411
+ }
412
+ const stored = { ...record, at: formatNotificationInstant(millis) };
413
+ const member = JSON.stringify(stored);
414
+ return {
415
+ commands: [(transaction) => transaction.zAdd(key, { member, score: millis })],
416
+ result: structuredClone(stored),
417
+ };
418
+ });
419
+ },
420
+ async listConsent(player) {
421
+ const key = keys.consent(player);
422
+ const members = await redis.zRange(key, 0, -1, { by: 'rank' });
423
+ return members.map((entry) => parse(entry.member, key));
424
+ },
425
+ async pruneConsent({ player, at }) {
426
+ // Every instant parses before anything is read or deleted.
427
+ const instants = new Set(at.map(instantOf));
428
+ if (instants.size === 0) {
429
+ return 0;
430
+ }
431
+ const consentKey = keys.consent(player);
432
+ const noticesKey = keys.notices(player);
433
+ return transact([consentKey, noticesKey], async () => {
434
+ const records = (await redis.zRange(consentKey, 0, -1, { by: 'rank' }))
435
+ .filter((entry) => instants.has(entry.score))
436
+ .map((entry) => entry.member);
437
+ const notices = Object.entries(await redis.hGetAll(noticesKey))
438
+ .filter(([, value]) => {
439
+ const notice = tryParse(value);
440
+ return typeof notice?.record?.at === 'string' && instants.has(Date.parse(notice.record.at));
441
+ })
442
+ .map(([field]) => field);
443
+ return {
444
+ commands: [
445
+ ...(records.length === 0 ? [] : [(transaction) => transaction.zRem(consentKey, records)]),
446
+ ...(notices.length === 0 ? [] : [(transaction) => transaction.hDel(noticesKey, notices)]),
447
+ ],
448
+ result: records.length,
449
+ };
450
+ });
451
+ },
452
+ async putEndpoint(write) {
453
+ const { provider, installationId } = write;
454
+ const valueKey = keys.endpoint(provider, installationId);
455
+ const addressKey = keys.endpointAddress(provider, write.address.value);
456
+ return transactPrepared(async () => {
457
+ // What decides the watched keys: this installation's endpoint, and
458
+ // the installation the new address points to now.
459
+ const seen = await readEndpoint(provider, installationId);
460
+ const seenOwner = await redis.get(addressKey);
461
+ const watched = [valueKey, addressKey];
462
+ if (seen !== undefined && seen.address.value !== write.address.value) {
463
+ watched.push(keys.endpointAddress(provider, seen.address.value));
464
+ }
465
+ if (!isMissing(seenOwner) && seenOwner !== installationId) {
466
+ watched.push(keys.endpoint(provider, seenOwner));
467
+ }
468
+ return {
469
+ watched,
470
+ plan: async () => {
471
+ const existing = await readEndpoint(provider, installationId);
472
+ const owner = await redis.get(addressKey);
473
+ if (JSON.stringify(existing) !== JSON.stringify(seen) || owner !== seenOwner) {
474
+ return { retry: true };
475
+ }
476
+ const commands = [];
477
+ if (!isMissing(owner) && owner !== installationId) {
478
+ // An address is unique per provider: the other installation loses it.
479
+ const other = await readEndpoint(provider, owner);
480
+ if (other !== undefined) {
481
+ commands.push(...endpointCommands(other, undefined, false));
482
+ }
483
+ }
484
+ const dead = existing?.status === 'invalid' && existing.address.value === write.address.value;
485
+ const stored = {
486
+ ...structuredClone(write),
487
+ status: dead ? 'invalid' : 'valid',
488
+ ...(dead && existing.invalidatedAt !== undefined ? { invalidatedAt: existing.invalidatedAt } : {}),
489
+ };
490
+ const ownsOldAddress = existing !== undefined
491
+ && (await redis.get(keys.endpointAddress(provider, existing.address.value))) === installationId;
492
+ commands.push(...endpointCommands(existing, stored, ownsOldAddress));
493
+ return { commands, result: structuredClone(stored) };
494
+ },
495
+ };
496
+ });
497
+ },
498
+ async listEndpoints({ player, provider }) {
499
+ const named = (await indexedEndpoints(keys.endpointsByPlayer(player)))
500
+ .filter(([indexed]) => indexed === provider);
501
+ if (named.length === 0) {
502
+ return [];
503
+ }
504
+ const valueKeys = named.map(([indexed, installation]) => keys.endpoint(indexed, installation));
505
+ const values = await redis.mGet(valueKeys);
506
+ return values
507
+ .flatMap((value, position) => (isMissing(value) ? [] : [parse(value, valueKeys[position] ?? '')]))
508
+ .filter((endpoint) => endpoint.player === player && endpoint.provider === provider)
509
+ .sort((left, right) => compareText(left.installationId, right.installationId));
510
+ },
511
+ async getEndpoint({ provider, installationId }) {
512
+ return readEndpoint(provider, installationId);
513
+ },
514
+ async invalidateEndpoint({ provider, installationId, value, at }) {
515
+ await transact([keys.endpoint(provider, installationId)], async () => {
516
+ const endpoint = await readEndpoint(provider, installationId);
517
+ if (endpoint === undefined || endpoint.address.value !== value || endpoint.status !== 'valid') {
518
+ return { commands: [], result: undefined };
519
+ }
520
+ const invalid = { ...endpoint, status: 'invalid', invalidatedAt: at };
521
+ return { commands: endpointCommands(endpoint, invalid, true, false), result: undefined };
522
+ });
523
+ },
524
+ async removeEndpoints({ installationId, player, provider }) {
525
+ const providers = provider === undefined
526
+ ? (await redis.zRange(keys.endpointsByInstallation(installationId), 0, -1, { by: 'rank' }))
527
+ .map((entry) => entry.member)
528
+ : [provider];
529
+ for (const named of providers) {
530
+ await removeEndpointIf(named, installationId, (endpoint) => endpoint.player === player, [
531
+ [keys.endpointsByInstallation(installationId), named],
532
+ ]);
533
+ }
534
+ },
535
+ async removeSessionEndpoints({ sessionId }) {
536
+ let removed = 0;
537
+ for (const [provider, installationId] of await indexedEndpoints(keys.endpointsBySession(sessionId))) {
538
+ if (await removeEndpointIf(provider, installationId, (endpoint) => endpoint.sessionId === sessionId)) {
539
+ removed += 1;
540
+ }
541
+ }
542
+ return removed;
543
+ },
544
+ async extendSessionEndpoints({ sessionId, expiresAt }) {
545
+ let changed = 0;
546
+ for (const [provider, installationId] of await indexedEndpoints(keys.endpointsBySession(sessionId))) {
547
+ const key = keys.endpoint(provider, installationId);
548
+ const moved = await transact([key], async () => {
549
+ const endpoint = await readEndpoint(provider, installationId);
550
+ if (endpoint === undefined
551
+ || endpoint.sessionId !== sessionId
552
+ || endpoint.sessionExpiresAt === undefined
553
+ || (expiresAt !== undefined && Date.parse(endpoint.sessionExpiresAt) >= Date.parse(expiresAt))) {
554
+ return { commands: [], result: false };
555
+ }
556
+ const { sessionExpiresAt: _earlier, ...kept } = endpoint;
557
+ const extended = expiresAt === undefined ? kept : { ...kept, sessionExpiresAt: expiresAt };
558
+ return { commands: endpointCommands(endpoint, extended, true, false), result: true };
559
+ });
560
+ if (moved) {
561
+ changed += 1;
562
+ }
563
+ }
564
+ return changed;
565
+ },
566
+ async putPlatformRegistration(registration) {
567
+ const field = JSON.stringify([registration.player, registration.provider]);
568
+ const value = JSON.stringify(registration);
569
+ // One write that reads nothing: no transaction, so registrations never contend.
570
+ await redis.hSet(keys.registrations, { [field]: value });
571
+ },
572
+ async getPlatformRegistration({ player, provider }) {
573
+ const value = await redis.hGet(keys.registrations, JSON.stringify([player, provider]));
574
+ return isMissing(value)
575
+ ? undefined
576
+ : parse(value, keys.registrations);
577
+ },
578
+ async addNoticeGiven(notice) {
579
+ const key = keys.notices(notice.player);
580
+ // Named by the notice, its record's instant to the millisecond, and its date.
581
+ const field = JSON.stringify([
582
+ notice.kind,
583
+ notice.record.kind,
584
+ Date.parse(notice.record.at),
585
+ notice.dueBy,
586
+ ]);
587
+ await transact([key], async () => {
588
+ if (!isMissing(await redis.hGet(key, field))) {
589
+ return { commands: [], result: undefined };
590
+ }
591
+ const value = JSON.stringify(notice);
592
+ return {
593
+ commands: [(transaction) => transaction.hSet(key, { [field]: value })],
594
+ result: undefined,
595
+ };
596
+ });
597
+ },
598
+ async listNoticesGiven(player) {
599
+ const key = keys.notices(player);
600
+ return Object.values(await redis.hGetAll(key))
601
+ .map((value) => parse(value, key))
602
+ .sort((left, right) => Date.parse(left.at) - Date.parse(right.at));
603
+ },
604
+ async getSend(key) {
605
+ const stored = await readSend(key);
606
+ return stored === undefined ? undefined : { send: stored.send, version: stored.version };
607
+ },
608
+ async createSend(send) {
609
+ const valueKey = keys.send(send.key);
610
+ return transact([valueKey], async () => {
611
+ if (!isMissing(await redis.get(valueKey))) {
612
+ return { commands: [], result: undefined };
613
+ }
614
+ const version = newVersion();
615
+ const value = JSON.stringify({ send, version });
616
+ return {
617
+ commands: [
618
+ (transaction) => transaction.set(valueKey, value),
619
+ ...sendIndexCommands(send.key, undefined, send),
620
+ ],
621
+ result: version,
622
+ };
623
+ });
624
+ },
625
+ async updateSend({ key, version, send }) {
626
+ const valueKey = keys.send(key);
627
+ return transact([valueKey], async () => {
628
+ const stored = await readSend(key);
629
+ if (stored === undefined || stored.version !== version) {
630
+ return { commands: [], result: false };
631
+ }
632
+ if (send === undefined) {
633
+ return {
634
+ commands: [
635
+ (transaction) => transaction.del(valueKey),
636
+ ...sendIndexCommands(key, stored.send, undefined),
637
+ ],
638
+ result: true,
639
+ };
640
+ }
641
+ const value = JSON.stringify({ send, version: newVersion() });
642
+ return {
643
+ commands: [
644
+ (transaction) => transaction.set(valueKey, value),
645
+ ...sendIndexCommands(key, stored.send, send),
646
+ ],
647
+ result: true,
648
+ };
649
+ });
650
+ },
651
+ async listInFlightSends(player) {
652
+ const index = await redis.zRange(keys.inFlight(player), 0, -1, { by: 'rank' });
653
+ if (index.length === 0) {
654
+ return [];
655
+ }
656
+ const values = await redis.mGet(index.map((entry) => keys.send(entry.member)));
657
+ const sends = [];
658
+ for (const value of values) {
659
+ if (isMissing(value)) {
660
+ continue;
661
+ }
662
+ const stored = readSendValue(value);
663
+ if (stored === 'unreadable') {
664
+ // An unreadable row is not this player's in-flight send; purge drops it.
665
+ continue;
666
+ }
667
+ const { send } = stored;
668
+ if (send.request.player === player && send.rows.some((row) => row.state.status === 'in-flight')) {
669
+ sends.push(send);
670
+ }
671
+ }
672
+ return sends;
673
+ },
674
+ async listDueSends({ at, limit }) {
675
+ const now = instantOf(at);
676
+ const count = Math.max(0, Math.floor(limit));
677
+ if (count === 0) {
678
+ return [];
679
+ }
680
+ const due = [];
681
+ const seen = new Set();
682
+ let offset = 0;
683
+ while (due.length < count) {
684
+ const want = Math.min(pageSize, count - due.length);
685
+ const page = await redis.zRange(keys.due, '-inf', String(now), {
686
+ by: 'score',
687
+ limit: { offset, count: want },
688
+ });
689
+ for (const entry of page) {
690
+ // Offsets move while other workers write the index: keep each key once.
691
+ if (!seen.has(entry.member)) {
692
+ seen.add(entry.member);
693
+ due.push(entry.member);
694
+ }
695
+ }
696
+ if (page.length < want) {
697
+ break;
698
+ }
699
+ offset += page.length;
700
+ }
701
+ return due;
702
+ },
703
+ async addHistory(entry) {
704
+ const score = instantOf(entry.at);
705
+ const key = keys.history(entry.player);
706
+ const field = JSON.stringify([entry.sendKey, entry.provider]);
707
+ await transact([key], async () => {
708
+ if (!isMissing(await redis.hGet(key, field))) {
709
+ return { commands: [], result: undefined };
710
+ }
711
+ const value = JSON.stringify(entry);
712
+ const everywhere = JSON.stringify([entry.player, field]);
713
+ return {
714
+ commands: [
715
+ (transaction) => transaction.hSet(key, { [field]: value }),
716
+ (transaction) => transaction.zAdd(keys.historyAt(entry.player), { member: field, score }),
717
+ (transaction) => transaction.zAdd(keys.historyAll, { member: everywhere, score }),
718
+ (transaction) => transaction.zAdd(keys.historyByProvider(entry.provider), { member: everywhere, score }),
719
+ ],
720
+ result: undefined,
721
+ };
722
+ });
723
+ },
724
+ async listHistory({ player, since }) {
725
+ const fields = await scoreRange(keys.historyAt(player), String(instantOf(since)), '+inf');
726
+ if (fields.length === 0) {
727
+ return [];
728
+ }
729
+ const key = keys.history(player);
730
+ const values = await redis.hMGet(key, fields);
731
+ return values
732
+ .filter((value) => typeof value === 'string')
733
+ .map((value) => parse(value, key))
734
+ .sort((left, right) => Date.parse(left.at) - Date.parse(right.at));
735
+ },
736
+ async countHistory({ provider, since, until }) {
737
+ const from = instantOf(since);
738
+ const to = instantOf(until);
739
+ return to < from ? 0 : countScores(keys.historyByProvider(provider), from, to);
740
+ },
741
+ async getScheduleEntry({ player, id }) {
742
+ const stored = await readSchedule(player, id);
743
+ return stored === undefined ? undefined : { entry: stored.entry, version: stored.version };
744
+ },
745
+ async createScheduleEntry(entry) {
746
+ if (entry.dueAt !== undefined) {
747
+ instantOf(entry.dueAt);
748
+ }
749
+ const valueKey = keys.schedule(entry.player, entry.id);
750
+ return transact([valueKey], async () => {
751
+ if (!isMissing(await redis.get(valueKey))) {
752
+ return { commands: [], result: undefined };
753
+ }
754
+ const { commands, version } = scheduleCommands(entry.player, entry.id, entry);
755
+ return { commands, result: version };
756
+ });
757
+ },
758
+ async updateScheduleEntry({ player, id, version, entry }) {
759
+ if (entry?.dueAt !== undefined) {
760
+ instantOf(entry.dueAt);
761
+ }
762
+ return transact([keys.schedule(player, id)], async () => {
763
+ const stored = await readSchedule(player, id);
764
+ if (stored === undefined || stored.version !== version) {
765
+ return { commands: [], result: false };
766
+ }
767
+ return { commands: scheduleCommands(player, id, entry).commands, result: true };
768
+ });
769
+ },
770
+ async countPendingReminders(player) {
771
+ return redis.zCard(keys.pendingReminders(player));
772
+ },
773
+ async listDueScheduleEntries({ at, limit }) {
774
+ const now = instantOf(at);
775
+ const count = Math.max(0, Math.floor(limit));
776
+ const due = [];
777
+ const seen = new Set();
778
+ let offset = 0;
779
+ while (due.length < count) {
780
+ const want = Math.min(pageSize, count - due.length);
781
+ const page = await redis.zRange(keys.scheduleDue, '-inf', String(now), {
782
+ by: 'score',
783
+ limit: { offset, count: want },
784
+ });
785
+ // Offset paging: an entry removed ahead of the offset during the
786
+ // listing shifts the rest, so one may be missed until the next run.
787
+ for (const { member } of page) {
788
+ const named = readScheduleMember(member);
789
+ // Offsets move while other workers write the index: keep each entry once.
790
+ if (named !== undefined && !seen.has(member)) {
791
+ seen.add(member);
792
+ due.push(named);
793
+ }
794
+ }
795
+ if (page.length < want) {
796
+ break;
797
+ }
798
+ offset += page.length;
799
+ }
800
+ return due;
801
+ },
802
+ async purge(cutoffs) {
803
+ // Every cutoff parses before anything is read or deleted.
804
+ for (const cutoff of Object.values(cutoffs)) {
805
+ instantOf(cutoff);
806
+ }
807
+ // Oldest first; with `purgeLimit`, at most that many sends and history
808
+ // rows per call, and the next scheduled purge continues.
809
+ let sendCount = 0;
810
+ const sendsBefore = instantOf(cutoffs.sendsChangedBefore);
811
+ for (const key of await scoreRange(keys.changed, '-inf', `(${String(sendsBefore)}`, purgeLimit)) {
812
+ const valueKey = keys.send(key);
813
+ const deleted = await transact([valueKey], async () => {
814
+ // A Redis failure here fails the purge; only a value that does not
815
+ // parse as a send is dropped, since nothing can use it.
816
+ const raw = await redis.get(valueKey);
817
+ const stored = isMissing(raw) ? undefined : readSendValue(raw);
818
+ if (stored === 'unreadable') {
819
+ // Its in-flight index entry, if any, names a player the value no
820
+ // longer tells; listInFlightSends skips the missing value.
821
+ return {
822
+ commands: [
823
+ (transaction) => transaction.del(valueKey),
824
+ (transaction) => transaction.zRem(keys.changed, [key]),
825
+ (transaction) => transaction.zRem(keys.due, [key]),
826
+ ],
827
+ result: true,
828
+ };
829
+ }
830
+ if (stored === undefined) {
831
+ // A stale index entry: drop it from the indexes that hold it by key.
832
+ return {
833
+ commands: [
834
+ (transaction) => transaction.zRem(keys.changed, [key]),
835
+ (transaction) => transaction.zRem(keys.due, [key]),
836
+ ],
837
+ result: false,
838
+ };
839
+ }
840
+ const changedAt = Date.parse(stored.send.changedAt);
841
+ if (!(changedAt < sendsBefore)) {
842
+ // Not old after all: rescore the index entry to the value's time so
843
+ // it stops coming first in every purge. A time that does not parse
844
+ // is never purged (as in the in-memory store), so it leaves the index.
845
+ return {
846
+ commands: [
847
+ Number.isNaN(changedAt)
848
+ ? (transaction) => transaction.zRem(keys.changed, [key])
849
+ : (transaction) => transaction.zAdd(keys.changed, { member: key, score: changedAt }),
850
+ ],
851
+ result: false,
852
+ };
853
+ }
854
+ return {
855
+ commands: [
856
+ (transaction) => transaction.del(valueKey),
857
+ ...sendIndexCommands(key, stored.send, undefined),
858
+ ],
859
+ result: true,
860
+ };
861
+ });
862
+ if (deleted) {
863
+ sendCount += 1;
864
+ }
865
+ }
866
+ let historyCount = 0;
867
+ const historyBefore = instantOf(cutoffs.historyBefore);
868
+ for (const member of await scoreRange(keys.historyAll, '-inf', `(${String(historyBefore)}`, purgeLimit)) {
869
+ const named = tryParse(member);
870
+ if (!Array.isArray(named)
871
+ || named.length !== 2
872
+ || typeof named[0] !== 'string'
873
+ || typeof named[1] !== 'string') {
874
+ await transact([keys.historyAll], async () => ({
875
+ commands: [(transaction) => transaction.zRem(keys.historyAll, [member])],
876
+ result: undefined,
877
+ }));
878
+ continue;
879
+ }
880
+ const [player, field] = named;
881
+ const key = keys.history(player);
882
+ // The field names the send and provider; the provider's index holds the same member.
883
+ const fieldNames = tryParse(field);
884
+ const byProvider = Array.isArray(fieldNames) && typeof fieldNames[1] === 'string'
885
+ ? keys.historyByProvider(fieldNames[1])
886
+ : undefined;
887
+ const removed = await transact([key], async () => {
888
+ const value = await redis.hGet(key, field);
889
+ const entry = isMissing(value) ? undefined : tryParse(value);
890
+ const at = typeof entry?.at === 'string' ? Date.parse(entry.at) : Number.NaN;
891
+ if (!isMissing(value) && Number.isFinite(at) && at >= historyBefore) {
892
+ // Not old after all (never written so): keep the entry, and rescore
893
+ // its indexes to its time so it stops coming first in every purge.
894
+ return {
895
+ commands: [
896
+ (transaction) => transaction.zAdd(keys.historyAt(player), { member: field, score: at }),
897
+ (transaction) => transaction.zAdd(keys.historyAll, { member, score: at }),
898
+ ...(byProvider === undefined
899
+ ? []
900
+ : [(transaction) => transaction.zAdd(byProvider, { member, score: at })]),
901
+ ],
902
+ result: false,
903
+ };
904
+ }
905
+ return {
906
+ commands: [
907
+ ...(isMissing(value)
908
+ ? []
909
+ : [(transaction) => transaction.hDel(key, [field])]),
910
+ (transaction) => transaction.zRem(keys.historyAt(player), [field]),
911
+ (transaction) => transaction.zRem(keys.historyAll, [member]),
912
+ ...(byProvider === undefined
913
+ ? []
914
+ : [(transaction) => transaction.zRem(byProvider, [member])]),
915
+ ],
916
+ result: !isMissing(value),
917
+ };
918
+ });
919
+ if (removed) {
920
+ historyCount += 1;
921
+ }
922
+ }
923
+ const before = (at, cutoff) => at !== undefined && Date.parse(at) < Date.parse(cutoff);
924
+ // Each cutoff reads only the endpoints past it, from its time index.
925
+ let endpointCount = 0;
926
+ const pastCutoff = (endpoint) => (endpoint.status === 'invalid' && before(endpoint.invalidatedAt, cutoffs.invalidEndpointsBefore))
927
+ || before(endpoint.lastRegisteredAt, cutoffs.staleEndpointsBefore)
928
+ || before(endpoint.sessionExpiresAt, cutoffs.sessionEndpointsBefore);
929
+ for (const [index, cutoff, timeOf] of [
930
+ [
931
+ keys.endpointsInvalidated,
932
+ cutoffs.invalidEndpointsBefore,
933
+ (endpoint) => (endpoint.status === 'invalid' ? endpoint.invalidatedAt : undefined),
934
+ ],
935
+ [keys.endpointsRegistered, cutoffs.staleEndpointsBefore, (endpoint) => endpoint.lastRegisteredAt],
936
+ [keys.endpointsSessionEnd, cutoffs.sessionEndpointsBefore, (endpoint) => endpoint.sessionExpiresAt],
937
+ ]) {
938
+ const cutoffMs = instantOf(cutoff);
939
+ const members = await scoreRange(index, '-inf', `(${String(cutoffMs)}`, purgeLimit);
940
+ for (const member of members) {
941
+ const named = tryParse(member);
942
+ if (!Array.isArray(named) || typeof named[0] !== 'string' || typeof named[1] !== 'string') {
943
+ await transact([index], async () => ({
944
+ commands: [(transaction) => transaction.zRem(index, [member])],
945
+ result: undefined,
946
+ }));
947
+ continue;
948
+ }
949
+ const reindex = (endpoint) => {
950
+ const at = timeOf(endpoint);
951
+ if (at === undefined) {
952
+ return [(transaction) => transaction.zRem(index, [member])];
953
+ }
954
+ const score = Date.parse(at);
955
+ if (Number.isNaN(score)) {
956
+ return [(transaction) => transaction.zRem(index, [member])];
957
+ }
958
+ return [(transaction) => transaction.zAdd(index, { member, score })];
959
+ };
960
+ if (await removeEndpointIf(named[0], named[1], pastCutoff, [[index, member]], reindex)) {
961
+ endpointCount += 1;
962
+ }
963
+ }
964
+ }
965
+ return { sends: sendCount, history: historyCount, endpoints: endpointCount };
966
+ },
967
+ };
968
+ }
969
+ /** Epoch milliseconds of a time; one that does not parse is a caller error, as in the in-memory store. */
970
+ function instantOf(at) {
971
+ const millis = Date.parse(at);
972
+ if (!Number.isFinite(millis)) {
973
+ throw new TypeError(`Not a timestamp: ${JSON.stringify(at)}`);
974
+ }
975
+ return millis;
976
+ }
977
+ /**
978
+ * Code points as fixed-width hex, so byte order is code-point order (lone
979
+ * surrogates included, as `Array.from` iterates them).
980
+ */
981
+ function encodeCodePoints(value) {
982
+ return Array.from(value, (char) => (char.codePointAt(0) ?? 0).toString(16).padStart(6, '0')).join('');
983
+ }
984
+ function decodeCodePoints(encoded) {
985
+ if (encoded.length % 6 !== 0 || !/^[0-9a-f]*$/u.test(encoded)) {
986
+ return undefined;
987
+ }
988
+ let value = '';
989
+ for (let index = 0; index < encoded.length; index += 6) {
990
+ const codePoint = Number.parseInt(encoded.slice(index, index + 6), 16);
991
+ if (codePoint > 0x10ffff) {
992
+ return undefined;
993
+ }
994
+ value += String.fromCodePoint(codePoint);
995
+ }
996
+ return value;
997
+ }
998
+ /**
999
+ * A due-index member: the player, `.`, and the id, each as fixed-width code
1000
+ * points. `.` sorts before every hex digit, so equal scores order by player
1001
+ * (a prefix first), then by id, in code-point order.
1002
+ */
1003
+ function scheduleMember(player, id) {
1004
+ return `${encodeCodePoints(player)}.${encodeCodePoints(id)}`;
1005
+ }
1006
+ function readScheduleMember(member) {
1007
+ const [player, id, ...rest] = member.split('.');
1008
+ const decodedPlayer = player === undefined ? undefined : decodeCodePoints(player);
1009
+ const decodedId = id === undefined ? undefined : decodeCodePoints(id);
1010
+ return rest.length > 0 || decodedPlayer === undefined || decodedId === undefined
1011
+ ? undefined
1012
+ : { player: decodedPlayer, id: decodedId };
1013
+ }
1014
+ /** Up to about 2 ms per attempt, jittered: enough to break lockstep retries. */
1015
+ function defaultBackoff(attempt) {
1016
+ const delayMs = Math.random() * 2 * Math.min(attempt, 8);
1017
+ return new Promise((resolve) => {
1018
+ setTimeout(resolve, delayMs);
1019
+ });
1020
+ }
1021
+ /** A missing key, field, or member: `@devvit/redis` answers `undefined`, a classic client `null`. */
1022
+ function isMissing(value) {
1023
+ return value === undefined || value === null;
1024
+ }
1025
+ function tryParse(value) {
1026
+ try {
1027
+ return JSON.parse(value);
1028
+ }
1029
+ catch {
1030
+ return undefined;
1031
+ }
1032
+ }
1033
+ function parse(value, key) {
1034
+ try {
1035
+ return JSON.parse(value);
1036
+ }
1037
+ catch (error) {
1038
+ throw new Error(`Devvit Redis notification store: ${key} holds a value that is not JSON.`, {
1039
+ cause: error,
1040
+ });
1041
+ }
1042
+ }
1043
+ function compareText(left, right) {
1044
+ return left < right ? -1 : left > right ? 1 : 0;
1045
+ }
1046
+ async function bestEffortReset(transaction, multiStarted) {
1047
+ try {
1048
+ if (multiStarted) {
1049
+ await transaction.discard();
1050
+ }
1051
+ else {
1052
+ await transaction.unwatch();
1053
+ }
1054
+ }
1055
+ catch {
1056
+ // Keep the original Redis failure; this cleanup is best-effort.
1057
+ }
1058
+ }