@serfab/cadre-core 1.6.0 → 1.7.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 (51) hide show
  1. package/dist/cadre-node.d.ts +204 -65
  2. package/dist/cadre-node.js +392 -95
  3. package/dist/cadre-node.js.map +1 -1
  4. package/dist/index.d.ts +5 -0
  5. package/dist/index.js +16 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/joined-strand-store.d.ts +112 -0
  8. package/dist/joined-strand-store.js +263 -0
  9. package/dist/joined-strand-store.js.map +1 -0
  10. package/dist/key-store.d.ts +6 -0
  11. package/dist/key-store.js.map +1 -1
  12. package/dist/node-local-snapshot.d.ts +5 -4
  13. package/dist/node-local-snapshot.js +6 -3
  14. package/dist/node-local-snapshot.js.map +1 -1
  15. package/dist/peer-join-backfill.d.ts +33 -14
  16. package/dist/peer-join-backfill.js +109 -50
  17. package/dist/peer-join-backfill.js.map +1 -1
  18. package/dist/strand-instance-manager.d.ts +39 -0
  19. package/dist/strand-instance-manager.js +74 -1
  20. package/dist/strand-instance-manager.js.map +1 -1
  21. package/dist/strand-peer-book-file.d.ts +12 -0
  22. package/dist/strand-peer-book-file.js +37 -0
  23. package/dist/strand-peer-book-file.js.map +1 -0
  24. package/dist/strand-peer-book-protocol.d.ts +159 -0
  25. package/dist/strand-peer-book-protocol.js +360 -0
  26. package/dist/strand-peer-book-protocol.js.map +1 -0
  27. package/dist/strand-peer-book-swap.d.ts +104 -0
  28. package/dist/strand-peer-book-swap.js +284 -0
  29. package/dist/strand-peer-book-swap.js.map +1 -0
  30. package/dist/strand-peer-book.d.ts +159 -0
  31. package/dist/strand-peer-book.js +354 -0
  32. package/dist/strand-peer-book.js.map +1 -0
  33. package/dist/strand-peer-observer.d.ts +88 -0
  34. package/dist/strand-peer-observer.js +180 -0
  35. package/dist/strand-peer-observer.js.map +1 -0
  36. package/dist/types.d.ts +58 -0
  37. package/dist/types.js.map +1 -1
  38. package/package.json +6 -2
  39. package/src/cadre-node.ts +7776 -7449
  40. package/src/index.ts +67 -0
  41. package/src/joined-strand-store.ts +308 -0
  42. package/src/key-store.ts +6 -0
  43. package/src/node-local-snapshot.ts +293 -289
  44. package/src/peer-join-backfill.ts +112 -51
  45. package/src/strand-instance-manager.ts +100 -1
  46. package/src/strand-peer-book-file.ts +39 -0
  47. package/src/strand-peer-book-protocol.ts +463 -0
  48. package/src/strand-peer-book-swap.ts +338 -0
  49. package/src/strand-peer-book.ts +468 -0
  50. package/src/strand-peer-observer.ts +235 -0
  51. package/src/types.ts +60 -0
@@ -1,17 +1,28 @@
1
1
  import debug from 'debug';
2
- import type { Connection, Libp2p, PeerId } from '@libp2p/interface';
2
+ import type { IdentifyResult, Libp2p, PeerId } from '@libp2p/interface';
3
3
  import type { ActionId, IPeerNetwork } from '@optimystic/db-core';
4
- import { BlockTransferClient, type BlockCommitProof, type IRawStorage } from '@optimystic/db-p2p';
4
+ import { BlockTransferClient, buildBlockTransferProtocol, type BlockCommitProof, type IRawStorage } from '@optimystic/db-p2p';
5
5
  import { peerJoinPushBudget } from './link-budget.js';
6
6
 
7
7
  // Peer-join whole-store block catch-up, shared by the STRAND networks and the CONTROL
8
- // network. On `connection:open` (debounced) it pushes every committed, materialized block
9
- // in the local raw store to the new peer, so a machine that joined after blocks were
8
+ // network. On a new peer's `peer:identify` result — libp2p's identify protocol runs once per
9
+ // connection and reports the protocols the remote supports — naming this network's own
10
+ // block-transfer protocol, it schedules a debounced push of every committed, materialized
11
+ // block in the local raw store to that peer, so a machine that joined after blocks were
10
12
  // committed still ends up physically holding them. Optimystic has no cohort-join catch-up
11
13
  // of its own; a block committed while its writer was alone has exactly one holder forever
12
14
  // without this (named collection-header blocks are written once, at collection creation,
13
15
  // and their revision never moves again — so no later commit ever carries them anywhere).
14
16
  //
17
+ // A peer whose identify does not name the protocol — a bare circuit relay, a bootstrap node,
18
+ // or (on the control network) a stranger the inbound gate admits without membership — is
19
+ // never scheduled at all: it cannot receive anything, since the protocol id is namespaced per
20
+ // network and the dial would simply fail. Before this filter existed, such a peer WAS
21
+ // scheduled, failed every dial, and after `PEER_JOIN_BACKFILL_WARN_AFTER_FAILURES` produced a
22
+ // `console.warn` naming it — gotchoices/sereus#18 is two relay-only parties restarting and
23
+ // printing exactly that warning about their own relay, forever, on every reconnect. See
24
+ // {@link speaksBlockTransfer}.
25
+ //
15
26
  // NOTE: this copies the WHOLE local store to every newly connected peer, which is right
16
27
  // while a network is one party's handful of machines (see docs/architecture.md →
17
28
  // Replication cluster size — the control network replicates to the whole party by intent,
@@ -26,12 +37,10 @@ import { peerJoinPushBudget } from './link-budget.js';
26
37
  // Membership — the one place the two networks differ, expressed as the optional
27
38
  // `authorizePeer` dep:
28
39
  //
29
- // - STRAND networks pass none. Anything connected on a strand's own libp2p network already
30
- // receives cohort replicas of new commits, so pushing older blocks to it exposes nothing
31
- // new; adding a gate would diverge from what ordinary replication already does there. A
32
- // peer that does NOT speak the strand's block-transfer protocol (a bare circuit relay or
33
- // bootstrap node the strand node also connects to) cannot receive anything: the protocol
34
- // id is namespaced per strand, so the dial fails and the push is dropped.
40
+ // - STRAND networks pass none. Anything that speaks the strand's own block-transfer protocol
41
+ // already receives cohort replicas of new commits, so pushing older blocks to it exposes
42
+ // nothing new; adding a gate would diverge from what ordinary replication already does
43
+ // there.
35
44
  // - The CONTROL network MUST pass one, because that argument does not carry over: its
36
45
  // inbound connection gate deliberately admits non-members in several states (an
37
46
  // un-enrolled node taking its seed, an open enrollment window, an outstanding
@@ -40,8 +49,8 @@ import { peerJoinPushBudget } from './link-budget.js';
40
49
  // peer would hand a stranger the party's entire membership, addresses and strand list.
41
50
  // `CadreNode` passes `isAuthorizedMember`; the gate is consulted at PUSH time, not at
42
51
  // schedule time, and fails closed on a thrown check. A denied run is not memoized, so
43
- // the peer is retried on its next `connection:open` — or sooner, via
44
- // `scheduleConnectedPeers()` on a membership change (the production join order is
52
+ // the peer is retried on its next `peer:identify` (a reconnect re-runs identify) — or
53
+ // sooner, via `scheduleConnectedPeers()` on a membership change (the production join order is
45
54
  // connect-then-authorize, so the denial at dial time is the expected first pass).
46
55
  //
47
56
  // Both per-push deadlines are DERIVED from the declared link round trip
@@ -62,14 +71,14 @@ import { peerJoinPushBudget } from './link-budget.js';
62
71
  //
63
72
  // RETRY, and why it backs off. A run whose PUSH FAILED — the transport threw, which is what
64
73
  // a dial or response deadline expiring looks like here — re-arms on a doubling backoff
65
- // (`retryBackoffMs` to `maxRetryBackoffMs`), and a `connection:open` arriving while that wait
74
+ // (`retryBackoffMs` to `maxRetryBackoffMs`), and a `peer:identify` arriving while that wait
66
75
  // is outstanding is DROPPED rather than collapsing it back to the debounce. Both halves are
67
76
  // load-bearing:
68
77
  //
69
78
  // - Without the re-arm, a transient push failure over a stable connection left the peer
70
79
  // partially copied until its next reconnect (read repair still covered reads meanwhile).
71
80
  // - Without dropping churn inside the wait, a peer that cannot be reached at all is
72
- // re-dialled on every `connection:open` forever. That is not hypothetical: the 2026-09-26
81
+ // re-dialled on every `peer:identify` forever. That is not hypothetical: the 2026-09-26
73
82
  // relayed reproduction shows one peer re-opening a connection about every 14.5 s for a
74
83
  // 200-second run, each event starting a catch-up whose dial could not possibly finish.
75
84
  // The connection kept re-appearing because Optimystic's own block-transfer push path
@@ -87,13 +96,13 @@ import { peerJoinPushBudget } from './link-budget.js';
87
96
  // {@link Chunk.proofs}). Re-arming on those would re-push the WHOLE store to that peer every
88
97
  // `maxRetryBackoffMs` for as long as the node runs, and report the failure below as a link
89
98
  // budget problem when nothing about the link is wrong. All three still leave the peer
90
- // un-memoized, so its next `connection:open` retries — the behaviour that predates the
99
+ // un-memoized, so its next `peer:identify` retries — the behaviour that predates the
91
100
  // backoff, and the right one for a verdict rather than a timeout. Denial in particular is
92
101
  // re-driven on purpose by `scheduleConnectedPeers()` the moment the membership commit lands,
93
102
  // because the control network's join order is connect-then-authorize.
94
103
  //
95
104
  // A re-arm is also skipped when the peer is no longer CONNECTED. This module's trigger is
96
- // `connection:open`, so a peer that went away already has one: re-arming instead would leave
105
+ // `peer:identify`, so a peer that went away already has one: re-arming instead would leave
97
106
  // a machine dialing a peer it cannot see once a minute for the rest of its uptime.
98
107
  //
99
108
  // After `PEER_JOIN_BACKFILL_WARN_AFTER_FAILURES` consecutive failures one `console.warn`
@@ -101,16 +110,25 @@ import { peerJoinPushBudget } from './link-budget.js';
101
110
  // nothing at all: the connection simply never appears and only a DEBUG log mentions a dial
102
111
  // timeout.
103
112
  //
104
- // NOTE: a failing dial to a non-speaking peer costs one dial timeout per attempt, and is
105
- // never memoized (only clean runs are). Bounded by the backoff above plus the
106
- // unreachable-peer bail in `runCatchUp` — one dial per attempt, not one per chunk. If a node
107
- // ever holds many such connections, or the enumeration ahead of the first chunk gets
108
- // expensive, pre-check `libp2p.peerStore` for this network's block-transfer protocol before
109
- // enumerating.
113
+ // NOTE: the protocol check happens once — either when a connection's `peer:identify` fires,
114
+ // or, for a peer already connected when this instance starts, when `scheduleConnectedPeers()`
115
+ // reads its protocols back from the peer store — and is never repeated once a peer is
116
+ // scheduled. A peer that stops speaking the protocol after being scheduled (should that ever
117
+ // happen) is not de-scheduled; `runCatchUp`'s own unreachable-peer bail is what would catch
118
+ // that case instead. `peer:identify` also fires on every identify-push from a connected peer
119
+ // (address or protocol changes), which only re-enters the debounce — cheap, and dropped
120
+ // outright for caught-up peers and peers inside a backoff wait.
121
+ //
122
+ // NOTE: a same-network peer whose identify never completes on a connection (the identify
123
+ // stream times out over a slow relayed link, say) is not caught up until a later identify
124
+ // succeeds — a reconnect, an identify-push, or on the control network a membership-change
125
+ // re-drive. Accepted: every db-p2p node runs identify, and read repair still covers reads
126
+ // meanwhile; if relayed links ever routinely outlast identify's timeout, raise that timeout
127
+ // in the node builder rather than scheduling unidentified peers here.
110
128
  //
111
129
  // NOTE: a peer that disconnects INSIDE a backoff wait still costs the one attempt that wait
112
130
  // was already armed for — the connectivity check is made when the re-arm is decided, not when
113
- // the timer fires, and this module subscribes to `connection:open` only. One dial, not a
131
+ // the timer fires, and this module subscribes to `peer:identify` only. One dial, not a
114
132
  // recurring one; if a node ever holds many transient peers, subscribe to `connection:close`
115
133
  // and clear the pending timer there.
116
134
 
@@ -124,6 +142,18 @@ const log = debug('sereus:cadre:peer-join-backfill');
124
142
  */
125
143
  export const MAX_BLOCK_MESSAGE_BYTES = 8 * 1024 * 1024;
126
144
 
145
+ /**
146
+ * Whether an identified peer's protocol list names this network's own block-transfer
147
+ * protocol — the one gate that decides whether {@link PeerJoinBackfill} schedules a peer at
148
+ * all. A peer that lacks it (a bare circuit relay, a bootstrap node, a stranger the control
149
+ * network's inbound gate admitted without membership) cannot receive a push no matter how
150
+ * long it is dialed, since the protocol id is namespaced per network. Exported because a
151
+ * later strand peer-address ticket's identify-driven observation needs the identical test.
152
+ */
153
+ export function speaksBlockTransfer(protocols: readonly string[], protocolPrefix: string): boolean {
154
+ return protocols.includes(buildBlockTransferProtocol(protocolPrefix));
155
+ }
156
+
127
157
  /**
128
158
  * Consecutive failed catch-up runs against ONE peer before the module says so on
129
159
  * `console.warn` rather than only in its DEBUG log. At the default backoff that is reached
@@ -277,7 +307,7 @@ function base64WireBytes(rawBytes: number): number {
277
307
  * Best-effort throughout: nothing here throws into a libp2p event handler or into a
278
308
  * runtime bring-up; a failed chunk is logged and the run continues. A peer is marked
279
309
  * fully caught up ONLY after a run with no thrown chunk and an empty `missing` list, so
280
- * the next `connection:open` from a peer whose catch-up failed retries it.
310
+ * the next `peer:identify` from a peer whose catch-up failed retries it.
281
311
  */
282
312
  export class PeerJoinBackfill {
283
313
  private readonly config: Required<PeerJoinBackfillConfig>;
@@ -303,31 +333,43 @@ export class PeerJoinBackfill {
303
333
  private readonly failures = new Map<string, number>();
304
334
  /**
305
335
  * Epoch ms before which a peer's catch-up must not be re-run, set alongside a backoff timer
306
- * that will run it. Its presence is what makes `connection:open` churn free: a schedule
336
+ * that will run it. Its presence is what makes `peer:identify` churn free: a schedule
307
337
  * arriving inside the wait is dropped, rather than collapsing the backoff to the debounce.
308
338
  */
309
339
  private readonly retryAfter = new Map<string, number>();
310
340
  /** Peers already reported on `console.warn`; reset when a run finally lands cleanly. */
311
341
  private readonly warned = new Set<string>();
312
- private readonly onConnectionOpen: (evt: CustomEvent<Connection>) => void;
342
+ private readonly onPeerIdentify: (evt: CustomEvent<IdentifyResult>) => void;
313
343
  private loggedNoListBlockIds = false;
314
344
 
315
345
  constructor(private readonly deps: PeerJoinBackfillDeps, config?: PeerJoinBackfillConfig) {
316
346
  this.config = { ...DEFAULT_PEER_JOIN_BACKFILL, ...config };
317
347
  this.createPushClient = deps.createPushClient
318
348
  ?? ((peerId) => new BlockTransferClient(peerId, deps.peerNetwork, deps.protocolPrefix));
319
- this.onConnectionOpen = (evt) => this.schedulePeer(evt.detail.remotePeer);
349
+ this.onPeerIdentify = (evt) => {
350
+ const { peerId, protocols } = evt.detail;
351
+ if (speaksBlockTransfer(protocols, this.deps.protocolPrefix)) {
352
+ this.schedulePeer(peerId);
353
+ }
354
+ };
320
355
  }
321
356
 
322
- /** Subscribe to connection:open AND schedule a catch-up for peers already connected. */
357
+ /**
358
+ * Subscribe to `peer:identify` AND schedule a catch-up for already-connected peers whose
359
+ * stored protocols already name this network's block-transfer protocol — a peer that does
360
+ * not speak it (a bare circuit relay, a bootstrap node) is never scheduled at all. See
361
+ * {@link speaksBlockTransfer} and the module comment.
362
+ */
323
363
  start(): void {
324
364
  if (this.started || this.stopped || !this.config.enabled) return;
325
365
  this.started = true;
326
- this.deps.libp2p.addEventListener('connection:open', this.onConnectionOpen);
327
- // A runtime rebuilt over live connections (resumeStrand) never sees their
328
- // connection:open, so walk what is already connected once.
329
- const scheduled = this.scheduleConnectedPeers();
330
- log('[%s] started (%d peer(s) already connected)', this.deps.label, scheduled);
366
+ this.deps.libp2p.addEventListener('peer:identify', this.onPeerIdentify);
367
+ // A runtime rebuilt over live connections (resumeStrand) never sees their peer:identify —
368
+ // it already fired before this instance existed, but its result is persisted in the peer
369
+ // store — so walk what is already connected once.
370
+ void this.scheduleConnectedPeers().then((scheduled) => {
371
+ log('[%s] started (%d peer(s) already connected)', this.deps.label, scheduled);
372
+ });
331
373
  }
332
374
 
333
375
  /** Unsubscribe, clear timers; in-flight runs observe the stopped flag and bail. */
@@ -335,7 +377,7 @@ export class PeerJoinBackfill {
335
377
  if (this.stopped) return;
336
378
  this.stopped = true;
337
379
  if (this.started) {
338
- this.deps.libp2p.removeEventListener('connection:open', this.onConnectionOpen);
380
+ this.deps.libp2p.removeEventListener('peer:identify', this.onPeerIdentify);
339
381
  }
340
382
  for (const timer of this.timers.values()) {
341
383
  clearTimeout(timer);
@@ -347,27 +389,46 @@ export class PeerJoinBackfill {
347
389
  }
348
390
 
349
391
  /**
350
- * (Re-)schedule a debounced catch-up for every currently-connected peer not yet caught
351
- * up. Idempotent and cheap (caught-up peers are skipped before any timer is set, and a
352
- * peer whose run is in flight is deferred to the end of that run rather than dropped).
353
- * Driven by {@link start}, and — on a gated network — by the embedder whenever
354
- * membership changes, so a peer whose first pass was denied (connected before it was
355
- * authorized: the production join order) is retried without waiting for a reconnect.
356
- * Returns how many distinct peers were considered (for the start() log) — a peer holding
357
- * several connections is one peer, and is scheduled once.
392
+ * (Re-)schedule a debounced catch-up for every currently-connected peer whose protocols, as
393
+ * already stored in the libp2p peer store, name this network's block-transfer protocol. A
394
+ * peer not yet in the peer store — its identify has not finished — is left to the
395
+ * `peer:identify` handler rather than dialed blind; one whose stored protocols are known
396
+ * and lack ours is skipped outright. Idempotent and cheap otherwise (caught-up peers are
397
+ * skipped before any timer is set, and a peer whose run is in flight is deferred to the end
398
+ * of that run rather than dropped). Driven by {@link start}, and — on a gated network — by
399
+ * the embedder whenever membership changes, so a peer whose first pass was denied (connected
400
+ * before it was authorized: the production join order) is retried without waiting for a
401
+ * reconnect. Returns how many distinct peers were considered (for the start() log) — a peer
402
+ * holding several connections is one peer, and is scheduled once.
358
403
  */
359
- scheduleConnectedPeers(): number {
404
+ async scheduleConnectedPeers(): Promise<number> {
360
405
  if (this.stopped) return 0;
361
- const peers = new Set<string>();
406
+ const peers = new Map<string, PeerId>();
362
407
  for (const connection of this.deps.libp2p.getConnections()) {
363
- const key = connection.remotePeer.toString();
364
- if (peers.has(key)) continue;
365
- peers.add(key);
366
- this.schedulePeer(connection.remotePeer);
408
+ peers.set(connection.remotePeer.toString(), connection.remotePeer);
367
409
  }
410
+ await Promise.all([...peers.values()].map((peerId) => this.scheduleIfKnownToSpeak(peerId)));
368
411
  return peers.size;
369
412
  }
370
413
 
414
+ /** Schedules `peerId` only if the peer store already knows it speaks this network's protocol. */
415
+ private async scheduleIfKnownToSpeak(peerId: PeerId): Promise<void> {
416
+ let protocols: string[];
417
+ try {
418
+ ({ protocols } = await this.deps.libp2p.peerStore.get(peerId));
419
+ } catch (error) {
420
+ // NotFoundError: identify has not finished. Leave it to the `peer:identify` handler
421
+ // rather than dialing blind. Anything else is unexpected — log it and skip the peer.
422
+ if ((error as Error).name !== 'NotFoundError') {
423
+ log('[%s] peer store read for %s failed — not scheduling: %o', this.deps.label, peerId.toString(), error);
424
+ }
425
+ return;
426
+ }
427
+ if (speaksBlockTransfer(protocols, this.deps.protocolPrefix)) {
428
+ this.schedulePeer(peerId);
429
+ }
430
+ }
431
+
371
432
  /** Debounced entry point for connection churn: one run per peer per settle window. */
372
433
  private schedulePeer(peerId: PeerId): void {
373
434
  const key = peerId.toString();
@@ -380,7 +441,7 @@ export class PeerJoinBackfill {
380
441
  }
381
442
  // A peer whose last run failed is already re-armed on a backoff timer that will run it, so
382
443
  // DROP this schedule instead of shortening the wait — otherwise a peer that cannot be
383
- // reached is re-dialled on every connection:open forever (see the module comment's retry
444
+ // reached is re-dialled on every peer:identify forever (see the module comment's retry
384
445
  // paragraph). A denied run never gets here: it does not set a backoff.
385
446
  const retryAt = this.retryAfter.get(key);
386
447
  if (retryAt !== undefined && Date.now() < retryAt) return;
@@ -420,7 +481,7 @@ export class PeerJoinBackfill {
420
481
  // rejection are all verdicts a retry cannot change, and re-arming on them would
421
482
  // re-push the whole store to that peer forever. See the module comment.
422
483
  //
423
- // Gated on `started` too: the re-arm exists to REPLACE a connection:open-driven retry,
484
+ // Gated on `started` too: the re-arm exists to REPLACE a peer:identify-driven retry,
424
485
  // so a caller driving `catchUpPeer` by hand against a backfill that was never started
425
486
  // owns its own retry policy and must not be left holding a background timer.
426
487
  this.scheduleRetryWithBackoff(peerId);
@@ -452,7 +513,7 @@ export class PeerJoinBackfill {
452
513
  /**
453
514
  * Re-arm one peer's catch-up after a run whose push failed, on a wait that doubles
454
515
  * per consecutive failure up to `maxRetryBackoffMs`. Sets {@link retryAfter} alongside the
455
- * timer, which is what makes `connection:open` churn inside the wait free.
516
+ * timer, which is what makes `peer:identify` churn inside the wait free.
456
517
  */
457
518
  private scheduleRetryWithBackoff(peerId: PeerId): void {
458
519
  const key = peerId.toString();
@@ -4,6 +4,9 @@ import { createLibp2pNode, type IRawStorage } from '@optimystic/db-p2p';
4
4
  import { wrapStorageWithCache, disposeStorageCache } from '@serfab/quereus-plugin-sereus';
5
5
  import { StrandDatabase } from './strand-database.js';
6
6
  import { PeerJoinBackfill, type PeerJoinBackfillConfig } from './peer-join-backfill.js';
7
+ import { StrandPeerObserver, type StrandPeerObservation } from './strand-peer-observer.js';
8
+ import { StrandPeerBookSwap } from './strand-peer-book-swap.js';
9
+ import type { StrandPeerBookStore } from './strand-peer-book.js';
7
10
  import {
8
11
  StrandRevocationEnforcer,
9
12
  createRevocationConnectionGater,
@@ -261,6 +264,28 @@ export interface StartStrandConfig {
261
264
  * hibernation wake's rebuilt supervisors carry it too.
262
265
  */
263
266
  announceDelegateToRelay?: (strandId: string, relayAddr: string, delegatePeerId: string) => Promise<void>;
267
+
268
+ /**
269
+ * Called when the strand's libp2p node identifies a STRAND peer — one whose
270
+ * identify result names this strand's block-transfer protocol, so never the
271
+ * circuit relay or a bootstrap node — with the addresses it is dialable at
272
+ * (`strand-peer-observer.ts`). Throttled per peer; must not throw. `CadreNode`
273
+ * wires it to the node-local strand peer book, which is what lets a restarted
274
+ * machine dial the peers it was talking to. Absent ⇒ nothing is observed.
275
+ * Retained with the launch config, so a hibernation wake re-arms it on the
276
+ * rebuilt node.
277
+ */
278
+ onStrandPeerIdentified?: (strandId: string, observation: StrandPeerObservation) => void;
279
+
280
+ /**
281
+ * The node-local strand peer book the book SWAP reads and writes
282
+ * (`strand-peer-book-swap.ts`): the strand node signs its own addresses with
283
+ * {@link privateKey}, exchanges signed entries with every strand peer it identifies,
284
+ * and files what verifies. Absent ⇒ no swap runs — a peer that dials this node's
285
+ * swap handler gets no answer. `CadreNode` passes its book store. Retained with the
286
+ * launch config, so a hibernation wake re-arms the swap on the rebuilt node.
287
+ */
288
+ strandPeerBook?: StrandPeerBookStore;
264
289
  }
265
290
 
266
291
  /**
@@ -370,6 +395,24 @@ export class StrandInstanceManager {
370
395
  * missed writes).
371
396
  */
372
397
  private backfills: Map<string, PeerJoinBackfill> = new Map();
398
+ /**
399
+ * The per-strand peer-book observation writer (`strand-peer-observer.ts`), keyed
400
+ * by strand id. Same lifecycle rationale as {@link backfills}: started in
401
+ * `buildStrandRuntime` right after the libp2p node exists, stopped and dropped in
402
+ * `releaseRuntime`, so quiesce → resume re-arms it on the rebuilt node. Only
403
+ * present when the launch config supplied `onStrandPeerIdentified`.
404
+ */
405
+ private peerObservers: Map<string, StrandPeerObserver> = new Map();
406
+
407
+ /**
408
+ * Per-strand strand peer book SWAP driver ({@link StrandPeerBookSwap}) — the signed
409
+ * exchange of book entries between strand peers. Same lifetime as the peer-book
410
+ * observer above: armed in `buildStrandRuntime` right after the libp2p node exists
411
+ * (its protocol handler must be registered before an early peer dials it), stopped
412
+ * and dropped in `releaseRuntime`. Only present when the launch config supplied
413
+ * `strandPeerBook`.
414
+ */
415
+ private peerBookSwaps: Map<string, StrandPeerBookSwap> = new Map();
373
416
  /**
374
417
  * The per-strand revoked-peer enforcer (closed strands only), keyed by strand
375
418
  * id. Same lifecycle rationale as {@link backfills}: created in
@@ -734,6 +777,48 @@ export class StrandInstanceManager {
734
777
 
735
778
  instance.libp2pNode = node;
736
779
 
780
+ // The peer-book observation writer, armed BEFORE the database bring-up below
781
+ // (unlike the backfill) because the bootstrap dials that follow `libp2p.start()`
782
+ // land during that bring-up, and they are exactly the peers worth remembering:
783
+ // identify for an early peer may already have fired by the time anything after
784
+ // the database initializes runs. Registered in the map immediately so the
785
+ // failure rollback (releaseRuntime) stops it like every other runtime component.
786
+ const onStrandPeerIdentified = config.onStrandPeerIdentified;
787
+ if (onStrandPeerIdentified) {
788
+ const observer = new StrandPeerObserver({
789
+ label: strandId,
790
+ libp2p: node,
791
+ protocolPrefix,
792
+ onObserved: (observation) => onStrandPeerIdentified(strandId, observation)
793
+ });
794
+ this.peerObservers.set(strandId, observer);
795
+ observer.start();
796
+ }
797
+
798
+ // The strand peer book SWAP, armed at the same moment and for the same reason as
799
+ // the observer: a peer that dials this node's swap handler before it is registered
800
+ // fails its exchange and sits out a ten-minute throttle. The own entry is signed
801
+ // now and re-signed on every address change, which covers the relay reservation
802
+ // landing below (`self:peer:update` fires when the circuit addr is added), so no
803
+ // separate post-relay step is needed. Registered in the map immediately so the
804
+ // failure rollback (releaseRuntime) stops it like every other runtime component.
805
+ //
806
+ // NOTE: this is a third `peer:identify` listener per running strand, beside the
807
+ // observer's and the backfill's — see the backfill's NOTE below for when to fold
808
+ // them into one dispatcher.
809
+ if (config.strandPeerBook) {
810
+ const swap = new StrandPeerBookSwap({
811
+ strandId,
812
+ libp2p: node,
813
+ protocolPrefix,
814
+ store: config.strandPeerBook,
815
+ privateKey: config.privateKey,
816
+ linkRoundTripMs: config.network?.linkRoundTripMs
817
+ });
818
+ this.peerBookSwaps.set(strandId, swap);
819
+ swap.start();
820
+ }
821
+
737
822
  // One reservation supervisor PER RELAY, started now so the first attempts
738
823
  // overlap the database bring-up below (the relay is never in a strand's
739
824
  // Optimystic cohort — its protocol ids are namespaced `/optimystic/strand-<id>/…`
@@ -850,7 +935,7 @@ export class StrandInstanceManager {
850
935
  // peer-join-backfill.ts for the strand-side argument (and why the control
851
936
  // network, which DOES gate, is different).
852
937
  //
853
- // NOTE: cost is one PeerJoinBackfill object + one `connection:open` listener
938
+ // NOTE: cost is one PeerJoinBackfill object + one `peer:identify` listener
854
939
  // per running strand — linear in strand count, negligible at the handful a
855
940
  // device or host runs today. If a node ever hosts strands by the hundred,
856
941
  // move to one shared listener that dispatches by strand id.
@@ -946,6 +1031,20 @@ export class StrandInstanceManager {
946
1031
  backfill.stop();
947
1032
  this.backfills.delete(instance.strandId);
948
1033
  }
1034
+ // The peer-book observer goes with the node it listened on; a resume re-arms
1035
+ // one on the rebuilt node.
1036
+ const peerObserver = this.peerObservers.get(instance.strandId);
1037
+ if (peerObserver) {
1038
+ peerObserver.stop();
1039
+ this.peerObservers.delete(instance.strandId);
1040
+ }
1041
+ // The book swap likewise: unsubscribed and its handler unregistered while the node is
1042
+ // still up; a resume re-arms one, which re-signs the own entry over the new addresses.
1043
+ const peerBookSwap = this.peerBookSwaps.get(instance.strandId);
1044
+ if (peerBookSwap) {
1045
+ this.peerBookSwaps.delete(instance.strandId);
1046
+ await peerBookSwap.stop();
1047
+ }
949
1048
  // The revoked-peer enforcer goes with the runtime it gated: a resume
950
1049
  // rebuilds it with a fresh (initially empty, fail-open) snapshot.
951
1050
  const revocationEnforcer = this.revocationEnforcers.get(instance.strandId);
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Node-only file-backed {@link StrandPeerBookStore}: one JSON file per party under
3
+ * a configured directory (the node's state directory — `cadre-cli` passes
4
+ * `ResolvedConfig.nodeStateDir`), holding the node-local, non-replicated strand
5
+ * peer book (the strand peers this node has met, with their last-known addresses).
6
+ *
7
+ * Nothing but the file itself lives here: the envelope, load policy, per-entry
8
+ * validation, the merge rule and the serialised snapshot writes are cross-platform
9
+ * in `PersistentStrandPeerBookStore` / `node-local-snapshot.ts`, and the file is a
10
+ * `FileDurableSlot`. This module exists only so the `node:fs` edge stays out of the
11
+ * package's cross-platform default entry (`./index.js`) — import it from the
12
+ * dedicated subpath instead:
13
+ *
14
+ * ```ts
15
+ * import { FileStrandPeerBookStore } from '@serfab/cadre-core/strand-peer-book-file';
16
+ * ```
17
+ *
18
+ * Same isolation pattern as `bootstrap-peer-store-file.ts` / `key-store-file.ts`.
19
+ */
20
+ import { FileDurableSlot } from './file-durable-slot.js';
21
+ import { PersistentStrandPeerBookStore } from './strand-peer-book.js';
22
+ import type { StrandPeerBookOptions, StrandPeerBookStore } from './strand-peer-book.js';
23
+
24
+ /** Base name of the store file: `<dir>/strand-peers.<encoded partyId>.json`. */
25
+ const SLOT_NAME = 'strand-peers';
26
+
27
+ /**
28
+ * File-backed {@link StrandPeerBookStore}. Open via {@link open}, which loads the
29
+ * existing file and returns the cross-platform store over it; an absent, corrupt,
30
+ * or wrong-party file is a cold start (empty book) and structurally junk or
31
+ * aged-out entries are dropped, while a present-but-unreadable file throws. Those
32
+ * rules and their reasoning live on `NodeLocalSnapshot.open`.
33
+ */
34
+ export const FileStrandPeerBookStore = {
35
+ /** Load (or cold-start) the party's strand peer book from a file in `dir`. */
36
+ async open(dir: string, partyId: string, options?: StrandPeerBookOptions): Promise<StrandPeerBookStore> {
37
+ return PersistentStrandPeerBookStore.open(new FileDurableSlot(dir, SLOT_NAME, partyId), partyId, options);
38
+ }
39
+ };