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