@stamprally/core 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/index.cjs +152 -25
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +42 -4
- package/dist/index.d.ts +42 -4
- package/dist/index.js +151 -26
- 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) {
|
|
@@ -117,24 +125,53 @@ function mergeRewardStates(serverRewards, localRewards) {
|
|
|
117
125
|
merged.set(localReward.rewardId, localReward);
|
|
118
126
|
continue;
|
|
119
127
|
}
|
|
120
|
-
|
|
121
|
-
|
|
128
|
+
const winner = localReward.status === "CONSUMED" && serverReward.status !== "CONSUMED" ? localReward : serverReward;
|
|
129
|
+
merged.set(localReward.rewardId, {
|
|
130
|
+
...winner,
|
|
131
|
+
...serverReward.unlockedAt === void 0 && localReward.unlockedAt === void 0 ? {} : {
|
|
132
|
+
unlockedAt: serverReward.unlockedAt === void 0 ? localReward.unlockedAt : localReward.unlockedAt === void 0 ? serverReward.unlockedAt : latestTimestamp(serverReward.unlockedAt, localReward.unlockedAt)
|
|
133
|
+
},
|
|
134
|
+
...serverReward.consumedAt === void 0 && localReward.consumedAt === void 0 ? {} : {
|
|
135
|
+
consumedAt: serverReward.consumedAt === void 0 ? localReward.consumedAt : localReward.consumedAt === void 0 ? serverReward.consumedAt : latestTimestamp(serverReward.consumedAt, localReward.consumedAt)
|
|
136
|
+
},
|
|
137
|
+
...serverReward.redeemedCount === void 0 && localReward.redeemedCount === void 0 ? {} : {
|
|
138
|
+
redeemedCount: Math.max(
|
|
139
|
+
serverReward.redeemedCount ?? 0,
|
|
140
|
+
localReward.redeemedCount ?? 0
|
|
141
|
+
)
|
|
142
|
+
},
|
|
143
|
+
...serverReward.userRedemptionCount === void 0 && localReward.userRedemptionCount === void 0 ? {} : {
|
|
144
|
+
userRedemptionCount: Math.max(
|
|
145
|
+
serverReward.userRedemptionCount ?? 0,
|
|
146
|
+
localReward.userRedemptionCount ?? 0
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
});
|
|
122
150
|
}
|
|
123
151
|
return [...merged.values()];
|
|
124
152
|
}
|
|
125
|
-
function
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
knownStamps.add(record.stampId);
|
|
153
|
+
function mergeStampRecords(serverRecords, localRecords) {
|
|
154
|
+
const merged = /* @__PURE__ */ new Map();
|
|
155
|
+
for (const record of [...serverRecords, ...localRecords]) {
|
|
156
|
+
const current = merged.get(record.stampId);
|
|
157
|
+
if (current === void 0) {
|
|
158
|
+
merged.set(record.stampId, record);
|
|
159
|
+
continue;
|
|
133
160
|
}
|
|
161
|
+
const acquiredAt = latestTimestamp(current.acquiredAt, record.acquiredAt);
|
|
162
|
+
merged.set(record.stampId, {
|
|
163
|
+
...current,
|
|
164
|
+
...acquiredAt === current.acquiredAt ? {} : { acquiredAt },
|
|
165
|
+
...current.metadata === void 0 && record.metadata !== void 0 ? { metadata: record.metadata } : {}
|
|
166
|
+
});
|
|
134
167
|
}
|
|
168
|
+
return [...merged.values()];
|
|
169
|
+
}
|
|
170
|
+
function resolveRallyStateConflict(serverState, localState, options = { policy: "merge" }) {
|
|
171
|
+
if (options.policy === "server_wins") return serverState;
|
|
135
172
|
return {
|
|
136
173
|
...serverState,
|
|
137
|
-
records,
|
|
174
|
+
records: mergeStampRecords(serverState.records, localState.records),
|
|
138
175
|
rewards: mergeRewardStates(serverState.rewards, localState.rewards),
|
|
139
176
|
updatedAt: latestTimestamp(serverState.updatedAt, localState.updatedAt)
|
|
140
177
|
};
|
|
@@ -1072,7 +1109,10 @@ var StampRallyClient = class {
|
|
|
1072
1109
|
initialize() {
|
|
1073
1110
|
if (this.#state !== null) return Promise.resolve(this.#state);
|
|
1074
1111
|
if (this.#initialization === null) {
|
|
1075
|
-
this.#initialization =
|
|
1112
|
+
this.#initialization = (async () => {
|
|
1113
|
+
await this.#offlineQueue?.setScope(this.#config.id, this.#userId);
|
|
1114
|
+
return this.#storage.load(this.#config.id, this.#userId);
|
|
1115
|
+
})().then((state) => {
|
|
1076
1116
|
const next = state === null ? emptyState(this.#config, this.#userId, this.#now()) : this.#reconcile(state);
|
|
1077
1117
|
this.#state = next;
|
|
1078
1118
|
this.#emit(next);
|
|
@@ -1090,6 +1130,7 @@ var StampRallyClient = class {
|
|
|
1090
1130
|
this.#userId = newUserId;
|
|
1091
1131
|
this.#state = null;
|
|
1092
1132
|
this.#initialization = null;
|
|
1133
|
+
await this.#offlineQueue?.switchUser(newUserId);
|
|
1093
1134
|
return this.initialize();
|
|
1094
1135
|
});
|
|
1095
1136
|
}
|
|
@@ -1338,7 +1379,8 @@ var StampRallyClient = class {
|
|
|
1338
1379
|
this.#state = next;
|
|
1339
1380
|
this.#emit(next);
|
|
1340
1381
|
}
|
|
1341
|
-
if ("
|
|
1382
|
+
if (event.error !== void 0) this.#emitEvent({ type: "error", error: event.error });
|
|
1383
|
+
else if (event.result !== void 0 && "ok" in event.result && !event.result.ok)
|
|
1342
1384
|
this.#emitEvent({ type: "error", error: event.result.error });
|
|
1343
1385
|
}
|
|
1344
1386
|
#now() {
|
|
@@ -1466,9 +1508,27 @@ function defaultStorage(databaseName) {
|
|
|
1466
1508
|
function operationId(operation) {
|
|
1467
1509
|
return operation.kind === "checkIn" ? `checkIn:${operation.request.rallyId}:${operation.request.userId ?? "anonymous"}:${operation.request.idempotencyKey}` : `claimReward:${operation.request.rallyId}:${operation.request.userId ?? "anonymous"}:${operation.request.idempotencyKey}`;
|
|
1468
1510
|
}
|
|
1511
|
+
function requestScope(operation) {
|
|
1512
|
+
return {
|
|
1513
|
+
rallyId: operation.request.rallyId,
|
|
1514
|
+
userId: operation.request.userId
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
function errorValue(value, fallbackCode) {
|
|
1518
|
+
if (typeof value === "object" && value !== null) {
|
|
1519
|
+
const candidate = value;
|
|
1520
|
+
if (typeof candidate.code === "string" && typeof candidate.message === "string")
|
|
1521
|
+
return { ...candidate, code: candidate.code, message: candidate.message };
|
|
1522
|
+
}
|
|
1523
|
+
if (value instanceof Error) return { code: fallbackCode, message: value.message };
|
|
1524
|
+
if (typeof value === "string") return { code: fallbackCode, message: value };
|
|
1525
|
+
return { code: fallbackCode, message: "Offline operation was rejected." };
|
|
1526
|
+
}
|
|
1469
1527
|
var OfflineQueue = class {
|
|
1470
1528
|
#storage;
|
|
1471
|
-
#
|
|
1529
|
+
#configuredKey;
|
|
1530
|
+
#rallyId;
|
|
1531
|
+
#userId;
|
|
1472
1532
|
#conflictPolicy;
|
|
1473
1533
|
#onSyncConflict;
|
|
1474
1534
|
#operations = [];
|
|
@@ -1483,7 +1543,9 @@ var OfflineQueue = class {
|
|
|
1483
1543
|
else if (options.storageLike !== void 0 && options.storageLike !== null)
|
|
1484
1544
|
this.#storage = new LocalStorageQueueStorage(options.storageLike);
|
|
1485
1545
|
else this.#storage = defaultStorage(options.databaseName);
|
|
1486
|
-
this.#
|
|
1546
|
+
this.#configuredKey = options.key;
|
|
1547
|
+
this.#rallyId = options.rallyId;
|
|
1548
|
+
this.#userId = options.userId ?? null;
|
|
1487
1549
|
this.#conflictPolicy = options.conflictPolicy ?? "server_wins";
|
|
1488
1550
|
this.#onSyncConflict = options.onSyncConflict;
|
|
1489
1551
|
}
|
|
@@ -1502,23 +1564,57 @@ var OfflineQueue = class {
|
|
|
1502
1564
|
get conflictPolicy() {
|
|
1503
1565
|
return this.#conflictPolicy;
|
|
1504
1566
|
}
|
|
1567
|
+
get storageKey() {
|
|
1568
|
+
return this.#storageKey();
|
|
1569
|
+
}
|
|
1570
|
+
get rallyId() {
|
|
1571
|
+
return this.#rallyId;
|
|
1572
|
+
}
|
|
1573
|
+
get userId() {
|
|
1574
|
+
return this.#userId;
|
|
1575
|
+
}
|
|
1505
1576
|
setSyncResultListener(listener) {
|
|
1506
1577
|
this.#syncResultListener = listener;
|
|
1507
1578
|
}
|
|
1508
1579
|
async initialize() {
|
|
1509
1580
|
if (this.#loaded) return;
|
|
1510
|
-
this.#operations = [...await this.#storage.load(this.#
|
|
1581
|
+
this.#operations = [...await this.#storage.load(this.#storageKey())];
|
|
1511
1582
|
this.#loaded = true;
|
|
1512
1583
|
}
|
|
1584
|
+
/** Selects a rally/user queue scope and loads its pending operations. */
|
|
1585
|
+
async setScope(rallyId, userId) {
|
|
1586
|
+
if (this.#configuredKey !== void 0) {
|
|
1587
|
+
this.#rallyId = rallyId;
|
|
1588
|
+
this.#userId = userId;
|
|
1589
|
+
return this.initialize();
|
|
1590
|
+
}
|
|
1591
|
+
if (this.#rallyId === rallyId && this.#userId === userId && this.#loaded) return;
|
|
1592
|
+
this.#rallyId = rallyId;
|
|
1593
|
+
this.#userId = userId;
|
|
1594
|
+
this.#operations = [];
|
|
1595
|
+
this.#loaded = false;
|
|
1596
|
+
await this.initialize();
|
|
1597
|
+
}
|
|
1598
|
+
async switchUser(newUserId) {
|
|
1599
|
+
if (this.#rallyId === void 0)
|
|
1600
|
+
throw new Error("OfflineQueue.switchUser requires a rally scope.");
|
|
1601
|
+
await this.setScope(this.#rallyId, newUserId);
|
|
1602
|
+
}
|
|
1513
1603
|
setSender(sender) {
|
|
1514
1604
|
this.#sender = sender;
|
|
1515
1605
|
}
|
|
1516
1606
|
async enqueue(operation) {
|
|
1607
|
+
if (this.#configuredKey === void 0) {
|
|
1608
|
+
const scope = requestScope(operation);
|
|
1609
|
+
if (this.#rallyId === void 0) await this.setScope(scope.rallyId, scope.userId);
|
|
1610
|
+
if (this.#rallyId !== scope.rallyId || this.#userId !== scope.userId)
|
|
1611
|
+
throw new Error("Offline operation belongs to another rally or user queue.");
|
|
1612
|
+
}
|
|
1517
1613
|
await this.initialize();
|
|
1518
1614
|
const id2 = operationId(operation);
|
|
1519
1615
|
if (this.#operations.some((item) => operationId(item) === id2)) return;
|
|
1520
1616
|
this.#operations = [...this.#operations, operation];
|
|
1521
|
-
await this.#storage.save(this.#
|
|
1617
|
+
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1522
1618
|
}
|
|
1523
1619
|
async enqueueCheckIn(request) {
|
|
1524
1620
|
return this.enqueue({ kind: "checkIn", request });
|
|
@@ -1529,7 +1625,7 @@ var OfflineQueue = class {
|
|
|
1529
1625
|
async clear() {
|
|
1530
1626
|
await this.initialize();
|
|
1531
1627
|
this.#operations = [];
|
|
1532
|
-
await this.#storage.save(this.#
|
|
1628
|
+
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1533
1629
|
}
|
|
1534
1630
|
async sync(sender = this.#sender) {
|
|
1535
1631
|
await this.initialize();
|
|
@@ -1551,20 +1647,30 @@ var OfflineQueue = class {
|
|
|
1551
1647
|
while (this.#operations.length > 0) {
|
|
1552
1648
|
const operation = this.#operations[0];
|
|
1553
1649
|
if (operation === void 0) break;
|
|
1554
|
-
let
|
|
1650
|
+
let rawResult;
|
|
1555
1651
|
try {
|
|
1556
|
-
|
|
1652
|
+
rawResult = await sender(operation);
|
|
1557
1653
|
} catch (cause) {
|
|
1558
|
-
throw
|
|
1654
|
+
throw new Error(errorValue(cause, "RETRYABLE_ERROR").message);
|
|
1655
|
+
}
|
|
1656
|
+
const response = this.#normalizeResponse(rawResult);
|
|
1657
|
+
if (response.status === "RETRYABLE_ERROR") {
|
|
1658
|
+
const error2 = errorValue(response.error ?? response.reason, "RETRYABLE_ERROR");
|
|
1659
|
+
await this.#syncResultListener?.({ operation, status: response.status, error: error2 });
|
|
1660
|
+
throw new Error(error2.message);
|
|
1559
1661
|
}
|
|
1560
|
-
const
|
|
1662
|
+
const result = response.result;
|
|
1663
|
+
const state = response.state ?? (result !== void 0 && "conflict" in result && result.conflict === true ? await this.resolveConflict(operation, result.localState, result.serverState) : result !== void 0 && "ok" in result && result.ok ? result.value.state : void 0);
|
|
1664
|
+
const error = response.status === "REJECTED_PERMANENT" ? errorValue(response.error ?? response.reason, "REJECTED_PERMANENT") : void 0;
|
|
1665
|
+
this.#operations = this.#operations.slice(1);
|
|
1666
|
+
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1561
1667
|
await this.#syncResultListener?.({
|
|
1562
1668
|
operation,
|
|
1563
|
-
result,
|
|
1669
|
+
...result === void 0 ? {} : { result },
|
|
1670
|
+
status: response.status,
|
|
1671
|
+
...error === void 0 ? {} : { error },
|
|
1564
1672
|
...state === void 0 ? {} : { state }
|
|
1565
1673
|
});
|
|
1566
|
-
this.#operations = this.#operations.slice(1);
|
|
1567
|
-
await this.#storage.save(this.#key, this.#operations);
|
|
1568
1674
|
}
|
|
1569
1675
|
this.#state = "idle";
|
|
1570
1676
|
} catch (cause) {
|
|
@@ -1573,6 +1679,25 @@ var OfflineQueue = class {
|
|
|
1573
1679
|
throw this.#error;
|
|
1574
1680
|
}
|
|
1575
1681
|
}
|
|
1682
|
+
#storageKey() {
|
|
1683
|
+
if (this.#configuredKey !== void 0) return this.#configuredKey;
|
|
1684
|
+
return `stamprally:queue:${this.#rallyId ?? "unscoped"}:${this.#userId ?? "anonymous"}`;
|
|
1685
|
+
}
|
|
1686
|
+
#normalizeResponse(value) {
|
|
1687
|
+
if ("ok" in value) {
|
|
1688
|
+
if (value.ok === false) {
|
|
1689
|
+
if ("status" in value && value.status === "RETRYABLE_ERROR")
|
|
1690
|
+
return { status: "RETRYABLE_ERROR", error: errorValue(value.error, "RETRYABLE_ERROR") };
|
|
1691
|
+
return { status: "REJECTED_PERMANENT", result: value };
|
|
1692
|
+
}
|
|
1693
|
+
return { status: "ACCEPTED", result: value };
|
|
1694
|
+
}
|
|
1695
|
+
if ("status" in value) {
|
|
1696
|
+
if (value.status === "ACCEPTED") return value;
|
|
1697
|
+
return value;
|
|
1698
|
+
}
|
|
1699
|
+
return { status: "ACCEPTED", result: value };
|
|
1700
|
+
}
|
|
1576
1701
|
async resolveConflict(operation, localState, serverState) {
|
|
1577
1702
|
const configured = this.#onSyncConflict;
|
|
1578
1703
|
const policy = (typeof configured === "function" ? await configured({ operation, localState, serverState }) : configured) ?? this.#conflictPolicy;
|
|
@@ -2486,9 +2611,11 @@ exports.createSignedSnapshotToken = createSignedSnapshotToken;
|
|
|
2486
2611
|
exports.createUniqueClaimTicketNumber = createUniqueClaimTicketNumber;
|
|
2487
2612
|
exports.evaluateCondition = evaluateCondition;
|
|
2488
2613
|
exports.evaluateConditionDetailed = evaluateConditionDetailed;
|
|
2614
|
+
exports.evaluateSpotStatus = evaluateSpotStatus;
|
|
2489
2615
|
exports.exportProgressToken = exportProgressToken;
|
|
2490
2616
|
exports.getCurrentGeoContext = getCurrentGeoContext;
|
|
2491
2617
|
exports.getOrderedSpots = getOrderedSpots;
|
|
2618
|
+
exports.getSpotStatus = getSpotStatus;
|
|
2492
2619
|
exports.importProgressToken = importProgressToken;
|
|
2493
2620
|
exports.isGeolocationSupported = isGeolocationSupported;
|
|
2494
2621
|
exports.isNfcSupported = isNfcSupported;
|