@stamprally/core 0.14.0 → 0.16.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.cjs CHANGED
@@ -617,7 +617,13 @@ function cloneState(state) {
617
617
  return {
618
618
  ...state,
619
619
  records: state.records.map(cloneRecord),
620
- ...state.rewards === void 0 ? {} : { rewards: state.rewards.map(cloneRewardState) }
620
+ ...state.rewards === void 0 ? {} : { rewards: state.rewards.map(cloneRewardState) },
621
+ ...state.inventory === void 0 ? {} : {
622
+ inventory: {
623
+ ...state.inventory,
624
+ ...state.inventory.rewardRemaining === void 0 ? {} : { rewardRemaining: { ...state.inventory.rewardRemaining } }
625
+ }
626
+ }
621
627
  };
622
628
  }
623
629
  function isRecord(value) {
@@ -637,7 +643,7 @@ function isRewardState(value) {
637
643
  function isStampRallyState(value) {
638
644
  if (typeof value !== "object" || value === null) return false;
639
645
  const state = value;
640
- return typeof state.rallyId === "string" && (typeof state.userId === "string" || state.userId === null) && typeof state.updatedAt === "string" && Array.isArray(state.records) && state.records.every(isRecord) && (state.rewards === void 0 || Array.isArray(state.rewards) && state.rewards.every(isRewardState));
646
+ return typeof state.rallyId === "string" && (typeof state.userId === "string" || state.userId === null) && typeof state.updatedAt === "string" && Array.isArray(state.records) && state.records.every(isRecord) && (state.rewards === void 0 || Array.isArray(state.rewards) && state.rewards.every(isRewardState)) && (state.inventory === void 0 || typeof state.inventory === "object" && state.inventory !== null && !Array.isArray(state.inventory) && (state.inventory.sharedRemaining === void 0 || typeof state.inventory.sharedRemaining === "number" && Number.isInteger(state.inventory.sharedRemaining) && state.inventory.sharedRemaining >= 0) && (state.inventory.rewardRemaining === void 0 || typeof state.inventory.rewardRemaining === "object" && state.inventory.rewardRemaining !== null && !Array.isArray(state.inventory.rewardRemaining)));
641
647
  }
642
648
  function isValidDate(value) {
643
649
  return typeof value === "string" && !Number.isNaN(Date.parse(value));
@@ -682,6 +688,35 @@ var InMemoryStorage = class {
682
688
  function storageKey(rallyId, userId) {
683
689
  return `stamprally:${rallyId}:${userId ?? "anonymous"}`;
684
690
  }
691
+ function createAnonymousSessionId(storage) {
692
+ const key = "stamprally:anonymous-session-id";
693
+ try {
694
+ const browserStorage = typeof window === "undefined" ? null : window.localStorage;
695
+ const value = storage?.getItem(key) ?? browserStorage?.getItem(key);
696
+ if (value !== null && value !== void 0 && isUuidV4(value)) return value;
697
+ const generated = randomUuidV4();
698
+ (storage ?? browserStorage)?.setItem(key, generated);
699
+ return generated;
700
+ } catch {
701
+ return randomUuidV4();
702
+ }
703
+ }
704
+ function isUuidV4(value) {
705
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
706
+ }
707
+ function randomUuidV4() {
708
+ const cryptoApi3 = globalThis.crypto;
709
+ if (cryptoApi3?.randomUUID !== void 0) return cryptoApi3.randomUUID();
710
+ if (cryptoApi3?.getRandomValues !== void 0) {
711
+ const bytes2 = cryptoApi3.getRandomValues(new Uint8Array(16));
712
+ bytes2[6] = (bytes2[6] ?? 0) & 15 | 64;
713
+ bytes2[8] = (bytes2[8] ?? 0) & 63 | 128;
714
+ const hex = Array.from(bytes2, (byte) => byte.toString(16).padStart(2, "0")).join("");
715
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
716
+ }
717
+ const random = `${Date.now().toString(16)}${Math.random().toString(16).slice(2)}`.padEnd(32, "0").slice(0, 32);
718
+ return `${random.slice(0, 8)}-${random.slice(8, 12)}-4${random.slice(13, 16)}-8${random.slice(17, 20)}-${random.slice(20)}`;
719
+ }
685
720
  var defaultStorageWarningHandler = (error) => {
686
721
  console.warn(`[@stamprally/core] ${error.message}`, error);
687
722
  };
@@ -1071,6 +1106,7 @@ var StampRallyClient = class {
1071
1106
  #config;
1072
1107
  #offlineQueue;
1073
1108
  #userId;
1109
+ #anonymousSessionId;
1074
1110
  #state = null;
1075
1111
  #initialization = null;
1076
1112
  #queue = Promise.resolve();
@@ -1078,7 +1114,8 @@ var StampRallyClient = class {
1078
1114
  this.#config = config;
1079
1115
  this.#options = isStorage(storageOrOptions) ? { storage: storageOrOptions } : storageOrOptions;
1080
1116
  this.#storage = this.#options.storage ?? new InMemoryStorage();
1081
- this.#userId = this.#options.userId ?? null;
1117
+ this.#anonymousSessionId = this.#options.anonymousSessionId ?? createAnonymousSessionId();
1118
+ this.#userId = this.#options.userId ?? this.#anonymousSessionId;
1082
1119
  this.#offlineQueue = this.#options.offlineQueue;
1083
1120
  this.#offlineQueue?.setSyncResultListener((event) => this.#handleOfflineSyncResult(event));
1084
1121
  }
@@ -1091,12 +1128,27 @@ var StampRallyClient = class {
1091
1128
  getUserId() {
1092
1129
  return this.#userId;
1093
1130
  }
1131
+ getAnonymousSessionId() {
1132
+ return this.#anonymousSessionId;
1133
+ }
1094
1134
  get syncState() {
1095
1135
  return this.#offlineQueue?.syncState ?? "idle";
1096
1136
  }
1097
1137
  get pendingCount() {
1098
1138
  return this.#offlineQueue?.pendingCount ?? 0;
1099
1139
  }
1140
+ get rejectedHistory() {
1141
+ return this.#offlineQueue?.rejectedHistory ?? [];
1142
+ }
1143
+ get queueCapability() {
1144
+ return this.#offlineQueue?.queueCapability ?? "custom";
1145
+ }
1146
+ discardRejected(operationId) {
1147
+ return this.#offlineQueue?.discardRejected(operationId) ?? Promise.resolve(false);
1148
+ }
1149
+ retryRejected(operationId) {
1150
+ return this.#offlineQueue?.retryRejected(operationId) ?? Promise.resolve(false);
1151
+ }
1100
1152
  subscribe(listener) {
1101
1153
  this.#listeners.add(listener);
1102
1154
  return () => this.#listeners.delete(listener);
@@ -1128,11 +1180,12 @@ var StampRallyClient = class {
1128
1180
  }
1129
1181
  switchUser(newUserId) {
1130
1182
  return this.#enqueue(async () => {
1131
- if (this.#userId === newUserId && this.#state !== null) return this.#state;
1132
- this.#userId = newUserId;
1183
+ const nextUserId = newUserId ?? this.#anonymousSessionId;
1184
+ if (this.#userId === nextUserId && this.#state !== null) return this.#state;
1185
+ this.#userId = nextUserId;
1133
1186
  this.#state = null;
1134
1187
  this.#initialization = null;
1135
- await this.#offlineQueue?.switchUser(newUserId);
1188
+ await this.#offlineQueue?.switchUser(nextUserId);
1136
1189
  return this.initialize();
1137
1190
  });
1138
1191
  }
@@ -1200,7 +1253,8 @@ var StampRallyClient = class {
1200
1253
  proofData,
1201
1254
  idempotencyKey: options.idempotencyKey ?? id("check-in"),
1202
1255
  now,
1203
- state: current
1256
+ state: current,
1257
+ ...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
1204
1258
  };
1205
1259
  const remote = this.#options.syncAdapter?.checkIn;
1206
1260
  if (options.sync !== false && remote !== void 0) {
@@ -1256,7 +1310,8 @@ var StampRallyClient = class {
1256
1310
  idempotencyKey: options.idempotencyKey ?? id("claim"),
1257
1311
  now,
1258
1312
  options,
1259
- state: current
1313
+ state: current,
1314
+ ...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
1260
1315
  };
1261
1316
  const remote = this.#options.syncAdapter?.claimReward;
1262
1317
  if (options.sync !== false && remote !== void 0) {
@@ -1308,13 +1363,10 @@ var StampRallyClient = class {
1308
1363
  const serverState = await adapter.sync({
1309
1364
  rallyId: this.#config.id,
1310
1365
  userId: this.#userId,
1311
- state: this.#state ?? current
1366
+ state: this.#state ?? current,
1367
+ ...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
1312
1368
  });
1313
- const localState = this.#state ?? current;
1314
- const merged = this.#offlineQueue === void 0 ? serverState : resolveRallyStateConflict(serverState, localState, {
1315
- policy: this.#offlineQueue.conflictPolicy
1316
- });
1317
- const next = this.#reconcile(merged);
1369
+ const next = this.#reconcile(serverState);
1318
1370
  await this.#storage.save(next);
1319
1371
  this.#state = next;
1320
1372
  this.#emit(next);
@@ -1419,12 +1471,19 @@ var StampRallyClient = class {
1419
1471
  // src/client/offlineQueue.ts
1420
1472
  var MemoryQueueStorage = class {
1421
1473
  #values = /* @__PURE__ */ new Map();
1474
+ #rejected = /* @__PURE__ */ new Map();
1422
1475
  async load(key) {
1423
1476
  return this.#values.get(key) ?? [];
1424
1477
  }
1425
1478
  async save(key, operations) {
1426
1479
  this.#values.set(key, structuredClone(operations));
1427
1480
  }
1481
+ async loadRejectedHistory(key) {
1482
+ return this.#rejected.get(key) ?? [];
1483
+ }
1484
+ async saveRejectedHistory(key, history) {
1485
+ this.#rejected.set(key, structuredClone(history));
1486
+ }
1428
1487
  };
1429
1488
  var LocalStorageQueueStorage = class {
1430
1489
  constructor(storage) {
@@ -1444,6 +1503,19 @@ var LocalStorageQueueStorage = class {
1444
1503
  async save(key, operations) {
1445
1504
  this.storage.setItem(key, JSON.stringify(operations));
1446
1505
  }
1506
+ async loadRejectedHistory(key) {
1507
+ const value = this.storage.getItem(`${key}:rejected-history`);
1508
+ if (value === null) return [];
1509
+ try {
1510
+ const parsed = JSON.parse(value);
1511
+ return Array.isArray(parsed) ? parsed : [];
1512
+ } catch {
1513
+ return [];
1514
+ }
1515
+ }
1516
+ async saveRejectedHistory(key, history) {
1517
+ this.storage.setItem(`${key}:rejected-history`, JSON.stringify(history));
1518
+ }
1447
1519
  };
1448
1520
  var IndexedDBOfflineQueueStorage = class {
1449
1521
  #providedFactory;
@@ -1471,6 +1543,26 @@ var IndexedDBOfflineQueueStorage = class {
1471
1543
  transaction.onabort = () => reject(transaction.error ?? new Error("Offline queue write aborted."));
1472
1544
  });
1473
1545
  }
1546
+ async loadRejectedHistory(key) {
1547
+ const database = await this.#open();
1548
+ return new Promise((resolve, reject) => {
1549
+ const request = database.transaction("operations", "readonly").objectStore("operations").get(`${key}:rejected-history`);
1550
+ request.onsuccess = () => resolve(
1551
+ Array.isArray(request.result) ? request.result : []
1552
+ );
1553
+ request.onerror = () => reject(request.error ?? new Error("Failed to read rejected operation history."));
1554
+ });
1555
+ }
1556
+ async saveRejectedHistory(key, history) {
1557
+ const database = await this.#open();
1558
+ return new Promise((resolve, reject) => {
1559
+ const transaction = database.transaction("operations", "readwrite");
1560
+ transaction.objectStore("operations").put(structuredClone(history), `${key}:rejected-history`);
1561
+ transaction.oncomplete = () => resolve();
1562
+ transaction.onerror = () => reject(transaction.error ?? new Error("Failed to save rejected operation history."));
1563
+ transaction.onabort = () => reject(transaction.error ?? new Error("Rejected operation history write aborted."));
1564
+ });
1565
+ }
1474
1566
  #open() {
1475
1567
  if (this.#databasePromise !== null) return this.#databasePromise;
1476
1568
  let factory = this.#providedFactory;
@@ -1493,22 +1585,35 @@ var IndexedDBOfflineQueueStorage = class {
1493
1585
  return this.#databasePromise;
1494
1586
  }
1495
1587
  };
1588
+ function availableLocalStorage() {
1589
+ try {
1590
+ const storage = globalThis.localStorage;
1591
+ return storage ?? null;
1592
+ } catch {
1593
+ return null;
1594
+ }
1595
+ }
1496
1596
  function defaultStorage(databaseName) {
1497
1597
  try {
1498
1598
  const indexedDB = globalThis.indexedDB;
1499
1599
  if (indexedDB !== void 0)
1500
- return new IndexedDBOfflineQueueStorage({
1501
- indexedDB,
1502
- ...databaseName === void 0 ? {} : { databaseName }
1503
- });
1504
- const storage = globalThis.localStorage;
1505
- if (storage !== void 0 && storage !== null) return new LocalStorageQueueStorage(storage);
1600
+ return {
1601
+ storage: new IndexedDBOfflineQueueStorage({
1602
+ indexedDB,
1603
+ ...databaseName === void 0 ? {} : { databaseName }
1604
+ }),
1605
+ capability: "indexeddb"
1606
+ };
1607
+ const storage = availableLocalStorage();
1608
+ if (storage !== void 0 && storage !== null)
1609
+ return { storage: new LocalStorageQueueStorage(storage), capability: "localstorage" };
1506
1610
  } catch {
1507
1611
  }
1508
- return new MemoryQueueStorage();
1612
+ return { storage: new MemoryQueueStorage(), capability: "memory" };
1509
1613
  }
1510
- function operationId(operation) {
1511
- return operation.kind === "checkIn" ? `checkIn:${operation.request.rallyId}:${operation.request.userId ?? "anonymous"}:${operation.request.idempotencyKey}` : `claimReward:${operation.request.rallyId}:${operation.request.userId ?? "anonymous"}:${operation.request.idempotencyKey}`;
1614
+ function offlineOperationId(operation) {
1615
+ const identity = operation.request.userId ?? operation.request.anonymousSessionId ?? "anonymous";
1616
+ return operation.kind === "checkIn" ? `checkIn:${operation.request.rallyId}:${identity}:${operation.request.idempotencyKey}` : `claimReward:${operation.request.rallyId}:${identity}:${operation.request.idempotencyKey}`;
1512
1617
  }
1513
1618
  function requestScope(operation) {
1514
1619
  return {
@@ -1528,17 +1633,24 @@ function errorValue(value, fallbackCode) {
1528
1633
  }
1529
1634
  var syncLocks = /* @__PURE__ */ new Map();
1530
1635
  var SYNC_LOCK_TTL_MS = 3e4;
1636
+ var DEFAULT_RETRY_OPTIONS = {
1637
+ maxRetries: 0,
1638
+ initialIntervalMs: 250,
1639
+ backoffMultiplier: 2
1640
+ };
1531
1641
  function randomId() {
1532
1642
  return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
1533
1643
  }
1534
1644
  var OfflineQueue = class {
1535
1645
  #storage;
1646
+ #queueCapability;
1536
1647
  #configuredKey;
1537
1648
  #rallyId;
1538
1649
  #userId;
1539
1650
  #conflictPolicy;
1540
1651
  #onSyncConflict;
1541
1652
  #operations = [];
1653
+ #rejectedHistory = [];
1542
1654
  #loaded = false;
1543
1655
  #state = "idle";
1544
1656
  #error = null;
@@ -1546,22 +1658,41 @@ var OfflineQueue = class {
1546
1658
  #syncPromise = null;
1547
1659
  #syncResultListener;
1548
1660
  #synchronizeInstances;
1661
+ #retryOptions;
1549
1662
  #instanceId = randomId();
1550
1663
  #lockStorage;
1664
+ #observedLocks = /* @__PURE__ */ new Map();
1665
+ #warnedMemoryLock = false;
1551
1666
  #storageListener;
1552
1667
  #channel = null;
1553
1668
  constructor(options = {}) {
1554
- if (options.storage !== void 0) this.#storage = options.storage;
1555
- else if (options.storageLike !== void 0 && options.storageLike !== null)
1669
+ if (options.storage !== void 0) {
1670
+ this.#storage = options.storage;
1671
+ this.#queueCapability = "custom";
1672
+ } else if (options.storageLike !== void 0 && options.storageLike !== null) {
1556
1673
  this.#storage = new LocalStorageQueueStorage(options.storageLike);
1557
- else this.#storage = defaultStorage(options.databaseName);
1674
+ this.#queueCapability = "localstorage";
1675
+ } else {
1676
+ const selected = defaultStorage(options.databaseName);
1677
+ this.#storage = selected.storage;
1678
+ this.#queueCapability = selected.capability;
1679
+ }
1558
1680
  this.#configuredKey = options.key;
1559
1681
  this.#rallyId = options.rallyId;
1560
1682
  this.#userId = options.userId ?? null;
1561
1683
  this.#conflictPolicy = options.conflictPolicy ?? "server_wins";
1562
1684
  this.#onSyncConflict = options.onSyncConflict;
1563
1685
  this.#synchronizeInstances = options.synchronizeInstances ?? true;
1564
- this.#lockStorage = options.storageLike ?? globalThis.localStorage ?? null;
1686
+ const retryOptions = {
1687
+ ...DEFAULT_RETRY_OPTIONS,
1688
+ ...options.retryOptions ?? options.syncRetryOptions ?? options.retry ?? {}
1689
+ };
1690
+ this.#retryOptions = {
1691
+ maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
1692
+ initialIntervalMs: Math.max(0, retryOptions.initialIntervalMs),
1693
+ backoffMultiplier: Math.max(1, retryOptions.backoffMultiplier)
1694
+ };
1695
+ this.#lockStorage = options.storageLike ?? availableLocalStorage();
1565
1696
  if (this.#synchronizeInstances) this.#subscribeToExternalChanges();
1566
1697
  }
1567
1698
  get syncState() {
@@ -1570,6 +1701,12 @@ var OfflineQueue = class {
1570
1701
  get pendingCount() {
1571
1702
  return this.#operations.length;
1572
1703
  }
1704
+ get queueCapability() {
1705
+ return this.#queueCapability;
1706
+ }
1707
+ get rejectedHistory() {
1708
+ return this.#rejectedHistory;
1709
+ }
1573
1710
  get error() {
1574
1711
  return this.#error;
1575
1712
  }
@@ -1593,7 +1730,20 @@ var OfflineQueue = class {
1593
1730
  }
1594
1731
  async initialize() {
1595
1732
  if (this.#loaded) return;
1596
- this.#operations = [...await this.#storage.load(this.#storageKey())];
1733
+ try {
1734
+ const key = this.#storageKey();
1735
+ this.#operations = (await this.#storage.load(key)).map(normalizeOperation);
1736
+ this.#rejectedHistory = (await this.#storage.loadRejectedHistory?.(key))?.map(normalizeRejectedHistory) ?? [];
1737
+ } catch (error) {
1738
+ if (this.#queueCapability === "memory") throw error;
1739
+ this.#storage = new MemoryQueueStorage();
1740
+ this.#queueCapability = "memory";
1741
+ this.#operations = [];
1742
+ this.#rejectedHistory = [];
1743
+ this.#warnMemoryLock(
1744
+ `Offline queue persistence is unavailable; queued data can be lost after reload (${String(error)}).`
1745
+ );
1746
+ }
1597
1747
  this.#loaded = true;
1598
1748
  }
1599
1749
  /** Releases browser listeners when the queue is no longer used. */
@@ -1636,9 +1786,9 @@ var OfflineQueue = class {
1636
1786
  throw new Error("Offline operation belongs to another rally or user queue.");
1637
1787
  }
1638
1788
  await this.initialize();
1639
- const id2 = operationId(operation);
1640
- if (this.#operations.some((item) => operationId(item) === id2)) return;
1641
- this.#operations = [...this.#operations, operation];
1789
+ const id2 = offlineOperationId(operation);
1790
+ if (this.#operations.some((item) => offlineOperationId(item) === id2)) return;
1791
+ this.#operations = [...this.#operations, { ...operation, status: "PENDING", attempts: 0 }];
1642
1792
  await this.#storage.save(this.#storageKey(), this.#operations);
1643
1793
  this.#announceChange();
1644
1794
  }
@@ -1654,6 +1804,47 @@ var OfflineQueue = class {
1654
1804
  await this.#storage.save(this.#storageKey(), this.#operations);
1655
1805
  this.#announceChange();
1656
1806
  }
1807
+ async discardRejected(operationId) {
1808
+ await this.initialize();
1809
+ const next = this.#rejectedHistory.filter(
1810
+ (entry) => offlineOperationId(entry.operation) !== operationId
1811
+ );
1812
+ if (next.length === this.#rejectedHistory.length) return false;
1813
+ this.#rejectedHistory = next;
1814
+ await this.#saveRejectedHistory();
1815
+ this.#announceChange();
1816
+ return true;
1817
+ }
1818
+ async retryRejected(operationId) {
1819
+ await this.initialize();
1820
+ const entry = this.#rejectedHistory.find(
1821
+ (candidate) => offlineOperationId(candidate.operation) === operationId
1822
+ );
1823
+ if (entry === void 0) return false;
1824
+ if (!this.#operations.some((operation) => offlineOperationId(operation) === operationId))
1825
+ this.#operations = [
1826
+ ...this.#operations,
1827
+ { ...entry.operation, status: "PENDING", attempts: 0 }
1828
+ ];
1829
+ this.#rejectedHistory = this.#rejectedHistory.filter((candidate) => candidate !== entry);
1830
+ await this.#storage.save(this.#storageKey(), this.#operations);
1831
+ await this.#saveRejectedHistory();
1832
+ this.#announceChange();
1833
+ return true;
1834
+ }
1835
+ async discardRejectedOperation(operationId) {
1836
+ return this.discardRejected(operationId);
1837
+ }
1838
+ async retryRejectedOperation(operationId) {
1839
+ return this.retryRejected(operationId);
1840
+ }
1841
+ async clearRejectedHistory() {
1842
+ await this.initialize();
1843
+ if (this.#rejectedHistory.length === 0) return;
1844
+ this.#rejectedHistory = [];
1845
+ await this.#saveRejectedHistory();
1846
+ this.#announceChange();
1847
+ }
1657
1848
  async sync(sender = this.#sender) {
1658
1849
  await this.initialize();
1659
1850
  if (sender === void 0) throw new Error("OfflineQueue.sync requires a sender.");
@@ -1668,6 +1859,37 @@ var OfflineQueue = class {
1668
1859
  return this.sync(sender);
1669
1860
  }
1670
1861
  async #run(sender) {
1862
+ const locks = globalThis.navigator?.locks;
1863
+ if (locks !== void 0 && typeof locks.request === "function") {
1864
+ let callbackStarted = false;
1865
+ try {
1866
+ const acquired = await locks.request(
1867
+ `stamprally:${this.#storageKey()}:sync`,
1868
+ { ifAvailable: true },
1869
+ async (lock) => {
1870
+ if (lock === null) {
1871
+ await this.#reloadFromStorage();
1872
+ this.#state = "idle";
1873
+ return false;
1874
+ }
1875
+ callbackStarted = true;
1876
+ await this.#runWithStorageLock(sender);
1877
+ return true;
1878
+ }
1879
+ );
1880
+ if (!acquired) return;
1881
+ return;
1882
+ } catch (error) {
1883
+ if (callbackStarted) throw error;
1884
+ }
1885
+ }
1886
+ if (this.#lockStorage === null)
1887
+ this.#warnMemoryLock(
1888
+ "No cross-tab storage lock is available; offline sync is single-tab only."
1889
+ );
1890
+ await this.#runWithStorageLock(sender);
1891
+ }
1892
+ async #runWithStorageLock(sender) {
1671
1893
  this.#state = "syncing";
1672
1894
  this.#error = null;
1673
1895
  if (!this.#acquireSyncLock()) {
@@ -1679,21 +1901,54 @@ var OfflineQueue = class {
1679
1901
  while (this.#operations.length > 0) {
1680
1902
  const operation = this.#operations[0];
1681
1903
  if (operation === void 0) break;
1682
- let rawResult;
1683
- try {
1684
- rawResult = await sender(operation);
1685
- } catch (cause) {
1686
- throw new Error(errorValue(cause, "RETRYABLE_ERROR").message);
1687
- }
1688
- const response = this.#normalizeResponse(rawResult);
1689
- if (response.status === "RETRYABLE_ERROR") {
1904
+ let attempt = 0;
1905
+ let response;
1906
+ while (true) {
1907
+ await this.#updateOperationStatus("IN_FLIGHT", attempt);
1908
+ try {
1909
+ response = this.#normalizeResponse(await sender(operation));
1910
+ } catch (cause) {
1911
+ response = {
1912
+ status: "RETRYABLE_ERROR",
1913
+ error: errorValue(cause, "RETRYABLE_ERROR")
1914
+ };
1915
+ }
1916
+ if (response.status !== "RETRYABLE_ERROR") break;
1690
1917
  const error2 = errorValue(response.error ?? response.reason, "RETRYABLE_ERROR");
1918
+ await this.#updateOperationStatus("PENDING", attempt + 1);
1691
1919
  await this.#syncResultListener?.({ operation, status: response.status, error: error2 });
1692
- throw new Error(error2.message);
1920
+ if (attempt >= this.#retryOptions.maxRetries) {
1921
+ await this.#updateOperationStatus("FAILED_RETRYABLE", attempt + 1);
1922
+ throw new Error(error2.message);
1923
+ }
1924
+ const interval = this.#retryOptions.initialIntervalMs * this.#retryOptions.backoffMultiplier ** attempt;
1925
+ await new Promise((resolve) => setTimeout(resolve, Math.max(0, interval)));
1926
+ attempt += 1;
1693
1927
  }
1694
1928
  const result = response.result;
1695
- const state = response.state ?? (result !== void 0 && "conflict" in result && result.conflict === true ? await this.resolveConflict(operation, result.localState, result.serverState) : result !== void 0 && "ok" in result && result.ok ? result.value.state : void 0);
1696
- const error = response.status === "REJECTED_PERMANENT" ? errorValue(response.error ?? response.reason, "REJECTED_PERMANENT") : void 0;
1929
+ const state = response.state ?? (result !== void 0 && "conflict" in result && result.conflict === true ? result.serverState : result !== void 0 && "ok" in result && result.ok ? result.value.state : void 0);
1930
+ const error = response.status === "REJECTED_PERMANENT" ? errorValue(
1931
+ response.error ?? response.reason ?? (result !== void 0 && "ok" in result && !result.ok ? result.error : void 0),
1932
+ "REJECTED_PERMANENT"
1933
+ ) : void 0;
1934
+ await this.#updateOperationStatus(
1935
+ response.status === "ACCEPTED" ? "ACCEPTED" : "REJECTED_PERMANENT",
1936
+ attempt + 1
1937
+ );
1938
+ const eventState = state;
1939
+ if (response.status === "REJECTED_PERMANENT" && error !== void 0) {
1940
+ this.#rejectedHistory = [
1941
+ ...this.#rejectedHistory,
1942
+ {
1943
+ operation: { ...operation, status: "REJECTED_PERMANENT", attempts: attempt + 1 },
1944
+ reason: error,
1945
+ errorCode: error.code,
1946
+ rejectedAt: (/* @__PURE__ */ new Date()).toISOString(),
1947
+ attempts: attempt + 1
1948
+ }
1949
+ ];
1950
+ await this.#saveRejectedHistory();
1951
+ }
1697
1952
  this.#operations = this.#operations.slice(1);
1698
1953
  await this.#storage.save(this.#storageKey(), this.#operations);
1699
1954
  this.#announceChange();
@@ -1702,7 +1957,7 @@ var OfflineQueue = class {
1702
1957
  ...result === void 0 ? {} : { result },
1703
1958
  status: response.status,
1704
1959
  ...error === void 0 ? {} : { error },
1705
- ...state === void 0 ? {} : { state }
1960
+ ...eventState === void 0 ? {} : { state: eventState }
1706
1961
  });
1707
1962
  }
1708
1963
  this.#state = "idle";
@@ -1714,11 +1969,19 @@ var OfflineQueue = class {
1714
1969
  this.#releaseSyncLock();
1715
1970
  }
1716
1971
  }
1972
+ async #updateOperationStatus(status, attempts) {
1973
+ const operation = this.#operations[0];
1974
+ if (operation === void 0) return;
1975
+ this.#operations = [{ ...operation, status, attempts }, ...this.#operations.slice(1)];
1976
+ await this.#storage.save(this.#storageKey(), this.#operations);
1977
+ this.#announceChange();
1978
+ }
1717
1979
  #subscribeToExternalChanges() {
1718
1980
  const windowLike = globalThis.window;
1719
1981
  if (windowLike !== void 0) {
1720
1982
  this.#storageListener = (event) => {
1721
- if (event.key === this.#storageKey()) void this.#reloadFromStorage();
1983
+ if (event.key === this.#storageKey() || event.key === `${this.#storageKey()}:rejected-history`)
1984
+ void this.#reloadFromStorage();
1722
1985
  };
1723
1986
  windowLike.addEventListener("storage", this.#storageListener);
1724
1987
  }
@@ -1727,6 +1990,20 @@ var OfflineQueue = class {
1727
1990
  try {
1728
1991
  this.#channel = new Channel("stamprally:queue-sync");
1729
1992
  this.#channel.addEventListener("message", (event) => {
1993
+ if (typeof event.data === "object" && event.data !== null) {
1994
+ const data = event.data;
1995
+ if (data.type === "lock" && data.lockKey === this.#lockKey()) {
1996
+ this.#observedLocks.set(data.lockKey, {
1997
+ owner: typeof data.owner === "string" ? data.owner : "unknown",
1998
+ expiresAt: typeof data.expiresAt === "number" ? data.expiresAt : 0
1999
+ });
2000
+ return;
2001
+ }
2002
+ if (data.type === "unlock" && data.lockKey === this.#lockKey()) {
2003
+ this.#observedLocks.delete(data.lockKey);
2004
+ return;
2005
+ }
2006
+ }
1730
2007
  if (typeof event.data === "object" && event.data !== null && "key" in event.data && event.data.key === this.#storageKey())
1731
2008
  void this.#reloadFromStorage();
1732
2009
  });
@@ -1736,12 +2013,18 @@ var OfflineQueue = class {
1736
2013
  }
1737
2014
  }
1738
2015
  #announceChange() {
1739
- this.#channel?.postMessage({ key: this.#storageKey(), owner: this.#instanceId });
2016
+ this.#channel?.postMessage({
2017
+ type: "change",
2018
+ key: this.#storageKey(),
2019
+ owner: this.#instanceId
2020
+ });
1740
2021
  }
1741
2022
  async #reloadFromStorage() {
1742
2023
  if (this.#state === "syncing") return;
1743
2024
  try {
1744
- this.#operations = [...await this.#storage.load(this.#storageKey())];
2025
+ const key = this.#storageKey();
2026
+ this.#operations = (await this.#storage.load(key)).map(normalizeOperation);
2027
+ this.#rejectedHistory = (await this.#storage.loadRejectedHistory?.(key))?.map(normalizeRejectedHistory) ?? this.#rejectedHistory;
1745
2028
  this.#loaded = true;
1746
2029
  } catch {
1747
2030
  }
@@ -1755,6 +2038,9 @@ var OfflineQueue = class {
1755
2038
  const local = syncLocks.get(key);
1756
2039
  if (local !== void 0 && local.expiresAt > now && local.owner !== this.#instanceId)
1757
2040
  return false;
2041
+ const observed = this.#observedLocks.get(key);
2042
+ if (observed !== void 0 && observed.expiresAt > now && observed.owner !== this.#instanceId)
2043
+ return false;
1758
2044
  if (this.#lockStorage !== null) {
1759
2045
  try {
1760
2046
  const existing = this.#lockStorage.getItem(key);
@@ -1767,7 +2053,14 @@ var OfflineQueue = class {
1767
2053
  key,
1768
2054
  JSON.stringify({ owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS })
1769
2055
  );
2056
+ this.#channel?.postMessage({
2057
+ type: "lock",
2058
+ lockKey: key,
2059
+ owner: this.#instanceId,
2060
+ expiresAt: now + SYNC_LOCK_TTL_MS
2061
+ });
1770
2062
  } catch {
2063
+ this.#warnMemoryLock("Persistent lock access failed; offline sync is single-tab only.");
1771
2064
  }
1772
2065
  }
1773
2066
  syncLocks.set(key, { owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS });
@@ -1782,6 +2075,7 @@ var OfflineQueue = class {
1782
2075
  const value = this.#lockStorage.getItem(key);
1783
2076
  if (value !== null && JSON.parse(value).owner === this.#instanceId)
1784
2077
  this.#lockStorage.removeItem?.(key);
2078
+ this.#channel?.postMessage({ type: "unlock", lockKey: key, owner: this.#instanceId });
1785
2079
  } catch {
1786
2080
  }
1787
2081
  }
@@ -1810,7 +2104,33 @@ var OfflineQueue = class {
1810
2104
  const policy = (typeof configured === "function" ? await configured({ operation, localState, serverState }) : configured) ?? this.#conflictPolicy;
1811
2105
  return resolveRallyStateConflict(serverState, localState, { policy });
1812
2106
  }
2107
+ async #saveRejectedHistory() {
2108
+ await this.#storage.saveRejectedHistory?.(this.#storageKey(), this.#rejectedHistory);
2109
+ }
2110
+ #warnMemoryLock(message) {
2111
+ if (this.#warnedMemoryLock) return;
2112
+ this.#warnedMemoryLock = true;
2113
+ console.warn(`[@stamprally/core] ${message}`);
2114
+ }
1813
2115
  };
2116
+ function normalizeOperation(operation) {
2117
+ const status = operation.status;
2118
+ return {
2119
+ ...operation,
2120
+ status: status === "IN_FLIGHT" || status === "REJECTED" ? "PENDING" : status === "RETRYABLE_ERROR" ? "FAILED_RETRYABLE" : operation.status ?? "PENDING",
2121
+ attempts: operation.attempts ?? 0
2122
+ };
2123
+ }
2124
+ function normalizeRejectedHistory(entry) {
2125
+ return {
2126
+ ...entry,
2127
+ operation: normalizeOperation(entry.operation),
2128
+ reason: errorValue(entry.reason, "REJECTED_PERMANENT"),
2129
+ errorCode: entry.errorCode || entry.reason.code,
2130
+ rejectedAt: entry.rejectedAt || (/* @__PURE__ */ new Date(0)).toISOString(),
2131
+ attempts: entry.attempts ?? entry.operation.attempts ?? 0
2132
+ };
2133
+ }
1814
2134
 
1815
2135
  // src/crypto/token.ts
1816
2136
  var encoder = new TextEncoder();
@@ -2379,6 +2699,8 @@ function condition(value, path, errors, isPublic) {
2379
2699
  finiteNumber(value, "latitude", path, errors);
2380
2700
  finiteNumber(value, "longitude", path, errors);
2381
2701
  finiteNumber(value, "radiusMeters", path, errors, 0);
2702
+ if (typeof value.radiusMeters === "number" && value.radiusMeters <= 0)
2703
+ add(errors, `${path}.radiusMeters`, "Expected a radius greater than 0.", "out_of_range");
2382
2704
  if (typeof value.latitude === "number" && (value.latitude < -90 || value.latitude > 90))
2383
2705
  add(errors, `${path}.latitude`, "Expected a latitude between -90 and 90.", "out_of_range");
2384
2706
  if (typeof value.longitude === "number" && (value.longitude < -180 || value.longitude > 180))
@@ -2804,6 +3126,7 @@ exports.assertPublicConfig = assertPublicConfig;
2804
3126
  exports.calculateDistanceMeters = calculateDistanceMeters;
2805
3127
  exports.calculateProgress = calculateProgress;
2806
3128
  exports.consumeReward = consumeReward;
3129
+ exports.createAnonymousSessionId = createAnonymousSessionId;
2807
3130
  exports.createClaimTicketNumber = createClaimTicketNumber;
2808
3131
  exports.createSecureToken = createSecureToken;
2809
3132
  exports.createSignedSnapshotToken = createSignedSnapshotToken;
@@ -2824,6 +3147,7 @@ exports.isRewardState = isRewardState;
2824
3147
  exports.isStampRallyState = isStampRallyState;
2825
3148
  exports.issueClaimTicketNumber = issueClaimTicketNumber;
2826
3149
  exports.normalizePasscode = normalizePasscode;
3150
+ exports.offlineOperationId = offlineOperationId;
2827
3151
  exports.parseAdminConfig = parseAdminConfig;
2828
3152
  exports.parsePublicConfig = parsePublicConfig;
2829
3153
  exports.processStamp = processStamp;