@breeztech/breez-sdk-spark 0.13.10-dev → 0.13.12-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.
Files changed (49) hide show
  1. package/breez-sdk-spark.tgz +0 -0
  2. package/bundler/breez_sdk_spark_wasm.d.ts +157 -0
  3. package/bundler/breez_sdk_spark_wasm.js +1 -1
  4. package/bundler/breez_sdk_spark_wasm_bg.js +419 -41
  5. package/bundler/breez_sdk_spark_wasm_bg.wasm +0 -0
  6. package/bundler/breez_sdk_spark_wasm_bg.wasm.d.ts +27 -7
  7. package/deno/breez_sdk_spark_wasm.d.ts +157 -0
  8. package/deno/breez_sdk_spark_wasm.js +419 -41
  9. package/deno/breez_sdk_spark_wasm_bg.wasm +0 -0
  10. package/deno/breez_sdk_spark_wasm_bg.wasm.d.ts +27 -7
  11. package/nodejs/breez_sdk_spark_wasm.d.ts +157 -0
  12. package/nodejs/breez_sdk_spark_wasm.js +428 -41
  13. package/nodejs/breez_sdk_spark_wasm_bg.wasm +0 -0
  14. package/nodejs/breez_sdk_spark_wasm_bg.wasm.d.ts +27 -7
  15. package/nodejs/index.js +56 -0
  16. package/nodejs/index.mjs +9 -0
  17. package/nodejs/mysql-session-manager/errors.cjs +13 -0
  18. package/nodejs/mysql-session-manager/index.cjs +144 -0
  19. package/nodejs/mysql-session-manager/migrations.cjs +102 -0
  20. package/nodejs/mysql-session-manager/package.json +9 -0
  21. package/nodejs/mysql-storage/errors.cjs +19 -0
  22. package/nodejs/mysql-storage/index.cjs +1367 -0
  23. package/nodejs/mysql-storage/migrations.cjs +387 -0
  24. package/nodejs/mysql-storage/package.json +9 -0
  25. package/nodejs/mysql-token-store/errors.cjs +9 -0
  26. package/nodejs/mysql-token-store/index.cjs +980 -0
  27. package/nodejs/mysql-token-store/migrations.cjs +255 -0
  28. package/nodejs/mysql-token-store/package.json +9 -0
  29. package/nodejs/mysql-tree-store/errors.cjs +9 -0
  30. package/nodejs/mysql-tree-store/index.cjs +941 -0
  31. package/nodejs/mysql-tree-store/migrations.cjs +221 -0
  32. package/nodejs/mysql-tree-store/package.json +9 -0
  33. package/nodejs/package.json +5 -0
  34. package/nodejs/postgres-session-manager/errors.cjs +13 -0
  35. package/nodejs/postgres-session-manager/index.cjs +165 -0
  36. package/nodejs/postgres-session-manager/migrations.cjs +126 -0
  37. package/nodejs/postgres-session-manager/package.json +9 -0
  38. package/nodejs/postgres-storage/index.cjs +147 -92
  39. package/nodejs/postgres-storage/migrations.cjs +85 -4
  40. package/nodejs/postgres-token-store/index.cjs +178 -102
  41. package/nodejs/postgres-token-store/migrations.cjs +92 -3
  42. package/nodejs/postgres-tree-store/index.cjs +168 -83
  43. package/nodejs/postgres-tree-store/migrations.cjs +80 -3
  44. package/package.json +1 -1
  45. package/ssr/index.js +53 -0
  46. package/web/breez_sdk_spark_wasm.d.ts +184 -7
  47. package/web/breez_sdk_spark_wasm.js +419 -41
  48. package/web/breez_sdk_spark_wasm_bg.wasm +0 -0
  49. package/web/breez_sdk_spark_wasm_bg.wasm.d.ts +27 -7
@@ -0,0 +1,1367 @@
1
+ /**
2
+ * CommonJS implementation for Node.js MySQL Storage.
3
+ *
4
+ * Mirrors `postgres-storage/index.cjs` for MySQL 8.0+. SQL translation rules:
5
+ * - `$N` placeholders → `?`
6
+ * - `JSONB` operators (`::jsonb->>`, `@>`) → `JSON_EXTRACT`/`JSON_UNQUOTE`/`JSON_CONTAINS`
7
+ * - `ON CONFLICT (col) DO UPDATE SET col = EXCLUDED.col` →
8
+ * `ON DUPLICATE KEY UPDATE col = VALUES(col)`
9
+ * - `ON CONFLICT DO NOTHING` → `INSERT … ON DUPLICATE KEY UPDATE <pk> = <pk>`
10
+ * (avoid `INSERT IGNORE`: it silently swallows non-PK errors too)
11
+ * - `pg`'s `pool.query(sql, params)` returns `{ rows, rowCount }`; mysql2's
12
+ * `pool.query(sql, params)` returns `[rows, fields]` for SELECT and
13
+ * `[okPacket, fields]` (with `affectedRows`) for write operations.
14
+ * - Reserved words like `key` need backtick quoting in MySQL.
15
+ * - `pool.connect()` → `pool.getConnection()`; `client.query("BEGIN")`/COMMIT/ROLLBACK
16
+ * → `conn.beginTransaction()`/`conn.commit()`/`conn.rollback()`.
17
+ */
18
+
19
+ let mysql;
20
+ try {
21
+ const mainModule = require.main;
22
+ if (mainModule) {
23
+ mysql = mainModule.require("mysql2/promise");
24
+ } else {
25
+ mysql = require("mysql2/promise");
26
+ }
27
+ } catch (error) {
28
+ try {
29
+ mysql = require("mysql2/promise");
30
+ } catch (fallbackError) {
31
+ throw new Error(
32
+ `mysql2 not found. Please install it in your project: npm install mysql2@^3.11.0\n` +
33
+ `Original error: ${error.message}\nFallback error: ${fallbackError.message}`
34
+ );
35
+ }
36
+ }
37
+
38
+ const { StorageError } = require("./errors.cjs");
39
+ const { MysqlMigrationManager } = require("./migrations.cjs");
40
+
41
+ /**
42
+ * Base query for payment lookups. All columns are accessed by name in _rowToPayment.
43
+ * parent_payment_id is only used by getPaymentsByParentIds.
44
+ */
45
+ const SELECT_PAYMENT_SQL = `
46
+ SELECT p.id,
47
+ p.payment_type,
48
+ p.status,
49
+ p.amount,
50
+ p.fees,
51
+ p.timestamp,
52
+ p.method,
53
+ p.withdraw_tx_id,
54
+ p.deposit_tx_id,
55
+ p.spark,
56
+ l.invoice AS lightning_invoice,
57
+ l.payment_hash AS lightning_payment_hash,
58
+ l.destination_pubkey AS lightning_destination_pubkey,
59
+ COALESCE(l.description, pm.lnurl_description) AS lightning_description,
60
+ l.preimage AS lightning_preimage,
61
+ l.htlc_status AS lightning_htlc_status,
62
+ l.htlc_expiry_time AS lightning_htlc_expiry_time,
63
+ pm.lnurl_pay_info,
64
+ pm.lnurl_withdraw_info,
65
+ pm.conversion_info,
66
+ pm.conversion_status,
67
+ t.metadata AS token_metadata,
68
+ t.tx_hash AS token_tx_hash,
69
+ t.tx_type AS token_tx_type,
70
+ t.invoice_details AS token_invoice_details,
71
+ s.invoice_details AS spark_invoice_details,
72
+ s.htlc_details AS spark_htlc_details,
73
+ lrm.nostr_zap_request AS lnurl_nostr_zap_request,
74
+ lrm.nostr_zap_receipt AS lnurl_nostr_zap_receipt,
75
+ lrm.sender_comment AS lnurl_sender_comment,
76
+ lrm.payment_hash AS lnurl_payment_hash,
77
+ pm.parent_payment_id
78
+ FROM payments p
79
+ LEFT JOIN payment_details_lightning l ON p.id = l.payment_id AND p.user_id = l.user_id
80
+ LEFT JOIN payment_details_token t ON p.id = t.payment_id AND p.user_id = t.user_id
81
+ LEFT JOIN payment_details_spark s ON p.id = s.payment_id AND p.user_id = s.user_id
82
+ LEFT JOIN payment_metadata pm ON p.id = pm.payment_id AND p.user_id = pm.user_id
83
+ LEFT JOIN lnurl_receive_metadata lrm ON l.payment_hash = lrm.payment_hash AND l.user_id = lrm.user_id`;
84
+
85
+ /**
86
+ * mysql2 may return JSON columns as either parsed objects or raw strings
87
+ * depending on driver/server behavior. This helper normalizes both shapes.
88
+ */
89
+ function parseJson(value) {
90
+ if (value == null) return null;
91
+ if (typeof value === "string") return JSON.parse(value);
92
+ return value;
93
+ }
94
+
95
+ /** Normalize MySQL's TINYINT(1) to a JS boolean. */
96
+ function toBool(value) {
97
+ if (value == null) return null;
98
+ if (typeof value === "boolean") return value;
99
+ return value === 1 || value === "1" || value === true;
100
+ }
101
+
102
+ class MysqlStorage {
103
+ /**
104
+ * @param {import('mysql2/promise').Pool} pool - Connection pool (may be shared with other tenants).
105
+ * @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey
106
+ * uniquely identifying this tenant. All reads and writes are scoped to it
107
+ * so that multiple instances with distinct identities can share one DB.
108
+ * @param {object} [logger]
109
+ */
110
+ constructor(pool, identity, logger = null) {
111
+ if (!identity) {
112
+ throw new StorageError("MysqlStorage requires a tenant identity");
113
+ }
114
+ this.pool = pool;
115
+ this.identity = Buffer.from(identity);
116
+ this.logger = logger;
117
+ }
118
+
119
+ /** Initialize the database (run migrations). */
120
+ async initialize() {
121
+ try {
122
+ const migrationManager = new MysqlMigrationManager(this.logger);
123
+ await migrationManager.migrate(this.pool, this.identity);
124
+ return this;
125
+ } catch (error) {
126
+ throw new StorageError(
127
+ `Failed to initialize MySQL database: ${error.message}`,
128
+ error
129
+ );
130
+ }
131
+ }
132
+
133
+ /** Close the pool. */
134
+ async close() {
135
+ if (this.pool) {
136
+ await this.pool.end();
137
+ this.pool = null;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Run a function inside a transaction.
143
+ * @param {function(import('mysql2/promise').PoolConnection): Promise<T>} fn
144
+ * @returns {Promise<T>}
145
+ * @template T
146
+ */
147
+ async _withTransaction(fn) {
148
+ const conn = await this.pool.getConnection();
149
+ try {
150
+ await conn.query("SET TRANSACTION ISOLATION LEVEL READ COMMITTED");
151
+ await conn.beginTransaction();
152
+ const result = await fn(conn);
153
+ await conn.commit();
154
+ return result;
155
+ } catch (error) {
156
+ await conn.rollback().catch(() => {});
157
+ throw error;
158
+ } finally {
159
+ conn.release();
160
+ }
161
+ }
162
+
163
+ // ===== Cache Operations =====
164
+
165
+ async getCachedItem(key) {
166
+ try {
167
+ const [rows] = await this.pool.query(
168
+ "SELECT value FROM settings WHERE user_id = ? AND `key` = ?",
169
+ [this.identity, key]
170
+ );
171
+ return rows.length > 0 ? rows[0].value : null;
172
+ } catch (error) {
173
+ throw new StorageError(
174
+ `Failed to get cached item '${key}': ${error.message}`,
175
+ error
176
+ );
177
+ }
178
+ }
179
+
180
+ async setCachedItem(key, value) {
181
+ try {
182
+ await this.pool.query(
183
+ "INSERT INTO settings (user_id, `key`, value) VALUES (?, ?, ?) " +
184
+ "ON DUPLICATE KEY UPDATE value = VALUES(value)",
185
+ [this.identity, key, value]
186
+ );
187
+ } catch (error) {
188
+ throw new StorageError(
189
+ `Failed to set cached item '${key}': ${error.message}`,
190
+ error
191
+ );
192
+ }
193
+ }
194
+
195
+ async deleteCachedItem(key) {
196
+ try {
197
+ await this.pool.query(
198
+ "DELETE FROM settings WHERE user_id = ? AND `key` = ?",
199
+ [this.identity, key]
200
+ );
201
+ } catch (error) {
202
+ throw new StorageError(
203
+ `Failed to delete cached item '${key}': ${error.message}`,
204
+ error
205
+ );
206
+ }
207
+ }
208
+
209
+ // ===== Payment Operations =====
210
+
211
+ async listPayments(request) {
212
+ try {
213
+ const actualOffset = request.offset != null ? request.offset : 0;
214
+ const actualLimit =
215
+ request.limit != null ? request.limit : 4294967295;
216
+
217
+ // Tenant scoping is always the first WHERE clause; subsequent dynamic
218
+ // filters add more clauses and parameters.
219
+ const whereClauses = ["p.user_id = ?"];
220
+ const params = [this.identity];
221
+
222
+ if (request.typeFilter && request.typeFilter.length > 0) {
223
+ const placeholders = request.typeFilter.map(() => "?");
224
+ whereClauses.push(
225
+ `p.payment_type IN (${placeholders.join(", ")})`
226
+ );
227
+ params.push(...request.typeFilter);
228
+ }
229
+
230
+ if (request.statusFilter && request.statusFilter.length > 0) {
231
+ const placeholders = request.statusFilter.map(() => "?");
232
+ whereClauses.push(`p.status IN (${placeholders.join(", ")})`);
233
+ params.push(...request.statusFilter);
234
+ }
235
+
236
+ if (request.fromTimestamp != null) {
237
+ whereClauses.push("p.timestamp >= ?");
238
+ params.push(request.fromTimestamp);
239
+ }
240
+
241
+ if (request.toTimestamp != null) {
242
+ whereClauses.push("p.timestamp < ?");
243
+ params.push(request.toTimestamp);
244
+ }
245
+
246
+ if (
247
+ request.paymentDetailsFilter &&
248
+ request.paymentDetailsFilter.length > 0
249
+ ) {
250
+ const allPaymentDetailsClauses = [];
251
+ for (const paymentDetailsFilter of request.paymentDetailsFilter) {
252
+ const paymentDetailsClauses = [];
253
+
254
+ const htlcAlias =
255
+ paymentDetailsFilter.type === "spark"
256
+ ? "s"
257
+ : paymentDetailsFilter.type === "lightning"
258
+ ? "l"
259
+ : null;
260
+ if (
261
+ htlcAlias &&
262
+ paymentDetailsFilter.htlcStatus !== undefined &&
263
+ paymentDetailsFilter.htlcStatus.length > 0
264
+ ) {
265
+ const placeholders = paymentDetailsFilter.htlcStatus.map(() => "?");
266
+ if (htlcAlias === "l") {
267
+ paymentDetailsClauses.push(
268
+ `l.htlc_status IN (${placeholders.join(", ")})`
269
+ );
270
+ } else {
271
+ paymentDetailsClauses.push(
272
+ `JSON_UNQUOTE(JSON_EXTRACT(s.htlc_details, '$.status')) IN (${placeholders.join(", ")})`
273
+ );
274
+ }
275
+ params.push(...paymentDetailsFilter.htlcStatus);
276
+ }
277
+
278
+ if (
279
+ (paymentDetailsFilter.type === "spark" ||
280
+ paymentDetailsFilter.type === "token") &&
281
+ paymentDetailsFilter.conversionRefundNeeded !== undefined
282
+ ) {
283
+ const typeCheck =
284
+ paymentDetailsFilter.type === "spark"
285
+ ? "p.spark = 1"
286
+ : "p.spark IS NULL";
287
+ const refundNeeded =
288
+ paymentDetailsFilter.conversionRefundNeeded === true
289
+ ? "= 'refundNeeded'"
290
+ : "!= 'refundNeeded'";
291
+ paymentDetailsClauses.push(
292
+ `${typeCheck} AND pm.conversion_info IS NOT NULL AND
293
+ JSON_UNQUOTE(JSON_EXTRACT(pm.conversion_info, '$.status')) ${refundNeeded}`
294
+ );
295
+ }
296
+
297
+ if (
298
+ paymentDetailsFilter.type === "token" &&
299
+ paymentDetailsFilter.txHash !== undefined
300
+ ) {
301
+ paymentDetailsClauses.push("t.tx_hash = ?");
302
+ params.push(paymentDetailsFilter.txHash);
303
+ }
304
+
305
+ if (
306
+ paymentDetailsFilter.type === "token" &&
307
+ paymentDetailsFilter.txType !== undefined
308
+ ) {
309
+ paymentDetailsClauses.push("t.tx_type = ?");
310
+ params.push(paymentDetailsFilter.txType);
311
+ }
312
+
313
+ if (paymentDetailsClauses.length > 0) {
314
+ allPaymentDetailsClauses.push(
315
+ `(${paymentDetailsClauses.join(" AND ")})`
316
+ );
317
+ }
318
+ }
319
+
320
+ if (allPaymentDetailsClauses.length > 0) {
321
+ whereClauses.push(`(${allPaymentDetailsClauses.join(" OR ")})`);
322
+ }
323
+ }
324
+
325
+ if (request.assetFilter) {
326
+ const assetFilter = request.assetFilter;
327
+ if (assetFilter.type === "bitcoin") {
328
+ whereClauses.push("t.metadata IS NULL");
329
+ } else if (assetFilter.type === "token") {
330
+ whereClauses.push("t.metadata IS NOT NULL");
331
+ if (assetFilter.tokenIdentifier) {
332
+ whereClauses.push(
333
+ "JSON_UNQUOTE(JSON_EXTRACT(t.metadata, '$.identifier')) = ?"
334
+ );
335
+ params.push(assetFilter.tokenIdentifier);
336
+ }
337
+ }
338
+ }
339
+
340
+ whereClauses.push("pm.parent_payment_id IS NULL");
341
+
342
+ const whereSql =
343
+ whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
344
+
345
+ const orderDirection = request.sortAscending ? "ASC" : "DESC";
346
+ const query = `${SELECT_PAYMENT_SQL} ${whereSql} ORDER BY p.timestamp ${orderDirection} LIMIT ? OFFSET ?`;
347
+
348
+ params.push(actualLimit, actualOffset);
349
+ const [rows] = await this.pool.query(query, params);
350
+ return rows.map(this._rowToPayment.bind(this));
351
+ } catch (error) {
352
+ if (error instanceof StorageError) throw error;
353
+ throw new StorageError(
354
+ `Failed to list payments (request: ${JSON.stringify(request)}): ${error.message}`,
355
+ error
356
+ );
357
+ }
358
+ }
359
+
360
+ async insertPayment(payment) {
361
+ try {
362
+ if (!payment) {
363
+ throw new StorageError("Payment cannot be null or undefined");
364
+ }
365
+
366
+ await this._withTransaction(async (conn) => {
367
+ const withdrawTxId =
368
+ payment.details?.type === "withdraw" ? payment.details.txId : null;
369
+ const depositTxId =
370
+ payment.details?.type === "deposit" ? payment.details.txId : null;
371
+ const spark = payment.details?.type === "spark" ? 1 : null;
372
+
373
+ await conn.query(
374
+ `INSERT INTO payments (user_id, id, payment_type, status, amount, fees, timestamp, method, withdraw_tx_id, deposit_tx_id, spark)
375
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
376
+ ON DUPLICATE KEY UPDATE
377
+ payment_type=VALUES(payment_type),
378
+ status=VALUES(status),
379
+ amount=VALUES(amount),
380
+ fees=VALUES(fees),
381
+ timestamp=VALUES(timestamp),
382
+ method=VALUES(method),
383
+ withdraw_tx_id=VALUES(withdraw_tx_id),
384
+ deposit_tx_id=VALUES(deposit_tx_id),
385
+ spark=VALUES(spark)`,
386
+ [
387
+ this.identity,
388
+ payment.id,
389
+ payment.paymentType,
390
+ payment.status,
391
+ payment.amount.toString(),
392
+ payment.fees.toString(),
393
+ payment.timestamp,
394
+ payment.method ? JSON.stringify(payment.method) : null,
395
+ withdrawTxId,
396
+ depositTxId,
397
+ spark,
398
+ ]
399
+ );
400
+
401
+ if (
402
+ payment.details?.type === "spark" &&
403
+ (payment.details.invoiceDetails != null ||
404
+ payment.details.htlcDetails != null)
405
+ ) {
406
+ await conn.query(
407
+ `INSERT INTO payment_details_spark (user_id, payment_id, invoice_details, htlc_details)
408
+ VALUES (?, ?, ?, ?)
409
+ ON DUPLICATE KEY UPDATE
410
+ invoice_details=COALESCE(VALUES(invoice_details), invoice_details),
411
+ htlc_details=COALESCE(VALUES(htlc_details), htlc_details)`,
412
+ [
413
+ this.identity,
414
+ payment.id,
415
+ payment.details.invoiceDetails
416
+ ? JSON.stringify(payment.details.invoiceDetails)
417
+ : null,
418
+ payment.details.htlcDetails
419
+ ? JSON.stringify(payment.details.htlcDetails)
420
+ : null,
421
+ ]
422
+ );
423
+ }
424
+
425
+ if (payment.details?.type === "lightning") {
426
+ await conn.query(
427
+ `INSERT INTO payment_details_lightning
428
+ (user_id, payment_id, invoice, payment_hash, destination_pubkey, description, preimage, htlc_status, htlc_expiry_time)
429
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
430
+ ON DUPLICATE KEY UPDATE
431
+ invoice=VALUES(invoice),
432
+ payment_hash=VALUES(payment_hash),
433
+ destination_pubkey=VALUES(destination_pubkey),
434
+ description=VALUES(description),
435
+ preimage=COALESCE(VALUES(preimage), preimage),
436
+ htlc_status=COALESCE(VALUES(htlc_status), htlc_status),
437
+ htlc_expiry_time=COALESCE(VALUES(htlc_expiry_time), htlc_expiry_time)`,
438
+ [
439
+ this.identity,
440
+ payment.id,
441
+ payment.details.invoice,
442
+ payment.details.htlcDetails.paymentHash,
443
+ payment.details.destinationPubkey,
444
+ payment.details.description,
445
+ payment.details.htlcDetails?.preimage,
446
+ payment.details.htlcDetails?.status ?? null,
447
+ payment.details.htlcDetails?.expiryTime ?? 0,
448
+ ]
449
+ );
450
+ }
451
+
452
+ if (payment.details?.type === "token") {
453
+ await conn.query(
454
+ `INSERT INTO payment_details_token
455
+ (user_id, payment_id, metadata, tx_hash, tx_type, invoice_details)
456
+ VALUES (?, ?, ?, ?, ?, ?)
457
+ ON DUPLICATE KEY UPDATE
458
+ metadata=VALUES(metadata),
459
+ tx_hash=VALUES(tx_hash),
460
+ tx_type=VALUES(tx_type),
461
+ invoice_details=COALESCE(VALUES(invoice_details), invoice_details)`,
462
+ [
463
+ this.identity,
464
+ payment.id,
465
+ JSON.stringify(payment.details.metadata),
466
+ payment.details.txHash,
467
+ payment.details.txType,
468
+ payment.details.invoiceDetails
469
+ ? JSON.stringify(payment.details.invoiceDetails)
470
+ : null,
471
+ ]
472
+ );
473
+ }
474
+ });
475
+ } catch (error) {
476
+ if (error instanceof StorageError) throw error;
477
+ throw new StorageError(
478
+ `Failed to insert payment '${payment.id}': ${error.message}`,
479
+ error
480
+ );
481
+ }
482
+ }
483
+
484
+ async getPaymentById(id) {
485
+ try {
486
+ if (!id) {
487
+ throw new StorageError("Payment ID cannot be null or undefined");
488
+ }
489
+
490
+ const [rows] = await this.pool.query(
491
+ `${SELECT_PAYMENT_SQL} WHERE p.user_id = ? AND p.id = ?`,
492
+ [this.identity, id]
493
+ );
494
+
495
+ if (rows.length === 0) {
496
+ throw new StorageError(`Payment with id '${id}' not found`);
497
+ }
498
+
499
+ return this._rowToPayment(rows[0]);
500
+ } catch (error) {
501
+ if (error instanceof StorageError) throw error;
502
+ throw new StorageError(
503
+ `Failed to get payment by id '${id || "unknown"}': ${error.message}`,
504
+ error
505
+ );
506
+ }
507
+ }
508
+
509
+ async getPaymentByInvoice(invoice) {
510
+ try {
511
+ if (!invoice) {
512
+ throw new StorageError("Invoice cannot be null or undefined");
513
+ }
514
+
515
+ const [rows] = await this.pool.query(
516
+ `${SELECT_PAYMENT_SQL} WHERE p.user_id = ? AND l.invoice = ?`,
517
+ [this.identity, invoice]
518
+ );
519
+
520
+ if (rows.length === 0) {
521
+ return null;
522
+ }
523
+
524
+ return this._rowToPayment(rows[0]);
525
+ } catch (error) {
526
+ if (error instanceof StorageError) throw error;
527
+ throw new StorageError(
528
+ `Failed to get payment by invoice '${invoice}': ${error.message}`,
529
+ error
530
+ );
531
+ }
532
+ }
533
+
534
+ async getPaymentsByParentIds(parentPaymentIds) {
535
+ try {
536
+ if (!parentPaymentIds || parentPaymentIds.length === 0) {
537
+ return {};
538
+ }
539
+
540
+ // Early exit if no related payments exist for this tenant. mysql2 returns EXISTS as 0/1.
541
+ const [hasRelatedRows] = await this.pool.query(
542
+ "SELECT EXISTS(SELECT 1 FROM payment_metadata WHERE user_id = ? AND parent_payment_id IS NOT NULL LIMIT 1) AS has_related",
543
+ [this.identity]
544
+ );
545
+ if (!hasRelatedRows[0].has_related) {
546
+ return {};
547
+ }
548
+
549
+ const placeholders = parentPaymentIds.map(() => "?");
550
+ const query = `${SELECT_PAYMENT_SQL} WHERE p.user_id = ? AND pm.parent_payment_id IN (${placeholders.join(", ")}) ORDER BY p.timestamp ASC`;
551
+
552
+ const [rows] = await this.pool.query(query, [this.identity, ...parentPaymentIds]);
553
+
554
+ const result = {};
555
+ for (const row of rows) {
556
+ const parentId = row.parent_payment_id;
557
+ if (!result[parentId]) {
558
+ result[parentId] = [];
559
+ }
560
+ result[parentId].push(this._rowToPayment(row));
561
+ }
562
+
563
+ return result;
564
+ } catch (error) {
565
+ if (error instanceof StorageError) throw error;
566
+ throw new StorageError(
567
+ `Failed to get payments by parent ids: ${error.message}`,
568
+ error
569
+ );
570
+ }
571
+ }
572
+
573
+ async insertPaymentMetadata(paymentId, metadata) {
574
+ try {
575
+ await this.pool.query(
576
+ `INSERT INTO payment_metadata (user_id, payment_id, parent_payment_id, lnurl_pay_info, lnurl_withdraw_info, lnurl_description, conversion_info, conversion_status)
577
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
578
+ ON DUPLICATE KEY UPDATE
579
+ parent_payment_id = COALESCE(VALUES(parent_payment_id), parent_payment_id),
580
+ lnurl_pay_info = COALESCE(VALUES(lnurl_pay_info), lnurl_pay_info),
581
+ lnurl_withdraw_info = COALESCE(VALUES(lnurl_withdraw_info), lnurl_withdraw_info),
582
+ lnurl_description = COALESCE(VALUES(lnurl_description), lnurl_description),
583
+ conversion_info = COALESCE(VALUES(conversion_info), conversion_info),
584
+ conversion_status = COALESCE(VALUES(conversion_status), conversion_status)`,
585
+ [
586
+ this.identity,
587
+ paymentId,
588
+ metadata.parentPaymentId,
589
+ metadata.lnurlPayInfo
590
+ ? JSON.stringify(metadata.lnurlPayInfo)
591
+ : null,
592
+ metadata.lnurlWithdrawInfo
593
+ ? JSON.stringify(metadata.lnurlWithdrawInfo)
594
+ : null,
595
+ metadata.lnurlDescription,
596
+ metadata.conversionInfo
597
+ ? JSON.stringify(metadata.conversionInfo)
598
+ : null,
599
+ metadata.conversionStatus ?? null,
600
+ ]
601
+ );
602
+ } catch (error) {
603
+ throw new StorageError(
604
+ `Failed to set payment metadata for '${paymentId}': ${error.message}`,
605
+ error
606
+ );
607
+ }
608
+ }
609
+
610
+ // ===== Deposit Operations =====
611
+
612
+ async addDeposit(txid, vout, amountSats, isMature) {
613
+ try {
614
+ await this.pool.query(
615
+ `INSERT INTO unclaimed_deposits (user_id, txid, vout, amount_sats, is_mature)
616
+ VALUES (?, ?, ?, ?, ?)
617
+ ON DUPLICATE KEY UPDATE is_mature = VALUES(is_mature), amount_sats = VALUES(amount_sats)`,
618
+ [this.identity, txid, vout, amountSats, isMature ? 1 : 0]
619
+ );
620
+ } catch (error) {
621
+ throw new StorageError(
622
+ `Failed to add deposit '${txid}:${vout}': ${error.message}`,
623
+ error
624
+ );
625
+ }
626
+ }
627
+
628
+ async deleteDeposit(txid, vout) {
629
+ try {
630
+ await this.pool.query(
631
+ "DELETE FROM unclaimed_deposits WHERE user_id = ? AND txid = ? AND vout = ?",
632
+ [this.identity, txid, vout]
633
+ );
634
+ } catch (error) {
635
+ throw new StorageError(
636
+ `Failed to delete deposit '${txid}:${vout}': ${error.message}`,
637
+ error
638
+ );
639
+ }
640
+ }
641
+
642
+ async listDeposits() {
643
+ try {
644
+ const [rows] = await this.pool.query(
645
+ "SELECT txid, vout, amount_sats, is_mature, claim_error, refund_tx, refund_tx_id FROM unclaimed_deposits WHERE user_id = ?",
646
+ [this.identity]
647
+ );
648
+
649
+ return rows.map((row) => ({
650
+ txid: row.txid,
651
+ vout: row.vout,
652
+ amountSats:
653
+ row.amount_sats != null ? BigInt(row.amount_sats) : BigInt(0),
654
+ isMature: toBool(row.is_mature) ?? true,
655
+ claimError: parseJson(row.claim_error),
656
+ refundTx: row.refund_tx,
657
+ refundTxId: row.refund_tx_id,
658
+ }));
659
+ } catch (error) {
660
+ throw new StorageError(
661
+ `Failed to list deposits: ${error.message}`,
662
+ error
663
+ );
664
+ }
665
+ }
666
+
667
+ async updateDeposit(txid, vout, payload) {
668
+ try {
669
+ if (payload.type === "claimError") {
670
+ await this.pool.query(
671
+ `UPDATE unclaimed_deposits
672
+ SET claim_error = ?, refund_tx = NULL, refund_tx_id = NULL
673
+ WHERE user_id = ? AND txid = ? AND vout = ?`,
674
+ [JSON.stringify(payload.error), this.identity, txid, vout]
675
+ );
676
+ } else if (payload.type === "refund") {
677
+ await this.pool.query(
678
+ `UPDATE unclaimed_deposits
679
+ SET refund_tx = ?, refund_tx_id = ?, claim_error = NULL
680
+ WHERE user_id = ? AND txid = ? AND vout = ?`,
681
+ [payload.refundTx, payload.refundTxid, this.identity, txid, vout]
682
+ );
683
+ } else {
684
+ throw new StorageError(`Unknown payload type: ${payload.type}`);
685
+ }
686
+ } catch (error) {
687
+ if (error instanceof StorageError) throw error;
688
+ throw new StorageError(
689
+ `Failed to update deposit '${txid}:${vout}': ${error.message}`,
690
+ error
691
+ );
692
+ }
693
+ }
694
+
695
+ async setLnurlMetadata(metadata) {
696
+ try {
697
+ await this._withTransaction(async (conn) => {
698
+ for (const item of metadata) {
699
+ await conn.query(
700
+ `INSERT INTO lnurl_receive_metadata (user_id, payment_hash, nostr_zap_request, nostr_zap_receipt, sender_comment)
701
+ VALUES (?, ?, ?, ?, ?)
702
+ ON DUPLICATE KEY UPDATE
703
+ nostr_zap_request = VALUES(nostr_zap_request),
704
+ nostr_zap_receipt = VALUES(nostr_zap_receipt),
705
+ sender_comment = VALUES(sender_comment)`,
706
+ [
707
+ this.identity,
708
+ item.paymentHash,
709
+ item.nostrZapRequest || null,
710
+ item.nostrZapReceipt || null,
711
+ item.senderComment || null,
712
+ ]
713
+ );
714
+ }
715
+ });
716
+ } catch (error) {
717
+ if (error instanceof StorageError) throw error;
718
+ throw new StorageError(
719
+ `Failed to add lnurl metadata: ${error.message}`,
720
+ error
721
+ );
722
+ }
723
+ }
724
+
725
+ // ===== Private Helper Methods =====
726
+
727
+ _rowToPayment(row) {
728
+ let details = null;
729
+ if (row.lightning_invoice) {
730
+ details = {
731
+ type: "lightning",
732
+ invoice: row.lightning_invoice,
733
+ destinationPubkey: row.lightning_destination_pubkey,
734
+ description: row.lightning_description,
735
+ htlcDetails: row.lightning_htlc_status
736
+ ? {
737
+ paymentHash: row.lightning_payment_hash,
738
+ preimage: row.lightning_preimage || null,
739
+ expiryTime: Number(row.lightning_htlc_expiry_time) ?? 0,
740
+ status: row.lightning_htlc_status,
741
+ }
742
+ : (() => {
743
+ throw new StorageError(
744
+ `htlc_status is required for Lightning payment ${row.id}`
745
+ );
746
+ })(),
747
+ };
748
+
749
+ if (row.lnurl_pay_info) {
750
+ details.lnurlPayInfo = parseJson(row.lnurl_pay_info);
751
+ }
752
+
753
+ if (row.lnurl_withdraw_info) {
754
+ details.lnurlWithdrawInfo = parseJson(row.lnurl_withdraw_info);
755
+ }
756
+
757
+ if (row.lnurl_payment_hash) {
758
+ details.lnurlReceiveMetadata = {
759
+ nostrZapRequest: row.lnurl_nostr_zap_request || null,
760
+ nostrZapReceipt: row.lnurl_nostr_zap_receipt || null,
761
+ senderComment: row.lnurl_sender_comment || null,
762
+ };
763
+ }
764
+ } else if (row.withdraw_tx_id) {
765
+ details = {
766
+ type: "withdraw",
767
+ txId: row.withdraw_tx_id,
768
+ };
769
+ } else if (row.deposit_tx_id) {
770
+ details = {
771
+ type: "deposit",
772
+ txId: row.deposit_tx_id,
773
+ };
774
+ } else if (toBool(row.spark)) {
775
+ details = {
776
+ type: "spark",
777
+ invoiceDetails: parseJson(row.spark_invoice_details),
778
+ htlcDetails: parseJson(row.spark_htlc_details),
779
+ conversionInfo: parseJson(row.conversion_info),
780
+ };
781
+ } else if (row.token_metadata) {
782
+ details = {
783
+ type: "token",
784
+ metadata: parseJson(row.token_metadata),
785
+ txHash: row.token_tx_hash,
786
+ txType: row.token_tx_type,
787
+ invoiceDetails: parseJson(row.token_invoice_details),
788
+ conversionInfo: parseJson(row.conversion_info),
789
+ };
790
+ }
791
+
792
+ let method = null;
793
+ if (row.method) {
794
+ try {
795
+ method = parseJson(row.method);
796
+ } catch (e) {
797
+ throw new StorageError(
798
+ `Failed to parse payment method JSON for payment ${row.id}: ${e.message}`,
799
+ e
800
+ );
801
+ }
802
+ }
803
+
804
+ return {
805
+ id: row.id,
806
+ paymentType: row.payment_type,
807
+ status: row.status,
808
+ amount: BigInt(row.amount),
809
+ fees: BigInt(row.fees),
810
+ timestamp: Number(row.timestamp),
811
+ method,
812
+ details,
813
+ conversionDetails: row.conversion_status
814
+ ? { status: row.conversion_status, from: null, to: null }
815
+ : null,
816
+ };
817
+ }
818
+
819
+ // ===== Contact Operations =====
820
+
821
+ async listContacts(request) {
822
+ try {
823
+ const offset = request.offset != null ? request.offset : 0;
824
+ const limit = request.limit != null ? request.limit : 4294967295;
825
+
826
+ const [rows] = await this.pool.query(
827
+ `SELECT id, name, payment_identifier, created_at, updated_at
828
+ FROM contacts
829
+ WHERE user_id = ?
830
+ ORDER BY name ASC
831
+ LIMIT ? OFFSET ?`,
832
+ [this.identity, limit, offset]
833
+ );
834
+
835
+ return rows.map((row) => ({
836
+ id: row.id,
837
+ name: row.name,
838
+ paymentIdentifier: row.payment_identifier,
839
+ createdAt: Number(row.created_at),
840
+ updatedAt: Number(row.updated_at),
841
+ }));
842
+ } catch (error) {
843
+ throw new StorageError(
844
+ `Failed to list contacts: ${error.message}`,
845
+ error
846
+ );
847
+ }
848
+ }
849
+
850
+ async getContact(id) {
851
+ try {
852
+ const [rows] = await this.pool.query(
853
+ `SELECT id, name, payment_identifier, created_at, updated_at
854
+ FROM contacts
855
+ WHERE user_id = ? AND id = ?`,
856
+ [this.identity, id]
857
+ );
858
+
859
+ if (rows.length === 0) {
860
+ return null;
861
+ }
862
+
863
+ const row = rows[0];
864
+ return {
865
+ id: row.id,
866
+ name: row.name,
867
+ paymentIdentifier: row.payment_identifier,
868
+ createdAt: Number(row.created_at),
869
+ updatedAt: Number(row.updated_at),
870
+ };
871
+ } catch (error) {
872
+ throw new StorageError(`Failed to get contact: ${error.message}`, error);
873
+ }
874
+ }
875
+
876
+ async insertContact(contact) {
877
+ try {
878
+ await this.pool.query(
879
+ `INSERT INTO contacts (user_id, id, name, payment_identifier, created_at, updated_at)
880
+ VALUES (?, ?, ?, ?, ?, ?)
881
+ ON DUPLICATE KEY UPDATE
882
+ name = VALUES(name),
883
+ payment_identifier = VALUES(payment_identifier),
884
+ updated_at = VALUES(updated_at)`,
885
+ [
886
+ this.identity,
887
+ contact.id,
888
+ contact.name,
889
+ contact.paymentIdentifier,
890
+ contact.createdAt,
891
+ contact.updatedAt,
892
+ ]
893
+ );
894
+ } catch (error) {
895
+ throw new StorageError(
896
+ `Failed to insert contact: ${error.message}`,
897
+ error
898
+ );
899
+ }
900
+ }
901
+
902
+ async deleteContact(id) {
903
+ try {
904
+ await this.pool.query(
905
+ "DELETE FROM contacts WHERE user_id = ? AND id = ?",
906
+ [this.identity, id]
907
+ );
908
+ } catch (error) {
909
+ throw new StorageError(
910
+ `Failed to delete contact: ${error.message}`,
911
+ error
912
+ );
913
+ }
914
+ }
915
+
916
+ // ===== Sync Operations =====
917
+
918
+ async syncAddOutgoingChange(record) {
919
+ try {
920
+ return await this._withTransaction(async (conn) => {
921
+ // The local queue revision is per-tenant — two tenants don't share a queue.
922
+ const [revisionRows] = await conn.query(
923
+ "SELECT COALESCE(MAX(revision), 0) + 1 AS revision FROM sync_outgoing WHERE user_id = ?",
924
+ [this.identity]
925
+ );
926
+ const revision = BigInt(revisionRows[0].revision);
927
+
928
+ await conn.query(
929
+ `INSERT INTO sync_outgoing (
930
+ user_id,
931
+ record_type,
932
+ data_id,
933
+ schema_version,
934
+ commit_time,
935
+ updated_fields_json,
936
+ revision
937
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`,
938
+ [
939
+ this.identity,
940
+ record.id.type,
941
+ record.id.dataId,
942
+ record.schemaVersion,
943
+ Math.floor(Date.now() / 1000),
944
+ JSON.stringify(record.updatedFields),
945
+ revision.toString(),
946
+ ]
947
+ );
948
+
949
+ return revision;
950
+ });
951
+ } catch (error) {
952
+ if (error instanceof StorageError) throw error;
953
+ throw new StorageError(
954
+ `Failed to add outgoing change: ${error.message}`,
955
+ error
956
+ );
957
+ }
958
+ }
959
+
960
+ async syncCompleteOutgoingSync(record, localRevision) {
961
+ try {
962
+ await this._withTransaction(async (conn) => {
963
+ const [deleteResult] = await conn.query(
964
+ "DELETE FROM sync_outgoing WHERE user_id = ? AND record_type = ? AND data_id = ? AND revision = ?",
965
+ [this.identity, record.id.type, record.id.dataId, localRevision.toString()]
966
+ );
967
+
968
+ if (deleteResult.affectedRows === 0) {
969
+ const msg = `complete_outgoing_sync: DELETE from sync_outgoing matched 0 rows (type=${record.id.type}, data_id=${record.id.dataId}, revision=${localRevision})`;
970
+ if (this.logger && typeof this.logger.log === "function") {
971
+ this.logger.log({ line: msg, level: "warn" });
972
+ } else {
973
+ // eslint-disable-next-line no-console
974
+ console.warn(`[MysqlStorage] ${msg}`);
975
+ }
976
+ }
977
+
978
+ await conn.query(
979
+ `INSERT INTO sync_state (
980
+ user_id,
981
+ record_type,
982
+ data_id,
983
+ revision,
984
+ schema_version,
985
+ commit_time,
986
+ data
987
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
988
+ ON DUPLICATE KEY UPDATE
989
+ schema_version = VALUES(schema_version),
990
+ commit_time = VALUES(commit_time),
991
+ data = VALUES(data),
992
+ revision = VALUES(revision)`,
993
+ [
994
+ this.identity,
995
+ record.id.type,
996
+ record.id.dataId,
997
+ record.revision.toString(),
998
+ record.schemaVersion,
999
+ Math.floor(Date.now() / 1000),
1000
+ JSON.stringify(record.data),
1001
+ ]
1002
+ );
1003
+
1004
+ // Upsert this tenant's revision row; fresh tenants without a row get one.
1005
+ await conn.query(
1006
+ `INSERT INTO sync_revision (user_id, revision) VALUES (?, ?)
1007
+ ON DUPLICATE KEY UPDATE revision = GREATEST(revision, VALUES(revision))`,
1008
+ [this.identity, record.revision.toString()]
1009
+ );
1010
+ });
1011
+ } catch (error) {
1012
+ if (error instanceof StorageError) throw error;
1013
+ throw new StorageError(
1014
+ `Failed to complete outgoing sync: ${error.message}`,
1015
+ error
1016
+ );
1017
+ }
1018
+ }
1019
+
1020
+ async syncGetPendingOutgoingChanges(limit) {
1021
+ try {
1022
+ const [rows] = await this.pool.query(
1023
+ `SELECT
1024
+ o.record_type,
1025
+ o.data_id,
1026
+ o.schema_version,
1027
+ o.commit_time,
1028
+ o.updated_fields_json,
1029
+ o.revision,
1030
+ e.schema_version AS existing_schema_version,
1031
+ e.commit_time AS existing_commit_time,
1032
+ e.data AS existing_data,
1033
+ e.revision AS existing_revision
1034
+ FROM sync_outgoing o
1035
+ LEFT JOIN sync_state e ON
1036
+ o.record_type = e.record_type AND
1037
+ o.data_id = e.data_id AND
1038
+ o.user_id = e.user_id
1039
+ WHERE o.user_id = ?
1040
+ ORDER BY o.revision ASC
1041
+ LIMIT ?`,
1042
+ [this.identity, limit]
1043
+ );
1044
+
1045
+ return rows.map((row) => {
1046
+ const change = {
1047
+ id: { type: row.record_type, dataId: row.data_id },
1048
+ schemaVersion: row.schema_version,
1049
+ updatedFields: parseJson(row.updated_fields_json),
1050
+ localRevision: BigInt(row.revision),
1051
+ };
1052
+
1053
+ let parent = null;
1054
+ if (row.existing_data) {
1055
+ parent = {
1056
+ id: { type: row.record_type, dataId: row.data_id },
1057
+ revision: BigInt(row.existing_revision),
1058
+ schemaVersion: row.existing_schema_version,
1059
+ data: parseJson(row.existing_data),
1060
+ };
1061
+ }
1062
+
1063
+ return { change, parent };
1064
+ });
1065
+ } catch (error) {
1066
+ throw new StorageError(
1067
+ `Failed to get pending outgoing changes: ${error.message}`,
1068
+ error
1069
+ );
1070
+ }
1071
+ }
1072
+
1073
+ async syncGetLastRevision() {
1074
+ try {
1075
+ // A tenant that hasn't synced anything yet may have no row; treat as 0.
1076
+ const [rows] = await this.pool.query(
1077
+ "SELECT revision FROM sync_revision WHERE user_id = ?",
1078
+ [this.identity]
1079
+ );
1080
+ return rows.length > 0 ? BigInt(rows[0].revision) : BigInt(0);
1081
+ } catch (error) {
1082
+ throw new StorageError(
1083
+ `Failed to get last revision: ${error.message}`,
1084
+ error
1085
+ );
1086
+ }
1087
+ }
1088
+
1089
+ async syncInsertIncomingRecords(records) {
1090
+ try {
1091
+ if (!records || records.length === 0) {
1092
+ return;
1093
+ }
1094
+
1095
+ await this._withTransaction(async (conn) => {
1096
+ for (const record of records) {
1097
+ await conn.query(
1098
+ `INSERT INTO sync_incoming (
1099
+ user_id,
1100
+ record_type,
1101
+ data_id,
1102
+ schema_version,
1103
+ commit_time,
1104
+ data,
1105
+ revision
1106
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
1107
+ ON DUPLICATE KEY UPDATE
1108
+ schema_version = VALUES(schema_version),
1109
+ commit_time = VALUES(commit_time),
1110
+ data = VALUES(data)`,
1111
+ [
1112
+ this.identity,
1113
+ record.id.type,
1114
+ record.id.dataId,
1115
+ record.schemaVersion,
1116
+ Math.floor(Date.now() / 1000),
1117
+ JSON.stringify(record.data),
1118
+ record.revision.toString(),
1119
+ ]
1120
+ );
1121
+ }
1122
+ });
1123
+ } catch (error) {
1124
+ if (error instanceof StorageError) throw error;
1125
+ throw new StorageError(
1126
+ `Failed to insert incoming records: ${error.message}`,
1127
+ error
1128
+ );
1129
+ }
1130
+ }
1131
+
1132
+ async syncDeleteIncomingRecord(record) {
1133
+ try {
1134
+ await this.pool.query(
1135
+ `DELETE FROM sync_incoming
1136
+ WHERE user_id = ?
1137
+ AND record_type = ?
1138
+ AND data_id = ?
1139
+ AND revision = ?`,
1140
+ [this.identity, record.id.type, record.id.dataId, record.revision.toString()]
1141
+ );
1142
+ } catch (error) {
1143
+ throw new StorageError(
1144
+ `Failed to delete incoming record: ${error.message}`,
1145
+ error
1146
+ );
1147
+ }
1148
+ }
1149
+
1150
+ async syncGetIncomingRecords(limit) {
1151
+ try {
1152
+ const [rows] = await this.pool.query(
1153
+ `SELECT i.record_type,
1154
+ i.data_id,
1155
+ i.schema_version,
1156
+ i.data,
1157
+ i.revision,
1158
+ e.schema_version AS existing_schema_version,
1159
+ e.commit_time AS existing_commit_time,
1160
+ e.data AS existing_data,
1161
+ e.revision AS existing_revision
1162
+ FROM sync_incoming i
1163
+ LEFT JOIN sync_state e ON i.record_type = e.record_type AND i.data_id = e.data_id AND i.user_id = e.user_id
1164
+ WHERE i.user_id = ?
1165
+ ORDER BY i.revision ASC
1166
+ LIMIT ?`,
1167
+ [this.identity, limit]
1168
+ );
1169
+
1170
+ return rows.map((row) => {
1171
+ const newState = {
1172
+ id: { type: row.record_type, dataId: row.data_id },
1173
+ revision: BigInt(row.revision),
1174
+ schemaVersion: row.schema_version,
1175
+ data: parseJson(row.data),
1176
+ };
1177
+
1178
+ let oldState = null;
1179
+ if (row.existing_data) {
1180
+ oldState = {
1181
+ id: { type: row.record_type, dataId: row.data_id },
1182
+ revision: BigInt(row.existing_revision),
1183
+ schemaVersion: row.existing_schema_version,
1184
+ data: parseJson(row.existing_data),
1185
+ };
1186
+ }
1187
+
1188
+ return { newState, oldState };
1189
+ });
1190
+ } catch (error) {
1191
+ throw new StorageError(
1192
+ `Failed to get incoming records: ${error.message}`,
1193
+ error
1194
+ );
1195
+ }
1196
+ }
1197
+
1198
+ async syncGetLatestOutgoingChange() {
1199
+ try {
1200
+ const [rows] = await this.pool.query(
1201
+ `SELECT
1202
+ o.record_type,
1203
+ o.data_id,
1204
+ o.schema_version,
1205
+ o.commit_time,
1206
+ o.updated_fields_json,
1207
+ o.revision,
1208
+ e.schema_version AS existing_schema_version,
1209
+ e.commit_time AS existing_commit_time,
1210
+ e.data AS existing_data,
1211
+ e.revision AS existing_revision
1212
+ FROM sync_outgoing o
1213
+ LEFT JOIN sync_state e ON
1214
+ o.record_type = e.record_type AND
1215
+ o.data_id = e.data_id AND
1216
+ o.user_id = e.user_id
1217
+ WHERE o.user_id = ?
1218
+ ORDER BY o.revision DESC
1219
+ LIMIT 1`,
1220
+ [this.identity]
1221
+ );
1222
+
1223
+ if (rows.length === 0) {
1224
+ return null;
1225
+ }
1226
+
1227
+ const row = rows[0];
1228
+
1229
+ const change = {
1230
+ id: { type: row.record_type, dataId: row.data_id },
1231
+ schemaVersion: row.schema_version,
1232
+ updatedFields: parseJson(row.updated_fields_json),
1233
+ localRevision: BigInt(row.revision),
1234
+ };
1235
+
1236
+ let parent = null;
1237
+ if (row.existing_data) {
1238
+ parent = {
1239
+ id: { type: row.record_type, dataId: row.data_id },
1240
+ revision: BigInt(row.existing_revision),
1241
+ schemaVersion: row.existing_schema_version,
1242
+ data: parseJson(row.existing_data),
1243
+ };
1244
+ }
1245
+
1246
+ return { change, parent };
1247
+ } catch (error) {
1248
+ throw new StorageError(
1249
+ `Failed to get latest outgoing change: ${error.message}`,
1250
+ error
1251
+ );
1252
+ }
1253
+ }
1254
+
1255
+ async syncUpdateRecordFromIncoming(record) {
1256
+ try {
1257
+ await this._withTransaction(async (conn) => {
1258
+ await conn.query(
1259
+ `INSERT INTO sync_state (
1260
+ user_id,
1261
+ record_type,
1262
+ data_id,
1263
+ revision,
1264
+ schema_version,
1265
+ commit_time,
1266
+ data
1267
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
1268
+ ON DUPLICATE KEY UPDATE
1269
+ schema_version = VALUES(schema_version),
1270
+ commit_time = VALUES(commit_time),
1271
+ data = VALUES(data),
1272
+ revision = VALUES(revision)`,
1273
+ [
1274
+ this.identity,
1275
+ record.id.type,
1276
+ record.id.dataId,
1277
+ record.revision.toString(),
1278
+ record.schemaVersion,
1279
+ Math.floor(Date.now() / 1000),
1280
+ JSON.stringify(record.data),
1281
+ ]
1282
+ );
1283
+
1284
+ await conn.query(
1285
+ `INSERT INTO sync_revision (user_id, revision) VALUES (?, ?)
1286
+ ON DUPLICATE KEY UPDATE revision = GREATEST(revision, VALUES(revision))`,
1287
+ [this.identity, record.revision.toString()]
1288
+ );
1289
+ });
1290
+ } catch (error) {
1291
+ if (error instanceof StorageError) throw error;
1292
+ throw new StorageError(
1293
+ `Failed to update record from incoming: ${error.message}`,
1294
+ error
1295
+ );
1296
+ }
1297
+ }
1298
+ }
1299
+
1300
+ /**
1301
+ * Creates a MysqlStorageConfig with the given connection string and default pool settings.
1302
+ *
1303
+ * Default values:
1304
+ * - maxPoolSize: 10
1305
+ * - createTimeoutSecs: 0 (no timeout)
1306
+ * - recycleTimeoutSecs: 10 (10 seconds idle before disconnect)
1307
+ *
1308
+ * @param {string} connectionString - MySQL connection URL (mysql://user:pass@host:3306/db)
1309
+ * @returns {object} MySQL storage configuration
1310
+ */
1311
+ function defaultMysqlStorageConfig(connectionString) {
1312
+ return {
1313
+ connectionString,
1314
+ maxPoolSize: 10,
1315
+ createTimeoutSecs: 0,
1316
+ recycleTimeoutSecs: 10,
1317
+ };
1318
+ }
1319
+
1320
+ /**
1321
+ * Create a mysql2 pool from a config object.
1322
+ * The returned pool can be shared across multiple store implementations.
1323
+ */
1324
+ function createMysqlPool(config) {
1325
+ return mysql.createPool({
1326
+ uri: config.connectionString,
1327
+ connectionLimit: config.maxPoolSize,
1328
+ connectTimeout: (config.createTimeoutSecs || 0) * 1000 || 10000,
1329
+ idleTimeout: (config.recycleTimeoutSecs || 0) * 1000 || 10000,
1330
+ waitForConnections: true,
1331
+ });
1332
+ }
1333
+
1334
+ /**
1335
+ * Create a MysqlStorage instance from an existing mysql2 pool.
1336
+ *
1337
+ * @param {import('mysql2/promise').Pool} pool - An existing connection pool
1338
+ * @param {Buffer|Uint8Array} identity - 33-byte tenant identity (secp256k1 pubkey)
1339
+ * @param {object} [logger]
1340
+ */
1341
+ async function createMysqlStorageWithPool(pool, identity, logger = null) {
1342
+ const storage = new MysqlStorage(pool, identity, logger);
1343
+ await storage.initialize();
1344
+ return storage;
1345
+ }
1346
+
1347
+ /**
1348
+ * Create a MysqlStorage instance from a config object.
1349
+ * Use defaultMysqlStorageConfig to create a config with sensible defaults.
1350
+ *
1351
+ * @param {object} config - MySQL storage config
1352
+ * @param {Buffer|Uint8Array} identity - 33-byte tenant identity (secp256k1 pubkey)
1353
+ * @param {object} [logger]
1354
+ */
1355
+ async function createMysqlStorage(config, identity, logger = null) {
1356
+ const pool = createMysqlPool(config);
1357
+ return createMysqlStorageWithPool(pool, identity, logger);
1358
+ }
1359
+
1360
+ module.exports = {
1361
+ MysqlStorage,
1362
+ createMysqlStorage,
1363
+ createMysqlPool,
1364
+ createMysqlStorageWithPool,
1365
+ defaultMysqlStorageConfig,
1366
+ StorageError,
1367
+ };