@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/dist/index.js CHANGED
@@ -59,7 +59,9 @@ function calculateProgress(state, config) {
59
59
  const acquired = new Set(
60
60
  state.records.map((record) => record.stampId).filter((id2) => ids.has(id2))
61
61
  );
62
- const remaining = config.spots.filter((spot2) => !acquired.has(spot2.id));
62
+ const remaining = config.spots.filter(
63
+ (spot2) => !acquired.has(spot2.id) && (spot2.prerequisites === void 0 || spot2.prerequisites.every((id2) => acquired.has(id2)))
64
+ );
63
65
  return {
64
66
  acquired: acquired.size,
65
67
  total: config.spots.length,
@@ -613,7 +615,13 @@ function cloneState(state) {
613
615
  return {
614
616
  ...state,
615
617
  records: state.records.map(cloneRecord),
616
- ...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
+ }
617
625
  };
618
626
  }
619
627
  function isRecord(value) {
@@ -633,7 +641,7 @@ function isRewardState(value) {
633
641
  function isStampRallyState(value) {
634
642
  if (typeof value !== "object" || value === null) return false;
635
643
  const state = value;
636
- 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)));
637
645
  }
638
646
  function isValidDate(value) {
639
647
  return typeof value === "string" && !Number.isNaN(Date.parse(value));
@@ -678,6 +686,35 @@ var InMemoryStorage = class {
678
686
  function storageKey(rallyId, userId) {
679
687
  return `stamprally:${rallyId}:${userId ?? "anonymous"}`;
680
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
+ }
681
718
  var defaultStorageWarningHandler = (error) => {
682
719
  console.warn(`[@stamprally/core] ${error.message}`, error);
683
720
  };
@@ -1067,6 +1104,7 @@ var StampRallyClient = class {
1067
1104
  #config;
1068
1105
  #offlineQueue;
1069
1106
  #userId;
1107
+ #anonymousSessionId;
1070
1108
  #state = null;
1071
1109
  #initialization = null;
1072
1110
  #queue = Promise.resolve();
@@ -1074,7 +1112,8 @@ var StampRallyClient = class {
1074
1112
  this.#config = config;
1075
1113
  this.#options = isStorage(storageOrOptions) ? { storage: storageOrOptions } : storageOrOptions;
1076
1114
  this.#storage = this.#options.storage ?? new InMemoryStorage();
1077
- this.#userId = this.#options.userId ?? null;
1115
+ this.#anonymousSessionId = this.#options.anonymousSessionId ?? createAnonymousSessionId();
1116
+ this.#userId = this.#options.userId ?? this.#anonymousSessionId;
1078
1117
  this.#offlineQueue = this.#options.offlineQueue;
1079
1118
  this.#offlineQueue?.setSyncResultListener((event) => this.#handleOfflineSyncResult(event));
1080
1119
  }
@@ -1087,6 +1126,9 @@ var StampRallyClient = class {
1087
1126
  getUserId() {
1088
1127
  return this.#userId;
1089
1128
  }
1129
+ getAnonymousSessionId() {
1130
+ return this.#anonymousSessionId;
1131
+ }
1090
1132
  get syncState() {
1091
1133
  return this.#offlineQueue?.syncState ?? "idle";
1092
1134
  }
@@ -1124,11 +1166,12 @@ var StampRallyClient = class {
1124
1166
  }
1125
1167
  switchUser(newUserId) {
1126
1168
  return this.#enqueue(async () => {
1127
- if (this.#userId === newUserId && this.#state !== null) return this.#state;
1128
- this.#userId = newUserId;
1169
+ const nextUserId = newUserId ?? this.#anonymousSessionId;
1170
+ if (this.#userId === nextUserId && this.#state !== null) return this.#state;
1171
+ this.#userId = nextUserId;
1129
1172
  this.#state = null;
1130
1173
  this.#initialization = null;
1131
- await this.#offlineQueue?.switchUser(newUserId);
1174
+ await this.#offlineQueue?.switchUser(nextUserId);
1132
1175
  return this.initialize();
1133
1176
  });
1134
1177
  }
@@ -1196,7 +1239,8 @@ var StampRallyClient = class {
1196
1239
  proofData,
1197
1240
  idempotencyKey: options.idempotencyKey ?? id("check-in"),
1198
1241
  now,
1199
- state: current
1242
+ state: current,
1243
+ ...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
1200
1244
  };
1201
1245
  const remote = this.#options.syncAdapter?.checkIn;
1202
1246
  if (options.sync !== false && remote !== void 0) {
@@ -1252,7 +1296,8 @@ var StampRallyClient = class {
1252
1296
  idempotencyKey: options.idempotencyKey ?? id("claim"),
1253
1297
  now,
1254
1298
  options,
1255
- state: current
1299
+ state: current,
1300
+ ...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
1256
1301
  };
1257
1302
  const remote = this.#options.syncAdapter?.claimReward;
1258
1303
  if (options.sync !== false && remote !== void 0) {
@@ -1304,13 +1349,10 @@ var StampRallyClient = class {
1304
1349
  const serverState = await adapter.sync({
1305
1350
  rallyId: this.#config.id,
1306
1351
  userId: this.#userId,
1307
- state: this.#state ?? current
1308
- });
1309
- const localState = this.#state ?? current;
1310
- const merged = this.#offlineQueue === void 0 ? serverState : resolveRallyStateConflict(serverState, localState, {
1311
- policy: this.#offlineQueue.conflictPolicy
1352
+ state: this.#state ?? current,
1353
+ ...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
1312
1354
  });
1313
- const next = this.#reconcile(merged);
1355
+ const next = this.#reconcile(serverState);
1314
1356
  await this.#storage.save(next);
1315
1357
  this.#state = next;
1316
1358
  this.#emit(next);
@@ -1522,6 +1564,16 @@ function errorValue(value, fallbackCode) {
1522
1564
  if (typeof value === "string") return { code: fallbackCode, message: value };
1523
1565
  return { code: fallbackCode, message: "Offline operation was rejected." };
1524
1566
  }
1567
+ var syncLocks = /* @__PURE__ */ new Map();
1568
+ var SYNC_LOCK_TTL_MS = 3e4;
1569
+ var DEFAULT_RETRY_OPTIONS = {
1570
+ maxRetries: 0,
1571
+ initialIntervalMs: 250,
1572
+ backoffMultiplier: 2
1573
+ };
1574
+ function randomId() {
1575
+ return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
1576
+ }
1525
1577
  var OfflineQueue = class {
1526
1578
  #storage;
1527
1579
  #configuredKey;
@@ -1536,6 +1588,12 @@ var OfflineQueue = class {
1536
1588
  #sender;
1537
1589
  #syncPromise = null;
1538
1590
  #syncResultListener;
1591
+ #synchronizeInstances;
1592
+ #retryOptions;
1593
+ #instanceId = randomId();
1594
+ #lockStorage;
1595
+ #storageListener;
1596
+ #channel = null;
1539
1597
  constructor(options = {}) {
1540
1598
  if (options.storage !== void 0) this.#storage = options.storage;
1541
1599
  else if (options.storageLike !== void 0 && options.storageLike !== null)
@@ -1546,6 +1604,18 @@ var OfflineQueue = class {
1546
1604
  this.#userId = options.userId ?? null;
1547
1605
  this.#conflictPolicy = options.conflictPolicy ?? "server_wins";
1548
1606
  this.#onSyncConflict = options.onSyncConflict;
1607
+ this.#synchronizeInstances = options.synchronizeInstances ?? true;
1608
+ const retryOptions = {
1609
+ ...DEFAULT_RETRY_OPTIONS,
1610
+ ...options.retryOptions ?? options.syncRetryOptions ?? options.retry ?? {}
1611
+ };
1612
+ this.#retryOptions = {
1613
+ maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
1614
+ initialIntervalMs: Math.max(0, retryOptions.initialIntervalMs),
1615
+ backoffMultiplier: Math.max(1, retryOptions.backoffMultiplier)
1616
+ };
1617
+ this.#lockStorage = options.storageLike ?? globalThis.localStorage ?? null;
1618
+ if (this.#synchronizeInstances) this.#subscribeToExternalChanges();
1549
1619
  }
1550
1620
  get syncState() {
1551
1621
  return this.#state;
@@ -1576,9 +1646,19 @@ var OfflineQueue = class {
1576
1646
  }
1577
1647
  async initialize() {
1578
1648
  if (this.#loaded) return;
1579
- this.#operations = [...await this.#storage.load(this.#storageKey())];
1649
+ this.#operations = (await this.#storage.load(this.#storageKey())).map(normalizeOperation);
1580
1650
  this.#loaded = true;
1581
1651
  }
1652
+ /** Releases browser listeners when the queue is no longer used. */
1653
+ dispose() {
1654
+ const windowLike = globalThis.window;
1655
+ if (windowLike !== void 0 && this.#storageListener !== void 0)
1656
+ windowLike.removeEventListener("storage", this.#storageListener);
1657
+ this.#storageListener = void 0;
1658
+ this.#channel?.close();
1659
+ this.#channel = null;
1660
+ this.#releaseSyncLock();
1661
+ }
1582
1662
  /** Selects a rally/user queue scope and loads its pending operations. */
1583
1663
  async setScope(rallyId, userId) {
1584
1664
  if (this.#configuredKey !== void 0) {
@@ -1611,8 +1691,9 @@ var OfflineQueue = class {
1611
1691
  await this.initialize();
1612
1692
  const id2 = operationId(operation);
1613
1693
  if (this.#operations.some((item) => operationId(item) === id2)) return;
1614
- this.#operations = [...this.#operations, operation];
1694
+ this.#operations = [...this.#operations, { ...operation, status: "PENDING", attempts: 0 }];
1615
1695
  await this.#storage.save(this.#storageKey(), this.#operations);
1696
+ this.#announceChange();
1616
1697
  }
1617
1698
  async enqueueCheckIn(request) {
1618
1699
  return this.enqueue({ kind: "checkIn", request });
@@ -1624,6 +1705,7 @@ var OfflineQueue = class {
1624
1705
  await this.initialize();
1625
1706
  this.#operations = [];
1626
1707
  await this.#storage.save(this.#storageKey(), this.#operations);
1708
+ this.#announceChange();
1627
1709
  }
1628
1710
  async sync(sender = this.#sender) {
1629
1711
  await this.initialize();
@@ -1639,35 +1721,83 @@ var OfflineQueue = class {
1639
1721
  return this.sync(sender);
1640
1722
  }
1641
1723
  async #run(sender) {
1724
+ const locks = globalThis.navigator?.locks;
1725
+ if (locks !== void 0) {
1726
+ let callbackStarted = false;
1727
+ try {
1728
+ const acquired = await locks.request(
1729
+ `stamprally:${this.#storageKey()}:sync`,
1730
+ { ifAvailable: true },
1731
+ async (lock) => {
1732
+ if (lock === null) {
1733
+ await this.#reloadFromStorage();
1734
+ this.#state = "idle";
1735
+ return false;
1736
+ }
1737
+ callbackStarted = true;
1738
+ await this.#runWithStorageLock(sender);
1739
+ return true;
1740
+ }
1741
+ );
1742
+ if (!acquired) return;
1743
+ return;
1744
+ } catch (error) {
1745
+ if (callbackStarted) throw error;
1746
+ }
1747
+ }
1748
+ await this.#runWithStorageLock(sender);
1749
+ }
1750
+ async #runWithStorageLock(sender) {
1642
1751
  this.#state = "syncing";
1643
1752
  this.#error = null;
1753
+ if (!this.#acquireSyncLock()) {
1754
+ await this.#reloadFromStorage();
1755
+ this.#state = "idle";
1756
+ return;
1757
+ }
1644
1758
  try {
1645
1759
  while (this.#operations.length > 0) {
1646
1760
  const operation = this.#operations[0];
1647
1761
  if (operation === void 0) break;
1648
- let rawResult;
1649
- try {
1650
- rawResult = await sender(operation);
1651
- } catch (cause) {
1652
- throw new Error(errorValue(cause, "RETRYABLE_ERROR").message);
1653
- }
1654
- const response = this.#normalizeResponse(rawResult);
1655
- if (response.status === "RETRYABLE_ERROR") {
1762
+ let attempt = 0;
1763
+ let response;
1764
+ while (true) {
1765
+ await this.#updateOperationStatus("IN_FLIGHT", attempt);
1766
+ try {
1767
+ response = this.#normalizeResponse(await sender(operation));
1768
+ } catch (cause) {
1769
+ response = {
1770
+ status: "RETRYABLE_ERROR",
1771
+ error: errorValue(cause, "RETRYABLE_ERROR")
1772
+ };
1773
+ }
1774
+ if (response.status !== "RETRYABLE_ERROR") break;
1656
1775
  const error2 = errorValue(response.error ?? response.reason, "RETRYABLE_ERROR");
1776
+ await this.#updateOperationStatus("PENDING", attempt + 1);
1657
1777
  await this.#syncResultListener?.({ operation, status: response.status, error: error2 });
1658
- throw new Error(error2.message);
1778
+ if (attempt >= this.#retryOptions.maxRetries) throw new Error(error2.message);
1779
+ const interval = this.#retryOptions.initialIntervalMs * this.#retryOptions.backoffMultiplier ** attempt;
1780
+ await new Promise((resolve) => setTimeout(resolve, Math.max(0, interval)));
1781
+ attempt += 1;
1659
1782
  }
1660
1783
  const result = response.result;
1661
1784
  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);
1662
1785
  const error = response.status === "REJECTED_PERMANENT" ? errorValue(response.error ?? response.reason, "REJECTED_PERMANENT") : void 0;
1786
+ await this.#updateOperationStatus(
1787
+ response.status === "ACCEPTED" ? "ACCEPTED" : "REJECTED",
1788
+ attempt + 1
1789
+ );
1790
+ const fallbackState = response.status === "REJECTED_PERMANENT" ? operation.request.state : void 0;
1791
+ const eventState = state ?? fallbackState;
1663
1792
  this.#operations = this.#operations.slice(1);
1664
1793
  await this.#storage.save(this.#storageKey(), this.#operations);
1794
+ this.#announceChange();
1665
1795
  await this.#syncResultListener?.({
1666
1796
  operation,
1667
1797
  ...result === void 0 ? {} : { result },
1668
1798
  status: response.status,
1669
1799
  ...error === void 0 ? {} : { error },
1670
- ...state === void 0 ? {} : { state }
1800
+ ...eventState === void 0 ? {} : { state: eventState }
1671
1801
  });
1672
1802
  }
1673
1803
  this.#state = "idle";
@@ -1675,6 +1805,87 @@ var OfflineQueue = class {
1675
1805
  this.#state = "error";
1676
1806
  this.#error = cause instanceof Error ? cause : new Error(String(cause));
1677
1807
  throw this.#error;
1808
+ } finally {
1809
+ this.#releaseSyncLock();
1810
+ }
1811
+ }
1812
+ async #updateOperationStatus(status, attempts) {
1813
+ const operation = this.#operations[0];
1814
+ if (operation === void 0) return;
1815
+ this.#operations = [{ ...operation, status, attempts }, ...this.#operations.slice(1)];
1816
+ await this.#storage.save(this.#storageKey(), this.#operations);
1817
+ this.#announceChange();
1818
+ }
1819
+ #subscribeToExternalChanges() {
1820
+ const windowLike = globalThis.window;
1821
+ if (windowLike !== void 0) {
1822
+ this.#storageListener = (event) => {
1823
+ if (event.key === this.#storageKey()) void this.#reloadFromStorage();
1824
+ };
1825
+ windowLike.addEventListener("storage", this.#storageListener);
1826
+ }
1827
+ const Channel = globalThis.BroadcastChannel;
1828
+ if (Channel !== void 0) {
1829
+ try {
1830
+ this.#channel = new Channel("stamprally:queue-sync");
1831
+ this.#channel.addEventListener("message", (event) => {
1832
+ if (typeof event.data === "object" && event.data !== null && "key" in event.data && event.data.key === this.#storageKey())
1833
+ void this.#reloadFromStorage();
1834
+ });
1835
+ } catch {
1836
+ this.#channel = null;
1837
+ }
1838
+ }
1839
+ }
1840
+ #announceChange() {
1841
+ this.#channel?.postMessage({ key: this.#storageKey(), owner: this.#instanceId });
1842
+ }
1843
+ async #reloadFromStorage() {
1844
+ if (this.#state === "syncing") return;
1845
+ try {
1846
+ this.#operations = (await this.#storage.load(this.#storageKey())).map(normalizeOperation);
1847
+ this.#loaded = true;
1848
+ } catch {
1849
+ }
1850
+ }
1851
+ #lockKey() {
1852
+ return `${this.#storageKey()}:sync-lock`;
1853
+ }
1854
+ #acquireSyncLock() {
1855
+ const key = this.#lockKey();
1856
+ const now = Date.now();
1857
+ const local = syncLocks.get(key);
1858
+ if (local !== void 0 && local.expiresAt > now && local.owner !== this.#instanceId)
1859
+ return false;
1860
+ if (this.#lockStorage !== null) {
1861
+ try {
1862
+ const existing = this.#lockStorage.getItem(key);
1863
+ if (existing !== null) {
1864
+ const parsed = JSON.parse(existing);
1865
+ if (typeof parsed === "object" && parsed !== null && typeof parsed.expiresAt === "number" && parsed.expiresAt > now && parsed.owner !== this.#instanceId)
1866
+ return false;
1867
+ }
1868
+ this.#lockStorage.setItem(
1869
+ key,
1870
+ JSON.stringify({ owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS })
1871
+ );
1872
+ } catch {
1873
+ }
1874
+ }
1875
+ syncLocks.set(key, { owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS });
1876
+ return true;
1877
+ }
1878
+ #releaseSyncLock() {
1879
+ const key = this.#lockKey();
1880
+ const current = syncLocks.get(key);
1881
+ if (current?.owner === this.#instanceId) syncLocks.delete(key);
1882
+ if (this.#lockStorage !== null) {
1883
+ try {
1884
+ const value = this.#lockStorage.getItem(key);
1885
+ if (value !== null && JSON.parse(value).owner === this.#instanceId)
1886
+ this.#lockStorage.removeItem?.(key);
1887
+ } catch {
1888
+ }
1678
1889
  }
1679
1890
  }
1680
1891
  #storageKey() {
@@ -1702,6 +1913,13 @@ var OfflineQueue = class {
1702
1913
  return resolveRallyStateConflict(serverState, localState, { policy });
1703
1914
  }
1704
1915
  };
1916
+ function normalizeOperation(operation) {
1917
+ return {
1918
+ ...operation,
1919
+ status: operation.status === "IN_FLIGHT" ? "PENDING" : operation.status ?? "PENDING",
1920
+ attempts: operation.attempts ?? 0
1921
+ };
1922
+ }
1705
1923
 
1706
1924
  // src/crypto/token.ts
1707
1925
  var encoder = new TextEncoder();
@@ -2270,6 +2488,8 @@ function condition(value, path, errors, isPublic) {
2270
2488
  finiteNumber(value, "latitude", path, errors);
2271
2489
  finiteNumber(value, "longitude", path, errors);
2272
2490
  finiteNumber(value, "radiusMeters", path, errors, 0);
2491
+ if (typeof value.radiusMeters === "number" && value.radiusMeters <= 0)
2492
+ add(errors, `${path}.radiusMeters`, "Expected a radius greater than 0.", "out_of_range");
2273
2493
  if (typeof value.latitude === "number" && (value.latitude < -90 || value.latitude > 90))
2274
2494
  add(errors, `${path}.latitude`, "Expected a latitude between -90 and 90.", "out_of_range");
2275
2495
  if (typeof value.longitude === "number" && (value.longitude < -180 || value.longitude > 180))
@@ -2437,21 +2657,112 @@ function validate(value, isPublic) {
2437
2657
  optionalString(value, "staffPasscode", "$", errors);
2438
2658
  if (hasOwn(value, "inventory") && value.inventory !== void 0 && !isRecord3(value.inventory))
2439
2659
  add(errors, "$.inventory", "Expected an object.", "invalid_type");
2660
+ if (hasOwn(value, "inventoryMode") && value.inventoryMode !== void 0 && value.inventoryMode !== "shared" && value.inventoryMode !== "per_reward")
2661
+ add(errors, "$.inventoryMode", "Expected shared or per_reward.", "invalid_enum");
2440
2662
  if (hasOwn(value, "serverMetadata") && value.serverMetadata !== void 0 && !isRecord3(value.serverMetadata))
2441
2663
  add(errors, "$.serverMetadata", "Expected an object.", "invalid_type");
2442
2664
  if (hasOwn(value, "publicMetadata") && value.publicMetadata !== void 0 && !isRecord3(value.publicMetadata))
2443
2665
  add(errors, "$.publicMetadata", "Expected an object.", "invalid_type");
2444
2666
  optionalString(value, "serverEndpoint", "$", errors);
2445
2667
  } else {
2446
- for (const key of ["staffPasscode", "serverMetadata", "inventory"])
2668
+ for (const key of ["staffPasscode", "serverMetadata", "inventory", "inventoryMode"])
2447
2669
  if (hasOwn(value, key))
2448
2670
  add(errors, `$.${key}`, "Private field is not allowed.", "private_field");
2449
2671
  optionalString(value, "serverEndpoint", "$", errors);
2450
2672
  }
2451
2673
  return errors;
2452
2674
  }
2675
+ function validateRallyConfigRelations(config) {
2676
+ const errors = [];
2677
+ const spotIds = /* @__PURE__ */ new Set();
2678
+ const rewardIds = /* @__PURE__ */ new Set();
2679
+ const orderIndexes = /* @__PURE__ */ new Map();
2680
+ config.spots.forEach((spot2, index) => {
2681
+ if (spotIds.has(spot2.id))
2682
+ add(errors, `spots[${index}].id`, "Spot ID must be unique.", "duplicate_spot_id");
2683
+ spotIds.add(spot2.id);
2684
+ const previousIndex = orderIndexes.get(spot2.orderIndex);
2685
+ if (previousIndex !== void 0)
2686
+ add(
2687
+ errors,
2688
+ `spots[${index}].orderIndex`,
2689
+ `orderIndex duplicates spots[${previousIndex}].`,
2690
+ "duplicate_order_index"
2691
+ );
2692
+ else orderIndexes.set(spot2.orderIndex, index);
2693
+ if (spot2.orderIndex < 0)
2694
+ add(
2695
+ errors,
2696
+ `spots[${index}].orderIndex`,
2697
+ "orderIndex must not be negative.",
2698
+ "negative_order_index"
2699
+ );
2700
+ spot2.prerequisites?.forEach((prerequisite, prerequisiteIndex) => {
2701
+ if (!spotIds.has(prerequisite) && !config.spots.some((candidate) => candidate.id === prerequisite))
2702
+ add(
2703
+ errors,
2704
+ `spots[${index}].prerequisites[${prerequisiteIndex}]`,
2705
+ "Prerequisite spot does not exist.",
2706
+ "missing_prerequisite"
2707
+ );
2708
+ });
2709
+ });
2710
+ config.rewards.forEach((reward2, index) => {
2711
+ if (rewardIds.has(reward2.id))
2712
+ add(errors, `rewards[${index}].id`, "Reward ID must be unique.", "duplicate_reward_id");
2713
+ rewardIds.add(reward2.id);
2714
+ const visit = (condition2, path) => {
2715
+ if (condition2.type === "stamps")
2716
+ condition2.stampIds.forEach((stampId, stampIndex) => {
2717
+ if (!spotIds.has(stampId))
2718
+ add(
2719
+ errors,
2720
+ `${path}.stampIds[${stampIndex}]`,
2721
+ "Referenced spot does not exist.",
2722
+ "missing_reward_spot"
2723
+ );
2724
+ });
2725
+ else if (condition2.type === "all" || condition2.type === "any")
2726
+ condition2.conditions.forEach((nested, nestedIndex) => {
2727
+ visit(nested, `${path}.conditions[${nestedIndex}]`);
2728
+ });
2729
+ };
2730
+ reward2.conditions?.forEach((condition2, conditionIndex) => {
2731
+ visit(condition2, `rewards[${index}].conditions[${conditionIndex}]`);
2732
+ });
2733
+ });
2734
+ const visiting = /* @__PURE__ */ new Set();
2735
+ const visited = /* @__PURE__ */ new Set();
2736
+ const cycleNodes = /* @__PURE__ */ new Set();
2737
+ const visitSpot = (spotId) => {
2738
+ if (visiting.has(spotId)) {
2739
+ cycleNodes.add(spotId);
2740
+ return;
2741
+ }
2742
+ if (visited.has(spotId)) return;
2743
+ visiting.add(spotId);
2744
+ const spot2 = config.spots.find((candidate) => candidate.id === spotId);
2745
+ spot2?.prerequisites?.forEach(visitSpot);
2746
+ visiting.delete(spotId);
2747
+ visited.add(spotId);
2748
+ };
2749
+ config.spots.forEach((spot2) => {
2750
+ visitSpot(spot2.id);
2751
+ });
2752
+ cycleNodes.forEach((spotId) => {
2753
+ const index = config.spots.findIndex((spot2) => spot2.id === spotId);
2754
+ add(
2755
+ errors,
2756
+ `spots[${index}].prerequisites`,
2757
+ "Prerequisites must form a DAG.",
2758
+ "cyclic_prerequisites"
2759
+ );
2760
+ });
2761
+ return errors;
2762
+ }
2453
2763
  function safeParseAdminConfig(input) {
2454
- const errors = validate(input, false);
2764
+ const errors = [...validate(input, false)];
2765
+ if (errors.length === 0) errors.push(...validateRallyConfigRelations(input));
2455
2766
  return errors.length === 0 ? { success: true, data: input } : { success: false, errors };
2456
2767
  }
2457
2768
  function parseAdminConfig(input) {
@@ -2460,7 +2771,8 @@ function parseAdminConfig(input) {
2460
2771
  return result.data;
2461
2772
  }
2462
2773
  function safeParsePublicConfig(input) {
2463
- const errors = validate(input, true);
2774
+ const errors = [...validate(input, true)];
2775
+ if (errors.length === 0) errors.push(...validateRallyConfigRelations(input));
2464
2776
  return errors.length === 0 ? { success: true, data: input } : { success: false, errors };
2465
2777
  }
2466
2778
  function parsePublicConfig(input) {
@@ -2588,6 +2900,6 @@ async function verifySnapshotToken(token, secretKey, now = Date.now()) {
2588
2900
  }
2589
2901
  }
2590
2902
 
2591
- 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, verifyPasscode, verifySecureToken, verifySnapshotToken };
2903
+ 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, parseAdminConfig, parsePublicConfig, processStamp, readNfcContext, readQrContext, reconcileRewardStates, resolveLocalizedText, resolveRallyStateConflict, safeParseAdminConfig, safeParsePublicConfig, sanitizeAdminConfig, storageKey, toLocalizedString, toPublicConfig, updateLocalizedField, validatePublicConfigSafety, validateRallyConfigRelations, verifyPasscode, verifySecureToken, verifySnapshotToken };
2592
2904
  //# sourceMappingURL=index.js.map
2593
2905
  //# sourceMappingURL=index.js.map