@stamprally/core 0.19.0 → 0.20.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
@@ -1198,8 +1198,6 @@ function errorValue(value, fallbackCode) {
1198
1198
  if (typeof value === "string") return { code: fallbackCode, message: value };
1199
1199
  return { code: fallbackCode, message: "Offline operation was rejected." };
1200
1200
  }
1201
- var syncLocks = /* @__PURE__ */ new Map();
1202
- var SYNC_LOCK_TTL_MS = 3e4;
1203
1201
  var DEFAULT_RETRY_OPTIONS = {
1204
1202
  maxRetries: 0,
1205
1203
  initialIntervalMs: 250,
@@ -1226,9 +1224,7 @@ var OfflineQueue = class {
1226
1224
  #synchronizeInstances;
1227
1225
  #retryOptions;
1228
1226
  #instanceId = randomId();
1229
- #lockStorage;
1230
- #observedLocks = /* @__PURE__ */ new Map();
1231
- #warnedMemoryLock = false;
1227
+ #warnedCapabilityMessages = /* @__PURE__ */ new Set();
1232
1228
  #capabilityWarningListener;
1233
1229
  #replayConfig;
1234
1230
  #storageListener;
@@ -1258,8 +1254,7 @@ var OfflineQueue = class {
1258
1254
  initialIntervalMs: Math.max(0, retryOptions.initialIntervalMs),
1259
1255
  backoffMultiplier: Math.max(1, retryOptions.backoffMultiplier)
1260
1256
  };
1261
- this.#lockStorage = options.storageLike ?? availableLocalStorage();
1262
- if (this.#synchronizeInstances) this.#subscribeToExternalChanges();
1257
+ if (this.#synchronizeInstances && this.#hasWebLocks()) this.#subscribeToExternalChanges();
1263
1258
  }
1264
1259
  get syncState() {
1265
1260
  return this.#state;
@@ -1268,12 +1263,14 @@ var OfflineQueue = class {
1268
1263
  return this.#operations.length;
1269
1264
  }
1270
1265
  get queueCapability() {
1271
- return this.#queueCapability;
1266
+ return {
1267
+ storage: this.#queueCapability,
1268
+ multiTabSync: this.#hasWebLocks() ? "supported_web_locks" : "disabled_unsafe_environment"
1269
+ };
1272
1270
  }
1273
1271
  get storageCapability() {
1274
1272
  if (this.#queueCapability === "memory") return "memory";
1275
- const locks = globalThis.navigator?.locks;
1276
- return locks !== void 0 || this.#lockStorage !== null ? this.#queueCapability : "volatile_single_tab";
1273
+ return this.#hasWebLocks() ? this.#queueCapability : "volatile_single_tab";
1277
1274
  }
1278
1275
  get isStoragePersistent() {
1279
1276
  return this.#queueCapability !== "memory";
@@ -1320,12 +1317,16 @@ var OfflineQueue = class {
1320
1317
  this.#queueCapability = "memory";
1321
1318
  this.#operations = [];
1322
1319
  this.#rejectedHistory = [];
1323
- this.#warnMemoryLock(
1320
+ this.#warnCapability(
1324
1321
  `Offline queue persistence is unavailable; queued data can be lost after reload (${String(error)}).`
1325
1322
  );
1326
1323
  }
1327
1324
  if (this.#queueCapability === "memory")
1328
- this.#warnMemoryLock("Offline queue persistence is unavailable; queued data is memory-only.");
1325
+ this.#warnCapability("Offline queue persistence is unavailable; queued data is memory-only.");
1326
+ if (this.queueCapability.multiTabSync === "disabled_unsafe_environment")
1327
+ this.#warnCapability(
1328
+ "Web Locks is unavailable; automatic cross-tab synchronization is disabled. Sync must be triggered by the foreground tab."
1329
+ );
1329
1330
  this.#loaded = true;
1330
1331
  }
1331
1332
  /** Releases browser listeners when the queue is no longer used. */
@@ -1336,7 +1337,6 @@ var OfflineQueue = class {
1336
1337
  this.#storageListener = void 0;
1337
1338
  this.#channel?.close();
1338
1339
  this.#channel = null;
1339
- this.#releaseSyncLock();
1340
1340
  }
1341
1341
  /** Selects a rally/user queue scope and loads its pending operations. */
1342
1342
  async setScope(rallyId, userId) {
@@ -1448,6 +1448,12 @@ var OfflineQueue = class {
1448
1448
  async retrySync(sender = this.#sender) {
1449
1449
  return this.sync(sender);
1450
1450
  }
1451
+ /** Applies a server-side batch response while preserving retryable operations. */
1452
+ async applySyncProgress(response) {
1453
+ await this.initialize();
1454
+ for (const result of response.results)
1455
+ await this.#applySyncOperationResult(result, response.currentState);
1456
+ }
1451
1457
  async #run(sender) {
1452
1458
  const locks = globalThis.navigator?.locks;
1453
1459
  if (locks !== void 0 && typeof locks.request === "function") {
@@ -1464,7 +1470,7 @@ var OfflineQueue = class {
1464
1470
  return false;
1465
1471
  }
1466
1472
  callbackStarted = true;
1467
- await this.#runWithStorageLock(sender);
1473
+ await this.#runSingleTab(sender);
1468
1474
  return true;
1469
1475
  }
1470
1476
  );
@@ -1474,22 +1480,12 @@ var OfflineQueue = class {
1474
1480
  if (callbackStarted) throw error;
1475
1481
  }
1476
1482
  }
1477
- if (this.storageCapability === "volatile_single_tab")
1478
- this.#warnMemoryLock(
1479
- "No cross-tab storage lock is available; offline sync is single-tab only."
1480
- );
1481
- await this.#runWithStorageLock(sender);
1483
+ await this.#runSingleTab(sender);
1482
1484
  }
1483
- async #runWithStorageLock(sender) {
1485
+ async #runSingleTab(sender) {
1484
1486
  this.#state = "syncing";
1485
1487
  this.#error = null;
1486
1488
  this.#changeListener?.();
1487
- if (!this.#acquireSyncLock()) {
1488
- await this.#reloadFromStorage();
1489
- this.#state = "idle";
1490
- this.#changeListener?.();
1491
- return;
1492
- }
1493
1489
  try {
1494
1490
  while (this.#operations.length > 0) {
1495
1491
  const operation = this.#operations[0];
@@ -1561,8 +1557,6 @@ var OfflineQueue = class {
1561
1557
  this.#error = cause instanceof Error ? cause : new Error(String(cause));
1562
1558
  this.#changeListener?.();
1563
1559
  throw this.#error;
1564
- } finally {
1565
- this.#releaseSyncLock();
1566
1560
  }
1567
1561
  }
1568
1562
  async #updateOperationStatus(status, attempts) {
@@ -1572,6 +1566,59 @@ var OfflineQueue = class {
1572
1566
  await this.#storage.save(this.#storageKey(), this.#operations);
1573
1567
  this.#announceChange();
1574
1568
  }
1569
+ async #applySyncOperationResult(result, currentState) {
1570
+ const operationIndex = this.#operations.findIndex(
1571
+ (operation2) => offlineOperationId(operation2) === result.operationId
1572
+ );
1573
+ const operation = operationIndex < 0 ? void 0 : this.#operations[operationIndex];
1574
+ if (operation === void 0) return;
1575
+ if (result.status === "FAILED_RETRYABLE") {
1576
+ const attempts = (operation.attempts ?? 0) + 1;
1577
+ this.#operations = [
1578
+ ...this.#operations.slice(0, operationIndex),
1579
+ { ...operation, status: "FAILED_RETRYABLE", attempts },
1580
+ ...this.#operations.slice(operationIndex + 1)
1581
+ ];
1582
+ await this.#storage.save(this.#storageKey(), this.#operations);
1583
+ this.#announceChange();
1584
+ await this.#syncResultListener?.({
1585
+ operation,
1586
+ status: "RETRYABLE_ERROR",
1587
+ error: { code: "FAILED_RETRYABLE", message: result.error }
1588
+ });
1589
+ return;
1590
+ }
1591
+ this.#operations = [
1592
+ ...this.#operations.slice(0, operationIndex),
1593
+ ...this.#operations.slice(operationIndex + 1)
1594
+ ];
1595
+ if (result.status === "REJECTED_PERMANENT") {
1596
+ const attempts = (operation.attempts ?? 0) + 1;
1597
+ const error = {
1598
+ code: result.errorCode,
1599
+ message: result.reason
1600
+ };
1601
+ this.#rejectedHistory = [
1602
+ ...this.#rejectedHistory,
1603
+ {
1604
+ operation: { ...operation, status: "REJECTED_PERMANENT", attempts },
1605
+ reason: error,
1606
+ errorCode: result.errorCode,
1607
+ rejectedAt: (/* @__PURE__ */ new Date()).toISOString(),
1608
+ attempts
1609
+ }
1610
+ ];
1611
+ await this.#saveRejectedHistory();
1612
+ }
1613
+ await this.#storage.save(this.#storageKey(), this.#operations);
1614
+ this.#announceChange();
1615
+ await this.#syncResultListener?.({
1616
+ operation,
1617
+ status: result.status,
1618
+ state: currentState,
1619
+ ...result.status === "REJECTED_PERMANENT" ? { error: { code: result.errorCode, message: result.reason } } : {}
1620
+ });
1621
+ }
1575
1622
  async #rejectFailedPrerequisite(operation) {
1576
1623
  if (operation.kind !== "checkIn" || this.#replayConfig === void 0) return false;
1577
1624
  const spot2 = this.#replayConfig.spots.find(
@@ -1625,20 +1672,6 @@ var OfflineQueue = class {
1625
1672
  try {
1626
1673
  this.#channel = new Channel("stamprally:queue-sync");
1627
1674
  this.#channel.addEventListener("message", (event) => {
1628
- if (typeof event.data === "object" && event.data !== null) {
1629
- const data = event.data;
1630
- if (data.type === "lock" && data.lockKey === this.#lockKey()) {
1631
- this.#observedLocks.set(data.lockKey, {
1632
- owner: typeof data.owner === "string" ? data.owner : "unknown",
1633
- expiresAt: typeof data.expiresAt === "number" ? data.expiresAt : 0
1634
- });
1635
- return;
1636
- }
1637
- if (data.type === "unlock" && data.lockKey === this.#lockKey()) {
1638
- this.#observedLocks.delete(data.lockKey);
1639
- return;
1640
- }
1641
- }
1642
1675
  if (typeof event.data === "object" && event.data !== null && "key" in event.data && event.data.key === this.#storageKey())
1643
1676
  void this.#reloadFromStorage();
1644
1677
  });
@@ -1665,57 +1698,6 @@ var OfflineQueue = class {
1665
1698
  } catch {
1666
1699
  }
1667
1700
  }
1668
- #lockKey() {
1669
- return `${this.#storageKey()}:sync-lock`;
1670
- }
1671
- #acquireSyncLock() {
1672
- const key = this.#lockKey();
1673
- const now = Date.now();
1674
- const local = syncLocks.get(key);
1675
- if (local !== void 0 && local.expiresAt > now && local.owner !== this.#instanceId)
1676
- return false;
1677
- const observed = this.#observedLocks.get(key);
1678
- if (observed !== void 0 && observed.expiresAt > now && observed.owner !== this.#instanceId)
1679
- return false;
1680
- if (this.#lockStorage !== null) {
1681
- try {
1682
- const existing = this.#lockStorage.getItem(key);
1683
- if (existing !== null) {
1684
- const parsed = JSON.parse(existing);
1685
- if (typeof parsed === "object" && parsed !== null && typeof parsed.expiresAt === "number" && parsed.expiresAt > now && parsed.owner !== this.#instanceId)
1686
- return false;
1687
- }
1688
- this.#lockStorage.setItem(
1689
- key,
1690
- JSON.stringify({ owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS })
1691
- );
1692
- this.#channel?.postMessage({
1693
- type: "lock",
1694
- lockKey: key,
1695
- owner: this.#instanceId,
1696
- expiresAt: now + SYNC_LOCK_TTL_MS
1697
- });
1698
- } catch {
1699
- this.#warnMemoryLock("Persistent lock access failed; offline sync is single-tab only.");
1700
- }
1701
- }
1702
- syncLocks.set(key, { owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS });
1703
- return true;
1704
- }
1705
- #releaseSyncLock() {
1706
- const key = this.#lockKey();
1707
- const current = syncLocks.get(key);
1708
- if (current?.owner === this.#instanceId) syncLocks.delete(key);
1709
- if (this.#lockStorage !== null) {
1710
- try {
1711
- const value = this.#lockStorage.getItem(key);
1712
- if (value !== null && JSON.parse(value).owner === this.#instanceId)
1713
- this.#lockStorage.removeItem?.(key);
1714
- this.#channel?.postMessage({ type: "unlock", lockKey: key, owner: this.#instanceId });
1715
- } catch {
1716
- }
1717
- }
1718
- }
1719
1701
  #storageKey() {
1720
1702
  if (this.#configuredKey !== void 0) return this.#configuredKey;
1721
1703
  return `stamprally:queue:${this.#rallyId ?? "unscoped"}:${this.#userId ?? "anonymous"}`;
@@ -1738,17 +1720,22 @@ var OfflineQueue = class {
1738
1720
  async #saveRejectedHistory() {
1739
1721
  await this.#storage.saveRejectedHistory?.(this.#storageKey(), this.#rejectedHistory);
1740
1722
  }
1741
- #warnMemoryLock(message) {
1742
- if (this.#warnedMemoryLock) return;
1743
- this.#warnedMemoryLock = true;
1723
+ #warnCapability(message) {
1724
+ if (this.#warnedCapabilityMessages.has(message)) return;
1725
+ this.#warnedCapabilityMessages.add(message);
1744
1726
  console.warn(`[@stamprally/core] ${message}`);
1745
1727
  this.#capabilityWarningListener?.({
1746
1728
  type: "STORAGE_CAPABILITY_WARNING",
1747
1729
  storageCapability: this.storageCapability === "memory" ? "memory" : "volatile_single_tab",
1730
+ multiTabSync: "disabled_unsafe_environment",
1748
1731
  isStoragePersistent: this.isStoragePersistent,
1749
1732
  message
1750
1733
  });
1751
1734
  }
1735
+ #hasWebLocks() {
1736
+ const locks = globalThis.navigator?.locks;
1737
+ return locks !== void 0 && typeof locks.request === "function";
1738
+ }
1752
1739
  };
1753
1740
  function normalizeOperation(operation) {
1754
1741
  const status = operation.status;
@@ -1940,6 +1927,9 @@ function applyOptimisticRewardClaim(state, rewardId, reward2, now) {
1940
1927
  function errorMessage(error, fallback) {
1941
1928
  return error !== void 0 && "message" in error && typeof error.message === "string" ? error.message : fallback;
1942
1929
  }
1930
+ function isSyncProgressResponse(value) {
1931
+ return typeof value === "object" && value !== null && "results" in value && Array.isArray(value.results) && "currentState" in value && "syncTimestamp" in value;
1932
+ }
1943
1933
  var StampRallyClient = class {
1944
1934
  #listeners = /* @__PURE__ */ new Set();
1945
1935
  #eventListeners = /* @__PURE__ */ new Set();
@@ -1997,7 +1987,10 @@ var StampRallyClient = class {
1997
1987
  return this.#syncRevision;
1998
1988
  }
1999
1989
  get queueCapability() {
2000
- return this.#offlineQueue?.queueCapability ?? "custom";
1990
+ return this.#offlineQueue?.queueCapability ?? {
1991
+ storage: "custom",
1992
+ multiTabSync: "disabled_unsafe_environment"
1993
+ };
2001
1994
  }
2002
1995
  get storageCapability() {
2003
1996
  return this.#offlineQueue?.storageCapability ?? "custom";
@@ -2214,7 +2207,9 @@ var StampRallyClient = class {
2214
2207
  this.#syncMetrics = { processed: 0, failed: 0 };
2215
2208
  try {
2216
2209
  const current = await this.initialize();
2217
- if (this.#offlineQueue !== void 0 && adapter !== void 0) {
2210
+ const queuedOperations = this.#offlineQueue?.operations ?? [];
2211
+ const hasPerOperationAdapters = adapter?.checkIn !== void 0 || adapter?.claimReward !== void 0;
2212
+ if (this.#offlineQueue !== void 0 && adapter !== void 0 && hasPerOperationAdapters) {
2218
2213
  await this.#offlineQueue.sync(async (operation) => {
2219
2214
  if (operation.kind === "checkIn") {
2220
2215
  if (adapter.checkIn === void 0)
@@ -2231,12 +2226,16 @@ var StampRallyClient = class {
2231
2226
  return;
2232
2227
  }
2233
2228
  const localState = this.#state ?? current;
2234
- const serverState = await adapter.sync({
2229
+ const syncResult = await adapter.sync({
2235
2230
  rallyId: this.#config.id,
2236
2231
  userId: this.#userId,
2237
2232
  state: localState,
2238
- ...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
2233
+ ...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {},
2234
+ ...this.#offlineQueue !== void 0 && !hasPerOperationAdapters && queuedOperations.length > 0 ? { operations: queuedOperations } : {}
2239
2235
  });
2236
+ const progress = isSyncProgressResponse(syncResult) ? syncResult : void 0;
2237
+ if (progress !== void 0) await this.#offlineQueue?.applySyncProgress(progress);
2238
+ const serverState = isSyncProgressResponse(syncResult) ? syncResult.currentState : syncResult;
2240
2239
  const resolved = rebuildUserStateFromLog(
2241
2240
  serverState,
2242
2241
  this.#offlineQueue?.operations ?? [],