@breeztech/breez-sdk-spark 0.11.0-dev2 → 0.11.0-dev3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,798 @@
1
+ /**
2
+ * CommonJS implementation for Node.js PostgreSQL Tree Store
3
+ */
4
+
5
+ let pg;
6
+ try {
7
+ const mainModule = require.main;
8
+ if (mainModule) {
9
+ pg = mainModule.require("pg");
10
+ } else {
11
+ pg = require("pg");
12
+ }
13
+ } catch (error) {
14
+ try {
15
+ pg = require("pg");
16
+ } catch (fallbackError) {
17
+ throw new Error(
18
+ `pg not found. Please install it in your project: npm install pg@^8.18.0\n` +
19
+ `Original error: ${error.message}\nFallback error: ${fallbackError.message}`
20
+ );
21
+ }
22
+ }
23
+
24
+ const { TreeStoreError } = require("./errors.cjs");
25
+ const { TreeStoreMigrationManager } = require("./migrations.cjs");
26
+
27
+ /**
28
+ * Advisory lock key for serializing tree store write operations.
29
+ * Matches the Rust constant TREE_STORE_WRITE_LOCK_KEY = 0x7472_6565_5354_4f52
30
+ */
31
+ const TREE_STORE_WRITE_LOCK_KEY = "8391086132283252818"; // 0x7472656553544f52 as decimal string
32
+
33
+ /**
34
+ * Timeout for reservations in seconds. Reservations older than this are stale.
35
+ */
36
+ const RESERVATION_TIMEOUT_SECS = 300; // 5 minutes
37
+
38
+ /**
39
+ * Threshold in milliseconds for cleaning up spent leaf markers.
40
+ */
41
+ const SPENT_MARKER_CLEANUP_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
42
+
43
+ class PostgresTreeStore {
44
+ constructor(pool, logger = null) {
45
+ this.pool = pool;
46
+ this.logger = logger;
47
+ }
48
+
49
+ /**
50
+ * Initialize the database (run migrations)
51
+ */
52
+ async initialize() {
53
+ try {
54
+ const migrationManager = new TreeStoreMigrationManager(this.logger);
55
+ await migrationManager.migrate(this.pool);
56
+ return this;
57
+ } catch (error) {
58
+ throw new TreeStoreError(
59
+ `Failed to initialize PostgreSQL tree store: ${error.message}`,
60
+ error
61
+ );
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Close the pool
67
+ */
68
+ async close() {
69
+ if (this.pool) {
70
+ await this.pool.end();
71
+ this.pool = null;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Run a function inside a transaction with the advisory lock.
77
+ * @param {function(import('pg').PoolClient): Promise<T>} fn
78
+ * @returns {Promise<T>}
79
+ * @template T
80
+ */
81
+ async _withWriteTransaction(fn) {
82
+ const client = await this.pool.connect();
83
+ try {
84
+ await client.query("BEGIN");
85
+ await client.query(`SELECT pg_advisory_xact_lock(${TREE_STORE_WRITE_LOCK_KEY})`);
86
+ const result = await fn(client);
87
+ await client.query("COMMIT");
88
+ return result;
89
+ } catch (error) {
90
+ await client.query("ROLLBACK").catch(() => {});
91
+ throw error;
92
+ } finally {
93
+ client.release();
94
+ }
95
+ }
96
+
97
+ // ===== TreeStore Methods =====
98
+
99
+ /**
100
+ * Add leaves to the store. Removes from spent leaves first, then upserts.
101
+ * @param {Array} leaves - Array of TreeNode objects
102
+ */
103
+ async addLeaves(leaves) {
104
+ try {
105
+ if (!leaves || leaves.length === 0) {
106
+ return;
107
+ }
108
+
109
+ await this._withWriteTransaction(async (client) => {
110
+ // Remove these leaves from spent_leaves table
111
+ const leafIds = leaves.map((l) => l.id);
112
+ await this._batchRemoveSpentLeaves(client, leafIds);
113
+
114
+ // Batch upsert all leaves
115
+ await this._batchUpsertLeaves(client, leaves, false, null);
116
+ });
117
+ } catch (error) {
118
+ if (error instanceof TreeStoreError) throw error;
119
+ throw new TreeStoreError(
120
+ `Failed to add leaves: ${error.message}`,
121
+ error
122
+ );
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Get all leaves categorized by status.
128
+ * @returns {Promise<Object>} Leaves object with available, notAvailable, etc.
129
+ */
130
+ async getLeaves() {
131
+ try {
132
+ const result = await this.pool.query(`
133
+ SELECT l.id, l.status, l.is_missing_from_operators, l.data,
134
+ l.reservation_id, r.purpose
135
+ FROM tree_leaves l
136
+ LEFT JOIN tree_reservations r ON l.reservation_id = r.id
137
+ `);
138
+
139
+ const available = [];
140
+ const notAvailable = [];
141
+ const availableMissingFromOperators = [];
142
+ const reservedForPayment = [];
143
+ const reservedForSwap = [];
144
+
145
+ for (const row of result.rows) {
146
+ const node = row.data;
147
+
148
+ if (row.purpose) {
149
+ if (row.purpose === "Payment") {
150
+ reservedForPayment.push(node);
151
+ } else if (row.purpose === "Swap") {
152
+ reservedForSwap.push(node);
153
+ }
154
+ } else if (row.is_missing_from_operators) {
155
+ if (node.status === "Available") {
156
+ availableMissingFromOperators.push(node);
157
+ }
158
+ } else if (node.status === "Available") {
159
+ available.push(node);
160
+ } else {
161
+ notAvailable.push(node);
162
+ }
163
+ }
164
+
165
+ return {
166
+ available,
167
+ notAvailable,
168
+ availableMissingFromOperators,
169
+ reservedForPayment,
170
+ reservedForSwap,
171
+ };
172
+ } catch (error) {
173
+ if (error instanceof TreeStoreError) throw error;
174
+ throw new TreeStoreError(
175
+ `Failed to get leaves: ${error.message}`,
176
+ error
177
+ );
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Set leaves from a refresh operation.
183
+ * @param {Array} leaves - Available leaves from operators
184
+ * @param {Array} missingLeaves - Leaves missing from some operators
185
+ * @param {number} refreshStartedAtMs - Epoch milliseconds when refresh started
186
+ */
187
+ async setLeaves(leaves, missingLeaves, refreshStartedAtMs) {
188
+ try {
189
+ await this._withWriteTransaction(async (client) => {
190
+ const refreshTimestamp = new Date(refreshStartedAtMs);
191
+
192
+ // Check for active swap or swap completed during refresh
193
+ const swapCheckResult = await client.query(`
194
+ SELECT
195
+ EXISTS(SELECT 1 FROM tree_reservations WHERE purpose = 'Swap') AS has_active_swap,
196
+ COALESCE((SELECT last_completed_at >= $1 FROM tree_swap_status WHERE id = 1), FALSE) AS swap_completed_during_refresh
197
+ `, [refreshTimestamp]);
198
+
199
+ const { has_active_swap, swap_completed_during_refresh } = swapCheckResult.rows[0];
200
+
201
+ if (has_active_swap || swap_completed_during_refresh) {
202
+ return;
203
+ }
204
+
205
+ // Clean up old spent markers
206
+ await this._cleanupSpentMarkers(client, refreshTimestamp);
207
+
208
+ // Get recent spent leaf IDs (spent_at >= refresh_timestamp)
209
+ const spentResult = await client.query(
210
+ "SELECT leaf_id FROM tree_spent_leaves WHERE spent_at >= $1",
211
+ [refreshTimestamp]
212
+ );
213
+ const spentIds = new Set(spentResult.rows.map((r) => r.leaf_id));
214
+
215
+ // Delete non-reserved leaves added before refresh started
216
+ await client.query(
217
+ "DELETE FROM tree_leaves WHERE reservation_id IS NULL AND added_at < $1",
218
+ [refreshTimestamp]
219
+ );
220
+
221
+ // Clean up stale reservations (after leaf delete)
222
+ await this._cleanupStaleReservations(client);
223
+
224
+ // Upsert all leaves (filtering spent)
225
+ await this._batchUpsertLeaves(client, leaves, false, spentIds);
226
+ await this._batchUpsertLeaves(client, missingLeaves, true, spentIds);
227
+ });
228
+ } catch (error) {
229
+ if (error instanceof TreeStoreError) throw error;
230
+ throw new TreeStoreError(
231
+ `Failed to set leaves: ${error.message}`,
232
+ error
233
+ );
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Cancel a reservation, releasing reserved leaves.
239
+ * @param {string} id - Reservation ID
240
+ */
241
+ async cancelReservation(id) {
242
+ try {
243
+ await this._withWriteTransaction(async (client) => {
244
+ // Check if reservation exists
245
+ const res = await client.query(
246
+ "SELECT id FROM tree_reservations WHERE id = $1",
247
+ [id]
248
+ );
249
+
250
+ if (res.rows.length === 0) {
251
+ return; // Already cancelled or finalized
252
+ }
253
+
254
+ // Delete reservation (ON DELETE SET NULL releases leaves)
255
+ await client.query(
256
+ "DELETE FROM tree_reservations WHERE id = $1",
257
+ [id]
258
+ );
259
+ });
260
+ } catch (error) {
261
+ if (error instanceof TreeStoreError) throw error;
262
+ throw new TreeStoreError(
263
+ `Failed to cancel reservation '${id}': ${error.message}`,
264
+ error
265
+ );
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Finalize a reservation, marking leaves as spent.
271
+ * @param {string} id - Reservation ID
272
+ * @param {Array|null} newLeaves - Optional new leaves to add
273
+ */
274
+ async finalizeReservation(id, newLeaves) {
275
+ try {
276
+ await this._withWriteTransaction(async (client) => {
277
+ // Check if reservation exists and get purpose
278
+ const res = await client.query(
279
+ "SELECT id, purpose FROM tree_reservations WHERE id = $1",
280
+ [id]
281
+ );
282
+
283
+ if (res.rows.length === 0) {
284
+ return; // Already finalized or cancelled
285
+ }
286
+
287
+ const isSwap = res.rows[0].purpose === "Swap";
288
+
289
+ // Get reserved leaf IDs
290
+ const leafResult = await client.query(
291
+ "SELECT id FROM tree_leaves WHERE reservation_id = $1",
292
+ [id]
293
+ );
294
+ const reservedLeafIds = leafResult.rows.map((r) => r.id);
295
+
296
+ // Mark as spent
297
+ await this._batchInsertSpentLeaves(client, reservedLeafIds);
298
+
299
+ // Delete reserved leaves and reservation
300
+ await client.query(
301
+ "DELETE FROM tree_leaves WHERE reservation_id = $1",
302
+ [id]
303
+ );
304
+ await client.query(
305
+ "DELETE FROM tree_reservations WHERE id = $1",
306
+ [id]
307
+ );
308
+
309
+ // Add new leaves if provided
310
+ if (newLeaves && newLeaves.length > 0) {
311
+ await this._batchUpsertLeaves(client, newLeaves, false, null);
312
+ }
313
+
314
+ // If swap with new leaves, update last_completed_at
315
+ if (isSwap && newLeaves && newLeaves.length > 0) {
316
+ await client.query(
317
+ "UPDATE tree_swap_status SET last_completed_at = NOW() WHERE id = 1"
318
+ );
319
+ }
320
+ });
321
+ } catch (error) {
322
+ if (error instanceof TreeStoreError) throw error;
323
+ throw new TreeStoreError(
324
+ `Failed to finalize reservation '${id}': ${error.message}`,
325
+ error
326
+ );
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Try to reserve leaves matching target amounts.
332
+ * @param {Object|null} targetAmounts - Target amounts spec
333
+ * @param {boolean} exactOnly - If true, only exact matches
334
+ * @param {string} purpose - "Payment" or "Swap"
335
+ * @returns {Promise<Object>} ReserveResult
336
+ */
337
+ async tryReserveLeaves(targetAmounts, exactOnly, purpose) {
338
+ try {
339
+ return await this._withWriteTransaction(async (client) => {
340
+ const targetAmount = targetAmounts ? this._totalSats(targetAmounts) : 0;
341
+
342
+ // Get available leaves
343
+ const availableResult = await client.query(`
344
+ SELECT data
345
+ FROM tree_leaves
346
+ WHERE status = 'Available'
347
+ AND is_missing_from_operators = FALSE
348
+ AND reservation_id IS NULL
349
+ `);
350
+
351
+ const availableLeaves = availableResult.rows.map((r) => r.data);
352
+ const available = availableLeaves.reduce((sum, l) => sum + l.value, 0);
353
+
354
+ // Calculate pending balance
355
+ const pending = await this._calculatePendingBalance(client);
356
+
357
+ // Try exact selection first
358
+ const selected = this._selectLeavesByTargetAmounts(availableLeaves, targetAmounts);
359
+
360
+ if (selected !== null) {
361
+ if (selected.length === 0) {
362
+ throw new TreeStoreError("NonReservableLeaves");
363
+ }
364
+
365
+ const reservationId = this._generateId();
366
+ await this._createReservation(client, reservationId, selected, purpose, 0);
367
+
368
+ return {
369
+ type: "success",
370
+ reservation: {
371
+ id: reservationId,
372
+ leaves: selected,
373
+ },
374
+ };
375
+ }
376
+
377
+ if (!exactOnly) {
378
+ // Try minimum amount selection
379
+ const minSelected = this._selectLeavesByMinimumAmount(availableLeaves, targetAmount);
380
+ if (minSelected !== null) {
381
+ const reservedAmount = minSelected.reduce((sum, l) => sum + l.value, 0);
382
+ const pendingChange = reservedAmount > targetAmount && targetAmount > 0
383
+ ? reservedAmount - targetAmount
384
+ : 0;
385
+
386
+ const reservationId = this._generateId();
387
+ await this._createReservation(client, reservationId, minSelected, purpose, pendingChange);
388
+
389
+ return {
390
+ type: "success",
391
+ reservation: {
392
+ id: reservationId,
393
+ leaves: minSelected,
394
+ },
395
+ };
396
+ }
397
+ }
398
+
399
+ // No suitable leaves found
400
+ if (available + pending >= targetAmount) {
401
+ return {
402
+ type: "waitForPending",
403
+ needed: targetAmount,
404
+ available,
405
+ pending,
406
+ };
407
+ }
408
+
409
+ return { type: "insufficientFunds" };
410
+ });
411
+ } catch (error) {
412
+ if (error instanceof TreeStoreError) throw error;
413
+ throw new TreeStoreError(
414
+ `Failed to try reserve leaves: ${error.message}`,
415
+ error
416
+ );
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Get current database time as epoch milliseconds.
422
+ * @returns {Promise<number>}
423
+ */
424
+ async now() {
425
+ try {
426
+ const result = await this.pool.query("SELECT NOW()");
427
+ return result.rows[0].now.getTime();
428
+ } catch (error) {
429
+ throw new TreeStoreError(
430
+ `Failed to get current time: ${error.message}`,
431
+ error
432
+ );
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Update a reservation after a swap.
438
+ * @param {string} reservationId - Existing reservation ID
439
+ * @param {Array} reservedLeaves - New reserved leaves
440
+ * @param {Array} changeLeaves - Change leaves to add to available pool
441
+ * @returns {Promise<Object>} Updated reservation
442
+ */
443
+ async updateReservation(reservationId, reservedLeaves, changeLeaves) {
444
+ try {
445
+ return await this._withWriteTransaction(async (client) => {
446
+ // Check if reservation exists
447
+ const res = await client.query(
448
+ "SELECT id FROM tree_reservations WHERE id = $1",
449
+ [reservationId]
450
+ );
451
+
452
+ if (res.rows.length === 0) {
453
+ throw new TreeStoreError(`Reservation ${reservationId} not found`);
454
+ }
455
+
456
+ // Get old reserved leaf IDs and mark as spent
457
+ const oldLeavesResult = await client.query(
458
+ "SELECT id FROM tree_leaves WHERE reservation_id = $1",
459
+ [reservationId]
460
+ );
461
+ const oldLeafIds = oldLeavesResult.rows.map((r) => r.id);
462
+
463
+ await this._batchInsertSpentLeaves(client, oldLeafIds);
464
+ await client.query(
465
+ "DELETE FROM tree_leaves WHERE reservation_id = $1",
466
+ [reservationId]
467
+ );
468
+
469
+ // Upsert change leaves to available pool
470
+ await this._batchUpsertLeaves(client, changeLeaves, false, null);
471
+
472
+ // Upsert reserved leaves
473
+ await this._batchUpsertLeaves(client, reservedLeaves, false, null);
474
+
475
+ // Set reservation_id on reserved leaves
476
+ const reservedLeafIds = reservedLeaves.map((l) => l.id);
477
+ await this._batchSetReservationId(client, reservationId, reservedLeafIds);
478
+
479
+ // Clear pending change amount
480
+ await client.query(
481
+ "UPDATE tree_reservations SET pending_change_amount = 0 WHERE id = $1",
482
+ [reservationId]
483
+ );
484
+
485
+ return {
486
+ id: reservationId,
487
+ leaves: reservedLeaves,
488
+ };
489
+ });
490
+ } catch (error) {
491
+ if (error instanceof TreeStoreError) throw error;
492
+ throw new TreeStoreError(
493
+ `Failed to update reservation '${reservationId}': ${error.message}`,
494
+ error
495
+ );
496
+ }
497
+ }
498
+
499
+ // ===== Private Helpers =====
500
+
501
+ /**
502
+ * Generate a unique reservation ID (UUIDv4).
503
+ */
504
+ _generateId() {
505
+ // Use crypto.randomUUID if available, otherwise manual
506
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
507
+ return crypto.randomUUID();
508
+ }
509
+ // Fallback UUIDv4
510
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
511
+ const r = (Math.random() * 16) | 0;
512
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
513
+ return v.toString(16);
514
+ });
515
+ }
516
+
517
+ /**
518
+ * Calculate total sats from target amounts.
519
+ */
520
+ _totalSats(targetAmounts) {
521
+ if (targetAmounts.type === "amountAndFee") {
522
+ return targetAmounts.amountSats + (targetAmounts.feeSats || 0);
523
+ }
524
+ if (targetAmounts.type === "exactDenominations") {
525
+ return targetAmounts.denominations.reduce((sum, d) => sum + d, 0);
526
+ }
527
+ return 0;
528
+ }
529
+
530
+ /**
531
+ * Select leaves by target amounts. Returns null if no exact match found.
532
+ */
533
+ _selectLeavesByTargetAmounts(leaves, targetAmounts) {
534
+ if (!targetAmounts) {
535
+ // No target: return all leaves (may be empty)
536
+ return [...leaves];
537
+ }
538
+
539
+ if (targetAmounts.type === "amountAndFee") {
540
+ const amountLeaves = this._selectLeavesByExactAmount(leaves, targetAmounts.amountSats);
541
+ if (amountLeaves === null) return null;
542
+
543
+ if (targetAmounts.feeSats != null && targetAmounts.feeSats > 0) {
544
+ const amountIds = new Set(amountLeaves.map((l) => l.id));
545
+ const remaining = leaves.filter((l) => !amountIds.has(l.id));
546
+ const feeLeaves = this._selectLeavesByExactAmount(remaining, targetAmounts.feeSats);
547
+ if (feeLeaves === null) return null;
548
+ return [...amountLeaves, ...feeLeaves];
549
+ }
550
+
551
+ return amountLeaves;
552
+ }
553
+
554
+ if (targetAmounts.type === "exactDenominations") {
555
+ return this._selectLeavesByExactDenominations(leaves, targetAmounts.denominations);
556
+ }
557
+
558
+ return null;
559
+ }
560
+
561
+ /**
562
+ * Select leaves that sum to exactly the target amount.
563
+ */
564
+ _selectLeavesByExactAmount(leaves, targetAmount) {
565
+ if (targetAmount === 0) return null; // Invalid amount
566
+
567
+ const totalAvailable = leaves.reduce((sum, l) => sum + l.value, 0);
568
+ if (totalAvailable < targetAmount) return null; // Insufficient funds
569
+
570
+ // Try single exact match
571
+ const single = leaves.find((l) => l.value === targetAmount);
572
+ if (single) return [single];
573
+
574
+ // Try greedy multiple match
575
+ const multipleResult = this._findExactMultipleMatch(leaves, targetAmount);
576
+ return multipleResult;
577
+ }
578
+
579
+ /**
580
+ * Select leaves that match exact denominations.
581
+ */
582
+ _selectLeavesByExactDenominations(leaves, denominations) {
583
+ const remaining = [...leaves];
584
+ const selected = [];
585
+
586
+ for (const denomination of denominations) {
587
+ const idx = remaining.findIndex((l) => l.value === denomination);
588
+ if (idx === -1) return null; // Can't match this denomination
589
+ selected.push(remaining[idx]);
590
+ remaining.splice(idx, 1);
591
+ }
592
+
593
+ return selected;
594
+ }
595
+
596
+ /**
597
+ * Select leaves summing to at least the target amount.
598
+ */
599
+ _selectLeavesByMinimumAmount(leaves, targetAmount) {
600
+ if (targetAmount === 0) return null;
601
+
602
+ const totalAvailable = leaves.reduce((sum, l) => sum + l.value, 0);
603
+ if (totalAvailable < targetAmount) return null;
604
+
605
+ const result = [];
606
+ let sum = 0;
607
+ for (const leaf of leaves) {
608
+ sum += leaf.value;
609
+ result.push(leaf);
610
+ if (sum >= targetAmount) break;
611
+ }
612
+
613
+ return sum >= targetAmount ? result : null;
614
+ }
615
+
616
+ /**
617
+ * Find exact multiple match using greedy algorithm.
618
+ */
619
+ _findExactMultipleMatch(leaves, targetAmount) {
620
+ if (targetAmount === 0) return [];
621
+ if (leaves.length === 0) return null;
622
+
623
+ // Pass 1: Try greedy on all leaves
624
+ const result = this._greedyExactMatch(leaves, targetAmount);
625
+ if (result) return result;
626
+
627
+ // Pass 2: Try with only power-of-two leaves
628
+ const powerOfTwoLeaves = leaves.filter((l) => this._isPowerOfTwo(l.value));
629
+ if (powerOfTwoLeaves.length === leaves.length) return null;
630
+
631
+ return this._greedyExactMatch(powerOfTwoLeaves, targetAmount);
632
+ }
633
+
634
+ /**
635
+ * Greedy exact match algorithm.
636
+ */
637
+ _greedyExactMatch(leaves, targetAmount) {
638
+ const sorted = [...leaves].sort((a, b) => b.value - a.value);
639
+ const result = [];
640
+ let remaining = targetAmount;
641
+
642
+ for (const leaf of sorted) {
643
+ if (leaf.value > remaining) continue;
644
+ remaining -= leaf.value;
645
+ result.push(leaf);
646
+ if (remaining === 0) return result;
647
+ }
648
+
649
+ return null;
650
+ }
651
+
652
+ /**
653
+ * Check if value is a power of two.
654
+ */
655
+ _isPowerOfTwo(value) {
656
+ return value > 0 && (value & (value - 1)) === 0;
657
+ }
658
+
659
+ /**
660
+ * Calculate pending balance from in-flight swaps.
661
+ */
662
+ async _calculatePendingBalance(client) {
663
+ const result = await client.query(
664
+ "SELECT COALESCE(SUM(pending_change_amount), 0)::BIGINT AS pending FROM tree_reservations"
665
+ );
666
+ return Number(result.rows[0].pending);
667
+ }
668
+
669
+ /**
670
+ * Create a reservation with the given leaves.
671
+ */
672
+ async _createReservation(client, reservationId, leaves, purpose, pendingChange) {
673
+ await client.query(
674
+ "INSERT INTO tree_reservations (id, purpose, pending_change_amount) VALUES ($1, $2, $3)",
675
+ [reservationId, purpose, pendingChange]
676
+ );
677
+
678
+ const leafIds = leaves.map((l) => l.id);
679
+ await this._batchSetReservationId(client, reservationId, leafIds);
680
+ }
681
+
682
+ /**
683
+ * Batch upsert leaves into tree_leaves table.
684
+ */
685
+ async _batchUpsertLeaves(client, leaves, isMissingFromOperators, skipIds) {
686
+ if (!leaves || leaves.length === 0) return;
687
+
688
+ const filtered = skipIds
689
+ ? leaves.filter((l) => !skipIds.has(l.id))
690
+ : leaves;
691
+
692
+ if (filtered.length === 0) return;
693
+
694
+ const ids = filtered.map((l) => l.id);
695
+ const statuses = filtered.map((l) => l.status);
696
+ const missingFlags = filtered.map(() => isMissingFromOperators);
697
+ const dataValues = filtered.map((l) => JSON.stringify(l));
698
+
699
+ await client.query(
700
+ `INSERT INTO tree_leaves (id, status, is_missing_from_operators, data, added_at)
701
+ SELECT id, status, missing, data::jsonb, NOW()
702
+ FROM UNNEST($1::text[], $2::text[], $3::bool[], $4::text[])
703
+ AS t(id, status, missing, data)
704
+ ON CONFLICT (id) DO UPDATE SET
705
+ status = EXCLUDED.status,
706
+ is_missing_from_operators = EXCLUDED.is_missing_from_operators,
707
+ data = EXCLUDED.data,
708
+ added_at = NOW()`,
709
+ [ids, statuses, missingFlags, dataValues]
710
+ );
711
+ }
712
+
713
+ /**
714
+ * Batch set reservation_id on leaves.
715
+ */
716
+ async _batchSetReservationId(client, reservationId, leafIds) {
717
+ if (leafIds.length === 0) return;
718
+
719
+ await client.query(
720
+ "UPDATE tree_leaves SET reservation_id = $1 WHERE id = ANY($2)",
721
+ [reservationId, leafIds]
722
+ );
723
+ }
724
+
725
+ /**
726
+ * Batch insert spent leaf markers.
727
+ */
728
+ async _batchInsertSpentLeaves(client, leafIds) {
729
+ if (leafIds.length === 0) return;
730
+
731
+ await client.query(
732
+ "INSERT INTO tree_spent_leaves (leaf_id) SELECT * FROM UNNEST($1::text[]) ON CONFLICT DO NOTHING",
733
+ [leafIds]
734
+ );
735
+ }
736
+
737
+ /**
738
+ * Batch remove spent leaf markers.
739
+ */
740
+ async _batchRemoveSpentLeaves(client, leafIds) {
741
+ if (leafIds.length === 0) return;
742
+
743
+ await client.query(
744
+ "DELETE FROM tree_spent_leaves WHERE leaf_id = ANY($1)",
745
+ [leafIds]
746
+ );
747
+ }
748
+
749
+ /**
750
+ * Clean up stale reservations.
751
+ */
752
+ async _cleanupStaleReservations(client) {
753
+ await client.query(
754
+ `DELETE FROM tree_reservations
755
+ WHERE created_at < NOW() - make_interval(secs => $1)`,
756
+ [RESERVATION_TIMEOUT_SECS]
757
+ );
758
+ }
759
+
760
+ /**
761
+ * Clean up old spent markers.
762
+ */
763
+ async _cleanupSpentMarkers(client, refreshTimestamp) {
764
+ const thresholdMs = SPENT_MARKER_CLEANUP_THRESHOLD_MS;
765
+ const cleanupCutoff = new Date(refreshTimestamp.getTime() - thresholdMs);
766
+
767
+ await client.query(
768
+ "DELETE FROM tree_spent_leaves WHERE spent_at < $1",
769
+ [cleanupCutoff]
770
+ );
771
+ }
772
+ }
773
+
774
+ /**
775
+ * Create a PostgresTreeStore instance from a config object.
776
+ *
777
+ * @param {object} config - PostgreSQL configuration
778
+ * @param {string} config.connectionString - PostgreSQL connection string
779
+ * @param {number} config.maxPoolSize - Maximum number of connections in the pool
780
+ * @param {number} config.createTimeoutSecs - Timeout in seconds for establishing a new connection
781
+ * @param {number} config.recycleTimeoutSecs - Timeout in seconds before recycling an idle connection
782
+ * @param {object} [logger] - Optional logger
783
+ * @returns {Promise<PostgresTreeStore>}
784
+ */
785
+ async function createPostgresTreeStore(config, logger = null) {
786
+ const pool = new pg.Pool({
787
+ connectionString: config.connectionString,
788
+ max: config.maxPoolSize,
789
+ connectionTimeoutMillis: config.createTimeoutSecs * 1000,
790
+ idleTimeoutMillis: config.recycleTimeoutSecs * 1000,
791
+ });
792
+
793
+ const store = new PostgresTreeStore(pool, logger);
794
+ await store.initialize();
795
+ return store;
796
+ }
797
+
798
+ module.exports = { PostgresTreeStore, createPostgresTreeStore, TreeStoreError };