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