@breeztech/breez-sdk-spark 0.22.3 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/breez-sdk-spark.tgz +0 -0
  2. package/bundler/breez_sdk_spark_wasm.d.ts +65 -5
  3. package/bundler/breez_sdk_spark_wasm_bg.js +66 -27
  4. package/bundler/breez_sdk_spark_wasm_bg.wasm +0 -0
  5. package/bundler/breez_sdk_spark_wasm_bg.wasm.d.ts +9 -6
  6. package/bundler/index.js +15 -2
  7. package/bundler/package.json +5 -0
  8. package/bundler/storage/index.js +6 -0
  9. package/bundler/tree-store/index.js +1518 -0
  10. package/bundler/tree-store/package.json +12 -0
  11. package/deno/breez_sdk_spark_wasm.d.ts +65 -5
  12. package/deno/breez_sdk_spark_wasm.js +66 -27
  13. package/deno/breez_sdk_spark_wasm_bg.wasm +0 -0
  14. package/deno/breez_sdk_spark_wasm_bg.wasm.d.ts +9 -6
  15. package/nodejs/breez_sdk_spark_wasm.d.ts +65 -5
  16. package/nodejs/breez_sdk_spark_wasm.js +66 -27
  17. package/nodejs/breez_sdk_spark_wasm_bg.wasm +0 -0
  18. package/nodejs/breez_sdk_spark_wasm_bg.wasm.d.ts +9 -6
  19. package/nodejs/index.js +11 -0
  20. package/nodejs/mysql-storage/index.cjs +9 -1
  21. package/nodejs/mysql-storage/migrations.cjs +6 -0
  22. package/nodejs/mysql-tree-store/index.cjs +296 -29
  23. package/nodejs/mysql-tree-store/migrations.cjs +198 -34
  24. package/nodejs/package.json +1 -0
  25. package/nodejs/postgres-storage/index.cjs +9 -1
  26. package/nodejs/postgres-storage/migrations.cjs +6 -0
  27. package/nodejs/postgres-tree-store/index.cjs +301 -40
  28. package/nodejs/postgres-tree-store/migrations.cjs +42 -0
  29. package/nodejs/storage/index.cjs +14 -3
  30. package/nodejs/storage/migrations.cjs +6 -0
  31. package/nodejs/tree-store/errors.cjs +13 -0
  32. package/nodejs/tree-store/index.cjs +1185 -0
  33. package/nodejs/tree-store/migrations.cjs +185 -0
  34. package/nodejs/tree-store/package.json +9 -0
  35. package/package.json +1 -1
  36. package/web/breez_sdk_spark_wasm.d.ts +74 -11
  37. package/web/breez_sdk_spark_wasm.js +66 -27
  38. package/web/breez_sdk_spark_wasm_bg.wasm +0 -0
  39. package/web/breez_sdk_spark_wasm_bg.wasm.d.ts +9 -6
  40. package/web/index.js +15 -2
  41. package/web/package.json +5 -0
  42. package/web/storage/index.js +6 -0
  43. package/web/tree-store/index.js +1518 -0
  44. package/web/tree-store/package.json +12 -0
@@ -54,6 +54,17 @@ const SPENT_MARKER_CLEANUP_THRESHOLD_MS = 5 * 60 * 1000;
54
54
  */
55
55
  const LEAF_UPSERT_CHUNK_SIZE = 1000;
56
56
 
57
+ /**
58
+ * Ancestor rows per INSERT when storing exit chains.
59
+ *
60
+ * A wallet-wide chain backfill carries a row per leaf per ancestor, each a JSON
61
+ * blob of up to five transactions. The insert goes through conn.query(), which
62
+ * interpolates its placeholders client-side, so the whole set would otherwise be
63
+ * built in memory as one statement and sent as one packet, against
64
+ * max_allowed_packet.
65
+ */
66
+ const ANCESTOR_INSERT_CHUNK_SIZE = 4096;
67
+
57
68
  /**
58
69
  * Slim projection: only (id, value) for leaves the selection might use.
59
70
  * Includes all leaves with value <= the max target (covers exact-match + the
@@ -114,6 +125,30 @@ function buildPlaceholders(n) {
114
125
  return new Array(n).fill("?").join(", ");
115
126
  }
116
127
 
128
+ /**
129
+ * Pair a leaf with its ancestors (nearest first) by walking `parent_node_id`
130
+ * through `nodes`. Returns null if the leaf itself is absent; stops at a gap or
131
+ * cycle, returning a partial chain.
132
+ * @param {Map<string, object>} nodes
133
+ * @param {string} leafId
134
+ * @returns {{leaf: object, ancestors: Array<object>}|null}
135
+ */
136
+ function assembleExitChain(nodes, leafId) {
137
+ const leaf = nodes.get(leafId);
138
+ if (!leaf) return null;
139
+ const ancestors = [];
140
+ const visited = new Set([leafId]);
141
+ let current = leaf.parent_node_id;
142
+ while (current != null && !visited.has(current)) {
143
+ visited.add(current);
144
+ const node = nodes.get(current);
145
+ if (!node) break;
146
+ ancestors.push(node);
147
+ current = node.parent_node_id;
148
+ }
149
+ return { leaf, ancestors };
150
+ }
151
+
117
152
  class MysqlTreeStore {
118
153
  /**
119
154
  * @param {import('mysql2/promise').Pool} pool
@@ -172,8 +207,8 @@ class MysqlTreeStore {
172
207
 
173
208
  /**
174
209
  * Run a function inside a transaction, holding the named write lock for the
175
- * duration. Reserved for operations whose correctness depends on serializing
176
- * the available-leaf set (`tryReserveLeaves`, `setLeaves`).
210
+ * duration. Used by every operation that mutates the leaf set or its
211
+ * reservations.
177
212
  * @param {function(import('mysql2/promise').PoolConnection): Promise<T>} fn
178
213
  * @returns {Promise<T>}
179
214
  * @template T
@@ -213,9 +248,8 @@ class MysqlTreeStore {
213
248
 
214
249
  /**
215
250
  * Run a function inside a transaction without the advisory lock. Used by
216
- * operations scoped to a single reservation_id (`addLeaves`,
217
- * `cancelReservation`, `updateReservation`) where row-level FK + InnoDB MVCC
218
- * suffice and the global lock would only add contention.
251
+ * `addLeaves` and by read-only queries (`trySelectLeaves`), where row-level
252
+ * FK + InnoDB MVCC suffice and the global lock would only add contention.
219
253
  * @param {function(import('mysql2/promise').PoolConnection): Promise<T>} fn
220
254
  * @returns {Promise<T>}
221
255
  * @template T
@@ -244,10 +278,11 @@ class MysqlTreeStore {
244
278
  return;
245
279
  }
246
280
 
281
+ const leafNodes = leaves;
247
282
  await this._withTransaction(async (conn) => {
248
- const leafIds = leaves.map((l) => l.id);
283
+ const leafIds = leafNodes.map((l) => l.id);
249
284
  await this._batchRemoveSpentLeaves(conn, leafIds);
250
- await this._batchUpsertLeaves(conn, leaves, false, null);
285
+ await this._batchUpsertLeaves(conn, leafNodes, false, null);
251
286
  });
252
287
  } catch (error) {
253
288
  if (error instanceof TreeStoreError) throw error;
@@ -258,6 +293,123 @@ class MysqlTreeStore {
258
293
  }
259
294
  }
260
295
 
296
+ /**
297
+ * Store the ancestor chain of each pedigree, leaving the leaf pool and any
298
+ * spent marker untouched.
299
+ * @param {Array} pedigrees - Array of LeafPedigree { leaf, ancestors }
300
+ */
301
+ async storeAncestors(pedigrees) {
302
+ try {
303
+ if (!pedigrees || pedigrees.length === 0) {
304
+ return;
305
+ }
306
+
307
+ await this._withWriteTransaction(async (conn) => {
308
+ // A leaf can be spent between its chain being resolved and this write, and
309
+ // a chain is only ever removed with its leaf. Writing one for a leaf that is
310
+ // already gone would leave it behind for good.
311
+ const placeholders = buildPlaceholders(pedigrees.length);
312
+ const [storedRows] = await conn.query(
313
+ `SELECT id FROM brz_tree_leaves WHERE user_id = ? AND id IN (${placeholders})`,
314
+ [this.identity, ...pedigrees.map((p) => p.leaf.id)]
315
+ );
316
+ const storedLeafIds = new Set(storedRows.map((row) => row.id));
317
+
318
+ await this._batchUpsertAncestors(
319
+ conn,
320
+ pedigrees.filter((p) => storedLeafIds.has(p.leaf.id))
321
+ );
322
+ });
323
+ } catch (error) {
324
+ if (error instanceof TreeStoreError) throw error;
325
+ throw new TreeStoreError(
326
+ `Failed to store ancestors: ${error.message}`,
327
+ error
328
+ );
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Ids of the stored leaves whose chain cannot back an exit: the leaf has a
334
+ * parent, and no ancestor row of its own holds that parent.
335
+ * @returns {Promise<Array<string>>}
336
+ */
337
+ async leavesMissingExitChains() {
338
+ try {
339
+ // A stored chain runs from its leaf's parent to a root, so a leaf whose
340
+ // chain holds the parent it has now is exitable. The join binds all three
341
+ // primary key columns, making it one index probe per leaf. A leaf that is
342
+ // itself a root needs no chain.
343
+ const [rows] = await this.pool.query(
344
+ `SELECT l.id
345
+ FROM brz_tree_leaves l
346
+ LEFT JOIN brz_tree_ancestors link
347
+ ON link.user_id = l.user_id AND link.leaf_id = l.id
348
+ AND link.id = l.parent_node_id
349
+ WHERE l.user_id = ?
350
+ AND l.parent_node_id IS NOT NULL
351
+ AND link.leaf_id IS NULL`,
352
+ [this.identity]
353
+ );
354
+ return rows.map((r) => r.id);
355
+ } catch (error) {
356
+ if (error instanceof TreeStoreError) throw error;
357
+ throw new TreeStoreError(
358
+ `Failed to get leaves missing exit chains: ${error.message}`,
359
+ error
360
+ );
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Reconstruct the exit chains for many leaves in one query, each as
366
+ * { leaf, ancestors } with ancestors nearest first. A leaf absent from the store
367
+ * is skipped; a chain that hits a gap comes back partial.
368
+ * @param {Array<string>} leafIds
369
+ * @returns {Promise<Array<{leaf: object, ancestors: Array<object>}>>}
370
+ */
371
+ async getExitChains(leafIds) {
372
+ try {
373
+ if (!leafIds || leafIds.length === 0) return [];
374
+ // One query loads each requested leaf's own row plus its ancestor rows,
375
+ // both tagged by the owning leaf id (a leaf's own row is tagged with its
376
+ // own id). Reading them in two queries could pair a leaf with ancestors
377
+ // from a different snapshot. Grouping by that tag keeps each leaf's node
378
+ // set separate, so a node id stored under another leaf can never
379
+ // cross-contaminate this one.
380
+ const placeholders = buildPlaceholders(leafIds.length);
381
+ const [rows] = await this.pool.query(
382
+ `SELECT leaf_id, data FROM brz_tree_ancestors WHERE user_id = ? AND leaf_id IN (${placeholders})
383
+ UNION ALL
384
+ SELECT id AS leaf_id, data FROM brz_tree_leaves WHERE user_id = ? AND id IN (${placeholders})`,
385
+ [this.identity, ...leafIds, this.identity, ...leafIds]
386
+ );
387
+
388
+ const nodesByLeaf = new Map();
389
+ for (const r of rows) {
390
+ let nodes = nodesByLeaf.get(r.leaf_id);
391
+ if (!nodes) {
392
+ nodes = new Map();
393
+ nodesByLeaf.set(r.leaf_id, nodes);
394
+ }
395
+ const node = parseJson(r.data);
396
+ nodes.set(node.id, node);
397
+ }
398
+
399
+ const result = [];
400
+ for (const id of leafIds) {
401
+ const nodes = nodesByLeaf.get(id);
402
+ if (!nodes) continue;
403
+ const pedigree = assembleExitChain(nodes, id);
404
+ if (pedigree) result.push(pedigree);
405
+ }
406
+ return result;
407
+ } catch (error) {
408
+ if (error instanceof TreeStoreError) throw error;
409
+ throw new TreeStoreError(`Failed to get exit chains: ${error.message}`, error);
410
+ }
411
+ }
412
+
261
413
  /**
262
414
  * Returns the wallet's spendable balance (available + missing-from-operators
263
415
  * + swap-reserved). Aggregated server-side so we don't fetch every leaf.
@@ -294,8 +446,8 @@ class MysqlTreeStore {
294
446
  // one, and nothing non-Available and unreserved.
295
447
  const [rows] = await this.pool.query(
296
448
  `SELECT l.id AS id,
297
- l.data->>'$.verifying_public_key' AS verifying,
298
- l.data->>'$.signing_keyshare.public_key' AS keyshare
449
+ l.verifying_public_key AS verifying,
450
+ l.signing_public_key AS keyshare
299
451
  FROM brz_tree_leaves l
300
452
  LEFT JOIN brz_tree_reservations r
301
453
  ON l.reservation_id = r.id AND l.user_id = r.user_id
@@ -332,6 +484,7 @@ class MysqlTreeStore {
332
484
 
333
485
  for (const row of rows) {
334
486
  const node = parseJson(row.data);
487
+ const spendable = node.status === "Available";
335
488
 
336
489
  if (row.purpose) {
337
490
  if (row.purpose === "Payment") {
@@ -339,14 +492,12 @@ class MysqlTreeStore {
339
492
  } else if (row.purpose === "Swap") {
340
493
  reservedForSwap.push(node);
341
494
  }
495
+ } else if (!spendable) {
496
+ notAvailable.push(node);
342
497
  } else if (toBool(row.is_missing_from_operators)) {
343
- if (node.status === "Available") {
344
- availableMissingFromOperators.push(node);
345
- }
346
- } else if (node.status === "Available") {
347
- available.push(node);
498
+ availableMissingFromOperators.push(node);
348
499
  } else {
349
- notAvailable.push(node);
500
+ available.push(node);
350
501
  }
351
502
  }
352
503
 
@@ -406,7 +557,13 @@ class MysqlTreeStore {
406
557
 
407
558
  // Includes leaves released earlier in this transaction by
408
559
  // _cleanupStaleReservations (which now NULLs reservation_id explicitly,
409
- // since the composite FK uses NO ACTION).
560
+ // since the composite FK uses NO ACTION). MySQL has no DELETE ...
561
+ // RETURNING, so the ids are read first.
562
+ const [oldLeafRows] = await conn.query(
563
+ "SELECT id FROM brz_tree_leaves WHERE user_id = ? AND reservation_id IS NULL AND added_at < ?",
564
+ [this.identity, refreshTimestamp]
565
+ );
566
+ const deletedIds = oldLeafRows.map((r) => r.id);
410
567
  await conn.query(
411
568
  "DELETE FROM brz_tree_leaves WHERE user_id = ? AND reservation_id IS NULL AND added_at < ?",
412
569
  [this.identity, refreshTimestamp]
@@ -414,6 +571,22 @@ class MysqlTreeStore {
414
571
 
415
572
  await this._batchUpsertLeaves(conn, leaves, false, spentIds);
416
573
  await this._batchUpsertLeaves(conn, missingLeaves, true, spentIds);
574
+
575
+ // A leaf reported again in this same refresh is re-inserted above, so its
576
+ // ancestor rows must survive: only ids that do NOT reappear (truly gone,
577
+ // e.g. spent) get their ancestor rows dropped alongside them.
578
+ const survivingIds = new Set();
579
+ for (const leaf of leaves.concat(missingLeaves || [])) {
580
+ if (!spentIds.has(leaf.id)) survivingIds.add(leaf.id);
581
+ }
582
+ const goneIds = deletedIds.filter((id) => !survivingIds.has(id));
583
+ if (goneIds.length > 0) {
584
+ const placeholders = buildPlaceholders(goneIds.length);
585
+ await conn.query(
586
+ `DELETE FROM brz_tree_ancestors WHERE user_id = ? AND leaf_id IN (${placeholders})`,
587
+ [this.identity, ...goneIds]
588
+ );
589
+ }
417
590
  });
418
591
  } catch (error) {
419
592
  if (error instanceof TreeStoreError) throw error;
@@ -427,14 +600,19 @@ class MysqlTreeStore {
427
600
  async cancelReservation(id, leavesToKeep) {
428
601
  try {
429
602
  await this._withTransaction(async (conn) => {
430
- const [existsRows] = await conn.query(
431
- "SELECT id FROM brz_tree_reservations WHERE user_id = ? AND id = ?",
603
+ // Return leavesToKeep to the pool even when the reservation is already
604
+ // gone (e.g. released by stale cleanup): dropping them here would lose
605
+ // the leaves until the next refresh. The deletes no-op in that case.
606
+ // Only the leaves are re-inserted: a kept leaf's ancestor rows stay put
607
+ // (they are not touched below); a dropped leaf's are removed with it.
608
+ const keepIds = new Set((leavesToKeep || []).map((l) => l.id));
609
+ const [reservedRows] = await conn.query(
610
+ "SELECT id FROM brz_tree_leaves WHERE user_id = ? AND reservation_id = ?",
432
611
  [this.identity, id]
433
612
  );
434
-
435
- if (existsRows.length === 0) {
436
- return;
437
- }
613
+ const droppedIds = reservedRows
614
+ .map((r) => r.id)
615
+ .filter((rid) => !keepIds.has(rid));
438
616
 
439
617
  await conn.query(
440
618
  "DELETE FROM brz_tree_leaves WHERE user_id = ? AND reservation_id = ?",
@@ -444,6 +622,13 @@ class MysqlTreeStore {
444
622
  "DELETE FROM brz_tree_reservations WHERE user_id = ? AND id = ?",
445
623
  [this.identity, id]
446
624
  );
625
+ if (droppedIds.length > 0) {
626
+ const placeholders = buildPlaceholders(droppedIds.length);
627
+ await conn.query(
628
+ `DELETE FROM brz_tree_ancestors WHERE user_id = ? AND leaf_id IN (${placeholders})`,
629
+ [this.identity, ...droppedIds]
630
+ );
631
+ }
447
632
 
448
633
  if (leavesToKeep && leavesToKeep.length > 0) {
449
634
  await this._batchUpsertLeaves(conn, leavesToKeep, false, null);
@@ -471,13 +656,14 @@ class MysqlTreeStore {
471
656
  );
472
657
 
473
658
  let isSwap = false;
659
+ let reservedLeafIds = [];
474
660
  if (resRows.length > 0) {
475
661
  isSwap = resRows[0].purpose === "Swap";
476
662
  const [leafRows] = await conn.query(
477
663
  "SELECT id FROM brz_tree_leaves WHERE user_id = ? AND reservation_id = ?",
478
664
  [this.identity, id]
479
665
  );
480
- const reservedLeafIds = leafRows.map((r) => r.id);
666
+ reservedLeafIds = leafRows.map((r) => r.id);
481
667
  await this._batchInsertSpentLeaves(conn, reservedLeafIds);
482
668
  await conn.query(
483
669
  "DELETE FROM brz_tree_leaves WHERE user_id = ? AND reservation_id = ?",
@@ -487,6 +673,15 @@ class MysqlTreeStore {
487
673
  "DELETE FROM brz_tree_reservations WHERE user_id = ? AND id = ?",
488
674
  [this.identity, id]
489
675
  );
676
+ // The spent leaves own these ancestor rows; remove them in the same
677
+ // transaction rather than leaving them to a separate reclaim pass.
678
+ if (reservedLeafIds.length > 0) {
679
+ const placeholders = buildPlaceholders(reservedLeafIds.length);
680
+ await conn.query(
681
+ `DELETE FROM brz_tree_ancestors WHERE user_id = ? AND leaf_id IN (${placeholders})`,
682
+ [this.identity, ...reservedLeafIds]
683
+ );
684
+ }
490
685
  }
491
686
 
492
687
  if (newLeaves && newLeaves.length > 0) {
@@ -687,7 +882,8 @@ class MysqlTreeStore {
687
882
  const [availableRows] = await conn.query(
688
883
  `SELECT id FROM brz_tree_leaves
689
884
  WHERE user_id = ? AND id IN (${placeholders})
690
- AND status = 'Available' AND is_missing_from_operators = 0
885
+ AND status = 'Available'
886
+ AND is_missing_from_operators = 0
691
887
  AND reservation_id IS NULL`,
692
888
  [this.identity, ...leafIds]
693
889
  );
@@ -746,6 +942,15 @@ class MysqlTreeStore {
746
942
  "DELETE FROM brz_tree_leaves WHERE user_id = ? AND reservation_id = ?",
747
943
  [this.identity, reservationId]
748
944
  );
945
+ // The spent leaves own these ancestor rows; remove them now since there
946
+ // is no later reclaim pass.
947
+ if (oldLeafIds.length > 0) {
948
+ const placeholders = buildPlaceholders(oldLeafIds.length);
949
+ await conn.query(
950
+ `DELETE FROM brz_tree_ancestors WHERE user_id = ? AND leaf_id IN (${placeholders})`,
951
+ [this.identity, ...oldLeafIds]
952
+ );
953
+ }
749
954
 
750
955
  await this._batchUpsertLeaves(conn, changeLeaves, false, null);
751
956
  await this._batchUpsertLeaves(conn, reservedLeaves, false, null);
@@ -758,6 +963,8 @@ class MysqlTreeStore {
758
963
  [this.identity, reservationId]
759
964
  );
760
965
 
966
+ // Return value must be plain TreeNodes: the Rust side deserializes
967
+ // Vec<TreeNode>.
761
968
  return { id: reservationId, leaves: reservedLeaves };
762
969
  });
763
970
  } catch (error) {
@@ -969,15 +1176,17 @@ class MysqlTreeStore {
969
1176
 
970
1177
  if (filtered.length === 0) return;
971
1178
 
1179
+ const leafNodes = filtered;
1180
+
972
1181
  // All chunks run inside the caller's transaction, so the full set still
973
1182
  // lands atomically. UTC_TIMESTAMP(6) is re-evaluated per statement, so
974
1183
  // added_at can differ by microseconds between chunks; every value is still
975
1184
  // after the caller's refreshStartedAt, which is all the timestamp-based
976
1185
  // deletion in setLeaves depends on.
977
- for (let i = 0; i < filtered.length; i += LEAF_UPSERT_CHUNK_SIZE) {
978
- const chunk = filtered.slice(i, i + LEAF_UPSERT_CHUNK_SIZE);
1186
+ for (let i = 0; i < leafNodes.length; i += LEAF_UPSERT_CHUNK_SIZE) {
1187
+ const chunk = leafNodes.slice(i, i + LEAF_UPSERT_CHUNK_SIZE);
979
1188
  const valueClauses = new Array(chunk.length)
980
- .fill("(?, ?, ?, ?, ?, ?, UTC_TIMESTAMP(6))")
1189
+ .fill("(?, ?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP(6))")
981
1190
  .join(", ");
982
1191
  const params = [];
983
1192
  for (const leaf of chunk) {
@@ -987,24 +1196,82 @@ class MysqlTreeStore {
987
1196
  leaf.status,
988
1197
  isMissingFromOperators ? 1 : 0,
989
1198
  JSON.stringify(leaf),
990
- leaf.value
1199
+ leaf.value,
1200
+ leaf.parent_node_id ?? null,
1201
+ leaf.verifying_public_key,
1202
+ leaf.signing_keyshare.public_key
991
1203
  );
992
1204
  }
993
1205
 
994
1206
  await conn.query(
995
- `INSERT INTO brz_tree_leaves (user_id, id, status, is_missing_from_operators, data, value, added_at)
1207
+ `INSERT INTO brz_tree_leaves
1208
+ (user_id, id, status, is_missing_from_operators, data, value,
1209
+ parent_node_id, verifying_public_key, signing_public_key, added_at)
996
1210
  VALUES ${valueClauses}
997
1211
  ON DUPLICATE KEY UPDATE
998
1212
  status = VALUES(status),
999
1213
  is_missing_from_operators = VALUES(is_missing_from_operators),
1000
1214
  data = VALUES(data),
1001
1215
  value = VALUES(value),
1216
+ parent_node_id = VALUES(parent_node_id),
1217
+ verifying_public_key = VALUES(verifying_public_key),
1218
+ signing_public_key = VALUES(signing_public_key),
1002
1219
  added_at = UTC_TIMESTAMP(6)`,
1003
1220
  params
1004
1221
  );
1005
1222
  }
1006
1223
  }
1007
1224
 
1225
+ /**
1226
+ * Replaces the ancestor rows of every pedigree wholesale (delete then
1227
+ * insert), in one delete and an insert per ANCESTOR_INSERT_CHUNK_SIZE rows. A
1228
+ * pedigree carrying no ancestors is skipped: an empty list means the chain is
1229
+ * unknown, not that the leaf has none, so a stored chain must survive being
1230
+ * re-added without one.
1231
+ */
1232
+ async _batchUpsertAncestors(conn, pedigrees) {
1233
+ const withAncestors = (pedigrees || []).filter(
1234
+ (p) => p.ancestors && p.ancestors.length > 0
1235
+ );
1236
+ if (withAncestors.length === 0) return;
1237
+
1238
+ const leafIds = withAncestors.map((p) => p.leaf.id);
1239
+ await conn.query(
1240
+ `DELETE FROM brz_tree_ancestors
1241
+ WHERE user_id = ? AND leaf_id IN (${buildPlaceholders(leafIds.length)})`,
1242
+ [this.identity, ...leafIds]
1243
+ );
1244
+
1245
+ const rows = [];
1246
+ for (const pedigree of withAncestors) {
1247
+ for (const node of pedigree.ancestors) {
1248
+ rows.push([
1249
+ this.identity,
1250
+ pedigree.leaf.id,
1251
+ node.id,
1252
+ node.parent_node_id ?? null,
1253
+ node.status,
1254
+ JSON.stringify(node),
1255
+ node.value,
1256
+ node.verifying_public_key,
1257
+ ]);
1258
+ }
1259
+ }
1260
+
1261
+ for (let i = 0; i < rows.length; i += ANCESTOR_INSERT_CHUNK_SIZE) {
1262
+ const chunk = rows.slice(i, i + ANCESTOR_INSERT_CHUNK_SIZE);
1263
+ const valueClauses = new Array(chunk.length)
1264
+ .fill("(?, ?, ?, ?, ?, ?, ?, ?)")
1265
+ .join(", ");
1266
+ await conn.query(
1267
+ `INSERT INTO brz_tree_ancestors
1268
+ (user_id, leaf_id, id, parent_node_id, status, data, value, verifying_public_key)
1269
+ VALUES ${valueClauses}`,
1270
+ chunk.flat()
1271
+ );
1272
+ }
1273
+ }
1274
+
1008
1275
  async _batchSetReservationId(conn, reservationId, leafIds) {
1009
1276
  if (leafIds.length === 0) return;
1010
1277