@mpgd/game-services 0.30.0 → 0.30.3
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/commerce-reconciler.d.ts +10 -3
- package/dist/commerce-reconciler.js +125 -15
- package/package.json +4 -4
|
@@ -20,7 +20,8 @@ export declare const commercePurchaseOperationKind: 'commerce-purchase';
|
|
|
20
20
|
export type CommercePurchaseOrigin = 'game' | 'discovered';
|
|
21
21
|
/**
|
|
22
22
|
* One commerce v2 purchase in the `MonetizationOperationStore`. `key` is the
|
|
23
|
-
* store's global key (`createCommercePurchaseOperationKey
|
|
23
|
+
* store's global key (`createCommercePurchaseOperationKey`, player-scoped for
|
|
24
|
+
* a durable restored while another player's record holds the shared key); `idempotencyKey`
|
|
24
25
|
* is the purchase key the backend sees: the game's key for a purchase the
|
|
25
26
|
* game started, or the derived key of a purchase discovered from the store.
|
|
26
27
|
* Every state change is its own revision; `request` is saved before the
|
|
@@ -55,8 +56,14 @@ export interface CommercePurchaseOperationRecord {
|
|
|
55
56
|
readonly error?: CommerceErrorInfo;
|
|
56
57
|
readonly revokedBy?: string;
|
|
57
58
|
}
|
|
58
|
-
/**
|
|
59
|
-
|
|
59
|
+
/**
|
|
60
|
+
* The store key of a commerce v2 purchase record: one namespace per ledger
|
|
61
|
+
* target, shared by every player of the install. `playerId` names the
|
|
62
|
+
* player-scoped key the reconciler uses only when another player's record
|
|
63
|
+
* already holds the shared key of a durable this player restores (a logout,
|
|
64
|
+
* then the next guest's Restore Purchases on the same install).
|
|
65
|
+
*/
|
|
66
|
+
export declare function createCommercePurchaseOperationKey(target: string, idempotencyKey: string, playerId?: string): string;
|
|
60
67
|
export declare function isCommercePurchaseOperationRecord(record: unknown): record is CommercePurchaseOperationRecord;
|
|
61
68
|
/** Summary status of a record: the RFC mapping, with `revoked` kept distinct. */
|
|
62
69
|
export declare function summarizeCommercePurchaseOperation(record: CommercePurchaseOperationRecord): CommercePurchaseSummaryStatus;
|
|
@@ -13,12 +13,24 @@ import { chunkLedgerEntryIds } from './commerce-ledger.js';
|
|
|
13
13
|
*/
|
|
14
14
|
/** Journal record kind of a commerce v2 purchase. */
|
|
15
15
|
export const commercePurchaseOperationKind = 'commerce-purchase';
|
|
16
|
-
/**
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* The store key of a commerce v2 purchase record: one namespace per ledger
|
|
18
|
+
* target, shared by every player of the install. `playerId` names the
|
|
19
|
+
* player-scoped key the reconciler uses only when another player's record
|
|
20
|
+
* already holds the shared key of a durable this player restores (a logout,
|
|
21
|
+
* then the next guest's Restore Purchases on the same install).
|
|
22
|
+
*/
|
|
23
|
+
export function createCommercePurchaseOperationKey(target, idempotencyKey, playerId) {
|
|
18
24
|
if (typeof idempotencyKey !== 'string' || idempotencyKey.trim() === '') {
|
|
19
25
|
throw new Error('Commerce purchase operations require a nonempty idempotency key.');
|
|
20
26
|
}
|
|
21
|
-
|
|
27
|
+
if (playerId === undefined) {
|
|
28
|
+
return JSON.stringify([target, commercePurchaseOperationKind, idempotencyKey]);
|
|
29
|
+
}
|
|
30
|
+
if (typeof playerId !== 'string' || playerId.trim() === '') {
|
|
31
|
+
throw new Error('A player-scoped commerce purchase key requires a nonempty player id.');
|
|
32
|
+
}
|
|
33
|
+
return JSON.stringify([target, commercePurchaseOperationKind, idempotencyKey, playerId]);
|
|
22
34
|
}
|
|
23
35
|
export function isCommercePurchaseOperationRecord(record) {
|
|
24
36
|
return typeof record === 'object'
|
|
@@ -105,6 +117,21 @@ export const defaultInFlightTimeoutMs = 10 * 60 * 1000;
|
|
|
105
117
|
function isOpen(state) {
|
|
106
118
|
return state === 'launched' || state === 'pending';
|
|
107
119
|
}
|
|
120
|
+
/** The backend's answer for a restore past the restore sharing holder limit (RFC "Restore sharing"). */
|
|
121
|
+
const purchaseHolderLimitReason = 'PURCHASE_HOLDER_LIMIT_REACHED';
|
|
122
|
+
/**
|
|
123
|
+
* A backend-pending record that only the player's Restore Purchases resends:
|
|
124
|
+
* a restore the backend answered `pending` at the holder limit. Nothing a
|
|
125
|
+
* pass can observe frees a holder, so an automatic resend would repeat the
|
|
126
|
+
* same answer on every launch, resume, and event; an explicit restore may
|
|
127
|
+
* find a holder freed (a deleted player, a reversed grant).
|
|
128
|
+
*/
|
|
129
|
+
function waitsForRestoreTrigger(record) {
|
|
130
|
+
return record.state === 'pending'
|
|
131
|
+
&& record.response !== undefined
|
|
132
|
+
&& !record.response.verified
|
|
133
|
+
&& record.response.reason === purchaseHolderLimitReason;
|
|
134
|
+
}
|
|
108
135
|
/** A backend answer that the capabilities read does not exist: HTTP 404 or oRPC NOT_FOUND. */
|
|
109
136
|
function isNotFoundError(error) {
|
|
110
137
|
if (typeof error !== 'object' || error === null) {
|
|
@@ -235,6 +262,15 @@ export function createCommerceReconciler(input) {
|
|
|
235
262
|
&& record.target === target
|
|
236
263
|
&& record.deploymentTarget === deploymentTarget
|
|
237
264
|
&& record.store === storeId;
|
|
265
|
+
/**
|
|
266
|
+
* Another player's record of this store under a shared key: on one install
|
|
267
|
+
* the derived key of a store purchase is the same for every player, so the
|
|
268
|
+
* player who journaled it first holds that key.
|
|
269
|
+
*/
|
|
270
|
+
const heldByAnotherPlayer = (record) => isCommercePurchaseOperationRecord(record)
|
|
271
|
+
&& record.playerId !== playerId
|
|
272
|
+
&& record.target === target
|
|
273
|
+
&& record.store === storeId;
|
|
238
274
|
const remember = (record) => {
|
|
239
275
|
records.set(record.idempotencyKey, record);
|
|
240
276
|
return record;
|
|
@@ -378,11 +414,15 @@ export function createCommerceReconciler(input) {
|
|
|
378
414
|
emit({ type: 'transition', record: next, previous: record.state });
|
|
379
415
|
return next;
|
|
380
416
|
};
|
|
381
|
-
|
|
417
|
+
/**
|
|
418
|
+
* Reserves the record under `key`: the shared key of its idempotency key
|
|
419
|
+
* unless `resolveSlot` chose this player's player-scoped one.
|
|
420
|
+
*/
|
|
421
|
+
const reserve = async (record, key = createCommercePurchaseOperationKey(target, record.idempotencyKey)) => {
|
|
382
422
|
const candidate = {
|
|
383
423
|
...record,
|
|
384
424
|
kind: commercePurchaseOperationKind,
|
|
385
|
-
key
|
|
425
|
+
key,
|
|
386
426
|
playerId,
|
|
387
427
|
target,
|
|
388
428
|
deploymentTarget,
|
|
@@ -417,8 +457,39 @@ export function createCommerceReconciler(input) {
|
|
|
417
457
|
retryAt.delete(record.idempotencyKey);
|
|
418
458
|
return next;
|
|
419
459
|
};
|
|
420
|
-
|
|
421
|
-
|
|
460
|
+
/**
|
|
461
|
+
* Where this player's record of an idempotency key lives, and what that key
|
|
462
|
+
* holds now. A record is under the shared key, or under this player's
|
|
463
|
+
* player-scoped key, which only the player's Restore Purchases takes, for a
|
|
464
|
+
* durable another player journaled first (`ingestPurchase`). The scoped key
|
|
465
|
+
* is read only where such a record can be, so a journal is never asked for
|
|
466
|
+
* one on a game key or a consumable:
|
|
467
|
+
*
|
|
468
|
+
* - `takeHeld`: when another player's record holds the shared key, the
|
|
469
|
+
* scoped key is the slot. Without it, another player's record is what
|
|
470
|
+
* the slot holds, and `recordIn` refuses it.
|
|
471
|
+
* - `probeFree`: when the shared key is free, a record under the scoped key
|
|
472
|
+
* still answers (retention may have dropped the other player's record).
|
|
473
|
+
*/
|
|
474
|
+
const resolveSlot = async (idempotencyKey, options = {}) => {
|
|
475
|
+
const sharedKey = createCommercePurchaseOperationKey(target, idempotencyKey);
|
|
476
|
+
const shared = await journal.read(sharedKey);
|
|
477
|
+
const heldByAnother = shared !== undefined && heldByAnotherPlayer(shared);
|
|
478
|
+
const probeScoped = heldByAnother
|
|
479
|
+
? options.takeHeld === true
|
|
480
|
+
: shared === undefined && options.probeFree === true;
|
|
481
|
+
if (!probeScoped) {
|
|
482
|
+
return { key: sharedKey, found: shared, heldByAnother };
|
|
483
|
+
}
|
|
484
|
+
const scopedKey = createCommercePurchaseOperationKey(target, idempotencyKey, playerId);
|
|
485
|
+
const scoped = await journal.read(scopedKey);
|
|
486
|
+
return scoped !== undefined || heldByAnother
|
|
487
|
+
? { key: scopedKey, found: scoped, heldByAnother }
|
|
488
|
+
: { key: sharedKey, found: undefined, heldByAnother };
|
|
489
|
+
};
|
|
490
|
+
/** This player's record in a slot; another player's or another store's is refused. */
|
|
491
|
+
const recordIn = (slot, idempotencyKey) => {
|
|
492
|
+
const found = slot.found;
|
|
422
493
|
if (found === undefined) {
|
|
423
494
|
return records.get(idempotencyKey);
|
|
424
495
|
}
|
|
@@ -427,6 +498,13 @@ export function createCommerceReconciler(input) {
|
|
|
427
498
|
}
|
|
428
499
|
return remember(found);
|
|
429
500
|
};
|
|
501
|
+
/**
|
|
502
|
+
* This player's record of a purchase idempotency key, under the shared key
|
|
503
|
+
* or its player-scoped key. A key another player's record holds is refused,
|
|
504
|
+
* so another player's consumable, subscription, or game-started attempt is
|
|
505
|
+
* never matched, sent, or replayed for this player.
|
|
506
|
+
*/
|
|
507
|
+
const readRecord = async (idempotencyKey) => recordIn(await resolveSlot(idempotencyKey), idempotencyKey);
|
|
430
508
|
const ownedRecordsOf = (listed) => listed.filter((record) => isCommercePurchaseOperationRecord(record) && ownsRecord(record));
|
|
431
509
|
const listRecoverable = async () => ownedRecordsOf(await journal.listRecoverable(playerId))
|
|
432
510
|
.filter((record) => !isTerminalCommercePurchaseState(record.state))
|
|
@@ -532,7 +610,7 @@ export function createCommerceReconciler(input) {
|
|
|
532
610
|
};
|
|
533
611
|
};
|
|
534
612
|
// Reconciler step 2: match a reported purchase to a journal record.
|
|
535
|
-
const ingestPurchase = async (purchase) => {
|
|
613
|
+
const ingestPurchase = async (purchase, restoring) => {
|
|
536
614
|
if (purchase.store !== storeId) {
|
|
537
615
|
fail('match', new Error(`Purchase ${purchase.transactionId} belongs to store ${purchase.store}, not ${storeId}.`));
|
|
538
616
|
return;
|
|
@@ -583,7 +661,21 @@ export function createCommerceReconciler(input) {
|
|
|
583
661
|
transactionId: purchase.transactionId,
|
|
584
662
|
token: purchase.token,
|
|
585
663
|
});
|
|
586
|
-
|
|
664
|
+
// The derived key is the same for every player of the install. On the
|
|
665
|
+
// player's Restore Purchases pass, a durable another player journaled
|
|
666
|
+
// first (a logout, then this player's restore) is this player's own
|
|
667
|
+
// restore, journaled under its player-scoped key; the backend keys the
|
|
668
|
+
// grant by player and key. Automatic passes, and any other kind held by
|
|
669
|
+
// another player, stay refused: a purchase a normal read lists for the
|
|
670
|
+
// earlier player (Play lists an unacknowledged one whoever is signed in)
|
|
671
|
+
// is never shared without the player asking, and one player's
|
|
672
|
+
// consumable is never sent for another.
|
|
673
|
+
const durable = product.kind === 'durable';
|
|
674
|
+
const slot = await resolveSlot(idempotencyKey, {
|
|
675
|
+
takeHeld: restoring && durable,
|
|
676
|
+
probeFree: durable,
|
|
677
|
+
});
|
|
678
|
+
record = recordIn(slot, idempotencyKey);
|
|
587
679
|
if (record !== undefined
|
|
588
680
|
&& (record.logicalId !== product.logicalId
|
|
589
681
|
|| (record.purchase !== undefined && !samePurchase(record.purchase, purchase)))) {
|
|
@@ -601,7 +693,7 @@ export function createCommerceReconciler(input) {
|
|
|
601
693
|
origin: 'discovered',
|
|
602
694
|
state: purchase.state,
|
|
603
695
|
purchase,
|
|
604
|
-
});
|
|
696
|
+
}, slot.key);
|
|
605
697
|
return;
|
|
606
698
|
}
|
|
607
699
|
}
|
|
@@ -897,7 +989,7 @@ export function createCommerceReconciler(input) {
|
|
|
897
989
|
defer(event);
|
|
898
990
|
continue;
|
|
899
991
|
}
|
|
900
|
-
await ingestPurchase(event.purchase);
|
|
992
|
+
await ingestPurchase(event.purchase, options.restoreTrigger === true);
|
|
901
993
|
}
|
|
902
994
|
else if (event.type === 'purchaseError') {
|
|
903
995
|
if (event.logicalId !== undefined && busy.has(event.logicalId)) {
|
|
@@ -911,8 +1003,14 @@ export function createCommerceReconciler(input) {
|
|
|
911
1003
|
fail('journal', error);
|
|
912
1004
|
}
|
|
913
1005
|
}
|
|
914
|
-
for (const
|
|
915
|
-
|
|
1006
|
+
for (const listed of await listRecoverable()) {
|
|
1007
|
+
const record = records.get(listed.idempotencyKey) ?? listed;
|
|
1008
|
+
if (options.restoreTrigger !== true && waitsForRestoreTrigger(record)) {
|
|
1009
|
+
// At the holder limit only the player's Restore Purchases asks again;
|
|
1010
|
+
// other passes skip the record before driving it.
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
await drive(record, capabilitiesNow, options.explicit);
|
|
916
1014
|
}
|
|
917
1015
|
await revokeReversed();
|
|
918
1016
|
};
|
|
@@ -1336,7 +1434,12 @@ export function createCommerceReconciler(input) {
|
|
|
1336
1434
|
initialScanPending = false;
|
|
1337
1435
|
}
|
|
1338
1436
|
}
|
|
1339
|
-
await pass({
|
|
1437
|
+
await pass({
|
|
1438
|
+
explicit: true,
|
|
1439
|
+
refreshCapabilities: false,
|
|
1440
|
+
restore: !restoreRead,
|
|
1441
|
+
restoreTrigger: true,
|
|
1442
|
+
});
|
|
1340
1443
|
});
|
|
1341
1444
|
await runUntilIdle();
|
|
1342
1445
|
},
|
|
@@ -1344,7 +1447,14 @@ export function createCommerceReconciler(input) {
|
|
|
1344
1447
|
async fetchProducts(request = {}) {
|
|
1345
1448
|
return provider.fetchProducts(request);
|
|
1346
1449
|
},
|
|
1347
|
-
|
|
1450
|
+
// A restored durable under this player's player-scoped key answers; any
|
|
1451
|
+
// other key another player's record holds is refused as before.
|
|
1452
|
+
read: async (idempotencyKey) => {
|
|
1453
|
+
const slot = await resolveSlot(idempotencyKey, { takeHeld: true });
|
|
1454
|
+
return slot.heldByAnother && slot.found === undefined
|
|
1455
|
+
? readRecord(idempotencyKey)
|
|
1456
|
+
: recordIn(slot, idempotencyKey);
|
|
1457
|
+
},
|
|
1348
1458
|
listRecoverable,
|
|
1349
1459
|
subscribe(listenerToAdd) {
|
|
1350
1460
|
listeners.add(listenerToAdd);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/game-services",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.3",
|
|
4
4
|
"description": "Client, contract, server, store, and test helpers for authoritative mpgd game services.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -231,9 +231,9 @@
|
|
|
231
231
|
"@orpc/client": "2.0.0-beta.14",
|
|
232
232
|
"@orpc/contract": "2.0.0-beta.14",
|
|
233
233
|
"@orpc/server": "2.0.0-beta.14",
|
|
234
|
-
"@mpgd/
|
|
235
|
-
"@mpgd/
|
|
236
|
-
"@mpgd/
|
|
234
|
+
"@mpgd/analytics": "0.5.5",
|
|
235
|
+
"@mpgd/catalog": "0.11.3",
|
|
236
|
+
"@mpgd/platform": "0.32.0"
|
|
237
237
|
},
|
|
238
238
|
"peerDependencies": {
|
|
239
239
|
"@apple/app-store-server-library": "^3.1.0"
|