@serfab/cadre-core 1.7.0 → 1.9.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 (188) hide show
  1. package/README.md +8 -6
  2. package/dist/cadre-node.d.ts +527 -206
  3. package/dist/cadre-node.js +1084 -528
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/control-authorization.d.ts +2 -2
  6. package/dist/control-authorization.js +1 -0
  7. package/dist/control-authorization.js.map +1 -1
  8. package/dist/control-cohort.d.ts +6 -0
  9. package/dist/control-cohort.js +6 -0
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +46 -8
  12. package/dist/control-database.js +97 -18
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +37 -14
  15. package/dist/control-formation-recorder.js +60 -10
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-read-retry.d.ts +13 -12
  18. package/dist/control-read-retry.js +14 -12
  19. package/dist/control-read-retry.js.map +1 -1
  20. package/dist/control-schema.d.ts +1 -1
  21. package/dist/control-schema.js +67 -5
  22. package/dist/control-schema.js.map +1 -1
  23. package/dist/control-stream.d.ts +9 -1
  24. package/dist/control-stream.js +24 -1
  25. package/dist/control-stream.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +12 -2
  27. package/dist/control-write-retry.js +13 -2
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/delegate-admission.d.ts +11 -8
  30. package/dist/delegate-admission.js +19 -11
  31. package/dist/delegate-admission.js.map +1 -1
  32. package/dist/diagnostics/connection-path.d.ts +0 -7
  33. package/dist/diagnostics/connection-path.js +0 -7
  34. package/dist/diagnostics/connection-path.js.map +1 -1
  35. package/dist/formation-approval.d.ts +7 -0
  36. package/dist/formation-approval.js +9 -3
  37. package/dist/formation-approval.js.map +1 -1
  38. package/dist/fs-atomic.d.ts +11 -2
  39. package/dist/fs-atomic.js +13 -3
  40. package/dist/fs-atomic.js.map +1 -1
  41. package/dist/hibernation-manager.d.ts +111 -8
  42. package/dist/hibernation-manager.js +234 -49
  43. package/dist/hibernation-manager.js.map +1 -1
  44. package/dist/index.d.ts +10 -10
  45. package/dist/index.js +25 -20
  46. package/dist/index.js.map +1 -1
  47. package/dist/joined-strand-store.d.ts +90 -25
  48. package/dist/joined-strand-store.js +223 -55
  49. package/dist/joined-strand-store.js.map +1 -1
  50. package/dist/key-store-file.js +14 -5
  51. package/dist/key-store-file.js.map +1 -1
  52. package/dist/link-budget.d.ts +367 -69
  53. package/dist/link-budget.js +375 -72
  54. package/dist/link-budget.js.map +1 -1
  55. package/dist/membership-connection-gater.d.ts +30 -43
  56. package/dist/membership-connection-gater.js +31 -42
  57. package/dist/membership-connection-gater.js.map +1 -1
  58. package/dist/node-local-snapshot.d.ts +6 -4
  59. package/dist/node-local-snapshot.js +7 -3
  60. package/dist/node-local-snapshot.js.map +1 -1
  61. package/dist/peer-dial.d.ts +24 -4
  62. package/dist/peer-dial.js +74 -5
  63. package/dist/peer-dial.js.map +1 -1
  64. package/dist/peer-join-backfill.d.ts +2 -10
  65. package/dist/peer-join-backfill.js +8 -8
  66. package/dist/peer-join-backfill.js.map +1 -1
  67. package/dist/push-fanout.d.ts +6 -0
  68. package/dist/push-fanout.js +6 -0
  69. package/dist/push-fanout.js.map +1 -1
  70. package/dist/relay-reservation.d.ts +3 -1
  71. package/dist/relay-reservation.js +3 -1
  72. package/dist/relay-reservation.js.map +1 -1
  73. package/dist/relay-server.d.ts +79 -0
  74. package/dist/relay-server.js +74 -0
  75. package/dist/relay-server.js.map +1 -0
  76. package/dist/seed-bootstrap.d.ts +57 -4
  77. package/dist/seed-bootstrap.js +107 -83
  78. package/dist/seed-bootstrap.js.map +1 -1
  79. package/dist/storage-scope.d.ts +33 -17
  80. package/dist/storage-scope.js +44 -23
  81. package/dist/storage-scope.js.map +1 -1
  82. package/dist/strand-addr-protocol.d.ts +58 -21
  83. package/dist/strand-addr-protocol.js +133 -85
  84. package/dist/strand-addr-protocol.js.map +1 -1
  85. package/dist/strand-database.d.ts +20 -2
  86. package/dist/strand-database.js +42 -5
  87. package/dist/strand-database.js.map +1 -1
  88. package/dist/strand-first-sync-gate.d.ts +59 -21
  89. package/dist/strand-first-sync-gate.js +58 -19
  90. package/dist/strand-first-sync-gate.js.map +1 -1
  91. package/dist/strand-formation-deadlines.d.ts +151 -0
  92. package/dist/strand-formation-deadlines.js +171 -0
  93. package/dist/strand-formation-deadlines.js.map +1 -0
  94. package/dist/strand-formation-manager.d.ts +63 -30
  95. package/dist/strand-formation-manager.js +67 -41
  96. package/dist/strand-formation-manager.js.map +1 -1
  97. package/dist/strand-formation-protocol.d.ts +45 -46
  98. package/dist/strand-formation-protocol.js +62 -126
  99. package/dist/strand-formation-protocol.js.map +1 -1
  100. package/dist/strand-fret-addrs.d.ts +54 -0
  101. package/dist/strand-fret-addrs.js +108 -0
  102. package/dist/strand-fret-addrs.js.map +1 -0
  103. package/dist/strand-instance-manager.d.ts +122 -44
  104. package/dist/strand-instance-manager.js +270 -114
  105. package/dist/strand-instance-manager.js.map +1 -1
  106. package/dist/strand-membership-writer.d.ts +6 -0
  107. package/dist/strand-membership-writer.js +11 -0
  108. package/dist/strand-membership-writer.js.map +1 -1
  109. package/dist/strand-network-state-file.d.ts +12 -0
  110. package/dist/strand-network-state-file.js +38 -0
  111. package/dist/strand-network-state-file.js.map +1 -0
  112. package/dist/strand-network-state.d.ts +111 -0
  113. package/dist/strand-network-state.js +191 -0
  114. package/dist/strand-network-state.js.map +1 -0
  115. package/dist/strand-revocation-enforcer.js +2 -1
  116. package/dist/strand-revocation-enforcer.js.map +1 -1
  117. package/dist/strand-solicitation.d.ts +36 -20
  118. package/dist/strand-solicitation.js +4 -4
  119. package/dist/strand-solicitation.js.map +1 -1
  120. package/dist/strand-wake-protocol.d.ts +80 -23
  121. package/dist/strand-wake-protocol.js +89 -64
  122. package/dist/strand-wake-protocol.js.map +1 -1
  123. package/dist/types.d.ts +148 -61
  124. package/dist/types.js +4 -3
  125. package/dist/types.js.map +1 -1
  126. package/package.json +11 -10
  127. package/src/cadre-node.ts +8400 -7776
  128. package/src/control-authorization.ts +2 -1
  129. package/src/control-cohort.ts +6 -0
  130. package/src/control-database.ts +122 -18
  131. package/src/control-formation-recorder.ts +354 -305
  132. package/src/control-read-retry.ts +14 -12
  133. package/src/control-schema.ts +67 -5
  134. package/src/control-stream.ts +25 -1
  135. package/src/control-write-retry.ts +13 -2
  136. package/src/delegate-admission.ts +23 -13
  137. package/src/diagnostics/connection-path.ts +0 -7
  138. package/src/formation-approval.ts +610 -604
  139. package/src/fs-atomic.ts +14 -4
  140. package/src/hibernation-manager.ts +638 -437
  141. package/src/index.ts +43 -65
  142. package/src/joined-strand-store.ts +247 -57
  143. package/src/key-store-file.ts +14 -5
  144. package/src/link-budget.ts +393 -74
  145. package/src/membership-connection-gater.ts +31 -45
  146. package/src/node-local-snapshot.ts +12 -7
  147. package/src/peer-dial.ts +81 -7
  148. package/src/peer-join-backfill.ts +10 -9
  149. package/src/push-fanout.ts +6 -0
  150. package/src/relay-reservation.ts +3 -1
  151. package/src/relay-server.ts +98 -0
  152. package/src/seed-bootstrap.ts +133 -91
  153. package/src/storage-scope.ts +44 -23
  154. package/src/strand-addr-protocol.ts +547 -458
  155. package/src/strand-database.ts +287 -242
  156. package/src/strand-first-sync-gate.ts +58 -19
  157. package/src/strand-formation-deadlines.ts +227 -0
  158. package/src/strand-formation-manager.ts +700 -647
  159. package/src/strand-formation-protocol.ts +106 -154
  160. package/src/strand-fret-addrs.ts +145 -0
  161. package/src/strand-instance-manager.ts +307 -140
  162. package/src/strand-membership-writer.ts +12 -0
  163. package/src/strand-network-state-file.ts +40 -0
  164. package/src/strand-network-state.ts +243 -0
  165. package/src/strand-revocation-enforcer.ts +2 -1
  166. package/src/strand-solicitation.ts +519 -500
  167. package/src/strand-wake-protocol.ts +401 -355
  168. package/src/types.ts +150 -61
  169. package/dist/strand-peer-book-file.d.ts +0 -12
  170. package/dist/strand-peer-book-file.js +0 -37
  171. package/dist/strand-peer-book-file.js.map +0 -1
  172. package/dist/strand-peer-book-protocol.d.ts +0 -159
  173. package/dist/strand-peer-book-protocol.js +0 -360
  174. package/dist/strand-peer-book-protocol.js.map +0 -1
  175. package/dist/strand-peer-book-swap.d.ts +0 -104
  176. package/dist/strand-peer-book-swap.js +0 -284
  177. package/dist/strand-peer-book-swap.js.map +0 -1
  178. package/dist/strand-peer-book.d.ts +0 -159
  179. package/dist/strand-peer-book.js +0 -354
  180. package/dist/strand-peer-book.js.map +0 -1
  181. package/dist/strand-peer-observer.d.ts +0 -88
  182. package/dist/strand-peer-observer.js +0 -180
  183. package/dist/strand-peer-observer.js.map +0 -1
  184. package/src/strand-peer-book-file.ts +0 -39
  185. package/src/strand-peer-book-protocol.ts +0 -463
  186. package/src/strand-peer-book-swap.ts +0 -338
  187. package/src/strand-peer-book.ts +0 -468
  188. package/src/strand-peer-observer.ts +0 -235
@@ -1,468 +0,0 @@
1
- /**
2
- * Node-local, NON-replicated **strand peer book**: for each strand this node runs,
3
- * the strand peers it knows an address for — the other parties' strand transport
4
- * peers, learned at formation and observed on every connection — with their
5
- * last-known strand-network addresses and when this node last held a connection
6
- * to them. Read on every launch, hibernation resume and periodic address refresh,
7
- * so a machine that restarts dials the peers it was talking to BEFORE it does
8
- * anything else, instead of coming back up alone.
9
- *
10
- * Why it exists (gotchoices/sereus#18): a cross-party strand learned the other
11
- * party's strand addresses exactly once, at formation, into an in-memory map, and
12
- * the strand-addr RPC answers own-party siblings only. After a restart neither
13
- * relay-only party had any address for the other: the FRET ring and the Optimystic
14
- * cohort each held only self, the strand reported `active`, local writes
15
- * succeeded, nothing replicated.
16
- *
17
- * NOTE: accepted design boundary (maintainer decision, 2026-09-27) — this is a
18
- * per-machine record in the machine's own storage, NOT a registry inside the
19
- * strand database. An in-strand registry (`Strand.MemberPeer` rows carrying
20
- * addresses) would let a member reach peers it has never connected to while they
21
- * are offline; nothing needs that today, and it is ruled out for now. Revisit only
22
- * if a case needs addresses to reach members that are offline.
23
- *
24
- * Three implementations mirroring `bootstrap-peer-store.ts` (read that first —
25
- * same "must outlive the process, storage differs per platform" problem, both
26
- * persistent forms share `node-local-snapshot.ts`):
27
- * - {@link MemoryStrandPeerBookStore} (this module, cross-platform) — ephemeral;
28
- * the default when no store is injected via `CadreNodeConfig.strandPeers`. A
29
- * node using it is exactly the pre-#18 shape after a restart.
30
- * - {@link PersistentStrandPeerBookStore} (this module, cross-platform) — durable
31
- * over any `DurableSlot` the embedding app supplies.
32
- * - `FileStrandPeerBookStore` — the above over a Node file in the node's state
33
- * directory; Node-only, behind the subpath
34
- * `@serfab/cadre-core/strand-peer-book-file` (same isolation pattern as
35
- * `bootstrap-peer-store-file`) so `node:fs` never lands in the RN/browser graph.
36
- *
37
- * One store per NODE holding every strand, rather than one slot per strand: the
38
- * snapshot machinery writes one slot whole, and a node runs a handful of strands
39
- * with at most {@link MAX_STRAND_PEERS} peers each — a few KB. The embedder
40
- * therefore wires one slot, like the other node-local stores.
41
- *
42
- * **Nothing here is trust-bearing.** An address grants no authority: the dialed
43
- * peer authenticates by peer id at the handshake, and every address is bound to
44
- * the peer id it is filed under (see {@link StrandPeerEntry.addrs}), so a dial
45
- * cannot be redirected to whoever answers. A junk address costs one failed dial per
46
- * launch until it ages out or the peer's next connection replaces the entry. What a
47
- * persistent backend's loader DOES owe is dropping structurally junk entries rather
48
- * than carrying them into the dial loop.
49
- */
50
- import debug from 'debug';
51
- import { peerIdFromString } from '@libp2p/peer-id';
52
- import { NodeLocalSnapshot, type DurableSlot, type NodeLocalSnapshotSpec } from './node-local-snapshot.js';
53
- import { groupAddrsByPeerId } from './peer-addr-book.js';
54
- import { orderSignalingFirst } from './peer-record.js';
55
- import { MAX_STRAND_ADDRS } from './strand-formation-protocol.js';
56
-
57
- const log = debug('sereus:cadre:strand-peer-book');
58
-
59
- /**
60
- * Cap on the peers remembered per strand. When full, the entry with the smallest
61
- * `max(issuedAt, lastSeenAt)` is evicted. Sixteen is generous for the parties a
62
- * strand has today (two, occasionally a few), and it bounds the worst case of a
63
- * launch dialing dead entries: a failed relayed dial costs up to 14 s at the
64
- * declared link, so the cap — with aging — is what keeps a stale book from
65
- * stalling bring-up for minutes.
66
- */
67
- export const MAX_STRAND_PEERS = 16;
68
-
69
- /**
70
- * Default age after which an entry nobody has refreshed is dropped: 14 days from
71
- * `max(issuedAt, lastSeenAt)`. A live peer refreshes its entry on every connection,
72
- * so only a peer this node has not reached in two weeks ages out. Overridable per
73
- * store via {@link StrandPeerBookOptions.maxAgeMs}.
74
- */
75
- export const STRAND_PEER_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
76
-
77
- /**
78
- * One strand peer this node knows an address for. `sig` is set only on an entry the
79
- * peer signed itself and the book swap carried here (`strand-peer-book-protocol.ts`),
80
- * or on this node's own entry, which the swap files under the node's own strand
81
- * transport id; a formation-carried or observed entry is unsigned and local-only.
82
- */
83
- export interface StrandPeerEntry {
84
- /** The peer's STRAND transport peer id (never a control peer id). */
85
- peerId: string;
86
- /**
87
- * Multiaddr strings, each bound to `peerId` (trailing `/p2p/<peerId>`, a relay
88
- * hop only with that destination behind it), signaling-first, at most
89
- * `MAX_STRAND_ADDRS`. Anything that does not attribute to `peerId` is dropped
90
- * at merge.
91
- */
92
- addrs: string[];
93
- /**
94
- * Signer's clock, ms: when the peer itself issued these addresses. 0 for an
95
- * entry this node observed or was handed unsigned.
96
- */
97
- issuedAt: number;
98
- /**
99
- * base64url Ed25519 signature by the peer's strand transport key over the canonical
100
- * JSON of `{ v, strandId, peerId, addrs, issuedAt }` (`signedStrandPeerEntryPayload`);
101
- * absent = local-only, never forwarded.
102
- */
103
- sig?: string;
104
- /**
105
- * This node's clock, ms: when it last held a connection to the peer — or, for a
106
- * formation-carried entry, when the responder disclosed the addresses live — and 0
107
- * for an entry it has never connected to. Local, never on the wire.
108
- */
109
- lastSeenAt: number;
110
- }
111
-
112
- export interface StrandPeerBookStore {
113
- /** Party this store is scoped to. */
114
- readonly partyId: string;
115
-
116
- /**
117
- * Snapshot of one strand's LIVE entries, freshest first
118
- * (`max(issuedAt, lastSeenAt)` descending). Empty for an unknown strand. Aged-out
119
- * entries are never returned. Every backend copies the entries per call, so the
120
- * result is decoupled from later {@link merge} / {@link forget} calls.
121
- */
122
- entries(strandId: string): StrandPeerEntry[];
123
-
124
- /**
125
- * Insert or replace by `(strandId, entry.peerId)` under the merge rule
126
- * ({@link mergeStrandPeerEntry}): a signed entry never yields to an unsigned
127
- * one; between two signed the greater `issuedAt` wins; between two unsigned the
128
- * greater `lastSeenAt` wins; `lastSeenAt` is always the max of old and new. Then
129
- * the strand's aged-out entries are dropped and, past {@link MAX_STRAND_PEERS},
130
- * the stalest evicted.
131
- *
132
- * Implementations MUST reflect the merge in {@link entries} SYNCHRONOUSLY; the
133
- * returned promise tracks durability only. An entry whose peer id does not parse
134
- * is logged and dropped rather than thrown — every writer is a best-effort event
135
- * handler.
136
- */
137
- merge(strandId: string, entry: StrandPeerEntry): Promise<void>;
138
-
139
- /**
140
- * Drop one peer, or the whole strand when `peerId` is omitted — for a strand this
141
- * node unpublished or left, whose peers must not stay dial targets. Forgetting
142
- * something absent is a no-op. Same contract as {@link merge}: reflected
143
- * synchronously, the promise tracks durability.
144
- */
145
- forget(strandId: string, peerId?: string): Promise<void>;
146
- }
147
-
148
- /** Tuning shared by every backend. */
149
- export interface StrandPeerBookOptions {
150
- /** Age after which an unrefreshed entry is dropped. Default {@link STRAND_PEER_MAX_AGE_MS}. */
151
- maxAgeMs?: number;
152
- /** Clock, for tests. Default `Date.now`. */
153
- now?: () => number;
154
- }
155
-
156
- /** One strand's peers, keyed by peer id — the unit the persistent envelope stores. */
157
- type StrandPeers = Record<string, StrandPeerEntry>;
158
-
159
- interface ResolvedOptions {
160
- maxAgeMs: number;
161
- now: () => number;
162
- }
163
-
164
- function resolveOptions(options?: StrandPeerBookOptions): ResolvedOptions {
165
- return { maxAgeMs: options?.maxAgeMs ?? STRAND_PEER_MAX_AGE_MS, now: options?.now ?? Date.now };
166
- }
167
-
168
- /** The moment an entry was last vouched for, by whichever clock spoke last. */
169
- export function strandPeerFreshness(entry: StrandPeerEntry): number {
170
- return Math.max(entry.issuedAt, entry.lastSeenAt);
171
- }
172
-
173
- /**
174
- * The merge rule, in one place. Returns the entry to hold for `incoming.peerId`
175
- * given what is already held — always a NEW object, so a held entry is never mutated
176
- * in place (the persistent backend's snapshot `put` replaces rather than patches).
177
- * `incoming` is assumed sanitized ({@link sanitizeStrandPeerEntry}).
178
- *
179
- * - A signed entry never yields to an unsigned one for the same peer: the peer's own
180
- * statement outranks this node's observation of it.
181
- * - Between two signed entries the greater `issuedAt` wins; between two unsigned
182
- * entries the greater `lastSeenAt` wins. A tie goes to `incoming`.
183
- * - `lastSeenAt` is always the max of old and new whichever entry wins, because it
184
- * is this node's own observation and no remote statement can lower it.
185
- */
186
- export function mergeStrandPeerEntry(
187
- existing: StrandPeerEntry | undefined,
188
- incoming: StrandPeerEntry
189
- ): StrandPeerEntry {
190
- const winner = existing === undefined || incomingWins(existing, incoming) ? incoming : existing;
191
- return {
192
- ...winner,
193
- addrs: [...winner.addrs],
194
- lastSeenAt: Math.max(existing?.lastSeenAt ?? 0, incoming.lastSeenAt)
195
- };
196
- }
197
-
198
- function incomingWins(existing: StrandPeerEntry, incoming: StrandPeerEntry): boolean {
199
- if (existing.sig !== undefined && incoming.sig === undefined) return false;
200
- if (existing.sig === undefined && incoming.sig !== undefined) return true;
201
- return existing.sig !== undefined
202
- ? incoming.issuedAt >= existing.issuedAt
203
- : incoming.lastSeenAt >= existing.lastSeenAt;
204
- }
205
-
206
- /**
207
- * Shape an entry for storage: the peer id must parse (else `undefined` — not a dial
208
- * target at all), and `addrs` is reduced to the entries that attribute to `peerId`
209
- * under `groupAddrsByPeerId`'s rule, de-duplicated, capped at `MAX_STRAND_ADDRS`, and
210
- * — for an UNSIGNED entry only — reordered signaling-first. A signed entry keeps the
211
- * signer's order: its signature covers the address list as signed, and the swap
212
- * forwards the stored copy, so any reordering here would make every forwarded entry
213
- * fail verification at the next peer (every signer already orders signaling-first). An
214
- * entry may legitimately end up with NO addresses — a signed "not reachable right
215
- * now" from the swap protocol is truthful and displaces a stale reachable list — so
216
- * an empty list is kept, not rejected.
217
- */
218
- export function sanitizeStrandPeerEntry(entry: StrandPeerEntry): StrandPeerEntry | undefined {
219
- try {
220
- peerIdFromString(entry.peerId);
221
- } catch (error) {
222
- log('dropping entry for unparsable peer id %s: %o', entry.peerId, error);
223
- return undefined;
224
- }
225
- const attributed = (groupAddrsByPeerId([...entry.addrs]).get(entry.peerId) ?? []).map((ma) => ma.toString());
226
- const addrs = (entry.sig === undefined ? orderSignalingFirst(attributed) : attributed).slice(0, MAX_STRAND_ADDRS);
227
- if (addrs.length < entry.addrs.length) {
228
- log('peer %s: kept %d of %d addr(s) — the rest do not attribute to it or exceed the cap',
229
- entry.peerId, addrs.length, entry.addrs.length);
230
- }
231
- return {
232
- peerId: entry.peerId,
233
- addrs,
234
- issuedAt: entry.issuedAt,
235
- ...(entry.sig !== undefined && { sig: entry.sig }),
236
- lastSeenAt: entry.lastSeenAt
237
- };
238
- }
239
-
240
- /**
241
- * Apply {@link mergeStrandPeerEntry} to one strand's map and re-bound it: aged-out
242
- * entries dropped, then the stalest evicted past {@link MAX_STRAND_PEERS}. Returns a
243
- * NEW map (the persistent backend's snapshot `put` replaces rather than mutates),
244
- * or `undefined` when the incoming entry was unusable and nothing changed.
245
- *
246
- * NOTE: aging is time-based only. The book cannot see dial outcomes (dials happen
247
- * inside libp2p and Optimystic), so a dead entry is re-dialed on every launch and
248
- * refresh until it ages out or the peer's next connection replaces it — bounded by
249
- * the peer cap and the 14-day age. If a case ever shows a dead entry re-dialed every
250
- * tick for two weeks, the lever is observing `connection:close` / dial failures
251
- * here, not a shorter age.
252
- */
253
- function mergeIntoStrand(
254
- strand: StrandPeers | undefined,
255
- incoming: StrandPeerEntry,
256
- options: ResolvedOptions
257
- ): StrandPeers | undefined {
258
- const entry = sanitizeStrandPeerEntry(incoming);
259
- if (!entry) return undefined;
260
- const merged = mergeStrandPeerEntry(strand?.[entry.peerId], entry);
261
- // Pruned AFTER the insert, so an incoming entry that is already past the age
262
- // (a swap forwarding a months-old statement) never lands at all.
263
- return evictPastCap(pruneAged({ ...strand, [entry.peerId]: merged }, options));
264
- }
265
-
266
- /**
267
- * The strand's map without the entries older than `maxAgeMs` (a new object only when
268
- * something dropped).
269
- *
270
- * Nothing is exempt, the node's OWN entry included: the book swap
271
- * (`strand-peer-book-swap.ts`) keeps the entry it SENDS in memory and re-signs it on
272
- * every address change and every launch, so the stored copy is a record for
273
- * diagnostics and the seed's self-skip, and losing it to age costs nothing. That is
274
- * why the store need not tell self from a peer.
275
- */
276
- function pruneAged(strand: StrandPeers, options: ResolvedOptions): StrandPeers {
277
- const cutoff = options.now() - options.maxAgeMs;
278
- const live = Object.values(strand).filter((entry) => strandPeerFreshness(entry) >= cutoff);
279
- return live.length === Object.keys(strand).length ? strand : Object.fromEntries(live.map((e) => [e.peerId, e]));
280
- }
281
-
282
- /**
283
- * Drop the stalest entries until the strand holds at most {@link MAX_STRAND_PEERS}.
284
- *
285
- * NOTE: freshness ranks a forwarded statement (signer's `issuedAt`, up to five minutes
286
- * ahead of this clock) above a peer this node has itself connected to, so one swap
287
- * frame of sixteen fresh self-signed entries evicts every met peer — see
288
- * `backlog/debt-strand-peer-book-remote-write-bounds`.
289
- */
290
- function evictPastCap(strand: StrandPeers): StrandPeers {
291
- const entries = Object.values(strand);
292
- if (entries.length <= MAX_STRAND_PEERS) return strand;
293
- const kept = sortFreshestFirst(entries).slice(0, MAX_STRAND_PEERS);
294
- log('evicting %d stalest peer(s) past the %d-peer cap', entries.length - kept.length, MAX_STRAND_PEERS);
295
- return Object.fromEntries(kept.map((e) => [e.peerId, e]));
296
- }
297
-
298
- function sortFreshestFirst(entries: StrandPeerEntry[]): StrandPeerEntry[] {
299
- return [...entries].sort((a, b) => strandPeerFreshness(b) - strandPeerFreshness(a));
300
- }
301
-
302
- /** The live entries of one strand, freshest first, copied. */
303
- function liveEntries(strand: StrandPeers | undefined, options: ResolvedOptions): StrandPeerEntry[] {
304
- if (!strand) return [];
305
- return sortFreshestFirst(Object.values(pruneAged(strand, options)))
306
- .map((entry) => ({ ...entry, addrs: [...entry.addrs] }));
307
- }
308
-
309
- /** The strand's map without `peerId`, or `undefined` when that leaves nothing (or nothing was there). */
310
- function withoutPeer(strand: StrandPeers | undefined, peerId: string): StrandPeers | undefined {
311
- if (!strand || !(peerId in strand)) return strand;
312
- const { [peerId]: _dropped, ...rest } = strand;
313
- return Object.keys(rest).length === 0 ? undefined : rest;
314
- }
315
-
316
- /**
317
- * Ephemeral in-memory book for nodes without durable storage (tests, and the
318
- * default when nothing is injected). Same contract, no disk: a node using this
319
- * forgets every strand peer on restart — the #18 shape.
320
- */
321
- export class MemoryStrandPeerBookStore implements StrandPeerBookStore {
322
- private readonly strands = new Map<string, StrandPeers>();
323
- private readonly options: ResolvedOptions;
324
-
325
- constructor(readonly partyId: string, options?: StrandPeerBookOptions) {
326
- this.options = resolveOptions(options);
327
- }
328
-
329
- entries(strandId: string): StrandPeerEntry[] {
330
- return liveEntries(this.strands.get(strandId), this.options);
331
- }
332
-
333
- async merge(strandId: string, entry: StrandPeerEntry): Promise<void> {
334
- const next = mergeIntoStrand(this.strands.get(strandId), entry, this.options);
335
- if (next) {
336
- this.strands.set(strandId, next);
337
- log('strand %s: peer %s merged (party=%s, addrs=%d)', strandId, entry.peerId, this.partyId, entry.addrs.length);
338
- }
339
- }
340
-
341
- async forget(strandId: string, peerId?: string): Promise<void> {
342
- if (peerId === undefined) {
343
- if (this.strands.delete(strandId)) {
344
- log('strand %s: book forgotten (party=%s)', strandId, this.partyId);
345
- }
346
- return;
347
- }
348
- const next = withoutPeer(this.strands.get(strandId), peerId);
349
- if (next === undefined) {
350
- this.strands.delete(strandId);
351
- } else {
352
- this.strands.set(strandId, next);
353
- }
354
- }
355
- }
356
-
357
- /**
358
- * What the store persists: `strands` maps strandId -> (peerId -> {@link StrandPeerEntry}).
359
- *
360
- * A structurally junk PEER entry is dropped and its siblings retained; a strand
361
- * whose value is not a record, or that holds no usable peer, is dropped as a whole
362
- * (`drop-entry`) — nothing here is trust-bearing (see the module comment) and the
363
- * book is a restarted node's only way back to the other parties, so discarding the
364
- * whole set over one bad entry would be strictly worse. What "junk" means: a peer id
365
- * that does not parse, a non-array or non-string address list, a non-number
366
- * `issuedAt` / `lastSeenAt`, or a non-string `sig`. Aged-out entries are dropped at
367
- * load too, so a slot last written months ago yields nothing to dial.
368
- */
369
- function strandPeerBookSnapshotSpec(options: ResolvedOptions): NodeLocalSnapshotSpec<StrandPeers> {
370
- return {
371
- label: 'strand peer book',
372
- payloadKey: 'strands',
373
- unusableEntry: 'drop-entry',
374
- acceptEntry: (strandId, raw) => {
375
- if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return undefined;
376
- let strand: StrandPeers = {};
377
- for (const [peerId, value] of Object.entries(raw as Record<string, unknown>)) {
378
- const entry = acceptPeerEntry(peerId, value);
379
- if (!entry) {
380
- log('strand %s: dropping unusable persisted entry for %s', strandId, peerId);
381
- continue;
382
- }
383
- strand[peerId] = entry;
384
- }
385
- strand = pruneAged(strand, options);
386
- return Object.keys(strand).length === 0 ? undefined : strand;
387
- }
388
- };
389
- }
390
-
391
- function acceptPeerEntry(peerId: string, value: unknown): StrandPeerEntry | undefined {
392
- if (typeof value !== 'object' || value === null) return undefined;
393
- const { addrs, issuedAt, sig, lastSeenAt } = value as Record<string, unknown>;
394
- if (!Array.isArray(addrs) || addrs.some((addr) => typeof addr !== 'string' || addr.length === 0)) return undefined;
395
- if (typeof issuedAt !== 'number' || typeof lastSeenAt !== 'number') return undefined;
396
- if (sig !== undefined && typeof sig !== 'string') return undefined;
397
- return sanitizeStrandPeerEntry({
398
- peerId,
399
- addrs: addrs as string[],
400
- issuedAt,
401
- ...(sig !== undefined && { sig }),
402
- lastSeenAt
403
- });
404
- }
405
-
406
- /**
407
- * Durable {@link StrandPeerBookStore} over an app-supplied {@link DurableSlot} —
408
- * the cross-platform half of every persistent backend (the Node
409
- * `FileStrandPeerBookStore` is this class over a file slot).
410
- *
411
- * Construct via {@link open}. Load and persist policy — what an absent, corrupt,
412
- * foreign-party or unreadable slot does (the last one THROWS, as
413
- * `PersistentBootstrapPeerStore.open` does), and what a failed persist does — is
414
- * documented once on `NodeLocalSnapshot`; this class only supplies the payload shape
415
- * and the drop-the-bad-entry policy above. Writes are serialised in-process by the
416
- * snapshot's write chain, so several strands' identify handlers merging at once
417
- * cannot interleave partial snapshots.
418
- */
419
- export class PersistentStrandPeerBookStore implements StrandPeerBookStore {
420
- private constructor(
421
- private readonly snapshot: NodeLocalSnapshot<StrandPeers>,
422
- private readonly options: ResolvedOptions
423
- ) {}
424
-
425
- /** Load (or cold-start) the party's book from `slot`. */
426
- static async open(
427
- slot: DurableSlot,
428
- partyId: string,
429
- options?: StrandPeerBookOptions
430
- ): Promise<PersistentStrandPeerBookStore> {
431
- const resolved = resolveOptions(options);
432
- return new PersistentStrandPeerBookStore(
433
- await NodeLocalSnapshot.open(slot, partyId, strandPeerBookSnapshotSpec(resolved)),
434
- resolved
435
- );
436
- }
437
-
438
- get partyId(): string {
439
- return this.snapshot.partyId;
440
- }
441
-
442
- entries(strandId: string): StrandPeerEntry[] {
443
- return liveEntries(this.snapshot.entrySnapshot().get(strandId), this.options);
444
- }
445
-
446
- /** Merge, visible via {@link entries} synchronously, then the full snapshot is persisted. */
447
- merge(strandId: string, entry: StrandPeerEntry): Promise<void> {
448
- const next = mergeIntoStrand(this.snapshot.entrySnapshot().get(strandId), entry, this.options);
449
- if (!next) return Promise.resolve();
450
- log('strand %s: peer %s merged (party=%s, addrs=%d); persisting',
451
- strandId, entry.peerId, this.partyId, entry.addrs.length);
452
- return this.snapshot.put(strandId, next);
453
- }
454
-
455
- /** Forget, gone from {@link entries} synchronously, then persisted — unless nothing was there. */
456
- forget(strandId: string, peerId?: string): Promise<void> {
457
- if (peerId === undefined) {
458
- if (this.snapshot.has(strandId)) {
459
- log('strand %s: book forgotten (party=%s); persisting', strandId, this.partyId);
460
- }
461
- return this.snapshot.remove(strandId);
462
- }
463
- const current = this.snapshot.entrySnapshot().get(strandId);
464
- const next = withoutPeer(current, peerId);
465
- if (next === current) return Promise.resolve();
466
- return next === undefined ? this.snapshot.remove(strandId) : this.snapshot.put(strandId, next);
467
- }
468
- }