@stamprally/core 0.10.0 → 0.12.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
@@ -97,6 +97,47 @@ function issueClaimTicketNumber(reward2, currentState, options = {}) {
97
97
  return { ...currentState, claimTicketNumber };
98
98
  }
99
99
 
100
+ // src/engine/sync.ts
101
+ function latestTimestamp(serverTimestamp, localTimestamp) {
102
+ const serverTime = Date.parse(serverTimestamp);
103
+ const localTime = Date.parse(localTimestamp);
104
+ if (!Number.isNaN(serverTime) && !Number.isNaN(localTime))
105
+ return serverTime >= localTime ? serverTimestamp : localTimestamp;
106
+ if (!Number.isNaN(serverTime)) return serverTimestamp;
107
+ if (!Number.isNaN(localTime)) return localTimestamp;
108
+ return serverTimestamp >= localTimestamp ? serverTimestamp : localTimestamp;
109
+ }
110
+ function mergeRewardStates(serverRewards, localRewards) {
111
+ const merged = new Map(serverRewards.map((reward2) => [reward2.rewardId, reward2]));
112
+ for (const localReward of localRewards) {
113
+ const serverReward = merged.get(localReward.rewardId);
114
+ if (serverReward === void 0) {
115
+ merged.set(localReward.rewardId, localReward);
116
+ continue;
117
+ }
118
+ if (localReward.status === "CONSUMED" && serverReward.status !== "CONSUMED")
119
+ merged.set(localReward.rewardId, localReward);
120
+ }
121
+ return [...merged.values()];
122
+ }
123
+ function resolveRallyStateConflict(serverState, localState, options = { policy: "merge" }) {
124
+ if (options.policy === "server_wins") return serverState;
125
+ const records = [...serverState.records];
126
+ const knownStamps = new Set(records.map((record) => record.stampId));
127
+ for (const record of localState.records) {
128
+ if (!knownStamps.has(record.stampId)) {
129
+ records.push(record);
130
+ knownStamps.add(record.stampId);
131
+ }
132
+ }
133
+ return {
134
+ ...serverState,
135
+ records,
136
+ rewards: mergeRewardStates(serverState.rewards, localState.rewards),
137
+ updatedAt: latestTimestamp(serverState.updatedAt, localState.updatedAt)
138
+ };
139
+ }
140
+
100
141
  // src/detectors/types.ts
101
142
  function createDetectorError(detector, code, message, cause) {
102
143
  return cause === void 0 ? { detector, code, message } : { detector, code, message, cause };
@@ -987,6 +1028,7 @@ var StampRallyClient = class {
987
1028
  #storage;
988
1029
  #options;
989
1030
  #config;
1031
+ #offlineQueue;
990
1032
  #userId;
991
1033
  #state = null;
992
1034
  #initialization = null;
@@ -996,6 +1038,8 @@ var StampRallyClient = class {
996
1038
  this.#options = isStorage(storageOrOptions) ? { storage: storageOrOptions } : storageOrOptions;
997
1039
  this.#storage = this.#options.storage ?? new InMemoryStorage();
998
1040
  this.#userId = this.#options.userId ?? null;
1041
+ this.#offlineQueue = this.#options.offlineQueue;
1042
+ this.#offlineQueue?.setSyncResultListener((event) => this.#handleOfflineSyncResult(event));
999
1043
  }
1000
1044
  getConfig() {
1001
1045
  return this.#config;
@@ -1006,6 +1050,12 @@ var StampRallyClient = class {
1006
1050
  getUserId() {
1007
1051
  return this.#userId;
1008
1052
  }
1053
+ get syncState() {
1054
+ return this.#offlineQueue?.syncState ?? "idle";
1055
+ }
1056
+ get pendingCount() {
1057
+ return this.#offlineQueue?.pendingCount ?? 0;
1058
+ }
1009
1059
  subscribe(listener) {
1010
1060
  this.#listeners.add(listener);
1011
1061
  return () => this.#listeners.delete(listener);
@@ -1109,8 +1159,21 @@ var StampRallyClient = class {
1109
1159
  };
1110
1160
  const remote = this.#options.syncAdapter?.checkIn;
1111
1161
  if (options.sync !== false && remote !== void 0) {
1112
- const result = await remote(request);
1113
- return result.ok ? this.#commitCheckIn(result) : this.#fail(result.error);
1162
+ try {
1163
+ const result = await remote(request);
1164
+ return result.ok ? this.#commitCheckIn(result) : this.#fail(result.error);
1165
+ } catch (error) {
1166
+ if (this.#offlineQueue === void 0) throw error;
1167
+ await this.#offlineQueue.enqueueCheckIn(request);
1168
+ const record2 = { stampId: spotId, acquiredAt: now };
1169
+ const next2 = this.#reconcile({
1170
+ ...current,
1171
+ records: [...current.records, record2],
1172
+ updatedAt: now
1173
+ });
1174
+ await this.#storage.save(next2);
1175
+ return this.#commitCheckIn({ ok: true, value: { state: next2, record: record2 } });
1176
+ }
1114
1177
  }
1115
1178
  const record = { stampId: spotId, acquiredAt: now };
1116
1179
  const next = this.#reconcile({
@@ -1152,8 +1215,22 @@ var StampRallyClient = class {
1152
1215
  };
1153
1216
  const remote = this.#options.syncAdapter?.claimReward;
1154
1217
  if (options.sync !== false && remote !== void 0) {
1155
- const result = await remote(request);
1156
- return result.ok ? this.#commitClaim(result) : this.#fail(result.error);
1218
+ try {
1219
+ const result = await remote(request);
1220
+ return result.ok ? this.#commitClaim(result) : this.#fail(result.error);
1221
+ } catch (error) {
1222
+ if (this.#offlineQueue === void 0) throw error;
1223
+ await this.#offlineQueue.enqueueClaimReward(request);
1224
+ const next2 = {
1225
+ ...current,
1226
+ rewards: current.rewards.map(
1227
+ (item) => item.rewardId === rewardId ? local.value : item
1228
+ ),
1229
+ updatedAt: now
1230
+ };
1231
+ await this.#storage.save(next2);
1232
+ return this.#commitClaim({ ok: true, value: { state: next2, reward: local.value } });
1233
+ }
1157
1234
  }
1158
1235
  const next = {
1159
1236
  ...current,
@@ -1167,19 +1244,41 @@ var StampRallyClient = class {
1167
1244
  sync(adapter = this.#options.syncAdapter) {
1168
1245
  return this.#enqueue(async () => {
1169
1246
  const current = await this.initialize();
1247
+ if (this.#offlineQueue !== void 0 && adapter !== void 0) {
1248
+ await this.#offlineQueue.sync(async (operation) => {
1249
+ if (operation.kind === "checkIn") {
1250
+ if (adapter.checkIn === void 0)
1251
+ throw new Error("No check-in sync adapter is configured.");
1252
+ return adapter.checkIn(operation.request);
1253
+ }
1254
+ if (adapter.claimReward === void 0)
1255
+ throw new Error("No reward sync adapter is configured.");
1256
+ return adapter.claimReward(operation.request);
1257
+ });
1258
+ }
1170
1259
  if (adapter?.sync === void 0) {
1171
- this.#emitEvent({ type: "sync", state: current });
1260
+ this.#emitEvent({ type: "sync", state: this.#state ?? current });
1172
1261
  return;
1173
1262
  }
1174
- const next = this.#reconcile(
1175
- await adapter.sync({ rallyId: this.#config.id, userId: this.#userId, state: current })
1176
- );
1263
+ const serverState = await adapter.sync({
1264
+ rallyId: this.#config.id,
1265
+ userId: this.#userId,
1266
+ state: this.#state ?? current
1267
+ });
1268
+ const localState = this.#state ?? current;
1269
+ const merged = this.#offlineQueue === void 0 ? serverState : resolveRallyStateConflict(serverState, localState, {
1270
+ policy: this.#offlineQueue.conflictPolicy
1271
+ });
1272
+ const next = this.#reconcile(merged);
1177
1273
  await this.#storage.save(next);
1178
1274
  this.#state = next;
1179
1275
  this.#emit(next);
1180
1276
  this.#emitEvent({ type: "sync", state: next });
1181
1277
  });
1182
1278
  }
1279
+ retrySync() {
1280
+ return this.sync();
1281
+ }
1183
1282
  reset() {
1184
1283
  return this.#enqueue(async () => {
1185
1284
  await this.#storage.remove(this.#config.id, this.#userId);
@@ -1230,6 +1329,16 @@ var StampRallyClient = class {
1230
1329
  )
1231
1330
  };
1232
1331
  }
1332
+ async #handleOfflineSyncResult(event) {
1333
+ if (event.state !== void 0) {
1334
+ const next = this.#reconcile(event.state);
1335
+ await this.#storage.save(next);
1336
+ this.#state = next;
1337
+ this.#emit(next);
1338
+ }
1339
+ if ("ok" in event.result && !event.result.ok)
1340
+ this.#emitEvent({ type: "error", error: event.result.error });
1341
+ }
1233
1342
  #now() {
1234
1343
  return this.#options.clock?.() ?? (/* @__PURE__ */ new Date()).toISOString();
1235
1344
  }
@@ -1261,6 +1370,214 @@ var StampRallyClient = class {
1261
1370
  }
1262
1371
  };
1263
1372
 
1373
+ // src/client/offlineQueue.ts
1374
+ var MemoryQueueStorage = class {
1375
+ #values = /* @__PURE__ */ new Map();
1376
+ async load(key) {
1377
+ return this.#values.get(key) ?? [];
1378
+ }
1379
+ async save(key, operations) {
1380
+ this.#values.set(key, structuredClone(operations));
1381
+ }
1382
+ };
1383
+ var LocalStorageQueueStorage = class {
1384
+ constructor(storage) {
1385
+ this.storage = storage;
1386
+ }
1387
+ storage;
1388
+ async load(key) {
1389
+ const value = this.storage.getItem(key);
1390
+ if (value === null) return [];
1391
+ try {
1392
+ const parsed = JSON.parse(value);
1393
+ return Array.isArray(parsed) ? parsed : [];
1394
+ } catch {
1395
+ return [];
1396
+ }
1397
+ }
1398
+ async save(key, operations) {
1399
+ this.storage.setItem(key, JSON.stringify(operations));
1400
+ }
1401
+ };
1402
+ var IndexedDBOfflineQueueStorage = class {
1403
+ #providedFactory;
1404
+ #databaseName;
1405
+ #databasePromise = null;
1406
+ constructor(options = {}) {
1407
+ this.#providedFactory = options.indexedDB;
1408
+ this.#databaseName = options.databaseName ?? "stamprally-offline-queue";
1409
+ }
1410
+ async load(key) {
1411
+ const database = await this.#open();
1412
+ return new Promise((resolve, reject) => {
1413
+ const request = database.transaction("operations", "readonly").objectStore("operations").get(key);
1414
+ request.onsuccess = () => resolve(Array.isArray(request.result) ? request.result : []);
1415
+ request.onerror = () => reject(request.error ?? new Error("Failed to read offline queue."));
1416
+ });
1417
+ }
1418
+ async save(key, operations) {
1419
+ const database = await this.#open();
1420
+ return new Promise((resolve, reject) => {
1421
+ const transaction = database.transaction("operations", "readwrite");
1422
+ transaction.objectStore("operations").put(structuredClone(operations), key);
1423
+ transaction.oncomplete = () => resolve();
1424
+ transaction.onerror = () => reject(transaction.error ?? new Error("Failed to save offline queue."));
1425
+ transaction.onabort = () => reject(transaction.error ?? new Error("Offline queue write aborted."));
1426
+ });
1427
+ }
1428
+ #open() {
1429
+ if (this.#databasePromise !== null) return this.#databasePromise;
1430
+ let factory = this.#providedFactory;
1431
+ if (factory === void 0)
1432
+ factory = globalThis.indexedDB;
1433
+ if (factory === void 0 || factory === null)
1434
+ return Promise.reject(new Error("IndexedDB is unavailable in this environment."));
1435
+ this.#databasePromise = new Promise((resolve, reject) => {
1436
+ const request = factory.open(this.#databaseName, 1);
1437
+ request.onupgradeneeded = () => {
1438
+ if (!request.result.objectStoreNames.contains("operations"))
1439
+ request.result.createObjectStore("operations");
1440
+ };
1441
+ request.onsuccess = () => resolve(request.result);
1442
+ request.onerror = () => reject(request.error ?? new Error("Failed to open offline queue."));
1443
+ }).catch((error) => {
1444
+ this.#databasePromise = null;
1445
+ throw error;
1446
+ });
1447
+ return this.#databasePromise;
1448
+ }
1449
+ };
1450
+ function defaultStorage(databaseName) {
1451
+ try {
1452
+ const indexedDB = globalThis.indexedDB;
1453
+ if (indexedDB !== void 0)
1454
+ return new IndexedDBOfflineQueueStorage({
1455
+ indexedDB,
1456
+ ...databaseName === void 0 ? {} : { databaseName }
1457
+ });
1458
+ const storage = globalThis.localStorage;
1459
+ if (storage !== void 0 && storage !== null) return new LocalStorageQueueStorage(storage);
1460
+ } catch {
1461
+ }
1462
+ return new MemoryQueueStorage();
1463
+ }
1464
+ function operationId(operation) {
1465
+ return operation.kind === "checkIn" ? `checkIn:${operation.request.rallyId}:${operation.request.userId ?? "anonymous"}:${operation.request.idempotencyKey}` : `claimReward:${operation.request.rallyId}:${operation.request.userId ?? "anonymous"}:${operation.request.idempotencyKey}`;
1466
+ }
1467
+ var OfflineQueue = class {
1468
+ #storage;
1469
+ #key;
1470
+ #conflictPolicy;
1471
+ #onSyncConflict;
1472
+ #operations = [];
1473
+ #loaded = false;
1474
+ #state = "idle";
1475
+ #error = null;
1476
+ #sender;
1477
+ #syncPromise = null;
1478
+ #syncResultListener;
1479
+ constructor(options = {}) {
1480
+ if (options.storage !== void 0) this.#storage = options.storage;
1481
+ else if (options.storageLike !== void 0 && options.storageLike !== null)
1482
+ this.#storage = new LocalStorageQueueStorage(options.storageLike);
1483
+ else this.#storage = defaultStorage(options.databaseName);
1484
+ this.#key = options.key ?? "stamprally:offline-queue";
1485
+ this.#conflictPolicy = options.conflictPolicy ?? "server_wins";
1486
+ this.#onSyncConflict = options.onSyncConflict;
1487
+ }
1488
+ get syncState() {
1489
+ return this.#state;
1490
+ }
1491
+ get pendingCount() {
1492
+ return this.#operations.length;
1493
+ }
1494
+ get error() {
1495
+ return this.#error;
1496
+ }
1497
+ get operations() {
1498
+ return this.#operations;
1499
+ }
1500
+ get conflictPolicy() {
1501
+ return this.#conflictPolicy;
1502
+ }
1503
+ setSyncResultListener(listener) {
1504
+ this.#syncResultListener = listener;
1505
+ }
1506
+ async initialize() {
1507
+ if (this.#loaded) return;
1508
+ this.#operations = [...await this.#storage.load(this.#key)];
1509
+ this.#loaded = true;
1510
+ }
1511
+ setSender(sender) {
1512
+ this.#sender = sender;
1513
+ }
1514
+ async enqueue(operation) {
1515
+ await this.initialize();
1516
+ const id2 = operationId(operation);
1517
+ if (this.#operations.some((item) => operationId(item) === id2)) return;
1518
+ this.#operations = [...this.#operations, operation];
1519
+ await this.#storage.save(this.#key, this.#operations);
1520
+ }
1521
+ async enqueueCheckIn(request) {
1522
+ return this.enqueue({ kind: "checkIn", request });
1523
+ }
1524
+ async enqueueClaimReward(request) {
1525
+ return this.enqueue({ kind: "claimReward", request });
1526
+ }
1527
+ async clear() {
1528
+ await this.initialize();
1529
+ this.#operations = [];
1530
+ await this.#storage.save(this.#key, this.#operations);
1531
+ }
1532
+ async sync(sender = this.#sender) {
1533
+ await this.initialize();
1534
+ if (sender === void 0) throw new Error("OfflineQueue.sync requires a sender.");
1535
+ if (this.#syncPromise !== null) return this.#syncPromise;
1536
+ this.#sender = sender;
1537
+ this.#syncPromise = this.#run(sender).finally(() => {
1538
+ this.#syncPromise = null;
1539
+ });
1540
+ return this.#syncPromise;
1541
+ }
1542
+ async retrySync(sender = this.#sender) {
1543
+ return this.sync(sender);
1544
+ }
1545
+ async #run(sender) {
1546
+ this.#state = "syncing";
1547
+ this.#error = null;
1548
+ try {
1549
+ while (this.#operations.length > 0) {
1550
+ const operation = this.#operations[0];
1551
+ if (operation === void 0) break;
1552
+ let result;
1553
+ try {
1554
+ result = await sender(operation);
1555
+ } catch (cause) {
1556
+ throw cause instanceof Error ? cause : new Error(String(cause));
1557
+ }
1558
+ const state = "conflict" in result && result.conflict === true ? await this.resolveConflict(operation, result.localState, result.serverState) : "ok" in result && result.ok ? result.value.state : void 0;
1559
+ await this.#syncResultListener?.({
1560
+ operation,
1561
+ result,
1562
+ ...state === void 0 ? {} : { state }
1563
+ });
1564
+ this.#operations = this.#operations.slice(1);
1565
+ await this.#storage.save(this.#key, this.#operations);
1566
+ }
1567
+ this.#state = "idle";
1568
+ } catch (cause) {
1569
+ this.#state = "error";
1570
+ this.#error = cause instanceof Error ? cause : new Error(String(cause));
1571
+ throw this.#error;
1572
+ }
1573
+ }
1574
+ async resolveConflict(operation, localState, serverState) {
1575
+ const configured = this.#onSyncConflict;
1576
+ const policy = (typeof configured === "function" ? await configured({ operation, localState, serverState }) : configured) ?? this.#conflictPolicy;
1577
+ return resolveRallyStateConflict(serverState, localState, { policy });
1578
+ }
1579
+ };
1580
+
1264
1581
  // src/crypto/token.ts
1265
1582
  var encoder = new TextEncoder();
1266
1583
  var decoder = new TextDecoder();
@@ -1393,27 +1710,6 @@ async function decryptPayload(body, secret) {
1393
1710
  );
1394
1711
  }
1395
1712
 
1396
- // src/domain/i18n.ts
1397
- function updateLocalizedField(current, locale, newValue) {
1398
- if (typeof current === "string" || current === void 0)
1399
- return { [locale]: newValue };
1400
- return { ...current, [locale]: newValue };
1401
- }
1402
- function resolveLocalizedText(text, locale, fallbackLocale) {
1403
- if (text === void 0 || text === "") return "";
1404
- if (typeof text === "string") return text;
1405
- const fallback = fallbackLocale === void 0 ? Object.values(text).find((value) => typeof value === "string") : text[fallbackLocale];
1406
- return text[locale] || fallback || "";
1407
- }
1408
- function toLocalizedString(text) {
1409
- if (text === void 0) return { ja: "", en: "" };
1410
- return typeof text === "string" ? { ja: text, en: "" } : {
1411
- ja: text["ja"] ?? "",
1412
- en: text["en"] ?? "",
1413
- ...text
1414
- };
1415
- }
1416
-
1417
1713
  // src/domain/models.ts
1418
1714
  var DEFAULT_SHEET_THEME = {
1419
1715
  primaryColor: "#9e551e",
@@ -1458,7 +1754,7 @@ function toPublicConfig(config) {
1458
1754
  rewards: config.rewards.map(
1459
1755
  ({ digitalContentUrl: _content, staffPasscode: _passcode, ...reward2 }) => reward2
1460
1756
  ),
1461
- ...config.metadata === void 0 ? {} : { metadata: config.metadata },
1757
+ ...config.publicMetadata !== void 0 ? { metadata: config.publicMetadata } : config.metadata === void 0 ? {} : { metadata: config.metadata },
1462
1758
  ...config.serverEndpoint === void 0 ? {} : { serverEndpoint: config.serverEndpoint }
1463
1759
  };
1464
1760
  }
@@ -1517,6 +1813,118 @@ function isPublicConfig(value) {
1517
1813
  });
1518
1814
  }
1519
1815
 
1816
+ // src/domain/configTransform.ts
1817
+ var PRIVATE_KEYS = /* @__PURE__ */ new Set([
1818
+ "staffPasscode",
1819
+ "serverMetadata",
1820
+ "inventory",
1821
+ "secretToken",
1822
+ "secretParams",
1823
+ "digitalContentUrl",
1824
+ "code",
1825
+ "tagId"
1826
+ ]);
1827
+ var SENSITIVE_KEY_PATTERN = /^(?:api[_-]?key|access[_-]?token|auth[_-]?token|password|private[_-]?key|secret)$/i;
1828
+ function isRecord2(value) {
1829
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1830
+ }
1831
+ function isPrivateKey(key) {
1832
+ return PRIVATE_KEYS.has(key) || SENSITIVE_KEY_PATTERN.test(key);
1833
+ }
1834
+ function sanitizeValue(value, customFilter, seen) {
1835
+ if (Array.isArray(value)) {
1836
+ if (seen.has(value)) return void 0;
1837
+ seen.add(value);
1838
+ const items = value.map((item) => sanitizeValue(item, customFilter, seen)).filter((item) => item !== void 0);
1839
+ return items;
1840
+ }
1841
+ if (!isRecord2(value)) return value;
1842
+ if (seen.has(value)) return void 0;
1843
+ seen.add(value);
1844
+ const result = {};
1845
+ for (const [key, item] of Object.entries(value)) {
1846
+ if (isPrivateKey(key) || customFilter?.(key, item) === false) continue;
1847
+ const sanitized = sanitizeValue(item, customFilter, seen);
1848
+ if (sanitized !== void 0) result[key] = sanitized;
1849
+ }
1850
+ return result;
1851
+ }
1852
+ function sanitizeSpot(spot2, customFilter) {
1853
+ return sanitizeValue(
1854
+ toPublicConfig({
1855
+ id: "__spot__",
1856
+ version: "1",
1857
+ title: "__spot__",
1858
+ spots: [spot2],
1859
+ rewards: []
1860
+ }).spots[0],
1861
+ customFilter,
1862
+ /* @__PURE__ */ new WeakSet()
1863
+ );
1864
+ }
1865
+ function sanitizeReward(reward2, customFilter) {
1866
+ return sanitizeValue(
1867
+ toPublicConfig({
1868
+ id: "__reward__",
1869
+ version: "1",
1870
+ title: "__reward__",
1871
+ spots: [],
1872
+ rewards: [reward2]
1873
+ }).rewards[0],
1874
+ customFilter,
1875
+ /* @__PURE__ */ new WeakSet()
1876
+ );
1877
+ }
1878
+ function sanitizeAdminConfig(admin, customFilter) {
1879
+ const publicConfig = toPublicConfig(admin);
1880
+ const sanitized = sanitizeValue(publicConfig, customFilter, /* @__PURE__ */ new WeakSet());
1881
+ sanitized.spots = admin.spots.map((spot2) => sanitizeSpot(spot2, customFilter));
1882
+ sanitized.rewards = admin.rewards.map((reward2) => sanitizeReward(reward2, customFilter));
1883
+ return sanitized;
1884
+ }
1885
+ function validatePublicConfigSafety(publicConfig) {
1886
+ const leakedKeys = [];
1887
+ const seen = /* @__PURE__ */ new WeakSet();
1888
+ const visit = (value, path) => {
1889
+ if (Array.isArray(value)) {
1890
+ value.forEach((item, index) => {
1891
+ visit(item, `${path}[${index}]`);
1892
+ });
1893
+ return;
1894
+ }
1895
+ if (!isRecord2(value) || seen.has(value)) return;
1896
+ seen.add(value);
1897
+ for (const [key, item] of Object.entries(value)) {
1898
+ const nextPath = path === "$" ? key : `${path}.${key}`;
1899
+ if (isPrivateKey(key)) leakedKeys.push(nextPath);
1900
+ visit(item, nextPath);
1901
+ }
1902
+ };
1903
+ visit(publicConfig, "$");
1904
+ return { safe: leakedKeys.length === 0, leakedKeys };
1905
+ }
1906
+
1907
+ // src/domain/i18n.ts
1908
+ function updateLocalizedField(current, locale, newValue) {
1909
+ if (typeof current === "string" || current === void 0)
1910
+ return { [locale]: newValue };
1911
+ return { ...current, [locale]: newValue };
1912
+ }
1913
+ function resolveLocalizedText(text, locale, fallbackLocale) {
1914
+ if (text === void 0 || text === "") return "";
1915
+ if (typeof text === "string") return text;
1916
+ const fallback = fallbackLocale === void 0 ? Object.values(text).find((value) => typeof value === "string") : text[fallbackLocale];
1917
+ return text[locale] || fallback || "";
1918
+ }
1919
+ function toLocalizedString(text) {
1920
+ if (text === void 0) return { ja: "", en: "" };
1921
+ return typeof text === "string" ? { ja: text, en: "" } : {
1922
+ ja: text["ja"] ?? "",
1923
+ en: text["en"] ?? "",
1924
+ ...text
1925
+ };
1926
+ }
1927
+
1520
1928
  // src/domain/themePresets.ts
1521
1929
  var THEME_PRESETS = [
1522
1930
  {
@@ -1625,7 +2033,7 @@ var ConfigValidationError = class extends Error {
1625
2033
  errors;
1626
2034
  name = "ConfigValidationError";
1627
2035
  };
1628
- function isRecord2(value) {
2036
+ function isRecord3(value) {
1629
2037
  return typeof value === "object" && value !== null && !Array.isArray(value);
1630
2038
  }
1631
2039
  function hasOwn(value, key) {
@@ -1664,7 +2072,7 @@ function finiteNumber(value, key, path, errors, minimum) {
1664
2072
  }
1665
2073
  function localizedText(value, path, errors) {
1666
2074
  if (typeof value === "string") return;
1667
- if (!isRecord2(value)) {
2075
+ if (!isRecord3(value)) {
1668
2076
  add(errors, path, "Expected a string or a locale map.", "invalid_localized_text");
1669
2077
  return;
1670
2078
  }
@@ -1674,7 +2082,7 @@ function localizedText(value, path, errors) {
1674
2082
  }
1675
2083
  }
1676
2084
  function theme(value, path, errors) {
1677
- if (!isRecord2(value)) {
2085
+ if (!isRecord3(value)) {
1678
2086
  add(errors, path, "Expected a theme object.", "invalid_type");
1679
2087
  return;
1680
2088
  }
@@ -1699,7 +2107,7 @@ function externalReferences(value, path, errors) {
1699
2107
  }
1700
2108
  value.forEach((item, index) => {
1701
2109
  const itemPath = `${path}[${index}]`;
1702
- if (!isRecord2(item)) {
2110
+ if (!isRecord3(item)) {
1703
2111
  add(errors, itemPath, "Expected an object.", "invalid_type");
1704
2112
  return;
1705
2113
  }
@@ -1709,7 +2117,7 @@ function externalReferences(value, path, errors) {
1709
2117
  });
1710
2118
  }
1711
2119
  function condition(value, path, errors, isPublic) {
1712
- if (!isRecord2(value)) {
2120
+ if (!isRecord3(value)) {
1713
2121
  add(errors, path, "Expected a condition object.", "invalid_type");
1714
2122
  return;
1715
2123
  }
@@ -1759,14 +2167,14 @@ function condition(value, path, errors, isPublic) {
1759
2167
  requiredString(value, "validatorName", path, errors);
1760
2168
  if (isPublic && hasOwn(value, "secretParams"))
1761
2169
  add(errors, `${path}.secretParams`, "Private field is not allowed.", "private_field");
1762
- if (!isPublic && hasOwn(value, "secretParams") && !isRecord2(value.secretParams))
2170
+ if (!isPublic && hasOwn(value, "secretParams") && !isRecord3(value.secretParams))
1763
2171
  add(errors, `${path}.secretParams`, "Expected an object.", "invalid_type");
1764
2172
  return;
1765
2173
  }
1766
2174
  add(errors, `${path}.type`, "Unknown condition type.", "invalid_enum");
1767
2175
  }
1768
2176
  function unlockCondition(value, path, errors) {
1769
- if (!isRecord2(value)) {
2177
+ if (!isRecord3(value)) {
1770
2178
  add(errors, path, "Expected an unlock condition object.", "invalid_type");
1771
2179
  return;
1772
2180
  }
@@ -1801,7 +2209,7 @@ function unlockCondition(value, path, errors) {
1801
2209
  add(errors, `${path}.type`, "Unknown unlock condition type.", "invalid_enum");
1802
2210
  }
1803
2211
  function spot(value, path, errors, isPublic) {
1804
- if (!isRecord2(value)) {
2212
+ if (!isRecord3(value)) {
1805
2213
  add(errors, path, "Expected a spot object.", "invalid_type");
1806
2214
  return;
1807
2215
  }
@@ -1838,7 +2246,7 @@ function spot(value, path, errors, isPublic) {
1838
2246
  });
1839
2247
  }
1840
2248
  function reward(value, path, errors, isPublic) {
1841
- if (!isRecord2(value)) {
2249
+ if (!isRecord3(value)) {
1842
2250
  add(errors, path, "Expected a reward object.", "invalid_type");
1843
2251
  return;
1844
2252
  }
@@ -1881,7 +2289,7 @@ function reward(value, path, errors, isPublic) {
1881
2289
  }
1882
2290
  function validate(value, isPublic) {
1883
2291
  const errors = [];
1884
- if (!isRecord2(value)) {
2292
+ if (!isRecord3(value)) {
1885
2293
  add(errors, "$", "Expected a configuration object.", "invalid_type");
1886
2294
  return errors;
1887
2295
  }
@@ -1902,10 +2310,12 @@ function validate(value, isPublic) {
1902
2310
  });
1903
2311
  if (!isPublic) {
1904
2312
  optionalString(value, "staffPasscode", "$", errors);
1905
- if (hasOwn(value, "inventory") && value.inventory !== void 0 && !isRecord2(value.inventory))
2313
+ if (hasOwn(value, "inventory") && value.inventory !== void 0 && !isRecord3(value.inventory))
1906
2314
  add(errors, "$.inventory", "Expected an object.", "invalid_type");
1907
- if (hasOwn(value, "serverMetadata") && value.serverMetadata !== void 0 && !isRecord2(value.serverMetadata))
2315
+ if (hasOwn(value, "serverMetadata") && value.serverMetadata !== void 0 && !isRecord3(value.serverMetadata))
1908
2316
  add(errors, "$.serverMetadata", "Expected an object.", "invalid_type");
2317
+ if (hasOwn(value, "publicMetadata") && value.publicMetadata !== void 0 && !isRecord3(value.publicMetadata))
2318
+ add(errors, "$.publicMetadata", "Expected an object.", "invalid_type");
1909
2319
  optionalString(value, "serverEndpoint", "$", errors);
1910
2320
  } else {
1911
2321
  for (const key of ["staffPasscode", "serverMetadata", "inventory"])
@@ -2053,6 +2463,6 @@ async function verifySnapshotToken(token, secretKey, now = Date.now()) {
2053
2463
  }
2054
2464
  }
2055
2465
 
2056
- export { ConfigValidationError, DEFAULT_SHEET_THEME, InMemoryStorage, IndexedDBAdapter, LocalStorageAdapter, StampRallyClient, StorageAdapterError, THEME_PRESETS, assertPublicConfig, calculateDistanceMeters, calculateProgress, consumeReward, createClaimTicketNumber, createSecureToken, createSignedSnapshotToken, createUniqueClaimTicketNumber, evaluateCondition, evaluateConditionDetailed, exportProgressToken, getCurrentGeoContext, getOrderedSpots, importProgressToken, isGeolocationSupported, isNfcSupported, isPublicConfig, isQrSupported, isRewardState, isStampRallyState, issueClaimTicketNumber, normalizePasscode, parseAdminConfig, parsePublicConfig, processStamp, readNfcContext, readQrContext, reconcileRewardStates, resolveLocalizedText, safeParseAdminConfig, safeParsePublicConfig, storageKey, toLocalizedString, toPublicConfig, updateLocalizedField, verifyPasscode, verifySecureToken, verifySnapshotToken };
2466
+ export { ConfigValidationError, DEFAULT_SHEET_THEME, MemoryQueueStorage as InMemoryOfflineQueueStorage, InMemoryStorage, IndexedDBAdapter, IndexedDBOfflineQueueStorage, LocalStorageAdapter, OfflineQueue, StampRallyClient, StorageAdapterError, THEME_PRESETS, assertPublicConfig, calculateDistanceMeters, calculateProgress, consumeReward, createClaimTicketNumber, createSecureToken, createSignedSnapshotToken, createUniqueClaimTicketNumber, evaluateCondition, evaluateConditionDetailed, exportProgressToken, getCurrentGeoContext, getOrderedSpots, importProgressToken, isGeolocationSupported, isNfcSupported, isPublicConfig, isQrSupported, isRewardState, isStampRallyState, issueClaimTicketNumber, normalizePasscode, parseAdminConfig, parsePublicConfig, processStamp, readNfcContext, readQrContext, reconcileRewardStates, resolveLocalizedText, resolveRallyStateConflict, safeParseAdminConfig, safeParsePublicConfig, sanitizeAdminConfig, storageKey, toLocalizedString, toPublicConfig, updateLocalizedField, validatePublicConfigSafety, verifyPasscode, verifySecureToken, verifySnapshotToken };
2057
2467
  //# sourceMappingURL=index.js.map
2058
2468
  //# sourceMappingURL=index.js.map