@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.
- package/dist/notifications-client.js +3 -4
- package/dist/notifications-contract.d.ts +3 -1
- package/dist/notifications-contract.js +3 -1
- package/dist/server/commerce-bridge.d.ts +21 -7
- package/dist/server/commerce-bridge.js +33 -34
- package/dist/server/notifications-bridge.d.ts +9 -6
- package/dist/server/notifications-bridge.js +23 -113
- package/dist/server/redis-verified-leaderboard.js +16 -4
- package/package.json +5 -5
|
@@ -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 {
|
|
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 :
|
|
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
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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.
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
|
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
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
|
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
|
-
|
|
138
|
+
return orders;
|
|
142
139
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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 {
|
|
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"):
|
|
99
|
-
* `@mpgd/bridge/notifications`
|
|
100
|
-
*
|
|
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
|
|
106
|
-
*
|
|
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 {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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"):
|
|
163
|
-
* `@mpgd/bridge/notifications`
|
|
164
|
-
*
|
|
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
|
|
170
|
-
*
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
"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.
|
|
58
|
-
"@mpgd/bridge": "0.
|
|
59
|
-
"@mpgd/
|
|
60
|
-
"@mpgd/
|
|
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",
|