@mpgd/adapter-devvit 0.7.0 → 0.8.1

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/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
2
2
  import { type BridgeRpcClient, type BridgeRpcEndpoint } from '@mpgd/bridge/orpc';
3
3
  import type { PlatformGateway } from '@mpgd/platform';
4
+ export * from './payments.js';
4
5
  export declare const defaultDevvitRpcEndpoint = "/api/mpgd/rpc";
5
6
  type BridgeErrorResponse = Extract<BridgeResponse, {
6
7
  readonly ok: false;
@@ -28,4 +29,3 @@ export declare function createDevvitOrpcBridge(input?: {
28
29
  readonly client?: BridgeRpcClient | undefined;
29
30
  }): DevvitBridge;
30
31
  export declare function createDevvitSandboxBridge(): DevvitBridge;
31
- export {};
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createBridgeError, } from '@mpgd/bridge';
2
2
  import { createBridgeOrpcClient, defaultBridgeRpcEndpoint, } from '@mpgd/bridge/orpc';
3
+ export * from './payments.js';
3
4
  export const defaultDevvitRpcEndpoint = defaultBridgeRpcEndpoint;
4
5
  const devvitFallbackStoragePrefix = 'mpgd:devvit:fallback:';
5
6
  export class DevvitBridgeError extends Error {
@@ -233,7 +234,7 @@ export function createDevvitSandboxBridge() {
233
234
  nativeAds: false,
234
235
  rewardedAds: false,
235
236
  interstitialAds: false,
236
- nativeLeaderboard: true,
237
+ nativeLeaderboard: false,
237
238
  achievements: false,
238
239
  cloudSave: true,
239
240
  socialShare: false,
@@ -299,7 +300,7 @@ export function createDevvitSandboxBridge() {
299
300
  });
300
301
  case 'leaderboard.submitScore':
301
302
  return ok(input, {
302
- submitted: true,
303
+ submitted: false,
303
304
  });
304
305
  case 'storage.load': {
305
306
  const payload = optionalObjectPayload(input.payload);
@@ -0,0 +1,21 @@
1
+ import type { CommerceAdapter, Entitlement, PlatformEvidenceEnvelope, PlatformGateway, ProductInfo, PurchaseResult } from '@mpgd/platform';
2
+ export interface DevvitCheckoutResult {
3
+ readonly status: PurchaseResult['status'];
4
+ readonly orderId?: string;
5
+ readonly evidence?: PlatformEvidenceEnvelope;
6
+ }
7
+ export interface DevvitPaymentsClient {
8
+ purchase(sku: string): Promise<DevvitCheckoutResult>;
9
+ getEntitlements(): Promise<readonly Entitlement[]>;
10
+ }
11
+ export interface DevvitCommerceProduct {
12
+ readonly info: ProductInfo;
13
+ readonly sku: string;
14
+ }
15
+ export interface CreateDevvitCommerceAdapterInput {
16
+ readonly products: readonly DevvitCommerceProduct[];
17
+ readonly client: DevvitPaymentsClient;
18
+ readonly onCheckoutError?: (error: unknown) => void;
19
+ }
20
+ export declare function createDevvitCommerceAdapter(input: CreateDevvitCommerceAdapterInput): CommerceAdapter;
21
+ export declare function withDevvitCommerceAdapter(gateway: PlatformGateway, commerce: CommerceAdapter): PlatformGateway;
@@ -0,0 +1,82 @@
1
+ export function createDevvitCommerceAdapter(input) {
2
+ const products = normalizeProducts(input.products);
3
+ const productsById = new Map(products.map((product) => [product.info.id, product]));
4
+ return {
5
+ async getProducts() {
6
+ return products.map((product) => product.info);
7
+ },
8
+ async purchase(request) {
9
+ const product = productsById.get(request.productId);
10
+ if (product === undefined) {
11
+ return failedPurchase();
12
+ }
13
+ try {
14
+ // Reddit's platform order ID is authoritative. Do not attach client operation
15
+ // metadata whose identifiers can exceed Devvit's metadata constraints.
16
+ const result = await input.client.purchase(product.sku);
17
+ return {
18
+ status: result.status,
19
+ ...(result.orderId === undefined ? {} : { transactionId: result.orderId }),
20
+ // Devvit server fulfillment and a subsequent entitlement read are authoritative.
21
+ entitlementIds: [],
22
+ ...(result.evidence === undefined ? {} : { evidence: result.evidence }),
23
+ };
24
+ }
25
+ catch (error) {
26
+ input.onCheckoutError?.(error);
27
+ return failedPurchase();
28
+ }
29
+ },
30
+ async restore() {
31
+ return { restoredEntitlements: await input.client.getEntitlements() };
32
+ },
33
+ async getEntitlements() {
34
+ return input.client.getEntitlements();
35
+ },
36
+ };
37
+ }
38
+ export function withDevvitCommerceAdapter(gateway, commerce) {
39
+ if (gateway.target !== 'reddit') {
40
+ throw new TypeError('Devvit commerce can only be installed on a reddit gateway.');
41
+ }
42
+ if ('getTargetRuntime' in gateway && typeof gateway.getTargetRuntime === 'function') {
43
+ throw new TypeError('Devvit commerce must be installed before target availability is applied.');
44
+ }
45
+ return {
46
+ ...gateway,
47
+ async getCapabilities() {
48
+ return {
49
+ ...await gateway.getCapabilities(),
50
+ nativeIap: true,
51
+ };
52
+ },
53
+ commerce,
54
+ };
55
+ }
56
+ function normalizeProducts(input) {
57
+ const productIds = new Set();
58
+ const skus = new Set();
59
+ return Object.freeze(input.map((product) => {
60
+ if (productIds.has(product.info.id)) {
61
+ throw new TypeError(`Duplicate Devvit logical product ID: ${product.info.id}`);
62
+ }
63
+ if (skus.has(product.sku)) {
64
+ throw new TypeError(`Duplicate Devvit product SKU: ${product.sku}`);
65
+ }
66
+ if (product.sku.length === 0 || product.sku.trim() !== product.sku) {
67
+ throw new TypeError('Devvit product SKU must be a non-empty identifier.');
68
+ }
69
+ productIds.add(product.info.id);
70
+ skus.add(product.sku);
71
+ return Object.freeze({
72
+ info: Object.freeze({ ...product.info }),
73
+ sku: product.sku,
74
+ });
75
+ }));
76
+ }
77
+ function failedPurchase() {
78
+ return Object.freeze({
79
+ status: 'failed',
80
+ entitlementIds: [],
81
+ });
82
+ }
@@ -1,3 +1,4 @@
1
+ export * from './payment-order.js';
1
2
  export * from './post-operation.js';
2
3
  export * from './redis-post-operation-store.js';
3
4
  export * from './redis-verified-leaderboard.js';
@@ -1,3 +1,4 @@
1
+ export * from './payment-order.js';
1
2
  export * from './post-operation.js';
2
3
  export * from './redis-post-operation-store.js';
3
4
  export * from './redis-verified-leaderboard.js';
@@ -0,0 +1,8 @@
1
+ import { type FulfillPlatformOrderInput, type RefundPlatformOrderInput } from '@mpgd/game-services/platform-order';
2
+ export interface NormalizeDevvitPaymentOrderInput {
3
+ readonly order: unknown;
4
+ /** Authenticated player ID supplied by the Devvit server request context. */
5
+ readonly playerId: string;
6
+ }
7
+ export declare function normalizeDevvitFulfillmentOrder(input: NormalizeDevvitPaymentOrderInput): FulfillPlatformOrderInput;
8
+ export declare function normalizeDevvitRefundOrder(input: NormalizeDevvitPaymentOrderInput): RefundPlatformOrderInput;
@@ -0,0 +1,80 @@
1
+ import { assertFulfillPlatformOrderInput, assertRefundPlatformOrderInput, } from '@mpgd/game-services/platform-order';
2
+ const devvitPaymentOrderEvidenceSchema = 'devvit.payment-order.v1';
3
+ export function normalizeDevvitFulfillmentOrder(input) {
4
+ const order = normalizeDevvitOrder(input, 'PAID');
5
+ const normalized = Object.freeze({
6
+ target: 'reddit',
7
+ orderId: order.orderId,
8
+ playerId: order.playerId,
9
+ platformSku: order.platformSku,
10
+ paidAt: order.occurredAt,
11
+ evidence: order.evidence,
12
+ });
13
+ return assertFulfillPlatformOrderInput(normalized);
14
+ }
15
+ export function normalizeDevvitRefundOrder(input) {
16
+ const order = normalizeDevvitOrder(input, 'REVERTED');
17
+ const normalized = Object.freeze({
18
+ target: 'reddit',
19
+ orderId: order.orderId,
20
+ playerId: order.playerId,
21
+ platformSku: order.platformSku,
22
+ refundedAt: order.occurredAt,
23
+ evidence: order.evidence,
24
+ });
25
+ return assertRefundPlatformOrderInput(normalized);
26
+ }
27
+ function normalizeDevvitOrder(input, expectedStatus) {
28
+ const playerId = requireString(input.playerId, 'Devvit payment context playerId');
29
+ const record = requireRecord(input.order, 'Devvit payment order');
30
+ if (record.status !== expectedStatus) {
31
+ throw new TypeError(`Devvit payment order status must be ${expectedStatus}.`);
32
+ }
33
+ if (!Array.isArray(record.products) || record.products.length !== 1) {
34
+ throw new TypeError('Devvit payment order must contain exactly one product.');
35
+ }
36
+ const product = requireRecord(record.products[0], 'Devvit payment product');
37
+ const orderId = requireString(record.id, 'order.id');
38
+ if (record.userId !== undefined
39
+ && requireString(record.userId, 'order.userId') !== playerId) {
40
+ throw new TypeError('Devvit payment order userId must match the authenticated context playerId.');
41
+ }
42
+ const platformSku = requireString(product.sku, 'order.products[0].sku');
43
+ const occurredAt = requireString(record.updatedAt ?? record.createdAt, 'order.updatedAt');
44
+ const evidencePayload = {
45
+ orderId,
46
+ status: expectedStatus,
47
+ occurredAt,
48
+ playerIdSource: 'devvit-context',
49
+ };
50
+ copyOptionalEvidenceField(record, evidencePayload, 'postId');
51
+ copyOptionalEvidenceField(record, evidencePayload, 'environment');
52
+ return Object.freeze({
53
+ orderId,
54
+ playerId,
55
+ platformSku,
56
+ occurredAt,
57
+ evidence: Object.freeze({
58
+ schema: devvitPaymentOrderEvidenceSchema,
59
+ payload: Object.freeze(evidencePayload),
60
+ }),
61
+ });
62
+ }
63
+ function copyOptionalEvidenceField(source, target, key) {
64
+ const value = source[key];
65
+ if (value !== undefined) {
66
+ target[key] = requireString(value, `order.${key}`);
67
+ }
68
+ }
69
+ function requireString(input, label) {
70
+ if (typeof input !== 'string') {
71
+ throw new TypeError(`${label} must be a string.`);
72
+ }
73
+ return input;
74
+ }
75
+ function requireRecord(input, label) {
76
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
77
+ throw new TypeError(`${label} must be an object.`);
78
+ }
79
+ return input;
80
+ }
@@ -1,4 +1,5 @@
1
1
  declare const operationSchemaVersion: 1;
2
+ export declare const devvitPostOperationMaximumPendingPageLimit = 100;
2
3
  export type DevvitJsonValue = null | boolean | number | string | readonly DevvitJsonValue[] | DevvitJsonObject;
3
4
  export interface DevvitJsonObject {
4
5
  readonly [key: string]: DevvitJsonValue;
@@ -15,6 +16,24 @@ export interface DevvitDurableOperationStore {
15
16
  /** Deletes a lease only when the current token matches. */
16
17
  releaseLease(key: string, token: string): Promise<void>;
17
18
  }
19
+ export interface DevvitDurableOperationIndexEntry {
20
+ readonly indexKey: string;
21
+ readonly member: string;
22
+ }
23
+ /**
24
+ * Optional durable-store capability used to discover pending operations without
25
+ * scanning the Redis keyspace. Implementations must establish stable registry
26
+ * membership before creating durable state and retain it for the record's
27
+ * lifetime. A failed state creation can leave a safe stale member, but a live
28
+ * operation must never be absent from its registry.
29
+ */
30
+ export interface DevvitIndexedDurableOperationStore extends DevvitDurableOperationStore {
31
+ /** Idempotently establishes stable registry membership for an existing record. */
32
+ ensureIndexed(index: DevvitDurableOperationIndexEntry): Promise<void>;
33
+ createIndexed(key: string, value: string, index: DevvitDurableOperationIndexEntry): Promise<boolean>;
34
+ compareAndSetIndexed(key: string, expectedValue: string, nextValue: string, index: DevvitDurableOperationIndexEntry): Promise<boolean>;
35
+ listIndex(key: string, startExclusive: string | undefined, limit: number): Promise<readonly string[]>;
36
+ }
18
37
  export interface DevvitPostOperationScope {
19
38
  readonly appScope: string;
20
39
  /** Use the canonical subreddit identifier, not a display name. */
@@ -106,10 +125,41 @@ export interface ExecuteDevvitPostOperationInput<TPayload extends DevvitJsonObje
106
125
  export interface ReconcileDevvitPostOperationInput<TPayload extends DevvitJsonObject, TLaunchParams extends DevvitJsonObject> extends DevvitPostOperationDescriptorInput<TPayload, TLaunchParams> {
107
126
  readonly findCandidates: (input: DevvitPostPublishInput<TPayload, TLaunchParams>) => Promise<readonly DevvitPostReconciliationCandidate[]>;
108
127
  }
128
+ export interface ListPendingDevvitPostOperationsInput {
129
+ readonly scope: DevvitPostOperationScope;
130
+ /** Opaque continuation returned by the previous page for the same scope. */
131
+ readonly cursor?: string;
132
+ readonly limit?: number;
133
+ }
134
+ export type DevvitPendingPostOperation<TPayload extends DevvitJsonObject, TLaunchParams extends DevvitJsonObject> = {
135
+ readonly status: 'prepared';
136
+ readonly operationId: string;
137
+ readonly updatedAt: number;
138
+ readonly postData: DevvitCanonicalPostData<TPayload, TLaunchParams>;
139
+ } | {
140
+ readonly status: 'reconciliation-required';
141
+ readonly operationId: string;
142
+ readonly reason: 'submission-attempted';
143
+ readonly updatedAt: number;
144
+ readonly postData: DevvitCanonicalPostData<TPayload, TLaunchParams>;
145
+ } | {
146
+ readonly status: 'terminal-unresolved';
147
+ readonly operationId: string;
148
+ readonly reason: 'multiple-exact-matches' | 'published-receipt-conflict';
149
+ readonly postIds: readonly string[];
150
+ readonly updatedAt: number;
151
+ readonly postData: DevvitCanonicalPostData<TPayload, TLaunchParams>;
152
+ };
153
+ export interface DevvitPendingPostOperationPage<TPayload extends DevvitJsonObject, TLaunchParams extends DevvitJsonObject> {
154
+ readonly operations: readonly DevvitPendingPostOperation<TPayload, TLaunchParams>[];
155
+ readonly nextCursor?: string;
156
+ }
109
157
  export interface DevvitPostOperationCoordinator<TPayload extends DevvitJsonObject, TLaunchParams extends DevvitJsonObject> {
110
158
  execute(input: ExecuteDevvitPostOperationInput<TPayload, TLaunchParams>): Promise<DevvitPostOperationResult<TPayload, TLaunchParams>>;
111
159
  read(input: DevvitPostOperationDescriptorInput<TPayload, TLaunchParams>): Promise<DevvitPostOperationResult<TPayload, TLaunchParams>>;
112
160
  reconcile(input: ReconcileDevvitPostOperationInput<TPayload, TLaunchParams>): Promise<DevvitPostOperationResult<TPayload, TLaunchParams>>;
161
+ /** Lists durable work only; it never invokes a Reddit API or reconciliation callback. */
162
+ listPending(input: ListPendingDevvitPostOperationsInput): Promise<DevvitPendingPostOperationPage<TPayload, TLaunchParams>>;
113
163
  }
114
164
  export interface CreateDevvitPostOperationCoordinatorOptions<TPayload extends DevvitJsonObject, TLaunchParams extends DevvitJsonObject> {
115
165
  readonly definition: DevvitPostOperationDefinition<TPayload, TLaunchParams>;
@@ -3,6 +3,8 @@ const defaultLeaseTtlMs = 30000;
3
3
  const defaultMaxPostDataBytes = 2048;
4
4
  const defaultStoreAttempts = 8;
5
5
  const maximumStoreAttempts = 32;
6
+ const defaultPendingPageLimit = 20;
7
+ export const devvitPostOperationMaximumPendingPageLimit = 100;
6
8
  const maximumIdentifierLength = 128;
7
9
  const maximumOperationTypeLength = 64;
8
10
  const maximumLeaseTtlMs = 60 * 60 * 1000;
@@ -39,6 +41,7 @@ export function createDevvitPostOperationCoordinator(input) {
39
41
  const createToken = input.createToken ?? defaultTokenFactory;
40
42
  const leaseTtlMs = input.leaseTtlMs ?? defaultLeaseTtlMs;
41
43
  const storeAttempts = input.storeAttempts ?? defaultStoreAttempts;
44
+ const indexedStore = readIndexedStore(input.store);
42
45
  assertPositiveSafeInteger(leaseTtlMs, 'leaseTtlMs');
43
46
  if (leaseTtlMs > maximumLeaseTtlMs) {
44
47
  throw new DevvitPostOperationValidationError(`leaseTtlMs must not exceed ${String(maximumLeaseTtlMs)}.`);
@@ -51,6 +54,7 @@ export function createDevvitPostOperationCoordinator(input) {
51
54
  execute,
52
55
  read,
53
56
  reconcile,
57
+ listPending,
54
58
  };
55
59
  async function execute(request) {
56
60
  if (typeof request.publish !== 'function') {
@@ -63,7 +67,7 @@ export function createDevvitPostOperationCoordinator(input) {
63
67
  operationId: request.operationId,
64
68
  });
65
69
  for (let attempt = 0; attempt < storeAttempts; attempt += 1) {
66
- let current = await readOperation(operationKey, descriptor, definition, input.store);
70
+ let current = await readStoredOperation(operationKey, descriptor);
67
71
  if (current.kind === 'missing') {
68
72
  const timestamp = readTimestamp(now);
69
73
  const prepared = {
@@ -76,7 +80,7 @@ export function createDevvitPostOperationCoordinator(input) {
76
80
  updatedAt: timestamp,
77
81
  };
78
82
  const preparedRaw = serializeRecord(prepared);
79
- if (!await input.store.create(operationKey, preparedRaw)) {
83
+ if (!await createStoredRecord(operationKey, preparedRaw, prepared)) {
80
84
  continue;
81
85
  }
82
86
  current = { kind: 'record', raw: preparedRaw, record: prepared };
@@ -96,7 +100,7 @@ export function createDevvitPostOperationCoordinator(input) {
96
100
  updatedAt: timestamp,
97
101
  };
98
102
  const attemptedRaw = serializeRecord(attempted);
99
- if (!await input.store.compareAndSet(operationKey, current.raw, attemptedRaw)) {
103
+ if (!await compareAndSetStoredRecord(operationKey, current.raw, attemptedRaw, current.record, attempted)) {
100
104
  continue;
101
105
  }
102
106
  return submitOnce(attempted, attemptedRaw, request.publish);
@@ -118,7 +122,7 @@ export function createDevvitPostOperationCoordinator(input) {
118
122
  operationType: definition.operationType,
119
123
  operationId: request.operationId,
120
124
  });
121
- const current = await readOperation(operationKey, descriptor, definition, input.store);
125
+ const current = await readStoredOperation(operationKey, descriptor);
122
126
  if (current.kind === 'missing') {
123
127
  return missingResult(descriptor);
124
128
  }
@@ -146,7 +150,7 @@ export function createDevvitPostOperationCoordinator(input) {
146
150
  operationType: definition.operationType,
147
151
  operationId: request.operationId,
148
152
  });
149
- const current = await readOperation(operationKey, descriptor, definition, input.store);
153
+ const current = await readStoredOperation(operationKey, descriptor);
150
154
  if (current.kind === 'missing') {
151
155
  return missingResult(descriptor);
152
156
  }
@@ -214,6 +218,56 @@ export function createDevvitPostOperationCoordinator(input) {
214
218
  }
215
219
  }
216
220
  }
221
+ async function listPending(request) {
222
+ if (indexedStore === undefined) {
223
+ throw new DevvitPostOperationStateError('The durable post operation store does not support operation registries.');
224
+ }
225
+ const scope = normalizeScope(request.scope);
226
+ const limit = normalizePendingPageLimit(request.limit);
227
+ const indexKey = createDevvitPostOperationIndexKey({
228
+ scope,
229
+ operationType: definition.operationType,
230
+ });
231
+ const cursor = normalizePendingCursor(request.cursor, scope, definition.operationType);
232
+ const members = await indexedStore.listIndex(indexKey, cursor, limit + 1);
233
+ const pageMembers = members.slice(0, limit);
234
+ const indexedOperations = await Promise.all(pageMembers.map(async (member) => {
235
+ const indexed = parsePendingIndexMember(member, scope, definition.operationType);
236
+ const raw = await indexedStore.read(indexed.operationKey);
237
+ if (raw === undefined) {
238
+ // Index-first creation can leave a conservative stale member if the
239
+ // following state write fails. It is safe to skip and advance the cursor.
240
+ return undefined;
241
+ }
242
+ const record = parseStoredRecord(raw, indexed.operationKey, definition);
243
+ const expectedOperationKey = createDevvitPostOperationKey({
244
+ scope: record.descriptor.mpgd,
245
+ operationType: record.descriptor.mpgd.operationType,
246
+ operationId: record.descriptor.mpgd.operationId,
247
+ });
248
+ const recordIndexKey = createDevvitPostOperationIndexKey({
249
+ scope: record.descriptor.mpgd,
250
+ operationType: record.descriptor.mpgd.operationType,
251
+ });
252
+ if (expectedOperationKey !== indexed.operationKey || recordIndexKey !== indexKey) {
253
+ throw new DevvitPostOperationStateError(`Pending operation index scope is inconsistent for state: ${indexed.operationKey}`);
254
+ }
255
+ const expectedMember = operationIndexMember(record);
256
+ if (record.phase === 'published') {
257
+ return undefined;
258
+ }
259
+ if (expectedMember !== member) {
260
+ throw new DevvitPostOperationStateError(`Pending operation index is inconsistent for state: ${indexed.operationKey}`);
261
+ }
262
+ return pendingOperationResult(record);
263
+ }));
264
+ const operations = indexedOperations.filter((operation) => operation !== undefined);
265
+ const nextCursor = members.length > limit ? pageMembers.at(-1) : undefined;
266
+ return Object.freeze({
267
+ operations: Object.freeze(operations),
268
+ ...(nextCursor === undefined ? {} : { nextCursor }),
269
+ });
270
+ }
217
271
  async function submitOnce(record, raw, publish) {
218
272
  let receipt;
219
273
  try {
@@ -251,10 +305,10 @@ export function createDevvitPostOperationCoordinator(input) {
251
305
  publishedAt: timestamp,
252
306
  recovered,
253
307
  };
254
- if (await input.store.compareAndSet(source.operationKey, expectedRaw, serializeRecord(published))) {
308
+ if (await compareAndSetStoredRecord(source.operationKey, expectedRaw, serializeRecord(published), expectedRecord, published)) {
255
309
  return publishedResult(published, successStatus);
256
310
  }
257
- const reread = await readOperation(source.operationKey, source.descriptor, definition, input.store);
311
+ const reread = await readStoredOperation(source.operationKey, source.descriptor);
258
312
  if (reread.kind === 'missing' || reread.kind === 'conflict') {
259
313
  throw new DevvitPostOperationStateError(`Durable post operation changed incompatibly while recording post ${postId}.`);
260
314
  }
@@ -302,10 +356,10 @@ export function createDevvitPostOperationCoordinator(input) {
302
356
  postIds: expectedPostIds,
303
357
  unresolvedAt: timestamp,
304
358
  };
305
- if (await input.store.compareAndSet(source.operationKey, expectedRaw, serializeRecord(terminal))) {
359
+ if (await compareAndSetStoredRecord(source.operationKey, expectedRaw, serializeRecord(terminal), expectedRecord, terminal)) {
306
360
  return terminalResult(terminal);
307
361
  }
308
- const current = await readOperation(source.operationKey, source.descriptor, definition, input.store);
362
+ const current = await readStoredOperation(source.operationKey, source.descriptor);
309
363
  if (current.kind !== 'record') {
310
364
  throw new DevvitPostOperationStateError('Durable post operation changed incompatibly while recording a terminal outcome.');
311
365
  }
@@ -324,6 +378,32 @@ export function createDevvitPostOperationCoordinator(input) {
324
378
  }
325
379
  return busyResult(source.descriptor, 'store-contention');
326
380
  }
381
+ function createStoredRecord(operationKey, raw, record) {
382
+ if (indexedStore === undefined) {
383
+ return input.store.create(operationKey, raw);
384
+ }
385
+ return indexedStore.createIndexed(operationKey, raw, createOperationIndexEntry(undefined, record));
386
+ }
387
+ async function readStoredOperation(operationKey, descriptor) {
388
+ const current = await readOperation(operationKey, descriptor, definition, input.store);
389
+ if (indexedStore !== undefined && current.kind !== 'missing') {
390
+ try {
391
+ await indexedStore.ensureIndexed(createOperationIndexEntry(undefined, current.record));
392
+ }
393
+ catch {
394
+ // A best-effort migration write must not hide an already-readable exact
395
+ // result. Indexed creation and every state CAS still require registry
396
+ // membership before mutating durable state.
397
+ }
398
+ }
399
+ return current;
400
+ }
401
+ function compareAndSetStoredRecord(operationKey, expectedRaw, nextRaw, previous, next) {
402
+ if (indexedStore === undefined) {
403
+ return input.store.compareAndSet(operationKey, expectedRaw, nextRaw);
404
+ }
405
+ return indexedStore.compareAndSetIndexed(operationKey, expectedRaw, nextRaw, createOperationIndexEntry(previous, next));
406
+ }
327
407
  }
328
408
  export function createDevvitPostOperationKey(input) {
329
409
  const appScope = assertIdentifier(input.scope.appScope, 'appScope');
@@ -339,6 +419,128 @@ export function createDevvitPostOperationKey(input) {
339
419
  'state',
340
420
  ].join(':');
341
421
  }
422
+ function createDevvitPostOperationIndexKey(input) {
423
+ const scope = normalizeScope(input.scope);
424
+ assertOperationType(input.operationType);
425
+ return [
426
+ 'mpgd:devvit:post-operation-index:v1',
427
+ encodeKeyComponent(scope.appScope),
428
+ encodeKeyComponent(scope.subredditId),
429
+ encodeKeyComponent(input.operationType),
430
+ 'operations',
431
+ ].join(':');
432
+ }
433
+ function readIndexedStore(store) {
434
+ const candidate = store;
435
+ const capabilities = [
436
+ candidate.createIndexed,
437
+ candidate.compareAndSetIndexed,
438
+ candidate.listIndex,
439
+ candidate.ensureIndexed,
440
+ ];
441
+ const supportedCapabilities = capabilities.filter((capability) => typeof capability === 'function').length;
442
+ if (supportedCapabilities === 0) {
443
+ return undefined;
444
+ }
445
+ if (supportedCapabilities !== capabilities.length) {
446
+ throw new DevvitPostOperationValidationError('Indexed durable operation stores must implement createIndexed, '
447
+ + 'compareAndSetIndexed, ensureIndexed, and listIndex together.');
448
+ }
449
+ return candidate;
450
+ }
451
+ function createOperationIndexEntry(previous, next) {
452
+ const indexKey = createDevvitPostOperationIndexKey({
453
+ scope: next.descriptor.mpgd,
454
+ operationType: next.descriptor.mpgd.operationType,
455
+ });
456
+ if (previous !== undefined) {
457
+ const previousIndexKey = createDevvitPostOperationIndexKey({
458
+ scope: previous.descriptor.mpgd,
459
+ operationType: previous.descriptor.mpgd.operationType,
460
+ });
461
+ if (previousIndexKey !== indexKey) {
462
+ throw new DevvitPostOperationStateError('A durable post operation cannot move between operation registries.');
463
+ }
464
+ }
465
+ return {
466
+ indexKey,
467
+ member: operationIndexMember(next),
468
+ };
469
+ }
470
+ function operationIndexMember(record) {
471
+ return record.operationKey;
472
+ }
473
+ function parsePendingIndexMember(member, scope, operationType) {
474
+ if (typeof member !== 'string' || member.length > 8192) {
475
+ throw new DevvitPostOperationStateError('Pending operation index member is invalid.');
476
+ }
477
+ const operationKey = member;
478
+ const operationKeyPrefix = [
479
+ 'mpgd:devvit:post-operation:v1',
480
+ encodeKeyComponent(scope.appScope),
481
+ encodeKeyComponent(scope.subredditId),
482
+ encodeKeyComponent(operationType),
483
+ '',
484
+ ].join(':');
485
+ if (!operationKey.startsWith(operationKeyPrefix)
486
+ || !operationKey.endsWith(':state')) {
487
+ throw new DevvitPostOperationStateError('Pending operation index member is invalid.');
488
+ }
489
+ return { operationKey };
490
+ }
491
+ function normalizePendingCursor(cursor, scope, operationType) {
492
+ if (cursor === undefined) {
493
+ return undefined;
494
+ }
495
+ parsePendingIndexMember(cursor, scope, operationType);
496
+ return cursor;
497
+ }
498
+ function normalizePendingPageLimit(limit) {
499
+ const normalized = limit ?? defaultPendingPageLimit;
500
+ if (!Number.isSafeInteger(normalized)
501
+ || normalized < 1
502
+ || normalized > devvitPostOperationMaximumPendingPageLimit) {
503
+ throw new DevvitPostOperationValidationError('limit must be a safe integer from 1 to '
504
+ + `${String(devvitPostOperationMaximumPendingPageLimit)}.`);
505
+ }
506
+ return normalized;
507
+ }
508
+ function normalizeScope(scope) {
509
+ return Object.freeze({
510
+ appScope: assertIdentifier(scope.appScope, 'appScope'),
511
+ subredditId: assertSubredditId(scope.subredditId),
512
+ });
513
+ }
514
+ function pendingOperationResult(record) {
515
+ switch (record.phase) {
516
+ case 'prepared':
517
+ return Object.freeze({
518
+ status: 'prepared',
519
+ operationId: record.descriptor.mpgd.operationId,
520
+ updatedAt: record.updatedAt,
521
+ postData: record.descriptor,
522
+ });
523
+ case 'attempted':
524
+ return Object.freeze({
525
+ status: 'reconciliation-required',
526
+ operationId: record.descriptor.mpgd.operationId,
527
+ reason: 'submission-attempted',
528
+ updatedAt: record.updatedAt,
529
+ postData: record.descriptor,
530
+ });
531
+ case 'terminal-unresolved':
532
+ return Object.freeze({
533
+ status: 'terminal-unresolved',
534
+ operationId: record.descriptor.mpgd.operationId,
535
+ reason: record.reason,
536
+ postIds: record.postIds,
537
+ updatedAt: record.updatedAt,
538
+ postData: record.descriptor,
539
+ });
540
+ case 'published':
541
+ throw new DevvitPostOperationStateError('Published post operation cannot be returned as pending work.');
542
+ }
543
+ }
342
544
  function createDescriptor(definition, input) {
343
545
  const scope = {
344
546
  appScope: assertIdentifier(input.scope.appScope, 'appScope'),
@@ -373,7 +575,7 @@ async function readOperation(operationKey, descriptor, definition, store) {
373
575
  }
374
576
  const record = parseStoredRecord(raw, operationKey, definition);
375
577
  if (canonicalJson(record.descriptor) !== canonicalJson(descriptor)) {
376
- return { kind: 'conflict' };
578
+ return { kind: 'conflict', raw, record };
377
579
  }
378
580
  return { kind: 'record', raw, record };
379
581
  }
@@ -1,9 +1,20 @@
1
- import type { DevvitDurableOperationStore } from './post-operation.js';
1
+ import { type DevvitIndexedDurableOperationStore } from './post-operation.js';
2
2
  export interface DevvitRedisSetOptions {
3
3
  readonly nx?: boolean;
4
4
  readonly xx?: boolean;
5
5
  readonly expiration?: Date;
6
6
  }
7
+ export interface DevvitRedisSortedSetMember {
8
+ readonly member: string;
9
+ readonly score: number;
10
+ }
11
+ export interface DevvitRedisRangeOptions {
12
+ readonly by: 'lex';
13
+ readonly limit: {
14
+ readonly offset: number;
15
+ readonly count: number;
16
+ };
17
+ }
7
18
  export interface DevvitRedisTransactionLike {
8
19
  multi(): Promise<void>;
9
20
  discard(): Promise<unknown>;
@@ -15,9 +26,11 @@ export interface DevvitRedisTransactionLike {
15
26
  export interface DevvitRedisLike {
16
27
  get(key: string): Promise<string | undefined>;
17
28
  set(key: string, value: string, options?: DevvitRedisSetOptions): Promise<string | undefined | null>;
29
+ zAdd(key: string, ...members: readonly DevvitRedisSortedSetMember[]): Promise<unknown>;
30
+ zRange(key: string, start: string, stop: string, options: DevvitRedisRangeOptions): Promise<readonly DevvitRedisSortedSetMember[]>;
18
31
  watch(...keys: readonly string[]): Promise<DevvitRedisTransactionLike>;
19
32
  }
20
33
  export interface DevvitRedisPostOperationStoreOptions {
21
34
  readonly transactionAttempts?: number;
22
35
  }
23
- export declare function createDevvitRedisPostOperationStore(redis: DevvitRedisLike, options?: DevvitRedisPostOperationStoreOptions): DevvitDurableOperationStore;
36
+ export declare function createDevvitRedisPostOperationStore(redis: DevvitRedisLike, options?: DevvitRedisPostOperationStoreOptions): DevvitIndexedDurableOperationStore;
@@ -1,3 +1,4 @@
1
+ import { devvitPostOperationMaximumPendingPageLimit, } from './post-operation.js';
1
2
  const defaultTransactionAttempts = 3;
2
3
  const maximumTransactionAttempts = 32;
3
4
  export function createDevvitRedisPostOperationStore(redis, options = {}) {
@@ -6,9 +7,21 @@ export function createDevvitRedisPostOperationStore(redis, options = {}) {
6
7
  read(key) {
7
8
  return redis.get(key);
8
9
  },
10
+ async ensureIndexed(index) {
11
+ await redis.zAdd(index.indexKey, { member: index.member, score: 0 });
12
+ },
9
13
  async create(key, value) {
10
14
  return setIfAbsent(redis, key, value);
11
15
  },
16
+ async createIndexed(key, value, index) {
17
+ if (index.member !== key) {
18
+ throw new TypeError('Indexed creation member must equal the durable state key.');
19
+ }
20
+ // Membership is intentionally established first. If the following state
21
+ // write fails, listing may see a stale member but cannot miss live work.
22
+ await redis.zAdd(index.indexKey, { member: index.member, score: 0 });
23
+ return setIfAbsent(redis, key, value);
24
+ },
12
25
  async compareAndSet(key, expectedValue, nextValue) {
13
26
  return mutateIfValue({
14
27
  redis,
@@ -18,6 +31,32 @@ export function createDevvitRedisPostOperationStore(redis, options = {}) {
18
31
  queueMutation: (transaction) => transaction.set(key, nextValue),
19
32
  });
20
33
  },
34
+ async compareAndSetIndexed(key, expectedValue, nextValue, index) {
35
+ if (index.member !== key) {
36
+ throw new TypeError('Indexed CAS member must equal the durable state key.');
37
+ }
38
+ // Stable registry membership was established before state creation and
39
+ // does not change across prepared, attempted, published, or terminal state.
40
+ // Re-adding it backfills records created before indexed stores existed.
41
+ await redis.zAdd(index.indexKey, { member: index.member, score: 0 });
42
+ return mutateIfValue({
43
+ redis,
44
+ key,
45
+ expectedValue,
46
+ transactionAttempts,
47
+ queueMutation: (transaction) => transaction.set(key, nextValue),
48
+ });
49
+ },
50
+ async listIndex(key, startExclusive, limit) {
51
+ assertIndexPageLimit(limit);
52
+ const members = await redis.zRange(key, startExclusive === undefined ? '-' : `(${startExclusive}`, '+', { by: 'lex', limit: { offset: 0, count: limit } });
53
+ return members.map((member) => {
54
+ if (member.score !== 0 || typeof member.member !== 'string') {
55
+ throw new Error(`Devvit Redis operation registry is invalid for key: ${key}`);
56
+ }
57
+ return member.member;
58
+ });
59
+ },
21
60
  async createLease(key, token, expiresAt) {
22
61
  assertExpirationDate(expiresAt);
23
62
  return setIfAbsent(redis, key, token, { expiration: expiresAt });
@@ -100,6 +139,12 @@ function normalizeTransactionAttempts(value) {
100
139
  }
101
140
  return transactionAttempts;
102
141
  }
142
+ function assertIndexPageLimit(value) {
143
+ const maximum = devvitPostOperationMaximumPendingPageLimit + 1;
144
+ if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
145
+ throw new TypeError(`Devvit Redis index page limit must be a safe integer from 1 to ${String(maximum)}.`);
146
+ }
147
+ }
103
148
  function assertExpirationDate(value) {
104
149
  if (!(value instanceof Date) || !Number.isFinite(value.getTime())) {
105
150
  throw new TypeError('Devvit Redis lease expiration must be a valid Date.');
@@ -0,0 +1,31 @@
1
+ export type DevvitViewMode = 'inline' | 'expanded';
2
+ export interface DevvitViewModeClient {
3
+ getWebViewMode(): DevvitViewMode;
4
+ }
5
+ export interface DevvitInlineModeContext {
6
+ /**
7
+ * Loads gameplay in the current inline mode. Call this only from an explicit
8
+ * user action so the initial post entry can remain a lightweight launch screen.
9
+ */
10
+ readonly startGameplay: () => Promise<void>;
11
+ }
12
+ export type DevvitViewModeResult = {
13
+ readonly mode: 'inline';
14
+ readonly startGameplay: () => Promise<void>;
15
+ } | {
16
+ readonly mode: 'expanded';
17
+ };
18
+ export interface DevvitViewModeOptions {
19
+ readonly client: DevvitViewModeClient;
20
+ readonly mountInlineMode: (context: DevvitInlineModeContext) => void | Promise<void>;
21
+ readonly loadGameplay: (mode: DevvitViewMode) => void | Promise<void>;
22
+ readonly onModeUnavailable?: (error: unknown) => void;
23
+ }
24
+ /**
25
+ * Starts a Devvit web view using Reddit's official inline and expanded mode
26
+ * terminology. Inline mode mounts lightweight content first and exposes a
27
+ * retryable, concurrency-safe gameplay loader for a later user action.
28
+ * Expanded mode loads gameplay immediately. Outside a Devvit host, expanded
29
+ * mode is used as the local-browser fallback.
30
+ */
31
+ export declare function startDevvitViewMode(options: DevvitViewModeOptions): Promise<DevvitViewModeResult>;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Starts a Devvit web view using Reddit's official inline and expanded mode
3
+ * terminology. Inline mode mounts lightweight content first and exposes a
4
+ * retryable, concurrency-safe gameplay loader for a later user action.
5
+ * Expanded mode loads gameplay immediately. Outside a Devvit host, expanded
6
+ * mode is used as the local-browser fallback.
7
+ */
8
+ export async function startDevvitViewMode(options) {
9
+ let mode;
10
+ try {
11
+ mode = options.client.getWebViewMode();
12
+ }
13
+ catch (error) {
14
+ options.onModeUnavailable?.(error);
15
+ await options.loadGameplay('expanded');
16
+ return { mode: 'expanded' };
17
+ }
18
+ if (mode === 'expanded') {
19
+ await options.loadGameplay('expanded');
20
+ return { mode: 'expanded' };
21
+ }
22
+ const startGameplay = createInlineModeGameplayStarter(options.loadGameplay);
23
+ await options.mountInlineMode({ startGameplay });
24
+ return {
25
+ mode: 'inline',
26
+ startGameplay,
27
+ };
28
+ }
29
+ function createInlineModeGameplayStarter(loadGameplay) {
30
+ let activeLoad;
31
+ return () => {
32
+ if (activeLoad !== undefined) {
33
+ return activeLoad.promise;
34
+ }
35
+ const pendingLoad = Promise.resolve().then(() => loadGameplay('inline'));
36
+ const loadState = { promise: pendingLoad };
37
+ activeLoad = loadState;
38
+ void pendingLoad.catch(() => {
39
+ if (activeLoad === loadState) {
40
+ activeLoad = undefined;
41
+ }
42
+ });
43
+ return pendingLoad;
44
+ };
45
+ }
package/dist/web.d.ts CHANGED
@@ -1,13 +1,21 @@
1
1
  import type { ShareResult } from '@mpgd/platform';
2
2
  import { type DevvitSurfaceOptions, type DevvitSurfaceResult } from './surface.js';
3
+ import { type DevvitViewModeOptions, type DevvitViewModeResult } from './view-mode.js';
3
4
  export type { DevvitSurfaceClient, DevvitSurfaceMode, DevvitSurfaceOptions, DevvitSurfaceResult, } from './surface.js';
5
+ export type { DevvitInlineModeContext, DevvitViewMode, DevvitViewModeClient, DevvitViewModeOptions, DevvitViewModeResult, } from './view-mode.js';
4
6
  export type DevvitWebSurfaceOptions = Omit<DevvitSurfaceOptions, 'client'>;
7
+ export type DevvitWebViewOptions = Omit<DevvitViewModeOptions, 'client'>;
5
8
  export interface DevvitShareSheetOptions {
6
9
  readonly data?: string;
7
10
  readonly title?: string;
8
11
  readonly text?: string;
9
12
  }
10
13
  export declare function startDevvitWebSurface(options: DevvitWebSurfaceOptions): Promise<DevvitSurfaceResult>;
14
+ /**
15
+ * Starts a Devvit web view in inline or expanded mode. Inline mode can defer
16
+ * gameplay until an explicit user action while remaining inside the post.
17
+ */
18
+ export declare function startDevvitWebView(options: DevvitWebViewOptions): Promise<DevvitViewModeResult>;
11
19
  export declare function requestDevvitExpandedMode(event: MouseEvent, entry: string): Promise<void>;
12
20
  /**
13
21
  * Presents Devvit's native share surface without claiming that the user
package/dist/web.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as devvitWebClient from '@devvit/web/client';
2
2
  import { startDevvitSurface, } from './surface.js';
3
+ import { startDevvitViewMode, } from './view-mode.js';
3
4
  // The Devvit package publishes browser implementations behind an export
4
5
  // condition while its default declaration is a server-side panic module.
5
6
  // Keep the narrow browser contract explicit until its default types expose it.
@@ -10,6 +11,16 @@ export function startDevvitWebSurface(options) {
10
11
  client: { getWebViewMode: browserClient.getWebViewMode },
11
12
  });
12
13
  }
14
+ /**
15
+ * Starts a Devvit web view in inline or expanded mode. Inline mode can defer
16
+ * gameplay until an explicit user action while remaining inside the post.
17
+ */
18
+ export function startDevvitWebView(options) {
19
+ return startDevvitViewMode({
20
+ ...options,
21
+ client: { getWebViewMode: browserClient.getWebViewMode },
22
+ });
23
+ }
13
24
  export async function requestDevvitExpandedMode(event, entry) {
14
25
  await browserClient.requestExpandedMode(event, entry);
15
26
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/adapter-devvit",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "Reddit Devvit Web platform adapter for mpgd games.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -29,6 +29,10 @@
29
29
  "types": "./dist/index.d.ts",
30
30
  "default": "./dist/index.js"
31
31
  },
32
+ "./payments": {
33
+ "types": "./dist/payments.d.ts",
34
+ "default": "./dist/payments.js"
35
+ },
32
36
  "./server": {
33
37
  "types": "./dist/server/index.d.ts",
34
38
  "default": "./dist/server/index.js"
@@ -37,6 +41,10 @@
37
41
  "types": "./dist/surface.d.ts",
38
42
  "default": "./dist/surface.js"
39
43
  },
44
+ "./view-mode": {
45
+ "types": "./dist/view-mode.d.ts",
46
+ "default": "./dist/view-mode.js"
47
+ },
40
48
  "./web": {
41
49
  "types": "./dist/web.d.ts",
42
50
  "default": "./dist/web.js"
@@ -47,9 +55,9 @@
47
55
  ],
48
56
  "dependencies": {
49
57
  "@devvit/web": "0.13.8",
58
+ "@mpgd/game-services": "0.8.1",
50
59
  "@mpgd/bridge": "0.6.0",
51
- "@mpgd/game-services": "0.7.0",
52
- "@mpgd/platform": "0.5.1"
60
+ "@mpgd/platform": "0.6.0"
53
61
  },
54
62
  "devDependencies": {
55
63
  "ttsc": "0.18.4",
@@ -66,6 +74,6 @@
66
74
  "lint": "ttsc --noEmit",
67
75
  "format": "ttsc format",
68
76
  "fix": "ttsc fix",
69
- "test": "vitest run && node test/server-dist-import.mjs && node --conditions=browser test/web-dist-import.mjs"
77
+ "test": "vitest run && node test/payments-dist-import.mjs && node test/server-dist-import.mjs && node --conditions=browser test/web-dist-import.mjs"
70
78
  }
71
79
  }