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