@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.js
CHANGED
|
@@ -615,7 +615,13 @@ function cloneState(state) {
|
|
|
615
615
|
return {
|
|
616
616
|
...state,
|
|
617
617
|
records: state.records.map(cloneRecord),
|
|
618
|
-
...state.rewards === void 0 ? {} : { rewards: state.rewards.map(cloneRewardState) }
|
|
618
|
+
...state.rewards === void 0 ? {} : { rewards: state.rewards.map(cloneRewardState) },
|
|
619
|
+
...state.inventory === void 0 ? {} : {
|
|
620
|
+
inventory: {
|
|
621
|
+
...state.inventory,
|
|
622
|
+
...state.inventory.rewardRemaining === void 0 ? {} : { rewardRemaining: { ...state.inventory.rewardRemaining } }
|
|
623
|
+
}
|
|
624
|
+
}
|
|
619
625
|
};
|
|
620
626
|
}
|
|
621
627
|
function isRecord(value) {
|
|
@@ -635,7 +641,7 @@ function isRewardState(value) {
|
|
|
635
641
|
function isStampRallyState(value) {
|
|
636
642
|
if (typeof value !== "object" || value === null) return false;
|
|
637
643
|
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));
|
|
644
|
+
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
645
|
}
|
|
640
646
|
function isValidDate(value) {
|
|
641
647
|
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
|
@@ -680,6 +686,35 @@ var InMemoryStorage = class {
|
|
|
680
686
|
function storageKey(rallyId, userId) {
|
|
681
687
|
return `stamprally:${rallyId}:${userId ?? "anonymous"}`;
|
|
682
688
|
}
|
|
689
|
+
function createAnonymousSessionId(storage) {
|
|
690
|
+
const key = "stamprally:anonymous-session-id";
|
|
691
|
+
try {
|
|
692
|
+
const browserStorage = typeof window === "undefined" ? null : window.localStorage;
|
|
693
|
+
const value = storage?.getItem(key) ?? browserStorage?.getItem(key);
|
|
694
|
+
if (value !== null && value !== void 0 && isUuidV4(value)) return value;
|
|
695
|
+
const generated = randomUuidV4();
|
|
696
|
+
(storage ?? browserStorage)?.setItem(key, generated);
|
|
697
|
+
return generated;
|
|
698
|
+
} catch {
|
|
699
|
+
return randomUuidV4();
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
function isUuidV4(value) {
|
|
703
|
+
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);
|
|
704
|
+
}
|
|
705
|
+
function randomUuidV4() {
|
|
706
|
+
const cryptoApi3 = globalThis.crypto;
|
|
707
|
+
if (cryptoApi3?.randomUUID !== void 0) return cryptoApi3.randomUUID();
|
|
708
|
+
if (cryptoApi3?.getRandomValues !== void 0) {
|
|
709
|
+
const bytes2 = cryptoApi3.getRandomValues(new Uint8Array(16));
|
|
710
|
+
bytes2[6] = (bytes2[6] ?? 0) & 15 | 64;
|
|
711
|
+
bytes2[8] = (bytes2[8] ?? 0) & 63 | 128;
|
|
712
|
+
const hex = Array.from(bytes2, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
713
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
714
|
+
}
|
|
715
|
+
const random = `${Date.now().toString(16)}${Math.random().toString(16).slice(2)}`.padEnd(32, "0").slice(0, 32);
|
|
716
|
+
return `${random.slice(0, 8)}-${random.slice(8, 12)}-4${random.slice(13, 16)}-8${random.slice(17, 20)}-${random.slice(20)}`;
|
|
717
|
+
}
|
|
683
718
|
var defaultStorageWarningHandler = (error) => {
|
|
684
719
|
console.warn(`[@stamprally/core] ${error.message}`, error);
|
|
685
720
|
};
|
|
@@ -1069,6 +1104,7 @@ var StampRallyClient = class {
|
|
|
1069
1104
|
#config;
|
|
1070
1105
|
#offlineQueue;
|
|
1071
1106
|
#userId;
|
|
1107
|
+
#anonymousSessionId;
|
|
1072
1108
|
#state = null;
|
|
1073
1109
|
#initialization = null;
|
|
1074
1110
|
#queue = Promise.resolve();
|
|
@@ -1076,7 +1112,8 @@ var StampRallyClient = class {
|
|
|
1076
1112
|
this.#config = config;
|
|
1077
1113
|
this.#options = isStorage(storageOrOptions) ? { storage: storageOrOptions } : storageOrOptions;
|
|
1078
1114
|
this.#storage = this.#options.storage ?? new InMemoryStorage();
|
|
1079
|
-
this.#
|
|
1115
|
+
this.#anonymousSessionId = this.#options.anonymousSessionId ?? createAnonymousSessionId();
|
|
1116
|
+
this.#userId = this.#options.userId ?? this.#anonymousSessionId;
|
|
1080
1117
|
this.#offlineQueue = this.#options.offlineQueue;
|
|
1081
1118
|
this.#offlineQueue?.setSyncResultListener((event) => this.#handleOfflineSyncResult(event));
|
|
1082
1119
|
}
|
|
@@ -1089,12 +1126,27 @@ var StampRallyClient = class {
|
|
|
1089
1126
|
getUserId() {
|
|
1090
1127
|
return this.#userId;
|
|
1091
1128
|
}
|
|
1129
|
+
getAnonymousSessionId() {
|
|
1130
|
+
return this.#anonymousSessionId;
|
|
1131
|
+
}
|
|
1092
1132
|
get syncState() {
|
|
1093
1133
|
return this.#offlineQueue?.syncState ?? "idle";
|
|
1094
1134
|
}
|
|
1095
1135
|
get pendingCount() {
|
|
1096
1136
|
return this.#offlineQueue?.pendingCount ?? 0;
|
|
1097
1137
|
}
|
|
1138
|
+
get rejectedHistory() {
|
|
1139
|
+
return this.#offlineQueue?.rejectedHistory ?? [];
|
|
1140
|
+
}
|
|
1141
|
+
get queueCapability() {
|
|
1142
|
+
return this.#offlineQueue?.queueCapability ?? "custom";
|
|
1143
|
+
}
|
|
1144
|
+
discardRejected(operationId) {
|
|
1145
|
+
return this.#offlineQueue?.discardRejected(operationId) ?? Promise.resolve(false);
|
|
1146
|
+
}
|
|
1147
|
+
retryRejected(operationId) {
|
|
1148
|
+
return this.#offlineQueue?.retryRejected(operationId) ?? Promise.resolve(false);
|
|
1149
|
+
}
|
|
1098
1150
|
subscribe(listener) {
|
|
1099
1151
|
this.#listeners.add(listener);
|
|
1100
1152
|
return () => this.#listeners.delete(listener);
|
|
@@ -1126,11 +1178,12 @@ var StampRallyClient = class {
|
|
|
1126
1178
|
}
|
|
1127
1179
|
switchUser(newUserId) {
|
|
1128
1180
|
return this.#enqueue(async () => {
|
|
1129
|
-
|
|
1130
|
-
this.#userId
|
|
1181
|
+
const nextUserId = newUserId ?? this.#anonymousSessionId;
|
|
1182
|
+
if (this.#userId === nextUserId && this.#state !== null) return this.#state;
|
|
1183
|
+
this.#userId = nextUserId;
|
|
1131
1184
|
this.#state = null;
|
|
1132
1185
|
this.#initialization = null;
|
|
1133
|
-
await this.#offlineQueue?.switchUser(
|
|
1186
|
+
await this.#offlineQueue?.switchUser(nextUserId);
|
|
1134
1187
|
return this.initialize();
|
|
1135
1188
|
});
|
|
1136
1189
|
}
|
|
@@ -1198,7 +1251,8 @@ var StampRallyClient = class {
|
|
|
1198
1251
|
proofData,
|
|
1199
1252
|
idempotencyKey: options.idempotencyKey ?? id("check-in"),
|
|
1200
1253
|
now,
|
|
1201
|
-
state: current
|
|
1254
|
+
state: current,
|
|
1255
|
+
...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
|
|
1202
1256
|
};
|
|
1203
1257
|
const remote = this.#options.syncAdapter?.checkIn;
|
|
1204
1258
|
if (options.sync !== false && remote !== void 0) {
|
|
@@ -1254,7 +1308,8 @@ var StampRallyClient = class {
|
|
|
1254
1308
|
idempotencyKey: options.idempotencyKey ?? id("claim"),
|
|
1255
1309
|
now,
|
|
1256
1310
|
options,
|
|
1257
|
-
state: current
|
|
1311
|
+
state: current,
|
|
1312
|
+
...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
|
|
1258
1313
|
};
|
|
1259
1314
|
const remote = this.#options.syncAdapter?.claimReward;
|
|
1260
1315
|
if (options.sync !== false && remote !== void 0) {
|
|
@@ -1306,13 +1361,10 @@ var StampRallyClient = class {
|
|
|
1306
1361
|
const serverState = await adapter.sync({
|
|
1307
1362
|
rallyId: this.#config.id,
|
|
1308
1363
|
userId: this.#userId,
|
|
1309
|
-
state: this.#state ?? current
|
|
1364
|
+
state: this.#state ?? current,
|
|
1365
|
+
...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
|
|
1310
1366
|
});
|
|
1311
|
-
const
|
|
1312
|
-
const merged = this.#offlineQueue === void 0 ? serverState : resolveRallyStateConflict(serverState, localState, {
|
|
1313
|
-
policy: this.#offlineQueue.conflictPolicy
|
|
1314
|
-
});
|
|
1315
|
-
const next = this.#reconcile(merged);
|
|
1367
|
+
const next = this.#reconcile(serverState);
|
|
1316
1368
|
await this.#storage.save(next);
|
|
1317
1369
|
this.#state = next;
|
|
1318
1370
|
this.#emit(next);
|
|
@@ -1417,12 +1469,19 @@ var StampRallyClient = class {
|
|
|
1417
1469
|
// src/client/offlineQueue.ts
|
|
1418
1470
|
var MemoryQueueStorage = class {
|
|
1419
1471
|
#values = /* @__PURE__ */ new Map();
|
|
1472
|
+
#rejected = /* @__PURE__ */ new Map();
|
|
1420
1473
|
async load(key) {
|
|
1421
1474
|
return this.#values.get(key) ?? [];
|
|
1422
1475
|
}
|
|
1423
1476
|
async save(key, operations) {
|
|
1424
1477
|
this.#values.set(key, structuredClone(operations));
|
|
1425
1478
|
}
|
|
1479
|
+
async loadRejectedHistory(key) {
|
|
1480
|
+
return this.#rejected.get(key) ?? [];
|
|
1481
|
+
}
|
|
1482
|
+
async saveRejectedHistory(key, history) {
|
|
1483
|
+
this.#rejected.set(key, structuredClone(history));
|
|
1484
|
+
}
|
|
1426
1485
|
};
|
|
1427
1486
|
var LocalStorageQueueStorage = class {
|
|
1428
1487
|
constructor(storage) {
|
|
@@ -1442,6 +1501,19 @@ var LocalStorageQueueStorage = class {
|
|
|
1442
1501
|
async save(key, operations) {
|
|
1443
1502
|
this.storage.setItem(key, JSON.stringify(operations));
|
|
1444
1503
|
}
|
|
1504
|
+
async loadRejectedHistory(key) {
|
|
1505
|
+
const value = this.storage.getItem(`${key}:rejected-history`);
|
|
1506
|
+
if (value === null) return [];
|
|
1507
|
+
try {
|
|
1508
|
+
const parsed = JSON.parse(value);
|
|
1509
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
1510
|
+
} catch {
|
|
1511
|
+
return [];
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
async saveRejectedHistory(key, history) {
|
|
1515
|
+
this.storage.setItem(`${key}:rejected-history`, JSON.stringify(history));
|
|
1516
|
+
}
|
|
1445
1517
|
};
|
|
1446
1518
|
var IndexedDBOfflineQueueStorage = class {
|
|
1447
1519
|
#providedFactory;
|
|
@@ -1469,6 +1541,26 @@ var IndexedDBOfflineQueueStorage = class {
|
|
|
1469
1541
|
transaction.onabort = () => reject(transaction.error ?? new Error("Offline queue write aborted."));
|
|
1470
1542
|
});
|
|
1471
1543
|
}
|
|
1544
|
+
async loadRejectedHistory(key) {
|
|
1545
|
+
const database = await this.#open();
|
|
1546
|
+
return new Promise((resolve, reject) => {
|
|
1547
|
+
const request = database.transaction("operations", "readonly").objectStore("operations").get(`${key}:rejected-history`);
|
|
1548
|
+
request.onsuccess = () => resolve(
|
|
1549
|
+
Array.isArray(request.result) ? request.result : []
|
|
1550
|
+
);
|
|
1551
|
+
request.onerror = () => reject(request.error ?? new Error("Failed to read rejected operation history."));
|
|
1552
|
+
});
|
|
1553
|
+
}
|
|
1554
|
+
async saveRejectedHistory(key, history) {
|
|
1555
|
+
const database = await this.#open();
|
|
1556
|
+
return new Promise((resolve, reject) => {
|
|
1557
|
+
const transaction = database.transaction("operations", "readwrite");
|
|
1558
|
+
transaction.objectStore("operations").put(structuredClone(history), `${key}:rejected-history`);
|
|
1559
|
+
transaction.oncomplete = () => resolve();
|
|
1560
|
+
transaction.onerror = () => reject(transaction.error ?? new Error("Failed to save rejected operation history."));
|
|
1561
|
+
transaction.onabort = () => reject(transaction.error ?? new Error("Rejected operation history write aborted."));
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1472
1564
|
#open() {
|
|
1473
1565
|
if (this.#databasePromise !== null) return this.#databasePromise;
|
|
1474
1566
|
let factory = this.#providedFactory;
|
|
@@ -1491,22 +1583,35 @@ var IndexedDBOfflineQueueStorage = class {
|
|
|
1491
1583
|
return this.#databasePromise;
|
|
1492
1584
|
}
|
|
1493
1585
|
};
|
|
1586
|
+
function availableLocalStorage() {
|
|
1587
|
+
try {
|
|
1588
|
+
const storage = globalThis.localStorage;
|
|
1589
|
+
return storage ?? null;
|
|
1590
|
+
} catch {
|
|
1591
|
+
return null;
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1494
1594
|
function defaultStorage(databaseName) {
|
|
1495
1595
|
try {
|
|
1496
1596
|
const indexedDB = globalThis.indexedDB;
|
|
1497
1597
|
if (indexedDB !== void 0)
|
|
1498
|
-
return
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1598
|
+
return {
|
|
1599
|
+
storage: new IndexedDBOfflineQueueStorage({
|
|
1600
|
+
indexedDB,
|
|
1601
|
+
...databaseName === void 0 ? {} : { databaseName }
|
|
1602
|
+
}),
|
|
1603
|
+
capability: "indexeddb"
|
|
1604
|
+
};
|
|
1605
|
+
const storage = availableLocalStorage();
|
|
1606
|
+
if (storage !== void 0 && storage !== null)
|
|
1607
|
+
return { storage: new LocalStorageQueueStorage(storage), capability: "localstorage" };
|
|
1504
1608
|
} catch {
|
|
1505
1609
|
}
|
|
1506
|
-
return new MemoryQueueStorage();
|
|
1610
|
+
return { storage: new MemoryQueueStorage(), capability: "memory" };
|
|
1507
1611
|
}
|
|
1508
|
-
function
|
|
1509
|
-
|
|
1612
|
+
function offlineOperationId(operation) {
|
|
1613
|
+
const identity = operation.request.userId ?? operation.request.anonymousSessionId ?? "anonymous";
|
|
1614
|
+
return operation.kind === "checkIn" ? `checkIn:${operation.request.rallyId}:${identity}:${operation.request.idempotencyKey}` : `claimReward:${operation.request.rallyId}:${identity}:${operation.request.idempotencyKey}`;
|
|
1510
1615
|
}
|
|
1511
1616
|
function requestScope(operation) {
|
|
1512
1617
|
return {
|
|
@@ -1526,17 +1631,24 @@ function errorValue(value, fallbackCode) {
|
|
|
1526
1631
|
}
|
|
1527
1632
|
var syncLocks = /* @__PURE__ */ new Map();
|
|
1528
1633
|
var SYNC_LOCK_TTL_MS = 3e4;
|
|
1634
|
+
var DEFAULT_RETRY_OPTIONS = {
|
|
1635
|
+
maxRetries: 0,
|
|
1636
|
+
initialIntervalMs: 250,
|
|
1637
|
+
backoffMultiplier: 2
|
|
1638
|
+
};
|
|
1529
1639
|
function randomId() {
|
|
1530
1640
|
return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
1531
1641
|
}
|
|
1532
1642
|
var OfflineQueue = class {
|
|
1533
1643
|
#storage;
|
|
1644
|
+
#queueCapability;
|
|
1534
1645
|
#configuredKey;
|
|
1535
1646
|
#rallyId;
|
|
1536
1647
|
#userId;
|
|
1537
1648
|
#conflictPolicy;
|
|
1538
1649
|
#onSyncConflict;
|
|
1539
1650
|
#operations = [];
|
|
1651
|
+
#rejectedHistory = [];
|
|
1540
1652
|
#loaded = false;
|
|
1541
1653
|
#state = "idle";
|
|
1542
1654
|
#error = null;
|
|
@@ -1544,22 +1656,41 @@ var OfflineQueue = class {
|
|
|
1544
1656
|
#syncPromise = null;
|
|
1545
1657
|
#syncResultListener;
|
|
1546
1658
|
#synchronizeInstances;
|
|
1659
|
+
#retryOptions;
|
|
1547
1660
|
#instanceId = randomId();
|
|
1548
1661
|
#lockStorage;
|
|
1662
|
+
#observedLocks = /* @__PURE__ */ new Map();
|
|
1663
|
+
#warnedMemoryLock = false;
|
|
1549
1664
|
#storageListener;
|
|
1550
1665
|
#channel = null;
|
|
1551
1666
|
constructor(options = {}) {
|
|
1552
|
-
if (options.storage !== void 0)
|
|
1553
|
-
|
|
1667
|
+
if (options.storage !== void 0) {
|
|
1668
|
+
this.#storage = options.storage;
|
|
1669
|
+
this.#queueCapability = "custom";
|
|
1670
|
+
} else if (options.storageLike !== void 0 && options.storageLike !== null) {
|
|
1554
1671
|
this.#storage = new LocalStorageQueueStorage(options.storageLike);
|
|
1555
|
-
|
|
1672
|
+
this.#queueCapability = "localstorage";
|
|
1673
|
+
} else {
|
|
1674
|
+
const selected = defaultStorage(options.databaseName);
|
|
1675
|
+
this.#storage = selected.storage;
|
|
1676
|
+
this.#queueCapability = selected.capability;
|
|
1677
|
+
}
|
|
1556
1678
|
this.#configuredKey = options.key;
|
|
1557
1679
|
this.#rallyId = options.rallyId;
|
|
1558
1680
|
this.#userId = options.userId ?? null;
|
|
1559
1681
|
this.#conflictPolicy = options.conflictPolicy ?? "server_wins";
|
|
1560
1682
|
this.#onSyncConflict = options.onSyncConflict;
|
|
1561
1683
|
this.#synchronizeInstances = options.synchronizeInstances ?? true;
|
|
1562
|
-
|
|
1684
|
+
const retryOptions = {
|
|
1685
|
+
...DEFAULT_RETRY_OPTIONS,
|
|
1686
|
+
...options.retryOptions ?? options.syncRetryOptions ?? options.retry ?? {}
|
|
1687
|
+
};
|
|
1688
|
+
this.#retryOptions = {
|
|
1689
|
+
maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
|
|
1690
|
+
initialIntervalMs: Math.max(0, retryOptions.initialIntervalMs),
|
|
1691
|
+
backoffMultiplier: Math.max(1, retryOptions.backoffMultiplier)
|
|
1692
|
+
};
|
|
1693
|
+
this.#lockStorage = options.storageLike ?? availableLocalStorage();
|
|
1563
1694
|
if (this.#synchronizeInstances) this.#subscribeToExternalChanges();
|
|
1564
1695
|
}
|
|
1565
1696
|
get syncState() {
|
|
@@ -1568,6 +1699,12 @@ var OfflineQueue = class {
|
|
|
1568
1699
|
get pendingCount() {
|
|
1569
1700
|
return this.#operations.length;
|
|
1570
1701
|
}
|
|
1702
|
+
get queueCapability() {
|
|
1703
|
+
return this.#queueCapability;
|
|
1704
|
+
}
|
|
1705
|
+
get rejectedHistory() {
|
|
1706
|
+
return this.#rejectedHistory;
|
|
1707
|
+
}
|
|
1571
1708
|
get error() {
|
|
1572
1709
|
return this.#error;
|
|
1573
1710
|
}
|
|
@@ -1591,7 +1728,20 @@ var OfflineQueue = class {
|
|
|
1591
1728
|
}
|
|
1592
1729
|
async initialize() {
|
|
1593
1730
|
if (this.#loaded) return;
|
|
1594
|
-
|
|
1731
|
+
try {
|
|
1732
|
+
const key = this.#storageKey();
|
|
1733
|
+
this.#operations = (await this.#storage.load(key)).map(normalizeOperation);
|
|
1734
|
+
this.#rejectedHistory = (await this.#storage.loadRejectedHistory?.(key))?.map(normalizeRejectedHistory) ?? [];
|
|
1735
|
+
} catch (error) {
|
|
1736
|
+
if (this.#queueCapability === "memory") throw error;
|
|
1737
|
+
this.#storage = new MemoryQueueStorage();
|
|
1738
|
+
this.#queueCapability = "memory";
|
|
1739
|
+
this.#operations = [];
|
|
1740
|
+
this.#rejectedHistory = [];
|
|
1741
|
+
this.#warnMemoryLock(
|
|
1742
|
+
`Offline queue persistence is unavailable; queued data can be lost after reload (${String(error)}).`
|
|
1743
|
+
);
|
|
1744
|
+
}
|
|
1595
1745
|
this.#loaded = true;
|
|
1596
1746
|
}
|
|
1597
1747
|
/** Releases browser listeners when the queue is no longer used. */
|
|
@@ -1634,9 +1784,9 @@ var OfflineQueue = class {
|
|
|
1634
1784
|
throw new Error("Offline operation belongs to another rally or user queue.");
|
|
1635
1785
|
}
|
|
1636
1786
|
await this.initialize();
|
|
1637
|
-
const id2 =
|
|
1638
|
-
if (this.#operations.some((item) =>
|
|
1639
|
-
this.#operations = [...this.#operations, operation];
|
|
1787
|
+
const id2 = offlineOperationId(operation);
|
|
1788
|
+
if (this.#operations.some((item) => offlineOperationId(item) === id2)) return;
|
|
1789
|
+
this.#operations = [...this.#operations, { ...operation, status: "PENDING", attempts: 0 }];
|
|
1640
1790
|
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1641
1791
|
this.#announceChange();
|
|
1642
1792
|
}
|
|
@@ -1652,6 +1802,47 @@ var OfflineQueue = class {
|
|
|
1652
1802
|
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1653
1803
|
this.#announceChange();
|
|
1654
1804
|
}
|
|
1805
|
+
async discardRejected(operationId) {
|
|
1806
|
+
await this.initialize();
|
|
1807
|
+
const next = this.#rejectedHistory.filter(
|
|
1808
|
+
(entry) => offlineOperationId(entry.operation) !== operationId
|
|
1809
|
+
);
|
|
1810
|
+
if (next.length === this.#rejectedHistory.length) return false;
|
|
1811
|
+
this.#rejectedHistory = next;
|
|
1812
|
+
await this.#saveRejectedHistory();
|
|
1813
|
+
this.#announceChange();
|
|
1814
|
+
return true;
|
|
1815
|
+
}
|
|
1816
|
+
async retryRejected(operationId) {
|
|
1817
|
+
await this.initialize();
|
|
1818
|
+
const entry = this.#rejectedHistory.find(
|
|
1819
|
+
(candidate) => offlineOperationId(candidate.operation) === operationId
|
|
1820
|
+
);
|
|
1821
|
+
if (entry === void 0) return false;
|
|
1822
|
+
if (!this.#operations.some((operation) => offlineOperationId(operation) === operationId))
|
|
1823
|
+
this.#operations = [
|
|
1824
|
+
...this.#operations,
|
|
1825
|
+
{ ...entry.operation, status: "PENDING", attempts: 0 }
|
|
1826
|
+
];
|
|
1827
|
+
this.#rejectedHistory = this.#rejectedHistory.filter((candidate) => candidate !== entry);
|
|
1828
|
+
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1829
|
+
await this.#saveRejectedHistory();
|
|
1830
|
+
this.#announceChange();
|
|
1831
|
+
return true;
|
|
1832
|
+
}
|
|
1833
|
+
async discardRejectedOperation(operationId) {
|
|
1834
|
+
return this.discardRejected(operationId);
|
|
1835
|
+
}
|
|
1836
|
+
async retryRejectedOperation(operationId) {
|
|
1837
|
+
return this.retryRejected(operationId);
|
|
1838
|
+
}
|
|
1839
|
+
async clearRejectedHistory() {
|
|
1840
|
+
await this.initialize();
|
|
1841
|
+
if (this.#rejectedHistory.length === 0) return;
|
|
1842
|
+
this.#rejectedHistory = [];
|
|
1843
|
+
await this.#saveRejectedHistory();
|
|
1844
|
+
this.#announceChange();
|
|
1845
|
+
}
|
|
1655
1846
|
async sync(sender = this.#sender) {
|
|
1656
1847
|
await this.initialize();
|
|
1657
1848
|
if (sender === void 0) throw new Error("OfflineQueue.sync requires a sender.");
|
|
@@ -1666,6 +1857,37 @@ var OfflineQueue = class {
|
|
|
1666
1857
|
return this.sync(sender);
|
|
1667
1858
|
}
|
|
1668
1859
|
async #run(sender) {
|
|
1860
|
+
const locks = globalThis.navigator?.locks;
|
|
1861
|
+
if (locks !== void 0 && typeof locks.request === "function") {
|
|
1862
|
+
let callbackStarted = false;
|
|
1863
|
+
try {
|
|
1864
|
+
const acquired = await locks.request(
|
|
1865
|
+
`stamprally:${this.#storageKey()}:sync`,
|
|
1866
|
+
{ ifAvailable: true },
|
|
1867
|
+
async (lock) => {
|
|
1868
|
+
if (lock === null) {
|
|
1869
|
+
await this.#reloadFromStorage();
|
|
1870
|
+
this.#state = "idle";
|
|
1871
|
+
return false;
|
|
1872
|
+
}
|
|
1873
|
+
callbackStarted = true;
|
|
1874
|
+
await this.#runWithStorageLock(sender);
|
|
1875
|
+
return true;
|
|
1876
|
+
}
|
|
1877
|
+
);
|
|
1878
|
+
if (!acquired) return;
|
|
1879
|
+
return;
|
|
1880
|
+
} catch (error) {
|
|
1881
|
+
if (callbackStarted) throw error;
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
if (this.#lockStorage === null)
|
|
1885
|
+
this.#warnMemoryLock(
|
|
1886
|
+
"No cross-tab storage lock is available; offline sync is single-tab only."
|
|
1887
|
+
);
|
|
1888
|
+
await this.#runWithStorageLock(sender);
|
|
1889
|
+
}
|
|
1890
|
+
async #runWithStorageLock(sender) {
|
|
1669
1891
|
this.#state = "syncing";
|
|
1670
1892
|
this.#error = null;
|
|
1671
1893
|
if (!this.#acquireSyncLock()) {
|
|
@@ -1677,21 +1899,54 @@ var OfflineQueue = class {
|
|
|
1677
1899
|
while (this.#operations.length > 0) {
|
|
1678
1900
|
const operation = this.#operations[0];
|
|
1679
1901
|
if (operation === void 0) break;
|
|
1680
|
-
let
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1902
|
+
let attempt = 0;
|
|
1903
|
+
let response;
|
|
1904
|
+
while (true) {
|
|
1905
|
+
await this.#updateOperationStatus("IN_FLIGHT", attempt);
|
|
1906
|
+
try {
|
|
1907
|
+
response = this.#normalizeResponse(await sender(operation));
|
|
1908
|
+
} catch (cause) {
|
|
1909
|
+
response = {
|
|
1910
|
+
status: "RETRYABLE_ERROR",
|
|
1911
|
+
error: errorValue(cause, "RETRYABLE_ERROR")
|
|
1912
|
+
};
|
|
1913
|
+
}
|
|
1914
|
+
if (response.status !== "RETRYABLE_ERROR") break;
|
|
1688
1915
|
const error2 = errorValue(response.error ?? response.reason, "RETRYABLE_ERROR");
|
|
1916
|
+
await this.#updateOperationStatus("PENDING", attempt + 1);
|
|
1689
1917
|
await this.#syncResultListener?.({ operation, status: response.status, error: error2 });
|
|
1690
|
-
|
|
1918
|
+
if (attempt >= this.#retryOptions.maxRetries) {
|
|
1919
|
+
await this.#updateOperationStatus("FAILED_RETRYABLE", attempt + 1);
|
|
1920
|
+
throw new Error(error2.message);
|
|
1921
|
+
}
|
|
1922
|
+
const interval = this.#retryOptions.initialIntervalMs * this.#retryOptions.backoffMultiplier ** attempt;
|
|
1923
|
+
await new Promise((resolve) => setTimeout(resolve, Math.max(0, interval)));
|
|
1924
|
+
attempt += 1;
|
|
1691
1925
|
}
|
|
1692
1926
|
const result = response.result;
|
|
1693
|
-
const state = response.state ?? (result !== void 0 && "conflict" in result && result.conflict === true ?
|
|
1694
|
-
const error = response.status === "REJECTED_PERMANENT" ? errorValue(
|
|
1927
|
+
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);
|
|
1928
|
+
const error = response.status === "REJECTED_PERMANENT" ? errorValue(
|
|
1929
|
+
response.error ?? response.reason ?? (result !== void 0 && "ok" in result && !result.ok ? result.error : void 0),
|
|
1930
|
+
"REJECTED_PERMANENT"
|
|
1931
|
+
) : void 0;
|
|
1932
|
+
await this.#updateOperationStatus(
|
|
1933
|
+
response.status === "ACCEPTED" ? "ACCEPTED" : "REJECTED_PERMANENT",
|
|
1934
|
+
attempt + 1
|
|
1935
|
+
);
|
|
1936
|
+
const eventState = state;
|
|
1937
|
+
if (response.status === "REJECTED_PERMANENT" && error !== void 0) {
|
|
1938
|
+
this.#rejectedHistory = [
|
|
1939
|
+
...this.#rejectedHistory,
|
|
1940
|
+
{
|
|
1941
|
+
operation: { ...operation, status: "REJECTED_PERMANENT", attempts: attempt + 1 },
|
|
1942
|
+
reason: error,
|
|
1943
|
+
errorCode: error.code,
|
|
1944
|
+
rejectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1945
|
+
attempts: attempt + 1
|
|
1946
|
+
}
|
|
1947
|
+
];
|
|
1948
|
+
await this.#saveRejectedHistory();
|
|
1949
|
+
}
|
|
1695
1950
|
this.#operations = this.#operations.slice(1);
|
|
1696
1951
|
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1697
1952
|
this.#announceChange();
|
|
@@ -1700,7 +1955,7 @@ var OfflineQueue = class {
|
|
|
1700
1955
|
...result === void 0 ? {} : { result },
|
|
1701
1956
|
status: response.status,
|
|
1702
1957
|
...error === void 0 ? {} : { error },
|
|
1703
|
-
...
|
|
1958
|
+
...eventState === void 0 ? {} : { state: eventState }
|
|
1704
1959
|
});
|
|
1705
1960
|
}
|
|
1706
1961
|
this.#state = "idle";
|
|
@@ -1712,11 +1967,19 @@ var OfflineQueue = class {
|
|
|
1712
1967
|
this.#releaseSyncLock();
|
|
1713
1968
|
}
|
|
1714
1969
|
}
|
|
1970
|
+
async #updateOperationStatus(status, attempts) {
|
|
1971
|
+
const operation = this.#operations[0];
|
|
1972
|
+
if (operation === void 0) return;
|
|
1973
|
+
this.#operations = [{ ...operation, status, attempts }, ...this.#operations.slice(1)];
|
|
1974
|
+
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1975
|
+
this.#announceChange();
|
|
1976
|
+
}
|
|
1715
1977
|
#subscribeToExternalChanges() {
|
|
1716
1978
|
const windowLike = globalThis.window;
|
|
1717
1979
|
if (windowLike !== void 0) {
|
|
1718
1980
|
this.#storageListener = (event) => {
|
|
1719
|
-
if (event.key === this.#storageKey()
|
|
1981
|
+
if (event.key === this.#storageKey() || event.key === `${this.#storageKey()}:rejected-history`)
|
|
1982
|
+
void this.#reloadFromStorage();
|
|
1720
1983
|
};
|
|
1721
1984
|
windowLike.addEventListener("storage", this.#storageListener);
|
|
1722
1985
|
}
|
|
@@ -1725,6 +1988,20 @@ var OfflineQueue = class {
|
|
|
1725
1988
|
try {
|
|
1726
1989
|
this.#channel = new Channel("stamprally:queue-sync");
|
|
1727
1990
|
this.#channel.addEventListener("message", (event) => {
|
|
1991
|
+
if (typeof event.data === "object" && event.data !== null) {
|
|
1992
|
+
const data = event.data;
|
|
1993
|
+
if (data.type === "lock" && data.lockKey === this.#lockKey()) {
|
|
1994
|
+
this.#observedLocks.set(data.lockKey, {
|
|
1995
|
+
owner: typeof data.owner === "string" ? data.owner : "unknown",
|
|
1996
|
+
expiresAt: typeof data.expiresAt === "number" ? data.expiresAt : 0
|
|
1997
|
+
});
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
if (data.type === "unlock" && data.lockKey === this.#lockKey()) {
|
|
2001
|
+
this.#observedLocks.delete(data.lockKey);
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
1728
2005
|
if (typeof event.data === "object" && event.data !== null && "key" in event.data && event.data.key === this.#storageKey())
|
|
1729
2006
|
void this.#reloadFromStorage();
|
|
1730
2007
|
});
|
|
@@ -1734,12 +2011,18 @@ var OfflineQueue = class {
|
|
|
1734
2011
|
}
|
|
1735
2012
|
}
|
|
1736
2013
|
#announceChange() {
|
|
1737
|
-
this.#channel?.postMessage({
|
|
2014
|
+
this.#channel?.postMessage({
|
|
2015
|
+
type: "change",
|
|
2016
|
+
key: this.#storageKey(),
|
|
2017
|
+
owner: this.#instanceId
|
|
2018
|
+
});
|
|
1738
2019
|
}
|
|
1739
2020
|
async #reloadFromStorage() {
|
|
1740
2021
|
if (this.#state === "syncing") return;
|
|
1741
2022
|
try {
|
|
1742
|
-
|
|
2023
|
+
const key = this.#storageKey();
|
|
2024
|
+
this.#operations = (await this.#storage.load(key)).map(normalizeOperation);
|
|
2025
|
+
this.#rejectedHistory = (await this.#storage.loadRejectedHistory?.(key))?.map(normalizeRejectedHistory) ?? this.#rejectedHistory;
|
|
1743
2026
|
this.#loaded = true;
|
|
1744
2027
|
} catch {
|
|
1745
2028
|
}
|
|
@@ -1753,6 +2036,9 @@ var OfflineQueue = class {
|
|
|
1753
2036
|
const local = syncLocks.get(key);
|
|
1754
2037
|
if (local !== void 0 && local.expiresAt > now && local.owner !== this.#instanceId)
|
|
1755
2038
|
return false;
|
|
2039
|
+
const observed = this.#observedLocks.get(key);
|
|
2040
|
+
if (observed !== void 0 && observed.expiresAt > now && observed.owner !== this.#instanceId)
|
|
2041
|
+
return false;
|
|
1756
2042
|
if (this.#lockStorage !== null) {
|
|
1757
2043
|
try {
|
|
1758
2044
|
const existing = this.#lockStorage.getItem(key);
|
|
@@ -1765,7 +2051,14 @@ var OfflineQueue = class {
|
|
|
1765
2051
|
key,
|
|
1766
2052
|
JSON.stringify({ owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS })
|
|
1767
2053
|
);
|
|
2054
|
+
this.#channel?.postMessage({
|
|
2055
|
+
type: "lock",
|
|
2056
|
+
lockKey: key,
|
|
2057
|
+
owner: this.#instanceId,
|
|
2058
|
+
expiresAt: now + SYNC_LOCK_TTL_MS
|
|
2059
|
+
});
|
|
1768
2060
|
} catch {
|
|
2061
|
+
this.#warnMemoryLock("Persistent lock access failed; offline sync is single-tab only.");
|
|
1769
2062
|
}
|
|
1770
2063
|
}
|
|
1771
2064
|
syncLocks.set(key, { owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS });
|
|
@@ -1780,6 +2073,7 @@ var OfflineQueue = class {
|
|
|
1780
2073
|
const value = this.#lockStorage.getItem(key);
|
|
1781
2074
|
if (value !== null && JSON.parse(value).owner === this.#instanceId)
|
|
1782
2075
|
this.#lockStorage.removeItem?.(key);
|
|
2076
|
+
this.#channel?.postMessage({ type: "unlock", lockKey: key, owner: this.#instanceId });
|
|
1783
2077
|
} catch {
|
|
1784
2078
|
}
|
|
1785
2079
|
}
|
|
@@ -1808,7 +2102,33 @@ var OfflineQueue = class {
|
|
|
1808
2102
|
const policy = (typeof configured === "function" ? await configured({ operation, localState, serverState }) : configured) ?? this.#conflictPolicy;
|
|
1809
2103
|
return resolveRallyStateConflict(serverState, localState, { policy });
|
|
1810
2104
|
}
|
|
2105
|
+
async #saveRejectedHistory() {
|
|
2106
|
+
await this.#storage.saveRejectedHistory?.(this.#storageKey(), this.#rejectedHistory);
|
|
2107
|
+
}
|
|
2108
|
+
#warnMemoryLock(message) {
|
|
2109
|
+
if (this.#warnedMemoryLock) return;
|
|
2110
|
+
this.#warnedMemoryLock = true;
|
|
2111
|
+
console.warn(`[@stamprally/core] ${message}`);
|
|
2112
|
+
}
|
|
1811
2113
|
};
|
|
2114
|
+
function normalizeOperation(operation) {
|
|
2115
|
+
const status = operation.status;
|
|
2116
|
+
return {
|
|
2117
|
+
...operation,
|
|
2118
|
+
status: status === "IN_FLIGHT" || status === "REJECTED" ? "PENDING" : status === "RETRYABLE_ERROR" ? "FAILED_RETRYABLE" : operation.status ?? "PENDING",
|
|
2119
|
+
attempts: operation.attempts ?? 0
|
|
2120
|
+
};
|
|
2121
|
+
}
|
|
2122
|
+
function normalizeRejectedHistory(entry) {
|
|
2123
|
+
return {
|
|
2124
|
+
...entry,
|
|
2125
|
+
operation: normalizeOperation(entry.operation),
|
|
2126
|
+
reason: errorValue(entry.reason, "REJECTED_PERMANENT"),
|
|
2127
|
+
errorCode: entry.errorCode || entry.reason.code,
|
|
2128
|
+
rejectedAt: entry.rejectedAt || (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
2129
|
+
attempts: entry.attempts ?? entry.operation.attempts ?? 0
|
|
2130
|
+
};
|
|
2131
|
+
}
|
|
1812
2132
|
|
|
1813
2133
|
// src/crypto/token.ts
|
|
1814
2134
|
var encoder = new TextEncoder();
|
|
@@ -2377,6 +2697,8 @@ function condition(value, path, errors, isPublic) {
|
|
|
2377
2697
|
finiteNumber(value, "latitude", path, errors);
|
|
2378
2698
|
finiteNumber(value, "longitude", path, errors);
|
|
2379
2699
|
finiteNumber(value, "radiusMeters", path, errors, 0);
|
|
2700
|
+
if (typeof value.radiusMeters === "number" && value.radiusMeters <= 0)
|
|
2701
|
+
add(errors, `${path}.radiusMeters`, "Expected a radius greater than 0.", "out_of_range");
|
|
2380
2702
|
if (typeof value.latitude === "number" && (value.latitude < -90 || value.latitude > 90))
|
|
2381
2703
|
add(errors, `${path}.latitude`, "Expected a latitude between -90 and 90.", "out_of_range");
|
|
2382
2704
|
if (typeof value.longitude === "number" && (value.longitude < -180 || value.longitude > 180))
|
|
@@ -2787,6 +3109,6 @@ async function verifySnapshotToken(token, secretKey, now = Date.now()) {
|
|
|
2787
3109
|
}
|
|
2788
3110
|
}
|
|
2789
3111
|
|
|
2790
|
-
export { ConfigValidationError, DEFAULT_SHEET_THEME, MemoryQueueStorage as InMemoryOfflineQueueStorage, InMemoryStorage, IndexedDBAdapter, IndexedDBOfflineQueueStorage, LocalStorageAdapter, OfflineQueue, StampRallyClient, StorageAdapterError, THEME_PRESETS, assertPublicConfig, calculateDistanceMeters, calculateProgress, consumeReward, createClaimTicketNumber, createSecureToken, createSignedSnapshotToken, createUniqueClaimTicketNumber, evaluateCondition, evaluateConditionDetailed, evaluateSpotStatus, exportProgressToken, getCurrentGeoContext, getOrderedSpots, getSpotStatus, importProgressToken, isGeolocationSupported, isNfcSupported, isPublicConfig, isQrSupported, isRewardState, isStampRallyState, issueClaimTicketNumber, normalizePasscode, parseAdminConfig, parsePublicConfig, processStamp, readNfcContext, readQrContext, reconcileRewardStates, resolveLocalizedText, resolveRallyStateConflict, safeParseAdminConfig, safeParsePublicConfig, sanitizeAdminConfig, storageKey, toLocalizedString, toPublicConfig, updateLocalizedField, validatePublicConfigSafety, validateRallyConfigRelations, verifyPasscode, verifySecureToken, verifySnapshotToken };
|
|
3112
|
+
export { ConfigValidationError, DEFAULT_SHEET_THEME, MemoryQueueStorage as InMemoryOfflineQueueStorage, InMemoryStorage, IndexedDBAdapter, IndexedDBOfflineQueueStorage, LocalStorageAdapter, OfflineQueue, StampRallyClient, StorageAdapterError, THEME_PRESETS, assertPublicConfig, calculateDistanceMeters, calculateProgress, consumeReward, createAnonymousSessionId, createClaimTicketNumber, createSecureToken, createSignedSnapshotToken, createUniqueClaimTicketNumber, evaluateCondition, evaluateConditionDetailed, evaluateSpotStatus, exportProgressToken, getCurrentGeoContext, getOrderedSpots, getSpotStatus, importProgressToken, isGeolocationSupported, isNfcSupported, isPublicConfig, isQrSupported, isRewardState, isStampRallyState, issueClaimTicketNumber, normalizePasscode, offlineOperationId, parseAdminConfig, parsePublicConfig, processStamp, readNfcContext, readQrContext, reconcileRewardStates, resolveLocalizedText, resolveRallyStateConflict, safeParseAdminConfig, safeParsePublicConfig, sanitizeAdminConfig, storageKey, toLocalizedString, toPublicConfig, updateLocalizedField, validatePublicConfigSafety, validateRallyConfigRelations, verifyPasscode, verifySecureToken, verifySnapshotToken };
|
|
2791
3113
|
//# sourceMappingURL=index.js.map
|
|
2792
3114
|
//# sourceMappingURL=index.js.map
|