@stamprally/core 0.12.0 → 0.14.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
@@ -39,6 +39,14 @@ function evaluateConditionDetailed(condition2, context) {
39
39
  function evaluateCondition(condition2, context) {
40
40
  return evaluateConditionDetailed(condition2, context).ok;
41
41
  }
42
+ function evaluateSpotStatus(spot2, state, options = {}) {
43
+ if (options.verifying === true) return "VERIFYING";
44
+ if (state.records.some((record) => record.stampId === spot2.id)) return "CLAIMED";
45
+ const acquired = new Set(state.records.map((record) => record.stampId));
46
+ if (spot2.prerequisites?.some((prerequisite) => !acquired.has(prerequisite))) return "LOCKED";
47
+ return "UNCLAIMED";
48
+ }
49
+ var getSpotStatus = evaluateSpotStatus;
42
50
 
43
51
  // src/engine/order.ts
44
52
  function getOrderedSpots(spots) {
@@ -51,7 +59,9 @@ function calculateProgress(state, config) {
51
59
  const acquired = new Set(
52
60
  state.records.map((record) => record.stampId).filter((id2) => ids.has(id2))
53
61
  );
54
- 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
+ );
55
65
  return {
56
66
  acquired: acquired.size,
57
67
  total: config.spots.length,
@@ -115,24 +125,53 @@ function mergeRewardStates(serverRewards, localRewards) {
115
125
  merged.set(localReward.rewardId, localReward);
116
126
  continue;
117
127
  }
118
- if (localReward.status === "CONSUMED" && serverReward.status !== "CONSUMED")
119
- merged.set(localReward.rewardId, localReward);
128
+ const winner = localReward.status === "CONSUMED" && serverReward.status !== "CONSUMED" ? localReward : serverReward;
129
+ merged.set(localReward.rewardId, {
130
+ ...winner,
131
+ ...serverReward.unlockedAt === void 0 && localReward.unlockedAt === void 0 ? {} : {
132
+ unlockedAt: serverReward.unlockedAt === void 0 ? localReward.unlockedAt : localReward.unlockedAt === void 0 ? serverReward.unlockedAt : latestTimestamp(serverReward.unlockedAt, localReward.unlockedAt)
133
+ },
134
+ ...serverReward.consumedAt === void 0 && localReward.consumedAt === void 0 ? {} : {
135
+ consumedAt: serverReward.consumedAt === void 0 ? localReward.consumedAt : localReward.consumedAt === void 0 ? serverReward.consumedAt : latestTimestamp(serverReward.consumedAt, localReward.consumedAt)
136
+ },
137
+ ...serverReward.redeemedCount === void 0 && localReward.redeemedCount === void 0 ? {} : {
138
+ redeemedCount: Math.max(
139
+ serverReward.redeemedCount ?? 0,
140
+ localReward.redeemedCount ?? 0
141
+ )
142
+ },
143
+ ...serverReward.userRedemptionCount === void 0 && localReward.userRedemptionCount === void 0 ? {} : {
144
+ userRedemptionCount: Math.max(
145
+ serverReward.userRedemptionCount ?? 0,
146
+ localReward.userRedemptionCount ?? 0
147
+ )
148
+ }
149
+ });
120
150
  }
121
151
  return [...merged.values()];
122
152
  }
123
- function resolveRallyStateConflict(serverState, localState, options = { policy: "merge" }) {
124
- if (options.policy === "server_wins") return serverState;
125
- const records = [...serverState.records];
126
- const knownStamps = new Set(records.map((record) => record.stampId));
127
- for (const record of localState.records) {
128
- if (!knownStamps.has(record.stampId)) {
129
- records.push(record);
130
- knownStamps.add(record.stampId);
153
+ function mergeStampRecords(serverRecords, localRecords) {
154
+ const merged = /* @__PURE__ */ new Map();
155
+ for (const record of [...serverRecords, ...localRecords]) {
156
+ const current = merged.get(record.stampId);
157
+ if (current === void 0) {
158
+ merged.set(record.stampId, record);
159
+ continue;
131
160
  }
161
+ const acquiredAt = latestTimestamp(current.acquiredAt, record.acquiredAt);
162
+ merged.set(record.stampId, {
163
+ ...current,
164
+ ...acquiredAt === current.acquiredAt ? {} : { acquiredAt },
165
+ ...current.metadata === void 0 && record.metadata !== void 0 ? { metadata: record.metadata } : {}
166
+ });
132
167
  }
168
+ return [...merged.values()];
169
+ }
170
+ function resolveRallyStateConflict(serverState, localState, options = { policy: "merge" }) {
171
+ if (options.policy === "server_wins") return serverState;
133
172
  return {
134
173
  ...serverState,
135
- records,
174
+ records: mergeStampRecords(serverState.records, localState.records),
136
175
  rewards: mergeRewardStates(serverState.rewards, localState.rewards),
137
176
  updatedAt: latestTimestamp(serverState.updatedAt, localState.updatedAt)
138
177
  };
@@ -1070,7 +1109,10 @@ var StampRallyClient = class {
1070
1109
  initialize() {
1071
1110
  if (this.#state !== null) return Promise.resolve(this.#state);
1072
1111
  if (this.#initialization === null) {
1073
- this.#initialization = this.#storage.load(this.#config.id, this.#userId).then((state) => {
1112
+ this.#initialization = (async () => {
1113
+ await this.#offlineQueue?.setScope(this.#config.id, this.#userId);
1114
+ return this.#storage.load(this.#config.id, this.#userId);
1115
+ })().then((state) => {
1074
1116
  const next = state === null ? emptyState(this.#config, this.#userId, this.#now()) : this.#reconcile(state);
1075
1117
  this.#state = next;
1076
1118
  this.#emit(next);
@@ -1088,6 +1130,7 @@ var StampRallyClient = class {
1088
1130
  this.#userId = newUserId;
1089
1131
  this.#state = null;
1090
1132
  this.#initialization = null;
1133
+ await this.#offlineQueue?.switchUser(newUserId);
1091
1134
  return this.initialize();
1092
1135
  });
1093
1136
  }
@@ -1336,7 +1379,8 @@ var StampRallyClient = class {
1336
1379
  this.#state = next;
1337
1380
  this.#emit(next);
1338
1381
  }
1339
- if ("ok" in event.result && !event.result.ok)
1382
+ if (event.error !== void 0) this.#emitEvent({ type: "error", error: event.error });
1383
+ else if (event.result !== void 0 && "ok" in event.result && !event.result.ok)
1340
1384
  this.#emitEvent({ type: "error", error: event.result.error });
1341
1385
  }
1342
1386
  #now() {
@@ -1464,9 +1508,32 @@ function defaultStorage(databaseName) {
1464
1508
  function operationId(operation) {
1465
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}`;
1466
1510
  }
1511
+ function requestScope(operation) {
1512
+ return {
1513
+ rallyId: operation.request.rallyId,
1514
+ userId: operation.request.userId
1515
+ };
1516
+ }
1517
+ function errorValue(value, fallbackCode) {
1518
+ if (typeof value === "object" && value !== null) {
1519
+ const candidate = value;
1520
+ if (typeof candidate.code === "string" && typeof candidate.message === "string")
1521
+ return { ...candidate, code: candidate.code, message: candidate.message };
1522
+ }
1523
+ if (value instanceof Error) return { code: fallbackCode, message: value.message };
1524
+ if (typeof value === "string") return { code: fallbackCode, message: value };
1525
+ return { code: fallbackCode, message: "Offline operation was rejected." };
1526
+ }
1527
+ var syncLocks = /* @__PURE__ */ new Map();
1528
+ var SYNC_LOCK_TTL_MS = 3e4;
1529
+ function randomId() {
1530
+ return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
1531
+ }
1467
1532
  var OfflineQueue = class {
1468
1533
  #storage;
1469
- #key;
1534
+ #configuredKey;
1535
+ #rallyId;
1536
+ #userId;
1470
1537
  #conflictPolicy;
1471
1538
  #onSyncConflict;
1472
1539
  #operations = [];
@@ -1476,14 +1543,24 @@ var OfflineQueue = class {
1476
1543
  #sender;
1477
1544
  #syncPromise = null;
1478
1545
  #syncResultListener;
1546
+ #synchronizeInstances;
1547
+ #instanceId = randomId();
1548
+ #lockStorage;
1549
+ #storageListener;
1550
+ #channel = null;
1479
1551
  constructor(options = {}) {
1480
1552
  if (options.storage !== void 0) this.#storage = options.storage;
1481
1553
  else if (options.storageLike !== void 0 && options.storageLike !== null)
1482
1554
  this.#storage = new LocalStorageQueueStorage(options.storageLike);
1483
1555
  else this.#storage = defaultStorage(options.databaseName);
1484
- this.#key = options.key ?? "stamprally:offline-queue";
1556
+ this.#configuredKey = options.key;
1557
+ this.#rallyId = options.rallyId;
1558
+ this.#userId = options.userId ?? null;
1485
1559
  this.#conflictPolicy = options.conflictPolicy ?? "server_wins";
1486
1560
  this.#onSyncConflict = options.onSyncConflict;
1561
+ this.#synchronizeInstances = options.synchronizeInstances ?? true;
1562
+ this.#lockStorage = options.storageLike ?? globalThis.localStorage ?? null;
1563
+ if (this.#synchronizeInstances) this.#subscribeToExternalChanges();
1487
1564
  }
1488
1565
  get syncState() {
1489
1566
  return this.#state;
@@ -1500,23 +1577,68 @@ var OfflineQueue = class {
1500
1577
  get conflictPolicy() {
1501
1578
  return this.#conflictPolicy;
1502
1579
  }
1580
+ get storageKey() {
1581
+ return this.#storageKey();
1582
+ }
1583
+ get rallyId() {
1584
+ return this.#rallyId;
1585
+ }
1586
+ get userId() {
1587
+ return this.#userId;
1588
+ }
1503
1589
  setSyncResultListener(listener) {
1504
1590
  this.#syncResultListener = listener;
1505
1591
  }
1506
1592
  async initialize() {
1507
1593
  if (this.#loaded) return;
1508
- this.#operations = [...await this.#storage.load(this.#key)];
1594
+ this.#operations = [...await this.#storage.load(this.#storageKey())];
1509
1595
  this.#loaded = true;
1510
1596
  }
1597
+ /** Releases browser listeners when the queue is no longer used. */
1598
+ dispose() {
1599
+ const windowLike = globalThis.window;
1600
+ if (windowLike !== void 0 && this.#storageListener !== void 0)
1601
+ windowLike.removeEventListener("storage", this.#storageListener);
1602
+ this.#storageListener = void 0;
1603
+ this.#channel?.close();
1604
+ this.#channel = null;
1605
+ this.#releaseSyncLock();
1606
+ }
1607
+ /** Selects a rally/user queue scope and loads its pending operations. */
1608
+ async setScope(rallyId, userId) {
1609
+ if (this.#configuredKey !== void 0) {
1610
+ this.#rallyId = rallyId;
1611
+ this.#userId = userId;
1612
+ return this.initialize();
1613
+ }
1614
+ if (this.#rallyId === rallyId && this.#userId === userId && this.#loaded) return;
1615
+ this.#rallyId = rallyId;
1616
+ this.#userId = userId;
1617
+ this.#operations = [];
1618
+ this.#loaded = false;
1619
+ await this.initialize();
1620
+ }
1621
+ async switchUser(newUserId) {
1622
+ if (this.#rallyId === void 0)
1623
+ throw new Error("OfflineQueue.switchUser requires a rally scope.");
1624
+ await this.setScope(this.#rallyId, newUserId);
1625
+ }
1511
1626
  setSender(sender) {
1512
1627
  this.#sender = sender;
1513
1628
  }
1514
1629
  async enqueue(operation) {
1630
+ if (this.#configuredKey === void 0) {
1631
+ const scope = requestScope(operation);
1632
+ if (this.#rallyId === void 0) await this.setScope(scope.rallyId, scope.userId);
1633
+ if (this.#rallyId !== scope.rallyId || this.#userId !== scope.userId)
1634
+ throw new Error("Offline operation belongs to another rally or user queue.");
1635
+ }
1515
1636
  await this.initialize();
1516
1637
  const id2 = operationId(operation);
1517
1638
  if (this.#operations.some((item) => operationId(item) === id2)) return;
1518
1639
  this.#operations = [...this.#operations, operation];
1519
- await this.#storage.save(this.#key, this.#operations);
1640
+ await this.#storage.save(this.#storageKey(), this.#operations);
1641
+ this.#announceChange();
1520
1642
  }
1521
1643
  async enqueueCheckIn(request) {
1522
1644
  return this.enqueue({ kind: "checkIn", request });
@@ -1527,7 +1649,8 @@ var OfflineQueue = class {
1527
1649
  async clear() {
1528
1650
  await this.initialize();
1529
1651
  this.#operations = [];
1530
- await this.#storage.save(this.#key, this.#operations);
1652
+ await this.#storage.save(this.#storageKey(), this.#operations);
1653
+ this.#announceChange();
1531
1654
  }
1532
1655
  async sync(sender = this.#sender) {
1533
1656
  await this.initialize();
@@ -1545,31 +1668,140 @@ var OfflineQueue = class {
1545
1668
  async #run(sender) {
1546
1669
  this.#state = "syncing";
1547
1670
  this.#error = null;
1671
+ if (!this.#acquireSyncLock()) {
1672
+ await this.#reloadFromStorage();
1673
+ this.#state = "idle";
1674
+ return;
1675
+ }
1548
1676
  try {
1549
1677
  while (this.#operations.length > 0) {
1550
1678
  const operation = this.#operations[0];
1551
1679
  if (operation === void 0) break;
1552
- let result;
1680
+ let rawResult;
1553
1681
  try {
1554
- result = await sender(operation);
1682
+ rawResult = await sender(operation);
1555
1683
  } catch (cause) {
1556
- throw cause instanceof Error ? cause : new Error(String(cause));
1684
+ throw new Error(errorValue(cause, "RETRYABLE_ERROR").message);
1557
1685
  }
1558
- const state = "conflict" in result && result.conflict === true ? await this.resolveConflict(operation, result.localState, result.serverState) : "ok" in result && result.ok ? result.value.state : void 0;
1686
+ const response = this.#normalizeResponse(rawResult);
1687
+ if (response.status === "RETRYABLE_ERROR") {
1688
+ const error2 = errorValue(response.error ?? response.reason, "RETRYABLE_ERROR");
1689
+ await this.#syncResultListener?.({ operation, status: response.status, error: error2 });
1690
+ throw new Error(error2.message);
1691
+ }
1692
+ 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;
1695
+ this.#operations = this.#operations.slice(1);
1696
+ await this.#storage.save(this.#storageKey(), this.#operations);
1697
+ this.#announceChange();
1559
1698
  await this.#syncResultListener?.({
1560
1699
  operation,
1561
- result,
1700
+ ...result === void 0 ? {} : { result },
1701
+ status: response.status,
1702
+ ...error === void 0 ? {} : { error },
1562
1703
  ...state === void 0 ? {} : { state }
1563
1704
  });
1564
- this.#operations = this.#operations.slice(1);
1565
- await this.#storage.save(this.#key, this.#operations);
1566
1705
  }
1567
1706
  this.#state = "idle";
1568
1707
  } catch (cause) {
1569
1708
  this.#state = "error";
1570
1709
  this.#error = cause instanceof Error ? cause : new Error(String(cause));
1571
1710
  throw this.#error;
1711
+ } finally {
1712
+ this.#releaseSyncLock();
1713
+ }
1714
+ }
1715
+ #subscribeToExternalChanges() {
1716
+ const windowLike = globalThis.window;
1717
+ if (windowLike !== void 0) {
1718
+ this.#storageListener = (event) => {
1719
+ if (event.key === this.#storageKey()) void this.#reloadFromStorage();
1720
+ };
1721
+ windowLike.addEventListener("storage", this.#storageListener);
1572
1722
  }
1723
+ const Channel = globalThis.BroadcastChannel;
1724
+ if (Channel !== void 0) {
1725
+ try {
1726
+ this.#channel = new Channel("stamprally:queue-sync");
1727
+ this.#channel.addEventListener("message", (event) => {
1728
+ if (typeof event.data === "object" && event.data !== null && "key" in event.data && event.data.key === this.#storageKey())
1729
+ void this.#reloadFromStorage();
1730
+ });
1731
+ } catch {
1732
+ this.#channel = null;
1733
+ }
1734
+ }
1735
+ }
1736
+ #announceChange() {
1737
+ this.#channel?.postMessage({ key: this.#storageKey(), owner: this.#instanceId });
1738
+ }
1739
+ async #reloadFromStorage() {
1740
+ if (this.#state === "syncing") return;
1741
+ try {
1742
+ this.#operations = [...await this.#storage.load(this.#storageKey())];
1743
+ this.#loaded = true;
1744
+ } catch {
1745
+ }
1746
+ }
1747
+ #lockKey() {
1748
+ return `${this.#storageKey()}:sync-lock`;
1749
+ }
1750
+ #acquireSyncLock() {
1751
+ const key = this.#lockKey();
1752
+ const now = Date.now();
1753
+ const local = syncLocks.get(key);
1754
+ if (local !== void 0 && local.expiresAt > now && local.owner !== this.#instanceId)
1755
+ return false;
1756
+ if (this.#lockStorage !== null) {
1757
+ try {
1758
+ const existing = this.#lockStorage.getItem(key);
1759
+ if (existing !== null) {
1760
+ const parsed = JSON.parse(existing);
1761
+ if (typeof parsed === "object" && parsed !== null && typeof parsed.expiresAt === "number" && parsed.expiresAt > now && parsed.owner !== this.#instanceId)
1762
+ return false;
1763
+ }
1764
+ this.#lockStorage.setItem(
1765
+ key,
1766
+ JSON.stringify({ owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS })
1767
+ );
1768
+ } catch {
1769
+ }
1770
+ }
1771
+ syncLocks.set(key, { owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS });
1772
+ return true;
1773
+ }
1774
+ #releaseSyncLock() {
1775
+ const key = this.#lockKey();
1776
+ const current = syncLocks.get(key);
1777
+ if (current?.owner === this.#instanceId) syncLocks.delete(key);
1778
+ if (this.#lockStorage !== null) {
1779
+ try {
1780
+ const value = this.#lockStorage.getItem(key);
1781
+ if (value !== null && JSON.parse(value).owner === this.#instanceId)
1782
+ this.#lockStorage.removeItem?.(key);
1783
+ } catch {
1784
+ }
1785
+ }
1786
+ }
1787
+ #storageKey() {
1788
+ if (this.#configuredKey !== void 0) return this.#configuredKey;
1789
+ return `stamprally:queue:${this.#rallyId ?? "unscoped"}:${this.#userId ?? "anonymous"}`;
1790
+ }
1791
+ #normalizeResponse(value) {
1792
+ if ("ok" in value) {
1793
+ if (value.ok === false) {
1794
+ if ("status" in value && value.status === "RETRYABLE_ERROR")
1795
+ return { status: "RETRYABLE_ERROR", error: errorValue(value.error, "RETRYABLE_ERROR") };
1796
+ return { status: "REJECTED_PERMANENT", result: value };
1797
+ }
1798
+ return { status: "ACCEPTED", result: value };
1799
+ }
1800
+ if ("status" in value) {
1801
+ if (value.status === "ACCEPTED") return value;
1802
+ return value;
1803
+ }
1804
+ return { status: "ACCEPTED", result: value };
1573
1805
  }
1574
1806
  async resolveConflict(operation, localState, serverState) {
1575
1807
  const configured = this.#onSyncConflict;
@@ -2312,21 +2544,112 @@ function validate(value, isPublic) {
2312
2544
  optionalString(value, "staffPasscode", "$", errors);
2313
2545
  if (hasOwn(value, "inventory") && value.inventory !== void 0 && !isRecord3(value.inventory))
2314
2546
  add(errors, "$.inventory", "Expected an object.", "invalid_type");
2547
+ if (hasOwn(value, "inventoryMode") && value.inventoryMode !== void 0 && value.inventoryMode !== "shared" && value.inventoryMode !== "per_reward")
2548
+ add(errors, "$.inventoryMode", "Expected shared or per_reward.", "invalid_enum");
2315
2549
  if (hasOwn(value, "serverMetadata") && value.serverMetadata !== void 0 && !isRecord3(value.serverMetadata))
2316
2550
  add(errors, "$.serverMetadata", "Expected an object.", "invalid_type");
2317
2551
  if (hasOwn(value, "publicMetadata") && value.publicMetadata !== void 0 && !isRecord3(value.publicMetadata))
2318
2552
  add(errors, "$.publicMetadata", "Expected an object.", "invalid_type");
2319
2553
  optionalString(value, "serverEndpoint", "$", errors);
2320
2554
  } else {
2321
- for (const key of ["staffPasscode", "serverMetadata", "inventory"])
2555
+ for (const key of ["staffPasscode", "serverMetadata", "inventory", "inventoryMode"])
2322
2556
  if (hasOwn(value, key))
2323
2557
  add(errors, `$.${key}`, "Private field is not allowed.", "private_field");
2324
2558
  optionalString(value, "serverEndpoint", "$", errors);
2325
2559
  }
2326
2560
  return errors;
2327
2561
  }
2562
+ function validateRallyConfigRelations(config) {
2563
+ const errors = [];
2564
+ const spotIds = /* @__PURE__ */ new Set();
2565
+ const rewardIds = /* @__PURE__ */ new Set();
2566
+ const orderIndexes = /* @__PURE__ */ new Map();
2567
+ config.spots.forEach((spot2, index) => {
2568
+ if (spotIds.has(spot2.id))
2569
+ add(errors, `spots[${index}].id`, "Spot ID must be unique.", "duplicate_spot_id");
2570
+ spotIds.add(spot2.id);
2571
+ const previousIndex = orderIndexes.get(spot2.orderIndex);
2572
+ if (previousIndex !== void 0)
2573
+ add(
2574
+ errors,
2575
+ `spots[${index}].orderIndex`,
2576
+ `orderIndex duplicates spots[${previousIndex}].`,
2577
+ "duplicate_order_index"
2578
+ );
2579
+ else orderIndexes.set(spot2.orderIndex, index);
2580
+ if (spot2.orderIndex < 0)
2581
+ add(
2582
+ errors,
2583
+ `spots[${index}].orderIndex`,
2584
+ "orderIndex must not be negative.",
2585
+ "negative_order_index"
2586
+ );
2587
+ spot2.prerequisites?.forEach((prerequisite, prerequisiteIndex) => {
2588
+ if (!spotIds.has(prerequisite) && !config.spots.some((candidate) => candidate.id === prerequisite))
2589
+ add(
2590
+ errors,
2591
+ `spots[${index}].prerequisites[${prerequisiteIndex}]`,
2592
+ "Prerequisite spot does not exist.",
2593
+ "missing_prerequisite"
2594
+ );
2595
+ });
2596
+ });
2597
+ config.rewards.forEach((reward2, index) => {
2598
+ if (rewardIds.has(reward2.id))
2599
+ add(errors, `rewards[${index}].id`, "Reward ID must be unique.", "duplicate_reward_id");
2600
+ rewardIds.add(reward2.id);
2601
+ const visit = (condition2, path) => {
2602
+ if (condition2.type === "stamps")
2603
+ condition2.stampIds.forEach((stampId, stampIndex) => {
2604
+ if (!spotIds.has(stampId))
2605
+ add(
2606
+ errors,
2607
+ `${path}.stampIds[${stampIndex}]`,
2608
+ "Referenced spot does not exist.",
2609
+ "missing_reward_spot"
2610
+ );
2611
+ });
2612
+ else if (condition2.type === "all" || condition2.type === "any")
2613
+ condition2.conditions.forEach((nested, nestedIndex) => {
2614
+ visit(nested, `${path}.conditions[${nestedIndex}]`);
2615
+ });
2616
+ };
2617
+ reward2.conditions?.forEach((condition2, conditionIndex) => {
2618
+ visit(condition2, `rewards[${index}].conditions[${conditionIndex}]`);
2619
+ });
2620
+ });
2621
+ const visiting = /* @__PURE__ */ new Set();
2622
+ const visited = /* @__PURE__ */ new Set();
2623
+ const cycleNodes = /* @__PURE__ */ new Set();
2624
+ const visitSpot = (spotId) => {
2625
+ if (visiting.has(spotId)) {
2626
+ cycleNodes.add(spotId);
2627
+ return;
2628
+ }
2629
+ if (visited.has(spotId)) return;
2630
+ visiting.add(spotId);
2631
+ const spot2 = config.spots.find((candidate) => candidate.id === spotId);
2632
+ spot2?.prerequisites?.forEach(visitSpot);
2633
+ visiting.delete(spotId);
2634
+ visited.add(spotId);
2635
+ };
2636
+ config.spots.forEach((spot2) => {
2637
+ visitSpot(spot2.id);
2638
+ });
2639
+ cycleNodes.forEach((spotId) => {
2640
+ const index = config.spots.findIndex((spot2) => spot2.id === spotId);
2641
+ add(
2642
+ errors,
2643
+ `spots[${index}].prerequisites`,
2644
+ "Prerequisites must form a DAG.",
2645
+ "cyclic_prerequisites"
2646
+ );
2647
+ });
2648
+ return errors;
2649
+ }
2328
2650
  function safeParseAdminConfig(input) {
2329
- const errors = validate(input, false);
2651
+ const errors = [...validate(input, false)];
2652
+ if (errors.length === 0) errors.push(...validateRallyConfigRelations(input));
2330
2653
  return errors.length === 0 ? { success: true, data: input } : { success: false, errors };
2331
2654
  }
2332
2655
  function parseAdminConfig(input) {
@@ -2335,7 +2658,8 @@ function parseAdminConfig(input) {
2335
2658
  return result.data;
2336
2659
  }
2337
2660
  function safeParsePublicConfig(input) {
2338
- const errors = validate(input, true);
2661
+ const errors = [...validate(input, true)];
2662
+ if (errors.length === 0) errors.push(...validateRallyConfigRelations(input));
2339
2663
  return errors.length === 0 ? { success: true, data: input } : { success: false, errors };
2340
2664
  }
2341
2665
  function parsePublicConfig(input) {
@@ -2463,6 +2787,6 @@ async function verifySnapshotToken(token, secretKey, now = Date.now()) {
2463
2787
  }
2464
2788
  }
2465
2789
 
2466
- 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, exportProgressToken, getCurrentGeoContext, getOrderedSpots, 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 };
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 };
2467
2791
  //# sourceMappingURL=index.js.map
2468
2792
  //# sourceMappingURL=index.js.map