@stamprally/core 0.11.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) {
@@ -99,6 +107,76 @@ function issueClaimTicketNumber(reward2, currentState, options = {}) {
99
107
  return { ...currentState, claimTicketNumber };
100
108
  }
101
109
 
110
+ // src/engine/sync.ts
111
+ function latestTimestamp(serverTimestamp, localTimestamp) {
112
+ const serverTime = Date.parse(serverTimestamp);
113
+ const localTime = Date.parse(localTimestamp);
114
+ if (!Number.isNaN(serverTime) && !Number.isNaN(localTime))
115
+ return serverTime >= localTime ? serverTimestamp : localTimestamp;
116
+ if (!Number.isNaN(serverTime)) return serverTimestamp;
117
+ if (!Number.isNaN(localTime)) return localTimestamp;
118
+ return serverTimestamp >= localTimestamp ? serverTimestamp : localTimestamp;
119
+ }
120
+ function mergeRewardStates(serverRewards, localRewards) {
121
+ const merged = new Map(serverRewards.map((reward2) => [reward2.rewardId, reward2]));
122
+ for (const localReward of localRewards) {
123
+ const serverReward = merged.get(localReward.rewardId);
124
+ if (serverReward === void 0) {
125
+ merged.set(localReward.rewardId, localReward);
126
+ continue;
127
+ }
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
+ });
150
+ }
151
+ return [...merged.values()];
152
+ }
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;
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
+ });
167
+ }
168
+ return [...merged.values()];
169
+ }
170
+ function resolveRallyStateConflict(serverState, localState, options = { policy: "merge" }) {
171
+ if (options.policy === "server_wins") return serverState;
172
+ return {
173
+ ...serverState,
174
+ records: mergeStampRecords(serverState.records, localState.records),
175
+ rewards: mergeRewardStates(serverState.rewards, localState.rewards),
176
+ updatedAt: latestTimestamp(serverState.updatedAt, localState.updatedAt)
177
+ };
178
+ }
179
+
102
180
  // src/detectors/types.ts
103
181
  function createDetectorError(detector, code, message, cause) {
104
182
  return cause === void 0 ? { detector, code, message } : { detector, code, message, cause };
@@ -1000,6 +1078,7 @@ var StampRallyClient = class {
1000
1078
  this.#storage = this.#options.storage ?? new InMemoryStorage();
1001
1079
  this.#userId = this.#options.userId ?? null;
1002
1080
  this.#offlineQueue = this.#options.offlineQueue;
1081
+ this.#offlineQueue?.setSyncResultListener((event) => this.#handleOfflineSyncResult(event));
1003
1082
  }
1004
1083
  getConfig() {
1005
1084
  return this.#config;
@@ -1030,7 +1109,10 @@ var StampRallyClient = class {
1030
1109
  initialize() {
1031
1110
  if (this.#state !== null) return Promise.resolve(this.#state);
1032
1111
  if (this.#initialization === null) {
1033
- 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) => {
1034
1116
  const next = state === null ? emptyState(this.#config, this.#userId, this.#now()) : this.#reconcile(state);
1035
1117
  this.#state = next;
1036
1118
  this.#emit(next);
@@ -1048,6 +1130,7 @@ var StampRallyClient = class {
1048
1130
  this.#userId = newUserId;
1049
1131
  this.#state = null;
1050
1132
  this.#initialization = null;
1133
+ await this.#offlineQueue?.switchUser(newUserId);
1051
1134
  return this.initialize();
1052
1135
  });
1053
1136
  }
@@ -1217,12 +1300,19 @@ var StampRallyClient = class {
1217
1300
  });
1218
1301
  }
1219
1302
  if (adapter?.sync === void 0) {
1220
- this.#emitEvent({ type: "sync", state: current });
1303
+ this.#emitEvent({ type: "sync", state: this.#state ?? current });
1221
1304
  return;
1222
1305
  }
1223
- const next = this.#reconcile(
1224
- await adapter.sync({ rallyId: this.#config.id, userId: this.#userId, state: current })
1225
- );
1306
+ const serverState = await adapter.sync({
1307
+ rallyId: this.#config.id,
1308
+ userId: this.#userId,
1309
+ state: this.#state ?? current
1310
+ });
1311
+ const localState = this.#state ?? current;
1312
+ const merged = this.#offlineQueue === void 0 ? serverState : resolveRallyStateConflict(serverState, localState, {
1313
+ policy: this.#offlineQueue.conflictPolicy
1314
+ });
1315
+ const next = this.#reconcile(merged);
1226
1316
  await this.#storage.save(next);
1227
1317
  this.#state = next;
1228
1318
  this.#emit(next);
@@ -1282,6 +1372,17 @@ var StampRallyClient = class {
1282
1372
  )
1283
1373
  };
1284
1374
  }
1375
+ async #handleOfflineSyncResult(event) {
1376
+ if (event.state !== void 0) {
1377
+ const next = this.#reconcile(event.state);
1378
+ await this.#storage.save(next);
1379
+ this.#state = next;
1380
+ this.#emit(next);
1381
+ }
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)
1384
+ this.#emitEvent({ type: "error", error: event.result.error });
1385
+ }
1285
1386
  #now() {
1286
1387
  return this.#options.clock?.() ?? (/* @__PURE__ */ new Date()).toISOString();
1287
1388
  }
@@ -1407,9 +1508,27 @@ function defaultStorage(databaseName) {
1407
1508
  function operationId(operation) {
1408
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}`;
1409
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
+ }
1410
1527
  var OfflineQueue = class {
1411
1528
  #storage;
1412
- #key;
1529
+ #configuredKey;
1530
+ #rallyId;
1531
+ #userId;
1413
1532
  #conflictPolicy;
1414
1533
  #onSyncConflict;
1415
1534
  #operations = [];
@@ -1418,12 +1537,15 @@ var OfflineQueue = class {
1418
1537
  #error = null;
1419
1538
  #sender;
1420
1539
  #syncPromise = null;
1540
+ #syncResultListener;
1421
1541
  constructor(options = {}) {
1422
1542
  if (options.storage !== void 0) this.#storage = options.storage;
1423
1543
  else if (options.storageLike !== void 0 && options.storageLike !== null)
1424
1544
  this.#storage = new LocalStorageQueueStorage(options.storageLike);
1425
1545
  else this.#storage = defaultStorage(options.databaseName);
1426
- this.#key = options.key ?? "stamprally:offline-queue";
1546
+ this.#configuredKey = options.key;
1547
+ this.#rallyId = options.rallyId;
1548
+ this.#userId = options.userId ?? null;
1427
1549
  this.#conflictPolicy = options.conflictPolicy ?? "server_wins";
1428
1550
  this.#onSyncConflict = options.onSyncConflict;
1429
1551
  }
@@ -1439,20 +1561,60 @@ var OfflineQueue = class {
1439
1561
  get operations() {
1440
1562
  return this.#operations;
1441
1563
  }
1564
+ get conflictPolicy() {
1565
+ return this.#conflictPolicy;
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
+ }
1576
+ setSyncResultListener(listener) {
1577
+ this.#syncResultListener = listener;
1578
+ }
1442
1579
  async initialize() {
1443
1580
  if (this.#loaded) return;
1444
- this.#operations = [...await this.#storage.load(this.#key)];
1581
+ this.#operations = [...await this.#storage.load(this.#storageKey())];
1445
1582
  this.#loaded = true;
1446
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
+ }
1447
1603
  setSender(sender) {
1448
1604
  this.#sender = sender;
1449
1605
  }
1450
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
+ }
1451
1613
  await this.initialize();
1452
1614
  const id2 = operationId(operation);
1453
1615
  if (this.#operations.some((item) => operationId(item) === id2)) return;
1454
1616
  this.#operations = [...this.#operations, operation];
1455
- await this.#storage.save(this.#key, this.#operations);
1617
+ await this.#storage.save(this.#storageKey(), this.#operations);
1456
1618
  }
1457
1619
  async enqueueCheckIn(request) {
1458
1620
  return this.enqueue({ kind: "checkIn", request });
@@ -1463,7 +1625,7 @@ var OfflineQueue = class {
1463
1625
  async clear() {
1464
1626
  await this.initialize();
1465
1627
  this.#operations = [];
1466
- await this.#storage.save(this.#key, this.#operations);
1628
+ await this.#storage.save(this.#storageKey(), this.#operations);
1467
1629
  }
1468
1630
  async sync(sender = this.#sender) {
1469
1631
  await this.initialize();
@@ -1485,16 +1647,30 @@ var OfflineQueue = class {
1485
1647
  while (this.#operations.length > 0) {
1486
1648
  const operation = this.#operations[0];
1487
1649
  if (operation === void 0) break;
1488
- let result;
1650
+ let rawResult;
1489
1651
  try {
1490
- result = await sender(operation);
1652
+ rawResult = await sender(operation);
1491
1653
  } catch (cause) {
1492
- throw cause instanceof Error ? cause : new Error(String(cause));
1654
+ throw new Error(errorValue(cause, "RETRYABLE_ERROR").message);
1493
1655
  }
1494
- if ("conflict" in result && result.conflict === true)
1495
- await this.resolveConflict(operation, result.localState, result.serverState);
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);
1661
+ }
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;
1496
1665
  this.#operations = this.#operations.slice(1);
1497
- await this.#storage.save(this.#key, this.#operations);
1666
+ await this.#storage.save(this.#storageKey(), this.#operations);
1667
+ await this.#syncResultListener?.({
1668
+ operation,
1669
+ ...result === void 0 ? {} : { result },
1670
+ status: response.status,
1671
+ ...error === void 0 ? {} : { error },
1672
+ ...state === void 0 ? {} : { state }
1673
+ });
1498
1674
  }
1499
1675
  this.#state = "idle";
1500
1676
  } catch (cause) {
@@ -1503,12 +1679,29 @@ var OfflineQueue = class {
1503
1679
  throw this.#error;
1504
1680
  }
1505
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
+ }
1506
1701
  async resolveConflict(operation, localState, serverState) {
1507
1702
  const configured = this.#onSyncConflict;
1508
1703
  const policy = (typeof configured === "function" ? await configured({ operation, localState, serverState }) : configured) ?? this.#conflictPolicy;
1509
- if (policy === "merge") {
1510
- return;
1511
- }
1704
+ return resolveRallyStateConflict(serverState, localState, { policy });
1512
1705
  }
1513
1706
  };
1514
1707
 
@@ -2418,9 +2611,11 @@ exports.createSignedSnapshotToken = createSignedSnapshotToken;
2418
2611
  exports.createUniqueClaimTicketNumber = createUniqueClaimTicketNumber;
2419
2612
  exports.evaluateCondition = evaluateCondition;
2420
2613
  exports.evaluateConditionDetailed = evaluateConditionDetailed;
2614
+ exports.evaluateSpotStatus = evaluateSpotStatus;
2421
2615
  exports.exportProgressToken = exportProgressToken;
2422
2616
  exports.getCurrentGeoContext = getCurrentGeoContext;
2423
2617
  exports.getOrderedSpots = getOrderedSpots;
2618
+ exports.getSpotStatus = getSpotStatus;
2424
2619
  exports.importProgressToken = importProgressToken;
2425
2620
  exports.isGeolocationSupported = isGeolocationSupported;
2426
2621
  exports.isNfcSupported = isNfcSupported;
@@ -2437,6 +2632,7 @@ exports.readNfcContext = readNfcContext;
2437
2632
  exports.readQrContext = readQrContext;
2438
2633
  exports.reconcileRewardStates = reconcileRewardStates;
2439
2634
  exports.resolveLocalizedText = resolveLocalizedText;
2635
+ exports.resolveRallyStateConflict = resolveRallyStateConflict;
2440
2636
  exports.safeParseAdminConfig = safeParseAdminConfig;
2441
2637
  exports.safeParsePublicConfig = safeParsePublicConfig;
2442
2638
  exports.sanitizeAdminConfig = sanitizeAdminConfig;