@breeztech/breez-sdk-spark 0.7.18 → 0.8.0-dev1

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.
@@ -148,6 +148,8 @@ class MigrationManager {
148
148
  {
149
149
  name: "Add sync tables",
150
150
  upgrade: (db, transaction) => {
151
+ // sync_revision: tracks the last committed revision (from server-acknowledged
152
+ // or server-received records). Does NOT include pending outgoing revisions.
151
153
  if (!db.objectStoreNames.contains("sync_revision")) {
152
154
  const syncRevisionStore = db.createObjectStore("sync_revision", {
153
155
  keyPath: "id",
@@ -178,15 +180,17 @@ class MigrationManager {
178
180
  if (!db.objectStoreNames.contains("sync_state")) {
179
181
  db.createObjectStore("sync_state", { keyPath: ["type", "dataId"] });
180
182
  }
181
- }
183
+ },
182
184
  },
183
185
  {
184
186
  name: "Create lnurl_receive_metadata store",
185
187
  upgrade: (db) => {
186
188
  if (!db.objectStoreNames.contains("lnurl_receive_metadata")) {
187
- db.createObjectStore("lnurl_receive_metadata", { keyPath: "paymentHash" });
189
+ db.createObjectStore("lnurl_receive_metadata", {
190
+ keyPath: "paymentHash",
191
+ });
188
192
  }
189
- }
193
+ },
190
194
  },
191
195
  {
192
196
  // Delete all unclaimed deposits to clear old claim_error JSON format.
@@ -197,7 +201,7 @@ class MigrationManager {
197
201
  const store = transaction.objectStore("unclaimed_deposits");
198
202
  store.clear();
199
203
  }
200
- }
204
+ },
201
205
  },
202
206
  {
203
207
  name: "Clear sync tables for BreezSigner backward compatibility",
@@ -236,6 +240,97 @@ class MigrationManager {
236
240
  settings.delete("sync_initial_complete");
237
241
  }
238
242
  }
243
+ },
244
+ {
245
+ name: "Create parentPaymentId index for related payments lookup",
246
+ upgrade: (db, transaction) => {
247
+ if (db.objectStoreNames.contains("payment_metadata")) {
248
+ const metadataStore = transaction.objectStore("payment_metadata");
249
+ if (!metadataStore.indexNames.contains("parentPaymentId")) {
250
+ metadataStore.createIndex("parentPaymentId", "parentPaymentId", { unique: false });
251
+ }
252
+ }
253
+ }
254
+ },
255
+ {
256
+ name: "Add tx_type to token payments and trigger token re-sync",
257
+ upgrade: (db, transaction) => {
258
+ // Update all existing token payments to have a default txType
259
+ if (db.objectStoreNames.contains("payments")) {
260
+ const paymentStore = transaction.objectStore("payments");
261
+ const getAllRequest = paymentStore.getAll();
262
+
263
+ getAllRequest.onsuccess = () => {
264
+ const payments = getAllRequest.result;
265
+
266
+ payments.forEach((payment) => {
267
+ // Parse details if it's a string
268
+ let details = null;
269
+ if (payment.details && typeof payment.details === "string") {
270
+ try {
271
+ details = JSON.parse(payment.details);
272
+ } catch (e) {
273
+ return; // Skip this payment if parsing fails
274
+ }
275
+ } else {
276
+ details = payment.details;
277
+ }
278
+
279
+ // Add default txType to token payments
280
+ if (details && details.type === "token" && !details.txType) {
281
+ details.txType = "transfer";
282
+ payment.details = JSON.stringify(details);
283
+ paymentStore.put(payment);
284
+ }
285
+ });
286
+ };
287
+ }
288
+
289
+ // Reset sync cache to trigger token re-sync
290
+ if (db.objectStoreNames.contains("settings")) {
291
+ const settingsStore = transaction.objectStore("settings");
292
+ const getRequest = settingsStore.get("sync_offset");
293
+
294
+ getRequest.onsuccess = () => {
295
+ const syncCache = getRequest.result;
296
+ if (syncCache && syncCache.value) {
297
+ try {
298
+ const syncInfo = JSON.parse(syncCache.value);
299
+ // Reset only the token sync position, keep the bitcoin offset
300
+ syncInfo.last_synced_final_token_payment_id = null;
301
+ settingsStore.put({
302
+ key: "sync_offset",
303
+ value: JSON.stringify(syncInfo),
304
+ });
305
+ } catch (e) {
306
+ // If parsing fails, just continue
307
+ }
308
+ }
309
+ };
310
+ }
311
+ },
312
+ },
313
+ {
314
+ name: "Clear sync tables to force re-sync",
315
+ upgrade: (db, transaction) => {
316
+ if (db.objectStoreNames.contains("sync_outgoing")) {
317
+ transaction.objectStore("sync_outgoing").clear();
318
+ }
319
+ if (db.objectStoreNames.contains("sync_incoming")) {
320
+ transaction.objectStore("sync_incoming").clear();
321
+ }
322
+ if (db.objectStoreNames.contains("sync_state")) {
323
+ transaction.objectStore("sync_state").clear();
324
+ }
325
+ if (db.objectStoreNames.contains("sync_revision")) {
326
+ const syncRevision = transaction.objectStore("sync_revision");
327
+ syncRevision.clear();
328
+ syncRevision.put({ id: 1, revision: "0" });
329
+ }
330
+ if (db.objectStoreNames.contains("settings")) {
331
+ transaction.objectStore("settings").delete("sync_initial_complete");
332
+ }
333
+ }
239
334
  }
240
335
  ];
241
336
  }
@@ -260,7 +355,7 @@ class IndexedDBStorage {
260
355
  this.db = null;
261
356
  this.migrationManager = null;
262
357
  this.logger = logger;
263
- this.dbVersion = 7; // Current schema version
358
+ this.dbVersion = 10; // Current schema version
264
359
  }
265
360
 
266
361
  /**
@@ -418,6 +513,59 @@ class IndexedDBStorage {
418
513
 
419
514
  // ===== Payment Operations =====
420
515
 
516
+ /**
517
+ * Gets the set of payment IDs that are related payments (have a parentPaymentId).
518
+ * Uses the parentPaymentId index for efficient lookup.
519
+ * @param {IDBObjectStore} metadataStore - The payment_metadata object store
520
+ * @returns {Promise<Set<string>>} Set of payment IDs that are related payments
521
+ */
522
+ _getRelatedPaymentIds(metadataStore) {
523
+ return new Promise((resolve) => {
524
+ const relatedPaymentIds = new Set();
525
+
526
+ // Check if the parentPaymentId index exists (added in migration)
527
+ if (!metadataStore.indexNames.contains("parentPaymentId")) {
528
+ // Index doesn't exist yet, fall back to scanning all metadata
529
+ const cursorRequest = metadataStore.openCursor();
530
+ cursorRequest.onsuccess = (event) => {
531
+ const cursor = event.target.result;
532
+ if (cursor) {
533
+ if (cursor.value.parentPaymentId) {
534
+ relatedPaymentIds.add(cursor.value.paymentId);
535
+ }
536
+ cursor.continue();
537
+ } else {
538
+ resolve(relatedPaymentIds);
539
+ }
540
+ };
541
+ cursorRequest.onerror = () => resolve(new Set());
542
+ return;
543
+ }
544
+
545
+ // Use the parentPaymentId index to find all metadata entries with a parent
546
+ const index = metadataStore.index("parentPaymentId");
547
+ const cursorRequest = index.openCursor();
548
+
549
+ cursorRequest.onsuccess = (event) => {
550
+ const cursor = event.target.result;
551
+ if (cursor) {
552
+ // Only add if parentPaymentId is truthy (not null/undefined)
553
+ if (cursor.value.parentPaymentId) {
554
+ relatedPaymentIds.add(cursor.value.paymentId);
555
+ }
556
+ cursor.continue();
557
+ } else {
558
+ resolve(relatedPaymentIds);
559
+ }
560
+ };
561
+
562
+ cursorRequest.onerror = () => {
563
+ // If index lookup fails, return empty set and fall back to per-payment lookup
564
+ resolve(new Set());
565
+ };
566
+ });
567
+ }
568
+
421
569
  async listPayments(request) {
422
570
  if (!this.db) {
423
571
  throw new StorageError("Database not initialized");
@@ -427,15 +575,18 @@ class IndexedDBStorage {
427
575
  const actualOffset = request.offset !== null ? request.offset : 0;
428
576
  const actualLimit = request.limit !== null ? request.limit : 4294967295; // u32::MAX
429
577
 
430
- return new Promise((resolve, reject) => {
431
- const transaction = this.db.transaction(
432
- ["payments", "payment_metadata", "lnurl_receive_metadata"],
433
- "readonly"
434
- );
435
- const paymentStore = transaction.objectStore("payments");
436
- const metadataStore = transaction.objectStore("payment_metadata");
437
- const lnurlReceiveMetadataStore = transaction.objectStore("lnurl_receive_metadata");
578
+ const transaction = this.db.transaction(
579
+ ["payments", "payment_metadata", "lnurl_receive_metadata"],
580
+ "readonly"
581
+ );
582
+ const paymentStore = transaction.objectStore("payments");
583
+ const metadataStore = transaction.objectStore("payment_metadata");
584
+ const lnurlReceiveMetadataStore = transaction.objectStore("lnurl_receive_metadata");
438
585
 
586
+ // Build set of related payment IDs upfront for O(1) filtering
587
+ const relatedPaymentIds = await this._getRelatedPaymentIds(metadataStore);
588
+
589
+ return new Promise((resolve, reject) => {
439
590
  const payments = [];
440
591
  let count = 0;
441
592
  let skipped = 0;
@@ -458,16 +609,23 @@ class IndexedDBStorage {
458
609
 
459
610
  const payment = cursor.value;
460
611
 
612
+ // Skip related payments (those with a parentPaymentId)
613
+ if (relatedPaymentIds.has(payment.id)) {
614
+ cursor.continue();
615
+ return;
616
+ }
617
+
461
618
  if (skipped < actualOffset) {
462
619
  skipped++;
463
620
  cursor.continue();
464
621
  return;
465
622
  }
466
623
 
467
- // Get metadata for this payment
624
+ // Get metadata for this payment (now only for non-related payments)
468
625
  const metadataRequest = metadataStore.get(payment.id);
469
626
  metadataRequest.onsuccess = () => {
470
627
  const metadata = metadataRequest.result;
628
+
471
629
  const paymentWithMetadata = this._mergePaymentMetadata(
472
630
  payment,
473
631
  metadata
@@ -478,9 +636,12 @@ class IndexedDBStorage {
478
636
  cursor.continue();
479
637
  return;
480
638
  }
481
-
639
+
482
640
  // Fetch lnurl receive metadata if it's a lightning payment
483
- this._fetchLnurlReceiveMetadata(paymentWithMetadata, lnurlReceiveMetadataStore)
641
+ this._fetchLnurlReceiveMetadata(
642
+ paymentWithMetadata,
643
+ lnurlReceiveMetadataStore
644
+ )
484
645
  .then((mergedPayment) => {
485
646
  payments.push(mergedPayment);
486
647
  count++;
@@ -560,7 +721,9 @@ class IndexedDBStorage {
560
721
  );
561
722
  const paymentStore = transaction.objectStore("payments");
562
723
  const metadataStore = transaction.objectStore("payment_metadata");
563
- const lnurlReceiveMetadataStore = transaction.objectStore("lnurl_receive_metadata");
724
+ const lnurlReceiveMetadataStore = transaction.objectStore(
725
+ "lnurl_receive_metadata"
726
+ );
564
727
 
565
728
  const paymentRequest = paymentStore.get(id);
566
729
 
@@ -579,9 +742,12 @@ class IndexedDBStorage {
579
742
  payment,
580
743
  metadata
581
744
  );
582
-
745
+
583
746
  // Fetch lnurl receive metadata if it's a lightning payment
584
- this._fetchLnurlReceiveMetadata(paymentWithMetadata, lnurlReceiveMetadataStore)
747
+ this._fetchLnurlReceiveMetadata(
748
+ paymentWithMetadata,
749
+ lnurlReceiveMetadataStore
750
+ )
585
751
  .then(resolve)
586
752
  .catch(() => {
587
753
  // Continue without lnurl receive metadata if fetch fails
@@ -620,7 +786,9 @@ class IndexedDBStorage {
620
786
  const paymentStore = transaction.objectStore("payments");
621
787
  const invoiceIndex = paymentStore.index("invoice");
622
788
  const metadataStore = transaction.objectStore("payment_metadata");
623
- const lnurlReceiveMetadataStore = transaction.objectStore("lnurl_receive_metadata");
789
+ const lnurlReceiveMetadataStore = transaction.objectStore(
790
+ "lnurl_receive_metadata"
791
+ );
624
792
 
625
793
  const paymentRequest = invoiceIndex.get(invoice);
626
794
 
@@ -639,9 +807,12 @@ class IndexedDBStorage {
639
807
  payment,
640
808
  metadata
641
809
  );
642
-
810
+
643
811
  // Fetch lnurl receive metadata if it's a lightning payment
644
- this._fetchLnurlReceiveMetadata(paymentWithMetadata, lnurlReceiveMetadataStore)
812
+ this._fetchLnurlReceiveMetadata(
813
+ paymentWithMetadata,
814
+ lnurlReceiveMetadataStore
815
+ )
645
816
  .then(resolve)
646
817
  .catch(() => {
647
818
  // Continue without lnurl receive metadata if fetch fails
@@ -667,7 +838,173 @@ class IndexedDBStorage {
667
838
  });
668
839
  }
669
840
 
670
- async setPaymentMetadata(paymentId, metadata) {
841
+ /**
842
+ * Checks if any related payments exist (payments with a parentPaymentId).
843
+ * Uses the parentPaymentId index for efficient lookup.
844
+ * @param {IDBObjectStore} metadataStore - The payment_metadata object store
845
+ * @returns {Promise<boolean>} True if any related payments exist
846
+ */
847
+ _hasRelatedPayments(metadataStore) {
848
+ return new Promise((resolve) => {
849
+ // Check if the parentPaymentId index exists (added in migration)
850
+ if (!metadataStore.indexNames.contains("parentPaymentId")) {
851
+ // Index doesn't exist yet, fall back to scanning all metadata
852
+ const cursorRequest = metadataStore.openCursor();
853
+ cursorRequest.onsuccess = (event) => {
854
+ const cursor = event.target.result;
855
+ if (cursor) {
856
+ if (cursor.value.parentPaymentId) {
857
+ resolve(true);
858
+ return;
859
+ }
860
+ cursor.continue();
861
+ } else {
862
+ resolve(false);
863
+ }
864
+ };
865
+ cursorRequest.onerror = () => resolve(true); // Assume there might be related payments on error
866
+ return;
867
+ }
868
+
869
+ const index = metadataStore.index("parentPaymentId");
870
+ const cursorRequest = index.openCursor();
871
+
872
+ cursorRequest.onsuccess = (event) => {
873
+ const cursor = event.target.result;
874
+ if (cursor && cursor.value.parentPaymentId) {
875
+ // Found at least one related payment
876
+ resolve(true);
877
+ } else if (cursor) {
878
+ // Entry with null parentPaymentId, continue searching
879
+ cursor.continue();
880
+ } else {
881
+ // No more entries
882
+ resolve(false);
883
+ }
884
+ };
885
+
886
+ cursorRequest.onerror = () => {
887
+ // If index lookup fails, assume there might be related payments
888
+ resolve(true);
889
+ };
890
+ });
891
+ }
892
+
893
+ /**
894
+ * Gets payments that have any of the specified parent payment IDs.
895
+ * @param {string[]} parentPaymentIds - Array of parent payment IDs
896
+ * @returns {Promise<Object>} Map of parentPaymentId -> array of RelatedPayment objects
897
+ */
898
+ async getPaymentsByParentIds(parentPaymentIds) {
899
+ if (!this.db) {
900
+ throw new StorageError("Database not initialized");
901
+ }
902
+
903
+ if (!parentPaymentIds || parentPaymentIds.length === 0) {
904
+ return {};
905
+ }
906
+
907
+ const transaction = this.db.transaction(
908
+ ["payments", "payment_metadata", "lnurl_receive_metadata"],
909
+ "readonly"
910
+ );
911
+ const metadataStore = transaction.objectStore("payment_metadata");
912
+
913
+ // Early exit if no related payments exist
914
+ const hasRelated = await this._hasRelatedPayments(metadataStore);
915
+ if (!hasRelated) {
916
+ return {};
917
+ }
918
+
919
+ const parentIdSet = new Set(parentPaymentIds);
920
+ const paymentStore = transaction.objectStore("payments");
921
+ const lnurlReceiveMetadataStore = transaction.objectStore("lnurl_receive_metadata");
922
+
923
+ return new Promise((resolve, reject) => {
924
+ const result = {};
925
+ const fetchedMetadata = [];
926
+
927
+ // Query all metadata records and filter by parentPaymentId
928
+ const cursorRequest = metadataStore.openCursor();
929
+
930
+ cursorRequest.onsuccess = (event) => {
931
+ const cursor = event.target.result;
932
+ if (!cursor) {
933
+ // All metadata processed, now fetch payment details
934
+ if (fetchedMetadata.length === 0) {
935
+ resolve(result);
936
+ return;
937
+ }
938
+
939
+ let processed = 0;
940
+ for (const metadata of fetchedMetadata) {
941
+ const parentId = metadata.parentPaymentId;
942
+ const paymentRequest = paymentStore.get(metadata.paymentId);
943
+ paymentRequest.onsuccess = () => {
944
+ const payment = paymentRequest.result;
945
+ if (payment) {
946
+ const paymentWithMetadata = this._mergePaymentMetadata(payment, metadata);
947
+
948
+ if (!result[parentId]) {
949
+ result[parentId] = [];
950
+ }
951
+
952
+ // Fetch lnurl receive metadata if applicable
953
+ this._fetchLnurlReceiveMetadata(paymentWithMetadata, lnurlReceiveMetadataStore)
954
+ .then((mergedPayment) => {
955
+ result[parentId].push(mergedPayment);
956
+ })
957
+ .catch(() => {
958
+ result[parentId].push(paymentWithMetadata);
959
+ })
960
+ .finally(() => {
961
+ processed++;
962
+ if (processed === fetchedMetadata.length) {
963
+ // Sort each parent's children by timestamp
964
+ for (const parentId of Object.keys(result)) {
965
+ result[parentId].sort((a, b) => a.timestamp - b.timestamp);
966
+ }
967
+ resolve(result);
968
+ }
969
+ });
970
+ } else {
971
+ processed++;
972
+ if (processed === fetchedMetadata.length) {
973
+ resolve(result);
974
+ }
975
+ }
976
+ };
977
+ paymentRequest.onerror = () => {
978
+ processed++;
979
+ if (processed === fetchedMetadata.length) {
980
+ resolve(result);
981
+ }
982
+ };
983
+ }
984
+ return;
985
+ }
986
+
987
+ const metadata = cursor.value;
988
+ if (metadata.parentPaymentId && parentIdSet.has(metadata.parentPaymentId)) {
989
+ fetchedMetadata.push(metadata);
990
+ }
991
+ cursor.continue();
992
+ };
993
+
994
+ cursorRequest.onerror = () => {
995
+ reject(
996
+ new StorageError(
997
+ `Failed to get payments by parent ids: ${
998
+ cursorRequest.error?.message || "Unknown error"
999
+ }`,
1000
+ cursorRequest.error
1001
+ )
1002
+ );
1003
+ };
1004
+ });
1005
+ }
1006
+
1007
+ async insertPaymentMetadata(paymentId, metadata) {
671
1008
  if (!this.db) {
672
1009
  throw new StorageError("Database not initialized");
673
1010
  }
@@ -676,30 +1013,47 @@ class IndexedDBStorage {
676
1013
  const transaction = this.db.transaction("payment_metadata", "readwrite");
677
1014
  const store = transaction.objectStore("payment_metadata");
678
1015
 
679
- const metadataToStore = {
680
- paymentId,
681
- parentPaymentId: metadata.parentPaymentId,
682
- lnurlPayInfo: metadata.lnurlPayInfo
683
- ? JSON.stringify(metadata.lnurlPayInfo)
684
- : null,
685
- lnurlWithdrawInfo: metadata.lnurlWithdrawInfo
686
- ? JSON.stringify(metadata.lnurlWithdrawInfo)
687
- : null,
688
- lnurlDescription: metadata.lnurlDescription,
689
- conversionInfo: metadata.conversionInfo
690
- ? JSON.stringify(metadata.conversionInfo)
691
- : null,
692
- };
1016
+ // First get existing record to merge with
1017
+ const getRequest = store.get(paymentId);
1018
+ getRequest.onsuccess = () => {
1019
+ const existing = getRequest.result || {};
1020
+
1021
+ // Use COALESCE-like behavior: new value if non-null, otherwise keep existing
1022
+ const metadataToStore = {
1023
+ paymentId,
1024
+ parentPaymentId: metadata.parentPaymentId ?? existing.parentPaymentId ?? null,
1025
+ lnurlPayInfo: metadata.lnurlPayInfo
1026
+ ? JSON.stringify(metadata.lnurlPayInfo)
1027
+ : existing.lnurlPayInfo ?? null,
1028
+ lnurlWithdrawInfo: metadata.lnurlWithdrawInfo
1029
+ ? JSON.stringify(metadata.lnurlWithdrawInfo)
1030
+ : existing.lnurlWithdrawInfo ?? null,
1031
+ lnurlDescription: metadata.lnurlDescription ?? existing.lnurlDescription ?? null,
1032
+ conversionInfo: metadata.conversionInfo
1033
+ ? JSON.stringify(metadata.conversionInfo)
1034
+ : existing.conversionInfo ?? null,
1035
+ };
693
1036
 
694
- const request = store.put(metadataToStore);
695
- request.onsuccess = () => resolve();
696
- request.onerror = () => {
1037
+ const putRequest = store.put(metadataToStore);
1038
+ putRequest.onsuccess = () => resolve();
1039
+ putRequest.onerror = () => {
1040
+ reject(
1041
+ new StorageError(
1042
+ `Failed to set payment metadata for '${paymentId}': ${
1043
+ putRequest.error?.message || "Unknown error"
1044
+ }`,
1045
+ putRequest.error
1046
+ )
1047
+ );
1048
+ };
1049
+ };
1050
+ getRequest.onerror = () => {
697
1051
  reject(
698
1052
  new StorageError(
699
- `Failed to set payment metadata for '${paymentId}': ${
700
- request.error?.message || "Unknown error"
1053
+ `Failed to get existing payment metadata for '${paymentId}': ${
1054
+ getRequest.error?.message || "Unknown error"
701
1055
  }`,
702
- request.error
1056
+ getRequest.error
703
1057
  )
704
1058
  );
705
1059
  };
@@ -909,9 +1263,9 @@ class IndexedDBStorage {
909
1263
  request.onerror = () => {
910
1264
  reject(
911
1265
  new StorageError(
912
- `Failed to add lnurl metadata for payment hash '${item.paymentHash}': ${
913
- request.error?.message || "Unknown error"
914
- }`,
1266
+ `Failed to add lnurl metadata for payment hash '${
1267
+ item.paymentHash
1268
+ }': ${request.error?.message || "Unknown error"}`,
915
1269
  request.error
916
1270
  )
917
1271
  );
@@ -931,63 +1285,74 @@ class IndexedDBStorage {
931
1285
  "readwrite"
932
1286
  );
933
1287
 
934
- // Get the next revision
1288
+ // Compute next revision as max(committed, max outgoing) + 1, without updating sync_revision
935
1289
  const revisionStore = transaction.objectStore("sync_revision");
1290
+ const outgoingStore = transaction.objectStore("sync_outgoing");
1291
+
936
1292
  const getRevisionRequest = revisionStore.get(1);
1293
+ const getAllOutgoingRequest = outgoingStore.getAll();
937
1294
 
938
- getRevisionRequest.onsuccess = () => {
939
- const revisionData = getRevisionRequest.result || {
940
- id: 1,
941
- revision: "0",
942
- };
943
- const nextRevision = BigInt(revisionData.revision) + BigInt(1);
1295
+ let committedRevision = null;
1296
+ let maxOutgoingRevision = null;
1297
+ let resultsReady = 0;
944
1298
 
945
- // Update the revision
946
- const updateRequest = revisionStore.put({
947
- id: 1,
948
- revision: nextRevision.toString(),
949
- });
1299
+ const onBothReady = () => {
1300
+ resultsReady++;
1301
+ if (resultsReady < 2) return;
950
1302
 
951
- updateRequest.onsuccess = () => {
952
- const outgoingStore = transaction.objectStore("sync_outgoing");
953
-
954
- const storeRecord = {
955
- type: record.id.type,
956
- dataId: record.id.dataId,
957
- revision: Number(nextRevision),
958
- record: {
959
- ...record,
960
- revision: nextRevision,
961
- },
962
- };
1303
+ const base = committedRevision > maxOutgoingRevision
1304
+ ? committedRevision
1305
+ : maxOutgoingRevision;
1306
+ const nextRevision = base + BigInt(1);
963
1307
 
964
- const addRequest = outgoingStore.add(storeRecord);
1308
+ const storeRecord = {
1309
+ type: record.id.type,
1310
+ dataId: record.id.dataId,
1311
+ revision: Number(nextRevision),
1312
+ record: {
1313
+ ...record,
1314
+ revision: nextRevision,
1315
+ },
1316
+ };
965
1317
 
966
- addRequest.onsuccess = () => {
967
- // Wait for transaction to complete before resolving
968
- transaction.oncomplete = () => {
969
- resolve(nextRevision);
970
- };
971
- };
1318
+ const addRequest = outgoingStore.add(storeRecord);
972
1319
 
973
- addRequest.onerror = (event) => {
974
- reject(
975
- new StorageError(
976
- `Failed to add outgoing change: ${event.target.error.message}`
977
- )
978
- );
1320
+ addRequest.onsuccess = () => {
1321
+ transaction.oncomplete = () => {
1322
+ resolve(nextRevision);
979
1323
  };
980
1324
  };
981
1325
 
982
- updateRequest.onerror = (event) => {
1326
+ addRequest.onerror = (event) => {
983
1327
  reject(
984
1328
  new StorageError(
985
- `Failed to update revision: ${event.target.error.message}`
1329
+ `Failed to add outgoing change: ${event.target.error.message}`
986
1330
  )
987
1331
  );
988
1332
  };
989
1333
  };
990
1334
 
1335
+ getRevisionRequest.onsuccess = () => {
1336
+ const revisionData = getRevisionRequest.result || {
1337
+ id: 1,
1338
+ revision: "0",
1339
+ };
1340
+ committedRevision = BigInt(revisionData.revision);
1341
+ onBothReady();
1342
+ };
1343
+
1344
+ getAllOutgoingRequest.onsuccess = () => {
1345
+ const records = getAllOutgoingRequest.result;
1346
+ maxOutgoingRevision = BigInt(0);
1347
+ for (const storeRecord of records) {
1348
+ const rev = BigInt(storeRecord.record.revision);
1349
+ if (rev > maxOutgoingRevision) {
1350
+ maxOutgoingRevision = rev;
1351
+ }
1352
+ }
1353
+ onBothReady();
1354
+ };
1355
+
991
1356
  getRevisionRequest.onerror = (event) => {
992
1357
  reject(
993
1358
  new StorageError(
@@ -996,6 +1361,14 @@ class IndexedDBStorage {
996
1361
  );
997
1362
  };
998
1363
 
1364
+ getAllOutgoingRequest.onerror = (event) => {
1365
+ reject(
1366
+ new StorageError(
1367
+ `Failed to get outgoing records: ${event.target.error.message}`
1368
+ )
1369
+ );
1370
+ };
1371
+
999
1372
  transaction.onerror = (event) => {
1000
1373
  reject(
1001
1374
  new StorageError(`Transaction failed: ${event.target.error.message}`)
@@ -1004,23 +1377,24 @@ class IndexedDBStorage {
1004
1377
  });
1005
1378
  }
1006
1379
 
1007
- async syncCompleteOutgoingSync(record) {
1380
+ async syncCompleteOutgoingSync(record, localRevision) {
1008
1381
  if (!this.db) {
1009
1382
  throw new StorageError("Database not initialized");
1010
1383
  }
1011
1384
 
1012
1385
  return new Promise((resolve, reject) => {
1013
1386
  const transaction = this.db.transaction(
1014
- ["sync_outgoing", "sync_state"],
1387
+ ["sync_outgoing", "sync_state", "sync_revision"],
1015
1388
  "readwrite"
1016
1389
  );
1017
1390
  const outgoingStore = transaction.objectStore("sync_outgoing");
1018
1391
  const stateStore = transaction.objectStore("sync_state");
1392
+ const revisionStore = transaction.objectStore("sync_revision");
1019
1393
 
1020
1394
  const deleteRequest = outgoingStore.delete([
1021
1395
  record.id.type,
1022
1396
  record.id.dataId,
1023
- Number(record.revision),
1397
+ Number(localRevision),
1024
1398
  ]);
1025
1399
 
1026
1400
  deleteRequest.onsuccess = () => {
@@ -1030,7 +1404,25 @@ class IndexedDBStorage {
1030
1404
  record: record,
1031
1405
  };
1032
1406
  stateStore.put(stateRecord);
1033
- resolve();
1407
+
1408
+ // Update sync_revision to track the highest known revision
1409
+ const getRevisionRequest = revisionStore.get(1);
1410
+ getRevisionRequest.onsuccess = () => {
1411
+ const current = getRevisionRequest.result || { id: 1, revision: "0" };
1412
+ const currentRevision = BigInt(current.revision);
1413
+ const recordRevision = BigInt(record.revision);
1414
+ if (recordRevision > currentRevision) {
1415
+ revisionStore.put({ id: 1, revision: recordRevision.toString() });
1416
+ }
1417
+ resolve();
1418
+ };
1419
+ getRevisionRequest.onerror = (event) => {
1420
+ reject(
1421
+ new StorageError(
1422
+ `Failed to update sync revision: ${event.target.error.message}`
1423
+ )
1424
+ );
1425
+ };
1034
1426
  };
1035
1427
 
1036
1428
  deleteRequest.onerror = (event) => {
@@ -1220,7 +1612,7 @@ class IndexedDBStorage {
1220
1612
  const outgoingStore = transaction.objectStore("sync_outgoing");
1221
1613
  const revisionStore = transaction.objectStore("sync_revision");
1222
1614
 
1223
- // Get the last revision from sync_revision table
1615
+ // Get the last committed revision from sync_revision
1224
1616
  const getRevisionRequest = revisionStore.get(1);
1225
1617
 
1226
1618
  getRevisionRequest.onsuccess = () => {
@@ -1231,10 +1623,17 @@ class IndexedDBStorage {
1231
1623
  const lastRevision = BigInt(revisionData.revision);
1232
1624
 
1233
1625
  // Calculate the difference
1234
- const diff = revision - lastRevision;
1626
+ const diff = revision > lastRevision ? revision - lastRevision : BigInt(0);
1627
+
1628
+ // Helper to update sync_revision within the same transaction so retries are idempotent
1629
+ const updateSyncRevision = () => {
1630
+ if (revision > lastRevision) {
1631
+ revisionStore.put({ id: 1, revision: revision.toString() });
1632
+ }
1633
+ };
1235
1634
 
1236
1635
  if (diff <= BigInt(0)) {
1237
- // No rebase needed
1636
+ updateSyncRevision();
1238
1637
  resolve();
1239
1638
  return;
1240
1639
  }
@@ -1244,13 +1643,15 @@ class IndexedDBStorage {
1244
1643
 
1245
1644
  getAllRequest.onsuccess = () => {
1246
1645
  const records = getAllRequest.result;
1247
- let updatesCompleted = 0;
1248
1646
 
1249
1647
  if (records.length === 0) {
1648
+ updateSyncRevision();
1250
1649
  resolve();
1251
1650
  return;
1252
1651
  }
1253
1652
 
1653
+ let updatesCompleted = 0;
1654
+
1254
1655
  for (const storeRecord of records) {
1255
1656
  // Delete the old record
1256
1657
  const oldKey = [
@@ -1278,6 +1679,7 @@ class IndexedDBStorage {
1278
1679
  putRequest.onsuccess = () => {
1279
1680
  updatesCompleted++;
1280
1681
  if (updatesCompleted === records.length) {
1682
+ updateSyncRevision();
1281
1683
  resolve();
1282
1684
  }
1283
1685
  };
@@ -1446,8 +1848,9 @@ class IndexedDBStorage {
1446
1848
  }
1447
1849
 
1448
1850
  return new Promise((resolve, reject) => {
1449
- const transaction = this.db.transaction(["sync_state"], "readwrite");
1851
+ const transaction = this.db.transaction(["sync_state", "sync_revision"], "readwrite");
1450
1852
  const stateStore = transaction.objectStore("sync_state");
1853
+ const revisionStore = transaction.objectStore("sync_revision");
1451
1854
 
1452
1855
  const storeRecord = {
1453
1856
  type: record.id.type,
@@ -1458,7 +1861,24 @@ class IndexedDBStorage {
1458
1861
  const request = stateStore.put(storeRecord);
1459
1862
 
1460
1863
  request.onsuccess = () => {
1461
- resolve();
1864
+ // Update sync_revision to track the highest known revision
1865
+ const getRevisionRequest = revisionStore.get(1);
1866
+ getRevisionRequest.onsuccess = () => {
1867
+ const current = getRevisionRequest.result || { id: 1, revision: "0" };
1868
+ const currentRevision = BigInt(current.revision);
1869
+ const incomingRevision = BigInt(record.revision);
1870
+ if (incomingRevision > currentRevision) {
1871
+ revisionStore.put({ id: 1, revision: incomingRevision.toString() });
1872
+ }
1873
+ resolve();
1874
+ };
1875
+ getRevisionRequest.onerror = (event) => {
1876
+ reject(
1877
+ new StorageError(
1878
+ `Failed to update sync revision: ${event.target.error.message}`
1879
+ )
1880
+ );
1881
+ };
1462
1882
  };
1463
1883
 
1464
1884
  request.onerror = (event) => {
@@ -1502,7 +1922,10 @@ class IndexedDBStorage {
1502
1922
  }
1503
1923
 
1504
1924
  // Filter by payment details
1505
- if (request.paymentDetailsFilter && request.paymentDetailsFilter.length > 0) {
1925
+ if (
1926
+ request.paymentDetailsFilter &&
1927
+ request.paymentDetailsFilter.length > 0
1928
+ ) {
1506
1929
  let details = null;
1507
1930
 
1508
1931
  // Parse details if it's a string (stored in IndexedDB)
@@ -1533,7 +1956,9 @@ class IndexedDBStorage {
1533
1956
  if (
1534
1957
  details.type !== "spark" ||
1535
1958
  !details.htlcDetails ||
1536
- !paymentDetailsFilter.htlcStatus.includes(details.htlcDetails.status)
1959
+ !paymentDetailsFilter.htlcStatus.includes(
1960
+ details.htlcDetails.status
1961
+ )
1537
1962
  ) {
1538
1963
  continue;
1539
1964
  }
@@ -1570,11 +1995,23 @@ class IndexedDBStorage {
1570
1995
  continue;
1571
1996
  }
1572
1997
  }
1998
+ // Filter by token transaction type
1999
+ if (
2000
+ paymentDetailsFilter.type === "token" &&
2001
+ paymentDetailsFilter.txType != null
2002
+ ) {
2003
+ if (
2004
+ details.type !== "token" ||
2005
+ details.txType !== paymentDetailsFilter.txType
2006
+ ) {
2007
+ continue;
2008
+ }
2009
+ }
1573
2010
 
1574
2011
  paymentDetailsFilterMatches = true;
1575
2012
  break;
1576
2013
  }
1577
-
2014
+
1578
2015
  if (!paymentDetailsFilterMatches) {
1579
2016
  return false;
1580
2017
  }
@@ -1706,7 +2143,11 @@ class IndexedDBStorage {
1706
2143
 
1707
2144
  _fetchLnurlReceiveMetadata(payment, lnurlReceiveMetadataStore) {
1708
2145
  // Only fetch for lightning payments with a payment hash
1709
- if (!payment.details || payment.details.type !== "lightning" || !payment.details.paymentHash) {
2146
+ if (
2147
+ !payment.details ||
2148
+ payment.details.type !== "lightning" ||
2149
+ !payment.details.paymentHash
2150
+ ) {
1710
2151
  return Promise.resolve(payment);
1711
2152
  }
1712
2153
 
@@ -1715,11 +2156,17 @@ class IndexedDBStorage {
1715
2156
  }
1716
2157
 
1717
2158
  return new Promise((resolve, reject) => {
1718
- const lnurlReceiveRequest = lnurlReceiveMetadataStore.get(payment.details.paymentHash);
1719
-
2159
+ const lnurlReceiveRequest = lnurlReceiveMetadataStore.get(
2160
+ payment.details.paymentHash
2161
+ );
2162
+
1720
2163
  lnurlReceiveRequest.onsuccess = () => {
1721
2164
  const lnurlReceiveMetadata = lnurlReceiveRequest.result;
1722
- if (lnurlReceiveMetadata && (lnurlReceiveMetadata.nostrZapRequest || lnurlReceiveMetadata.senderComment)) {
2165
+ if (
2166
+ lnurlReceiveMetadata &&
2167
+ (lnurlReceiveMetadata.nostrZapRequest ||
2168
+ lnurlReceiveMetadata.senderComment)
2169
+ ) {
1723
2170
  payment.details.lnurlReceiveMetadata = {
1724
2171
  nostrZapRequest: lnurlReceiveMetadata.nostrZapRequest || null,
1725
2172
  nostrZapReceipt: lnurlReceiveMetadata.nostrZapReceipt || null,
@@ -1728,7 +2175,7 @@ class IndexedDBStorage {
1728
2175
  }
1729
2176
  resolve(payment);
1730
2177
  };
1731
-
2178
+
1732
2179
  lnurlReceiveRequest.onerror = () => {
1733
2180
  // Continue without lnurlReceiveMetadata if fetch fails
1734
2181
  reject(new Error("Failed to fetch lnurl receive metadata"));