@mpgd/adapter-devvit 0.13.3 → 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.
@@ -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();
@@ -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.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.2",
59
- "@mpgd/platform": "0.27.0",
60
- "@mpgd/game-services": "0.27.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",