@stamprally/core 0.14.0 → 0.16.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 +370 -46
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +59 -3
- package/dist/index.d.ts +59 -3
- package/dist/index.js +369 -47
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
package/dist/index.cjs
CHANGED
|
@@ -617,7 +617,13 @@ function cloneState(state) {
|
|
|
617
617
|
return {
|
|
618
618
|
...state,
|
|
619
619
|
records: state.records.map(cloneRecord),
|
|
620
|
-
...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
|
+
}
|
|
621
627
|
};
|
|
622
628
|
}
|
|
623
629
|
function isRecord(value) {
|
|
@@ -637,7 +643,7 @@ function isRewardState(value) {
|
|
|
637
643
|
function isStampRallyState(value) {
|
|
638
644
|
if (typeof value !== "object" || value === null) return false;
|
|
639
645
|
const state = value;
|
|
640
|
-
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)));
|
|
641
647
|
}
|
|
642
648
|
function isValidDate(value) {
|
|
643
649
|
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
|
@@ -682,6 +688,35 @@ var InMemoryStorage = class {
|
|
|
682
688
|
function storageKey(rallyId, userId) {
|
|
683
689
|
return `stamprally:${rallyId}:${userId ?? "anonymous"}`;
|
|
684
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
|
+
}
|
|
685
720
|
var defaultStorageWarningHandler = (error) => {
|
|
686
721
|
console.warn(`[@stamprally/core] ${error.message}`, error);
|
|
687
722
|
};
|
|
@@ -1071,6 +1106,7 @@ var StampRallyClient = class {
|
|
|
1071
1106
|
#config;
|
|
1072
1107
|
#offlineQueue;
|
|
1073
1108
|
#userId;
|
|
1109
|
+
#anonymousSessionId;
|
|
1074
1110
|
#state = null;
|
|
1075
1111
|
#initialization = null;
|
|
1076
1112
|
#queue = Promise.resolve();
|
|
@@ -1078,7 +1114,8 @@ var StampRallyClient = class {
|
|
|
1078
1114
|
this.#config = config;
|
|
1079
1115
|
this.#options = isStorage(storageOrOptions) ? { storage: storageOrOptions } : storageOrOptions;
|
|
1080
1116
|
this.#storage = this.#options.storage ?? new InMemoryStorage();
|
|
1081
|
-
this.#
|
|
1117
|
+
this.#anonymousSessionId = this.#options.anonymousSessionId ?? createAnonymousSessionId();
|
|
1118
|
+
this.#userId = this.#options.userId ?? this.#anonymousSessionId;
|
|
1082
1119
|
this.#offlineQueue = this.#options.offlineQueue;
|
|
1083
1120
|
this.#offlineQueue?.setSyncResultListener((event) => this.#handleOfflineSyncResult(event));
|
|
1084
1121
|
}
|
|
@@ -1091,12 +1128,27 @@ var StampRallyClient = class {
|
|
|
1091
1128
|
getUserId() {
|
|
1092
1129
|
return this.#userId;
|
|
1093
1130
|
}
|
|
1131
|
+
getAnonymousSessionId() {
|
|
1132
|
+
return this.#anonymousSessionId;
|
|
1133
|
+
}
|
|
1094
1134
|
get syncState() {
|
|
1095
1135
|
return this.#offlineQueue?.syncState ?? "idle";
|
|
1096
1136
|
}
|
|
1097
1137
|
get pendingCount() {
|
|
1098
1138
|
return this.#offlineQueue?.pendingCount ?? 0;
|
|
1099
1139
|
}
|
|
1140
|
+
get rejectedHistory() {
|
|
1141
|
+
return this.#offlineQueue?.rejectedHistory ?? [];
|
|
1142
|
+
}
|
|
1143
|
+
get queueCapability() {
|
|
1144
|
+
return this.#offlineQueue?.queueCapability ?? "custom";
|
|
1145
|
+
}
|
|
1146
|
+
discardRejected(operationId) {
|
|
1147
|
+
return this.#offlineQueue?.discardRejected(operationId) ?? Promise.resolve(false);
|
|
1148
|
+
}
|
|
1149
|
+
retryRejected(operationId) {
|
|
1150
|
+
return this.#offlineQueue?.retryRejected(operationId) ?? Promise.resolve(false);
|
|
1151
|
+
}
|
|
1100
1152
|
subscribe(listener) {
|
|
1101
1153
|
this.#listeners.add(listener);
|
|
1102
1154
|
return () => this.#listeners.delete(listener);
|
|
@@ -1128,11 +1180,12 @@ var StampRallyClient = class {
|
|
|
1128
1180
|
}
|
|
1129
1181
|
switchUser(newUserId) {
|
|
1130
1182
|
return this.#enqueue(async () => {
|
|
1131
|
-
|
|
1132
|
-
this.#userId
|
|
1183
|
+
const nextUserId = newUserId ?? this.#anonymousSessionId;
|
|
1184
|
+
if (this.#userId === nextUserId && this.#state !== null) return this.#state;
|
|
1185
|
+
this.#userId = nextUserId;
|
|
1133
1186
|
this.#state = null;
|
|
1134
1187
|
this.#initialization = null;
|
|
1135
|
-
await this.#offlineQueue?.switchUser(
|
|
1188
|
+
await this.#offlineQueue?.switchUser(nextUserId);
|
|
1136
1189
|
return this.initialize();
|
|
1137
1190
|
});
|
|
1138
1191
|
}
|
|
@@ -1200,7 +1253,8 @@ var StampRallyClient = class {
|
|
|
1200
1253
|
proofData,
|
|
1201
1254
|
idempotencyKey: options.idempotencyKey ?? id("check-in"),
|
|
1202
1255
|
now,
|
|
1203
|
-
state: current
|
|
1256
|
+
state: current,
|
|
1257
|
+
...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
|
|
1204
1258
|
};
|
|
1205
1259
|
const remote = this.#options.syncAdapter?.checkIn;
|
|
1206
1260
|
if (options.sync !== false && remote !== void 0) {
|
|
@@ -1256,7 +1310,8 @@ var StampRallyClient = class {
|
|
|
1256
1310
|
idempotencyKey: options.idempotencyKey ?? id("claim"),
|
|
1257
1311
|
now,
|
|
1258
1312
|
options,
|
|
1259
|
-
state: current
|
|
1313
|
+
state: current,
|
|
1314
|
+
...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
|
|
1260
1315
|
};
|
|
1261
1316
|
const remote = this.#options.syncAdapter?.claimReward;
|
|
1262
1317
|
if (options.sync !== false && remote !== void 0) {
|
|
@@ -1308,13 +1363,10 @@ var StampRallyClient = class {
|
|
|
1308
1363
|
const serverState = await adapter.sync({
|
|
1309
1364
|
rallyId: this.#config.id,
|
|
1310
1365
|
userId: this.#userId,
|
|
1311
|
-
state: this.#state ?? current
|
|
1366
|
+
state: this.#state ?? current,
|
|
1367
|
+
...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
|
|
1312
1368
|
});
|
|
1313
|
-
const
|
|
1314
|
-
const merged = this.#offlineQueue === void 0 ? serverState : resolveRallyStateConflict(serverState, localState, {
|
|
1315
|
-
policy: this.#offlineQueue.conflictPolicy
|
|
1316
|
-
});
|
|
1317
|
-
const next = this.#reconcile(merged);
|
|
1369
|
+
const next = this.#reconcile(serverState);
|
|
1318
1370
|
await this.#storage.save(next);
|
|
1319
1371
|
this.#state = next;
|
|
1320
1372
|
this.#emit(next);
|
|
@@ -1419,12 +1471,19 @@ var StampRallyClient = class {
|
|
|
1419
1471
|
// src/client/offlineQueue.ts
|
|
1420
1472
|
var MemoryQueueStorage = class {
|
|
1421
1473
|
#values = /* @__PURE__ */ new Map();
|
|
1474
|
+
#rejected = /* @__PURE__ */ new Map();
|
|
1422
1475
|
async load(key) {
|
|
1423
1476
|
return this.#values.get(key) ?? [];
|
|
1424
1477
|
}
|
|
1425
1478
|
async save(key, operations) {
|
|
1426
1479
|
this.#values.set(key, structuredClone(operations));
|
|
1427
1480
|
}
|
|
1481
|
+
async loadRejectedHistory(key) {
|
|
1482
|
+
return this.#rejected.get(key) ?? [];
|
|
1483
|
+
}
|
|
1484
|
+
async saveRejectedHistory(key, history) {
|
|
1485
|
+
this.#rejected.set(key, structuredClone(history));
|
|
1486
|
+
}
|
|
1428
1487
|
};
|
|
1429
1488
|
var LocalStorageQueueStorage = class {
|
|
1430
1489
|
constructor(storage) {
|
|
@@ -1444,6 +1503,19 @@ var LocalStorageQueueStorage = class {
|
|
|
1444
1503
|
async save(key, operations) {
|
|
1445
1504
|
this.storage.setItem(key, JSON.stringify(operations));
|
|
1446
1505
|
}
|
|
1506
|
+
async loadRejectedHistory(key) {
|
|
1507
|
+
const value = this.storage.getItem(`${key}:rejected-history`);
|
|
1508
|
+
if (value === null) return [];
|
|
1509
|
+
try {
|
|
1510
|
+
const parsed = JSON.parse(value);
|
|
1511
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
1512
|
+
} catch {
|
|
1513
|
+
return [];
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
async saveRejectedHistory(key, history) {
|
|
1517
|
+
this.storage.setItem(`${key}:rejected-history`, JSON.stringify(history));
|
|
1518
|
+
}
|
|
1447
1519
|
};
|
|
1448
1520
|
var IndexedDBOfflineQueueStorage = class {
|
|
1449
1521
|
#providedFactory;
|
|
@@ -1471,6 +1543,26 @@ var IndexedDBOfflineQueueStorage = class {
|
|
|
1471
1543
|
transaction.onabort = () => reject(transaction.error ?? new Error("Offline queue write aborted."));
|
|
1472
1544
|
});
|
|
1473
1545
|
}
|
|
1546
|
+
async loadRejectedHistory(key) {
|
|
1547
|
+
const database = await this.#open();
|
|
1548
|
+
return new Promise((resolve, reject) => {
|
|
1549
|
+
const request = database.transaction("operations", "readonly").objectStore("operations").get(`${key}:rejected-history`);
|
|
1550
|
+
request.onsuccess = () => resolve(
|
|
1551
|
+
Array.isArray(request.result) ? request.result : []
|
|
1552
|
+
);
|
|
1553
|
+
request.onerror = () => reject(request.error ?? new Error("Failed to read rejected operation history."));
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1556
|
+
async saveRejectedHistory(key, history) {
|
|
1557
|
+
const database = await this.#open();
|
|
1558
|
+
return new Promise((resolve, reject) => {
|
|
1559
|
+
const transaction = database.transaction("operations", "readwrite");
|
|
1560
|
+
transaction.objectStore("operations").put(structuredClone(history), `${key}:rejected-history`);
|
|
1561
|
+
transaction.oncomplete = () => resolve();
|
|
1562
|
+
transaction.onerror = () => reject(transaction.error ?? new Error("Failed to save rejected operation history."));
|
|
1563
|
+
transaction.onabort = () => reject(transaction.error ?? new Error("Rejected operation history write aborted."));
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1474
1566
|
#open() {
|
|
1475
1567
|
if (this.#databasePromise !== null) return this.#databasePromise;
|
|
1476
1568
|
let factory = this.#providedFactory;
|
|
@@ -1493,22 +1585,35 @@ var IndexedDBOfflineQueueStorage = class {
|
|
|
1493
1585
|
return this.#databasePromise;
|
|
1494
1586
|
}
|
|
1495
1587
|
};
|
|
1588
|
+
function availableLocalStorage() {
|
|
1589
|
+
try {
|
|
1590
|
+
const storage = globalThis.localStorage;
|
|
1591
|
+
return storage ?? null;
|
|
1592
|
+
} catch {
|
|
1593
|
+
return null;
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1496
1596
|
function defaultStorage(databaseName) {
|
|
1497
1597
|
try {
|
|
1498
1598
|
const indexedDB = globalThis.indexedDB;
|
|
1499
1599
|
if (indexedDB !== void 0)
|
|
1500
|
-
return
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1600
|
+
return {
|
|
1601
|
+
storage: new IndexedDBOfflineQueueStorage({
|
|
1602
|
+
indexedDB,
|
|
1603
|
+
...databaseName === void 0 ? {} : { databaseName }
|
|
1604
|
+
}),
|
|
1605
|
+
capability: "indexeddb"
|
|
1606
|
+
};
|
|
1607
|
+
const storage = availableLocalStorage();
|
|
1608
|
+
if (storage !== void 0 && storage !== null)
|
|
1609
|
+
return { storage: new LocalStorageQueueStorage(storage), capability: "localstorage" };
|
|
1506
1610
|
} catch {
|
|
1507
1611
|
}
|
|
1508
|
-
return new MemoryQueueStorage();
|
|
1612
|
+
return { storage: new MemoryQueueStorage(), capability: "memory" };
|
|
1509
1613
|
}
|
|
1510
|
-
function
|
|
1511
|
-
|
|
1614
|
+
function offlineOperationId(operation) {
|
|
1615
|
+
const identity = operation.request.userId ?? operation.request.anonymousSessionId ?? "anonymous";
|
|
1616
|
+
return operation.kind === "checkIn" ? `checkIn:${operation.request.rallyId}:${identity}:${operation.request.idempotencyKey}` : `claimReward:${operation.request.rallyId}:${identity}:${operation.request.idempotencyKey}`;
|
|
1512
1617
|
}
|
|
1513
1618
|
function requestScope(operation) {
|
|
1514
1619
|
return {
|
|
@@ -1528,17 +1633,24 @@ function errorValue(value, fallbackCode) {
|
|
|
1528
1633
|
}
|
|
1529
1634
|
var syncLocks = /* @__PURE__ */ new Map();
|
|
1530
1635
|
var SYNC_LOCK_TTL_MS = 3e4;
|
|
1636
|
+
var DEFAULT_RETRY_OPTIONS = {
|
|
1637
|
+
maxRetries: 0,
|
|
1638
|
+
initialIntervalMs: 250,
|
|
1639
|
+
backoffMultiplier: 2
|
|
1640
|
+
};
|
|
1531
1641
|
function randomId() {
|
|
1532
1642
|
return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
1533
1643
|
}
|
|
1534
1644
|
var OfflineQueue = class {
|
|
1535
1645
|
#storage;
|
|
1646
|
+
#queueCapability;
|
|
1536
1647
|
#configuredKey;
|
|
1537
1648
|
#rallyId;
|
|
1538
1649
|
#userId;
|
|
1539
1650
|
#conflictPolicy;
|
|
1540
1651
|
#onSyncConflict;
|
|
1541
1652
|
#operations = [];
|
|
1653
|
+
#rejectedHistory = [];
|
|
1542
1654
|
#loaded = false;
|
|
1543
1655
|
#state = "idle";
|
|
1544
1656
|
#error = null;
|
|
@@ -1546,22 +1658,41 @@ var OfflineQueue = class {
|
|
|
1546
1658
|
#syncPromise = null;
|
|
1547
1659
|
#syncResultListener;
|
|
1548
1660
|
#synchronizeInstances;
|
|
1661
|
+
#retryOptions;
|
|
1549
1662
|
#instanceId = randomId();
|
|
1550
1663
|
#lockStorage;
|
|
1664
|
+
#observedLocks = /* @__PURE__ */ new Map();
|
|
1665
|
+
#warnedMemoryLock = false;
|
|
1551
1666
|
#storageListener;
|
|
1552
1667
|
#channel = null;
|
|
1553
1668
|
constructor(options = {}) {
|
|
1554
|
-
if (options.storage !== void 0)
|
|
1555
|
-
|
|
1669
|
+
if (options.storage !== void 0) {
|
|
1670
|
+
this.#storage = options.storage;
|
|
1671
|
+
this.#queueCapability = "custom";
|
|
1672
|
+
} else if (options.storageLike !== void 0 && options.storageLike !== null) {
|
|
1556
1673
|
this.#storage = new LocalStorageQueueStorage(options.storageLike);
|
|
1557
|
-
|
|
1674
|
+
this.#queueCapability = "localstorage";
|
|
1675
|
+
} else {
|
|
1676
|
+
const selected = defaultStorage(options.databaseName);
|
|
1677
|
+
this.#storage = selected.storage;
|
|
1678
|
+
this.#queueCapability = selected.capability;
|
|
1679
|
+
}
|
|
1558
1680
|
this.#configuredKey = options.key;
|
|
1559
1681
|
this.#rallyId = options.rallyId;
|
|
1560
1682
|
this.#userId = options.userId ?? null;
|
|
1561
1683
|
this.#conflictPolicy = options.conflictPolicy ?? "server_wins";
|
|
1562
1684
|
this.#onSyncConflict = options.onSyncConflict;
|
|
1563
1685
|
this.#synchronizeInstances = options.synchronizeInstances ?? true;
|
|
1564
|
-
|
|
1686
|
+
const retryOptions = {
|
|
1687
|
+
...DEFAULT_RETRY_OPTIONS,
|
|
1688
|
+
...options.retryOptions ?? options.syncRetryOptions ?? options.retry ?? {}
|
|
1689
|
+
};
|
|
1690
|
+
this.#retryOptions = {
|
|
1691
|
+
maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
|
|
1692
|
+
initialIntervalMs: Math.max(0, retryOptions.initialIntervalMs),
|
|
1693
|
+
backoffMultiplier: Math.max(1, retryOptions.backoffMultiplier)
|
|
1694
|
+
};
|
|
1695
|
+
this.#lockStorage = options.storageLike ?? availableLocalStorage();
|
|
1565
1696
|
if (this.#synchronizeInstances) this.#subscribeToExternalChanges();
|
|
1566
1697
|
}
|
|
1567
1698
|
get syncState() {
|
|
@@ -1570,6 +1701,12 @@ var OfflineQueue = class {
|
|
|
1570
1701
|
get pendingCount() {
|
|
1571
1702
|
return this.#operations.length;
|
|
1572
1703
|
}
|
|
1704
|
+
get queueCapability() {
|
|
1705
|
+
return this.#queueCapability;
|
|
1706
|
+
}
|
|
1707
|
+
get rejectedHistory() {
|
|
1708
|
+
return this.#rejectedHistory;
|
|
1709
|
+
}
|
|
1573
1710
|
get error() {
|
|
1574
1711
|
return this.#error;
|
|
1575
1712
|
}
|
|
@@ -1593,7 +1730,20 @@ var OfflineQueue = class {
|
|
|
1593
1730
|
}
|
|
1594
1731
|
async initialize() {
|
|
1595
1732
|
if (this.#loaded) return;
|
|
1596
|
-
|
|
1733
|
+
try {
|
|
1734
|
+
const key = this.#storageKey();
|
|
1735
|
+
this.#operations = (await this.#storage.load(key)).map(normalizeOperation);
|
|
1736
|
+
this.#rejectedHistory = (await this.#storage.loadRejectedHistory?.(key))?.map(normalizeRejectedHistory) ?? [];
|
|
1737
|
+
} catch (error) {
|
|
1738
|
+
if (this.#queueCapability === "memory") throw error;
|
|
1739
|
+
this.#storage = new MemoryQueueStorage();
|
|
1740
|
+
this.#queueCapability = "memory";
|
|
1741
|
+
this.#operations = [];
|
|
1742
|
+
this.#rejectedHistory = [];
|
|
1743
|
+
this.#warnMemoryLock(
|
|
1744
|
+
`Offline queue persistence is unavailable; queued data can be lost after reload (${String(error)}).`
|
|
1745
|
+
);
|
|
1746
|
+
}
|
|
1597
1747
|
this.#loaded = true;
|
|
1598
1748
|
}
|
|
1599
1749
|
/** Releases browser listeners when the queue is no longer used. */
|
|
@@ -1636,9 +1786,9 @@ var OfflineQueue = class {
|
|
|
1636
1786
|
throw new Error("Offline operation belongs to another rally or user queue.");
|
|
1637
1787
|
}
|
|
1638
1788
|
await this.initialize();
|
|
1639
|
-
const id2 =
|
|
1640
|
-
if (this.#operations.some((item) =>
|
|
1641
|
-
this.#operations = [...this.#operations, operation];
|
|
1789
|
+
const id2 = offlineOperationId(operation);
|
|
1790
|
+
if (this.#operations.some((item) => offlineOperationId(item) === id2)) return;
|
|
1791
|
+
this.#operations = [...this.#operations, { ...operation, status: "PENDING", attempts: 0 }];
|
|
1642
1792
|
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1643
1793
|
this.#announceChange();
|
|
1644
1794
|
}
|
|
@@ -1654,6 +1804,47 @@ var OfflineQueue = class {
|
|
|
1654
1804
|
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1655
1805
|
this.#announceChange();
|
|
1656
1806
|
}
|
|
1807
|
+
async discardRejected(operationId) {
|
|
1808
|
+
await this.initialize();
|
|
1809
|
+
const next = this.#rejectedHistory.filter(
|
|
1810
|
+
(entry) => offlineOperationId(entry.operation) !== operationId
|
|
1811
|
+
);
|
|
1812
|
+
if (next.length === this.#rejectedHistory.length) return false;
|
|
1813
|
+
this.#rejectedHistory = next;
|
|
1814
|
+
await this.#saveRejectedHistory();
|
|
1815
|
+
this.#announceChange();
|
|
1816
|
+
return true;
|
|
1817
|
+
}
|
|
1818
|
+
async retryRejected(operationId) {
|
|
1819
|
+
await this.initialize();
|
|
1820
|
+
const entry = this.#rejectedHistory.find(
|
|
1821
|
+
(candidate) => offlineOperationId(candidate.operation) === operationId
|
|
1822
|
+
);
|
|
1823
|
+
if (entry === void 0) return false;
|
|
1824
|
+
if (!this.#operations.some((operation) => offlineOperationId(operation) === operationId))
|
|
1825
|
+
this.#operations = [
|
|
1826
|
+
...this.#operations,
|
|
1827
|
+
{ ...entry.operation, status: "PENDING", attempts: 0 }
|
|
1828
|
+
];
|
|
1829
|
+
this.#rejectedHistory = this.#rejectedHistory.filter((candidate) => candidate !== entry);
|
|
1830
|
+
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1831
|
+
await this.#saveRejectedHistory();
|
|
1832
|
+
this.#announceChange();
|
|
1833
|
+
return true;
|
|
1834
|
+
}
|
|
1835
|
+
async discardRejectedOperation(operationId) {
|
|
1836
|
+
return this.discardRejected(operationId);
|
|
1837
|
+
}
|
|
1838
|
+
async retryRejectedOperation(operationId) {
|
|
1839
|
+
return this.retryRejected(operationId);
|
|
1840
|
+
}
|
|
1841
|
+
async clearRejectedHistory() {
|
|
1842
|
+
await this.initialize();
|
|
1843
|
+
if (this.#rejectedHistory.length === 0) return;
|
|
1844
|
+
this.#rejectedHistory = [];
|
|
1845
|
+
await this.#saveRejectedHistory();
|
|
1846
|
+
this.#announceChange();
|
|
1847
|
+
}
|
|
1657
1848
|
async sync(sender = this.#sender) {
|
|
1658
1849
|
await this.initialize();
|
|
1659
1850
|
if (sender === void 0) throw new Error("OfflineQueue.sync requires a sender.");
|
|
@@ -1668,6 +1859,37 @@ var OfflineQueue = class {
|
|
|
1668
1859
|
return this.sync(sender);
|
|
1669
1860
|
}
|
|
1670
1861
|
async #run(sender) {
|
|
1862
|
+
const locks = globalThis.navigator?.locks;
|
|
1863
|
+
if (locks !== void 0 && typeof locks.request === "function") {
|
|
1864
|
+
let callbackStarted = false;
|
|
1865
|
+
try {
|
|
1866
|
+
const acquired = await locks.request(
|
|
1867
|
+
`stamprally:${this.#storageKey()}:sync`,
|
|
1868
|
+
{ ifAvailable: true },
|
|
1869
|
+
async (lock) => {
|
|
1870
|
+
if (lock === null) {
|
|
1871
|
+
await this.#reloadFromStorage();
|
|
1872
|
+
this.#state = "idle";
|
|
1873
|
+
return false;
|
|
1874
|
+
}
|
|
1875
|
+
callbackStarted = true;
|
|
1876
|
+
await this.#runWithStorageLock(sender);
|
|
1877
|
+
return true;
|
|
1878
|
+
}
|
|
1879
|
+
);
|
|
1880
|
+
if (!acquired) return;
|
|
1881
|
+
return;
|
|
1882
|
+
} catch (error) {
|
|
1883
|
+
if (callbackStarted) throw error;
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
if (this.#lockStorage === null)
|
|
1887
|
+
this.#warnMemoryLock(
|
|
1888
|
+
"No cross-tab storage lock is available; offline sync is single-tab only."
|
|
1889
|
+
);
|
|
1890
|
+
await this.#runWithStorageLock(sender);
|
|
1891
|
+
}
|
|
1892
|
+
async #runWithStorageLock(sender) {
|
|
1671
1893
|
this.#state = "syncing";
|
|
1672
1894
|
this.#error = null;
|
|
1673
1895
|
if (!this.#acquireSyncLock()) {
|
|
@@ -1679,21 +1901,54 @@ var OfflineQueue = class {
|
|
|
1679
1901
|
while (this.#operations.length > 0) {
|
|
1680
1902
|
const operation = this.#operations[0];
|
|
1681
1903
|
if (operation === void 0) break;
|
|
1682
|
-
let
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1904
|
+
let attempt = 0;
|
|
1905
|
+
let response;
|
|
1906
|
+
while (true) {
|
|
1907
|
+
await this.#updateOperationStatus("IN_FLIGHT", attempt);
|
|
1908
|
+
try {
|
|
1909
|
+
response = this.#normalizeResponse(await sender(operation));
|
|
1910
|
+
} catch (cause) {
|
|
1911
|
+
response = {
|
|
1912
|
+
status: "RETRYABLE_ERROR",
|
|
1913
|
+
error: errorValue(cause, "RETRYABLE_ERROR")
|
|
1914
|
+
};
|
|
1915
|
+
}
|
|
1916
|
+
if (response.status !== "RETRYABLE_ERROR") break;
|
|
1690
1917
|
const error2 = errorValue(response.error ?? response.reason, "RETRYABLE_ERROR");
|
|
1918
|
+
await this.#updateOperationStatus("PENDING", attempt + 1);
|
|
1691
1919
|
await this.#syncResultListener?.({ operation, status: response.status, error: error2 });
|
|
1692
|
-
|
|
1920
|
+
if (attempt >= this.#retryOptions.maxRetries) {
|
|
1921
|
+
await this.#updateOperationStatus("FAILED_RETRYABLE", attempt + 1);
|
|
1922
|
+
throw new Error(error2.message);
|
|
1923
|
+
}
|
|
1924
|
+
const interval = this.#retryOptions.initialIntervalMs * this.#retryOptions.backoffMultiplier ** attempt;
|
|
1925
|
+
await new Promise((resolve) => setTimeout(resolve, Math.max(0, interval)));
|
|
1926
|
+
attempt += 1;
|
|
1693
1927
|
}
|
|
1694
1928
|
const result = response.result;
|
|
1695
|
-
const state = response.state ?? (result !== void 0 && "conflict" in result && result.conflict === true ?
|
|
1696
|
-
const error = response.status === "REJECTED_PERMANENT" ? errorValue(
|
|
1929
|
+
const state = response.state ?? (result !== void 0 && "conflict" in result && result.conflict === true ? result.serverState : result !== void 0 && "ok" in result && result.ok ? result.value.state : void 0);
|
|
1930
|
+
const error = response.status === "REJECTED_PERMANENT" ? errorValue(
|
|
1931
|
+
response.error ?? response.reason ?? (result !== void 0 && "ok" in result && !result.ok ? result.error : void 0),
|
|
1932
|
+
"REJECTED_PERMANENT"
|
|
1933
|
+
) : void 0;
|
|
1934
|
+
await this.#updateOperationStatus(
|
|
1935
|
+
response.status === "ACCEPTED" ? "ACCEPTED" : "REJECTED_PERMANENT",
|
|
1936
|
+
attempt + 1
|
|
1937
|
+
);
|
|
1938
|
+
const eventState = state;
|
|
1939
|
+
if (response.status === "REJECTED_PERMANENT" && error !== void 0) {
|
|
1940
|
+
this.#rejectedHistory = [
|
|
1941
|
+
...this.#rejectedHistory,
|
|
1942
|
+
{
|
|
1943
|
+
operation: { ...operation, status: "REJECTED_PERMANENT", attempts: attempt + 1 },
|
|
1944
|
+
reason: error,
|
|
1945
|
+
errorCode: error.code,
|
|
1946
|
+
rejectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1947
|
+
attempts: attempt + 1
|
|
1948
|
+
}
|
|
1949
|
+
];
|
|
1950
|
+
await this.#saveRejectedHistory();
|
|
1951
|
+
}
|
|
1697
1952
|
this.#operations = this.#operations.slice(1);
|
|
1698
1953
|
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1699
1954
|
this.#announceChange();
|
|
@@ -1702,7 +1957,7 @@ var OfflineQueue = class {
|
|
|
1702
1957
|
...result === void 0 ? {} : { result },
|
|
1703
1958
|
status: response.status,
|
|
1704
1959
|
...error === void 0 ? {} : { error },
|
|
1705
|
-
...
|
|
1960
|
+
...eventState === void 0 ? {} : { state: eventState }
|
|
1706
1961
|
});
|
|
1707
1962
|
}
|
|
1708
1963
|
this.#state = "idle";
|
|
@@ -1714,11 +1969,19 @@ var OfflineQueue = class {
|
|
|
1714
1969
|
this.#releaseSyncLock();
|
|
1715
1970
|
}
|
|
1716
1971
|
}
|
|
1972
|
+
async #updateOperationStatus(status, attempts) {
|
|
1973
|
+
const operation = this.#operations[0];
|
|
1974
|
+
if (operation === void 0) return;
|
|
1975
|
+
this.#operations = [{ ...operation, status, attempts }, ...this.#operations.slice(1)];
|
|
1976
|
+
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1977
|
+
this.#announceChange();
|
|
1978
|
+
}
|
|
1717
1979
|
#subscribeToExternalChanges() {
|
|
1718
1980
|
const windowLike = globalThis.window;
|
|
1719
1981
|
if (windowLike !== void 0) {
|
|
1720
1982
|
this.#storageListener = (event) => {
|
|
1721
|
-
if (event.key === this.#storageKey()
|
|
1983
|
+
if (event.key === this.#storageKey() || event.key === `${this.#storageKey()}:rejected-history`)
|
|
1984
|
+
void this.#reloadFromStorage();
|
|
1722
1985
|
};
|
|
1723
1986
|
windowLike.addEventListener("storage", this.#storageListener);
|
|
1724
1987
|
}
|
|
@@ -1727,6 +1990,20 @@ var OfflineQueue = class {
|
|
|
1727
1990
|
try {
|
|
1728
1991
|
this.#channel = new Channel("stamprally:queue-sync");
|
|
1729
1992
|
this.#channel.addEventListener("message", (event) => {
|
|
1993
|
+
if (typeof event.data === "object" && event.data !== null) {
|
|
1994
|
+
const data = event.data;
|
|
1995
|
+
if (data.type === "lock" && data.lockKey === this.#lockKey()) {
|
|
1996
|
+
this.#observedLocks.set(data.lockKey, {
|
|
1997
|
+
owner: typeof data.owner === "string" ? data.owner : "unknown",
|
|
1998
|
+
expiresAt: typeof data.expiresAt === "number" ? data.expiresAt : 0
|
|
1999
|
+
});
|
|
2000
|
+
return;
|
|
2001
|
+
}
|
|
2002
|
+
if (data.type === "unlock" && data.lockKey === this.#lockKey()) {
|
|
2003
|
+
this.#observedLocks.delete(data.lockKey);
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
1730
2007
|
if (typeof event.data === "object" && event.data !== null && "key" in event.data && event.data.key === this.#storageKey())
|
|
1731
2008
|
void this.#reloadFromStorage();
|
|
1732
2009
|
});
|
|
@@ -1736,12 +2013,18 @@ var OfflineQueue = class {
|
|
|
1736
2013
|
}
|
|
1737
2014
|
}
|
|
1738
2015
|
#announceChange() {
|
|
1739
|
-
this.#channel?.postMessage({
|
|
2016
|
+
this.#channel?.postMessage({
|
|
2017
|
+
type: "change",
|
|
2018
|
+
key: this.#storageKey(),
|
|
2019
|
+
owner: this.#instanceId
|
|
2020
|
+
});
|
|
1740
2021
|
}
|
|
1741
2022
|
async #reloadFromStorage() {
|
|
1742
2023
|
if (this.#state === "syncing") return;
|
|
1743
2024
|
try {
|
|
1744
|
-
|
|
2025
|
+
const key = this.#storageKey();
|
|
2026
|
+
this.#operations = (await this.#storage.load(key)).map(normalizeOperation);
|
|
2027
|
+
this.#rejectedHistory = (await this.#storage.loadRejectedHistory?.(key))?.map(normalizeRejectedHistory) ?? this.#rejectedHistory;
|
|
1745
2028
|
this.#loaded = true;
|
|
1746
2029
|
} catch {
|
|
1747
2030
|
}
|
|
@@ -1755,6 +2038,9 @@ var OfflineQueue = class {
|
|
|
1755
2038
|
const local = syncLocks.get(key);
|
|
1756
2039
|
if (local !== void 0 && local.expiresAt > now && local.owner !== this.#instanceId)
|
|
1757
2040
|
return false;
|
|
2041
|
+
const observed = this.#observedLocks.get(key);
|
|
2042
|
+
if (observed !== void 0 && observed.expiresAt > now && observed.owner !== this.#instanceId)
|
|
2043
|
+
return false;
|
|
1758
2044
|
if (this.#lockStorage !== null) {
|
|
1759
2045
|
try {
|
|
1760
2046
|
const existing = this.#lockStorage.getItem(key);
|
|
@@ -1767,7 +2053,14 @@ var OfflineQueue = class {
|
|
|
1767
2053
|
key,
|
|
1768
2054
|
JSON.stringify({ owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS })
|
|
1769
2055
|
);
|
|
2056
|
+
this.#channel?.postMessage({
|
|
2057
|
+
type: "lock",
|
|
2058
|
+
lockKey: key,
|
|
2059
|
+
owner: this.#instanceId,
|
|
2060
|
+
expiresAt: now + SYNC_LOCK_TTL_MS
|
|
2061
|
+
});
|
|
1770
2062
|
} catch {
|
|
2063
|
+
this.#warnMemoryLock("Persistent lock access failed; offline sync is single-tab only.");
|
|
1771
2064
|
}
|
|
1772
2065
|
}
|
|
1773
2066
|
syncLocks.set(key, { owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS });
|
|
@@ -1782,6 +2075,7 @@ var OfflineQueue = class {
|
|
|
1782
2075
|
const value = this.#lockStorage.getItem(key);
|
|
1783
2076
|
if (value !== null && JSON.parse(value).owner === this.#instanceId)
|
|
1784
2077
|
this.#lockStorage.removeItem?.(key);
|
|
2078
|
+
this.#channel?.postMessage({ type: "unlock", lockKey: key, owner: this.#instanceId });
|
|
1785
2079
|
} catch {
|
|
1786
2080
|
}
|
|
1787
2081
|
}
|
|
@@ -1810,7 +2104,33 @@ var OfflineQueue = class {
|
|
|
1810
2104
|
const policy = (typeof configured === "function" ? await configured({ operation, localState, serverState }) : configured) ?? this.#conflictPolicy;
|
|
1811
2105
|
return resolveRallyStateConflict(serverState, localState, { policy });
|
|
1812
2106
|
}
|
|
2107
|
+
async #saveRejectedHistory() {
|
|
2108
|
+
await this.#storage.saveRejectedHistory?.(this.#storageKey(), this.#rejectedHistory);
|
|
2109
|
+
}
|
|
2110
|
+
#warnMemoryLock(message) {
|
|
2111
|
+
if (this.#warnedMemoryLock) return;
|
|
2112
|
+
this.#warnedMemoryLock = true;
|
|
2113
|
+
console.warn(`[@stamprally/core] ${message}`);
|
|
2114
|
+
}
|
|
1813
2115
|
};
|
|
2116
|
+
function normalizeOperation(operation) {
|
|
2117
|
+
const status = operation.status;
|
|
2118
|
+
return {
|
|
2119
|
+
...operation,
|
|
2120
|
+
status: status === "IN_FLIGHT" || status === "REJECTED" ? "PENDING" : status === "RETRYABLE_ERROR" ? "FAILED_RETRYABLE" : operation.status ?? "PENDING",
|
|
2121
|
+
attempts: operation.attempts ?? 0
|
|
2122
|
+
};
|
|
2123
|
+
}
|
|
2124
|
+
function normalizeRejectedHistory(entry) {
|
|
2125
|
+
return {
|
|
2126
|
+
...entry,
|
|
2127
|
+
operation: normalizeOperation(entry.operation),
|
|
2128
|
+
reason: errorValue(entry.reason, "REJECTED_PERMANENT"),
|
|
2129
|
+
errorCode: entry.errorCode || entry.reason.code,
|
|
2130
|
+
rejectedAt: entry.rejectedAt || (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
2131
|
+
attempts: entry.attempts ?? entry.operation.attempts ?? 0
|
|
2132
|
+
};
|
|
2133
|
+
}
|
|
1814
2134
|
|
|
1815
2135
|
// src/crypto/token.ts
|
|
1816
2136
|
var encoder = new TextEncoder();
|
|
@@ -2379,6 +2699,8 @@ function condition(value, path, errors, isPublic) {
|
|
|
2379
2699
|
finiteNumber(value, "latitude", path, errors);
|
|
2380
2700
|
finiteNumber(value, "longitude", path, errors);
|
|
2381
2701
|
finiteNumber(value, "radiusMeters", path, errors, 0);
|
|
2702
|
+
if (typeof value.radiusMeters === "number" && value.radiusMeters <= 0)
|
|
2703
|
+
add(errors, `${path}.radiusMeters`, "Expected a radius greater than 0.", "out_of_range");
|
|
2382
2704
|
if (typeof value.latitude === "number" && (value.latitude < -90 || value.latitude > 90))
|
|
2383
2705
|
add(errors, `${path}.latitude`, "Expected a latitude between -90 and 90.", "out_of_range");
|
|
2384
2706
|
if (typeof value.longitude === "number" && (value.longitude < -180 || value.longitude > 180))
|
|
@@ -2804,6 +3126,7 @@ exports.assertPublicConfig = assertPublicConfig;
|
|
|
2804
3126
|
exports.calculateDistanceMeters = calculateDistanceMeters;
|
|
2805
3127
|
exports.calculateProgress = calculateProgress;
|
|
2806
3128
|
exports.consumeReward = consumeReward;
|
|
3129
|
+
exports.createAnonymousSessionId = createAnonymousSessionId;
|
|
2807
3130
|
exports.createClaimTicketNumber = createClaimTicketNumber;
|
|
2808
3131
|
exports.createSecureToken = createSecureToken;
|
|
2809
3132
|
exports.createSignedSnapshotToken = createSignedSnapshotToken;
|
|
@@ -2824,6 +3147,7 @@ exports.isRewardState = isRewardState;
|
|
|
2824
3147
|
exports.isStampRallyState = isStampRallyState;
|
|
2825
3148
|
exports.issueClaimTicketNumber = issueClaimTicketNumber;
|
|
2826
3149
|
exports.normalizePasscode = normalizePasscode;
|
|
3150
|
+
exports.offlineOperationId = offlineOperationId;
|
|
2827
3151
|
exports.parseAdminConfig = parseAdminConfig;
|
|
2828
3152
|
exports.parsePublicConfig = parsePublicConfig;
|
|
2829
3153
|
exports.processStamp = processStamp;
|