@stamprally/core 0.13.0 → 0.15.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
1
+ # @stamprally/core v0.15.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.9.1",
10
+ version: "0.15.0",
11
11
  title: "City Tour",
12
12
  spots: [{ id: "station", orderIndex: 0, name: "Central Station", conditions: [{ type: "passcode" }] }],
13
13
  rewards: [],
@@ -23,7 +23,7 @@ The browser detectors `getCurrentGeoContext`, `readNfcContext`, and `readQrConte
23
23
 
24
24
  `InMemoryStorage`, `LocalStorageAdapter`, and `IndexedDBAdapter` implement `StampStorage`. `updateLocalizedField` updates one locale without dropping existing translations.
25
25
 
26
- ## v0.13 offline synchronization
26
+ ## v0.15.0 offline synchronization
27
27
 
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
@@ -32,6 +32,10 @@ timestamps, and gives `CONSUMED` reward states priority; `server_wins` uses the
32
32
  server state unchanged. Sync adapters may return `ACCEPTED`, `REJECTED_PERMANENT`,
33
33
  or `RETRYABLE_ERROR`; only the first two remove an operation.
34
34
 
35
+ When no authenticated user is supplied, the client creates a persistent UUID v4
36
+ `anonymousSessionId` and includes it in sync requests. Queue replay uses Web Locks
37
+ when available and supports bounded exponential backoff through `retryOptions`.
38
+
35
39
  `evaluateSpotStatus` derives `UNCLAIMED`, `CLAIMED`, `LOCKED`, or `VERIFYING`
36
40
  without mutating state. A spot with incomplete `prerequisites` is `LOCKED` and
37
41
  must not be verified by a client or viewer.
package/dist/index.cjs CHANGED
@@ -61,7 +61,9 @@ function calculateProgress(state, config) {
61
61
  const acquired = new Set(
62
62
  state.records.map((record) => record.stampId).filter((id2) => ids.has(id2))
63
63
  );
64
- const remaining = config.spots.filter((spot2) => !acquired.has(spot2.id));
64
+ const remaining = config.spots.filter(
65
+ (spot2) => !acquired.has(spot2.id) && (spot2.prerequisites === void 0 || spot2.prerequisites.every((id2) => acquired.has(id2)))
66
+ );
65
67
  return {
66
68
  acquired: acquired.size,
67
69
  total: config.spots.length,
@@ -615,7 +617,13 @@ function cloneState(state) {
615
617
  return {
616
618
  ...state,
617
619
  records: state.records.map(cloneRecord),
618
- ...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
+ }
619
627
  };
620
628
  }
621
629
  function isRecord(value) {
@@ -635,7 +643,7 @@ function isRewardState(value) {
635
643
  function isStampRallyState(value) {
636
644
  if (typeof value !== "object" || value === null) return false;
637
645
  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));
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)));
639
647
  }
640
648
  function isValidDate(value) {
641
649
  return typeof value === "string" && !Number.isNaN(Date.parse(value));
@@ -680,6 +688,35 @@ var InMemoryStorage = class {
680
688
  function storageKey(rallyId, userId) {
681
689
  return `stamprally:${rallyId}:${userId ?? "anonymous"}`;
682
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
+ }
683
720
  var defaultStorageWarningHandler = (error) => {
684
721
  console.warn(`[@stamprally/core] ${error.message}`, error);
685
722
  };
@@ -1069,6 +1106,7 @@ var StampRallyClient = class {
1069
1106
  #config;
1070
1107
  #offlineQueue;
1071
1108
  #userId;
1109
+ #anonymousSessionId;
1072
1110
  #state = null;
1073
1111
  #initialization = null;
1074
1112
  #queue = Promise.resolve();
@@ -1076,7 +1114,8 @@ var StampRallyClient = class {
1076
1114
  this.#config = config;
1077
1115
  this.#options = isStorage(storageOrOptions) ? { storage: storageOrOptions } : storageOrOptions;
1078
1116
  this.#storage = this.#options.storage ?? new InMemoryStorage();
1079
- this.#userId = this.#options.userId ?? null;
1117
+ this.#anonymousSessionId = this.#options.anonymousSessionId ?? createAnonymousSessionId();
1118
+ this.#userId = this.#options.userId ?? this.#anonymousSessionId;
1080
1119
  this.#offlineQueue = this.#options.offlineQueue;
1081
1120
  this.#offlineQueue?.setSyncResultListener((event) => this.#handleOfflineSyncResult(event));
1082
1121
  }
@@ -1089,6 +1128,9 @@ var StampRallyClient = class {
1089
1128
  getUserId() {
1090
1129
  return this.#userId;
1091
1130
  }
1131
+ getAnonymousSessionId() {
1132
+ return this.#anonymousSessionId;
1133
+ }
1092
1134
  get syncState() {
1093
1135
  return this.#offlineQueue?.syncState ?? "idle";
1094
1136
  }
@@ -1126,11 +1168,12 @@ var StampRallyClient = class {
1126
1168
  }
1127
1169
  switchUser(newUserId) {
1128
1170
  return this.#enqueue(async () => {
1129
- if (this.#userId === newUserId && this.#state !== null) return this.#state;
1130
- this.#userId = newUserId;
1171
+ const nextUserId = newUserId ?? this.#anonymousSessionId;
1172
+ if (this.#userId === nextUserId && this.#state !== null) return this.#state;
1173
+ this.#userId = nextUserId;
1131
1174
  this.#state = null;
1132
1175
  this.#initialization = null;
1133
- await this.#offlineQueue?.switchUser(newUserId);
1176
+ await this.#offlineQueue?.switchUser(nextUserId);
1134
1177
  return this.initialize();
1135
1178
  });
1136
1179
  }
@@ -1198,7 +1241,8 @@ var StampRallyClient = class {
1198
1241
  proofData,
1199
1242
  idempotencyKey: options.idempotencyKey ?? id("check-in"),
1200
1243
  now,
1201
- state: current
1244
+ state: current,
1245
+ ...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
1202
1246
  };
1203
1247
  const remote = this.#options.syncAdapter?.checkIn;
1204
1248
  if (options.sync !== false && remote !== void 0) {
@@ -1254,7 +1298,8 @@ var StampRallyClient = class {
1254
1298
  idempotencyKey: options.idempotencyKey ?? id("claim"),
1255
1299
  now,
1256
1300
  options,
1257
- state: current
1301
+ state: current,
1302
+ ...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
1258
1303
  };
1259
1304
  const remote = this.#options.syncAdapter?.claimReward;
1260
1305
  if (options.sync !== false && remote !== void 0) {
@@ -1306,13 +1351,10 @@ var StampRallyClient = class {
1306
1351
  const serverState = await adapter.sync({
1307
1352
  rallyId: this.#config.id,
1308
1353
  userId: this.#userId,
1309
- state: this.#state ?? current
1310
- });
1311
- const localState = this.#state ?? current;
1312
- const merged = this.#offlineQueue === void 0 ? serverState : resolveRallyStateConflict(serverState, localState, {
1313
- policy: this.#offlineQueue.conflictPolicy
1354
+ state: this.#state ?? current,
1355
+ ...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
1314
1356
  });
1315
- const next = this.#reconcile(merged);
1357
+ const next = this.#reconcile(serverState);
1316
1358
  await this.#storage.save(next);
1317
1359
  this.#state = next;
1318
1360
  this.#emit(next);
@@ -1524,6 +1566,16 @@ function errorValue(value, fallbackCode) {
1524
1566
  if (typeof value === "string") return { code: fallbackCode, message: value };
1525
1567
  return { code: fallbackCode, message: "Offline operation was rejected." };
1526
1568
  }
1569
+ var syncLocks = /* @__PURE__ */ new Map();
1570
+ var SYNC_LOCK_TTL_MS = 3e4;
1571
+ var DEFAULT_RETRY_OPTIONS = {
1572
+ maxRetries: 0,
1573
+ initialIntervalMs: 250,
1574
+ backoffMultiplier: 2
1575
+ };
1576
+ function randomId() {
1577
+ return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
1578
+ }
1527
1579
  var OfflineQueue = class {
1528
1580
  #storage;
1529
1581
  #configuredKey;
@@ -1538,6 +1590,12 @@ var OfflineQueue = class {
1538
1590
  #sender;
1539
1591
  #syncPromise = null;
1540
1592
  #syncResultListener;
1593
+ #synchronizeInstances;
1594
+ #retryOptions;
1595
+ #instanceId = randomId();
1596
+ #lockStorage;
1597
+ #storageListener;
1598
+ #channel = null;
1541
1599
  constructor(options = {}) {
1542
1600
  if (options.storage !== void 0) this.#storage = options.storage;
1543
1601
  else if (options.storageLike !== void 0 && options.storageLike !== null)
@@ -1548,6 +1606,18 @@ var OfflineQueue = class {
1548
1606
  this.#userId = options.userId ?? null;
1549
1607
  this.#conflictPolicy = options.conflictPolicy ?? "server_wins";
1550
1608
  this.#onSyncConflict = options.onSyncConflict;
1609
+ this.#synchronizeInstances = options.synchronizeInstances ?? true;
1610
+ const retryOptions = {
1611
+ ...DEFAULT_RETRY_OPTIONS,
1612
+ ...options.retryOptions ?? options.syncRetryOptions ?? options.retry ?? {}
1613
+ };
1614
+ this.#retryOptions = {
1615
+ maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
1616
+ initialIntervalMs: Math.max(0, retryOptions.initialIntervalMs),
1617
+ backoffMultiplier: Math.max(1, retryOptions.backoffMultiplier)
1618
+ };
1619
+ this.#lockStorage = options.storageLike ?? globalThis.localStorage ?? null;
1620
+ if (this.#synchronizeInstances) this.#subscribeToExternalChanges();
1551
1621
  }
1552
1622
  get syncState() {
1553
1623
  return this.#state;
@@ -1578,9 +1648,19 @@ var OfflineQueue = class {
1578
1648
  }
1579
1649
  async initialize() {
1580
1650
  if (this.#loaded) return;
1581
- this.#operations = [...await this.#storage.load(this.#storageKey())];
1651
+ this.#operations = (await this.#storage.load(this.#storageKey())).map(normalizeOperation);
1582
1652
  this.#loaded = true;
1583
1653
  }
1654
+ /** Releases browser listeners when the queue is no longer used. */
1655
+ dispose() {
1656
+ const windowLike = globalThis.window;
1657
+ if (windowLike !== void 0 && this.#storageListener !== void 0)
1658
+ windowLike.removeEventListener("storage", this.#storageListener);
1659
+ this.#storageListener = void 0;
1660
+ this.#channel?.close();
1661
+ this.#channel = null;
1662
+ this.#releaseSyncLock();
1663
+ }
1584
1664
  /** Selects a rally/user queue scope and loads its pending operations. */
1585
1665
  async setScope(rallyId, userId) {
1586
1666
  if (this.#configuredKey !== void 0) {
@@ -1613,8 +1693,9 @@ var OfflineQueue = class {
1613
1693
  await this.initialize();
1614
1694
  const id2 = operationId(operation);
1615
1695
  if (this.#operations.some((item) => operationId(item) === id2)) return;
1616
- this.#operations = [...this.#operations, operation];
1696
+ this.#operations = [...this.#operations, { ...operation, status: "PENDING", attempts: 0 }];
1617
1697
  await this.#storage.save(this.#storageKey(), this.#operations);
1698
+ this.#announceChange();
1618
1699
  }
1619
1700
  async enqueueCheckIn(request) {
1620
1701
  return this.enqueue({ kind: "checkIn", request });
@@ -1626,6 +1707,7 @@ var OfflineQueue = class {
1626
1707
  await this.initialize();
1627
1708
  this.#operations = [];
1628
1709
  await this.#storage.save(this.#storageKey(), this.#operations);
1710
+ this.#announceChange();
1629
1711
  }
1630
1712
  async sync(sender = this.#sender) {
1631
1713
  await this.initialize();
@@ -1641,35 +1723,83 @@ var OfflineQueue = class {
1641
1723
  return this.sync(sender);
1642
1724
  }
1643
1725
  async #run(sender) {
1726
+ const locks = globalThis.navigator?.locks;
1727
+ if (locks !== void 0) {
1728
+ let callbackStarted = false;
1729
+ try {
1730
+ const acquired = await locks.request(
1731
+ `stamprally:${this.#storageKey()}:sync`,
1732
+ { ifAvailable: true },
1733
+ async (lock) => {
1734
+ if (lock === null) {
1735
+ await this.#reloadFromStorage();
1736
+ this.#state = "idle";
1737
+ return false;
1738
+ }
1739
+ callbackStarted = true;
1740
+ await this.#runWithStorageLock(sender);
1741
+ return true;
1742
+ }
1743
+ );
1744
+ if (!acquired) return;
1745
+ return;
1746
+ } catch (error) {
1747
+ if (callbackStarted) throw error;
1748
+ }
1749
+ }
1750
+ await this.#runWithStorageLock(sender);
1751
+ }
1752
+ async #runWithStorageLock(sender) {
1644
1753
  this.#state = "syncing";
1645
1754
  this.#error = null;
1755
+ if (!this.#acquireSyncLock()) {
1756
+ await this.#reloadFromStorage();
1757
+ this.#state = "idle";
1758
+ return;
1759
+ }
1646
1760
  try {
1647
1761
  while (this.#operations.length > 0) {
1648
1762
  const operation = this.#operations[0];
1649
1763
  if (operation === void 0) break;
1650
- let rawResult;
1651
- try {
1652
- rawResult = await sender(operation);
1653
- } catch (cause) {
1654
- throw new Error(errorValue(cause, "RETRYABLE_ERROR").message);
1655
- }
1656
- const response = this.#normalizeResponse(rawResult);
1657
- if (response.status === "RETRYABLE_ERROR") {
1764
+ let attempt = 0;
1765
+ let response;
1766
+ while (true) {
1767
+ await this.#updateOperationStatus("IN_FLIGHT", attempt);
1768
+ try {
1769
+ response = this.#normalizeResponse(await sender(operation));
1770
+ } catch (cause) {
1771
+ response = {
1772
+ status: "RETRYABLE_ERROR",
1773
+ error: errorValue(cause, "RETRYABLE_ERROR")
1774
+ };
1775
+ }
1776
+ if (response.status !== "RETRYABLE_ERROR") break;
1658
1777
  const error2 = errorValue(response.error ?? response.reason, "RETRYABLE_ERROR");
1778
+ await this.#updateOperationStatus("PENDING", attempt + 1);
1659
1779
  await this.#syncResultListener?.({ operation, status: response.status, error: error2 });
1660
- throw new Error(error2.message);
1780
+ if (attempt >= this.#retryOptions.maxRetries) throw new Error(error2.message);
1781
+ const interval = this.#retryOptions.initialIntervalMs * this.#retryOptions.backoffMultiplier ** attempt;
1782
+ await new Promise((resolve) => setTimeout(resolve, Math.max(0, interval)));
1783
+ attempt += 1;
1661
1784
  }
1662
1785
  const result = response.result;
1663
1786
  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);
1664
1787
  const error = response.status === "REJECTED_PERMANENT" ? errorValue(response.error ?? response.reason, "REJECTED_PERMANENT") : void 0;
1788
+ await this.#updateOperationStatus(
1789
+ response.status === "ACCEPTED" ? "ACCEPTED" : "REJECTED",
1790
+ attempt + 1
1791
+ );
1792
+ const fallbackState = response.status === "REJECTED_PERMANENT" ? operation.request.state : void 0;
1793
+ const eventState = state ?? fallbackState;
1665
1794
  this.#operations = this.#operations.slice(1);
1666
1795
  await this.#storage.save(this.#storageKey(), this.#operations);
1796
+ this.#announceChange();
1667
1797
  await this.#syncResultListener?.({
1668
1798
  operation,
1669
1799
  ...result === void 0 ? {} : { result },
1670
1800
  status: response.status,
1671
1801
  ...error === void 0 ? {} : { error },
1672
- ...state === void 0 ? {} : { state }
1802
+ ...eventState === void 0 ? {} : { state: eventState }
1673
1803
  });
1674
1804
  }
1675
1805
  this.#state = "idle";
@@ -1677,6 +1807,87 @@ var OfflineQueue = class {
1677
1807
  this.#state = "error";
1678
1808
  this.#error = cause instanceof Error ? cause : new Error(String(cause));
1679
1809
  throw this.#error;
1810
+ } finally {
1811
+ this.#releaseSyncLock();
1812
+ }
1813
+ }
1814
+ async #updateOperationStatus(status, attempts) {
1815
+ const operation = this.#operations[0];
1816
+ if (operation === void 0) return;
1817
+ this.#operations = [{ ...operation, status, attempts }, ...this.#operations.slice(1)];
1818
+ await this.#storage.save(this.#storageKey(), this.#operations);
1819
+ this.#announceChange();
1820
+ }
1821
+ #subscribeToExternalChanges() {
1822
+ const windowLike = globalThis.window;
1823
+ if (windowLike !== void 0) {
1824
+ this.#storageListener = (event) => {
1825
+ if (event.key === this.#storageKey()) void this.#reloadFromStorage();
1826
+ };
1827
+ windowLike.addEventListener("storage", this.#storageListener);
1828
+ }
1829
+ const Channel = globalThis.BroadcastChannel;
1830
+ if (Channel !== void 0) {
1831
+ try {
1832
+ this.#channel = new Channel("stamprally:queue-sync");
1833
+ this.#channel.addEventListener("message", (event) => {
1834
+ if (typeof event.data === "object" && event.data !== null && "key" in event.data && event.data.key === this.#storageKey())
1835
+ void this.#reloadFromStorage();
1836
+ });
1837
+ } catch {
1838
+ this.#channel = null;
1839
+ }
1840
+ }
1841
+ }
1842
+ #announceChange() {
1843
+ this.#channel?.postMessage({ key: this.#storageKey(), owner: this.#instanceId });
1844
+ }
1845
+ async #reloadFromStorage() {
1846
+ if (this.#state === "syncing") return;
1847
+ try {
1848
+ this.#operations = (await this.#storage.load(this.#storageKey())).map(normalizeOperation);
1849
+ this.#loaded = true;
1850
+ } catch {
1851
+ }
1852
+ }
1853
+ #lockKey() {
1854
+ return `${this.#storageKey()}:sync-lock`;
1855
+ }
1856
+ #acquireSyncLock() {
1857
+ const key = this.#lockKey();
1858
+ const now = Date.now();
1859
+ const local = syncLocks.get(key);
1860
+ if (local !== void 0 && local.expiresAt > now && local.owner !== this.#instanceId)
1861
+ return false;
1862
+ if (this.#lockStorage !== null) {
1863
+ try {
1864
+ const existing = this.#lockStorage.getItem(key);
1865
+ if (existing !== null) {
1866
+ const parsed = JSON.parse(existing);
1867
+ if (typeof parsed === "object" && parsed !== null && typeof parsed.expiresAt === "number" && parsed.expiresAt > now && parsed.owner !== this.#instanceId)
1868
+ return false;
1869
+ }
1870
+ this.#lockStorage.setItem(
1871
+ key,
1872
+ JSON.stringify({ owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS })
1873
+ );
1874
+ } catch {
1875
+ }
1876
+ }
1877
+ syncLocks.set(key, { owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS });
1878
+ return true;
1879
+ }
1880
+ #releaseSyncLock() {
1881
+ const key = this.#lockKey();
1882
+ const current = syncLocks.get(key);
1883
+ if (current?.owner === this.#instanceId) syncLocks.delete(key);
1884
+ if (this.#lockStorage !== null) {
1885
+ try {
1886
+ const value = this.#lockStorage.getItem(key);
1887
+ if (value !== null && JSON.parse(value).owner === this.#instanceId)
1888
+ this.#lockStorage.removeItem?.(key);
1889
+ } catch {
1890
+ }
1680
1891
  }
1681
1892
  }
1682
1893
  #storageKey() {
@@ -1704,6 +1915,13 @@ var OfflineQueue = class {
1704
1915
  return resolveRallyStateConflict(serverState, localState, { policy });
1705
1916
  }
1706
1917
  };
1918
+ function normalizeOperation(operation) {
1919
+ return {
1920
+ ...operation,
1921
+ status: operation.status === "IN_FLIGHT" ? "PENDING" : operation.status ?? "PENDING",
1922
+ attempts: operation.attempts ?? 0
1923
+ };
1924
+ }
1707
1925
 
1708
1926
  // src/crypto/token.ts
1709
1927
  var encoder = new TextEncoder();
@@ -2272,6 +2490,8 @@ function condition(value, path, errors, isPublic) {
2272
2490
  finiteNumber(value, "latitude", path, errors);
2273
2491
  finiteNumber(value, "longitude", path, errors);
2274
2492
  finiteNumber(value, "radiusMeters", path, errors, 0);
2493
+ if (typeof value.radiusMeters === "number" && value.radiusMeters <= 0)
2494
+ add(errors, `${path}.radiusMeters`, "Expected a radius greater than 0.", "out_of_range");
2275
2495
  if (typeof value.latitude === "number" && (value.latitude < -90 || value.latitude > 90))
2276
2496
  add(errors, `${path}.latitude`, "Expected a latitude between -90 and 90.", "out_of_range");
2277
2497
  if (typeof value.longitude === "number" && (value.longitude < -180 || value.longitude > 180))
@@ -2439,21 +2659,112 @@ function validate(value, isPublic) {
2439
2659
  optionalString(value, "staffPasscode", "$", errors);
2440
2660
  if (hasOwn(value, "inventory") && value.inventory !== void 0 && !isRecord3(value.inventory))
2441
2661
  add(errors, "$.inventory", "Expected an object.", "invalid_type");
2662
+ if (hasOwn(value, "inventoryMode") && value.inventoryMode !== void 0 && value.inventoryMode !== "shared" && value.inventoryMode !== "per_reward")
2663
+ add(errors, "$.inventoryMode", "Expected shared or per_reward.", "invalid_enum");
2442
2664
  if (hasOwn(value, "serverMetadata") && value.serverMetadata !== void 0 && !isRecord3(value.serverMetadata))
2443
2665
  add(errors, "$.serverMetadata", "Expected an object.", "invalid_type");
2444
2666
  if (hasOwn(value, "publicMetadata") && value.publicMetadata !== void 0 && !isRecord3(value.publicMetadata))
2445
2667
  add(errors, "$.publicMetadata", "Expected an object.", "invalid_type");
2446
2668
  optionalString(value, "serverEndpoint", "$", errors);
2447
2669
  } else {
2448
- for (const key of ["staffPasscode", "serverMetadata", "inventory"])
2670
+ for (const key of ["staffPasscode", "serverMetadata", "inventory", "inventoryMode"])
2449
2671
  if (hasOwn(value, key))
2450
2672
  add(errors, `$.${key}`, "Private field is not allowed.", "private_field");
2451
2673
  optionalString(value, "serverEndpoint", "$", errors);
2452
2674
  }
2453
2675
  return errors;
2454
2676
  }
2677
+ function validateRallyConfigRelations(config) {
2678
+ const errors = [];
2679
+ const spotIds = /* @__PURE__ */ new Set();
2680
+ const rewardIds = /* @__PURE__ */ new Set();
2681
+ const orderIndexes = /* @__PURE__ */ new Map();
2682
+ config.spots.forEach((spot2, index) => {
2683
+ if (spotIds.has(spot2.id))
2684
+ add(errors, `spots[${index}].id`, "Spot ID must be unique.", "duplicate_spot_id");
2685
+ spotIds.add(spot2.id);
2686
+ const previousIndex = orderIndexes.get(spot2.orderIndex);
2687
+ if (previousIndex !== void 0)
2688
+ add(
2689
+ errors,
2690
+ `spots[${index}].orderIndex`,
2691
+ `orderIndex duplicates spots[${previousIndex}].`,
2692
+ "duplicate_order_index"
2693
+ );
2694
+ else orderIndexes.set(spot2.orderIndex, index);
2695
+ if (spot2.orderIndex < 0)
2696
+ add(
2697
+ errors,
2698
+ `spots[${index}].orderIndex`,
2699
+ "orderIndex must not be negative.",
2700
+ "negative_order_index"
2701
+ );
2702
+ spot2.prerequisites?.forEach((prerequisite, prerequisiteIndex) => {
2703
+ if (!spotIds.has(prerequisite) && !config.spots.some((candidate) => candidate.id === prerequisite))
2704
+ add(
2705
+ errors,
2706
+ `spots[${index}].prerequisites[${prerequisiteIndex}]`,
2707
+ "Prerequisite spot does not exist.",
2708
+ "missing_prerequisite"
2709
+ );
2710
+ });
2711
+ });
2712
+ config.rewards.forEach((reward2, index) => {
2713
+ if (rewardIds.has(reward2.id))
2714
+ add(errors, `rewards[${index}].id`, "Reward ID must be unique.", "duplicate_reward_id");
2715
+ rewardIds.add(reward2.id);
2716
+ const visit = (condition2, path) => {
2717
+ if (condition2.type === "stamps")
2718
+ condition2.stampIds.forEach((stampId, stampIndex) => {
2719
+ if (!spotIds.has(stampId))
2720
+ add(
2721
+ errors,
2722
+ `${path}.stampIds[${stampIndex}]`,
2723
+ "Referenced spot does not exist.",
2724
+ "missing_reward_spot"
2725
+ );
2726
+ });
2727
+ else if (condition2.type === "all" || condition2.type === "any")
2728
+ condition2.conditions.forEach((nested, nestedIndex) => {
2729
+ visit(nested, `${path}.conditions[${nestedIndex}]`);
2730
+ });
2731
+ };
2732
+ reward2.conditions?.forEach((condition2, conditionIndex) => {
2733
+ visit(condition2, `rewards[${index}].conditions[${conditionIndex}]`);
2734
+ });
2735
+ });
2736
+ const visiting = /* @__PURE__ */ new Set();
2737
+ const visited = /* @__PURE__ */ new Set();
2738
+ const cycleNodes = /* @__PURE__ */ new Set();
2739
+ const visitSpot = (spotId) => {
2740
+ if (visiting.has(spotId)) {
2741
+ cycleNodes.add(spotId);
2742
+ return;
2743
+ }
2744
+ if (visited.has(spotId)) return;
2745
+ visiting.add(spotId);
2746
+ const spot2 = config.spots.find((candidate) => candidate.id === spotId);
2747
+ spot2?.prerequisites?.forEach(visitSpot);
2748
+ visiting.delete(spotId);
2749
+ visited.add(spotId);
2750
+ };
2751
+ config.spots.forEach((spot2) => {
2752
+ visitSpot(spot2.id);
2753
+ });
2754
+ cycleNodes.forEach((spotId) => {
2755
+ const index = config.spots.findIndex((spot2) => spot2.id === spotId);
2756
+ add(
2757
+ errors,
2758
+ `spots[${index}].prerequisites`,
2759
+ "Prerequisites must form a DAG.",
2760
+ "cyclic_prerequisites"
2761
+ );
2762
+ });
2763
+ return errors;
2764
+ }
2455
2765
  function safeParseAdminConfig(input) {
2456
- const errors = validate(input, false);
2766
+ const errors = [...validate(input, false)];
2767
+ if (errors.length === 0) errors.push(...validateRallyConfigRelations(input));
2457
2768
  return errors.length === 0 ? { success: true, data: input } : { success: false, errors };
2458
2769
  }
2459
2770
  function parseAdminConfig(input) {
@@ -2462,7 +2773,8 @@ function parseAdminConfig(input) {
2462
2773
  return result.data;
2463
2774
  }
2464
2775
  function safeParsePublicConfig(input) {
2465
- const errors = validate(input, true);
2776
+ const errors = [...validate(input, true)];
2777
+ if (errors.length === 0) errors.push(...validateRallyConfigRelations(input));
2466
2778
  return errors.length === 0 ? { success: true, data: input } : { success: false, errors };
2467
2779
  }
2468
2780
  function parsePublicConfig(input) {
@@ -2605,6 +2917,7 @@ exports.assertPublicConfig = assertPublicConfig;
2605
2917
  exports.calculateDistanceMeters = calculateDistanceMeters;
2606
2918
  exports.calculateProgress = calculateProgress;
2607
2919
  exports.consumeReward = consumeReward;
2920
+ exports.createAnonymousSessionId = createAnonymousSessionId;
2608
2921
  exports.createClaimTicketNumber = createClaimTicketNumber;
2609
2922
  exports.createSecureToken = createSecureToken;
2610
2923
  exports.createSignedSnapshotToken = createSignedSnapshotToken;
@@ -2641,6 +2954,7 @@ exports.toLocalizedString = toLocalizedString;
2641
2954
  exports.toPublicConfig = toPublicConfig;
2642
2955
  exports.updateLocalizedField = updateLocalizedField;
2643
2956
  exports.validatePublicConfigSafety = validatePublicConfigSafety;
2957
+ exports.validateRallyConfigRelations = validateRallyConfigRelations;
2644
2958
  exports.verifyPasscode = verifyPasscode;
2645
2959
  exports.verifySecureToken = verifySecureToken;
2646
2960
  exports.verifySnapshotToken = verifySnapshotToken;