@stamprally/core 0.18.0 → 0.20.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 CHANGED
@@ -1,4 +1,4 @@
1
- # @stamprally/core v0.18.0
1
+ # @stamprally/core v0.20.0
2
2
 
3
3
  Dependency-free domain models, immutable state transitions, storage adapters, browser detectors, and safe configuration parsers.
4
4
 
@@ -7,7 +7,7 @@ import { InMemoryStorage, StampRallyClient, type PublicRallyConfig } from "@stam
7
7
 
8
8
  const config: PublicRallyConfig = {
9
9
  id: "city-tour",
10
- version: "0.18.0",
10
+ version: "0.20.0",
11
11
  title: "City Tour",
12
12
  spots: [{ id: "station", orderIndex: 0, name: "Central Station", conditions: [{ type: "passcode" }] }],
13
13
  rewards: [],
@@ -28,14 +28,40 @@ The browser detectors `getCurrentGeoContext`, `readNfcContext`, and `readQrConte
28
28
  Configure `OfflineQueue` with `rallyId` and `userId` to persist pending work under
29
29
  `stamprally:queue:<rallyId>:<userId-or-anonymous>`. `switchUser` loads the other
30
30
  user's queue. The default `authoritative_replay` policy uses the server state as
31
- the conflict baseline; `merge` is retained only as an explicit compatibility
32
- option, and `server_wins` uses the server state unchanged. Sync adapters may return `ACCEPTED`, `REJECTED_PERMANENT`,
31
+ the conflict baseline; it is the only supported conflict policy. Sync adapters may return `ACCEPTED`, `REJECTED_PERMANENT`,
33
32
  or `RETRYABLE_ERROR`; only the first two remove an operation.
34
33
 
35
34
  When no authenticated user is supplied, the client creates a persistent UUID v4
36
35
  `anonymousSessionId` and includes it in sync requests. Queue replay uses Web Locks
37
36
  when available and supports bounded exponential backoff through `retryOptions`.
38
37
 
38
+ ### v0.20.0 batch sync and storage capability
39
+
40
+ An adapter can return `SyncProgressResponse` from `sync` when it sends queued
41
+ operations to a server-side `syncProgress` endpoint. The adapter passes queued
42
+ operations to its transport and maps the server request shape as needed:
43
+
44
+ ```ts
45
+ const batchAdapter: SyncAdapter = {
46
+ sync: async ({ rallyId, userId, state, operations }) =>
47
+ postBatch({ rallyId, userId, state, operations }),
48
+ };
49
+ await client.sync(batchAdapter);
50
+ ```
51
+
52
+ Each `SyncOperationResult`
53
+ is `ACCEPTED`, `REJECTED_PERMANENT`, or `FAILED_RETRYABLE`. Accepted and permanent
54
+ results are removed from the queue; permanent reasons are added to
55
+ `rejectedHistory`, while retryable operations are kept for the next send. A
56
+ permanent prerequisite failure does not prevent later independent operations from
57
+ being evaluated.
58
+
59
+ `client.queueCapability.multiTabSync` is `"supported_web_locks"` when the Web
60
+ Locks API is available. Otherwise it is `"disabled_unsafe_environment"`:
61
+ cross-tab automatic synchronization is disabled, the foreground tab must trigger
62
+ sync explicitly, and a `storageCapabilityWarning` event is emitted. localStorage
63
+ may still be used for durable queue data, but it is never used as a lock.
64
+
39
65
  `evaluateSpotStatus` derives `UNCLAIMED`, `CLAIMED`, `LOCKED`, or `VERIFYING`
40
66
  without mutating state. A spot with incomplete `prerequisites` is `LOCKED` and
41
67
  must not be verified by a client or viewer.
package/dist/index.cjs CHANGED
@@ -1200,8 +1200,6 @@ function errorValue(value, fallbackCode) {
1200
1200
  if (typeof value === "string") return { code: fallbackCode, message: value };
1201
1201
  return { code: fallbackCode, message: "Offline operation was rejected." };
1202
1202
  }
1203
- var syncLocks = /* @__PURE__ */ new Map();
1204
- var SYNC_LOCK_TTL_MS = 3e4;
1205
1203
  var DEFAULT_RETRY_OPTIONS = {
1206
1204
  maxRetries: 0,
1207
1205
  initialIntervalMs: 250,
@@ -1228,9 +1226,7 @@ var OfflineQueue = class {
1228
1226
  #synchronizeInstances;
1229
1227
  #retryOptions;
1230
1228
  #instanceId = randomId();
1231
- #lockStorage;
1232
- #observedLocks = /* @__PURE__ */ new Map();
1233
- #warnedMemoryLock = false;
1229
+ #warnedCapabilityMessages = /* @__PURE__ */ new Set();
1234
1230
  #capabilityWarningListener;
1235
1231
  #replayConfig;
1236
1232
  #storageListener;
@@ -1260,8 +1256,7 @@ var OfflineQueue = class {
1260
1256
  initialIntervalMs: Math.max(0, retryOptions.initialIntervalMs),
1261
1257
  backoffMultiplier: Math.max(1, retryOptions.backoffMultiplier)
1262
1258
  };
1263
- this.#lockStorage = options.storageLike ?? availableLocalStorage();
1264
- if (this.#synchronizeInstances) this.#subscribeToExternalChanges();
1259
+ if (this.#synchronizeInstances && this.#hasWebLocks()) this.#subscribeToExternalChanges();
1265
1260
  }
1266
1261
  get syncState() {
1267
1262
  return this.#state;
@@ -1270,12 +1265,14 @@ var OfflineQueue = class {
1270
1265
  return this.#operations.length;
1271
1266
  }
1272
1267
  get queueCapability() {
1273
- return this.#queueCapability;
1268
+ return {
1269
+ storage: this.#queueCapability,
1270
+ multiTabSync: this.#hasWebLocks() ? "supported_web_locks" : "disabled_unsafe_environment"
1271
+ };
1274
1272
  }
1275
1273
  get storageCapability() {
1276
1274
  if (this.#queueCapability === "memory") return "memory";
1277
- const locks = globalThis.navigator?.locks;
1278
- return locks !== void 0 || this.#lockStorage !== null ? this.#queueCapability : "volatile_single_tab";
1275
+ return this.#hasWebLocks() ? this.#queueCapability : "volatile_single_tab";
1279
1276
  }
1280
1277
  get isStoragePersistent() {
1281
1278
  return this.#queueCapability !== "memory";
@@ -1322,12 +1319,16 @@ var OfflineQueue = class {
1322
1319
  this.#queueCapability = "memory";
1323
1320
  this.#operations = [];
1324
1321
  this.#rejectedHistory = [];
1325
- this.#warnMemoryLock(
1322
+ this.#warnCapability(
1326
1323
  `Offline queue persistence is unavailable; queued data can be lost after reload (${String(error)}).`
1327
1324
  );
1328
1325
  }
1329
1326
  if (this.#queueCapability === "memory")
1330
- this.#warnMemoryLock("Offline queue persistence is unavailable; queued data is memory-only.");
1327
+ this.#warnCapability("Offline queue persistence is unavailable; queued data is memory-only.");
1328
+ if (this.queueCapability.multiTabSync === "disabled_unsafe_environment")
1329
+ this.#warnCapability(
1330
+ "Web Locks is unavailable; automatic cross-tab synchronization is disabled. Sync must be triggered by the foreground tab."
1331
+ );
1331
1332
  this.#loaded = true;
1332
1333
  }
1333
1334
  /** Releases browser listeners when the queue is no longer used. */
@@ -1338,7 +1339,6 @@ var OfflineQueue = class {
1338
1339
  this.#storageListener = void 0;
1339
1340
  this.#channel?.close();
1340
1341
  this.#channel = null;
1341
- this.#releaseSyncLock();
1342
1342
  }
1343
1343
  /** Selects a rally/user queue scope and loads its pending operations. */
1344
1344
  async setScope(rallyId, userId) {
@@ -1450,6 +1450,12 @@ var OfflineQueue = class {
1450
1450
  async retrySync(sender = this.#sender) {
1451
1451
  return this.sync(sender);
1452
1452
  }
1453
+ /** Applies a server-side batch response while preserving retryable operations. */
1454
+ async applySyncProgress(response) {
1455
+ await this.initialize();
1456
+ for (const result of response.results)
1457
+ await this.#applySyncOperationResult(result, response.currentState);
1458
+ }
1453
1459
  async #run(sender) {
1454
1460
  const locks = globalThis.navigator?.locks;
1455
1461
  if (locks !== void 0 && typeof locks.request === "function") {
@@ -1466,7 +1472,7 @@ var OfflineQueue = class {
1466
1472
  return false;
1467
1473
  }
1468
1474
  callbackStarted = true;
1469
- await this.#runWithStorageLock(sender);
1475
+ await this.#runSingleTab(sender);
1470
1476
  return true;
1471
1477
  }
1472
1478
  );
@@ -1476,22 +1482,12 @@ var OfflineQueue = class {
1476
1482
  if (callbackStarted) throw error;
1477
1483
  }
1478
1484
  }
1479
- if (this.storageCapability === "volatile_single_tab")
1480
- this.#warnMemoryLock(
1481
- "No cross-tab storage lock is available; offline sync is single-tab only."
1482
- );
1483
- await this.#runWithStorageLock(sender);
1485
+ await this.#runSingleTab(sender);
1484
1486
  }
1485
- async #runWithStorageLock(sender) {
1487
+ async #runSingleTab(sender) {
1486
1488
  this.#state = "syncing";
1487
1489
  this.#error = null;
1488
1490
  this.#changeListener?.();
1489
- if (!this.#acquireSyncLock()) {
1490
- await this.#reloadFromStorage();
1491
- this.#state = "idle";
1492
- this.#changeListener?.();
1493
- return;
1494
- }
1495
1491
  try {
1496
1492
  while (this.#operations.length > 0) {
1497
1493
  const operation = this.#operations[0];
@@ -1563,8 +1559,6 @@ var OfflineQueue = class {
1563
1559
  this.#error = cause instanceof Error ? cause : new Error(String(cause));
1564
1560
  this.#changeListener?.();
1565
1561
  throw this.#error;
1566
- } finally {
1567
- this.#releaseSyncLock();
1568
1562
  }
1569
1563
  }
1570
1564
  async #updateOperationStatus(status, attempts) {
@@ -1574,6 +1568,59 @@ var OfflineQueue = class {
1574
1568
  await this.#storage.save(this.#storageKey(), this.#operations);
1575
1569
  this.#announceChange();
1576
1570
  }
1571
+ async #applySyncOperationResult(result, currentState) {
1572
+ const operationIndex = this.#operations.findIndex(
1573
+ (operation2) => offlineOperationId(operation2) === result.operationId
1574
+ );
1575
+ const operation = operationIndex < 0 ? void 0 : this.#operations[operationIndex];
1576
+ if (operation === void 0) return;
1577
+ if (result.status === "FAILED_RETRYABLE") {
1578
+ const attempts = (operation.attempts ?? 0) + 1;
1579
+ this.#operations = [
1580
+ ...this.#operations.slice(0, operationIndex),
1581
+ { ...operation, status: "FAILED_RETRYABLE", attempts },
1582
+ ...this.#operations.slice(operationIndex + 1)
1583
+ ];
1584
+ await this.#storage.save(this.#storageKey(), this.#operations);
1585
+ this.#announceChange();
1586
+ await this.#syncResultListener?.({
1587
+ operation,
1588
+ status: "RETRYABLE_ERROR",
1589
+ error: { code: "FAILED_RETRYABLE", message: result.error }
1590
+ });
1591
+ return;
1592
+ }
1593
+ this.#operations = [
1594
+ ...this.#operations.slice(0, operationIndex),
1595
+ ...this.#operations.slice(operationIndex + 1)
1596
+ ];
1597
+ if (result.status === "REJECTED_PERMANENT") {
1598
+ const attempts = (operation.attempts ?? 0) + 1;
1599
+ const error = {
1600
+ code: result.errorCode,
1601
+ message: result.reason
1602
+ };
1603
+ this.#rejectedHistory = [
1604
+ ...this.#rejectedHistory,
1605
+ {
1606
+ operation: { ...operation, status: "REJECTED_PERMANENT", attempts },
1607
+ reason: error,
1608
+ errorCode: result.errorCode,
1609
+ rejectedAt: (/* @__PURE__ */ new Date()).toISOString(),
1610
+ attempts
1611
+ }
1612
+ ];
1613
+ await this.#saveRejectedHistory();
1614
+ }
1615
+ await this.#storage.save(this.#storageKey(), this.#operations);
1616
+ this.#announceChange();
1617
+ await this.#syncResultListener?.({
1618
+ operation,
1619
+ status: result.status,
1620
+ state: currentState,
1621
+ ...result.status === "REJECTED_PERMANENT" ? { error: { code: result.errorCode, message: result.reason } } : {}
1622
+ });
1623
+ }
1577
1624
  async #rejectFailedPrerequisite(operation) {
1578
1625
  if (operation.kind !== "checkIn" || this.#replayConfig === void 0) return false;
1579
1626
  const spot2 = this.#replayConfig.spots.find(
@@ -1581,9 +1628,10 @@ var OfflineQueue = class {
1581
1628
  );
1582
1629
  if (spot2 === void 0) return false;
1583
1630
  const failedSpots = new Set(
1584
- this.#rejectedHistory.filter((entry) => entry.operation.kind === "checkIn").map(
1585
- (entry) => entry.operation.kind === "checkIn" ? entry.operation.request.spotId : void 0
1586
- ).filter((spotId) => spotId !== void 0)
1631
+ [
1632
+ ...this.#rejectedHistory.map((entry) => entry.operation),
1633
+ ...this.#operations.filter((candidate) => candidate.status === "REJECTED_PERMANENT")
1634
+ ].filter((candidate) => candidate.kind === "checkIn").map((candidate) => candidate.kind === "checkIn" ? candidate.request.spotId : void 0).filter((spotId) => spotId !== void 0)
1587
1635
  );
1588
1636
  if (!spot2.prerequisites?.some((prerequisite) => failedSpots.has(prerequisite))) return false;
1589
1637
  const error = {
@@ -1626,20 +1674,6 @@ var OfflineQueue = class {
1626
1674
  try {
1627
1675
  this.#channel = new Channel("stamprally:queue-sync");
1628
1676
  this.#channel.addEventListener("message", (event) => {
1629
- if (typeof event.data === "object" && event.data !== null) {
1630
- const data = event.data;
1631
- if (data.type === "lock" && data.lockKey === this.#lockKey()) {
1632
- this.#observedLocks.set(data.lockKey, {
1633
- owner: typeof data.owner === "string" ? data.owner : "unknown",
1634
- expiresAt: typeof data.expiresAt === "number" ? data.expiresAt : 0
1635
- });
1636
- return;
1637
- }
1638
- if (data.type === "unlock" && data.lockKey === this.#lockKey()) {
1639
- this.#observedLocks.delete(data.lockKey);
1640
- return;
1641
- }
1642
- }
1643
1677
  if (typeof event.data === "object" && event.data !== null && "key" in event.data && event.data.key === this.#storageKey())
1644
1678
  void this.#reloadFromStorage();
1645
1679
  });
@@ -1666,57 +1700,6 @@ var OfflineQueue = class {
1666
1700
  } catch {
1667
1701
  }
1668
1702
  }
1669
- #lockKey() {
1670
- return `${this.#storageKey()}:sync-lock`;
1671
- }
1672
- #acquireSyncLock() {
1673
- const key = this.#lockKey();
1674
- const now = Date.now();
1675
- const local = syncLocks.get(key);
1676
- if (local !== void 0 && local.expiresAt > now && local.owner !== this.#instanceId)
1677
- return false;
1678
- const observed = this.#observedLocks.get(key);
1679
- if (observed !== void 0 && observed.expiresAt > now && observed.owner !== this.#instanceId)
1680
- return false;
1681
- if (this.#lockStorage !== null) {
1682
- try {
1683
- const existing = this.#lockStorage.getItem(key);
1684
- if (existing !== null) {
1685
- const parsed = JSON.parse(existing);
1686
- if (typeof parsed === "object" && parsed !== null && typeof parsed.expiresAt === "number" && parsed.expiresAt > now && parsed.owner !== this.#instanceId)
1687
- return false;
1688
- }
1689
- this.#lockStorage.setItem(
1690
- key,
1691
- JSON.stringify({ owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS })
1692
- );
1693
- this.#channel?.postMessage({
1694
- type: "lock",
1695
- lockKey: key,
1696
- owner: this.#instanceId,
1697
- expiresAt: now + SYNC_LOCK_TTL_MS
1698
- });
1699
- } catch {
1700
- this.#warnMemoryLock("Persistent lock access failed; offline sync is single-tab only.");
1701
- }
1702
- }
1703
- syncLocks.set(key, { owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS });
1704
- return true;
1705
- }
1706
- #releaseSyncLock() {
1707
- const key = this.#lockKey();
1708
- const current = syncLocks.get(key);
1709
- if (current?.owner === this.#instanceId) syncLocks.delete(key);
1710
- if (this.#lockStorage !== null) {
1711
- try {
1712
- const value = this.#lockStorage.getItem(key);
1713
- if (value !== null && JSON.parse(value).owner === this.#instanceId)
1714
- this.#lockStorage.removeItem?.(key);
1715
- this.#channel?.postMessage({ type: "unlock", lockKey: key, owner: this.#instanceId });
1716
- } catch {
1717
- }
1718
- }
1719
- }
1720
1703
  #storageKey() {
1721
1704
  if (this.#configuredKey !== void 0) return this.#configuredKey;
1722
1705
  return `stamprally:queue:${this.#rallyId ?? "unscoped"}:${this.#userId ?? "anonymous"}`;
@@ -1739,17 +1722,22 @@ var OfflineQueue = class {
1739
1722
  async #saveRejectedHistory() {
1740
1723
  await this.#storage.saveRejectedHistory?.(this.#storageKey(), this.#rejectedHistory);
1741
1724
  }
1742
- #warnMemoryLock(message) {
1743
- if (this.#warnedMemoryLock) return;
1744
- this.#warnedMemoryLock = true;
1725
+ #warnCapability(message) {
1726
+ if (this.#warnedCapabilityMessages.has(message)) return;
1727
+ this.#warnedCapabilityMessages.add(message);
1745
1728
  console.warn(`[@stamprally/core] ${message}`);
1746
1729
  this.#capabilityWarningListener?.({
1747
1730
  type: "STORAGE_CAPABILITY_WARNING",
1748
1731
  storageCapability: this.storageCapability === "memory" ? "memory" : "volatile_single_tab",
1732
+ multiTabSync: "disabled_unsafe_environment",
1749
1733
  isStoragePersistent: this.isStoragePersistent,
1750
1734
  message
1751
1735
  });
1752
1736
  }
1737
+ #hasWebLocks() {
1738
+ const locks = globalThis.navigator?.locks;
1739
+ return locks !== void 0 && typeof locks.request === "function";
1740
+ }
1753
1741
  };
1754
1742
  function normalizeOperation(operation) {
1755
1743
  const status = operation.status;
@@ -1794,18 +1782,18 @@ function applyInventoryDelta(state, previous, optimistic) {
1794
1782
  if (before !== void 0 && after !== void 0)
1795
1783
  rewardRemaining[key] = Math.max(0, (currentRewards[key] ?? before) + after - before);
1796
1784
  }
1797
- return {
1798
- ...state,
1799
- inventory: {
1800
- ...currentInventory.sharedRemaining === void 0 || sharedDelta === void 0 ? {} : { sharedRemaining: Math.max(0, currentInventory.sharedRemaining + sharedDelta) },
1801
- ...Object.keys(rewardRemaining).length === 0 ? {} : { rewardRemaining }
1802
- }
1785
+ const nextInventory = {
1786
+ ...currentInventory.sharedRemaining === void 0 || sharedDelta === void 0 ? optimisticInventory.sharedRemaining === void 0 || sharedDelta === void 0 ? {} : { sharedRemaining: Math.max(0, optimisticInventory.sharedRemaining) } : { sharedRemaining: Math.max(0, currentInventory.sharedRemaining + sharedDelta) },
1787
+ ...Object.keys(rewardRemaining).length === 0 ? {} : { rewardRemaining }
1803
1788
  };
1789
+ return { ...state, inventory: nextInventory };
1804
1790
  }
1805
- function applyOperation(state, operation, config) {
1791
+ function applyOperation(state, operation, config, rejectedCheckIns) {
1806
1792
  if (operation.kind === "checkIn") {
1807
1793
  const spot2 = config?.spots.find((candidate) => candidate.id === operation.request.spotId);
1808
- if (spot2?.prerequisites?.some((id2) => !state.records.some((record2) => record2.stampId === id2)))
1794
+ if (spot2?.prerequisites?.some(
1795
+ (id2) => rejectedCheckIns.has(id2) || !state.records.some((record2) => record2.stampId === id2)
1796
+ ))
1809
1797
  return { state, prerequisiteFailed: true };
1810
1798
  if (state.records.some((record2) => record2.stampId === operation.request.spotId))
1811
1799
  return { state, prerequisiteFailed: false };
@@ -1852,11 +1840,21 @@ function rebuildUserStateFromLog(baselineOrOptions, operationsArgument, configAr
1852
1840
  function rebuildUserStateLog(baseline, operations, config) {
1853
1841
  let state = cloneState(baseline);
1854
1842
  const rejectedOperationIds = [];
1843
+ const rejectedCheckIns = new Set(
1844
+ operations.filter(
1845
+ (operation) => operation.status === "REJECTED_PERMANENT" && operation.kind === "checkIn"
1846
+ ).map((operation) => operation.kind === "checkIn" ? operation.request.spotId : void 0).filter((spotId) => spotId !== void 0)
1847
+ );
1855
1848
  for (const operation of operations) {
1849
+ if (operation.status === "REJECTED_PERMANENT") {
1850
+ if (operation.kind === "checkIn") rejectedCheckIns.add(operation.request.spotId);
1851
+ continue;
1852
+ }
1856
1853
  if (!isReplayable(operation)) continue;
1857
- const replay = applyOperation(state, operation, config);
1854
+ const replay = applyOperation(state, operation, config, rejectedCheckIns);
1858
1855
  if (replay.prerequisiteFailed) {
1859
1856
  rejectedOperationIds.push(operationId(operation));
1857
+ if (operation.kind === "checkIn") rejectedCheckIns.add(operation.request.spotId);
1860
1858
  continue;
1861
1859
  }
1862
1860
  state = replay.state;
@@ -1904,9 +1902,36 @@ function emptyState(config, userId, now) {
1904
1902
  updatedAt: now
1905
1903
  };
1906
1904
  }
1905
+ function applyOptimisticRewardClaim(state, rewardId, reward2, now) {
1906
+ if (reward2.claimTicketNumber === void 0 || state.inventory === void 0)
1907
+ return {
1908
+ ...state,
1909
+ rewards: state.rewards.map((item) => item.rewardId === rewardId ? reward2 : item),
1910
+ updatedAt: now
1911
+ };
1912
+ const currentInventory = state.inventory;
1913
+ const currentRewardRemaining = currentInventory.rewardRemaining?.[rewardId];
1914
+ return {
1915
+ ...state,
1916
+ rewards: state.rewards.map((item) => item.rewardId === rewardId ? reward2 : item),
1917
+ updatedAt: now,
1918
+ inventory: {
1919
+ ...currentInventory.sharedRemaining === void 0 ? {} : { sharedRemaining: Math.max(0, currentInventory.sharedRemaining - 1) },
1920
+ ...currentInventory.rewardRemaining === void 0 || currentRewardRemaining === void 0 ? {} : {
1921
+ rewardRemaining: {
1922
+ ...currentInventory.rewardRemaining,
1923
+ [rewardId]: Math.max(0, currentRewardRemaining - 1)
1924
+ }
1925
+ }
1926
+ }
1927
+ };
1928
+ }
1907
1929
  function errorMessage(error, fallback) {
1908
1930
  return error !== void 0 && "message" in error && typeof error.message === "string" ? error.message : fallback;
1909
1931
  }
1932
+ function isSyncProgressResponse(value) {
1933
+ return typeof value === "object" && value !== null && "results" in value && Array.isArray(value.results) && "currentState" in value && "syncTimestamp" in value;
1934
+ }
1910
1935
  var StampRallyClient = class {
1911
1936
  #listeners = /* @__PURE__ */ new Set();
1912
1937
  #eventListeners = /* @__PURE__ */ new Set();
@@ -1931,6 +1956,9 @@ var StampRallyClient = class {
1931
1956
  this.#offlineQueue = this.#options.offlineQueue;
1932
1957
  this.#offlineQueue?.setReplayConfig(config);
1933
1958
  this.#offlineQueue?.setSyncResultListener((event) => this.#handleOfflineSyncResult(event));
1959
+ this.#offlineQueue?.setCapabilityWarningListener(
1960
+ (warning) => this.#emitEvent({ type: "storageCapabilityWarning", warning })
1961
+ );
1934
1962
  this.#offlineQueue?.setChangeListener(() => {
1935
1963
  this.#syncRevision += 1;
1936
1964
  if (this.#state !== null) this.#emit(this.#state);
@@ -1961,7 +1989,10 @@ var StampRallyClient = class {
1961
1989
  return this.#syncRevision;
1962
1990
  }
1963
1991
  get queueCapability() {
1964
- return this.#offlineQueue?.queueCapability ?? "custom";
1992
+ return this.#offlineQueue?.queueCapability ?? {
1993
+ storage: "custom",
1994
+ multiTabSync: "disabled_unsafe_environment"
1995
+ };
1965
1996
  }
1966
1997
  get storageCapability() {
1967
1998
  return this.#offlineQueue?.storageCapability ?? "custom";
@@ -2161,23 +2192,13 @@ var StampRallyClient = class {
2161
2192
  return result.ok ? this.#commitClaim(result) : this.#fail(result.error);
2162
2193
  } catch (error) {
2163
2194
  if (this.#offlineQueue === void 0) throw error;
2164
- const next2 = {
2165
- ...current,
2166
- rewards: current.rewards.map(
2167
- (item) => item.rewardId === rewardId ? local.value : item
2168
- ),
2169
- updatedAt: now
2170
- };
2195
+ const next2 = applyOptimisticRewardClaim(current, rewardId, local.value, now);
2171
2196
  await this.#offlineQueue.enqueueClaimReward(request, next2);
2172
2197
  await this.#storage.save(next2);
2173
2198
  return this.#commitClaim({ ok: true, value: { state: next2, reward: local.value } });
2174
2199
  }
2175
2200
  }
2176
- const next = {
2177
- ...current,
2178
- rewards: current.rewards.map((item) => item.rewardId === rewardId ? local.value : item),
2179
- updatedAt: now
2180
- };
2201
+ const next = applyOptimisticRewardClaim(current, rewardId, local.value, now);
2181
2202
  await this.#storage.save(next);
2182
2203
  return this.#commitClaim({ ok: true, value: { state: next, reward: local.value } });
2183
2204
  });
@@ -2188,7 +2209,9 @@ var StampRallyClient = class {
2188
2209
  this.#syncMetrics = { processed: 0, failed: 0 };
2189
2210
  try {
2190
2211
  const current = await this.initialize();
2191
- if (this.#offlineQueue !== void 0 && adapter !== void 0) {
2212
+ const queuedOperations = this.#offlineQueue?.operations ?? [];
2213
+ const hasPerOperationAdapters = adapter?.checkIn !== void 0 || adapter?.claimReward !== void 0;
2214
+ if (this.#offlineQueue !== void 0 && adapter !== void 0 && hasPerOperationAdapters) {
2192
2215
  await this.#offlineQueue.sync(async (operation) => {
2193
2216
  if (operation.kind === "checkIn") {
2194
2217
  if (adapter.checkIn === void 0)
@@ -2205,12 +2228,16 @@ var StampRallyClient = class {
2205
2228
  return;
2206
2229
  }
2207
2230
  const localState = this.#state ?? current;
2208
- const serverState = await adapter.sync({
2231
+ const syncResult = await adapter.sync({
2209
2232
  rallyId: this.#config.id,
2210
2233
  userId: this.#userId,
2211
2234
  state: localState,
2212
- ...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
2235
+ ...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {},
2236
+ ...this.#offlineQueue !== void 0 && !hasPerOperationAdapters && queuedOperations.length > 0 ? { operations: queuedOperations } : {}
2213
2237
  });
2238
+ const progress = isSyncProgressResponse(syncResult) ? syncResult : void 0;
2239
+ if (progress !== void 0) await this.#offlineQueue?.applySyncProgress(progress);
2240
+ const serverState = isSyncProgressResponse(syncResult) ? syncResult.currentState : syncResult;
2214
2241
  const resolved = rebuildUserStateFromLog(
2215
2242
  serverState,
2216
2243
  this.#offlineQueue?.operations ?? [],
@@ -3251,7 +3278,7 @@ function validateRallyConfigRelations(config) {
3251
3278
  ["stockKey", reward2.stockKey],
3252
3279
  ["secondaryStockKey", reward2.secondaryStockKey]
3253
3280
  ]) {
3254
- if (key !== void 0 && key !== "__shared__" && inventory?.[key] === void 0)
3281
+ if (key !== void 0 && (key === "__shared__" && inventory?.sharedStock === void 0 || key !== "__shared__" && inventory?.[key] === void 0))
3255
3282
  add(
3256
3283
  errors,
3257
3284
  `rewards[${index}].${field}`,
@@ -3259,13 +3286,6 @@ function validateRallyConfigRelations(config) {
3259
3286
  "missing_inventory_key"
3260
3287
  );
3261
3288
  }
3262
- if (reward2.stockKey === "__shared__" && inventory?.sharedStock === void 0)
3263
- add(
3264
- errors,
3265
- `rewards[${index}].stockKey`,
3266
- "sharedStock is not defined.",
3267
- "missing_inventory_key"
3268
- );
3269
3289
  });
3270
3290
  const visiting = /* @__PURE__ */ new Set();
3271
3291
  const visited = /* @__PURE__ */ new Set();