@mpgd/adapter-devvit 0.13.2 → 0.13.4

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
@@ -20,6 +20,12 @@ export declare const devvitCommerceV2MethodUnsupportedBridgeErrorCode: 'COMMERCE
20
20
  export declare const devvitCommerceCheckoutClientOnlyBridgeErrorCode: 'DEVVIT_COMMERCE_CHECKOUT_CLIENT_ONLY';
21
21
  /** Bridge error when the payments plugin fails; retryable. */
22
22
  export declare const devvitPaymentsUnavailableBridgeErrorCode: 'DEVVIT_PAYMENTS_UNAVAILABLE';
23
+ /**
24
+ * Bridge error when the paid-order scan hit its page cap with pages left.
25
+ * Retryable. A caller must not treat orders absent from this scan as delivered:
26
+ * the list was not finished.
27
+ */
28
+ export declare const devvitOrderScanIncompleteBridgeErrorCode: 'DEVVIT_ORDER_SCAN_INCOMPLETE';
23
29
  /** `Order.status` as `@devvit/protos` encodes it (`OrderStatus` enum) or as the handler types spell it. */
24
30
  export type DevvitPaymentsOrderStatusLike = number | string;
25
31
  /** The fields of a `@devvit/payments` `Order` the handler reads. */
@@ -81,6 +87,17 @@ export interface DevvitCommerceV2BridgeHandler {
81
87
  request(input: BridgeRequest): Promise<BridgeResponse>;
82
88
  }
83
89
  /**
90
+ * Hard cap on the pages scanned per listing. `@devvit/payments` 0.14 ignores
91
+ * `status` and `buyer` (`GetOrdersRequest` marks both "currently does nothing";
92
+ * an empty filter means every order), and `PaymentsClient` forwards the
93
+ * request unchanged, so every status is scanned. Devvit serves this handler
94
+ * inside a time-bounded HTTP request. Thirty-two serial plugin calls stay
95
+ * inside that budget (the kit treats about 20 seconds as the practical limit
96
+ * for a Devvit task). Hitting the cap with a next page fails the request
97
+ * instead of returning a partial list: unseen orders are not delivered.
98
+ */
99
+ export declare const maximumDevvitOrderScanPages = 32;
100
+ /**
84
101
  * Serves `commerce.v2.*` from the payments plugin:
85
102
  *
86
103
  * - `getCapabilities` answers the Devvit profile (`createDevvitCommerceCapabilities`).
@@ -92,13 +109,10 @@ export interface DevvitCommerceV2BridgeHandler {
92
109
  * `purchased` (the order id as `transactionId` and `token`,
93
110
  * `devvit.order.v1` evidence whose `status` says which), bounded by
94
111
  * `maximumDevvitAvailableOrders`. Reddit marks an order `DELIVERED` as soon
95
- * as the fulfillment handler answered, so the delivered orders are the ones
96
- * whose ledger entry exists; the webview provider lists only the `PAID`
97
- * ones as available and reports both as events, so a session that lost its
98
- * checkout result still finds the order. The scan reads pages of 64
99
- * (newest first, as the Devvit docs describe) and stops after two
100
- * consecutive pages without a paid or delivered order or after
101
- * `maximumOrderPages`, warning when pages are left.
112
+ * as the fulfillment handler answered. The scan reads pages of 64 until the
113
+ * cursor ends, or until `maximumDevvitOrderScanPages`. A scan that still has
114
+ * a next page at that cap fails with `DEVVIT_ORDER_SCAN_INCOMPLETE` rather
115
+ * than returning a partial list: an order that was not read is not delivered.
102
116
  * - `requestPurchase` is refused with `DEVVIT_COMMERCE_CHECKOUT_CLIENT_ONLY`,
103
117
  * `finishTransaction` with `COMMERCE_V2_METHOD_UNSUPPORTED`, and
104
118
  * `restorePurchases` answers `{ restored: false }`: the webview provider
@@ -22,6 +22,12 @@ export const devvitCommerceV2MethodUnsupportedBridgeErrorCode = 'COMMERCE_V2_MET
22
22
  export const devvitCommerceCheckoutClientOnlyBridgeErrorCode = 'DEVVIT_COMMERCE_CHECKOUT_CLIENT_ONLY';
23
23
  /** Bridge error when the payments plugin fails; retryable. */
24
24
  export const devvitPaymentsUnavailableBridgeErrorCode = 'DEVVIT_PAYMENTS_UNAVAILABLE';
25
+ /**
26
+ * Bridge error when the paid-order scan hit its page cap with pages left.
27
+ * Retryable. A caller must not treat orders absent from this scan as delivered:
28
+ * the list was not finished.
29
+ */
30
+ export const devvitOrderScanIncompleteBridgeErrorCode = 'DEVVIT_ORDER_SCAN_INCOMPLETE';
25
31
  /** `OrderStatus.ORDER_STATUS_PAID` of `@devvit/protos`, and its handler-type spelling. */
26
32
  const paidOrderStatuses = new Set([
27
33
  3,
@@ -61,17 +67,16 @@ const durableAccountingTypes = new Set([
61
67
  ]);
62
68
  const orderPageSize = maximumDevvitAvailableOrders;
63
69
  /**
64
- * Hard cap on the pages scanned per listing. The plugin's `status` filter
65
- * does nothing in `@devvit/payments` 0.14 (its proto marks it so, and the
66
- * `PaymentsClient` forwards it unchanged), so every status is scanned. The
67
- * Devvit docs describe the list as newest first ("Manage Payments", "Check
68
- * orders"); that is documentation, not a plugin guarantee, so the scan stops
69
- * early only after two consecutive pages without a paid or delivered order,
70
- * and warns when it stops with pages left.
70
+ * Hard cap on the pages scanned per listing. `@devvit/payments` 0.14 ignores
71
+ * `status` and `buyer` (`GetOrdersRequest` marks both "currently does nothing";
72
+ * an empty filter means every order), and `PaymentsClient` forwards the
73
+ * request unchanged, so every status is scanned. Devvit serves this handler
74
+ * inside a time-bounded HTTP request. Thirty-two serial plugin calls stay
75
+ * inside that budget (the kit treats about 20 seconds as the practical limit
76
+ * for a Devvit task). Hitting the cap with a next page fails the request
77
+ * instead of returning a partial list: unseen orders are not delivered.
71
78
  */
72
- const maximumOrderPages = 8;
73
- /** Consecutive pages without a paid or delivered order after which the scan stops early. */
74
- const settledPagesBeforeStop = 2;
79
+ export const maximumDevvitOrderScanPages = 32;
75
80
  /**
76
81
  * Serves `commerce.v2.*` from the payments plugin:
77
82
  *
@@ -84,13 +89,10 @@ const settledPagesBeforeStop = 2;
84
89
  * `purchased` (the order id as `transactionId` and `token`,
85
90
  * `devvit.order.v1` evidence whose `status` says which), bounded by
86
91
  * `maximumDevvitAvailableOrders`. Reddit marks an order `DELIVERED` as soon
87
- * as the fulfillment handler answered, so the delivered orders are the ones
88
- * whose ledger entry exists; the webview provider lists only the `PAID`
89
- * ones as available and reports both as events, so a session that lost its
90
- * checkout result still finds the order. The scan reads pages of 64
91
- * (newest first, as the Devvit docs describe) and stops after two
92
- * consecutive pages without a paid or delivered order or after
93
- * `maximumOrderPages`, warning when pages are left.
92
+ * as the fulfillment handler answered. The scan reads pages of 64 until the
93
+ * cursor ends, or until `maximumDevvitOrderScanPages`. A scan that still has
94
+ * a next page at that cap fails with `DEVVIT_ORDER_SCAN_INCOMPLETE` rather
95
+ * than returning a partial list: an order that was not read is not delivered.
94
96
  * - `requestPurchase` is refused with `DEVVIT_COMMERCE_CHECKOUT_CLIENT_ONLY`,
95
97
  * `finishTransaction` with `COMMERCE_V2_METHOD_UNSUPPORTED`, and
96
98
  * `restorePurchases` answers `{ restored: false }`: the webview provider
@@ -113,19 +115,14 @@ export function createDevvitCommerceV2BridgeHandler(input) {
113
115
  const listPaidOrders = async () => {
114
116
  const orders = [];
115
117
  let cursor;
116
- let settledPages = 0;
117
- for (let page = 0; page < maximumOrderPages; page += 1) {
118
+ for (let page = 0; page < maximumDevvitOrderScanPages; page += 1) {
118
119
  const response = await input.payments.getOrders({
119
120
  limit: orderPageSize,
120
121
  ...(cursor === undefined ? {} : { cursor }),
121
122
  });
122
123
  const next = response.pageInfo;
123
124
  const hasNextPage = next !== undefined && next.hasNextPage && next.endCursor.length > 0;
124
- let listableOnPage = 0;
125
125
  for (const [index, order] of response.orders.entries()) {
126
- if (paidOrderStatuses.has(order.status) || deliveredOrderStatuses.has(order.status)) {
127
- listableOnPage += 1;
128
- }
129
126
  const paid = readListedOrder(order, productsBySku);
130
127
  if (paid !== undefined) {
131
128
  orders.push(paid);
@@ -138,21 +135,15 @@ export function createDevvitCommerceV2BridgeHandler(input) {
138
135
  }
139
136
  }
140
137
  if (!hasNextPage || next === undefined) {
141
- break;
138
+ return orders;
142
139
  }
143
- settledPages = listableOnPage === 0 ? settledPages + 1 : 0;
144
- if (settledPages >= settledPagesBeforeStop) {
145
- // Newest first, as documented: the older pages left hold nothing to list.
146
- warn(`Devvit order scan stopped after ${String(settledPagesBeforeStop)} pages without a `
147
- + 'paid or delivered order; older pages were not read.');
148
- break;
149
- }
150
- if (page + 1 === maximumOrderPages) {
151
- warn('Devvit order scan reached its page cap; older pages were not read.');
140
+ if (page + 1 === maximumDevvitOrderScanPages) {
141
+ // A partial list would look complete. Unseen orders are not delivered.
142
+ throw incompleteOrderScan();
152
143
  }
153
144
  cursor = next.endCursor;
154
145
  }
155
- return orders;
146
+ throw incompleteOrderScan();
156
147
  };
157
148
  const serve = async (request) => {
158
149
  const { payload } = request;
@@ -343,6 +334,14 @@ function normalizeBridgeProducts(input) {
343
334
  return { productId: product.productId, sku: product.sku };
344
335
  });
345
336
  }
337
+ function incompleteOrderScan() {
338
+ return new PlatformOperationError({
339
+ code: devvitOrderScanIncompleteBridgeErrorCode,
340
+ message: `Devvit order scan is incomplete: it reached ${String(maximumDevvitOrderScanPages)} `
341
+ + 'pages with pages left. Unseen orders were not read and are not delivered.',
342
+ retryable: true,
343
+ });
344
+ }
346
345
  async function paymentsCall(call) {
347
346
  try {
348
347
  return await call();
@@ -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;
@@ -4,6 +4,8 @@ const defaultSnapshotLimit = 10;
4
4
  const defaultTransactionAttempts = 3;
5
5
  const maximumTransactionAttempts = 32;
6
6
  const keyPrefixPattern = /^[A-Za-z0-9:_-]{1,128}$/;
7
+ /** The commit marker's value; its SET replies `OK`, which `@devvit/redis` always maps. */
8
+ const commitMarkerValue = '1';
7
9
  /**
8
10
  * Durable Devvit Redis provider for modest verified leaderboards. Writes are
9
11
  * fenced with WATCH/MULTI/EXEC and keep the original idempotency response.
@@ -66,13 +68,22 @@ export function createDevvitRedisVerifiedLeaderboardService(redis, options = {})
66
68
  await transaction.hSet(keys.attempts, {
67
69
  [attemptMember]: serializeAttemptDecision(request.attempt, response),
68
70
  });
71
+ // `@devvit/redis` `TxClient.exec()` resolves an aborted WATCH as `[]`
72
+ // and drops zero or nil replies. The marker's `OK` always maps, so a
73
+ // committed EXEC answers at least that reply even when the array is
74
+ // shorter than the queued commands. Only an empty, `null`, or all-nil
75
+ // result is an abort. Requiring `results.length === queued` would
76
+ // retry a landed score whose zero replies Devvit dropped.
77
+ await transaction.set(keys.commitMarker, commitMarkerValue);
69
78
  const results = await transaction.exec();
70
- if (results === null || (Array.isArray(results) && results.length === 0)) {
71
- continue;
72
- }
73
- if (!Array.isArray(results)) {
79
+ if (results !== null && !Array.isArray(results)) {
74
80
  throw new Error('Devvit Redis transaction returned an unsupported response.');
75
81
  }
82
+ if (results === null
83
+ || results.length === 0
84
+ || results.every((reply) => reply === null || reply === undefined)) {
85
+ continue;
86
+ }
76
87
  return response;
77
88
  }
78
89
  catch (error) {
@@ -136,6 +147,7 @@ async function createBoardKeys(keyPrefix, leaderboardId) {
136
147
  attempts: `${base}:attempts`,
137
148
  entries: `${base}:entries`,
138
149
  ranking: `${base}:ranking`,
150
+ commitMarker: `${keyPrefix}:commit`,
139
151
  };
140
152
  return {
141
153
  ...keys,
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.4",
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.3",
59
+ "@mpgd/platform": "0.27.1",
60
+ "@mpgd/game-services": "0.27.1"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/node": "^24.0.0",