@stamprally/core 0.15.0 → 0.17.0

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