@stamprally/core 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +204 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +10 -1
- package/dist/index.d.ts +10 -1
- package/dist/index.js +204 -5
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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,
|
|
@@ -1524,6 +1526,11 @@ function errorValue(value, fallbackCode) {
|
|
|
1524
1526
|
if (typeof value === "string") return { code: fallbackCode, message: value };
|
|
1525
1527
|
return { code: fallbackCode, message: "Offline operation was rejected." };
|
|
1526
1528
|
}
|
|
1529
|
+
var syncLocks = /* @__PURE__ */ new Map();
|
|
1530
|
+
var SYNC_LOCK_TTL_MS = 3e4;
|
|
1531
|
+
function randomId() {
|
|
1532
|
+
return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
1533
|
+
}
|
|
1527
1534
|
var OfflineQueue = class {
|
|
1528
1535
|
#storage;
|
|
1529
1536
|
#configuredKey;
|
|
@@ -1538,6 +1545,11 @@ var OfflineQueue = class {
|
|
|
1538
1545
|
#sender;
|
|
1539
1546
|
#syncPromise = null;
|
|
1540
1547
|
#syncResultListener;
|
|
1548
|
+
#synchronizeInstances;
|
|
1549
|
+
#instanceId = randomId();
|
|
1550
|
+
#lockStorage;
|
|
1551
|
+
#storageListener;
|
|
1552
|
+
#channel = null;
|
|
1541
1553
|
constructor(options = {}) {
|
|
1542
1554
|
if (options.storage !== void 0) this.#storage = options.storage;
|
|
1543
1555
|
else if (options.storageLike !== void 0 && options.storageLike !== null)
|
|
@@ -1548,6 +1560,9 @@ var OfflineQueue = class {
|
|
|
1548
1560
|
this.#userId = options.userId ?? null;
|
|
1549
1561
|
this.#conflictPolicy = options.conflictPolicy ?? "server_wins";
|
|
1550
1562
|
this.#onSyncConflict = options.onSyncConflict;
|
|
1563
|
+
this.#synchronizeInstances = options.synchronizeInstances ?? true;
|
|
1564
|
+
this.#lockStorage = options.storageLike ?? globalThis.localStorage ?? null;
|
|
1565
|
+
if (this.#synchronizeInstances) this.#subscribeToExternalChanges();
|
|
1551
1566
|
}
|
|
1552
1567
|
get syncState() {
|
|
1553
1568
|
return this.#state;
|
|
@@ -1581,6 +1596,16 @@ var OfflineQueue = class {
|
|
|
1581
1596
|
this.#operations = [...await this.#storage.load(this.#storageKey())];
|
|
1582
1597
|
this.#loaded = true;
|
|
1583
1598
|
}
|
|
1599
|
+
/** Releases browser listeners when the queue is no longer used. */
|
|
1600
|
+
dispose() {
|
|
1601
|
+
const windowLike = globalThis.window;
|
|
1602
|
+
if (windowLike !== void 0 && this.#storageListener !== void 0)
|
|
1603
|
+
windowLike.removeEventListener("storage", this.#storageListener);
|
|
1604
|
+
this.#storageListener = void 0;
|
|
1605
|
+
this.#channel?.close();
|
|
1606
|
+
this.#channel = null;
|
|
1607
|
+
this.#releaseSyncLock();
|
|
1608
|
+
}
|
|
1584
1609
|
/** Selects a rally/user queue scope and loads its pending operations. */
|
|
1585
1610
|
async setScope(rallyId, userId) {
|
|
1586
1611
|
if (this.#configuredKey !== void 0) {
|
|
@@ -1615,6 +1640,7 @@ var OfflineQueue = class {
|
|
|
1615
1640
|
if (this.#operations.some((item) => operationId(item) === id2)) return;
|
|
1616
1641
|
this.#operations = [...this.#operations, operation];
|
|
1617
1642
|
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1643
|
+
this.#announceChange();
|
|
1618
1644
|
}
|
|
1619
1645
|
async enqueueCheckIn(request) {
|
|
1620
1646
|
return this.enqueue({ kind: "checkIn", request });
|
|
@@ -1626,6 +1652,7 @@ var OfflineQueue = class {
|
|
|
1626
1652
|
await this.initialize();
|
|
1627
1653
|
this.#operations = [];
|
|
1628
1654
|
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1655
|
+
this.#announceChange();
|
|
1629
1656
|
}
|
|
1630
1657
|
async sync(sender = this.#sender) {
|
|
1631
1658
|
await this.initialize();
|
|
@@ -1643,6 +1670,11 @@ var OfflineQueue = class {
|
|
|
1643
1670
|
async #run(sender) {
|
|
1644
1671
|
this.#state = "syncing";
|
|
1645
1672
|
this.#error = null;
|
|
1673
|
+
if (!this.#acquireSyncLock()) {
|
|
1674
|
+
await this.#reloadFromStorage();
|
|
1675
|
+
this.#state = "idle";
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1646
1678
|
try {
|
|
1647
1679
|
while (this.#operations.length > 0) {
|
|
1648
1680
|
const operation = this.#operations[0];
|
|
@@ -1664,6 +1696,7 @@ var OfflineQueue = class {
|
|
|
1664
1696
|
const error = response.status === "REJECTED_PERMANENT" ? errorValue(response.error ?? response.reason, "REJECTED_PERMANENT") : void 0;
|
|
1665
1697
|
this.#operations = this.#operations.slice(1);
|
|
1666
1698
|
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1699
|
+
this.#announceChange();
|
|
1667
1700
|
await this.#syncResultListener?.({
|
|
1668
1701
|
operation,
|
|
1669
1702
|
...result === void 0 ? {} : { result },
|
|
@@ -1677,6 +1710,80 @@ var OfflineQueue = class {
|
|
|
1677
1710
|
this.#state = "error";
|
|
1678
1711
|
this.#error = cause instanceof Error ? cause : new Error(String(cause));
|
|
1679
1712
|
throw this.#error;
|
|
1713
|
+
} finally {
|
|
1714
|
+
this.#releaseSyncLock();
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
#subscribeToExternalChanges() {
|
|
1718
|
+
const windowLike = globalThis.window;
|
|
1719
|
+
if (windowLike !== void 0) {
|
|
1720
|
+
this.#storageListener = (event) => {
|
|
1721
|
+
if (event.key === this.#storageKey()) void this.#reloadFromStorage();
|
|
1722
|
+
};
|
|
1723
|
+
windowLike.addEventListener("storage", this.#storageListener);
|
|
1724
|
+
}
|
|
1725
|
+
const Channel = globalThis.BroadcastChannel;
|
|
1726
|
+
if (Channel !== void 0) {
|
|
1727
|
+
try {
|
|
1728
|
+
this.#channel = new Channel("stamprally:queue-sync");
|
|
1729
|
+
this.#channel.addEventListener("message", (event) => {
|
|
1730
|
+
if (typeof event.data === "object" && event.data !== null && "key" in event.data && event.data.key === this.#storageKey())
|
|
1731
|
+
void this.#reloadFromStorage();
|
|
1732
|
+
});
|
|
1733
|
+
} catch {
|
|
1734
|
+
this.#channel = null;
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
#announceChange() {
|
|
1739
|
+
this.#channel?.postMessage({ key: this.#storageKey(), owner: this.#instanceId });
|
|
1740
|
+
}
|
|
1741
|
+
async #reloadFromStorage() {
|
|
1742
|
+
if (this.#state === "syncing") return;
|
|
1743
|
+
try {
|
|
1744
|
+
this.#operations = [...await this.#storage.load(this.#storageKey())];
|
|
1745
|
+
this.#loaded = true;
|
|
1746
|
+
} catch {
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
#lockKey() {
|
|
1750
|
+
return `${this.#storageKey()}:sync-lock`;
|
|
1751
|
+
}
|
|
1752
|
+
#acquireSyncLock() {
|
|
1753
|
+
const key = this.#lockKey();
|
|
1754
|
+
const now = Date.now();
|
|
1755
|
+
const local = syncLocks.get(key);
|
|
1756
|
+
if (local !== void 0 && local.expiresAt > now && local.owner !== this.#instanceId)
|
|
1757
|
+
return false;
|
|
1758
|
+
if (this.#lockStorage !== null) {
|
|
1759
|
+
try {
|
|
1760
|
+
const existing = this.#lockStorage.getItem(key);
|
|
1761
|
+
if (existing !== null) {
|
|
1762
|
+
const parsed = JSON.parse(existing);
|
|
1763
|
+
if (typeof parsed === "object" && parsed !== null && typeof parsed.expiresAt === "number" && parsed.expiresAt > now && parsed.owner !== this.#instanceId)
|
|
1764
|
+
return false;
|
|
1765
|
+
}
|
|
1766
|
+
this.#lockStorage.setItem(
|
|
1767
|
+
key,
|
|
1768
|
+
JSON.stringify({ owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS })
|
|
1769
|
+
);
|
|
1770
|
+
} catch {
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
syncLocks.set(key, { owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS });
|
|
1774
|
+
return true;
|
|
1775
|
+
}
|
|
1776
|
+
#releaseSyncLock() {
|
|
1777
|
+
const key = this.#lockKey();
|
|
1778
|
+
const current = syncLocks.get(key);
|
|
1779
|
+
if (current?.owner === this.#instanceId) syncLocks.delete(key);
|
|
1780
|
+
if (this.#lockStorage !== null) {
|
|
1781
|
+
try {
|
|
1782
|
+
const value = this.#lockStorage.getItem(key);
|
|
1783
|
+
if (value !== null && JSON.parse(value).owner === this.#instanceId)
|
|
1784
|
+
this.#lockStorage.removeItem?.(key);
|
|
1785
|
+
} catch {
|
|
1786
|
+
}
|
|
1680
1787
|
}
|
|
1681
1788
|
}
|
|
1682
1789
|
#storageKey() {
|
|
@@ -2439,21 +2546,112 @@ function validate(value, isPublic) {
|
|
|
2439
2546
|
optionalString(value, "staffPasscode", "$", errors);
|
|
2440
2547
|
if (hasOwn(value, "inventory") && value.inventory !== void 0 && !isRecord3(value.inventory))
|
|
2441
2548
|
add(errors, "$.inventory", "Expected an object.", "invalid_type");
|
|
2549
|
+
if (hasOwn(value, "inventoryMode") && value.inventoryMode !== void 0 && value.inventoryMode !== "shared" && value.inventoryMode !== "per_reward")
|
|
2550
|
+
add(errors, "$.inventoryMode", "Expected shared or per_reward.", "invalid_enum");
|
|
2442
2551
|
if (hasOwn(value, "serverMetadata") && value.serverMetadata !== void 0 && !isRecord3(value.serverMetadata))
|
|
2443
2552
|
add(errors, "$.serverMetadata", "Expected an object.", "invalid_type");
|
|
2444
2553
|
if (hasOwn(value, "publicMetadata") && value.publicMetadata !== void 0 && !isRecord3(value.publicMetadata))
|
|
2445
2554
|
add(errors, "$.publicMetadata", "Expected an object.", "invalid_type");
|
|
2446
2555
|
optionalString(value, "serverEndpoint", "$", errors);
|
|
2447
2556
|
} else {
|
|
2448
|
-
for (const key of ["staffPasscode", "serverMetadata", "inventory"])
|
|
2557
|
+
for (const key of ["staffPasscode", "serverMetadata", "inventory", "inventoryMode"])
|
|
2449
2558
|
if (hasOwn(value, key))
|
|
2450
2559
|
add(errors, `$.${key}`, "Private field is not allowed.", "private_field");
|
|
2451
2560
|
optionalString(value, "serverEndpoint", "$", errors);
|
|
2452
2561
|
}
|
|
2453
2562
|
return errors;
|
|
2454
2563
|
}
|
|
2564
|
+
function validateRallyConfigRelations(config) {
|
|
2565
|
+
const errors = [];
|
|
2566
|
+
const spotIds = /* @__PURE__ */ new Set();
|
|
2567
|
+
const rewardIds = /* @__PURE__ */ new Set();
|
|
2568
|
+
const orderIndexes = /* @__PURE__ */ new Map();
|
|
2569
|
+
config.spots.forEach((spot2, index) => {
|
|
2570
|
+
if (spotIds.has(spot2.id))
|
|
2571
|
+
add(errors, `spots[${index}].id`, "Spot ID must be unique.", "duplicate_spot_id");
|
|
2572
|
+
spotIds.add(spot2.id);
|
|
2573
|
+
const previousIndex = orderIndexes.get(spot2.orderIndex);
|
|
2574
|
+
if (previousIndex !== void 0)
|
|
2575
|
+
add(
|
|
2576
|
+
errors,
|
|
2577
|
+
`spots[${index}].orderIndex`,
|
|
2578
|
+
`orderIndex duplicates spots[${previousIndex}].`,
|
|
2579
|
+
"duplicate_order_index"
|
|
2580
|
+
);
|
|
2581
|
+
else orderIndexes.set(spot2.orderIndex, index);
|
|
2582
|
+
if (spot2.orderIndex < 0)
|
|
2583
|
+
add(
|
|
2584
|
+
errors,
|
|
2585
|
+
`spots[${index}].orderIndex`,
|
|
2586
|
+
"orderIndex must not be negative.",
|
|
2587
|
+
"negative_order_index"
|
|
2588
|
+
);
|
|
2589
|
+
spot2.prerequisites?.forEach((prerequisite, prerequisiteIndex) => {
|
|
2590
|
+
if (!spotIds.has(prerequisite) && !config.spots.some((candidate) => candidate.id === prerequisite))
|
|
2591
|
+
add(
|
|
2592
|
+
errors,
|
|
2593
|
+
`spots[${index}].prerequisites[${prerequisiteIndex}]`,
|
|
2594
|
+
"Prerequisite spot does not exist.",
|
|
2595
|
+
"missing_prerequisite"
|
|
2596
|
+
);
|
|
2597
|
+
});
|
|
2598
|
+
});
|
|
2599
|
+
config.rewards.forEach((reward2, index) => {
|
|
2600
|
+
if (rewardIds.has(reward2.id))
|
|
2601
|
+
add(errors, `rewards[${index}].id`, "Reward ID must be unique.", "duplicate_reward_id");
|
|
2602
|
+
rewardIds.add(reward2.id);
|
|
2603
|
+
const visit = (condition2, path) => {
|
|
2604
|
+
if (condition2.type === "stamps")
|
|
2605
|
+
condition2.stampIds.forEach((stampId, stampIndex) => {
|
|
2606
|
+
if (!spotIds.has(stampId))
|
|
2607
|
+
add(
|
|
2608
|
+
errors,
|
|
2609
|
+
`${path}.stampIds[${stampIndex}]`,
|
|
2610
|
+
"Referenced spot does not exist.",
|
|
2611
|
+
"missing_reward_spot"
|
|
2612
|
+
);
|
|
2613
|
+
});
|
|
2614
|
+
else if (condition2.type === "all" || condition2.type === "any")
|
|
2615
|
+
condition2.conditions.forEach((nested, nestedIndex) => {
|
|
2616
|
+
visit(nested, `${path}.conditions[${nestedIndex}]`);
|
|
2617
|
+
});
|
|
2618
|
+
};
|
|
2619
|
+
reward2.conditions?.forEach((condition2, conditionIndex) => {
|
|
2620
|
+
visit(condition2, `rewards[${index}].conditions[${conditionIndex}]`);
|
|
2621
|
+
});
|
|
2622
|
+
});
|
|
2623
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
2624
|
+
const visited = /* @__PURE__ */ new Set();
|
|
2625
|
+
const cycleNodes = /* @__PURE__ */ new Set();
|
|
2626
|
+
const visitSpot = (spotId) => {
|
|
2627
|
+
if (visiting.has(spotId)) {
|
|
2628
|
+
cycleNodes.add(spotId);
|
|
2629
|
+
return;
|
|
2630
|
+
}
|
|
2631
|
+
if (visited.has(spotId)) return;
|
|
2632
|
+
visiting.add(spotId);
|
|
2633
|
+
const spot2 = config.spots.find((candidate) => candidate.id === spotId);
|
|
2634
|
+
spot2?.prerequisites?.forEach(visitSpot);
|
|
2635
|
+
visiting.delete(spotId);
|
|
2636
|
+
visited.add(spotId);
|
|
2637
|
+
};
|
|
2638
|
+
config.spots.forEach((spot2) => {
|
|
2639
|
+
visitSpot(spot2.id);
|
|
2640
|
+
});
|
|
2641
|
+
cycleNodes.forEach((spotId) => {
|
|
2642
|
+
const index = config.spots.findIndex((spot2) => spot2.id === spotId);
|
|
2643
|
+
add(
|
|
2644
|
+
errors,
|
|
2645
|
+
`spots[${index}].prerequisites`,
|
|
2646
|
+
"Prerequisites must form a DAG.",
|
|
2647
|
+
"cyclic_prerequisites"
|
|
2648
|
+
);
|
|
2649
|
+
});
|
|
2650
|
+
return errors;
|
|
2651
|
+
}
|
|
2455
2652
|
function safeParseAdminConfig(input) {
|
|
2456
|
-
const errors = validate(input, false);
|
|
2653
|
+
const errors = [...validate(input, false)];
|
|
2654
|
+
if (errors.length === 0) errors.push(...validateRallyConfigRelations(input));
|
|
2457
2655
|
return errors.length === 0 ? { success: true, data: input } : { success: false, errors };
|
|
2458
2656
|
}
|
|
2459
2657
|
function parseAdminConfig(input) {
|
|
@@ -2462,7 +2660,8 @@ function parseAdminConfig(input) {
|
|
|
2462
2660
|
return result.data;
|
|
2463
2661
|
}
|
|
2464
2662
|
function safeParsePublicConfig(input) {
|
|
2465
|
-
const errors = validate(input, true);
|
|
2663
|
+
const errors = [...validate(input, true)];
|
|
2664
|
+
if (errors.length === 0) errors.push(...validateRallyConfigRelations(input));
|
|
2466
2665
|
return errors.length === 0 ? { success: true, data: input } : { success: false, errors };
|
|
2467
2666
|
}
|
|
2468
2667
|
function parsePublicConfig(input) {
|
|
@@ -2641,6 +2840,7 @@ exports.toLocalizedString = toLocalizedString;
|
|
|
2641
2840
|
exports.toPublicConfig = toPublicConfig;
|
|
2642
2841
|
exports.updateLocalizedField = updateLocalizedField;
|
|
2643
2842
|
exports.validatePublicConfigSafety = validatePublicConfigSafety;
|
|
2843
|
+
exports.validateRallyConfigRelations = validateRallyConfigRelations;
|
|
2644
2844
|
exports.verifyPasscode = verifyPasscode;
|
|
2645
2845
|
exports.verifySecureToken = verifySecureToken;
|
|
2646
2846
|
exports.verifySnapshotToken = verifySnapshotToken;
|