@mpgd/game-services 0.30.2 → 0.30.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,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`); `idempotencyKey`
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
- /** The store key of a commerce v2 purchase record: one namespace per ledger target. */
59
- export declare function createCommercePurchaseOperationKey(target: string, idempotencyKey: string): string;
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
- /** The store key of a commerce v2 purchase record: one namespace per ledger target. */
17
- export function createCommercePurchaseOperationKey(target, idempotencyKey) {
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
- return JSON.stringify([target, commercePurchaseOperationKind, idempotencyKey]);
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
- const reserve = async (record) => {
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: createCommercePurchaseOperationKey(target, record.idempotencyKey),
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
- const readRecord = async (idempotencyKey) => {
421
- const found = await journal.read(createCommercePurchaseOperationKey(target, idempotencyKey));
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
- record = await readRecord(idempotencyKey);
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 record of await listRecoverable()) {
915
- await drive(records.get(record.idempotencyKey) ?? record, capabilitiesNow, options.explicit);
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({ explicit: true, refreshCapabilities: false, restore: !restoreRead });
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
- read: readRecord,
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.2",
3
+ "version": "0.30.4",
4
4
  "description": "Client, contract, server, store, and test helpers for authoritative mpgd game services.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -233,7 +233,7 @@
233
233
  "@orpc/server": "2.0.0-beta.14",
234
234
  "@mpgd/analytics": "0.5.5",
235
235
  "@mpgd/platform": "0.32.0",
236
- "@mpgd/catalog": "0.11.3"
236
+ "@mpgd/catalog": "0.12.0"
237
237
  },
238
238
  "peerDependencies": {
239
239
  "@apple/app-store-server-library": "^3.1.0"