@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.
- package/README.md +30 -0
- package/dist/admob-ssv-conformance.js +3 -1
- package/dist/app-store-recovery.js +5 -0
- package/dist/client.d.ts +39 -1
- package/dist/client.js +198 -0
- package/dist/commerce-evidence-registry.d.ts +406 -0
- package/dist/commerce-evidence-registry.js +773 -0
- package/dist/commerce-ledger.d.ts +64 -0
- package/dist/commerce-ledger.js +146 -0
- package/dist/commerce-reconciler.d.ts +191 -0
- package/dist/commerce-reconciler.js +1251 -0
- package/dist/contract.d.ts +4 -1
- package/dist/contract.js +8 -0
- package/dist/evidence-verification.d.ts +14 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/monetization-operation-store.d.ts +20 -0
- package/dist/monetization-operation-store.js +80 -0
- package/dist/monetization-recovery.d.ts +57 -2
- package/dist/monetization-recovery.js +102 -8
- package/dist/operation-results.d.ts +14 -0
- package/dist/operations.d.ts +2 -1
- package/dist/platform-order.d.ts +47 -0
- package/dist/platform-order.js +86 -0
- package/dist/runtime.js +12 -1
- package/dist/server.d.ts +43 -1
- package/dist/server.js +305 -7
- package/dist/types.d.ts +79 -3
- package/dist/types.js +97 -6
- package/package.json +18 -6
|
@@ -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
|
+
}
|