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