@stamprally/core 0.16.0 → 0.17.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 +1121 -960
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +43 -3
- package/dist/index.d.ts +43 -3
- package/dist/index.js +1121 -961
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -171,6 +171,12 @@ function mergeStampRecords(serverRecords, localRecords) {
|
|
|
171
171
|
}
|
|
172
172
|
function resolveRallyStateConflict(serverState, localState, options = { policy: "merge" }) {
|
|
173
173
|
if (options.policy === "server_wins") return serverState;
|
|
174
|
+
if (options.policy === "authoritative_replay")
|
|
175
|
+
return {
|
|
176
|
+
...serverState,
|
|
177
|
+
records: serverState.records.map((record) => ({ ...record })),
|
|
178
|
+
rewards: serverState.rewards.map((reward2) => ({ ...reward2 }))
|
|
179
|
+
};
|
|
174
180
|
return {
|
|
175
181
|
...serverState,
|
|
176
182
|
records: mergeStampRecords(serverState.records, localState.records),
|
|
@@ -1058,1079 +1064,1217 @@ var IndexedDBAdapter = class {
|
|
|
1058
1064
|
}
|
|
1059
1065
|
};
|
|
1060
1066
|
|
|
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;
|
|
1067
|
+
// src/client/offlineQueue.ts
|
|
1068
|
+
function rollbackOptimisticOperation(state, operation) {
|
|
1069
|
+
const previous = operation.request.state;
|
|
1070
|
+
if (operation.kind === "checkIn") {
|
|
1071
|
+
const records = state.records.filter(
|
|
1072
|
+
(record) => record.stampId !== operation.request.spotId || record.acquiredAt !== operation.request.now
|
|
1073
|
+
);
|
|
1074
|
+
const previousRewards = new Map(previous.rewards.map((reward2) => [reward2.rewardId, reward2]));
|
|
1075
|
+
const rewards2 = state.rewards.map((reward2) => previousRewards.get(reward2.rewardId) ?? reward2);
|
|
1076
|
+
return { ...cloneState(state), records, rewards: rewards2, updatedAt: previous.updatedAt };
|
|
1089
1077
|
}
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1078
|
+
const previousReward = previous.rewards.find(
|
|
1079
|
+
(reward2) => reward2.rewardId === operation.request.rewardId
|
|
1080
|
+
);
|
|
1081
|
+
const rewards = state.rewards.filter(
|
|
1082
|
+
(reward2) => reward2.rewardId !== operation.request.rewardId || previousReward !== void 0
|
|
1083
|
+
).map(
|
|
1084
|
+
(reward2) => reward2.rewardId === operation.request.rewardId && previousReward !== void 0 ? { ...previousReward } : reward2
|
|
1085
|
+
);
|
|
1086
|
+
const cloned = cloneState(state);
|
|
1087
|
+
const { inventory: _inventory, ...stateWithoutInventory } = cloned;
|
|
1088
|
+
const previousInventory = previous.inventory;
|
|
1089
|
+
return previousInventory === void 0 ? { ...stateWithoutInventory, rewards, updatedAt: previous.updatedAt } : {
|
|
1090
|
+
...stateWithoutInventory,
|
|
1091
|
+
rewards,
|
|
1092
|
+
inventory: {
|
|
1093
|
+
...previousInventory,
|
|
1094
|
+
...previousInventory.rewardRemaining === void 0 ? {} : { rewardRemaining: { ...previousInventory.rewardRemaining } }
|
|
1095
|
+
},
|
|
1096
|
+
updatedAt: previous.updatedAt
|
|
1099
1097
|
};
|
|
1100
1098
|
}
|
|
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;
|
|
1099
|
+
var MemoryQueueStorage = class {
|
|
1100
|
+
#values = /* @__PURE__ */ new Map();
|
|
1101
|
+
#rejected = /* @__PURE__ */ new Map();
|
|
1102
|
+
async load(key) {
|
|
1103
|
+
return this.#values.get(key) ?? [];
|
|
1124
1104
|
}
|
|
1125
|
-
|
|
1126
|
-
|
|
1105
|
+
async save(key, operations) {
|
|
1106
|
+
this.#values.set(key, structuredClone(operations));
|
|
1127
1107
|
}
|
|
1128
|
-
|
|
1129
|
-
return this.#
|
|
1108
|
+
async loadRejectedHistory(key) {
|
|
1109
|
+
return this.#rejected.get(key) ?? [];
|
|
1130
1110
|
}
|
|
1131
|
-
|
|
1132
|
-
|
|
1111
|
+
async saveRejectedHistory(key, history) {
|
|
1112
|
+
this.#rejected.set(key, structuredClone(history));
|
|
1133
1113
|
}
|
|
1134
|
-
|
|
1135
|
-
|
|
1114
|
+
};
|
|
1115
|
+
var LocalStorageQueueStorage = class {
|
|
1116
|
+
constructor(storage) {
|
|
1117
|
+
this.storage = storage;
|
|
1136
1118
|
}
|
|
1137
|
-
|
|
1138
|
-
|
|
1119
|
+
storage;
|
|
1120
|
+
async load(key) {
|
|
1121
|
+
const value = this.storage.getItem(key);
|
|
1122
|
+
if (value === null) return [];
|
|
1123
|
+
try {
|
|
1124
|
+
const parsed = JSON.parse(value);
|
|
1125
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
1126
|
+
} catch {
|
|
1127
|
+
return [];
|
|
1128
|
+
}
|
|
1139
1129
|
}
|
|
1140
|
-
|
|
1141
|
-
|
|
1130
|
+
async save(key, operations) {
|
|
1131
|
+
this.storage.setItem(key, JSON.stringify(operations));
|
|
1142
1132
|
}
|
|
1143
|
-
|
|
1144
|
-
|
|
1133
|
+
async loadRejectedHistory(key) {
|
|
1134
|
+
const value = this.storage.getItem(`${key}:rejected-history`);
|
|
1135
|
+
if (value === null) return [];
|
|
1136
|
+
try {
|
|
1137
|
+
const parsed = JSON.parse(value);
|
|
1138
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
1139
|
+
} catch {
|
|
1140
|
+
return [];
|
|
1141
|
+
}
|
|
1145
1142
|
}
|
|
1146
|
-
|
|
1147
|
-
|
|
1143
|
+
async saveRejectedHistory(key, history) {
|
|
1144
|
+
this.storage.setItem(`${key}:rejected-history`, JSON.stringify(history));
|
|
1148
1145
|
}
|
|
1149
|
-
|
|
1150
|
-
|
|
1146
|
+
};
|
|
1147
|
+
var IndexedDBOfflineQueueStorage = class {
|
|
1148
|
+
#providedFactory;
|
|
1149
|
+
#databaseName;
|
|
1150
|
+
#databasePromise = null;
|
|
1151
|
+
constructor(options = {}) {
|
|
1152
|
+
this.#providedFactory = options.indexedDB;
|
|
1153
|
+
this.#databaseName = options.databaseName ?? "stamprally-offline-queue";
|
|
1151
1154
|
}
|
|
1152
|
-
|
|
1153
|
-
this.#
|
|
1154
|
-
return () =>
|
|
1155
|
+
async load(key) {
|
|
1156
|
+
const database = await this.#open();
|
|
1157
|
+
return new Promise((resolve, reject) => {
|
|
1158
|
+
const request = database.transaction("operations", "readonly").objectStore("operations").get(key);
|
|
1159
|
+
request.onsuccess = () => resolve(Array.isArray(request.result) ? request.result : []);
|
|
1160
|
+
request.onerror = () => reject(request.error ?? new Error("Failed to read offline queue."));
|
|
1161
|
+
});
|
|
1155
1162
|
}
|
|
1156
|
-
|
|
1157
|
-
this.#
|
|
1158
|
-
return () =>
|
|
1163
|
+
async save(key, operations) {
|
|
1164
|
+
const database = await this.#open();
|
|
1165
|
+
return new Promise((resolve, reject) => {
|
|
1166
|
+
const transaction = database.transaction("operations", "readwrite");
|
|
1167
|
+
transaction.objectStore("operations").put(structuredClone(operations), key);
|
|
1168
|
+
transaction.oncomplete = () => resolve();
|
|
1169
|
+
transaction.onerror = () => reject(transaction.error ?? new Error("Failed to save offline queue."));
|
|
1170
|
+
transaction.onabort = () => reject(transaction.error ?? new Error("Offline queue write aborted."));
|
|
1171
|
+
});
|
|
1159
1172
|
}
|
|
1160
|
-
|
|
1161
|
-
|
|
1173
|
+
async loadRejectedHistory(key) {
|
|
1174
|
+
const database = await this.#open();
|
|
1175
|
+
return new Promise((resolve, reject) => {
|
|
1176
|
+
const request = database.transaction("operations", "readonly").objectStore("operations").get(`${key}:rejected-history`);
|
|
1177
|
+
request.onsuccess = () => resolve(
|
|
1178
|
+
Array.isArray(request.result) ? request.result : []
|
|
1179
|
+
);
|
|
1180
|
+
request.onerror = () => reject(request.error ?? new Error("Failed to read rejected operation history."));
|
|
1181
|
+
});
|
|
1162
1182
|
}
|
|
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;
|
|
1183
|
+
async saveRejectedHistory(key, history) {
|
|
1184
|
+
const database = await this.#open();
|
|
1185
|
+
return new Promise((resolve, reject) => {
|
|
1186
|
+
const transaction = database.transaction("operations", "readwrite");
|
|
1187
|
+
transaction.objectStore("operations").put(structuredClone(history), `${key}:rejected-history`);
|
|
1188
|
+
transaction.oncomplete = () => resolve();
|
|
1189
|
+
transaction.onerror = () => reject(transaction.error ?? new Error("Failed to save rejected operation history."));
|
|
1190
|
+
transaction.onabort = () => reject(transaction.error ?? new Error("Rejected operation history write aborted."));
|
|
1191
|
+
});
|
|
1180
1192
|
}
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
this
|
|
1188
|
-
|
|
1189
|
-
|
|
1193
|
+
#open() {
|
|
1194
|
+
if (this.#databasePromise !== null) return this.#databasePromise;
|
|
1195
|
+
let factory = this.#providedFactory;
|
|
1196
|
+
if (factory === void 0)
|
|
1197
|
+
factory = globalThis.indexedDB;
|
|
1198
|
+
if (factory === void 0 || factory === null)
|
|
1199
|
+
return Promise.reject(new Error("IndexedDB is unavailable in this environment."));
|
|
1200
|
+
this.#databasePromise = new Promise((resolve, reject) => {
|
|
1201
|
+
const request = factory.open(this.#databaseName, 1);
|
|
1202
|
+
request.onupgradeneeded = () => {
|
|
1203
|
+
if (!request.result.objectStoreNames.contains("operations"))
|
|
1204
|
+
request.result.createObjectStore("operations");
|
|
1205
|
+
};
|
|
1206
|
+
request.onsuccess = () => resolve(request.result);
|
|
1207
|
+
request.onerror = () => reject(request.error ?? new Error("Failed to open offline queue."));
|
|
1208
|
+
}).catch((error) => {
|
|
1209
|
+
this.#databasePromise = null;
|
|
1210
|
+
throw error;
|
|
1190
1211
|
});
|
|
1212
|
+
return this.#databasePromise;
|
|
1191
1213
|
}
|
|
1192
|
-
|
|
1193
|
-
|
|
1214
|
+
};
|
|
1215
|
+
function availableLocalStorage() {
|
|
1216
|
+
try {
|
|
1217
|
+
const storage = globalThis.localStorage;
|
|
1218
|
+
return storage ?? null;
|
|
1219
|
+
} catch {
|
|
1220
|
+
return null;
|
|
1194
1221
|
}
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1222
|
+
}
|
|
1223
|
+
function defaultStorage(databaseName) {
|
|
1224
|
+
try {
|
|
1225
|
+
const indexedDB = globalThis.indexedDB;
|
|
1226
|
+
if (indexedDB !== void 0)
|
|
1227
|
+
return {
|
|
1228
|
+
storage: new IndexedDBOfflineQueueStorage({
|
|
1229
|
+
indexedDB,
|
|
1230
|
+
...databaseName === void 0 ? {} : { databaseName }
|
|
1231
|
+
}),
|
|
1232
|
+
capability: "indexeddb"
|
|
1233
|
+
};
|
|
1234
|
+
const storage = availableLocalStorage();
|
|
1235
|
+
if (storage !== void 0 && storage !== null)
|
|
1236
|
+
return { storage: new LocalStorageQueueStorage(storage), capability: "localstorage" };
|
|
1237
|
+
} catch {
|
|
1202
1238
|
}
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
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
|
-
});
|
|
1239
|
+
return { storage: new MemoryQueueStorage(), capability: "memory" };
|
|
1240
|
+
}
|
|
1241
|
+
function offlineOperationId(operation) {
|
|
1242
|
+
const identity = operation.request.userId ?? operation.request.anonymousSessionId ?? "anonymous";
|
|
1243
|
+
return operation.kind === "checkIn" ? `checkIn:${operation.request.rallyId}:${identity}:${operation.request.idempotencyKey}` : `claimReward:${operation.request.rallyId}:${identity}:${operation.request.idempotencyKey}`;
|
|
1244
|
+
}
|
|
1245
|
+
function requestScope(operation) {
|
|
1246
|
+
return {
|
|
1247
|
+
rallyId: operation.request.rallyId,
|
|
1248
|
+
userId: operation.request.userId
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
function errorValue(value, fallbackCode) {
|
|
1252
|
+
if (typeof value === "object" && value !== null) {
|
|
1253
|
+
const candidate = value;
|
|
1254
|
+
if (typeof candidate.code === "string" && typeof candidate.message === "string")
|
|
1255
|
+
return { ...candidate, code: candidate.code, message: candidate.message };
|
|
1286
1256
|
}
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1257
|
+
if (value instanceof Error) return { code: fallbackCode, message: value.message };
|
|
1258
|
+
if (typeof value === "string") return { code: fallbackCode, message: value };
|
|
1259
|
+
return { code: fallbackCode, message: "Offline operation was rejected." };
|
|
1260
|
+
}
|
|
1261
|
+
var syncLocks = /* @__PURE__ */ new Map();
|
|
1262
|
+
var SYNC_LOCK_TTL_MS = 3e4;
|
|
1263
|
+
var DEFAULT_RETRY_OPTIONS = {
|
|
1264
|
+
maxRetries: 0,
|
|
1265
|
+
initialIntervalMs: 250,
|
|
1266
|
+
backoffMultiplier: 2
|
|
1267
|
+
};
|
|
1268
|
+
function randomId() {
|
|
1269
|
+
return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
1270
|
+
}
|
|
1271
|
+
var OfflineQueue = class {
|
|
1272
|
+
#storage;
|
|
1273
|
+
#queueCapability;
|
|
1274
|
+
#configuredKey;
|
|
1275
|
+
#rallyId;
|
|
1276
|
+
#userId;
|
|
1277
|
+
#conflictPolicy;
|
|
1278
|
+
#onSyncConflict;
|
|
1279
|
+
#operations = [];
|
|
1280
|
+
#rejectedHistory = [];
|
|
1281
|
+
#loaded = false;
|
|
1282
|
+
#state = "idle";
|
|
1283
|
+
#error = null;
|
|
1284
|
+
#sender;
|
|
1285
|
+
#syncPromise = null;
|
|
1286
|
+
#syncResultListener;
|
|
1287
|
+
#changeListener;
|
|
1288
|
+
#synchronizeInstances;
|
|
1289
|
+
#retryOptions;
|
|
1290
|
+
#instanceId = randomId();
|
|
1291
|
+
#lockStorage;
|
|
1292
|
+
#observedLocks = /* @__PURE__ */ new Map();
|
|
1293
|
+
#warnedMemoryLock = false;
|
|
1294
|
+
#storageListener;
|
|
1295
|
+
#channel = null;
|
|
1296
|
+
constructor(options = {}) {
|
|
1297
|
+
if (options.storage !== void 0) {
|
|
1298
|
+
this.#storage = options.storage;
|
|
1299
|
+
this.#queueCapability = "custom";
|
|
1300
|
+
} else if (options.storageLike !== void 0 && options.storageLike !== null) {
|
|
1301
|
+
this.#storage = new LocalStorageQueueStorage(options.storageLike);
|
|
1302
|
+
this.#queueCapability = "localstorage";
|
|
1303
|
+
} else {
|
|
1304
|
+
const selected = defaultStorage(options.databaseName);
|
|
1305
|
+
this.#storage = selected.storage;
|
|
1306
|
+
this.#queueCapability = selected.capability;
|
|
1307
|
+
}
|
|
1308
|
+
this.#configuredKey = options.key;
|
|
1309
|
+
this.#rallyId = options.rallyId;
|
|
1310
|
+
this.#userId = options.userId ?? null;
|
|
1311
|
+
this.#conflictPolicy = options.conflictPolicy ?? "authoritative_replay";
|
|
1312
|
+
this.#onSyncConflict = options.onSyncConflict;
|
|
1313
|
+
this.#synchronizeInstances = options.synchronizeInstances ?? true;
|
|
1314
|
+
const retryOptions = {
|
|
1315
|
+
...DEFAULT_RETRY_OPTIONS,
|
|
1316
|
+
...options.retryOptions ?? options.syncRetryOptions ?? options.retry ?? {}
|
|
1317
|
+
};
|
|
1318
|
+
this.#retryOptions = {
|
|
1319
|
+
maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
|
|
1320
|
+
initialIntervalMs: Math.max(0, retryOptions.initialIntervalMs),
|
|
1321
|
+
backoffMultiplier: Math.max(1, retryOptions.backoffMultiplier)
|
|
1322
|
+
};
|
|
1323
|
+
this.#lockStorage = options.storageLike ?? availableLocalStorage();
|
|
1324
|
+
if (this.#synchronizeInstances) this.#subscribeToExternalChanges();
|
|
1343
1325
|
}
|
|
1344
|
-
|
|
1345
|
-
return this.#
|
|
1346
|
-
const current = await this.initialize();
|
|
1347
|
-
if (this.#offlineQueue !== void 0 && adapter !== void 0) {
|
|
1348
|
-
await this.#offlineQueue.sync(async (operation) => {
|
|
1349
|
-
if (operation.kind === "checkIn") {
|
|
1350
|
-
if (adapter.checkIn === void 0)
|
|
1351
|
-
throw new Error("No check-in sync adapter is configured.");
|
|
1352
|
-
return adapter.checkIn(operation.request);
|
|
1353
|
-
}
|
|
1354
|
-
if (adapter.claimReward === void 0)
|
|
1355
|
-
throw new Error("No reward sync adapter is configured.");
|
|
1356
|
-
return adapter.claimReward(operation.request);
|
|
1357
|
-
});
|
|
1358
|
-
}
|
|
1359
|
-
if (adapter?.sync === void 0) {
|
|
1360
|
-
this.#emitEvent({ type: "sync", state: this.#state ?? current });
|
|
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
|
-
});
|
|
1326
|
+
get syncState() {
|
|
1327
|
+
return this.#state;
|
|
1375
1328
|
}
|
|
1376
|
-
|
|
1377
|
-
return this.
|
|
1329
|
+
get pendingCount() {
|
|
1330
|
+
return this.#operations.length;
|
|
1378
1331
|
}
|
|
1379
|
-
|
|
1380
|
-
return this.#
|
|
1381
|
-
await this.#storage.remove(this.#config.id, this.#userId);
|
|
1382
|
-
return this.#initializeFresh();
|
|
1383
|
-
});
|
|
1332
|
+
get queueCapability() {
|
|
1333
|
+
return this.#queueCapability;
|
|
1384
1334
|
}
|
|
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
|
-
});
|
|
1335
|
+
get rejectedHistory() {
|
|
1336
|
+
return this.#rejectedHistory;
|
|
1396
1337
|
}
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
this.#queue = next.then(
|
|
1400
|
-
() => void 0,
|
|
1401
|
-
() => void 0
|
|
1402
|
-
);
|
|
1403
|
-
return next;
|
|
1338
|
+
get error() {
|
|
1339
|
+
return this.#error;
|
|
1404
1340
|
}
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
this.#state = next;
|
|
1408
|
-
this.#initialization = Promise.resolve(next);
|
|
1409
|
-
this.#emit(next);
|
|
1410
|
-
return Promise.resolve(next);
|
|
1341
|
+
get operations() {
|
|
1342
|
+
return this.#operations;
|
|
1411
1343
|
}
|
|
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
|
-
};
|
|
1344
|
+
get conflictPolicy() {
|
|
1345
|
+
return this.#conflictPolicy;
|
|
1428
1346
|
}
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
const next = this.#reconcile(event.state);
|
|
1432
|
-
await this.#storage.save(next);
|
|
1433
|
-
this.#state = next;
|
|
1434
|
-
this.#emit(next);
|
|
1435
|
-
}
|
|
1436
|
-
if (event.error !== void 0) this.#emitEvent({ type: "error", error: event.error });
|
|
1437
|
-
else if (event.result !== void 0 && "ok" in event.result && !event.result.ok)
|
|
1438
|
-
this.#emitEvent({ type: "error", error: event.result.error });
|
|
1347
|
+
get storageKey() {
|
|
1348
|
+
return this.#storageKey();
|
|
1439
1349
|
}
|
|
1440
|
-
|
|
1441
|
-
return this.#
|
|
1350
|
+
get rallyId() {
|
|
1351
|
+
return this.#rallyId;
|
|
1442
1352
|
}
|
|
1443
|
-
|
|
1444
|
-
this.#
|
|
1445
|
-
return { ok: false, error };
|
|
1353
|
+
get userId() {
|
|
1354
|
+
return this.#userId;
|
|
1446
1355
|
}
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
this.#state = result.value.state;
|
|
1450
|
-
this.#emit(this.#state);
|
|
1451
|
-
}
|
|
1452
|
-
this.#emitEvent({ type: "checkIn", result });
|
|
1453
|
-
return result;
|
|
1356
|
+
setSyncResultListener(listener) {
|
|
1357
|
+
this.#syncResultListener = listener;
|
|
1454
1358
|
}
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1359
|
+
setChangeListener(listener) {
|
|
1360
|
+
this.#changeListener = listener;
|
|
1361
|
+
}
|
|
1362
|
+
async initialize() {
|
|
1363
|
+
if (this.#loaded) return;
|
|
1364
|
+
try {
|
|
1365
|
+
const key = this.#storageKey();
|
|
1366
|
+
this.#operations = (await this.#storage.load(key)).map(normalizeOperation);
|
|
1367
|
+
this.#rejectedHistory = (await this.#storage.loadRejectedHistory?.(key))?.map(normalizeRejectedHistory) ?? [];
|
|
1368
|
+
} catch (error) {
|
|
1369
|
+
if (this.#queueCapability === "memory") throw error;
|
|
1370
|
+
this.#storage = new MemoryQueueStorage();
|
|
1371
|
+
this.#queueCapability = "memory";
|
|
1372
|
+
this.#operations = [];
|
|
1373
|
+
this.#rejectedHistory = [];
|
|
1374
|
+
this.#warnMemoryLock(
|
|
1375
|
+
`Offline queue persistence is unavailable; queued data can be lost after reload (${String(error)}).`
|
|
1376
|
+
);
|
|
1459
1377
|
}
|
|
1460
|
-
this.#
|
|
1461
|
-
return result;
|
|
1378
|
+
this.#loaded = true;
|
|
1462
1379
|
}
|
|
1463
|
-
|
|
1464
|
-
|
|
1380
|
+
/** Releases browser listeners when the queue is no longer used. */
|
|
1381
|
+
dispose() {
|
|
1382
|
+
const windowLike = globalThis.window;
|
|
1383
|
+
if (windowLike !== void 0 && this.#storageListener !== void 0)
|
|
1384
|
+
windowLike.removeEventListener("storage", this.#storageListener);
|
|
1385
|
+
this.#storageListener = void 0;
|
|
1386
|
+
this.#channel?.close();
|
|
1387
|
+
this.#channel = null;
|
|
1388
|
+
this.#releaseSyncLock();
|
|
1465
1389
|
}
|
|
1466
|
-
|
|
1467
|
-
|
|
1390
|
+
/** Selects a rally/user queue scope and loads its pending operations. */
|
|
1391
|
+
async setScope(rallyId, userId) {
|
|
1392
|
+
if (this.#configuredKey !== void 0) {
|
|
1393
|
+
this.#rallyId = rallyId;
|
|
1394
|
+
this.#userId = userId;
|
|
1395
|
+
return this.initialize();
|
|
1396
|
+
}
|
|
1397
|
+
if (this.#rallyId === rallyId && this.#userId === userId && this.#loaded) return;
|
|
1398
|
+
this.#rallyId = rallyId;
|
|
1399
|
+
this.#userId = userId;
|
|
1400
|
+
this.#operations = [];
|
|
1401
|
+
this.#loaded = false;
|
|
1402
|
+
await this.initialize();
|
|
1468
1403
|
}
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
#values = /* @__PURE__ */ new Map();
|
|
1474
|
-
#rejected = /* @__PURE__ */ new Map();
|
|
1475
|
-
async load(key) {
|
|
1476
|
-
return this.#values.get(key) ?? [];
|
|
1404
|
+
async switchUser(newUserId) {
|
|
1405
|
+
if (this.#rallyId === void 0)
|
|
1406
|
+
throw new Error("OfflineQueue.switchUser requires a rally scope.");
|
|
1407
|
+
await this.setScope(this.#rallyId, newUserId);
|
|
1477
1408
|
}
|
|
1478
|
-
|
|
1479
|
-
this.#
|
|
1409
|
+
setSender(sender) {
|
|
1410
|
+
this.#sender = sender;
|
|
1480
1411
|
}
|
|
1481
|
-
async
|
|
1482
|
-
|
|
1412
|
+
async enqueue(operation) {
|
|
1413
|
+
if (this.#configuredKey === void 0) {
|
|
1414
|
+
const scope = requestScope(operation);
|
|
1415
|
+
if (this.#rallyId === void 0) await this.setScope(scope.rallyId, scope.userId);
|
|
1416
|
+
if (this.#rallyId !== scope.rallyId || this.#userId !== scope.userId)
|
|
1417
|
+
throw new Error("Offline operation belongs to another rally or user queue.");
|
|
1418
|
+
}
|
|
1419
|
+
await this.initialize();
|
|
1420
|
+
const id2 = offlineOperationId(operation);
|
|
1421
|
+
if (this.#operations.some((item) => offlineOperationId(item) === id2)) return;
|
|
1422
|
+
this.#operations = [...this.#operations, { ...operation, status: "PENDING", attempts: 0 }];
|
|
1423
|
+
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1424
|
+
this.#announceChange();
|
|
1483
1425
|
}
|
|
1484
|
-
async
|
|
1485
|
-
this
|
|
1426
|
+
async enqueueCheckIn(request) {
|
|
1427
|
+
return this.enqueue({ kind: "checkIn", request });
|
|
1486
1428
|
}
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
constructor(storage) {
|
|
1490
|
-
this.storage = storage;
|
|
1429
|
+
async enqueueClaimReward(request) {
|
|
1430
|
+
return this.enqueue({ kind: "claimReward", request });
|
|
1491
1431
|
}
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
const parsed = JSON.parse(value);
|
|
1498
|
-
return Array.isArray(parsed) ? parsed : [];
|
|
1499
|
-
} catch {
|
|
1500
|
-
return [];
|
|
1501
|
-
}
|
|
1432
|
+
async clear() {
|
|
1433
|
+
await this.initialize();
|
|
1434
|
+
this.#operations = [];
|
|
1435
|
+
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1436
|
+
this.#announceChange();
|
|
1502
1437
|
}
|
|
1503
|
-
async
|
|
1504
|
-
this.
|
|
1438
|
+
async discardRejected(operationId) {
|
|
1439
|
+
await this.initialize();
|
|
1440
|
+
const next = this.#rejectedHistory.filter(
|
|
1441
|
+
(entry) => offlineOperationId(entry.operation) !== operationId
|
|
1442
|
+
);
|
|
1443
|
+
if (next.length === this.#rejectedHistory.length) return false;
|
|
1444
|
+
this.#rejectedHistory = next;
|
|
1445
|
+
await this.#saveRejectedHistory();
|
|
1446
|
+
this.#announceChange();
|
|
1447
|
+
return true;
|
|
1505
1448
|
}
|
|
1506
|
-
async
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1449
|
+
async retryRejected(operationId) {
|
|
1450
|
+
await this.initialize();
|
|
1451
|
+
const entry = this.#rejectedHistory.find(
|
|
1452
|
+
(candidate) => offlineOperationId(candidate.operation) === operationId
|
|
1453
|
+
);
|
|
1454
|
+
if (entry === void 0) return false;
|
|
1455
|
+
if (!this.#operations.some((operation) => offlineOperationId(operation) === operationId))
|
|
1456
|
+
this.#operations = [
|
|
1457
|
+
...this.#operations,
|
|
1458
|
+
{ ...entry.operation, status: "PENDING", attempts: 0 }
|
|
1459
|
+
];
|
|
1460
|
+
this.#rejectedHistory = this.#rejectedHistory.filter((candidate) => candidate !== entry);
|
|
1461
|
+
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1462
|
+
await this.#saveRejectedHistory();
|
|
1463
|
+
this.#announceChange();
|
|
1464
|
+
return true;
|
|
1515
1465
|
}
|
|
1516
|
-
async
|
|
1517
|
-
this.
|
|
1466
|
+
async discardRejectedOperation(operationId) {
|
|
1467
|
+
return this.discardRejected(operationId);
|
|
1518
1468
|
}
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
#providedFactory;
|
|
1522
|
-
#databaseName;
|
|
1523
|
-
#databasePromise = null;
|
|
1524
|
-
constructor(options = {}) {
|
|
1525
|
-
this.#providedFactory = options.indexedDB;
|
|
1526
|
-
this.#databaseName = options.databaseName ?? "stamprally-offline-queue";
|
|
1469
|
+
async retryRejectedOperation(operationId) {
|
|
1470
|
+
return this.retryRejected(operationId);
|
|
1527
1471
|
}
|
|
1528
|
-
async
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
});
|
|
1472
|
+
async clearRejectedHistory() {
|
|
1473
|
+
await this.initialize();
|
|
1474
|
+
if (this.#rejectedHistory.length === 0) return;
|
|
1475
|
+
this.#rejectedHistory = [];
|
|
1476
|
+
await this.#saveRejectedHistory();
|
|
1477
|
+
this.#announceChange();
|
|
1535
1478
|
}
|
|
1536
|
-
async
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
transaction.onabort = () => reject(transaction.error ?? new Error("Offline queue write aborted."));
|
|
1479
|
+
async sync(sender = this.#sender) {
|
|
1480
|
+
await this.initialize();
|
|
1481
|
+
if (sender === void 0) throw new Error("OfflineQueue.sync requires a sender.");
|
|
1482
|
+
if (this.#syncPromise !== null) return this.#syncPromise;
|
|
1483
|
+
this.#sender = sender;
|
|
1484
|
+
this.#syncPromise = this.#run(sender).finally(() => {
|
|
1485
|
+
this.#syncPromise = null;
|
|
1544
1486
|
});
|
|
1487
|
+
return this.#syncPromise;
|
|
1545
1488
|
}
|
|
1546
|
-
async
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1489
|
+
async retrySync(sender = this.#sender) {
|
|
1490
|
+
return this.sync(sender);
|
|
1491
|
+
}
|
|
1492
|
+
async #run(sender) {
|
|
1493
|
+
const locks = globalThis.navigator?.locks;
|
|
1494
|
+
if (locks !== void 0 && typeof locks.request === "function") {
|
|
1495
|
+
let callbackStarted = false;
|
|
1496
|
+
try {
|
|
1497
|
+
const acquired = await locks.request(
|
|
1498
|
+
`stamprally:${this.#storageKey()}:sync`,
|
|
1499
|
+
{ ifAvailable: true },
|
|
1500
|
+
async (lock) => {
|
|
1501
|
+
if (lock === null) {
|
|
1502
|
+
await this.#reloadFromStorage();
|
|
1503
|
+
this.#state = "idle";
|
|
1504
|
+
this.#changeListener?.();
|
|
1505
|
+
return false;
|
|
1506
|
+
}
|
|
1507
|
+
callbackStarted = true;
|
|
1508
|
+
await this.#runWithStorageLock(sender);
|
|
1509
|
+
return true;
|
|
1510
|
+
}
|
|
1511
|
+
);
|
|
1512
|
+
if (!acquired) return;
|
|
1513
|
+
return;
|
|
1514
|
+
} catch (error) {
|
|
1515
|
+
if (callbackStarted) throw error;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
if (this.#lockStorage === null)
|
|
1519
|
+
this.#warnMemoryLock(
|
|
1520
|
+
"No cross-tab storage lock is available; offline sync is single-tab only."
|
|
1552
1521
|
);
|
|
1553
|
-
|
|
1554
|
-
});
|
|
1522
|
+
await this.#runWithStorageLock(sender);
|
|
1555
1523
|
}
|
|
1556
|
-
async
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1524
|
+
async #runWithStorageLock(sender) {
|
|
1525
|
+
this.#state = "syncing";
|
|
1526
|
+
this.#error = null;
|
|
1527
|
+
this.#changeListener?.();
|
|
1528
|
+
if (!this.#acquireSyncLock()) {
|
|
1529
|
+
await this.#reloadFromStorage();
|
|
1530
|
+
this.#state = "idle";
|
|
1531
|
+
this.#changeListener?.();
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
try {
|
|
1535
|
+
while (this.#operations.length > 0) {
|
|
1536
|
+
const operation = this.#operations[0];
|
|
1537
|
+
if (operation === void 0) break;
|
|
1538
|
+
let attempt = 0;
|
|
1539
|
+
let response;
|
|
1540
|
+
while (true) {
|
|
1541
|
+
await this.#updateOperationStatus("IN_FLIGHT", attempt);
|
|
1542
|
+
try {
|
|
1543
|
+
response = this.#normalizeResponse(await sender(operation));
|
|
1544
|
+
} catch (cause) {
|
|
1545
|
+
response = {
|
|
1546
|
+
status: "RETRYABLE_ERROR",
|
|
1547
|
+
error: errorValue(cause, "RETRYABLE_ERROR")
|
|
1548
|
+
};
|
|
1549
|
+
}
|
|
1550
|
+
if (response.status !== "RETRYABLE_ERROR") break;
|
|
1551
|
+
const error2 = errorValue(response.error ?? response.reason, "RETRYABLE_ERROR");
|
|
1552
|
+
await this.#updateOperationStatus("PENDING", attempt + 1);
|
|
1553
|
+
await this.#syncResultListener?.({ operation, status: response.status, error: error2 });
|
|
1554
|
+
if (attempt >= this.#retryOptions.maxRetries) {
|
|
1555
|
+
await this.#updateOperationStatus("FAILED_RETRYABLE", attempt + 1);
|
|
1556
|
+
throw new Error(error2.message);
|
|
1557
|
+
}
|
|
1558
|
+
const interval = this.#retryOptions.initialIntervalMs * this.#retryOptions.backoffMultiplier ** attempt;
|
|
1559
|
+
await new Promise((resolve) => setTimeout(resolve, Math.max(0, interval)));
|
|
1560
|
+
attempt += 1;
|
|
1561
|
+
}
|
|
1562
|
+
const result = response.result;
|
|
1563
|
+
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);
|
|
1564
|
+
const error = response.status === "REJECTED_PERMANENT" ? errorValue(
|
|
1565
|
+
response.error ?? response.reason ?? (result !== void 0 && "ok" in result && !result.ok ? result.error : void 0),
|
|
1566
|
+
"REJECTED_PERMANENT"
|
|
1567
|
+
) : void 0;
|
|
1568
|
+
await this.#updateOperationStatus(
|
|
1569
|
+
response.status === "ACCEPTED" ? "ACCEPTED" : "REJECTED_PERMANENT",
|
|
1570
|
+
attempt + 1
|
|
1571
|
+
);
|
|
1572
|
+
const eventState = state;
|
|
1573
|
+
if (response.status === "REJECTED_PERMANENT" && error !== void 0) {
|
|
1574
|
+
this.#rejectedHistory = [
|
|
1575
|
+
...this.#rejectedHistory,
|
|
1576
|
+
{
|
|
1577
|
+
operation: { ...operation, status: "REJECTED_PERMANENT", attempts: attempt + 1 },
|
|
1578
|
+
reason: error,
|
|
1579
|
+
errorCode: error.code,
|
|
1580
|
+
rejectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1581
|
+
attempts: attempt + 1
|
|
1582
|
+
}
|
|
1583
|
+
];
|
|
1584
|
+
await this.#saveRejectedHistory();
|
|
1585
|
+
}
|
|
1586
|
+
this.#operations = this.#operations.slice(1);
|
|
1587
|
+
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1588
|
+
this.#announceChange();
|
|
1589
|
+
await this.#syncResultListener?.({
|
|
1590
|
+
operation,
|
|
1591
|
+
...result === void 0 ? {} : { result },
|
|
1592
|
+
status: response.status,
|
|
1593
|
+
...error === void 0 ? {} : { error },
|
|
1594
|
+
...eventState === void 0 ? {} : { state: eventState }
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
this.#state = "idle";
|
|
1598
|
+
this.#changeListener?.();
|
|
1599
|
+
} catch (cause) {
|
|
1600
|
+
this.#state = "error";
|
|
1601
|
+
this.#error = cause instanceof Error ? cause : new Error(String(cause));
|
|
1602
|
+
this.#changeListener?.();
|
|
1603
|
+
throw this.#error;
|
|
1604
|
+
} finally {
|
|
1605
|
+
this.#releaseSyncLock();
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
async #updateOperationStatus(status, attempts) {
|
|
1609
|
+
const operation = this.#operations[0];
|
|
1610
|
+
if (operation === void 0) return;
|
|
1611
|
+
this.#operations = [{ ...operation, status, attempts }, ...this.#operations.slice(1)];
|
|
1612
|
+
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1613
|
+
this.#announceChange();
|
|
1565
1614
|
}
|
|
1566
|
-
#
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
return Promise.reject(new Error("IndexedDB is unavailable in this environment."));
|
|
1573
|
-
this.#databasePromise = new Promise((resolve, reject) => {
|
|
1574
|
-
const request = factory.open(this.#databaseName, 1);
|
|
1575
|
-
request.onupgradeneeded = () => {
|
|
1576
|
-
if (!request.result.objectStoreNames.contains("operations"))
|
|
1577
|
-
request.result.createObjectStore("operations");
|
|
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();
|
|
1578
1621
|
};
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
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
|
|
1584
1657
|
});
|
|
1585
|
-
return this.#databasePromise;
|
|
1586
1658
|
}
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1659
|
+
async #reloadFromStorage() {
|
|
1660
|
+
if (this.#state === "syncing") return;
|
|
1661
|
+
try {
|
|
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;
|
|
1666
|
+
} catch {
|
|
1667
|
+
}
|
|
1594
1668
|
}
|
|
1595
|
-
|
|
1596
|
-
|
|
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 {
|
|
1669
|
+
#lockKey() {
|
|
1670
|
+
return `${this.#storageKey()}:sync-lock`;
|
|
1611
1671
|
}
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
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;
|
|
1629
1705
|
}
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
}
|
|
1641
|
-
|
|
1642
|
-
return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
1643
|
-
}
|
|
1644
|
-
var OfflineQueue = class {
|
|
1645
|
-
#storage;
|
|
1646
|
-
#queueCapability;
|
|
1647
|
-
#configuredKey;
|
|
1648
|
-
#rallyId;
|
|
1649
|
-
#userId;
|
|
1650
|
-
#conflictPolicy;
|
|
1651
|
-
#onSyncConflict;
|
|
1652
|
-
#operations = [];
|
|
1653
|
-
#rejectedHistory = [];
|
|
1654
|
-
#loaded = false;
|
|
1655
|
-
#state = "idle";
|
|
1656
|
-
#error = null;
|
|
1657
|
-
#sender;
|
|
1658
|
-
#syncPromise = null;
|
|
1659
|
-
#syncResultListener;
|
|
1660
|
-
#synchronizeInstances;
|
|
1661
|
-
#retryOptions;
|
|
1662
|
-
#instanceId = randomId();
|
|
1663
|
-
#lockStorage;
|
|
1664
|
-
#observedLocks = /* @__PURE__ */ new Map();
|
|
1665
|
-
#warnedMemoryLock = false;
|
|
1666
|
-
#storageListener;
|
|
1667
|
-
#channel = null;
|
|
1668
|
-
constructor(options = {}) {
|
|
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;
|
|
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
|
+
}
|
|
1679
1718
|
}
|
|
1680
|
-
this.#configuredKey = options.key;
|
|
1681
|
-
this.#rallyId = options.rallyId;
|
|
1682
|
-
this.#userId = options.userId ?? null;
|
|
1683
|
-
this.#conflictPolicy = options.conflictPolicy ?? "server_wins";
|
|
1684
|
-
this.#onSyncConflict = options.onSyncConflict;
|
|
1685
|
-
this.#synchronizeInstances = options.synchronizeInstances ?? true;
|
|
1686
|
-
const retryOptions = {
|
|
1687
|
-
...DEFAULT_RETRY_OPTIONS,
|
|
1688
|
-
...options.retryOptions ?? options.syncRetryOptions ?? options.retry ?? {}
|
|
1689
|
-
};
|
|
1690
|
-
this.#retryOptions = {
|
|
1691
|
-
maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
|
|
1692
|
-
initialIntervalMs: Math.max(0, retryOptions.initialIntervalMs),
|
|
1693
|
-
backoffMultiplier: Math.max(1, retryOptions.backoffMultiplier)
|
|
1694
|
-
};
|
|
1695
|
-
this.#lockStorage = options.storageLike ?? availableLocalStorage();
|
|
1696
|
-
if (this.#synchronizeInstances) this.#subscribeToExternalChanges();
|
|
1697
1719
|
}
|
|
1698
|
-
|
|
1699
|
-
return this.#
|
|
1720
|
+
#storageKey() {
|
|
1721
|
+
if (this.#configuredKey !== void 0) return this.#configuredKey;
|
|
1722
|
+
return `stamprally:queue:${this.#rallyId ?? "unscoped"}:${this.#userId ?? "anonymous"}`;
|
|
1700
1723
|
}
|
|
1701
|
-
|
|
1702
|
-
|
|
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 };
|
|
1703
1738
|
}
|
|
1704
|
-
|
|
1705
|
-
|
|
1739
|
+
async resolveConflict(operation, localState, serverState) {
|
|
1740
|
+
const configured = this.#onSyncConflict;
|
|
1741
|
+
const policy = (typeof configured === "function" ? await configured({ operation, localState, serverState }) : configured) ?? this.#conflictPolicy;
|
|
1742
|
+
return resolveRallyStateConflict(serverState, localState, { policy });
|
|
1706
1743
|
}
|
|
1707
|
-
|
|
1708
|
-
|
|
1744
|
+
async #saveRejectedHistory() {
|
|
1745
|
+
await this.#storage.saveRejectedHistory?.(this.#storageKey(), this.#rejectedHistory);
|
|
1746
|
+
}
|
|
1747
|
+
#warnMemoryLock(message) {
|
|
1748
|
+
if (this.#warnedMemoryLock) return;
|
|
1749
|
+
this.#warnedMemoryLock = true;
|
|
1750
|
+
console.warn(`[@stamprally/core] ${message}`);
|
|
1709
1751
|
}
|
|
1710
|
-
|
|
1711
|
-
|
|
1752
|
+
};
|
|
1753
|
+
function normalizeOperation(operation) {
|
|
1754
|
+
const status = operation.status;
|
|
1755
|
+
return {
|
|
1756
|
+
...operation,
|
|
1757
|
+
status: status === "IN_FLIGHT" || status === "REJECTED" ? "PENDING" : status === "RETRYABLE_ERROR" ? "FAILED_RETRYABLE" : operation.status ?? "PENDING",
|
|
1758
|
+
attempts: operation.attempts ?? 0
|
|
1759
|
+
};
|
|
1760
|
+
}
|
|
1761
|
+
function normalizeRejectedHistory(entry) {
|
|
1762
|
+
return {
|
|
1763
|
+
...entry,
|
|
1764
|
+
operation: normalizeOperation(entry.operation),
|
|
1765
|
+
reason: errorValue(entry.reason, "REJECTED_PERMANENT"),
|
|
1766
|
+
errorCode: entry.errorCode || entry.reason.code,
|
|
1767
|
+
rejectedAt: entry.rejectedAt || (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
1768
|
+
attempts: entry.attempts ?? entry.operation.attempts ?? 0
|
|
1769
|
+
};
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
// src/client/client.ts
|
|
1773
|
+
function isStorage(value) {
|
|
1774
|
+
return "load" in value && "save" in value && "remove" in value;
|
|
1775
|
+
}
|
|
1776
|
+
function id(prefix) {
|
|
1777
|
+
return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`;
|
|
1778
|
+
}
|
|
1779
|
+
function proof(value) {
|
|
1780
|
+
if (typeof value === "string") return value;
|
|
1781
|
+
if (typeof value === "object" && value !== null) {
|
|
1782
|
+
const item = value;
|
|
1783
|
+
for (const key of ["token", "code", "passcode", "value", "tagId"])
|
|
1784
|
+
if (typeof item[key] === "string") return item[key];
|
|
1712
1785
|
}
|
|
1713
|
-
|
|
1714
|
-
|
|
1786
|
+
return "";
|
|
1787
|
+
}
|
|
1788
|
+
function matches(condition2, value) {
|
|
1789
|
+
if (condition2.type === "gps") {
|
|
1790
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1791
|
+
const item = value;
|
|
1792
|
+
const latitude = item.latitude;
|
|
1793
|
+
const longitude = item.longitude;
|
|
1794
|
+
if (typeof latitude !== "number" || typeof longitude !== "number") return false;
|
|
1795
|
+
const radians = (v) => v * Math.PI / 180;
|
|
1796
|
+
const dLat = radians(latitude - condition2.latitude);
|
|
1797
|
+
const dLon = radians(longitude - condition2.longitude);
|
|
1798
|
+
const h = Math.sin(dLat / 2) ** 2 + Math.cos(radians(condition2.latitude)) * Math.cos(radians(latitude)) * Math.sin(dLon / 2) ** 2;
|
|
1799
|
+
return 6371e3 * 2 * Math.asin(Math.sqrt(Math.min(1, h))) <= condition2.radiusMeters;
|
|
1715
1800
|
}
|
|
1716
|
-
|
|
1717
|
-
|
|
1801
|
+
return proof(value).trim() !== "";
|
|
1802
|
+
}
|
|
1803
|
+
function emptyState(config, userId, now) {
|
|
1804
|
+
return {
|
|
1805
|
+
rallyId: config.id,
|
|
1806
|
+
userId,
|
|
1807
|
+
records: [],
|
|
1808
|
+
rewards: reconcileRewardStates(config.rewards, [], 0, now),
|
|
1809
|
+
updatedAt: now
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
function errorMessage(error, fallback) {
|
|
1813
|
+
return error !== void 0 && "message" in error && typeof error.message === "string" ? error.message : fallback;
|
|
1814
|
+
}
|
|
1815
|
+
var StampRallyClient = class {
|
|
1816
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
1817
|
+
#eventListeners = /* @__PURE__ */ new Set();
|
|
1818
|
+
#syncEventListeners = /* @__PURE__ */ new Set();
|
|
1819
|
+
#storage;
|
|
1820
|
+
#options;
|
|
1821
|
+
#config;
|
|
1822
|
+
#offlineQueue;
|
|
1823
|
+
#userId;
|
|
1824
|
+
#anonymousSessionId;
|
|
1825
|
+
#state = null;
|
|
1826
|
+
#initialization = null;
|
|
1827
|
+
#queue = Promise.resolve();
|
|
1828
|
+
#syncRevision = 0;
|
|
1829
|
+
#syncMetrics = null;
|
|
1830
|
+
constructor(config, storageOrOptions = {}) {
|
|
1831
|
+
this.#config = config;
|
|
1832
|
+
this.#options = isStorage(storageOrOptions) ? { storage: storageOrOptions } : storageOrOptions;
|
|
1833
|
+
this.#storage = this.#options.storage ?? new InMemoryStorage();
|
|
1834
|
+
this.#anonymousSessionId = this.#options.anonymousSessionId ?? createAnonymousSessionId();
|
|
1835
|
+
this.#userId = this.#options.userId ?? this.#anonymousSessionId;
|
|
1836
|
+
this.#offlineQueue = this.#options.offlineQueue;
|
|
1837
|
+
this.#offlineQueue?.setSyncResultListener((event) => this.#handleOfflineSyncResult(event));
|
|
1838
|
+
this.#offlineQueue?.setChangeListener(() => {
|
|
1839
|
+
this.#syncRevision += 1;
|
|
1840
|
+
if (this.#state !== null) this.#emit(this.#state);
|
|
1841
|
+
});
|
|
1718
1842
|
}
|
|
1719
|
-
|
|
1720
|
-
return this.#
|
|
1843
|
+
getConfig() {
|
|
1844
|
+
return this.#config;
|
|
1721
1845
|
}
|
|
1722
|
-
|
|
1723
|
-
return this.#
|
|
1846
|
+
getState() {
|
|
1847
|
+
return this.#state;
|
|
1724
1848
|
}
|
|
1725
|
-
|
|
1849
|
+
getUserId() {
|
|
1726
1850
|
return this.#userId;
|
|
1727
1851
|
}
|
|
1728
|
-
|
|
1729
|
-
this.#
|
|
1852
|
+
getAnonymousSessionId() {
|
|
1853
|
+
return this.#anonymousSessionId;
|
|
1730
1854
|
}
|
|
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;
|
|
1855
|
+
get syncState() {
|
|
1856
|
+
return this.#offlineQueue?.syncState ?? "idle";
|
|
1748
1857
|
}
|
|
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();
|
|
1858
|
+
get pendingCount() {
|
|
1859
|
+
return this.#offlineQueue?.pendingCount ?? 0;
|
|
1758
1860
|
}
|
|
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();
|
|
1861
|
+
get rejectedHistory() {
|
|
1862
|
+
return this.#offlineQueue?.rejectedHistory ?? [];
|
|
1772
1863
|
}
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
throw new Error("OfflineQueue.switchUser requires a rally scope.");
|
|
1776
|
-
await this.setScope(this.#rallyId, newUserId);
|
|
1864
|
+
getSyncRevision() {
|
|
1865
|
+
return this.#syncRevision;
|
|
1777
1866
|
}
|
|
1778
|
-
|
|
1779
|
-
this.#
|
|
1867
|
+
get queueCapability() {
|
|
1868
|
+
return this.#offlineQueue?.queueCapability ?? "custom";
|
|
1780
1869
|
}
|
|
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();
|
|
1870
|
+
discardRejected(operationId) {
|
|
1871
|
+
return this.#offlineQueue?.discardRejected(operationId) ?? Promise.resolve(false);
|
|
1794
1872
|
}
|
|
1795
|
-
|
|
1796
|
-
return this
|
|
1873
|
+
retryRejected(operationId) {
|
|
1874
|
+
return this.#offlineQueue?.retryRejected(operationId) ?? Promise.resolve(false);
|
|
1797
1875
|
}
|
|
1798
|
-
|
|
1799
|
-
return this.
|
|
1876
|
+
dismissRejectedOperation(operationId) {
|
|
1877
|
+
return this.discardRejected(operationId);
|
|
1800
1878
|
}
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
this.#operations = [];
|
|
1804
|
-
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1805
|
-
this.#announceChange();
|
|
1879
|
+
retryOperation(operationId) {
|
|
1880
|
+
return this.retryRejected(operationId);
|
|
1806
1881
|
}
|
|
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;
|
|
1882
|
+
subscribe(listener) {
|
|
1883
|
+
this.#listeners.add(listener);
|
|
1884
|
+
return () => this.#listeners.delete(listener);
|
|
1817
1885
|
}
|
|
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;
|
|
1886
|
+
subscribeEvents(listener) {
|
|
1887
|
+
this.#eventListeners.add(listener);
|
|
1888
|
+
return () => this.#eventListeners.delete(listener);
|
|
1834
1889
|
}
|
|
1835
|
-
|
|
1836
|
-
|
|
1890
|
+
subscribeSyncEvents(listener) {
|
|
1891
|
+
this.#syncEventListeners.add(listener);
|
|
1892
|
+
return () => this.#syncEventListeners.delete(listener);
|
|
1837
1893
|
}
|
|
1838
|
-
|
|
1839
|
-
|
|
1894
|
+
subscribeSyncState(listener) {
|
|
1895
|
+
const wrapped = () => listener();
|
|
1896
|
+
this.#listeners.add(wrapped);
|
|
1897
|
+
return () => this.#listeners.delete(wrapped);
|
|
1840
1898
|
}
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
if (this.#rejectedHistory.length === 0) return;
|
|
1844
|
-
this.#rejectedHistory = [];
|
|
1845
|
-
await this.#saveRejectedHistory();
|
|
1846
|
-
this.#announceChange();
|
|
1899
|
+
init() {
|
|
1900
|
+
return this.initialize();
|
|
1847
1901
|
}
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
if (
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1902
|
+
initialize() {
|
|
1903
|
+
if (this.#state !== null) return Promise.resolve(this.#state);
|
|
1904
|
+
if (this.#initialization === null) {
|
|
1905
|
+
this.#initialization = (async () => {
|
|
1906
|
+
await this.#offlineQueue?.setScope(this.#config.id, this.#userId);
|
|
1907
|
+
return this.#storage.load(this.#config.id, this.#userId);
|
|
1908
|
+
})().then((state) => {
|
|
1909
|
+
const next = state === null ? emptyState(this.#config, this.#userId, this.#now()) : this.#reconcile(state);
|
|
1910
|
+
this.#state = next;
|
|
1911
|
+
this.#emit(next);
|
|
1912
|
+
return next;
|
|
1913
|
+
}).catch((error) => {
|
|
1914
|
+
this.#initialization = null;
|
|
1915
|
+
throw error;
|
|
1916
|
+
});
|
|
1917
|
+
}
|
|
1918
|
+
return this.#initialization;
|
|
1919
|
+
}
|
|
1920
|
+
switchUser(newUserId) {
|
|
1921
|
+
return this.#enqueue(async () => {
|
|
1922
|
+
const nextUserId = newUserId ?? this.#anonymousSessionId;
|
|
1923
|
+
if (this.#userId === nextUserId && this.#state !== null) return this.#state;
|
|
1924
|
+
this.#userId = nextUserId;
|
|
1925
|
+
this.#state = null;
|
|
1926
|
+
this.#initialization = null;
|
|
1927
|
+
await this.#offlineQueue?.switchUser(nextUserId);
|
|
1928
|
+
return this.initialize();
|
|
1855
1929
|
});
|
|
1856
|
-
return this.#syncPromise;
|
|
1857
1930
|
}
|
|
1858
|
-
async
|
|
1859
|
-
return this.
|
|
1931
|
+
async getUserState(rallyId, userId) {
|
|
1932
|
+
return this.#storage.load(rallyId, userId);
|
|
1860
1933
|
}
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
const acquired = await locks.request(
|
|
1867
|
-
`stamprally:${this.#storageKey()}:sync`,
|
|
1868
|
-
{ ifAvailable: true },
|
|
1869
|
-
async (lock) => {
|
|
1870
|
-
if (lock === null) {
|
|
1871
|
-
await this.#reloadFromStorage();
|
|
1872
|
-
this.#state = "idle";
|
|
1873
|
-
return false;
|
|
1874
|
-
}
|
|
1875
|
-
callbackStarted = true;
|
|
1876
|
-
await this.#runWithStorageLock(sender);
|
|
1877
|
-
return true;
|
|
1878
|
-
}
|
|
1879
|
-
);
|
|
1880
|
-
if (!acquired) return;
|
|
1881
|
-
return;
|
|
1882
|
-
} catch (error) {
|
|
1883
|
-
if (callbackStarted) throw error;
|
|
1934
|
+
clearUserState(userId = this.#userId) {
|
|
1935
|
+
return this.#enqueue(async () => {
|
|
1936
|
+
await this.#storage.remove(this.#config.id, userId);
|
|
1937
|
+
if (userId === this.#userId) {
|
|
1938
|
+
await this.#initializeFresh();
|
|
1884
1939
|
}
|
|
1885
|
-
}
|
|
1886
|
-
if (this.#lockStorage === null)
|
|
1887
|
-
this.#warnMemoryLock(
|
|
1888
|
-
"No cross-tab storage lock is available; offline sync is single-tab only."
|
|
1889
|
-
);
|
|
1890
|
-
await this.#runWithStorageLock(sender);
|
|
1940
|
+
});
|
|
1891
1941
|
}
|
|
1892
|
-
|
|
1893
|
-
this.#
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
} catch (cause) {
|
|
1911
|
-
response = {
|
|
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();
|
|
1951
|
-
}
|
|
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 }
|
|
1942
|
+
checkIn(spotId, proofData, options = {}) {
|
|
1943
|
+
return this.#enqueue(async () => {
|
|
1944
|
+
const current = await this.initialize();
|
|
1945
|
+
const spot2 = this.#config.spots.find((item) => item.id === spotId);
|
|
1946
|
+
if (spot2 === void 0)
|
|
1947
|
+
return this.#fail({ code: "SPOT_NOT_FOUND", spotId, message: "Spot was not found." });
|
|
1948
|
+
if (current.records.some((record2) => record2.stampId === spotId))
|
|
1949
|
+
return this.#fail({
|
|
1950
|
+
code: "STAMP_ALREADY_ACQUIRED",
|
|
1951
|
+
spotId,
|
|
1952
|
+
message: "Spot was already claimed."
|
|
1953
|
+
});
|
|
1954
|
+
const acquired = new Set(current.records.map((record2) => record2.stampId));
|
|
1955
|
+
if (spot2.prerequisites?.some((id2) => !acquired.has(id2)))
|
|
1956
|
+
return this.#fail({
|
|
1957
|
+
code: "PREREQUISITES_NOT_MET",
|
|
1958
|
+
spotId,
|
|
1959
|
+
message: "Prerequisite spots are not complete."
|
|
1961
1960
|
});
|
|
1961
|
+
for (const condition2 of spot2.conditions) {
|
|
1962
|
+
if (condition2.type === "custom") {
|
|
1963
|
+
const validator = this.#options.customValidators?.[condition2.validatorName] ?? this.#options.customValidator;
|
|
1964
|
+
if (validator === void 0)
|
|
1965
|
+
return this.#fail({
|
|
1966
|
+
code: "CUSTOM_VALIDATION_FAILED",
|
|
1967
|
+
spotId,
|
|
1968
|
+
message: "No custom validator is registered."
|
|
1969
|
+
});
|
|
1970
|
+
const context = {
|
|
1971
|
+
rallyId: this.#config.id,
|
|
1972
|
+
spotId,
|
|
1973
|
+
proofData,
|
|
1974
|
+
condition: { type: "custom", validatorName: condition2.validatorName },
|
|
1975
|
+
userState: current
|
|
1976
|
+
};
|
|
1977
|
+
const result = typeof validator === "function" ? await validator(context) : await validator.validate(context);
|
|
1978
|
+
if (result === false || typeof result === "object" && !result.valid)
|
|
1979
|
+
return this.#fail({
|
|
1980
|
+
code: "CUSTOM_VALIDATION_FAILED",
|
|
1981
|
+
spotId,
|
|
1982
|
+
message: typeof result === "object" && result.message !== void 0 ? result.message : "Custom validation failed."
|
|
1983
|
+
});
|
|
1984
|
+
} else if (!matches(condition2, proofData))
|
|
1985
|
+
return this.#fail({ code: "INVALID_PROOF", spotId, message: "Verification failed." });
|
|
1962
1986
|
}
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1987
|
+
const now = options.now ?? this.#now();
|
|
1988
|
+
const request = {
|
|
1989
|
+
rallyId: this.#config.id,
|
|
1990
|
+
userId: this.#userId,
|
|
1991
|
+
spotId,
|
|
1992
|
+
proofData,
|
|
1993
|
+
idempotencyKey: options.idempotencyKey ?? id("check-in"),
|
|
1994
|
+
now,
|
|
1995
|
+
state: current,
|
|
1996
|
+
...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
|
|
1997
|
+
};
|
|
1998
|
+
const remote = this.#options.syncAdapter?.checkIn;
|
|
1999
|
+
if (options.sync !== false && remote !== void 0) {
|
|
2000
|
+
try {
|
|
2001
|
+
const result = await remote(request);
|
|
2002
|
+
return result.ok ? this.#commitCheckIn(result) : this.#fail(result.error);
|
|
2003
|
+
} catch (error) {
|
|
2004
|
+
if (this.#offlineQueue === void 0) throw error;
|
|
2005
|
+
await this.#offlineQueue.enqueueCheckIn(request);
|
|
2006
|
+
const record2 = { stampId: spotId, acquiredAt: now };
|
|
2007
|
+
const next2 = this.#reconcile({
|
|
2008
|
+
...current,
|
|
2009
|
+
records: [...current.records, record2],
|
|
2010
|
+
updatedAt: now
|
|
2011
|
+
});
|
|
2012
|
+
await this.#storage.save(next2);
|
|
2013
|
+
return this.#commitCheckIn({ ok: true, value: { state: next2, record: record2 } });
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
const record = { stampId: spotId, acquiredAt: now };
|
|
2017
|
+
const next = this.#reconcile({
|
|
2018
|
+
...current,
|
|
2019
|
+
records: [...current.records, record],
|
|
2020
|
+
updatedAt: now
|
|
2021
|
+
});
|
|
2022
|
+
await this.#storage.save(next);
|
|
2023
|
+
return this.#commitCheckIn({ ok: true, value: { state: next, record } });
|
|
2024
|
+
});
|
|
1978
2025
|
}
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
2026
|
+
claimReward(rewardId, options = {}) {
|
|
2027
|
+
return this.#enqueue(async () => {
|
|
2028
|
+
const current = await this.initialize();
|
|
2029
|
+
const configured = this.#config.rewards.find((item) => item.id === rewardId);
|
|
2030
|
+
if (configured === void 0)
|
|
2031
|
+
return this.#fail({ code: "REWARD_NOT_FOUND", rewardId, message: "Reward was not found." });
|
|
2032
|
+
const now = options.now ?? this.#now();
|
|
2033
|
+
const state = current.rewards.find((item) => item.rewardId === rewardId) ?? {
|
|
2034
|
+
rewardId,
|
|
2035
|
+
status: "LOCKED"
|
|
2036
|
+
};
|
|
2037
|
+
const local = consumeReward({
|
|
2038
|
+
reward: configured,
|
|
2039
|
+
currentState: state,
|
|
2040
|
+
now,
|
|
2041
|
+
...options.staffPasscode === void 0 ? {} : { inputPasscode: options.staffPasscode },
|
|
2042
|
+
...options.staffId === void 0 ? {} : { staffId: options.staffId }
|
|
2043
|
+
});
|
|
2044
|
+
if (!local.ok) return this.#fail(local.error);
|
|
2045
|
+
const request = {
|
|
2046
|
+
rallyId: this.#config.id,
|
|
2047
|
+
userId: this.#userId,
|
|
2048
|
+
rewardId,
|
|
2049
|
+
idempotencyKey: options.idempotencyKey ?? id("claim"),
|
|
2050
|
+
now,
|
|
2051
|
+
options,
|
|
2052
|
+
state: current,
|
|
2053
|
+
...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
|
|
2054
|
+
};
|
|
2055
|
+
const remote = this.#options.syncAdapter?.claimReward;
|
|
2056
|
+
if (options.sync !== false && remote !== void 0) {
|
|
2057
|
+
try {
|
|
2058
|
+
const result = await remote(request);
|
|
2059
|
+
return result.ok ? this.#commitClaim(result) : this.#fail(result.error);
|
|
2060
|
+
} catch (error) {
|
|
2061
|
+
if (this.#offlineQueue === void 0) throw error;
|
|
2062
|
+
await this.#offlineQueue.enqueueClaimReward(request);
|
|
2063
|
+
const next2 = {
|
|
2064
|
+
...current,
|
|
2065
|
+
rewards: current.rewards.map(
|
|
2066
|
+
(item) => item.rewardId === rewardId ? local.value : item
|
|
2067
|
+
),
|
|
2068
|
+
updatedAt: now
|
|
2069
|
+
};
|
|
2070
|
+
await this.#storage.save(next2);
|
|
2071
|
+
return this.#commitClaim({ ok: true, value: { state: next2, reward: local.value } });
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
const next = {
|
|
2075
|
+
...current,
|
|
2076
|
+
rewards: current.rewards.map((item) => item.rewardId === rewardId ? local.value : item),
|
|
2077
|
+
updatedAt: now
|
|
1985
2078
|
};
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
2079
|
+
await this.#storage.save(next);
|
|
2080
|
+
return this.#commitClaim({ ok: true, value: { state: next, reward: local.value } });
|
|
2081
|
+
});
|
|
2082
|
+
}
|
|
2083
|
+
sync(adapter = this.#options.syncAdapter) {
|
|
2084
|
+
return this.#enqueue(async () => {
|
|
2085
|
+
this.#emitSyncEvent({ type: "SYNC_STARTED" });
|
|
2086
|
+
this.#syncMetrics = { processed: 0, failed: 0 };
|
|
1990
2087
|
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;
|
|
2088
|
+
const current = await this.initialize();
|
|
2089
|
+
if (this.#offlineQueue !== void 0 && adapter !== void 0) {
|
|
2090
|
+
await this.#offlineQueue.sync(async (operation) => {
|
|
2091
|
+
if (operation.kind === "checkIn") {
|
|
2092
|
+
if (adapter.checkIn === void 0)
|
|
2093
|
+
throw new Error("No check-in sync adapter is configured.");
|
|
2094
|
+
return adapter.checkIn(operation.request);
|
|
2005
2095
|
}
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2096
|
+
if (adapter.claimReward === void 0)
|
|
2097
|
+
throw new Error("No reward sync adapter is configured.");
|
|
2098
|
+
return adapter.claimReward(operation.request);
|
|
2099
|
+
});
|
|
2100
|
+
}
|
|
2101
|
+
if (adapter?.sync === void 0) {
|
|
2102
|
+
this.#emitEvent({ type: "sync", state: this.#state ?? current });
|
|
2103
|
+
return;
|
|
2104
|
+
}
|
|
2105
|
+
const localState = this.#state ?? current;
|
|
2106
|
+
const serverState = await adapter.sync({
|
|
2107
|
+
rallyId: this.#config.id,
|
|
2108
|
+
userId: this.#userId,
|
|
2109
|
+
state: localState,
|
|
2110
|
+
...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
|
|
2111
|
+
});
|
|
2112
|
+
const resolved = resolveRallyStateConflict(serverState, localState, {
|
|
2113
|
+
policy: this.#options.conflictResolutionPolicy ?? this.#options.conflictPolicy ?? "authoritative_replay"
|
|
2114
|
+
});
|
|
2115
|
+
const next = this.#reconcile(resolved);
|
|
2116
|
+
await this.#storage.save(next);
|
|
2117
|
+
this.#state = next;
|
|
2118
|
+
this.#emit(next);
|
|
2119
|
+
this.#emitEvent({ type: "sync", state: next });
|
|
2120
|
+
} finally {
|
|
2121
|
+
const metrics = this.#syncMetrics ?? { processed: 0, failed: 0 };
|
|
2122
|
+
this.#syncMetrics = null;
|
|
2123
|
+
this.#emitSyncEvent({
|
|
2124
|
+
type: "SYNC_COMPLETED",
|
|
2125
|
+
totalProcessed: metrics.processed,
|
|
2126
|
+
failedCount: metrics.failed
|
|
2009
2127
|
});
|
|
2010
|
-
} catch {
|
|
2011
|
-
this.#channel = null;
|
|
2012
2128
|
}
|
|
2013
|
-
}
|
|
2129
|
+
});
|
|
2014
2130
|
}
|
|
2015
|
-
|
|
2016
|
-
this
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2131
|
+
retrySync() {
|
|
2132
|
+
return this.sync();
|
|
2133
|
+
}
|
|
2134
|
+
reset() {
|
|
2135
|
+
return this.#enqueue(async () => {
|
|
2136
|
+
await this.#storage.remove(this.#config.id, this.#userId);
|
|
2137
|
+
return this.#initializeFresh();
|
|
2020
2138
|
});
|
|
2021
2139
|
}
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
this.#
|
|
2029
|
-
|
|
2030
|
-
|
|
2140
|
+
restore(state) {
|
|
2141
|
+
return this.#enqueue(async () => {
|
|
2142
|
+
if (state.rallyId !== this.#config.id || state.userId !== this.#userId)
|
|
2143
|
+
throw new Error("State belongs to another rally or user.");
|
|
2144
|
+
const next = this.#reconcile(state);
|
|
2145
|
+
await this.#storage.save(next);
|
|
2146
|
+
this.#state = next;
|
|
2147
|
+
this.#initialization = Promise.resolve(next);
|
|
2148
|
+
this.#emit(next);
|
|
2149
|
+
return next;
|
|
2150
|
+
});
|
|
2031
2151
|
}
|
|
2032
|
-
#
|
|
2033
|
-
|
|
2152
|
+
#enqueue(operation) {
|
|
2153
|
+
const next = this.#queue.then(operation, operation);
|
|
2154
|
+
this.#queue = next.then(
|
|
2155
|
+
() => void 0,
|
|
2156
|
+
() => void 0
|
|
2157
|
+
);
|
|
2158
|
+
return next;
|
|
2034
2159
|
}
|
|
2035
|
-
#
|
|
2036
|
-
const
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
const observed = this.#observedLocks.get(key);
|
|
2042
|
-
if (observed !== void 0 && observed.expiresAt > now && observed.owner !== this.#instanceId)
|
|
2043
|
-
return false;
|
|
2044
|
-
if (this.#lockStorage !== null) {
|
|
2045
|
-
try {
|
|
2046
|
-
const existing = this.#lockStorage.getItem(key);
|
|
2047
|
-
if (existing !== null) {
|
|
2048
|
-
const parsed = JSON.parse(existing);
|
|
2049
|
-
if (typeof parsed === "object" && parsed !== null && typeof parsed.expiresAt === "number" && parsed.expiresAt > now && parsed.owner !== this.#instanceId)
|
|
2050
|
-
return false;
|
|
2051
|
-
}
|
|
2052
|
-
this.#lockStorage.setItem(
|
|
2053
|
-
key,
|
|
2054
|
-
JSON.stringify({ owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS })
|
|
2055
|
-
);
|
|
2056
|
-
this.#channel?.postMessage({
|
|
2057
|
-
type: "lock",
|
|
2058
|
-
lockKey: key,
|
|
2059
|
-
owner: this.#instanceId,
|
|
2060
|
-
expiresAt: now + SYNC_LOCK_TTL_MS
|
|
2061
|
-
});
|
|
2062
|
-
} catch {
|
|
2063
|
-
this.#warnMemoryLock("Persistent lock access failed; offline sync is single-tab only.");
|
|
2064
|
-
}
|
|
2065
|
-
}
|
|
2066
|
-
syncLocks.set(key, { owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS });
|
|
2067
|
-
return true;
|
|
2160
|
+
#initializeFresh() {
|
|
2161
|
+
const next = emptyState(this.#config, this.#userId, this.#now());
|
|
2162
|
+
this.#state = next;
|
|
2163
|
+
this.#initialization = Promise.resolve(next);
|
|
2164
|
+
this.#emit(next);
|
|
2165
|
+
return Promise.resolve(next);
|
|
2068
2166
|
}
|
|
2069
|
-
#
|
|
2070
|
-
const
|
|
2071
|
-
const
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2167
|
+
#reconcile(state) {
|
|
2168
|
+
const ids = new Set(this.#config.spots.map((spot2) => spot2.id));
|
|
2169
|
+
const records = state.records.filter(
|
|
2170
|
+
(record, index, all) => ids.has(record.stampId) && all.findIndex((candidate) => candidate.stampId === record.stampId) === index
|
|
2171
|
+
);
|
|
2172
|
+
return {
|
|
2173
|
+
...cloneState(state),
|
|
2174
|
+
userId: this.#userId,
|
|
2175
|
+
records,
|
|
2176
|
+
rewards: reconcileRewardStates(
|
|
2177
|
+
this.#config.rewards,
|
|
2178
|
+
state.rewards,
|
|
2179
|
+
records.length,
|
|
2180
|
+
state.updatedAt
|
|
2181
|
+
)
|
|
2182
|
+
};
|
|
2183
|
+
}
|
|
2184
|
+
async #handleOfflineSyncResult(event) {
|
|
2185
|
+
if (event.status === "ACCEPTED") {
|
|
2186
|
+
if (this.#syncMetrics !== null) this.#syncMetrics.processed += 1;
|
|
2187
|
+
if (event.state !== void 0) {
|
|
2188
|
+
const next = this.#reconcile(event.state);
|
|
2189
|
+
await this.#storage.save(next);
|
|
2190
|
+
this.#state = next;
|
|
2191
|
+
this.#emit(next);
|
|
2192
|
+
}
|
|
2193
|
+
this.#emitSyncEvent({
|
|
2194
|
+
type: "OPERATION_ACCEPTED",
|
|
2195
|
+
operationId: this.#operationId(event.operation),
|
|
2196
|
+
resourceId: this.#resourceId(event.operation)
|
|
2197
|
+
});
|
|
2198
|
+
} else if (event.status === "REJECTED_PERMANENT") {
|
|
2199
|
+
const error = event.error ?? {
|
|
2200
|
+
code: "REJECTED_PERMANENT",
|
|
2201
|
+
message: "Offline operation was rejected."
|
|
2202
|
+
};
|
|
2203
|
+
if (this.#syncMetrics !== null) {
|
|
2204
|
+
this.#syncMetrics.processed += 1;
|
|
2205
|
+
this.#syncMetrics.failed += 1;
|
|
2080
2206
|
}
|
|
2207
|
+
const base = event.state ?? this.#state ?? event.operation.request.state;
|
|
2208
|
+
const next = this.#reconcile(rollbackOptimisticOperation(base, event.operation));
|
|
2209
|
+
await this.#storage.save(next);
|
|
2210
|
+
this.#state = next;
|
|
2211
|
+
this.#emit(next);
|
|
2212
|
+
this.#emitSyncEvent({
|
|
2213
|
+
type: "OPERATION_ROLLED_BACK",
|
|
2214
|
+
operationId: this.#operationId(event.operation),
|
|
2215
|
+
resourceId: this.#resourceId(event.operation),
|
|
2216
|
+
reason: errorMessage(error, "Offline operation was rejected."),
|
|
2217
|
+
errorCode: error.code
|
|
2218
|
+
});
|
|
2219
|
+
} else if (event.status === "RETRYABLE_ERROR") {
|
|
2220
|
+
if (this.#syncMetrics !== null) this.#syncMetrics.failed += 1;
|
|
2221
|
+
this.#emitSyncEvent({
|
|
2222
|
+
type: "OPERATION_RETRYABLE_ERROR",
|
|
2223
|
+
operationId: this.#operationId(event.operation),
|
|
2224
|
+
error: errorMessage(event.error, "Retryable sync error.")
|
|
2225
|
+
});
|
|
2226
|
+
}
|
|
2227
|
+
if (event.state !== void 0 && event.status === void 0) {
|
|
2228
|
+
const next = this.#reconcile(event.state);
|
|
2229
|
+
await this.#storage.save(next);
|
|
2230
|
+
this.#state = next;
|
|
2231
|
+
this.#emit(next);
|
|
2081
2232
|
}
|
|
2233
|
+
if (event.error !== void 0) this.#emitEvent({ type: "error", error: event.error });
|
|
2234
|
+
else if (event.result !== void 0 && "ok" in event.result && !event.result.ok)
|
|
2235
|
+
this.#emitEvent({ type: "error", error: event.result.error });
|
|
2082
2236
|
}
|
|
2083
|
-
#
|
|
2084
|
-
|
|
2085
|
-
return `stamprally:queue:${this.#rallyId ?? "unscoped"}:${this.#userId ?? "anonymous"}`;
|
|
2237
|
+
#now() {
|
|
2238
|
+
return this.#options.clock?.() ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
2086
2239
|
}
|
|
2087
|
-
#
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2240
|
+
#fail(error) {
|
|
2241
|
+
this.#emitEvent({ type: "error", error });
|
|
2242
|
+
return { ok: false, error };
|
|
2243
|
+
}
|
|
2244
|
+
#commitCheckIn(result) {
|
|
2245
|
+
if (result.ok) {
|
|
2246
|
+
this.#state = result.value.state;
|
|
2247
|
+
this.#emit(this.#state);
|
|
2095
2248
|
}
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2249
|
+
this.#emitEvent({ type: "checkIn", result });
|
|
2250
|
+
return result;
|
|
2251
|
+
}
|
|
2252
|
+
#commitClaim(result) {
|
|
2253
|
+
if (result.ok) {
|
|
2254
|
+
this.#state = result.value.state;
|
|
2255
|
+
this.#emit(this.#state);
|
|
2099
2256
|
}
|
|
2100
|
-
|
|
2257
|
+
this.#emitEvent({ type: "rewardClaimed", result });
|
|
2258
|
+
return result;
|
|
2101
2259
|
}
|
|
2102
|
-
|
|
2103
|
-
const
|
|
2104
|
-
const policy = (typeof configured === "function" ? await configured({ operation, localState, serverState }) : configured) ?? this.#conflictPolicy;
|
|
2105
|
-
return resolveRallyStateConflict(serverState, localState, { policy });
|
|
2260
|
+
#emit(state) {
|
|
2261
|
+
for (const listener of this.#listeners) listener(state);
|
|
2106
2262
|
}
|
|
2107
|
-
|
|
2108
|
-
|
|
2263
|
+
#emitEvent(event) {
|
|
2264
|
+
for (const listener of this.#eventListeners) listener(event);
|
|
2109
2265
|
}
|
|
2110
|
-
#
|
|
2111
|
-
|
|
2112
|
-
this.#
|
|
2113
|
-
|
|
2266
|
+
#emitSyncEvent(event) {
|
|
2267
|
+
for (const listener of this.#syncEventListeners) listener(event);
|
|
2268
|
+
this.#emitEvent({ type: "syncLifecycle", event });
|
|
2269
|
+
}
|
|
2270
|
+
#operationId(operation) {
|
|
2271
|
+
const identity = operation.request.userId ?? operation.request.anonymousSessionId ?? "anonymous";
|
|
2272
|
+
return `${operation.kind}:${operation.request.rallyId}:${identity}:${operation.request.idempotencyKey}`;
|
|
2273
|
+
}
|
|
2274
|
+
#resourceId(operation) {
|
|
2275
|
+
return operation.kind === "checkIn" ? operation.request.spotId : operation.request.rewardId;
|
|
2114
2276
|
}
|
|
2115
2277
|
};
|
|
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
2278
|
|
|
2135
2279
|
// src/crypto/token.ts
|
|
2136
2280
|
var encoder = new TextEncoder();
|
|
@@ -2624,6 +2768,18 @@ function finiteNumber(value, key, path, errors, minimum) {
|
|
|
2624
2768
|
);
|
|
2625
2769
|
}
|
|
2626
2770
|
}
|
|
2771
|
+
function nonNegativeInteger(value, key, path, errors) {
|
|
2772
|
+
const item = value[key];
|
|
2773
|
+
if (typeof item !== "number" || !Number.isFinite(item)) {
|
|
2774
|
+
add(errors, `${path}.${key}`, "Expected a finite number.", "invalid_number");
|
|
2775
|
+
return;
|
|
2776
|
+
}
|
|
2777
|
+
if (!Number.isInteger(item)) {
|
|
2778
|
+
add(errors, `${path}.${key}`, "Expected a non-negative integer.", "invalid_integer");
|
|
2779
|
+
return;
|
|
2780
|
+
}
|
|
2781
|
+
if (item < 0) add(errors, `${path}.${key}`, "Expected a non-negative integer.", "out_of_range");
|
|
2782
|
+
}
|
|
2627
2783
|
function localizedText(value, path, errors) {
|
|
2628
2784
|
if (typeof value === "string") return;
|
|
2629
2785
|
if (!isRecord3(value)) {
|
|
@@ -2818,9 +2974,7 @@ function reward(value, path, errors, isPublic) {
|
|
|
2818
2974
|
finiteNumber(value, "requiredStampCount", path, errors, 0);
|
|
2819
2975
|
for (const key of ["stockLimit", "userClaimLimit"]) {
|
|
2820
2976
|
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");
|
|
2977
|
+
nonNegativeInteger(value, key, path, errors);
|
|
2824
2978
|
}
|
|
2825
2979
|
}
|
|
2826
2980
|
optionalString(value, "validUntil", path, errors);
|
|
@@ -2866,8 +3020,14 @@ function validate(value, isPublic) {
|
|
|
2866
3020
|
});
|
|
2867
3021
|
if (!isPublic) {
|
|
2868
3022
|
optionalString(value, "staffPasscode", "$", errors);
|
|
2869
|
-
if (hasOwn(value, "inventory") && value.inventory !== void 0
|
|
2870
|
-
|
|
3023
|
+
if (hasOwn(value, "inventory") && value.inventory !== void 0) {
|
|
3024
|
+
if (!isRecord3(value.inventory))
|
|
3025
|
+
add(errors, "$.inventory", "Expected an object.", "invalid_type");
|
|
3026
|
+
else {
|
|
3027
|
+
for (const [key, item] of Object.entries(value.inventory))
|
|
3028
|
+
if (item !== void 0) nonNegativeInteger(value.inventory, key, "$.inventory", errors);
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
2871
3031
|
if (hasOwn(value, "inventoryMode") && value.inventoryMode !== void 0 && value.inventoryMode !== "shared" && value.inventoryMode !== "per_reward")
|
|
2872
3032
|
add(errors, "$.inventoryMode", "Expected shared or per_reward.", "invalid_enum");
|
|
2873
3033
|
if (hasOwn(value, "serverMetadata") && value.serverMetadata !== void 0 && !isRecord3(value.serverMetadata))
|
|
@@ -3156,6 +3316,7 @@ exports.readQrContext = readQrContext;
|
|
|
3156
3316
|
exports.reconcileRewardStates = reconcileRewardStates;
|
|
3157
3317
|
exports.resolveLocalizedText = resolveLocalizedText;
|
|
3158
3318
|
exports.resolveRallyStateConflict = resolveRallyStateConflict;
|
|
3319
|
+
exports.rollbackOptimisticOperation = rollbackOptimisticOperation;
|
|
3159
3320
|
exports.safeParseAdminConfig = safeParseAdminConfig;
|
|
3160
3321
|
exports.safeParsePublicConfig = safeParsePublicConfig;
|
|
3161
3322
|
exports.sanitizeAdminConfig = sanitizeAdminConfig;
|