@stamprally/core 0.13.0 → 0.15.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 +7 -3
- package/dist/index.cjs +344 -30
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +40 -3
- package/dist/index.d.ts +40 -3
- package/dist/index.js +343 -31
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# @stamprally/core
|
|
1
|
+
# @stamprally/core v0.15.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.
|
|
10
|
+
version: "0.15.0",
|
|
11
11
|
title: "City Tour",
|
|
12
12
|
spots: [{ id: "station", orderIndex: 0, name: "Central Station", conditions: [{ type: "passcode" }] }],
|
|
13
13
|
rewards: [],
|
|
@@ -23,7 +23,7 @@ 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.
|
|
26
|
+
## v0.15.0 offline synchronization
|
|
27
27
|
|
|
28
28
|
Configure `OfflineQueue` with `rallyId` and `userId` to persist pending work under
|
|
29
29
|
`stamprally:queue:<rallyId>:<userId-or-anonymous>`. `switchUser` loads the other
|
|
@@ -32,6 +32,10 @@ timestamps, and gives `CONSUMED` reward states priority; `server_wins` uses the
|
|
|
32
32
|
server state unchanged. Sync adapters may return `ACCEPTED`, `REJECTED_PERMANENT`,
|
|
33
33
|
or `RETRYABLE_ERROR`; only the first two remove an operation.
|
|
34
34
|
|
|
35
|
+
When no authenticated user is supplied, the client creates a persistent UUID v4
|
|
36
|
+
`anonymousSessionId` and includes it in sync requests. Queue replay uses Web Locks
|
|
37
|
+
when available and supports bounded exponential backoff through `retryOptions`.
|
|
38
|
+
|
|
35
39
|
`evaluateSpotStatus` derives `UNCLAIMED`, `CLAIMED`, `LOCKED`, or `VERIFYING`
|
|
36
40
|
without mutating state. A spot with incomplete `prerequisites` is `LOCKED` and
|
|
37
41
|
must not be verified by a client or viewer.
|
package/dist/index.cjs
CHANGED
|
@@ -61,7 +61,9 @@ function calculateProgress(state, config) {
|
|
|
61
61
|
const acquired = new Set(
|
|
62
62
|
state.records.map((record) => record.stampId).filter((id2) => ids.has(id2))
|
|
63
63
|
);
|
|
64
|
-
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
|
+
);
|
|
65
67
|
return {
|
|
66
68
|
acquired: acquired.size,
|
|
67
69
|
total: config.spots.length,
|
|
@@ -615,7 +617,13 @@ function cloneState(state) {
|
|
|
615
617
|
return {
|
|
616
618
|
...state,
|
|
617
619
|
records: state.records.map(cloneRecord),
|
|
618
|
-
...state.rewards === void 0 ? {} : { rewards: state.rewards.map(cloneRewardState) }
|
|
620
|
+
...state.rewards === void 0 ? {} : { rewards: state.rewards.map(cloneRewardState) },
|
|
621
|
+
...state.inventory === void 0 ? {} : {
|
|
622
|
+
inventory: {
|
|
623
|
+
...state.inventory,
|
|
624
|
+
...state.inventory.rewardRemaining === void 0 ? {} : { rewardRemaining: { ...state.inventory.rewardRemaining } }
|
|
625
|
+
}
|
|
626
|
+
}
|
|
619
627
|
};
|
|
620
628
|
}
|
|
621
629
|
function isRecord(value) {
|
|
@@ -635,7 +643,7 @@ function isRewardState(value) {
|
|
|
635
643
|
function isStampRallyState(value) {
|
|
636
644
|
if (typeof value !== "object" || value === null) return false;
|
|
637
645
|
const state = value;
|
|
638
|
-
return typeof state.rallyId === "string" && (typeof state.userId === "string" || state.userId === null) && typeof state.updatedAt === "string" && Array.isArray(state.records) && state.records.every(isRecord) && (state.rewards === void 0 || Array.isArray(state.rewards) && state.rewards.every(isRewardState));
|
|
646
|
+
return typeof state.rallyId === "string" && (typeof state.userId === "string" || state.userId === null) && typeof state.updatedAt === "string" && Array.isArray(state.records) && state.records.every(isRecord) && (state.rewards === void 0 || Array.isArray(state.rewards) && state.rewards.every(isRewardState)) && (state.inventory === void 0 || typeof state.inventory === "object" && state.inventory !== null && !Array.isArray(state.inventory) && (state.inventory.sharedRemaining === void 0 || typeof state.inventory.sharedRemaining === "number" && Number.isInteger(state.inventory.sharedRemaining) && state.inventory.sharedRemaining >= 0) && (state.inventory.rewardRemaining === void 0 || typeof state.inventory.rewardRemaining === "object" && state.inventory.rewardRemaining !== null && !Array.isArray(state.inventory.rewardRemaining)));
|
|
639
647
|
}
|
|
640
648
|
function isValidDate(value) {
|
|
641
649
|
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
|
@@ -680,6 +688,35 @@ var InMemoryStorage = class {
|
|
|
680
688
|
function storageKey(rallyId, userId) {
|
|
681
689
|
return `stamprally:${rallyId}:${userId ?? "anonymous"}`;
|
|
682
690
|
}
|
|
691
|
+
function createAnonymousSessionId(storage) {
|
|
692
|
+
const key = "stamprally:anonymous-session-id";
|
|
693
|
+
try {
|
|
694
|
+
const browserStorage = typeof window === "undefined" ? null : window.localStorage;
|
|
695
|
+
const value = storage?.getItem(key) ?? browserStorage?.getItem(key);
|
|
696
|
+
if (value !== null && value !== void 0 && isUuidV4(value)) return value;
|
|
697
|
+
const generated = randomUuidV4();
|
|
698
|
+
(storage ?? browserStorage)?.setItem(key, generated);
|
|
699
|
+
return generated;
|
|
700
|
+
} catch {
|
|
701
|
+
return randomUuidV4();
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
function isUuidV4(value) {
|
|
705
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
|
706
|
+
}
|
|
707
|
+
function randomUuidV4() {
|
|
708
|
+
const cryptoApi3 = globalThis.crypto;
|
|
709
|
+
if (cryptoApi3?.randomUUID !== void 0) return cryptoApi3.randomUUID();
|
|
710
|
+
if (cryptoApi3?.getRandomValues !== void 0) {
|
|
711
|
+
const bytes2 = cryptoApi3.getRandomValues(new Uint8Array(16));
|
|
712
|
+
bytes2[6] = (bytes2[6] ?? 0) & 15 | 64;
|
|
713
|
+
bytes2[8] = (bytes2[8] ?? 0) & 63 | 128;
|
|
714
|
+
const hex = Array.from(bytes2, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
715
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
716
|
+
}
|
|
717
|
+
const random = `${Date.now().toString(16)}${Math.random().toString(16).slice(2)}`.padEnd(32, "0").slice(0, 32);
|
|
718
|
+
return `${random.slice(0, 8)}-${random.slice(8, 12)}-4${random.slice(13, 16)}-8${random.slice(17, 20)}-${random.slice(20)}`;
|
|
719
|
+
}
|
|
683
720
|
var defaultStorageWarningHandler = (error) => {
|
|
684
721
|
console.warn(`[@stamprally/core] ${error.message}`, error);
|
|
685
722
|
};
|
|
@@ -1069,6 +1106,7 @@ var StampRallyClient = class {
|
|
|
1069
1106
|
#config;
|
|
1070
1107
|
#offlineQueue;
|
|
1071
1108
|
#userId;
|
|
1109
|
+
#anonymousSessionId;
|
|
1072
1110
|
#state = null;
|
|
1073
1111
|
#initialization = null;
|
|
1074
1112
|
#queue = Promise.resolve();
|
|
@@ -1076,7 +1114,8 @@ var StampRallyClient = class {
|
|
|
1076
1114
|
this.#config = config;
|
|
1077
1115
|
this.#options = isStorage(storageOrOptions) ? { storage: storageOrOptions } : storageOrOptions;
|
|
1078
1116
|
this.#storage = this.#options.storage ?? new InMemoryStorage();
|
|
1079
|
-
this.#
|
|
1117
|
+
this.#anonymousSessionId = this.#options.anonymousSessionId ?? createAnonymousSessionId();
|
|
1118
|
+
this.#userId = this.#options.userId ?? this.#anonymousSessionId;
|
|
1080
1119
|
this.#offlineQueue = this.#options.offlineQueue;
|
|
1081
1120
|
this.#offlineQueue?.setSyncResultListener((event) => this.#handleOfflineSyncResult(event));
|
|
1082
1121
|
}
|
|
@@ -1089,6 +1128,9 @@ var StampRallyClient = class {
|
|
|
1089
1128
|
getUserId() {
|
|
1090
1129
|
return this.#userId;
|
|
1091
1130
|
}
|
|
1131
|
+
getAnonymousSessionId() {
|
|
1132
|
+
return this.#anonymousSessionId;
|
|
1133
|
+
}
|
|
1092
1134
|
get syncState() {
|
|
1093
1135
|
return this.#offlineQueue?.syncState ?? "idle";
|
|
1094
1136
|
}
|
|
@@ -1126,11 +1168,12 @@ var StampRallyClient = class {
|
|
|
1126
1168
|
}
|
|
1127
1169
|
switchUser(newUserId) {
|
|
1128
1170
|
return this.#enqueue(async () => {
|
|
1129
|
-
|
|
1130
|
-
this.#userId
|
|
1171
|
+
const nextUserId = newUserId ?? this.#anonymousSessionId;
|
|
1172
|
+
if (this.#userId === nextUserId && this.#state !== null) return this.#state;
|
|
1173
|
+
this.#userId = nextUserId;
|
|
1131
1174
|
this.#state = null;
|
|
1132
1175
|
this.#initialization = null;
|
|
1133
|
-
await this.#offlineQueue?.switchUser(
|
|
1176
|
+
await this.#offlineQueue?.switchUser(nextUserId);
|
|
1134
1177
|
return this.initialize();
|
|
1135
1178
|
});
|
|
1136
1179
|
}
|
|
@@ -1198,7 +1241,8 @@ var StampRallyClient = class {
|
|
|
1198
1241
|
proofData,
|
|
1199
1242
|
idempotencyKey: options.idempotencyKey ?? id("check-in"),
|
|
1200
1243
|
now,
|
|
1201
|
-
state: current
|
|
1244
|
+
state: current,
|
|
1245
|
+
...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
|
|
1202
1246
|
};
|
|
1203
1247
|
const remote = this.#options.syncAdapter?.checkIn;
|
|
1204
1248
|
if (options.sync !== false && remote !== void 0) {
|
|
@@ -1254,7 +1298,8 @@ var StampRallyClient = class {
|
|
|
1254
1298
|
idempotencyKey: options.idempotencyKey ?? id("claim"),
|
|
1255
1299
|
now,
|
|
1256
1300
|
options,
|
|
1257
|
-
state: current
|
|
1301
|
+
state: current,
|
|
1302
|
+
...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
|
|
1258
1303
|
};
|
|
1259
1304
|
const remote = this.#options.syncAdapter?.claimReward;
|
|
1260
1305
|
if (options.sync !== false && remote !== void 0) {
|
|
@@ -1306,13 +1351,10 @@ var StampRallyClient = class {
|
|
|
1306
1351
|
const serverState = await adapter.sync({
|
|
1307
1352
|
rallyId: this.#config.id,
|
|
1308
1353
|
userId: this.#userId,
|
|
1309
|
-
state: this.#state ?? current
|
|
1310
|
-
|
|
1311
|
-
const localState = this.#state ?? current;
|
|
1312
|
-
const merged = this.#offlineQueue === void 0 ? serverState : resolveRallyStateConflict(serverState, localState, {
|
|
1313
|
-
policy: this.#offlineQueue.conflictPolicy
|
|
1354
|
+
state: this.#state ?? current,
|
|
1355
|
+
...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
|
|
1314
1356
|
});
|
|
1315
|
-
const next = this.#reconcile(
|
|
1357
|
+
const next = this.#reconcile(serverState);
|
|
1316
1358
|
await this.#storage.save(next);
|
|
1317
1359
|
this.#state = next;
|
|
1318
1360
|
this.#emit(next);
|
|
@@ -1524,6 +1566,16 @@ function errorValue(value, fallbackCode) {
|
|
|
1524
1566
|
if (typeof value === "string") return { code: fallbackCode, message: value };
|
|
1525
1567
|
return { code: fallbackCode, message: "Offline operation was rejected." };
|
|
1526
1568
|
}
|
|
1569
|
+
var syncLocks = /* @__PURE__ */ new Map();
|
|
1570
|
+
var SYNC_LOCK_TTL_MS = 3e4;
|
|
1571
|
+
var DEFAULT_RETRY_OPTIONS = {
|
|
1572
|
+
maxRetries: 0,
|
|
1573
|
+
initialIntervalMs: 250,
|
|
1574
|
+
backoffMultiplier: 2
|
|
1575
|
+
};
|
|
1576
|
+
function randomId() {
|
|
1577
|
+
return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
1578
|
+
}
|
|
1527
1579
|
var OfflineQueue = class {
|
|
1528
1580
|
#storage;
|
|
1529
1581
|
#configuredKey;
|
|
@@ -1538,6 +1590,12 @@ var OfflineQueue = class {
|
|
|
1538
1590
|
#sender;
|
|
1539
1591
|
#syncPromise = null;
|
|
1540
1592
|
#syncResultListener;
|
|
1593
|
+
#synchronizeInstances;
|
|
1594
|
+
#retryOptions;
|
|
1595
|
+
#instanceId = randomId();
|
|
1596
|
+
#lockStorage;
|
|
1597
|
+
#storageListener;
|
|
1598
|
+
#channel = null;
|
|
1541
1599
|
constructor(options = {}) {
|
|
1542
1600
|
if (options.storage !== void 0) this.#storage = options.storage;
|
|
1543
1601
|
else if (options.storageLike !== void 0 && options.storageLike !== null)
|
|
@@ -1548,6 +1606,18 @@ var OfflineQueue = class {
|
|
|
1548
1606
|
this.#userId = options.userId ?? null;
|
|
1549
1607
|
this.#conflictPolicy = options.conflictPolicy ?? "server_wins";
|
|
1550
1608
|
this.#onSyncConflict = options.onSyncConflict;
|
|
1609
|
+
this.#synchronizeInstances = options.synchronizeInstances ?? true;
|
|
1610
|
+
const retryOptions = {
|
|
1611
|
+
...DEFAULT_RETRY_OPTIONS,
|
|
1612
|
+
...options.retryOptions ?? options.syncRetryOptions ?? options.retry ?? {}
|
|
1613
|
+
};
|
|
1614
|
+
this.#retryOptions = {
|
|
1615
|
+
maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
|
|
1616
|
+
initialIntervalMs: Math.max(0, retryOptions.initialIntervalMs),
|
|
1617
|
+
backoffMultiplier: Math.max(1, retryOptions.backoffMultiplier)
|
|
1618
|
+
};
|
|
1619
|
+
this.#lockStorage = options.storageLike ?? globalThis.localStorage ?? null;
|
|
1620
|
+
if (this.#synchronizeInstances) this.#subscribeToExternalChanges();
|
|
1551
1621
|
}
|
|
1552
1622
|
get syncState() {
|
|
1553
1623
|
return this.#state;
|
|
@@ -1578,9 +1648,19 @@ var OfflineQueue = class {
|
|
|
1578
1648
|
}
|
|
1579
1649
|
async initialize() {
|
|
1580
1650
|
if (this.#loaded) return;
|
|
1581
|
-
this.#operations =
|
|
1651
|
+
this.#operations = (await this.#storage.load(this.#storageKey())).map(normalizeOperation);
|
|
1582
1652
|
this.#loaded = true;
|
|
1583
1653
|
}
|
|
1654
|
+
/** Releases browser listeners when the queue is no longer used. */
|
|
1655
|
+
dispose() {
|
|
1656
|
+
const windowLike = globalThis.window;
|
|
1657
|
+
if (windowLike !== void 0 && this.#storageListener !== void 0)
|
|
1658
|
+
windowLike.removeEventListener("storage", this.#storageListener);
|
|
1659
|
+
this.#storageListener = void 0;
|
|
1660
|
+
this.#channel?.close();
|
|
1661
|
+
this.#channel = null;
|
|
1662
|
+
this.#releaseSyncLock();
|
|
1663
|
+
}
|
|
1584
1664
|
/** Selects a rally/user queue scope and loads its pending operations. */
|
|
1585
1665
|
async setScope(rallyId, userId) {
|
|
1586
1666
|
if (this.#configuredKey !== void 0) {
|
|
@@ -1613,8 +1693,9 @@ var OfflineQueue = class {
|
|
|
1613
1693
|
await this.initialize();
|
|
1614
1694
|
const id2 = operationId(operation);
|
|
1615
1695
|
if (this.#operations.some((item) => operationId(item) === id2)) return;
|
|
1616
|
-
this.#operations = [...this.#operations, operation];
|
|
1696
|
+
this.#operations = [...this.#operations, { ...operation, status: "PENDING", attempts: 0 }];
|
|
1617
1697
|
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1698
|
+
this.#announceChange();
|
|
1618
1699
|
}
|
|
1619
1700
|
async enqueueCheckIn(request) {
|
|
1620
1701
|
return this.enqueue({ kind: "checkIn", request });
|
|
@@ -1626,6 +1707,7 @@ var OfflineQueue = class {
|
|
|
1626
1707
|
await this.initialize();
|
|
1627
1708
|
this.#operations = [];
|
|
1628
1709
|
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1710
|
+
this.#announceChange();
|
|
1629
1711
|
}
|
|
1630
1712
|
async sync(sender = this.#sender) {
|
|
1631
1713
|
await this.initialize();
|
|
@@ -1641,35 +1723,83 @@ var OfflineQueue = class {
|
|
|
1641
1723
|
return this.sync(sender);
|
|
1642
1724
|
}
|
|
1643
1725
|
async #run(sender) {
|
|
1726
|
+
const locks = globalThis.navigator?.locks;
|
|
1727
|
+
if (locks !== void 0) {
|
|
1728
|
+
let callbackStarted = false;
|
|
1729
|
+
try {
|
|
1730
|
+
const acquired = await locks.request(
|
|
1731
|
+
`stamprally:${this.#storageKey()}:sync`,
|
|
1732
|
+
{ ifAvailable: true },
|
|
1733
|
+
async (lock) => {
|
|
1734
|
+
if (lock === null) {
|
|
1735
|
+
await this.#reloadFromStorage();
|
|
1736
|
+
this.#state = "idle";
|
|
1737
|
+
return false;
|
|
1738
|
+
}
|
|
1739
|
+
callbackStarted = true;
|
|
1740
|
+
await this.#runWithStorageLock(sender);
|
|
1741
|
+
return true;
|
|
1742
|
+
}
|
|
1743
|
+
);
|
|
1744
|
+
if (!acquired) return;
|
|
1745
|
+
return;
|
|
1746
|
+
} catch (error) {
|
|
1747
|
+
if (callbackStarted) throw error;
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
await this.#runWithStorageLock(sender);
|
|
1751
|
+
}
|
|
1752
|
+
async #runWithStorageLock(sender) {
|
|
1644
1753
|
this.#state = "syncing";
|
|
1645
1754
|
this.#error = null;
|
|
1755
|
+
if (!this.#acquireSyncLock()) {
|
|
1756
|
+
await this.#reloadFromStorage();
|
|
1757
|
+
this.#state = "idle";
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1646
1760
|
try {
|
|
1647
1761
|
while (this.#operations.length > 0) {
|
|
1648
1762
|
const operation = this.#operations[0];
|
|
1649
1763
|
if (operation === void 0) break;
|
|
1650
|
-
let
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1764
|
+
let attempt = 0;
|
|
1765
|
+
let response;
|
|
1766
|
+
while (true) {
|
|
1767
|
+
await this.#updateOperationStatus("IN_FLIGHT", attempt);
|
|
1768
|
+
try {
|
|
1769
|
+
response = this.#normalizeResponse(await sender(operation));
|
|
1770
|
+
} catch (cause) {
|
|
1771
|
+
response = {
|
|
1772
|
+
status: "RETRYABLE_ERROR",
|
|
1773
|
+
error: errorValue(cause, "RETRYABLE_ERROR")
|
|
1774
|
+
};
|
|
1775
|
+
}
|
|
1776
|
+
if (response.status !== "RETRYABLE_ERROR") break;
|
|
1658
1777
|
const error2 = errorValue(response.error ?? response.reason, "RETRYABLE_ERROR");
|
|
1778
|
+
await this.#updateOperationStatus("PENDING", attempt + 1);
|
|
1659
1779
|
await this.#syncResultListener?.({ operation, status: response.status, error: error2 });
|
|
1660
|
-
throw new Error(error2.message);
|
|
1780
|
+
if (attempt >= this.#retryOptions.maxRetries) throw new Error(error2.message);
|
|
1781
|
+
const interval = this.#retryOptions.initialIntervalMs * this.#retryOptions.backoffMultiplier ** attempt;
|
|
1782
|
+
await new Promise((resolve) => setTimeout(resolve, Math.max(0, interval)));
|
|
1783
|
+
attempt += 1;
|
|
1661
1784
|
}
|
|
1662
1785
|
const result = response.result;
|
|
1663
1786
|
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
1787
|
const error = response.status === "REJECTED_PERMANENT" ? errorValue(response.error ?? response.reason, "REJECTED_PERMANENT") : void 0;
|
|
1788
|
+
await this.#updateOperationStatus(
|
|
1789
|
+
response.status === "ACCEPTED" ? "ACCEPTED" : "REJECTED",
|
|
1790
|
+
attempt + 1
|
|
1791
|
+
);
|
|
1792
|
+
const fallbackState = response.status === "REJECTED_PERMANENT" ? operation.request.state : void 0;
|
|
1793
|
+
const eventState = state ?? fallbackState;
|
|
1665
1794
|
this.#operations = this.#operations.slice(1);
|
|
1666
1795
|
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1796
|
+
this.#announceChange();
|
|
1667
1797
|
await this.#syncResultListener?.({
|
|
1668
1798
|
operation,
|
|
1669
1799
|
...result === void 0 ? {} : { result },
|
|
1670
1800
|
status: response.status,
|
|
1671
1801
|
...error === void 0 ? {} : { error },
|
|
1672
|
-
...
|
|
1802
|
+
...eventState === void 0 ? {} : { state: eventState }
|
|
1673
1803
|
});
|
|
1674
1804
|
}
|
|
1675
1805
|
this.#state = "idle";
|
|
@@ -1677,6 +1807,87 @@ var OfflineQueue = class {
|
|
|
1677
1807
|
this.#state = "error";
|
|
1678
1808
|
this.#error = cause instanceof Error ? cause : new Error(String(cause));
|
|
1679
1809
|
throw this.#error;
|
|
1810
|
+
} finally {
|
|
1811
|
+
this.#releaseSyncLock();
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
async #updateOperationStatus(status, attempts) {
|
|
1815
|
+
const operation = this.#operations[0];
|
|
1816
|
+
if (operation === void 0) return;
|
|
1817
|
+
this.#operations = [{ ...operation, status, attempts }, ...this.#operations.slice(1)];
|
|
1818
|
+
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1819
|
+
this.#announceChange();
|
|
1820
|
+
}
|
|
1821
|
+
#subscribeToExternalChanges() {
|
|
1822
|
+
const windowLike = globalThis.window;
|
|
1823
|
+
if (windowLike !== void 0) {
|
|
1824
|
+
this.#storageListener = (event) => {
|
|
1825
|
+
if (event.key === this.#storageKey()) void this.#reloadFromStorage();
|
|
1826
|
+
};
|
|
1827
|
+
windowLike.addEventListener("storage", this.#storageListener);
|
|
1828
|
+
}
|
|
1829
|
+
const Channel = globalThis.BroadcastChannel;
|
|
1830
|
+
if (Channel !== void 0) {
|
|
1831
|
+
try {
|
|
1832
|
+
this.#channel = new Channel("stamprally:queue-sync");
|
|
1833
|
+
this.#channel.addEventListener("message", (event) => {
|
|
1834
|
+
if (typeof event.data === "object" && event.data !== null && "key" in event.data && event.data.key === this.#storageKey())
|
|
1835
|
+
void this.#reloadFromStorage();
|
|
1836
|
+
});
|
|
1837
|
+
} catch {
|
|
1838
|
+
this.#channel = null;
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
#announceChange() {
|
|
1843
|
+
this.#channel?.postMessage({ key: this.#storageKey(), owner: this.#instanceId });
|
|
1844
|
+
}
|
|
1845
|
+
async #reloadFromStorage() {
|
|
1846
|
+
if (this.#state === "syncing") return;
|
|
1847
|
+
try {
|
|
1848
|
+
this.#operations = (await this.#storage.load(this.#storageKey())).map(normalizeOperation);
|
|
1849
|
+
this.#loaded = true;
|
|
1850
|
+
} catch {
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
#lockKey() {
|
|
1854
|
+
return `${this.#storageKey()}:sync-lock`;
|
|
1855
|
+
}
|
|
1856
|
+
#acquireSyncLock() {
|
|
1857
|
+
const key = this.#lockKey();
|
|
1858
|
+
const now = Date.now();
|
|
1859
|
+
const local = syncLocks.get(key);
|
|
1860
|
+
if (local !== void 0 && local.expiresAt > now && local.owner !== this.#instanceId)
|
|
1861
|
+
return false;
|
|
1862
|
+
if (this.#lockStorage !== null) {
|
|
1863
|
+
try {
|
|
1864
|
+
const existing = this.#lockStorage.getItem(key);
|
|
1865
|
+
if (existing !== null) {
|
|
1866
|
+
const parsed = JSON.parse(existing);
|
|
1867
|
+
if (typeof parsed === "object" && parsed !== null && typeof parsed.expiresAt === "number" && parsed.expiresAt > now && parsed.owner !== this.#instanceId)
|
|
1868
|
+
return false;
|
|
1869
|
+
}
|
|
1870
|
+
this.#lockStorage.setItem(
|
|
1871
|
+
key,
|
|
1872
|
+
JSON.stringify({ owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS })
|
|
1873
|
+
);
|
|
1874
|
+
} catch {
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
syncLocks.set(key, { owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS });
|
|
1878
|
+
return true;
|
|
1879
|
+
}
|
|
1880
|
+
#releaseSyncLock() {
|
|
1881
|
+
const key = this.#lockKey();
|
|
1882
|
+
const current = syncLocks.get(key);
|
|
1883
|
+
if (current?.owner === this.#instanceId) syncLocks.delete(key);
|
|
1884
|
+
if (this.#lockStorage !== null) {
|
|
1885
|
+
try {
|
|
1886
|
+
const value = this.#lockStorage.getItem(key);
|
|
1887
|
+
if (value !== null && JSON.parse(value).owner === this.#instanceId)
|
|
1888
|
+
this.#lockStorage.removeItem?.(key);
|
|
1889
|
+
} catch {
|
|
1890
|
+
}
|
|
1680
1891
|
}
|
|
1681
1892
|
}
|
|
1682
1893
|
#storageKey() {
|
|
@@ -1704,6 +1915,13 @@ var OfflineQueue = class {
|
|
|
1704
1915
|
return resolveRallyStateConflict(serverState, localState, { policy });
|
|
1705
1916
|
}
|
|
1706
1917
|
};
|
|
1918
|
+
function normalizeOperation(operation) {
|
|
1919
|
+
return {
|
|
1920
|
+
...operation,
|
|
1921
|
+
status: operation.status === "IN_FLIGHT" ? "PENDING" : operation.status ?? "PENDING",
|
|
1922
|
+
attempts: operation.attempts ?? 0
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1707
1925
|
|
|
1708
1926
|
// src/crypto/token.ts
|
|
1709
1927
|
var encoder = new TextEncoder();
|
|
@@ -2272,6 +2490,8 @@ function condition(value, path, errors, isPublic) {
|
|
|
2272
2490
|
finiteNumber(value, "latitude", path, errors);
|
|
2273
2491
|
finiteNumber(value, "longitude", path, errors);
|
|
2274
2492
|
finiteNumber(value, "radiusMeters", path, errors, 0);
|
|
2493
|
+
if (typeof value.radiusMeters === "number" && value.radiusMeters <= 0)
|
|
2494
|
+
add(errors, `${path}.radiusMeters`, "Expected a radius greater than 0.", "out_of_range");
|
|
2275
2495
|
if (typeof value.latitude === "number" && (value.latitude < -90 || value.latitude > 90))
|
|
2276
2496
|
add(errors, `${path}.latitude`, "Expected a latitude between -90 and 90.", "out_of_range");
|
|
2277
2497
|
if (typeof value.longitude === "number" && (value.longitude < -180 || value.longitude > 180))
|
|
@@ -2439,21 +2659,112 @@ function validate(value, isPublic) {
|
|
|
2439
2659
|
optionalString(value, "staffPasscode", "$", errors);
|
|
2440
2660
|
if (hasOwn(value, "inventory") && value.inventory !== void 0 && !isRecord3(value.inventory))
|
|
2441
2661
|
add(errors, "$.inventory", "Expected an object.", "invalid_type");
|
|
2662
|
+
if (hasOwn(value, "inventoryMode") && value.inventoryMode !== void 0 && value.inventoryMode !== "shared" && value.inventoryMode !== "per_reward")
|
|
2663
|
+
add(errors, "$.inventoryMode", "Expected shared or per_reward.", "invalid_enum");
|
|
2442
2664
|
if (hasOwn(value, "serverMetadata") && value.serverMetadata !== void 0 && !isRecord3(value.serverMetadata))
|
|
2443
2665
|
add(errors, "$.serverMetadata", "Expected an object.", "invalid_type");
|
|
2444
2666
|
if (hasOwn(value, "publicMetadata") && value.publicMetadata !== void 0 && !isRecord3(value.publicMetadata))
|
|
2445
2667
|
add(errors, "$.publicMetadata", "Expected an object.", "invalid_type");
|
|
2446
2668
|
optionalString(value, "serverEndpoint", "$", errors);
|
|
2447
2669
|
} else {
|
|
2448
|
-
for (const key of ["staffPasscode", "serverMetadata", "inventory"])
|
|
2670
|
+
for (const key of ["staffPasscode", "serverMetadata", "inventory", "inventoryMode"])
|
|
2449
2671
|
if (hasOwn(value, key))
|
|
2450
2672
|
add(errors, `$.${key}`, "Private field is not allowed.", "private_field");
|
|
2451
2673
|
optionalString(value, "serverEndpoint", "$", errors);
|
|
2452
2674
|
}
|
|
2453
2675
|
return errors;
|
|
2454
2676
|
}
|
|
2677
|
+
function validateRallyConfigRelations(config) {
|
|
2678
|
+
const errors = [];
|
|
2679
|
+
const spotIds = /* @__PURE__ */ new Set();
|
|
2680
|
+
const rewardIds = /* @__PURE__ */ new Set();
|
|
2681
|
+
const orderIndexes = /* @__PURE__ */ new Map();
|
|
2682
|
+
config.spots.forEach((spot2, index) => {
|
|
2683
|
+
if (spotIds.has(spot2.id))
|
|
2684
|
+
add(errors, `spots[${index}].id`, "Spot ID must be unique.", "duplicate_spot_id");
|
|
2685
|
+
spotIds.add(spot2.id);
|
|
2686
|
+
const previousIndex = orderIndexes.get(spot2.orderIndex);
|
|
2687
|
+
if (previousIndex !== void 0)
|
|
2688
|
+
add(
|
|
2689
|
+
errors,
|
|
2690
|
+
`spots[${index}].orderIndex`,
|
|
2691
|
+
`orderIndex duplicates spots[${previousIndex}].`,
|
|
2692
|
+
"duplicate_order_index"
|
|
2693
|
+
);
|
|
2694
|
+
else orderIndexes.set(spot2.orderIndex, index);
|
|
2695
|
+
if (spot2.orderIndex < 0)
|
|
2696
|
+
add(
|
|
2697
|
+
errors,
|
|
2698
|
+
`spots[${index}].orderIndex`,
|
|
2699
|
+
"orderIndex must not be negative.",
|
|
2700
|
+
"negative_order_index"
|
|
2701
|
+
);
|
|
2702
|
+
spot2.prerequisites?.forEach((prerequisite, prerequisiteIndex) => {
|
|
2703
|
+
if (!spotIds.has(prerequisite) && !config.spots.some((candidate) => candidate.id === prerequisite))
|
|
2704
|
+
add(
|
|
2705
|
+
errors,
|
|
2706
|
+
`spots[${index}].prerequisites[${prerequisiteIndex}]`,
|
|
2707
|
+
"Prerequisite spot does not exist.",
|
|
2708
|
+
"missing_prerequisite"
|
|
2709
|
+
);
|
|
2710
|
+
});
|
|
2711
|
+
});
|
|
2712
|
+
config.rewards.forEach((reward2, index) => {
|
|
2713
|
+
if (rewardIds.has(reward2.id))
|
|
2714
|
+
add(errors, `rewards[${index}].id`, "Reward ID must be unique.", "duplicate_reward_id");
|
|
2715
|
+
rewardIds.add(reward2.id);
|
|
2716
|
+
const visit = (condition2, path) => {
|
|
2717
|
+
if (condition2.type === "stamps")
|
|
2718
|
+
condition2.stampIds.forEach((stampId, stampIndex) => {
|
|
2719
|
+
if (!spotIds.has(stampId))
|
|
2720
|
+
add(
|
|
2721
|
+
errors,
|
|
2722
|
+
`${path}.stampIds[${stampIndex}]`,
|
|
2723
|
+
"Referenced spot does not exist.",
|
|
2724
|
+
"missing_reward_spot"
|
|
2725
|
+
);
|
|
2726
|
+
});
|
|
2727
|
+
else if (condition2.type === "all" || condition2.type === "any")
|
|
2728
|
+
condition2.conditions.forEach((nested, nestedIndex) => {
|
|
2729
|
+
visit(nested, `${path}.conditions[${nestedIndex}]`);
|
|
2730
|
+
});
|
|
2731
|
+
};
|
|
2732
|
+
reward2.conditions?.forEach((condition2, conditionIndex) => {
|
|
2733
|
+
visit(condition2, `rewards[${index}].conditions[${conditionIndex}]`);
|
|
2734
|
+
});
|
|
2735
|
+
});
|
|
2736
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
2737
|
+
const visited = /* @__PURE__ */ new Set();
|
|
2738
|
+
const cycleNodes = /* @__PURE__ */ new Set();
|
|
2739
|
+
const visitSpot = (spotId) => {
|
|
2740
|
+
if (visiting.has(spotId)) {
|
|
2741
|
+
cycleNodes.add(spotId);
|
|
2742
|
+
return;
|
|
2743
|
+
}
|
|
2744
|
+
if (visited.has(spotId)) return;
|
|
2745
|
+
visiting.add(spotId);
|
|
2746
|
+
const spot2 = config.spots.find((candidate) => candidate.id === spotId);
|
|
2747
|
+
spot2?.prerequisites?.forEach(visitSpot);
|
|
2748
|
+
visiting.delete(spotId);
|
|
2749
|
+
visited.add(spotId);
|
|
2750
|
+
};
|
|
2751
|
+
config.spots.forEach((spot2) => {
|
|
2752
|
+
visitSpot(spot2.id);
|
|
2753
|
+
});
|
|
2754
|
+
cycleNodes.forEach((spotId) => {
|
|
2755
|
+
const index = config.spots.findIndex((spot2) => spot2.id === spotId);
|
|
2756
|
+
add(
|
|
2757
|
+
errors,
|
|
2758
|
+
`spots[${index}].prerequisites`,
|
|
2759
|
+
"Prerequisites must form a DAG.",
|
|
2760
|
+
"cyclic_prerequisites"
|
|
2761
|
+
);
|
|
2762
|
+
});
|
|
2763
|
+
return errors;
|
|
2764
|
+
}
|
|
2455
2765
|
function safeParseAdminConfig(input) {
|
|
2456
|
-
const errors = validate(input, false);
|
|
2766
|
+
const errors = [...validate(input, false)];
|
|
2767
|
+
if (errors.length === 0) errors.push(...validateRallyConfigRelations(input));
|
|
2457
2768
|
return errors.length === 0 ? { success: true, data: input } : { success: false, errors };
|
|
2458
2769
|
}
|
|
2459
2770
|
function parseAdminConfig(input) {
|
|
@@ -2462,7 +2773,8 @@ function parseAdminConfig(input) {
|
|
|
2462
2773
|
return result.data;
|
|
2463
2774
|
}
|
|
2464
2775
|
function safeParsePublicConfig(input) {
|
|
2465
|
-
const errors = validate(input, true);
|
|
2776
|
+
const errors = [...validate(input, true)];
|
|
2777
|
+
if (errors.length === 0) errors.push(...validateRallyConfigRelations(input));
|
|
2466
2778
|
return errors.length === 0 ? { success: true, data: input } : { success: false, errors };
|
|
2467
2779
|
}
|
|
2468
2780
|
function parsePublicConfig(input) {
|
|
@@ -2605,6 +2917,7 @@ exports.assertPublicConfig = assertPublicConfig;
|
|
|
2605
2917
|
exports.calculateDistanceMeters = calculateDistanceMeters;
|
|
2606
2918
|
exports.calculateProgress = calculateProgress;
|
|
2607
2919
|
exports.consumeReward = consumeReward;
|
|
2920
|
+
exports.createAnonymousSessionId = createAnonymousSessionId;
|
|
2608
2921
|
exports.createClaimTicketNumber = createClaimTicketNumber;
|
|
2609
2922
|
exports.createSecureToken = createSecureToken;
|
|
2610
2923
|
exports.createSignedSnapshotToken = createSignedSnapshotToken;
|
|
@@ -2641,6 +2954,7 @@ exports.toLocalizedString = toLocalizedString;
|
|
|
2641
2954
|
exports.toPublicConfig = toPublicConfig;
|
|
2642
2955
|
exports.updateLocalizedField = updateLocalizedField;
|
|
2643
2956
|
exports.validatePublicConfigSafety = validatePublicConfigSafety;
|
|
2957
|
+
exports.validateRallyConfigRelations = validateRallyConfigRelations;
|
|
2644
2958
|
exports.verifyPasscode = verifyPasscode;
|
|
2645
2959
|
exports.verifySecureToken = verifySecureToken;
|
|
2646
2960
|
exports.verifySnapshotToken = verifySnapshotToken;
|