@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 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 `devvitNotificationSession(userId)`.
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`, such as an app not yet approved) throws
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), and the state the opt-out produced:
80
- * `prompt`, not opted in.
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`, such as an app not yet approved) throws
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), and the state the opt-out produced:
29
- * `prompt`, not opted in.
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
- throw failure;
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 the user's refusal (not opted in, no
110
- * such user) in an error that names this recipient is `rejected` with
111
- * reason `recipient`. Any other error is `ambiguous` (provider code
112
- * `DEVVIT_ENQUEUE_UNCLASSIFIED`): Devvit documents no error messages, so
113
- * an unknown one is neither proof of a setup error nor of a refusal. The
114
- * service reports it to the operator and replays it after each lease,
115
- * which doubles from the claim lease up to an hour, for at most 24 hours
116
- * after the first attempt (`notificationAmbiguousReplayHours`): with the
117
- * default 300-second lease, at most 27 calls. A call that throws,
118
- * or an answer that counts neither a success nor a failure, is
119
- * `ambiguous` too: Devvit may have queued it, and `enqueue` takes no
120
- * idempotency key, so a replay may show twice.
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 a `devvitNotificationSession(userId)`. Server
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 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)) {
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 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.
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 a `devvitNotificationSession(userId)`. Server
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. `countHistory` counts the provider's
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
- const { watched, plan } = await prepare();
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
- await bestEffortReset(transaction, multiStarted);
114
- throw error;
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 values = await redis.hMGet(key, fields);
731
- return values
732
- .filter((value) => typeof value === 'string')
733
- .map((value) => parse(value, key))
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
- // Its in-flight index entry, if any, names a player the value no
820
- // longer tells; listInFlightSends skips the missing value.
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.2",
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.1",
59
- "@mpgd/platform": "0.26.0",
60
- "@mpgd/game-services": "0.26.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",