@mpgd/adapter-devvit 0.13.2 → 0.13.3
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 +8 -1
- package/dist/server/notifications-bridge.d.ts +8 -3
- package/dist/server/notifications-bridge.js +21 -6
- package/dist/server/notifications-delivery.d.ts +37 -14
- package/dist/server/notifications-delivery.js +57 -18
- package/dist/server/redis-notification-store.d.ts +13 -3
- package/dist/server/redis-notification-store.js +108 -11
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -58,7 +58,14 @@ Reddit approves. The profile follows the
|
|
|
58
58
|
send needs a post (`t3_`) or comment (`t1_`) link, from the send or the
|
|
59
59
|
topic's mapping. Titles are at most 60 and bodies at most 100 Unicode code
|
|
60
60
|
points of the filled text. Devvit allows 2 sends per user per day and 25,000
|
|
61
|
-
per app per day. Players' own calls take
|
|
61
|
+
per app per day. Players' own calls take
|
|
62
|
+
`devvitNotificationSessionFromContext(context)` so the player is the
|
|
63
|
+
request's `context.userId`. `devvitNotificationSession(userId)` remains
|
|
64
|
+
for a caller that already holds that id.
|
|
65
|
+
|
|
66
|
+
<!--
|
|
67
|
+
@link ./src/server/notifications-delivery.ts#devvitNotificationSessionFromContext Builds the session from the request context user id.
|
|
68
|
+
-->
|
|
62
69
|
Two `devvit.json` scheduler tasks keep its work moving:
|
|
63
70
|
`mpgd-notifications-dispatch` (every minute, the service's `runScheduler`:
|
|
64
71
|
due server reminders and due sends, in rounds of 10, starting no round
|
|
@@ -68,7 +68,10 @@ export declare const devvitNotificationHostCodes: Readonly<{
|
|
|
68
68
|
* turns `prompt` into `denied` once a decision is recorded).
|
|
69
69
|
* - **Requests.** Already opted in is `already-granted` without a second
|
|
70
70
|
* opt-in; otherwise `optInCurrentUser` answers `granted`. A refused opt-in
|
|
71
|
-
* (`success: false
|
|
71
|
+
* (`success: false`) is re-read with `isOptedIn`. If that read is
|
|
72
|
+
* `granted`, a concurrent request already opted the user in and the
|
|
73
|
+
* observed state is returned (`already-granted`), not a permanent
|
|
74
|
+
* configuration error. If the user is still out, it throws
|
|
72
75
|
* `NOTIFICATIONS_CONFIGURATION_REQUIRED` with host code
|
|
73
76
|
* `DEVVIT_OPT_IN_REFUSED`, which is not retryable: Devvit documents no
|
|
74
77
|
* transient refusal, and a refusal the game retried would loop. A plugin
|
|
@@ -76,8 +79,10 @@ export declare const devvitNotificationHostCodes: Readonly<{
|
|
|
76
79
|
* - **Revocation.** `optOutCurrentUser` for a user who is opted in (one who
|
|
77
80
|
* is already out needs none, which Devvit might refuse), the `denied`
|
|
78
81
|
* outcome for every mapped topic (`unavailable` for the others, and no
|
|
79
|
-
* opt-out when none is mapped)
|
|
80
|
-
*
|
|
82
|
+
* opt-out when none is mapped). A refused opt-out is re-read the same
|
|
83
|
+
* way: still opted in throws `DEVVIT_OPT_OUT_REFUSED`; already out returns
|
|
84
|
+
* the observed `prompt` state. A successful opt-out answers the state it
|
|
85
|
+
* produced (`prompt`), not a second read that could be stale.
|
|
81
86
|
* - **Unmapped topics.** A topic the catalog does not map for `reddit`
|
|
82
87
|
* reads `unsupported` in every snapshot.
|
|
83
88
|
* - **Registration.** A logged-in user registers `{ kind: 'platform-user' }`;
|
|
@@ -17,7 +17,10 @@ export const devvitNotificationHostCodes = Object.freeze({
|
|
|
17
17
|
* turns `prompt` into `denied` once a decision is recorded).
|
|
18
18
|
* - **Requests.** Already opted in is `already-granted` without a second
|
|
19
19
|
* opt-in; otherwise `optInCurrentUser` answers `granted`. A refused opt-in
|
|
20
|
-
* (`success: false
|
|
20
|
+
* (`success: false`) is re-read with `isOptedIn`. If that read is
|
|
21
|
+
* `granted`, a concurrent request already opted the user in and the
|
|
22
|
+
* observed state is returned (`already-granted`), not a permanent
|
|
23
|
+
* configuration error. If the user is still out, it throws
|
|
21
24
|
* `NOTIFICATIONS_CONFIGURATION_REQUIRED` with host code
|
|
22
25
|
* `DEVVIT_OPT_IN_REFUSED`, which is not retryable: Devvit documents no
|
|
23
26
|
* transient refusal, and a refusal the game retried would loop. A plugin
|
|
@@ -25,8 +28,10 @@ export const devvitNotificationHostCodes = Object.freeze({
|
|
|
25
28
|
* - **Revocation.** `optOutCurrentUser` for a user who is opted in (one who
|
|
26
29
|
* is already out needs none, which Devvit might refuse), the `denied`
|
|
27
30
|
* outcome for every mapped topic (`unavailable` for the others, and no
|
|
28
|
-
* opt-out when none is mapped)
|
|
29
|
-
*
|
|
31
|
+
* opt-out when none is mapped). A refused opt-out is re-read the same
|
|
32
|
+
* way: still opted in throws `DEVVIT_OPT_OUT_REFUSED`; already out returns
|
|
33
|
+
* the observed `prompt` state. A successful opt-out answers the state it
|
|
34
|
+
* produced (`prompt`), not a second read that could be stale.
|
|
30
35
|
* - **Unmapped topics.** A topic the catalog does not map for `reddit`
|
|
31
36
|
* reads `unsupported` in every snapshot.
|
|
32
37
|
* - **Registration.** A logged-in user registers `{ kind: 'platform-user' }`;
|
|
@@ -103,9 +108,17 @@ export function createDevvitServerNotificationProvider(input) {
|
|
|
103
108
|
const response = await plugin(() => input.notifications.optInCurrentUser(), 'optInCurrentUser');
|
|
104
109
|
const failure = refused(response, devvitNotificationHostCodes.optInRefused, 'the opt-in');
|
|
105
110
|
if (failure !== undefined) {
|
|
106
|
-
|
|
111
|
+
// Both of two concurrent requests can see not-granted and call
|
|
112
|
+
// opt-in. The duplicate answers success:false. That is a permanent
|
|
113
|
+
// configuration error only when a re-read still shows the user out.
|
|
114
|
+
if ((await read(userId)) !== 'granted') {
|
|
115
|
+
throw failure;
|
|
116
|
+
}
|
|
117
|
+
outcome = 'already-granted';
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
outcome = 'granted';
|
|
107
121
|
}
|
|
108
|
-
outcome = 'granted';
|
|
109
122
|
}
|
|
110
123
|
return { snapshot: snapshotOf(topics, 'granted'), outcomes: outcomesWith(topics, outcome) };
|
|
111
124
|
},
|
|
@@ -123,7 +136,9 @@ export function createDevvitServerNotificationProvider(input) {
|
|
|
123
136
|
if ((await read(userId)) === 'granted') {
|
|
124
137
|
const response = await plugin(() => input.notifications.optOutCurrentUser(), 'optOutCurrentUser');
|
|
125
138
|
const failure = refused(response, devvitNotificationHostCodes.optOutRefused, 'the opt-out');
|
|
126
|
-
if (failure !== undefined) {
|
|
139
|
+
if (failure !== undefined && (await read(userId)) === 'granted') {
|
|
140
|
+
// Still opted in: the refusal stands. Already out: a concurrent
|
|
141
|
+
// opt-out won, and the observed state is the one returned below.
|
|
127
142
|
throw failure;
|
|
128
143
|
}
|
|
129
144
|
}
|
|
@@ -102,22 +102,28 @@ export declare const devvitNotificationProviderCodes: Readonly<{
|
|
|
102
102
|
* points of the filled text (see `devvitNotificationTextLength`), a link
|
|
103
103
|
* that is not a `t3_` or `t1_` id, and a recipient that is not a `t2_` id
|
|
104
104
|
* are `rejected` with reason `configuration` (the recipient: `recipient`),
|
|
105
|
-
* because the same send would fail again.
|
|
105
|
+
* because the same send would fail again. An `enqueue` error that reads
|
|
106
|
+
* as a length or content refusal (a size or text word, and refusal
|
|
107
|
+
* evidence such as too long, exceeds, invalid, not allowed, or not found)
|
|
108
|
+
* is `rejected` with reason `configuration` once, not replayed. A field
|
|
109
|
+
* name alone stays ambiguous.
|
|
106
110
|
* - **Answers.** `successCount` above 0 is `accepted`. Otherwise an error for
|
|
107
111
|
* this recipient (or for no named user) is a failure Devvit counted:
|
|
108
112
|
* a message that reads as a rate limit is `rate-limited` (deferred, for
|
|
109
|
-
* at most a day), one that reads as
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* `
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* after
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
113
|
+
* at most a day), one that reads as a length or content refusal is
|
|
114
|
+
* `rejected` with reason `configuration`, one that reads as the user's
|
|
115
|
+
* refusal (not opted in, no such user) in an error that names this
|
|
116
|
+
* recipient is `rejected` with reason `recipient`. Any other error is
|
|
117
|
+
* `ambiguous` (provider code `DEVVIT_ENQUEUE_UNCLASSIFIED`): Devvit
|
|
118
|
+
* documents no error messages, so an unknown one is neither proof of a
|
|
119
|
+
* setup error nor of a refusal. The service reports it to the operator
|
|
120
|
+
* and replays it after each lease, which doubles from the claim lease up
|
|
121
|
+
* to an hour, for at most 24 hours after the first attempt
|
|
122
|
+
* (`notificationAmbiguousReplayHours`): with the default 300-second
|
|
123
|
+
* lease, at most 27 calls. A call that throws, or an answer that counts
|
|
124
|
+
* neither a success nor a failure, is `ambiguous` too: Devvit may have
|
|
125
|
+
* queued it, and `enqueue` takes no idempotency key, so a replay may
|
|
126
|
+
* show twice.
|
|
121
127
|
* `classifyEnqueueError` overrides each default, and `onEnqueueError`
|
|
122
128
|
* sees every failure with Devvit's message.
|
|
123
129
|
* - **Opt-in at send time.** `readPermission` reads `isOptedIn`, since no
|
|
@@ -159,7 +165,9 @@ export interface DevvitNotificationService {
|
|
|
159
165
|
* (`context.userId`), which is also the recipient; any other player id has
|
|
160
166
|
* no Devvit recipient (`unavailable: no-endpoint`).
|
|
161
167
|
*
|
|
162
|
-
* Players' own calls take
|
|
168
|
+
* Players' own calls take `devvitNotificationSessionFromContext(context)`
|
|
169
|
+
* (the request's `context.userId`). `devvitNotificationSession(userId)` remains
|
|
170
|
+
* for a caller that already holds that id. Server
|
|
163
171
|
* reminders (`setReminder`) fire through the `dispatch` scheduler task
|
|
164
172
|
* (`runDevvitNotificationTask`). The scheduler pushes no consent notices
|
|
165
173
|
* through Devvit: its entry fixes the title and body per topic, so it does
|
|
@@ -171,8 +179,23 @@ export declare function createDevvitNotificationService(input: CreateDevvitNotif
|
|
|
171
179
|
* player has no game-services session: the user id is the player, and one
|
|
172
180
|
* session and installation per user, which no endpoint uses because Devvit
|
|
173
181
|
* registers no device address.
|
|
182
|
+
*
|
|
183
|
+
* @deprecated Prefer `devvitNotificationSessionFromContext`. A caller-supplied
|
|
184
|
+
* id can name a different player than the request.
|
|
174
185
|
*/
|
|
175
186
|
export declare function devvitNotificationSession(userId: string): NotificationSession;
|
|
187
|
+
/** The Devvit request context fields this session reads (`context` from `@devvit/web/server`). */
|
|
188
|
+
export interface DevvitNotificationSessionContext {
|
|
189
|
+
/** The logged-in Reddit user of the current request (`context.userId`). */
|
|
190
|
+
readonly userId?: string | undefined;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The notifications session of the current Devvit request. The player is
|
|
194
|
+
* `context.userId`, not a string the caller supplies, so a handler cannot
|
|
195
|
+
* record consent or set a reminder for a different user by passing one.
|
|
196
|
+
* A missing or non-`t2_` id throws: a logged-out request has no session.
|
|
197
|
+
*/
|
|
198
|
+
export declare function devvitNotificationSessionFromContext(context: DevvitNotificationSessionContext): NotificationSession;
|
|
176
199
|
/**
|
|
177
200
|
* The Devvit scheduler tasks that keep the service's own work moving:
|
|
178
201
|
* `dispatch` runs the service's scheduler (`runScheduler`) every minute,
|
|
@@ -26,10 +26,13 @@ function readsAsRateLimit(message) {
|
|
|
26
26
|
if (/rate[ -]?limit|too many (?:requests|notifications)|quota|throttl/iu.test(message)) {
|
|
27
27
|
return true;
|
|
28
28
|
}
|
|
29
|
-
// A
|
|
30
|
-
// whatever period it names.
|
|
31
|
-
//
|
|
32
|
-
|
|
29
|
+
// A size or text word plus refusal evidence marks a length or content
|
|
30
|
+
// refusal, whatever period it names. Such a refusal is configuration: the
|
|
31
|
+
// same send fails again, so it must not be replayed as ambiguous. A field
|
|
32
|
+
// name alone ("payload service temporarily unavailable") is not evidence.
|
|
33
|
+
// "Message" may count sends ("daily message limit"), so it marks a rate
|
|
34
|
+
// limit only when the message names no period and no size or text refusal.
|
|
35
|
+
if (readsAsSizeOrTextRefusal(message)) {
|
|
33
36
|
return false;
|
|
34
37
|
}
|
|
35
38
|
if (!dailyLimitWords.test(message) && /\bmessages?\b/iu.test(message)) {
|
|
@@ -47,6 +50,16 @@ function readsAsRateLimit(message) {
|
|
|
47
50
|
const dailyPeriodSource = String.raw `daily|per[- ]?(?:day|user|app)|today'?s?`;
|
|
48
51
|
const dailyLimitWords = new RegExp(String.raw `\b(?:${dailyPeriodSource})\b`, 'iu');
|
|
49
52
|
const adjacentCountedLimit = new RegExp(String.raw `\b(?:${dailyPeriodSource}|send(?:ing)?|notifications?) limits?\b`, 'iu');
|
|
53
|
+
/** Title, character, length, link, and the other size or text words a refusal names. */
|
|
54
|
+
const sizeOrTextWords = /\b(?:characters?|length|long|size|bytes?|title|body|link|text|content|payload|template)\b/iu;
|
|
55
|
+
/**
|
|
56
|
+
* Words that prove a deterministic refusal. A field name alone is not one:
|
|
57
|
+
* "payload service temporarily unavailable" stays ambiguous and is replayed.
|
|
58
|
+
*/
|
|
59
|
+
const refusalEvidence = /\b(?:too long|exceed(?:s|ed|ing)?|invalid|not allowed|not found|does not exist)\b/iu;
|
|
60
|
+
function readsAsSizeOrTextRefusal(message) {
|
|
61
|
+
return sizeOrTextWords.test(message) && refusalEvidence.test(message);
|
|
62
|
+
}
|
|
50
63
|
/**
|
|
51
64
|
* A message that reads as this user's refusal: not opted in, opted out, no
|
|
52
65
|
* such user or account. It counts only for an error that names this
|
|
@@ -86,22 +99,28 @@ function reasonOf(answer) {
|
|
|
86
99
|
* points of the filled text (see `devvitNotificationTextLength`), a link
|
|
87
100
|
* that is not a `t3_` or `t1_` id, and a recipient that is not a `t2_` id
|
|
88
101
|
* are `rejected` with reason `configuration` (the recipient: `recipient`),
|
|
89
|
-
* because the same send would fail again.
|
|
102
|
+
* because the same send would fail again. An `enqueue` error that reads
|
|
103
|
+
* as a length or content refusal (a size or text word, and refusal
|
|
104
|
+
* evidence such as too long, exceeds, invalid, not allowed, or not found)
|
|
105
|
+
* is `rejected` with reason `configuration` once, not replayed. A field
|
|
106
|
+
* name alone stays ambiguous.
|
|
90
107
|
* - **Answers.** `successCount` above 0 is `accepted`. Otherwise an error for
|
|
91
108
|
* this recipient (or for no named user) is a failure Devvit counted:
|
|
92
109
|
* a message that reads as a rate limit is `rate-limited` (deferred, for
|
|
93
|
-
* at most a day), one that reads as
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* `
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* after
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
110
|
+
* at most a day), one that reads as a length or content refusal is
|
|
111
|
+
* `rejected` with reason `configuration`, one that reads as the user's
|
|
112
|
+
* refusal (not opted in, no such user) in an error that names this
|
|
113
|
+
* recipient is `rejected` with reason `recipient`. Any other error is
|
|
114
|
+
* `ambiguous` (provider code `DEVVIT_ENQUEUE_UNCLASSIFIED`): Devvit
|
|
115
|
+
* documents no error messages, so an unknown one is neither proof of a
|
|
116
|
+
* setup error nor of a refusal. The service reports it to the operator
|
|
117
|
+
* and replays it after each lease, which doubles from the claim lease up
|
|
118
|
+
* to an hour, for at most 24 hours after the first attempt
|
|
119
|
+
* (`notificationAmbiguousReplayHours`): with the default 300-second
|
|
120
|
+
* lease, at most 27 calls. A call that throws, or an answer that counts
|
|
121
|
+
* neither a success nor a failure, is `ambiguous` too: Devvit may have
|
|
122
|
+
* queued it, and `enqueue` takes no idempotency key, so a replay may
|
|
123
|
+
* show twice.
|
|
105
124
|
* `classifyEnqueueError` overrides each default, and `onEnqueueError`
|
|
106
125
|
* sees every failure with Devvit's message.
|
|
107
126
|
* - **Opt-in at send time.** `readPermission` reads `isOptedIn`, since no
|
|
@@ -226,6 +245,9 @@ export function createDevvitNotificationDeliveryProvider(input) {
|
|
|
226
245
|
if (readsAsRateLimit(message)) {
|
|
227
246
|
fallback = 'rate-limited';
|
|
228
247
|
}
|
|
248
|
+
else if (readsAsSizeOrTextRefusal(message)) {
|
|
249
|
+
fallback = 'configuration';
|
|
250
|
+
}
|
|
229
251
|
else if (ours?.userId === userId && readsAsUserRefusal(message)) {
|
|
230
252
|
// Only an error that names this recipient proves a refusal of the user.
|
|
231
253
|
fallback = 'recipient';
|
|
@@ -269,7 +291,9 @@ export function createDevvitNotificationProviderEntry() {
|
|
|
269
291
|
* (`context.userId`), which is also the recipient; any other player id has
|
|
270
292
|
* no Devvit recipient (`unavailable: no-endpoint`).
|
|
271
293
|
*
|
|
272
|
-
* Players' own calls take
|
|
294
|
+
* Players' own calls take `devvitNotificationSessionFromContext(context)`
|
|
295
|
+
* (the request's `context.userId`). `devvitNotificationSession(userId)` remains
|
|
296
|
+
* for a caller that already holds that id. Server
|
|
273
297
|
* reminders (`setReminder`) fire through the `dispatch` scheduler task
|
|
274
298
|
* (`runDevvitNotificationTask`). The scheduler pushes no consent notices
|
|
275
299
|
* through Devvit: its entry fixes the title and body per topic, so it does
|
|
@@ -336,6 +360,9 @@ export function createDevvitNotificationService(input) {
|
|
|
336
360
|
* player has no game-services session: the user id is the player, and one
|
|
337
361
|
* session and installation per user, which no endpoint uses because Devvit
|
|
338
362
|
* registers no device address.
|
|
363
|
+
*
|
|
364
|
+
* @deprecated Prefer `devvitNotificationSessionFromContext`. A caller-supplied
|
|
365
|
+
* id can name a different player than the request.
|
|
339
366
|
*/
|
|
340
367
|
export function devvitNotificationSession(userId) {
|
|
341
368
|
if (!devvitUserIdPattern.test(userId)) {
|
|
@@ -343,6 +370,18 @@ export function devvitNotificationSession(userId) {
|
|
|
343
370
|
}
|
|
344
371
|
return { player: userId, sessionId: `devvit:${userId}`, installationId: `devvit:${userId}` };
|
|
345
372
|
}
|
|
373
|
+
/**
|
|
374
|
+
* The notifications session of the current Devvit request. The player is
|
|
375
|
+
* `context.userId`, not a string the caller supplies, so a handler cannot
|
|
376
|
+
* record consent or set a reminder for a different user by passing one.
|
|
377
|
+
* A missing or non-`t2_` id throws: a logged-out request has no session.
|
|
378
|
+
*/
|
|
379
|
+
export function devvitNotificationSessionFromContext(context) {
|
|
380
|
+
if (typeof context.userId !== 'string') {
|
|
381
|
+
throw new TypeError('A Devvit notifications session needs the request context user id.');
|
|
382
|
+
}
|
|
383
|
+
return devvitNotificationSession(context.userId);
|
|
384
|
+
}
|
|
346
385
|
/**
|
|
347
386
|
* The Devvit scheduler tasks that keep the service's own work moving:
|
|
348
387
|
* `dispatch` runs the service's scheduler (`runScheduler`) every minute,
|
|
@@ -29,7 +29,11 @@ import { type NotificationStore } from '@mpgd/game-services/notifications';
|
|
|
29
29
|
* providers, so it stores none in practice.
|
|
30
30
|
* - **History** is a hash per player (one field per send and provider, so a
|
|
31
31
|
* repeat is ignored) with sorted sets by time: per player, for all
|
|
32
|
-
* players (purge), and per provider. `
|
|
32
|
+
* players (purge), and per provider. `listHistory` reads the hash with
|
|
33
|
+
* `hGetAll`, not `hMGet`: Devvit documents `hMGet` as an allowlisted
|
|
34
|
+
* command that may be disabled per app, and the gate calls `listHistory`
|
|
35
|
+
* before any write, so a disabled `hMGet` would fail every send.
|
|
36
|
+
* `countHistory` counts the provider's
|
|
33
37
|
* set by rank (`@devvit/redis` has no ZCOUNT): a few reads, not a
|
|
34
38
|
* snapshot, which the scheduler's approximate pacing allows.
|
|
35
39
|
* - **Schedule entries** (server reminders, and notice checks for a
|
|
@@ -59,7 +63,14 @@ import { type NotificationStore } from '@mpgd/game-services/notifications';
|
|
|
59
63
|
* `createDevvitRedisPostOperationStore`. Here every transaction ends with a
|
|
60
64
|
* commit marker whose reply always maps, so only an empty or all-nil result
|
|
61
65
|
* 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
|
|
66
|
+
* Devvit allows 20 concurrent transactions per installation by default, and
|
|
67
|
+
* a transaction still open after 5 seconds is aborted. A throw from WATCH
|
|
68
|
+
* or the planning read that is transient, and an EXEC throw that proves the
|
|
69
|
+
* transaction aborted before commit (that cap, that timeout), are retried
|
|
70
|
+
* inside the same attempt budget with the step's backoff. A connection loss
|
|
71
|
+
* thrown by EXEC is not: the reply may have been lost after commit, and
|
|
72
|
+
* re-planning would report the landed write as contention. A non-transient
|
|
73
|
+
* failure throws on the attempt that saw it.
|
|
63
74
|
*
|
|
64
75
|
* Devvit Redis: <https://developers.reddit.com/docs/versioned_docs/version-0.14/capabilities/server/redis>
|
|
65
76
|
* (transactions: WATCH, MULTI, EXEC, DISCARD, UNWATCH).
|
|
@@ -88,7 +99,6 @@ export interface DevvitNotificationRedis {
|
|
|
88
99
|
mGet(keys: string[]): Promise<(string | null)[]>;
|
|
89
100
|
hGet(key: string, field: string): Promise<string | undefined>;
|
|
90
101
|
hGetAll(key: string): Promise<Record<string, string>>;
|
|
91
|
-
hMGet(key: string, fields: string[]): Promise<(string | null)[]>;
|
|
92
102
|
hSet(key: string, fieldValues: Record<string, string>): Promise<unknown>;
|
|
93
103
|
zRange(key: string, start: number | string, stop: number | string, options?: {
|
|
94
104
|
readonly by: 'score' | 'lex' | 'rank';
|
|
@@ -41,6 +41,10 @@ export function createDevvitRedisNotificationStore(redis, options = {}) {
|
|
|
41
41
|
due: `${namespace}:sends:due`,
|
|
42
42
|
changed: `${namespace}:sends:changed`,
|
|
43
43
|
inFlight: (player) => `${namespace}:sends:in-flight:${enc(player)}`,
|
|
44
|
+
// The in-flight member is the send key, which does not name the player.
|
|
45
|
+
// This pointer is written in the same transaction, so purge can drop the
|
|
46
|
+
// member when the send value no longer parses.
|
|
47
|
+
inFlightOwner: (key) => `${namespace}:sends:in-flight-owner:${enc(key)}`,
|
|
44
48
|
history: (player) => `${namespace}:history:${enc(player)}`,
|
|
45
49
|
historyAt: (player) => `${namespace}:history-at:${enc(player)}`,
|
|
46
50
|
historyAll: `${namespace}:history-all`,
|
|
@@ -76,11 +80,13 @@ export function createDevvitRedisNotificationStore(redis, options = {}) {
|
|
|
76
80
|
if (attempt > 0) {
|
|
77
81
|
await backoff(attempt);
|
|
78
82
|
}
|
|
79
|
-
|
|
80
|
-
lastWatched = watched;
|
|
81
|
-
const transaction = await redis.watch(...watched);
|
|
83
|
+
let transaction;
|
|
82
84
|
let multiStarted = false;
|
|
85
|
+
let execStarted = false;
|
|
83
86
|
try {
|
|
87
|
+
const { watched, plan } = await prepare();
|
|
88
|
+
lastWatched = watched;
|
|
89
|
+
transaction = await redis.watch(...watched);
|
|
84
90
|
const planned = await plan();
|
|
85
91
|
if ('retry' in planned) {
|
|
86
92
|
await transaction.unwatch();
|
|
@@ -97,6 +103,7 @@ export function createDevvitRedisNotificationStore(redis, options = {}) {
|
|
|
97
103
|
await command(transaction);
|
|
98
104
|
}
|
|
99
105
|
await transaction.set(keys.commitMarker, commitMarkerValue);
|
|
106
|
+
execStarted = true;
|
|
100
107
|
const results = await transaction.exec();
|
|
101
108
|
if (results !== null && !Array.isArray(results)) {
|
|
102
109
|
throw new Error('Devvit Redis transaction returned an unsupported response.');
|
|
@@ -110,8 +117,22 @@ export function createDevvitRedisNotificationStore(redis, options = {}) {
|
|
|
110
117
|
return result;
|
|
111
118
|
}
|
|
112
119
|
catch (error) {
|
|
113
|
-
|
|
114
|
-
|
|
120
|
+
if (transaction !== undefined) {
|
|
121
|
+
await bestEffortReset(transaction, multiStarted);
|
|
122
|
+
}
|
|
123
|
+
// A nil EXEC retries above. A throw before EXEC (WATCH, the planning
|
|
124
|
+
// read, MULTI, or a queued command) retries when it is transient:
|
|
125
|
+
// EXEC did not run. A throw from EXEC retries only when it proves
|
|
126
|
+
// the transaction aborted before commit (the 20-transaction cap, the
|
|
127
|
+
// 5-second timeout). A connection loss on EXEC is not retried: the
|
|
128
|
+
// reply may have been lost after commit, and re-planning would
|
|
129
|
+
// report the landed write as contention.
|
|
130
|
+
const retryable = execStarted
|
|
131
|
+
? provesExecAbortedBeforeCommit(error)
|
|
132
|
+
: isTransientDevvitRedisError(error);
|
|
133
|
+
if (!retryable || attempt + 1 >= attempts) {
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
115
136
|
}
|
|
116
137
|
}
|
|
117
138
|
throw new Error(`Devvit Redis notification store: contention exceeded ${String(attempts)} attempts on ${String(lastWatched.length)} watched key(s).`);
|
|
@@ -278,12 +299,31 @@ export function createDevvitRedisNotificationStore(redis, options = {}) {
|
|
|
278
299
|
const flying = (send) => send !== undefined && send.rows.some((row) => row.state.status === 'in-flight');
|
|
279
300
|
if (before !== undefined && flying(before) && !flying(after)) {
|
|
280
301
|
commands.push((transaction) => transaction.zRem(keys.inFlight(before.request.player), [key]));
|
|
302
|
+
commands.push((transaction) => transaction.del(keys.inFlightOwner(key)));
|
|
281
303
|
}
|
|
282
304
|
if (after !== undefined && flying(after)) {
|
|
283
305
|
commands.push((transaction) => transaction.zAdd(keys.inFlight(after.request.player), { member: key, score: 0 }));
|
|
306
|
+
commands.push((transaction) => transaction.set(keys.inFlightOwner(key), after.request.player));
|
|
284
307
|
}
|
|
285
308
|
return commands;
|
|
286
309
|
}
|
|
310
|
+
/**
|
|
311
|
+
* Commands that drop an in-flight index member whose send value no longer
|
|
312
|
+
* names the player. The owner pointer is written with the member. A row
|
|
313
|
+
* written before that pointer existed cannot be removed from a player
|
|
314
|
+
* index the value no longer tells; `listInFlightSends` skips the missing
|
|
315
|
+
* value.
|
|
316
|
+
*/
|
|
317
|
+
async function inFlightCleanup(key) {
|
|
318
|
+
const owner = await redis.get(keys.inFlightOwner(key));
|
|
319
|
+
if (isMissing(owner) || owner.length === 0) {
|
|
320
|
+
return [];
|
|
321
|
+
}
|
|
322
|
+
return [
|
|
323
|
+
(transaction) => transaction.zRem(keys.inFlight(owner), [key]),
|
|
324
|
+
(transaction) => transaction.del(keys.inFlightOwner(key)),
|
|
325
|
+
];
|
|
326
|
+
}
|
|
287
327
|
/** A stored send value, or `'unreadable'` for one that is not a send. */
|
|
288
328
|
function readSendValue(raw) {
|
|
289
329
|
const value = tryParse(raw);
|
|
@@ -727,10 +767,12 @@ export function createDevvitRedisNotificationStore(redis, options = {}) {
|
|
|
727
767
|
return [];
|
|
728
768
|
}
|
|
729
769
|
const key = keys.history(player);
|
|
730
|
-
const
|
|
731
|
-
return
|
|
732
|
-
.
|
|
733
|
-
|
|
770
|
+
const stored = await redis.hGetAll(key);
|
|
771
|
+
return fields
|
|
772
|
+
.flatMap((field) => {
|
|
773
|
+
const value = stored[field];
|
|
774
|
+
return typeof value === 'string' ? [parse(value, key)] : [];
|
|
775
|
+
})
|
|
734
776
|
.sort((left, right) => Date.parse(left.at) - Date.parse(right.at));
|
|
735
777
|
},
|
|
736
778
|
async countHistory({ provider, since, until }) {
|
|
@@ -816,13 +858,14 @@ export function createDevvitRedisNotificationStore(redis, options = {}) {
|
|
|
816
858
|
const raw = await redis.get(valueKey);
|
|
817
859
|
const stored = isMissing(raw) ? undefined : readSendValue(raw);
|
|
818
860
|
if (stored === 'unreadable') {
|
|
819
|
-
//
|
|
820
|
-
//
|
|
861
|
+
// The value no longer names the player. The owner pointer, written
|
|
862
|
+
// with the in-flight member, is what makes that member cleanable.
|
|
821
863
|
return {
|
|
822
864
|
commands: [
|
|
823
865
|
(transaction) => transaction.del(valueKey),
|
|
824
866
|
(transaction) => transaction.zRem(keys.changed, [key]),
|
|
825
867
|
(transaction) => transaction.zRem(keys.due, [key]),
|
|
868
|
+
...(await inFlightCleanup(key)),
|
|
826
869
|
],
|
|
827
870
|
result: true,
|
|
828
871
|
};
|
|
@@ -833,6 +876,7 @@ export function createDevvitRedisNotificationStore(redis, options = {}) {
|
|
|
833
876
|
commands: [
|
|
834
877
|
(transaction) => transaction.zRem(keys.changed, [key]),
|
|
835
878
|
(transaction) => transaction.zRem(keys.due, [key]),
|
|
879
|
+
...(await inFlightCleanup(key)),
|
|
836
880
|
],
|
|
837
881
|
result: false,
|
|
838
882
|
};
|
|
@@ -1018,6 +1062,59 @@ function defaultBackoff(attempt) {
|
|
|
1018
1062
|
setTimeout(resolve, delayMs);
|
|
1019
1063
|
});
|
|
1020
1064
|
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Whether a Redis client throw is worth another attempt when EXEC has not
|
|
1067
|
+
* run: the installation's 20-transaction cap, a transaction aborted after 5
|
|
1068
|
+
* seconds, or another transient failure. A wrong type, a disabled command,
|
|
1069
|
+
* or a value this store cannot parse is not transient.
|
|
1070
|
+
*/
|
|
1071
|
+
function isTransientDevvitRedisError(error) {
|
|
1072
|
+
const kind = redisThrowKind(error);
|
|
1073
|
+
return kind === 'aborted-before-commit' || kind === 'connection-loss';
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Whether an EXEC throw proves the transaction aborted before commit. A
|
|
1077
|
+
* connection loss does not: the server may have committed and dropped the
|
|
1078
|
+
* reply, so the caller must see the error instead of a retried plan.
|
|
1079
|
+
*/
|
|
1080
|
+
function provesExecAbortedBeforeCommit(error) {
|
|
1081
|
+
return redisThrowKind(error) === 'aborted-before-commit';
|
|
1082
|
+
}
|
|
1083
|
+
const permanentRedisError = /not allowed|allow-?list|unknown command|WRONGTYPE|unsupported response|is not JSON|NOAUTH/iu;
|
|
1084
|
+
const connectionLoss = /\b(?:econnreset|econnrefused|socket hang up|temporarily unavailable|resource[_ ]exhausted|try again later)\b/iu;
|
|
1085
|
+
const transactionAbort = /\b(?:timed?\s*out|timeout|deadline exceeded)\b/iu;
|
|
1086
|
+
function redisThrowKind(error) {
|
|
1087
|
+
if (error instanceof TypeError || error instanceof RangeError || error instanceof SyntaxError) {
|
|
1088
|
+
return 'permanent';
|
|
1089
|
+
}
|
|
1090
|
+
const text = redisErrorText(error);
|
|
1091
|
+
if (text.length === 0 || permanentRedisError.test(text)) {
|
|
1092
|
+
return 'permanent';
|
|
1093
|
+
}
|
|
1094
|
+
// A dropped connection can follow a commit whose reply never arrived, even
|
|
1095
|
+
// when the message also names a timeout. That is not proof of an abort.
|
|
1096
|
+
if (connectionLoss.test(text)) {
|
|
1097
|
+
return 'connection-loss';
|
|
1098
|
+
}
|
|
1099
|
+
if ((/\btransactions?\b/iu.test(text)
|
|
1100
|
+
&& /\b(?:limit|cap|exceed\w*|too many|concurrent|timeout|timed out|deadline)\b/iu.test(text))
|
|
1101
|
+
|| transactionAbort.test(text)) {
|
|
1102
|
+
return 'aborted-before-commit';
|
|
1103
|
+
}
|
|
1104
|
+
return 'other';
|
|
1105
|
+
}
|
|
1106
|
+
function redisErrorText(error) {
|
|
1107
|
+
if (typeof error === 'string') {
|
|
1108
|
+
return error;
|
|
1109
|
+
}
|
|
1110
|
+
if (typeof error !== 'object' || error === null) {
|
|
1111
|
+
return '';
|
|
1112
|
+
}
|
|
1113
|
+
const record = error;
|
|
1114
|
+
return [record.message, record.code, record.msg]
|
|
1115
|
+
.filter((part) => typeof part === 'string')
|
|
1116
|
+
.join(' ');
|
|
1117
|
+
}
|
|
1021
1118
|
/** A missing key, field, or member: `@devvit/redis` answers `undefined`, a classic client `null`. */
|
|
1022
1119
|
function isMissing(value) {
|
|
1023
1120
|
return value === undefined || value === null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/adapter-devvit",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.3",
|
|
4
4
|
"description": "Reddit Devvit Web platform adapter for mpgd games.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -55,9 +55,9 @@
|
|
|
55
55
|
],
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"@devvit/web": "0.14.1",
|
|
58
|
-
"@mpgd/bridge": "0.14.
|
|
59
|
-
"@mpgd/platform": "0.
|
|
60
|
-
"@mpgd/game-services": "0.
|
|
58
|
+
"@mpgd/bridge": "0.14.2",
|
|
59
|
+
"@mpgd/platform": "0.27.0",
|
|
60
|
+
"@mpgd/game-services": "0.27.0"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
63
|
"@types/node": "^24.0.0",
|