@stamprally/core 0.17.0 → 0.18.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.17.0
1
+ # @stamprally/core v0.18.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.17.0",
10
+ version: "0.18.0",
11
11
  title: "City Tour",
12
12
  spots: [{ id: "station", orderIndex: 0, name: "Central Station", conditions: [{ type: "passcode" }] }],
13
13
  rewards: [],
package/dist/index.cjs CHANGED
@@ -110,78 +110,20 @@ function issueClaimTicketNumber(reward2, currentState, options = {}) {
110
110
  }
111
111
 
112
112
  // src/engine/sync.ts
113
- function latestTimestamp(serverTimestamp, localTimestamp) {
114
- const serverTime = Date.parse(serverTimestamp);
115
- const localTime = Date.parse(localTimestamp);
116
- if (!Number.isNaN(serverTime) && !Number.isNaN(localTime))
117
- return serverTime >= localTime ? serverTimestamp : localTimestamp;
118
- if (!Number.isNaN(serverTime)) return serverTimestamp;
119
- if (!Number.isNaN(localTime)) return localTimestamp;
120
- return serverTimestamp >= localTimestamp ? serverTimestamp : localTimestamp;
121
- }
122
- function mergeRewardStates(serverRewards, localRewards) {
123
- const merged = new Map(serverRewards.map((reward2) => [reward2.rewardId, reward2]));
124
- for (const localReward of localRewards) {
125
- const serverReward = merged.get(localReward.rewardId);
126
- if (serverReward === void 0) {
127
- merged.set(localReward.rewardId, localReward);
128
- continue;
129
- }
130
- const winner = localReward.status === "CONSUMED" && serverReward.status !== "CONSUMED" ? localReward : serverReward;
131
- merged.set(localReward.rewardId, {
132
- ...winner,
133
- ...serverReward.unlockedAt === void 0 && localReward.unlockedAt === void 0 ? {} : {
134
- unlockedAt: serverReward.unlockedAt === void 0 ? localReward.unlockedAt : localReward.unlockedAt === void 0 ? serverReward.unlockedAt : latestTimestamp(serverReward.unlockedAt, localReward.unlockedAt)
135
- },
136
- ...serverReward.consumedAt === void 0 && localReward.consumedAt === void 0 ? {} : {
137
- consumedAt: serverReward.consumedAt === void 0 ? localReward.consumedAt : localReward.consumedAt === void 0 ? serverReward.consumedAt : latestTimestamp(serverReward.consumedAt, localReward.consumedAt)
138
- },
139
- ...serverReward.redeemedCount === void 0 && localReward.redeemedCount === void 0 ? {} : {
140
- redeemedCount: Math.max(
141
- serverReward.redeemedCount ?? 0,
142
- localReward.redeemedCount ?? 0
143
- )
144
- },
145
- ...serverReward.userRedemptionCount === void 0 && localReward.userRedemptionCount === void 0 ? {} : {
146
- userRedemptionCount: Math.max(
147
- serverReward.userRedemptionCount ?? 0,
148
- localReward.userRedemptionCount ?? 0
149
- )
150
- }
151
- });
152
- }
153
- return [...merged.values()];
154
- }
155
- function mergeStampRecords(serverRecords, localRecords) {
156
- const merged = /* @__PURE__ */ new Map();
157
- for (const record of [...serverRecords, ...localRecords]) {
158
- const current = merged.get(record.stampId);
159
- if (current === void 0) {
160
- merged.set(record.stampId, record);
161
- continue;
162
- }
163
- const acquiredAt = latestTimestamp(current.acquiredAt, record.acquiredAt);
164
- merged.set(record.stampId, {
165
- ...current,
166
- ...acquiredAt === current.acquiredAt ? {} : { acquiredAt },
167
- ...current.metadata === void 0 && record.metadata !== void 0 ? { metadata: record.metadata } : {}
168
- });
169
- }
170
- return [...merged.values()];
171
- }
172
- function resolveRallyStateConflict(serverState, localState, options = { policy: "merge" }) {
173
- if (options.policy === "server_wins") return serverState;
174
- if (options.policy === "authoritative_replay")
175
- return {
176
- ...serverState,
177
- records: serverState.records.map((record) => ({ ...record })),
178
- rewards: serverState.rewards.map((reward2) => ({ ...reward2 }))
179
- };
113
+ function resolveRallyStateConflict(serverState, _localState) {
180
114
  return {
181
115
  ...serverState,
182
- records: mergeStampRecords(serverState.records, localState.records),
183
- rewards: mergeRewardStates(serverState.rewards, localState.rewards),
184
- updatedAt: latestTimestamp(serverState.updatedAt, localState.updatedAt)
116
+ records: serverState.records.map((record) => ({
117
+ ...record,
118
+ ...record.metadata === void 0 ? {} : { metadata: { ...record.metadata } }
119
+ })),
120
+ rewards: serverState.rewards.map((reward2) => ({ ...reward2 })),
121
+ ...serverState.inventory === void 0 ? {} : {
122
+ inventory: {
123
+ ...serverState.inventory,
124
+ ...serverState.inventory.rewardRemaining === void 0 ? {} : { rewardRemaining: { ...serverState.inventory.rewardRemaining } }
125
+ }
126
+ }
185
127
  };
186
128
  }
187
129
 
@@ -1274,8 +1216,6 @@ var OfflineQueue = class {
1274
1216
  #configuredKey;
1275
1217
  #rallyId;
1276
1218
  #userId;
1277
- #conflictPolicy;
1278
- #onSyncConflict;
1279
1219
  #operations = [];
1280
1220
  #rejectedHistory = [];
1281
1221
  #loaded = false;
@@ -1291,6 +1231,8 @@ var OfflineQueue = class {
1291
1231
  #lockStorage;
1292
1232
  #observedLocks = /* @__PURE__ */ new Map();
1293
1233
  #warnedMemoryLock = false;
1234
+ #capabilityWarningListener;
1235
+ #replayConfig;
1294
1236
  #storageListener;
1295
1237
  #channel = null;
1296
1238
  constructor(options = {}) {
@@ -1308,8 +1250,6 @@ var OfflineQueue = class {
1308
1250
  this.#configuredKey = options.key;
1309
1251
  this.#rallyId = options.rallyId;
1310
1252
  this.#userId = options.userId ?? null;
1311
- this.#conflictPolicy = options.conflictPolicy ?? "authoritative_replay";
1312
- this.#onSyncConflict = options.onSyncConflict;
1313
1253
  this.#synchronizeInstances = options.synchronizeInstances ?? true;
1314
1254
  const retryOptions = {
1315
1255
  ...DEFAULT_RETRY_OPTIONS,
@@ -1332,6 +1272,14 @@ var OfflineQueue = class {
1332
1272
  get queueCapability() {
1333
1273
  return this.#queueCapability;
1334
1274
  }
1275
+ get storageCapability() {
1276
+ 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";
1279
+ }
1280
+ get isStoragePersistent() {
1281
+ return this.#queueCapability !== "memory";
1282
+ }
1335
1283
  get rejectedHistory() {
1336
1284
  return this.#rejectedHistory;
1337
1285
  }
@@ -1341,9 +1289,6 @@ var OfflineQueue = class {
1341
1289
  get operations() {
1342
1290
  return this.#operations;
1343
1291
  }
1344
- get conflictPolicy() {
1345
- return this.#conflictPolicy;
1346
- }
1347
1292
  get storageKey() {
1348
1293
  return this.#storageKey();
1349
1294
  }
@@ -1359,6 +1304,12 @@ var OfflineQueue = class {
1359
1304
  setChangeListener(listener) {
1360
1305
  this.#changeListener = listener;
1361
1306
  }
1307
+ setCapabilityWarningListener(listener) {
1308
+ this.#capabilityWarningListener = listener;
1309
+ }
1310
+ setReplayConfig(config) {
1311
+ this.#replayConfig = config;
1312
+ }
1362
1313
  async initialize() {
1363
1314
  if (this.#loaded) return;
1364
1315
  try {
@@ -1375,6 +1326,8 @@ var OfflineQueue = class {
1375
1326
  `Offline queue persistence is unavailable; queued data can be lost after reload (${String(error)}).`
1376
1327
  );
1377
1328
  }
1329
+ if (this.#queueCapability === "memory")
1330
+ this.#warnMemoryLock("Offline queue persistence is unavailable; queued data is memory-only.");
1378
1331
  this.#loaded = true;
1379
1332
  }
1380
1333
  /** Releases browser listeners when the queue is no longer used. */
@@ -1423,11 +1376,19 @@ var OfflineQueue = class {
1423
1376
  await this.#storage.save(this.#storageKey(), this.#operations);
1424
1377
  this.#announceChange();
1425
1378
  }
1426
- async enqueueCheckIn(request) {
1427
- return this.enqueue({ kind: "checkIn", request });
1379
+ async enqueueCheckIn(request, optimisticState) {
1380
+ return this.enqueue({
1381
+ kind: "checkIn",
1382
+ request,
1383
+ ...optimisticState === void 0 ? {} : { optimisticState }
1384
+ });
1428
1385
  }
1429
- async enqueueClaimReward(request) {
1430
- return this.enqueue({ kind: "claimReward", request });
1386
+ async enqueueClaimReward(request, optimisticState) {
1387
+ return this.enqueue({
1388
+ kind: "claimReward",
1389
+ request,
1390
+ ...optimisticState === void 0 ? {} : { optimisticState }
1391
+ });
1431
1392
  }
1432
1393
  async clear() {
1433
1394
  await this.initialize();
@@ -1435,10 +1396,10 @@ var OfflineQueue = class {
1435
1396
  await this.#storage.save(this.#storageKey(), this.#operations);
1436
1397
  this.#announceChange();
1437
1398
  }
1438
- async discardRejected(operationId) {
1399
+ async discardRejected(operationId2) {
1439
1400
  await this.initialize();
1440
1401
  const next = this.#rejectedHistory.filter(
1441
- (entry) => offlineOperationId(entry.operation) !== operationId
1402
+ (entry) => offlineOperationId(entry.operation) !== operationId2
1442
1403
  );
1443
1404
  if (next.length === this.#rejectedHistory.length) return false;
1444
1405
  this.#rejectedHistory = next;
@@ -1446,13 +1407,13 @@ var OfflineQueue = class {
1446
1407
  this.#announceChange();
1447
1408
  return true;
1448
1409
  }
1449
- async retryRejected(operationId) {
1410
+ async retryRejected(operationId2) {
1450
1411
  await this.initialize();
1451
1412
  const entry = this.#rejectedHistory.find(
1452
- (candidate) => offlineOperationId(candidate.operation) === operationId
1413
+ (candidate) => offlineOperationId(candidate.operation) === operationId2
1453
1414
  );
1454
1415
  if (entry === void 0) return false;
1455
- if (!this.#operations.some((operation) => offlineOperationId(operation) === operationId))
1416
+ if (!this.#operations.some((operation) => offlineOperationId(operation) === operationId2))
1456
1417
  this.#operations = [
1457
1418
  ...this.#operations,
1458
1419
  { ...entry.operation, status: "PENDING", attempts: 0 }
@@ -1463,11 +1424,11 @@ var OfflineQueue = class {
1463
1424
  this.#announceChange();
1464
1425
  return true;
1465
1426
  }
1466
- async discardRejectedOperation(operationId) {
1467
- return this.discardRejected(operationId);
1427
+ async discardRejectedOperation(operationId2) {
1428
+ return this.discardRejected(operationId2);
1468
1429
  }
1469
- async retryRejectedOperation(operationId) {
1470
- return this.retryRejected(operationId);
1430
+ async retryRejectedOperation(operationId2) {
1431
+ return this.retryRejected(operationId2);
1471
1432
  }
1472
1433
  async clearRejectedHistory() {
1473
1434
  await this.initialize();
@@ -1515,7 +1476,7 @@ var OfflineQueue = class {
1515
1476
  if (callbackStarted) throw error;
1516
1477
  }
1517
1478
  }
1518
- if (this.#lockStorage === null)
1479
+ if (this.storageCapability === "volatile_single_tab")
1519
1480
  this.#warnMemoryLock(
1520
1481
  "No cross-tab storage lock is available; offline sync is single-tab only."
1521
1482
  );
@@ -1535,6 +1496,7 @@ var OfflineQueue = class {
1535
1496
  while (this.#operations.length > 0) {
1536
1497
  const operation = this.#operations[0];
1537
1498
  if (operation === void 0) break;
1499
+ if (await this.#rejectFailedPrerequisite(operation)) continue;
1538
1500
  let attempt = 0;
1539
1501
  let response;
1540
1502
  while (true) {
@@ -1612,6 +1574,44 @@ var OfflineQueue = class {
1612
1574
  await this.#storage.save(this.#storageKey(), this.#operations);
1613
1575
  this.#announceChange();
1614
1576
  }
1577
+ async #rejectFailedPrerequisite(operation) {
1578
+ if (operation.kind !== "checkIn" || this.#replayConfig === void 0) return false;
1579
+ const spot2 = this.#replayConfig.spots.find(
1580
+ (candidate) => candidate.id === operation.request.spotId
1581
+ );
1582
+ if (spot2 === void 0) return false;
1583
+ const failedSpots = new Set(
1584
+ this.#rejectedHistory.filter((entry) => entry.operation.kind === "checkIn").map(
1585
+ (entry) => entry.operation.kind === "checkIn" ? entry.operation.request.spotId : void 0
1586
+ ).filter((spotId) => spotId !== void 0)
1587
+ );
1588
+ if (!spot2.prerequisites?.some((prerequisite) => failedSpots.has(prerequisite))) return false;
1589
+ const error = {
1590
+ code: "REJECTED_PREREQUISITE_FAILED",
1591
+ message: "A prerequisite operation was rejected by the server."
1592
+ };
1593
+ const rejectedOperation = { ...operation, status: "REJECTED_PERMANENT" };
1594
+ this.#rejectedHistory = [
1595
+ ...this.#rejectedHistory,
1596
+ {
1597
+ operation: rejectedOperation,
1598
+ reason: error,
1599
+ errorCode: error.code,
1600
+ rejectedAt: (/* @__PURE__ */ new Date()).toISOString(),
1601
+ attempts: operation.attempts ?? 0
1602
+ }
1603
+ ];
1604
+ this.#operations = this.#operations.slice(1);
1605
+ await this.#storage.save(this.#storageKey(), this.#operations);
1606
+ await this.#saveRejectedHistory();
1607
+ this.#announceChange();
1608
+ await this.#syncResultListener?.({
1609
+ operation,
1610
+ status: "REJECTED_PERMANENT",
1611
+ error
1612
+ });
1613
+ return true;
1614
+ }
1615
1615
  #subscribeToExternalChanges() {
1616
1616
  const windowLike = globalThis.window;
1617
1617
  if (windowLike !== void 0) {
@@ -1736,11 +1736,6 @@ var OfflineQueue = class {
1736
1736
  }
1737
1737
  return { status: "ACCEPTED", result: value };
1738
1738
  }
1739
- async resolveConflict(operation, localState, serverState) {
1740
- const configured = this.#onSyncConflict;
1741
- const policy = (typeof configured === "function" ? await configured({ operation, localState, serverState }) : configured) ?? this.#conflictPolicy;
1742
- return resolveRallyStateConflict(serverState, localState, { policy });
1743
- }
1744
1739
  async #saveRejectedHistory() {
1745
1740
  await this.#storage.saveRejectedHistory?.(this.#storageKey(), this.#rejectedHistory);
1746
1741
  }
@@ -1748,6 +1743,12 @@ var OfflineQueue = class {
1748
1743
  if (this.#warnedMemoryLock) return;
1749
1744
  this.#warnedMemoryLock = true;
1750
1745
  console.warn(`[@stamprally/core] ${message}`);
1746
+ this.#capabilityWarningListener?.({
1747
+ type: "STORAGE_CAPABILITY_WARNING",
1748
+ storageCapability: this.storageCapability === "memory" ? "memory" : "volatile_single_tab",
1749
+ isStoragePersistent: this.isStoragePersistent,
1750
+ message
1751
+ });
1751
1752
  }
1752
1753
  };
1753
1754
  function normalizeOperation(operation) {
@@ -1769,6 +1770,100 @@ function normalizeRejectedHistory(entry) {
1769
1770
  };
1770
1771
  }
1771
1772
 
1773
+ // src/client/sync.ts
1774
+ function isReplayable(operation) {
1775
+ return operation.status === void 0 || operation.status === "ACCEPTED" || operation.status === "PENDING" || operation.status === "IN_FLIGHT" || operation.status === "FAILED_RETRYABLE";
1776
+ }
1777
+ function operationId(operation) {
1778
+ const identity = operation.request.userId ?? operation.request.anonymousSessionId ?? "anonymous";
1779
+ return `${operation.kind}:${operation.request.rallyId}:${identity}:${operation.request.idempotencyKey}`;
1780
+ }
1781
+ function applyInventoryDelta(state, previous, optimistic) {
1782
+ const previousInventory = previous.inventory;
1783
+ const optimisticInventory = optimistic.inventory;
1784
+ if (previousInventory === void 0 || optimisticInventory === void 0) return state;
1785
+ const currentInventory = state.inventory ?? {};
1786
+ const sharedDelta = previousInventory.sharedRemaining === void 0 || optimisticInventory.sharedRemaining === void 0 ? void 0 : optimisticInventory.sharedRemaining - previousInventory.sharedRemaining;
1787
+ const previousRewards = previousInventory.rewardRemaining ?? {};
1788
+ const optimisticRewards = optimisticInventory.rewardRemaining ?? {};
1789
+ const currentRewards = currentInventory.rewardRemaining ?? {};
1790
+ const rewardRemaining = { ...currentRewards };
1791
+ for (const key of /* @__PURE__ */ new Set([...Object.keys(previousRewards), ...Object.keys(optimisticRewards)])) {
1792
+ const before = previousRewards[key];
1793
+ const after = optimisticRewards[key];
1794
+ if (before !== void 0 && after !== void 0)
1795
+ rewardRemaining[key] = Math.max(0, (currentRewards[key] ?? before) + after - before);
1796
+ }
1797
+ return {
1798
+ ...state,
1799
+ inventory: {
1800
+ ...currentInventory.sharedRemaining === void 0 || sharedDelta === void 0 ? {} : { sharedRemaining: Math.max(0, currentInventory.sharedRemaining + sharedDelta) },
1801
+ ...Object.keys(rewardRemaining).length === 0 ? {} : { rewardRemaining }
1802
+ }
1803
+ };
1804
+ }
1805
+ function applyOperation(state, operation, config) {
1806
+ if (operation.kind === "checkIn") {
1807
+ const spot2 = config?.spots.find((candidate) => candidate.id === operation.request.spotId);
1808
+ if (spot2?.prerequisites?.some((id2) => !state.records.some((record2) => record2.stampId === id2)))
1809
+ return { state, prerequisiteFailed: true };
1810
+ if (state.records.some((record2) => record2.stampId === operation.request.spotId))
1811
+ return { state, prerequisiteFailed: false };
1812
+ const optimisticRecord = operation.optimisticState?.records.find(
1813
+ (record2) => record2.stampId === operation.request.spotId
1814
+ );
1815
+ const record = optimisticRecord ?? {
1816
+ stampId: operation.request.spotId,
1817
+ acquiredAt: operation.request.now
1818
+ };
1819
+ const records = [...state.records, { ...record }];
1820
+ const rewards2 = config === void 0 ? state.rewards : reconcileRewardStates(config.rewards, state.rewards, records.length, record.acquiredAt);
1821
+ return {
1822
+ state: { ...state, records, rewards: rewards2, updatedAt: record.acquiredAt },
1823
+ prerequisiteFailed: false
1824
+ };
1825
+ }
1826
+ const optimisticState = operation.optimisticState;
1827
+ if (optimisticState === void 0) return { state, prerequisiteFailed: false };
1828
+ const optimisticReward = optimisticState?.rewards.find(
1829
+ (reward2) => reward2.rewardId === operation.request.rewardId
1830
+ );
1831
+ if (optimisticReward === void 0) return { state, prerequisiteFailed: false };
1832
+ const rewards = state.rewards.some((reward2) => reward2.rewardId === optimisticReward.rewardId) ? state.rewards.map(
1833
+ (reward2) => reward2.rewardId === optimisticReward.rewardId ? { ...optimisticReward } : reward2
1834
+ ) : [...state.rewards, { ...optimisticReward }];
1835
+ return {
1836
+ state: applyInventoryDelta(
1837
+ { ...state, rewards, updatedAt: optimisticReward.consumedAt ?? state.updatedAt },
1838
+ operation.request.state,
1839
+ optimisticState
1840
+ ),
1841
+ prerequisiteFailed: false
1842
+ };
1843
+ }
1844
+ function rebuildUserStateFromLog(baselineOrOptions, operationsArgument, configArgument) {
1845
+ const { state } = rebuildUserStateLog(
1846
+ "baseline" in baselineOrOptions ? baselineOrOptions.baseline : baselineOrOptions,
1847
+ "baseline" in baselineOrOptions ? baselineOrOptions.operations : operationsArgument ?? [],
1848
+ "baseline" in baselineOrOptions ? baselineOrOptions.config : configArgument
1849
+ );
1850
+ return state;
1851
+ }
1852
+ function rebuildUserStateLog(baseline, operations, config) {
1853
+ let state = cloneState(baseline);
1854
+ const rejectedOperationIds = [];
1855
+ for (const operation of operations) {
1856
+ if (!isReplayable(operation)) continue;
1857
+ const replay = applyOperation(state, operation, config);
1858
+ if (replay.prerequisiteFailed) {
1859
+ rejectedOperationIds.push(operationId(operation));
1860
+ continue;
1861
+ }
1862
+ state = replay.state;
1863
+ }
1864
+ return { state, rejectedOperationIds };
1865
+ }
1866
+
1772
1867
  // src/client/client.ts
1773
1868
  function isStorage(value) {
1774
1869
  return "load" in value && "save" in value && "remove" in value;
@@ -1834,6 +1929,7 @@ var StampRallyClient = class {
1834
1929
  this.#anonymousSessionId = this.#options.anonymousSessionId ?? createAnonymousSessionId();
1835
1930
  this.#userId = this.#options.userId ?? this.#anonymousSessionId;
1836
1931
  this.#offlineQueue = this.#options.offlineQueue;
1932
+ this.#offlineQueue?.setReplayConfig(config);
1837
1933
  this.#offlineQueue?.setSyncResultListener((event) => this.#handleOfflineSyncResult(event));
1838
1934
  this.#offlineQueue?.setChangeListener(() => {
1839
1935
  this.#syncRevision += 1;
@@ -1867,17 +1963,23 @@ var StampRallyClient = class {
1867
1963
  get queueCapability() {
1868
1964
  return this.#offlineQueue?.queueCapability ?? "custom";
1869
1965
  }
1870
- discardRejected(operationId) {
1871
- return this.#offlineQueue?.discardRejected(operationId) ?? Promise.resolve(false);
1966
+ get storageCapability() {
1967
+ return this.#offlineQueue?.storageCapability ?? "custom";
1968
+ }
1969
+ get isStoragePersistent() {
1970
+ return this.#offlineQueue?.isStoragePersistent ?? true;
1971
+ }
1972
+ discardRejected(operationId2) {
1973
+ return this.#offlineQueue?.discardRejected(operationId2) ?? Promise.resolve(false);
1872
1974
  }
1873
- retryRejected(operationId) {
1874
- return this.#offlineQueue?.retryRejected(operationId) ?? Promise.resolve(false);
1975
+ retryRejected(operationId2) {
1976
+ return this.#offlineQueue?.retryRejected(operationId2) ?? Promise.resolve(false);
1875
1977
  }
1876
- dismissRejectedOperation(operationId) {
1877
- return this.discardRejected(operationId);
1978
+ dismissRejectedOperation(operationId2) {
1979
+ return this.discardRejected(operationId2);
1878
1980
  }
1879
- retryOperation(operationId) {
1880
- return this.retryRejected(operationId);
1981
+ retryOperation(operationId2) {
1982
+ return this.retryRejected(operationId2);
1881
1983
  }
1882
1984
  subscribe(listener) {
1883
1985
  this.#listeners.add(listener);
@@ -2002,13 +2104,13 @@ var StampRallyClient = class {
2002
2104
  return result.ok ? this.#commitCheckIn(result) : this.#fail(result.error);
2003
2105
  } catch (error) {
2004
2106
  if (this.#offlineQueue === void 0) throw error;
2005
- await this.#offlineQueue.enqueueCheckIn(request);
2006
2107
  const record2 = { stampId: spotId, acquiredAt: now };
2007
2108
  const next2 = this.#reconcile({
2008
2109
  ...current,
2009
2110
  records: [...current.records, record2],
2010
2111
  updatedAt: now
2011
2112
  });
2113
+ await this.#offlineQueue.enqueueCheckIn(request, next2);
2012
2114
  await this.#storage.save(next2);
2013
2115
  return this.#commitCheckIn({ ok: true, value: { state: next2, record: record2 } });
2014
2116
  }
@@ -2059,7 +2161,6 @@ var StampRallyClient = class {
2059
2161
  return result.ok ? this.#commitClaim(result) : this.#fail(result.error);
2060
2162
  } catch (error) {
2061
2163
  if (this.#offlineQueue === void 0) throw error;
2062
- await this.#offlineQueue.enqueueClaimReward(request);
2063
2164
  const next2 = {
2064
2165
  ...current,
2065
2166
  rewards: current.rewards.map(
@@ -2067,6 +2168,7 @@ var StampRallyClient = class {
2067
2168
  ),
2068
2169
  updatedAt: now
2069
2170
  };
2171
+ await this.#offlineQueue.enqueueClaimReward(request, next2);
2070
2172
  await this.#storage.save(next2);
2071
2173
  return this.#commitClaim({ ok: true, value: { state: next2, reward: local.value } });
2072
2174
  }
@@ -2109,9 +2211,11 @@ var StampRallyClient = class {
2109
2211
  state: localState,
2110
2212
  ...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
2111
2213
  });
2112
- const resolved = resolveRallyStateConflict(serverState, localState, {
2113
- policy: this.#options.conflictResolutionPolicy ?? this.#options.conflictPolicy ?? "authoritative_replay"
2114
- });
2214
+ const resolved = rebuildUserStateFromLog(
2215
+ serverState,
2216
+ this.#offlineQueue?.operations ?? [],
2217
+ this.#config
2218
+ );
2115
2219
  const next = this.#reconcile(resolved);
2116
2220
  await this.#storage.save(next);
2117
2221
  this.#state = next;
@@ -2185,7 +2289,12 @@ var StampRallyClient = class {
2185
2289
  if (event.status === "ACCEPTED") {
2186
2290
  if (this.#syncMetrics !== null) this.#syncMetrics.processed += 1;
2187
2291
  if (event.state !== void 0) {
2188
- const next = this.#reconcile(event.state);
2292
+ const rebuilt = rebuildUserStateFromLog(
2293
+ event.state,
2294
+ this.#offlineQueue?.operations ?? [],
2295
+ this.#config
2296
+ );
2297
+ const next = this.#reconcile(rebuilt);
2189
2298
  await this.#storage.save(next);
2190
2299
  this.#state = next;
2191
2300
  this.#emit(next);
@@ -2205,7 +2314,13 @@ var StampRallyClient = class {
2205
2314
  this.#syncMetrics.failed += 1;
2206
2315
  }
2207
2316
  const base = event.state ?? this.#state ?? event.operation.request.state;
2208
- const next = this.#reconcile(rollbackOptimisticOperation(base, event.operation));
2317
+ const rollbackBase = event.state === void 0 ? rollbackOptimisticOperation(base, event.operation) : base;
2318
+ const rebuilt = rebuildUserStateFromLog(
2319
+ rollbackBase,
2320
+ this.#offlineQueue?.operations ?? [],
2321
+ this.#config
2322
+ );
2323
+ const next = this.#reconcile(rebuilt);
2209
2324
  await this.#storage.save(next);
2210
2325
  this.#state = next;
2211
2326
  this.#emit(next);
@@ -2808,7 +2923,11 @@ function theme(value, path, errors) {
2808
2923
  finiteNumber(value, "gridColumns", path, errors, 1);
2809
2924
  if (typeof value.gridColumns === "number" && !Number.isInteger(value.gridColumns))
2810
2925
  add(errors, `${path}.gridColumns`, "Expected an integer.", "invalid_integer");
2811
- if (hasOwn(value, "unclaimedOpacity")) finiteNumber(value, "unclaimedOpacity", path, errors, 0);
2926
+ if (hasOwn(value, "unclaimedOpacity")) {
2927
+ finiteNumber(value, "unclaimedOpacity", path, errors, 0);
2928
+ if (typeof value.unclaimedOpacity === "number" && value.unclaimedOpacity > 1)
2929
+ add(errors, `${path}.unclaimedOpacity`, "Expected a value between 0 and 1.", "out_of_range");
2930
+ }
2812
2931
  }
2813
2932
  function externalReferences(value, path, errors) {
2814
2933
  if (!Array.isArray(value)) {
@@ -2971,12 +3090,14 @@ function reward(value, path, errors, isPublic) {
2971
3090
  String(value.redemptionMethod)
2972
3091
  ))
2973
3092
  add(errors, `${path}.redemptionMethod`, "Unknown redemption method.", "invalid_enum");
2974
- finiteNumber(value, "requiredStampCount", path, errors, 0);
3093
+ nonNegativeInteger(value, "requiredStampCount", path, errors);
2975
3094
  for (const key of ["stockLimit", "userClaimLimit"]) {
2976
3095
  if (hasOwn(value, key) && value[key] !== void 0) {
2977
3096
  nonNegativeInteger(value, key, path, errors);
2978
3097
  }
2979
3098
  }
3099
+ optionalString(value, "stockKey", path, errors);
3100
+ optionalString(value, "secondaryStockKey", path, errors);
2980
3101
  optionalString(value, "validUntil", path, errors);
2981
3102
  if (typeof value.validUntil === "string" && Number.isNaN(Date.parse(value.validUntil)))
2982
3103
  add(errors, `${path}.validUntil`, "Expected a valid date string.", "invalid_date");
@@ -3024,8 +3145,25 @@ function validate(value, isPublic) {
3024
3145
  if (!isRecord3(value.inventory))
3025
3146
  add(errors, "$.inventory", "Expected an object.", "invalid_type");
3026
3147
  else {
3027
- for (const [key, item] of Object.entries(value.inventory))
3148
+ for (const [key, item] of Object.entries(value.inventory)) {
3149
+ if (key === "global") {
3150
+ add(
3151
+ errors,
3152
+ "$.inventory.global",
3153
+ "Use sharedStock instead of global.",
3154
+ "deprecated_field"
3155
+ );
3156
+ continue;
3157
+ }
3028
3158
  if (item !== void 0) nonNegativeInteger(value.inventory, key, "$.inventory", errors);
3159
+ }
3160
+ if (hasOwn(value.inventory, "sharedStock") && hasOwn(value.inventory, "global"))
3161
+ add(
3162
+ errors,
3163
+ "$.inventory",
3164
+ "sharedStock and global cannot be configured together.",
3165
+ "conflicting_fields"
3166
+ );
3029
3167
  }
3030
3168
  }
3031
3169
  if (hasOwn(value, "inventoryMode") && value.inventoryMode !== void 0 && value.inventoryMode !== "shared" && value.inventoryMode !== "per_reward")
@@ -3101,6 +3239,33 @@ function validateRallyConfigRelations(config) {
3101
3239
  reward2.conditions?.forEach((condition2, conditionIndex) => {
3102
3240
  visit(condition2, `rewards[${index}].conditions[${conditionIndex}]`);
3103
3241
  });
3242
+ if (reward2.requiredStampCount > config.spots.length)
3243
+ add(
3244
+ errors,
3245
+ `rewards[${index}].requiredStampCount`,
3246
+ "requiredStampCount cannot exceed the number of spots.",
3247
+ "required_stamp_count_exceeds_spots"
3248
+ );
3249
+ const inventory = "inventory" in config ? config.inventory : void 0;
3250
+ for (const [field, key] of [
3251
+ ["stockKey", reward2.stockKey],
3252
+ ["secondaryStockKey", reward2.secondaryStockKey]
3253
+ ]) {
3254
+ if (key !== void 0 && key !== "__shared__" && inventory?.[key] === void 0)
3255
+ add(
3256
+ errors,
3257
+ `rewards[${index}].${field}`,
3258
+ "Referenced inventory key does not exist.",
3259
+ "missing_inventory_key"
3260
+ );
3261
+ }
3262
+ if (reward2.stockKey === "__shared__" && inventory?.sharedStock === void 0)
3263
+ add(
3264
+ errors,
3265
+ `rewards[${index}].stockKey`,
3266
+ "sharedStock is not defined.",
3267
+ "missing_inventory_key"
3268
+ );
3104
3269
  });
3105
3270
  const visiting = /* @__PURE__ */ new Set();
3106
3271
  const visited = /* @__PURE__ */ new Set();
@@ -3313,6 +3478,8 @@ exports.parsePublicConfig = parsePublicConfig;
3313
3478
  exports.processStamp = processStamp;
3314
3479
  exports.readNfcContext = readNfcContext;
3315
3480
  exports.readQrContext = readQrContext;
3481
+ exports.rebuildUserStateFromLog = rebuildUserStateFromLog;
3482
+ exports.rebuildUserStateLog = rebuildUserStateLog;
3316
3483
  exports.reconcileRewardStates = reconcileRewardStates;
3317
3484
  exports.resolveLocalizedText = resolveLocalizedText;
3318
3485
  exports.resolveRallyStateConflict = resolveRallyStateConflict;