@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.
- package/breez-sdk-spark.tgz +0 -0
- package/bundler/breez_sdk_spark_wasm.d.ts +33 -0
- package/bundler/breez_sdk_spark_wasm.js +1 -1
- package/bundler/breez_sdk_spark_wasm_bg.js +66 -24
- package/bundler/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/bundler/breez_sdk_spark_wasm_bg.wasm.d.ts +7 -5
- package/deno/breez_sdk_spark_wasm.d.ts +33 -0
- package/deno/breez_sdk_spark_wasm.js +66 -24
- package/deno/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/deno/breez_sdk_spark_wasm_bg.wasm.d.ts +7 -5
- package/nodejs/breez_sdk_spark_wasm.d.ts +33 -0
- package/nodejs/breez_sdk_spark_wasm.js +67 -24
- package/nodejs/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/nodejs/breez_sdk_spark_wasm_bg.wasm.d.ts +7 -5
- package/nodejs/index.js +34 -0
- package/nodejs/index.mjs +1 -0
- package/nodejs/mysql-storage/errors.cjs +19 -0
- package/nodejs/mysql-storage/index.cjs +1366 -0
- package/nodejs/mysql-storage/migrations.cjs +387 -0
- package/nodejs/mysql-storage/package.json +9 -0
- package/nodejs/mysql-token-store/errors.cjs +9 -0
- package/nodejs/mysql-token-store/index.cjs +988 -0
- package/nodejs/mysql-token-store/migrations.cjs +255 -0
- package/nodejs/mysql-token-store/package.json +9 -0
- package/nodejs/mysql-tree-store/errors.cjs +9 -0
- package/nodejs/mysql-tree-store/index.cjs +939 -0
- package/nodejs/mysql-tree-store/migrations.cjs +221 -0
- package/nodejs/mysql-tree-store/package.json +9 -0
- package/nodejs/package.json +3 -0
- package/nodejs/postgres-storage/index.cjs +147 -92
- package/nodejs/postgres-storage/migrations.cjs +85 -4
- package/nodejs/postgres-token-store/index.cjs +176 -89
- package/nodejs/postgres-token-store/migrations.cjs +92 -3
- package/nodejs/postgres-tree-store/index.cjs +168 -83
- package/nodejs/postgres-tree-store/migrations.cjs +80 -3
- package/package.json +1 -1
- package/ssr/index.js +5 -0
- package/web/breez_sdk_spark_wasm.d.ts +40 -5
- package/web/breez_sdk_spark_wasm.js +66 -24
- package/web/breez_sdk_spark_wasm_bg.wasm +0 -0
- 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
|
+
};
|