@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,939 @@
1
+ /**
2
+ * CommonJS implementation for Node.js MySQL Tree Store.
3
+ *
4
+ * Mirrors `postgres-tree-store/index.cjs` for MySQL 8.0+. See
5
+ * `mysql-storage/index.cjs` for SQL translation rules. Notable differences:
6
+ * - `pg_advisory_xact_lock` is transaction-scoped; MySQL `GET_LOCK` is
7
+ * session-scoped, so we acquire it on the connection, run the transaction,
8
+ * release it explicitly afterwards.
9
+ * - `UNNEST(arr)` batch inserts → manually built `VALUES (?,…), (?,…)`.
10
+ * - `ANY(arr)` IN-array predicates → manually built `IN (?, ?, …)`.
11
+ */
12
+
13
+ let mysql;
14
+ try {
15
+ const mainModule = require.main;
16
+ if (mainModule) {
17
+ mysql = mainModule.require("mysql2/promise");
18
+ } else {
19
+ mysql = require("mysql2/promise");
20
+ }
21
+ } catch (error) {
22
+ try {
23
+ mysql = require("mysql2/promise");
24
+ } catch (fallbackError) {
25
+ throw new Error(
26
+ `mysql2 not found. Please install it in your project: npm install mysql2@^3.11.0\n` +
27
+ `Original error: ${error.message}\nFallback error: ${fallbackError.message}`
28
+ );
29
+ }
30
+ }
31
+
32
+ const { TreeStoreError } = require("./errors.cjs");
33
+ const { MysqlTreeStoreMigrationManager } = require("./migrations.cjs");
34
+
35
+ /**
36
+ * Domain prefix mixed into the per-tenant `GET_LOCK` name. Distinct prefixes
37
+ * guarantee that tree-store and token-store locks never collide.
38
+ */
39
+ const TREE_STORE_LOCK_PREFIX = "breez-spark-sdk:tree:";
40
+ /** Seconds to wait when acquiring the write lock. */
41
+ const WRITE_LOCK_TIMEOUT_SECS = 30;
42
+
43
+ const RESERVATION_TIMEOUT_SECS = 300;
44
+ const SPENT_MARKER_CLEANUP_THRESHOLD_MS = 5 * 60 * 1000;
45
+
46
+ /**
47
+ * Derive a stable per-tenant lock name from a tenant identity pubkey. Hashes
48
+ * a domain prefix together with the identity (SHA-256, first 8 bytes hex).
49
+ */
50
+ function _identityLockName(prefix, identity) {
51
+ const crypto = require("crypto");
52
+ const hash = crypto.createHash("sha256");
53
+ hash.update(prefix);
54
+ hash.update(Buffer.from(identity));
55
+ return prefix + hash.digest("hex").slice(0, 16);
56
+ }
57
+
58
+ /** mysql2 may return JSON columns as either parsed objects or raw strings. */
59
+ function parseJson(value) {
60
+ if (value == null) return null;
61
+ if (typeof value === "string") return JSON.parse(value);
62
+ return value;
63
+ }
64
+
65
+ /** Normalize MySQL's TINYINT(1) to a JS boolean. */
66
+ function toBool(value) {
67
+ if (value == null) return null;
68
+ if (typeof value === "boolean") return value;
69
+ return value === 1 || value === "1" || value === true;
70
+ }
71
+
72
+ function buildPlaceholders(n) {
73
+ return new Array(n).fill("?").join(", ");
74
+ }
75
+
76
+ class MysqlTreeStore {
77
+ /**
78
+ * @param {import('mysql2/promise').Pool} pool
79
+ * @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey
80
+ * identifying the tenant. All reads and writes are scoped by this.
81
+ * @param {object} [logger]
82
+ */
83
+ constructor(pool, identity, logger = null) {
84
+ if (!identity || identity.length !== 33) {
85
+ throw new TreeStoreError(
86
+ "tenant identity (33-byte secp256k1 pubkey) is required"
87
+ );
88
+ }
89
+ this.pool = pool;
90
+ this.identity = Buffer.from(identity);
91
+ this.lockName = _identityLockName(TREE_STORE_LOCK_PREFIX, identity);
92
+ this.logger = logger;
93
+ }
94
+
95
+ async initialize() {
96
+ try {
97
+ const migrationManager = new MysqlTreeStoreMigrationManager(this.logger);
98
+ await migrationManager.migrate(this.pool, this.identity);
99
+ return this;
100
+ } catch (error) {
101
+ throw new TreeStoreError(
102
+ `Failed to initialize MySQL tree store: ${error.message}`,
103
+ error
104
+ );
105
+ }
106
+ }
107
+
108
+ async close() {
109
+ if (this.pool) {
110
+ await this.pool.end();
111
+ this.pool = null;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Run a function inside a transaction, holding the named write lock for the
117
+ * duration. Reserved for operations whose correctness depends on serializing
118
+ * the available-leaf set (`tryReserveLeaves`, `setLeaves`).
119
+ * @param {function(import('mysql2/promise').PoolConnection): Promise<T>} fn
120
+ * @returns {Promise<T>}
121
+ * @template T
122
+ */
123
+ async _withWriteTransaction(fn) {
124
+ const conn = await this.pool.getConnection();
125
+ let lockAcquired = false;
126
+ try {
127
+ const [lockRows] = await conn.query(
128
+ "SELECT GET_LOCK(?, ?) AS acquired",
129
+ [this.lockName, WRITE_LOCK_TIMEOUT_SECS]
130
+ );
131
+ if (!lockRows || lockRows[0].acquired !== 1) {
132
+ throw new TreeStoreError(
133
+ `Failed to acquire tree store write lock within ${WRITE_LOCK_TIMEOUT_SECS}s`
134
+ );
135
+ }
136
+ lockAcquired = true;
137
+
138
+ await conn.beginTransaction();
139
+ const result = await fn(conn);
140
+ await conn.commit();
141
+ return result;
142
+ } catch (error) {
143
+ await conn.rollback().catch(() => {});
144
+ throw error;
145
+ } finally {
146
+ if (lockAcquired) {
147
+ await conn
148
+ .query("SELECT RELEASE_LOCK(?)", [this.lockName])
149
+ .catch(() => {});
150
+ }
151
+ conn.release();
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Run a function inside a transaction without the advisory lock. Used by
157
+ * operations scoped to a single reservation_id (`addLeaves`,
158
+ * `cancelReservation`, `updateReservation`) where row-level FK + InnoDB MVCC
159
+ * suffice and the global lock would only add contention.
160
+ * @param {function(import('mysql2/promise').PoolConnection): Promise<T>} fn
161
+ * @returns {Promise<T>}
162
+ * @template T
163
+ */
164
+ async _withTransaction(fn) {
165
+ const conn = await this.pool.getConnection();
166
+ try {
167
+ await conn.beginTransaction();
168
+ const result = await fn(conn);
169
+ await conn.commit();
170
+ return result;
171
+ } catch (error) {
172
+ await conn.rollback().catch(() => {});
173
+ throw error;
174
+ } finally {
175
+ conn.release();
176
+ }
177
+ }
178
+
179
+ // ===== TreeStore Methods =====
180
+
181
+ async addLeaves(leaves) {
182
+ try {
183
+ if (!leaves || leaves.length === 0) {
184
+ return;
185
+ }
186
+
187
+ await this._withTransaction(async (conn) => {
188
+ const leafIds = leaves.map((l) => l.id);
189
+ await this._batchRemoveSpentLeaves(conn, leafIds);
190
+ await this._batchUpsertLeaves(conn, leaves, false, null);
191
+ });
192
+ } catch (error) {
193
+ if (error instanceof TreeStoreError) throw error;
194
+ throw new TreeStoreError(
195
+ `Failed to add leaves: ${error.message}`,
196
+ error
197
+ );
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Returns the wallet's spendable balance (available + missing-from-operators
203
+ * + swap-reserved). Aggregated server-side so we don't fetch every leaf.
204
+ * @returns {Promise<bigint>}
205
+ */
206
+ async getAvailableBalance() {
207
+ try {
208
+ const [rows] = await this.pool.query(
209
+ `SELECT COALESCE(SUM(l.value), 0) AS balance
210
+ FROM tree_leaves l
211
+ LEFT JOIN tree_reservations r
212
+ ON l.reservation_id = r.id AND l.user_id = r.user_id
213
+ WHERE l.user_id = ?
214
+ AND (
215
+ (l.reservation_id IS NULL AND l.status = 'Available')
216
+ OR r.purpose = 'Swap'
217
+ )`,
218
+ [this.identity]
219
+ );
220
+ return BigInt(rows[0].balance);
221
+ } catch (error) {
222
+ throw new TreeStoreError(
223
+ `Failed to get available balance: ${error.message}`,
224
+ error
225
+ );
226
+ }
227
+ }
228
+
229
+ async getLeaves() {
230
+ try {
231
+ const [rows] = await this.pool.query(
232
+ `SELECT l.id, l.status, l.is_missing_from_operators, l.data,
233
+ l.reservation_id, r.purpose
234
+ FROM tree_leaves l
235
+ LEFT JOIN tree_reservations r
236
+ ON l.reservation_id = r.id AND l.user_id = r.user_id
237
+ WHERE l.user_id = ?`,
238
+ [this.identity]
239
+ );
240
+
241
+ const available = [];
242
+ const notAvailable = [];
243
+ const availableMissingFromOperators = [];
244
+ const reservedForPayment = [];
245
+ const reservedForSwap = [];
246
+
247
+ for (const row of rows) {
248
+ const node = parseJson(row.data);
249
+
250
+ if (row.purpose) {
251
+ if (row.purpose === "Payment") {
252
+ reservedForPayment.push(node);
253
+ } else if (row.purpose === "Swap") {
254
+ reservedForSwap.push(node);
255
+ }
256
+ } else if (toBool(row.is_missing_from_operators)) {
257
+ if (node.status === "Available") {
258
+ availableMissingFromOperators.push(node);
259
+ }
260
+ } else if (node.status === "Available") {
261
+ available.push(node);
262
+ } else {
263
+ notAvailable.push(node);
264
+ }
265
+ }
266
+
267
+ return {
268
+ available,
269
+ notAvailable,
270
+ availableMissingFromOperators,
271
+ reservedForPayment,
272
+ reservedForSwap,
273
+ };
274
+ } catch (error) {
275
+ if (error instanceof TreeStoreError) throw error;
276
+ throw new TreeStoreError(
277
+ `Failed to get leaves: ${error.message}`,
278
+ error
279
+ );
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Set leaves from a refresh operation.
285
+ * @param {Array} leaves - Available leaves from operators
286
+ * @param {Array} missingLeaves - Leaves missing from some operators
287
+ * @param {number} refreshStartedAtMs - Epoch milliseconds when refresh started
288
+ */
289
+ async setLeaves(leaves, missingLeaves, refreshStartedAtMs) {
290
+ try {
291
+ await this._withWriteTransaction(async (conn) => {
292
+ const refreshTimestamp = new Date(refreshStartedAtMs);
293
+
294
+ // Drop expired reservations BEFORE evaluating has_active_swap.
295
+ await this._cleanupStaleReservations(conn);
296
+
297
+ const [swapRows] = await conn.query(
298
+ `SELECT
299
+ (SELECT EXISTS(SELECT 1 FROM tree_reservations WHERE user_id = ? AND purpose = 'Swap')) AS has_active_swap,
300
+ COALESCE(
301
+ (SELECT (last_completed_at >= ?) FROM tree_swap_status WHERE user_id = ?),
302
+ 0
303
+ ) AS swap_completed_during_refresh`,
304
+ [this.identity, refreshTimestamp, this.identity]
305
+ );
306
+ const hasActiveSwap = !!swapRows[0].has_active_swap;
307
+ const swapCompletedDuringRefresh = !!swapRows[0].swap_completed_during_refresh;
308
+
309
+ if (hasActiveSwap || swapCompletedDuringRefresh) {
310
+ return;
311
+ }
312
+
313
+ await this._cleanupSpentMarkers(conn, refreshTimestamp);
314
+
315
+ const [spentRows] = await conn.query(
316
+ "SELECT leaf_id FROM tree_spent_leaves WHERE user_id = ? AND spent_at >= ?",
317
+ [this.identity, refreshTimestamp]
318
+ );
319
+ const spentIds = new Set(spentRows.map((r) => r.leaf_id));
320
+
321
+ // Includes leaves released earlier in this transaction by
322
+ // _cleanupStaleReservations (which now NULLs reservation_id explicitly,
323
+ // since the composite FK uses NO ACTION).
324
+ await conn.query(
325
+ "DELETE FROM tree_leaves WHERE user_id = ? AND reservation_id IS NULL AND added_at < ?",
326
+ [this.identity, refreshTimestamp]
327
+ );
328
+
329
+ await this._batchUpsertLeaves(conn, leaves, false, spentIds);
330
+ await this._batchUpsertLeaves(conn, missingLeaves, true, spentIds);
331
+ });
332
+ } catch (error) {
333
+ if (error instanceof TreeStoreError) throw error;
334
+ throw new TreeStoreError(
335
+ `Failed to set leaves: ${error.message}`,
336
+ error
337
+ );
338
+ }
339
+ }
340
+
341
+ async cancelReservation(id, leavesToKeep) {
342
+ try {
343
+ await this._withTransaction(async (conn) => {
344
+ const [existsRows] = await conn.query(
345
+ "SELECT id FROM tree_reservations WHERE user_id = ? AND id = ?",
346
+ [this.identity, id]
347
+ );
348
+
349
+ if (existsRows.length === 0) {
350
+ return;
351
+ }
352
+
353
+ await conn.query(
354
+ "DELETE FROM tree_leaves WHERE user_id = ? AND reservation_id = ?",
355
+ [this.identity, id]
356
+ );
357
+ await conn.query(
358
+ "DELETE FROM tree_reservations WHERE user_id = ? AND id = ?",
359
+ [this.identity, id]
360
+ );
361
+
362
+ if (leavesToKeep && leavesToKeep.length > 0) {
363
+ await this._batchUpsertLeaves(conn, leavesToKeep, false, null);
364
+ }
365
+ });
366
+ } catch (error) {
367
+ if (error instanceof TreeStoreError) throw error;
368
+ throw new TreeStoreError(
369
+ `Failed to cancel reservation '${id}': ${error.message}`,
370
+ error
371
+ );
372
+ }
373
+ }
374
+
375
+ async finalizeReservation(id, newLeaves) {
376
+ try {
377
+ // _withWriteTransaction acquires the GET_LOCK so this serializes
378
+ // against `setLeaves`. Without it, a concurrent setLeaves could read
379
+ // tree_spent_leaves before our marker commits and re-insert the
380
+ // just-spent leaf as Available.
381
+ await this._withWriteTransaction(async (conn) => {
382
+ const [resRows] = await conn.query(
383
+ "SELECT id, purpose FROM tree_reservations WHERE user_id = ? AND id = ?",
384
+ [this.identity, id]
385
+ );
386
+
387
+ let isSwap = false;
388
+ if (resRows.length > 0) {
389
+ isSwap = resRows[0].purpose === "Swap";
390
+ const [leafRows] = await conn.query(
391
+ "SELECT id FROM tree_leaves WHERE user_id = ? AND reservation_id = ?",
392
+ [this.identity, id]
393
+ );
394
+ const reservedLeafIds = leafRows.map((r) => r.id);
395
+ await this._batchInsertSpentLeaves(conn, reservedLeafIds);
396
+ await conn.query(
397
+ "DELETE FROM tree_leaves WHERE user_id = ? AND reservation_id = ?",
398
+ [this.identity, id]
399
+ );
400
+ await conn.query(
401
+ "DELETE FROM tree_reservations WHERE user_id = ? AND id = ?",
402
+ [this.identity, id]
403
+ );
404
+ }
405
+
406
+ if (newLeaves && newLeaves.length > 0) {
407
+ await this._batchUpsertLeaves(conn, newLeaves, false, null);
408
+ }
409
+
410
+ // UPSERT so a tenant that joined after the multi-tenant migration
411
+ // (and thus has no row) gets one created lazily.
412
+ if (isSwap && newLeaves && newLeaves.length > 0) {
413
+ await conn.query(
414
+ `INSERT INTO tree_swap_status (user_id, last_completed_at) VALUES (?, NOW(6))
415
+ ON DUPLICATE KEY UPDATE last_completed_at = VALUES(last_completed_at)`,
416
+ [this.identity]
417
+ );
418
+ }
419
+ });
420
+ } catch (error) {
421
+ if (error instanceof TreeStoreError) throw error;
422
+ throw new TreeStoreError(
423
+ `Failed to finalize reservation '${id}': ${error.message}`,
424
+ error
425
+ );
426
+ }
427
+ }
428
+
429
+ async tryReserveLeaves(targetAmounts, exactOnly, purpose) {
430
+ try {
431
+ return await this._withWriteTransaction(async (conn) => {
432
+ const targetAmount = targetAmounts ? this._totalSats(targetAmounts) : 0;
433
+ const maxTarget = this._maxTargetForPrefilter(targetAmounts);
434
+
435
+ const [totalRows] = await conn.query(
436
+ `SELECT COALESCE(SUM(value), 0) AS total
437
+ FROM tree_leaves
438
+ WHERE user_id = ?
439
+ AND status = 'Available'
440
+ AND is_missing_from_operators = 0
441
+ AND reservation_id IS NULL`,
442
+ [this.identity]
443
+ );
444
+ const available = Number(totalRows[0].total);
445
+
446
+ const [slimRows] = await conn.query(
447
+ `SELECT id, value
448
+ FROM tree_leaves
449
+ WHERE user_id = ?
450
+ AND status = 'Available'
451
+ AND is_missing_from_operators = 0
452
+ AND reservation_id IS NULL
453
+ AND (
454
+ value <= ?
455
+ OR id = (
456
+ SELECT id FROM (
457
+ SELECT id FROM tree_leaves
458
+ WHERE user_id = ?
459
+ AND status = 'Available'
460
+ AND is_missing_from_operators = 0
461
+ AND reservation_id IS NULL
462
+ AND value > ?
463
+ ORDER BY value
464
+ LIMIT 1
465
+ ) AS smallest_over
466
+ )
467
+ )`,
468
+ [this.identity, maxTarget, this.identity, maxTarget]
469
+ );
470
+
471
+ const slimLeaves = slimRows.map((r) => ({
472
+ id: r.id,
473
+ value: Number(r.value),
474
+ }));
475
+
476
+ const pending = await this._calculatePendingBalance(conn);
477
+
478
+ // Try exact selection on slim leaves — selection only reads .id/.value
479
+ const selected = this._selectLeavesByTargetAmounts(
480
+ slimLeaves,
481
+ targetAmounts
482
+ );
483
+
484
+ if (selected !== null) {
485
+ if (selected.length === 0) {
486
+ throw new TreeStoreError("NonReservableLeaves");
487
+ }
488
+
489
+ const fullLeaves = await this._fetchFullLeavesByIds(
490
+ conn,
491
+ selected.map((l) => l.id)
492
+ );
493
+ const reservationId = this._generateId();
494
+ await this._createReservation(
495
+ conn,
496
+ reservationId,
497
+ fullLeaves,
498
+ purpose,
499
+ 0
500
+ );
501
+
502
+ return {
503
+ type: "success",
504
+ reservation: { id: reservationId, leaves: fullLeaves },
505
+ };
506
+ }
507
+
508
+ if (!exactOnly) {
509
+ const minSelected = this._selectLeavesByMinimumAmount(
510
+ slimLeaves,
511
+ targetAmount
512
+ );
513
+ if (minSelected !== null) {
514
+ const fullLeaves = await this._fetchFullLeavesByIds(
515
+ conn,
516
+ minSelected.map((l) => l.id)
517
+ );
518
+ const reservedAmount = fullLeaves.reduce(
519
+ (sum, l) => sum + l.value,
520
+ 0
521
+ );
522
+ const pendingChange =
523
+ reservedAmount > targetAmount && targetAmount > 0
524
+ ? reservedAmount - targetAmount
525
+ : 0;
526
+
527
+ const reservationId = this._generateId();
528
+ await this._createReservation(
529
+ conn,
530
+ reservationId,
531
+ fullLeaves,
532
+ purpose,
533
+ pendingChange
534
+ );
535
+
536
+ return {
537
+ type: "success",
538
+ reservation: { id: reservationId, leaves: fullLeaves },
539
+ };
540
+ }
541
+ }
542
+
543
+ if (available + pending >= targetAmount) {
544
+ return {
545
+ type: "waitForPending",
546
+ needed: targetAmount,
547
+ available,
548
+ pending,
549
+ };
550
+ }
551
+
552
+ return { type: "insufficientFunds" };
553
+ });
554
+ } catch (error) {
555
+ if (error instanceof TreeStoreError) throw error;
556
+ throw new TreeStoreError(
557
+ `Failed to try reserve leaves: ${error.message}`,
558
+ error
559
+ );
560
+ }
561
+ }
562
+
563
+ async now() {
564
+ try {
565
+ const [rows] = await this.pool.query("SELECT NOW(6) AS now");
566
+ const value = rows[0].now;
567
+ // mysql2 typically returns DATETIME as a JS Date when dateStrings is false (default).
568
+ if (value instanceof Date) return value.getTime();
569
+ return new Date(value).getTime();
570
+ } catch (error) {
571
+ throw new TreeStoreError(
572
+ `Failed to get current time: ${error.message}`,
573
+ error
574
+ );
575
+ }
576
+ }
577
+
578
+ async updateReservation(reservationId, reservedLeaves, changeLeaves) {
579
+ try {
580
+ return await this._withTransaction(async (conn) => {
581
+ const [existsRows] = await conn.query(
582
+ "SELECT id FROM tree_reservations WHERE user_id = ? AND id = ?",
583
+ [this.identity, reservationId]
584
+ );
585
+
586
+ if (existsRows.length === 0) {
587
+ throw new TreeStoreError(`Reservation ${reservationId} not found`);
588
+ }
589
+
590
+ const [oldLeafRows] = await conn.query(
591
+ "SELECT id FROM tree_leaves WHERE user_id = ? AND reservation_id = ?",
592
+ [this.identity, reservationId]
593
+ );
594
+ const oldLeafIds = oldLeafRows.map((r) => r.id);
595
+
596
+ await this._batchInsertSpentLeaves(conn, oldLeafIds);
597
+ await conn.query(
598
+ "DELETE FROM tree_leaves WHERE user_id = ? AND reservation_id = ?",
599
+ [this.identity, reservationId]
600
+ );
601
+
602
+ await this._batchUpsertLeaves(conn, changeLeaves, false, null);
603
+ await this._batchUpsertLeaves(conn, reservedLeaves, false, null);
604
+
605
+ const reservedLeafIds = reservedLeaves.map((l) => l.id);
606
+ await this._batchSetReservationId(conn, reservationId, reservedLeafIds);
607
+
608
+ await conn.query(
609
+ "UPDATE tree_reservations SET pending_change_amount = 0 WHERE user_id = ? AND id = ?",
610
+ [this.identity, reservationId]
611
+ );
612
+
613
+ return { id: reservationId, leaves: reservedLeaves };
614
+ });
615
+ } catch (error) {
616
+ if (error instanceof TreeStoreError) throw error;
617
+ throw new TreeStoreError(
618
+ `Failed to update reservation '${reservationId}': ${error.message}`,
619
+ error
620
+ );
621
+ }
622
+ }
623
+
624
+ // ===== Private Helpers =====
625
+
626
+ _generateId() {
627
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
628
+ return crypto.randomUUID();
629
+ }
630
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
631
+ const r = (Math.random() * 16) | 0;
632
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
633
+ return v.toString(16);
634
+ });
635
+ }
636
+
637
+ _totalSats(targetAmounts) {
638
+ if (targetAmounts.type === "amountAndFee") {
639
+ return targetAmounts.amountSats + (targetAmounts.feeSats || 0);
640
+ }
641
+ if (targetAmounts.type === "exactDenominations") {
642
+ return targetAmounts.denominations.reduce((sum, d) => sum + d, 0);
643
+ }
644
+ return 0;
645
+ }
646
+
647
+ _maxTargetForPrefilter(targetAmounts) {
648
+ if (!targetAmounts) return Number.MAX_SAFE_INTEGER;
649
+ if (targetAmounts.type === "amountAndFee") {
650
+ return targetAmounts.amountSats + (targetAmounts.feeSats || 0);
651
+ }
652
+ if (targetAmounts.type === "exactDenominations") {
653
+ return targetAmounts.denominations.reduce((m, v) => m + v, 0);
654
+ }
655
+ return Number.MAX_SAFE_INTEGER;
656
+ }
657
+
658
+ /**
659
+ * Pull the full `data` JSON for the leaves the selection algorithm picked.
660
+ * Typically this is 1-3 rows even when the prefiltered set was thousands.
661
+ */
662
+ async _fetchFullLeavesByIds(conn, ids) {
663
+ if (!ids || ids.length === 0) return [];
664
+ const placeholders = ids.map(() => "?").join(", ");
665
+ const [rows] = await conn.query(
666
+ `SELECT data FROM tree_leaves WHERE user_id = ? AND id IN (${placeholders})`,
667
+ [this.identity, ...ids]
668
+ );
669
+ return rows.map((r) => parseJson(r.data));
670
+ }
671
+
672
+ _selectLeavesByTargetAmounts(leaves, targetAmounts) {
673
+ if (!targetAmounts) {
674
+ return [...leaves];
675
+ }
676
+
677
+ if (targetAmounts.type === "amountAndFee") {
678
+ const amountLeaves = this._selectLeavesByExactAmount(
679
+ leaves,
680
+ targetAmounts.amountSats
681
+ );
682
+ if (amountLeaves === null) return null;
683
+
684
+ if (targetAmounts.feeSats != null && targetAmounts.feeSats > 0) {
685
+ const amountIds = new Set(amountLeaves.map((l) => l.id));
686
+ const remaining = leaves.filter((l) => !amountIds.has(l.id));
687
+ const feeLeaves = this._selectLeavesByExactAmount(
688
+ remaining,
689
+ targetAmounts.feeSats
690
+ );
691
+ if (feeLeaves === null) return null;
692
+ return [...amountLeaves, ...feeLeaves];
693
+ }
694
+
695
+ return amountLeaves;
696
+ }
697
+
698
+ if (targetAmounts.type === "exactDenominations") {
699
+ return this._selectLeavesByExactDenominations(
700
+ leaves,
701
+ targetAmounts.denominations
702
+ );
703
+ }
704
+
705
+ return null;
706
+ }
707
+
708
+ _selectLeavesByExactAmount(leaves, targetAmount) {
709
+ if (targetAmount === 0) return null;
710
+
711
+ const totalAvailable = leaves.reduce((sum, l) => sum + l.value, 0);
712
+ if (totalAvailable < targetAmount) return null;
713
+
714
+ const single = leaves.find((l) => l.value === targetAmount);
715
+ if (single) return [single];
716
+
717
+ return this._findExactMultipleMatch(leaves, targetAmount);
718
+ }
719
+
720
+ _selectLeavesByExactDenominations(leaves, denominations) {
721
+ const remaining = [...leaves];
722
+ const selected = [];
723
+
724
+ for (const denomination of denominations) {
725
+ const idx = remaining.findIndex((l) => l.value === denomination);
726
+ if (idx === -1) return null;
727
+ selected.push(remaining[idx]);
728
+ remaining.splice(idx, 1);
729
+ }
730
+
731
+ return selected;
732
+ }
733
+
734
+ _selectLeavesByMinimumAmount(leaves, targetAmount) {
735
+ if (targetAmount === 0) return null;
736
+
737
+ const totalAvailable = leaves.reduce((sum, l) => sum + l.value, 0);
738
+ if (totalAvailable < targetAmount) return null;
739
+
740
+ const result = [];
741
+ let sum = 0;
742
+ for (const leaf of leaves) {
743
+ sum += leaf.value;
744
+ result.push(leaf);
745
+ if (sum >= targetAmount) break;
746
+ }
747
+
748
+ return sum >= targetAmount ? result : null;
749
+ }
750
+
751
+ _findExactMultipleMatch(leaves, targetAmount) {
752
+ if (targetAmount === 0) return [];
753
+ if (leaves.length === 0) return null;
754
+
755
+ const result = this._greedyExactMatch(leaves, targetAmount);
756
+ if (result) return result;
757
+
758
+ const powerOfTwoLeaves = leaves.filter((l) => this._isPowerOfTwo(l.value));
759
+ if (powerOfTwoLeaves.length === leaves.length) return null;
760
+
761
+ return this._greedyExactMatch(powerOfTwoLeaves, targetAmount);
762
+ }
763
+
764
+ _greedyExactMatch(leaves, targetAmount) {
765
+ const sorted = [...leaves].sort((a, b) => b.value - a.value);
766
+ const result = [];
767
+ let remaining = targetAmount;
768
+
769
+ for (const leaf of sorted) {
770
+ if (leaf.value > remaining) continue;
771
+ remaining -= leaf.value;
772
+ result.push(leaf);
773
+ if (remaining === 0) return result;
774
+ }
775
+
776
+ return null;
777
+ }
778
+
779
+ _isPowerOfTwo(value) {
780
+ return value > 0 && (value & (value - 1)) === 0;
781
+ }
782
+
783
+ async _calculatePendingBalance(conn) {
784
+ const [rows] = await conn.query(
785
+ "SELECT COALESCE(SUM(pending_change_amount), 0) AS pending FROM tree_reservations WHERE user_id = ?",
786
+ [this.identity]
787
+ );
788
+ return Number(rows[0].pending);
789
+ }
790
+
791
+ async _createReservation(conn, reservationId, leaves, purpose, pendingChange) {
792
+ await conn.query(
793
+ "INSERT INTO tree_reservations (user_id, id, purpose, pending_change_amount) VALUES (?, ?, ?, ?)",
794
+ [this.identity, reservationId, purpose, pendingChange]
795
+ );
796
+
797
+ const leafIds = leaves.map((l) => l.id);
798
+ await this._batchSetReservationId(conn, reservationId, leafIds);
799
+ }
800
+
801
+ async _batchUpsertLeaves(conn, leaves, isMissingFromOperators, skipIds) {
802
+ if (!leaves || leaves.length === 0) return;
803
+
804
+ const filtered = skipIds
805
+ ? leaves.filter((l) => !skipIds.has(l.id))
806
+ : leaves;
807
+
808
+ if (filtered.length === 0) return;
809
+
810
+ const valueClauses = new Array(filtered.length)
811
+ .fill("(?, ?, ?, ?, ?, ?, NOW(6))")
812
+ .join(", ");
813
+ const params = [];
814
+ for (const leaf of filtered) {
815
+ params.push(
816
+ this.identity,
817
+ leaf.id,
818
+ leaf.status,
819
+ isMissingFromOperators ? 1 : 0,
820
+ JSON.stringify(leaf),
821
+ leaf.value
822
+ );
823
+ }
824
+
825
+ await conn.query(
826
+ `INSERT INTO tree_leaves (user_id, id, status, is_missing_from_operators, data, value, added_at)
827
+ VALUES ${valueClauses}
828
+ ON DUPLICATE KEY UPDATE
829
+ status = VALUES(status),
830
+ is_missing_from_operators = VALUES(is_missing_from_operators),
831
+ data = VALUES(data),
832
+ value = VALUES(value),
833
+ added_at = NOW(6)`,
834
+ params
835
+ );
836
+ }
837
+
838
+ async _batchSetReservationId(conn, reservationId, leafIds) {
839
+ if (leafIds.length === 0) return;
840
+
841
+ const placeholders = buildPlaceholders(leafIds.length);
842
+ await conn.query(
843
+ `UPDATE tree_leaves SET reservation_id = ? WHERE user_id = ? AND id IN (${placeholders})`,
844
+ [reservationId, this.identity, ...leafIds]
845
+ );
846
+ }
847
+
848
+ async _batchInsertSpentLeaves(conn, leafIds) {
849
+ if (leafIds.length === 0) return;
850
+
851
+ const valueClauses = new Array(leafIds.length).fill("(?, ?)").join(", ");
852
+ const params = [];
853
+ for (const id of leafIds) {
854
+ params.push(this.identity, id);
855
+ }
856
+ // Suppress duplicate-PK errors only — unlike INSERT IGNORE, real
857
+ // problems (FK violations, NOT NULL violations, type errors) still
858
+ // propagate.
859
+ await conn.query(
860
+ `INSERT INTO tree_spent_leaves (user_id, leaf_id) VALUES ${valueClauses}
861
+ ON DUPLICATE KEY UPDATE leaf_id = leaf_id`,
862
+ params
863
+ );
864
+ }
865
+
866
+ async _batchRemoveSpentLeaves(conn, leafIds) {
867
+ if (leafIds.length === 0) return;
868
+
869
+ const placeholders = buildPlaceholders(leafIds.length);
870
+ await conn.query(
871
+ `DELETE FROM tree_spent_leaves WHERE user_id = ? AND leaf_id IN (${placeholders})`,
872
+ [this.identity, ...leafIds]
873
+ );
874
+ }
875
+
876
+ /// Cleans up stale reservations for THIS tenant. Releases dependent leaves
877
+ /// by clearing reservation_id first, then deletes the parent rows — the
878
+ /// composite FK uses NO ACTION because column-list SET NULL would null
879
+ /// user_id (NOT NULL).
880
+ async _cleanupStaleReservations(conn) {
881
+ await conn.query(
882
+ `UPDATE tree_leaves SET reservation_id = NULL
883
+ WHERE user_id = ?
884
+ AND reservation_id IN (
885
+ SELECT id FROM (
886
+ SELECT id FROM tree_reservations
887
+ WHERE user_id = ?
888
+ AND created_at < DATE_SUB(NOW(6), INTERVAL ? SECOND)
889
+ ) AS stale
890
+ )`,
891
+ [this.identity, this.identity, RESERVATION_TIMEOUT_SECS]
892
+ );
893
+ await conn.query(
894
+ `DELETE FROM tree_reservations
895
+ WHERE user_id = ? AND created_at < DATE_SUB(NOW(6), INTERVAL ? SECOND)`,
896
+ [this.identity, RESERVATION_TIMEOUT_SECS]
897
+ );
898
+ }
899
+
900
+ async _cleanupSpentMarkers(conn, refreshTimestamp) {
901
+ const cleanupCutoff = new Date(
902
+ refreshTimestamp.getTime() - SPENT_MARKER_CLEANUP_THRESHOLD_MS
903
+ );
904
+
905
+ await conn.query(
906
+ "DELETE FROM tree_spent_leaves WHERE user_id = ? AND spent_at < ?",
907
+ [this.identity, cleanupCutoff]
908
+ );
909
+ }
910
+ }
911
+
912
+ /** Create a mysql2 pool from a config object. */
913
+ function createMysqlPool(config) {
914
+ return mysql.createPool({
915
+ uri: config.connectionString,
916
+ connectionLimit: config.maxPoolSize,
917
+ connectTimeout: (config.createTimeoutSecs || 0) * 1000 || 10000,
918
+ idleTimeout: (config.recycleTimeoutSecs || 0) * 1000 || 10000,
919
+ waitForConnections: true,
920
+ });
921
+ }
922
+
923
+ async function createMysqlTreeStore(config, identity, logger = null) {
924
+ const pool = createMysqlPool(config);
925
+ return createMysqlTreeStoreWithPool(pool, identity, logger);
926
+ }
927
+
928
+ async function createMysqlTreeStoreWithPool(pool, identity, logger = null) {
929
+ const store = new MysqlTreeStore(pool, identity, logger);
930
+ await store.initialize();
931
+ return store;
932
+ }
933
+
934
+ module.exports = {
935
+ MysqlTreeStore,
936
+ createMysqlTreeStore,
937
+ createMysqlTreeStoreWithPool,
938
+ TreeStoreError,
939
+ };