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