@stamprally/core 0.13.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.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,
@@ -1524,6 +1526,11 @@ function errorValue(value, fallbackCode) {
1524
1526
  if (typeof value === "string") return { code: fallbackCode, message: value };
1525
1527
  return { code: fallbackCode, message: "Offline operation was rejected." };
1526
1528
  }
1529
+ var syncLocks = /* @__PURE__ */ new Map();
1530
+ var SYNC_LOCK_TTL_MS = 3e4;
1531
+ function randomId() {
1532
+ return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
1533
+ }
1527
1534
  var OfflineQueue = class {
1528
1535
  #storage;
1529
1536
  #configuredKey;
@@ -1538,6 +1545,11 @@ var OfflineQueue = class {
1538
1545
  #sender;
1539
1546
  #syncPromise = null;
1540
1547
  #syncResultListener;
1548
+ #synchronizeInstances;
1549
+ #instanceId = randomId();
1550
+ #lockStorage;
1551
+ #storageListener;
1552
+ #channel = null;
1541
1553
  constructor(options = {}) {
1542
1554
  if (options.storage !== void 0) this.#storage = options.storage;
1543
1555
  else if (options.storageLike !== void 0 && options.storageLike !== null)
@@ -1548,6 +1560,9 @@ var OfflineQueue = class {
1548
1560
  this.#userId = options.userId ?? null;
1549
1561
  this.#conflictPolicy = options.conflictPolicy ?? "server_wins";
1550
1562
  this.#onSyncConflict = options.onSyncConflict;
1563
+ this.#synchronizeInstances = options.synchronizeInstances ?? true;
1564
+ this.#lockStorage = options.storageLike ?? globalThis.localStorage ?? null;
1565
+ if (this.#synchronizeInstances) this.#subscribeToExternalChanges();
1551
1566
  }
1552
1567
  get syncState() {
1553
1568
  return this.#state;
@@ -1581,6 +1596,16 @@ var OfflineQueue = class {
1581
1596
  this.#operations = [...await this.#storage.load(this.#storageKey())];
1582
1597
  this.#loaded = true;
1583
1598
  }
1599
+ /** Releases browser listeners when the queue is no longer used. */
1600
+ dispose() {
1601
+ const windowLike = globalThis.window;
1602
+ if (windowLike !== void 0 && this.#storageListener !== void 0)
1603
+ windowLike.removeEventListener("storage", this.#storageListener);
1604
+ this.#storageListener = void 0;
1605
+ this.#channel?.close();
1606
+ this.#channel = null;
1607
+ this.#releaseSyncLock();
1608
+ }
1584
1609
  /** Selects a rally/user queue scope and loads its pending operations. */
1585
1610
  async setScope(rallyId, userId) {
1586
1611
  if (this.#configuredKey !== void 0) {
@@ -1615,6 +1640,7 @@ var OfflineQueue = class {
1615
1640
  if (this.#operations.some((item) => operationId(item) === id2)) return;
1616
1641
  this.#operations = [...this.#operations, operation];
1617
1642
  await this.#storage.save(this.#storageKey(), this.#operations);
1643
+ this.#announceChange();
1618
1644
  }
1619
1645
  async enqueueCheckIn(request) {
1620
1646
  return this.enqueue({ kind: "checkIn", request });
@@ -1626,6 +1652,7 @@ var OfflineQueue = class {
1626
1652
  await this.initialize();
1627
1653
  this.#operations = [];
1628
1654
  await this.#storage.save(this.#storageKey(), this.#operations);
1655
+ this.#announceChange();
1629
1656
  }
1630
1657
  async sync(sender = this.#sender) {
1631
1658
  await this.initialize();
@@ -1643,6 +1670,11 @@ var OfflineQueue = class {
1643
1670
  async #run(sender) {
1644
1671
  this.#state = "syncing";
1645
1672
  this.#error = null;
1673
+ if (!this.#acquireSyncLock()) {
1674
+ await this.#reloadFromStorage();
1675
+ this.#state = "idle";
1676
+ return;
1677
+ }
1646
1678
  try {
1647
1679
  while (this.#operations.length > 0) {
1648
1680
  const operation = this.#operations[0];
@@ -1664,6 +1696,7 @@ var OfflineQueue = class {
1664
1696
  const error = response.status === "REJECTED_PERMANENT" ? errorValue(response.error ?? response.reason, "REJECTED_PERMANENT") : void 0;
1665
1697
  this.#operations = this.#operations.slice(1);
1666
1698
  await this.#storage.save(this.#storageKey(), this.#operations);
1699
+ this.#announceChange();
1667
1700
  await this.#syncResultListener?.({
1668
1701
  operation,
1669
1702
  ...result === void 0 ? {} : { result },
@@ -1677,6 +1710,80 @@ var OfflineQueue = class {
1677
1710
  this.#state = "error";
1678
1711
  this.#error = cause instanceof Error ? cause : new Error(String(cause));
1679
1712
  throw this.#error;
1713
+ } finally {
1714
+ this.#releaseSyncLock();
1715
+ }
1716
+ }
1717
+ #subscribeToExternalChanges() {
1718
+ const windowLike = globalThis.window;
1719
+ if (windowLike !== void 0) {
1720
+ this.#storageListener = (event) => {
1721
+ if (event.key === this.#storageKey()) void this.#reloadFromStorage();
1722
+ };
1723
+ windowLike.addEventListener("storage", this.#storageListener);
1724
+ }
1725
+ const Channel = globalThis.BroadcastChannel;
1726
+ if (Channel !== void 0) {
1727
+ try {
1728
+ this.#channel = new Channel("stamprally:queue-sync");
1729
+ this.#channel.addEventListener("message", (event) => {
1730
+ if (typeof event.data === "object" && event.data !== null && "key" in event.data && event.data.key === this.#storageKey())
1731
+ void this.#reloadFromStorage();
1732
+ });
1733
+ } catch {
1734
+ this.#channel = null;
1735
+ }
1736
+ }
1737
+ }
1738
+ #announceChange() {
1739
+ this.#channel?.postMessage({ key: this.#storageKey(), owner: this.#instanceId });
1740
+ }
1741
+ async #reloadFromStorage() {
1742
+ if (this.#state === "syncing") return;
1743
+ try {
1744
+ this.#operations = [...await this.#storage.load(this.#storageKey())];
1745
+ this.#loaded = true;
1746
+ } catch {
1747
+ }
1748
+ }
1749
+ #lockKey() {
1750
+ return `${this.#storageKey()}:sync-lock`;
1751
+ }
1752
+ #acquireSyncLock() {
1753
+ const key = this.#lockKey();
1754
+ const now = Date.now();
1755
+ const local = syncLocks.get(key);
1756
+ if (local !== void 0 && local.expiresAt > now && local.owner !== this.#instanceId)
1757
+ return false;
1758
+ if (this.#lockStorage !== null) {
1759
+ try {
1760
+ const existing = this.#lockStorage.getItem(key);
1761
+ if (existing !== null) {
1762
+ const parsed = JSON.parse(existing);
1763
+ if (typeof parsed === "object" && parsed !== null && typeof parsed.expiresAt === "number" && parsed.expiresAt > now && parsed.owner !== this.#instanceId)
1764
+ return false;
1765
+ }
1766
+ this.#lockStorage.setItem(
1767
+ key,
1768
+ JSON.stringify({ owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS })
1769
+ );
1770
+ } catch {
1771
+ }
1772
+ }
1773
+ syncLocks.set(key, { owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS });
1774
+ return true;
1775
+ }
1776
+ #releaseSyncLock() {
1777
+ const key = this.#lockKey();
1778
+ const current = syncLocks.get(key);
1779
+ if (current?.owner === this.#instanceId) syncLocks.delete(key);
1780
+ if (this.#lockStorage !== null) {
1781
+ try {
1782
+ const value = this.#lockStorage.getItem(key);
1783
+ if (value !== null && JSON.parse(value).owner === this.#instanceId)
1784
+ this.#lockStorage.removeItem?.(key);
1785
+ } catch {
1786
+ }
1680
1787
  }
1681
1788
  }
1682
1789
  #storageKey() {
@@ -2439,21 +2546,112 @@ function validate(value, isPublic) {
2439
2546
  optionalString(value, "staffPasscode", "$", errors);
2440
2547
  if (hasOwn(value, "inventory") && value.inventory !== void 0 && !isRecord3(value.inventory))
2441
2548
  add(errors, "$.inventory", "Expected an object.", "invalid_type");
2549
+ if (hasOwn(value, "inventoryMode") && value.inventoryMode !== void 0 && value.inventoryMode !== "shared" && value.inventoryMode !== "per_reward")
2550
+ add(errors, "$.inventoryMode", "Expected shared or per_reward.", "invalid_enum");
2442
2551
  if (hasOwn(value, "serverMetadata") && value.serverMetadata !== void 0 && !isRecord3(value.serverMetadata))
2443
2552
  add(errors, "$.serverMetadata", "Expected an object.", "invalid_type");
2444
2553
  if (hasOwn(value, "publicMetadata") && value.publicMetadata !== void 0 && !isRecord3(value.publicMetadata))
2445
2554
  add(errors, "$.publicMetadata", "Expected an object.", "invalid_type");
2446
2555
  optionalString(value, "serverEndpoint", "$", errors);
2447
2556
  } else {
2448
- for (const key of ["staffPasscode", "serverMetadata", "inventory"])
2557
+ for (const key of ["staffPasscode", "serverMetadata", "inventory", "inventoryMode"])
2449
2558
  if (hasOwn(value, key))
2450
2559
  add(errors, `$.${key}`, "Private field is not allowed.", "private_field");
2451
2560
  optionalString(value, "serverEndpoint", "$", errors);
2452
2561
  }
2453
2562
  return errors;
2454
2563
  }
2564
+ function validateRallyConfigRelations(config) {
2565
+ const errors = [];
2566
+ const spotIds = /* @__PURE__ */ new Set();
2567
+ const rewardIds = /* @__PURE__ */ new Set();
2568
+ const orderIndexes = /* @__PURE__ */ new Map();
2569
+ config.spots.forEach((spot2, index) => {
2570
+ if (spotIds.has(spot2.id))
2571
+ add(errors, `spots[${index}].id`, "Spot ID must be unique.", "duplicate_spot_id");
2572
+ spotIds.add(spot2.id);
2573
+ const previousIndex = orderIndexes.get(spot2.orderIndex);
2574
+ if (previousIndex !== void 0)
2575
+ add(
2576
+ errors,
2577
+ `spots[${index}].orderIndex`,
2578
+ `orderIndex duplicates spots[${previousIndex}].`,
2579
+ "duplicate_order_index"
2580
+ );
2581
+ else orderIndexes.set(spot2.orderIndex, index);
2582
+ if (spot2.orderIndex < 0)
2583
+ add(
2584
+ errors,
2585
+ `spots[${index}].orderIndex`,
2586
+ "orderIndex must not be negative.",
2587
+ "negative_order_index"
2588
+ );
2589
+ spot2.prerequisites?.forEach((prerequisite, prerequisiteIndex) => {
2590
+ if (!spotIds.has(prerequisite) && !config.spots.some((candidate) => candidate.id === prerequisite))
2591
+ add(
2592
+ errors,
2593
+ `spots[${index}].prerequisites[${prerequisiteIndex}]`,
2594
+ "Prerequisite spot does not exist.",
2595
+ "missing_prerequisite"
2596
+ );
2597
+ });
2598
+ });
2599
+ config.rewards.forEach((reward2, index) => {
2600
+ if (rewardIds.has(reward2.id))
2601
+ add(errors, `rewards[${index}].id`, "Reward ID must be unique.", "duplicate_reward_id");
2602
+ rewardIds.add(reward2.id);
2603
+ const visit = (condition2, path) => {
2604
+ if (condition2.type === "stamps")
2605
+ condition2.stampIds.forEach((stampId, stampIndex) => {
2606
+ if (!spotIds.has(stampId))
2607
+ add(
2608
+ errors,
2609
+ `${path}.stampIds[${stampIndex}]`,
2610
+ "Referenced spot does not exist.",
2611
+ "missing_reward_spot"
2612
+ );
2613
+ });
2614
+ else if (condition2.type === "all" || condition2.type === "any")
2615
+ condition2.conditions.forEach((nested, nestedIndex) => {
2616
+ visit(nested, `${path}.conditions[${nestedIndex}]`);
2617
+ });
2618
+ };
2619
+ reward2.conditions?.forEach((condition2, conditionIndex) => {
2620
+ visit(condition2, `rewards[${index}].conditions[${conditionIndex}]`);
2621
+ });
2622
+ });
2623
+ const visiting = /* @__PURE__ */ new Set();
2624
+ const visited = /* @__PURE__ */ new Set();
2625
+ const cycleNodes = /* @__PURE__ */ new Set();
2626
+ const visitSpot = (spotId) => {
2627
+ if (visiting.has(spotId)) {
2628
+ cycleNodes.add(spotId);
2629
+ return;
2630
+ }
2631
+ if (visited.has(spotId)) return;
2632
+ visiting.add(spotId);
2633
+ const spot2 = config.spots.find((candidate) => candidate.id === spotId);
2634
+ spot2?.prerequisites?.forEach(visitSpot);
2635
+ visiting.delete(spotId);
2636
+ visited.add(spotId);
2637
+ };
2638
+ config.spots.forEach((spot2) => {
2639
+ visitSpot(spot2.id);
2640
+ });
2641
+ cycleNodes.forEach((spotId) => {
2642
+ const index = config.spots.findIndex((spot2) => spot2.id === spotId);
2643
+ add(
2644
+ errors,
2645
+ `spots[${index}].prerequisites`,
2646
+ "Prerequisites must form a DAG.",
2647
+ "cyclic_prerequisites"
2648
+ );
2649
+ });
2650
+ return errors;
2651
+ }
2455
2652
  function safeParseAdminConfig(input) {
2456
- const errors = validate(input, false);
2653
+ const errors = [...validate(input, false)];
2654
+ if (errors.length === 0) errors.push(...validateRallyConfigRelations(input));
2457
2655
  return errors.length === 0 ? { success: true, data: input } : { success: false, errors };
2458
2656
  }
2459
2657
  function parseAdminConfig(input) {
@@ -2462,7 +2660,8 @@ function parseAdminConfig(input) {
2462
2660
  return result.data;
2463
2661
  }
2464
2662
  function safeParsePublicConfig(input) {
2465
- const errors = validate(input, true);
2663
+ const errors = [...validate(input, true)];
2664
+ if (errors.length === 0) errors.push(...validateRallyConfigRelations(input));
2466
2665
  return errors.length === 0 ? { success: true, data: input } : { success: false, errors };
2467
2666
  }
2468
2667
  function parsePublicConfig(input) {
@@ -2641,6 +2840,7 @@ exports.toLocalizedString = toLocalizedString;
2641
2840
  exports.toPublicConfig = toPublicConfig;
2642
2841
  exports.updateLocalizedField = updateLocalizedField;
2643
2842
  exports.validatePublicConfigSafety = validatePublicConfigSafety;
2843
+ exports.validateRallyConfigRelations = validateRallyConfigRelations;
2644
2844
  exports.verifyPasscode = verifyPasscode;
2645
2845
  exports.verifySecureToken = verifySecureToken;
2646
2846
  exports.verifySnapshotToken = verifySnapshotToken;