@mpgd/platform 0.19.0 → 0.21.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.
@@ -1,4 +1,7 @@
1
1
  import { assertCommercePurchaseTransition, assertRequestPurchaseSupported, CommerceError, commerceErrorInfoOf, createCommerceEventBuffer, createDerivedPurchaseIdempotencyKey, isTerminalCommercePurchaseState, } from './commerce.js';
2
+ import { createCreditCommerceProvider, createInMemoryCreditWallet, normalizeIsoTimestamp, } from './commerce-wallet.js';
3
+ // The wallet rules moved to `./commerce-wallet` with B7; they stay importable here.
4
+ export { addIsoDuration, compareCreditLotSpendOrder, deriveCreditLotSpendScope, normalizeIsoTimestamp, } from './commerce-wallet.js';
2
5
  /**
3
6
  * Commerce v2 conformance runner (RFC "Conformance vectors") with the in-memory
4
7
  * reference pieces every vector needs: a fake clock, a fake store with its
@@ -10,7 +13,11 @@ import { assertCommercePurchaseTransition, assertRequestPurchaseSupported, Comme
10
13
  *
11
14
  * Store (B-phase) providers run the same vectors by supplying
12
15
  * `createProvider` and translating the store actor steps into their own fake
13
- * SDK; the reconciler, journal, backend, and matcher stay shared.
16
+ * SDK; the reconciler, journal, backend, and matcher stay shared. A vector
17
+ * flagged `profile.adaptive` then matches expectations derived from the
18
+ * provider's declared capabilities rather than its fixture profile; see
19
+ * `deriveAdaptiveCommerceConformanceExpectation` and
20
+ * `commerceConformanceAdaptiveSkipReason`.
14
21
  */
15
22
  export const commerceConformanceDefaultPlayerId = 'player-1';
16
23
  export const commerceConformanceDefaultTitleId = 'title-a';
@@ -28,31 +35,6 @@ export function createCommerceConformanceClock(initial = commerceConformanceDefa
28
35
  },
29
36
  };
30
37
  }
31
- /** Canonical `YYYY-MM-DDTHH:mm:ssZ`; milliseconds are kept only when non-zero. */
32
- export function normalizeIsoTimestamp(input) {
33
- const millis = Date.parse(input);
34
- if (Number.isNaN(millis)) {
35
- throw new TypeError(`Invalid ISO-8601 timestamp: ${input}`);
36
- }
37
- return formatIsoTimestamp(millis);
38
- }
39
- function formatIsoTimestamp(millis) {
40
- const iso = new Date(millis).toISOString();
41
- return iso.endsWith('.000Z') ? `${iso.slice(0, -5)}Z` : iso;
42
- }
43
- /** Adds an ISO-8601 duration (`P30D`, `P1M`, `PT12H`, ...) to a timestamp, in UTC calendar units. */
44
- export function addIsoDuration(at, duration) {
45
- const match = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/u
46
- .exec(duration);
47
- if (match === null || duration === 'P' || duration === 'PT') {
48
- throw new TypeError(`Invalid ISO-8601 duration: ${duration}`);
49
- }
50
- const [, years, months, weeks, days, hours, minutes, seconds] = match;
51
- const date = new Date(normalizeIsoTimestamp(at));
52
- date.setUTCFullYear(date.getUTCFullYear() + Number(years ?? 0), date.getUTCMonth() + Number(months ?? 0), date.getUTCDate() + Number(weeks ?? 0) * 7 + Number(days ?? 0));
53
- date.setUTCHours(date.getUTCHours() + Number(hours ?? 0), date.getUTCMinutes() + Number(minutes ?? 0), date.getUTCSeconds() + Number(seconds ?? 0));
54
- return formatIsoTimestamp(date.getTime());
55
- }
56
38
  // Capabilities and fixture filling.
57
39
  /** Capabilities of a vector profile: omitted blocks default to all supported and `available`. */
58
40
  export function createCommerceConformanceCapabilities(profile) {
@@ -137,6 +119,10 @@ export function createCommerceConformanceStore(input) {
137
119
  let purchaseUiOpened = 0;
138
120
  let restoreCalls = 0;
139
121
  const emit = (event) => {
122
+ // An event-less store reports nothing; the reconciler polls its available list instead.
123
+ if (!capabilities.lifecycle.events) {
124
+ return;
125
+ }
140
126
  if (listener === undefined) {
141
127
  buffer.push(event);
142
128
  return;
@@ -283,12 +269,16 @@ function matchesFetchProducts(entry, request) {
283
269
  * Fake backend: every verify call consumes the next `queueVerify` decision in
284
270
  * order and is logged; an unqueued verify is an unscripted operation. A
285
271
  * `granted` decision makes sure the ledger holds the entry, so a replayed
286
- * grant and a host-pushed grant never create a second entry.
272
+ * grant and a host-pushed grant never create a second entry. With
273
+ * `finalizeOnReread`, a replayed granted key is answered as finalized from
274
+ * the backend's own state.
287
275
  */
288
276
  export function createCommerceConformanceBackend(input = {}) {
289
277
  const decisions = [];
290
278
  const verifyRequests = [];
291
279
  const ledger = [];
280
+ /** Ledger entry id per granted idempotency key. */
281
+ const grants = new Map();
292
282
  const findEntry = (entryId) => ledger.find((entry) => entry.entryId === entryId);
293
283
  const reversalOf = (ledgerEntryId) => ledger.find((entry) => entry.type === 'revoke' && entry.reverses === ledgerEntryId);
294
284
  return {
@@ -303,6 +293,26 @@ export function createCommerceConformanceBackend(input = {}) {
303
293
  },
304
294
  async verifyPurchase(request) {
305
295
  verifyRequests.push(request);
296
+ const granted = grants.get(request.idempotencyKey);
297
+ if (granted !== undefined
298
+ && reversalOf(granted) === undefined
299
+ && input.finalizeOnReread?.() === true) {
300
+ // The key's own grant answers the re-read: no queued decision is
301
+ // consumed, so a decision scripted for another record stays in place.
302
+ // A reversed grant is not finalized; the re-read falls through to the script.
303
+ await input.finalize?.({
304
+ store: request.store,
305
+ transactionId: request.transactionId,
306
+ ledgerEntryId: granted,
307
+ logicalId: request.logicalId,
308
+ });
309
+ return {
310
+ status: 'granted',
311
+ ledgerEntryId: granted,
312
+ finalization: 'completed',
313
+ alreadyProcessed: true,
314
+ };
315
+ }
306
316
  const decision = decisions.shift();
307
317
  if (decision === undefined) {
308
318
  throw new CommerceConformanceScriptError(`Unscripted verify for ${request.idempotencyKey} (${request.store}/${request.transactionId}); `
@@ -325,6 +335,7 @@ export function createCommerceConformanceBackend(input = {}) {
325
335
  idempotencyKey: request.idempotencyKey,
326
336
  });
327
337
  }
338
+ grants.set(request.idempotencyKey, ledgerEntryId);
328
339
  if (decision.finalization === 'completed') {
329
340
  await input.finalize?.({
330
341
  store: request.store,
@@ -726,6 +737,14 @@ export function createCommerceConformanceRuntime(input) {
726
737
  };
727
738
  const pass = async () => {
728
739
  const capabilities = await provider.getCapabilities();
740
+ // A provider without lifecycle.events is polled: every pass reads its available purchases.
741
+ if (!capabilities.lifecycle.events && capabilities.lifecycle.availablePurchases) {
742
+ for (const purchase of await provider.getAvailablePurchases({
743
+ includePending: capabilities.lifecycle.pending,
744
+ })) {
745
+ inbox.push({ type: 'purchaseUpdated', purchase });
746
+ }
747
+ }
729
748
  for (const event of inbox.splice(0, inbox.length)) {
730
749
  if (event.type === 'purchaseUpdated') {
731
750
  await ingestPurchase(event.purchase);
@@ -787,7 +806,8 @@ export function createCommerceConformanceRuntime(input) {
787
806
  // Subscribe first, then read: an event delivered before the listener is found either way.
788
807
  unsubscribe = provider.subscribe(listener);
789
808
  const capabilities = await provider.getCapabilities();
790
- if (capabilities.lifecycle.availablePurchases) {
809
+ // An event-less provider is polled by the first pass instead, so it is read once.
810
+ if (capabilities.lifecycle.availablePurchases && capabilities.lifecycle.events) {
791
811
  for (const purchase of await provider.getAvailablePurchases({
792
812
  includePending: capabilities.lifecycle.pending,
793
813
  })) {
@@ -932,361 +952,76 @@ export function createCommerceConformanceRuntime(input) {
932
952
  };
933
953
  }
934
954
  /**
935
- * Reference in-memory wallet implementing the RFC "Credit lots" and "Billing
936
- * authority and memberships" rules: lot ids equal ledger entry ids, spend
937
- * scope derives from the lot origin, spends consume eligible lots in the
938
- * fixed order, are idempotent by key, and debit nothing on
939
- * `INSUFFICIENT_BALANCE`; memberships debit `membership:<id>:<period>` per
940
- * paid period and lapse at period end when the eligible balance runs out.
955
+ * The reference wallet (`createInMemoryCreditWallet`) on the vector clock,
956
+ * with the actor surface: fixture lots credit through `creditFromGrant`, the
957
+ * backend finalizer redeems through `finalize`, and owner-side mistakes in a
958
+ * vector surface as script errors.
941
959
  */
942
960
  export function createCommerceConformanceWallet(input) {
943
- const { clock, store } = input;
944
- const currencies = new Set(input.currencies);
945
- const lots = [];
946
- const spends = new Map();
947
- const debits = [];
948
- const entitlements = [];
949
- const subscriptions = new Map();
950
- const subscriptionEvents = [];
951
- const listeners = new Set();
952
- const buffer = createCommerceEventBuffer();
953
- const snapshotLot = (lot) => ({ ...lot });
954
- const toEntitlement = (entitlement) => ({
955
- entitlementId: entitlement.entitlementId,
956
- titleId: entitlement.titleId,
957
- logicalId: entitlement.logicalId,
958
- idempotencyKey: entitlement.idempotencyKey,
959
- createdAt: entitlement.createdAt,
960
- debits: [...entitlement.debits],
961
- });
962
- const toActiveSubscription = (subscription) => ({
963
- store,
964
- logicalId: subscription.logicalId,
965
- subscriptionId: subscription.subscriptionId,
966
- periodIndex: subscription.periodIndex,
967
- state: subscription.state,
968
- startedAt: subscription.startedAt,
969
- expiresAt: subscription.expiresAt,
970
- autoRenew: subscription.state === 'active',
961
+ const { clock } = input;
962
+ const wallet = createInMemoryCreditWallet({
963
+ store: input.store,
964
+ currencies: input.currencies,
965
+ now: () => clock.now(),
966
+ ...(input.offers === undefined ? {} : { offers: input.offers }),
971
967
  });
972
- const emitSubscription = (subscription, type, periodIndex, reason) => {
973
- const eventId = `${subscription.subscriptionId}:${periodIndex}:${type}`;
974
- if (subscriptionEvents.some((event) => event.eventId === eventId)) {
975
- return;
976
- }
977
- subscriptionEvents.push({
978
- eventId,
979
- type,
980
- subscriptionId: subscription.subscriptionId,
981
- logicalId: subscription.logicalId,
982
- periodIndex,
983
- expiresAt: subscription.expiresAt,
984
- ...(reason === undefined ? {} : { reason }),
985
- });
986
- const event = {
987
- type: 'subscriptionUpdated',
988
- subscription: toActiveSubscription(subscription),
989
- };
990
- if (listeners.size === 0) {
991
- buffer.push(event);
992
- return;
993
- }
994
- for (const listener of listeners) {
995
- listener(event);
996
- }
997
- };
998
- const eligibleLots = (titleId, currency, scope) => {
999
- const now = Date.parse(clock.now());
1000
- return lots
1001
- .filter((lot) => lot.currency === currency
1002
- && lot.remaining > 0
1003
- && (lot.expiresAt === undefined || Date.parse(lot.expiresAt) > now)
1004
- && (lot.spendScope.kind === 'cross-title'
1005
- || (scope === 'title' && lot.spendScope.titleId === titleId)))
1006
- .sort(compareCreditLotSpendOrder);
1007
- };
1008
- const spendScoped = async (request, scope) => {
1009
- const replay = spends.get(request.idempotencyKey);
1010
- if (replay !== undefined) {
1011
- return {
1012
- status: 'debited',
1013
- entitlementId: replay.entitlementId,
1014
- debits: [...replay.debits],
1015
- alreadyProcessed: true,
1016
- };
1017
- }
1018
- if (!currencies.has(request.currency)) {
1019
- return {
1020
- status: 'rejected',
1021
- error: {
1022
- code: 'ITEM_UNAVAILABLE',
1023
- retryable: false,
1024
- message: `Currency ${request.currency} is not declared by the wallet.`,
1025
- },
1026
- };
1027
- }
1028
- if (!(request.amount > 0)) {
1029
- return {
1030
- status: 'rejected',
1031
- error: {
1032
- code: 'INVALID_RESPONSE',
1033
- retryable: false,
1034
- message: 'Spend amount must be positive.',
1035
- },
1036
- };
1037
- }
1038
- const candidates = eligibleLots(request.titleId, request.currency, scope);
1039
- const available = candidates.reduce((sum, lot) => sum + lot.remaining, 0);
1040
- if (available < request.amount) {
1041
- return {
1042
- status: 'rejected',
1043
- error: {
1044
- code: 'INSUFFICIENT_BALANCE',
1045
- retryable: false,
1046
- message: `Eligible ${request.currency} balance ${available} cannot cover ${request.amount}.`,
1047
- },
1048
- };
1049
- }
1050
- const spent = [];
1051
- let remaining = request.amount;
1052
- for (const lot of candidates) {
1053
- if (remaining <= 0) {
1054
- break;
1055
- }
1056
- const amount = Math.min(lot.remaining, remaining);
1057
- lot.remaining -= amount;
1058
- remaining -= amount;
1059
- spent.push({ lotId: lot.lotId, amount });
1060
- debits.push({ idempotencyKey: request.idempotencyKey, lotId: lot.lotId, amount });
1061
- }
1062
- const entitlementId = request.idempotencyKey;
1063
- spends.set(request.idempotencyKey, { entitlementId, debits: spent });
1064
- if (request.logicalId !== undefined) {
1065
- entitlements.push({
1066
- entitlementId,
1067
- titleId: request.titleId,
1068
- logicalId: request.logicalId,
1069
- idempotencyKey: request.idempotencyKey,
1070
- createdAt: clock.now(),
1071
- debits: [...spent],
1072
- redeemed: false,
1073
- });
1074
- }
1075
- return { status: 'debited', entitlementId, debits: [...spent] };
1076
- };
1077
- const renew = async (subscription) => {
1078
- while (subscription.state === 'active' && Date.parse(subscription.expiresAt) <= Date.parse(clock.now())) {
1079
- const nextPeriod = subscription.periodIndex + 1;
1080
- const result = await spendScoped({
1081
- titleId: subscription.titleId,
1082
- currency: subscription.terms.price.currency,
1083
- amount: subscription.terms.price.amount,
1084
- idempotencyKey: `membership:${subscription.subscriptionId}:${nextPeriod}`,
1085
- }, subscription.terms.scope);
1086
- if (result.status === 'rejected') {
1087
- subscription.state = 'lapsed';
1088
- emitSubscription(subscription, 'subscriptionLapsed', nextPeriod, result.error.code);
1089
- return;
1090
- }
1091
- subscription.periodIndex = nextPeriod;
1092
- subscription.expiresAt = addIsoDuration(subscription.expiresAt, subscription.terms.period);
1093
- emitSubscription(subscription, 'subscriptionRenewed', nextPeriod);
1094
- }
1095
- };
1096
968
  return {
1097
- store,
969
+ store: wallet.store,
1098
970
  get lots() {
1099
- return lots.map(snapshotLot);
971
+ return wallet.lots;
1100
972
  },
1101
973
  get debits() {
1102
- return [...debits];
974
+ return wallet.debits;
1103
975
  },
1104
976
  get entitlements() {
1105
- return entitlements.map(toEntitlement);
977
+ return wallet.entitlements;
1106
978
  },
1107
979
  get subscriptionEvents() {
1108
- return [...subscriptionEvents];
980
+ return wallet.subscriptionEvents;
1109
981
  },
1110
- creditFromGrant(fixture, options = {}) {
1111
- const existing = lots.find((lot) => lot.lotId === fixture.ledgerEntryId);
1112
- if (existing !== undefined) {
1113
- return snapshotLot(existing);
1114
- }
1115
- if (!currencies.has(fixture.currency)) {
1116
- throw new CommerceConformanceScriptError(`Lot ${fixture.ledgerEntryId} uses currency ${fixture.currency}, which the wallet does not declare.`);
1117
- }
1118
- const lot = {
1119
- lotId: fixture.ledgerEntryId,
1120
- currency: fixture.currency,
1121
- amount: fixture.amount,
1122
- remaining: fixture.amount,
1123
- createdAt: normalizeIsoTimestamp(fixture.createdAt),
1124
- ...(fixture.expiresAt === undefined
1125
- ? {}
1126
- : { expiresAt: normalizeIsoTimestamp(fixture.expiresAt) }),
1127
- origin: { ...fixture.origin },
1128
- spendScope: deriveCreditLotSpendScope(fixture.origin, options.spendScope),
1129
- };
1130
- lots.push(lot);
1131
- return snapshotLot(lot);
982
+ get offers() {
983
+ return wallet.offers;
1132
984
  },
1133
- spend: (request) => spendScoped(request, 'title'),
1134
- async startMembership(request) {
1135
- const current = subscriptions.get(request.subscriptionId);
1136
- if (current !== undefined && current.state === 'active') {
1137
- return {
1138
- status: 'started',
1139
- subscription: toActiveSubscription(current),
1140
- entitlementId: `membership:${request.subscriptionId}:0`,
1141
- };
1142
- }
1143
- // The terms are validated before any credit moves, so malformed terms never charge.
1144
- const startedAt = clock.now();
1145
- let expiresAt;
985
+ addOffers: (offers) => wallet.addOffers(offers),
986
+ creditFromGrant(fixture, options = {}) {
1146
987
  try {
1147
- expiresAt = addIsoDuration(startedAt, request.terms.period);
1148
- }
1149
- catch {
1150
- return {
1151
- status: 'rejected',
1152
- error: {
1153
- code: 'ITEM_UNAVAILABLE',
1154
- retryable: false,
1155
- message: `Membership period ${request.terms.period} is not an ISO-8601 duration.`,
1156
- },
1157
- };
1158
- }
1159
- const result = await spendScoped({
1160
- titleId: request.titleId,
1161
- currency: request.terms.price.currency,
1162
- amount: request.terms.price.amount,
1163
- idempotencyKey: `membership:${request.subscriptionId}:0`,
1164
- logicalId: request.logicalId,
1165
- }, request.terms.scope);
1166
- if (result.status === 'rejected') {
1167
- return result;
1168
- }
1169
- const subscription = {
1170
- subscriptionId: request.subscriptionId,
1171
- titleId: request.titleId,
1172
- logicalId: request.logicalId,
1173
- terms: request.terms,
1174
- periodIndex: 0,
1175
- startedAt,
1176
- expiresAt,
1177
- state: 'active',
1178
- };
1179
- subscriptions.set(subscription.subscriptionId, subscription);
1180
- emitSubscription(subscription, 'subscriptionStarted', 0);
1181
- return {
1182
- status: 'started',
1183
- subscription: toActiveSubscription(subscription),
1184
- entitlementId: result.entitlementId,
1185
- };
1186
- },
1187
- async renewDue() {
1188
- for (const subscription of subscriptions.values()) {
1189
- await renew(subscription);
1190
- }
1191
- },
1192
- async listOffers() {
1193
- return [...(input.offers ?? [])];
1194
- },
1195
- async listUnredeemed(request) {
1196
- return entitlements
1197
- .filter((entitlement) => !entitlement.redeemed && entitlement.titleId === request.titleId)
1198
- .map(toEntitlement);
1199
- },
1200
- async redeem(request) {
1201
- const entitlement = entitlements.find((candidate) => candidate.entitlementId === request.entitlementId);
1202
- if (entitlement === undefined) {
1203
- return { redeemed: false };
1204
- }
1205
- entitlement.redeemed = true;
1206
- return { redeemed: true };
1207
- },
1208
- finalize(request) {
1209
- const entitlement = entitlements.find((candidate) => candidate.entitlementId === request.transactionId);
1210
- if (entitlement !== undefined) {
1211
- entitlement.redeemed = true;
988
+ return wallet.creditFromGrant(fixture, options.spendScope === undefined ? {} : { explicit: options.spendScope });
1212
989
  }
1213
- },
1214
- async getBalance(request) {
1215
- const balances = {};
1216
- for (const currency of currencies) {
1217
- balances[currency] = eligibleLots(request.titleId, currency, 'title').reduce((sum, lot) => sum + lot.remaining, 0);
990
+ catch (error) {
991
+ throw new CommerceConformanceScriptError(describeError(error));
1218
992
  }
1219
- return { titleId: request.titleId, balances };
1220
993
  },
1221
- async getActiveSubscriptions() {
1222
- return [...subscriptions.values()]
1223
- .filter((subscription) => subscription.state === 'active')
1224
- .map(toActiveSubscription);
1225
- },
1226
- subscribe(listener) {
1227
- const first = listeners.size === 0;
1228
- listeners.add(listener);
1229
- if (first) {
1230
- buffer.drain(listener);
1231
- }
1232
- return () => {
1233
- listeners.delete(listener);
1234
- };
994
+ spend: (request) => wallet.spend(request),
995
+ startMembership: (request) => wallet.startMembership(request),
996
+ renewDue: () => wallet.renewDue(),
997
+ listOffers: (request) => wallet.listOffers(request),
998
+ listUnredeemed: (request) => wallet.listUnredeemed(request),
999
+ redeem: (request) => wallet.redeem(request),
1000
+ async finalize(request) {
1001
+ await wallet.redeem({
1002
+ entitlementId: request.transactionId,
1003
+ ledgerEntryId: request.ledgerEntryId,
1004
+ });
1235
1005
  },
1006
+ getBalance: (request) => wallet.getBalance(request),
1007
+ getActiveSubscriptions: (request) => wallet.getActiveSubscriptions(request),
1008
+ subscribe: (listener, request) => wallet.subscribe(listener, request),
1236
1009
  };
1237
1010
  }
1238
- /** Store lots are title-scoped, off-store lots cross-title, promotion lots as issued (title by default). */
1239
- export function deriveCreditLotSpendScope(origin, explicit) {
1240
- switch (origin.channel) {
1241
- case 'store':
1242
- return { kind: 'title', titleId: origin.titleId };
1243
- case 'off-store':
1244
- return { kind: 'cross-title' };
1245
- case 'promotion':
1246
- return explicit ?? { kind: 'title', titleId: origin.titleId };
1247
- }
1248
- }
1249
- /** RFC spend order: earliest `expiresAt` (none last), `title` before `cross-title`, oldest `createdAt`, then `lotId`. */
1250
- export function compareCreditLotSpendOrder(left, right) {
1251
- const leftExpiry = left.expiresAt === undefined
1252
- ? Number.POSITIVE_INFINITY
1253
- : Date.parse(left.expiresAt);
1254
- const rightExpiry = right.expiresAt === undefined
1255
- ? Number.POSITIVE_INFINITY
1256
- : Date.parse(right.expiresAt);
1257
- if (leftExpiry !== rightExpiry) {
1258
- return leftExpiry < rightExpiry ? -1 : 1;
1259
- }
1260
- if (left.spendScope.kind !== right.spendScope.kind) {
1261
- return left.spendScope.kind === 'title' ? -1 : 1;
1262
- }
1263
- const createdDelta = Date.parse(left.createdAt) - Date.parse(right.createdAt);
1264
- if (createdDelta !== 0) {
1265
- return createdDelta < 0 ? -1 : 1;
1266
- }
1267
- return left.lotId < right.lotId ? -1 : left.lotId > right.lotId ? 1 : 0;
1268
- }
1269
1011
  /**
1270
- * `CommerceProvider` over the reference wallet: `fetchProducts` lists the
1271
- * wallet's offers (catalog-derived offers only when the wallet lists none),
1272
- * `requestPurchase` spends the offer price times the requested quantity
1273
- * (outcome `purchased`, `settlement` set, evidence adapter-settled),
1274
- * `getAvailablePurchases` lists unredeemed entitlements, and finishing is the
1275
- * backend's `redeem`. Memberships exist only where the billing authority is
1276
- * `host`.
1012
+ * `createCreditCommerceProvider` over the reference wallet with the vector
1013
+ * profile as its capability block. A wallet that lists no offers has the
1014
+ * vector catalog registered as its offers first (a fixture price of one unit
1015
+ * of the first wallet currency for entries without membership terms), so the
1016
+ * wallet prices every spend and starts memberships itself. Memberships exist
1017
+ * only where the billing authority is `host`.
1277
1018
  */
1278
1019
  export function createCommerceConformanceWalletProvider(input) {
1279
1020
  const { wallet, catalog, titleId, clock } = input;
1280
- const store = input.profile.store;
1281
- const membershipsOffered = input.billingAuthority === 'host';
1282
- const defaultCurrency = input.currencies[0] ?? 'credits';
1283
- const capabilities = () => {
1284
- const base = createCommerceConformanceCapabilities(input.profile);
1285
- return {
1286
- ...base,
1287
- purchase: { ...base.purchase, subscription: base.purchase.subscription && membershipsOffered },
1288
- };
1289
- };
1021
+ const defaultCurrency = input.currencies[0];
1022
+ if (defaultCurrency === undefined) {
1023
+ throw new CommerceConformanceScriptError('The wallet profile declares no currencies.');
1024
+ }
1290
1025
  const toOffer = (entry) => ({
1291
1026
  logicalId: entry.logicalId,
1292
1027
  storeSku: entry.storeSku,
@@ -1297,112 +1032,23 @@ export function createCommerceConformanceWalletProvider(input) {
1297
1032
  price: entry.membership?.price ?? { currency: defaultCurrency, amount: 1 },
1298
1033
  ...(entry.membership === undefined ? {} : { membership: entry.membership }),
1299
1034
  });
1300
- /** The wallet's own offers decide availability and price; the catalog fills in only when it lists none. */
1301
- const listOffers = async () => {
1302
- const offers = await wallet.listOffers({ titleId });
1303
- return (offers.length === 0 ? catalog.map(toOffer) : offers).filter((offer) => offer.membership === undefined || membershipsOffered);
1304
- };
1305
- /** Quantities of purchases made through this provider, so re-listed entitlements keep them. */
1306
- const quantities = new Map();
1307
- const toPurchase = (offer, entitlementId, createdAt, quantity) => ({
1308
- store,
1309
- transactionId: entitlementId,
1310
- storeSku: offer.storeSku,
1311
- logicalId: offer.logicalId,
1312
- quantity,
1313
- state: 'purchased',
1314
- purchasedAt: createdAt,
1315
- token: entitlementId,
1316
- evidence: { schema: `fixture.${store}.entitlement.v1`, payload: { entitlementId } },
1317
- accountBinding: { kind: 'host-identity' },
1318
- settlement: { ledgerEntryId: entitlementId },
1319
- });
1320
- const unavailable = (message) => ({
1321
- outcome: 'failed',
1322
- error: new CommerceError('ITEM_UNAVAILABLE', { message }).toCommerceErrorInfo(),
1323
- });
1324
- return {
1325
- store,
1326
- async getCapabilities() {
1327
- return capabilities();
1328
- },
1329
- async fetchProducts(request) {
1330
- return (await listOffers())
1331
- .filter((offer) => matchesFetchProducts(offer, request))
1332
- .map((offer) => ({
1333
- logicalId: offer.logicalId,
1334
- store,
1335
- storeSku: offer.storeSku,
1336
- kind: offer.kind,
1337
- title: offer.title,
1338
- description: offer.description,
1339
- price: {
1340
- formatted: `${offer.price.amount} ${offer.price.currency}`,
1341
- currencyCode: offer.price.currency,
1342
- },
1343
- }));
1344
- },
1345
- async requestPurchase(request) {
1346
- const offer = (await listOffers()).find((candidate) => candidate.logicalId === request.logicalId);
1347
- if (offer === undefined) {
1348
- return unavailable(`The wallet offers no product ${request.logicalId}.`);
1349
- }
1350
- const quantity = request.quantity ?? 1;
1351
- if (quantity > 1 && !capabilities().purchase.quantity) {
1352
- return unavailable('The wallet does not support purchase quantities above 1.');
1353
- }
1354
- if (offer.membership !== undefined) {
1355
- if (quantity > 1) {
1356
- return unavailable('A membership is purchased one period at a time.');
1357
- }
1358
- const started = await wallet.startMembership({
1359
- subscriptionId: request.idempotencyKey,
1360
- titleId,
1361
- logicalId: offer.logicalId,
1362
- terms: offer.membership,
1363
- });
1364
- if (started.status === 'rejected') {
1365
- return { outcome: 'failed', error: started.error };
1366
- }
1367
- quantities.set(started.entitlementId, 1);
1368
- return {
1369
- outcome: 'purchased',
1370
- purchase: toPurchase(offer, started.entitlementId, started.subscription.startedAt, 1),
1371
- };
1372
- }
1373
- const spend = await wallet.spend({
1374
- titleId,
1375
- currency: offer.price.currency,
1376
- amount: offer.price.amount * quantity,
1377
- idempotencyKey: request.idempotencyKey,
1378
- logicalId: offer.logicalId,
1379
- });
1380
- if (spend.status === 'rejected') {
1381
- return { outcome: 'failed', error: spend.error };
1382
- }
1383
- quantities.set(spend.entitlementId, quantity);
1384
- return {
1385
- outcome: 'purchased',
1386
- purchase: toPurchase(offer, spend.entitlementId, clock.now(), quantity),
1387
- };
1388
- },
1389
- async getAvailablePurchases() {
1390
- const offers = await listOffers();
1391
- const unredeemed = await wallet.listUnredeemed({ titleId });
1392
- return unredeemed.flatMap((entitlement) => {
1393
- const offer = offers.find((candidate) => candidate.logicalId === entitlement.logicalId);
1394
- return offer === undefined
1395
- ? []
1396
- : [toPurchase(offer, entitlement.entitlementId, entitlement.createdAt, quantities.get(entitlement.entitlementId) ?? 1)];
1397
- });
1398
- },
1399
- async getActiveSubscriptions() {
1400
- return wallet.getActiveSubscriptions();
1401
- },
1402
- subscribe(listener) {
1403
- return wallet.subscribe(listener);
1035
+ if (wallet.offers.length === 0) {
1036
+ wallet.addOffers(catalog.map(toOffer));
1037
+ }
1038
+ const capabilities = createCommerceConformanceCapabilities(input.profile);
1039
+ return createCreditCommerceProvider(wallet, {
1040
+ titleId,
1041
+ billingAuthority: input.billingAuthority,
1042
+ currencies: input.currencies,
1043
+ capabilities: {
1044
+ purchase: capabilities.purchase,
1045
+ catalog: capabilities.catalog,
1046
+ lifecycle: capabilities.lifecycle,
1047
+ authority: capabilities.authority,
1048
+ availability: capabilities.availability,
1404
1049
  },
1405
- };
1050
+ now: () => clock.now(),
1051
+ });
1406
1052
  }
1407
1053
  export function createCommerceConformanceHarness(context) {
1408
1054
  if (context.vector.wallet === undefined) {
@@ -1430,31 +1076,153 @@ export function createCommerceConformanceHarness(context) {
1430
1076
  * each, then matches `expect`. Matching compares only the listed fields; arrays
1431
1077
  * compare in order and by length; a listed `journal` is the whole journal.
1432
1078
  * Any unscripted operation fails the vector at the step that needed it.
1079
+ *
1080
+ * With `options.createProvider`, a vector flagged `profile.adaptive` is first
1081
+ * checked against the provider's declared capabilities
1082
+ * (`commerceConformanceAdaptiveSkipReason`) and then matched against
1083
+ * expectations derived from a reference run under those capabilities
1084
+ * (`deriveAdaptiveCommerceConformanceExpectation`). Duplicate detection needs
1085
+ * the whole run set, so it applies through `runCommerceConformanceVectors`.
1433
1086
  */
1434
1087
  export async function runCommerceConformanceVector(vector, options = {}) {
1435
- const failures = [];
1436
- const playerId = options.playerId ?? commerceConformanceDefaultPlayerId;
1437
- const titleId = options.titleId ?? commerceConformanceDefaultTitleId;
1088
+ return runVectorIn(vector, options, purchaseFinishFamilyOf([vector]));
1089
+ }
1090
+ export async function runCommerceConformanceVectors(vectors, options = {}) {
1091
+ // The `purchase-<owner>-finish` family of this run is fixed up front; the
1092
+ // canonical member per owner is then one lookup per vector.
1093
+ const family = purchaseFinishFamilyOf(vectors);
1094
+ const results = [];
1095
+ for (const vector of vectors) {
1096
+ results.push(await runVectorIn(vector, options, family));
1097
+ }
1098
+ return results;
1099
+ }
1100
+ async function runVectorIn(vector, options, family) {
1101
+ const ids = {
1102
+ playerId: options.playerId ?? commerceConformanceDefaultPlayerId,
1103
+ titleId: options.titleId ?? commerceConformanceDefaultTitleId,
1104
+ };
1105
+ const failed = (failures) => ({ vector: vector.vector, status: 'failed', passed: false, failures });
1106
+ const skipped = (reason) => ({ vector: vector.vector, status: 'skipped', passed: false, failures: [], reason });
1107
+ // A harness that cannot be built is a failed run, never a rejected promise.
1108
+ let execution;
1109
+ try {
1110
+ execution = await prepareExecution(vector, options.createProvider ?? createCommerceConformanceHarness, ids);
1111
+ }
1112
+ catch (error) {
1113
+ return failed([
1114
+ { path: 'harness', expected: 'createProvider resolves', actual: describeError(error) },
1115
+ ]);
1116
+ }
1117
+ let expectation = vector.expect;
1118
+ let adaptive;
1119
+ let adaptation;
1120
+ if (options.createProvider !== undefined && vector.profile.adaptive === true) {
1121
+ let capabilities;
1122
+ try {
1123
+ capabilities = await execution.harness.provider.getCapabilities();
1124
+ }
1125
+ catch (error) {
1126
+ return failed([
1127
+ {
1128
+ path: 'capabilities',
1129
+ expected: 'provider.getCapabilities() resolves',
1130
+ actual: describeError(error),
1131
+ },
1132
+ ]);
1133
+ }
1134
+ const reason = adaptiveSkipReasonWith(vector, capabilities, family);
1135
+ if (reason !== undefined) {
1136
+ return skipped(reason);
1137
+ }
1138
+ const store = execution.harness.provider.store;
1139
+ adaptation = adaptationFor(vector, capabilities);
1140
+ // The reference run reports the tokens the provider under test reports.
1141
+ const tokenOf = execution.harness.store?.tokenOf;
1142
+ const tokenAdaptedVector = tokenOf === undefined
1143
+ ? vector
1144
+ : withStoreTokens(vector, (purchase) => tokenOf.call(execution.harness.store, purchase));
1145
+ let reference;
1146
+ try {
1147
+ reference = await prepareExecution(referenceVectorFor(tokenAdaptedVector, store, capabilities), createCommerceConformanceHarness, ids);
1148
+ }
1149
+ catch (error) {
1150
+ return failed([
1151
+ {
1152
+ path: 'reference.harness',
1153
+ expected: 'reference harness builds',
1154
+ actual: describeError(error),
1155
+ },
1156
+ ]);
1157
+ }
1158
+ const referenceFailures = await executeSteps(reference, adaptation);
1159
+ const referenceFailure = referenceFailures[0];
1160
+ if (referenceFailure !== undefined) {
1161
+ // The reference pieces cannot play the scenario under these capabilities,
1162
+ // which is a property of the vector and the capability set, not of the
1163
+ // provider under test; the step and its error are kept in the reason.
1164
+ return skipped(`the reference run under the provider capabilities failed at ${referenceFailure.path}: `
1165
+ + `expected ${String(referenceFailure.expected)}, got ${String(referenceFailure.actual)}`);
1166
+ }
1167
+ const derived = await deriveAdaptiveExpectation(tokenAdaptedVector, reference, store);
1168
+ if (derived.reason !== undefined) {
1169
+ return skipped(derived.reason);
1170
+ }
1171
+ expectation = derived.expect;
1172
+ adaptive = {
1173
+ store,
1174
+ finish: capabilities.lifecycle.finish,
1175
+ evidence: capabilities.authority.evidence,
1176
+ expect: expectation,
1177
+ };
1178
+ }
1179
+ const failures = await executeSteps(execution, adaptation);
1180
+ if (failures.length > 0) {
1181
+ return { ...failed(failures), ...(adaptive === undefined ? {} : { adaptive }) };
1182
+ }
1183
+ try {
1184
+ await matchExpectations(expectation, { ...execution, failures });
1185
+ }
1186
+ catch (error) {
1187
+ // A provider read that throws while expectations are evaluated is a failed
1188
+ // run, never a rejected promise.
1189
+ failures.push({
1190
+ path: 'expect',
1191
+ expected: 'expectations evaluate',
1192
+ actual: describeError(error),
1193
+ });
1194
+ }
1195
+ return {
1196
+ vector: vector.vector,
1197
+ status: failures.length === 0 ? 'passed' : 'failed',
1198
+ passed: failures.length === 0,
1199
+ failures,
1200
+ ...(adaptive === undefined ? {} : { adaptive }),
1201
+ };
1202
+ }
1203
+ async function prepareExecution(vector, createHarness, ids) {
1438
1204
  const clock = createCommerceConformanceClock();
1439
1205
  const journal = createCommerceConformanceJournal();
1440
1206
  const harnessRef = {};
1207
+ const adaptation = { current: undefined };
1441
1208
  const backend = createCommerceConformanceBackend({
1442
1209
  finalize: async (request) => {
1443
1210
  await harnessRef.current?.store?.finalize?.(request);
1444
1211
  await harnessRef.current?.wallet?.finalize?.(request);
1445
1212
  },
1213
+ finalizeOnReread: () => adaptation.current?.serverFinalization === true,
1446
1214
  });
1447
1215
  const context = {
1448
1216
  vector,
1449
1217
  profile: vector.profile,
1450
1218
  catalog: vector.catalog ?? [],
1451
- playerId,
1452
- titleId,
1219
+ playerId: ids.playerId,
1220
+ titleId: ids.titleId,
1453
1221
  clock,
1454
1222
  backend,
1455
1223
  journal,
1456
1224
  };
1457
- const harness = await (options.createProvider ?? createCommerceConformanceHarness)(context);
1225
+ const harness = await createHarness(context);
1458
1226
  harnessRef.current = harness;
1459
1227
  const runtime = createCommerceConformanceRuntime({
1460
1228
  provider: harness.provider,
@@ -1462,32 +1230,330 @@ export async function runCommerceConformanceVector(vector, options = {}) {
1462
1230
  journal,
1463
1231
  clock,
1464
1232
  catalog: context.catalog,
1465
- playerId,
1233
+ playerId: ids.playerId,
1466
1234
  });
1467
- for (const [index, step] of vector.steps.entries()) {
1235
+ return { vector, harness, runtime, backend, journal, clock, adaptation };
1236
+ }
1237
+ /** Runs every step; a thrown step ends the run with a failure at that step. */
1238
+ async function executeSteps(execution, adaptation) {
1239
+ const failures = [];
1240
+ execution.adaptation.current = adaptation;
1241
+ const context = { ...execution, failures };
1242
+ for (const [index, step] of execution.vector.steps.entries()) {
1468
1243
  const path = `steps[${index}]`;
1469
1244
  try {
1470
- await runStep(step, path, { harness, runtime, backend, clock, failures });
1471
- await runtime.runUntilIdle();
1245
+ await runStep(step, path, context);
1246
+ await execution.runtime.runUntilIdle();
1472
1247
  }
1473
1248
  catch (error) {
1474
1249
  failures.push({
1475
1250
  path,
1476
1251
  expected: `${step.actor}.${step.action} completes`,
1477
- actual: error instanceof Error ? `${error.name}: ${error.message}` : String(error),
1252
+ actual: describeError(error),
1478
1253
  });
1479
- return { vector: vector.vector, passed: false, failures };
1254
+ break;
1480
1255
  }
1481
1256
  }
1482
- await matchExpectations(vector, { harness, runtime, backend, journal, failures });
1483
- return { vector: vector.vector, passed: failures.length === 0, failures };
1257
+ return failures;
1484
1258
  }
1485
- export async function runCommerceConformanceVectors(vectors, options = {}) {
1486
- const results = [];
1487
- for (const vector of vectors) {
1488
- results.push(await runCommerceConformanceVector(vector, options));
1259
+ function describeError(error) {
1260
+ return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
1261
+ }
1262
+ // Adaptive vectors (RFC "Conformance vectors", adaptive vectors).
1263
+ /**
1264
+ * Members of the `purchase-<owner>-finish` family describe the same purchase
1265
+ * under different fixture finish owners; a provider runs the one named after
1266
+ * its own owner, or the first by name when none is (`finish: 'none'`).
1267
+ */
1268
+ const purchaseFinishFamily = /^purchase-(?:client|server|host|none)-finish$/u;
1269
+ export function isAdaptiveCommerceConformanceVector(vector) {
1270
+ return vector.profile.adaptive === true;
1271
+ }
1272
+ /**
1273
+ * Why an adaptive vector does not apply to a provider with `capabilities`, or
1274
+ * `undefined` when it runs. `set` is the whole run (for duplicate detection).
1275
+ * The rules:
1276
+ *
1277
+ * - a `game.requestPurchase` of a product kind the provider does not sell
1278
+ * (`purchase.<kind>` false, by the vector catalog), or with a quantity above
1279
+ * one without `purchase.quantity`, cannot open the store UI;
1280
+ * - a store-side `pending` purchase needs `lifecycle.pending`;
1281
+ * - a store `error` event needs `lifecycle.events`;
1282
+ * - without `lifecycle.events`, a store `complete` or `update` reaches the
1283
+ * reconciler only by polling, so the next `runtime` step after it must be
1284
+ * `reconcile` or `start`;
1285
+ * - a `queueFinish` script needs a `client` or `host` finish owner, since the
1286
+ * provider never calls `finishTransaction` under `server` or `none`;
1287
+ * - within the `purchase-<owner>-finish` family only one member runs: the
1288
+ * one named after the provider's owner, else the first by name.
1289
+ */
1290
+ export function commerceConformanceAdaptiveSkipReason(vector, capabilities, set = [vector]) {
1291
+ return adaptiveSkipReasonWith(vector, capabilities, purchaseFinishFamilyOf(set));
1292
+ }
1293
+ function adaptiveSkipReasonWith(vector, capabilities, family) {
1294
+ const stepReason = stepSkipReason(vector, capabilities);
1295
+ if (stepReason !== undefined) {
1296
+ return stepReason;
1489
1297
  }
1490
- return results;
1298
+ if (purchaseFinishFamily.test(vector.vector)) {
1299
+ const owner = capabilities.lifecycle.finish;
1300
+ // The canonical member must itself be runnable here, so a provider whose
1301
+ // capabilities rule out the member named after its owner still runs one.
1302
+ const runnable = family.filter((member) => stepSkipReason(member, capabilities) === undefined);
1303
+ const named = runnable.find((member) => member.vector === `purchase-${owner}-finish`);
1304
+ const canonical = (named ?? runnable[0])?.vector;
1305
+ if (canonical !== undefined && canonical !== vector.vector) {
1306
+ return `duplicate of ${canonical} for finish='${owner}'`;
1307
+ }
1308
+ }
1309
+ return undefined;
1310
+ }
1311
+ /** The step-level rules of `commerceConformanceAdaptiveSkipReason`, without the family rule. */
1312
+ function stepSkipReason(vector, capabilities) {
1313
+ const { lifecycle, purchase } = capabilities;
1314
+ const owner = lifecycle.finish;
1315
+ const catalog = vector.catalog ?? [];
1316
+ const steps = vector.steps;
1317
+ for (const [index, step] of steps.entries()) {
1318
+ if (step.actor === 'game' && step.action === 'requestPurchase') {
1319
+ const kind = catalog.find((entry) => entry.logicalId === step.input.logicalId)?.kind;
1320
+ if (kind !== undefined && !purchase[kind]) {
1321
+ return `requestPurchase of ${step.input.logicalId} needs purchase.${kind}; `
1322
+ + `the provider declares ${kind}: false`;
1323
+ }
1324
+ const quantity = step.input.quantity ?? 1;
1325
+ if (quantity > 1 && !purchase.quantity) {
1326
+ return `requestPurchase of ${step.input.logicalId} with quantity ${String(quantity)} needs `
1327
+ + 'purchase.quantity; the provider declares quantity: false';
1328
+ }
1329
+ continue;
1330
+ }
1331
+ if (step.actor !== 'store') {
1332
+ continue;
1333
+ }
1334
+ if (!lifecycle.pending
1335
+ && ((step.action === 'complete' || step.action === 'update') && step.purchase.state === 'pending'
1336
+ || (step.action === 'queueRequest'
1337
+ && step.result.outcome === 'purchased'
1338
+ && step.result.purchase.state === 'pending'))) {
1339
+ return 'store-side pending purchase needs lifecycle.pending; the provider declares pending: false';
1340
+ }
1341
+ if (step.action === 'error' && !lifecycle.events) {
1342
+ return 'purchaseError event needs lifecycle.events; the provider declares events: false';
1343
+ }
1344
+ if ((step.action === 'complete' || step.action === 'update') && !lifecycle.events) {
1345
+ // Polling ingests the purchase at the next reconcile or start; a step in
1346
+ // between that would have observed the event-driven grant or finish
1347
+ // (another store script, a ledger write) makes the scenario timing-bound.
1348
+ const prefix = `store.${step.action} at steps[${String(index)}] needs lifecycle.events; `
1349
+ + 'the provider declares events: false';
1350
+ if (!lifecycle.availablePurchases) {
1351
+ return `${prefix} and lifecycle.availablePurchases: false, so nothing can poll the purchase`;
1352
+ }
1353
+ let polled = false;
1354
+ for (const [offset, later] of steps.slice(index + 1).entries()) {
1355
+ if (later.actor === 'runtime') {
1356
+ if (later.action === 'stop') {
1357
+ break;
1358
+ }
1359
+ polled = true;
1360
+ break;
1361
+ }
1362
+ if (later.actor === 'clock' || (later.actor === 'backend' && later.action === 'queueVerify')) {
1363
+ continue;
1364
+ }
1365
+ return `${prefix} and ${later.actor}.${later.action} at steps[${String(index + 1 + offset)}] `
1366
+ + 'runs before the reconcile or start that would poll the purchase';
1367
+ }
1368
+ if (!polled) {
1369
+ return `${prefix} and no reconcile or start follows it to poll the purchase`;
1370
+ }
1371
+ }
1372
+ if (step.action === 'queueFinish' && owner !== 'client' && owner !== 'host') {
1373
+ return `queueFinish scripts a client finish; the provider declares finish: '${owner}'`;
1374
+ }
1375
+ }
1376
+ return undefined;
1377
+ }
1378
+ /** The adaptive `purchase-<owner>-finish` members of a run, sorted by name. */
1379
+ function purchaseFinishFamilyOf(set) {
1380
+ return set
1381
+ .filter((candidate) => candidate.profile.adaptive === true && purchaseFinishFamily.test(candidate.vector))
1382
+ .sort((left, right) => (left.vector < right.vector ? -1 : left.vector > right.vector ? 1 : 0));
1383
+ }
1384
+ function adaptationFor(vector, capabilities) {
1385
+ const fixtureOwner = createCommerceConformanceCapabilities(vector.profile).lifecycle.finish;
1386
+ return {
1387
+ serverFinalization: capabilities.lifecycle.finish === 'server' && fixtureOwner !== 'server',
1388
+ };
1389
+ }
1390
+ /**
1391
+ * The vector with every scripted store purchase (`complete`, `update`, and a
1392
+ * `queueRequest` answering `purchased`) carrying the token the store actor
1393
+ * reports for it (`CommerceConformanceStoreActor.tokenOf`).
1394
+ */
1395
+ export function withStoreTokens(vector, tokenOf) {
1396
+ const withToken = (purchase) => ({ ...purchase, token: tokenOf(purchase) });
1397
+ return {
1398
+ ...vector,
1399
+ steps: vector.steps.map((step) => {
1400
+ if (step.actor !== 'store') {
1401
+ return step;
1402
+ }
1403
+ if (step.action === 'complete' || step.action === 'update') {
1404
+ return { ...step, purchase: withToken(step.purchase) };
1405
+ }
1406
+ if (step.action === 'queueRequest' && step.result.outcome === 'purchased') {
1407
+ return { ...step, result: { ...step.result, purchase: withToken(step.result.purchase) } };
1408
+ }
1409
+ return step;
1410
+ }),
1411
+ };
1412
+ }
1413
+ /** The vector with the provider's store id and declared capabilities as its profile. */
1414
+ function referenceVectorFor(vector, store, capabilities) {
1415
+ return {
1416
+ ...vector,
1417
+ profile: {
1418
+ store,
1419
+ purchase: capabilities.purchase,
1420
+ lifecycle: capabilities.lifecycle,
1421
+ authority: capabilities.authority,
1422
+ adaptive: true,
1423
+ },
1424
+ };
1425
+ }
1426
+ /**
1427
+ * Derives the adaptive expectation for `vector` from the observable state of a
1428
+ * finished reference run under the provider's capabilities, applying the
1429
+ * rules the RFC table states:
1430
+ *
1431
+ * - `journal`: the reference journal, so the tail after `granted` is
1432
+ * `finishing, finished` for `client`, `host`, and `server` owners and
1433
+ * `finished` for `none`; derived keys carry the provider's store id;
1434
+ * - `finishCalls`: the reference store's calls, `by` the owner for `client`
1435
+ * and `host`, none for `server` and `none`;
1436
+ * - `verifyRequests`: the reference backend's log, with the finalization
1437
+ * re-read a `server` owner adds unless the grant already reported
1438
+ * `finalization: 'completed'`;
1439
+ * - `available`: what the reference store still lists once idle, so empty
1440
+ * after a completed finish for `client`, `host`, and `server` owners.
1441
+ *
1442
+ * Fields the fixture does not assert stay unasserted. The derivation is
1443
+ * refused (a skip reason) when the reference run does not end every journal
1444
+ * key in the fixture's terminal state, since the scenario then depends on a
1445
+ * capability the provider lacks.
1446
+ */
1447
+ export async function deriveAdaptiveCommerceConformanceExpectation(vector, reference) {
1448
+ const { expect } = vector;
1449
+ if (expect.journal !== undefined) {
1450
+ const adapted = new Map();
1451
+ for (const fixtureKey of Object.keys(expect.journal)) {
1452
+ adapted.set(fixtureKey, await adaptDerivedPurchaseKey(vector, fixtureKey, reference.store));
1453
+ }
1454
+ for (const [fixtureKey, states] of Object.entries(expect.journal)) {
1455
+ const key = adapted.get(fixtureKey) ?? fixtureKey;
1456
+ const terminal = states.at(-1);
1457
+ const actual = reference.journal[key]?.at(-1);
1458
+ if (actual !== terminal) {
1459
+ return {
1460
+ reason: `the reference run under the provider capabilities ends ${key} in `
1461
+ + `'${String(actual)}', not '${String(terminal)}'`,
1462
+ };
1463
+ }
1464
+ }
1465
+ const expectedKeys = new Set(adapted.values());
1466
+ const extra = Object.keys(reference.journal).filter((key) => !expectedKeys.has(key));
1467
+ if (extra.length > 0) {
1468
+ return {
1469
+ reason: `the reference run under the provider capabilities journals ${extra.join(', ')}, `
1470
+ + 'which the vector does not expect',
1471
+ };
1472
+ }
1473
+ }
1474
+ return {
1475
+ expect: {
1476
+ ...expect,
1477
+ ...(expect.journal === undefined ? {} : { journal: reference.journal }),
1478
+ ...(expect.verifyRequests === undefined
1479
+ ? {}
1480
+ : {
1481
+ verifyRequests: reference.verifyRequests.map((request) => ({
1482
+ idempotencyKey: request.idempotencyKey,
1483
+ store: request.store,
1484
+ transactionId: request.transactionId,
1485
+ token: request.token,
1486
+ })),
1487
+ }),
1488
+ ...(expect.finishCalls === undefined ? {} : { finishCalls: reference.finishCalls }),
1489
+ ...(expect.available === undefined
1490
+ ? {}
1491
+ : {
1492
+ available: reference.available.map((purchase) => ({
1493
+ transactionId: purchase.transactionId,
1494
+ storeSku: purchase.storeSku,
1495
+ state: purchase.state,
1496
+ })),
1497
+ }),
1498
+ },
1499
+ };
1500
+ }
1501
+ /**
1502
+ * A fixture journal key as the provider's store id derives it: only the
1503
+ * encoded store segment of a `store:<store>:<transactionId>` key
1504
+ * (`createDerivedPurchaseIdempotencyKey`) is replaced, so a transaction id
1505
+ * that happens to contain the fixture store id is left alone. Game-chosen
1506
+ * keys and store-specific key forms carry no such prefix and pass through.
1507
+ */
1508
+ export function adaptDerivedPurchaseKeyStore(key, fixtureStore, providerStore) {
1509
+ const fixturePrefix = `store:${encodeURIComponent(fixtureStore)}:`;
1510
+ return key.startsWith(fixturePrefix)
1511
+ ? `store:${encodeURIComponent(providerStore)}:${key.slice(fixturePrefix.length)}`
1512
+ : key;
1513
+ }
1514
+ /**
1515
+ * A fixture journal key as the provider's store id derives it. When the key is
1516
+ * the derived key of a purchase the vector scripts under the fixture store, it
1517
+ * is re-derived with `createDerivedPurchaseIdempotencyKey` for the provider
1518
+ * store, which also covers the store-specific key forms (`google-play` token
1519
+ * keys, `apps-in-toss:purchase:<id>`); otherwise only the generic
1520
+ * `store:<store>:` prefix is swapped (`adaptDerivedPurchaseKeyStore`).
1521
+ */
1522
+ export async function adaptDerivedPurchaseKey(vector, key, providerStore) {
1523
+ const fixtureStore = vector.profile.store;
1524
+ for (const purchase of scriptedPurchasesOf(vector)) {
1525
+ const derive = (store) => createDerivedPurchaseIdempotencyKey({
1526
+ store,
1527
+ transactionId: purchase.transactionId,
1528
+ token: purchase.token,
1529
+ });
1530
+ if (await derive(fixtureStore) === key) {
1531
+ return derive(providerStore);
1532
+ }
1533
+ }
1534
+ return adaptDerivedPurchaseKeyStore(key, fixtureStore, providerStore);
1535
+ }
1536
+ function scriptedPurchasesOf(vector) {
1537
+ return vector.steps.flatMap((step) => {
1538
+ if (step.actor !== 'store') {
1539
+ return [];
1540
+ }
1541
+ if (step.action === 'complete' || step.action === 'update') {
1542
+ return [step.purchase];
1543
+ }
1544
+ return step.action === 'queueRequest' && step.result.outcome === 'purchased'
1545
+ ? [step.result.purchase]
1546
+ : [];
1547
+ });
1548
+ }
1549
+ async function deriveAdaptiveExpectation(vector, reference, store) {
1550
+ return deriveAdaptiveCommerceConformanceExpectation(vector, {
1551
+ store,
1552
+ journal: reference.journal.snapshot(),
1553
+ verifyRequests: reference.backend.verifyRequests,
1554
+ finishCalls: reference.harness.store?.finishCalls ?? [],
1555
+ available: await reference.harness.provider.getAvailablePurchases({ includePending: true }),
1556
+ });
1491
1557
  }
1492
1558
  function storeActorOf(context, step) {
1493
1559
  if (context.harness.store === undefined) {
@@ -1604,8 +1670,7 @@ async function runStep(step, path, context) {
1604
1670
  return;
1605
1671
  }
1606
1672
  }
1607
- async function matchExpectations(vector, context) {
1608
- const { expect } = vector;
1673
+ async function matchExpectations(expect, context) {
1609
1674
  const { failures, harness } = context;
1610
1675
  if (expect.journal !== undefined) {
1611
1676
  const actual = context.journal.snapshot();