@breeztech/breez-sdk-spark 0.9.1 → 0.11.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.
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Database Migration Manager for Breez SDK PostgreSQL Storage
3
+ *
4
+ * Uses a schema_migrations table + pg_advisory_xact_lock to safely run
5
+ * migrations from concurrent processes.
6
+ */
7
+
8
+ const { StorageError } = require("./errors.cjs");
9
+
10
+ /**
11
+ * Advisory lock ID for migrations.
12
+ * Derived from ASCII bytes of "MIGR" (0x4D49_4752).
13
+ */
14
+ const MIGRATION_LOCK_ID = "1296388946"; // 0x4D494752 as decimal string
15
+
16
+ class PostgresMigrationManager {
17
+ constructor(logger = null) {
18
+ this.logger = logger;
19
+ }
20
+
21
+ /**
22
+ * Run all pending migrations inside a single transaction with an advisory lock.
23
+ * @param {import('pg').Pool} pool
24
+ */
25
+ async migrate(pool) {
26
+ const client = await pool.connect();
27
+ try {
28
+ await client.query("BEGIN");
29
+
30
+ // Transaction-level advisory lock — automatically released on COMMIT/ROLLBACK
31
+ await client.query(`SELECT pg_advisory_xact_lock(${MIGRATION_LOCK_ID})`);
32
+
33
+ // Create the migrations tracking table if needed
34
+ await client.query(`
35
+ CREATE TABLE IF NOT EXISTS schema_migrations (
36
+ version INTEGER PRIMARY KEY,
37
+ applied_at TIMESTAMPTZ DEFAULT NOW()
38
+ )
39
+ `);
40
+
41
+ // Get current version
42
+ const versionResult = await client.query(
43
+ "SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations"
44
+ );
45
+ const currentVersion = versionResult.rows[0].version;
46
+
47
+ const migrations = this._getMigrations();
48
+
49
+ if (currentVersion >= migrations.length) {
50
+ this._log("info", `Database is up to date (version ${currentVersion})`);
51
+ await client.query("COMMIT");
52
+ return;
53
+ }
54
+
55
+ this._log(
56
+ "info",
57
+ `Migrating database from version ${currentVersion} to ${migrations.length}`
58
+ );
59
+
60
+ for (let i = currentVersion; i < migrations.length; i++) {
61
+ const migration = migrations[i];
62
+ const version = i + 1;
63
+ this._log("debug", `Running migration ${version}: ${migration.name}`);
64
+
65
+ for (const sql of migration.sql) {
66
+ await client.query(sql);
67
+ }
68
+
69
+ await client.query(
70
+ "INSERT INTO schema_migrations (version) VALUES ($1)",
71
+ [version]
72
+ );
73
+ }
74
+
75
+ await client.query("COMMIT");
76
+ this._log("info", "Database migration completed successfully");
77
+ } catch (error) {
78
+ await client.query("ROLLBACK").catch(() => {});
79
+ throw new StorageError(
80
+ `Migration failed: ${error.message}`,
81
+ error
82
+ );
83
+ } finally {
84
+ client.release();
85
+ }
86
+ }
87
+
88
+ _log(level, message) {
89
+ if (this.logger && typeof this.logger.log === "function") {
90
+ this.logger.log({ line: message, level });
91
+ } else if (level === "error") {
92
+ console.error(`[PostgresMigrationManager] ${message}`);
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Single migration creating all tables at their final schema.
98
+ * This mirrors the Rust-native PostgresStorage schema but uses camelCase
99
+ * enum values (as produced by the WASM bridge).
100
+ */
101
+ _getMigrations() {
102
+ return [
103
+ {
104
+ name: "Create all tables at final schema",
105
+ sql: [
106
+ // -- Core tables --
107
+ `CREATE TABLE IF NOT EXISTS payments (
108
+ id TEXT PRIMARY KEY,
109
+ payment_type TEXT NOT NULL,
110
+ status TEXT NOT NULL,
111
+ amount TEXT NOT NULL,
112
+ fees TEXT NOT NULL,
113
+ timestamp BIGINT NOT NULL,
114
+ method TEXT,
115
+ withdraw_tx_id TEXT,
116
+ deposit_tx_id TEXT,
117
+ spark BOOLEAN
118
+ )`,
119
+
120
+ `CREATE TABLE IF NOT EXISTS settings (
121
+ key TEXT PRIMARY KEY,
122
+ value TEXT NOT NULL
123
+ )`,
124
+
125
+ `CREATE TABLE IF NOT EXISTS unclaimed_deposits (
126
+ txid TEXT NOT NULL,
127
+ vout INTEGER NOT NULL,
128
+ amount_sats BIGINT,
129
+ claim_error JSONB,
130
+ refund_tx TEXT,
131
+ refund_tx_id TEXT,
132
+ PRIMARY KEY (txid, vout)
133
+ )`,
134
+
135
+ `CREATE TABLE IF NOT EXISTS payment_metadata (
136
+ payment_id TEXT PRIMARY KEY,
137
+ parent_payment_id TEXT,
138
+ lnurl_pay_info JSONB,
139
+ lnurl_withdraw_info JSONB,
140
+ lnurl_description TEXT,
141
+ conversion_info JSONB
142
+ )`,
143
+
144
+ `CREATE TABLE IF NOT EXISTS payment_details_lightning (
145
+ payment_id TEXT PRIMARY KEY,
146
+ invoice TEXT NOT NULL,
147
+ payment_hash TEXT NOT NULL,
148
+ destination_pubkey TEXT NOT NULL,
149
+ description TEXT,
150
+ preimage TEXT,
151
+ htlc_status TEXT NOT NULL,
152
+ htlc_expiry_time BIGINT NOT NULL
153
+ )`,
154
+
155
+ `CREATE TABLE IF NOT EXISTS payment_details_token (
156
+ payment_id TEXT PRIMARY KEY,
157
+ metadata JSONB NOT NULL,
158
+ tx_hash TEXT NOT NULL,
159
+ tx_type TEXT NOT NULL,
160
+ invoice_details JSONB
161
+ )`,
162
+
163
+ `CREATE TABLE IF NOT EXISTS payment_details_spark (
164
+ payment_id TEXT PRIMARY KEY,
165
+ invoice_details JSONB,
166
+ htlc_details JSONB
167
+ )`,
168
+
169
+ `CREATE TABLE IF NOT EXISTS lnurl_receive_metadata (
170
+ payment_hash TEXT PRIMARY KEY,
171
+ nostr_zap_request TEXT,
172
+ nostr_zap_receipt TEXT,
173
+ sender_comment TEXT,
174
+ preimage TEXT
175
+ )`,
176
+
177
+ // -- Sync tables --
178
+ `CREATE TABLE IF NOT EXISTS sync_revision (
179
+ id INTEGER PRIMARY KEY DEFAULT 1,
180
+ revision BIGINT NOT NULL DEFAULT 0,
181
+ CHECK (id = 1)
182
+ )`,
183
+ `INSERT INTO sync_revision (id, revision) VALUES (1, 0) ON CONFLICT (id) DO NOTHING`,
184
+
185
+ `CREATE TABLE IF NOT EXISTS sync_outgoing (
186
+ record_type TEXT NOT NULL,
187
+ data_id TEXT NOT NULL,
188
+ schema_version TEXT NOT NULL,
189
+ commit_time BIGINT NOT NULL,
190
+ updated_fields_json JSONB NOT NULL,
191
+ revision BIGINT NOT NULL
192
+ )`,
193
+
194
+ `CREATE TABLE IF NOT EXISTS sync_state (
195
+ record_type TEXT NOT NULL,
196
+ data_id TEXT NOT NULL,
197
+ schema_version TEXT NOT NULL,
198
+ commit_time BIGINT NOT NULL,
199
+ data JSONB NOT NULL,
200
+ revision BIGINT NOT NULL,
201
+ PRIMARY KEY (record_type, data_id)
202
+ )`,
203
+
204
+ `CREATE TABLE IF NOT EXISTS sync_incoming (
205
+ record_type TEXT NOT NULL,
206
+ data_id TEXT NOT NULL,
207
+ schema_version TEXT NOT NULL,
208
+ commit_time BIGINT NOT NULL,
209
+ data JSONB NOT NULL,
210
+ revision BIGINT NOT NULL,
211
+ PRIMARY KEY (record_type, data_id, revision)
212
+ )`,
213
+
214
+ // -- Indexes --
215
+ `CREATE INDEX IF NOT EXISTS idx_payments_timestamp ON payments(timestamp)`,
216
+ `CREATE INDEX IF NOT EXISTS idx_payments_payment_type ON payments(payment_type)`,
217
+ `CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status)`,
218
+ `CREATE INDEX IF NOT EXISTS idx_payment_details_lightning_invoice ON payment_details_lightning(invoice)`,
219
+ `CREATE INDEX IF NOT EXISTS idx_payment_details_lightning_payment_hash ON payment_details_lightning(payment_hash)`,
220
+ `CREATE INDEX IF NOT EXISTS idx_payment_metadata_parent ON payment_metadata(parent_payment_id)`,
221
+ `CREATE INDEX IF NOT EXISTS idx_sync_outgoing_data_id_record_type ON sync_outgoing(record_type, data_id)`,
222
+ `CREATE INDEX IF NOT EXISTS idx_sync_incoming_revision ON sync_incoming(revision)`,
223
+ ],
224
+ },
225
+ {
226
+ name: "Create contacts table",
227
+ sql: [
228
+ `CREATE TABLE IF NOT EXISTS contacts (
229
+ id TEXT PRIMARY KEY,
230
+ name TEXT NOT NULL,
231
+ payment_identifier TEXT NOT NULL,
232
+ created_at BIGINT NOT NULL,
233
+ updated_at BIGINT NOT NULL
234
+ )`,
235
+ ],
236
+ },
237
+ ];
238
+ }
239
+ }
240
+
241
+ module.exports = { PostgresMigrationManager };
@@ -0,0 +1,9 @@
1
+ {
2
+ "dependencies": {
3
+ "pg": "^8.18.0"
4
+ },
5
+ "description": "Node.js PostgreSQL storage implementation for Breez SDK WASM (CommonJS)",
6
+ "main": "index.cjs",
7
+ "name": "@breez-sdk/postgres-storage",
8
+ "version": "1.0.0"
9
+ }
@@ -62,6 +62,7 @@ const SELECT_PAYMENT_SQL = `
62
62
  lrm.nostr_zap_request AS lnurl_nostr_zap_request,
63
63
  lrm.nostr_zap_receipt AS lnurl_nostr_zap_receipt,
64
64
  lrm.sender_comment AS lnurl_sender_comment,
65
+ lrm.payment_hash AS lnurl_payment_hash,
65
66
  pm.parent_payment_id
66
67
  FROM payments p
67
68
  LEFT JOIN payment_details_lightning l ON p.id = l.payment_id
@@ -259,6 +260,19 @@ class SqliteStorage {
259
260
  paymentDetailsClauses.push("t.tx_type = ?");
260
261
  params.push(paymentDetailsFilter.txType);
261
262
  }
263
+ // Filter by LNURL preimage status
264
+ if (
265
+ paymentDetailsFilter.type === "lightning" &&
266
+ paymentDetailsFilter.hasLnurlPreimage !== undefined
267
+ ) {
268
+ if (paymentDetailsFilter.hasLnurlPreimage) {
269
+ paymentDetailsClauses.push("lrm.preimage IS NOT NULL");
270
+ } else {
271
+ paymentDetailsClauses.push(
272
+ "lrm.payment_hash IS NOT NULL AND l.preimage IS NOT NULL AND lrm.preimage IS NULL"
273
+ );
274
+ }
275
+ }
262
276
 
263
277
 
264
278
  if (paymentDetailsClauses.length > 0) {
@@ -406,8 +420,8 @@ class SqliteStorage {
406
420
  destinationPubkey: payment.details.destinationPubkey,
407
421
  description: payment.details.description,
408
422
  preimage: payment.details.htlcDetails?.preimage,
409
- htlcStatus: payment.details.htlcDetails?.status || null,
410
- htlcExpiryTime: payment.details.htlcDetails?.expiryTime || 0,
423
+ htlcStatus: payment.details.htlcDetails?.status ?? null,
424
+ htlcExpiryTime: payment.details.htlcDetails?.expiryTime ?? 0,
411
425
  });
412
426
  }
413
427
 
@@ -681,7 +695,7 @@ class SqliteStorage {
681
695
  setLnurlMetadata(metadata) {
682
696
  try {
683
697
  const stmt = this.db.prepare(
684
- "INSERT OR REPLACE INTO lnurl_receive_metadata (payment_hash, nostr_zap_request, nostr_zap_receipt, sender_comment) VALUES (?, ?, ?, ?)"
698
+ "INSERT OR REPLACE INTO lnurl_receive_metadata (payment_hash, nostr_zap_request, nostr_zap_receipt, sender_comment, preimage) VALUES (?, ?, ?, ?, ?)"
685
699
  );
686
700
 
687
701
  const transaction = this.db.transaction(() => {
@@ -690,7 +704,8 @@ class SqliteStorage {
690
704
  item.paymentHash,
691
705
  item.nostrZapRequest || null,
692
706
  item.nostrZapReceipt || null,
693
- item.senderComment || null
707
+ item.senderComment || null,
708
+ item.preimage || null
694
709
  );
695
710
  }
696
711
  });
@@ -721,7 +736,7 @@ class SqliteStorage {
721
736
  ? {
722
737
  paymentHash: row.lightning_payment_hash,
723
738
  preimage: row.lightning_preimage || null,
724
- expiryTime: row.lightning_htlc_expiry_time || 0,
739
+ expiryTime: row.lightning_htlc_expiry_time ?? 0,
725
740
  status: row.lightning_htlc_status,
726
741
  }
727
742
  : (() => { throw new StorageError(`htlc_status is required for Lightning payment ${row.id}`); })(),
@@ -749,7 +764,7 @@ class SqliteStorage {
749
764
  }
750
765
  }
751
766
 
752
- if (row.lnurl_nostr_zap_request || row.lnurl_sender_comment) {
767
+ if (row.lnurl_payment_hash) {
753
768
  details.lnurlReceiveMetadata = {
754
769
  nostrZapRequest: row.lnurl_nostr_zap_request || null,
755
770
  nostrZapReceipt: row.lnurl_nostr_zap_receipt || null,
@@ -815,6 +830,7 @@ class SqliteStorage {
815
830
  timestamp: row.timestamp,
816
831
  method,
817
832
  details,
833
+ conversionDetails: null,
818
834
  };
819
835
  }
820
836
 
@@ -1239,6 +1255,84 @@ class SqliteStorage {
1239
1255
  );
1240
1256
  }
1241
1257
  }
1258
+
1259
+ // ===== Contact Operations =====
1260
+
1261
+ listContacts(request) {
1262
+ try {
1263
+ const offset = request.offset !== null && request.offset !== undefined ? request.offset : 0;
1264
+ const limit = request.limit !== null && request.limit !== undefined ? request.limit : 4294967295;
1265
+
1266
+ const stmt = this.db.prepare(`
1267
+ SELECT id, name, payment_identifier AS paymentIdentifier, created_at AS createdAt, updated_at AS updatedAt
1268
+ FROM contacts
1269
+ ORDER BY name ASC
1270
+ LIMIT ? OFFSET ?
1271
+ `);
1272
+ const rows = stmt.all(limit, offset);
1273
+
1274
+ return Promise.resolve(rows);
1275
+ } catch (error) {
1276
+ return Promise.reject(
1277
+ new StorageError(`Failed to list contacts: ${error.message}`, error)
1278
+ );
1279
+ }
1280
+ }
1281
+
1282
+ getContact(id) {
1283
+ try {
1284
+ const stmt = this.db.prepare(`
1285
+ SELECT id, name, payment_identifier AS paymentIdentifier, created_at AS createdAt, updated_at AS updatedAt
1286
+ FROM contacts
1287
+ WHERE id = ?
1288
+ `);
1289
+ const row = stmt.get(id);
1290
+ return Promise.resolve(row || null);
1291
+ } catch (error) {
1292
+ return Promise.reject(
1293
+ new StorageError(`Failed to get contact: ${error.message}`, error)
1294
+ );
1295
+ }
1296
+ }
1297
+
1298
+ insertContact(contact) {
1299
+ try {
1300
+ const stmt = this.db.prepare(`
1301
+ INSERT INTO contacts (id, name, payment_identifier, created_at, updated_at)
1302
+ VALUES (?, ?, ?, ?, ?)
1303
+ ON CONFLICT(id) DO UPDATE SET
1304
+ name = excluded.name,
1305
+ payment_identifier = excluded.payment_identifier,
1306
+ updated_at = excluded.updated_at
1307
+ `);
1308
+
1309
+ stmt.run(
1310
+ contact.id,
1311
+ contact.name,
1312
+ contact.paymentIdentifier,
1313
+ contact.createdAt,
1314
+ contact.updatedAt
1315
+ );
1316
+
1317
+ return Promise.resolve();
1318
+ } catch (error) {
1319
+ return Promise.reject(
1320
+ new StorageError(`Failed to insert contact: ${error.message}`, error)
1321
+ );
1322
+ }
1323
+ }
1324
+
1325
+ deleteContact(id) {
1326
+ try {
1327
+ const stmt = this.db.prepare("DELETE FROM contacts WHERE id = ?");
1328
+ stmt.run(id);
1329
+ return Promise.resolve();
1330
+ } catch (error) {
1331
+ return Promise.reject(
1332
+ new StorageError(`Failed to delete contact: ${error.message}`, error)
1333
+ );
1334
+ }
1335
+ }
1242
1336
  }
1243
1337
 
1244
1338
  async function createDefaultStorage(dataDir, logger = null) {
@@ -344,6 +344,13 @@ class MigrationManager {
344
344
  `DELETE FROM sync_state`,
345
345
  `UPDATE sync_revision SET revision = 0`,
346
346
  `DELETE FROM settings WHERE key = 'sync_initial_complete'`
347
+ ],
348
+ },
349
+ {
350
+ name: "Add preimage column to lnurl_receive_metadata for LUD-21 and NIP-57",
351
+ sql: [
352
+ `ALTER TABLE lnurl_receive_metadata ADD COLUMN preimage TEXT`,
353
+ `DELETE FROM settings WHERE key = 'lnurl_metadata_updated_after'`
347
354
  ]
348
355
  },
349
356
  {
@@ -367,6 +374,24 @@ class MigrationManager {
367
374
  WHERE key = 'sync_offset' AND json_valid(value)`,
368
375
  ]
369
376
  },
377
+ {
378
+ name: "Clear cached lightning address for LnurlInfo schema change",
379
+ sql: `DELETE FROM settings WHERE key = 'lightning_address'`
380
+ },
381
+ {
382
+ name: "Add index on payment_hash for JOIN with lnurl_receive_metadata",
383
+ sql: `CREATE INDEX IF NOT EXISTS idx_payment_details_lightning_payment_hash ON payment_details_lightning(payment_hash)`
384
+ },
385
+ {
386
+ name: "Create contacts table",
387
+ sql: `CREATE TABLE contacts (
388
+ id TEXT PRIMARY KEY,
389
+ name TEXT NOT NULL,
390
+ payment_identifier TEXT NOT NULL,
391
+ created_at INTEGER NOT NULL,
392
+ updated_at INTEGER NOT NULL
393
+ )`
394
+ },
370
395
  ];
371
396
  }
372
397
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@breeztech/breez-sdk-spark",
3
- "version": "0.9.1",
3
+ "version": "0.11.0-dev1",
4
4
  "description": "Breez Spark SDK",
5
5
  "repository": "https://github.com/breez/spark-sdk",
6
6
  "author": "Breez <contact@breez.technology> (https://github.com/breez)",
@@ -34,6 +34,7 @@
34
34
  "node": ">=22"
35
35
  },
36
36
  "optionalDependencies": {
37
- "better-sqlite3": "^12.2.0"
37
+ "better-sqlite3": "^12.2.0",
38
+ "pg": "^8.18.0"
38
39
  }
39
40
  }