@stamprally/core 0.17.0 → 0.19.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 +3 -4
- package/dist/index.cjs +314 -126
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +43 -24
- package/dist/index.d.ts +43 -24
- package/dist/index.js +313 -127
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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:
|
|
183
|
-
|
|
184
|
-
|
|
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({
|
|
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({
|
|
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(
|
|
1399
|
+
async discardRejected(operationId2) {
|
|
1439
1400
|
await this.initialize();
|
|
1440
1401
|
const next = this.#rejectedHistory.filter(
|
|
1441
|
-
(entry) => offlineOperationId(entry.operation) !==
|
|
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(
|
|
1410
|
+
async retryRejected(operationId2) {
|
|
1450
1411
|
await this.initialize();
|
|
1451
1412
|
const entry = this.#rejectedHistory.find(
|
|
1452
|
-
(candidate) => offlineOperationId(candidate.operation) ===
|
|
1413
|
+
(candidate) => offlineOperationId(candidate.operation) === operationId2
|
|
1453
1414
|
);
|
|
1454
1415
|
if (entry === void 0) return false;
|
|
1455
|
-
if (!this.#operations.some((operation) => offlineOperationId(operation) ===
|
|
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(
|
|
1467
|
-
return this.discardRejected(
|
|
1427
|
+
async discardRejectedOperation(operationId2) {
|
|
1428
|
+
return this.discardRejected(operationId2);
|
|
1468
1429
|
}
|
|
1469
|
-
async retryRejectedOperation(
|
|
1470
|
-
return this.retryRejected(
|
|
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
|
|
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,45 @@ 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
|
+
[
|
|
1585
|
+
...this.#rejectedHistory.map((entry) => entry.operation),
|
|
1586
|
+
...this.#operations.filter((candidate) => candidate.status === "REJECTED_PERMANENT")
|
|
1587
|
+
].filter((candidate) => candidate.kind === "checkIn").map((candidate) => candidate.kind === "checkIn" ? candidate.request.spotId : void 0).filter((spotId) => spotId !== void 0)
|
|
1588
|
+
);
|
|
1589
|
+
if (!spot2.prerequisites?.some((prerequisite) => failedSpots.has(prerequisite))) return false;
|
|
1590
|
+
const error = {
|
|
1591
|
+
code: "REJECTED_PREREQUISITE_FAILED",
|
|
1592
|
+
message: "A prerequisite operation was rejected by the server."
|
|
1593
|
+
};
|
|
1594
|
+
const rejectedOperation = { ...operation, status: "REJECTED_PERMANENT" };
|
|
1595
|
+
this.#rejectedHistory = [
|
|
1596
|
+
...this.#rejectedHistory,
|
|
1597
|
+
{
|
|
1598
|
+
operation: rejectedOperation,
|
|
1599
|
+
reason: error,
|
|
1600
|
+
errorCode: error.code,
|
|
1601
|
+
rejectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1602
|
+
attempts: operation.attempts ?? 0
|
|
1603
|
+
}
|
|
1604
|
+
];
|
|
1605
|
+
this.#operations = this.#operations.slice(1);
|
|
1606
|
+
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1607
|
+
await this.#saveRejectedHistory();
|
|
1608
|
+
this.#announceChange();
|
|
1609
|
+
await this.#syncResultListener?.({
|
|
1610
|
+
operation,
|
|
1611
|
+
status: "REJECTED_PERMANENT",
|
|
1612
|
+
error
|
|
1613
|
+
});
|
|
1614
|
+
return true;
|
|
1615
|
+
}
|
|
1615
1616
|
#subscribeToExternalChanges() {
|
|
1616
1617
|
const windowLike = globalThis.window;
|
|
1617
1618
|
if (windowLike !== void 0) {
|
|
@@ -1736,11 +1737,6 @@ var OfflineQueue = class {
|
|
|
1736
1737
|
}
|
|
1737
1738
|
return { status: "ACCEPTED", result: value };
|
|
1738
1739
|
}
|
|
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
1740
|
async #saveRejectedHistory() {
|
|
1745
1741
|
await this.#storage.saveRejectedHistory?.(this.#storageKey(), this.#rejectedHistory);
|
|
1746
1742
|
}
|
|
@@ -1748,6 +1744,12 @@ var OfflineQueue = class {
|
|
|
1748
1744
|
if (this.#warnedMemoryLock) return;
|
|
1749
1745
|
this.#warnedMemoryLock = true;
|
|
1750
1746
|
console.warn(`[@stamprally/core] ${message}`);
|
|
1747
|
+
this.#capabilityWarningListener?.({
|
|
1748
|
+
type: "STORAGE_CAPABILITY_WARNING",
|
|
1749
|
+
storageCapability: this.storageCapability === "memory" ? "memory" : "volatile_single_tab",
|
|
1750
|
+
isStoragePersistent: this.isStoragePersistent,
|
|
1751
|
+
message
|
|
1752
|
+
});
|
|
1751
1753
|
}
|
|
1752
1754
|
};
|
|
1753
1755
|
function normalizeOperation(operation) {
|
|
@@ -1769,6 +1771,110 @@ function normalizeRejectedHistory(entry) {
|
|
|
1769
1771
|
};
|
|
1770
1772
|
}
|
|
1771
1773
|
|
|
1774
|
+
// src/client/sync.ts
|
|
1775
|
+
function isReplayable(operation) {
|
|
1776
|
+
return operation.status === void 0 || operation.status === "ACCEPTED" || operation.status === "PENDING" || operation.status === "IN_FLIGHT" || operation.status === "FAILED_RETRYABLE";
|
|
1777
|
+
}
|
|
1778
|
+
function operationId(operation) {
|
|
1779
|
+
const identity = operation.request.userId ?? operation.request.anonymousSessionId ?? "anonymous";
|
|
1780
|
+
return `${operation.kind}:${operation.request.rallyId}:${identity}:${operation.request.idempotencyKey}`;
|
|
1781
|
+
}
|
|
1782
|
+
function applyInventoryDelta(state, previous, optimistic) {
|
|
1783
|
+
const previousInventory = previous.inventory;
|
|
1784
|
+
const optimisticInventory = optimistic.inventory;
|
|
1785
|
+
if (previousInventory === void 0 || optimisticInventory === void 0) return state;
|
|
1786
|
+
const currentInventory = state.inventory ?? {};
|
|
1787
|
+
const sharedDelta = previousInventory.sharedRemaining === void 0 || optimisticInventory.sharedRemaining === void 0 ? void 0 : optimisticInventory.sharedRemaining - previousInventory.sharedRemaining;
|
|
1788
|
+
const previousRewards = previousInventory.rewardRemaining ?? {};
|
|
1789
|
+
const optimisticRewards = optimisticInventory.rewardRemaining ?? {};
|
|
1790
|
+
const currentRewards = currentInventory.rewardRemaining ?? {};
|
|
1791
|
+
const rewardRemaining = { ...currentRewards };
|
|
1792
|
+
for (const key of /* @__PURE__ */ new Set([...Object.keys(previousRewards), ...Object.keys(optimisticRewards)])) {
|
|
1793
|
+
const before = previousRewards[key];
|
|
1794
|
+
const after = optimisticRewards[key];
|
|
1795
|
+
if (before !== void 0 && after !== void 0)
|
|
1796
|
+
rewardRemaining[key] = Math.max(0, (currentRewards[key] ?? before) + after - before);
|
|
1797
|
+
}
|
|
1798
|
+
const nextInventory = {
|
|
1799
|
+
...currentInventory.sharedRemaining === void 0 || sharedDelta === void 0 ? optimisticInventory.sharedRemaining === void 0 || sharedDelta === void 0 ? {} : { sharedRemaining: Math.max(0, optimisticInventory.sharedRemaining) } : { sharedRemaining: Math.max(0, currentInventory.sharedRemaining + sharedDelta) },
|
|
1800
|
+
...Object.keys(rewardRemaining).length === 0 ? {} : { rewardRemaining }
|
|
1801
|
+
};
|
|
1802
|
+
return { ...state, inventory: nextInventory };
|
|
1803
|
+
}
|
|
1804
|
+
function applyOperation(state, operation, config, rejectedCheckIns) {
|
|
1805
|
+
if (operation.kind === "checkIn") {
|
|
1806
|
+
const spot2 = config?.spots.find((candidate) => candidate.id === operation.request.spotId);
|
|
1807
|
+
if (spot2?.prerequisites?.some(
|
|
1808
|
+
(id2) => rejectedCheckIns.has(id2) || !state.records.some((record2) => record2.stampId === id2)
|
|
1809
|
+
))
|
|
1810
|
+
return { state, prerequisiteFailed: true };
|
|
1811
|
+
if (state.records.some((record2) => record2.stampId === operation.request.spotId))
|
|
1812
|
+
return { state, prerequisiteFailed: false };
|
|
1813
|
+
const optimisticRecord = operation.optimisticState?.records.find(
|
|
1814
|
+
(record2) => record2.stampId === operation.request.spotId
|
|
1815
|
+
);
|
|
1816
|
+
const record = optimisticRecord ?? {
|
|
1817
|
+
stampId: operation.request.spotId,
|
|
1818
|
+
acquiredAt: operation.request.now
|
|
1819
|
+
};
|
|
1820
|
+
const records = [...state.records, { ...record }];
|
|
1821
|
+
const rewards2 = config === void 0 ? state.rewards : reconcileRewardStates(config.rewards, state.rewards, records.length, record.acquiredAt);
|
|
1822
|
+
return {
|
|
1823
|
+
state: { ...state, records, rewards: rewards2, updatedAt: record.acquiredAt },
|
|
1824
|
+
prerequisiteFailed: false
|
|
1825
|
+
};
|
|
1826
|
+
}
|
|
1827
|
+
const optimisticState = operation.optimisticState;
|
|
1828
|
+
if (optimisticState === void 0) return { state, prerequisiteFailed: false };
|
|
1829
|
+
const optimisticReward = optimisticState?.rewards.find(
|
|
1830
|
+
(reward2) => reward2.rewardId === operation.request.rewardId
|
|
1831
|
+
);
|
|
1832
|
+
if (optimisticReward === void 0) return { state, prerequisiteFailed: false };
|
|
1833
|
+
const rewards = state.rewards.some((reward2) => reward2.rewardId === optimisticReward.rewardId) ? state.rewards.map(
|
|
1834
|
+
(reward2) => reward2.rewardId === optimisticReward.rewardId ? { ...optimisticReward } : reward2
|
|
1835
|
+
) : [...state.rewards, { ...optimisticReward }];
|
|
1836
|
+
return {
|
|
1837
|
+
state: applyInventoryDelta(
|
|
1838
|
+
{ ...state, rewards, updatedAt: optimisticReward.consumedAt ?? state.updatedAt },
|
|
1839
|
+
operation.request.state,
|
|
1840
|
+
optimisticState
|
|
1841
|
+
),
|
|
1842
|
+
prerequisiteFailed: false
|
|
1843
|
+
};
|
|
1844
|
+
}
|
|
1845
|
+
function rebuildUserStateFromLog(baselineOrOptions, operationsArgument, configArgument) {
|
|
1846
|
+
const { state } = rebuildUserStateLog(
|
|
1847
|
+
"baseline" in baselineOrOptions ? baselineOrOptions.baseline : baselineOrOptions,
|
|
1848
|
+
"baseline" in baselineOrOptions ? baselineOrOptions.operations : operationsArgument ?? [],
|
|
1849
|
+
"baseline" in baselineOrOptions ? baselineOrOptions.config : configArgument
|
|
1850
|
+
);
|
|
1851
|
+
return state;
|
|
1852
|
+
}
|
|
1853
|
+
function rebuildUserStateLog(baseline, operations, config) {
|
|
1854
|
+
let state = cloneState(baseline);
|
|
1855
|
+
const rejectedOperationIds = [];
|
|
1856
|
+
const rejectedCheckIns = new Set(
|
|
1857
|
+
operations.filter(
|
|
1858
|
+
(operation) => operation.status === "REJECTED_PERMANENT" && operation.kind === "checkIn"
|
|
1859
|
+
).map((operation) => operation.kind === "checkIn" ? operation.request.spotId : void 0).filter((spotId) => spotId !== void 0)
|
|
1860
|
+
);
|
|
1861
|
+
for (const operation of operations) {
|
|
1862
|
+
if (operation.status === "REJECTED_PERMANENT") {
|
|
1863
|
+
if (operation.kind === "checkIn") rejectedCheckIns.add(operation.request.spotId);
|
|
1864
|
+
continue;
|
|
1865
|
+
}
|
|
1866
|
+
if (!isReplayable(operation)) continue;
|
|
1867
|
+
const replay = applyOperation(state, operation, config, rejectedCheckIns);
|
|
1868
|
+
if (replay.prerequisiteFailed) {
|
|
1869
|
+
rejectedOperationIds.push(operationId(operation));
|
|
1870
|
+
if (operation.kind === "checkIn") rejectedCheckIns.add(operation.request.spotId);
|
|
1871
|
+
continue;
|
|
1872
|
+
}
|
|
1873
|
+
state = replay.state;
|
|
1874
|
+
}
|
|
1875
|
+
return { state, rejectedOperationIds };
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1772
1878
|
// src/client/client.ts
|
|
1773
1879
|
function isStorage(value) {
|
|
1774
1880
|
return "load" in value && "save" in value && "remove" in value;
|
|
@@ -1809,6 +1915,30 @@ function emptyState(config, userId, now) {
|
|
|
1809
1915
|
updatedAt: now
|
|
1810
1916
|
};
|
|
1811
1917
|
}
|
|
1918
|
+
function applyOptimisticRewardClaim(state, rewardId, reward2, now) {
|
|
1919
|
+
if (reward2.claimTicketNumber === void 0 || state.inventory === void 0)
|
|
1920
|
+
return {
|
|
1921
|
+
...state,
|
|
1922
|
+
rewards: state.rewards.map((item) => item.rewardId === rewardId ? reward2 : item),
|
|
1923
|
+
updatedAt: now
|
|
1924
|
+
};
|
|
1925
|
+
const currentInventory = state.inventory;
|
|
1926
|
+
const currentRewardRemaining = currentInventory.rewardRemaining?.[rewardId];
|
|
1927
|
+
return {
|
|
1928
|
+
...state,
|
|
1929
|
+
rewards: state.rewards.map((item) => item.rewardId === rewardId ? reward2 : item),
|
|
1930
|
+
updatedAt: now,
|
|
1931
|
+
inventory: {
|
|
1932
|
+
...currentInventory.sharedRemaining === void 0 ? {} : { sharedRemaining: Math.max(0, currentInventory.sharedRemaining - 1) },
|
|
1933
|
+
...currentInventory.rewardRemaining === void 0 || currentRewardRemaining === void 0 ? {} : {
|
|
1934
|
+
rewardRemaining: {
|
|
1935
|
+
...currentInventory.rewardRemaining,
|
|
1936
|
+
[rewardId]: Math.max(0, currentRewardRemaining - 1)
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
};
|
|
1941
|
+
}
|
|
1812
1942
|
function errorMessage(error, fallback) {
|
|
1813
1943
|
return error !== void 0 && "message" in error && typeof error.message === "string" ? error.message : fallback;
|
|
1814
1944
|
}
|
|
@@ -1834,7 +1964,11 @@ var StampRallyClient = class {
|
|
|
1834
1964
|
this.#anonymousSessionId = this.#options.anonymousSessionId ?? createAnonymousSessionId();
|
|
1835
1965
|
this.#userId = this.#options.userId ?? this.#anonymousSessionId;
|
|
1836
1966
|
this.#offlineQueue = this.#options.offlineQueue;
|
|
1967
|
+
this.#offlineQueue?.setReplayConfig(config);
|
|
1837
1968
|
this.#offlineQueue?.setSyncResultListener((event) => this.#handleOfflineSyncResult(event));
|
|
1969
|
+
this.#offlineQueue?.setCapabilityWarningListener(
|
|
1970
|
+
(warning) => this.#emitEvent({ type: "storageCapabilityWarning", warning })
|
|
1971
|
+
);
|
|
1838
1972
|
this.#offlineQueue?.setChangeListener(() => {
|
|
1839
1973
|
this.#syncRevision += 1;
|
|
1840
1974
|
if (this.#state !== null) this.#emit(this.#state);
|
|
@@ -1867,17 +2001,23 @@ var StampRallyClient = class {
|
|
|
1867
2001
|
get queueCapability() {
|
|
1868
2002
|
return this.#offlineQueue?.queueCapability ?? "custom";
|
|
1869
2003
|
}
|
|
1870
|
-
|
|
1871
|
-
return this.#offlineQueue?.
|
|
2004
|
+
get storageCapability() {
|
|
2005
|
+
return this.#offlineQueue?.storageCapability ?? "custom";
|
|
2006
|
+
}
|
|
2007
|
+
get isStoragePersistent() {
|
|
2008
|
+
return this.#offlineQueue?.isStoragePersistent ?? true;
|
|
2009
|
+
}
|
|
2010
|
+
discardRejected(operationId2) {
|
|
2011
|
+
return this.#offlineQueue?.discardRejected(operationId2) ?? Promise.resolve(false);
|
|
1872
2012
|
}
|
|
1873
|
-
retryRejected(
|
|
1874
|
-
return this.#offlineQueue?.retryRejected(
|
|
2013
|
+
retryRejected(operationId2) {
|
|
2014
|
+
return this.#offlineQueue?.retryRejected(operationId2) ?? Promise.resolve(false);
|
|
1875
2015
|
}
|
|
1876
|
-
dismissRejectedOperation(
|
|
1877
|
-
return this.discardRejected(
|
|
2016
|
+
dismissRejectedOperation(operationId2) {
|
|
2017
|
+
return this.discardRejected(operationId2);
|
|
1878
2018
|
}
|
|
1879
|
-
retryOperation(
|
|
1880
|
-
return this.retryRejected(
|
|
2019
|
+
retryOperation(operationId2) {
|
|
2020
|
+
return this.retryRejected(operationId2);
|
|
1881
2021
|
}
|
|
1882
2022
|
subscribe(listener) {
|
|
1883
2023
|
this.#listeners.add(listener);
|
|
@@ -2002,13 +2142,13 @@ var StampRallyClient = class {
|
|
|
2002
2142
|
return result.ok ? this.#commitCheckIn(result) : this.#fail(result.error);
|
|
2003
2143
|
} catch (error) {
|
|
2004
2144
|
if (this.#offlineQueue === void 0) throw error;
|
|
2005
|
-
await this.#offlineQueue.enqueueCheckIn(request);
|
|
2006
2145
|
const record2 = { stampId: spotId, acquiredAt: now };
|
|
2007
2146
|
const next2 = this.#reconcile({
|
|
2008
2147
|
...current,
|
|
2009
2148
|
records: [...current.records, record2],
|
|
2010
2149
|
updatedAt: now
|
|
2011
2150
|
});
|
|
2151
|
+
await this.#offlineQueue.enqueueCheckIn(request, next2);
|
|
2012
2152
|
await this.#storage.save(next2);
|
|
2013
2153
|
return this.#commitCheckIn({ ok: true, value: { state: next2, record: record2 } });
|
|
2014
2154
|
}
|
|
@@ -2059,23 +2199,13 @@ var StampRallyClient = class {
|
|
|
2059
2199
|
return result.ok ? this.#commitClaim(result) : this.#fail(result.error);
|
|
2060
2200
|
} catch (error) {
|
|
2061
2201
|
if (this.#offlineQueue === void 0) throw error;
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
...current,
|
|
2065
|
-
rewards: current.rewards.map(
|
|
2066
|
-
(item) => item.rewardId === rewardId ? local.value : item
|
|
2067
|
-
),
|
|
2068
|
-
updatedAt: now
|
|
2069
|
-
};
|
|
2202
|
+
const next2 = applyOptimisticRewardClaim(current, rewardId, local.value, now);
|
|
2203
|
+
await this.#offlineQueue.enqueueClaimReward(request, next2);
|
|
2070
2204
|
await this.#storage.save(next2);
|
|
2071
2205
|
return this.#commitClaim({ ok: true, value: { state: next2, reward: local.value } });
|
|
2072
2206
|
}
|
|
2073
2207
|
}
|
|
2074
|
-
const next =
|
|
2075
|
-
...current,
|
|
2076
|
-
rewards: current.rewards.map((item) => item.rewardId === rewardId ? local.value : item),
|
|
2077
|
-
updatedAt: now
|
|
2078
|
-
};
|
|
2208
|
+
const next = applyOptimisticRewardClaim(current, rewardId, local.value, now);
|
|
2079
2209
|
await this.#storage.save(next);
|
|
2080
2210
|
return this.#commitClaim({ ok: true, value: { state: next, reward: local.value } });
|
|
2081
2211
|
});
|
|
@@ -2109,9 +2239,11 @@ var StampRallyClient = class {
|
|
|
2109
2239
|
state: localState,
|
|
2110
2240
|
...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
|
|
2111
2241
|
});
|
|
2112
|
-
const resolved =
|
|
2113
|
-
|
|
2114
|
-
|
|
2242
|
+
const resolved = rebuildUserStateFromLog(
|
|
2243
|
+
serverState,
|
|
2244
|
+
this.#offlineQueue?.operations ?? [],
|
|
2245
|
+
this.#config
|
|
2246
|
+
);
|
|
2115
2247
|
const next = this.#reconcile(resolved);
|
|
2116
2248
|
await this.#storage.save(next);
|
|
2117
2249
|
this.#state = next;
|
|
@@ -2185,7 +2317,12 @@ var StampRallyClient = class {
|
|
|
2185
2317
|
if (event.status === "ACCEPTED") {
|
|
2186
2318
|
if (this.#syncMetrics !== null) this.#syncMetrics.processed += 1;
|
|
2187
2319
|
if (event.state !== void 0) {
|
|
2188
|
-
const
|
|
2320
|
+
const rebuilt = rebuildUserStateFromLog(
|
|
2321
|
+
event.state,
|
|
2322
|
+
this.#offlineQueue?.operations ?? [],
|
|
2323
|
+
this.#config
|
|
2324
|
+
);
|
|
2325
|
+
const next = this.#reconcile(rebuilt);
|
|
2189
2326
|
await this.#storage.save(next);
|
|
2190
2327
|
this.#state = next;
|
|
2191
2328
|
this.#emit(next);
|
|
@@ -2205,7 +2342,13 @@ var StampRallyClient = class {
|
|
|
2205
2342
|
this.#syncMetrics.failed += 1;
|
|
2206
2343
|
}
|
|
2207
2344
|
const base = event.state ?? this.#state ?? event.operation.request.state;
|
|
2208
|
-
const
|
|
2345
|
+
const rollbackBase = event.state === void 0 ? rollbackOptimisticOperation(base, event.operation) : base;
|
|
2346
|
+
const rebuilt = rebuildUserStateFromLog(
|
|
2347
|
+
rollbackBase,
|
|
2348
|
+
this.#offlineQueue?.operations ?? [],
|
|
2349
|
+
this.#config
|
|
2350
|
+
);
|
|
2351
|
+
const next = this.#reconcile(rebuilt);
|
|
2209
2352
|
await this.#storage.save(next);
|
|
2210
2353
|
this.#state = next;
|
|
2211
2354
|
this.#emit(next);
|
|
@@ -2808,7 +2951,11 @@ function theme(value, path, errors) {
|
|
|
2808
2951
|
finiteNumber(value, "gridColumns", path, errors, 1);
|
|
2809
2952
|
if (typeof value.gridColumns === "number" && !Number.isInteger(value.gridColumns))
|
|
2810
2953
|
add(errors, `${path}.gridColumns`, "Expected an integer.", "invalid_integer");
|
|
2811
|
-
if (hasOwn(value, "unclaimedOpacity"))
|
|
2954
|
+
if (hasOwn(value, "unclaimedOpacity")) {
|
|
2955
|
+
finiteNumber(value, "unclaimedOpacity", path, errors, 0);
|
|
2956
|
+
if (typeof value.unclaimedOpacity === "number" && value.unclaimedOpacity > 1)
|
|
2957
|
+
add(errors, `${path}.unclaimedOpacity`, "Expected a value between 0 and 1.", "out_of_range");
|
|
2958
|
+
}
|
|
2812
2959
|
}
|
|
2813
2960
|
function externalReferences(value, path, errors) {
|
|
2814
2961
|
if (!Array.isArray(value)) {
|
|
@@ -2971,12 +3118,14 @@ function reward(value, path, errors, isPublic) {
|
|
|
2971
3118
|
String(value.redemptionMethod)
|
|
2972
3119
|
))
|
|
2973
3120
|
add(errors, `${path}.redemptionMethod`, "Unknown redemption method.", "invalid_enum");
|
|
2974
|
-
|
|
3121
|
+
nonNegativeInteger(value, "requiredStampCount", path, errors);
|
|
2975
3122
|
for (const key of ["stockLimit", "userClaimLimit"]) {
|
|
2976
3123
|
if (hasOwn(value, key) && value[key] !== void 0) {
|
|
2977
3124
|
nonNegativeInteger(value, key, path, errors);
|
|
2978
3125
|
}
|
|
2979
3126
|
}
|
|
3127
|
+
optionalString(value, "stockKey", path, errors);
|
|
3128
|
+
optionalString(value, "secondaryStockKey", path, errors);
|
|
2980
3129
|
optionalString(value, "validUntil", path, errors);
|
|
2981
3130
|
if (typeof value.validUntil === "string" && Number.isNaN(Date.parse(value.validUntil)))
|
|
2982
3131
|
add(errors, `${path}.validUntil`, "Expected a valid date string.", "invalid_date");
|
|
@@ -3024,8 +3173,25 @@ function validate(value, isPublic) {
|
|
|
3024
3173
|
if (!isRecord3(value.inventory))
|
|
3025
3174
|
add(errors, "$.inventory", "Expected an object.", "invalid_type");
|
|
3026
3175
|
else {
|
|
3027
|
-
for (const [key, item] of Object.entries(value.inventory))
|
|
3176
|
+
for (const [key, item] of Object.entries(value.inventory)) {
|
|
3177
|
+
if (key === "global") {
|
|
3178
|
+
add(
|
|
3179
|
+
errors,
|
|
3180
|
+
"$.inventory.global",
|
|
3181
|
+
"Use sharedStock instead of global.",
|
|
3182
|
+
"deprecated_field"
|
|
3183
|
+
);
|
|
3184
|
+
continue;
|
|
3185
|
+
}
|
|
3028
3186
|
if (item !== void 0) nonNegativeInteger(value.inventory, key, "$.inventory", errors);
|
|
3187
|
+
}
|
|
3188
|
+
if (hasOwn(value.inventory, "sharedStock") && hasOwn(value.inventory, "global"))
|
|
3189
|
+
add(
|
|
3190
|
+
errors,
|
|
3191
|
+
"$.inventory",
|
|
3192
|
+
"sharedStock and global cannot be configured together.",
|
|
3193
|
+
"conflicting_fields"
|
|
3194
|
+
);
|
|
3029
3195
|
}
|
|
3030
3196
|
}
|
|
3031
3197
|
if (hasOwn(value, "inventoryMode") && value.inventoryMode !== void 0 && value.inventoryMode !== "shared" && value.inventoryMode !== "per_reward")
|
|
@@ -3101,6 +3267,26 @@ function validateRallyConfigRelations(config) {
|
|
|
3101
3267
|
reward2.conditions?.forEach((condition2, conditionIndex) => {
|
|
3102
3268
|
visit(condition2, `rewards[${index}].conditions[${conditionIndex}]`);
|
|
3103
3269
|
});
|
|
3270
|
+
if (reward2.requiredStampCount > config.spots.length)
|
|
3271
|
+
add(
|
|
3272
|
+
errors,
|
|
3273
|
+
`rewards[${index}].requiredStampCount`,
|
|
3274
|
+
"requiredStampCount cannot exceed the number of spots.",
|
|
3275
|
+
"required_stamp_count_exceeds_spots"
|
|
3276
|
+
);
|
|
3277
|
+
const inventory = "inventory" in config ? config.inventory : void 0;
|
|
3278
|
+
for (const [field, key] of [
|
|
3279
|
+
["stockKey", reward2.stockKey],
|
|
3280
|
+
["secondaryStockKey", reward2.secondaryStockKey]
|
|
3281
|
+
]) {
|
|
3282
|
+
if (key !== void 0 && (key === "__shared__" && inventory?.sharedStock === void 0 || key !== "__shared__" && inventory?.[key] === void 0))
|
|
3283
|
+
add(
|
|
3284
|
+
errors,
|
|
3285
|
+
`rewards[${index}].${field}`,
|
|
3286
|
+
"Referenced inventory key does not exist.",
|
|
3287
|
+
"missing_inventory_key"
|
|
3288
|
+
);
|
|
3289
|
+
}
|
|
3104
3290
|
});
|
|
3105
3291
|
const visiting = /* @__PURE__ */ new Set();
|
|
3106
3292
|
const visited = /* @__PURE__ */ new Set();
|
|
@@ -3313,6 +3499,8 @@ exports.parsePublicConfig = parsePublicConfig;
|
|
|
3313
3499
|
exports.processStamp = processStamp;
|
|
3314
3500
|
exports.readNfcContext = readNfcContext;
|
|
3315
3501
|
exports.readQrContext = readQrContext;
|
|
3502
|
+
exports.rebuildUserStateFromLog = rebuildUserStateFromLog;
|
|
3503
|
+
exports.rebuildUserStateLog = rebuildUserStateLog;
|
|
3316
3504
|
exports.reconcileRewardStates = reconcileRewardStates;
|
|
3317
3505
|
exports.resolveLocalizedText = resolveLocalizedText;
|
|
3318
3506
|
exports.resolveRallyStateConflict = resolveRallyStateConflict;
|