@stamprally/core 0.17.0 → 0.18.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/dist/index.js CHANGED
@@ -108,78 +108,20 @@ function issueClaimTicketNumber(reward2, currentState, options = {}) {
108
108
  }
109
109
 
110
110
  // src/engine/sync.ts
111
- function latestTimestamp(serverTimestamp, localTimestamp) {
112
- const serverTime = Date.parse(serverTimestamp);
113
- const localTime = Date.parse(localTimestamp);
114
- if (!Number.isNaN(serverTime) && !Number.isNaN(localTime))
115
- return serverTime >= localTime ? serverTimestamp : localTimestamp;
116
- if (!Number.isNaN(serverTime)) return serverTimestamp;
117
- if (!Number.isNaN(localTime)) return localTimestamp;
118
- return serverTimestamp >= localTimestamp ? serverTimestamp : localTimestamp;
119
- }
120
- function mergeRewardStates(serverRewards, localRewards) {
121
- const merged = new Map(serverRewards.map((reward2) => [reward2.rewardId, reward2]));
122
- for (const localReward of localRewards) {
123
- const serverReward = merged.get(localReward.rewardId);
124
- if (serverReward === void 0) {
125
- merged.set(localReward.rewardId, localReward);
126
- continue;
127
- }
128
- const winner = localReward.status === "CONSUMED" && serverReward.status !== "CONSUMED" ? localReward : serverReward;
129
- merged.set(localReward.rewardId, {
130
- ...winner,
131
- ...serverReward.unlockedAt === void 0 && localReward.unlockedAt === void 0 ? {} : {
132
- unlockedAt: serverReward.unlockedAt === void 0 ? localReward.unlockedAt : localReward.unlockedAt === void 0 ? serverReward.unlockedAt : latestTimestamp(serverReward.unlockedAt, localReward.unlockedAt)
133
- },
134
- ...serverReward.consumedAt === void 0 && localReward.consumedAt === void 0 ? {} : {
135
- consumedAt: serverReward.consumedAt === void 0 ? localReward.consumedAt : localReward.consumedAt === void 0 ? serverReward.consumedAt : latestTimestamp(serverReward.consumedAt, localReward.consumedAt)
136
- },
137
- ...serverReward.redeemedCount === void 0 && localReward.redeemedCount === void 0 ? {} : {
138
- redeemedCount: Math.max(
139
- serverReward.redeemedCount ?? 0,
140
- localReward.redeemedCount ?? 0
141
- )
142
- },
143
- ...serverReward.userRedemptionCount === void 0 && localReward.userRedemptionCount === void 0 ? {} : {
144
- userRedemptionCount: Math.max(
145
- serverReward.userRedemptionCount ?? 0,
146
- localReward.userRedemptionCount ?? 0
147
- )
148
- }
149
- });
150
- }
151
- return [...merged.values()];
152
- }
153
- function mergeStampRecords(serverRecords, localRecords) {
154
- const merged = /* @__PURE__ */ new Map();
155
- for (const record of [...serverRecords, ...localRecords]) {
156
- const current = merged.get(record.stampId);
157
- if (current === void 0) {
158
- merged.set(record.stampId, record);
159
- continue;
160
- }
161
- const acquiredAt = latestTimestamp(current.acquiredAt, record.acquiredAt);
162
- merged.set(record.stampId, {
163
- ...current,
164
- ...acquiredAt === current.acquiredAt ? {} : { acquiredAt },
165
- ...current.metadata === void 0 && record.metadata !== void 0 ? { metadata: record.metadata } : {}
166
- });
167
- }
168
- return [...merged.values()];
169
- }
170
- function resolveRallyStateConflict(serverState, localState, options = { policy: "merge" }) {
171
- if (options.policy === "server_wins") return serverState;
172
- if (options.policy === "authoritative_replay")
173
- return {
174
- ...serverState,
175
- records: serverState.records.map((record) => ({ ...record })),
176
- rewards: serverState.rewards.map((reward2) => ({ ...reward2 }))
177
- };
111
+ function resolveRallyStateConflict(serverState, _localState) {
178
112
  return {
179
113
  ...serverState,
180
- records: mergeStampRecords(serverState.records, localState.records),
181
- rewards: mergeRewardStates(serverState.rewards, localState.rewards),
182
- updatedAt: latestTimestamp(serverState.updatedAt, localState.updatedAt)
114
+ records: serverState.records.map((record) => ({
115
+ ...record,
116
+ ...record.metadata === void 0 ? {} : { metadata: { ...record.metadata } }
117
+ })),
118
+ rewards: serverState.rewards.map((reward2) => ({ ...reward2 })),
119
+ ...serverState.inventory === void 0 ? {} : {
120
+ inventory: {
121
+ ...serverState.inventory,
122
+ ...serverState.inventory.rewardRemaining === void 0 ? {} : { rewardRemaining: { ...serverState.inventory.rewardRemaining } }
123
+ }
124
+ }
183
125
  };
184
126
  }
185
127
 
@@ -1272,8 +1214,6 @@ var OfflineQueue = class {
1272
1214
  #configuredKey;
1273
1215
  #rallyId;
1274
1216
  #userId;
1275
- #conflictPolicy;
1276
- #onSyncConflict;
1277
1217
  #operations = [];
1278
1218
  #rejectedHistory = [];
1279
1219
  #loaded = false;
@@ -1289,6 +1229,8 @@ var OfflineQueue = class {
1289
1229
  #lockStorage;
1290
1230
  #observedLocks = /* @__PURE__ */ new Map();
1291
1231
  #warnedMemoryLock = false;
1232
+ #capabilityWarningListener;
1233
+ #replayConfig;
1292
1234
  #storageListener;
1293
1235
  #channel = null;
1294
1236
  constructor(options = {}) {
@@ -1306,8 +1248,6 @@ var OfflineQueue = class {
1306
1248
  this.#configuredKey = options.key;
1307
1249
  this.#rallyId = options.rallyId;
1308
1250
  this.#userId = options.userId ?? null;
1309
- this.#conflictPolicy = options.conflictPolicy ?? "authoritative_replay";
1310
- this.#onSyncConflict = options.onSyncConflict;
1311
1251
  this.#synchronizeInstances = options.synchronizeInstances ?? true;
1312
1252
  const retryOptions = {
1313
1253
  ...DEFAULT_RETRY_OPTIONS,
@@ -1330,6 +1270,14 @@ var OfflineQueue = class {
1330
1270
  get queueCapability() {
1331
1271
  return this.#queueCapability;
1332
1272
  }
1273
+ get storageCapability() {
1274
+ if (this.#queueCapability === "memory") return "memory";
1275
+ const locks = globalThis.navigator?.locks;
1276
+ return locks !== void 0 || this.#lockStorage !== null ? this.#queueCapability : "volatile_single_tab";
1277
+ }
1278
+ get isStoragePersistent() {
1279
+ return this.#queueCapability !== "memory";
1280
+ }
1333
1281
  get rejectedHistory() {
1334
1282
  return this.#rejectedHistory;
1335
1283
  }
@@ -1339,9 +1287,6 @@ var OfflineQueue = class {
1339
1287
  get operations() {
1340
1288
  return this.#operations;
1341
1289
  }
1342
- get conflictPolicy() {
1343
- return this.#conflictPolicy;
1344
- }
1345
1290
  get storageKey() {
1346
1291
  return this.#storageKey();
1347
1292
  }
@@ -1357,6 +1302,12 @@ var OfflineQueue = class {
1357
1302
  setChangeListener(listener) {
1358
1303
  this.#changeListener = listener;
1359
1304
  }
1305
+ setCapabilityWarningListener(listener) {
1306
+ this.#capabilityWarningListener = listener;
1307
+ }
1308
+ setReplayConfig(config) {
1309
+ this.#replayConfig = config;
1310
+ }
1360
1311
  async initialize() {
1361
1312
  if (this.#loaded) return;
1362
1313
  try {
@@ -1373,6 +1324,8 @@ var OfflineQueue = class {
1373
1324
  `Offline queue persistence is unavailable; queued data can be lost after reload (${String(error)}).`
1374
1325
  );
1375
1326
  }
1327
+ if (this.#queueCapability === "memory")
1328
+ this.#warnMemoryLock("Offline queue persistence is unavailable; queued data is memory-only.");
1376
1329
  this.#loaded = true;
1377
1330
  }
1378
1331
  /** Releases browser listeners when the queue is no longer used. */
@@ -1421,11 +1374,19 @@ var OfflineQueue = class {
1421
1374
  await this.#storage.save(this.#storageKey(), this.#operations);
1422
1375
  this.#announceChange();
1423
1376
  }
1424
- async enqueueCheckIn(request) {
1425
- return this.enqueue({ kind: "checkIn", request });
1377
+ async enqueueCheckIn(request, optimisticState) {
1378
+ return this.enqueue({
1379
+ kind: "checkIn",
1380
+ request,
1381
+ ...optimisticState === void 0 ? {} : { optimisticState }
1382
+ });
1426
1383
  }
1427
- async enqueueClaimReward(request) {
1428
- return this.enqueue({ kind: "claimReward", request });
1384
+ async enqueueClaimReward(request, optimisticState) {
1385
+ return this.enqueue({
1386
+ kind: "claimReward",
1387
+ request,
1388
+ ...optimisticState === void 0 ? {} : { optimisticState }
1389
+ });
1429
1390
  }
1430
1391
  async clear() {
1431
1392
  await this.initialize();
@@ -1433,10 +1394,10 @@ var OfflineQueue = class {
1433
1394
  await this.#storage.save(this.#storageKey(), this.#operations);
1434
1395
  this.#announceChange();
1435
1396
  }
1436
- async discardRejected(operationId) {
1397
+ async discardRejected(operationId2) {
1437
1398
  await this.initialize();
1438
1399
  const next = this.#rejectedHistory.filter(
1439
- (entry) => offlineOperationId(entry.operation) !== operationId
1400
+ (entry) => offlineOperationId(entry.operation) !== operationId2
1440
1401
  );
1441
1402
  if (next.length === this.#rejectedHistory.length) return false;
1442
1403
  this.#rejectedHistory = next;
@@ -1444,13 +1405,13 @@ var OfflineQueue = class {
1444
1405
  this.#announceChange();
1445
1406
  return true;
1446
1407
  }
1447
- async retryRejected(operationId) {
1408
+ async retryRejected(operationId2) {
1448
1409
  await this.initialize();
1449
1410
  const entry = this.#rejectedHistory.find(
1450
- (candidate) => offlineOperationId(candidate.operation) === operationId
1411
+ (candidate) => offlineOperationId(candidate.operation) === operationId2
1451
1412
  );
1452
1413
  if (entry === void 0) return false;
1453
- if (!this.#operations.some((operation) => offlineOperationId(operation) === operationId))
1414
+ if (!this.#operations.some((operation) => offlineOperationId(operation) === operationId2))
1454
1415
  this.#operations = [
1455
1416
  ...this.#operations,
1456
1417
  { ...entry.operation, status: "PENDING", attempts: 0 }
@@ -1461,11 +1422,11 @@ var OfflineQueue = class {
1461
1422
  this.#announceChange();
1462
1423
  return true;
1463
1424
  }
1464
- async discardRejectedOperation(operationId) {
1465
- return this.discardRejected(operationId);
1425
+ async discardRejectedOperation(operationId2) {
1426
+ return this.discardRejected(operationId2);
1466
1427
  }
1467
- async retryRejectedOperation(operationId) {
1468
- return this.retryRejected(operationId);
1428
+ async retryRejectedOperation(operationId2) {
1429
+ return this.retryRejected(operationId2);
1469
1430
  }
1470
1431
  async clearRejectedHistory() {
1471
1432
  await this.initialize();
@@ -1513,7 +1474,7 @@ var OfflineQueue = class {
1513
1474
  if (callbackStarted) throw error;
1514
1475
  }
1515
1476
  }
1516
- if (this.#lockStorage === null)
1477
+ if (this.storageCapability === "volatile_single_tab")
1517
1478
  this.#warnMemoryLock(
1518
1479
  "No cross-tab storage lock is available; offline sync is single-tab only."
1519
1480
  );
@@ -1533,6 +1494,7 @@ var OfflineQueue = class {
1533
1494
  while (this.#operations.length > 0) {
1534
1495
  const operation = this.#operations[0];
1535
1496
  if (operation === void 0) break;
1497
+ if (await this.#rejectFailedPrerequisite(operation)) continue;
1536
1498
  let attempt = 0;
1537
1499
  let response;
1538
1500
  while (true) {
@@ -1610,6 +1572,44 @@ var OfflineQueue = class {
1610
1572
  await this.#storage.save(this.#storageKey(), this.#operations);
1611
1573
  this.#announceChange();
1612
1574
  }
1575
+ async #rejectFailedPrerequisite(operation) {
1576
+ if (operation.kind !== "checkIn" || this.#replayConfig === void 0) return false;
1577
+ const spot2 = this.#replayConfig.spots.find(
1578
+ (candidate) => candidate.id === operation.request.spotId
1579
+ );
1580
+ if (spot2 === void 0) return false;
1581
+ const failedSpots = new Set(
1582
+ this.#rejectedHistory.filter((entry) => entry.operation.kind === "checkIn").map(
1583
+ (entry) => entry.operation.kind === "checkIn" ? entry.operation.request.spotId : void 0
1584
+ ).filter((spotId) => spotId !== void 0)
1585
+ );
1586
+ if (!spot2.prerequisites?.some((prerequisite) => failedSpots.has(prerequisite))) return false;
1587
+ const error = {
1588
+ code: "REJECTED_PREREQUISITE_FAILED",
1589
+ message: "A prerequisite operation was rejected by the server."
1590
+ };
1591
+ const rejectedOperation = { ...operation, status: "REJECTED_PERMANENT" };
1592
+ this.#rejectedHistory = [
1593
+ ...this.#rejectedHistory,
1594
+ {
1595
+ operation: rejectedOperation,
1596
+ reason: error,
1597
+ errorCode: error.code,
1598
+ rejectedAt: (/* @__PURE__ */ new Date()).toISOString(),
1599
+ attempts: operation.attempts ?? 0
1600
+ }
1601
+ ];
1602
+ this.#operations = this.#operations.slice(1);
1603
+ await this.#storage.save(this.#storageKey(), this.#operations);
1604
+ await this.#saveRejectedHistory();
1605
+ this.#announceChange();
1606
+ await this.#syncResultListener?.({
1607
+ operation,
1608
+ status: "REJECTED_PERMANENT",
1609
+ error
1610
+ });
1611
+ return true;
1612
+ }
1613
1613
  #subscribeToExternalChanges() {
1614
1614
  const windowLike = globalThis.window;
1615
1615
  if (windowLike !== void 0) {
@@ -1734,11 +1734,6 @@ var OfflineQueue = class {
1734
1734
  }
1735
1735
  return { status: "ACCEPTED", result: value };
1736
1736
  }
1737
- async resolveConflict(operation, localState, serverState) {
1738
- const configured = this.#onSyncConflict;
1739
- const policy = (typeof configured === "function" ? await configured({ operation, localState, serverState }) : configured) ?? this.#conflictPolicy;
1740
- return resolveRallyStateConflict(serverState, localState, { policy });
1741
- }
1742
1737
  async #saveRejectedHistory() {
1743
1738
  await this.#storage.saveRejectedHistory?.(this.#storageKey(), this.#rejectedHistory);
1744
1739
  }
@@ -1746,6 +1741,12 @@ var OfflineQueue = class {
1746
1741
  if (this.#warnedMemoryLock) return;
1747
1742
  this.#warnedMemoryLock = true;
1748
1743
  console.warn(`[@stamprally/core] ${message}`);
1744
+ this.#capabilityWarningListener?.({
1745
+ type: "STORAGE_CAPABILITY_WARNING",
1746
+ storageCapability: this.storageCapability === "memory" ? "memory" : "volatile_single_tab",
1747
+ isStoragePersistent: this.isStoragePersistent,
1748
+ message
1749
+ });
1749
1750
  }
1750
1751
  };
1751
1752
  function normalizeOperation(operation) {
@@ -1767,6 +1768,100 @@ function normalizeRejectedHistory(entry) {
1767
1768
  };
1768
1769
  }
1769
1770
 
1771
+ // src/client/sync.ts
1772
+ function isReplayable(operation) {
1773
+ return operation.status === void 0 || operation.status === "ACCEPTED" || operation.status === "PENDING" || operation.status === "IN_FLIGHT" || operation.status === "FAILED_RETRYABLE";
1774
+ }
1775
+ function operationId(operation) {
1776
+ const identity = operation.request.userId ?? operation.request.anonymousSessionId ?? "anonymous";
1777
+ return `${operation.kind}:${operation.request.rallyId}:${identity}:${operation.request.idempotencyKey}`;
1778
+ }
1779
+ function applyInventoryDelta(state, previous, optimistic) {
1780
+ const previousInventory = previous.inventory;
1781
+ const optimisticInventory = optimistic.inventory;
1782
+ if (previousInventory === void 0 || optimisticInventory === void 0) return state;
1783
+ const currentInventory = state.inventory ?? {};
1784
+ const sharedDelta = previousInventory.sharedRemaining === void 0 || optimisticInventory.sharedRemaining === void 0 ? void 0 : optimisticInventory.sharedRemaining - previousInventory.sharedRemaining;
1785
+ const previousRewards = previousInventory.rewardRemaining ?? {};
1786
+ const optimisticRewards = optimisticInventory.rewardRemaining ?? {};
1787
+ const currentRewards = currentInventory.rewardRemaining ?? {};
1788
+ const rewardRemaining = { ...currentRewards };
1789
+ for (const key of /* @__PURE__ */ new Set([...Object.keys(previousRewards), ...Object.keys(optimisticRewards)])) {
1790
+ const before = previousRewards[key];
1791
+ const after = optimisticRewards[key];
1792
+ if (before !== void 0 && after !== void 0)
1793
+ rewardRemaining[key] = Math.max(0, (currentRewards[key] ?? before) + after - before);
1794
+ }
1795
+ return {
1796
+ ...state,
1797
+ inventory: {
1798
+ ...currentInventory.sharedRemaining === void 0 || sharedDelta === void 0 ? {} : { sharedRemaining: Math.max(0, currentInventory.sharedRemaining + sharedDelta) },
1799
+ ...Object.keys(rewardRemaining).length === 0 ? {} : { rewardRemaining }
1800
+ }
1801
+ };
1802
+ }
1803
+ function applyOperation(state, operation, config) {
1804
+ if (operation.kind === "checkIn") {
1805
+ const spot2 = config?.spots.find((candidate) => candidate.id === operation.request.spotId);
1806
+ if (spot2?.prerequisites?.some((id2) => !state.records.some((record2) => record2.stampId === id2)))
1807
+ return { state, prerequisiteFailed: true };
1808
+ if (state.records.some((record2) => record2.stampId === operation.request.spotId))
1809
+ return { state, prerequisiteFailed: false };
1810
+ const optimisticRecord = operation.optimisticState?.records.find(
1811
+ (record2) => record2.stampId === operation.request.spotId
1812
+ );
1813
+ const record = optimisticRecord ?? {
1814
+ stampId: operation.request.spotId,
1815
+ acquiredAt: operation.request.now
1816
+ };
1817
+ const records = [...state.records, { ...record }];
1818
+ const rewards2 = config === void 0 ? state.rewards : reconcileRewardStates(config.rewards, state.rewards, records.length, record.acquiredAt);
1819
+ return {
1820
+ state: { ...state, records, rewards: rewards2, updatedAt: record.acquiredAt },
1821
+ prerequisiteFailed: false
1822
+ };
1823
+ }
1824
+ const optimisticState = operation.optimisticState;
1825
+ if (optimisticState === void 0) return { state, prerequisiteFailed: false };
1826
+ const optimisticReward = optimisticState?.rewards.find(
1827
+ (reward2) => reward2.rewardId === operation.request.rewardId
1828
+ );
1829
+ if (optimisticReward === void 0) return { state, prerequisiteFailed: false };
1830
+ const rewards = state.rewards.some((reward2) => reward2.rewardId === optimisticReward.rewardId) ? state.rewards.map(
1831
+ (reward2) => reward2.rewardId === optimisticReward.rewardId ? { ...optimisticReward } : reward2
1832
+ ) : [...state.rewards, { ...optimisticReward }];
1833
+ return {
1834
+ state: applyInventoryDelta(
1835
+ { ...state, rewards, updatedAt: optimisticReward.consumedAt ?? state.updatedAt },
1836
+ operation.request.state,
1837
+ optimisticState
1838
+ ),
1839
+ prerequisiteFailed: false
1840
+ };
1841
+ }
1842
+ function rebuildUserStateFromLog(baselineOrOptions, operationsArgument, configArgument) {
1843
+ const { state } = rebuildUserStateLog(
1844
+ "baseline" in baselineOrOptions ? baselineOrOptions.baseline : baselineOrOptions,
1845
+ "baseline" in baselineOrOptions ? baselineOrOptions.operations : operationsArgument ?? [],
1846
+ "baseline" in baselineOrOptions ? baselineOrOptions.config : configArgument
1847
+ );
1848
+ return state;
1849
+ }
1850
+ function rebuildUserStateLog(baseline, operations, config) {
1851
+ let state = cloneState(baseline);
1852
+ const rejectedOperationIds = [];
1853
+ for (const operation of operations) {
1854
+ if (!isReplayable(operation)) continue;
1855
+ const replay = applyOperation(state, operation, config);
1856
+ if (replay.prerequisiteFailed) {
1857
+ rejectedOperationIds.push(operationId(operation));
1858
+ continue;
1859
+ }
1860
+ state = replay.state;
1861
+ }
1862
+ return { state, rejectedOperationIds };
1863
+ }
1864
+
1770
1865
  // src/client/client.ts
1771
1866
  function isStorage(value) {
1772
1867
  return "load" in value && "save" in value && "remove" in value;
@@ -1832,6 +1927,7 @@ var StampRallyClient = class {
1832
1927
  this.#anonymousSessionId = this.#options.anonymousSessionId ?? createAnonymousSessionId();
1833
1928
  this.#userId = this.#options.userId ?? this.#anonymousSessionId;
1834
1929
  this.#offlineQueue = this.#options.offlineQueue;
1930
+ this.#offlineQueue?.setReplayConfig(config);
1835
1931
  this.#offlineQueue?.setSyncResultListener((event) => this.#handleOfflineSyncResult(event));
1836
1932
  this.#offlineQueue?.setChangeListener(() => {
1837
1933
  this.#syncRevision += 1;
@@ -1865,17 +1961,23 @@ var StampRallyClient = class {
1865
1961
  get queueCapability() {
1866
1962
  return this.#offlineQueue?.queueCapability ?? "custom";
1867
1963
  }
1868
- discardRejected(operationId) {
1869
- return this.#offlineQueue?.discardRejected(operationId) ?? Promise.resolve(false);
1964
+ get storageCapability() {
1965
+ return this.#offlineQueue?.storageCapability ?? "custom";
1966
+ }
1967
+ get isStoragePersistent() {
1968
+ return this.#offlineQueue?.isStoragePersistent ?? true;
1969
+ }
1970
+ discardRejected(operationId2) {
1971
+ return this.#offlineQueue?.discardRejected(operationId2) ?? Promise.resolve(false);
1870
1972
  }
1871
- retryRejected(operationId) {
1872
- return this.#offlineQueue?.retryRejected(operationId) ?? Promise.resolve(false);
1973
+ retryRejected(operationId2) {
1974
+ return this.#offlineQueue?.retryRejected(operationId2) ?? Promise.resolve(false);
1873
1975
  }
1874
- dismissRejectedOperation(operationId) {
1875
- return this.discardRejected(operationId);
1976
+ dismissRejectedOperation(operationId2) {
1977
+ return this.discardRejected(operationId2);
1876
1978
  }
1877
- retryOperation(operationId) {
1878
- return this.retryRejected(operationId);
1979
+ retryOperation(operationId2) {
1980
+ return this.retryRejected(operationId2);
1879
1981
  }
1880
1982
  subscribe(listener) {
1881
1983
  this.#listeners.add(listener);
@@ -2000,13 +2102,13 @@ var StampRallyClient = class {
2000
2102
  return result.ok ? this.#commitCheckIn(result) : this.#fail(result.error);
2001
2103
  } catch (error) {
2002
2104
  if (this.#offlineQueue === void 0) throw error;
2003
- await this.#offlineQueue.enqueueCheckIn(request);
2004
2105
  const record2 = { stampId: spotId, acquiredAt: now };
2005
2106
  const next2 = this.#reconcile({
2006
2107
  ...current,
2007
2108
  records: [...current.records, record2],
2008
2109
  updatedAt: now
2009
2110
  });
2111
+ await this.#offlineQueue.enqueueCheckIn(request, next2);
2010
2112
  await this.#storage.save(next2);
2011
2113
  return this.#commitCheckIn({ ok: true, value: { state: next2, record: record2 } });
2012
2114
  }
@@ -2057,7 +2159,6 @@ var StampRallyClient = class {
2057
2159
  return result.ok ? this.#commitClaim(result) : this.#fail(result.error);
2058
2160
  } catch (error) {
2059
2161
  if (this.#offlineQueue === void 0) throw error;
2060
- await this.#offlineQueue.enqueueClaimReward(request);
2061
2162
  const next2 = {
2062
2163
  ...current,
2063
2164
  rewards: current.rewards.map(
@@ -2065,6 +2166,7 @@ var StampRallyClient = class {
2065
2166
  ),
2066
2167
  updatedAt: now
2067
2168
  };
2169
+ await this.#offlineQueue.enqueueClaimReward(request, next2);
2068
2170
  await this.#storage.save(next2);
2069
2171
  return this.#commitClaim({ ok: true, value: { state: next2, reward: local.value } });
2070
2172
  }
@@ -2107,9 +2209,11 @@ var StampRallyClient = class {
2107
2209
  state: localState,
2108
2210
  ...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
2109
2211
  });
2110
- const resolved = resolveRallyStateConflict(serverState, localState, {
2111
- policy: this.#options.conflictResolutionPolicy ?? this.#options.conflictPolicy ?? "authoritative_replay"
2112
- });
2212
+ const resolved = rebuildUserStateFromLog(
2213
+ serverState,
2214
+ this.#offlineQueue?.operations ?? [],
2215
+ this.#config
2216
+ );
2113
2217
  const next = this.#reconcile(resolved);
2114
2218
  await this.#storage.save(next);
2115
2219
  this.#state = next;
@@ -2183,7 +2287,12 @@ var StampRallyClient = class {
2183
2287
  if (event.status === "ACCEPTED") {
2184
2288
  if (this.#syncMetrics !== null) this.#syncMetrics.processed += 1;
2185
2289
  if (event.state !== void 0) {
2186
- const next = this.#reconcile(event.state);
2290
+ const rebuilt = rebuildUserStateFromLog(
2291
+ event.state,
2292
+ this.#offlineQueue?.operations ?? [],
2293
+ this.#config
2294
+ );
2295
+ const next = this.#reconcile(rebuilt);
2187
2296
  await this.#storage.save(next);
2188
2297
  this.#state = next;
2189
2298
  this.#emit(next);
@@ -2203,7 +2312,13 @@ var StampRallyClient = class {
2203
2312
  this.#syncMetrics.failed += 1;
2204
2313
  }
2205
2314
  const base = event.state ?? this.#state ?? event.operation.request.state;
2206
- const next = this.#reconcile(rollbackOptimisticOperation(base, event.operation));
2315
+ const rollbackBase = event.state === void 0 ? rollbackOptimisticOperation(base, event.operation) : base;
2316
+ const rebuilt = rebuildUserStateFromLog(
2317
+ rollbackBase,
2318
+ this.#offlineQueue?.operations ?? [],
2319
+ this.#config
2320
+ );
2321
+ const next = this.#reconcile(rebuilt);
2207
2322
  await this.#storage.save(next);
2208
2323
  this.#state = next;
2209
2324
  this.#emit(next);
@@ -2806,7 +2921,11 @@ function theme(value, path, errors) {
2806
2921
  finiteNumber(value, "gridColumns", path, errors, 1);
2807
2922
  if (typeof value.gridColumns === "number" && !Number.isInteger(value.gridColumns))
2808
2923
  add(errors, `${path}.gridColumns`, "Expected an integer.", "invalid_integer");
2809
- if (hasOwn(value, "unclaimedOpacity")) finiteNumber(value, "unclaimedOpacity", path, errors, 0);
2924
+ if (hasOwn(value, "unclaimedOpacity")) {
2925
+ finiteNumber(value, "unclaimedOpacity", path, errors, 0);
2926
+ if (typeof value.unclaimedOpacity === "number" && value.unclaimedOpacity > 1)
2927
+ add(errors, `${path}.unclaimedOpacity`, "Expected a value between 0 and 1.", "out_of_range");
2928
+ }
2810
2929
  }
2811
2930
  function externalReferences(value, path, errors) {
2812
2931
  if (!Array.isArray(value)) {
@@ -2969,12 +3088,14 @@ function reward(value, path, errors, isPublic) {
2969
3088
  String(value.redemptionMethod)
2970
3089
  ))
2971
3090
  add(errors, `${path}.redemptionMethod`, "Unknown redemption method.", "invalid_enum");
2972
- finiteNumber(value, "requiredStampCount", path, errors, 0);
3091
+ nonNegativeInteger(value, "requiredStampCount", path, errors);
2973
3092
  for (const key of ["stockLimit", "userClaimLimit"]) {
2974
3093
  if (hasOwn(value, key) && value[key] !== void 0) {
2975
3094
  nonNegativeInteger(value, key, path, errors);
2976
3095
  }
2977
3096
  }
3097
+ optionalString(value, "stockKey", path, errors);
3098
+ optionalString(value, "secondaryStockKey", path, errors);
2978
3099
  optionalString(value, "validUntil", path, errors);
2979
3100
  if (typeof value.validUntil === "string" && Number.isNaN(Date.parse(value.validUntil)))
2980
3101
  add(errors, `${path}.validUntil`, "Expected a valid date string.", "invalid_date");
@@ -3022,8 +3143,25 @@ function validate(value, isPublic) {
3022
3143
  if (!isRecord3(value.inventory))
3023
3144
  add(errors, "$.inventory", "Expected an object.", "invalid_type");
3024
3145
  else {
3025
- for (const [key, item] of Object.entries(value.inventory))
3146
+ for (const [key, item] of Object.entries(value.inventory)) {
3147
+ if (key === "global") {
3148
+ add(
3149
+ errors,
3150
+ "$.inventory.global",
3151
+ "Use sharedStock instead of global.",
3152
+ "deprecated_field"
3153
+ );
3154
+ continue;
3155
+ }
3026
3156
  if (item !== void 0) nonNegativeInteger(value.inventory, key, "$.inventory", errors);
3157
+ }
3158
+ if (hasOwn(value.inventory, "sharedStock") && hasOwn(value.inventory, "global"))
3159
+ add(
3160
+ errors,
3161
+ "$.inventory",
3162
+ "sharedStock and global cannot be configured together.",
3163
+ "conflicting_fields"
3164
+ );
3027
3165
  }
3028
3166
  }
3029
3167
  if (hasOwn(value, "inventoryMode") && value.inventoryMode !== void 0 && value.inventoryMode !== "shared" && value.inventoryMode !== "per_reward")
@@ -3099,6 +3237,33 @@ function validateRallyConfigRelations(config) {
3099
3237
  reward2.conditions?.forEach((condition2, conditionIndex) => {
3100
3238
  visit(condition2, `rewards[${index}].conditions[${conditionIndex}]`);
3101
3239
  });
3240
+ if (reward2.requiredStampCount > config.spots.length)
3241
+ add(
3242
+ errors,
3243
+ `rewards[${index}].requiredStampCount`,
3244
+ "requiredStampCount cannot exceed the number of spots.",
3245
+ "required_stamp_count_exceeds_spots"
3246
+ );
3247
+ const inventory = "inventory" in config ? config.inventory : void 0;
3248
+ for (const [field, key] of [
3249
+ ["stockKey", reward2.stockKey],
3250
+ ["secondaryStockKey", reward2.secondaryStockKey]
3251
+ ]) {
3252
+ if (key !== void 0 && key !== "__shared__" && inventory?.[key] === void 0)
3253
+ add(
3254
+ errors,
3255
+ `rewards[${index}].${field}`,
3256
+ "Referenced inventory key does not exist.",
3257
+ "missing_inventory_key"
3258
+ );
3259
+ }
3260
+ if (reward2.stockKey === "__shared__" && inventory?.sharedStock === void 0)
3261
+ add(
3262
+ errors,
3263
+ `rewards[${index}].stockKey`,
3264
+ "sharedStock is not defined.",
3265
+ "missing_inventory_key"
3266
+ );
3102
3267
  });
3103
3268
  const visiting = /* @__PURE__ */ new Set();
3104
3269
  const visited = /* @__PURE__ */ new Set();
@@ -3269,6 +3434,6 @@ async function verifySnapshotToken(token, secretKey, now = Date.now()) {
3269
3434
  }
3270
3435
  }
3271
3436
 
3272
- export { ConfigValidationError, DEFAULT_SHEET_THEME, MemoryQueueStorage as InMemoryOfflineQueueStorage, InMemoryStorage, IndexedDBAdapter, IndexedDBOfflineQueueStorage, LocalStorageAdapter, OfflineQueue, StampRallyClient, StorageAdapterError, THEME_PRESETS, assertPublicConfig, calculateDistanceMeters, calculateProgress, consumeReward, createAnonymousSessionId, createClaimTicketNumber, createSecureToken, createSignedSnapshotToken, createUniqueClaimTicketNumber, evaluateCondition, evaluateConditionDetailed, evaluateSpotStatus, exportProgressToken, getCurrentGeoContext, getOrderedSpots, getSpotStatus, importProgressToken, isGeolocationSupported, isNfcSupported, isPublicConfig, isQrSupported, isRewardState, isStampRallyState, issueClaimTicketNumber, normalizePasscode, offlineOperationId, parseAdminConfig, parsePublicConfig, processStamp, readNfcContext, readQrContext, reconcileRewardStates, resolveLocalizedText, resolveRallyStateConflict, rollbackOptimisticOperation, safeParseAdminConfig, safeParsePublicConfig, sanitizeAdminConfig, storageKey, toLocalizedString, toPublicConfig, updateLocalizedField, validatePublicConfigSafety, validateRallyConfigRelations, verifyPasscode, verifySecureToken, verifySnapshotToken };
3437
+ export { ConfigValidationError, DEFAULT_SHEET_THEME, MemoryQueueStorage as InMemoryOfflineQueueStorage, InMemoryStorage, IndexedDBAdapter, IndexedDBOfflineQueueStorage, LocalStorageAdapter, OfflineQueue, StampRallyClient, StorageAdapterError, THEME_PRESETS, assertPublicConfig, calculateDistanceMeters, calculateProgress, consumeReward, createAnonymousSessionId, createClaimTicketNumber, createSecureToken, createSignedSnapshotToken, createUniqueClaimTicketNumber, evaluateCondition, evaluateConditionDetailed, evaluateSpotStatus, exportProgressToken, getCurrentGeoContext, getOrderedSpots, getSpotStatus, importProgressToken, isGeolocationSupported, isNfcSupported, isPublicConfig, isQrSupported, isRewardState, isStampRallyState, issueClaimTicketNumber, normalizePasscode, offlineOperationId, parseAdminConfig, parsePublicConfig, processStamp, readNfcContext, readQrContext, rebuildUserStateFromLog, rebuildUserStateLog, reconcileRewardStates, resolveLocalizedText, resolveRallyStateConflict, rollbackOptimisticOperation, safeParseAdminConfig, safeParsePublicConfig, sanitizeAdminConfig, storageKey, toLocalizedString, toPublicConfig, updateLocalizedField, validatePublicConfigSafety, validateRallyConfigRelations, verifyPasscode, verifySecureToken, verifySnapshotToken };
3273
3438
  //# sourceMappingURL=index.js.map
3274
3439
  //# sourceMappingURL=index.js.map