@stamprally/core 0.16.0 → 0.17.0

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