@breeztech/breez-sdk-spark 0.13.10-dev → 0.13.12-dev1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/breez-sdk-spark.tgz +0 -0
- package/bundler/breez_sdk_spark_wasm.d.ts +157 -0
- package/bundler/breez_sdk_spark_wasm.js +1 -1
- package/bundler/breez_sdk_spark_wasm_bg.js +419 -41
- package/bundler/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/bundler/breez_sdk_spark_wasm_bg.wasm.d.ts +27 -7
- package/deno/breez_sdk_spark_wasm.d.ts +157 -0
- package/deno/breez_sdk_spark_wasm.js +419 -41
- package/deno/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/deno/breez_sdk_spark_wasm_bg.wasm.d.ts +27 -7
- package/nodejs/breez_sdk_spark_wasm.d.ts +157 -0
- package/nodejs/breez_sdk_spark_wasm.js +428 -41
- package/nodejs/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/nodejs/breez_sdk_spark_wasm_bg.wasm.d.ts +27 -7
- package/nodejs/index.js +56 -0
- package/nodejs/index.mjs +9 -0
- package/nodejs/mysql-session-manager/errors.cjs +13 -0
- package/nodejs/mysql-session-manager/index.cjs +144 -0
- package/nodejs/mysql-session-manager/migrations.cjs +102 -0
- package/nodejs/mysql-session-manager/package.json +9 -0
- package/nodejs/mysql-storage/errors.cjs +19 -0
- package/nodejs/mysql-storage/index.cjs +1367 -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 +980 -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 +941 -0
- package/nodejs/mysql-tree-store/migrations.cjs +221 -0
- package/nodejs/mysql-tree-store/package.json +9 -0
- package/nodejs/package.json +5 -0
- package/nodejs/postgres-session-manager/errors.cjs +13 -0
- package/nodejs/postgres-session-manager/index.cjs +165 -0
- package/nodejs/postgres-session-manager/migrations.cjs +126 -0
- package/nodejs/postgres-session-manager/package.json +9 -0
- package/nodejs/postgres-storage/index.cjs +147 -92
- package/nodejs/postgres-storage/migrations.cjs +85 -4
- package/nodejs/postgres-token-store/index.cjs +178 -102
- 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 +53 -0
- package/web/breez_sdk_spark_wasm.d.ts +184 -7
- package/web/breez_sdk_spark_wasm.js +419 -41
- package/web/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/web/breez_sdk_spark_wasm_bg.wasm.d.ts +27 -7
|
@@ -22,8 +22,16 @@ class TokenStoreMigrationManager {
|
|
|
22
22
|
/**
|
|
23
23
|
* Run all pending migrations inside a single transaction with an advisory lock.
|
|
24
24
|
* @param {import('pg').Pool} pool
|
|
25
|
+
* @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey
|
|
26
|
+
* identifying the tenant. Used to backfill `user_id` columns in the
|
|
27
|
+
* multi-tenant scoping migration. Required.
|
|
25
28
|
*/
|
|
26
|
-
async migrate(pool) {
|
|
29
|
+
async migrate(pool, identity) {
|
|
30
|
+
if (!identity || identity.length !== 33) {
|
|
31
|
+
throw new TokenStoreError(
|
|
32
|
+
"tenant identity (33-byte secp256k1 pubkey) is required"
|
|
33
|
+
);
|
|
34
|
+
}
|
|
27
35
|
const client = await pool.connect();
|
|
28
36
|
try {
|
|
29
37
|
await client.query("BEGIN");
|
|
@@ -45,7 +53,7 @@ class TokenStoreMigrationManager {
|
|
|
45
53
|
);
|
|
46
54
|
const currentVersion = versionResult.rows[0].version;
|
|
47
55
|
|
|
48
|
-
const migrations = this._getMigrations();
|
|
56
|
+
const migrations = this._getMigrations(identity);
|
|
49
57
|
|
|
50
58
|
if (currentVersion >= migrations.length) {
|
|
51
59
|
this._log("info", `Token store database is up to date (version ${currentVersion})`);
|
|
@@ -96,8 +104,16 @@ class TokenStoreMigrationManager {
|
|
|
96
104
|
|
|
97
105
|
/**
|
|
98
106
|
* Migrations matching the Rust PostgresTokenStore schema exactly.
|
|
107
|
+
*
|
|
108
|
+
* @param {Buffer|Uint8Array} identity - tenant identity inlined as a hex
|
|
109
|
+
* BYTEA literal in the multi-tenant scoping migration. Safe because the
|
|
110
|
+
* bytes come from a typed secp256k1 pubkey (`[0-9a-f]{66}` after hex
|
|
111
|
+
* encoding) — not user-controlled input.
|
|
99
112
|
*/
|
|
100
|
-
_getMigrations() {
|
|
113
|
+
_getMigrations(identity) {
|
|
114
|
+
const idHex = Buffer.from(identity).toString("hex");
|
|
115
|
+
const idLit = `'\\x${idHex}'::bytea`;
|
|
116
|
+
|
|
101
117
|
return [
|
|
102
118
|
{
|
|
103
119
|
name: "Create token store tables with race condition protection",
|
|
@@ -156,6 +172,79 @@ class TokenStoreMigrationManager {
|
|
|
156
172
|
`INSERT INTO token_swap_status (id) VALUES (1) ON CONFLICT DO NOTHING`,
|
|
157
173
|
],
|
|
158
174
|
},
|
|
175
|
+
{
|
|
176
|
+
// Mirrors Rust migration 2 in spark-postgres/src/token_store.rs.
|
|
177
|
+
// Adds user_id to every token-store table (including token_metadata —
|
|
178
|
+
// per-tenant to avoid 0-balance leakage for tokens a tenant never
|
|
179
|
+
// owned), backfills with the connecting tenant, and rewrites primary
|
|
180
|
+
// keys / FKs / indexes to lead with user_id. Composite FKs use NO
|
|
181
|
+
// ACTION because column-list SET NULL is PG15+ and a whole-row SET
|
|
182
|
+
// NULL would null user_id (NOT NULL).
|
|
183
|
+
name: "Multi-tenant scoping: add user_id and rewrite primary keys",
|
|
184
|
+
sql: [
|
|
185
|
+
// Drop dependent FKs FIRST so we can rebuild parent PKs they
|
|
186
|
+
// reference. Inline `REFERENCES` clauses get auto-named
|
|
187
|
+
// `<table>_<column>_fkey`.
|
|
188
|
+
`ALTER TABLE token_outputs
|
|
189
|
+
DROP CONSTRAINT IF EXISTS token_outputs_reservation_id_fkey`,
|
|
190
|
+
`ALTER TABLE token_outputs
|
|
191
|
+
DROP CONSTRAINT IF EXISTS token_outputs_token_identifier_fkey`,
|
|
192
|
+
|
|
193
|
+
// token_metadata: per-tenant scoping (privacy — see header).
|
|
194
|
+
`ALTER TABLE token_metadata ADD COLUMN user_id BYTEA`,
|
|
195
|
+
`UPDATE token_metadata SET user_id = ${idLit}`,
|
|
196
|
+
`ALTER TABLE token_metadata
|
|
197
|
+
ALTER COLUMN user_id SET NOT NULL,
|
|
198
|
+
DROP CONSTRAINT IF EXISTS token_metadata_pkey,
|
|
199
|
+
ADD PRIMARY KEY (user_id, identifier)`,
|
|
200
|
+
`DROP INDEX IF EXISTS idx_token_metadata_issuer_pk`,
|
|
201
|
+
`CREATE INDEX idx_token_metadata_user_issuer_pk
|
|
202
|
+
ON token_metadata (user_id, issuer_public_key)`,
|
|
203
|
+
|
|
204
|
+
// token_reservations: scope by user_id.
|
|
205
|
+
`ALTER TABLE token_reservations ADD COLUMN user_id BYTEA`,
|
|
206
|
+
`UPDATE token_reservations SET user_id = ${idLit}`,
|
|
207
|
+
`ALTER TABLE token_reservations
|
|
208
|
+
ALTER COLUMN user_id SET NOT NULL,
|
|
209
|
+
DROP CONSTRAINT IF EXISTS token_reservations_pkey,
|
|
210
|
+
ADD PRIMARY KEY (user_id, id)`,
|
|
211
|
+
|
|
212
|
+
// token_outputs: scope by user_id, rekey, re-add composite FKs.
|
|
213
|
+
`ALTER TABLE token_outputs ADD COLUMN user_id BYTEA`,
|
|
214
|
+
`UPDATE token_outputs SET user_id = ${idLit}`,
|
|
215
|
+
`ALTER TABLE token_outputs
|
|
216
|
+
ALTER COLUMN user_id SET NOT NULL,
|
|
217
|
+
DROP CONSTRAINT IF EXISTS token_outputs_pkey,
|
|
218
|
+
ADD PRIMARY KEY (user_id, id),
|
|
219
|
+
ADD FOREIGN KEY (user_id, token_identifier)
|
|
220
|
+
REFERENCES token_metadata(user_id, identifier),
|
|
221
|
+
ADD FOREIGN KEY (user_id, reservation_id)
|
|
222
|
+
REFERENCES token_reservations(user_id, id)`,
|
|
223
|
+
`DROP INDEX IF EXISTS idx_token_outputs_identifier`,
|
|
224
|
+
`DROP INDEX IF EXISTS idx_token_outputs_reservation`,
|
|
225
|
+
`CREATE INDEX idx_token_outputs_user_identifier
|
|
226
|
+
ON token_outputs (user_id, token_identifier)`,
|
|
227
|
+
`CREATE INDEX idx_token_outputs_user_reservation
|
|
228
|
+
ON token_outputs (user_id, reservation_id)
|
|
229
|
+
WHERE reservation_id IS NOT NULL`,
|
|
230
|
+
|
|
231
|
+
// token_spent_outputs: scope by user_id.
|
|
232
|
+
`ALTER TABLE token_spent_outputs ADD COLUMN user_id BYTEA`,
|
|
233
|
+
`UPDATE token_spent_outputs SET user_id = ${idLit}`,
|
|
234
|
+
`ALTER TABLE token_spent_outputs
|
|
235
|
+
ALTER COLUMN user_id SET NOT NULL,
|
|
236
|
+
DROP CONSTRAINT IF EXISTS token_spent_outputs_pkey,
|
|
237
|
+
ADD PRIMARY KEY (user_id, output_id)`,
|
|
238
|
+
|
|
239
|
+
// token_swap_status: drop the singleton id, rekey by user_id.
|
|
240
|
+
`ALTER TABLE token_swap_status DROP COLUMN id CASCADE`,
|
|
241
|
+
`ALTER TABLE token_swap_status ADD COLUMN user_id BYTEA`,
|
|
242
|
+
`UPDATE token_swap_status SET user_id = ${idLit}`,
|
|
243
|
+
`ALTER TABLE token_swap_status
|
|
244
|
+
ALTER COLUMN user_id SET NOT NULL,
|
|
245
|
+
ADD PRIMARY KEY (user_id)`,
|
|
246
|
+
],
|
|
247
|
+
},
|
|
159
248
|
];
|
|
160
249
|
}
|
|
161
250
|
}
|
|
@@ -25,10 +25,10 @@ const { TreeStoreError } = require("./errors.cjs");
|
|
|
25
25
|
const { TreeStoreMigrationManager } = require("./migrations.cjs");
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
28
|
+
* Domain prefix mixed into the per-tenant advisory-lock key. Distinct prefixes
|
|
29
|
+
* guarantee that locks from different stores (tree, token, …) never collide.
|
|
30
30
|
*/
|
|
31
|
-
const
|
|
31
|
+
const TREE_STORE_LOCK_PREFIX = "breez-spark-sdk:tree:";
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Timeout for reservations in seconds. Reservations older than this are stale.
|
|
@@ -40,9 +40,37 @@ const RESERVATION_TIMEOUT_SECS = 300; // 5 minutes
|
|
|
40
40
|
*/
|
|
41
41
|
const SPENT_MARKER_CLEANUP_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Derive a stable per-tenant 64-bit advisory-lock key by hashing a domain
|
|
45
|
+
* prefix together with the identity pubkey and folding the first 8 bytes of
|
|
46
|
+
* the SHA-256 digest into a signed big-endian i64 — the type expected by
|
|
47
|
+
* `pg_advisory_xact_lock(bigint)`. The 64-bit space keeps cross-tenant
|
|
48
|
+
* collisions negligible (~1.2e-10 at 65k tenants).
|
|
49
|
+
*/
|
|
50
|
+
function _identityLockKey(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 hash.digest().readBigInt64BE(0);
|
|
56
|
+
}
|
|
57
|
+
|
|
43
58
|
class PostgresTreeStore {
|
|
44
|
-
|
|
59
|
+
/**
|
|
60
|
+
* @param {import('pg').Pool} pool
|
|
61
|
+
* @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey
|
|
62
|
+
* identifying the tenant. All reads and writes are scoped by this.
|
|
63
|
+
* @param {object} [logger]
|
|
64
|
+
*/
|
|
65
|
+
constructor(pool, identity, logger = null) {
|
|
66
|
+
if (!identity || identity.length !== 33) {
|
|
67
|
+
throw new TreeStoreError(
|
|
68
|
+
"tenant identity (33-byte secp256k1 pubkey) is required"
|
|
69
|
+
);
|
|
70
|
+
}
|
|
45
71
|
this.pool = pool;
|
|
72
|
+
this.identity = Buffer.from(identity);
|
|
73
|
+
this.lockKey = _identityLockKey(TREE_STORE_LOCK_PREFIX, identity);
|
|
46
74
|
this.logger = logger;
|
|
47
75
|
}
|
|
48
76
|
|
|
@@ -52,7 +80,7 @@ class PostgresTreeStore {
|
|
|
52
80
|
async initialize() {
|
|
53
81
|
try {
|
|
54
82
|
const migrationManager = new TreeStoreMigrationManager(this.logger);
|
|
55
|
-
await migrationManager.migrate(this.pool);
|
|
83
|
+
await migrationManager.migrate(this.pool, this.identity);
|
|
56
84
|
return this;
|
|
57
85
|
} catch (error) {
|
|
58
86
|
throw new TreeStoreError(
|
|
@@ -84,7 +112,10 @@ class PostgresTreeStore {
|
|
|
84
112
|
const client = await this.pool.connect();
|
|
85
113
|
try {
|
|
86
114
|
await client.query("BEGIN");
|
|
87
|
-
|
|
115
|
+
// Per-tenant advisory lock: 64-bit key derived from a tree-store domain
|
|
116
|
+
// prefix and the tenant identity, so different tenants don't serialize
|
|
117
|
+
// on each other and tree/token locks never collide.
|
|
118
|
+
await client.query("SELECT pg_advisory_xact_lock($1)", [this.lockKey]);
|
|
88
119
|
const result = await fn(client);
|
|
89
120
|
await client.query("COMMIT");
|
|
90
121
|
return result;
|
|
@@ -160,14 +191,20 @@ class PostgresTreeStore {
|
|
|
160
191
|
*/
|
|
161
192
|
async getAvailableBalance() {
|
|
162
193
|
try {
|
|
163
|
-
const result = await this.pool.query(
|
|
194
|
+
const result = await this.pool.query(
|
|
195
|
+
`
|
|
164
196
|
SELECT COALESCE(SUM((l.data->>'value')::bigint), 0)::bigint AS balance
|
|
165
197
|
FROM tree_leaves l
|
|
166
|
-
LEFT JOIN tree_reservations r
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
198
|
+
LEFT JOIN tree_reservations r
|
|
199
|
+
ON l.reservation_id = r.id AND l.user_id = r.user_id
|
|
200
|
+
WHERE l.user_id = $1
|
|
201
|
+
AND (
|
|
202
|
+
(l.reservation_id IS NULL AND l.status = 'Available')
|
|
203
|
+
OR r.purpose = 'Swap'
|
|
204
|
+
)
|
|
205
|
+
`,
|
|
206
|
+
[this.identity]
|
|
207
|
+
);
|
|
171
208
|
return BigInt(result.rows[0].balance);
|
|
172
209
|
} catch (error) {
|
|
173
210
|
throw new TreeStoreError(
|
|
@@ -179,12 +216,17 @@ class PostgresTreeStore {
|
|
|
179
216
|
|
|
180
217
|
async getLeaves() {
|
|
181
218
|
try {
|
|
182
|
-
const result = await this.pool.query(
|
|
219
|
+
const result = await this.pool.query(
|
|
220
|
+
`
|
|
183
221
|
SELECT l.id, l.status, l.is_missing_from_operators, l.data,
|
|
184
222
|
l.reservation_id, r.purpose
|
|
185
223
|
FROM tree_leaves l
|
|
186
|
-
LEFT JOIN tree_reservations r
|
|
187
|
-
|
|
224
|
+
LEFT JOIN tree_reservations r
|
|
225
|
+
ON l.reservation_id = r.id AND l.user_id = r.user_id
|
|
226
|
+
WHERE l.user_id = $1
|
|
227
|
+
`,
|
|
228
|
+
[this.identity]
|
|
229
|
+
);
|
|
188
230
|
|
|
189
231
|
const available = [];
|
|
190
232
|
const notAvailable = [];
|
|
@@ -246,11 +288,21 @@ class PostgresTreeStore {
|
|
|
246
288
|
await this._cleanupStaleReservations(client);
|
|
247
289
|
|
|
248
290
|
// Check for active swap or swap completed during refresh
|
|
249
|
-
const swapCheckResult = await client.query(
|
|
291
|
+
const swapCheckResult = await client.query(
|
|
292
|
+
`
|
|
250
293
|
SELECT
|
|
251
|
-
EXISTS(
|
|
252
|
-
|
|
253
|
-
|
|
294
|
+
EXISTS(
|
|
295
|
+
SELECT 1 FROM tree_reservations
|
|
296
|
+
WHERE user_id = $1 AND purpose = 'Swap'
|
|
297
|
+
) AS has_active_swap,
|
|
298
|
+
COALESCE(
|
|
299
|
+
(SELECT last_completed_at >= $2
|
|
300
|
+
FROM tree_swap_status WHERE user_id = $1),
|
|
301
|
+
FALSE
|
|
302
|
+
) AS swap_completed_during_refresh
|
|
303
|
+
`,
|
|
304
|
+
[this.identity, refreshTimestamp]
|
|
305
|
+
);
|
|
254
306
|
|
|
255
307
|
const { has_active_swap, swap_completed_during_refresh } = swapCheckResult.rows[0];
|
|
256
308
|
|
|
@@ -262,18 +314,18 @@ class PostgresTreeStore {
|
|
|
262
314
|
await this._cleanupSpentMarkers(client, refreshTimestamp);
|
|
263
315
|
|
|
264
316
|
const spentResult = await client.query(
|
|
265
|
-
"SELECT leaf_id FROM tree_spent_leaves WHERE spent_at >= $
|
|
266
|
-
[refreshTimestamp]
|
|
317
|
+
"SELECT leaf_id FROM tree_spent_leaves WHERE user_id = $1 AND spent_at >= $2",
|
|
318
|
+
[this.identity, refreshTimestamp]
|
|
267
319
|
);
|
|
268
320
|
const spentIds = new Set(spentResult.rows.map((r) => r.leaf_id));
|
|
269
321
|
|
|
270
322
|
// Delete non-reserved leaves added before refresh started.
|
|
271
|
-
// Includes leaves released earlier in this transaction by
|
|
272
|
-
// (
|
|
273
|
-
//
|
|
323
|
+
// Includes leaves released earlier in this transaction by
|
|
324
|
+
// _cleanupStaleReservations (which now NULLs reservation_id explicitly,
|
|
325
|
+
// since the composite FK uses NO ACTION).
|
|
274
326
|
await client.query(
|
|
275
|
-
"DELETE FROM tree_leaves WHERE reservation_id IS NULL AND added_at < $
|
|
276
|
-
[refreshTimestamp]
|
|
327
|
+
"DELETE FROM tree_leaves WHERE user_id = $1 AND reservation_id IS NULL AND added_at < $2",
|
|
328
|
+
[this.identity, refreshTimestamp]
|
|
277
329
|
);
|
|
278
330
|
|
|
279
331
|
// Upsert all leaves (filtering spent)
|
|
@@ -306,8 +358,8 @@ class PostgresTreeStore {
|
|
|
306
358
|
try {
|
|
307
359
|
await this._withTransaction(async (client) => {
|
|
308
360
|
const res = await client.query(
|
|
309
|
-
"SELECT id FROM tree_reservations WHERE id = $
|
|
310
|
-
[id]
|
|
361
|
+
"SELECT id FROM tree_reservations WHERE user_id = $1 AND id = $2",
|
|
362
|
+
[this.identity, id]
|
|
311
363
|
);
|
|
312
364
|
|
|
313
365
|
if (res.rows.length === 0) {
|
|
@@ -315,13 +367,13 @@ class PostgresTreeStore {
|
|
|
315
367
|
}
|
|
316
368
|
|
|
317
369
|
await client.query(
|
|
318
|
-
"DELETE FROM tree_leaves WHERE reservation_id = $
|
|
319
|
-
[id]
|
|
370
|
+
"DELETE FROM tree_leaves WHERE user_id = $1 AND reservation_id = $2",
|
|
371
|
+
[this.identity, id]
|
|
320
372
|
);
|
|
321
373
|
|
|
322
374
|
await client.query(
|
|
323
|
-
"DELETE FROM tree_reservations WHERE id = $
|
|
324
|
-
[id]
|
|
375
|
+
"DELETE FROM tree_reservations WHERE user_id = $1 AND id = $2",
|
|
376
|
+
[this.identity, id]
|
|
325
377
|
);
|
|
326
378
|
|
|
327
379
|
if (leavesToKeep && leavesToKeep.length > 0) {
|
|
@@ -351,8 +403,8 @@ class PostgresTreeStore {
|
|
|
351
403
|
await this._withWriteTransaction(async (client) => {
|
|
352
404
|
// Check if reservation exists and get purpose
|
|
353
405
|
const res = await client.query(
|
|
354
|
-
"SELECT id, purpose FROM tree_reservations WHERE id = $
|
|
355
|
-
[id]
|
|
406
|
+
"SELECT id, purpose FROM tree_reservations WHERE user_id = $1 AND id = $2",
|
|
407
|
+
[this.identity, id]
|
|
356
408
|
);
|
|
357
409
|
|
|
358
410
|
let isSwap = false;
|
|
@@ -360,18 +412,18 @@ class PostgresTreeStore {
|
|
|
360
412
|
if (res.rows.length > 0) {
|
|
361
413
|
isSwap = res.rows[0].purpose === "Swap";
|
|
362
414
|
const leafResult = await client.query(
|
|
363
|
-
"SELECT id FROM tree_leaves WHERE reservation_id = $
|
|
364
|
-
[id]
|
|
415
|
+
"SELECT id FROM tree_leaves WHERE user_id = $1 AND reservation_id = $2",
|
|
416
|
+
[this.identity, id]
|
|
365
417
|
);
|
|
366
418
|
reservedLeafIds = leafResult.rows.map((r) => r.id);
|
|
367
419
|
await this._batchInsertSpentLeaves(client, reservedLeafIds);
|
|
368
420
|
await client.query(
|
|
369
|
-
"DELETE FROM tree_leaves WHERE reservation_id = $
|
|
370
|
-
[id]
|
|
421
|
+
"DELETE FROM tree_leaves WHERE user_id = $1 AND reservation_id = $2",
|
|
422
|
+
[this.identity, id]
|
|
371
423
|
);
|
|
372
424
|
await client.query(
|
|
373
|
-
"DELETE FROM tree_reservations WHERE id = $
|
|
374
|
-
[id]
|
|
425
|
+
"DELETE FROM tree_reservations WHERE user_id = $1 AND id = $2",
|
|
426
|
+
[this.identity, id]
|
|
375
427
|
);
|
|
376
428
|
}
|
|
377
429
|
|
|
@@ -380,10 +432,15 @@ class PostgresTreeStore {
|
|
|
380
432
|
await this._batchUpsertLeaves(client, newLeaves, false, null);
|
|
381
433
|
}
|
|
382
434
|
|
|
383
|
-
// If swap with new leaves, update last_completed_at
|
|
435
|
+
// If swap with new leaves, update last_completed_at. UPSERT so a tenant
|
|
436
|
+
// that joined after migration 3 (and thus has no row) gets one created.
|
|
384
437
|
if (isSwap && newLeaves && newLeaves.length > 0) {
|
|
385
438
|
await client.query(
|
|
386
|
-
|
|
439
|
+
`INSERT INTO tree_swap_status (user_id, last_completed_at)
|
|
440
|
+
VALUES ($1, NOW())
|
|
441
|
+
ON CONFLICT (user_id) DO UPDATE
|
|
442
|
+
SET last_completed_at = EXCLUDED.last_completed_at`,
|
|
443
|
+
[this.identity]
|
|
387
444
|
);
|
|
388
445
|
}
|
|
389
446
|
});
|
|
@@ -412,13 +469,17 @@ class PostgresTreeStore {
|
|
|
412
469
|
// True total available, computed server-side over ALL eligible leaves.
|
|
413
470
|
// Required for the WaitForPending decision below — must NOT be derived
|
|
414
471
|
// from the prefiltered set since the prefilter may exclude big leaves.
|
|
415
|
-
const totalResult = await client.query(
|
|
472
|
+
const totalResult = await client.query(
|
|
473
|
+
`
|
|
416
474
|
SELECT COALESCE(SUM((data->>'value')::bigint), 0)::bigint AS total
|
|
417
475
|
FROM tree_leaves
|
|
418
|
-
WHERE
|
|
476
|
+
WHERE user_id = $1
|
|
477
|
+
AND status = 'Available'
|
|
419
478
|
AND is_missing_from_operators = FALSE
|
|
420
479
|
AND reservation_id IS NULL
|
|
421
|
-
|
|
480
|
+
`,
|
|
481
|
+
[this.identity]
|
|
482
|
+
);
|
|
422
483
|
const available = Number(totalResult.rows[0].total);
|
|
423
484
|
|
|
424
485
|
// Slim projection: only (id, value) for leaves the selection might use.
|
|
@@ -426,25 +487,30 @@ class PostgresTreeStore {
|
|
|
426
487
|
// small-leaf accumulators for the minimum-amount path) plus the single
|
|
427
488
|
// smallest leaf with value > maxTarget (covers the minimum-amount
|
|
428
489
|
// fallback case where one larger leaf is sufficient).
|
|
429
|
-
const slimResult = await client.query(
|
|
490
|
+
const slimResult = await client.query(
|
|
491
|
+
`
|
|
430
492
|
SELECT id, (data->>'value')::bigint AS value
|
|
431
493
|
FROM tree_leaves
|
|
432
|
-
WHERE
|
|
494
|
+
WHERE user_id = $1
|
|
495
|
+
AND status = 'Available'
|
|
433
496
|
AND is_missing_from_operators = FALSE
|
|
434
497
|
AND reservation_id IS NULL
|
|
435
498
|
AND (
|
|
436
|
-
(data->>'value')::bigint <= $
|
|
499
|
+
(data->>'value')::bigint <= $2
|
|
437
500
|
OR id = (
|
|
438
501
|
SELECT id FROM tree_leaves
|
|
439
|
-
WHERE
|
|
502
|
+
WHERE user_id = $1
|
|
503
|
+
AND status = 'Available'
|
|
440
504
|
AND is_missing_from_operators = FALSE
|
|
441
505
|
AND reservation_id IS NULL
|
|
442
|
-
AND (data->>'value')::bigint > $
|
|
506
|
+
AND (data->>'value')::bigint > $2
|
|
443
507
|
ORDER BY (data->>'value')::bigint
|
|
444
508
|
LIMIT 1
|
|
445
509
|
)
|
|
446
510
|
)
|
|
447
|
-
`,
|
|
511
|
+
`,
|
|
512
|
+
[this.identity, maxTarget]
|
|
513
|
+
);
|
|
448
514
|
|
|
449
515
|
const slimLeaves = slimResult.rows.map((r) => ({
|
|
450
516
|
id: r.id,
|
|
@@ -543,8 +609,8 @@ class PostgresTreeStore {
|
|
|
543
609
|
async _fetchFullLeavesByIds(client, ids) {
|
|
544
610
|
if (!ids || ids.length === 0) return [];
|
|
545
611
|
const result = await client.query(
|
|
546
|
-
"SELECT data FROM tree_leaves WHERE id = ANY($1)",
|
|
547
|
-
[ids]
|
|
612
|
+
"SELECT data FROM tree_leaves WHERE user_id = $2 AND id = ANY($1)",
|
|
613
|
+
[ids, this.identity]
|
|
548
614
|
);
|
|
549
615
|
return result.rows.map((r) => r.data);
|
|
550
616
|
}
|
|
@@ -577,8 +643,8 @@ class PostgresTreeStore {
|
|
|
577
643
|
return await this._withTransaction(async (client) => {
|
|
578
644
|
// Check if reservation exists
|
|
579
645
|
const res = await client.query(
|
|
580
|
-
"SELECT id FROM tree_reservations WHERE id = $
|
|
581
|
-
[reservationId]
|
|
646
|
+
"SELECT id FROM tree_reservations WHERE user_id = $1 AND id = $2",
|
|
647
|
+
[this.identity, reservationId]
|
|
582
648
|
);
|
|
583
649
|
|
|
584
650
|
if (res.rows.length === 0) {
|
|
@@ -587,15 +653,15 @@ class PostgresTreeStore {
|
|
|
587
653
|
|
|
588
654
|
// Get old reserved leaf IDs and mark as spent
|
|
589
655
|
const oldLeavesResult = await client.query(
|
|
590
|
-
"SELECT id FROM tree_leaves WHERE reservation_id = $
|
|
591
|
-
[reservationId]
|
|
656
|
+
"SELECT id FROM tree_leaves WHERE user_id = $1 AND reservation_id = $2",
|
|
657
|
+
[this.identity, reservationId]
|
|
592
658
|
);
|
|
593
659
|
const oldLeafIds = oldLeavesResult.rows.map((r) => r.id);
|
|
594
660
|
|
|
595
661
|
await this._batchInsertSpentLeaves(client, oldLeafIds);
|
|
596
662
|
await client.query(
|
|
597
|
-
"DELETE FROM tree_leaves WHERE reservation_id = $
|
|
598
|
-
[reservationId]
|
|
663
|
+
"DELETE FROM tree_leaves WHERE user_id = $1 AND reservation_id = $2",
|
|
664
|
+
[this.identity, reservationId]
|
|
599
665
|
);
|
|
600
666
|
|
|
601
667
|
// Upsert change leaves to available pool
|
|
@@ -610,8 +676,8 @@ class PostgresTreeStore {
|
|
|
610
676
|
|
|
611
677
|
// Clear pending change amount
|
|
612
678
|
await client.query(
|
|
613
|
-
"UPDATE tree_reservations SET pending_change_amount = 0 WHERE id = $
|
|
614
|
-
[reservationId]
|
|
679
|
+
"UPDATE tree_reservations SET pending_change_amount = 0 WHERE user_id = $1 AND id = $2",
|
|
680
|
+
[this.identity, reservationId]
|
|
615
681
|
);
|
|
616
682
|
|
|
617
683
|
return {
|
|
@@ -793,7 +859,8 @@ class PostgresTreeStore {
|
|
|
793
859
|
*/
|
|
794
860
|
async _calculatePendingBalance(client) {
|
|
795
861
|
const result = await client.query(
|
|
796
|
-
"SELECT COALESCE(SUM(pending_change_amount), 0)::BIGINT AS pending FROM tree_reservations"
|
|
862
|
+
"SELECT COALESCE(SUM(pending_change_amount), 0)::BIGINT AS pending FROM tree_reservations WHERE user_id = $1",
|
|
863
|
+
[this.identity]
|
|
797
864
|
);
|
|
798
865
|
return Number(result.rows[0].pending);
|
|
799
866
|
}
|
|
@@ -803,8 +870,8 @@ class PostgresTreeStore {
|
|
|
803
870
|
*/
|
|
804
871
|
async _createReservation(client, reservationId, leaves, purpose, pendingChange) {
|
|
805
872
|
await client.query(
|
|
806
|
-
"INSERT INTO tree_reservations (id, purpose, pending_change_amount) VALUES ($1, $2, $3)",
|
|
807
|
-
[reservationId, purpose, pendingChange]
|
|
873
|
+
"INSERT INTO tree_reservations (user_id, id, purpose, pending_change_amount) VALUES ($1, $2, $3, $4)",
|
|
874
|
+
[this.identity, reservationId, purpose, pendingChange]
|
|
808
875
|
);
|
|
809
876
|
|
|
810
877
|
const leafIds = leaves.map((l) => l.id);
|
|
@@ -829,16 +896,16 @@ class PostgresTreeStore {
|
|
|
829
896
|
const dataValues = filtered.map((l) => JSON.stringify(l));
|
|
830
897
|
|
|
831
898
|
await client.query(
|
|
832
|
-
`INSERT INTO tree_leaves (id, status, is_missing_from_operators, data, added_at)
|
|
833
|
-
SELECT id, status, missing, data::jsonb, NOW()
|
|
899
|
+
`INSERT INTO tree_leaves (user_id, id, status, is_missing_from_operators, data, added_at)
|
|
900
|
+
SELECT $5, id, status, missing, data::jsonb, NOW()
|
|
834
901
|
FROM UNNEST($1::text[], $2::text[], $3::bool[], $4::text[])
|
|
835
902
|
AS t(id, status, missing, data)
|
|
836
|
-
ON CONFLICT (id) DO UPDATE SET
|
|
903
|
+
ON CONFLICT (user_id, id) DO UPDATE SET
|
|
837
904
|
status = EXCLUDED.status,
|
|
838
905
|
is_missing_from_operators = EXCLUDED.is_missing_from_operators,
|
|
839
906
|
data = EXCLUDED.data,
|
|
840
907
|
added_at = NOW()`,
|
|
841
|
-
[ids, statuses, missingFlags, dataValues]
|
|
908
|
+
[ids, statuses, missingFlags, dataValues, this.identity]
|
|
842
909
|
);
|
|
843
910
|
}
|
|
844
911
|
|
|
@@ -849,8 +916,8 @@ class PostgresTreeStore {
|
|
|
849
916
|
if (leafIds.length === 0) return;
|
|
850
917
|
|
|
851
918
|
await client.query(
|
|
852
|
-
"UPDATE tree_leaves SET reservation_id = $1 WHERE id = ANY($2)",
|
|
853
|
-
[reservationId, leafIds]
|
|
919
|
+
"UPDATE tree_leaves SET reservation_id = $1 WHERE user_id = $3 AND id = ANY($2)",
|
|
920
|
+
[reservationId, leafIds, this.identity]
|
|
854
921
|
);
|
|
855
922
|
}
|
|
856
923
|
|
|
@@ -861,8 +928,10 @@ class PostgresTreeStore {
|
|
|
861
928
|
if (leafIds.length === 0) return;
|
|
862
929
|
|
|
863
930
|
await client.query(
|
|
864
|
-
|
|
865
|
-
|
|
931
|
+
`INSERT INTO tree_spent_leaves (user_id, leaf_id)
|
|
932
|
+
SELECT $2, leaf_id FROM UNNEST($1::text[]) AS t(leaf_id)
|
|
933
|
+
ON CONFLICT DO NOTHING`,
|
|
934
|
+
[leafIds, this.identity]
|
|
866
935
|
);
|
|
867
936
|
}
|
|
868
937
|
|
|
@@ -873,19 +942,33 @@ class PostgresTreeStore {
|
|
|
873
942
|
if (leafIds.length === 0) return;
|
|
874
943
|
|
|
875
944
|
await client.query(
|
|
876
|
-
"DELETE FROM tree_spent_leaves WHERE leaf_id = ANY($1)",
|
|
877
|
-
[leafIds]
|
|
945
|
+
"DELETE FROM tree_spent_leaves WHERE user_id = $2 AND leaf_id = ANY($1)",
|
|
946
|
+
[leafIds, this.identity]
|
|
878
947
|
);
|
|
879
948
|
}
|
|
880
949
|
|
|
881
950
|
/**
|
|
882
|
-
* Clean up stale reservations.
|
|
951
|
+
* Clean up stale reservations. Releases the leaves by clearing their
|
|
952
|
+
* reservation_id first, then deletes the parent reservations — the composite
|
|
953
|
+
* FK uses NO ACTION (the default) since column-list SET NULL is PG15+ and a
|
|
954
|
+
* whole-row SET NULL would null user_id (NOT NULL).
|
|
883
955
|
*/
|
|
884
956
|
async _cleanupStaleReservations(client) {
|
|
957
|
+
await client.query(
|
|
958
|
+
`UPDATE tree_leaves SET reservation_id = NULL
|
|
959
|
+
WHERE user_id = $2
|
|
960
|
+
AND reservation_id IN (
|
|
961
|
+
SELECT id FROM tree_reservations
|
|
962
|
+
WHERE user_id = $2
|
|
963
|
+
AND created_at < NOW() - make_interval(secs => $1)
|
|
964
|
+
)`,
|
|
965
|
+
[RESERVATION_TIMEOUT_SECS, this.identity]
|
|
966
|
+
);
|
|
885
967
|
await client.query(
|
|
886
968
|
`DELETE FROM tree_reservations
|
|
887
|
-
WHERE
|
|
888
|
-
|
|
969
|
+
WHERE user_id = $2
|
|
970
|
+
AND created_at < NOW() - make_interval(secs => $1)`,
|
|
971
|
+
[RESERVATION_TIMEOUT_SECS, this.identity]
|
|
889
972
|
);
|
|
890
973
|
}
|
|
891
974
|
|
|
@@ -897,8 +980,8 @@ class PostgresTreeStore {
|
|
|
897
980
|
const cleanupCutoff = new Date(refreshTimestamp.getTime() - thresholdMs);
|
|
898
981
|
|
|
899
982
|
await client.query(
|
|
900
|
-
"DELETE FROM tree_spent_leaves WHERE spent_at < $1",
|
|
901
|
-
[cleanupCutoff]
|
|
983
|
+
"DELETE FROM tree_spent_leaves WHERE user_id = $2 AND spent_at < $1",
|
|
984
|
+
[cleanupCutoff, this.identity]
|
|
902
985
|
);
|
|
903
986
|
}
|
|
904
987
|
}
|
|
@@ -911,28 +994,30 @@ class PostgresTreeStore {
|
|
|
911
994
|
* @param {number} config.maxPoolSize - Maximum number of connections in the pool
|
|
912
995
|
* @param {number} config.createTimeoutSecs - Timeout in seconds for establishing a new connection
|
|
913
996
|
* @param {number} config.recycleTimeoutSecs - Timeout in seconds before recycling an idle connection
|
|
997
|
+
* @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey scoping reads/writes
|
|
914
998
|
* @param {object} [logger] - Optional logger
|
|
915
999
|
* @returns {Promise<PostgresTreeStore>}
|
|
916
1000
|
*/
|
|
917
|
-
async function createPostgresTreeStore(config, logger = null) {
|
|
1001
|
+
async function createPostgresTreeStore(config, identity, logger = null) {
|
|
918
1002
|
const pool = new pg.Pool({
|
|
919
1003
|
connectionString: config.connectionString,
|
|
920
1004
|
max: config.maxPoolSize,
|
|
921
1005
|
connectionTimeoutMillis: config.createTimeoutSecs * 1000,
|
|
922
1006
|
idleTimeoutMillis: config.recycleTimeoutSecs * 1000,
|
|
923
1007
|
});
|
|
924
|
-
return createPostgresTreeStoreWithPool(pool, logger);
|
|
1008
|
+
return createPostgresTreeStoreWithPool(pool, identity, logger);
|
|
925
1009
|
}
|
|
926
1010
|
|
|
927
1011
|
/**
|
|
928
1012
|
* Create a PostgresTreeStore instance from an existing pg.Pool.
|
|
929
1013
|
*
|
|
930
1014
|
* @param {pg.Pool} pool - An existing connection pool
|
|
1015
|
+
* @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey scoping reads/writes
|
|
931
1016
|
* @param {object} [logger] - Optional logger
|
|
932
1017
|
* @returns {Promise<PostgresTreeStore>}
|
|
933
1018
|
*/
|
|
934
|
-
async function createPostgresTreeStoreWithPool(pool, logger = null) {
|
|
935
|
-
const store = new PostgresTreeStore(pool, logger);
|
|
1019
|
+
async function createPostgresTreeStoreWithPool(pool, identity, logger = null) {
|
|
1020
|
+
const store = new PostgresTreeStore(pool, identity, logger);
|
|
936
1021
|
await store.initialize();
|
|
937
1022
|
return store;
|
|
938
1023
|
}
|