@stamprally/core 0.16.0 → 0.18.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 +6 -6
- package/dist/index.cjs +1349 -1021
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +74 -18
- package/dist/index.d.ts +74 -18
- package/dist/index.js +1347 -1022
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -110,72 +110,20 @@ function issueClaimTicketNumber(reward2, currentState, options = {}) {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
// src/engine/sync.ts
|
|
113
|
-
function
|
|
114
|
-
const serverTime = Date.parse(serverTimestamp);
|
|
115
|
-
const localTime = Date.parse(localTimestamp);
|
|
116
|
-
if (!Number.isNaN(serverTime) && !Number.isNaN(localTime))
|
|
117
|
-
return serverTime >= localTime ? serverTimestamp : localTimestamp;
|
|
118
|
-
if (!Number.isNaN(serverTime)) return serverTimestamp;
|
|
119
|
-
if (!Number.isNaN(localTime)) return localTimestamp;
|
|
120
|
-
return serverTimestamp >= localTimestamp ? serverTimestamp : localTimestamp;
|
|
121
|
-
}
|
|
122
|
-
function mergeRewardStates(serverRewards, localRewards) {
|
|
123
|
-
const merged = new Map(serverRewards.map((reward2) => [reward2.rewardId, reward2]));
|
|
124
|
-
for (const localReward of localRewards) {
|
|
125
|
-
const serverReward = merged.get(localReward.rewardId);
|
|
126
|
-
if (serverReward === void 0) {
|
|
127
|
-
merged.set(localReward.rewardId, localReward);
|
|
128
|
-
continue;
|
|
129
|
-
}
|
|
130
|
-
const winner = localReward.status === "CONSUMED" && serverReward.status !== "CONSUMED" ? localReward : serverReward;
|
|
131
|
-
merged.set(localReward.rewardId, {
|
|
132
|
-
...winner,
|
|
133
|
-
...serverReward.unlockedAt === void 0 && localReward.unlockedAt === void 0 ? {} : {
|
|
134
|
-
unlockedAt: serverReward.unlockedAt === void 0 ? localReward.unlockedAt : localReward.unlockedAt === void 0 ? serverReward.unlockedAt : latestTimestamp(serverReward.unlockedAt, localReward.unlockedAt)
|
|
135
|
-
},
|
|
136
|
-
...serverReward.consumedAt === void 0 && localReward.consumedAt === void 0 ? {} : {
|
|
137
|
-
consumedAt: serverReward.consumedAt === void 0 ? localReward.consumedAt : localReward.consumedAt === void 0 ? serverReward.consumedAt : latestTimestamp(serverReward.consumedAt, localReward.consumedAt)
|
|
138
|
-
},
|
|
139
|
-
...serverReward.redeemedCount === void 0 && localReward.redeemedCount === void 0 ? {} : {
|
|
140
|
-
redeemedCount: Math.max(
|
|
141
|
-
serverReward.redeemedCount ?? 0,
|
|
142
|
-
localReward.redeemedCount ?? 0
|
|
143
|
-
)
|
|
144
|
-
},
|
|
145
|
-
...serverReward.userRedemptionCount === void 0 && localReward.userRedemptionCount === void 0 ? {} : {
|
|
146
|
-
userRedemptionCount: Math.max(
|
|
147
|
-
serverReward.userRedemptionCount ?? 0,
|
|
148
|
-
localReward.userRedemptionCount ?? 0
|
|
149
|
-
)
|
|
150
|
-
}
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
return [...merged.values()];
|
|
154
|
-
}
|
|
155
|
-
function mergeStampRecords(serverRecords, localRecords) {
|
|
156
|
-
const merged = /* @__PURE__ */ new Map();
|
|
157
|
-
for (const record of [...serverRecords, ...localRecords]) {
|
|
158
|
-
const current = merged.get(record.stampId);
|
|
159
|
-
if (current === void 0) {
|
|
160
|
-
merged.set(record.stampId, record);
|
|
161
|
-
continue;
|
|
162
|
-
}
|
|
163
|
-
const acquiredAt = latestTimestamp(current.acquiredAt, record.acquiredAt);
|
|
164
|
-
merged.set(record.stampId, {
|
|
165
|
-
...current,
|
|
166
|
-
...acquiredAt === current.acquiredAt ? {} : { acquiredAt },
|
|
167
|
-
...current.metadata === void 0 && record.metadata !== void 0 ? { metadata: record.metadata } : {}
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
return [...merged.values()];
|
|
171
|
-
}
|
|
172
|
-
function resolveRallyStateConflict(serverState, localState, options = { policy: "merge" }) {
|
|
173
|
-
if (options.policy === "server_wins") return serverState;
|
|
113
|
+
function resolveRallyStateConflict(serverState, _localState) {
|
|
174
114
|
return {
|
|
175
115
|
...serverState,
|
|
176
|
-
records:
|
|
177
|
-
|
|
178
|
-
|
|
116
|
+
records: serverState.records.map((record) => ({
|
|
117
|
+
...record,
|
|
118
|
+
...record.metadata === void 0 ? {} : { metadata: { ...record.metadata } }
|
|
119
|
+
})),
|
|
120
|
+
rewards: serverState.rewards.map((reward2) => ({ ...reward2 })),
|
|
121
|
+
...serverState.inventory === void 0 ? {} : {
|
|
122
|
+
inventory: {
|
|
123
|
+
...serverState.inventory,
|
|
124
|
+
...serverState.inventory.rewardRemaining === void 0 ? {} : { rewardRemaining: { ...serverState.inventory.rewardRemaining } }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
179
127
|
};
|
|
180
128
|
}
|
|
181
129
|
|
|
@@ -1058,1079 +1006,1390 @@ var IndexedDBAdapter = class {
|
|
|
1058
1006
|
}
|
|
1059
1007
|
};
|
|
1060
1008
|
|
|
1061
|
-
// src/client/
|
|
1062
|
-
function
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
const item = value;
|
|
1072
|
-
for (const key of ["token", "code", "passcode", "value", "tagId"])
|
|
1073
|
-
if (typeof item[key] === "string") return item[key];
|
|
1074
|
-
}
|
|
1075
|
-
return "";
|
|
1076
|
-
}
|
|
1077
|
-
function matches(condition2, value) {
|
|
1078
|
-
if (condition2.type === "gps") {
|
|
1079
|
-
if (typeof value !== "object" || value === null) return false;
|
|
1080
|
-
const item = value;
|
|
1081
|
-
const latitude = item.latitude;
|
|
1082
|
-
const longitude = item.longitude;
|
|
1083
|
-
if (typeof latitude !== "number" || typeof longitude !== "number") return false;
|
|
1084
|
-
const radians = (v) => v * Math.PI / 180;
|
|
1085
|
-
const dLat = radians(latitude - condition2.latitude);
|
|
1086
|
-
const dLon = radians(longitude - condition2.longitude);
|
|
1087
|
-
const h = Math.sin(dLat / 2) ** 2 + Math.cos(radians(condition2.latitude)) * Math.cos(radians(latitude)) * Math.sin(dLon / 2) ** 2;
|
|
1088
|
-
return 6371e3 * 2 * Math.asin(Math.sqrt(Math.min(1, h))) <= condition2.radiusMeters;
|
|
1009
|
+
// src/client/offlineQueue.ts
|
|
1010
|
+
function rollbackOptimisticOperation(state, operation) {
|
|
1011
|
+
const previous = operation.request.state;
|
|
1012
|
+
if (operation.kind === "checkIn") {
|
|
1013
|
+
const records = state.records.filter(
|
|
1014
|
+
(record) => record.stampId !== operation.request.spotId || record.acquiredAt !== operation.request.now
|
|
1015
|
+
);
|
|
1016
|
+
const previousRewards = new Map(previous.rewards.map((reward2) => [reward2.rewardId, reward2]));
|
|
1017
|
+
const rewards2 = state.rewards.map((reward2) => previousRewards.get(reward2.rewardId) ?? reward2);
|
|
1018
|
+
return { ...cloneState(state), records, rewards: rewards2, updatedAt: previous.updatedAt };
|
|
1089
1019
|
}
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1020
|
+
const previousReward = previous.rewards.find(
|
|
1021
|
+
(reward2) => reward2.rewardId === operation.request.rewardId
|
|
1022
|
+
);
|
|
1023
|
+
const rewards = state.rewards.filter(
|
|
1024
|
+
(reward2) => reward2.rewardId !== operation.request.rewardId || previousReward !== void 0
|
|
1025
|
+
).map(
|
|
1026
|
+
(reward2) => reward2.rewardId === operation.request.rewardId && previousReward !== void 0 ? { ...previousReward } : reward2
|
|
1027
|
+
);
|
|
1028
|
+
const cloned = cloneState(state);
|
|
1029
|
+
const { inventory: _inventory, ...stateWithoutInventory } = cloned;
|
|
1030
|
+
const previousInventory = previous.inventory;
|
|
1031
|
+
return previousInventory === void 0 ? { ...stateWithoutInventory, rewards, updatedAt: previous.updatedAt } : {
|
|
1032
|
+
...stateWithoutInventory,
|
|
1033
|
+
rewards,
|
|
1034
|
+
inventory: {
|
|
1035
|
+
...previousInventory,
|
|
1036
|
+
...previousInventory.rewardRemaining === void 0 ? {} : { rewardRemaining: { ...previousInventory.rewardRemaining } }
|
|
1037
|
+
},
|
|
1038
|
+
updatedAt: previous.updatedAt
|
|
1099
1039
|
};
|
|
1100
1040
|
}
|
|
1101
|
-
var
|
|
1102
|
-
#
|
|
1103
|
-
#
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
#config;
|
|
1107
|
-
#offlineQueue;
|
|
1108
|
-
#userId;
|
|
1109
|
-
#anonymousSessionId;
|
|
1110
|
-
#state = null;
|
|
1111
|
-
#initialization = null;
|
|
1112
|
-
#queue = Promise.resolve();
|
|
1113
|
-
constructor(config, storageOrOptions = {}) {
|
|
1114
|
-
this.#config = config;
|
|
1115
|
-
this.#options = isStorage(storageOrOptions) ? { storage: storageOrOptions } : storageOrOptions;
|
|
1116
|
-
this.#storage = this.#options.storage ?? new InMemoryStorage();
|
|
1117
|
-
this.#anonymousSessionId = this.#options.anonymousSessionId ?? createAnonymousSessionId();
|
|
1118
|
-
this.#userId = this.#options.userId ?? this.#anonymousSessionId;
|
|
1119
|
-
this.#offlineQueue = this.#options.offlineQueue;
|
|
1120
|
-
this.#offlineQueue?.setSyncResultListener((event) => this.#handleOfflineSyncResult(event));
|
|
1121
|
-
}
|
|
1122
|
-
getConfig() {
|
|
1123
|
-
return this.#config;
|
|
1124
|
-
}
|
|
1125
|
-
getState() {
|
|
1126
|
-
return this.#state;
|
|
1127
|
-
}
|
|
1128
|
-
getUserId() {
|
|
1129
|
-
return this.#userId;
|
|
1041
|
+
var MemoryQueueStorage = class {
|
|
1042
|
+
#values = /* @__PURE__ */ new Map();
|
|
1043
|
+
#rejected = /* @__PURE__ */ new Map();
|
|
1044
|
+
async load(key) {
|
|
1045
|
+
return this.#values.get(key) ?? [];
|
|
1130
1046
|
}
|
|
1131
|
-
|
|
1132
|
-
|
|
1047
|
+
async save(key, operations) {
|
|
1048
|
+
this.#values.set(key, structuredClone(operations));
|
|
1133
1049
|
}
|
|
1134
|
-
|
|
1135
|
-
return this.#
|
|
1050
|
+
async loadRejectedHistory(key) {
|
|
1051
|
+
return this.#rejected.get(key) ?? [];
|
|
1136
1052
|
}
|
|
1137
|
-
|
|
1138
|
-
|
|
1053
|
+
async saveRejectedHistory(key, history) {
|
|
1054
|
+
this.#rejected.set(key, structuredClone(history));
|
|
1139
1055
|
}
|
|
1140
|
-
|
|
1141
|
-
|
|
1056
|
+
};
|
|
1057
|
+
var LocalStorageQueueStorage = class {
|
|
1058
|
+
constructor(storage) {
|
|
1059
|
+
this.storage = storage;
|
|
1142
1060
|
}
|
|
1143
|
-
|
|
1144
|
-
|
|
1061
|
+
storage;
|
|
1062
|
+
async load(key) {
|
|
1063
|
+
const value = this.storage.getItem(key);
|
|
1064
|
+
if (value === null) return [];
|
|
1065
|
+
try {
|
|
1066
|
+
const parsed = JSON.parse(value);
|
|
1067
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
1068
|
+
} catch {
|
|
1069
|
+
return [];
|
|
1070
|
+
}
|
|
1145
1071
|
}
|
|
1146
|
-
|
|
1147
|
-
|
|
1072
|
+
async save(key, operations) {
|
|
1073
|
+
this.storage.setItem(key, JSON.stringify(operations));
|
|
1148
1074
|
}
|
|
1149
|
-
|
|
1150
|
-
|
|
1075
|
+
async loadRejectedHistory(key) {
|
|
1076
|
+
const value = this.storage.getItem(`${key}:rejected-history`);
|
|
1077
|
+
if (value === null) return [];
|
|
1078
|
+
try {
|
|
1079
|
+
const parsed = JSON.parse(value);
|
|
1080
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
1081
|
+
} catch {
|
|
1082
|
+
return [];
|
|
1083
|
+
}
|
|
1151
1084
|
}
|
|
1152
|
-
|
|
1153
|
-
this
|
|
1154
|
-
return () => this.#listeners.delete(listener);
|
|
1085
|
+
async saveRejectedHistory(key, history) {
|
|
1086
|
+
this.storage.setItem(`${key}:rejected-history`, JSON.stringify(history));
|
|
1155
1087
|
}
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1088
|
+
};
|
|
1089
|
+
var IndexedDBOfflineQueueStorage = class {
|
|
1090
|
+
#providedFactory;
|
|
1091
|
+
#databaseName;
|
|
1092
|
+
#databasePromise = null;
|
|
1093
|
+
constructor(options = {}) {
|
|
1094
|
+
this.#providedFactory = options.indexedDB;
|
|
1095
|
+
this.#databaseName = options.databaseName ?? "stamprally-offline-queue";
|
|
1159
1096
|
}
|
|
1160
|
-
|
|
1161
|
-
|
|
1097
|
+
async load(key) {
|
|
1098
|
+
const database = await this.#open();
|
|
1099
|
+
return new Promise((resolve, reject) => {
|
|
1100
|
+
const request = database.transaction("operations", "readonly").objectStore("operations").get(key);
|
|
1101
|
+
request.onsuccess = () => resolve(Array.isArray(request.result) ? request.result : []);
|
|
1102
|
+
request.onerror = () => reject(request.error ?? new Error("Failed to read offline queue."));
|
|
1103
|
+
});
|
|
1162
1104
|
}
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
this.#emit(next);
|
|
1173
|
-
return next;
|
|
1174
|
-
}).catch((error) => {
|
|
1175
|
-
this.#initialization = null;
|
|
1176
|
-
throw error;
|
|
1177
|
-
});
|
|
1178
|
-
}
|
|
1179
|
-
return this.#initialization;
|
|
1105
|
+
async save(key, operations) {
|
|
1106
|
+
const database = await this.#open();
|
|
1107
|
+
return new Promise((resolve, reject) => {
|
|
1108
|
+
const transaction = database.transaction("operations", "readwrite");
|
|
1109
|
+
transaction.objectStore("operations").put(structuredClone(operations), key);
|
|
1110
|
+
transaction.oncomplete = () => resolve();
|
|
1111
|
+
transaction.onerror = () => reject(transaction.error ?? new Error("Failed to save offline queue."));
|
|
1112
|
+
transaction.onabort = () => reject(transaction.error ?? new Error("Offline queue write aborted."));
|
|
1113
|
+
});
|
|
1180
1114
|
}
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
return this.initialize();
|
|
1115
|
+
async loadRejectedHistory(key) {
|
|
1116
|
+
const database = await this.#open();
|
|
1117
|
+
return new Promise((resolve, reject) => {
|
|
1118
|
+
const request = database.transaction("operations", "readonly").objectStore("operations").get(`${key}:rejected-history`);
|
|
1119
|
+
request.onsuccess = () => resolve(
|
|
1120
|
+
Array.isArray(request.result) ? request.result : []
|
|
1121
|
+
);
|
|
1122
|
+
request.onerror = () => reject(request.error ?? new Error("Failed to read rejected operation history."));
|
|
1190
1123
|
});
|
|
1191
1124
|
}
|
|
1192
|
-
async
|
|
1193
|
-
|
|
1125
|
+
async saveRejectedHistory(key, history) {
|
|
1126
|
+
const database = await this.#open();
|
|
1127
|
+
return new Promise((resolve, reject) => {
|
|
1128
|
+
const transaction = database.transaction("operations", "readwrite");
|
|
1129
|
+
transaction.objectStore("operations").put(structuredClone(history), `${key}:rejected-history`);
|
|
1130
|
+
transaction.oncomplete = () => resolve();
|
|
1131
|
+
transaction.onerror = () => reject(transaction.error ?? new Error("Failed to save rejected operation history."));
|
|
1132
|
+
transaction.onabort = () => reject(transaction.error ?? new Error("Rejected operation history write aborted."));
|
|
1133
|
+
});
|
|
1194
1134
|
}
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1135
|
+
#open() {
|
|
1136
|
+
if (this.#databasePromise !== null) return this.#databasePromise;
|
|
1137
|
+
let factory = this.#providedFactory;
|
|
1138
|
+
if (factory === void 0)
|
|
1139
|
+
factory = globalThis.indexedDB;
|
|
1140
|
+
if (factory === void 0 || factory === null)
|
|
1141
|
+
return Promise.reject(new Error("IndexedDB is unavailable in this environment."));
|
|
1142
|
+
this.#databasePromise = new Promise((resolve, reject) => {
|
|
1143
|
+
const request = factory.open(this.#databaseName, 1);
|
|
1144
|
+
request.onupgradeneeded = () => {
|
|
1145
|
+
if (!request.result.objectStoreNames.contains("operations"))
|
|
1146
|
+
request.result.createObjectStore("operations");
|
|
1147
|
+
};
|
|
1148
|
+
request.onsuccess = () => resolve(request.result);
|
|
1149
|
+
request.onerror = () => reject(request.error ?? new Error("Failed to open offline queue."));
|
|
1150
|
+
}).catch((error) => {
|
|
1151
|
+
this.#databasePromise = null;
|
|
1152
|
+
throw error;
|
|
1201
1153
|
});
|
|
1154
|
+
return this.#databasePromise;
|
|
1202
1155
|
}
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
return this.#fail({
|
|
1211
|
-
code: "STAMP_ALREADY_ACQUIRED",
|
|
1212
|
-
spotId,
|
|
1213
|
-
message: "Spot was already claimed."
|
|
1214
|
-
});
|
|
1215
|
-
const acquired = new Set(current.records.map((record2) => record2.stampId));
|
|
1216
|
-
if (spot2.prerequisites?.some((id2) => !acquired.has(id2)))
|
|
1217
|
-
return this.#fail({
|
|
1218
|
-
code: "PREREQUISITES_NOT_MET",
|
|
1219
|
-
spotId,
|
|
1220
|
-
message: "Prerequisite spots are not complete."
|
|
1221
|
-
});
|
|
1222
|
-
for (const condition2 of spot2.conditions) {
|
|
1223
|
-
if (condition2.type === "custom") {
|
|
1224
|
-
const validator = this.#options.customValidators?.[condition2.validatorName] ?? this.#options.customValidator;
|
|
1225
|
-
if (validator === void 0)
|
|
1226
|
-
return this.#fail({
|
|
1227
|
-
code: "CUSTOM_VALIDATION_FAILED",
|
|
1228
|
-
spotId,
|
|
1229
|
-
message: "No custom validator is registered."
|
|
1230
|
-
});
|
|
1231
|
-
const context = {
|
|
1232
|
-
rallyId: this.#config.id,
|
|
1233
|
-
spotId,
|
|
1234
|
-
proofData,
|
|
1235
|
-
condition: { type: "custom", validatorName: condition2.validatorName },
|
|
1236
|
-
userState: current
|
|
1237
|
-
};
|
|
1238
|
-
const result = typeof validator === "function" ? await validator(context) : await validator.validate(context);
|
|
1239
|
-
if (result === false || typeof result === "object" && !result.valid)
|
|
1240
|
-
return this.#fail({
|
|
1241
|
-
code: "CUSTOM_VALIDATION_FAILED",
|
|
1242
|
-
spotId,
|
|
1243
|
-
message: typeof result === "object" && result.message !== void 0 ? result.message : "Custom validation failed."
|
|
1244
|
-
});
|
|
1245
|
-
} else if (!matches(condition2, proofData))
|
|
1246
|
-
return this.#fail({ code: "INVALID_PROOF", spotId, message: "Verification failed." });
|
|
1247
|
-
}
|
|
1248
|
-
const now = options.now ?? this.#now();
|
|
1249
|
-
const request = {
|
|
1250
|
-
rallyId: this.#config.id,
|
|
1251
|
-
userId: this.#userId,
|
|
1252
|
-
spotId,
|
|
1253
|
-
proofData,
|
|
1254
|
-
idempotencyKey: options.idempotencyKey ?? id("check-in"),
|
|
1255
|
-
now,
|
|
1256
|
-
state: current,
|
|
1257
|
-
...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
|
|
1258
|
-
};
|
|
1259
|
-
const remote = this.#options.syncAdapter?.checkIn;
|
|
1260
|
-
if (options.sync !== false && remote !== void 0) {
|
|
1261
|
-
try {
|
|
1262
|
-
const result = await remote(request);
|
|
1263
|
-
return result.ok ? this.#commitCheckIn(result) : this.#fail(result.error);
|
|
1264
|
-
} catch (error) {
|
|
1265
|
-
if (this.#offlineQueue === void 0) throw error;
|
|
1266
|
-
await this.#offlineQueue.enqueueCheckIn(request);
|
|
1267
|
-
const record2 = { stampId: spotId, acquiredAt: now };
|
|
1268
|
-
const next2 = this.#reconcile({
|
|
1269
|
-
...current,
|
|
1270
|
-
records: [...current.records, record2],
|
|
1271
|
-
updatedAt: now
|
|
1272
|
-
});
|
|
1273
|
-
await this.#storage.save(next2);
|
|
1274
|
-
return this.#commitCheckIn({ ok: true, value: { state: next2, record: record2 } });
|
|
1275
|
-
}
|
|
1276
|
-
}
|
|
1277
|
-
const record = { stampId: spotId, acquiredAt: now };
|
|
1278
|
-
const next = this.#reconcile({
|
|
1279
|
-
...current,
|
|
1280
|
-
records: [...current.records, record],
|
|
1281
|
-
updatedAt: now
|
|
1282
|
-
});
|
|
1283
|
-
await this.#storage.save(next);
|
|
1284
|
-
return this.#commitCheckIn({ ok: true, value: { state: next, record } });
|
|
1285
|
-
});
|
|
1156
|
+
};
|
|
1157
|
+
function availableLocalStorage() {
|
|
1158
|
+
try {
|
|
1159
|
+
const storage = globalThis.localStorage;
|
|
1160
|
+
return storage ?? null;
|
|
1161
|
+
} catch {
|
|
1162
|
+
return null;
|
|
1286
1163
|
}
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
const local = consumeReward({
|
|
1299
|
-
reward: configured,
|
|
1300
|
-
currentState: state,
|
|
1301
|
-
now,
|
|
1302
|
-
...options.staffPasscode === void 0 ? {} : { inputPasscode: options.staffPasscode },
|
|
1303
|
-
...options.staffId === void 0 ? {} : { staffId: options.staffId }
|
|
1304
|
-
});
|
|
1305
|
-
if (!local.ok) return this.#fail(local.error);
|
|
1306
|
-
const request = {
|
|
1307
|
-
rallyId: this.#config.id,
|
|
1308
|
-
userId: this.#userId,
|
|
1309
|
-
rewardId,
|
|
1310
|
-
idempotencyKey: options.idempotencyKey ?? id("claim"),
|
|
1311
|
-
now,
|
|
1312
|
-
options,
|
|
1313
|
-
state: current,
|
|
1314
|
-
...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
|
|
1315
|
-
};
|
|
1316
|
-
const remote = this.#options.syncAdapter?.claimReward;
|
|
1317
|
-
if (options.sync !== false && remote !== void 0) {
|
|
1318
|
-
try {
|
|
1319
|
-
const result = await remote(request);
|
|
1320
|
-
return result.ok ? this.#commitClaim(result) : this.#fail(result.error);
|
|
1321
|
-
} catch (error) {
|
|
1322
|
-
if (this.#offlineQueue === void 0) throw error;
|
|
1323
|
-
await this.#offlineQueue.enqueueClaimReward(request);
|
|
1324
|
-
const next2 = {
|
|
1325
|
-
...current,
|
|
1326
|
-
rewards: current.rewards.map(
|
|
1327
|
-
(item) => item.rewardId === rewardId ? local.value : item
|
|
1328
|
-
),
|
|
1329
|
-
updatedAt: now
|
|
1330
|
-
};
|
|
1331
|
-
await this.#storage.save(next2);
|
|
1332
|
-
return this.#commitClaim({ ok: true, value: { state: next2, reward: local.value } });
|
|
1333
|
-
}
|
|
1334
|
-
}
|
|
1335
|
-
const next = {
|
|
1336
|
-
...current,
|
|
1337
|
-
rewards: current.rewards.map((item) => item.rewardId === rewardId ? local.value : item),
|
|
1338
|
-
updatedAt: now
|
|
1164
|
+
}
|
|
1165
|
+
function defaultStorage(databaseName) {
|
|
1166
|
+
try {
|
|
1167
|
+
const indexedDB = globalThis.indexedDB;
|
|
1168
|
+
if (indexedDB !== void 0)
|
|
1169
|
+
return {
|
|
1170
|
+
storage: new IndexedDBOfflineQueueStorage({
|
|
1171
|
+
indexedDB,
|
|
1172
|
+
...databaseName === void 0 ? {} : { databaseName }
|
|
1173
|
+
}),
|
|
1174
|
+
capability: "indexeddb"
|
|
1339
1175
|
};
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1176
|
+
const storage = availableLocalStorage();
|
|
1177
|
+
if (storage !== void 0 && storage !== null)
|
|
1178
|
+
return { storage: new LocalStorageQueueStorage(storage), capability: "localstorage" };
|
|
1179
|
+
} catch {
|
|
1343
1180
|
}
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
return;
|
|
1362
|
-
}
|
|
1363
|
-
const serverState = await adapter.sync({
|
|
1364
|
-
rallyId: this.#config.id,
|
|
1365
|
-
userId: this.#userId,
|
|
1366
|
-
state: this.#state ?? current,
|
|
1367
|
-
...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
|
|
1368
|
-
});
|
|
1369
|
-
const next = this.#reconcile(serverState);
|
|
1370
|
-
await this.#storage.save(next);
|
|
1371
|
-
this.#state = next;
|
|
1372
|
-
this.#emit(next);
|
|
1373
|
-
this.#emitEvent({ type: "sync", state: next });
|
|
1374
|
-
});
|
|
1181
|
+
return { storage: new MemoryQueueStorage(), capability: "memory" };
|
|
1182
|
+
}
|
|
1183
|
+
function offlineOperationId(operation) {
|
|
1184
|
+
const identity = operation.request.userId ?? operation.request.anonymousSessionId ?? "anonymous";
|
|
1185
|
+
return operation.kind === "checkIn" ? `checkIn:${operation.request.rallyId}:${identity}:${operation.request.idempotencyKey}` : `claimReward:${operation.request.rallyId}:${identity}:${operation.request.idempotencyKey}`;
|
|
1186
|
+
}
|
|
1187
|
+
function requestScope(operation) {
|
|
1188
|
+
return {
|
|
1189
|
+
rallyId: operation.request.rallyId,
|
|
1190
|
+
userId: operation.request.userId
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
function errorValue(value, fallbackCode) {
|
|
1194
|
+
if (typeof value === "object" && value !== null) {
|
|
1195
|
+
const candidate = value;
|
|
1196
|
+
if (typeof candidate.code === "string" && typeof candidate.message === "string")
|
|
1197
|
+
return { ...candidate, code: candidate.code, message: candidate.message };
|
|
1375
1198
|
}
|
|
1376
|
-
|
|
1377
|
-
|
|
1199
|
+
if (value instanceof Error) return { code: fallbackCode, message: value.message };
|
|
1200
|
+
if (typeof value === "string") return { code: fallbackCode, message: value };
|
|
1201
|
+
return { code: fallbackCode, message: "Offline operation was rejected." };
|
|
1202
|
+
}
|
|
1203
|
+
var syncLocks = /* @__PURE__ */ new Map();
|
|
1204
|
+
var SYNC_LOCK_TTL_MS = 3e4;
|
|
1205
|
+
var DEFAULT_RETRY_OPTIONS = {
|
|
1206
|
+
maxRetries: 0,
|
|
1207
|
+
initialIntervalMs: 250,
|
|
1208
|
+
backoffMultiplier: 2
|
|
1209
|
+
};
|
|
1210
|
+
function randomId() {
|
|
1211
|
+
return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
1212
|
+
}
|
|
1213
|
+
var OfflineQueue = class {
|
|
1214
|
+
#storage;
|
|
1215
|
+
#queueCapability;
|
|
1216
|
+
#configuredKey;
|
|
1217
|
+
#rallyId;
|
|
1218
|
+
#userId;
|
|
1219
|
+
#operations = [];
|
|
1220
|
+
#rejectedHistory = [];
|
|
1221
|
+
#loaded = false;
|
|
1222
|
+
#state = "idle";
|
|
1223
|
+
#error = null;
|
|
1224
|
+
#sender;
|
|
1225
|
+
#syncPromise = null;
|
|
1226
|
+
#syncResultListener;
|
|
1227
|
+
#changeListener;
|
|
1228
|
+
#synchronizeInstances;
|
|
1229
|
+
#retryOptions;
|
|
1230
|
+
#instanceId = randomId();
|
|
1231
|
+
#lockStorage;
|
|
1232
|
+
#observedLocks = /* @__PURE__ */ new Map();
|
|
1233
|
+
#warnedMemoryLock = false;
|
|
1234
|
+
#capabilityWarningListener;
|
|
1235
|
+
#replayConfig;
|
|
1236
|
+
#storageListener;
|
|
1237
|
+
#channel = null;
|
|
1238
|
+
constructor(options = {}) {
|
|
1239
|
+
if (options.storage !== void 0) {
|
|
1240
|
+
this.#storage = options.storage;
|
|
1241
|
+
this.#queueCapability = "custom";
|
|
1242
|
+
} else if (options.storageLike !== void 0 && options.storageLike !== null) {
|
|
1243
|
+
this.#storage = new LocalStorageQueueStorage(options.storageLike);
|
|
1244
|
+
this.#queueCapability = "localstorage";
|
|
1245
|
+
} else {
|
|
1246
|
+
const selected = defaultStorage(options.databaseName);
|
|
1247
|
+
this.#storage = selected.storage;
|
|
1248
|
+
this.#queueCapability = selected.capability;
|
|
1249
|
+
}
|
|
1250
|
+
this.#configuredKey = options.key;
|
|
1251
|
+
this.#rallyId = options.rallyId;
|
|
1252
|
+
this.#userId = options.userId ?? null;
|
|
1253
|
+
this.#synchronizeInstances = options.synchronizeInstances ?? true;
|
|
1254
|
+
const retryOptions = {
|
|
1255
|
+
...DEFAULT_RETRY_OPTIONS,
|
|
1256
|
+
...options.retryOptions ?? options.syncRetryOptions ?? options.retry ?? {}
|
|
1257
|
+
};
|
|
1258
|
+
this.#retryOptions = {
|
|
1259
|
+
maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
|
|
1260
|
+
initialIntervalMs: Math.max(0, retryOptions.initialIntervalMs),
|
|
1261
|
+
backoffMultiplier: Math.max(1, retryOptions.backoffMultiplier)
|
|
1262
|
+
};
|
|
1263
|
+
this.#lockStorage = options.storageLike ?? availableLocalStorage();
|
|
1264
|
+
if (this.#synchronizeInstances) this.#subscribeToExternalChanges();
|
|
1378
1265
|
}
|
|
1379
|
-
|
|
1380
|
-
return this.#
|
|
1381
|
-
await this.#storage.remove(this.#config.id, this.#userId);
|
|
1382
|
-
return this.#initializeFresh();
|
|
1383
|
-
});
|
|
1266
|
+
get syncState() {
|
|
1267
|
+
return this.#state;
|
|
1384
1268
|
}
|
|
1385
|
-
|
|
1386
|
-
return this.#
|
|
1387
|
-
if (state.rallyId !== this.#config.id || state.userId !== this.#userId)
|
|
1388
|
-
throw new Error("State belongs to another rally or user.");
|
|
1389
|
-
const next = this.#reconcile(state);
|
|
1390
|
-
await this.#storage.save(next);
|
|
1391
|
-
this.#state = next;
|
|
1392
|
-
this.#initialization = Promise.resolve(next);
|
|
1393
|
-
this.#emit(next);
|
|
1394
|
-
return next;
|
|
1395
|
-
});
|
|
1269
|
+
get pendingCount() {
|
|
1270
|
+
return this.#operations.length;
|
|
1396
1271
|
}
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
this.#queue = next.then(
|
|
1400
|
-
() => void 0,
|
|
1401
|
-
() => void 0
|
|
1402
|
-
);
|
|
1403
|
-
return next;
|
|
1272
|
+
get queueCapability() {
|
|
1273
|
+
return this.#queueCapability;
|
|
1404
1274
|
}
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
this.#
|
|
1409
|
-
this.#emit(next);
|
|
1410
|
-
return Promise.resolve(next);
|
|
1275
|
+
get storageCapability() {
|
|
1276
|
+
if (this.#queueCapability === "memory") return "memory";
|
|
1277
|
+
const locks = globalThis.navigator?.locks;
|
|
1278
|
+
return locks !== void 0 || this.#lockStorage !== null ? this.#queueCapability : "volatile_single_tab";
|
|
1411
1279
|
}
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
const records = state.records.filter(
|
|
1415
|
-
(record, index, all) => ids.has(record.stampId) && all.findIndex((candidate) => candidate.stampId === record.stampId) === index
|
|
1416
|
-
);
|
|
1417
|
-
return {
|
|
1418
|
-
...cloneState(state),
|
|
1419
|
-
userId: this.#userId,
|
|
1420
|
-
records,
|
|
1421
|
-
rewards: reconcileRewardStates(
|
|
1422
|
-
this.#config.rewards,
|
|
1423
|
-
state.rewards,
|
|
1424
|
-
records.length,
|
|
1425
|
-
state.updatedAt
|
|
1426
|
-
)
|
|
1427
|
-
};
|
|
1280
|
+
get isStoragePersistent() {
|
|
1281
|
+
return this.#queueCapability !== "memory";
|
|
1428
1282
|
}
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1283
|
+
get rejectedHistory() {
|
|
1284
|
+
return this.#rejectedHistory;
|
|
1285
|
+
}
|
|
1286
|
+
get error() {
|
|
1287
|
+
return this.#error;
|
|
1288
|
+
}
|
|
1289
|
+
get operations() {
|
|
1290
|
+
return this.#operations;
|
|
1291
|
+
}
|
|
1292
|
+
get storageKey() {
|
|
1293
|
+
return this.#storageKey();
|
|
1294
|
+
}
|
|
1295
|
+
get rallyId() {
|
|
1296
|
+
return this.#rallyId;
|
|
1297
|
+
}
|
|
1298
|
+
get userId() {
|
|
1299
|
+
return this.#userId;
|
|
1300
|
+
}
|
|
1301
|
+
setSyncResultListener(listener) {
|
|
1302
|
+
this.#syncResultListener = listener;
|
|
1303
|
+
}
|
|
1304
|
+
setChangeListener(listener) {
|
|
1305
|
+
this.#changeListener = listener;
|
|
1306
|
+
}
|
|
1307
|
+
setCapabilityWarningListener(listener) {
|
|
1308
|
+
this.#capabilityWarningListener = listener;
|
|
1309
|
+
}
|
|
1310
|
+
setReplayConfig(config) {
|
|
1311
|
+
this.#replayConfig = config;
|
|
1312
|
+
}
|
|
1313
|
+
async initialize() {
|
|
1314
|
+
if (this.#loaded) return;
|
|
1315
|
+
try {
|
|
1316
|
+
const key = this.#storageKey();
|
|
1317
|
+
this.#operations = (await this.#storage.load(key)).map(normalizeOperation);
|
|
1318
|
+
this.#rejectedHistory = (await this.#storage.loadRejectedHistory?.(key))?.map(normalizeRejectedHistory) ?? [];
|
|
1319
|
+
} catch (error) {
|
|
1320
|
+
if (this.#queueCapability === "memory") throw error;
|
|
1321
|
+
this.#storage = new MemoryQueueStorage();
|
|
1322
|
+
this.#queueCapability = "memory";
|
|
1323
|
+
this.#operations = [];
|
|
1324
|
+
this.#rejectedHistory = [];
|
|
1325
|
+
this.#warnMemoryLock(
|
|
1326
|
+
`Offline queue persistence is unavailable; queued data can be lost after reload (${String(error)}).`
|
|
1327
|
+
);
|
|
1435
1328
|
}
|
|
1436
|
-
if (
|
|
1437
|
-
|
|
1438
|
-
|
|
1329
|
+
if (this.#queueCapability === "memory")
|
|
1330
|
+
this.#warnMemoryLock("Offline queue persistence is unavailable; queued data is memory-only.");
|
|
1331
|
+
this.#loaded = true;
|
|
1439
1332
|
}
|
|
1440
|
-
|
|
1441
|
-
|
|
1333
|
+
/** Releases browser listeners when the queue is no longer used. */
|
|
1334
|
+
dispose() {
|
|
1335
|
+
const windowLike = globalThis.window;
|
|
1336
|
+
if (windowLike !== void 0 && this.#storageListener !== void 0)
|
|
1337
|
+
windowLike.removeEventListener("storage", this.#storageListener);
|
|
1338
|
+
this.#storageListener = void 0;
|
|
1339
|
+
this.#channel?.close();
|
|
1340
|
+
this.#channel = null;
|
|
1341
|
+
this.#releaseSyncLock();
|
|
1442
1342
|
}
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1343
|
+
/** Selects a rally/user queue scope and loads its pending operations. */
|
|
1344
|
+
async setScope(rallyId, userId) {
|
|
1345
|
+
if (this.#configuredKey !== void 0) {
|
|
1346
|
+
this.#rallyId = rallyId;
|
|
1347
|
+
this.#userId = userId;
|
|
1348
|
+
return this.initialize();
|
|
1349
|
+
}
|
|
1350
|
+
if (this.#rallyId === rallyId && this.#userId === userId && this.#loaded) return;
|
|
1351
|
+
this.#rallyId = rallyId;
|
|
1352
|
+
this.#userId = userId;
|
|
1353
|
+
this.#operations = [];
|
|
1354
|
+
this.#loaded = false;
|
|
1355
|
+
await this.initialize();
|
|
1356
|
+
}
|
|
1357
|
+
async switchUser(newUserId) {
|
|
1358
|
+
if (this.#rallyId === void 0)
|
|
1359
|
+
throw new Error("OfflineQueue.switchUser requires a rally scope.");
|
|
1360
|
+
await this.setScope(this.#rallyId, newUserId);
|
|
1361
|
+
}
|
|
1362
|
+
setSender(sender) {
|
|
1363
|
+
this.#sender = sender;
|
|
1364
|
+
}
|
|
1365
|
+
async enqueue(operation) {
|
|
1366
|
+
if (this.#configuredKey === void 0) {
|
|
1367
|
+
const scope = requestScope(operation);
|
|
1368
|
+
if (this.#rallyId === void 0) await this.setScope(scope.rallyId, scope.userId);
|
|
1369
|
+
if (this.#rallyId !== scope.rallyId || this.#userId !== scope.userId)
|
|
1370
|
+
throw new Error("Offline operation belongs to another rally or user queue.");
|
|
1371
|
+
}
|
|
1372
|
+
await this.initialize();
|
|
1373
|
+
const id2 = offlineOperationId(operation);
|
|
1374
|
+
if (this.#operations.some((item) => offlineOperationId(item) === id2)) return;
|
|
1375
|
+
this.#operations = [...this.#operations, { ...operation, status: "PENDING", attempts: 0 }];
|
|
1376
|
+
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1377
|
+
this.#announceChange();
|
|
1378
|
+
}
|
|
1379
|
+
async enqueueCheckIn(request, optimisticState) {
|
|
1380
|
+
return this.enqueue({
|
|
1381
|
+
kind: "checkIn",
|
|
1382
|
+
request,
|
|
1383
|
+
...optimisticState === void 0 ? {} : { optimisticState }
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
async enqueueClaimReward(request, optimisticState) {
|
|
1387
|
+
return this.enqueue({
|
|
1388
|
+
kind: "claimReward",
|
|
1389
|
+
request,
|
|
1390
|
+
...optimisticState === void 0 ? {} : { optimisticState }
|
|
1391
|
+
});
|
|
1446
1392
|
}
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
this.#emitEvent({ type: "checkIn", result });
|
|
1453
|
-
return result;
|
|
1393
|
+
async clear() {
|
|
1394
|
+
await this.initialize();
|
|
1395
|
+
this.#operations = [];
|
|
1396
|
+
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1397
|
+
this.#announceChange();
|
|
1454
1398
|
}
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1399
|
+
async discardRejected(operationId2) {
|
|
1400
|
+
await this.initialize();
|
|
1401
|
+
const next = this.#rejectedHistory.filter(
|
|
1402
|
+
(entry) => offlineOperationId(entry.operation) !== operationId2
|
|
1403
|
+
);
|
|
1404
|
+
if (next.length === this.#rejectedHistory.length) return false;
|
|
1405
|
+
this.#rejectedHistory = next;
|
|
1406
|
+
await this.#saveRejectedHistory();
|
|
1407
|
+
this.#announceChange();
|
|
1408
|
+
return true;
|
|
1462
1409
|
}
|
|
1463
|
-
|
|
1464
|
-
|
|
1410
|
+
async retryRejected(operationId2) {
|
|
1411
|
+
await this.initialize();
|
|
1412
|
+
const entry = this.#rejectedHistory.find(
|
|
1413
|
+
(candidate) => offlineOperationId(candidate.operation) === operationId2
|
|
1414
|
+
);
|
|
1415
|
+
if (entry === void 0) return false;
|
|
1416
|
+
if (!this.#operations.some((operation) => offlineOperationId(operation) === operationId2))
|
|
1417
|
+
this.#operations = [
|
|
1418
|
+
...this.#operations,
|
|
1419
|
+
{ ...entry.operation, status: "PENDING", attempts: 0 }
|
|
1420
|
+
];
|
|
1421
|
+
this.#rejectedHistory = this.#rejectedHistory.filter((candidate) => candidate !== entry);
|
|
1422
|
+
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1423
|
+
await this.#saveRejectedHistory();
|
|
1424
|
+
this.#announceChange();
|
|
1425
|
+
return true;
|
|
1465
1426
|
}
|
|
1466
|
-
|
|
1467
|
-
|
|
1427
|
+
async discardRejectedOperation(operationId2) {
|
|
1428
|
+
return this.discardRejected(operationId2);
|
|
1468
1429
|
}
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
// src/client/offlineQueue.ts
|
|
1472
|
-
var MemoryQueueStorage = class {
|
|
1473
|
-
#values = /* @__PURE__ */ new Map();
|
|
1474
|
-
#rejected = /* @__PURE__ */ new Map();
|
|
1475
|
-
async load(key) {
|
|
1476
|
-
return this.#values.get(key) ?? [];
|
|
1430
|
+
async retryRejectedOperation(operationId2) {
|
|
1431
|
+
return this.retryRejected(operationId2);
|
|
1477
1432
|
}
|
|
1478
|
-
async
|
|
1479
|
-
this
|
|
1433
|
+
async clearRejectedHistory() {
|
|
1434
|
+
await this.initialize();
|
|
1435
|
+
if (this.#rejectedHistory.length === 0) return;
|
|
1436
|
+
this.#rejectedHistory = [];
|
|
1437
|
+
await this.#saveRejectedHistory();
|
|
1438
|
+
this.#announceChange();
|
|
1480
1439
|
}
|
|
1481
|
-
async
|
|
1482
|
-
|
|
1440
|
+
async sync(sender = this.#sender) {
|
|
1441
|
+
await this.initialize();
|
|
1442
|
+
if (sender === void 0) throw new Error("OfflineQueue.sync requires a sender.");
|
|
1443
|
+
if (this.#syncPromise !== null) return this.#syncPromise;
|
|
1444
|
+
this.#sender = sender;
|
|
1445
|
+
this.#syncPromise = this.#run(sender).finally(() => {
|
|
1446
|
+
this.#syncPromise = null;
|
|
1447
|
+
});
|
|
1448
|
+
return this.#syncPromise;
|
|
1483
1449
|
}
|
|
1484
|
-
async
|
|
1485
|
-
this
|
|
1450
|
+
async retrySync(sender = this.#sender) {
|
|
1451
|
+
return this.sync(sender);
|
|
1486
1452
|
}
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1453
|
+
async #run(sender) {
|
|
1454
|
+
const locks = globalThis.navigator?.locks;
|
|
1455
|
+
if (locks !== void 0 && typeof locks.request === "function") {
|
|
1456
|
+
let callbackStarted = false;
|
|
1457
|
+
try {
|
|
1458
|
+
const acquired = await locks.request(
|
|
1459
|
+
`stamprally:${this.#storageKey()}:sync`,
|
|
1460
|
+
{ ifAvailable: true },
|
|
1461
|
+
async (lock) => {
|
|
1462
|
+
if (lock === null) {
|
|
1463
|
+
await this.#reloadFromStorage();
|
|
1464
|
+
this.#state = "idle";
|
|
1465
|
+
this.#changeListener?.();
|
|
1466
|
+
return false;
|
|
1467
|
+
}
|
|
1468
|
+
callbackStarted = true;
|
|
1469
|
+
await this.#runWithStorageLock(sender);
|
|
1470
|
+
return true;
|
|
1471
|
+
}
|
|
1472
|
+
);
|
|
1473
|
+
if (!acquired) return;
|
|
1474
|
+
return;
|
|
1475
|
+
} catch (error) {
|
|
1476
|
+
if (callbackStarted) throw error;
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
if (this.storageCapability === "volatile_single_tab")
|
|
1480
|
+
this.#warnMemoryLock(
|
|
1481
|
+
"No cross-tab storage lock is available; offline sync is single-tab only."
|
|
1482
|
+
);
|
|
1483
|
+
await this.#runWithStorageLock(sender);
|
|
1491
1484
|
}
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1485
|
+
async #runWithStorageLock(sender) {
|
|
1486
|
+
this.#state = "syncing";
|
|
1487
|
+
this.#error = null;
|
|
1488
|
+
this.#changeListener?.();
|
|
1489
|
+
if (!this.#acquireSyncLock()) {
|
|
1490
|
+
await this.#reloadFromStorage();
|
|
1491
|
+
this.#state = "idle";
|
|
1492
|
+
this.#changeListener?.();
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1496
1495
|
try {
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1496
|
+
while (this.#operations.length > 0) {
|
|
1497
|
+
const operation = this.#operations[0];
|
|
1498
|
+
if (operation === void 0) break;
|
|
1499
|
+
if (await this.#rejectFailedPrerequisite(operation)) continue;
|
|
1500
|
+
let attempt = 0;
|
|
1501
|
+
let response;
|
|
1502
|
+
while (true) {
|
|
1503
|
+
await this.#updateOperationStatus("IN_FLIGHT", attempt);
|
|
1504
|
+
try {
|
|
1505
|
+
response = this.#normalizeResponse(await sender(operation));
|
|
1506
|
+
} catch (cause) {
|
|
1507
|
+
response = {
|
|
1508
|
+
status: "RETRYABLE_ERROR",
|
|
1509
|
+
error: errorValue(cause, "RETRYABLE_ERROR")
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
if (response.status !== "RETRYABLE_ERROR") break;
|
|
1513
|
+
const error2 = errorValue(response.error ?? response.reason, "RETRYABLE_ERROR");
|
|
1514
|
+
await this.#updateOperationStatus("PENDING", attempt + 1);
|
|
1515
|
+
await this.#syncResultListener?.({ operation, status: response.status, error: error2 });
|
|
1516
|
+
if (attempt >= this.#retryOptions.maxRetries) {
|
|
1517
|
+
await this.#updateOperationStatus("FAILED_RETRYABLE", attempt + 1);
|
|
1518
|
+
throw new Error(error2.message);
|
|
1519
|
+
}
|
|
1520
|
+
const interval = this.#retryOptions.initialIntervalMs * this.#retryOptions.backoffMultiplier ** attempt;
|
|
1521
|
+
await new Promise((resolve) => setTimeout(resolve, Math.max(0, interval)));
|
|
1522
|
+
attempt += 1;
|
|
1523
|
+
}
|
|
1524
|
+
const result = response.result;
|
|
1525
|
+
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);
|
|
1526
|
+
const error = response.status === "REJECTED_PERMANENT" ? errorValue(
|
|
1527
|
+
response.error ?? response.reason ?? (result !== void 0 && "ok" in result && !result.ok ? result.error : void 0),
|
|
1528
|
+
"REJECTED_PERMANENT"
|
|
1529
|
+
) : void 0;
|
|
1530
|
+
await this.#updateOperationStatus(
|
|
1531
|
+
response.status === "ACCEPTED" ? "ACCEPTED" : "REJECTED_PERMANENT",
|
|
1532
|
+
attempt + 1
|
|
1533
|
+
);
|
|
1534
|
+
const eventState = state;
|
|
1535
|
+
if (response.status === "REJECTED_PERMANENT" && error !== void 0) {
|
|
1536
|
+
this.#rejectedHistory = [
|
|
1537
|
+
...this.#rejectedHistory,
|
|
1538
|
+
{
|
|
1539
|
+
operation: { ...operation, status: "REJECTED_PERMANENT", attempts: attempt + 1 },
|
|
1540
|
+
reason: error,
|
|
1541
|
+
errorCode: error.code,
|
|
1542
|
+
rejectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1543
|
+
attempts: attempt + 1
|
|
1544
|
+
}
|
|
1545
|
+
];
|
|
1546
|
+
await this.#saveRejectedHistory();
|
|
1547
|
+
}
|
|
1548
|
+
this.#operations = this.#operations.slice(1);
|
|
1549
|
+
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1550
|
+
this.#announceChange();
|
|
1551
|
+
await this.#syncResultListener?.({
|
|
1552
|
+
operation,
|
|
1553
|
+
...result === void 0 ? {} : { result },
|
|
1554
|
+
status: response.status,
|
|
1555
|
+
...error === void 0 ? {} : { error },
|
|
1556
|
+
...eventState === void 0 ? {} : { state: eventState }
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
this.#state = "idle";
|
|
1560
|
+
this.#changeListener?.();
|
|
1561
|
+
} catch (cause) {
|
|
1562
|
+
this.#state = "error";
|
|
1563
|
+
this.#error = cause instanceof Error ? cause : new Error(String(cause));
|
|
1564
|
+
this.#changeListener?.();
|
|
1565
|
+
throw this.#error;
|
|
1566
|
+
} finally {
|
|
1567
|
+
this.#releaseSyncLock();
|
|
1501
1568
|
}
|
|
1502
1569
|
}
|
|
1503
|
-
async
|
|
1504
|
-
this
|
|
1570
|
+
async #updateOperationStatus(status, attempts) {
|
|
1571
|
+
const operation = this.#operations[0];
|
|
1572
|
+
if (operation === void 0) return;
|
|
1573
|
+
this.#operations = [{ ...operation, status, attempts }, ...this.#operations.slice(1)];
|
|
1574
|
+
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1575
|
+
this.#announceChange();
|
|
1505
1576
|
}
|
|
1506
|
-
async
|
|
1507
|
-
|
|
1508
|
-
|
|
1577
|
+
async #rejectFailedPrerequisite(operation) {
|
|
1578
|
+
if (operation.kind !== "checkIn" || this.#replayConfig === void 0) return false;
|
|
1579
|
+
const spot2 = this.#replayConfig.spots.find(
|
|
1580
|
+
(candidate) => candidate.id === operation.request.spotId
|
|
1581
|
+
);
|
|
1582
|
+
if (spot2 === void 0) return false;
|
|
1583
|
+
const failedSpots = new Set(
|
|
1584
|
+
this.#rejectedHistory.filter((entry) => entry.operation.kind === "checkIn").map(
|
|
1585
|
+
(entry) => entry.operation.kind === "checkIn" ? entry.operation.request.spotId : void 0
|
|
1586
|
+
).filter((spotId) => spotId !== void 0)
|
|
1587
|
+
);
|
|
1588
|
+
if (!spot2.prerequisites?.some((prerequisite) => failedSpots.has(prerequisite))) return false;
|
|
1589
|
+
const error = {
|
|
1590
|
+
code: "REJECTED_PREREQUISITE_FAILED",
|
|
1591
|
+
message: "A prerequisite operation was rejected by the server."
|
|
1592
|
+
};
|
|
1593
|
+
const rejectedOperation = { ...operation, status: "REJECTED_PERMANENT" };
|
|
1594
|
+
this.#rejectedHistory = [
|
|
1595
|
+
...this.#rejectedHistory,
|
|
1596
|
+
{
|
|
1597
|
+
operation: rejectedOperation,
|
|
1598
|
+
reason: error,
|
|
1599
|
+
errorCode: error.code,
|
|
1600
|
+
rejectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1601
|
+
attempts: operation.attempts ?? 0
|
|
1602
|
+
}
|
|
1603
|
+
];
|
|
1604
|
+
this.#operations = this.#operations.slice(1);
|
|
1605
|
+
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1606
|
+
await this.#saveRejectedHistory();
|
|
1607
|
+
this.#announceChange();
|
|
1608
|
+
await this.#syncResultListener?.({
|
|
1609
|
+
operation,
|
|
1610
|
+
status: "REJECTED_PERMANENT",
|
|
1611
|
+
error
|
|
1612
|
+
});
|
|
1613
|
+
return true;
|
|
1614
|
+
}
|
|
1615
|
+
#subscribeToExternalChanges() {
|
|
1616
|
+
const windowLike = globalThis.window;
|
|
1617
|
+
if (windowLike !== void 0) {
|
|
1618
|
+
this.#storageListener = (event) => {
|
|
1619
|
+
if (event.key === this.#storageKey() || event.key === `${this.#storageKey()}:rejected-history`)
|
|
1620
|
+
void this.#reloadFromStorage();
|
|
1621
|
+
};
|
|
1622
|
+
windowLike.addEventListener("storage", this.#storageListener);
|
|
1623
|
+
}
|
|
1624
|
+
const Channel = globalThis.BroadcastChannel;
|
|
1625
|
+
if (Channel !== void 0) {
|
|
1626
|
+
try {
|
|
1627
|
+
this.#channel = new Channel("stamprally:queue-sync");
|
|
1628
|
+
this.#channel.addEventListener("message", (event) => {
|
|
1629
|
+
if (typeof event.data === "object" && event.data !== null) {
|
|
1630
|
+
const data = event.data;
|
|
1631
|
+
if (data.type === "lock" && data.lockKey === this.#lockKey()) {
|
|
1632
|
+
this.#observedLocks.set(data.lockKey, {
|
|
1633
|
+
owner: typeof data.owner === "string" ? data.owner : "unknown",
|
|
1634
|
+
expiresAt: typeof data.expiresAt === "number" ? data.expiresAt : 0
|
|
1635
|
+
});
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1638
|
+
if (data.type === "unlock" && data.lockKey === this.#lockKey()) {
|
|
1639
|
+
this.#observedLocks.delete(data.lockKey);
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
if (typeof event.data === "object" && event.data !== null && "key" in event.data && event.data.key === this.#storageKey())
|
|
1644
|
+
void this.#reloadFromStorage();
|
|
1645
|
+
});
|
|
1646
|
+
} catch {
|
|
1647
|
+
this.#channel = null;
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
#announceChange() {
|
|
1652
|
+
this.#changeListener?.();
|
|
1653
|
+
this.#channel?.postMessage({
|
|
1654
|
+
type: "change",
|
|
1655
|
+
key: this.#storageKey(),
|
|
1656
|
+
owner: this.#instanceId
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
async #reloadFromStorage() {
|
|
1660
|
+
if (this.#state === "syncing") return;
|
|
1509
1661
|
try {
|
|
1510
|
-
const
|
|
1511
|
-
|
|
1662
|
+
const key = this.#storageKey();
|
|
1663
|
+
this.#operations = (await this.#storage.load(key)).map(normalizeOperation);
|
|
1664
|
+
this.#rejectedHistory = (await this.#storage.loadRejectedHistory?.(key))?.map(normalizeRejectedHistory) ?? this.#rejectedHistory;
|
|
1665
|
+
this.#loaded = true;
|
|
1512
1666
|
} catch {
|
|
1513
|
-
return [];
|
|
1514
1667
|
}
|
|
1515
1668
|
}
|
|
1516
|
-
|
|
1517
|
-
this
|
|
1669
|
+
#lockKey() {
|
|
1670
|
+
return `${this.#storageKey()}:sync-lock`;
|
|
1518
1671
|
}
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1672
|
+
#acquireSyncLock() {
|
|
1673
|
+
const key = this.#lockKey();
|
|
1674
|
+
const now = Date.now();
|
|
1675
|
+
const local = syncLocks.get(key);
|
|
1676
|
+
if (local !== void 0 && local.expiresAt > now && local.owner !== this.#instanceId)
|
|
1677
|
+
return false;
|
|
1678
|
+
const observed = this.#observedLocks.get(key);
|
|
1679
|
+
if (observed !== void 0 && observed.expiresAt > now && observed.owner !== this.#instanceId)
|
|
1680
|
+
return false;
|
|
1681
|
+
if (this.#lockStorage !== null) {
|
|
1682
|
+
try {
|
|
1683
|
+
const existing = this.#lockStorage.getItem(key);
|
|
1684
|
+
if (existing !== null) {
|
|
1685
|
+
const parsed = JSON.parse(existing);
|
|
1686
|
+
if (typeof parsed === "object" && parsed !== null && typeof parsed.expiresAt === "number" && parsed.expiresAt > now && parsed.owner !== this.#instanceId)
|
|
1687
|
+
return false;
|
|
1688
|
+
}
|
|
1689
|
+
this.#lockStorage.setItem(
|
|
1690
|
+
key,
|
|
1691
|
+
JSON.stringify({ owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS })
|
|
1692
|
+
);
|
|
1693
|
+
this.#channel?.postMessage({
|
|
1694
|
+
type: "lock",
|
|
1695
|
+
lockKey: key,
|
|
1696
|
+
owner: this.#instanceId,
|
|
1697
|
+
expiresAt: now + SYNC_LOCK_TTL_MS
|
|
1698
|
+
});
|
|
1699
|
+
} catch {
|
|
1700
|
+
this.#warnMemoryLock("Persistent lock access failed; offline sync is single-tab only.");
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
syncLocks.set(key, { owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS });
|
|
1704
|
+
return true;
|
|
1527
1705
|
}
|
|
1528
|
-
|
|
1529
|
-
const
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1706
|
+
#releaseSyncLock() {
|
|
1707
|
+
const key = this.#lockKey();
|
|
1708
|
+
const current = syncLocks.get(key);
|
|
1709
|
+
if (current?.owner === this.#instanceId) syncLocks.delete(key);
|
|
1710
|
+
if (this.#lockStorage !== null) {
|
|
1711
|
+
try {
|
|
1712
|
+
const value = this.#lockStorage.getItem(key);
|
|
1713
|
+
if (value !== null && JSON.parse(value).owner === this.#instanceId)
|
|
1714
|
+
this.#lockStorage.removeItem?.(key);
|
|
1715
|
+
this.#channel?.postMessage({ type: "unlock", lockKey: key, owner: this.#instanceId });
|
|
1716
|
+
} catch {
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1535
1719
|
}
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
return
|
|
1539
|
-
const transaction = database.transaction("operations", "readwrite");
|
|
1540
|
-
transaction.objectStore("operations").put(structuredClone(operations), key);
|
|
1541
|
-
transaction.oncomplete = () => resolve();
|
|
1542
|
-
transaction.onerror = () => reject(transaction.error ?? new Error("Failed to save offline queue."));
|
|
1543
|
-
transaction.onabort = () => reject(transaction.error ?? new Error("Offline queue write aborted."));
|
|
1544
|
-
});
|
|
1720
|
+
#storageKey() {
|
|
1721
|
+
if (this.#configuredKey !== void 0) return this.#configuredKey;
|
|
1722
|
+
return `stamprally:queue:${this.#rallyId ?? "unscoped"}:${this.#userId ?? "anonymous"}`;
|
|
1545
1723
|
}
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
}
|
|
1724
|
+
#normalizeResponse(value) {
|
|
1725
|
+
if ("ok" in value) {
|
|
1726
|
+
if (value.ok === false) {
|
|
1727
|
+
if ("status" in value && value.status === "RETRYABLE_ERROR")
|
|
1728
|
+
return { status: "RETRYABLE_ERROR", error: errorValue(value.error, "RETRYABLE_ERROR") };
|
|
1729
|
+
return { status: "REJECTED_PERMANENT", result: value };
|
|
1730
|
+
}
|
|
1731
|
+
return { status: "ACCEPTED", result: value };
|
|
1732
|
+
}
|
|
1733
|
+
if ("status" in value) {
|
|
1734
|
+
if (value.status === "ACCEPTED") return value;
|
|
1735
|
+
return value;
|
|
1736
|
+
}
|
|
1737
|
+
return { status: "ACCEPTED", result: value };
|
|
1555
1738
|
}
|
|
1556
|
-
async saveRejectedHistory(
|
|
1557
|
-
|
|
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
|
-
});
|
|
1739
|
+
async #saveRejectedHistory() {
|
|
1740
|
+
await this.#storage.saveRejectedHistory?.(this.#storageKey(), this.#rejectedHistory);
|
|
1565
1741
|
}
|
|
1566
|
-
#
|
|
1567
|
-
if (this.#
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
request.onupgradeneeded = () => {
|
|
1576
|
-
if (!request.result.objectStoreNames.contains("operations"))
|
|
1577
|
-
request.result.createObjectStore("operations");
|
|
1578
|
-
};
|
|
1579
|
-
request.onsuccess = () => resolve(request.result);
|
|
1580
|
-
request.onerror = () => reject(request.error ?? new Error("Failed to open offline queue."));
|
|
1581
|
-
}).catch((error) => {
|
|
1582
|
-
this.#databasePromise = null;
|
|
1583
|
-
throw error;
|
|
1742
|
+
#warnMemoryLock(message) {
|
|
1743
|
+
if (this.#warnedMemoryLock) return;
|
|
1744
|
+
this.#warnedMemoryLock = true;
|
|
1745
|
+
console.warn(`[@stamprally/core] ${message}`);
|
|
1746
|
+
this.#capabilityWarningListener?.({
|
|
1747
|
+
type: "STORAGE_CAPABILITY_WARNING",
|
|
1748
|
+
storageCapability: this.storageCapability === "memory" ? "memory" : "volatile_single_tab",
|
|
1749
|
+
isStoragePersistent: this.isStoragePersistent,
|
|
1750
|
+
message
|
|
1584
1751
|
});
|
|
1585
|
-
return this.#databasePromise;
|
|
1586
1752
|
}
|
|
1587
1753
|
};
|
|
1588
|
-
function
|
|
1589
|
-
|
|
1590
|
-
const storage = globalThis.localStorage;
|
|
1591
|
-
return storage ?? null;
|
|
1592
|
-
} catch {
|
|
1593
|
-
return null;
|
|
1594
|
-
}
|
|
1595
|
-
}
|
|
1596
|
-
function defaultStorage(databaseName) {
|
|
1597
|
-
try {
|
|
1598
|
-
const indexedDB = globalThis.indexedDB;
|
|
1599
|
-
if (indexedDB !== void 0)
|
|
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" };
|
|
1610
|
-
} catch {
|
|
1611
|
-
}
|
|
1612
|
-
return { storage: new MemoryQueueStorage(), capability: "memory" };
|
|
1613
|
-
}
|
|
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}`;
|
|
1617
|
-
}
|
|
1618
|
-
function requestScope(operation) {
|
|
1754
|
+
function normalizeOperation(operation) {
|
|
1755
|
+
const status = operation.status;
|
|
1619
1756
|
return {
|
|
1620
|
-
|
|
1621
|
-
|
|
1757
|
+
...operation,
|
|
1758
|
+
status: status === "IN_FLIGHT" || status === "REJECTED" ? "PENDING" : status === "RETRYABLE_ERROR" ? "FAILED_RETRYABLE" : operation.status ?? "PENDING",
|
|
1759
|
+
attempts: operation.attempts ?? 0
|
|
1622
1760
|
};
|
|
1623
1761
|
}
|
|
1624
|
-
function
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1762
|
+
function normalizeRejectedHistory(entry) {
|
|
1763
|
+
return {
|
|
1764
|
+
...entry,
|
|
1765
|
+
operation: normalizeOperation(entry.operation),
|
|
1766
|
+
reason: errorValue(entry.reason, "REJECTED_PERMANENT"),
|
|
1767
|
+
errorCode: entry.errorCode || entry.reason.code,
|
|
1768
|
+
rejectedAt: entry.rejectedAt || (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
1769
|
+
attempts: entry.attempts ?? entry.operation.attempts ?? 0
|
|
1770
|
+
};
|
|
1633
1771
|
}
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
initialIntervalMs: 250,
|
|
1639
|
-
backoffMultiplier: 2
|
|
1640
|
-
};
|
|
1641
|
-
function randomId() {
|
|
1642
|
-
return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
1772
|
+
|
|
1773
|
+
// src/client/sync.ts
|
|
1774
|
+
function isReplayable(operation) {
|
|
1775
|
+
return operation.status === void 0 || operation.status === "ACCEPTED" || operation.status === "PENDING" || operation.status === "IN_FLIGHT" || operation.status === "FAILED_RETRYABLE";
|
|
1643
1776
|
}
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
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) {
|
|
1673
|
-
this.#storage = new LocalStorageQueueStorage(options.storageLike);
|
|
1674
|
-
this.#queueCapability = "localstorage";
|
|
1675
|
-
} else {
|
|
1676
|
-
const selected = defaultStorage(options.databaseName);
|
|
1677
|
-
this.#storage = selected.storage;
|
|
1678
|
-
this.#queueCapability = selected.capability;
|
|
1777
|
+
function operationId(operation) {
|
|
1778
|
+
const identity = operation.request.userId ?? operation.request.anonymousSessionId ?? "anonymous";
|
|
1779
|
+
return `${operation.kind}:${operation.request.rallyId}:${identity}:${operation.request.idempotencyKey}`;
|
|
1780
|
+
}
|
|
1781
|
+
function applyInventoryDelta(state, previous, optimistic) {
|
|
1782
|
+
const previousInventory = previous.inventory;
|
|
1783
|
+
const optimisticInventory = optimistic.inventory;
|
|
1784
|
+
if (previousInventory === void 0 || optimisticInventory === void 0) return state;
|
|
1785
|
+
const currentInventory = state.inventory ?? {};
|
|
1786
|
+
const sharedDelta = previousInventory.sharedRemaining === void 0 || optimisticInventory.sharedRemaining === void 0 ? void 0 : optimisticInventory.sharedRemaining - previousInventory.sharedRemaining;
|
|
1787
|
+
const previousRewards = previousInventory.rewardRemaining ?? {};
|
|
1788
|
+
const optimisticRewards = optimisticInventory.rewardRemaining ?? {};
|
|
1789
|
+
const currentRewards = currentInventory.rewardRemaining ?? {};
|
|
1790
|
+
const rewardRemaining = { ...currentRewards };
|
|
1791
|
+
for (const key of /* @__PURE__ */ new Set([...Object.keys(previousRewards), ...Object.keys(optimisticRewards)])) {
|
|
1792
|
+
const before = previousRewards[key];
|
|
1793
|
+
const after = optimisticRewards[key];
|
|
1794
|
+
if (before !== void 0 && after !== void 0)
|
|
1795
|
+
rewardRemaining[key] = Math.max(0, (currentRewards[key] ?? before) + after - before);
|
|
1796
|
+
}
|
|
1797
|
+
return {
|
|
1798
|
+
...state,
|
|
1799
|
+
inventory: {
|
|
1800
|
+
...currentInventory.sharedRemaining === void 0 || sharedDelta === void 0 ? {} : { sharedRemaining: Math.max(0, currentInventory.sharedRemaining + sharedDelta) },
|
|
1801
|
+
...Object.keys(rewardRemaining).length === 0 ? {} : { rewardRemaining }
|
|
1679
1802
|
}
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1803
|
+
};
|
|
1804
|
+
}
|
|
1805
|
+
function applyOperation(state, operation, config) {
|
|
1806
|
+
if (operation.kind === "checkIn") {
|
|
1807
|
+
const spot2 = config?.spots.find((candidate) => candidate.id === operation.request.spotId);
|
|
1808
|
+
if (spot2?.prerequisites?.some((id2) => !state.records.some((record2) => record2.stampId === id2)))
|
|
1809
|
+
return { state, prerequisiteFailed: true };
|
|
1810
|
+
if (state.records.some((record2) => record2.stampId === operation.request.spotId))
|
|
1811
|
+
return { state, prerequisiteFailed: false };
|
|
1812
|
+
const optimisticRecord = operation.optimisticState?.records.find(
|
|
1813
|
+
(record2) => record2.stampId === operation.request.spotId
|
|
1814
|
+
);
|
|
1815
|
+
const record = optimisticRecord ?? {
|
|
1816
|
+
stampId: operation.request.spotId,
|
|
1817
|
+
acquiredAt: operation.request.now
|
|
1689
1818
|
};
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1819
|
+
const records = [...state.records, { ...record }];
|
|
1820
|
+
const rewards2 = config === void 0 ? state.rewards : reconcileRewardStates(config.rewards, state.rewards, records.length, record.acquiredAt);
|
|
1821
|
+
return {
|
|
1822
|
+
state: { ...state, records, rewards: rewards2, updatedAt: record.acquiredAt },
|
|
1823
|
+
prerequisiteFailed: false
|
|
1694
1824
|
};
|
|
1695
|
-
this.#lockStorage = options.storageLike ?? availableLocalStorage();
|
|
1696
|
-
if (this.#synchronizeInstances) this.#subscribeToExternalChanges();
|
|
1697
1825
|
}
|
|
1698
|
-
|
|
1699
|
-
|
|
1826
|
+
const optimisticState = operation.optimisticState;
|
|
1827
|
+
if (optimisticState === void 0) return { state, prerequisiteFailed: false };
|
|
1828
|
+
const optimisticReward = optimisticState?.rewards.find(
|
|
1829
|
+
(reward2) => reward2.rewardId === operation.request.rewardId
|
|
1830
|
+
);
|
|
1831
|
+
if (optimisticReward === void 0) return { state, prerequisiteFailed: false };
|
|
1832
|
+
const rewards = state.rewards.some((reward2) => reward2.rewardId === optimisticReward.rewardId) ? state.rewards.map(
|
|
1833
|
+
(reward2) => reward2.rewardId === optimisticReward.rewardId ? { ...optimisticReward } : reward2
|
|
1834
|
+
) : [...state.rewards, { ...optimisticReward }];
|
|
1835
|
+
return {
|
|
1836
|
+
state: applyInventoryDelta(
|
|
1837
|
+
{ ...state, rewards, updatedAt: optimisticReward.consumedAt ?? state.updatedAt },
|
|
1838
|
+
operation.request.state,
|
|
1839
|
+
optimisticState
|
|
1840
|
+
),
|
|
1841
|
+
prerequisiteFailed: false
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
function rebuildUserStateFromLog(baselineOrOptions, operationsArgument, configArgument) {
|
|
1845
|
+
const { state } = rebuildUserStateLog(
|
|
1846
|
+
"baseline" in baselineOrOptions ? baselineOrOptions.baseline : baselineOrOptions,
|
|
1847
|
+
"baseline" in baselineOrOptions ? baselineOrOptions.operations : operationsArgument ?? [],
|
|
1848
|
+
"baseline" in baselineOrOptions ? baselineOrOptions.config : configArgument
|
|
1849
|
+
);
|
|
1850
|
+
return state;
|
|
1851
|
+
}
|
|
1852
|
+
function rebuildUserStateLog(baseline, operations, config) {
|
|
1853
|
+
let state = cloneState(baseline);
|
|
1854
|
+
const rejectedOperationIds = [];
|
|
1855
|
+
for (const operation of operations) {
|
|
1856
|
+
if (!isReplayable(operation)) continue;
|
|
1857
|
+
const replay = applyOperation(state, operation, config);
|
|
1858
|
+
if (replay.prerequisiteFailed) {
|
|
1859
|
+
rejectedOperationIds.push(operationId(operation));
|
|
1860
|
+
continue;
|
|
1861
|
+
}
|
|
1862
|
+
state = replay.state;
|
|
1700
1863
|
}
|
|
1701
|
-
|
|
1702
|
-
|
|
1864
|
+
return { state, rejectedOperationIds };
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
// src/client/client.ts
|
|
1868
|
+
function isStorage(value) {
|
|
1869
|
+
return "load" in value && "save" in value && "remove" in value;
|
|
1870
|
+
}
|
|
1871
|
+
function id(prefix) {
|
|
1872
|
+
return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`;
|
|
1873
|
+
}
|
|
1874
|
+
function proof(value) {
|
|
1875
|
+
if (typeof value === "string") return value;
|
|
1876
|
+
if (typeof value === "object" && value !== null) {
|
|
1877
|
+
const item = value;
|
|
1878
|
+
for (const key of ["token", "code", "passcode", "value", "tagId"])
|
|
1879
|
+
if (typeof item[key] === "string") return item[key];
|
|
1703
1880
|
}
|
|
1704
|
-
|
|
1705
|
-
|
|
1881
|
+
return "";
|
|
1882
|
+
}
|
|
1883
|
+
function matches(condition2, value) {
|
|
1884
|
+
if (condition2.type === "gps") {
|
|
1885
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1886
|
+
const item = value;
|
|
1887
|
+
const latitude = item.latitude;
|
|
1888
|
+
const longitude = item.longitude;
|
|
1889
|
+
if (typeof latitude !== "number" || typeof longitude !== "number") return false;
|
|
1890
|
+
const radians = (v) => v * Math.PI / 180;
|
|
1891
|
+
const dLat = radians(latitude - condition2.latitude);
|
|
1892
|
+
const dLon = radians(longitude - condition2.longitude);
|
|
1893
|
+
const h = Math.sin(dLat / 2) ** 2 + Math.cos(radians(condition2.latitude)) * Math.cos(radians(latitude)) * Math.sin(dLon / 2) ** 2;
|
|
1894
|
+
return 6371e3 * 2 * Math.asin(Math.sqrt(Math.min(1, h))) <= condition2.radiusMeters;
|
|
1706
1895
|
}
|
|
1707
|
-
|
|
1708
|
-
|
|
1896
|
+
return proof(value).trim() !== "";
|
|
1897
|
+
}
|
|
1898
|
+
function emptyState(config, userId, now) {
|
|
1899
|
+
return {
|
|
1900
|
+
rallyId: config.id,
|
|
1901
|
+
userId,
|
|
1902
|
+
records: [],
|
|
1903
|
+
rewards: reconcileRewardStates(config.rewards, [], 0, now),
|
|
1904
|
+
updatedAt: now
|
|
1905
|
+
};
|
|
1906
|
+
}
|
|
1907
|
+
function errorMessage(error, fallback) {
|
|
1908
|
+
return error !== void 0 && "message" in error && typeof error.message === "string" ? error.message : fallback;
|
|
1909
|
+
}
|
|
1910
|
+
var StampRallyClient = class {
|
|
1911
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
1912
|
+
#eventListeners = /* @__PURE__ */ new Set();
|
|
1913
|
+
#syncEventListeners = /* @__PURE__ */ new Set();
|
|
1914
|
+
#storage;
|
|
1915
|
+
#options;
|
|
1916
|
+
#config;
|
|
1917
|
+
#offlineQueue;
|
|
1918
|
+
#userId;
|
|
1919
|
+
#anonymousSessionId;
|
|
1920
|
+
#state = null;
|
|
1921
|
+
#initialization = null;
|
|
1922
|
+
#queue = Promise.resolve();
|
|
1923
|
+
#syncRevision = 0;
|
|
1924
|
+
#syncMetrics = null;
|
|
1925
|
+
constructor(config, storageOrOptions = {}) {
|
|
1926
|
+
this.#config = config;
|
|
1927
|
+
this.#options = isStorage(storageOrOptions) ? { storage: storageOrOptions } : storageOrOptions;
|
|
1928
|
+
this.#storage = this.#options.storage ?? new InMemoryStorage();
|
|
1929
|
+
this.#anonymousSessionId = this.#options.anonymousSessionId ?? createAnonymousSessionId();
|
|
1930
|
+
this.#userId = this.#options.userId ?? this.#anonymousSessionId;
|
|
1931
|
+
this.#offlineQueue = this.#options.offlineQueue;
|
|
1932
|
+
this.#offlineQueue?.setReplayConfig(config);
|
|
1933
|
+
this.#offlineQueue?.setSyncResultListener((event) => this.#handleOfflineSyncResult(event));
|
|
1934
|
+
this.#offlineQueue?.setChangeListener(() => {
|
|
1935
|
+
this.#syncRevision += 1;
|
|
1936
|
+
if (this.#state !== null) this.#emit(this.#state);
|
|
1937
|
+
});
|
|
1709
1938
|
}
|
|
1710
|
-
|
|
1711
|
-
return this.#
|
|
1939
|
+
getConfig() {
|
|
1940
|
+
return this.#config;
|
|
1712
1941
|
}
|
|
1713
|
-
|
|
1714
|
-
return this.#
|
|
1942
|
+
getState() {
|
|
1943
|
+
return this.#state;
|
|
1715
1944
|
}
|
|
1716
|
-
|
|
1717
|
-
return this.#
|
|
1945
|
+
getUserId() {
|
|
1946
|
+
return this.#userId;
|
|
1718
1947
|
}
|
|
1719
|
-
|
|
1720
|
-
return this.#
|
|
1948
|
+
getAnonymousSessionId() {
|
|
1949
|
+
return this.#anonymousSessionId;
|
|
1721
1950
|
}
|
|
1722
|
-
get
|
|
1723
|
-
return this.#
|
|
1951
|
+
get syncState() {
|
|
1952
|
+
return this.#offlineQueue?.syncState ?? "idle";
|
|
1724
1953
|
}
|
|
1725
|
-
get
|
|
1726
|
-
return this.#
|
|
1954
|
+
get pendingCount() {
|
|
1955
|
+
return this.#offlineQueue?.pendingCount ?? 0;
|
|
1727
1956
|
}
|
|
1728
|
-
|
|
1729
|
-
this.#
|
|
1957
|
+
get rejectedHistory() {
|
|
1958
|
+
return this.#offlineQueue?.rejectedHistory ?? [];
|
|
1730
1959
|
}
|
|
1731
|
-
|
|
1732
|
-
|
|
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
|
-
}
|
|
1747
|
-
this.#loaded = true;
|
|
1960
|
+
getSyncRevision() {
|
|
1961
|
+
return this.#syncRevision;
|
|
1748
1962
|
}
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
const windowLike = globalThis.window;
|
|
1752
|
-
if (windowLike !== void 0 && this.#storageListener !== void 0)
|
|
1753
|
-
windowLike.removeEventListener("storage", this.#storageListener);
|
|
1754
|
-
this.#storageListener = void 0;
|
|
1755
|
-
this.#channel?.close();
|
|
1756
|
-
this.#channel = null;
|
|
1757
|
-
this.#releaseSyncLock();
|
|
1963
|
+
get queueCapability() {
|
|
1964
|
+
return this.#offlineQueue?.queueCapability ?? "custom";
|
|
1758
1965
|
}
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
if (this.#configuredKey !== void 0) {
|
|
1762
|
-
this.#rallyId = rallyId;
|
|
1763
|
-
this.#userId = userId;
|
|
1764
|
-
return this.initialize();
|
|
1765
|
-
}
|
|
1766
|
-
if (this.#rallyId === rallyId && this.#userId === userId && this.#loaded) return;
|
|
1767
|
-
this.#rallyId = rallyId;
|
|
1768
|
-
this.#userId = userId;
|
|
1769
|
-
this.#operations = [];
|
|
1770
|
-
this.#loaded = false;
|
|
1771
|
-
await this.initialize();
|
|
1966
|
+
get storageCapability() {
|
|
1967
|
+
return this.#offlineQueue?.storageCapability ?? "custom";
|
|
1772
1968
|
}
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
throw new Error("OfflineQueue.switchUser requires a rally scope.");
|
|
1776
|
-
await this.setScope(this.#rallyId, newUserId);
|
|
1969
|
+
get isStoragePersistent() {
|
|
1970
|
+
return this.#offlineQueue?.isStoragePersistent ?? true;
|
|
1777
1971
|
}
|
|
1778
|
-
|
|
1779
|
-
this.#
|
|
1972
|
+
discardRejected(operationId2) {
|
|
1973
|
+
return this.#offlineQueue?.discardRejected(operationId2) ?? Promise.resolve(false);
|
|
1780
1974
|
}
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
const scope = requestScope(operation);
|
|
1784
|
-
if (this.#rallyId === void 0) await this.setScope(scope.rallyId, scope.userId);
|
|
1785
|
-
if (this.#rallyId !== scope.rallyId || this.#userId !== scope.userId)
|
|
1786
|
-
throw new Error("Offline operation belongs to another rally or user queue.");
|
|
1787
|
-
}
|
|
1788
|
-
await this.initialize();
|
|
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 }];
|
|
1792
|
-
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1793
|
-
this.#announceChange();
|
|
1975
|
+
retryRejected(operationId2) {
|
|
1976
|
+
return this.#offlineQueue?.retryRejected(operationId2) ?? Promise.resolve(false);
|
|
1794
1977
|
}
|
|
1795
|
-
|
|
1796
|
-
return this.
|
|
1978
|
+
dismissRejectedOperation(operationId2) {
|
|
1979
|
+
return this.discardRejected(operationId2);
|
|
1797
1980
|
}
|
|
1798
|
-
|
|
1799
|
-
return this.
|
|
1981
|
+
retryOperation(operationId2) {
|
|
1982
|
+
return this.retryRejected(operationId2);
|
|
1800
1983
|
}
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
this.#
|
|
1804
|
-
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1805
|
-
this.#announceChange();
|
|
1984
|
+
subscribe(listener) {
|
|
1985
|
+
this.#listeners.add(listener);
|
|
1986
|
+
return () => this.#listeners.delete(listener);
|
|
1806
1987
|
}
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
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;
|
|
1988
|
+
subscribeEvents(listener) {
|
|
1989
|
+
this.#eventListeners.add(listener);
|
|
1990
|
+
return () => this.#eventListeners.delete(listener);
|
|
1817
1991
|
}
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
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;
|
|
1992
|
+
subscribeSyncEvents(listener) {
|
|
1993
|
+
this.#syncEventListeners.add(listener);
|
|
1994
|
+
return () => this.#syncEventListeners.delete(listener);
|
|
1834
1995
|
}
|
|
1835
|
-
|
|
1836
|
-
|
|
1996
|
+
subscribeSyncState(listener) {
|
|
1997
|
+
const wrapped = () => listener();
|
|
1998
|
+
this.#listeners.add(wrapped);
|
|
1999
|
+
return () => this.#listeners.delete(wrapped);
|
|
1837
2000
|
}
|
|
1838
|
-
|
|
1839
|
-
return this.
|
|
2001
|
+
init() {
|
|
2002
|
+
return this.initialize();
|
|
1840
2003
|
}
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
if (this.#
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
2004
|
+
initialize() {
|
|
2005
|
+
if (this.#state !== null) return Promise.resolve(this.#state);
|
|
2006
|
+
if (this.#initialization === null) {
|
|
2007
|
+
this.#initialization = (async () => {
|
|
2008
|
+
await this.#offlineQueue?.setScope(this.#config.id, this.#userId);
|
|
2009
|
+
return this.#storage.load(this.#config.id, this.#userId);
|
|
2010
|
+
})().then((state) => {
|
|
2011
|
+
const next = state === null ? emptyState(this.#config, this.#userId, this.#now()) : this.#reconcile(state);
|
|
2012
|
+
this.#state = next;
|
|
2013
|
+
this.#emit(next);
|
|
2014
|
+
return next;
|
|
2015
|
+
}).catch((error) => {
|
|
2016
|
+
this.#initialization = null;
|
|
2017
|
+
throw error;
|
|
2018
|
+
});
|
|
2019
|
+
}
|
|
2020
|
+
return this.#initialization;
|
|
1847
2021
|
}
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
this.#
|
|
2022
|
+
switchUser(newUserId) {
|
|
2023
|
+
return this.#enqueue(async () => {
|
|
2024
|
+
const nextUserId = newUserId ?? this.#anonymousSessionId;
|
|
2025
|
+
if (this.#userId === nextUserId && this.#state !== null) return this.#state;
|
|
2026
|
+
this.#userId = nextUserId;
|
|
2027
|
+
this.#state = null;
|
|
2028
|
+
this.#initialization = null;
|
|
2029
|
+
await this.#offlineQueue?.switchUser(nextUserId);
|
|
2030
|
+
return this.initialize();
|
|
2031
|
+
});
|
|
2032
|
+
}
|
|
2033
|
+
async getUserState(rallyId, userId) {
|
|
2034
|
+
return this.#storage.load(rallyId, userId);
|
|
2035
|
+
}
|
|
2036
|
+
clearUserState(userId = this.#userId) {
|
|
2037
|
+
return this.#enqueue(async () => {
|
|
2038
|
+
await this.#storage.remove(this.#config.id, userId);
|
|
2039
|
+
if (userId === this.#userId) {
|
|
2040
|
+
await this.#initializeFresh();
|
|
2041
|
+
}
|
|
1855
2042
|
});
|
|
1856
|
-
return this.#syncPromise;
|
|
1857
|
-
}
|
|
1858
|
-
async retrySync(sender = this.#sender) {
|
|
1859
|
-
return this.sync(sender);
|
|
1860
2043
|
}
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
);
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
2044
|
+
checkIn(spotId, proofData, options = {}) {
|
|
2045
|
+
return this.#enqueue(async () => {
|
|
2046
|
+
const current = await this.initialize();
|
|
2047
|
+
const spot2 = this.#config.spots.find((item) => item.id === spotId);
|
|
2048
|
+
if (spot2 === void 0)
|
|
2049
|
+
return this.#fail({ code: "SPOT_NOT_FOUND", spotId, message: "Spot was not found." });
|
|
2050
|
+
if (current.records.some((record2) => record2.stampId === spotId))
|
|
2051
|
+
return this.#fail({
|
|
2052
|
+
code: "STAMP_ALREADY_ACQUIRED",
|
|
2053
|
+
spotId,
|
|
2054
|
+
message: "Spot was already claimed."
|
|
2055
|
+
});
|
|
2056
|
+
const acquired = new Set(current.records.map((record2) => record2.stampId));
|
|
2057
|
+
if (spot2.prerequisites?.some((id2) => !acquired.has(id2)))
|
|
2058
|
+
return this.#fail({
|
|
2059
|
+
code: "PREREQUISITES_NOT_MET",
|
|
2060
|
+
spotId,
|
|
2061
|
+
message: "Prerequisite spots are not complete."
|
|
2062
|
+
});
|
|
2063
|
+
for (const condition2 of spot2.conditions) {
|
|
2064
|
+
if (condition2.type === "custom") {
|
|
2065
|
+
const validator = this.#options.customValidators?.[condition2.validatorName] ?? this.#options.customValidator;
|
|
2066
|
+
if (validator === void 0)
|
|
2067
|
+
return this.#fail({
|
|
2068
|
+
code: "CUSTOM_VALIDATION_FAILED",
|
|
2069
|
+
spotId,
|
|
2070
|
+
message: "No custom validator is registered."
|
|
2071
|
+
});
|
|
2072
|
+
const context = {
|
|
2073
|
+
rallyId: this.#config.id,
|
|
2074
|
+
spotId,
|
|
2075
|
+
proofData,
|
|
2076
|
+
condition: { type: "custom", validatorName: condition2.validatorName },
|
|
2077
|
+
userState: current
|
|
2078
|
+
};
|
|
2079
|
+
const result = typeof validator === "function" ? await validator(context) : await validator.validate(context);
|
|
2080
|
+
if (result === false || typeof result === "object" && !result.valid)
|
|
2081
|
+
return this.#fail({
|
|
2082
|
+
code: "CUSTOM_VALIDATION_FAILED",
|
|
2083
|
+
spotId,
|
|
2084
|
+
message: typeof result === "object" && result.message !== void 0 ? result.message : "Custom validation failed."
|
|
2085
|
+
});
|
|
2086
|
+
} else if (!matches(condition2, proofData))
|
|
2087
|
+
return this.#fail({ code: "INVALID_PROOF", spotId, message: "Verification failed." });
|
|
1884
2088
|
}
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
status: "RETRYABLE_ERROR",
|
|
1913
|
-
error: errorValue(cause, "RETRYABLE_ERROR")
|
|
1914
|
-
};
|
|
1915
|
-
}
|
|
1916
|
-
if (response.status !== "RETRYABLE_ERROR") break;
|
|
1917
|
-
const error2 = errorValue(response.error ?? response.reason, "RETRYABLE_ERROR");
|
|
1918
|
-
await this.#updateOperationStatus("PENDING", attempt + 1);
|
|
1919
|
-
await this.#syncResultListener?.({ operation, status: response.status, error: error2 });
|
|
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;
|
|
1927
|
-
}
|
|
1928
|
-
const result = response.result;
|
|
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();
|
|
2089
|
+
const now = options.now ?? this.#now();
|
|
2090
|
+
const request = {
|
|
2091
|
+
rallyId: this.#config.id,
|
|
2092
|
+
userId: this.#userId,
|
|
2093
|
+
spotId,
|
|
2094
|
+
proofData,
|
|
2095
|
+
idempotencyKey: options.idempotencyKey ?? id("check-in"),
|
|
2096
|
+
now,
|
|
2097
|
+
state: current,
|
|
2098
|
+
...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
|
|
2099
|
+
};
|
|
2100
|
+
const remote = this.#options.syncAdapter?.checkIn;
|
|
2101
|
+
if (options.sync !== false && remote !== void 0) {
|
|
2102
|
+
try {
|
|
2103
|
+
const result = await remote(request);
|
|
2104
|
+
return result.ok ? this.#commitCheckIn(result) : this.#fail(result.error);
|
|
2105
|
+
} catch (error) {
|
|
2106
|
+
if (this.#offlineQueue === void 0) throw error;
|
|
2107
|
+
const record2 = { stampId: spotId, acquiredAt: now };
|
|
2108
|
+
const next2 = this.#reconcile({
|
|
2109
|
+
...current,
|
|
2110
|
+
records: [...current.records, record2],
|
|
2111
|
+
updatedAt: now
|
|
2112
|
+
});
|
|
2113
|
+
await this.#offlineQueue.enqueueCheckIn(request, next2);
|
|
2114
|
+
await this.#storage.save(next2);
|
|
2115
|
+
return this.#commitCheckIn({ ok: true, value: { state: next2, record: record2 } });
|
|
1951
2116
|
}
|
|
1952
|
-
this.#operations = this.#operations.slice(1);
|
|
1953
|
-
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1954
|
-
this.#announceChange();
|
|
1955
|
-
await this.#syncResultListener?.({
|
|
1956
|
-
operation,
|
|
1957
|
-
...result === void 0 ? {} : { result },
|
|
1958
|
-
status: response.status,
|
|
1959
|
-
...error === void 0 ? {} : { error },
|
|
1960
|
-
...eventState === void 0 ? {} : { state: eventState }
|
|
1961
|
-
});
|
|
1962
2117
|
}
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
this.#
|
|
1970
|
-
|
|
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();
|
|
2118
|
+
const record = { stampId: spotId, acquiredAt: now };
|
|
2119
|
+
const next = this.#reconcile({
|
|
2120
|
+
...current,
|
|
2121
|
+
records: [...current.records, record],
|
|
2122
|
+
updatedAt: now
|
|
2123
|
+
});
|
|
2124
|
+
await this.#storage.save(next);
|
|
2125
|
+
return this.#commitCheckIn({ ok: true, value: { state: next, record } });
|
|
2126
|
+
});
|
|
1978
2127
|
}
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
2128
|
+
claimReward(rewardId, options = {}) {
|
|
2129
|
+
return this.#enqueue(async () => {
|
|
2130
|
+
const current = await this.initialize();
|
|
2131
|
+
const configured = this.#config.rewards.find((item) => item.id === rewardId);
|
|
2132
|
+
if (configured === void 0)
|
|
2133
|
+
return this.#fail({ code: "REWARD_NOT_FOUND", rewardId, message: "Reward was not found." });
|
|
2134
|
+
const now = options.now ?? this.#now();
|
|
2135
|
+
const state = current.rewards.find((item) => item.rewardId === rewardId) ?? {
|
|
2136
|
+
rewardId,
|
|
2137
|
+
status: "LOCKED"
|
|
1985
2138
|
};
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
2139
|
+
const local = consumeReward({
|
|
2140
|
+
reward: configured,
|
|
2141
|
+
currentState: state,
|
|
2142
|
+
now,
|
|
2143
|
+
...options.staffPasscode === void 0 ? {} : { inputPasscode: options.staffPasscode },
|
|
2144
|
+
...options.staffId === void 0 ? {} : { staffId: options.staffId }
|
|
2145
|
+
});
|
|
2146
|
+
if (!local.ok) return this.#fail(local.error);
|
|
2147
|
+
const request = {
|
|
2148
|
+
rallyId: this.#config.id,
|
|
2149
|
+
userId: this.#userId,
|
|
2150
|
+
rewardId,
|
|
2151
|
+
idempotencyKey: options.idempotencyKey ?? id("claim"),
|
|
2152
|
+
now,
|
|
2153
|
+
options,
|
|
2154
|
+
state: current,
|
|
2155
|
+
...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
|
|
2156
|
+
};
|
|
2157
|
+
const remote = this.#options.syncAdapter?.claimReward;
|
|
2158
|
+
if (options.sync !== false && remote !== void 0) {
|
|
2159
|
+
try {
|
|
2160
|
+
const result = await remote(request);
|
|
2161
|
+
return result.ok ? this.#commitClaim(result) : this.#fail(result.error);
|
|
2162
|
+
} catch (error) {
|
|
2163
|
+
if (this.#offlineQueue === void 0) throw error;
|
|
2164
|
+
const next2 = {
|
|
2165
|
+
...current,
|
|
2166
|
+
rewards: current.rewards.map(
|
|
2167
|
+
(item) => item.rewardId === rewardId ? local.value : item
|
|
2168
|
+
),
|
|
2169
|
+
updatedAt: now
|
|
2170
|
+
};
|
|
2171
|
+
await this.#offlineQueue.enqueueClaimReward(request, next2);
|
|
2172
|
+
await this.#storage.save(next2);
|
|
2173
|
+
return this.#commitClaim({ ok: true, value: { state: next2, reward: local.value } });
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
const next = {
|
|
2177
|
+
...current,
|
|
2178
|
+
rewards: current.rewards.map((item) => item.rewardId === rewardId ? local.value : item),
|
|
2179
|
+
updatedAt: now
|
|
2180
|
+
};
|
|
2181
|
+
await this.#storage.save(next);
|
|
2182
|
+
return this.#commitClaim({ ok: true, value: { state: next, reward: local.value } });
|
|
2183
|
+
});
|
|
2184
|
+
}
|
|
2185
|
+
sync(adapter = this.#options.syncAdapter) {
|
|
2186
|
+
return this.#enqueue(async () => {
|
|
2187
|
+
this.#emitSyncEvent({ type: "SYNC_STARTED" });
|
|
2188
|
+
this.#syncMetrics = { processed: 0, failed: 0 };
|
|
1990
2189
|
try {
|
|
1991
|
-
|
|
1992
|
-
this.#
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
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;
|
|
2190
|
+
const current = await this.initialize();
|
|
2191
|
+
if (this.#offlineQueue !== void 0 && adapter !== void 0) {
|
|
2192
|
+
await this.#offlineQueue.sync(async (operation) => {
|
|
2193
|
+
if (operation.kind === "checkIn") {
|
|
2194
|
+
if (adapter.checkIn === void 0)
|
|
2195
|
+
throw new Error("No check-in sync adapter is configured.");
|
|
2196
|
+
return adapter.checkIn(operation.request);
|
|
2005
2197
|
}
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2198
|
+
if (adapter.claimReward === void 0)
|
|
2199
|
+
throw new Error("No reward sync adapter is configured.");
|
|
2200
|
+
return adapter.claimReward(operation.request);
|
|
2201
|
+
});
|
|
2202
|
+
}
|
|
2203
|
+
if (adapter?.sync === void 0) {
|
|
2204
|
+
this.#emitEvent({ type: "sync", state: this.#state ?? current });
|
|
2205
|
+
return;
|
|
2206
|
+
}
|
|
2207
|
+
const localState = this.#state ?? current;
|
|
2208
|
+
const serverState = await adapter.sync({
|
|
2209
|
+
rallyId: this.#config.id,
|
|
2210
|
+
userId: this.#userId,
|
|
2211
|
+
state: localState,
|
|
2212
|
+
...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
|
|
2213
|
+
});
|
|
2214
|
+
const resolved = rebuildUserStateFromLog(
|
|
2215
|
+
serverState,
|
|
2216
|
+
this.#offlineQueue?.operations ?? [],
|
|
2217
|
+
this.#config
|
|
2218
|
+
);
|
|
2219
|
+
const next = this.#reconcile(resolved);
|
|
2220
|
+
await this.#storage.save(next);
|
|
2221
|
+
this.#state = next;
|
|
2222
|
+
this.#emit(next);
|
|
2223
|
+
this.#emitEvent({ type: "sync", state: next });
|
|
2224
|
+
} finally {
|
|
2225
|
+
const metrics = this.#syncMetrics ?? { processed: 0, failed: 0 };
|
|
2226
|
+
this.#syncMetrics = null;
|
|
2227
|
+
this.#emitSyncEvent({
|
|
2228
|
+
type: "SYNC_COMPLETED",
|
|
2229
|
+
totalProcessed: metrics.processed,
|
|
2230
|
+
failedCount: metrics.failed
|
|
2009
2231
|
});
|
|
2010
|
-
} catch {
|
|
2011
|
-
this.#channel = null;
|
|
2012
2232
|
}
|
|
2013
|
-
}
|
|
2233
|
+
});
|
|
2014
2234
|
}
|
|
2015
|
-
|
|
2016
|
-
this
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2235
|
+
retrySync() {
|
|
2236
|
+
return this.sync();
|
|
2237
|
+
}
|
|
2238
|
+
reset() {
|
|
2239
|
+
return this.#enqueue(async () => {
|
|
2240
|
+
await this.#storage.remove(this.#config.id, this.#userId);
|
|
2241
|
+
return this.#initializeFresh();
|
|
2020
2242
|
});
|
|
2021
2243
|
}
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
this.#
|
|
2029
|
-
|
|
2030
|
-
|
|
2244
|
+
restore(state) {
|
|
2245
|
+
return this.#enqueue(async () => {
|
|
2246
|
+
if (state.rallyId !== this.#config.id || state.userId !== this.#userId)
|
|
2247
|
+
throw new Error("State belongs to another rally or user.");
|
|
2248
|
+
const next = this.#reconcile(state);
|
|
2249
|
+
await this.#storage.save(next);
|
|
2250
|
+
this.#state = next;
|
|
2251
|
+
this.#initialization = Promise.resolve(next);
|
|
2252
|
+
this.#emit(next);
|
|
2253
|
+
return next;
|
|
2254
|
+
});
|
|
2031
2255
|
}
|
|
2032
|
-
#
|
|
2033
|
-
|
|
2256
|
+
#enqueue(operation) {
|
|
2257
|
+
const next = this.#queue.then(operation, operation);
|
|
2258
|
+
this.#queue = next.then(
|
|
2259
|
+
() => void 0,
|
|
2260
|
+
() => void 0
|
|
2261
|
+
);
|
|
2262
|
+
return next;
|
|
2034
2263
|
}
|
|
2035
|
-
#
|
|
2036
|
-
const
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
this.#
|
|
2053
|
-
|
|
2054
|
-
|
|
2264
|
+
#initializeFresh() {
|
|
2265
|
+
const next = emptyState(this.#config, this.#userId, this.#now());
|
|
2266
|
+
this.#state = next;
|
|
2267
|
+
this.#initialization = Promise.resolve(next);
|
|
2268
|
+
this.#emit(next);
|
|
2269
|
+
return Promise.resolve(next);
|
|
2270
|
+
}
|
|
2271
|
+
#reconcile(state) {
|
|
2272
|
+
const ids = new Set(this.#config.spots.map((spot2) => spot2.id));
|
|
2273
|
+
const records = state.records.filter(
|
|
2274
|
+
(record, index, all) => ids.has(record.stampId) && all.findIndex((candidate) => candidate.stampId === record.stampId) === index
|
|
2275
|
+
);
|
|
2276
|
+
return {
|
|
2277
|
+
...cloneState(state),
|
|
2278
|
+
userId: this.#userId,
|
|
2279
|
+
records,
|
|
2280
|
+
rewards: reconcileRewardStates(
|
|
2281
|
+
this.#config.rewards,
|
|
2282
|
+
state.rewards,
|
|
2283
|
+
records.length,
|
|
2284
|
+
state.updatedAt
|
|
2285
|
+
)
|
|
2286
|
+
};
|
|
2287
|
+
}
|
|
2288
|
+
async #handleOfflineSyncResult(event) {
|
|
2289
|
+
if (event.status === "ACCEPTED") {
|
|
2290
|
+
if (this.#syncMetrics !== null) this.#syncMetrics.processed += 1;
|
|
2291
|
+
if (event.state !== void 0) {
|
|
2292
|
+
const rebuilt = rebuildUserStateFromLog(
|
|
2293
|
+
event.state,
|
|
2294
|
+
this.#offlineQueue?.operations ?? [],
|
|
2295
|
+
this.#config
|
|
2055
2296
|
);
|
|
2056
|
-
this.#
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
expiresAt: now + SYNC_LOCK_TTL_MS
|
|
2061
|
-
});
|
|
2062
|
-
} catch {
|
|
2063
|
-
this.#warnMemoryLock("Persistent lock access failed; offline sync is single-tab only.");
|
|
2297
|
+
const next = this.#reconcile(rebuilt);
|
|
2298
|
+
await this.#storage.save(next);
|
|
2299
|
+
this.#state = next;
|
|
2300
|
+
this.#emit(next);
|
|
2064
2301
|
}
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
this.#channel?.postMessage({ type: "unlock", lockKey: key, owner: this.#instanceId });
|
|
2079
|
-
} catch {
|
|
2302
|
+
this.#emitSyncEvent({
|
|
2303
|
+
type: "OPERATION_ACCEPTED",
|
|
2304
|
+
operationId: this.#operationId(event.operation),
|
|
2305
|
+
resourceId: this.#resourceId(event.operation)
|
|
2306
|
+
});
|
|
2307
|
+
} else if (event.status === "REJECTED_PERMANENT") {
|
|
2308
|
+
const error = event.error ?? {
|
|
2309
|
+
code: "REJECTED_PERMANENT",
|
|
2310
|
+
message: "Offline operation was rejected."
|
|
2311
|
+
};
|
|
2312
|
+
if (this.#syncMetrics !== null) {
|
|
2313
|
+
this.#syncMetrics.processed += 1;
|
|
2314
|
+
this.#syncMetrics.failed += 1;
|
|
2080
2315
|
}
|
|
2316
|
+
const base = event.state ?? this.#state ?? event.operation.request.state;
|
|
2317
|
+
const rollbackBase = event.state === void 0 ? rollbackOptimisticOperation(base, event.operation) : base;
|
|
2318
|
+
const rebuilt = rebuildUserStateFromLog(
|
|
2319
|
+
rollbackBase,
|
|
2320
|
+
this.#offlineQueue?.operations ?? [],
|
|
2321
|
+
this.#config
|
|
2322
|
+
);
|
|
2323
|
+
const next = this.#reconcile(rebuilt);
|
|
2324
|
+
await this.#storage.save(next);
|
|
2325
|
+
this.#state = next;
|
|
2326
|
+
this.#emit(next);
|
|
2327
|
+
this.#emitSyncEvent({
|
|
2328
|
+
type: "OPERATION_ROLLED_BACK",
|
|
2329
|
+
operationId: this.#operationId(event.operation),
|
|
2330
|
+
resourceId: this.#resourceId(event.operation),
|
|
2331
|
+
reason: errorMessage(error, "Offline operation was rejected."),
|
|
2332
|
+
errorCode: error.code
|
|
2333
|
+
});
|
|
2334
|
+
} else if (event.status === "RETRYABLE_ERROR") {
|
|
2335
|
+
if (this.#syncMetrics !== null) this.#syncMetrics.failed += 1;
|
|
2336
|
+
this.#emitSyncEvent({
|
|
2337
|
+
type: "OPERATION_RETRYABLE_ERROR",
|
|
2338
|
+
operationId: this.#operationId(event.operation),
|
|
2339
|
+
error: errorMessage(event.error, "Retryable sync error.")
|
|
2340
|
+
});
|
|
2341
|
+
}
|
|
2342
|
+
if (event.state !== void 0 && event.status === void 0) {
|
|
2343
|
+
const next = this.#reconcile(event.state);
|
|
2344
|
+
await this.#storage.save(next);
|
|
2345
|
+
this.#state = next;
|
|
2346
|
+
this.#emit(next);
|
|
2081
2347
|
}
|
|
2348
|
+
if (event.error !== void 0) this.#emitEvent({ type: "error", error: event.error });
|
|
2349
|
+
else if (event.result !== void 0 && "ok" in event.result && !event.result.ok)
|
|
2350
|
+
this.#emitEvent({ type: "error", error: event.result.error });
|
|
2082
2351
|
}
|
|
2083
|
-
#
|
|
2084
|
-
|
|
2085
|
-
return `stamprally:queue:${this.#rallyId ?? "unscoped"}:${this.#userId ?? "anonymous"}`;
|
|
2352
|
+
#now() {
|
|
2353
|
+
return this.#options.clock?.() ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
2086
2354
|
}
|
|
2087
|
-
#
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2355
|
+
#fail(error) {
|
|
2356
|
+
this.#emitEvent({ type: "error", error });
|
|
2357
|
+
return { ok: false, error };
|
|
2358
|
+
}
|
|
2359
|
+
#commitCheckIn(result) {
|
|
2360
|
+
if (result.ok) {
|
|
2361
|
+
this.#state = result.value.state;
|
|
2362
|
+
this.#emit(this.#state);
|
|
2095
2363
|
}
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2364
|
+
this.#emitEvent({ type: "checkIn", result });
|
|
2365
|
+
return result;
|
|
2366
|
+
}
|
|
2367
|
+
#commitClaim(result) {
|
|
2368
|
+
if (result.ok) {
|
|
2369
|
+
this.#state = result.value.state;
|
|
2370
|
+
this.#emit(this.#state);
|
|
2099
2371
|
}
|
|
2100
|
-
|
|
2372
|
+
this.#emitEvent({ type: "rewardClaimed", result });
|
|
2373
|
+
return result;
|
|
2101
2374
|
}
|
|
2102
|
-
|
|
2103
|
-
const
|
|
2104
|
-
const policy = (typeof configured === "function" ? await configured({ operation, localState, serverState }) : configured) ?? this.#conflictPolicy;
|
|
2105
|
-
return resolveRallyStateConflict(serverState, localState, { policy });
|
|
2375
|
+
#emit(state) {
|
|
2376
|
+
for (const listener of this.#listeners) listener(state);
|
|
2106
2377
|
}
|
|
2107
|
-
|
|
2108
|
-
|
|
2378
|
+
#emitEvent(event) {
|
|
2379
|
+
for (const listener of this.#eventListeners) listener(event);
|
|
2109
2380
|
}
|
|
2110
|
-
#
|
|
2111
|
-
|
|
2112
|
-
this.#
|
|
2113
|
-
|
|
2381
|
+
#emitSyncEvent(event) {
|
|
2382
|
+
for (const listener of this.#syncEventListeners) listener(event);
|
|
2383
|
+
this.#emitEvent({ type: "syncLifecycle", event });
|
|
2384
|
+
}
|
|
2385
|
+
#operationId(operation) {
|
|
2386
|
+
const identity = operation.request.userId ?? operation.request.anonymousSessionId ?? "anonymous";
|
|
2387
|
+
return `${operation.kind}:${operation.request.rallyId}:${identity}:${operation.request.idempotencyKey}`;
|
|
2388
|
+
}
|
|
2389
|
+
#resourceId(operation) {
|
|
2390
|
+
return operation.kind === "checkIn" ? operation.request.spotId : operation.request.rewardId;
|
|
2114
2391
|
}
|
|
2115
2392
|
};
|
|
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
|
-
}
|
|
2134
2393
|
|
|
2135
2394
|
// src/crypto/token.ts
|
|
2136
2395
|
var encoder = new TextEncoder();
|
|
@@ -2624,6 +2883,18 @@ function finiteNumber(value, key, path, errors, minimum) {
|
|
|
2624
2883
|
);
|
|
2625
2884
|
}
|
|
2626
2885
|
}
|
|
2886
|
+
function nonNegativeInteger(value, key, path, errors) {
|
|
2887
|
+
const item = value[key];
|
|
2888
|
+
if (typeof item !== "number" || !Number.isFinite(item)) {
|
|
2889
|
+
add(errors, `${path}.${key}`, "Expected a finite number.", "invalid_number");
|
|
2890
|
+
return;
|
|
2891
|
+
}
|
|
2892
|
+
if (!Number.isInteger(item)) {
|
|
2893
|
+
add(errors, `${path}.${key}`, "Expected a non-negative integer.", "invalid_integer");
|
|
2894
|
+
return;
|
|
2895
|
+
}
|
|
2896
|
+
if (item < 0) add(errors, `${path}.${key}`, "Expected a non-negative integer.", "out_of_range");
|
|
2897
|
+
}
|
|
2627
2898
|
function localizedText(value, path, errors) {
|
|
2628
2899
|
if (typeof value === "string") return;
|
|
2629
2900
|
if (!isRecord3(value)) {
|
|
@@ -2652,7 +2923,11 @@ function theme(value, path, errors) {
|
|
|
2652
2923
|
finiteNumber(value, "gridColumns", path, errors, 1);
|
|
2653
2924
|
if (typeof value.gridColumns === "number" && !Number.isInteger(value.gridColumns))
|
|
2654
2925
|
add(errors, `${path}.gridColumns`, "Expected an integer.", "invalid_integer");
|
|
2655
|
-
if (hasOwn(value, "unclaimedOpacity"))
|
|
2926
|
+
if (hasOwn(value, "unclaimedOpacity")) {
|
|
2927
|
+
finiteNumber(value, "unclaimedOpacity", path, errors, 0);
|
|
2928
|
+
if (typeof value.unclaimedOpacity === "number" && value.unclaimedOpacity > 1)
|
|
2929
|
+
add(errors, `${path}.unclaimedOpacity`, "Expected a value between 0 and 1.", "out_of_range");
|
|
2930
|
+
}
|
|
2656
2931
|
}
|
|
2657
2932
|
function externalReferences(value, path, errors) {
|
|
2658
2933
|
if (!Array.isArray(value)) {
|
|
@@ -2815,14 +3090,14 @@ function reward(value, path, errors, isPublic) {
|
|
|
2815
3090
|
String(value.redemptionMethod)
|
|
2816
3091
|
))
|
|
2817
3092
|
add(errors, `${path}.redemptionMethod`, "Unknown redemption method.", "invalid_enum");
|
|
2818
|
-
|
|
3093
|
+
nonNegativeInteger(value, "requiredStampCount", path, errors);
|
|
2819
3094
|
for (const key of ["stockLimit", "userClaimLimit"]) {
|
|
2820
3095
|
if (hasOwn(value, key) && value[key] !== void 0) {
|
|
2821
|
-
|
|
2822
|
-
if (typeof value[key] === "number" && !Number.isInteger(value[key]))
|
|
2823
|
-
add(errors, `${path}.${key}`, "Expected an integer.", "invalid_integer");
|
|
3096
|
+
nonNegativeInteger(value, key, path, errors);
|
|
2824
3097
|
}
|
|
2825
3098
|
}
|
|
3099
|
+
optionalString(value, "stockKey", path, errors);
|
|
3100
|
+
optionalString(value, "secondaryStockKey", path, errors);
|
|
2826
3101
|
optionalString(value, "validUntil", path, errors);
|
|
2827
3102
|
if (typeof value.validUntil === "string" && Number.isNaN(Date.parse(value.validUntil)))
|
|
2828
3103
|
add(errors, `${path}.validUntil`, "Expected a valid date string.", "invalid_date");
|
|
@@ -2866,8 +3141,31 @@ function validate(value, isPublic) {
|
|
|
2866
3141
|
});
|
|
2867
3142
|
if (!isPublic) {
|
|
2868
3143
|
optionalString(value, "staffPasscode", "$", errors);
|
|
2869
|
-
if (hasOwn(value, "inventory") && value.inventory !== void 0
|
|
2870
|
-
|
|
3144
|
+
if (hasOwn(value, "inventory") && value.inventory !== void 0) {
|
|
3145
|
+
if (!isRecord3(value.inventory))
|
|
3146
|
+
add(errors, "$.inventory", "Expected an object.", "invalid_type");
|
|
3147
|
+
else {
|
|
3148
|
+
for (const [key, item] of Object.entries(value.inventory)) {
|
|
3149
|
+
if (key === "global") {
|
|
3150
|
+
add(
|
|
3151
|
+
errors,
|
|
3152
|
+
"$.inventory.global",
|
|
3153
|
+
"Use sharedStock instead of global.",
|
|
3154
|
+
"deprecated_field"
|
|
3155
|
+
);
|
|
3156
|
+
continue;
|
|
3157
|
+
}
|
|
3158
|
+
if (item !== void 0) nonNegativeInteger(value.inventory, key, "$.inventory", errors);
|
|
3159
|
+
}
|
|
3160
|
+
if (hasOwn(value.inventory, "sharedStock") && hasOwn(value.inventory, "global"))
|
|
3161
|
+
add(
|
|
3162
|
+
errors,
|
|
3163
|
+
"$.inventory",
|
|
3164
|
+
"sharedStock and global cannot be configured together.",
|
|
3165
|
+
"conflicting_fields"
|
|
3166
|
+
);
|
|
3167
|
+
}
|
|
3168
|
+
}
|
|
2871
3169
|
if (hasOwn(value, "inventoryMode") && value.inventoryMode !== void 0 && value.inventoryMode !== "shared" && value.inventoryMode !== "per_reward")
|
|
2872
3170
|
add(errors, "$.inventoryMode", "Expected shared or per_reward.", "invalid_enum");
|
|
2873
3171
|
if (hasOwn(value, "serverMetadata") && value.serverMetadata !== void 0 && !isRecord3(value.serverMetadata))
|
|
@@ -2941,6 +3239,33 @@ function validateRallyConfigRelations(config) {
|
|
|
2941
3239
|
reward2.conditions?.forEach((condition2, conditionIndex) => {
|
|
2942
3240
|
visit(condition2, `rewards[${index}].conditions[${conditionIndex}]`);
|
|
2943
3241
|
});
|
|
3242
|
+
if (reward2.requiredStampCount > config.spots.length)
|
|
3243
|
+
add(
|
|
3244
|
+
errors,
|
|
3245
|
+
`rewards[${index}].requiredStampCount`,
|
|
3246
|
+
"requiredStampCount cannot exceed the number of spots.",
|
|
3247
|
+
"required_stamp_count_exceeds_spots"
|
|
3248
|
+
);
|
|
3249
|
+
const inventory = "inventory" in config ? config.inventory : void 0;
|
|
3250
|
+
for (const [field, key] of [
|
|
3251
|
+
["stockKey", reward2.stockKey],
|
|
3252
|
+
["secondaryStockKey", reward2.secondaryStockKey]
|
|
3253
|
+
]) {
|
|
3254
|
+
if (key !== void 0 && key !== "__shared__" && inventory?.[key] === void 0)
|
|
3255
|
+
add(
|
|
3256
|
+
errors,
|
|
3257
|
+
`rewards[${index}].${field}`,
|
|
3258
|
+
"Referenced inventory key does not exist.",
|
|
3259
|
+
"missing_inventory_key"
|
|
3260
|
+
);
|
|
3261
|
+
}
|
|
3262
|
+
if (reward2.stockKey === "__shared__" && inventory?.sharedStock === void 0)
|
|
3263
|
+
add(
|
|
3264
|
+
errors,
|
|
3265
|
+
`rewards[${index}].stockKey`,
|
|
3266
|
+
"sharedStock is not defined.",
|
|
3267
|
+
"missing_inventory_key"
|
|
3268
|
+
);
|
|
2944
3269
|
});
|
|
2945
3270
|
const visiting = /* @__PURE__ */ new Set();
|
|
2946
3271
|
const visited = /* @__PURE__ */ new Set();
|
|
@@ -3153,9 +3478,12 @@ exports.parsePublicConfig = parsePublicConfig;
|
|
|
3153
3478
|
exports.processStamp = processStamp;
|
|
3154
3479
|
exports.readNfcContext = readNfcContext;
|
|
3155
3480
|
exports.readQrContext = readQrContext;
|
|
3481
|
+
exports.rebuildUserStateFromLog = rebuildUserStateFromLog;
|
|
3482
|
+
exports.rebuildUserStateLog = rebuildUserStateLog;
|
|
3156
3483
|
exports.reconcileRewardStates = reconcileRewardStates;
|
|
3157
3484
|
exports.resolveLocalizedText = resolveLocalizedText;
|
|
3158
3485
|
exports.resolveRallyStateConflict = resolveRallyStateConflict;
|
|
3486
|
+
exports.rollbackOptimisticOperation = rollbackOptimisticOperation;
|
|
3159
3487
|
exports.safeParseAdminConfig = safeParseAdminConfig;
|
|
3160
3488
|
exports.safeParsePublicConfig = safeParsePublicConfig;
|
|
3161
3489
|
exports.sanitizeAdminConfig = sanitizeAdminConfig;
|