@stamprally/core 0.14.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
@@ -617,7 +617,13 @@ function cloneState(state) {
617
617
  return {
618
618
  ...state,
619
619
  records: state.records.map(cloneRecord),
620
- ...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
+ }
621
627
  };
622
628
  }
623
629
  function isRecord(value) {
@@ -637,7 +643,7 @@ function isRewardState(value) {
637
643
  function isStampRallyState(value) {
638
644
  if (typeof value !== "object" || value === null) return false;
639
645
  const state = value;
640
- 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)));
641
647
  }
642
648
  function isValidDate(value) {
643
649
  return typeof value === "string" && !Number.isNaN(Date.parse(value));
@@ -682,6 +688,35 @@ var InMemoryStorage = class {
682
688
  function storageKey(rallyId, userId) {
683
689
  return `stamprally:${rallyId}:${userId ?? "anonymous"}`;
684
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
+ }
685
720
  var defaultStorageWarningHandler = (error) => {
686
721
  console.warn(`[@stamprally/core] ${error.message}`, error);
687
722
  };
@@ -1071,6 +1106,7 @@ var StampRallyClient = class {
1071
1106
  #config;
1072
1107
  #offlineQueue;
1073
1108
  #userId;
1109
+ #anonymousSessionId;
1074
1110
  #state = null;
1075
1111
  #initialization = null;
1076
1112
  #queue = Promise.resolve();
@@ -1078,7 +1114,8 @@ var StampRallyClient = class {
1078
1114
  this.#config = config;
1079
1115
  this.#options = isStorage(storageOrOptions) ? { storage: storageOrOptions } : storageOrOptions;
1080
1116
  this.#storage = this.#options.storage ?? new InMemoryStorage();
1081
- this.#userId = this.#options.userId ?? null;
1117
+ this.#anonymousSessionId = this.#options.anonymousSessionId ?? createAnonymousSessionId();
1118
+ this.#userId = this.#options.userId ?? this.#anonymousSessionId;
1082
1119
  this.#offlineQueue = this.#options.offlineQueue;
1083
1120
  this.#offlineQueue?.setSyncResultListener((event) => this.#handleOfflineSyncResult(event));
1084
1121
  }
@@ -1091,6 +1128,9 @@ var StampRallyClient = class {
1091
1128
  getUserId() {
1092
1129
  return this.#userId;
1093
1130
  }
1131
+ getAnonymousSessionId() {
1132
+ return this.#anonymousSessionId;
1133
+ }
1094
1134
  get syncState() {
1095
1135
  return this.#offlineQueue?.syncState ?? "idle";
1096
1136
  }
@@ -1128,11 +1168,12 @@ var StampRallyClient = class {
1128
1168
  }
1129
1169
  switchUser(newUserId) {
1130
1170
  return this.#enqueue(async () => {
1131
- if (this.#userId === newUserId && this.#state !== null) return this.#state;
1132
- this.#userId = newUserId;
1171
+ const nextUserId = newUserId ?? this.#anonymousSessionId;
1172
+ if (this.#userId === nextUserId && this.#state !== null) return this.#state;
1173
+ this.#userId = nextUserId;
1133
1174
  this.#state = null;
1134
1175
  this.#initialization = null;
1135
- await this.#offlineQueue?.switchUser(newUserId);
1176
+ await this.#offlineQueue?.switchUser(nextUserId);
1136
1177
  return this.initialize();
1137
1178
  });
1138
1179
  }
@@ -1200,7 +1241,8 @@ var StampRallyClient = class {
1200
1241
  proofData,
1201
1242
  idempotencyKey: options.idempotencyKey ?? id("check-in"),
1202
1243
  now,
1203
- state: current
1244
+ state: current,
1245
+ ...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
1204
1246
  };
1205
1247
  const remote = this.#options.syncAdapter?.checkIn;
1206
1248
  if (options.sync !== false && remote !== void 0) {
@@ -1256,7 +1298,8 @@ var StampRallyClient = class {
1256
1298
  idempotencyKey: options.idempotencyKey ?? id("claim"),
1257
1299
  now,
1258
1300
  options,
1259
- state: current
1301
+ state: current,
1302
+ ...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
1260
1303
  };
1261
1304
  const remote = this.#options.syncAdapter?.claimReward;
1262
1305
  if (options.sync !== false && remote !== void 0) {
@@ -1308,13 +1351,10 @@ var StampRallyClient = class {
1308
1351
  const serverState = await adapter.sync({
1309
1352
  rallyId: this.#config.id,
1310
1353
  userId: this.#userId,
1311
- state: this.#state ?? current
1312
- });
1313
- const localState = this.#state ?? current;
1314
- const merged = this.#offlineQueue === void 0 ? serverState : resolveRallyStateConflict(serverState, localState, {
1315
- policy: this.#offlineQueue.conflictPolicy
1354
+ state: this.#state ?? current,
1355
+ ...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
1316
1356
  });
1317
- const next = this.#reconcile(merged);
1357
+ const next = this.#reconcile(serverState);
1318
1358
  await this.#storage.save(next);
1319
1359
  this.#state = next;
1320
1360
  this.#emit(next);
@@ -1528,6 +1568,11 @@ function errorValue(value, fallbackCode) {
1528
1568
  }
1529
1569
  var syncLocks = /* @__PURE__ */ new Map();
1530
1570
  var SYNC_LOCK_TTL_MS = 3e4;
1571
+ var DEFAULT_RETRY_OPTIONS = {
1572
+ maxRetries: 0,
1573
+ initialIntervalMs: 250,
1574
+ backoffMultiplier: 2
1575
+ };
1531
1576
  function randomId() {
1532
1577
  return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
1533
1578
  }
@@ -1546,6 +1591,7 @@ var OfflineQueue = class {
1546
1591
  #syncPromise = null;
1547
1592
  #syncResultListener;
1548
1593
  #synchronizeInstances;
1594
+ #retryOptions;
1549
1595
  #instanceId = randomId();
1550
1596
  #lockStorage;
1551
1597
  #storageListener;
@@ -1561,6 +1607,15 @@ var OfflineQueue = class {
1561
1607
  this.#conflictPolicy = options.conflictPolicy ?? "server_wins";
1562
1608
  this.#onSyncConflict = options.onSyncConflict;
1563
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
+ };
1564
1619
  this.#lockStorage = options.storageLike ?? globalThis.localStorage ?? null;
1565
1620
  if (this.#synchronizeInstances) this.#subscribeToExternalChanges();
1566
1621
  }
@@ -1593,7 +1648,7 @@ var OfflineQueue = class {
1593
1648
  }
1594
1649
  async initialize() {
1595
1650
  if (this.#loaded) return;
1596
- this.#operations = [...await this.#storage.load(this.#storageKey())];
1651
+ this.#operations = (await this.#storage.load(this.#storageKey())).map(normalizeOperation);
1597
1652
  this.#loaded = true;
1598
1653
  }
1599
1654
  /** Releases browser listeners when the queue is no longer used. */
@@ -1638,7 +1693,7 @@ var OfflineQueue = class {
1638
1693
  await this.initialize();
1639
1694
  const id2 = operationId(operation);
1640
1695
  if (this.#operations.some((item) => operationId(item) === id2)) return;
1641
- this.#operations = [...this.#operations, operation];
1696
+ this.#operations = [...this.#operations, { ...operation, status: "PENDING", attempts: 0 }];
1642
1697
  await this.#storage.save(this.#storageKey(), this.#operations);
1643
1698
  this.#announceChange();
1644
1699
  }
@@ -1668,6 +1723,33 @@ var OfflineQueue = class {
1668
1723
  return this.sync(sender);
1669
1724
  }
1670
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) {
1671
1753
  this.#state = "syncing";
1672
1754
  this.#error = null;
1673
1755
  if (!this.#acquireSyncLock()) {
@@ -1679,21 +1761,36 @@ var OfflineQueue = class {
1679
1761
  while (this.#operations.length > 0) {
1680
1762
  const operation = this.#operations[0];
1681
1763
  if (operation === void 0) break;
1682
- let rawResult;
1683
- try {
1684
- rawResult = await sender(operation);
1685
- } catch (cause) {
1686
- throw new Error(errorValue(cause, "RETRYABLE_ERROR").message);
1687
- }
1688
- const response = this.#normalizeResponse(rawResult);
1689
- 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;
1690
1777
  const error2 = errorValue(response.error ?? response.reason, "RETRYABLE_ERROR");
1778
+ await this.#updateOperationStatus("PENDING", attempt + 1);
1691
1779
  await this.#syncResultListener?.({ operation, status: response.status, error: error2 });
1692
- 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;
1693
1784
  }
1694
1785
  const result = response.result;
1695
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);
1696
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;
1697
1794
  this.#operations = this.#operations.slice(1);
1698
1795
  await this.#storage.save(this.#storageKey(), this.#operations);
1699
1796
  this.#announceChange();
@@ -1702,7 +1799,7 @@ var OfflineQueue = class {
1702
1799
  ...result === void 0 ? {} : { result },
1703
1800
  status: response.status,
1704
1801
  ...error === void 0 ? {} : { error },
1705
- ...state === void 0 ? {} : { state }
1802
+ ...eventState === void 0 ? {} : { state: eventState }
1706
1803
  });
1707
1804
  }
1708
1805
  this.#state = "idle";
@@ -1714,6 +1811,13 @@ var OfflineQueue = class {
1714
1811
  this.#releaseSyncLock();
1715
1812
  }
1716
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
+ }
1717
1821
  #subscribeToExternalChanges() {
1718
1822
  const windowLike = globalThis.window;
1719
1823
  if (windowLike !== void 0) {
@@ -1741,7 +1845,7 @@ var OfflineQueue = class {
1741
1845
  async #reloadFromStorage() {
1742
1846
  if (this.#state === "syncing") return;
1743
1847
  try {
1744
- this.#operations = [...await this.#storage.load(this.#storageKey())];
1848
+ this.#operations = (await this.#storage.load(this.#storageKey())).map(normalizeOperation);
1745
1849
  this.#loaded = true;
1746
1850
  } catch {
1747
1851
  }
@@ -1811,6 +1915,13 @@ var OfflineQueue = class {
1811
1915
  return resolveRallyStateConflict(serverState, localState, { policy });
1812
1916
  }
1813
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
+ }
1814
1925
 
1815
1926
  // src/crypto/token.ts
1816
1927
  var encoder = new TextEncoder();
@@ -2379,6 +2490,8 @@ function condition(value, path, errors, isPublic) {
2379
2490
  finiteNumber(value, "latitude", path, errors);
2380
2491
  finiteNumber(value, "longitude", path, errors);
2381
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");
2382
2495
  if (typeof value.latitude === "number" && (value.latitude < -90 || value.latitude > 90))
2383
2496
  add(errors, `${path}.latitude`, "Expected a latitude between -90 and 90.", "out_of_range");
2384
2497
  if (typeof value.longitude === "number" && (value.longitude < -180 || value.longitude > 180))
@@ -2804,6 +2917,7 @@ exports.assertPublicConfig = assertPublicConfig;
2804
2917
  exports.calculateDistanceMeters = calculateDistanceMeters;
2805
2918
  exports.calculateProgress = calculateProgress;
2806
2919
  exports.consumeReward = consumeReward;
2920
+ exports.createAnonymousSessionId = createAnonymousSessionId;
2807
2921
  exports.createClaimTicketNumber = createClaimTicketNumber;
2808
2922
  exports.createSecureToken = createSecureToken;
2809
2923
  exports.createSignedSnapshotToken = createSignedSnapshotToken;