@stamprally/core 0.12.0 → 0.14.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 +13 -0
- package/dist/index.cjs +356 -29
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +51 -4
- package/dist/index.d.ts +51 -4
- package/dist/index.js +354 -30
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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) {
|
|
@@ -53,7 +61,9 @@ function calculateProgress(state, config) {
|
|
|
53
61
|
const acquired = new Set(
|
|
54
62
|
state.records.map((record) => record.stampId).filter((id2) => ids.has(id2))
|
|
55
63
|
);
|
|
56
|
-
const remaining = config.spots.filter(
|
|
64
|
+
const remaining = config.spots.filter(
|
|
65
|
+
(spot2) => !acquired.has(spot2.id) && (spot2.prerequisites === void 0 || spot2.prerequisites.every((id2) => acquired.has(id2)))
|
|
66
|
+
);
|
|
57
67
|
return {
|
|
58
68
|
acquired: acquired.size,
|
|
59
69
|
total: config.spots.length,
|
|
@@ -117,24 +127,53 @@ function mergeRewardStates(serverRewards, localRewards) {
|
|
|
117
127
|
merged.set(localReward.rewardId, localReward);
|
|
118
128
|
continue;
|
|
119
129
|
}
|
|
120
|
-
|
|
121
|
-
|
|
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
|
+
});
|
|
122
152
|
}
|
|
123
153
|
return [...merged.values()];
|
|
124
154
|
}
|
|
125
|
-
function
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
knownStamps.add(record.stampId);
|
|
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;
|
|
133
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
|
+
});
|
|
134
169
|
}
|
|
170
|
+
return [...merged.values()];
|
|
171
|
+
}
|
|
172
|
+
function resolveRallyStateConflict(serverState, localState, options = { policy: "merge" }) {
|
|
173
|
+
if (options.policy === "server_wins") return serverState;
|
|
135
174
|
return {
|
|
136
175
|
...serverState,
|
|
137
|
-
records,
|
|
176
|
+
records: mergeStampRecords(serverState.records, localState.records),
|
|
138
177
|
rewards: mergeRewardStates(serverState.rewards, localState.rewards),
|
|
139
178
|
updatedAt: latestTimestamp(serverState.updatedAt, localState.updatedAt)
|
|
140
179
|
};
|
|
@@ -1072,7 +1111,10 @@ var StampRallyClient = class {
|
|
|
1072
1111
|
initialize() {
|
|
1073
1112
|
if (this.#state !== null) return Promise.resolve(this.#state);
|
|
1074
1113
|
if (this.#initialization === null) {
|
|
1075
|
-
this.#initialization =
|
|
1114
|
+
this.#initialization = (async () => {
|
|
1115
|
+
await this.#offlineQueue?.setScope(this.#config.id, this.#userId);
|
|
1116
|
+
return this.#storage.load(this.#config.id, this.#userId);
|
|
1117
|
+
})().then((state) => {
|
|
1076
1118
|
const next = state === null ? emptyState(this.#config, this.#userId, this.#now()) : this.#reconcile(state);
|
|
1077
1119
|
this.#state = next;
|
|
1078
1120
|
this.#emit(next);
|
|
@@ -1090,6 +1132,7 @@ var StampRallyClient = class {
|
|
|
1090
1132
|
this.#userId = newUserId;
|
|
1091
1133
|
this.#state = null;
|
|
1092
1134
|
this.#initialization = null;
|
|
1135
|
+
await this.#offlineQueue?.switchUser(newUserId);
|
|
1093
1136
|
return this.initialize();
|
|
1094
1137
|
});
|
|
1095
1138
|
}
|
|
@@ -1338,7 +1381,8 @@ var StampRallyClient = class {
|
|
|
1338
1381
|
this.#state = next;
|
|
1339
1382
|
this.#emit(next);
|
|
1340
1383
|
}
|
|
1341
|
-
if ("
|
|
1384
|
+
if (event.error !== void 0) this.#emitEvent({ type: "error", error: event.error });
|
|
1385
|
+
else if (event.result !== void 0 && "ok" in event.result && !event.result.ok)
|
|
1342
1386
|
this.#emitEvent({ type: "error", error: event.result.error });
|
|
1343
1387
|
}
|
|
1344
1388
|
#now() {
|
|
@@ -1466,9 +1510,32 @@ function defaultStorage(databaseName) {
|
|
|
1466
1510
|
function operationId(operation) {
|
|
1467
1511
|
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
1512
|
}
|
|
1513
|
+
function requestScope(operation) {
|
|
1514
|
+
return {
|
|
1515
|
+
rallyId: operation.request.rallyId,
|
|
1516
|
+
userId: operation.request.userId
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
function errorValue(value, fallbackCode) {
|
|
1520
|
+
if (typeof value === "object" && value !== null) {
|
|
1521
|
+
const candidate = value;
|
|
1522
|
+
if (typeof candidate.code === "string" && typeof candidate.message === "string")
|
|
1523
|
+
return { ...candidate, code: candidate.code, message: candidate.message };
|
|
1524
|
+
}
|
|
1525
|
+
if (value instanceof Error) return { code: fallbackCode, message: value.message };
|
|
1526
|
+
if (typeof value === "string") return { code: fallbackCode, message: value };
|
|
1527
|
+
return { code: fallbackCode, message: "Offline operation was rejected." };
|
|
1528
|
+
}
|
|
1529
|
+
var syncLocks = /* @__PURE__ */ new Map();
|
|
1530
|
+
var SYNC_LOCK_TTL_MS = 3e4;
|
|
1531
|
+
function randomId() {
|
|
1532
|
+
return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
1533
|
+
}
|
|
1469
1534
|
var OfflineQueue = class {
|
|
1470
1535
|
#storage;
|
|
1471
|
-
#
|
|
1536
|
+
#configuredKey;
|
|
1537
|
+
#rallyId;
|
|
1538
|
+
#userId;
|
|
1472
1539
|
#conflictPolicy;
|
|
1473
1540
|
#onSyncConflict;
|
|
1474
1541
|
#operations = [];
|
|
@@ -1478,14 +1545,24 @@ var OfflineQueue = class {
|
|
|
1478
1545
|
#sender;
|
|
1479
1546
|
#syncPromise = null;
|
|
1480
1547
|
#syncResultListener;
|
|
1548
|
+
#synchronizeInstances;
|
|
1549
|
+
#instanceId = randomId();
|
|
1550
|
+
#lockStorage;
|
|
1551
|
+
#storageListener;
|
|
1552
|
+
#channel = null;
|
|
1481
1553
|
constructor(options = {}) {
|
|
1482
1554
|
if (options.storage !== void 0) this.#storage = options.storage;
|
|
1483
1555
|
else if (options.storageLike !== void 0 && options.storageLike !== null)
|
|
1484
1556
|
this.#storage = new LocalStorageQueueStorage(options.storageLike);
|
|
1485
1557
|
else this.#storage = defaultStorage(options.databaseName);
|
|
1486
|
-
this.#
|
|
1558
|
+
this.#configuredKey = options.key;
|
|
1559
|
+
this.#rallyId = options.rallyId;
|
|
1560
|
+
this.#userId = options.userId ?? null;
|
|
1487
1561
|
this.#conflictPolicy = options.conflictPolicy ?? "server_wins";
|
|
1488
1562
|
this.#onSyncConflict = options.onSyncConflict;
|
|
1563
|
+
this.#synchronizeInstances = options.synchronizeInstances ?? true;
|
|
1564
|
+
this.#lockStorage = options.storageLike ?? globalThis.localStorage ?? null;
|
|
1565
|
+
if (this.#synchronizeInstances) this.#subscribeToExternalChanges();
|
|
1489
1566
|
}
|
|
1490
1567
|
get syncState() {
|
|
1491
1568
|
return this.#state;
|
|
@@ -1502,23 +1579,68 @@ var OfflineQueue = class {
|
|
|
1502
1579
|
get conflictPolicy() {
|
|
1503
1580
|
return this.#conflictPolicy;
|
|
1504
1581
|
}
|
|
1582
|
+
get storageKey() {
|
|
1583
|
+
return this.#storageKey();
|
|
1584
|
+
}
|
|
1585
|
+
get rallyId() {
|
|
1586
|
+
return this.#rallyId;
|
|
1587
|
+
}
|
|
1588
|
+
get userId() {
|
|
1589
|
+
return this.#userId;
|
|
1590
|
+
}
|
|
1505
1591
|
setSyncResultListener(listener) {
|
|
1506
1592
|
this.#syncResultListener = listener;
|
|
1507
1593
|
}
|
|
1508
1594
|
async initialize() {
|
|
1509
1595
|
if (this.#loaded) return;
|
|
1510
|
-
this.#operations = [...await this.#storage.load(this.#
|
|
1596
|
+
this.#operations = [...await this.#storage.load(this.#storageKey())];
|
|
1511
1597
|
this.#loaded = true;
|
|
1512
1598
|
}
|
|
1599
|
+
/** Releases browser listeners when the queue is no longer used. */
|
|
1600
|
+
dispose() {
|
|
1601
|
+
const windowLike = globalThis.window;
|
|
1602
|
+
if (windowLike !== void 0 && this.#storageListener !== void 0)
|
|
1603
|
+
windowLike.removeEventListener("storage", this.#storageListener);
|
|
1604
|
+
this.#storageListener = void 0;
|
|
1605
|
+
this.#channel?.close();
|
|
1606
|
+
this.#channel = null;
|
|
1607
|
+
this.#releaseSyncLock();
|
|
1608
|
+
}
|
|
1609
|
+
/** Selects a rally/user queue scope and loads its pending operations. */
|
|
1610
|
+
async setScope(rallyId, userId) {
|
|
1611
|
+
if (this.#configuredKey !== void 0) {
|
|
1612
|
+
this.#rallyId = rallyId;
|
|
1613
|
+
this.#userId = userId;
|
|
1614
|
+
return this.initialize();
|
|
1615
|
+
}
|
|
1616
|
+
if (this.#rallyId === rallyId && this.#userId === userId && this.#loaded) return;
|
|
1617
|
+
this.#rallyId = rallyId;
|
|
1618
|
+
this.#userId = userId;
|
|
1619
|
+
this.#operations = [];
|
|
1620
|
+
this.#loaded = false;
|
|
1621
|
+
await this.initialize();
|
|
1622
|
+
}
|
|
1623
|
+
async switchUser(newUserId) {
|
|
1624
|
+
if (this.#rallyId === void 0)
|
|
1625
|
+
throw new Error("OfflineQueue.switchUser requires a rally scope.");
|
|
1626
|
+
await this.setScope(this.#rallyId, newUserId);
|
|
1627
|
+
}
|
|
1513
1628
|
setSender(sender) {
|
|
1514
1629
|
this.#sender = sender;
|
|
1515
1630
|
}
|
|
1516
1631
|
async enqueue(operation) {
|
|
1632
|
+
if (this.#configuredKey === void 0) {
|
|
1633
|
+
const scope = requestScope(operation);
|
|
1634
|
+
if (this.#rallyId === void 0) await this.setScope(scope.rallyId, scope.userId);
|
|
1635
|
+
if (this.#rallyId !== scope.rallyId || this.#userId !== scope.userId)
|
|
1636
|
+
throw new Error("Offline operation belongs to another rally or user queue.");
|
|
1637
|
+
}
|
|
1517
1638
|
await this.initialize();
|
|
1518
1639
|
const id2 = operationId(operation);
|
|
1519
1640
|
if (this.#operations.some((item) => operationId(item) === id2)) return;
|
|
1520
1641
|
this.#operations = [...this.#operations, operation];
|
|
1521
|
-
await this.#storage.save(this.#
|
|
1642
|
+
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1643
|
+
this.#announceChange();
|
|
1522
1644
|
}
|
|
1523
1645
|
async enqueueCheckIn(request) {
|
|
1524
1646
|
return this.enqueue({ kind: "checkIn", request });
|
|
@@ -1529,7 +1651,8 @@ var OfflineQueue = class {
|
|
|
1529
1651
|
async clear() {
|
|
1530
1652
|
await this.initialize();
|
|
1531
1653
|
this.#operations = [];
|
|
1532
|
-
await this.#storage.save(this.#
|
|
1654
|
+
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1655
|
+
this.#announceChange();
|
|
1533
1656
|
}
|
|
1534
1657
|
async sync(sender = this.#sender) {
|
|
1535
1658
|
await this.initialize();
|
|
@@ -1547,31 +1670,140 @@ var OfflineQueue = class {
|
|
|
1547
1670
|
async #run(sender) {
|
|
1548
1671
|
this.#state = "syncing";
|
|
1549
1672
|
this.#error = null;
|
|
1673
|
+
if (!this.#acquireSyncLock()) {
|
|
1674
|
+
await this.#reloadFromStorage();
|
|
1675
|
+
this.#state = "idle";
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1550
1678
|
try {
|
|
1551
1679
|
while (this.#operations.length > 0) {
|
|
1552
1680
|
const operation = this.#operations[0];
|
|
1553
1681
|
if (operation === void 0) break;
|
|
1554
|
-
let
|
|
1682
|
+
let rawResult;
|
|
1555
1683
|
try {
|
|
1556
|
-
|
|
1684
|
+
rawResult = await sender(operation);
|
|
1557
1685
|
} catch (cause) {
|
|
1558
|
-
throw
|
|
1686
|
+
throw new Error(errorValue(cause, "RETRYABLE_ERROR").message);
|
|
1559
1687
|
}
|
|
1560
|
-
const
|
|
1688
|
+
const response = this.#normalizeResponse(rawResult);
|
|
1689
|
+
if (response.status === "RETRYABLE_ERROR") {
|
|
1690
|
+
const error2 = errorValue(response.error ?? response.reason, "RETRYABLE_ERROR");
|
|
1691
|
+
await this.#syncResultListener?.({ operation, status: response.status, error: error2 });
|
|
1692
|
+
throw new Error(error2.message);
|
|
1693
|
+
}
|
|
1694
|
+
const result = response.result;
|
|
1695
|
+
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);
|
|
1696
|
+
const error = response.status === "REJECTED_PERMANENT" ? errorValue(response.error ?? response.reason, "REJECTED_PERMANENT") : void 0;
|
|
1697
|
+
this.#operations = this.#operations.slice(1);
|
|
1698
|
+
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1699
|
+
this.#announceChange();
|
|
1561
1700
|
await this.#syncResultListener?.({
|
|
1562
1701
|
operation,
|
|
1563
|
-
result,
|
|
1702
|
+
...result === void 0 ? {} : { result },
|
|
1703
|
+
status: response.status,
|
|
1704
|
+
...error === void 0 ? {} : { error },
|
|
1564
1705
|
...state === void 0 ? {} : { state }
|
|
1565
1706
|
});
|
|
1566
|
-
this.#operations = this.#operations.slice(1);
|
|
1567
|
-
await this.#storage.save(this.#key, this.#operations);
|
|
1568
1707
|
}
|
|
1569
1708
|
this.#state = "idle";
|
|
1570
1709
|
} catch (cause) {
|
|
1571
1710
|
this.#state = "error";
|
|
1572
1711
|
this.#error = cause instanceof Error ? cause : new Error(String(cause));
|
|
1573
1712
|
throw this.#error;
|
|
1713
|
+
} finally {
|
|
1714
|
+
this.#releaseSyncLock();
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
#subscribeToExternalChanges() {
|
|
1718
|
+
const windowLike = globalThis.window;
|
|
1719
|
+
if (windowLike !== void 0) {
|
|
1720
|
+
this.#storageListener = (event) => {
|
|
1721
|
+
if (event.key === this.#storageKey()) void this.#reloadFromStorage();
|
|
1722
|
+
};
|
|
1723
|
+
windowLike.addEventListener("storage", this.#storageListener);
|
|
1574
1724
|
}
|
|
1725
|
+
const Channel = globalThis.BroadcastChannel;
|
|
1726
|
+
if (Channel !== void 0) {
|
|
1727
|
+
try {
|
|
1728
|
+
this.#channel = new Channel("stamprally:queue-sync");
|
|
1729
|
+
this.#channel.addEventListener("message", (event) => {
|
|
1730
|
+
if (typeof event.data === "object" && event.data !== null && "key" in event.data && event.data.key === this.#storageKey())
|
|
1731
|
+
void this.#reloadFromStorage();
|
|
1732
|
+
});
|
|
1733
|
+
} catch {
|
|
1734
|
+
this.#channel = null;
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
#announceChange() {
|
|
1739
|
+
this.#channel?.postMessage({ key: this.#storageKey(), owner: this.#instanceId });
|
|
1740
|
+
}
|
|
1741
|
+
async #reloadFromStorage() {
|
|
1742
|
+
if (this.#state === "syncing") return;
|
|
1743
|
+
try {
|
|
1744
|
+
this.#operations = [...await this.#storage.load(this.#storageKey())];
|
|
1745
|
+
this.#loaded = true;
|
|
1746
|
+
} catch {
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
#lockKey() {
|
|
1750
|
+
return `${this.#storageKey()}:sync-lock`;
|
|
1751
|
+
}
|
|
1752
|
+
#acquireSyncLock() {
|
|
1753
|
+
const key = this.#lockKey();
|
|
1754
|
+
const now = Date.now();
|
|
1755
|
+
const local = syncLocks.get(key);
|
|
1756
|
+
if (local !== void 0 && local.expiresAt > now && local.owner !== this.#instanceId)
|
|
1757
|
+
return false;
|
|
1758
|
+
if (this.#lockStorage !== null) {
|
|
1759
|
+
try {
|
|
1760
|
+
const existing = this.#lockStorage.getItem(key);
|
|
1761
|
+
if (existing !== null) {
|
|
1762
|
+
const parsed = JSON.parse(existing);
|
|
1763
|
+
if (typeof parsed === "object" && parsed !== null && typeof parsed.expiresAt === "number" && parsed.expiresAt > now && parsed.owner !== this.#instanceId)
|
|
1764
|
+
return false;
|
|
1765
|
+
}
|
|
1766
|
+
this.#lockStorage.setItem(
|
|
1767
|
+
key,
|
|
1768
|
+
JSON.stringify({ owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS })
|
|
1769
|
+
);
|
|
1770
|
+
} catch {
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
syncLocks.set(key, { owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS });
|
|
1774
|
+
return true;
|
|
1775
|
+
}
|
|
1776
|
+
#releaseSyncLock() {
|
|
1777
|
+
const key = this.#lockKey();
|
|
1778
|
+
const current = syncLocks.get(key);
|
|
1779
|
+
if (current?.owner === this.#instanceId) syncLocks.delete(key);
|
|
1780
|
+
if (this.#lockStorage !== null) {
|
|
1781
|
+
try {
|
|
1782
|
+
const value = this.#lockStorage.getItem(key);
|
|
1783
|
+
if (value !== null && JSON.parse(value).owner === this.#instanceId)
|
|
1784
|
+
this.#lockStorage.removeItem?.(key);
|
|
1785
|
+
} catch {
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
#storageKey() {
|
|
1790
|
+
if (this.#configuredKey !== void 0) return this.#configuredKey;
|
|
1791
|
+
return `stamprally:queue:${this.#rallyId ?? "unscoped"}:${this.#userId ?? "anonymous"}`;
|
|
1792
|
+
}
|
|
1793
|
+
#normalizeResponse(value) {
|
|
1794
|
+
if ("ok" in value) {
|
|
1795
|
+
if (value.ok === false) {
|
|
1796
|
+
if ("status" in value && value.status === "RETRYABLE_ERROR")
|
|
1797
|
+
return { status: "RETRYABLE_ERROR", error: errorValue(value.error, "RETRYABLE_ERROR") };
|
|
1798
|
+
return { status: "REJECTED_PERMANENT", result: value };
|
|
1799
|
+
}
|
|
1800
|
+
return { status: "ACCEPTED", result: value };
|
|
1801
|
+
}
|
|
1802
|
+
if ("status" in value) {
|
|
1803
|
+
if (value.status === "ACCEPTED") return value;
|
|
1804
|
+
return value;
|
|
1805
|
+
}
|
|
1806
|
+
return { status: "ACCEPTED", result: value };
|
|
1575
1807
|
}
|
|
1576
1808
|
async resolveConflict(operation, localState, serverState) {
|
|
1577
1809
|
const configured = this.#onSyncConflict;
|
|
@@ -2314,21 +2546,112 @@ function validate(value, isPublic) {
|
|
|
2314
2546
|
optionalString(value, "staffPasscode", "$", errors);
|
|
2315
2547
|
if (hasOwn(value, "inventory") && value.inventory !== void 0 && !isRecord3(value.inventory))
|
|
2316
2548
|
add(errors, "$.inventory", "Expected an object.", "invalid_type");
|
|
2549
|
+
if (hasOwn(value, "inventoryMode") && value.inventoryMode !== void 0 && value.inventoryMode !== "shared" && value.inventoryMode !== "per_reward")
|
|
2550
|
+
add(errors, "$.inventoryMode", "Expected shared or per_reward.", "invalid_enum");
|
|
2317
2551
|
if (hasOwn(value, "serverMetadata") && value.serverMetadata !== void 0 && !isRecord3(value.serverMetadata))
|
|
2318
2552
|
add(errors, "$.serverMetadata", "Expected an object.", "invalid_type");
|
|
2319
2553
|
if (hasOwn(value, "publicMetadata") && value.publicMetadata !== void 0 && !isRecord3(value.publicMetadata))
|
|
2320
2554
|
add(errors, "$.publicMetadata", "Expected an object.", "invalid_type");
|
|
2321
2555
|
optionalString(value, "serverEndpoint", "$", errors);
|
|
2322
2556
|
} else {
|
|
2323
|
-
for (const key of ["staffPasscode", "serverMetadata", "inventory"])
|
|
2557
|
+
for (const key of ["staffPasscode", "serverMetadata", "inventory", "inventoryMode"])
|
|
2324
2558
|
if (hasOwn(value, key))
|
|
2325
2559
|
add(errors, `$.${key}`, "Private field is not allowed.", "private_field");
|
|
2326
2560
|
optionalString(value, "serverEndpoint", "$", errors);
|
|
2327
2561
|
}
|
|
2328
2562
|
return errors;
|
|
2329
2563
|
}
|
|
2564
|
+
function validateRallyConfigRelations(config) {
|
|
2565
|
+
const errors = [];
|
|
2566
|
+
const spotIds = /* @__PURE__ */ new Set();
|
|
2567
|
+
const rewardIds = /* @__PURE__ */ new Set();
|
|
2568
|
+
const orderIndexes = /* @__PURE__ */ new Map();
|
|
2569
|
+
config.spots.forEach((spot2, index) => {
|
|
2570
|
+
if (spotIds.has(spot2.id))
|
|
2571
|
+
add(errors, `spots[${index}].id`, "Spot ID must be unique.", "duplicate_spot_id");
|
|
2572
|
+
spotIds.add(spot2.id);
|
|
2573
|
+
const previousIndex = orderIndexes.get(spot2.orderIndex);
|
|
2574
|
+
if (previousIndex !== void 0)
|
|
2575
|
+
add(
|
|
2576
|
+
errors,
|
|
2577
|
+
`spots[${index}].orderIndex`,
|
|
2578
|
+
`orderIndex duplicates spots[${previousIndex}].`,
|
|
2579
|
+
"duplicate_order_index"
|
|
2580
|
+
);
|
|
2581
|
+
else orderIndexes.set(spot2.orderIndex, index);
|
|
2582
|
+
if (spot2.orderIndex < 0)
|
|
2583
|
+
add(
|
|
2584
|
+
errors,
|
|
2585
|
+
`spots[${index}].orderIndex`,
|
|
2586
|
+
"orderIndex must not be negative.",
|
|
2587
|
+
"negative_order_index"
|
|
2588
|
+
);
|
|
2589
|
+
spot2.prerequisites?.forEach((prerequisite, prerequisiteIndex) => {
|
|
2590
|
+
if (!spotIds.has(prerequisite) && !config.spots.some((candidate) => candidate.id === prerequisite))
|
|
2591
|
+
add(
|
|
2592
|
+
errors,
|
|
2593
|
+
`spots[${index}].prerequisites[${prerequisiteIndex}]`,
|
|
2594
|
+
"Prerequisite spot does not exist.",
|
|
2595
|
+
"missing_prerequisite"
|
|
2596
|
+
);
|
|
2597
|
+
});
|
|
2598
|
+
});
|
|
2599
|
+
config.rewards.forEach((reward2, index) => {
|
|
2600
|
+
if (rewardIds.has(reward2.id))
|
|
2601
|
+
add(errors, `rewards[${index}].id`, "Reward ID must be unique.", "duplicate_reward_id");
|
|
2602
|
+
rewardIds.add(reward2.id);
|
|
2603
|
+
const visit = (condition2, path) => {
|
|
2604
|
+
if (condition2.type === "stamps")
|
|
2605
|
+
condition2.stampIds.forEach((stampId, stampIndex) => {
|
|
2606
|
+
if (!spotIds.has(stampId))
|
|
2607
|
+
add(
|
|
2608
|
+
errors,
|
|
2609
|
+
`${path}.stampIds[${stampIndex}]`,
|
|
2610
|
+
"Referenced spot does not exist.",
|
|
2611
|
+
"missing_reward_spot"
|
|
2612
|
+
);
|
|
2613
|
+
});
|
|
2614
|
+
else if (condition2.type === "all" || condition2.type === "any")
|
|
2615
|
+
condition2.conditions.forEach((nested, nestedIndex) => {
|
|
2616
|
+
visit(nested, `${path}.conditions[${nestedIndex}]`);
|
|
2617
|
+
});
|
|
2618
|
+
};
|
|
2619
|
+
reward2.conditions?.forEach((condition2, conditionIndex) => {
|
|
2620
|
+
visit(condition2, `rewards[${index}].conditions[${conditionIndex}]`);
|
|
2621
|
+
});
|
|
2622
|
+
});
|
|
2623
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
2624
|
+
const visited = /* @__PURE__ */ new Set();
|
|
2625
|
+
const cycleNodes = /* @__PURE__ */ new Set();
|
|
2626
|
+
const visitSpot = (spotId) => {
|
|
2627
|
+
if (visiting.has(spotId)) {
|
|
2628
|
+
cycleNodes.add(spotId);
|
|
2629
|
+
return;
|
|
2630
|
+
}
|
|
2631
|
+
if (visited.has(spotId)) return;
|
|
2632
|
+
visiting.add(spotId);
|
|
2633
|
+
const spot2 = config.spots.find((candidate) => candidate.id === spotId);
|
|
2634
|
+
spot2?.prerequisites?.forEach(visitSpot);
|
|
2635
|
+
visiting.delete(spotId);
|
|
2636
|
+
visited.add(spotId);
|
|
2637
|
+
};
|
|
2638
|
+
config.spots.forEach((spot2) => {
|
|
2639
|
+
visitSpot(spot2.id);
|
|
2640
|
+
});
|
|
2641
|
+
cycleNodes.forEach((spotId) => {
|
|
2642
|
+
const index = config.spots.findIndex((spot2) => spot2.id === spotId);
|
|
2643
|
+
add(
|
|
2644
|
+
errors,
|
|
2645
|
+
`spots[${index}].prerequisites`,
|
|
2646
|
+
"Prerequisites must form a DAG.",
|
|
2647
|
+
"cyclic_prerequisites"
|
|
2648
|
+
);
|
|
2649
|
+
});
|
|
2650
|
+
return errors;
|
|
2651
|
+
}
|
|
2330
2652
|
function safeParseAdminConfig(input) {
|
|
2331
|
-
const errors = validate(input, false);
|
|
2653
|
+
const errors = [...validate(input, false)];
|
|
2654
|
+
if (errors.length === 0) errors.push(...validateRallyConfigRelations(input));
|
|
2332
2655
|
return errors.length === 0 ? { success: true, data: input } : { success: false, errors };
|
|
2333
2656
|
}
|
|
2334
2657
|
function parseAdminConfig(input) {
|
|
@@ -2337,7 +2660,8 @@ function parseAdminConfig(input) {
|
|
|
2337
2660
|
return result.data;
|
|
2338
2661
|
}
|
|
2339
2662
|
function safeParsePublicConfig(input) {
|
|
2340
|
-
const errors = validate(input, true);
|
|
2663
|
+
const errors = [...validate(input, true)];
|
|
2664
|
+
if (errors.length === 0) errors.push(...validateRallyConfigRelations(input));
|
|
2341
2665
|
return errors.length === 0 ? { success: true, data: input } : { success: false, errors };
|
|
2342
2666
|
}
|
|
2343
2667
|
function parsePublicConfig(input) {
|
|
@@ -2486,9 +2810,11 @@ exports.createSignedSnapshotToken = createSignedSnapshotToken;
|
|
|
2486
2810
|
exports.createUniqueClaimTicketNumber = createUniqueClaimTicketNumber;
|
|
2487
2811
|
exports.evaluateCondition = evaluateCondition;
|
|
2488
2812
|
exports.evaluateConditionDetailed = evaluateConditionDetailed;
|
|
2813
|
+
exports.evaluateSpotStatus = evaluateSpotStatus;
|
|
2489
2814
|
exports.exportProgressToken = exportProgressToken;
|
|
2490
2815
|
exports.getCurrentGeoContext = getCurrentGeoContext;
|
|
2491
2816
|
exports.getOrderedSpots = getOrderedSpots;
|
|
2817
|
+
exports.getSpotStatus = getSpotStatus;
|
|
2492
2818
|
exports.importProgressToken = importProgressToken;
|
|
2493
2819
|
exports.isGeolocationSupported = isGeolocationSupported;
|
|
2494
2820
|
exports.isNfcSupported = isNfcSupported;
|
|
@@ -2514,6 +2840,7 @@ exports.toLocalizedString = toLocalizedString;
|
|
|
2514
2840
|
exports.toPublicConfig = toPublicConfig;
|
|
2515
2841
|
exports.updateLocalizedField = updateLocalizedField;
|
|
2516
2842
|
exports.validatePublicConfigSafety = validatePublicConfigSafety;
|
|
2843
|
+
exports.validateRallyConfigRelations = validateRallyConfigRelations;
|
|
2517
2844
|
exports.verifyPasscode = verifyPasscode;
|
|
2518
2845
|
exports.verifySecureToken = verifySecureToken;
|
|
2519
2846
|
exports.verifySnapshotToken = verifySnapshotToken;
|