@mpgd/adapter-devvit 0.13.3 → 0.13.5

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.
@@ -1,6 +1,6 @@
1
- import { assertBridgeNotificationsV2Result, } from '@mpgd/bridge/notifications';
1
+ import { assertBridgeNotificationsV2Result, readBridgeNotificationsHostCode, } from '@mpgd/bridge/notifications';
2
2
  import { isNotificationErrorCode, isValidNotificationTimeZone, NotificationError, notificationsProtocol, toNotificationSubscriptionAdapter, } from '@mpgd/platform/notifications';
3
- import { devvitHostCodeCharacters, devvitNotificationProviderId, } from './notifications-contract.js';
3
+ import { devvitNotificationProviderId } from './notifications-contract.js';
4
4
  /**
5
5
  * The Devvit profile's provider: reads, the opt-in request, the opt-out
6
6
  * (`revokePermission`, because the game renders the consent), and the
@@ -108,7 +108,6 @@ function defaultLocale() {
108
108
  const navigatorLike = globalThis.navigator;
109
109
  return typeof navigatorLike?.language === 'string' ? navigatorLike.language : undefined;
110
110
  }
111
- const hostCodePattern = new RegExp(`\\(host code (${devvitHostCodeCharacters})\\)$`, 'u');
112
111
  function toNotificationError(error) {
113
112
  if (error instanceof NotificationError) {
114
113
  return error;
@@ -119,7 +118,7 @@ function toNotificationError(error) {
119
118
  const message = typeof failure.message === 'string' ? failure.message : undefined;
120
119
  const explicit = typeof failure.retryable === 'boolean' ? { retryable: failure.retryable } : {};
121
120
  if (isNotificationErrorCode(failure.code)) {
122
- const hostCode = message === undefined ? undefined : hostCodePattern.exec(message)?.[1];
121
+ const hostCode = message === undefined ? undefined : readBridgeNotificationsHostCode(message);
123
122
  return new NotificationError(failure.code, {
124
123
  ...(message === undefined ? {} : { message }),
125
124
  ...explicit,
@@ -46,7 +46,9 @@ export declare const devvitNotificationsPerAppPerDay = 25000;
46
46
  export declare function devvitNotificationTextLength(text: string): number;
47
47
  /**
48
48
  * The characters of a Devvit host code. The server bridge names a host code
49
- * in an error message as `(host code <code>)`, and the webview reads it back.
49
+ * in an error message as `(host code <code>)`, and the webview reads it back,
50
+ * both through `@mpgd/bridge/notifications` (`createNotificationsV2BridgeHandler`
51
+ * and `readBridgeNotificationsHostCode`), whose pattern this equals.
50
52
  */
51
53
  export declare const devvitHostCodeCharacters = "[A-Za-z0-9_.:-]{1,128}";
52
54
  /** A post (`t3_`) or comment (`t1_`) id, the only links `enqueue` accepts. */
@@ -47,7 +47,9 @@ export function devvitNotificationTextLength(text) {
47
47
  }
48
48
  /**
49
49
  * The characters of a Devvit host code. The server bridge names a host code
50
- * in an error message as `(host code <code>)`, and the webview reads it back.
50
+ * in an error message as `(host code <code>)`, and the webview reads it back,
51
+ * both through `@mpgd/bridge/notifications` (`createNotificationsV2BridgeHandler`
52
+ * and `readBridgeNotificationsHostCode`), whose pattern this equals.
51
53
  */
52
54
  export const devvitHostCodeCharacters = '[A-Za-z0-9_.:-]{1,128}';
53
55
  /** A post (`t3_`) or comment (`t1_`) id, the only links `enqueue` accepts. */
@@ -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();
@@ -1,4 +1,4 @@
1
- import { type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
1
+ import type { BridgeRequest, BridgeResponse } from '@mpgd/bridge';
2
2
  import { type NotificationProvider } from '@mpgd/platform/notifications';
3
3
  /**
4
4
  * The game-rendered consent of the Devvit profile (notifications v2 RFC,
@@ -95,14 +95,17 @@ export interface DevvitNotificationsV2BridgeHandler {
95
95
  }
96
96
  /**
97
97
  * The server bridge side of notifications v2 on Devvit (RFC "Bridge and
98
- * versioning"): each method's payload and answer are checked with the
99
- * `@mpgd/bridge/notifications` validators. A payload the validator rejects
100
- * answers `NOTIFICATIONS_INVALID_INPUT`; a method the profile lacks (the
98
+ * versioning"): `createNotificationsV2BridgeHandler` from
99
+ * `@mpgd/bridge/notifications` over the Devvit server provider. Each method's
100
+ * payload and answer are checked with the `@mpgd/bridge/notifications`
101
+ * validators. A payload the validator rejects answers
102
+ * `NOTIFICATIONS_INVALID_INPUT`; a method the profile lacks (the
101
103
  * local-notification methods) `NOTIFICATIONS_UNSUPPORTED`; `openSettings`
102
104
  * answers `unavailable`, because Devvit has no settings link; an answer the
103
105
  * contract rejects `NOTIFICATIONS_CONFIGURATION_REQUIRED`. A thrown
104
106
  * `NotificationError` crosses as `{ code, message, retryable }` with its host
105
- * code named in the message. Devvit has no server-to-webview event channel,
106
- * so the bridge carries no notifications events.
107
+ * code named in the message, and an unexpected failure's text stays on the
108
+ * server. Devvit has no server-to-webview event channel, so the bridge
109
+ * carries no notifications events.
107
110
  */
108
111
  export declare function createDevvitNotificationsV2BridgeHandler(input: CreateDevvitNotificationsV2BridgeHandlerInput): DevvitNotificationsV2BridgeHandler;
@@ -1,8 +1,6 @@
1
- import { createBridgeError, isBridgeNotificationsV2Method, } from '@mpgd/bridge';
2
- import { assertBridgeNotificationsV2Payload, assertBridgeNotificationsV2Result, } from '@mpgd/bridge/notifications';
3
- import { PlatformOperationError } from '@mpgd/platform';
4
- import { isNotificationErrorCode, NotificationError, notificationsProtocolVersion, } from '@mpgd/platform/notifications';
5
- import { createDevvitNotificationCapabilities, devvitHostCodeCharacters, devvitNotificationProviderId, devvitUserIdPattern, } from '../notifications-contract.js';
1
+ import { createNotificationsV2BridgeHandler } from '@mpgd/bridge/notifications';
2
+ import { NotificationError, } from '@mpgd/platform/notifications';
3
+ import { createDevvitNotificationCapabilities, devvitNotificationProviderId, devvitUserIdPattern, } from '../notifications-contract.js';
6
4
  /** Host codes of the failures this provider normalizes. */
7
5
  export const devvitNotificationHostCodes = Object.freeze({
8
6
  optInRefused: 'DEVVIT_OPT_IN_REFUSED',
@@ -159,119 +157,31 @@ export function createDevvitServerNotificationProvider(input) {
159
157
  }
160
158
  /**
161
159
  * The server bridge side of notifications v2 on Devvit (RFC "Bridge and
162
- * versioning"): each method's payload and answer are checked with the
163
- * `@mpgd/bridge/notifications` validators. A payload the validator rejects
164
- * answers `NOTIFICATIONS_INVALID_INPUT`; a method the profile lacks (the
160
+ * versioning"): `createNotificationsV2BridgeHandler` from
161
+ * `@mpgd/bridge/notifications` over the Devvit server provider. Each method's
162
+ * payload and answer are checked with the `@mpgd/bridge/notifications`
163
+ * validators. A payload the validator rejects answers
164
+ * `NOTIFICATIONS_INVALID_INPUT`; a method the profile lacks (the
165
165
  * local-notification methods) `NOTIFICATIONS_UNSUPPORTED`; `openSettings`
166
166
  * answers `unavailable`, because Devvit has no settings link; an answer the
167
167
  * contract rejects `NOTIFICATIONS_CONFIGURATION_REQUIRED`. A thrown
168
168
  * `NotificationError` crosses as `{ code, message, retryable }` with its host
169
- * code named in the message. Devvit has no server-to-webview event channel,
170
- * so the bridge carries no notifications events.
169
+ * code named in the message, and an unexpected failure's text stays on the
170
+ * server. Devvit has no server-to-webview event channel, so the bridge
171
+ * carries no notifications events.
171
172
  */
172
173
  export function createDevvitNotificationsV2BridgeHandler(input) {
173
- const provider = createDevvitServerNotificationProvider(input);
174
- const onError = input.onError ?? ((error, context) => {
175
- if (error instanceof NotificationError && error.code === 'NOTIFICATIONS_UNSUPPORTED') {
176
- return;
177
- }
178
- console.warn(`devvit notifications ${context.method} failed`, error);
179
- });
180
- const serve = async (method, payload) => {
181
- switch (method) {
182
- case 'notifications.v2.getCapabilities':
183
- validPayload(method, payload);
184
- return {
185
- protocolVersion: notificationsProtocolVersion,
186
- provider: provider.provider,
187
- capabilities: await provider.getCapabilities(),
188
- };
189
- case 'notifications.v2.getPermission':
190
- return provider.getPermission(validPayload(method, payload));
191
- case 'notifications.v2.requestPermission':
192
- return provider.requestPermission(validPayload(method, payload));
193
- case 'notifications.v2.revokePermission':
194
- return provider.revokePermission
195
- .call(provider, validPayload(method, payload));
196
- case 'notifications.v2.register':
197
- validPayload(method, payload);
198
- return { registration: (await provider.register?.()) ?? null };
199
- case 'notifications.v2.openSettings':
200
- validPayload(method, payload);
201
- return { status: 'unavailable' };
202
- case 'notifications.v2.scheduleLocal':
203
- case 'notifications.v2.cancelLocal':
204
- case 'notifications.v2.listLocal':
205
- validPayload(method, payload);
206
- throw new NotificationError('NOTIFICATIONS_UNSUPPORTED', {
207
- message: `Devvit has no local notifications (${method}).`,
208
- });
209
- }
210
- };
211
- return {
212
- async request(request) {
213
- if (!isBridgeNotificationsV2Method(request.method)) {
214
- return createBridgeError(request.id, 'UNSUPPORTED_METHOD', `${request.method} is not a notifications v2 method.`);
215
- }
216
- const method = request.method;
217
- try {
218
- const data = await serve(method, request.payload);
219
- return { id: request.id, ok: true, data: validResult(method, data) };
220
- }
221
- catch (error) {
222
- if (!(error instanceof InvalidPayloadError)) {
223
- try {
224
- const logged = onError(error, { method });
225
- if (logged instanceof Promise) {
226
- logged.catch(() => { });
227
- }
228
- }
229
- catch {
230
- // A failing log never changes the answer.
231
- }
232
- }
233
- return toBridgeError(request, error);
174
+ const handler = createNotificationsV2BridgeHandler({
175
+ provider: createDevvitServerNotificationProvider(input),
176
+ label: 'Devvit',
177
+ // An unexpected failure's text stays on the server.
178
+ unexpectedFailureMessage: (method) => `${method} failed on the Devvit server.`,
179
+ onError: input.onError ?? ((error, context) => {
180
+ if (error instanceof NotificationError && error.code === 'NOTIFICATIONS_UNSUPPORTED') {
181
+ return;
234
182
  }
235
- },
236
- };
237
- }
238
- class InvalidPayloadError extends Error {
239
- name = 'InvalidPayloadError';
240
- }
241
- function validPayload(method, payload) {
242
- try {
243
- return assertBridgeNotificationsV2Payload(method, payload);
244
- }
245
- catch (error) {
246
- throw new InvalidPayloadError(`${method} payload is invalid: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
247
- }
248
- }
249
- function validResult(method, data) {
250
- try {
251
- return assertBridgeNotificationsV2Result(method, data);
252
- }
253
- catch (error) {
254
- throw new NotificationError('NOTIFICATIONS_CONFIGURATION_REQUIRED', {
255
- message: `The Devvit notifications provider answered ${method} with invalid data: ${error instanceof Error ? error.message : String(error)}`,
256
- cause: error,
257
- });
258
- }
259
- }
260
- const hostCodeValue = new RegExp(`^${devvitHostCodeCharacters}$`, 'u');
261
- function toBridgeError(request, error) {
262
- if (error instanceof InvalidPayloadError) {
263
- return createBridgeError(request.id, 'NOTIFICATIONS_INVALID_INPUT', error.message);
264
- }
265
- if (error instanceof NotificationError) {
266
- return createBridgeError(request.id, error.code, error.hostCode === undefined || !hostCodeValue.test(error.hostCode)
267
- ? error.message
268
- : `${error.message} (host code ${error.hostCode})`, error.retryable);
269
- }
270
- if (error instanceof PlatformOperationError) {
271
- return isNotificationErrorCode(error.code)
272
- ? createBridgeError(request.id, error.code, error.message, error.retryable)
273
- : createBridgeError(request.id, 'NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', `${request.method} failed: ${error.message}`, error.retryable);
274
- }
275
- // An unexpected failure's text stays on the server.
276
- return createBridgeError(request.id, 'NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', `${request.method} failed on the Devvit server.`, true);
183
+ console.warn(`devvit notifications ${context.method} failed`, error);
184
+ }),
185
+ });
186
+ return { request: (request) => handler.request(request) };
277
187
  }
@@ -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.3",
3
+ "version": "0.13.5",
4
4
  "description": "Reddit Devvit Web platform adapter for mpgd games.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -54,10 +54,10 @@
54
54
  "dist"
55
55
  ],
56
56
  "dependencies": {
57
- "@devvit/web": "0.14.1",
58
- "@mpgd/bridge": "0.14.2",
59
- "@mpgd/platform": "0.27.0",
60
- "@mpgd/game-services": "0.27.0"
57
+ "@devvit/web": "0.15.0",
58
+ "@mpgd/bridge": "0.15.0",
59
+ "@mpgd/game-services": "0.27.2",
60
+ "@mpgd/platform": "0.27.2"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/node": "^24.0.0",