@stamprally/core 0.12.0 → 0.13.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
@@ -23,6 +23,19 @@ 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
27
+
28
+ Configure `OfflineQueue` with `rallyId` and `userId` to persist pending work under
29
+ `stamprally:queue:<rallyId>:<userId-or-anonymous>`. `switchUser` loads the other
30
+ user's queue. Conflict policy `merge` unions stamp records, keeps the latest
31
+ timestamps, and gives `CONSUMED` reward states priority; `server_wins` uses the
32
+ server state unchanged. Sync adapters may return `ACCEPTED`, `REJECTED_PERMANENT`,
33
+ or `RETRYABLE_ERROR`; only the first two remove an operation.
34
+
35
+ `evaluateSpotStatus` derives `UNCLAIMED`, `CLAIMED`, `LOCKED`, or `VERIFYING`
36
+ without mutating state. A spot with incomplete `prerequisites` is `LOCKED` and
37
+ must not be verified by a client or viewer.
38
+
26
39
  ## License
27
40
 
28
41
  MIT
package/dist/index.cjs CHANGED
@@ -41,6 +41,14 @@ function evaluateConditionDetailed(condition2, context) {
41
41
  function evaluateCondition(condition2, context) {
42
42
  return evaluateConditionDetailed(condition2, context).ok;
43
43
  }
44
+ function evaluateSpotStatus(spot2, state, options = {}) {
45
+ if (options.verifying === true) return "VERIFYING";
46
+ if (state.records.some((record) => record.stampId === spot2.id)) return "CLAIMED";
47
+ const acquired = new Set(state.records.map((record) => record.stampId));
48
+ if (spot2.prerequisites?.some((prerequisite) => !acquired.has(prerequisite))) return "LOCKED";
49
+ return "UNCLAIMED";
50
+ }
51
+ var getSpotStatus = evaluateSpotStatus;
44
52
 
45
53
  // src/engine/order.ts
46
54
  function getOrderedSpots(spots) {
@@ -117,24 +125,53 @@ function mergeRewardStates(serverRewards, localRewards) {
117
125
  merged.set(localReward.rewardId, localReward);
118
126
  continue;
119
127
  }
120
- if (localReward.status === "CONSUMED" && serverReward.status !== "CONSUMED")
121
- 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
+ });
122
150
  }
123
151
  return [...merged.values()];
124
152
  }
125
- function resolveRallyStateConflict(serverState, localState, options = { policy: "merge" }) {
126
- if (options.policy === "server_wins") return serverState;
127
- const records = [...serverState.records];
128
- const knownStamps = new Set(records.map((record) => record.stampId));
129
- for (const record of localState.records) {
130
- if (!knownStamps.has(record.stampId)) {
131
- records.push(record);
132
- 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;
133
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
+ });
134
167
  }
168
+ return [...merged.values()];
169
+ }
170
+ function resolveRallyStateConflict(serverState, localState, options = { policy: "merge" }) {
171
+ if (options.policy === "server_wins") return serverState;
135
172
  return {
136
173
  ...serverState,
137
- records,
174
+ records: mergeStampRecords(serverState.records, localState.records),
138
175
  rewards: mergeRewardStates(serverState.rewards, localState.rewards),
139
176
  updatedAt: latestTimestamp(serverState.updatedAt, localState.updatedAt)
140
177
  };
@@ -1072,7 +1109,10 @@ var StampRallyClient = class {
1072
1109
  initialize() {
1073
1110
  if (this.#state !== null) return Promise.resolve(this.#state);
1074
1111
  if (this.#initialization === null) {
1075
- 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) => {
1076
1116
  const next = state === null ? emptyState(this.#config, this.#userId, this.#now()) : this.#reconcile(state);
1077
1117
  this.#state = next;
1078
1118
  this.#emit(next);
@@ -1090,6 +1130,7 @@ var StampRallyClient = class {
1090
1130
  this.#userId = newUserId;
1091
1131
  this.#state = null;
1092
1132
  this.#initialization = null;
1133
+ await this.#offlineQueue?.switchUser(newUserId);
1093
1134
  return this.initialize();
1094
1135
  });
1095
1136
  }
@@ -1338,7 +1379,8 @@ var StampRallyClient = class {
1338
1379
  this.#state = next;
1339
1380
  this.#emit(next);
1340
1381
  }
1341
- 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)
1342
1384
  this.#emitEvent({ type: "error", error: event.result.error });
1343
1385
  }
1344
1386
  #now() {
@@ -1466,9 +1508,27 @@ function defaultStorage(databaseName) {
1466
1508
  function operationId(operation) {
1467
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}`;
1468
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
+ }
1469
1527
  var OfflineQueue = class {
1470
1528
  #storage;
1471
- #key;
1529
+ #configuredKey;
1530
+ #rallyId;
1531
+ #userId;
1472
1532
  #conflictPolicy;
1473
1533
  #onSyncConflict;
1474
1534
  #operations = [];
@@ -1483,7 +1543,9 @@ var OfflineQueue = class {
1483
1543
  else if (options.storageLike !== void 0 && options.storageLike !== null)
1484
1544
  this.#storage = new LocalStorageQueueStorage(options.storageLike);
1485
1545
  else this.#storage = defaultStorage(options.databaseName);
1486
- this.#key = options.key ?? "stamprally:offline-queue";
1546
+ this.#configuredKey = options.key;
1547
+ this.#rallyId = options.rallyId;
1548
+ this.#userId = options.userId ?? null;
1487
1549
  this.#conflictPolicy = options.conflictPolicy ?? "server_wins";
1488
1550
  this.#onSyncConflict = options.onSyncConflict;
1489
1551
  }
@@ -1502,23 +1564,57 @@ var OfflineQueue = class {
1502
1564
  get conflictPolicy() {
1503
1565
  return this.#conflictPolicy;
1504
1566
  }
1567
+ get storageKey() {
1568
+ return this.#storageKey();
1569
+ }
1570
+ get rallyId() {
1571
+ return this.#rallyId;
1572
+ }
1573
+ get userId() {
1574
+ return this.#userId;
1575
+ }
1505
1576
  setSyncResultListener(listener) {
1506
1577
  this.#syncResultListener = listener;
1507
1578
  }
1508
1579
  async initialize() {
1509
1580
  if (this.#loaded) return;
1510
- this.#operations = [...await this.#storage.load(this.#key)];
1581
+ this.#operations = [...await this.#storage.load(this.#storageKey())];
1511
1582
  this.#loaded = true;
1512
1583
  }
1584
+ /** Selects a rally/user queue scope and loads its pending operations. */
1585
+ async setScope(rallyId, userId) {
1586
+ if (this.#configuredKey !== void 0) {
1587
+ this.#rallyId = rallyId;
1588
+ this.#userId = userId;
1589
+ return this.initialize();
1590
+ }
1591
+ if (this.#rallyId === rallyId && this.#userId === userId && this.#loaded) return;
1592
+ this.#rallyId = rallyId;
1593
+ this.#userId = userId;
1594
+ this.#operations = [];
1595
+ this.#loaded = false;
1596
+ await this.initialize();
1597
+ }
1598
+ async switchUser(newUserId) {
1599
+ if (this.#rallyId === void 0)
1600
+ throw new Error("OfflineQueue.switchUser requires a rally scope.");
1601
+ await this.setScope(this.#rallyId, newUserId);
1602
+ }
1513
1603
  setSender(sender) {
1514
1604
  this.#sender = sender;
1515
1605
  }
1516
1606
  async enqueue(operation) {
1607
+ if (this.#configuredKey === void 0) {
1608
+ const scope = requestScope(operation);
1609
+ if (this.#rallyId === void 0) await this.setScope(scope.rallyId, scope.userId);
1610
+ if (this.#rallyId !== scope.rallyId || this.#userId !== scope.userId)
1611
+ throw new Error("Offline operation belongs to another rally or user queue.");
1612
+ }
1517
1613
  await this.initialize();
1518
1614
  const id2 = operationId(operation);
1519
1615
  if (this.#operations.some((item) => operationId(item) === id2)) return;
1520
1616
  this.#operations = [...this.#operations, operation];
1521
- await this.#storage.save(this.#key, this.#operations);
1617
+ await this.#storage.save(this.#storageKey(), this.#operations);
1522
1618
  }
1523
1619
  async enqueueCheckIn(request) {
1524
1620
  return this.enqueue({ kind: "checkIn", request });
@@ -1529,7 +1625,7 @@ var OfflineQueue = class {
1529
1625
  async clear() {
1530
1626
  await this.initialize();
1531
1627
  this.#operations = [];
1532
- await this.#storage.save(this.#key, this.#operations);
1628
+ await this.#storage.save(this.#storageKey(), this.#operations);
1533
1629
  }
1534
1630
  async sync(sender = this.#sender) {
1535
1631
  await this.initialize();
@@ -1551,20 +1647,30 @@ var OfflineQueue = class {
1551
1647
  while (this.#operations.length > 0) {
1552
1648
  const operation = this.#operations[0];
1553
1649
  if (operation === void 0) break;
1554
- let result;
1650
+ let rawResult;
1555
1651
  try {
1556
- result = await sender(operation);
1652
+ rawResult = await sender(operation);
1557
1653
  } catch (cause) {
1558
- throw cause instanceof Error ? cause : new Error(String(cause));
1654
+ throw new Error(errorValue(cause, "RETRYABLE_ERROR").message);
1655
+ }
1656
+ const response = this.#normalizeResponse(rawResult);
1657
+ if (response.status === "RETRYABLE_ERROR") {
1658
+ const error2 = errorValue(response.error ?? response.reason, "RETRYABLE_ERROR");
1659
+ await this.#syncResultListener?.({ operation, status: response.status, error: error2 });
1660
+ throw new Error(error2.message);
1559
1661
  }
1560
- 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;
1662
+ const result = response.result;
1663
+ 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);
1664
+ const error = response.status === "REJECTED_PERMANENT" ? errorValue(response.error ?? response.reason, "REJECTED_PERMANENT") : void 0;
1665
+ this.#operations = this.#operations.slice(1);
1666
+ await this.#storage.save(this.#storageKey(), this.#operations);
1561
1667
  await this.#syncResultListener?.({
1562
1668
  operation,
1563
- result,
1669
+ ...result === void 0 ? {} : { result },
1670
+ status: response.status,
1671
+ ...error === void 0 ? {} : { error },
1564
1672
  ...state === void 0 ? {} : { state }
1565
1673
  });
1566
- this.#operations = this.#operations.slice(1);
1567
- await this.#storage.save(this.#key, this.#operations);
1568
1674
  }
1569
1675
  this.#state = "idle";
1570
1676
  } catch (cause) {
@@ -1573,6 +1679,25 @@ var OfflineQueue = class {
1573
1679
  throw this.#error;
1574
1680
  }
1575
1681
  }
1682
+ #storageKey() {
1683
+ if (this.#configuredKey !== void 0) return this.#configuredKey;
1684
+ return `stamprally:queue:${this.#rallyId ?? "unscoped"}:${this.#userId ?? "anonymous"}`;
1685
+ }
1686
+ #normalizeResponse(value) {
1687
+ if ("ok" in value) {
1688
+ if (value.ok === false) {
1689
+ if ("status" in value && value.status === "RETRYABLE_ERROR")
1690
+ return { status: "RETRYABLE_ERROR", error: errorValue(value.error, "RETRYABLE_ERROR") };
1691
+ return { status: "REJECTED_PERMANENT", result: value };
1692
+ }
1693
+ return { status: "ACCEPTED", result: value };
1694
+ }
1695
+ if ("status" in value) {
1696
+ if (value.status === "ACCEPTED") return value;
1697
+ return value;
1698
+ }
1699
+ return { status: "ACCEPTED", result: value };
1700
+ }
1576
1701
  async resolveConflict(operation, localState, serverState) {
1577
1702
  const configured = this.#onSyncConflict;
1578
1703
  const policy = (typeof configured === "function" ? await configured({ operation, localState, serverState }) : configured) ?? this.#conflictPolicy;
@@ -2486,9 +2611,11 @@ exports.createSignedSnapshotToken = createSignedSnapshotToken;
2486
2611
  exports.createUniqueClaimTicketNumber = createUniqueClaimTicketNumber;
2487
2612
  exports.evaluateCondition = evaluateCondition;
2488
2613
  exports.evaluateConditionDetailed = evaluateConditionDetailed;
2614
+ exports.evaluateSpotStatus = evaluateSpotStatus;
2489
2615
  exports.exportProgressToken = exportProgressToken;
2490
2616
  exports.getCurrentGeoContext = getCurrentGeoContext;
2491
2617
  exports.getOrderedSpots = getOrderedSpots;
2618
+ exports.getSpotStatus = getSpotStatus;
2492
2619
  exports.importProgressToken = importProgressToken;
2493
2620
  exports.isGeolocationSupported = isGeolocationSupported;
2494
2621
  exports.isNfcSupported = isNfcSupported;