@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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # @stamprally/core v0.19.0
1
+ # @stamprally/core v0.20.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.19.0",
10
+ version: "0.20.0",
11
11
  title: "City Tour",
12
12
  spots: [{ id: "station", orderIndex: 0, name: "Central Station", conditions: [{ type: "passcode" }] }],
13
13
  rewards: [],
@@ -35,6 +35,33 @@ When no authenticated user is supplied, the client creates a persistent UUID v4
35
35
  `anonymousSessionId` and includes it in sync requests. Queue replay uses Web Locks
36
36
  when available and supports bounded exponential backoff through `retryOptions`.
37
37
 
38
+ ### v0.20.0 batch sync and storage capability
39
+
40
+ An adapter can return `SyncProgressResponse` from `sync` when it sends queued
41
+ operations to a server-side `syncProgress` endpoint. The adapter passes queued
42
+ operations to its transport and maps the server request shape as needed:
43
+
44
+ ```ts
45
+ const batchAdapter: SyncAdapter = {
46
+ sync: async ({ rallyId, userId, state, operations }) =>
47
+ postBatch({ rallyId, userId, state, operations }),
48
+ };
49
+ await client.sync(batchAdapter);
50
+ ```
51
+
52
+ Each `SyncOperationResult`
53
+ is `ACCEPTED`, `REJECTED_PERMANENT`, or `FAILED_RETRYABLE`. Accepted and permanent
54
+ results are removed from the queue; permanent reasons are added to
55
+ `rejectedHistory`, while retryable operations are kept for the next send. A
56
+ permanent prerequisite failure does not prevent later independent operations from
57
+ being evaluated.
58
+
59
+ `client.queueCapability.multiTabSync` is `"supported_web_locks"` when the Web
60
+ Locks API is available. Otherwise it is `"disabled_unsafe_environment"`:
61
+ cross-tab automatic synchronization is disabled, the foreground tab must trigger
62
+ sync explicitly, and a `storageCapabilityWarning` event is emitted. localStorage
63
+ may still be used for durable queue data, but it is never used as a lock.
64
+
38
65
  `evaluateSpotStatus` derives `UNCLAIMED`, `CLAIMED`, `LOCKED`, or `VERIFYING`
39
66
  without mutating state. A spot with incomplete `prerequisites` is `LOCKED` and
40
67
  must not be verified by a client or viewer.
package/dist/index.cjs CHANGED
@@ -1200,8 +1200,6 @@ function errorValue(value, fallbackCode) {
1200
1200
  if (typeof value === "string") return { code: fallbackCode, message: value };
1201
1201
  return { code: fallbackCode, message: "Offline operation was rejected." };
1202
1202
  }
1203
- var syncLocks = /* @__PURE__ */ new Map();
1204
- var SYNC_LOCK_TTL_MS = 3e4;
1205
1203
  var DEFAULT_RETRY_OPTIONS = {
1206
1204
  maxRetries: 0,
1207
1205
  initialIntervalMs: 250,
@@ -1228,9 +1226,7 @@ var OfflineQueue = class {
1228
1226
  #synchronizeInstances;
1229
1227
  #retryOptions;
1230
1228
  #instanceId = randomId();
1231
- #lockStorage;
1232
- #observedLocks = /* @__PURE__ */ new Map();
1233
- #warnedMemoryLock = false;
1229
+ #warnedCapabilityMessages = /* @__PURE__ */ new Set();
1234
1230
  #capabilityWarningListener;
1235
1231
  #replayConfig;
1236
1232
  #storageListener;
@@ -1260,8 +1256,7 @@ var OfflineQueue = class {
1260
1256
  initialIntervalMs: Math.max(0, retryOptions.initialIntervalMs),
1261
1257
  backoffMultiplier: Math.max(1, retryOptions.backoffMultiplier)
1262
1258
  };
1263
- this.#lockStorage = options.storageLike ?? availableLocalStorage();
1264
- if (this.#synchronizeInstances) this.#subscribeToExternalChanges();
1259
+ if (this.#synchronizeInstances && this.#hasWebLocks()) this.#subscribeToExternalChanges();
1265
1260
  }
1266
1261
  get syncState() {
1267
1262
  return this.#state;
@@ -1270,12 +1265,14 @@ var OfflineQueue = class {
1270
1265
  return this.#operations.length;
1271
1266
  }
1272
1267
  get queueCapability() {
1273
- return this.#queueCapability;
1268
+ return {
1269
+ storage: this.#queueCapability,
1270
+ multiTabSync: this.#hasWebLocks() ? "supported_web_locks" : "disabled_unsafe_environment"
1271
+ };
1274
1272
  }
1275
1273
  get storageCapability() {
1276
1274
  if (this.#queueCapability === "memory") return "memory";
1277
- const locks = globalThis.navigator?.locks;
1278
- return locks !== void 0 || this.#lockStorage !== null ? this.#queueCapability : "volatile_single_tab";
1275
+ return this.#hasWebLocks() ? this.#queueCapability : "volatile_single_tab";
1279
1276
  }
1280
1277
  get isStoragePersistent() {
1281
1278
  return this.#queueCapability !== "memory";
@@ -1322,12 +1319,16 @@ var OfflineQueue = class {
1322
1319
  this.#queueCapability = "memory";
1323
1320
  this.#operations = [];
1324
1321
  this.#rejectedHistory = [];
1325
- this.#warnMemoryLock(
1322
+ this.#warnCapability(
1326
1323
  `Offline queue persistence is unavailable; queued data can be lost after reload (${String(error)}).`
1327
1324
  );
1328
1325
  }
1329
1326
  if (this.#queueCapability === "memory")
1330
- this.#warnMemoryLock("Offline queue persistence is unavailable; queued data is memory-only.");
1327
+ this.#warnCapability("Offline queue persistence is unavailable; queued data is memory-only.");
1328
+ if (this.queueCapability.multiTabSync === "disabled_unsafe_environment")
1329
+ this.#warnCapability(
1330
+ "Web Locks is unavailable; automatic cross-tab synchronization is disabled. Sync must be triggered by the foreground tab."
1331
+ );
1331
1332
  this.#loaded = true;
1332
1333
  }
1333
1334
  /** Releases browser listeners when the queue is no longer used. */
@@ -1338,7 +1339,6 @@ var OfflineQueue = class {
1338
1339
  this.#storageListener = void 0;
1339
1340
  this.#channel?.close();
1340
1341
  this.#channel = null;
1341
- this.#releaseSyncLock();
1342
1342
  }
1343
1343
  /** Selects a rally/user queue scope and loads its pending operations. */
1344
1344
  async setScope(rallyId, userId) {
@@ -1450,6 +1450,12 @@ var OfflineQueue = class {
1450
1450
  async retrySync(sender = this.#sender) {
1451
1451
  return this.sync(sender);
1452
1452
  }
1453
+ /** Applies a server-side batch response while preserving retryable operations. */
1454
+ async applySyncProgress(response) {
1455
+ await this.initialize();
1456
+ for (const result of response.results)
1457
+ await this.#applySyncOperationResult(result, response.currentState);
1458
+ }
1453
1459
  async #run(sender) {
1454
1460
  const locks = globalThis.navigator?.locks;
1455
1461
  if (locks !== void 0 && typeof locks.request === "function") {
@@ -1466,7 +1472,7 @@ var OfflineQueue = class {
1466
1472
  return false;
1467
1473
  }
1468
1474
  callbackStarted = true;
1469
- await this.#runWithStorageLock(sender);
1475
+ await this.#runSingleTab(sender);
1470
1476
  return true;
1471
1477
  }
1472
1478
  );
@@ -1476,22 +1482,12 @@ var OfflineQueue = class {
1476
1482
  if (callbackStarted) throw error;
1477
1483
  }
1478
1484
  }
1479
- if (this.storageCapability === "volatile_single_tab")
1480
- this.#warnMemoryLock(
1481
- "No cross-tab storage lock is available; offline sync is single-tab only."
1482
- );
1483
- await this.#runWithStorageLock(sender);
1485
+ await this.#runSingleTab(sender);
1484
1486
  }
1485
- async #runWithStorageLock(sender) {
1487
+ async #runSingleTab(sender) {
1486
1488
  this.#state = "syncing";
1487
1489
  this.#error = null;
1488
1490
  this.#changeListener?.();
1489
- if (!this.#acquireSyncLock()) {
1490
- await this.#reloadFromStorage();
1491
- this.#state = "idle";
1492
- this.#changeListener?.();
1493
- return;
1494
- }
1495
1491
  try {
1496
1492
  while (this.#operations.length > 0) {
1497
1493
  const operation = this.#operations[0];
@@ -1563,8 +1559,6 @@ var OfflineQueue = class {
1563
1559
  this.#error = cause instanceof Error ? cause : new Error(String(cause));
1564
1560
  this.#changeListener?.();
1565
1561
  throw this.#error;
1566
- } finally {
1567
- this.#releaseSyncLock();
1568
1562
  }
1569
1563
  }
1570
1564
  async #updateOperationStatus(status, attempts) {
@@ -1574,6 +1568,59 @@ var OfflineQueue = class {
1574
1568
  await this.#storage.save(this.#storageKey(), this.#operations);
1575
1569
  this.#announceChange();
1576
1570
  }
1571
+ async #applySyncOperationResult(result, currentState) {
1572
+ const operationIndex = this.#operations.findIndex(
1573
+ (operation2) => offlineOperationId(operation2) === result.operationId
1574
+ );
1575
+ const operation = operationIndex < 0 ? void 0 : this.#operations[operationIndex];
1576
+ if (operation === void 0) return;
1577
+ if (result.status === "FAILED_RETRYABLE") {
1578
+ const attempts = (operation.attempts ?? 0) + 1;
1579
+ this.#operations = [
1580
+ ...this.#operations.slice(0, operationIndex),
1581
+ { ...operation, status: "FAILED_RETRYABLE", attempts },
1582
+ ...this.#operations.slice(operationIndex + 1)
1583
+ ];
1584
+ await this.#storage.save(this.#storageKey(), this.#operations);
1585
+ this.#announceChange();
1586
+ await this.#syncResultListener?.({
1587
+ operation,
1588
+ status: "RETRYABLE_ERROR",
1589
+ error: { code: "FAILED_RETRYABLE", message: result.error }
1590
+ });
1591
+ return;
1592
+ }
1593
+ this.#operations = [
1594
+ ...this.#operations.slice(0, operationIndex),
1595
+ ...this.#operations.slice(operationIndex + 1)
1596
+ ];
1597
+ if (result.status === "REJECTED_PERMANENT") {
1598
+ const attempts = (operation.attempts ?? 0) + 1;
1599
+ const error = {
1600
+ code: result.errorCode,
1601
+ message: result.reason
1602
+ };
1603
+ this.#rejectedHistory = [
1604
+ ...this.#rejectedHistory,
1605
+ {
1606
+ operation: { ...operation, status: "REJECTED_PERMANENT", attempts },
1607
+ reason: error,
1608
+ errorCode: result.errorCode,
1609
+ rejectedAt: (/* @__PURE__ */ new Date()).toISOString(),
1610
+ attempts
1611
+ }
1612
+ ];
1613
+ await this.#saveRejectedHistory();
1614
+ }
1615
+ await this.#storage.save(this.#storageKey(), this.#operations);
1616
+ this.#announceChange();
1617
+ await this.#syncResultListener?.({
1618
+ operation,
1619
+ status: result.status,
1620
+ state: currentState,
1621
+ ...result.status === "REJECTED_PERMANENT" ? { error: { code: result.errorCode, message: result.reason } } : {}
1622
+ });
1623
+ }
1577
1624
  async #rejectFailedPrerequisite(operation) {
1578
1625
  if (operation.kind !== "checkIn" || this.#replayConfig === void 0) return false;
1579
1626
  const spot2 = this.#replayConfig.spots.find(
@@ -1627,20 +1674,6 @@ var OfflineQueue = class {
1627
1674
  try {
1628
1675
  this.#channel = new Channel("stamprally:queue-sync");
1629
1676
  this.#channel.addEventListener("message", (event) => {
1630
- if (typeof event.data === "object" && event.data !== null) {
1631
- const data = event.data;
1632
- if (data.type === "lock" && data.lockKey === this.#lockKey()) {
1633
- this.#observedLocks.set(data.lockKey, {
1634
- owner: typeof data.owner === "string" ? data.owner : "unknown",
1635
- expiresAt: typeof data.expiresAt === "number" ? data.expiresAt : 0
1636
- });
1637
- return;
1638
- }
1639
- if (data.type === "unlock" && data.lockKey === this.#lockKey()) {
1640
- this.#observedLocks.delete(data.lockKey);
1641
- return;
1642
- }
1643
- }
1644
1677
  if (typeof event.data === "object" && event.data !== null && "key" in event.data && event.data.key === this.#storageKey())
1645
1678
  void this.#reloadFromStorage();
1646
1679
  });
@@ -1667,57 +1700,6 @@ var OfflineQueue = class {
1667
1700
  } catch {
1668
1701
  }
1669
1702
  }
1670
- #lockKey() {
1671
- return `${this.#storageKey()}:sync-lock`;
1672
- }
1673
- #acquireSyncLock() {
1674
- const key = this.#lockKey();
1675
- const now = Date.now();
1676
- const local = syncLocks.get(key);
1677
- if (local !== void 0 && local.expiresAt > now && local.owner !== this.#instanceId)
1678
- return false;
1679
- const observed = this.#observedLocks.get(key);
1680
- if (observed !== void 0 && observed.expiresAt > now && observed.owner !== this.#instanceId)
1681
- return false;
1682
- if (this.#lockStorage !== null) {
1683
- try {
1684
- const existing = this.#lockStorage.getItem(key);
1685
- if (existing !== null) {
1686
- const parsed = JSON.parse(existing);
1687
- if (typeof parsed === "object" && parsed !== null && typeof parsed.expiresAt === "number" && parsed.expiresAt > now && parsed.owner !== this.#instanceId)
1688
- return false;
1689
- }
1690
- this.#lockStorage.setItem(
1691
- key,
1692
- JSON.stringify({ owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS })
1693
- );
1694
- this.#channel?.postMessage({
1695
- type: "lock",
1696
- lockKey: key,
1697
- owner: this.#instanceId,
1698
- expiresAt: now + SYNC_LOCK_TTL_MS
1699
- });
1700
- } catch {
1701
- this.#warnMemoryLock("Persistent lock access failed; offline sync is single-tab only.");
1702
- }
1703
- }
1704
- syncLocks.set(key, { owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS });
1705
- return true;
1706
- }
1707
- #releaseSyncLock() {
1708
- const key = this.#lockKey();
1709
- const current = syncLocks.get(key);
1710
- if (current?.owner === this.#instanceId) syncLocks.delete(key);
1711
- if (this.#lockStorage !== null) {
1712
- try {
1713
- const value = this.#lockStorage.getItem(key);
1714
- if (value !== null && JSON.parse(value).owner === this.#instanceId)
1715
- this.#lockStorage.removeItem?.(key);
1716
- this.#channel?.postMessage({ type: "unlock", lockKey: key, owner: this.#instanceId });
1717
- } catch {
1718
- }
1719
- }
1720
- }
1721
1703
  #storageKey() {
1722
1704
  if (this.#configuredKey !== void 0) return this.#configuredKey;
1723
1705
  return `stamprally:queue:${this.#rallyId ?? "unscoped"}:${this.#userId ?? "anonymous"}`;
@@ -1740,17 +1722,22 @@ var OfflineQueue = class {
1740
1722
  async #saveRejectedHistory() {
1741
1723
  await this.#storage.saveRejectedHistory?.(this.#storageKey(), this.#rejectedHistory);
1742
1724
  }
1743
- #warnMemoryLock(message) {
1744
- if (this.#warnedMemoryLock) return;
1745
- this.#warnedMemoryLock = true;
1725
+ #warnCapability(message) {
1726
+ if (this.#warnedCapabilityMessages.has(message)) return;
1727
+ this.#warnedCapabilityMessages.add(message);
1746
1728
  console.warn(`[@stamprally/core] ${message}`);
1747
1729
  this.#capabilityWarningListener?.({
1748
1730
  type: "STORAGE_CAPABILITY_WARNING",
1749
1731
  storageCapability: this.storageCapability === "memory" ? "memory" : "volatile_single_tab",
1732
+ multiTabSync: "disabled_unsafe_environment",
1750
1733
  isStoragePersistent: this.isStoragePersistent,
1751
1734
  message
1752
1735
  });
1753
1736
  }
1737
+ #hasWebLocks() {
1738
+ const locks = globalThis.navigator?.locks;
1739
+ return locks !== void 0 && typeof locks.request === "function";
1740
+ }
1754
1741
  };
1755
1742
  function normalizeOperation(operation) {
1756
1743
  const status = operation.status;
@@ -1942,6 +1929,9 @@ function applyOptimisticRewardClaim(state, rewardId, reward2, now) {
1942
1929
  function errorMessage(error, fallback) {
1943
1930
  return error !== void 0 && "message" in error && typeof error.message === "string" ? error.message : fallback;
1944
1931
  }
1932
+ function isSyncProgressResponse(value) {
1933
+ return typeof value === "object" && value !== null && "results" in value && Array.isArray(value.results) && "currentState" in value && "syncTimestamp" in value;
1934
+ }
1945
1935
  var StampRallyClient = class {
1946
1936
  #listeners = /* @__PURE__ */ new Set();
1947
1937
  #eventListeners = /* @__PURE__ */ new Set();
@@ -1999,7 +1989,10 @@ var StampRallyClient = class {
1999
1989
  return this.#syncRevision;
2000
1990
  }
2001
1991
  get queueCapability() {
2002
- return this.#offlineQueue?.queueCapability ?? "custom";
1992
+ return this.#offlineQueue?.queueCapability ?? {
1993
+ storage: "custom",
1994
+ multiTabSync: "disabled_unsafe_environment"
1995
+ };
2003
1996
  }
2004
1997
  get storageCapability() {
2005
1998
  return this.#offlineQueue?.storageCapability ?? "custom";
@@ -2216,7 +2209,9 @@ var StampRallyClient = class {
2216
2209
  this.#syncMetrics = { processed: 0, failed: 0 };
2217
2210
  try {
2218
2211
  const current = await this.initialize();
2219
- if (this.#offlineQueue !== void 0 && adapter !== void 0) {
2212
+ const queuedOperations = this.#offlineQueue?.operations ?? [];
2213
+ const hasPerOperationAdapters = adapter?.checkIn !== void 0 || adapter?.claimReward !== void 0;
2214
+ if (this.#offlineQueue !== void 0 && adapter !== void 0 && hasPerOperationAdapters) {
2220
2215
  await this.#offlineQueue.sync(async (operation) => {
2221
2216
  if (operation.kind === "checkIn") {
2222
2217
  if (adapter.checkIn === void 0)
@@ -2233,12 +2228,16 @@ var StampRallyClient = class {
2233
2228
  return;
2234
2229
  }
2235
2230
  const localState = this.#state ?? current;
2236
- const serverState = await adapter.sync({
2231
+ const syncResult = await adapter.sync({
2237
2232
  rallyId: this.#config.id,
2238
2233
  userId: this.#userId,
2239
2234
  state: localState,
2240
- ...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
2235
+ ...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {},
2236
+ ...this.#offlineQueue !== void 0 && !hasPerOperationAdapters && queuedOperations.length > 0 ? { operations: queuedOperations } : {}
2241
2237
  });
2238
+ const progress = isSyncProgressResponse(syncResult) ? syncResult : void 0;
2239
+ if (progress !== void 0) await this.#offlineQueue?.applySyncProgress(progress);
2240
+ const serverState = isSyncProgressResponse(syncResult) ? syncResult.currentState : syncResult;
2242
2241
  const resolved = rebuildUserStateFromLog(
2243
2242
  serverState,
2244
2243
  this.#offlineQueue?.operations ?? [],