@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.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,587 +1064,291 @@ 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));
|
|
1099
|
+
var MemoryQueueStorage = class {
|
|
1100
|
+
#values = /* @__PURE__ */ new Map();
|
|
1101
|
+
#rejected = /* @__PURE__ */ new Map();
|
|
1102
|
+
async load(key) {
|
|
1103
|
+
return this.#values.get(key) ?? [];
|
|
1121
1104
|
}
|
|
1122
|
-
|
|
1123
|
-
|
|
1105
|
+
async save(key, operations) {
|
|
1106
|
+
this.#values.set(key, structuredClone(operations));
|
|
1124
1107
|
}
|
|
1125
|
-
|
|
1126
|
-
return this.#
|
|
1108
|
+
async loadRejectedHistory(key) {
|
|
1109
|
+
return this.#rejected.get(key) ?? [];
|
|
1127
1110
|
}
|
|
1128
|
-
|
|
1129
|
-
|
|
1111
|
+
async saveRejectedHistory(key, history) {
|
|
1112
|
+
this.#rejected.set(key, structuredClone(history));
|
|
1130
1113
|
}
|
|
1131
|
-
|
|
1132
|
-
|
|
1114
|
+
};
|
|
1115
|
+
var LocalStorageQueueStorage = class {
|
|
1116
|
+
constructor(storage) {
|
|
1117
|
+
this.storage = storage;
|
|
1133
1118
|
}
|
|
1134
|
-
|
|
1135
|
-
|
|
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
|
+
}
|
|
1136
1129
|
}
|
|
1137
|
-
|
|
1138
|
-
|
|
1130
|
+
async save(key, operations) {
|
|
1131
|
+
this.storage.setItem(key, JSON.stringify(operations));
|
|
1139
1132
|
}
|
|
1140
|
-
|
|
1141
|
-
this
|
|
1142
|
-
|
|
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
|
+
}
|
|
1143
1142
|
}
|
|
1144
|
-
|
|
1145
|
-
this
|
|
1146
|
-
return () => this.#eventListeners.delete(listener);
|
|
1143
|
+
async saveRejectedHistory(key, history) {
|
|
1144
|
+
this.storage.setItem(`${key}:rejected-history`, JSON.stringify(history));
|
|
1147
1145
|
}
|
|
1148
|
-
|
|
1149
|
-
|
|
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";
|
|
1150
1154
|
}
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
const next = state === null ? emptyState(this.#config, this.#userId, this.#now()) : this.#reconcile(state);
|
|
1159
|
-
this.#state = next;
|
|
1160
|
-
this.#emit(next);
|
|
1161
|
-
return next;
|
|
1162
|
-
}).catch((error) => {
|
|
1163
|
-
this.#initialization = null;
|
|
1164
|
-
throw error;
|
|
1165
|
-
});
|
|
1166
|
-
}
|
|
1167
|
-
return this.#initialization;
|
|
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
|
+
});
|
|
1168
1162
|
}
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
return this.initialize();
|
|
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."));
|
|
1178
1171
|
});
|
|
1179
1172
|
}
|
|
1180
|
-
async
|
|
1181
|
-
|
|
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
|
+
});
|
|
1182
1182
|
}
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
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."));
|
|
1189
1191
|
});
|
|
1190
1192
|
}
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
const acquired = new Set(current.records.map((record2) => record2.stampId));
|
|
1204
|
-
if (spot2.prerequisites?.some((id2) => !acquired.has(id2)))
|
|
1205
|
-
return this.#fail({
|
|
1206
|
-
code: "PREREQUISITES_NOT_MET",
|
|
1207
|
-
spotId,
|
|
1208
|
-
message: "Prerequisite spots are not complete."
|
|
1209
|
-
});
|
|
1210
|
-
for (const condition2 of spot2.conditions) {
|
|
1211
|
-
if (condition2.type === "custom") {
|
|
1212
|
-
const validator = this.#options.customValidators?.[condition2.validatorName] ?? this.#options.customValidator;
|
|
1213
|
-
if (validator === void 0)
|
|
1214
|
-
return this.#fail({
|
|
1215
|
-
code: "CUSTOM_VALIDATION_FAILED",
|
|
1216
|
-
spotId,
|
|
1217
|
-
message: "No custom validator is registered."
|
|
1218
|
-
});
|
|
1219
|
-
const context = {
|
|
1220
|
-
rallyId: this.#config.id,
|
|
1221
|
-
spotId,
|
|
1222
|
-
proofData,
|
|
1223
|
-
condition: { type: "custom", validatorName: condition2.validatorName },
|
|
1224
|
-
userState: current
|
|
1225
|
-
};
|
|
1226
|
-
const result = typeof validator === "function" ? await validator(context) : await validator.validate(context);
|
|
1227
|
-
if (result === false || typeof result === "object" && !result.valid)
|
|
1228
|
-
return this.#fail({
|
|
1229
|
-
code: "CUSTOM_VALIDATION_FAILED",
|
|
1230
|
-
spotId,
|
|
1231
|
-
message: typeof result === "object" && result.message !== void 0 ? result.message : "Custom validation failed."
|
|
1232
|
-
});
|
|
1233
|
-
} else if (!matches(condition2, proofData))
|
|
1234
|
-
return this.#fail({ code: "INVALID_PROOF", spotId, message: "Verification failed." });
|
|
1235
|
-
}
|
|
1236
|
-
const now = options.now ?? this.#now();
|
|
1237
|
-
const request = {
|
|
1238
|
-
rallyId: this.#config.id,
|
|
1239
|
-
userId: this.#userId,
|
|
1240
|
-
spotId,
|
|
1241
|
-
proofData,
|
|
1242
|
-
idempotencyKey: options.idempotencyKey ?? id("check-in"),
|
|
1243
|
-
now,
|
|
1244
|
-
state: current,
|
|
1245
|
-
...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
|
|
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");
|
|
1246
1205
|
};
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
} catch (error) {
|
|
1253
|
-
if (this.#offlineQueue === void 0) throw error;
|
|
1254
|
-
await this.#offlineQueue.enqueueCheckIn(request);
|
|
1255
|
-
const record2 = { stampId: spotId, acquiredAt: now };
|
|
1256
|
-
const next2 = this.#reconcile({
|
|
1257
|
-
...current,
|
|
1258
|
-
records: [...current.records, record2],
|
|
1259
|
-
updatedAt: now
|
|
1260
|
-
});
|
|
1261
|
-
await this.#storage.save(next2);
|
|
1262
|
-
return this.#commitCheckIn({ ok: true, value: { state: next2, record: record2 } });
|
|
1263
|
-
}
|
|
1264
|
-
}
|
|
1265
|
-
const record = { stampId: spotId, acquiredAt: now };
|
|
1266
|
-
const next = this.#reconcile({
|
|
1267
|
-
...current,
|
|
1268
|
-
records: [...current.records, record],
|
|
1269
|
-
updatedAt: now
|
|
1270
|
-
});
|
|
1271
|
-
await this.#storage.save(next);
|
|
1272
|
-
return this.#commitCheckIn({ ok: true, value: { state: next, record } });
|
|
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;
|
|
1273
1211
|
});
|
|
1212
|
+
return this.#databasePromise;
|
|
1274
1213
|
}
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
const request = {
|
|
1295
|
-
rallyId: this.#config.id,
|
|
1296
|
-
userId: this.#userId,
|
|
1297
|
-
rewardId,
|
|
1298
|
-
idempotencyKey: options.idempotencyKey ?? id("claim"),
|
|
1299
|
-
now,
|
|
1300
|
-
options,
|
|
1301
|
-
state: current,
|
|
1302
|
-
...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
|
|
1303
|
-
};
|
|
1304
|
-
const remote = this.#options.syncAdapter?.claimReward;
|
|
1305
|
-
if (options.sync !== false && remote !== void 0) {
|
|
1306
|
-
try {
|
|
1307
|
-
const result = await remote(request);
|
|
1308
|
-
return result.ok ? this.#commitClaim(result) : this.#fail(result.error);
|
|
1309
|
-
} catch (error) {
|
|
1310
|
-
if (this.#offlineQueue === void 0) throw error;
|
|
1311
|
-
await this.#offlineQueue.enqueueClaimReward(request);
|
|
1312
|
-
const next2 = {
|
|
1313
|
-
...current,
|
|
1314
|
-
rewards: current.rewards.map(
|
|
1315
|
-
(item) => item.rewardId === rewardId ? local.value : item
|
|
1316
|
-
),
|
|
1317
|
-
updatedAt: now
|
|
1318
|
-
};
|
|
1319
|
-
await this.#storage.save(next2);
|
|
1320
|
-
return this.#commitClaim({ ok: true, value: { state: next2, reward: local.value } });
|
|
1321
|
-
}
|
|
1322
|
-
}
|
|
1323
|
-
const next = {
|
|
1324
|
-
...current,
|
|
1325
|
-
rewards: current.rewards.map((item) => item.rewardId === rewardId ? local.value : item),
|
|
1326
|
-
updatedAt: now
|
|
1214
|
+
};
|
|
1215
|
+
function availableLocalStorage() {
|
|
1216
|
+
try {
|
|
1217
|
+
const storage = globalThis.localStorage;
|
|
1218
|
+
return storage ?? null;
|
|
1219
|
+
} catch {
|
|
1220
|
+
return null;
|
|
1221
|
+
}
|
|
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"
|
|
1327
1233
|
};
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1234
|
+
const storage = availableLocalStorage();
|
|
1235
|
+
if (storage !== void 0 && storage !== null)
|
|
1236
|
+
return { storage: new LocalStorageQueueStorage(storage), capability: "localstorage" };
|
|
1237
|
+
} catch {
|
|
1331
1238
|
}
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
return;
|
|
1350
|
-
}
|
|
1351
|
-
const serverState = await adapter.sync({
|
|
1352
|
-
rallyId: this.#config.id,
|
|
1353
|
-
userId: this.#userId,
|
|
1354
|
-
state: this.#state ?? current,
|
|
1355
|
-
...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
|
|
1356
|
-
});
|
|
1357
|
-
const next = this.#reconcile(serverState);
|
|
1358
|
-
await this.#storage.save(next);
|
|
1359
|
-
this.#state = next;
|
|
1360
|
-
this.#emit(next);
|
|
1361
|
-
this.#emitEvent({ type: "sync", state: next });
|
|
1362
|
-
});
|
|
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 };
|
|
1363
1256
|
}
|
|
1364
|
-
|
|
1365
|
-
|
|
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();
|
|
1366
1325
|
}
|
|
1367
|
-
|
|
1368
|
-
return this.#
|
|
1369
|
-
await this.#storage.remove(this.#config.id, this.#userId);
|
|
1370
|
-
return this.#initializeFresh();
|
|
1371
|
-
});
|
|
1326
|
+
get syncState() {
|
|
1327
|
+
return this.#state;
|
|
1372
1328
|
}
|
|
1373
|
-
|
|
1374
|
-
return this.#
|
|
1375
|
-
if (state.rallyId !== this.#config.id || state.userId !== this.#userId)
|
|
1376
|
-
throw new Error("State belongs to another rally or user.");
|
|
1377
|
-
const next = this.#reconcile(state);
|
|
1378
|
-
await this.#storage.save(next);
|
|
1379
|
-
this.#state = next;
|
|
1380
|
-
this.#initialization = Promise.resolve(next);
|
|
1381
|
-
this.#emit(next);
|
|
1382
|
-
return next;
|
|
1383
|
-
});
|
|
1329
|
+
get pendingCount() {
|
|
1330
|
+
return this.#operations.length;
|
|
1384
1331
|
}
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
this.#queue = next.then(
|
|
1388
|
-
() => void 0,
|
|
1389
|
-
() => void 0
|
|
1390
|
-
);
|
|
1391
|
-
return next;
|
|
1332
|
+
get queueCapability() {
|
|
1333
|
+
return this.#queueCapability;
|
|
1392
1334
|
}
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
this.#state = next;
|
|
1396
|
-
this.#initialization = Promise.resolve(next);
|
|
1397
|
-
this.#emit(next);
|
|
1398
|
-
return Promise.resolve(next);
|
|
1335
|
+
get rejectedHistory() {
|
|
1336
|
+
return this.#rejectedHistory;
|
|
1399
1337
|
}
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
const records = state.records.filter(
|
|
1403
|
-
(record, index, all) => ids.has(record.stampId) && all.findIndex((candidate) => candidate.stampId === record.stampId) === index
|
|
1404
|
-
);
|
|
1405
|
-
return {
|
|
1406
|
-
...cloneState(state),
|
|
1407
|
-
userId: this.#userId,
|
|
1408
|
-
records,
|
|
1409
|
-
rewards: reconcileRewardStates(
|
|
1410
|
-
this.#config.rewards,
|
|
1411
|
-
state.rewards,
|
|
1412
|
-
records.length,
|
|
1413
|
-
state.updatedAt
|
|
1414
|
-
)
|
|
1415
|
-
};
|
|
1338
|
+
get error() {
|
|
1339
|
+
return this.#error;
|
|
1416
1340
|
}
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
const next = this.#reconcile(event.state);
|
|
1420
|
-
await this.#storage.save(next);
|
|
1421
|
-
this.#state = next;
|
|
1422
|
-
this.#emit(next);
|
|
1423
|
-
}
|
|
1424
|
-
if (event.error !== void 0) this.#emitEvent({ type: "error", error: event.error });
|
|
1425
|
-
else if (event.result !== void 0 && "ok" in event.result && !event.result.ok)
|
|
1426
|
-
this.#emitEvent({ type: "error", error: event.result.error });
|
|
1341
|
+
get operations() {
|
|
1342
|
+
return this.#operations;
|
|
1427
1343
|
}
|
|
1428
|
-
|
|
1429
|
-
return this.#
|
|
1344
|
+
get conflictPolicy() {
|
|
1345
|
+
return this.#conflictPolicy;
|
|
1430
1346
|
}
|
|
1431
|
-
|
|
1432
|
-
this.#
|
|
1433
|
-
return { ok: false, error };
|
|
1347
|
+
get storageKey() {
|
|
1348
|
+
return this.#storageKey();
|
|
1434
1349
|
}
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
this.#state = result.value.state;
|
|
1438
|
-
this.#emit(this.#state);
|
|
1439
|
-
}
|
|
1440
|
-
this.#emitEvent({ type: "checkIn", result });
|
|
1441
|
-
return result;
|
|
1442
|
-
}
|
|
1443
|
-
#commitClaim(result) {
|
|
1444
|
-
if (result.ok) {
|
|
1445
|
-
this.#state = result.value.state;
|
|
1446
|
-
this.#emit(this.#state);
|
|
1447
|
-
}
|
|
1448
|
-
this.#emitEvent({ type: "rewardClaimed", result });
|
|
1449
|
-
return result;
|
|
1450
|
-
}
|
|
1451
|
-
#emit(state) {
|
|
1452
|
-
for (const listener of this.#listeners) listener(state);
|
|
1453
|
-
}
|
|
1454
|
-
#emitEvent(event) {
|
|
1455
|
-
for (const listener of this.#eventListeners) listener(event);
|
|
1456
|
-
}
|
|
1457
|
-
};
|
|
1458
|
-
|
|
1459
|
-
// src/client/offlineQueue.ts
|
|
1460
|
-
var MemoryQueueStorage = class {
|
|
1461
|
-
#values = /* @__PURE__ */ new Map();
|
|
1462
|
-
async load(key) {
|
|
1463
|
-
return this.#values.get(key) ?? [];
|
|
1464
|
-
}
|
|
1465
|
-
async save(key, operations) {
|
|
1466
|
-
this.#values.set(key, structuredClone(operations));
|
|
1467
|
-
}
|
|
1468
|
-
};
|
|
1469
|
-
var LocalStorageQueueStorage = class {
|
|
1470
|
-
constructor(storage) {
|
|
1471
|
-
this.storage = storage;
|
|
1472
|
-
}
|
|
1473
|
-
storage;
|
|
1474
|
-
async load(key) {
|
|
1475
|
-
const value = this.storage.getItem(key);
|
|
1476
|
-
if (value === null) return [];
|
|
1477
|
-
try {
|
|
1478
|
-
const parsed = JSON.parse(value);
|
|
1479
|
-
return Array.isArray(parsed) ? parsed : [];
|
|
1480
|
-
} catch {
|
|
1481
|
-
return [];
|
|
1482
|
-
}
|
|
1483
|
-
}
|
|
1484
|
-
async save(key, operations) {
|
|
1485
|
-
this.storage.setItem(key, JSON.stringify(operations));
|
|
1486
|
-
}
|
|
1487
|
-
};
|
|
1488
|
-
var IndexedDBOfflineQueueStorage = class {
|
|
1489
|
-
#providedFactory;
|
|
1490
|
-
#databaseName;
|
|
1491
|
-
#databasePromise = null;
|
|
1492
|
-
constructor(options = {}) {
|
|
1493
|
-
this.#providedFactory = options.indexedDB;
|
|
1494
|
-
this.#databaseName = options.databaseName ?? "stamprally-offline-queue";
|
|
1495
|
-
}
|
|
1496
|
-
async load(key) {
|
|
1497
|
-
const database = await this.#open();
|
|
1498
|
-
return new Promise((resolve, reject) => {
|
|
1499
|
-
const request = database.transaction("operations", "readonly").objectStore("operations").get(key);
|
|
1500
|
-
request.onsuccess = () => resolve(Array.isArray(request.result) ? request.result : []);
|
|
1501
|
-
request.onerror = () => reject(request.error ?? new Error("Failed to read offline queue."));
|
|
1502
|
-
});
|
|
1503
|
-
}
|
|
1504
|
-
async save(key, operations) {
|
|
1505
|
-
const database = await this.#open();
|
|
1506
|
-
return new Promise((resolve, reject) => {
|
|
1507
|
-
const transaction = database.transaction("operations", "readwrite");
|
|
1508
|
-
transaction.objectStore("operations").put(structuredClone(operations), key);
|
|
1509
|
-
transaction.oncomplete = () => resolve();
|
|
1510
|
-
transaction.onerror = () => reject(transaction.error ?? new Error("Failed to save offline queue."));
|
|
1511
|
-
transaction.onabort = () => reject(transaction.error ?? new Error("Offline queue write aborted."));
|
|
1512
|
-
});
|
|
1513
|
-
}
|
|
1514
|
-
#open() {
|
|
1515
|
-
if (this.#databasePromise !== null) return this.#databasePromise;
|
|
1516
|
-
let factory = this.#providedFactory;
|
|
1517
|
-
if (factory === void 0)
|
|
1518
|
-
factory = globalThis.indexedDB;
|
|
1519
|
-
if (factory === void 0 || factory === null)
|
|
1520
|
-
return Promise.reject(new Error("IndexedDB is unavailable in this environment."));
|
|
1521
|
-
this.#databasePromise = new Promise((resolve, reject) => {
|
|
1522
|
-
const request = factory.open(this.#databaseName, 1);
|
|
1523
|
-
request.onupgradeneeded = () => {
|
|
1524
|
-
if (!request.result.objectStoreNames.contains("operations"))
|
|
1525
|
-
request.result.createObjectStore("operations");
|
|
1526
|
-
};
|
|
1527
|
-
request.onsuccess = () => resolve(request.result);
|
|
1528
|
-
request.onerror = () => reject(request.error ?? new Error("Failed to open offline queue."));
|
|
1529
|
-
}).catch((error) => {
|
|
1530
|
-
this.#databasePromise = null;
|
|
1531
|
-
throw error;
|
|
1532
|
-
});
|
|
1533
|
-
return this.#databasePromise;
|
|
1534
|
-
}
|
|
1535
|
-
};
|
|
1536
|
-
function defaultStorage(databaseName) {
|
|
1537
|
-
try {
|
|
1538
|
-
const indexedDB = globalThis.indexedDB;
|
|
1539
|
-
if (indexedDB !== void 0)
|
|
1540
|
-
return new IndexedDBOfflineQueueStorage({
|
|
1541
|
-
indexedDB,
|
|
1542
|
-
...databaseName === void 0 ? {} : { databaseName }
|
|
1543
|
-
});
|
|
1544
|
-
const storage = globalThis.localStorage;
|
|
1545
|
-
if (storage !== void 0 && storage !== null) return new LocalStorageQueueStorage(storage);
|
|
1546
|
-
} catch {
|
|
1547
|
-
}
|
|
1548
|
-
return new MemoryQueueStorage();
|
|
1549
|
-
}
|
|
1550
|
-
function operationId(operation) {
|
|
1551
|
-
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}`;
|
|
1552
|
-
}
|
|
1553
|
-
function requestScope(operation) {
|
|
1554
|
-
return {
|
|
1555
|
-
rallyId: operation.request.rallyId,
|
|
1556
|
-
userId: operation.request.userId
|
|
1557
|
-
};
|
|
1558
|
-
}
|
|
1559
|
-
function errorValue(value, fallbackCode) {
|
|
1560
|
-
if (typeof value === "object" && value !== null) {
|
|
1561
|
-
const candidate = value;
|
|
1562
|
-
if (typeof candidate.code === "string" && typeof candidate.message === "string")
|
|
1563
|
-
return { ...candidate, code: candidate.code, message: candidate.message };
|
|
1564
|
-
}
|
|
1565
|
-
if (value instanceof Error) return { code: fallbackCode, message: value.message };
|
|
1566
|
-
if (typeof value === "string") return { code: fallbackCode, message: value };
|
|
1567
|
-
return { code: fallbackCode, message: "Offline operation was rejected." };
|
|
1568
|
-
}
|
|
1569
|
-
var syncLocks = /* @__PURE__ */ new Map();
|
|
1570
|
-
var SYNC_LOCK_TTL_MS = 3e4;
|
|
1571
|
-
var DEFAULT_RETRY_OPTIONS = {
|
|
1572
|
-
maxRetries: 0,
|
|
1573
|
-
initialIntervalMs: 250,
|
|
1574
|
-
backoffMultiplier: 2
|
|
1575
|
-
};
|
|
1576
|
-
function randomId() {
|
|
1577
|
-
return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
1578
|
-
}
|
|
1579
|
-
var OfflineQueue = class {
|
|
1580
|
-
#storage;
|
|
1581
|
-
#configuredKey;
|
|
1582
|
-
#rallyId;
|
|
1583
|
-
#userId;
|
|
1584
|
-
#conflictPolicy;
|
|
1585
|
-
#onSyncConflict;
|
|
1586
|
-
#operations = [];
|
|
1587
|
-
#loaded = false;
|
|
1588
|
-
#state = "idle";
|
|
1589
|
-
#error = null;
|
|
1590
|
-
#sender;
|
|
1591
|
-
#syncPromise = null;
|
|
1592
|
-
#syncResultListener;
|
|
1593
|
-
#synchronizeInstances;
|
|
1594
|
-
#retryOptions;
|
|
1595
|
-
#instanceId = randomId();
|
|
1596
|
-
#lockStorage;
|
|
1597
|
-
#storageListener;
|
|
1598
|
-
#channel = null;
|
|
1599
|
-
constructor(options = {}) {
|
|
1600
|
-
if (options.storage !== void 0) this.#storage = options.storage;
|
|
1601
|
-
else if (options.storageLike !== void 0 && options.storageLike !== null)
|
|
1602
|
-
this.#storage = new LocalStorageQueueStorage(options.storageLike);
|
|
1603
|
-
else this.#storage = defaultStorage(options.databaseName);
|
|
1604
|
-
this.#configuredKey = options.key;
|
|
1605
|
-
this.#rallyId = options.rallyId;
|
|
1606
|
-
this.#userId = options.userId ?? null;
|
|
1607
|
-
this.#conflictPolicy = options.conflictPolicy ?? "server_wins";
|
|
1608
|
-
this.#onSyncConflict = options.onSyncConflict;
|
|
1609
|
-
this.#synchronizeInstances = options.synchronizeInstances ?? true;
|
|
1610
|
-
const retryOptions = {
|
|
1611
|
-
...DEFAULT_RETRY_OPTIONS,
|
|
1612
|
-
...options.retryOptions ?? options.syncRetryOptions ?? options.retry ?? {}
|
|
1613
|
-
};
|
|
1614
|
-
this.#retryOptions = {
|
|
1615
|
-
maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
|
|
1616
|
-
initialIntervalMs: Math.max(0, retryOptions.initialIntervalMs),
|
|
1617
|
-
backoffMultiplier: Math.max(1, retryOptions.backoffMultiplier)
|
|
1618
|
-
};
|
|
1619
|
-
this.#lockStorage = options.storageLike ?? globalThis.localStorage ?? null;
|
|
1620
|
-
if (this.#synchronizeInstances) this.#subscribeToExternalChanges();
|
|
1621
|
-
}
|
|
1622
|
-
get syncState() {
|
|
1623
|
-
return this.#state;
|
|
1624
|
-
}
|
|
1625
|
-
get pendingCount() {
|
|
1626
|
-
return this.#operations.length;
|
|
1627
|
-
}
|
|
1628
|
-
get error() {
|
|
1629
|
-
return this.#error;
|
|
1630
|
-
}
|
|
1631
|
-
get operations() {
|
|
1632
|
-
return this.#operations;
|
|
1633
|
-
}
|
|
1634
|
-
get conflictPolicy() {
|
|
1635
|
-
return this.#conflictPolicy;
|
|
1636
|
-
}
|
|
1637
|
-
get storageKey() {
|
|
1638
|
-
return this.#storageKey();
|
|
1639
|
-
}
|
|
1640
|
-
get rallyId() {
|
|
1641
|
-
return this.#rallyId;
|
|
1350
|
+
get rallyId() {
|
|
1351
|
+
return this.#rallyId;
|
|
1642
1352
|
}
|
|
1643
1353
|
get userId() {
|
|
1644
1354
|
return this.#userId;
|
|
@@ -1646,9 +1356,25 @@ var OfflineQueue = class {
|
|
|
1646
1356
|
setSyncResultListener(listener) {
|
|
1647
1357
|
this.#syncResultListener = listener;
|
|
1648
1358
|
}
|
|
1359
|
+
setChangeListener(listener) {
|
|
1360
|
+
this.#changeListener = listener;
|
|
1361
|
+
}
|
|
1649
1362
|
async initialize() {
|
|
1650
1363
|
if (this.#loaded) return;
|
|
1651
|
-
|
|
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
|
+
);
|
|
1377
|
+
}
|
|
1652
1378
|
this.#loaded = true;
|
|
1653
1379
|
}
|
|
1654
1380
|
/** Releases browser listeners when the queue is no longer used. */
|
|
@@ -1691,8 +1417,8 @@ var OfflineQueue = class {
|
|
|
1691
1417
|
throw new Error("Offline operation belongs to another rally or user queue.");
|
|
1692
1418
|
}
|
|
1693
1419
|
await this.initialize();
|
|
1694
|
-
const id2 =
|
|
1695
|
-
if (this.#operations.some((item) =>
|
|
1420
|
+
const id2 = offlineOperationId(operation);
|
|
1421
|
+
if (this.#operations.some((item) => offlineOperationId(item) === id2)) return;
|
|
1696
1422
|
this.#operations = [...this.#operations, { ...operation, status: "PENDING", attempts: 0 }];
|
|
1697
1423
|
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1698
1424
|
this.#announceChange();
|
|
@@ -1709,6 +1435,47 @@ var OfflineQueue = class {
|
|
|
1709
1435
|
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1710
1436
|
this.#announceChange();
|
|
1711
1437
|
}
|
|
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;
|
|
1448
|
+
}
|
|
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;
|
|
1465
|
+
}
|
|
1466
|
+
async discardRejectedOperation(operationId) {
|
|
1467
|
+
return this.discardRejected(operationId);
|
|
1468
|
+
}
|
|
1469
|
+
async retryRejectedOperation(operationId) {
|
|
1470
|
+
return this.retryRejected(operationId);
|
|
1471
|
+
}
|
|
1472
|
+
async clearRejectedHistory() {
|
|
1473
|
+
await this.initialize();
|
|
1474
|
+
if (this.#rejectedHistory.length === 0) return;
|
|
1475
|
+
this.#rejectedHistory = [];
|
|
1476
|
+
await this.#saveRejectedHistory();
|
|
1477
|
+
this.#announceChange();
|
|
1478
|
+
}
|
|
1712
1479
|
async sync(sender = this.#sender) {
|
|
1713
1480
|
await this.initialize();
|
|
1714
1481
|
if (sender === void 0) throw new Error("OfflineQueue.sync requires a sender.");
|
|
@@ -1724,7 +1491,7 @@ var OfflineQueue = class {
|
|
|
1724
1491
|
}
|
|
1725
1492
|
async #run(sender) {
|
|
1726
1493
|
const locks = globalThis.navigator?.locks;
|
|
1727
|
-
if (locks !== void 0) {
|
|
1494
|
+
if (locks !== void 0 && typeof locks.request === "function") {
|
|
1728
1495
|
let callbackStarted = false;
|
|
1729
1496
|
try {
|
|
1730
1497
|
const acquired = await locks.request(
|
|
@@ -1734,6 +1501,7 @@ var OfflineQueue = class {
|
|
|
1734
1501
|
if (lock === null) {
|
|
1735
1502
|
await this.#reloadFromStorage();
|
|
1736
1503
|
this.#state = "idle";
|
|
1504
|
+
this.#changeListener?.();
|
|
1737
1505
|
return false;
|
|
1738
1506
|
}
|
|
1739
1507
|
callbackStarted = true;
|
|
@@ -1747,14 +1515,20 @@ var OfflineQueue = class {
|
|
|
1747
1515
|
if (callbackStarted) throw error;
|
|
1748
1516
|
}
|
|
1749
1517
|
}
|
|
1518
|
+
if (this.#lockStorage === null)
|
|
1519
|
+
this.#warnMemoryLock(
|
|
1520
|
+
"No cross-tab storage lock is available; offline sync is single-tab only."
|
|
1521
|
+
);
|
|
1750
1522
|
await this.#runWithStorageLock(sender);
|
|
1751
1523
|
}
|
|
1752
1524
|
async #runWithStorageLock(sender) {
|
|
1753
1525
|
this.#state = "syncing";
|
|
1754
1526
|
this.#error = null;
|
|
1527
|
+
this.#changeListener?.();
|
|
1755
1528
|
if (!this.#acquireSyncLock()) {
|
|
1756
1529
|
await this.#reloadFromStorage();
|
|
1757
1530
|
this.#state = "idle";
|
|
1531
|
+
this.#changeListener?.();
|
|
1758
1532
|
return;
|
|
1759
1533
|
}
|
|
1760
1534
|
try {
|
|
@@ -1777,151 +1551,730 @@ var OfflineQueue = class {
|
|
|
1777
1551
|
const error2 = errorValue(response.error ?? response.reason, "RETRYABLE_ERROR");
|
|
1778
1552
|
await this.#updateOperationStatus("PENDING", attempt + 1);
|
|
1779
1553
|
await this.#syncResultListener?.({ operation, status: response.status, error: error2 });
|
|
1780
|
-
if (attempt >= this.#retryOptions.maxRetries)
|
|
1554
|
+
if (attempt >= this.#retryOptions.maxRetries) {
|
|
1555
|
+
await this.#updateOperationStatus("FAILED_RETRYABLE", attempt + 1);
|
|
1556
|
+
throw new Error(error2.message);
|
|
1557
|
+
}
|
|
1781
1558
|
const interval = this.#retryOptions.initialIntervalMs * this.#retryOptions.backoffMultiplier ** attempt;
|
|
1782
1559
|
await new Promise((resolve) => setTimeout(resolve, Math.max(0, interval)));
|
|
1783
1560
|
attempt += 1;
|
|
1784
1561
|
}
|
|
1785
|
-
const result = response.result;
|
|
1786
|
-
const state = response.state ?? (result !== void 0 && "conflict" in result && result.conflict === true ?
|
|
1787
|
-
const error = response.status === "REJECTED_PERMANENT" ? errorValue(
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
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();
|
|
1614
|
+
}
|
|
1615
|
+
#subscribeToExternalChanges() {
|
|
1616
|
+
const windowLike = globalThis.window;
|
|
1617
|
+
if (windowLike !== void 0) {
|
|
1618
|
+
this.#storageListener = (event) => {
|
|
1619
|
+
if (event.key === this.#storageKey() || event.key === `${this.#storageKey()}:rejected-history`)
|
|
1620
|
+
void this.#reloadFromStorage();
|
|
1621
|
+
};
|
|
1622
|
+
windowLike.addEventListener("storage", this.#storageListener);
|
|
1623
|
+
}
|
|
1624
|
+
const Channel = globalThis.BroadcastChannel;
|
|
1625
|
+
if (Channel !== void 0) {
|
|
1626
|
+
try {
|
|
1627
|
+
this.#channel = new Channel("stamprally:queue-sync");
|
|
1628
|
+
this.#channel.addEventListener("message", (event) => {
|
|
1629
|
+
if (typeof event.data === "object" && event.data !== null) {
|
|
1630
|
+
const data = event.data;
|
|
1631
|
+
if (data.type === "lock" && data.lockKey === this.#lockKey()) {
|
|
1632
|
+
this.#observedLocks.set(data.lockKey, {
|
|
1633
|
+
owner: typeof data.owner === "string" ? data.owner : "unknown",
|
|
1634
|
+
expiresAt: typeof data.expiresAt === "number" ? data.expiresAt : 0
|
|
1635
|
+
});
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1638
|
+
if (data.type === "unlock" && data.lockKey === this.#lockKey()) {
|
|
1639
|
+
this.#observedLocks.delete(data.lockKey);
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
if (typeof event.data === "object" && event.data !== null && "key" in event.data && event.data.key === this.#storageKey())
|
|
1644
|
+
void this.#reloadFromStorage();
|
|
1645
|
+
});
|
|
1646
|
+
} catch {
|
|
1647
|
+
this.#channel = null;
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
#announceChange() {
|
|
1652
|
+
this.#changeListener?.();
|
|
1653
|
+
this.#channel?.postMessage({
|
|
1654
|
+
type: "change",
|
|
1655
|
+
key: this.#storageKey(),
|
|
1656
|
+
owner: this.#instanceId
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
async #reloadFromStorage() {
|
|
1660
|
+
if (this.#state === "syncing") return;
|
|
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
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
#lockKey() {
|
|
1670
|
+
return `${this.#storageKey()}:sync-lock`;
|
|
1671
|
+
}
|
|
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;
|
|
1705
|
+
}
|
|
1706
|
+
#releaseSyncLock() {
|
|
1707
|
+
const key = this.#lockKey();
|
|
1708
|
+
const current = syncLocks.get(key);
|
|
1709
|
+
if (current?.owner === this.#instanceId) syncLocks.delete(key);
|
|
1710
|
+
if (this.#lockStorage !== null) {
|
|
1711
|
+
try {
|
|
1712
|
+
const value = this.#lockStorage.getItem(key);
|
|
1713
|
+
if (value !== null && JSON.parse(value).owner === this.#instanceId)
|
|
1714
|
+
this.#lockStorage.removeItem?.(key);
|
|
1715
|
+
this.#channel?.postMessage({ type: "unlock", lockKey: key, owner: this.#instanceId });
|
|
1716
|
+
} catch {
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
#storageKey() {
|
|
1721
|
+
if (this.#configuredKey !== void 0) return this.#configuredKey;
|
|
1722
|
+
return `stamprally:queue:${this.#rallyId ?? "unscoped"}:${this.#userId ?? "anonymous"}`;
|
|
1723
|
+
}
|
|
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 };
|
|
1738
|
+
}
|
|
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 });
|
|
1743
|
+
}
|
|
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}`);
|
|
1751
|
+
}
|
|
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];
|
|
1785
|
+
}
|
|
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;
|
|
1800
|
+
}
|
|
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
|
+
});
|
|
1842
|
+
}
|
|
1843
|
+
getConfig() {
|
|
1844
|
+
return this.#config;
|
|
1845
|
+
}
|
|
1846
|
+
getState() {
|
|
1847
|
+
return this.#state;
|
|
1848
|
+
}
|
|
1849
|
+
getUserId() {
|
|
1850
|
+
return this.#userId;
|
|
1851
|
+
}
|
|
1852
|
+
getAnonymousSessionId() {
|
|
1853
|
+
return this.#anonymousSessionId;
|
|
1854
|
+
}
|
|
1855
|
+
get syncState() {
|
|
1856
|
+
return this.#offlineQueue?.syncState ?? "idle";
|
|
1857
|
+
}
|
|
1858
|
+
get pendingCount() {
|
|
1859
|
+
return this.#offlineQueue?.pendingCount ?? 0;
|
|
1860
|
+
}
|
|
1861
|
+
get rejectedHistory() {
|
|
1862
|
+
return this.#offlineQueue?.rejectedHistory ?? [];
|
|
1863
|
+
}
|
|
1864
|
+
getSyncRevision() {
|
|
1865
|
+
return this.#syncRevision;
|
|
1866
|
+
}
|
|
1867
|
+
get queueCapability() {
|
|
1868
|
+
return this.#offlineQueue?.queueCapability ?? "custom";
|
|
1869
|
+
}
|
|
1870
|
+
discardRejected(operationId) {
|
|
1871
|
+
return this.#offlineQueue?.discardRejected(operationId) ?? Promise.resolve(false);
|
|
1872
|
+
}
|
|
1873
|
+
retryRejected(operationId) {
|
|
1874
|
+
return this.#offlineQueue?.retryRejected(operationId) ?? Promise.resolve(false);
|
|
1875
|
+
}
|
|
1876
|
+
dismissRejectedOperation(operationId) {
|
|
1877
|
+
return this.discardRejected(operationId);
|
|
1878
|
+
}
|
|
1879
|
+
retryOperation(operationId) {
|
|
1880
|
+
return this.retryRejected(operationId);
|
|
1881
|
+
}
|
|
1882
|
+
subscribe(listener) {
|
|
1883
|
+
this.#listeners.add(listener);
|
|
1884
|
+
return () => this.#listeners.delete(listener);
|
|
1885
|
+
}
|
|
1886
|
+
subscribeEvents(listener) {
|
|
1887
|
+
this.#eventListeners.add(listener);
|
|
1888
|
+
return () => this.#eventListeners.delete(listener);
|
|
1889
|
+
}
|
|
1890
|
+
subscribeSyncEvents(listener) {
|
|
1891
|
+
this.#syncEventListeners.add(listener);
|
|
1892
|
+
return () => this.#syncEventListeners.delete(listener);
|
|
1893
|
+
}
|
|
1894
|
+
subscribeSyncState(listener) {
|
|
1895
|
+
const wrapped = () => listener();
|
|
1896
|
+
this.#listeners.add(wrapped);
|
|
1897
|
+
return () => this.#listeners.delete(wrapped);
|
|
1898
|
+
}
|
|
1899
|
+
init() {
|
|
1900
|
+
return this.initialize();
|
|
1901
|
+
}
|
|
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();
|
|
1929
|
+
});
|
|
1930
|
+
}
|
|
1931
|
+
async getUserState(rallyId, userId) {
|
|
1932
|
+
return this.#storage.load(rallyId, userId);
|
|
1933
|
+
}
|
|
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();
|
|
1939
|
+
}
|
|
1940
|
+
});
|
|
1941
|
+
}
|
|
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."
|
|
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." });
|
|
1986
|
+
}
|
|
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
|
+
});
|
|
2025
|
+
}
|
|
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
|
+
}
|
|
1804
2073
|
}
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
throw this.#error;
|
|
1810
|
-
} finally {
|
|
1811
|
-
this.#releaseSyncLock();
|
|
1812
|
-
}
|
|
1813
|
-
}
|
|
1814
|
-
async #updateOperationStatus(status, attempts) {
|
|
1815
|
-
const operation = this.#operations[0];
|
|
1816
|
-
if (operation === void 0) return;
|
|
1817
|
-
this.#operations = [{ ...operation, status, attempts }, ...this.#operations.slice(1)];
|
|
1818
|
-
await this.#storage.save(this.#storageKey(), this.#operations);
|
|
1819
|
-
this.#announceChange();
|
|
1820
|
-
}
|
|
1821
|
-
#subscribeToExternalChanges() {
|
|
1822
|
-
const windowLike = globalThis.window;
|
|
1823
|
-
if (windowLike !== void 0) {
|
|
1824
|
-
this.#storageListener = (event) => {
|
|
1825
|
-
if (event.key === this.#storageKey()) void this.#reloadFromStorage();
|
|
2074
|
+
const next = {
|
|
2075
|
+
...current,
|
|
2076
|
+
rewards: current.rewards.map((item) => item.rewardId === rewardId ? local.value : item),
|
|
2077
|
+
updatedAt: now
|
|
1826
2078
|
};
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
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 };
|
|
1831
2087
|
try {
|
|
1832
|
-
|
|
1833
|
-
this.#
|
|
1834
|
-
|
|
1835
|
-
|
|
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);
|
|
2095
|
+
}
|
|
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
|
|
1836
2127
|
});
|
|
1837
|
-
} catch {
|
|
1838
|
-
this.#channel = null;
|
|
1839
2128
|
}
|
|
1840
|
-
}
|
|
2129
|
+
});
|
|
1841
2130
|
}
|
|
1842
|
-
|
|
1843
|
-
|
|
2131
|
+
retrySync() {
|
|
2132
|
+
return this.sync();
|
|
1844
2133
|
}
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
} catch {
|
|
1851
|
-
}
|
|
2134
|
+
reset() {
|
|
2135
|
+
return this.#enqueue(async () => {
|
|
2136
|
+
await this.#storage.remove(this.#config.id, this.#userId);
|
|
2137
|
+
return this.#initializeFresh();
|
|
2138
|
+
});
|
|
1852
2139
|
}
|
|
1853
|
-
|
|
1854
|
-
return
|
|
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
|
+
});
|
|
1855
2151
|
}
|
|
1856
|
-
#
|
|
1857
|
-
const
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
try {
|
|
1864
|
-
const existing = this.#lockStorage.getItem(key);
|
|
1865
|
-
if (existing !== null) {
|
|
1866
|
-
const parsed = JSON.parse(existing);
|
|
1867
|
-
if (typeof parsed === "object" && parsed !== null && typeof parsed.expiresAt === "number" && parsed.expiresAt > now && parsed.owner !== this.#instanceId)
|
|
1868
|
-
return false;
|
|
1869
|
-
}
|
|
1870
|
-
this.#lockStorage.setItem(
|
|
1871
|
-
key,
|
|
1872
|
-
JSON.stringify({ owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS })
|
|
1873
|
-
);
|
|
1874
|
-
} catch {
|
|
1875
|
-
}
|
|
1876
|
-
}
|
|
1877
|
-
syncLocks.set(key, { owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS });
|
|
1878
|
-
return true;
|
|
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;
|
|
1879
2159
|
}
|
|
1880
|
-
#
|
|
1881
|
-
const
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
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);
|
|
2166
|
+
}
|
|
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;
|
|
1890
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);
|
|
1891
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 });
|
|
1892
2236
|
}
|
|
1893
|
-
#
|
|
1894
|
-
|
|
1895
|
-
return `stamprally:queue:${this.#rallyId ?? "unscoped"}:${this.#userId ?? "anonymous"}`;
|
|
2237
|
+
#now() {
|
|
2238
|
+
return this.#options.clock?.() ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
1896
2239
|
}
|
|
1897
|
-
#
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
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);
|
|
1905
2248
|
}
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
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);
|
|
1909
2256
|
}
|
|
1910
|
-
|
|
2257
|
+
this.#emitEvent({ type: "rewardClaimed", result });
|
|
2258
|
+
return result;
|
|
1911
2259
|
}
|
|
1912
|
-
|
|
1913
|
-
const
|
|
1914
|
-
|
|
1915
|
-
|
|
2260
|
+
#emit(state) {
|
|
2261
|
+
for (const listener of this.#listeners) listener(state);
|
|
2262
|
+
}
|
|
2263
|
+
#emitEvent(event) {
|
|
2264
|
+
for (const listener of this.#eventListeners) listener(event);
|
|
2265
|
+
}
|
|
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;
|
|
1916
2276
|
}
|
|
1917
2277
|
};
|
|
1918
|
-
function normalizeOperation(operation) {
|
|
1919
|
-
return {
|
|
1920
|
-
...operation,
|
|
1921
|
-
status: operation.status === "IN_FLIGHT" ? "PENDING" : operation.status ?? "PENDING",
|
|
1922
|
-
attempts: operation.attempts ?? 0
|
|
1923
|
-
};
|
|
1924
|
-
}
|
|
1925
2278
|
|
|
1926
2279
|
// src/crypto/token.ts
|
|
1927
2280
|
var encoder = new TextEncoder();
|
|
@@ -2415,6 +2768,18 @@ function finiteNumber(value, key, path, errors, minimum) {
|
|
|
2415
2768
|
);
|
|
2416
2769
|
}
|
|
2417
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
|
+
}
|
|
2418
2783
|
function localizedText(value, path, errors) {
|
|
2419
2784
|
if (typeof value === "string") return;
|
|
2420
2785
|
if (!isRecord3(value)) {
|
|
@@ -2609,9 +2974,7 @@ function reward(value, path, errors, isPublic) {
|
|
|
2609
2974
|
finiteNumber(value, "requiredStampCount", path, errors, 0);
|
|
2610
2975
|
for (const key of ["stockLimit", "userClaimLimit"]) {
|
|
2611
2976
|
if (hasOwn(value, key) && value[key] !== void 0) {
|
|
2612
|
-
|
|
2613
|
-
if (typeof value[key] === "number" && !Number.isInteger(value[key]))
|
|
2614
|
-
add(errors, `${path}.${key}`, "Expected an integer.", "invalid_integer");
|
|
2977
|
+
nonNegativeInteger(value, key, path, errors);
|
|
2615
2978
|
}
|
|
2616
2979
|
}
|
|
2617
2980
|
optionalString(value, "validUntil", path, errors);
|
|
@@ -2657,8 +3020,14 @@ function validate(value, isPublic) {
|
|
|
2657
3020
|
});
|
|
2658
3021
|
if (!isPublic) {
|
|
2659
3022
|
optionalString(value, "staffPasscode", "$", errors);
|
|
2660
|
-
if (hasOwn(value, "inventory") && value.inventory !== void 0
|
|
2661
|
-
|
|
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
|
+
}
|
|
2662
3031
|
if (hasOwn(value, "inventoryMode") && value.inventoryMode !== void 0 && value.inventoryMode !== "shared" && value.inventoryMode !== "per_reward")
|
|
2663
3032
|
add(errors, "$.inventoryMode", "Expected shared or per_reward.", "invalid_enum");
|
|
2664
3033
|
if (hasOwn(value, "serverMetadata") && value.serverMetadata !== void 0 && !isRecord3(value.serverMetadata))
|
|
@@ -2938,6 +3307,7 @@ exports.isRewardState = isRewardState;
|
|
|
2938
3307
|
exports.isStampRallyState = isStampRallyState;
|
|
2939
3308
|
exports.issueClaimTicketNumber = issueClaimTicketNumber;
|
|
2940
3309
|
exports.normalizePasscode = normalizePasscode;
|
|
3310
|
+
exports.offlineOperationId = offlineOperationId;
|
|
2941
3311
|
exports.parseAdminConfig = parseAdminConfig;
|
|
2942
3312
|
exports.parsePublicConfig = parsePublicConfig;
|
|
2943
3313
|
exports.processStamp = processStamp;
|
|
@@ -2946,6 +3316,7 @@ exports.readQrContext = readQrContext;
|
|
|
2946
3316
|
exports.reconcileRewardStates = reconcileRewardStates;
|
|
2947
3317
|
exports.resolveLocalizedText = resolveLocalizedText;
|
|
2948
3318
|
exports.resolveRallyStateConflict = resolveRallyStateConflict;
|
|
3319
|
+
exports.rollbackOptimisticOperation = rollbackOptimisticOperation;
|
|
2949
3320
|
exports.safeParseAdminConfig = safeParseAdminConfig;
|
|
2950
3321
|
exports.safeParsePublicConfig = safeParsePublicConfig;
|
|
2951
3322
|
exports.sanitizeAdminConfig = sanitizeAdminConfig;
|