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