@mpgd/game-services 0.21.0 → 0.22.0

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.
@@ -0,0 +1,1251 @@
1
+ import { assertCommercePurchaseTransition, assertRequestPurchaseSupported, CommerceError, commerceErrorInfoOf, createDerivedPurchaseIdempotencyKey, isTerminalCommercePurchaseState, summarizeCommercePurchaseState, } from '@mpgd/platform/commerce';
2
+ import { compareCommerceServiceCapabilities, } from './commerce-evidence-registry.js';
3
+ import { chunkLedgerEntryIds } from './commerce-ledger.js';
4
+ /**
5
+ * Commerce v2 reconciler (RFC "Purchase lifecycle", "Reconciler", and
6
+ * "Idempotency keys"). One reconciler per `CommerceProvider` replaces the
7
+ * per-store recovery drivers: it journals every purchase the game starts or
8
+ * the store reports as one record moving through the RFC states, sends the
9
+ * saved verify request to the game backend, and finishes a purchase only after
10
+ * the backend's ledger grant. Nothing here grants: only the backend response
11
+ * moves a record to `granted`, and only the backend ledger moves it to
12
+ * `revoked`.
13
+ */
14
+ /** Journal record kind of a commerce v2 purchase. */
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) {
18
+ if (typeof idempotencyKey !== 'string' || idempotencyKey.trim() === '') {
19
+ throw new Error('Commerce purchase operations require a nonempty idempotency key.');
20
+ }
21
+ return JSON.stringify([target, commercePurchaseOperationKind, idempotencyKey]);
22
+ }
23
+ export function isCommercePurchaseOperationRecord(record) {
24
+ return typeof record === 'object'
25
+ && record !== null
26
+ && record.kind === commercePurchaseOperationKind;
27
+ }
28
+ /** Summary status of a record: the RFC mapping, with `revoked` kept distinct. */
29
+ export function summarizeCommercePurchaseOperation(record) {
30
+ return summarizeCommercePurchaseState(record.state);
31
+ }
32
+ /**
33
+ * Only `granted` and `finishing` records that hold the backend's ledger entry
34
+ * id and a store transaction may be finished. The StoreKit v2 SDK keeps no
35
+ * finish allow-list of its own (the v1 driver's `finishGrantedTransaction`
36
+ * pairing), so this is the finish gate for every `client` and `host` owner:
37
+ * the ledger entry id comes from the journal revision the backend grant wrote.
38
+ */
39
+ export function assertCommerceFinishAllowed(record) {
40
+ if (record.state !== 'granted' && record.state !== 'finishing') {
41
+ throw new Error(`Commerce purchase ${record.idempotencyKey} is ${record.state}; only granted or finishing records are finished.`);
42
+ }
43
+ const ledgerEntryId = record.ledgerEntryId;
44
+ const transactionId = record.purchase?.transactionId;
45
+ if (typeof ledgerEntryId !== 'string' || ledgerEntryId.trim() === '') {
46
+ throw new Error(`Commerce purchase ${record.idempotencyKey} has no ledger entry id; the backend grant must finish it.`);
47
+ }
48
+ if (typeof transactionId !== 'string' || transactionId.trim() === '') {
49
+ throw new Error(`Commerce purchase ${record.idempotencyKey} has no store transaction to finish.`);
50
+ }
51
+ return { transactionId, ledgerEntryId };
52
+ }
53
+ /** The `GameServicesPurchaseResult` a journal record stands for; `pending` allows no client-side grant. */
54
+ export function toGameServicesPurchaseResult(record) {
55
+ const status = summarizeCommercePurchaseOperation(record);
56
+ return {
57
+ status,
58
+ purchase: toPurchaseResult(record),
59
+ ...(record.response === undefined ? {} : { verification: record.response }),
60
+ ...(record.ledgerEntryId === undefined ? {} : { ledgerEntryId: record.ledgerEntryId }),
61
+ };
62
+ }
63
+ function toPurchaseResult(record) {
64
+ const purchase = record.purchase;
65
+ if (record.state === 'cancelled' || record.state === 'failed') {
66
+ return {
67
+ status: record.state,
68
+ entitlementIds: [],
69
+ ...(record.error === undefined ? {} : { diagnostic: toDiagnostic(record.error) }),
70
+ };
71
+ }
72
+ if (purchase === undefined) {
73
+ return { status: 'pending', entitlementIds: [] };
74
+ }
75
+ return {
76
+ status: purchase.state === 'purchased' ? 'completed' : 'pending',
77
+ transactionId: purchase.transactionId,
78
+ entitlementIds: [],
79
+ evidence: purchase.evidence,
80
+ };
81
+ }
82
+ function toDiagnostic(error) {
83
+ const providerCode = error.storeCode;
84
+ return {
85
+ code: error.code,
86
+ retryable: error.retryable,
87
+ ...(providerCode !== undefined && /^[A-Za-z0-9_.:-]{1,128}$/u.test(providerCode)
88
+ ? { providerCode }
89
+ : {}),
90
+ };
91
+ }
92
+ export const defaultStaleLaunchedAfterMs = 24 * 60 * 60 * 1000;
93
+ /** How long one backend capabilities read may take before the last comparison is kept. */
94
+ export const defaultServiceCapabilitiesTimeoutMs = 10 * 1000;
95
+ export const defaultRetryIntervalMs = 60 * 1000;
96
+ export const defaultInFlightTimeoutMs = 10 * 60 * 1000;
97
+ function isOpen(state) {
98
+ return state === 'launched' || state === 'pending';
99
+ }
100
+ /** A backend answer that the capabilities read does not exist: HTTP 404 or oRPC NOT_FOUND. */
101
+ function isNotFoundError(error) {
102
+ if (typeof error !== 'object' || error === null) {
103
+ return false;
104
+ }
105
+ const { status, code } = error;
106
+ return status === 404 || code === 'NOT_FOUND';
107
+ }
108
+ export function createCommerceReconciler(input) {
109
+ const { provider, backend, store: journal, playerId } = input;
110
+ const storeId = provider.store;
111
+ const target = input.target;
112
+ const deploymentTarget = input.deploymentTarget ?? input.target;
113
+ const now = input.now ?? (() => new Date().toISOString());
114
+ const staleLaunchedAfterMs = input.staleLaunchedAfterMs ?? defaultStaleLaunchedAfterMs;
115
+ const retryIntervalMs = input.retryIntervalMs ?? defaultRetryIntervalMs;
116
+ const inFlightTimeoutMs = input.inFlightTimeoutMs ?? defaultInFlightTimeoutMs;
117
+ const serviceCapabilitiesTimeoutMs = input.serviceCapabilitiesTimeoutMs
118
+ ?? defaultServiceCapabilitiesTimeoutMs;
119
+ if (!Number.isFinite(serviceCapabilitiesTimeoutMs) || serviceCapabilitiesTimeoutMs <= 0) {
120
+ throw new RangeError('serviceCapabilitiesTimeoutMs must be a positive finite duration.');
121
+ }
122
+ if (typeof playerId !== 'string' || playerId.trim() === '') {
123
+ throw new TypeError('The commerce reconciler requires an authenticated player id.');
124
+ }
125
+ if (!(staleLaunchedAfterMs > 0)) {
126
+ throw new RangeError('staleLaunchedAfterMs must be a positive duration.');
127
+ }
128
+ if (!(retryIntervalMs >= 0)) {
129
+ throw new RangeError('retryIntervalMs must be a non-negative duration.');
130
+ }
131
+ if (!(inFlightTimeoutMs > 0)) {
132
+ throw new RangeError('inFlightTimeoutMs must be a positive duration.');
133
+ }
134
+ const listeners = new Set();
135
+ if (input.onEvent !== undefined) {
136
+ listeners.add(input.onEvent);
137
+ }
138
+ const emit = (event) => {
139
+ for (const listener of [...listeners]) {
140
+ try {
141
+ listener(event);
142
+ }
143
+ catch {
144
+ // Observers have no authority over the journal.
145
+ }
146
+ }
147
+ };
148
+ const fail = (stage, error, idempotencyKey) => {
149
+ emit({
150
+ type: 'failure',
151
+ stage,
152
+ ...(idempotencyKey === undefined ? {} : { idempotencyKey }),
153
+ error,
154
+ });
155
+ };
156
+ /** Records seen this session by purchase idempotency key, terminal ones included for matching. */
157
+ const records = new Map();
158
+ const inbox = [];
159
+ /** The start-time read of the available purchases failed; the next pass retries it. */
160
+ let initialScanPending = false;
161
+ /**
162
+ * Purchase calls whose store call is still running, by key and product; a
163
+ * `requested` record outside this map is stale, and events for these
164
+ * products wait until the call journals the store answer.
165
+ */
166
+ const inFlightKeys = new Map();
167
+ const nowMs = () => Date.parse(now());
168
+ const isInFlight = (idempotencyKey) => {
169
+ const entry = inFlightKeys.get(idempotencyKey);
170
+ return entry !== undefined && nowMs() - entry.since < inFlightTimeoutMs;
171
+ };
172
+ const inFlightProducts = () => new Set([...inFlightKeys.entries()]
173
+ .filter(([key]) => isInFlight(key))
174
+ .map(([, entry]) => entry.logicalId));
175
+ /** Events for an in-flight product, replayed by the pass after the call ends. */
176
+ const deferred = [];
177
+ const defer = (event) => {
178
+ // A polled provider lists the same purchase on every pass; keep one copy.
179
+ if (event.type === 'purchaseUpdated'
180
+ && deferred.some((waiting) => waiting.type === 'purchaseUpdated'
181
+ && waiting.purchase.transactionId === event.purchase.transactionId
182
+ && waiting.purchase.state === event.purchase.state)) {
183
+ return;
184
+ }
185
+ deferred.push(event);
186
+ };
187
+ /** Earliest time (ms) an event-triggered pass may retry a record's backend or store step. */
188
+ const retryAt = new Map();
189
+ const retryLater = (idempotencyKey) => {
190
+ retryAt.set(idempotencyKey, nowMs() + retryIntervalMs);
191
+ };
192
+ /**
193
+ * The same store purchase: the transaction id, or the token when the store
194
+ * renames the transaction (Play reports the token key until it assigns an
195
+ * order id). A store whose ids are unique among unfinished purchases only
196
+ * (Microsoft Store reuses the add-on id) relies on the provider deriving a
197
+ * transaction-unique id; until then a repurchase that the store lists under
198
+ * a finished record's id is journaled only when the game started it.
199
+ */
200
+ const samePurchase = (left, right) => left.transactionId === right.transactionId
201
+ || (left.token !== undefined && left.token === right.token);
202
+ const productCache = new Map();
203
+ let capabilities;
204
+ let serviceAlignment;
205
+ /** A backend that answered 404 / NOT_FOUND for the ledger status read: not asked again. */
206
+ let ledgerStatusUnpublished = false;
207
+ /** Serializes the backend capabilities read so overlapping triggers share one. */
208
+ let serviceAlignmentRead;
209
+ let unsubscribe;
210
+ let started = false;
211
+ /** The start in progress, so overlapping calls share one subscription. */
212
+ let starting;
213
+ let triggerPending = false;
214
+ let running;
215
+ let pendingFailure;
216
+ let tail = Promise.resolve();
217
+ /** Passes and purchase requests never interleave their journal writes. */
218
+ function withLock(job) {
219
+ const next = tail.then(job, job);
220
+ tail = next.catch(() => undefined);
221
+ return next;
222
+ }
223
+ const trigger = () => {
224
+ triggerPending = true;
225
+ };
226
+ const ownsRecord = (record) => record.playerId === playerId
227
+ && record.target === target
228
+ && record.deploymentTarget === deploymentTarget
229
+ && record.store === storeId;
230
+ const remember = (record) => {
231
+ records.set(record.idempotencyKey, record);
232
+ return record;
233
+ };
234
+ /**
235
+ * The provider's capability block. Event-triggered passes use the last read;
236
+ * an explicit trigger (`start`, `reconcile`, `restorePurchases`) reads it
237
+ * again, since availability can change at runtime. A failed read keeps the
238
+ * last block.
239
+ */
240
+ const withServiceAlignment = (block) => block === undefined || serviceAlignment?.status !== 'configuration-required'
241
+ ? block
242
+ : { ...block, availability: 'configuration-required' };
243
+ const readCapabilities = async (refresh = false) => {
244
+ if (capabilities !== undefined && !refresh) {
245
+ return withServiceAlignment(capabilities);
246
+ }
247
+ try {
248
+ capabilities = await provider.getCapabilities();
249
+ }
250
+ catch (error) {
251
+ fail('capabilities', error);
252
+ }
253
+ return withServiceAlignment(capabilities);
254
+ };
255
+ /**
256
+ * Compares the provider with the backend's published capabilities when the
257
+ * backend offers them: a store the backend does not accept, or a finish
258
+ * owner that disagrees, sets availability to `configuration-required`
259
+ * before any purchase UI opens. A failed read keeps the last comparison.
260
+ */
261
+ const readServiceAlignment = async () => {
262
+ const readService = backend.getCommerceCapabilities;
263
+ if (readService === undefined
264
+ || capabilities === undefined
265
+ || serviceAlignment?.status === 'unpublished') {
266
+ return;
267
+ }
268
+ if (serviceAlignmentRead !== undefined) {
269
+ return serviceAlignmentRead;
270
+ }
271
+ const providerBlock = capabilities;
272
+ serviceAlignmentRead = (async () => {
273
+ let timeout;
274
+ try {
275
+ const service = await Promise.race([
276
+ readService.call(backend),
277
+ new Promise((_resolve, reject) => {
278
+ timeout = setTimeout(() => {
279
+ reject(new Error('The backend commerce capabilities read timed out.'));
280
+ }, serviceCapabilitiesTimeoutMs);
281
+ }),
282
+ ]);
283
+ serviceAlignment = compareCommerceServiceCapabilities({
284
+ store: storeId,
285
+ provider: providerBlock,
286
+ service,
287
+ });
288
+ if (serviceAlignment.status === 'configuration-required') {
289
+ fail('capabilities', new CommerceError('CONFIGURATION_REQUIRED', {
290
+ message: serviceAlignment.message,
291
+ storeCode: serviceAlignment.reason,
292
+ }));
293
+ }
294
+ }
295
+ catch (error) {
296
+ if (isNotFoundError(error)) {
297
+ // A v1 backend behind a v2 client adapter: it publishes nothing, and
298
+ // that is remembered rather than read again on every refresh.
299
+ serviceAlignment = { status: 'unpublished', store: storeId };
300
+ return;
301
+ }
302
+ // A failed or timed-out read keeps the last comparison.
303
+ fail('capabilities', error);
304
+ }
305
+ finally {
306
+ if (timeout !== undefined) {
307
+ clearTimeout(timeout);
308
+ }
309
+ serviceAlignmentRead = undefined;
310
+ }
311
+ })();
312
+ return serviceAlignmentRead;
313
+ };
314
+ const resolveProduct = async (lookup) => {
315
+ const matches = (product) => 'logicalId' in lookup
316
+ ? product.logicalId === lookup.logicalId
317
+ : product.storeSku === lookup.storeSku;
318
+ const configured = input.products?.find(matches);
319
+ if (configured !== undefined) {
320
+ return configured;
321
+ }
322
+ const cached = [...productCache.values()].find(matches);
323
+ if (cached !== undefined) {
324
+ return cached;
325
+ }
326
+ try {
327
+ const products = await provider.fetchProducts('logicalId' in lookup
328
+ ? { logicalIds: [lookup.logicalId] }
329
+ : { type: 'all' });
330
+ for (const product of products) {
331
+ productCache.set(product.logicalId, {
332
+ logicalId: product.logicalId,
333
+ kind: product.kind,
334
+ storeSku: product.storeSku,
335
+ });
336
+ }
337
+ return [...productCache.values()].find(matches);
338
+ }
339
+ catch (error) {
340
+ fail('catalog', error);
341
+ return undefined;
342
+ }
343
+ };
344
+ const resolveLogicalId = async (purchase) => {
345
+ const product = purchase.logicalId === undefined
346
+ ? await resolveProduct({ storeSku: purchase.storeSku })
347
+ : await resolveProduct({ logicalId: purchase.logicalId });
348
+ if (product === undefined) {
349
+ if (purchase.logicalId !== undefined) {
350
+ // The provider mapped the SKU itself. The kind stays unknown until the
351
+ // catalog answers: a client or host finish never guesses consumable.
352
+ return { logicalId: purchase.logicalId };
353
+ }
354
+ return undefined;
355
+ }
356
+ return { logicalId: product.logicalId, kind: product.kind };
357
+ };
358
+ const write = async (record, state, patch = {}) => {
359
+ assertCommercePurchaseTransition(record.state, state);
360
+ const next = {
361
+ ...record,
362
+ ...patch,
363
+ state,
364
+ revision: record.revision + 1,
365
+ updatedAt: now(),
366
+ };
367
+ await journal.replace(record.revision, next);
368
+ remember(next);
369
+ retryAt.delete(next.idempotencyKey);
370
+ emit({ type: 'transition', record: next, previous: record.state });
371
+ return next;
372
+ };
373
+ const reserve = async (record) => {
374
+ const candidate = {
375
+ ...record,
376
+ kind: commercePurchaseOperationKind,
377
+ key: createCommercePurchaseOperationKey(target, record.idempotencyKey),
378
+ playerId,
379
+ target,
380
+ deploymentTarget,
381
+ revision: 0,
382
+ updatedAt: now(),
383
+ };
384
+ const reserved = await journal.reserve(candidate);
385
+ if (!isCommercePurchaseOperationRecord(reserved.record)) {
386
+ throw new Error('Commerce purchase operation key is bound to another operation kind.');
387
+ }
388
+ if (!ownsRecord(reserved.record)) {
389
+ throw new Error('Commerce purchase operation key is bound to another player or store.');
390
+ }
391
+ remember(reserved.record);
392
+ if (reserved.created) {
393
+ emit({ type: 'transition', record: reserved.record, previous: undefined });
394
+ }
395
+ return { created: reserved.created, record: reserved.record };
396
+ };
397
+ const readRecord = async (idempotencyKey) => {
398
+ const found = await journal.read(createCommercePurchaseOperationKey(target, idempotencyKey));
399
+ if (found === undefined) {
400
+ return records.get(idempotencyKey);
401
+ }
402
+ if (!isCommercePurchaseOperationRecord(found) || !ownsRecord(found)) {
403
+ throw new Error('Commerce purchase operation key is bound to another player or store.');
404
+ }
405
+ return remember(found);
406
+ };
407
+ const ownedRecordsOf = (listed) => listed.filter((record) => isCommercePurchaseOperationRecord(record) && ownsRecord(record));
408
+ const listRecoverable = async () => ownedRecordsOf(await journal.listRecoverable(playerId))
409
+ .filter((record) => !isTerminalCommercePurchaseState(record.state))
410
+ .map(remember);
411
+ const listGranted = async () => {
412
+ const listed = journal.listGranted === undefined
413
+ ? []
414
+ : ownedRecordsOf(await journal.listGranted(playerId)).map(remember);
415
+ const granted = new Map();
416
+ for (const record of [...listed, ...records.values()]) {
417
+ if (record.ledgerEntryId !== undefined
418
+ && (record.state === 'granted' || record.state === 'finishing' || record.state === 'finished')) {
419
+ granted.set(record.idempotencyKey, record);
420
+ }
421
+ }
422
+ return [...granted.values()];
423
+ };
424
+ const loadRecords = async () => {
425
+ await listRecoverable();
426
+ if (journal.listGranted !== undefined) {
427
+ ownedRecordsOf(await journal.listGranted(playerId)).forEach(remember);
428
+ }
429
+ };
430
+ const openRecordsFor = (logicalId) => [...records.values()].filter((record) => record.store === storeId
431
+ && (logicalId === undefined || record.logicalId === logicalId)
432
+ && isOpen(record.state));
433
+ const listener = (event) => {
434
+ if (event.type === 'subscriptionUpdated') {
435
+ return;
436
+ }
437
+ inbox.push(event);
438
+ trigger();
439
+ scheduleRun();
440
+ };
441
+ const readAvailable = async (capabilitiesNow) => {
442
+ if (!capabilitiesNow.lifecycle.availablePurchases) {
443
+ return true;
444
+ }
445
+ try {
446
+ const available = await provider.getAvailablePurchases({
447
+ includePending: capabilitiesNow.lifecycle.pending,
448
+ });
449
+ for (const purchase of available) {
450
+ inbox.push({ type: 'purchaseUpdated', purchase });
451
+ }
452
+ return true;
453
+ }
454
+ catch (error) {
455
+ fail('available', error);
456
+ return false;
457
+ }
458
+ };
459
+ const buildRequest = (record) => {
460
+ const purchase = record.purchase;
461
+ if (purchase === undefined) {
462
+ throw new Error(`Commerce purchase ${record.idempotencyKey} is purchased without a purchase.`);
463
+ }
464
+ return {
465
+ // The ledger target names the deployment platform; `store` names the
466
+ // store the purchase was made in (RFC "Open store ids"). A v1 server
467
+ // ignores `store` and keeps mapping the target.
468
+ target,
469
+ store: storeId,
470
+ ...(deploymentTarget === target ? {} : { deploymentTarget }),
471
+ playerId,
472
+ productId: record.logicalId,
473
+ platformTransactionId: purchase.transactionId,
474
+ idempotencyKey: record.idempotencyKey,
475
+ purchasedAt: purchase.purchasedAt ?? now(),
476
+ evidence: purchase.evidence,
477
+ };
478
+ };
479
+ // Reconciler step 2: match a reported purchase to a journal record.
480
+ const ingestPurchase = async (purchase) => {
481
+ if (purchase.store !== storeId) {
482
+ fail('match', new Error(`Purchase ${purchase.transactionId} belongs to store ${purchase.store}, not ${storeId}.`));
483
+ return;
484
+ }
485
+ const product = await resolveLogicalId(purchase);
486
+ if (product === undefined) {
487
+ fail('catalog', new Error(`Purchase ${purchase.transactionId} names SKU ${purchase.storeSku}, which no catalog entry declares.`));
488
+ return;
489
+ }
490
+ let record = [...records.values()].find((candidate) => candidate.store === storeId
491
+ && candidate.purchase !== undefined
492
+ && samePurchase(candidate.purchase, purchase));
493
+ if (record === undefined && journal.findByTransaction !== undefined) {
494
+ // A terminal record of this transaction (finished, rejected) is not in
495
+ // memory after a restart; the journal index keeps it from being
496
+ // verified again under a derived key.
497
+ const stored = await journal.findByTransaction({
498
+ playerId,
499
+ target,
500
+ deploymentTarget,
501
+ store: storeId,
502
+ transactionId: purchase.transactionId,
503
+ });
504
+ if (stored !== undefined && isCommercePurchaseOperationRecord(stored) && ownsRecord(stored)) {
505
+ record = remember(stored);
506
+ }
507
+ }
508
+ if (record === undefined) {
509
+ const open = openRecordsFor(product.logicalId);
510
+ if (open.length > 1) {
511
+ fail('match', new Error(`Purchase ${purchase.transactionId} matches ${open.length} open records for ${product.logicalId}.`), open[0]?.idempotencyKey);
512
+ return;
513
+ }
514
+ record = open[0];
515
+ if (record?.purchase !== undefined && !samePurchase(record.purchase, purchase)) {
516
+ // The open attempt already holds another transaction (store-side or
517
+ // backend-side pending): this is a second purchase of the product.
518
+ record = undefined;
519
+ }
520
+ }
521
+ if (record === undefined) {
522
+ if (purchase.state === 'revoked') {
523
+ // Revoked store states never revoke on their own: only the ledger does (step 5).
524
+ return;
525
+ }
526
+ const idempotencyKey = await createDerivedPurchaseIdempotencyKey({
527
+ store: purchase.store,
528
+ transactionId: purchase.transactionId,
529
+ token: purchase.token,
530
+ });
531
+ record = await readRecord(idempotencyKey);
532
+ if (record !== undefined
533
+ && (record.logicalId !== product.logicalId
534
+ || (record.purchase !== undefined && !samePurchase(record.purchase, purchase)))) {
535
+ // A game key that happens to equal the derived key names another
536
+ // attempt; the purchase is never attached to it.
537
+ fail('match', new Error(`Purchase ${purchase.transactionId} derives key ${idempotencyKey}, which journals another attempt.`), idempotencyKey);
538
+ return;
539
+ }
540
+ if (record === undefined) {
541
+ await reserve({
542
+ idempotencyKey,
543
+ store: purchase.store,
544
+ logicalId: product.logicalId,
545
+ ...(product.kind === undefined ? {} : { productKind: product.kind }),
546
+ origin: 'discovered',
547
+ state: purchase.state,
548
+ purchase,
549
+ });
550
+ return;
551
+ }
552
+ }
553
+ if (purchase.state === 'pending' && record.state === 'launched') {
554
+ await write(record, 'pending', { purchase });
555
+ return;
556
+ }
557
+ if (purchase.state === 'purchased'
558
+ && (record.state === 'launched' || (record.state === 'pending' && record.request === undefined))) {
559
+ await write(record, 'purchased', { purchase });
560
+ }
561
+ };
562
+ const ingestError = async (logicalId, error) => {
563
+ if (error.code === 'OUTCOME_UNKNOWN' || error.code === 'ALREADY_OWNED') {
564
+ // Not terminal for an attempt: an unfinished purchase may exist, so the
565
+ // record stays open and the available purchases resolve it.
566
+ return;
567
+ }
568
+ if (logicalId === undefined) {
569
+ // An unattributed error cannot be bound to one attempt; the available
570
+ // purchases or the launched-attempt expiry decide it.
571
+ return;
572
+ }
573
+ // Only an attempt the store never answered can end on an error event: a
574
+ // record holding a store purchase or a saved verify request is decided by
575
+ // the available purchases and the backend, never by a later error.
576
+ const open = openRecordsFor(logicalId).filter((record) => record.purchase === undefined && record.request === undefined);
577
+ if (open.length > 1) {
578
+ fail('match', new Error(`purchaseError${logicalId === undefined ? '' : ` for ${logicalId}`} matches ${open.length} open records.`));
579
+ return;
580
+ }
581
+ const record = open[0];
582
+ if (record !== undefined) {
583
+ await write(record, error.code === 'USER_CANCELLED' ? 'cancelled' : 'failed', { error });
584
+ }
585
+ };
586
+ const staleAttemptError = (message) => ({
587
+ code: 'OUTCOME_UNKNOWN',
588
+ retryable: false,
589
+ message,
590
+ });
591
+ const sendVerify = async (record, request) => {
592
+ emit({ type: 'verify-requested', record });
593
+ try {
594
+ const response = await backend.verifyPurchase(request);
595
+ emit({ type: 'verify-result', record, response });
596
+ return response;
597
+ }
598
+ catch (error) {
599
+ // Transport failure: the state is kept and the next pass resends the saved request.
600
+ fail('verify', error, record.idempotencyKey);
601
+ return undefined;
602
+ }
603
+ };
604
+ // Reconciler steps 3 and 4: drive one record as far as the backend and the store allow.
605
+ const drive = async (initial, capabilitiesNow, explicit) => {
606
+ let record = initial;
607
+ /** Finalization the grant of this drive reported; a later pass re-reads it from the backend. */
608
+ let finalization;
609
+ const waitUntil = retryAt.get(record.idempotencyKey);
610
+ if (!explicit && waitUntil !== undefined && nowMs() < waitUntil) {
611
+ // An unrelated store event does not retry a backend or store step
612
+ // inside the interval; an explicit trigger always does.
613
+ return;
614
+ }
615
+ try {
616
+ for (;;) {
617
+ switch (record.state) {
618
+ case 'requested':
619
+ if (isInFlight(record.idempotencyKey)) {
620
+ return;
621
+ }
622
+ // The store answer was never journaled (a crash or a lost write
623
+ // after the reservation): the attempt ends so the product is not
624
+ // serialized behind it; a purchase the store reports later is
625
+ // journaled under its derived key.
626
+ await write(record, 'failed', {
627
+ error: staleAttemptError(`The store answer for ${record.idempotencyKey} was never journaled.`),
628
+ });
629
+ return;
630
+ case 'launched': {
631
+ if (record.purchase !== undefined || isInFlight(record.idempotencyKey)) {
632
+ return;
633
+ }
634
+ const age = Date.parse(now()) - Date.parse(record.updatedAt);
635
+ if (Number.isFinite(age) && age > staleLaunchedAfterMs) {
636
+ await write(record, 'failed', {
637
+ error: staleAttemptError(`No store purchase was reported for ${record.idempotencyKey} within ${String(staleLaunchedAfterMs)} ms.`),
638
+ });
639
+ }
640
+ return;
641
+ }
642
+ case 'purchased':
643
+ record = await write(record, 'verifying', {
644
+ request: record.request ?? buildRequest(record),
645
+ });
646
+ continue;
647
+ case 'pending':
648
+ if (record.request === undefined) {
649
+ // Store-side pending: waiting on the store, never sent.
650
+ return;
651
+ }
652
+ record = await write(record, 'verifying');
653
+ continue;
654
+ case 'verifying': {
655
+ const request = record.request ?? buildRequest(record);
656
+ const response = await sendVerify(record, request);
657
+ if (response === undefined) {
658
+ retryLater(record.idempotencyKey);
659
+ return;
660
+ }
661
+ if (!response.verified) {
662
+ if (response.disposition === 'pending') {
663
+ await write(record, 'pending', { request, response });
664
+ retryLater(record.idempotencyKey);
665
+ }
666
+ else {
667
+ await write(record, 'rejected', { request, response });
668
+ }
669
+ return;
670
+ }
671
+ const ledgerEntryId = response.ledgerEntryId;
672
+ if (typeof ledgerEntryId !== 'string' || ledgerEntryId.trim() === '') {
673
+ fail('verify', new Error(`A verified response for ${record.idempotencyKey} must carry a ledgerEntryId.`), record.idempotencyKey);
674
+ return;
675
+ }
676
+ finalization = response.finalization?.status;
677
+ record = await write(record, 'granted', { request, response, ledgerEntryId });
678
+ continue;
679
+ }
680
+ case 'granted':
681
+ if (capabilitiesNow === undefined) {
682
+ return;
683
+ }
684
+ if (capabilitiesNow.lifecycle.finish === 'none') {
685
+ await write(record, 'finished');
686
+ return;
687
+ }
688
+ record = await write(record, 'finishing');
689
+ continue;
690
+ case 'finishing': {
691
+ if (capabilitiesNow === undefined) {
692
+ return;
693
+ }
694
+ const owner = capabilitiesNow.lifecycle.finish;
695
+ if (owner === 'none') {
696
+ await write(record, 'finished');
697
+ return;
698
+ }
699
+ if (owner === 'server') {
700
+ let reread;
701
+ if (finalization === undefined) {
702
+ // A pass that did not grant resends the saved request to read
703
+ // finalization; the backend replays the grant, never a second one.
704
+ reread = await sendVerify(record, record.request ?? buildRequest(record));
705
+ finalization = reread?.verified === true
706
+ ? reread.finalization?.status
707
+ : undefined;
708
+ }
709
+ if (finalization === 'completed') {
710
+ // The re-read is the last backend answer the record reports.
711
+ await write(record, 'finished', reread === undefined ? {} : { response: reread });
712
+ }
713
+ else {
714
+ retryLater(record.idempotencyKey);
715
+ }
716
+ return;
717
+ }
718
+ const finish = assertCommerceFinishAllowed(record);
719
+ if (provider.finishTransaction === undefined) {
720
+ fail('finish', new Error(`The provider declares finish '${owner}' but has no finishTransaction.`), record.idempotencyKey);
721
+ return;
722
+ }
723
+ // A consume and an acknowledge are not interchangeable: the finish
724
+ // waits until the catalog says which kind the product is.
725
+ const kind = record.productKind
726
+ ?? (await resolveProduct({ logicalId: record.logicalId }))?.kind;
727
+ if (kind === undefined) {
728
+ fail('catalog', new Error(`The kind of ${record.logicalId} is unknown; the finish waits for the catalog.`), record.idempotencyKey);
729
+ retryLater(record.idempotencyKey);
730
+ return;
731
+ }
732
+ let finished = false;
733
+ try {
734
+ finished = (await provider.finishTransaction({
735
+ transactionId: finish.transactionId,
736
+ ledgerEntryId: finish.ledgerEntryId,
737
+ isConsumable: kind === 'consumable',
738
+ })).finished;
739
+ }
740
+ catch (error) {
741
+ // Transport-class failure: keep `finishing` for the next pass.
742
+ fail('finish', error, record.idempotencyKey);
743
+ retryLater(record.idempotencyKey);
744
+ return;
745
+ }
746
+ if (finished) {
747
+ await write(record, 'finished');
748
+ }
749
+ else {
750
+ retryLater(record.idempotencyKey);
751
+ }
752
+ return;
753
+ }
754
+ default:
755
+ return;
756
+ }
757
+ }
758
+ }
759
+ catch (error) {
760
+ // A journal write that lost its compare-and-swap, or a malformed record:
761
+ // the next pass reads the stored revision again.
762
+ fail('journal', error, record.idempotencyKey);
763
+ }
764
+ };
765
+ // Reconciler step 5: reversed grants move to revoked. Needs the backend's
766
+ // ledger status read (C2); without it the hook is inert.
767
+ const revokeReversed = async () => {
768
+ const ledgerStatus = backend.getPurchaseLedgerStatus;
769
+ if (ledgerStatus === undefined || ledgerStatusUnpublished) {
770
+ return;
771
+ }
772
+ const granted = await listGranted();
773
+ if (granted.length === 0) {
774
+ return;
775
+ }
776
+ const ledgerEntryIds = [...new Set(granted.flatMap((record) => (record.ledgerEntryId === undefined ? [] : [record.ledgerEntryId])))];
777
+ const statuses = [];
778
+ try {
779
+ // Reads are chunked to what a backend store accepts in one query.
780
+ for (const chunk of chunkLedgerEntryIds(ledgerEntryIds)) {
781
+ const response = await ledgerStatus.call(backend, { ledgerEntryIds: chunk, playerId });
782
+ statuses.push(...response.statuses);
783
+ }
784
+ }
785
+ catch (error) {
786
+ if (isNotFoundError(error)) {
787
+ // A pre-C2 backend behind a transport adapter that always exposes the
788
+ // read: the hook stays inert, as it is for a backend without it.
789
+ ledgerStatusUnpublished = true;
790
+ return;
791
+ }
792
+ fail('ledger-status', error);
793
+ return;
794
+ }
795
+ for (const record of granted) {
796
+ const status = statuses.find((candidate) => candidate.ledgerEntryId === record.ledgerEntryId);
797
+ if (status?.status !== 'reversed') {
798
+ continue;
799
+ }
800
+ try {
801
+ await write(record, 'revoked', {
802
+ ...(status.reversedBy === undefined ? {} : { revokedBy: status.reversedBy }),
803
+ });
804
+ }
805
+ catch (error) {
806
+ fail('journal', error, record.idempotencyKey);
807
+ }
808
+ }
809
+ };
810
+ const eventPass = { explicit: false, refreshCapabilities: false };
811
+ const pass = async (options) => {
812
+ let capabilitiesNow = await readCapabilities(options.refreshCapabilities);
813
+ if (options.refreshCapabilities) {
814
+ await readServiceAlignment();
815
+ capabilitiesNow = withServiceAlignment(capabilities);
816
+ }
817
+ // A provider without lifecycle.events is polled: every pass reads its
818
+ // available purchases. An event provider whose start-time read failed is
819
+ // read again until it succeeds, so a purchase that predates the
820
+ // subscription is not missed for the session.
821
+ if (capabilitiesNow !== undefined && (!capabilitiesNow.lifecycle.events || initialScanPending)) {
822
+ initialScanPending = !(await readAvailable(capabilitiesNow)) && capabilitiesNow.lifecycle.events;
823
+ }
824
+ // An event for a product whose store call is still running waits for that
825
+ // call to journal the store answer: matched then, it is never journaled
826
+ // twice, and a cancel event ends the attempt the call launched.
827
+ const busy = inFlightProducts();
828
+ for (const event of [
829
+ ...deferred.splice(0, deferred.length),
830
+ ...inbox.splice(0, inbox.length),
831
+ ]) {
832
+ try {
833
+ if (event.type === 'purchaseUpdated') {
834
+ const product = busy.size === 0 ? undefined : await resolveLogicalId(event.purchase);
835
+ if (product !== undefined && busy.has(product.logicalId)) {
836
+ defer(event);
837
+ continue;
838
+ }
839
+ await ingestPurchase(event.purchase);
840
+ }
841
+ else if (event.type === 'purchaseError') {
842
+ if (event.logicalId !== undefined && busy.has(event.logicalId)) {
843
+ defer(event);
844
+ continue;
845
+ }
846
+ await ingestError(event.logicalId, event.error);
847
+ }
848
+ }
849
+ catch (error) {
850
+ fail('journal', error);
851
+ }
852
+ }
853
+ for (const record of await listRecoverable()) {
854
+ await drive(records.get(record.idempotencyKey) ?? record, capabilitiesNow, options.explicit);
855
+ }
856
+ await revokeReversed();
857
+ };
858
+ const runUntilIdle = async () => {
859
+ if (running !== undefined) {
860
+ try {
861
+ await running;
862
+ }
863
+ catch (error) {
864
+ pendingFailure = undefined;
865
+ throw error;
866
+ }
867
+ // An event that landed between the loop's last check and its teardown.
868
+ if (started && triggerPending && running === undefined) {
869
+ await runUntilIdle();
870
+ }
871
+ return;
872
+ }
873
+ if (pendingFailure !== undefined) {
874
+ const failure = pendingFailure;
875
+ pendingFailure = undefined;
876
+ throw failure.error;
877
+ }
878
+ if (!started || !triggerPending) {
879
+ return;
880
+ }
881
+ const loop = (async () => {
882
+ while (started && triggerPending) {
883
+ triggerPending = false;
884
+ await withLock(() => pass(eventPass));
885
+ }
886
+ })();
887
+ running = loop.finally(() => {
888
+ running = undefined;
889
+ });
890
+ await running;
891
+ };
892
+ function scheduleRun() {
893
+ runUntilIdle().catch((error) => {
894
+ pendingFailure = { error };
895
+ });
896
+ }
897
+ const journaled = (record) => {
898
+ if ((record.state === 'cancelled' || record.state === 'failed') && record.error !== undefined) {
899
+ return { outcome: record.state, error: record.error };
900
+ }
901
+ if (record.purchase !== undefined && record.state !== 'requested') {
902
+ return { outcome: 'purchased', purchase: record.purchase };
903
+ }
904
+ return { outcome: 'launched' };
905
+ };
906
+ const failedOutcome = (error) => ({
907
+ outcome: 'failed',
908
+ error: commerceErrorInfoOf(error),
909
+ });
910
+ /**
911
+ * Journal work before the store call, under the lock: a journaled key is
912
+ * answered, a product the store does not sell or a provider that is not
913
+ * ready is refused without a record (the key stays usable), an open attempt
914
+ * for the product refuses with IN_PROGRESS without a record, and only a call
915
+ * that will open the store reserves its record.
916
+ */
917
+ const prepareRequest = async (request) => {
918
+ if (!started) {
919
+ await loadRecords();
920
+ }
921
+ // A key is never regenerated on retry: the journaled attempt is the answer,
922
+ // and the store UI opens once per key however many calls overlap.
923
+ const existing = await readRecord(request.idempotencyKey);
924
+ if (existing !== undefined) {
925
+ if (existing.logicalId !== request.logicalId
926
+ || (existing.input !== undefined && existing.input.source !== request.source)) {
927
+ throw new Error('Purchase idempotency key is bound to another product or source.');
928
+ }
929
+ return { kind: 'answered', result: journaled(existing) };
930
+ }
931
+ const product = await resolveProduct({ logicalId: request.logicalId });
932
+ if (product === undefined) {
933
+ return {
934
+ kind: 'answered',
935
+ result: failedOutcome(new CommerceError('ITEM_UNAVAILABLE', {
936
+ message: `Store ${storeId} does not sell ${request.logicalId}.`,
937
+ })),
938
+ };
939
+ }
940
+ const capabilitiesNow = await readCapabilities();
941
+ if (capabilitiesNow === undefined) {
942
+ return {
943
+ kind: 'answered',
944
+ result: failedOutcome(new CommerceError('NOT_PREPARED', {
945
+ message: `The ${storeId} provider capabilities could not be read.`,
946
+ })),
947
+ };
948
+ }
949
+ if (serviceAlignment === undefined) {
950
+ // A purchase before `start()` finished, or whose start could not read
951
+ // the backend: the comparison is attempted before any store UI opens.
952
+ await readServiceAlignment();
953
+ }
954
+ if (serviceAlignment?.status === 'configuration-required') {
955
+ // The backend does not serve this store as the provider declares it:
956
+ // no store UI opens until the deployment is corrected.
957
+ return {
958
+ kind: 'answered',
959
+ result: failedOutcome(new CommerceError('CONFIGURATION_REQUIRED', {
960
+ message: serviceAlignment.message,
961
+ storeCode: serviceAlignment.reason,
962
+ })),
963
+ };
964
+ }
965
+ // Purchases are serialized per player, store, and logical product: an
966
+ // open attempt for the same product, or one whose store call is still
967
+ // running, refuses this one with IN_PROGRESS, so a reported purchase
968
+ // matches at most one open record.
969
+ const open = (await listRecoverable()).filter((candidate) => candidate.logicalId === product.logicalId
970
+ && (isOpen(candidate.state)
971
+ || (candidate.state === 'requested' && isInFlight(candidate.idempotencyKey))));
972
+ if (open.length > 0) {
973
+ return {
974
+ kind: 'answered',
975
+ result: failedOutcome(new CommerceError('IN_PROGRESS', {
976
+ message: `A purchase of ${product.logicalId} is already in progress (${open[0]?.idempotencyKey}).`,
977
+ })),
978
+ };
979
+ }
980
+ const { created, record } = await reserve({
981
+ idempotencyKey: request.idempotencyKey,
982
+ store: storeId,
983
+ logicalId: product.logicalId,
984
+ productKind: product.kind,
985
+ origin: 'game',
986
+ state: 'requested',
987
+ input: {
988
+ productId: product.logicalId,
989
+ source: request.source,
990
+ idempotencyKey: request.idempotencyKey,
991
+ },
992
+ });
993
+ if (!created) {
994
+ return { kind: 'answered', result: journaled(record) };
995
+ }
996
+ try {
997
+ assertRequestPurchaseSupported(capabilitiesNow, request, product.kind);
998
+ }
999
+ catch (error) {
1000
+ // The store does not sell this kind: a definitive answer the journal keeps.
1001
+ const info = commerceErrorInfoOf(error);
1002
+ await write(record, 'failed', { error: info });
1003
+ return { kind: 'answered', result: { outcome: 'failed', error: info } };
1004
+ }
1005
+ // Only a call that opens the store is in flight; the entry is released by
1006
+ // that call's `finally`, so no refused path can leave a product deferred.
1007
+ inFlightKeys.set(request.idempotencyKey, { logicalId: product.logicalId, since: nowMs() });
1008
+ return { kind: 'launch', record, product };
1009
+ };
1010
+ /**
1011
+ * Journals what the store answered, under the lock. A journal write that
1012
+ * fails here (a stale attempt another pass ended after a very long sheet)
1013
+ * is reported; the store answer is still returned to the caller.
1014
+ */
1015
+ const recordAnswer = async (request, record, product, result) => {
1016
+ try {
1017
+ return await journalAnswer(request, record, product, result);
1018
+ }
1019
+ catch (error) {
1020
+ fail('journal', error, record.idempotencyKey);
1021
+ return result;
1022
+ }
1023
+ };
1024
+ const journalAnswer = async (request, reserved, product, result) => {
1025
+ // A sheet open past inFlightTimeoutMs may have been ended by a pass as a
1026
+ // stale attempt; the journal's answer stands, and a purchase the store
1027
+ // reported meanwhile is journaled under its own record.
1028
+ const record = (await readRecord(reserved.idempotencyKey)) ?? reserved;
1029
+ if (record.state !== 'requested') {
1030
+ return journaled(record);
1031
+ }
1032
+ if (result.outcome === 'launched') {
1033
+ await write(record, 'launched');
1034
+ }
1035
+ else if (result.outcome === 'purchased') {
1036
+ const purchase = result.purchase;
1037
+ const launched = await write(record, 'launched');
1038
+ const reported = await resolveLogicalId(purchase);
1039
+ // A record that journals this transaction and is still open, or that
1040
+ // ended after this attempt was reserved, holds the purchase the store
1041
+ // just answered with (an event that arrived first). A record that ended
1042
+ // before the attempt started is an earlier purchase whose id the store
1043
+ // reused (Microsoft Store add-on ids), so this answer is a new purchase.
1044
+ const reservedAt = Date.parse(record.updatedAt);
1045
+ const holder = [...records.values()].find((candidate) => candidate.idempotencyKey !== record.idempotencyKey
1046
+ && candidate.store === storeId
1047
+ && candidate.purchase !== undefined
1048
+ && samePurchase(candidate.purchase, purchase)
1049
+ && (!isTerminalCommercePurchaseState(candidate.state)
1050
+ || Date.parse(candidate.updatedAt) > reservedAt));
1051
+ if (holder !== undefined) {
1052
+ // The transaction is verified once, under the holder's key. This
1053
+ // attempt is superseded so the product is not serialized behind it.
1054
+ const error = staleAttemptError(`Purchase ${purchase.transactionId} is journaled as ${holder.idempotencyKey}; this attempt is superseded.`);
1055
+ await write(launched, 'failed', { error });
1056
+ return { outcome: 'failed', error };
1057
+ }
1058
+ if (purchase.store !== storeId || reported?.logicalId !== product.logicalId) {
1059
+ // A synchronous answer for another product is not this attempt's
1060
+ // purchase; the attempt stays open and the available purchases decide.
1061
+ fail('match', new Error(`The provider answered ${request.idempotencyKey} with purchase ${purchase.transactionId} for ${purchase.store}/${purchase.storeSku}.`), record.idempotencyKey);
1062
+ return { outcome: 'launched' };
1063
+ }
1064
+ if (purchase.state === 'pending') {
1065
+ await write(launched, 'pending', { purchase });
1066
+ }
1067
+ else if (purchase.state === 'purchased') {
1068
+ await write(launched, 'purchased', { purchase });
1069
+ }
1070
+ }
1071
+ else {
1072
+ await write(record, result.outcome, { error: result.error });
1073
+ }
1074
+ return result;
1075
+ };
1076
+ const requestPurchase = async (request) => {
1077
+ let result;
1078
+ // Only the call that reserved the record owns its in-flight entry: a
1079
+ // same-key replay must not release it while the store sheet is open.
1080
+ let owned = false;
1081
+ try {
1082
+ const prepared = await withLock(() => prepareRequest(request));
1083
+ if (prepared.kind === 'answered') {
1084
+ result = prepared.result;
1085
+ }
1086
+ else {
1087
+ owned = true;
1088
+ // The store call runs outside the lock: the sheet may stay open for
1089
+ // minutes, and events arriving meanwhile are reconciled right away.
1090
+ let answer;
1091
+ try {
1092
+ answer = await provider.requestPurchase(request);
1093
+ }
1094
+ catch (error) {
1095
+ answer = failedOutcome(error);
1096
+ }
1097
+ result = await withLock(() => recordAnswer(request, prepared.record, prepared.product, answer));
1098
+ }
1099
+ }
1100
+ finally {
1101
+ if (owned) {
1102
+ inFlightKeys.delete(request.idempotencyKey);
1103
+ }
1104
+ }
1105
+ trigger();
1106
+ try {
1107
+ await runUntilIdle();
1108
+ }
1109
+ catch (error) {
1110
+ // The store answer is journaled; a pass that failed meanwhile is
1111
+ // reported, not thrown at the caller of a purchase already made.
1112
+ fail('journal', error, request.idempotencyKey);
1113
+ }
1114
+ return result;
1115
+ };
1116
+ return {
1117
+ provider,
1118
+ get started() {
1119
+ return started;
1120
+ },
1121
+ get serviceAlignment() {
1122
+ return serviceAlignment;
1123
+ },
1124
+ async getCapabilities() {
1125
+ const block = await readCapabilities();
1126
+ if (block === undefined) {
1127
+ throw new CommerceError('NOT_PREPARED', {
1128
+ message: `The ${storeId} provider capabilities could not be read.`,
1129
+ });
1130
+ }
1131
+ return block;
1132
+ },
1133
+ async start() {
1134
+ if (started) {
1135
+ return;
1136
+ }
1137
+ if (starting !== undefined) {
1138
+ return starting;
1139
+ }
1140
+ starting = (async () => {
1141
+ try {
1142
+ await withLock(async () => {
1143
+ await loadRecords();
1144
+ // Subscribe first, then read: an event delivered before the listener is found either way.
1145
+ unsubscribe = provider.subscribe(listener);
1146
+ started = true;
1147
+ const capabilitiesNow = await readCapabilities(true);
1148
+ await readServiceAlignment();
1149
+ // An event-less provider is polled by the first pass instead, so it
1150
+ // is read once; a failed read is retried by the next pass.
1151
+ initialScanPending = capabilitiesNow === undefined
1152
+ || (capabilitiesNow.lifecycle.events && !(await readAvailable(capabilitiesNow)));
1153
+ await pass({ explicit: true, refreshCapabilities: false });
1154
+ });
1155
+ await runUntilIdle();
1156
+ }
1157
+ catch (error) {
1158
+ // A failed start is retryable: nothing stays subscribed or started.
1159
+ started = false;
1160
+ unsubscribe?.();
1161
+ unsubscribe = undefined;
1162
+ triggerPending = false;
1163
+ pendingFailure = undefined;
1164
+ inbox.length = 0;
1165
+ deferred.length = 0;
1166
+ records.clear();
1167
+ retryAt.clear();
1168
+ throw error;
1169
+ }
1170
+ finally {
1171
+ starting = undefined;
1172
+ }
1173
+ })();
1174
+ return starting;
1175
+ },
1176
+ async stop() {
1177
+ // A start still waiting for the lock subscribes later; let it finish so
1178
+ // the teardown below sees its subscription.
1179
+ if (starting !== undefined) {
1180
+ try {
1181
+ await starting;
1182
+ }
1183
+ catch {
1184
+ // The start cleaned up after itself.
1185
+ }
1186
+ }
1187
+ started = false;
1188
+ unsubscribe?.();
1189
+ unsubscribe = undefined;
1190
+ // Quiesce: a pass awaiting the backend or the store finishes before state is dropped.
1191
+ if (running !== undefined) {
1192
+ try {
1193
+ await running;
1194
+ }
1195
+ catch (error) {
1196
+ pendingFailure = { error };
1197
+ }
1198
+ }
1199
+ await tail;
1200
+ triggerPending = false;
1201
+ inbox.length = 0;
1202
+ deferred.length = 0;
1203
+ records.clear();
1204
+ retryAt.clear();
1205
+ },
1206
+ async reconcile() {
1207
+ await withLock(async () => {
1208
+ if (!started) {
1209
+ await loadRecords();
1210
+ }
1211
+ await pass({ explicit: true, refreshCapabilities: true });
1212
+ });
1213
+ await runUntilIdle();
1214
+ },
1215
+ runUntilIdle,
1216
+ async restorePurchases() {
1217
+ if (provider.restorePurchases !== undefined) {
1218
+ try {
1219
+ await provider.restorePurchases();
1220
+ }
1221
+ catch (error) {
1222
+ fail('restore', error);
1223
+ }
1224
+ }
1225
+ await withLock(async () => {
1226
+ if (!started) {
1227
+ await loadRecords();
1228
+ }
1229
+ const capabilitiesNow = await readCapabilities(true);
1230
+ // A polled provider is read by the pass itself.
1231
+ if (capabilitiesNow !== undefined && capabilitiesNow.lifecycle.events) {
1232
+ await readAvailable(capabilitiesNow);
1233
+ }
1234
+ await pass({ explicit: true, refreshCapabilities: false });
1235
+ });
1236
+ await runUntilIdle();
1237
+ },
1238
+ requestPurchase,
1239
+ async fetchProducts(request = {}) {
1240
+ return provider.fetchProducts(request);
1241
+ },
1242
+ read: readRecord,
1243
+ listRecoverable,
1244
+ subscribe(listenerToAdd) {
1245
+ listeners.add(listenerToAdd);
1246
+ return () => {
1247
+ listeners.delete(listenerToAdd);
1248
+ };
1249
+ },
1250
+ };
1251
+ }