@stamprally/core 0.16.0 → 0.18.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
@@ -110,72 +110,20 @@ function issueClaimTicketNumber(reward2, currentState, options = {}) {
110
110
  }
111
111
 
112
112
  // src/engine/sync.ts
113
- function latestTimestamp(serverTimestamp, localTimestamp) {
114
- const serverTime = Date.parse(serverTimestamp);
115
- const localTime = Date.parse(localTimestamp);
116
- if (!Number.isNaN(serverTime) && !Number.isNaN(localTime))
117
- return serverTime >= localTime ? serverTimestamp : localTimestamp;
118
- if (!Number.isNaN(serverTime)) return serverTimestamp;
119
- if (!Number.isNaN(localTime)) return localTimestamp;
120
- return serverTimestamp >= localTimestamp ? serverTimestamp : localTimestamp;
121
- }
122
- function mergeRewardStates(serverRewards, localRewards) {
123
- const merged = new Map(serverRewards.map((reward2) => [reward2.rewardId, reward2]));
124
- for (const localReward of localRewards) {
125
- const serverReward = merged.get(localReward.rewardId);
126
- if (serverReward === void 0) {
127
- merged.set(localReward.rewardId, localReward);
128
- continue;
129
- }
130
- const winner = localReward.status === "CONSUMED" && serverReward.status !== "CONSUMED" ? localReward : serverReward;
131
- merged.set(localReward.rewardId, {
132
- ...winner,
133
- ...serverReward.unlockedAt === void 0 && localReward.unlockedAt === void 0 ? {} : {
134
- unlockedAt: serverReward.unlockedAt === void 0 ? localReward.unlockedAt : localReward.unlockedAt === void 0 ? serverReward.unlockedAt : latestTimestamp(serverReward.unlockedAt, localReward.unlockedAt)
135
- },
136
- ...serverReward.consumedAt === void 0 && localReward.consumedAt === void 0 ? {} : {
137
- consumedAt: serverReward.consumedAt === void 0 ? localReward.consumedAt : localReward.consumedAt === void 0 ? serverReward.consumedAt : latestTimestamp(serverReward.consumedAt, localReward.consumedAt)
138
- },
139
- ...serverReward.redeemedCount === void 0 && localReward.redeemedCount === void 0 ? {} : {
140
- redeemedCount: Math.max(
141
- serverReward.redeemedCount ?? 0,
142
- localReward.redeemedCount ?? 0
143
- )
144
- },
145
- ...serverReward.userRedemptionCount === void 0 && localReward.userRedemptionCount === void 0 ? {} : {
146
- userRedemptionCount: Math.max(
147
- serverReward.userRedemptionCount ?? 0,
148
- localReward.userRedemptionCount ?? 0
149
- )
150
- }
151
- });
152
- }
153
- return [...merged.values()];
154
- }
155
- function mergeStampRecords(serverRecords, localRecords) {
156
- const merged = /* @__PURE__ */ new Map();
157
- for (const record of [...serverRecords, ...localRecords]) {
158
- const current = merged.get(record.stampId);
159
- if (current === void 0) {
160
- merged.set(record.stampId, record);
161
- continue;
162
- }
163
- const acquiredAt = latestTimestamp(current.acquiredAt, record.acquiredAt);
164
- merged.set(record.stampId, {
165
- ...current,
166
- ...acquiredAt === current.acquiredAt ? {} : { acquiredAt },
167
- ...current.metadata === void 0 && record.metadata !== void 0 ? { metadata: record.metadata } : {}
168
- });
169
- }
170
- return [...merged.values()];
171
- }
172
- function resolveRallyStateConflict(serverState, localState, options = { policy: "merge" }) {
173
- if (options.policy === "server_wins") return serverState;
113
+ function resolveRallyStateConflict(serverState, _localState) {
174
114
  return {
175
115
  ...serverState,
176
- records: mergeStampRecords(serverState.records, localState.records),
177
- rewards: mergeRewardStates(serverState.rewards, localState.rewards),
178
- updatedAt: latestTimestamp(serverState.updatedAt, localState.updatedAt)
116
+ records: serverState.records.map((record) => ({
117
+ ...record,
118
+ ...record.metadata === void 0 ? {} : { metadata: { ...record.metadata } }
119
+ })),
120
+ rewards: serverState.rewards.map((reward2) => ({ ...reward2 })),
121
+ ...serverState.inventory === void 0 ? {} : {
122
+ inventory: {
123
+ ...serverState.inventory,
124
+ ...serverState.inventory.rewardRemaining === void 0 ? {} : { rewardRemaining: { ...serverState.inventory.rewardRemaining } }
125
+ }
126
+ }
179
127
  };
180
128
  }
181
129
 
@@ -1058,1079 +1006,1390 @@ var IndexedDBAdapter = class {
1058
1006
  }
1059
1007
  };
1060
1008
 
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;
1009
+ // src/client/offlineQueue.ts
1010
+ function rollbackOptimisticOperation(state, operation) {
1011
+ const previous = operation.request.state;
1012
+ if (operation.kind === "checkIn") {
1013
+ const records = state.records.filter(
1014
+ (record) => record.stampId !== operation.request.spotId || record.acquiredAt !== operation.request.now
1015
+ );
1016
+ const previousRewards = new Map(previous.rewards.map((reward2) => [reward2.rewardId, reward2]));
1017
+ const rewards2 = state.rewards.map((reward2) => previousRewards.get(reward2.rewardId) ?? reward2);
1018
+ return { ...cloneState(state), records, rewards: rewards2, updatedAt: previous.updatedAt };
1089
1019
  }
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
1020
+ const previousReward = previous.rewards.find(
1021
+ (reward2) => reward2.rewardId === operation.request.rewardId
1022
+ );
1023
+ const rewards = state.rewards.filter(
1024
+ (reward2) => reward2.rewardId !== operation.request.rewardId || previousReward !== void 0
1025
+ ).map(
1026
+ (reward2) => reward2.rewardId === operation.request.rewardId && previousReward !== void 0 ? { ...previousReward } : reward2
1027
+ );
1028
+ const cloned = cloneState(state);
1029
+ const { inventory: _inventory, ...stateWithoutInventory } = cloned;
1030
+ const previousInventory = previous.inventory;
1031
+ return previousInventory === void 0 ? { ...stateWithoutInventory, rewards, updatedAt: previous.updatedAt } : {
1032
+ ...stateWithoutInventory,
1033
+ rewards,
1034
+ inventory: {
1035
+ ...previousInventory,
1036
+ ...previousInventory.rewardRemaining === void 0 ? {} : { rewardRemaining: { ...previousInventory.rewardRemaining } }
1037
+ },
1038
+ updatedAt: previous.updatedAt
1099
1039
  };
1100
1040
  }
1101
- var StampRallyClient = class {
1102
- #listeners = /* @__PURE__ */ new Set();
1103
- #eventListeners = /* @__PURE__ */ new Set();
1104
- #storage;
1105
- #options;
1106
- #config;
1107
- #offlineQueue;
1108
- #userId;
1109
- #anonymousSessionId;
1110
- #state = null;
1111
- #initialization = null;
1112
- #queue = Promise.resolve();
1113
- constructor(config, storageOrOptions = {}) {
1114
- this.#config = config;
1115
- this.#options = isStorage(storageOrOptions) ? { storage: storageOrOptions } : storageOrOptions;
1116
- this.#storage = this.#options.storage ?? new InMemoryStorage();
1117
- this.#anonymousSessionId = this.#options.anonymousSessionId ?? createAnonymousSessionId();
1118
- this.#userId = this.#options.userId ?? this.#anonymousSessionId;
1119
- this.#offlineQueue = this.#options.offlineQueue;
1120
- this.#offlineQueue?.setSyncResultListener((event) => this.#handleOfflineSyncResult(event));
1121
- }
1122
- getConfig() {
1123
- return this.#config;
1124
- }
1125
- getState() {
1126
- return this.#state;
1127
- }
1128
- getUserId() {
1129
- return this.#userId;
1041
+ var MemoryQueueStorage = class {
1042
+ #values = /* @__PURE__ */ new Map();
1043
+ #rejected = /* @__PURE__ */ new Map();
1044
+ async load(key) {
1045
+ return this.#values.get(key) ?? [];
1130
1046
  }
1131
- getAnonymousSessionId() {
1132
- return this.#anonymousSessionId;
1047
+ async save(key, operations) {
1048
+ this.#values.set(key, structuredClone(operations));
1133
1049
  }
1134
- get syncState() {
1135
- return this.#offlineQueue?.syncState ?? "idle";
1050
+ async loadRejectedHistory(key) {
1051
+ return this.#rejected.get(key) ?? [];
1136
1052
  }
1137
- get pendingCount() {
1138
- return this.#offlineQueue?.pendingCount ?? 0;
1053
+ async saveRejectedHistory(key, history) {
1054
+ this.#rejected.set(key, structuredClone(history));
1139
1055
  }
1140
- get rejectedHistory() {
1141
- return this.#offlineQueue?.rejectedHistory ?? [];
1056
+ };
1057
+ var LocalStorageQueueStorage = class {
1058
+ constructor(storage) {
1059
+ this.storage = storage;
1142
1060
  }
1143
- get queueCapability() {
1144
- return this.#offlineQueue?.queueCapability ?? "custom";
1061
+ storage;
1062
+ async load(key) {
1063
+ const value = this.storage.getItem(key);
1064
+ if (value === null) return [];
1065
+ try {
1066
+ const parsed = JSON.parse(value);
1067
+ return Array.isArray(parsed) ? parsed : [];
1068
+ } catch {
1069
+ return [];
1070
+ }
1145
1071
  }
1146
- discardRejected(operationId) {
1147
- return this.#offlineQueue?.discardRejected(operationId) ?? Promise.resolve(false);
1072
+ async save(key, operations) {
1073
+ this.storage.setItem(key, JSON.stringify(operations));
1148
1074
  }
1149
- retryRejected(operationId) {
1150
- return this.#offlineQueue?.retryRejected(operationId) ?? Promise.resolve(false);
1075
+ async loadRejectedHistory(key) {
1076
+ const value = this.storage.getItem(`${key}:rejected-history`);
1077
+ if (value === null) return [];
1078
+ try {
1079
+ const parsed = JSON.parse(value);
1080
+ return Array.isArray(parsed) ? parsed : [];
1081
+ } catch {
1082
+ return [];
1083
+ }
1151
1084
  }
1152
- subscribe(listener) {
1153
- this.#listeners.add(listener);
1154
- return () => this.#listeners.delete(listener);
1085
+ async saveRejectedHistory(key, history) {
1086
+ this.storage.setItem(`${key}:rejected-history`, JSON.stringify(history));
1155
1087
  }
1156
- subscribeEvents(listener) {
1157
- this.#eventListeners.add(listener);
1158
- return () => this.#eventListeners.delete(listener);
1088
+ };
1089
+ var IndexedDBOfflineQueueStorage = class {
1090
+ #providedFactory;
1091
+ #databaseName;
1092
+ #databasePromise = null;
1093
+ constructor(options = {}) {
1094
+ this.#providedFactory = options.indexedDB;
1095
+ this.#databaseName = options.databaseName ?? "stamprally-offline-queue";
1159
1096
  }
1160
- init() {
1161
- return this.initialize();
1097
+ async load(key) {
1098
+ const database = await this.#open();
1099
+ return new Promise((resolve, reject) => {
1100
+ const request = database.transaction("operations", "readonly").objectStore("operations").get(key);
1101
+ request.onsuccess = () => resolve(Array.isArray(request.result) ? request.result : []);
1102
+ request.onerror = () => reject(request.error ?? new Error("Failed to read offline queue."));
1103
+ });
1162
1104
  }
1163
- initialize() {
1164
- if (this.#state !== null) return Promise.resolve(this.#state);
1165
- if (this.#initialization === null) {
1166
- this.#initialization = (async () => {
1167
- await this.#offlineQueue?.setScope(this.#config.id, this.#userId);
1168
- return this.#storage.load(this.#config.id, this.#userId);
1169
- })().then((state) => {
1170
- const next = state === null ? emptyState(this.#config, this.#userId, this.#now()) : this.#reconcile(state);
1171
- this.#state = next;
1172
- this.#emit(next);
1173
- return next;
1174
- }).catch((error) => {
1175
- this.#initialization = null;
1176
- throw error;
1177
- });
1178
- }
1179
- return this.#initialization;
1105
+ async save(key, operations) {
1106
+ const database = await this.#open();
1107
+ return new Promise((resolve, reject) => {
1108
+ const transaction = database.transaction("operations", "readwrite");
1109
+ transaction.objectStore("operations").put(structuredClone(operations), key);
1110
+ transaction.oncomplete = () => resolve();
1111
+ transaction.onerror = () => reject(transaction.error ?? new Error("Failed to save offline queue."));
1112
+ transaction.onabort = () => reject(transaction.error ?? new Error("Offline queue write aborted."));
1113
+ });
1180
1114
  }
1181
- switchUser(newUserId) {
1182
- return this.#enqueue(async () => {
1183
- const nextUserId = newUserId ?? this.#anonymousSessionId;
1184
- if (this.#userId === nextUserId && this.#state !== null) return this.#state;
1185
- this.#userId = nextUserId;
1186
- this.#state = null;
1187
- this.#initialization = null;
1188
- await this.#offlineQueue?.switchUser(nextUserId);
1189
- return this.initialize();
1115
+ async loadRejectedHistory(key) {
1116
+ const database = await this.#open();
1117
+ return new Promise((resolve, reject) => {
1118
+ const request = database.transaction("operations", "readonly").objectStore("operations").get(`${key}:rejected-history`);
1119
+ request.onsuccess = () => resolve(
1120
+ Array.isArray(request.result) ? request.result : []
1121
+ );
1122
+ request.onerror = () => reject(request.error ?? new Error("Failed to read rejected operation history."));
1190
1123
  });
1191
1124
  }
1192
- async getUserState(rallyId, userId) {
1193
- return this.#storage.load(rallyId, userId);
1125
+ async saveRejectedHistory(key, history) {
1126
+ const database = await this.#open();
1127
+ return new Promise((resolve, reject) => {
1128
+ const transaction = database.transaction("operations", "readwrite");
1129
+ transaction.objectStore("operations").put(structuredClone(history), `${key}:rejected-history`);
1130
+ transaction.oncomplete = () => resolve();
1131
+ transaction.onerror = () => reject(transaction.error ?? new Error("Failed to save rejected operation history."));
1132
+ transaction.onabort = () => reject(transaction.error ?? new Error("Rejected operation history write aborted."));
1133
+ });
1194
1134
  }
1195
- clearUserState(userId = this.#userId) {
1196
- return this.#enqueue(async () => {
1197
- await this.#storage.remove(this.#config.id, userId);
1198
- if (userId === this.#userId) {
1199
- await this.#initializeFresh();
1200
- }
1135
+ #open() {
1136
+ if (this.#databasePromise !== null) return this.#databasePromise;
1137
+ let factory = this.#providedFactory;
1138
+ if (factory === void 0)
1139
+ factory = globalThis.indexedDB;
1140
+ if (factory === void 0 || factory === null)
1141
+ return Promise.reject(new Error("IndexedDB is unavailable in this environment."));
1142
+ this.#databasePromise = new Promise((resolve, reject) => {
1143
+ const request = factory.open(this.#databaseName, 1);
1144
+ request.onupgradeneeded = () => {
1145
+ if (!request.result.objectStoreNames.contains("operations"))
1146
+ request.result.createObjectStore("operations");
1147
+ };
1148
+ request.onsuccess = () => resolve(request.result);
1149
+ request.onerror = () => reject(request.error ?? new Error("Failed to open offline queue."));
1150
+ }).catch((error) => {
1151
+ this.#databasePromise = null;
1152
+ throw error;
1201
1153
  });
1154
+ return this.#databasePromise;
1202
1155
  }
1203
- checkIn(spotId, proofData, options = {}) {
1204
- return this.#enqueue(async () => {
1205
- const current = await this.initialize();
1206
- const spot2 = this.#config.spots.find((item) => item.id === spotId);
1207
- if (spot2 === void 0)
1208
- return this.#fail({ code: "SPOT_NOT_FOUND", spotId, message: "Spot was not found." });
1209
- if (current.records.some((record2) => record2.stampId === spotId))
1210
- return this.#fail({
1211
- code: "STAMP_ALREADY_ACQUIRED",
1212
- spotId,
1213
- message: "Spot was already claimed."
1214
- });
1215
- const acquired = new Set(current.records.map((record2) => record2.stampId));
1216
- if (spot2.prerequisites?.some((id2) => !acquired.has(id2)))
1217
- return this.#fail({
1218
- code: "PREREQUISITES_NOT_MET",
1219
- spotId,
1220
- message: "Prerequisite spots are not complete."
1221
- });
1222
- for (const condition2 of spot2.conditions) {
1223
- if (condition2.type === "custom") {
1224
- const validator = this.#options.customValidators?.[condition2.validatorName] ?? this.#options.customValidator;
1225
- if (validator === void 0)
1226
- return this.#fail({
1227
- code: "CUSTOM_VALIDATION_FAILED",
1228
- spotId,
1229
- message: "No custom validator is registered."
1230
- });
1231
- const context = {
1232
- rallyId: this.#config.id,
1233
- spotId,
1234
- proofData,
1235
- condition: { type: "custom", validatorName: condition2.validatorName },
1236
- userState: current
1237
- };
1238
- const result = typeof validator === "function" ? await validator(context) : await validator.validate(context);
1239
- if (result === false || typeof result === "object" && !result.valid)
1240
- return this.#fail({
1241
- code: "CUSTOM_VALIDATION_FAILED",
1242
- spotId,
1243
- message: typeof result === "object" && result.message !== void 0 ? result.message : "Custom validation failed."
1244
- });
1245
- } else if (!matches(condition2, proofData))
1246
- return this.#fail({ code: "INVALID_PROOF", spotId, message: "Verification failed." });
1247
- }
1248
- const now = options.now ?? this.#now();
1249
- const request = {
1250
- rallyId: this.#config.id,
1251
- userId: this.#userId,
1252
- spotId,
1253
- proofData,
1254
- idempotencyKey: options.idempotencyKey ?? id("check-in"),
1255
- now,
1256
- state: current,
1257
- ...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
1258
- };
1259
- const remote = this.#options.syncAdapter?.checkIn;
1260
- if (options.sync !== false && remote !== void 0) {
1261
- try {
1262
- const result = await remote(request);
1263
- return result.ok ? this.#commitCheckIn(result) : this.#fail(result.error);
1264
- } catch (error) {
1265
- if (this.#offlineQueue === void 0) throw error;
1266
- await this.#offlineQueue.enqueueCheckIn(request);
1267
- const record2 = { stampId: spotId, acquiredAt: now };
1268
- const next2 = this.#reconcile({
1269
- ...current,
1270
- records: [...current.records, record2],
1271
- updatedAt: now
1272
- });
1273
- await this.#storage.save(next2);
1274
- return this.#commitCheckIn({ ok: true, value: { state: next2, record: record2 } });
1275
- }
1276
- }
1277
- const record = { stampId: spotId, acquiredAt: now };
1278
- const next = this.#reconcile({
1279
- ...current,
1280
- records: [...current.records, record],
1281
- updatedAt: now
1282
- });
1283
- await this.#storage.save(next);
1284
- return this.#commitCheckIn({ ok: true, value: { state: next, record } });
1285
- });
1156
+ };
1157
+ function availableLocalStorage() {
1158
+ try {
1159
+ const storage = globalThis.localStorage;
1160
+ return storage ?? null;
1161
+ } catch {
1162
+ return null;
1286
1163
  }
1287
- claimReward(rewardId, options = {}) {
1288
- return this.#enqueue(async () => {
1289
- const current = await this.initialize();
1290
- const configured = this.#config.rewards.find((item) => item.id === rewardId);
1291
- if (configured === void 0)
1292
- return this.#fail({ code: "REWARD_NOT_FOUND", rewardId, message: "Reward was not found." });
1293
- const now = options.now ?? this.#now();
1294
- const state = current.rewards.find((item) => item.rewardId === rewardId) ?? {
1295
- rewardId,
1296
- status: "LOCKED"
1297
- };
1298
- const local = consumeReward({
1299
- reward: configured,
1300
- currentState: state,
1301
- now,
1302
- ...options.staffPasscode === void 0 ? {} : { inputPasscode: options.staffPasscode },
1303
- ...options.staffId === void 0 ? {} : { staffId: options.staffId }
1304
- });
1305
- if (!local.ok) return this.#fail(local.error);
1306
- const request = {
1307
- rallyId: this.#config.id,
1308
- userId: this.#userId,
1309
- rewardId,
1310
- idempotencyKey: options.idempotencyKey ?? id("claim"),
1311
- now,
1312
- options,
1313
- state: current,
1314
- ...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
1315
- };
1316
- const remote = this.#options.syncAdapter?.claimReward;
1317
- if (options.sync !== false && remote !== void 0) {
1318
- try {
1319
- const result = await remote(request);
1320
- return result.ok ? this.#commitClaim(result) : this.#fail(result.error);
1321
- } catch (error) {
1322
- if (this.#offlineQueue === void 0) throw error;
1323
- await this.#offlineQueue.enqueueClaimReward(request);
1324
- const next2 = {
1325
- ...current,
1326
- rewards: current.rewards.map(
1327
- (item) => item.rewardId === rewardId ? local.value : item
1328
- ),
1329
- updatedAt: now
1330
- };
1331
- await this.#storage.save(next2);
1332
- return this.#commitClaim({ ok: true, value: { state: next2, reward: local.value } });
1333
- }
1334
- }
1335
- const next = {
1336
- ...current,
1337
- rewards: current.rewards.map((item) => item.rewardId === rewardId ? local.value : item),
1338
- updatedAt: now
1164
+ }
1165
+ function defaultStorage(databaseName) {
1166
+ try {
1167
+ const indexedDB = globalThis.indexedDB;
1168
+ if (indexedDB !== void 0)
1169
+ return {
1170
+ storage: new IndexedDBOfflineQueueStorage({
1171
+ indexedDB,
1172
+ ...databaseName === void 0 ? {} : { databaseName }
1173
+ }),
1174
+ capability: "indexeddb"
1339
1175
  };
1340
- await this.#storage.save(next);
1341
- return this.#commitClaim({ ok: true, value: { state: next, reward: local.value } });
1342
- });
1176
+ const storage = availableLocalStorage();
1177
+ if (storage !== void 0 && storage !== null)
1178
+ return { storage: new LocalStorageQueueStorage(storage), capability: "localstorage" };
1179
+ } catch {
1343
1180
  }
1344
- sync(adapter = this.#options.syncAdapter) {
1345
- return this.#enqueue(async () => {
1346
- const current = await this.initialize();
1347
- if (this.#offlineQueue !== void 0 && adapter !== void 0) {
1348
- await this.#offlineQueue.sync(async (operation) => {
1349
- if (operation.kind === "checkIn") {
1350
- if (adapter.checkIn === void 0)
1351
- throw new Error("No check-in sync adapter is configured.");
1352
- return adapter.checkIn(operation.request);
1353
- }
1354
- if (adapter.claimReward === void 0)
1355
- throw new Error("No reward sync adapter is configured.");
1356
- return adapter.claimReward(operation.request);
1357
- });
1358
- }
1359
- if (adapter?.sync === void 0) {
1360
- this.#emitEvent({ type: "sync", state: this.#state ?? current });
1361
- return;
1362
- }
1363
- const serverState = await adapter.sync({
1364
- rallyId: this.#config.id,
1365
- userId: this.#userId,
1366
- state: this.#state ?? current,
1367
- ...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
1368
- });
1369
- const next = this.#reconcile(serverState);
1370
- await this.#storage.save(next);
1371
- this.#state = next;
1372
- this.#emit(next);
1373
- this.#emitEvent({ type: "sync", state: next });
1374
- });
1181
+ return { storage: new MemoryQueueStorage(), capability: "memory" };
1182
+ }
1183
+ function offlineOperationId(operation) {
1184
+ const identity = operation.request.userId ?? operation.request.anonymousSessionId ?? "anonymous";
1185
+ return operation.kind === "checkIn" ? `checkIn:${operation.request.rallyId}:${identity}:${operation.request.idempotencyKey}` : `claimReward:${operation.request.rallyId}:${identity}:${operation.request.idempotencyKey}`;
1186
+ }
1187
+ function requestScope(operation) {
1188
+ return {
1189
+ rallyId: operation.request.rallyId,
1190
+ userId: operation.request.userId
1191
+ };
1192
+ }
1193
+ function errorValue(value, fallbackCode) {
1194
+ if (typeof value === "object" && value !== null) {
1195
+ const candidate = value;
1196
+ if (typeof candidate.code === "string" && typeof candidate.message === "string")
1197
+ return { ...candidate, code: candidate.code, message: candidate.message };
1375
1198
  }
1376
- retrySync() {
1377
- return this.sync();
1199
+ if (value instanceof Error) return { code: fallbackCode, message: value.message };
1200
+ if (typeof value === "string") return { code: fallbackCode, message: value };
1201
+ return { code: fallbackCode, message: "Offline operation was rejected." };
1202
+ }
1203
+ var syncLocks = /* @__PURE__ */ new Map();
1204
+ var SYNC_LOCK_TTL_MS = 3e4;
1205
+ var DEFAULT_RETRY_OPTIONS = {
1206
+ maxRetries: 0,
1207
+ initialIntervalMs: 250,
1208
+ backoffMultiplier: 2
1209
+ };
1210
+ function randomId() {
1211
+ return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
1212
+ }
1213
+ var OfflineQueue = class {
1214
+ #storage;
1215
+ #queueCapability;
1216
+ #configuredKey;
1217
+ #rallyId;
1218
+ #userId;
1219
+ #operations = [];
1220
+ #rejectedHistory = [];
1221
+ #loaded = false;
1222
+ #state = "idle";
1223
+ #error = null;
1224
+ #sender;
1225
+ #syncPromise = null;
1226
+ #syncResultListener;
1227
+ #changeListener;
1228
+ #synchronizeInstances;
1229
+ #retryOptions;
1230
+ #instanceId = randomId();
1231
+ #lockStorage;
1232
+ #observedLocks = /* @__PURE__ */ new Map();
1233
+ #warnedMemoryLock = false;
1234
+ #capabilityWarningListener;
1235
+ #replayConfig;
1236
+ #storageListener;
1237
+ #channel = null;
1238
+ constructor(options = {}) {
1239
+ if (options.storage !== void 0) {
1240
+ this.#storage = options.storage;
1241
+ this.#queueCapability = "custom";
1242
+ } else if (options.storageLike !== void 0 && options.storageLike !== null) {
1243
+ this.#storage = new LocalStorageQueueStorage(options.storageLike);
1244
+ this.#queueCapability = "localstorage";
1245
+ } else {
1246
+ const selected = defaultStorage(options.databaseName);
1247
+ this.#storage = selected.storage;
1248
+ this.#queueCapability = selected.capability;
1249
+ }
1250
+ this.#configuredKey = options.key;
1251
+ this.#rallyId = options.rallyId;
1252
+ this.#userId = options.userId ?? null;
1253
+ this.#synchronizeInstances = options.synchronizeInstances ?? true;
1254
+ const retryOptions = {
1255
+ ...DEFAULT_RETRY_OPTIONS,
1256
+ ...options.retryOptions ?? options.syncRetryOptions ?? options.retry ?? {}
1257
+ };
1258
+ this.#retryOptions = {
1259
+ maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
1260
+ initialIntervalMs: Math.max(0, retryOptions.initialIntervalMs),
1261
+ backoffMultiplier: Math.max(1, retryOptions.backoffMultiplier)
1262
+ };
1263
+ this.#lockStorage = options.storageLike ?? availableLocalStorage();
1264
+ if (this.#synchronizeInstances) this.#subscribeToExternalChanges();
1378
1265
  }
1379
- reset() {
1380
- return this.#enqueue(async () => {
1381
- await this.#storage.remove(this.#config.id, this.#userId);
1382
- return this.#initializeFresh();
1383
- });
1266
+ get syncState() {
1267
+ return this.#state;
1384
1268
  }
1385
- restore(state) {
1386
- return this.#enqueue(async () => {
1387
- if (state.rallyId !== this.#config.id || state.userId !== this.#userId)
1388
- throw new Error("State belongs to another rally or user.");
1389
- const next = this.#reconcile(state);
1390
- await this.#storage.save(next);
1391
- this.#state = next;
1392
- this.#initialization = Promise.resolve(next);
1393
- this.#emit(next);
1394
- return next;
1395
- });
1269
+ get pendingCount() {
1270
+ return this.#operations.length;
1396
1271
  }
1397
- #enqueue(operation) {
1398
- const next = this.#queue.then(operation, operation);
1399
- this.#queue = next.then(
1400
- () => void 0,
1401
- () => void 0
1402
- );
1403
- return next;
1272
+ get queueCapability() {
1273
+ return this.#queueCapability;
1404
1274
  }
1405
- #initializeFresh() {
1406
- const next = emptyState(this.#config, this.#userId, this.#now());
1407
- this.#state = next;
1408
- this.#initialization = Promise.resolve(next);
1409
- this.#emit(next);
1410
- return Promise.resolve(next);
1275
+ get storageCapability() {
1276
+ if (this.#queueCapability === "memory") return "memory";
1277
+ const locks = globalThis.navigator?.locks;
1278
+ return locks !== void 0 || this.#lockStorage !== null ? this.#queueCapability : "volatile_single_tab";
1411
1279
  }
1412
- #reconcile(state) {
1413
- const ids = new Set(this.#config.spots.map((spot2) => spot2.id));
1414
- const records = state.records.filter(
1415
- (record, index, all) => ids.has(record.stampId) && all.findIndex((candidate) => candidate.stampId === record.stampId) === index
1416
- );
1417
- return {
1418
- ...cloneState(state),
1419
- userId: this.#userId,
1420
- records,
1421
- rewards: reconcileRewardStates(
1422
- this.#config.rewards,
1423
- state.rewards,
1424
- records.length,
1425
- state.updatedAt
1426
- )
1427
- };
1280
+ get isStoragePersistent() {
1281
+ return this.#queueCapability !== "memory";
1428
1282
  }
1429
- async #handleOfflineSyncResult(event) {
1430
- if (event.state !== void 0) {
1431
- const next = this.#reconcile(event.state);
1432
- await this.#storage.save(next);
1433
- this.#state = next;
1434
- this.#emit(next);
1283
+ get rejectedHistory() {
1284
+ return this.#rejectedHistory;
1285
+ }
1286
+ get error() {
1287
+ return this.#error;
1288
+ }
1289
+ get operations() {
1290
+ return this.#operations;
1291
+ }
1292
+ get storageKey() {
1293
+ return this.#storageKey();
1294
+ }
1295
+ get rallyId() {
1296
+ return this.#rallyId;
1297
+ }
1298
+ get userId() {
1299
+ return this.#userId;
1300
+ }
1301
+ setSyncResultListener(listener) {
1302
+ this.#syncResultListener = listener;
1303
+ }
1304
+ setChangeListener(listener) {
1305
+ this.#changeListener = listener;
1306
+ }
1307
+ setCapabilityWarningListener(listener) {
1308
+ this.#capabilityWarningListener = listener;
1309
+ }
1310
+ setReplayConfig(config) {
1311
+ this.#replayConfig = config;
1312
+ }
1313
+ async initialize() {
1314
+ if (this.#loaded) return;
1315
+ try {
1316
+ const key = this.#storageKey();
1317
+ this.#operations = (await this.#storage.load(key)).map(normalizeOperation);
1318
+ this.#rejectedHistory = (await this.#storage.loadRejectedHistory?.(key))?.map(normalizeRejectedHistory) ?? [];
1319
+ } catch (error) {
1320
+ if (this.#queueCapability === "memory") throw error;
1321
+ this.#storage = new MemoryQueueStorage();
1322
+ this.#queueCapability = "memory";
1323
+ this.#operations = [];
1324
+ this.#rejectedHistory = [];
1325
+ this.#warnMemoryLock(
1326
+ `Offline queue persistence is unavailable; queued data can be lost after reload (${String(error)}).`
1327
+ );
1435
1328
  }
1436
- if (event.error !== void 0) this.#emitEvent({ type: "error", error: event.error });
1437
- else if (event.result !== void 0 && "ok" in event.result && !event.result.ok)
1438
- this.#emitEvent({ type: "error", error: event.result.error });
1329
+ if (this.#queueCapability === "memory")
1330
+ this.#warnMemoryLock("Offline queue persistence is unavailable; queued data is memory-only.");
1331
+ this.#loaded = true;
1439
1332
  }
1440
- #now() {
1441
- return this.#options.clock?.() ?? (/* @__PURE__ */ new Date()).toISOString();
1333
+ /** Releases browser listeners when the queue is no longer used. */
1334
+ dispose() {
1335
+ const windowLike = globalThis.window;
1336
+ if (windowLike !== void 0 && this.#storageListener !== void 0)
1337
+ windowLike.removeEventListener("storage", this.#storageListener);
1338
+ this.#storageListener = void 0;
1339
+ this.#channel?.close();
1340
+ this.#channel = null;
1341
+ this.#releaseSyncLock();
1442
1342
  }
1443
- #fail(error) {
1444
- this.#emitEvent({ type: "error", error });
1445
- return { ok: false, error };
1343
+ /** Selects a rally/user queue scope and loads its pending operations. */
1344
+ async setScope(rallyId, userId) {
1345
+ if (this.#configuredKey !== void 0) {
1346
+ this.#rallyId = rallyId;
1347
+ this.#userId = userId;
1348
+ return this.initialize();
1349
+ }
1350
+ if (this.#rallyId === rallyId && this.#userId === userId && this.#loaded) return;
1351
+ this.#rallyId = rallyId;
1352
+ this.#userId = userId;
1353
+ this.#operations = [];
1354
+ this.#loaded = false;
1355
+ await this.initialize();
1356
+ }
1357
+ async switchUser(newUserId) {
1358
+ if (this.#rallyId === void 0)
1359
+ throw new Error("OfflineQueue.switchUser requires a rally scope.");
1360
+ await this.setScope(this.#rallyId, newUserId);
1361
+ }
1362
+ setSender(sender) {
1363
+ this.#sender = sender;
1364
+ }
1365
+ async enqueue(operation) {
1366
+ if (this.#configuredKey === void 0) {
1367
+ const scope = requestScope(operation);
1368
+ if (this.#rallyId === void 0) await this.setScope(scope.rallyId, scope.userId);
1369
+ if (this.#rallyId !== scope.rallyId || this.#userId !== scope.userId)
1370
+ throw new Error("Offline operation belongs to another rally or user queue.");
1371
+ }
1372
+ await this.initialize();
1373
+ const id2 = offlineOperationId(operation);
1374
+ if (this.#operations.some((item) => offlineOperationId(item) === id2)) return;
1375
+ this.#operations = [...this.#operations, { ...operation, status: "PENDING", attempts: 0 }];
1376
+ await this.#storage.save(this.#storageKey(), this.#operations);
1377
+ this.#announceChange();
1378
+ }
1379
+ async enqueueCheckIn(request, optimisticState) {
1380
+ return this.enqueue({
1381
+ kind: "checkIn",
1382
+ request,
1383
+ ...optimisticState === void 0 ? {} : { optimisticState }
1384
+ });
1385
+ }
1386
+ async enqueueClaimReward(request, optimisticState) {
1387
+ return this.enqueue({
1388
+ kind: "claimReward",
1389
+ request,
1390
+ ...optimisticState === void 0 ? {} : { optimisticState }
1391
+ });
1446
1392
  }
1447
- #commitCheckIn(result) {
1448
- if (result.ok) {
1449
- this.#state = result.value.state;
1450
- this.#emit(this.#state);
1451
- }
1452
- this.#emitEvent({ type: "checkIn", result });
1453
- return result;
1393
+ async clear() {
1394
+ await this.initialize();
1395
+ this.#operations = [];
1396
+ await this.#storage.save(this.#storageKey(), this.#operations);
1397
+ this.#announceChange();
1454
1398
  }
1455
- #commitClaim(result) {
1456
- if (result.ok) {
1457
- this.#state = result.value.state;
1458
- this.#emit(this.#state);
1459
- }
1460
- this.#emitEvent({ type: "rewardClaimed", result });
1461
- return result;
1399
+ async discardRejected(operationId2) {
1400
+ await this.initialize();
1401
+ const next = this.#rejectedHistory.filter(
1402
+ (entry) => offlineOperationId(entry.operation) !== operationId2
1403
+ );
1404
+ if (next.length === this.#rejectedHistory.length) return false;
1405
+ this.#rejectedHistory = next;
1406
+ await this.#saveRejectedHistory();
1407
+ this.#announceChange();
1408
+ return true;
1462
1409
  }
1463
- #emit(state) {
1464
- for (const listener of this.#listeners) listener(state);
1410
+ async retryRejected(operationId2) {
1411
+ await this.initialize();
1412
+ const entry = this.#rejectedHistory.find(
1413
+ (candidate) => offlineOperationId(candidate.operation) === operationId2
1414
+ );
1415
+ if (entry === void 0) return false;
1416
+ if (!this.#operations.some((operation) => offlineOperationId(operation) === operationId2))
1417
+ this.#operations = [
1418
+ ...this.#operations,
1419
+ { ...entry.operation, status: "PENDING", attempts: 0 }
1420
+ ];
1421
+ this.#rejectedHistory = this.#rejectedHistory.filter((candidate) => candidate !== entry);
1422
+ await this.#storage.save(this.#storageKey(), this.#operations);
1423
+ await this.#saveRejectedHistory();
1424
+ this.#announceChange();
1425
+ return true;
1465
1426
  }
1466
- #emitEvent(event) {
1467
- for (const listener of this.#eventListeners) listener(event);
1427
+ async discardRejectedOperation(operationId2) {
1428
+ return this.discardRejected(operationId2);
1468
1429
  }
1469
- };
1470
-
1471
- // src/client/offlineQueue.ts
1472
- var MemoryQueueStorage = class {
1473
- #values = /* @__PURE__ */ new Map();
1474
- #rejected = /* @__PURE__ */ new Map();
1475
- async load(key) {
1476
- return this.#values.get(key) ?? [];
1430
+ async retryRejectedOperation(operationId2) {
1431
+ return this.retryRejected(operationId2);
1477
1432
  }
1478
- async save(key, operations) {
1479
- this.#values.set(key, structuredClone(operations));
1433
+ async clearRejectedHistory() {
1434
+ await this.initialize();
1435
+ if (this.#rejectedHistory.length === 0) return;
1436
+ this.#rejectedHistory = [];
1437
+ await this.#saveRejectedHistory();
1438
+ this.#announceChange();
1480
1439
  }
1481
- async loadRejectedHistory(key) {
1482
- return this.#rejected.get(key) ?? [];
1440
+ async sync(sender = this.#sender) {
1441
+ await this.initialize();
1442
+ if (sender === void 0) throw new Error("OfflineQueue.sync requires a sender.");
1443
+ if (this.#syncPromise !== null) return this.#syncPromise;
1444
+ this.#sender = sender;
1445
+ this.#syncPromise = this.#run(sender).finally(() => {
1446
+ this.#syncPromise = null;
1447
+ });
1448
+ return this.#syncPromise;
1483
1449
  }
1484
- async saveRejectedHistory(key, history) {
1485
- this.#rejected.set(key, structuredClone(history));
1450
+ async retrySync(sender = this.#sender) {
1451
+ return this.sync(sender);
1486
1452
  }
1487
- };
1488
- var LocalStorageQueueStorage = class {
1489
- constructor(storage) {
1490
- this.storage = storage;
1453
+ async #run(sender) {
1454
+ const locks = globalThis.navigator?.locks;
1455
+ if (locks !== void 0 && typeof locks.request === "function") {
1456
+ let callbackStarted = false;
1457
+ try {
1458
+ const acquired = await locks.request(
1459
+ `stamprally:${this.#storageKey()}:sync`,
1460
+ { ifAvailable: true },
1461
+ async (lock) => {
1462
+ if (lock === null) {
1463
+ await this.#reloadFromStorage();
1464
+ this.#state = "idle";
1465
+ this.#changeListener?.();
1466
+ return false;
1467
+ }
1468
+ callbackStarted = true;
1469
+ await this.#runWithStorageLock(sender);
1470
+ return true;
1471
+ }
1472
+ );
1473
+ if (!acquired) return;
1474
+ return;
1475
+ } catch (error) {
1476
+ if (callbackStarted) throw error;
1477
+ }
1478
+ }
1479
+ if (this.storageCapability === "volatile_single_tab")
1480
+ this.#warnMemoryLock(
1481
+ "No cross-tab storage lock is available; offline sync is single-tab only."
1482
+ );
1483
+ await this.#runWithStorageLock(sender);
1491
1484
  }
1492
- storage;
1493
- async load(key) {
1494
- const value = this.storage.getItem(key);
1495
- if (value === null) return [];
1485
+ async #runWithStorageLock(sender) {
1486
+ this.#state = "syncing";
1487
+ this.#error = null;
1488
+ this.#changeListener?.();
1489
+ if (!this.#acquireSyncLock()) {
1490
+ await this.#reloadFromStorage();
1491
+ this.#state = "idle";
1492
+ this.#changeListener?.();
1493
+ return;
1494
+ }
1496
1495
  try {
1497
- const parsed = JSON.parse(value);
1498
- return Array.isArray(parsed) ? parsed : [];
1499
- } catch {
1500
- return [];
1496
+ while (this.#operations.length > 0) {
1497
+ const operation = this.#operations[0];
1498
+ if (operation === void 0) break;
1499
+ if (await this.#rejectFailedPrerequisite(operation)) continue;
1500
+ let attempt = 0;
1501
+ let response;
1502
+ while (true) {
1503
+ await this.#updateOperationStatus("IN_FLIGHT", attempt);
1504
+ try {
1505
+ response = this.#normalizeResponse(await sender(operation));
1506
+ } catch (cause) {
1507
+ response = {
1508
+ status: "RETRYABLE_ERROR",
1509
+ error: errorValue(cause, "RETRYABLE_ERROR")
1510
+ };
1511
+ }
1512
+ if (response.status !== "RETRYABLE_ERROR") break;
1513
+ const error2 = errorValue(response.error ?? response.reason, "RETRYABLE_ERROR");
1514
+ await this.#updateOperationStatus("PENDING", attempt + 1);
1515
+ await this.#syncResultListener?.({ operation, status: response.status, error: error2 });
1516
+ if (attempt >= this.#retryOptions.maxRetries) {
1517
+ await this.#updateOperationStatus("FAILED_RETRYABLE", attempt + 1);
1518
+ throw new Error(error2.message);
1519
+ }
1520
+ const interval = this.#retryOptions.initialIntervalMs * this.#retryOptions.backoffMultiplier ** attempt;
1521
+ await new Promise((resolve) => setTimeout(resolve, Math.max(0, interval)));
1522
+ attempt += 1;
1523
+ }
1524
+ const result = response.result;
1525
+ 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);
1526
+ const error = response.status === "REJECTED_PERMANENT" ? errorValue(
1527
+ response.error ?? response.reason ?? (result !== void 0 && "ok" in result && !result.ok ? result.error : void 0),
1528
+ "REJECTED_PERMANENT"
1529
+ ) : void 0;
1530
+ await this.#updateOperationStatus(
1531
+ response.status === "ACCEPTED" ? "ACCEPTED" : "REJECTED_PERMANENT",
1532
+ attempt + 1
1533
+ );
1534
+ const eventState = state;
1535
+ if (response.status === "REJECTED_PERMANENT" && error !== void 0) {
1536
+ this.#rejectedHistory = [
1537
+ ...this.#rejectedHistory,
1538
+ {
1539
+ operation: { ...operation, status: "REJECTED_PERMANENT", attempts: attempt + 1 },
1540
+ reason: error,
1541
+ errorCode: error.code,
1542
+ rejectedAt: (/* @__PURE__ */ new Date()).toISOString(),
1543
+ attempts: attempt + 1
1544
+ }
1545
+ ];
1546
+ await this.#saveRejectedHistory();
1547
+ }
1548
+ this.#operations = this.#operations.slice(1);
1549
+ await this.#storage.save(this.#storageKey(), this.#operations);
1550
+ this.#announceChange();
1551
+ await this.#syncResultListener?.({
1552
+ operation,
1553
+ ...result === void 0 ? {} : { result },
1554
+ status: response.status,
1555
+ ...error === void 0 ? {} : { error },
1556
+ ...eventState === void 0 ? {} : { state: eventState }
1557
+ });
1558
+ }
1559
+ this.#state = "idle";
1560
+ this.#changeListener?.();
1561
+ } catch (cause) {
1562
+ this.#state = "error";
1563
+ this.#error = cause instanceof Error ? cause : new Error(String(cause));
1564
+ this.#changeListener?.();
1565
+ throw this.#error;
1566
+ } finally {
1567
+ this.#releaseSyncLock();
1501
1568
  }
1502
1569
  }
1503
- async save(key, operations) {
1504
- this.storage.setItem(key, JSON.stringify(operations));
1570
+ async #updateOperationStatus(status, attempts) {
1571
+ const operation = this.#operations[0];
1572
+ if (operation === void 0) return;
1573
+ this.#operations = [{ ...operation, status, attempts }, ...this.#operations.slice(1)];
1574
+ await this.#storage.save(this.#storageKey(), this.#operations);
1575
+ this.#announceChange();
1505
1576
  }
1506
- async loadRejectedHistory(key) {
1507
- const value = this.storage.getItem(`${key}:rejected-history`);
1508
- if (value === null) return [];
1577
+ async #rejectFailedPrerequisite(operation) {
1578
+ if (operation.kind !== "checkIn" || this.#replayConfig === void 0) return false;
1579
+ const spot2 = this.#replayConfig.spots.find(
1580
+ (candidate) => candidate.id === operation.request.spotId
1581
+ );
1582
+ if (spot2 === void 0) return false;
1583
+ const failedSpots = new Set(
1584
+ this.#rejectedHistory.filter((entry) => entry.operation.kind === "checkIn").map(
1585
+ (entry) => entry.operation.kind === "checkIn" ? entry.operation.request.spotId : void 0
1586
+ ).filter((spotId) => spotId !== void 0)
1587
+ );
1588
+ if (!spot2.prerequisites?.some((prerequisite) => failedSpots.has(prerequisite))) return false;
1589
+ const error = {
1590
+ code: "REJECTED_PREREQUISITE_FAILED",
1591
+ message: "A prerequisite operation was rejected by the server."
1592
+ };
1593
+ const rejectedOperation = { ...operation, status: "REJECTED_PERMANENT" };
1594
+ this.#rejectedHistory = [
1595
+ ...this.#rejectedHistory,
1596
+ {
1597
+ operation: rejectedOperation,
1598
+ reason: error,
1599
+ errorCode: error.code,
1600
+ rejectedAt: (/* @__PURE__ */ new Date()).toISOString(),
1601
+ attempts: operation.attempts ?? 0
1602
+ }
1603
+ ];
1604
+ this.#operations = this.#operations.slice(1);
1605
+ await this.#storage.save(this.#storageKey(), this.#operations);
1606
+ await this.#saveRejectedHistory();
1607
+ this.#announceChange();
1608
+ await this.#syncResultListener?.({
1609
+ operation,
1610
+ status: "REJECTED_PERMANENT",
1611
+ error
1612
+ });
1613
+ return true;
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;
1509
1661
  try {
1510
- const parsed = JSON.parse(value);
1511
- return Array.isArray(parsed) ? parsed : [];
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;
1512
1666
  } catch {
1513
- return [];
1514
1667
  }
1515
1668
  }
1516
- async saveRejectedHistory(key, history) {
1517
- this.storage.setItem(`${key}:rejected-history`, JSON.stringify(history));
1669
+ #lockKey() {
1670
+ return `${this.#storageKey()}:sync-lock`;
1518
1671
  }
1519
- };
1520
- var IndexedDBOfflineQueueStorage = class {
1521
- #providedFactory;
1522
- #databaseName;
1523
- #databasePromise = null;
1524
- constructor(options = {}) {
1525
- this.#providedFactory = options.indexedDB;
1526
- this.#databaseName = options.databaseName ?? "stamprally-offline-queue";
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;
1527
1705
  }
1528
- async load(key) {
1529
- const database = await this.#open();
1530
- return new Promise((resolve, reject) => {
1531
- const request = database.transaction("operations", "readonly").objectStore("operations").get(key);
1532
- request.onsuccess = () => resolve(Array.isArray(request.result) ? request.result : []);
1533
- request.onerror = () => reject(request.error ?? new Error("Failed to read offline queue."));
1534
- });
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
+ }
1535
1719
  }
1536
- async save(key, operations) {
1537
- const database = await this.#open();
1538
- return new Promise((resolve, reject) => {
1539
- const transaction = database.transaction("operations", "readwrite");
1540
- transaction.objectStore("operations").put(structuredClone(operations), key);
1541
- transaction.oncomplete = () => resolve();
1542
- transaction.onerror = () => reject(transaction.error ?? new Error("Failed to save offline queue."));
1543
- transaction.onabort = () => reject(transaction.error ?? new Error("Offline queue write aborted."));
1544
- });
1720
+ #storageKey() {
1721
+ if (this.#configuredKey !== void 0) return this.#configuredKey;
1722
+ return `stamprally:queue:${this.#rallyId ?? "unscoped"}:${this.#userId ?? "anonymous"}`;
1545
1723
  }
1546
- async loadRejectedHistory(key) {
1547
- const database = await this.#open();
1548
- return new Promise((resolve, reject) => {
1549
- const request = database.transaction("operations", "readonly").objectStore("operations").get(`${key}:rejected-history`);
1550
- request.onsuccess = () => resolve(
1551
- Array.isArray(request.result) ? request.result : []
1552
- );
1553
- request.onerror = () => reject(request.error ?? new Error("Failed to read rejected operation history."));
1554
- });
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 };
1555
1738
  }
1556
- async saveRejectedHistory(key, history) {
1557
- const database = await this.#open();
1558
- return new Promise((resolve, reject) => {
1559
- const transaction = database.transaction("operations", "readwrite");
1560
- transaction.objectStore("operations").put(structuredClone(history), `${key}:rejected-history`);
1561
- transaction.oncomplete = () => resolve();
1562
- transaction.onerror = () => reject(transaction.error ?? new Error("Failed to save rejected operation history."));
1563
- transaction.onabort = () => reject(transaction.error ?? new Error("Rejected operation history write aborted."));
1564
- });
1739
+ async #saveRejectedHistory() {
1740
+ await this.#storage.saveRejectedHistory?.(this.#storageKey(), this.#rejectedHistory);
1565
1741
  }
1566
- #open() {
1567
- if (this.#databasePromise !== null) return this.#databasePromise;
1568
- let factory = this.#providedFactory;
1569
- if (factory === void 0)
1570
- factory = globalThis.indexedDB;
1571
- if (factory === void 0 || factory === null)
1572
- return Promise.reject(new Error("IndexedDB is unavailable in this environment."));
1573
- this.#databasePromise = new Promise((resolve, reject) => {
1574
- const request = factory.open(this.#databaseName, 1);
1575
- request.onupgradeneeded = () => {
1576
- if (!request.result.objectStoreNames.contains("operations"))
1577
- request.result.createObjectStore("operations");
1578
- };
1579
- request.onsuccess = () => resolve(request.result);
1580
- request.onerror = () => reject(request.error ?? new Error("Failed to open offline queue."));
1581
- }).catch((error) => {
1582
- this.#databasePromise = null;
1583
- throw error;
1742
+ #warnMemoryLock(message) {
1743
+ if (this.#warnedMemoryLock) return;
1744
+ this.#warnedMemoryLock = true;
1745
+ console.warn(`[@stamprally/core] ${message}`);
1746
+ this.#capabilityWarningListener?.({
1747
+ type: "STORAGE_CAPABILITY_WARNING",
1748
+ storageCapability: this.storageCapability === "memory" ? "memory" : "volatile_single_tab",
1749
+ isStoragePersistent: this.isStoragePersistent,
1750
+ message
1584
1751
  });
1585
- return this.#databasePromise;
1586
1752
  }
1587
1753
  };
1588
- function availableLocalStorage() {
1589
- try {
1590
- const storage = globalThis.localStorage;
1591
- return storage ?? null;
1592
- } catch {
1593
- return null;
1594
- }
1595
- }
1596
- function defaultStorage(databaseName) {
1597
- try {
1598
- const indexedDB = globalThis.indexedDB;
1599
- if (indexedDB !== void 0)
1600
- return {
1601
- storage: new IndexedDBOfflineQueueStorage({
1602
- indexedDB,
1603
- ...databaseName === void 0 ? {} : { databaseName }
1604
- }),
1605
- capability: "indexeddb"
1606
- };
1607
- const storage = availableLocalStorage();
1608
- if (storage !== void 0 && storage !== null)
1609
- return { storage: new LocalStorageQueueStorage(storage), capability: "localstorage" };
1610
- } catch {
1611
- }
1612
- return { storage: new MemoryQueueStorage(), capability: "memory" };
1613
- }
1614
- function offlineOperationId(operation) {
1615
- const identity = operation.request.userId ?? operation.request.anonymousSessionId ?? "anonymous";
1616
- return operation.kind === "checkIn" ? `checkIn:${operation.request.rallyId}:${identity}:${operation.request.idempotencyKey}` : `claimReward:${operation.request.rallyId}:${identity}:${operation.request.idempotencyKey}`;
1617
- }
1618
- function requestScope(operation) {
1754
+ function normalizeOperation(operation) {
1755
+ const status = operation.status;
1619
1756
  return {
1620
- rallyId: operation.request.rallyId,
1621
- userId: operation.request.userId
1757
+ ...operation,
1758
+ status: status === "IN_FLIGHT" || status === "REJECTED" ? "PENDING" : status === "RETRYABLE_ERROR" ? "FAILED_RETRYABLE" : operation.status ?? "PENDING",
1759
+ attempts: operation.attempts ?? 0
1622
1760
  };
1623
1761
  }
1624
- function errorValue(value, fallbackCode) {
1625
- if (typeof value === "object" && value !== null) {
1626
- const candidate = value;
1627
- if (typeof candidate.code === "string" && typeof candidate.message === "string")
1628
- return { ...candidate, code: candidate.code, message: candidate.message };
1629
- }
1630
- if (value instanceof Error) return { code: fallbackCode, message: value.message };
1631
- if (typeof value === "string") return { code: fallbackCode, message: value };
1632
- return { code: fallbackCode, message: "Offline operation was rejected." };
1762
+ function normalizeRejectedHistory(entry) {
1763
+ return {
1764
+ ...entry,
1765
+ operation: normalizeOperation(entry.operation),
1766
+ reason: errorValue(entry.reason, "REJECTED_PERMANENT"),
1767
+ errorCode: entry.errorCode || entry.reason.code,
1768
+ rejectedAt: entry.rejectedAt || (/* @__PURE__ */ new Date(0)).toISOString(),
1769
+ attempts: entry.attempts ?? entry.operation.attempts ?? 0
1770
+ };
1633
1771
  }
1634
- var syncLocks = /* @__PURE__ */ new Map();
1635
- var SYNC_LOCK_TTL_MS = 3e4;
1636
- var DEFAULT_RETRY_OPTIONS = {
1637
- maxRetries: 0,
1638
- initialIntervalMs: 250,
1639
- backoffMultiplier: 2
1640
- };
1641
- function randomId() {
1642
- return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
1772
+
1773
+ // src/client/sync.ts
1774
+ function isReplayable(operation) {
1775
+ return operation.status === void 0 || operation.status === "ACCEPTED" || operation.status === "PENDING" || operation.status === "IN_FLIGHT" || operation.status === "FAILED_RETRYABLE";
1643
1776
  }
1644
- var OfflineQueue = class {
1645
- #storage;
1646
- #queueCapability;
1647
- #configuredKey;
1648
- #rallyId;
1649
- #userId;
1650
- #conflictPolicy;
1651
- #onSyncConflict;
1652
- #operations = [];
1653
- #rejectedHistory = [];
1654
- #loaded = false;
1655
- #state = "idle";
1656
- #error = null;
1657
- #sender;
1658
- #syncPromise = null;
1659
- #syncResultListener;
1660
- #synchronizeInstances;
1661
- #retryOptions;
1662
- #instanceId = randomId();
1663
- #lockStorage;
1664
- #observedLocks = /* @__PURE__ */ new Map();
1665
- #warnedMemoryLock = false;
1666
- #storageListener;
1667
- #channel = null;
1668
- constructor(options = {}) {
1669
- if (options.storage !== void 0) {
1670
- this.#storage = options.storage;
1671
- this.#queueCapability = "custom";
1672
- } else if (options.storageLike !== void 0 && options.storageLike !== null) {
1673
- this.#storage = new LocalStorageQueueStorage(options.storageLike);
1674
- this.#queueCapability = "localstorage";
1675
- } else {
1676
- const selected = defaultStorage(options.databaseName);
1677
- this.#storage = selected.storage;
1678
- this.#queueCapability = selected.capability;
1777
+ function operationId(operation) {
1778
+ const identity = operation.request.userId ?? operation.request.anonymousSessionId ?? "anonymous";
1779
+ return `${operation.kind}:${operation.request.rallyId}:${identity}:${operation.request.idempotencyKey}`;
1780
+ }
1781
+ function applyInventoryDelta(state, previous, optimistic) {
1782
+ const previousInventory = previous.inventory;
1783
+ const optimisticInventory = optimistic.inventory;
1784
+ if (previousInventory === void 0 || optimisticInventory === void 0) return state;
1785
+ const currentInventory = state.inventory ?? {};
1786
+ const sharedDelta = previousInventory.sharedRemaining === void 0 || optimisticInventory.sharedRemaining === void 0 ? void 0 : optimisticInventory.sharedRemaining - previousInventory.sharedRemaining;
1787
+ const previousRewards = previousInventory.rewardRemaining ?? {};
1788
+ const optimisticRewards = optimisticInventory.rewardRemaining ?? {};
1789
+ const currentRewards = currentInventory.rewardRemaining ?? {};
1790
+ const rewardRemaining = { ...currentRewards };
1791
+ for (const key of /* @__PURE__ */ new Set([...Object.keys(previousRewards), ...Object.keys(optimisticRewards)])) {
1792
+ const before = previousRewards[key];
1793
+ const after = optimisticRewards[key];
1794
+ if (before !== void 0 && after !== void 0)
1795
+ rewardRemaining[key] = Math.max(0, (currentRewards[key] ?? before) + after - before);
1796
+ }
1797
+ return {
1798
+ ...state,
1799
+ inventory: {
1800
+ ...currentInventory.sharedRemaining === void 0 || sharedDelta === void 0 ? {} : { sharedRemaining: Math.max(0, currentInventory.sharedRemaining + sharedDelta) },
1801
+ ...Object.keys(rewardRemaining).length === 0 ? {} : { rewardRemaining }
1679
1802
  }
1680
- this.#configuredKey = options.key;
1681
- this.#rallyId = options.rallyId;
1682
- this.#userId = options.userId ?? null;
1683
- this.#conflictPolicy = options.conflictPolicy ?? "server_wins";
1684
- this.#onSyncConflict = options.onSyncConflict;
1685
- this.#synchronizeInstances = options.synchronizeInstances ?? true;
1686
- const retryOptions = {
1687
- ...DEFAULT_RETRY_OPTIONS,
1688
- ...options.retryOptions ?? options.syncRetryOptions ?? options.retry ?? {}
1803
+ };
1804
+ }
1805
+ function applyOperation(state, operation, config) {
1806
+ if (operation.kind === "checkIn") {
1807
+ const spot2 = config?.spots.find((candidate) => candidate.id === operation.request.spotId);
1808
+ if (spot2?.prerequisites?.some((id2) => !state.records.some((record2) => record2.stampId === id2)))
1809
+ return { state, prerequisiteFailed: true };
1810
+ if (state.records.some((record2) => record2.stampId === operation.request.spotId))
1811
+ return { state, prerequisiteFailed: false };
1812
+ const optimisticRecord = operation.optimisticState?.records.find(
1813
+ (record2) => record2.stampId === operation.request.spotId
1814
+ );
1815
+ const record = optimisticRecord ?? {
1816
+ stampId: operation.request.spotId,
1817
+ acquiredAt: operation.request.now
1689
1818
  };
1690
- this.#retryOptions = {
1691
- maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
1692
- initialIntervalMs: Math.max(0, retryOptions.initialIntervalMs),
1693
- backoffMultiplier: Math.max(1, retryOptions.backoffMultiplier)
1819
+ const records = [...state.records, { ...record }];
1820
+ const rewards2 = config === void 0 ? state.rewards : reconcileRewardStates(config.rewards, state.rewards, records.length, record.acquiredAt);
1821
+ return {
1822
+ state: { ...state, records, rewards: rewards2, updatedAt: record.acquiredAt },
1823
+ prerequisiteFailed: false
1694
1824
  };
1695
- this.#lockStorage = options.storageLike ?? availableLocalStorage();
1696
- if (this.#synchronizeInstances) this.#subscribeToExternalChanges();
1697
1825
  }
1698
- get syncState() {
1699
- return this.#state;
1826
+ const optimisticState = operation.optimisticState;
1827
+ if (optimisticState === void 0) return { state, prerequisiteFailed: false };
1828
+ const optimisticReward = optimisticState?.rewards.find(
1829
+ (reward2) => reward2.rewardId === operation.request.rewardId
1830
+ );
1831
+ if (optimisticReward === void 0) return { state, prerequisiteFailed: false };
1832
+ const rewards = state.rewards.some((reward2) => reward2.rewardId === optimisticReward.rewardId) ? state.rewards.map(
1833
+ (reward2) => reward2.rewardId === optimisticReward.rewardId ? { ...optimisticReward } : reward2
1834
+ ) : [...state.rewards, { ...optimisticReward }];
1835
+ return {
1836
+ state: applyInventoryDelta(
1837
+ { ...state, rewards, updatedAt: optimisticReward.consumedAt ?? state.updatedAt },
1838
+ operation.request.state,
1839
+ optimisticState
1840
+ ),
1841
+ prerequisiteFailed: false
1842
+ };
1843
+ }
1844
+ function rebuildUserStateFromLog(baselineOrOptions, operationsArgument, configArgument) {
1845
+ const { state } = rebuildUserStateLog(
1846
+ "baseline" in baselineOrOptions ? baselineOrOptions.baseline : baselineOrOptions,
1847
+ "baseline" in baselineOrOptions ? baselineOrOptions.operations : operationsArgument ?? [],
1848
+ "baseline" in baselineOrOptions ? baselineOrOptions.config : configArgument
1849
+ );
1850
+ return state;
1851
+ }
1852
+ function rebuildUserStateLog(baseline, operations, config) {
1853
+ let state = cloneState(baseline);
1854
+ const rejectedOperationIds = [];
1855
+ for (const operation of operations) {
1856
+ if (!isReplayable(operation)) continue;
1857
+ const replay = applyOperation(state, operation, config);
1858
+ if (replay.prerequisiteFailed) {
1859
+ rejectedOperationIds.push(operationId(operation));
1860
+ continue;
1861
+ }
1862
+ state = replay.state;
1700
1863
  }
1701
- get pendingCount() {
1702
- return this.#operations.length;
1864
+ return { state, rejectedOperationIds };
1865
+ }
1866
+
1867
+ // src/client/client.ts
1868
+ function isStorage(value) {
1869
+ return "load" in value && "save" in value && "remove" in value;
1870
+ }
1871
+ function id(prefix) {
1872
+ return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`;
1873
+ }
1874
+ function proof(value) {
1875
+ if (typeof value === "string") return value;
1876
+ if (typeof value === "object" && value !== null) {
1877
+ const item = value;
1878
+ for (const key of ["token", "code", "passcode", "value", "tagId"])
1879
+ if (typeof item[key] === "string") return item[key];
1703
1880
  }
1704
- get queueCapability() {
1705
- return this.#queueCapability;
1881
+ return "";
1882
+ }
1883
+ function matches(condition2, value) {
1884
+ if (condition2.type === "gps") {
1885
+ if (typeof value !== "object" || value === null) return false;
1886
+ const item = value;
1887
+ const latitude = item.latitude;
1888
+ const longitude = item.longitude;
1889
+ if (typeof latitude !== "number" || typeof longitude !== "number") return false;
1890
+ const radians = (v) => v * Math.PI / 180;
1891
+ const dLat = radians(latitude - condition2.latitude);
1892
+ const dLon = radians(longitude - condition2.longitude);
1893
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(radians(condition2.latitude)) * Math.cos(radians(latitude)) * Math.sin(dLon / 2) ** 2;
1894
+ return 6371e3 * 2 * Math.asin(Math.sqrt(Math.min(1, h))) <= condition2.radiusMeters;
1706
1895
  }
1707
- get rejectedHistory() {
1708
- return this.#rejectedHistory;
1896
+ return proof(value).trim() !== "";
1897
+ }
1898
+ function emptyState(config, userId, now) {
1899
+ return {
1900
+ rallyId: config.id,
1901
+ userId,
1902
+ records: [],
1903
+ rewards: reconcileRewardStates(config.rewards, [], 0, now),
1904
+ updatedAt: now
1905
+ };
1906
+ }
1907
+ function errorMessage(error, fallback) {
1908
+ return error !== void 0 && "message" in error && typeof error.message === "string" ? error.message : fallback;
1909
+ }
1910
+ var StampRallyClient = class {
1911
+ #listeners = /* @__PURE__ */ new Set();
1912
+ #eventListeners = /* @__PURE__ */ new Set();
1913
+ #syncEventListeners = /* @__PURE__ */ new Set();
1914
+ #storage;
1915
+ #options;
1916
+ #config;
1917
+ #offlineQueue;
1918
+ #userId;
1919
+ #anonymousSessionId;
1920
+ #state = null;
1921
+ #initialization = null;
1922
+ #queue = Promise.resolve();
1923
+ #syncRevision = 0;
1924
+ #syncMetrics = null;
1925
+ constructor(config, storageOrOptions = {}) {
1926
+ this.#config = config;
1927
+ this.#options = isStorage(storageOrOptions) ? { storage: storageOrOptions } : storageOrOptions;
1928
+ this.#storage = this.#options.storage ?? new InMemoryStorage();
1929
+ this.#anonymousSessionId = this.#options.anonymousSessionId ?? createAnonymousSessionId();
1930
+ this.#userId = this.#options.userId ?? this.#anonymousSessionId;
1931
+ this.#offlineQueue = this.#options.offlineQueue;
1932
+ this.#offlineQueue?.setReplayConfig(config);
1933
+ this.#offlineQueue?.setSyncResultListener((event) => this.#handleOfflineSyncResult(event));
1934
+ this.#offlineQueue?.setChangeListener(() => {
1935
+ this.#syncRevision += 1;
1936
+ if (this.#state !== null) this.#emit(this.#state);
1937
+ });
1709
1938
  }
1710
- get error() {
1711
- return this.#error;
1939
+ getConfig() {
1940
+ return this.#config;
1712
1941
  }
1713
- get operations() {
1714
- return this.#operations;
1942
+ getState() {
1943
+ return this.#state;
1715
1944
  }
1716
- get conflictPolicy() {
1717
- return this.#conflictPolicy;
1945
+ getUserId() {
1946
+ return this.#userId;
1718
1947
  }
1719
- get storageKey() {
1720
- return this.#storageKey();
1948
+ getAnonymousSessionId() {
1949
+ return this.#anonymousSessionId;
1721
1950
  }
1722
- get rallyId() {
1723
- return this.#rallyId;
1951
+ get syncState() {
1952
+ return this.#offlineQueue?.syncState ?? "idle";
1724
1953
  }
1725
- get userId() {
1726
- return this.#userId;
1954
+ get pendingCount() {
1955
+ return this.#offlineQueue?.pendingCount ?? 0;
1727
1956
  }
1728
- setSyncResultListener(listener) {
1729
- this.#syncResultListener = listener;
1957
+ get rejectedHistory() {
1958
+ return this.#offlineQueue?.rejectedHistory ?? [];
1730
1959
  }
1731
- async initialize() {
1732
- if (this.#loaded) return;
1733
- try {
1734
- const key = this.#storageKey();
1735
- this.#operations = (await this.#storage.load(key)).map(normalizeOperation);
1736
- this.#rejectedHistory = (await this.#storage.loadRejectedHistory?.(key))?.map(normalizeRejectedHistory) ?? [];
1737
- } catch (error) {
1738
- if (this.#queueCapability === "memory") throw error;
1739
- this.#storage = new MemoryQueueStorage();
1740
- this.#queueCapability = "memory";
1741
- this.#operations = [];
1742
- this.#rejectedHistory = [];
1743
- this.#warnMemoryLock(
1744
- `Offline queue persistence is unavailable; queued data can be lost after reload (${String(error)}).`
1745
- );
1746
- }
1747
- this.#loaded = true;
1960
+ getSyncRevision() {
1961
+ return this.#syncRevision;
1748
1962
  }
1749
- /** Releases browser listeners when the queue is no longer used. */
1750
- dispose() {
1751
- const windowLike = globalThis.window;
1752
- if (windowLike !== void 0 && this.#storageListener !== void 0)
1753
- windowLike.removeEventListener("storage", this.#storageListener);
1754
- this.#storageListener = void 0;
1755
- this.#channel?.close();
1756
- this.#channel = null;
1757
- this.#releaseSyncLock();
1963
+ get queueCapability() {
1964
+ return this.#offlineQueue?.queueCapability ?? "custom";
1758
1965
  }
1759
- /** Selects a rally/user queue scope and loads its pending operations. */
1760
- async setScope(rallyId, userId) {
1761
- if (this.#configuredKey !== void 0) {
1762
- this.#rallyId = rallyId;
1763
- this.#userId = userId;
1764
- return this.initialize();
1765
- }
1766
- if (this.#rallyId === rallyId && this.#userId === userId && this.#loaded) return;
1767
- this.#rallyId = rallyId;
1768
- this.#userId = userId;
1769
- this.#operations = [];
1770
- this.#loaded = false;
1771
- await this.initialize();
1966
+ get storageCapability() {
1967
+ return this.#offlineQueue?.storageCapability ?? "custom";
1772
1968
  }
1773
- async switchUser(newUserId) {
1774
- if (this.#rallyId === void 0)
1775
- throw new Error("OfflineQueue.switchUser requires a rally scope.");
1776
- await this.setScope(this.#rallyId, newUserId);
1969
+ get isStoragePersistent() {
1970
+ return this.#offlineQueue?.isStoragePersistent ?? true;
1777
1971
  }
1778
- setSender(sender) {
1779
- this.#sender = sender;
1972
+ discardRejected(operationId2) {
1973
+ return this.#offlineQueue?.discardRejected(operationId2) ?? Promise.resolve(false);
1780
1974
  }
1781
- async enqueue(operation) {
1782
- if (this.#configuredKey === void 0) {
1783
- const scope = requestScope(operation);
1784
- if (this.#rallyId === void 0) await this.setScope(scope.rallyId, scope.userId);
1785
- if (this.#rallyId !== scope.rallyId || this.#userId !== scope.userId)
1786
- throw new Error("Offline operation belongs to another rally or user queue.");
1787
- }
1788
- await this.initialize();
1789
- const id2 = offlineOperationId(operation);
1790
- if (this.#operations.some((item) => offlineOperationId(item) === id2)) return;
1791
- this.#operations = [...this.#operations, { ...operation, status: "PENDING", attempts: 0 }];
1792
- await this.#storage.save(this.#storageKey(), this.#operations);
1793
- this.#announceChange();
1975
+ retryRejected(operationId2) {
1976
+ return this.#offlineQueue?.retryRejected(operationId2) ?? Promise.resolve(false);
1794
1977
  }
1795
- async enqueueCheckIn(request) {
1796
- return this.enqueue({ kind: "checkIn", request });
1978
+ dismissRejectedOperation(operationId2) {
1979
+ return this.discardRejected(operationId2);
1797
1980
  }
1798
- async enqueueClaimReward(request) {
1799
- return this.enqueue({ kind: "claimReward", request });
1981
+ retryOperation(operationId2) {
1982
+ return this.retryRejected(operationId2);
1800
1983
  }
1801
- async clear() {
1802
- await this.initialize();
1803
- this.#operations = [];
1804
- await this.#storage.save(this.#storageKey(), this.#operations);
1805
- this.#announceChange();
1984
+ subscribe(listener) {
1985
+ this.#listeners.add(listener);
1986
+ return () => this.#listeners.delete(listener);
1806
1987
  }
1807
- async discardRejected(operationId) {
1808
- await this.initialize();
1809
- const next = this.#rejectedHistory.filter(
1810
- (entry) => offlineOperationId(entry.operation) !== operationId
1811
- );
1812
- if (next.length === this.#rejectedHistory.length) return false;
1813
- this.#rejectedHistory = next;
1814
- await this.#saveRejectedHistory();
1815
- this.#announceChange();
1816
- return true;
1988
+ subscribeEvents(listener) {
1989
+ this.#eventListeners.add(listener);
1990
+ return () => this.#eventListeners.delete(listener);
1817
1991
  }
1818
- async retryRejected(operationId) {
1819
- await this.initialize();
1820
- const entry = this.#rejectedHistory.find(
1821
- (candidate) => offlineOperationId(candidate.operation) === operationId
1822
- );
1823
- if (entry === void 0) return false;
1824
- if (!this.#operations.some((operation) => offlineOperationId(operation) === operationId))
1825
- this.#operations = [
1826
- ...this.#operations,
1827
- { ...entry.operation, status: "PENDING", attempts: 0 }
1828
- ];
1829
- this.#rejectedHistory = this.#rejectedHistory.filter((candidate) => candidate !== entry);
1830
- await this.#storage.save(this.#storageKey(), this.#operations);
1831
- await this.#saveRejectedHistory();
1832
- this.#announceChange();
1833
- return true;
1992
+ subscribeSyncEvents(listener) {
1993
+ this.#syncEventListeners.add(listener);
1994
+ return () => this.#syncEventListeners.delete(listener);
1834
1995
  }
1835
- async discardRejectedOperation(operationId) {
1836
- return this.discardRejected(operationId);
1996
+ subscribeSyncState(listener) {
1997
+ const wrapped = () => listener();
1998
+ this.#listeners.add(wrapped);
1999
+ return () => this.#listeners.delete(wrapped);
1837
2000
  }
1838
- async retryRejectedOperation(operationId) {
1839
- return this.retryRejected(operationId);
2001
+ init() {
2002
+ return this.initialize();
1840
2003
  }
1841
- async clearRejectedHistory() {
1842
- await this.initialize();
1843
- if (this.#rejectedHistory.length === 0) return;
1844
- this.#rejectedHistory = [];
1845
- await this.#saveRejectedHistory();
1846
- this.#announceChange();
2004
+ initialize() {
2005
+ if (this.#state !== null) return Promise.resolve(this.#state);
2006
+ if (this.#initialization === null) {
2007
+ this.#initialization = (async () => {
2008
+ await this.#offlineQueue?.setScope(this.#config.id, this.#userId);
2009
+ return this.#storage.load(this.#config.id, this.#userId);
2010
+ })().then((state) => {
2011
+ const next = state === null ? emptyState(this.#config, this.#userId, this.#now()) : this.#reconcile(state);
2012
+ this.#state = next;
2013
+ this.#emit(next);
2014
+ return next;
2015
+ }).catch((error) => {
2016
+ this.#initialization = null;
2017
+ throw error;
2018
+ });
2019
+ }
2020
+ return this.#initialization;
1847
2021
  }
1848
- async sync(sender = this.#sender) {
1849
- await this.initialize();
1850
- if (sender === void 0) throw new Error("OfflineQueue.sync requires a sender.");
1851
- if (this.#syncPromise !== null) return this.#syncPromise;
1852
- this.#sender = sender;
1853
- this.#syncPromise = this.#run(sender).finally(() => {
1854
- this.#syncPromise = null;
2022
+ switchUser(newUserId) {
2023
+ return this.#enqueue(async () => {
2024
+ const nextUserId = newUserId ?? this.#anonymousSessionId;
2025
+ if (this.#userId === nextUserId && this.#state !== null) return this.#state;
2026
+ this.#userId = nextUserId;
2027
+ this.#state = null;
2028
+ this.#initialization = null;
2029
+ await this.#offlineQueue?.switchUser(nextUserId);
2030
+ return this.initialize();
2031
+ });
2032
+ }
2033
+ async getUserState(rallyId, userId) {
2034
+ return this.#storage.load(rallyId, userId);
2035
+ }
2036
+ clearUserState(userId = this.#userId) {
2037
+ return this.#enqueue(async () => {
2038
+ await this.#storage.remove(this.#config.id, userId);
2039
+ if (userId === this.#userId) {
2040
+ await this.#initializeFresh();
2041
+ }
1855
2042
  });
1856
- return this.#syncPromise;
1857
- }
1858
- async retrySync(sender = this.#sender) {
1859
- return this.sync(sender);
1860
2043
  }
1861
- async #run(sender) {
1862
- const locks = globalThis.navigator?.locks;
1863
- if (locks !== void 0 && typeof locks.request === "function") {
1864
- let callbackStarted = false;
1865
- try {
1866
- const acquired = await locks.request(
1867
- `stamprally:${this.#storageKey()}:sync`,
1868
- { ifAvailable: true },
1869
- async (lock) => {
1870
- if (lock === null) {
1871
- await this.#reloadFromStorage();
1872
- this.#state = "idle";
1873
- return false;
1874
- }
1875
- callbackStarted = true;
1876
- await this.#runWithStorageLock(sender);
1877
- return true;
1878
- }
1879
- );
1880
- if (!acquired) return;
1881
- return;
1882
- } catch (error) {
1883
- if (callbackStarted) throw error;
2044
+ checkIn(spotId, proofData, options = {}) {
2045
+ return this.#enqueue(async () => {
2046
+ const current = await this.initialize();
2047
+ const spot2 = this.#config.spots.find((item) => item.id === spotId);
2048
+ if (spot2 === void 0)
2049
+ return this.#fail({ code: "SPOT_NOT_FOUND", spotId, message: "Spot was not found." });
2050
+ if (current.records.some((record2) => record2.stampId === spotId))
2051
+ return this.#fail({
2052
+ code: "STAMP_ALREADY_ACQUIRED",
2053
+ spotId,
2054
+ message: "Spot was already claimed."
2055
+ });
2056
+ const acquired = new Set(current.records.map((record2) => record2.stampId));
2057
+ if (spot2.prerequisites?.some((id2) => !acquired.has(id2)))
2058
+ return this.#fail({
2059
+ code: "PREREQUISITES_NOT_MET",
2060
+ spotId,
2061
+ message: "Prerequisite spots are not complete."
2062
+ });
2063
+ for (const condition2 of spot2.conditions) {
2064
+ if (condition2.type === "custom") {
2065
+ const validator = this.#options.customValidators?.[condition2.validatorName] ?? this.#options.customValidator;
2066
+ if (validator === void 0)
2067
+ return this.#fail({
2068
+ code: "CUSTOM_VALIDATION_FAILED",
2069
+ spotId,
2070
+ message: "No custom validator is registered."
2071
+ });
2072
+ const context = {
2073
+ rallyId: this.#config.id,
2074
+ spotId,
2075
+ proofData,
2076
+ condition: { type: "custom", validatorName: condition2.validatorName },
2077
+ userState: current
2078
+ };
2079
+ const result = typeof validator === "function" ? await validator(context) : await validator.validate(context);
2080
+ if (result === false || typeof result === "object" && !result.valid)
2081
+ return this.#fail({
2082
+ code: "CUSTOM_VALIDATION_FAILED",
2083
+ spotId,
2084
+ message: typeof result === "object" && result.message !== void 0 ? result.message : "Custom validation failed."
2085
+ });
2086
+ } else if (!matches(condition2, proofData))
2087
+ return this.#fail({ code: "INVALID_PROOF", spotId, message: "Verification failed." });
1884
2088
  }
1885
- }
1886
- if (this.#lockStorage === null)
1887
- this.#warnMemoryLock(
1888
- "No cross-tab storage lock is available; offline sync is single-tab only."
1889
- );
1890
- await this.#runWithStorageLock(sender);
1891
- }
1892
- async #runWithStorageLock(sender) {
1893
- this.#state = "syncing";
1894
- this.#error = null;
1895
- if (!this.#acquireSyncLock()) {
1896
- await this.#reloadFromStorage();
1897
- this.#state = "idle";
1898
- return;
1899
- }
1900
- try {
1901
- while (this.#operations.length > 0) {
1902
- const operation = this.#operations[0];
1903
- if (operation === void 0) break;
1904
- let attempt = 0;
1905
- let response;
1906
- while (true) {
1907
- await this.#updateOperationStatus("IN_FLIGHT", attempt);
1908
- try {
1909
- response = this.#normalizeResponse(await sender(operation));
1910
- } catch (cause) {
1911
- response = {
1912
- status: "RETRYABLE_ERROR",
1913
- error: errorValue(cause, "RETRYABLE_ERROR")
1914
- };
1915
- }
1916
- if (response.status !== "RETRYABLE_ERROR") break;
1917
- const error2 = errorValue(response.error ?? response.reason, "RETRYABLE_ERROR");
1918
- await this.#updateOperationStatus("PENDING", attempt + 1);
1919
- await this.#syncResultListener?.({ operation, status: response.status, error: error2 });
1920
- if (attempt >= this.#retryOptions.maxRetries) {
1921
- await this.#updateOperationStatus("FAILED_RETRYABLE", attempt + 1);
1922
- throw new Error(error2.message);
1923
- }
1924
- const interval = this.#retryOptions.initialIntervalMs * this.#retryOptions.backoffMultiplier ** attempt;
1925
- await new Promise((resolve) => setTimeout(resolve, Math.max(0, interval)));
1926
- attempt += 1;
1927
- }
1928
- const result = response.result;
1929
- const state = response.state ?? (result !== void 0 && "conflict" in result && result.conflict === true ? result.serverState : result !== void 0 && "ok" in result && result.ok ? result.value.state : void 0);
1930
- const error = response.status === "REJECTED_PERMANENT" ? errorValue(
1931
- response.error ?? response.reason ?? (result !== void 0 && "ok" in result && !result.ok ? result.error : void 0),
1932
- "REJECTED_PERMANENT"
1933
- ) : void 0;
1934
- await this.#updateOperationStatus(
1935
- response.status === "ACCEPTED" ? "ACCEPTED" : "REJECTED_PERMANENT",
1936
- attempt + 1
1937
- );
1938
- const eventState = state;
1939
- if (response.status === "REJECTED_PERMANENT" && error !== void 0) {
1940
- this.#rejectedHistory = [
1941
- ...this.#rejectedHistory,
1942
- {
1943
- operation: { ...operation, status: "REJECTED_PERMANENT", attempts: attempt + 1 },
1944
- reason: error,
1945
- errorCode: error.code,
1946
- rejectedAt: (/* @__PURE__ */ new Date()).toISOString(),
1947
- attempts: attempt + 1
1948
- }
1949
- ];
1950
- await this.#saveRejectedHistory();
2089
+ const now = options.now ?? this.#now();
2090
+ const request = {
2091
+ rallyId: this.#config.id,
2092
+ userId: this.#userId,
2093
+ spotId,
2094
+ proofData,
2095
+ idempotencyKey: options.idempotencyKey ?? id("check-in"),
2096
+ now,
2097
+ state: current,
2098
+ ...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
2099
+ };
2100
+ const remote = this.#options.syncAdapter?.checkIn;
2101
+ if (options.sync !== false && remote !== void 0) {
2102
+ try {
2103
+ const result = await remote(request);
2104
+ return result.ok ? this.#commitCheckIn(result) : this.#fail(result.error);
2105
+ } catch (error) {
2106
+ if (this.#offlineQueue === void 0) throw error;
2107
+ const record2 = { stampId: spotId, acquiredAt: now };
2108
+ const next2 = this.#reconcile({
2109
+ ...current,
2110
+ records: [...current.records, record2],
2111
+ updatedAt: now
2112
+ });
2113
+ await this.#offlineQueue.enqueueCheckIn(request, next2);
2114
+ await this.#storage.save(next2);
2115
+ return this.#commitCheckIn({ ok: true, value: { state: next2, record: record2 } });
1951
2116
  }
1952
- this.#operations = this.#operations.slice(1);
1953
- await this.#storage.save(this.#storageKey(), this.#operations);
1954
- this.#announceChange();
1955
- await this.#syncResultListener?.({
1956
- operation,
1957
- ...result === void 0 ? {} : { result },
1958
- status: response.status,
1959
- ...error === void 0 ? {} : { error },
1960
- ...eventState === void 0 ? {} : { state: eventState }
1961
- });
1962
2117
  }
1963
- this.#state = "idle";
1964
- } catch (cause) {
1965
- this.#state = "error";
1966
- this.#error = cause instanceof Error ? cause : new Error(String(cause));
1967
- throw this.#error;
1968
- } finally {
1969
- this.#releaseSyncLock();
1970
- }
1971
- }
1972
- async #updateOperationStatus(status, attempts) {
1973
- const operation = this.#operations[0];
1974
- if (operation === void 0) return;
1975
- this.#operations = [{ ...operation, status, attempts }, ...this.#operations.slice(1)];
1976
- await this.#storage.save(this.#storageKey(), this.#operations);
1977
- this.#announceChange();
2118
+ const record = { stampId: spotId, acquiredAt: now };
2119
+ const next = this.#reconcile({
2120
+ ...current,
2121
+ records: [...current.records, record],
2122
+ updatedAt: now
2123
+ });
2124
+ await this.#storage.save(next);
2125
+ return this.#commitCheckIn({ ok: true, value: { state: next, record } });
2126
+ });
1978
2127
  }
1979
- #subscribeToExternalChanges() {
1980
- const windowLike = globalThis.window;
1981
- if (windowLike !== void 0) {
1982
- this.#storageListener = (event) => {
1983
- if (event.key === this.#storageKey() || event.key === `${this.#storageKey()}:rejected-history`)
1984
- void this.#reloadFromStorage();
2128
+ claimReward(rewardId, options = {}) {
2129
+ return this.#enqueue(async () => {
2130
+ const current = await this.initialize();
2131
+ const configured = this.#config.rewards.find((item) => item.id === rewardId);
2132
+ if (configured === void 0)
2133
+ return this.#fail({ code: "REWARD_NOT_FOUND", rewardId, message: "Reward was not found." });
2134
+ const now = options.now ?? this.#now();
2135
+ const state = current.rewards.find((item) => item.rewardId === rewardId) ?? {
2136
+ rewardId,
2137
+ status: "LOCKED"
1985
2138
  };
1986
- windowLike.addEventListener("storage", this.#storageListener);
1987
- }
1988
- const Channel = globalThis.BroadcastChannel;
1989
- if (Channel !== void 0) {
2139
+ const local = consumeReward({
2140
+ reward: configured,
2141
+ currentState: state,
2142
+ now,
2143
+ ...options.staffPasscode === void 0 ? {} : { inputPasscode: options.staffPasscode },
2144
+ ...options.staffId === void 0 ? {} : { staffId: options.staffId }
2145
+ });
2146
+ if (!local.ok) return this.#fail(local.error);
2147
+ const request = {
2148
+ rallyId: this.#config.id,
2149
+ userId: this.#userId,
2150
+ rewardId,
2151
+ idempotencyKey: options.idempotencyKey ?? id("claim"),
2152
+ now,
2153
+ options,
2154
+ state: current,
2155
+ ...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
2156
+ };
2157
+ const remote = this.#options.syncAdapter?.claimReward;
2158
+ if (options.sync !== false && remote !== void 0) {
2159
+ try {
2160
+ const result = await remote(request);
2161
+ return result.ok ? this.#commitClaim(result) : this.#fail(result.error);
2162
+ } catch (error) {
2163
+ if (this.#offlineQueue === void 0) throw error;
2164
+ const next2 = {
2165
+ ...current,
2166
+ rewards: current.rewards.map(
2167
+ (item) => item.rewardId === rewardId ? local.value : item
2168
+ ),
2169
+ updatedAt: now
2170
+ };
2171
+ await this.#offlineQueue.enqueueClaimReward(request, next2);
2172
+ await this.#storage.save(next2);
2173
+ return this.#commitClaim({ ok: true, value: { state: next2, reward: local.value } });
2174
+ }
2175
+ }
2176
+ const next = {
2177
+ ...current,
2178
+ rewards: current.rewards.map((item) => item.rewardId === rewardId ? local.value : item),
2179
+ updatedAt: now
2180
+ };
2181
+ await this.#storage.save(next);
2182
+ return this.#commitClaim({ ok: true, value: { state: next, reward: local.value } });
2183
+ });
2184
+ }
2185
+ sync(adapter = this.#options.syncAdapter) {
2186
+ return this.#enqueue(async () => {
2187
+ this.#emitSyncEvent({ type: "SYNC_STARTED" });
2188
+ this.#syncMetrics = { processed: 0, failed: 0 };
1990
2189
  try {
1991
- this.#channel = new Channel("stamprally:queue-sync");
1992
- this.#channel.addEventListener("message", (event) => {
1993
- if (typeof event.data === "object" && event.data !== null) {
1994
- const data = event.data;
1995
- if (data.type === "lock" && data.lockKey === this.#lockKey()) {
1996
- this.#observedLocks.set(data.lockKey, {
1997
- owner: typeof data.owner === "string" ? data.owner : "unknown",
1998
- expiresAt: typeof data.expiresAt === "number" ? data.expiresAt : 0
1999
- });
2000
- return;
2001
- }
2002
- if (data.type === "unlock" && data.lockKey === this.#lockKey()) {
2003
- this.#observedLocks.delete(data.lockKey);
2004
- return;
2190
+ const current = await this.initialize();
2191
+ if (this.#offlineQueue !== void 0 && adapter !== void 0) {
2192
+ await this.#offlineQueue.sync(async (operation) => {
2193
+ if (operation.kind === "checkIn") {
2194
+ if (adapter.checkIn === void 0)
2195
+ throw new Error("No check-in sync adapter is configured.");
2196
+ return adapter.checkIn(operation.request);
2005
2197
  }
2006
- }
2007
- if (typeof event.data === "object" && event.data !== null && "key" in event.data && event.data.key === this.#storageKey())
2008
- void this.#reloadFromStorage();
2198
+ if (adapter.claimReward === void 0)
2199
+ throw new Error("No reward sync adapter is configured.");
2200
+ return adapter.claimReward(operation.request);
2201
+ });
2202
+ }
2203
+ if (adapter?.sync === void 0) {
2204
+ this.#emitEvent({ type: "sync", state: this.#state ?? current });
2205
+ return;
2206
+ }
2207
+ const localState = this.#state ?? current;
2208
+ const serverState = await adapter.sync({
2209
+ rallyId: this.#config.id,
2210
+ userId: this.#userId,
2211
+ state: localState,
2212
+ ...this.#userId === this.#anonymousSessionId ? { anonymousSessionId: this.#anonymousSessionId } : {}
2213
+ });
2214
+ const resolved = rebuildUserStateFromLog(
2215
+ serverState,
2216
+ this.#offlineQueue?.operations ?? [],
2217
+ this.#config
2218
+ );
2219
+ const next = this.#reconcile(resolved);
2220
+ await this.#storage.save(next);
2221
+ this.#state = next;
2222
+ this.#emit(next);
2223
+ this.#emitEvent({ type: "sync", state: next });
2224
+ } finally {
2225
+ const metrics = this.#syncMetrics ?? { processed: 0, failed: 0 };
2226
+ this.#syncMetrics = null;
2227
+ this.#emitSyncEvent({
2228
+ type: "SYNC_COMPLETED",
2229
+ totalProcessed: metrics.processed,
2230
+ failedCount: metrics.failed
2009
2231
  });
2010
- } catch {
2011
- this.#channel = null;
2012
2232
  }
2013
- }
2233
+ });
2014
2234
  }
2015
- #announceChange() {
2016
- this.#channel?.postMessage({
2017
- type: "change",
2018
- key: this.#storageKey(),
2019
- owner: this.#instanceId
2235
+ retrySync() {
2236
+ return this.sync();
2237
+ }
2238
+ reset() {
2239
+ return this.#enqueue(async () => {
2240
+ await this.#storage.remove(this.#config.id, this.#userId);
2241
+ return this.#initializeFresh();
2020
2242
  });
2021
2243
  }
2022
- async #reloadFromStorage() {
2023
- if (this.#state === "syncing") return;
2024
- try {
2025
- const key = this.#storageKey();
2026
- this.#operations = (await this.#storage.load(key)).map(normalizeOperation);
2027
- this.#rejectedHistory = (await this.#storage.loadRejectedHistory?.(key))?.map(normalizeRejectedHistory) ?? this.#rejectedHistory;
2028
- this.#loaded = true;
2029
- } catch {
2030
- }
2244
+ restore(state) {
2245
+ return this.#enqueue(async () => {
2246
+ if (state.rallyId !== this.#config.id || state.userId !== this.#userId)
2247
+ throw new Error("State belongs to another rally or user.");
2248
+ const next = this.#reconcile(state);
2249
+ await this.#storage.save(next);
2250
+ this.#state = next;
2251
+ this.#initialization = Promise.resolve(next);
2252
+ this.#emit(next);
2253
+ return next;
2254
+ });
2031
2255
  }
2032
- #lockKey() {
2033
- return `${this.#storageKey()}:sync-lock`;
2256
+ #enqueue(operation) {
2257
+ const next = this.#queue.then(operation, operation);
2258
+ this.#queue = next.then(
2259
+ () => void 0,
2260
+ () => void 0
2261
+ );
2262
+ return next;
2034
2263
  }
2035
- #acquireSyncLock() {
2036
- const key = this.#lockKey();
2037
- const now = Date.now();
2038
- const local = syncLocks.get(key);
2039
- if (local !== void 0 && local.expiresAt > now && local.owner !== this.#instanceId)
2040
- return false;
2041
- const observed = this.#observedLocks.get(key);
2042
- if (observed !== void 0 && observed.expiresAt > now && observed.owner !== this.#instanceId)
2043
- return false;
2044
- if (this.#lockStorage !== null) {
2045
- try {
2046
- const existing = this.#lockStorage.getItem(key);
2047
- if (existing !== null) {
2048
- const parsed = JSON.parse(existing);
2049
- if (typeof parsed === "object" && parsed !== null && typeof parsed.expiresAt === "number" && parsed.expiresAt > now && parsed.owner !== this.#instanceId)
2050
- return false;
2051
- }
2052
- this.#lockStorage.setItem(
2053
- key,
2054
- JSON.stringify({ owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS })
2264
+ #initializeFresh() {
2265
+ const next = emptyState(this.#config, this.#userId, this.#now());
2266
+ this.#state = next;
2267
+ this.#initialization = Promise.resolve(next);
2268
+ this.#emit(next);
2269
+ return Promise.resolve(next);
2270
+ }
2271
+ #reconcile(state) {
2272
+ const ids = new Set(this.#config.spots.map((spot2) => spot2.id));
2273
+ const records = state.records.filter(
2274
+ (record, index, all) => ids.has(record.stampId) && all.findIndex((candidate) => candidate.stampId === record.stampId) === index
2275
+ );
2276
+ return {
2277
+ ...cloneState(state),
2278
+ userId: this.#userId,
2279
+ records,
2280
+ rewards: reconcileRewardStates(
2281
+ this.#config.rewards,
2282
+ state.rewards,
2283
+ records.length,
2284
+ state.updatedAt
2285
+ )
2286
+ };
2287
+ }
2288
+ async #handleOfflineSyncResult(event) {
2289
+ if (event.status === "ACCEPTED") {
2290
+ if (this.#syncMetrics !== null) this.#syncMetrics.processed += 1;
2291
+ if (event.state !== void 0) {
2292
+ const rebuilt = rebuildUserStateFromLog(
2293
+ event.state,
2294
+ this.#offlineQueue?.operations ?? [],
2295
+ this.#config
2055
2296
  );
2056
- this.#channel?.postMessage({
2057
- type: "lock",
2058
- lockKey: key,
2059
- owner: this.#instanceId,
2060
- expiresAt: now + SYNC_LOCK_TTL_MS
2061
- });
2062
- } catch {
2063
- this.#warnMemoryLock("Persistent lock access failed; offline sync is single-tab only.");
2297
+ const next = this.#reconcile(rebuilt);
2298
+ await this.#storage.save(next);
2299
+ this.#state = next;
2300
+ this.#emit(next);
2064
2301
  }
2065
- }
2066
- syncLocks.set(key, { owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS });
2067
- return true;
2068
- }
2069
- #releaseSyncLock() {
2070
- const key = this.#lockKey();
2071
- const current = syncLocks.get(key);
2072
- if (current?.owner === this.#instanceId) syncLocks.delete(key);
2073
- if (this.#lockStorage !== null) {
2074
- try {
2075
- const value = this.#lockStorage.getItem(key);
2076
- if (value !== null && JSON.parse(value).owner === this.#instanceId)
2077
- this.#lockStorage.removeItem?.(key);
2078
- this.#channel?.postMessage({ type: "unlock", lockKey: key, owner: this.#instanceId });
2079
- } catch {
2302
+ this.#emitSyncEvent({
2303
+ type: "OPERATION_ACCEPTED",
2304
+ operationId: this.#operationId(event.operation),
2305
+ resourceId: this.#resourceId(event.operation)
2306
+ });
2307
+ } else if (event.status === "REJECTED_PERMANENT") {
2308
+ const error = event.error ?? {
2309
+ code: "REJECTED_PERMANENT",
2310
+ message: "Offline operation was rejected."
2311
+ };
2312
+ if (this.#syncMetrics !== null) {
2313
+ this.#syncMetrics.processed += 1;
2314
+ this.#syncMetrics.failed += 1;
2080
2315
  }
2316
+ const base = event.state ?? this.#state ?? event.operation.request.state;
2317
+ const rollbackBase = event.state === void 0 ? rollbackOptimisticOperation(base, event.operation) : base;
2318
+ const rebuilt = rebuildUserStateFromLog(
2319
+ rollbackBase,
2320
+ this.#offlineQueue?.operations ?? [],
2321
+ this.#config
2322
+ );
2323
+ const next = this.#reconcile(rebuilt);
2324
+ await this.#storage.save(next);
2325
+ this.#state = next;
2326
+ this.#emit(next);
2327
+ this.#emitSyncEvent({
2328
+ type: "OPERATION_ROLLED_BACK",
2329
+ operationId: this.#operationId(event.operation),
2330
+ resourceId: this.#resourceId(event.operation),
2331
+ reason: errorMessage(error, "Offline operation was rejected."),
2332
+ errorCode: error.code
2333
+ });
2334
+ } else if (event.status === "RETRYABLE_ERROR") {
2335
+ if (this.#syncMetrics !== null) this.#syncMetrics.failed += 1;
2336
+ this.#emitSyncEvent({
2337
+ type: "OPERATION_RETRYABLE_ERROR",
2338
+ operationId: this.#operationId(event.operation),
2339
+ error: errorMessage(event.error, "Retryable sync error.")
2340
+ });
2341
+ }
2342
+ if (event.state !== void 0 && event.status === void 0) {
2343
+ const next = this.#reconcile(event.state);
2344
+ await this.#storage.save(next);
2345
+ this.#state = next;
2346
+ this.#emit(next);
2081
2347
  }
2348
+ if (event.error !== void 0) this.#emitEvent({ type: "error", error: event.error });
2349
+ else if (event.result !== void 0 && "ok" in event.result && !event.result.ok)
2350
+ this.#emitEvent({ type: "error", error: event.result.error });
2082
2351
  }
2083
- #storageKey() {
2084
- if (this.#configuredKey !== void 0) return this.#configuredKey;
2085
- return `stamprally:queue:${this.#rallyId ?? "unscoped"}:${this.#userId ?? "anonymous"}`;
2352
+ #now() {
2353
+ return this.#options.clock?.() ?? (/* @__PURE__ */ new Date()).toISOString();
2086
2354
  }
2087
- #normalizeResponse(value) {
2088
- if ("ok" in value) {
2089
- if (value.ok === false) {
2090
- if ("status" in value && value.status === "RETRYABLE_ERROR")
2091
- return { status: "RETRYABLE_ERROR", error: errorValue(value.error, "RETRYABLE_ERROR") };
2092
- return { status: "REJECTED_PERMANENT", result: value };
2093
- }
2094
- return { status: "ACCEPTED", result: value };
2355
+ #fail(error) {
2356
+ this.#emitEvent({ type: "error", error });
2357
+ return { ok: false, error };
2358
+ }
2359
+ #commitCheckIn(result) {
2360
+ if (result.ok) {
2361
+ this.#state = result.value.state;
2362
+ this.#emit(this.#state);
2095
2363
  }
2096
- if ("status" in value) {
2097
- if (value.status === "ACCEPTED") return value;
2098
- return value;
2364
+ this.#emitEvent({ type: "checkIn", result });
2365
+ return result;
2366
+ }
2367
+ #commitClaim(result) {
2368
+ if (result.ok) {
2369
+ this.#state = result.value.state;
2370
+ this.#emit(this.#state);
2099
2371
  }
2100
- return { status: "ACCEPTED", result: value };
2372
+ this.#emitEvent({ type: "rewardClaimed", result });
2373
+ return result;
2101
2374
  }
2102
- async resolveConflict(operation, localState, serverState) {
2103
- const configured = this.#onSyncConflict;
2104
- const policy = (typeof configured === "function" ? await configured({ operation, localState, serverState }) : configured) ?? this.#conflictPolicy;
2105
- return resolveRallyStateConflict(serverState, localState, { policy });
2375
+ #emit(state) {
2376
+ for (const listener of this.#listeners) listener(state);
2106
2377
  }
2107
- async #saveRejectedHistory() {
2108
- await this.#storage.saveRejectedHistory?.(this.#storageKey(), this.#rejectedHistory);
2378
+ #emitEvent(event) {
2379
+ for (const listener of this.#eventListeners) listener(event);
2109
2380
  }
2110
- #warnMemoryLock(message) {
2111
- if (this.#warnedMemoryLock) return;
2112
- this.#warnedMemoryLock = true;
2113
- console.warn(`[@stamprally/core] ${message}`);
2381
+ #emitSyncEvent(event) {
2382
+ for (const listener of this.#syncEventListeners) listener(event);
2383
+ this.#emitEvent({ type: "syncLifecycle", event });
2384
+ }
2385
+ #operationId(operation) {
2386
+ const identity = operation.request.userId ?? operation.request.anonymousSessionId ?? "anonymous";
2387
+ return `${operation.kind}:${operation.request.rallyId}:${identity}:${operation.request.idempotencyKey}`;
2388
+ }
2389
+ #resourceId(operation) {
2390
+ return operation.kind === "checkIn" ? operation.request.spotId : operation.request.rewardId;
2114
2391
  }
2115
2392
  };
2116
- function normalizeOperation(operation) {
2117
- const status = operation.status;
2118
- return {
2119
- ...operation,
2120
- status: status === "IN_FLIGHT" || status === "REJECTED" ? "PENDING" : status === "RETRYABLE_ERROR" ? "FAILED_RETRYABLE" : operation.status ?? "PENDING",
2121
- attempts: operation.attempts ?? 0
2122
- };
2123
- }
2124
- function normalizeRejectedHistory(entry) {
2125
- return {
2126
- ...entry,
2127
- operation: normalizeOperation(entry.operation),
2128
- reason: errorValue(entry.reason, "REJECTED_PERMANENT"),
2129
- errorCode: entry.errorCode || entry.reason.code,
2130
- rejectedAt: entry.rejectedAt || (/* @__PURE__ */ new Date(0)).toISOString(),
2131
- attempts: entry.attempts ?? entry.operation.attempts ?? 0
2132
- };
2133
- }
2134
2393
 
2135
2394
  // src/crypto/token.ts
2136
2395
  var encoder = new TextEncoder();
@@ -2624,6 +2883,18 @@ function finiteNumber(value, key, path, errors, minimum) {
2624
2883
  );
2625
2884
  }
2626
2885
  }
2886
+ function nonNegativeInteger(value, key, path, errors) {
2887
+ const item = value[key];
2888
+ if (typeof item !== "number" || !Number.isFinite(item)) {
2889
+ add(errors, `${path}.${key}`, "Expected a finite number.", "invalid_number");
2890
+ return;
2891
+ }
2892
+ if (!Number.isInteger(item)) {
2893
+ add(errors, `${path}.${key}`, "Expected a non-negative integer.", "invalid_integer");
2894
+ return;
2895
+ }
2896
+ if (item < 0) add(errors, `${path}.${key}`, "Expected a non-negative integer.", "out_of_range");
2897
+ }
2627
2898
  function localizedText(value, path, errors) {
2628
2899
  if (typeof value === "string") return;
2629
2900
  if (!isRecord3(value)) {
@@ -2652,7 +2923,11 @@ function theme(value, path, errors) {
2652
2923
  finiteNumber(value, "gridColumns", path, errors, 1);
2653
2924
  if (typeof value.gridColumns === "number" && !Number.isInteger(value.gridColumns))
2654
2925
  add(errors, `${path}.gridColumns`, "Expected an integer.", "invalid_integer");
2655
- if (hasOwn(value, "unclaimedOpacity")) finiteNumber(value, "unclaimedOpacity", path, errors, 0);
2926
+ if (hasOwn(value, "unclaimedOpacity")) {
2927
+ finiteNumber(value, "unclaimedOpacity", path, errors, 0);
2928
+ if (typeof value.unclaimedOpacity === "number" && value.unclaimedOpacity > 1)
2929
+ add(errors, `${path}.unclaimedOpacity`, "Expected a value between 0 and 1.", "out_of_range");
2930
+ }
2656
2931
  }
2657
2932
  function externalReferences(value, path, errors) {
2658
2933
  if (!Array.isArray(value)) {
@@ -2815,14 +3090,14 @@ function reward(value, path, errors, isPublic) {
2815
3090
  String(value.redemptionMethod)
2816
3091
  ))
2817
3092
  add(errors, `${path}.redemptionMethod`, "Unknown redemption method.", "invalid_enum");
2818
- finiteNumber(value, "requiredStampCount", path, errors, 0);
3093
+ nonNegativeInteger(value, "requiredStampCount", path, errors);
2819
3094
  for (const key of ["stockLimit", "userClaimLimit"]) {
2820
3095
  if (hasOwn(value, key) && value[key] !== void 0) {
2821
- finiteNumber(value, key, path, errors, 0);
2822
- if (typeof value[key] === "number" && !Number.isInteger(value[key]))
2823
- add(errors, `${path}.${key}`, "Expected an integer.", "invalid_integer");
3096
+ nonNegativeInteger(value, key, path, errors);
2824
3097
  }
2825
3098
  }
3099
+ optionalString(value, "stockKey", path, errors);
3100
+ optionalString(value, "secondaryStockKey", path, errors);
2826
3101
  optionalString(value, "validUntil", path, errors);
2827
3102
  if (typeof value.validUntil === "string" && Number.isNaN(Date.parse(value.validUntil)))
2828
3103
  add(errors, `${path}.validUntil`, "Expected a valid date string.", "invalid_date");
@@ -2866,8 +3141,31 @@ function validate(value, isPublic) {
2866
3141
  });
2867
3142
  if (!isPublic) {
2868
3143
  optionalString(value, "staffPasscode", "$", errors);
2869
- if (hasOwn(value, "inventory") && value.inventory !== void 0 && !isRecord3(value.inventory))
2870
- add(errors, "$.inventory", "Expected an object.", "invalid_type");
3144
+ if (hasOwn(value, "inventory") && value.inventory !== void 0) {
3145
+ if (!isRecord3(value.inventory))
3146
+ add(errors, "$.inventory", "Expected an object.", "invalid_type");
3147
+ else {
3148
+ for (const [key, item] of Object.entries(value.inventory)) {
3149
+ if (key === "global") {
3150
+ add(
3151
+ errors,
3152
+ "$.inventory.global",
3153
+ "Use sharedStock instead of global.",
3154
+ "deprecated_field"
3155
+ );
3156
+ continue;
3157
+ }
3158
+ if (item !== void 0) nonNegativeInteger(value.inventory, key, "$.inventory", errors);
3159
+ }
3160
+ if (hasOwn(value.inventory, "sharedStock") && hasOwn(value.inventory, "global"))
3161
+ add(
3162
+ errors,
3163
+ "$.inventory",
3164
+ "sharedStock and global cannot be configured together.",
3165
+ "conflicting_fields"
3166
+ );
3167
+ }
3168
+ }
2871
3169
  if (hasOwn(value, "inventoryMode") && value.inventoryMode !== void 0 && value.inventoryMode !== "shared" && value.inventoryMode !== "per_reward")
2872
3170
  add(errors, "$.inventoryMode", "Expected shared or per_reward.", "invalid_enum");
2873
3171
  if (hasOwn(value, "serverMetadata") && value.serverMetadata !== void 0 && !isRecord3(value.serverMetadata))
@@ -2941,6 +3239,33 @@ function validateRallyConfigRelations(config) {
2941
3239
  reward2.conditions?.forEach((condition2, conditionIndex) => {
2942
3240
  visit(condition2, `rewards[${index}].conditions[${conditionIndex}]`);
2943
3241
  });
3242
+ if (reward2.requiredStampCount > config.spots.length)
3243
+ add(
3244
+ errors,
3245
+ `rewards[${index}].requiredStampCount`,
3246
+ "requiredStampCount cannot exceed the number of spots.",
3247
+ "required_stamp_count_exceeds_spots"
3248
+ );
3249
+ const inventory = "inventory" in config ? config.inventory : void 0;
3250
+ for (const [field, key] of [
3251
+ ["stockKey", reward2.stockKey],
3252
+ ["secondaryStockKey", reward2.secondaryStockKey]
3253
+ ]) {
3254
+ if (key !== void 0 && key !== "__shared__" && inventory?.[key] === void 0)
3255
+ add(
3256
+ errors,
3257
+ `rewards[${index}].${field}`,
3258
+ "Referenced inventory key does not exist.",
3259
+ "missing_inventory_key"
3260
+ );
3261
+ }
3262
+ if (reward2.stockKey === "__shared__" && inventory?.sharedStock === void 0)
3263
+ add(
3264
+ errors,
3265
+ `rewards[${index}].stockKey`,
3266
+ "sharedStock is not defined.",
3267
+ "missing_inventory_key"
3268
+ );
2944
3269
  });
2945
3270
  const visiting = /* @__PURE__ */ new Set();
2946
3271
  const visited = /* @__PURE__ */ new Set();
@@ -3153,9 +3478,12 @@ exports.parsePublicConfig = parsePublicConfig;
3153
3478
  exports.processStamp = processStamp;
3154
3479
  exports.readNfcContext = readNfcContext;
3155
3480
  exports.readQrContext = readQrContext;
3481
+ exports.rebuildUserStateFromLog = rebuildUserStateFromLog;
3482
+ exports.rebuildUserStateLog = rebuildUserStateLog;
3156
3483
  exports.reconcileRewardStates = reconcileRewardStates;
3157
3484
  exports.resolveLocalizedText = resolveLocalizedText;
3158
3485
  exports.resolveRallyStateConflict = resolveRallyStateConflict;
3486
+ exports.rollbackOptimisticOperation = rollbackOptimisticOperation;
3159
3487
  exports.safeParseAdminConfig = safeParseAdminConfig;
3160
3488
  exports.safeParsePublicConfig = safeParsePublicConfig;
3161
3489
  exports.sanitizeAdminConfig = sanitizeAdminConfig;