@optimystic/db-p2p 0.1.1 → 0.1.3

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 (64) hide show
  1. package/{readme.md → README.md} +7 -0
  2. package/dist/index.min.js +31 -30
  3. package/dist/index.min.js.map +4 -4
  4. package/dist/src/cluster/cluster-repo.d.ts +27 -0
  5. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  6. package/dist/src/cluster/cluster-repo.js +139 -18
  7. package/dist/src/cluster/cluster-repo.js.map +1 -1
  8. package/dist/src/cluster/service.d.ts +13 -2
  9. package/dist/src/cluster/service.d.ts.map +1 -1
  10. package/dist/src/cluster/service.js +17 -7
  11. package/dist/src/cluster/service.js.map +1 -1
  12. package/dist/src/index.d.ts +1 -1
  13. package/dist/src/index.d.ts.map +1 -1
  14. package/dist/src/index.js +1 -1
  15. package/dist/src/index.js.map +1 -1
  16. package/dist/src/libp2p-node.d.ts +13 -2
  17. package/dist/src/libp2p-node.d.ts.map +1 -1
  18. package/dist/src/libp2p-node.js +35 -16
  19. package/dist/src/libp2p-node.js.map +1 -1
  20. package/dist/src/protocol-client.d.ts.map +1 -1
  21. package/dist/src/protocol-client.js +8 -7
  22. package/dist/src/protocol-client.js.map +1 -1
  23. package/dist/src/repo/cluster-coordinator.d.ts +7 -2
  24. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  25. package/dist/src/repo/cluster-coordinator.js +18 -3
  26. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  27. package/dist/src/repo/coordinator-repo.d.ts +26 -3
  28. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  29. package/dist/src/repo/coordinator-repo.js +117 -22
  30. package/dist/src/repo/coordinator-repo.js.map +1 -1
  31. package/dist/src/repo/service.d.ts +13 -2
  32. package/dist/src/repo/service.d.ts.map +1 -1
  33. package/dist/src/repo/service.js +25 -12
  34. package/dist/src/repo/service.js.map +1 -1
  35. package/dist/src/storage/memory-storage.d.ts +15 -0
  36. package/dist/src/storage/memory-storage.d.ts.map +1 -1
  37. package/dist/src/storage/memory-storage.js +23 -4
  38. package/dist/src/storage/memory-storage.js.map +1 -1
  39. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  40. package/dist/src/storage/storage-repo.js.map +1 -1
  41. package/dist/src/sync/service.d.ts.map +1 -1
  42. package/dist/src/sync/service.js +7 -2
  43. package/dist/src/sync/service.js.map +1 -1
  44. package/package.json +27 -21
  45. package/src/cluster/cluster-repo.ts +836 -711
  46. package/src/cluster/service.ts +44 -31
  47. package/src/index.ts +1 -1
  48. package/src/libp2p-key-network.ts +334 -334
  49. package/src/libp2p-node.ts +371 -339
  50. package/src/network/network-manager-service.ts +334 -334
  51. package/src/protocol-client.ts +53 -54
  52. package/src/repo/client.ts +112 -112
  53. package/src/repo/cluster-coordinator.ts +613 -592
  54. package/src/repo/coordinator-repo.ts +269 -137
  55. package/src/repo/service.ts +237 -219
  56. package/src/storage/block-storage.ts +182 -182
  57. package/src/storage/memory-storage.ts +24 -5
  58. package/src/storage/storage-repo.ts +321 -320
  59. package/src/sync/service.ts +7 -6
  60. package/dist/src/storage/file-storage.d.ts +0 -30
  61. package/dist/src/storage/file-storage.d.ts.map +0 -1
  62. package/dist/src/storage/file-storage.js +0 -127
  63. package/dist/src/storage/file-storage.js.map +0 -1
  64. package/src/storage/file-storage.ts +0 -163
@@ -1,334 +1,334 @@
1
- import type { AbortOptions, Libp2p, PeerId, Stream } from "@libp2p/interface";
2
- import { toString as u8ToString } from 'uint8arrays/to-string'
3
- import type { ClusterPeers, FindCoordinatorOptions, IKeyNetwork, IPeerNetwork } from "@optimystic/db-core";
4
- import { peerIdFromString } from '@libp2p/peer-id'
5
- import { multiaddr } from '@multiformats/multiaddr'
6
- import type { FretService } from 'p2p-fret'
7
- import { hashKey } from 'p2p-fret'
8
- import { createLogger } from './logger.js'
9
-
10
- interface WithFretService { services?: { fret?: FretService } }
11
-
12
- /**
13
- * Configuration options for self-coordination behavior
14
- */
15
- export interface SelfCoordinationConfig {
16
- /** Time (ms) after last connection before allowing self-coordination. Default: 30000 */
17
- gracePeriodMs?: number;
18
- /** Threshold for suspicious network shrinkage (0-1). >50% drop is suspicious. Default: 0.5 */
19
- shrinkageThreshold?: number;
20
- /** Allow self-coordination at all. Default: true (for testing). Set false in production. */
21
- allowSelfCoordination?: boolean;
22
- }
23
-
24
- /**
25
- * Decision result from self-coordination guard
26
- */
27
- export interface SelfCoordinationDecision {
28
- allow: boolean;
29
- reason: 'bootstrap-node' | 'partition-detected' | 'suspicious-shrinkage' | 'grace-period-not-elapsed' | 'extended-isolation' | 'disabled';
30
- warn?: boolean;
31
- }
32
-
33
- export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
34
- private readonly selfCoordinationConfig: Required<SelfCoordinationConfig>;
35
- private networkHighWaterMark = 1;
36
- private lastConnectedTime = Date.now();
37
-
38
- constructor(
39
- private readonly libp2p: Libp2p,
40
- private readonly clusterSize: number = 16,
41
- selfCoordinationConfig?: SelfCoordinationConfig
42
- ) {
43
- this.selfCoordinationConfig = {
44
- gracePeriodMs: selfCoordinationConfig?.gracePeriodMs ?? 30_000,
45
- shrinkageThreshold: selfCoordinationConfig?.shrinkageThreshold ?? 0.5,
46
- allowSelfCoordination: selfCoordinationConfig?.allowSelfCoordination ?? true
47
- };
48
- this.setupConnectionTracking();
49
- }
50
-
51
- // coordinator cache: key (base64url) -> peerId until expiry (bounded LRU-ish via Map insertion order)
52
- private readonly coordinatorCache = new Map<string, { id: PeerId, expires: number }>()
53
- private static readonly MAX_CACHE_ENTRIES = 1000
54
- private readonly log = createLogger('libp2p-key-network')
55
-
56
- private toCacheKey(key: Uint8Array): string { return u8ToString(key, 'base64url') }
57
-
58
- /**
59
- * Set up connection event tracking to update high water mark and last connected time.
60
- */
61
- private setupConnectionTracking(): void {
62
- this.libp2p.addEventListener('connection:open', () => {
63
- this.updateNetworkObservations();
64
- });
65
- }
66
-
67
- /**
68
- * Update network high water mark and last connected time.
69
- * Called on new connections.
70
- */
71
- private updateNetworkObservations(): void {
72
- const connections = this.libp2p.getConnections?.() ?? [];
73
- if (connections.length > 0) {
74
- this.lastConnectedTime = Date.now();
75
- }
76
-
77
- try {
78
- const fret = this.getFret();
79
- const estimate = fret.getNetworkSizeEstimate();
80
- if (estimate.size_estimate > this.networkHighWaterMark) {
81
- this.networkHighWaterMark = estimate.size_estimate;
82
- this.log('network-hwm-updated mark=%d confidence=%f', this.networkHighWaterMark, estimate.confidence);
83
- }
84
- } catch {
85
- // FRET not available - use connection count as fallback
86
- const connectionCount = this.libp2p.getConnections?.().length ?? 0;
87
- const observedSize = connectionCount + 1; // +1 for self
88
- if (observedSize > this.networkHighWaterMark) {
89
- this.networkHighWaterMark = observedSize;
90
- this.log('network-hwm-updated mark=%d (from connections)', this.networkHighWaterMark);
91
- }
92
- }
93
- }
94
-
95
- /**
96
- * Determine if self-coordination should be allowed based on network observations.
97
- *
98
- * Principle: If we've ever seen a larger network, assume our connectivity is the problem,
99
- * not the network shrinking.
100
- */
101
- shouldAllowSelfCoordination(): SelfCoordinationDecision {
102
- // Check global disable
103
- if (!this.selfCoordinationConfig.allowSelfCoordination) {
104
- return { allow: false, reason: 'disabled' };
105
- }
106
-
107
- // Case 1: New/bootstrap node (never seen larger network)
108
- if (this.networkHighWaterMark <= 1) {
109
- return { allow: true, reason: 'bootstrap-node' };
110
- }
111
-
112
- // Case 2: Check for partition via FRET
113
- try {
114
- const fret = this.getFret();
115
- if (fret.detectPartition()) {
116
- this.log('self-coord-blocked: partition-detected');
117
- return { allow: false, reason: 'partition-detected' };
118
- }
119
-
120
- // Case 3: Suspicious network shrinkage (>threshold drop)
121
- const estimate = fret.getNetworkSizeEstimate();
122
- const shrinkage = 1 - (estimate.size_estimate / this.networkHighWaterMark);
123
- if (shrinkage > this.selfCoordinationConfig.shrinkageThreshold) {
124
- this.log('self-coord-blocked: suspicious-shrinkage current=%d hwm=%d shrinkage=%f',
125
- estimate.size_estimate, this.networkHighWaterMark, shrinkage);
126
- return { allow: false, reason: 'suspicious-shrinkage' };
127
- }
128
- } catch {
129
- // FRET not available - be conservative
130
- const connections = this.libp2p.getConnections?.() ?? [];
131
- if (this.networkHighWaterMark > 1 && connections.length === 0) {
132
- // We've seen peers before but have none now - suspicious
133
- const timeSinceConnection = Date.now() - this.lastConnectedTime;
134
- if (timeSinceConnection < this.selfCoordinationConfig.gracePeriodMs) {
135
- this.log('self-coord-blocked: grace-period-not-elapsed since=%dms', timeSinceConnection);
136
- return { allow: false, reason: 'grace-period-not-elapsed' };
137
- }
138
- }
139
- }
140
-
141
- // Case 4: Recently connected (grace period not elapsed)
142
- const timeSinceConnection = Date.now() - this.lastConnectedTime;
143
- if (timeSinceConnection < this.selfCoordinationConfig.gracePeriodMs) {
144
- const connections = this.libp2p.getConnections?.() ?? [];
145
- // Only block if we have no connections but did recently
146
- if (connections.length === 0) {
147
- this.log('self-coord-blocked: grace-period-not-elapsed since=%dms', timeSinceConnection);
148
- return { allow: false, reason: 'grace-period-not-elapsed' };
149
- }
150
- }
151
-
152
- // Case 5: Extended isolation with gradual shrinkage - allow with warning
153
- this.log('self-coord-allowed: extended-isolation (warn)');
154
- return { allow: true, reason: 'extended-isolation', warn: true };
155
- }
156
-
157
- public recordCoordinator(key: Uint8Array, peerId: PeerId, ttlMs = 30 * 60 * 1000): void {
158
- const k = this.toCacheKey(key)
159
- const now = Date.now()
160
- for (const [ck, entry] of this.coordinatorCache) {
161
- if (entry.expires <= now) this.coordinatorCache.delete(ck)
162
- }
163
- this.coordinatorCache.set(k, { id: peerId, expires: now + ttlMs })
164
- while (this.coordinatorCache.size > Libp2pKeyPeerNetwork.MAX_CACHE_ENTRIES) {
165
- const firstKey = this.coordinatorCache.keys().next().value as string | undefined
166
- if (firstKey == null) break
167
- this.coordinatorCache.delete(firstKey)
168
- }
169
- }
170
-
171
- private getCachedCoordinator(key: Uint8Array): PeerId | undefined {
172
- const k = this.toCacheKey(key)
173
- const hit = this.coordinatorCache.get(k)
174
- if (hit && hit.expires > Date.now()) return hit.id
175
- if (hit) this.coordinatorCache.delete(k)
176
- return undefined
177
- }
178
-
179
- connect(peerId: PeerId, protocol: string, _options?: AbortOptions): Promise<Stream> {
180
- const conns = (this.libp2p as any).getConnections?.(peerId) ?? []
181
- if (Array.isArray(conns) && conns.length > 0 && typeof conns[0]?.newStream === 'function') {
182
- return conns[0].newStream([protocol]) as Promise<Stream>
183
- }
184
- const dialOptions = { runOnLimitedConnection: true, negotiateFully: false } as const
185
- return this.libp2p.dialProtocol(peerId, [protocol], dialOptions)
186
- }
187
-
188
- private getFret(): FretService {
189
- const svc = (this.libp2p as unknown as WithFretService).services?.fret
190
- if (svc == null) throw new Error('FRET service is not registered on this libp2p node')
191
- return svc
192
- }
193
-
194
- private async getNeighborIdsForKey(key: Uint8Array, wants: number): Promise<string[]> {
195
- const fret = this.getFret()
196
- const coord = await hashKey(key)
197
- const both = fret.getNeighbors(coord, 'both', wants)
198
- return Array.from(new Set(both)).slice(0, wants)
199
- }
200
-
201
- async findCoordinator(key: Uint8Array, _options?: Partial<FindCoordinatorOptions>): Promise<PeerId> {
202
- const excludedSet = new Set<string>((_options?.excludedPeers ?? []).map(p => p.toString()))
203
- const keyStr = this.toCacheKey(key).substring(0, 12);
204
-
205
- this.log('findCoordinator:start key=%s excluded=%o', keyStr, Array.from(excludedSet).map(s => s.substring(0, 12)))
206
-
207
- // honor cache if not excluded
208
- const cached = this.getCachedCoordinator(key)
209
- if (cached != null && !excludedSet.has(cached.toString())) {
210
- this.log('findCoordinator:cached-hit key=%s coordinator=%s', keyStr, cached.toString().substring(0, 12))
211
- return cached
212
- }
213
-
214
- // Retry logic: connections can be temporarily down, so retry a few times with delay
215
- const maxRetries = 3;
216
- const retryDelayMs = 500;
217
-
218
- for (let attempt = 0; attempt < maxRetries; attempt++) {
219
- // Get currently connected peers for filtering
220
- const connected = (this.libp2p.getConnections?.() ?? []).map((c: any) => c.remotePeer) as PeerId[]
221
- const connectedSet = new Set(connected.map(p => p.toString()))
222
- this.log('findCoordinator:connected-peers key=%s count=%d peers=%o attempt=%d', keyStr, connected.length, connected.map(p => p.toString().substring(0, 12)), attempt)
223
-
224
- // prefer FRET neighbors that are also connected, pick first non-excluded
225
- try {
226
- const ids = await this.getNeighborIdsForKey(key, this.clusterSize)
227
- this.log('findCoordinator:fret-neighbors key=%s candidates=%o', keyStr, ids.map(s => s.substring(0, 12)))
228
-
229
- // Filter to only connected FRET neighbors
230
- const connectedFretIds = ids.filter(id => connectedSet.has(id) || id === this.libp2p.peerId.toString())
231
- this.log('findCoordinator:fret-connected key=%s count=%d peers=%o', keyStr, connectedFretIds.length, connectedFretIds.map(s => s.substring(0, 12)))
232
-
233
- const pick = connectedFretIds.find(id => !excludedSet.has(id))
234
- if (pick) {
235
- const pid = peerIdFromString(pick)
236
- this.recordCoordinator(key, pid)
237
- this.log('findCoordinator:fret-selected key=%s coordinator=%s', keyStr, pick.substring(0, 12))
238
- return pid
239
- }
240
- } catch (err) {
241
- this.log('findCoordinator getNeighborIdsForKey failed - %o', err)
242
- }
243
-
244
- // fallback: prefer any existing connected peer that's not excluded
245
- const connectedPick = connected.find(p => !excludedSet.has(p.toString()))
246
- if (connectedPick) {
247
- this.recordCoordinator(key, connectedPick)
248
- this.log('findCoordinator:connected-fallback key=%s coordinator=%s', keyStr, connectedPick.toString().substring(0, 12))
249
- return connectedPick
250
- }
251
-
252
- // If no connections and not the last attempt, wait and retry
253
- if (connected.length === 0 && attempt < maxRetries - 1) {
254
- this.log('findCoordinator:no-connections-retry key=%s attempt=%d delay=%dms', keyStr, attempt, retryDelayMs)
255
- await new Promise(resolve => setTimeout(resolve, retryDelayMs))
256
- continue
257
- }
258
- }
259
-
260
- // last resort: prefer self only if not excluded and guard allows
261
- const self = this.libp2p.peerId
262
- if (!excludedSet.has(self.toString())) {
263
- const decision = this.shouldAllowSelfCoordination();
264
- if (!decision.allow) {
265
- this.log('findCoordinator:self-coord-blocked key=%s reason=%s', keyStr, decision.reason);
266
- throw new Error(`Self-coordination blocked: ${decision.reason}. No coordinator available for key.`);
267
- }
268
- if (decision.warn) {
269
- this.log('findCoordinator:self-selected-warn key=%s coordinator=%s reason=%s',
270
- keyStr, self.toString().substring(0, 12), decision.reason);
271
- } else {
272
- this.log('findCoordinator:self-selected key=%s coordinator=%s reason=%s',
273
- keyStr, self.toString().substring(0, 12), decision.reason);
274
- }
275
- return self
276
- }
277
-
278
- this.log('findCoordinator:all-excluded key=%s self=%s', keyStr, self.toString().substring(0, 12))
279
- throw new Error('No coordinator available for key (all candidates excluded)')
280
- }
281
-
282
- private getConnectedAddrsByPeer(): Record<string, string[]> {
283
- const conns = this.libp2p.getConnections()
284
- const byPeer: Record<string, string[]> = {}
285
- for (const c of conns) {
286
- const id = c.remotePeer.toString()
287
- const addr = c.remoteAddr?.toString?.()
288
- if (addr) (byPeer[id] ??= []).push(addr)
289
- }
290
- return byPeer
291
- }
292
-
293
- private parseMultiaddrs(addrs: string[]): ReturnType<typeof multiaddr>[] {
294
- const out: ReturnType<typeof multiaddr>[] = []
295
- for (const a of addrs) {
296
- try { out.push(multiaddr(a)) } catch (err) { console.warn('invalid multiaddr from connection', a, err) }
297
- }
298
- return out
299
- }
300
-
301
- async findCluster(key: Uint8Array): Promise<ClusterPeers> {
302
- const fret = this.getFret()
303
- const coord = await hashKey(key)
304
- const cohort = fret.assembleCohort(coord, this.clusterSize)
305
- const keyStr = this.toCacheKey(key).substring(0, 12);
306
-
307
- // Include self in the cohort
308
- const ids = Array.from(new Set([...cohort, this.libp2p.peerId.toString()]))
309
-
310
- const connectedByPeer = this.getConnectedAddrsByPeer()
311
- const connectedPeerIds = Object.keys(connectedByPeer)
312
-
313
- this.log('findCluster key=%s fretCohort=%d connected=%d cohortPeers=%o',
314
- keyStr, cohort.length, connectedPeerIds.length, ids.map(s => s.substring(0, 12)))
315
-
316
- const peers: ClusterPeers = {}
317
-
318
- for (const idStr of ids) {
319
- if (idStr === this.libp2p.peerId.toString()) {
320
- peers[idStr] = { multiaddrs: this.libp2p.getMultiaddrs(), publicKey: this.libp2p.peerId.publicKey?.raw ?? new Uint8Array() }
321
- continue
322
- }
323
- const strings = connectedByPeer[idStr] ?? []
324
- const addrs = this.parseMultiaddrs(strings)
325
- peers[idStr] = { multiaddrs: addrs, publicKey: new Uint8Array() }
326
- }
327
-
328
- this.log('findCluster:result key=%s clusterSize=%d withAddrs=%d connectedInCohort=%d',
329
- keyStr, Object.keys(peers).length,
330
- Object.values(peers).filter(p => p.multiaddrs.length > 0).length,
331
- ids.filter(id => connectedPeerIds.includes(id) || id === this.libp2p.peerId.toString()).length)
332
- return peers
333
- }
334
- }
1
+ import type { AbortOptions, Libp2p, PeerId, Stream } from "@libp2p/interface";
2
+ import { toString as u8ToString } from 'uint8arrays/to-string'
3
+ import type { ClusterPeers, FindCoordinatorOptions, IKeyNetwork, IPeerNetwork } from "@optimystic/db-core";
4
+ import { peerIdFromString } from '@libp2p/peer-id'
5
+ import { multiaddr } from '@multiformats/multiaddr'
6
+ import type { FretService } from 'p2p-fret'
7
+ import { hashKey } from 'p2p-fret'
8
+ import { createLogger } from './logger.js'
9
+
10
+ interface WithFretService { services?: { fret?: FretService } }
11
+
12
+ /**
13
+ * Configuration options for self-coordination behavior
14
+ */
15
+ export interface SelfCoordinationConfig {
16
+ /** Time (ms) after last connection before allowing self-coordination. Default: 30000 */
17
+ gracePeriodMs?: number;
18
+ /** Threshold for suspicious network shrinkage (0-1). >50% drop is suspicious. Default: 0.5 */
19
+ shrinkageThreshold?: number;
20
+ /** Allow self-coordination at all. Default: true (for testing). Set false in production. */
21
+ allowSelfCoordination?: boolean;
22
+ }
23
+
24
+ /**
25
+ * Decision result from self-coordination guard
26
+ */
27
+ export interface SelfCoordinationDecision {
28
+ allow: boolean;
29
+ reason: 'bootstrap-node' | 'partition-detected' | 'suspicious-shrinkage' | 'grace-period-not-elapsed' | 'extended-isolation' | 'disabled';
30
+ warn?: boolean;
31
+ }
32
+
33
+ export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
34
+ private readonly selfCoordinationConfig: Required<SelfCoordinationConfig>;
35
+ private networkHighWaterMark = 1;
36
+ private lastConnectedTime = Date.now();
37
+
38
+ constructor(
39
+ private readonly libp2p: Libp2p,
40
+ private readonly clusterSize: number = 16,
41
+ selfCoordinationConfig?: SelfCoordinationConfig
42
+ ) {
43
+ this.selfCoordinationConfig = {
44
+ gracePeriodMs: selfCoordinationConfig?.gracePeriodMs ?? 30_000,
45
+ shrinkageThreshold: selfCoordinationConfig?.shrinkageThreshold ?? 0.5,
46
+ allowSelfCoordination: selfCoordinationConfig?.allowSelfCoordination ?? true
47
+ };
48
+ this.setupConnectionTracking();
49
+ }
50
+
51
+ // coordinator cache: key (base64url) -> peerId until expiry (bounded LRU-ish via Map insertion order)
52
+ private readonly coordinatorCache = new Map<string, { id: PeerId, expires: number }>()
53
+ private static readonly MAX_CACHE_ENTRIES = 1000
54
+ private readonly log = createLogger('libp2p-key-network')
55
+
56
+ private toCacheKey(key: Uint8Array): string { return u8ToString(key, 'base64url') }
57
+
58
+ /**
59
+ * Set up connection event tracking to update high water mark and last connected time.
60
+ */
61
+ private setupConnectionTracking(): void {
62
+ this.libp2p.addEventListener('connection:open', () => {
63
+ this.updateNetworkObservations();
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Update network high water mark and last connected time.
69
+ * Called on new connections.
70
+ */
71
+ private updateNetworkObservations(): void {
72
+ const connections = this.libp2p.getConnections?.() ?? [];
73
+ if (connections.length > 0) {
74
+ this.lastConnectedTime = Date.now();
75
+ }
76
+
77
+ try {
78
+ const fret = this.getFret();
79
+ const estimate = fret.getNetworkSizeEstimate();
80
+ if (estimate.size_estimate > this.networkHighWaterMark) {
81
+ this.networkHighWaterMark = estimate.size_estimate;
82
+ this.log('network-hwm-updated mark=%d confidence=%f', this.networkHighWaterMark, estimate.confidence);
83
+ }
84
+ } catch {
85
+ // FRET not available - use connection count as fallback
86
+ const connectionCount = this.libp2p.getConnections?.().length ?? 0;
87
+ const observedSize = connectionCount + 1; // +1 for self
88
+ if (observedSize > this.networkHighWaterMark) {
89
+ this.networkHighWaterMark = observedSize;
90
+ this.log('network-hwm-updated mark=%d (from connections)', this.networkHighWaterMark);
91
+ }
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Determine if self-coordination should be allowed based on network observations.
97
+ *
98
+ * Principle: If we've ever seen a larger network, assume our connectivity is the problem,
99
+ * not the network shrinking.
100
+ */
101
+ shouldAllowSelfCoordination(): SelfCoordinationDecision {
102
+ // Check global disable
103
+ if (!this.selfCoordinationConfig.allowSelfCoordination) {
104
+ return { allow: false, reason: 'disabled' };
105
+ }
106
+
107
+ // Case 1: New/bootstrap node (never seen larger network)
108
+ if (this.networkHighWaterMark <= 1) {
109
+ return { allow: true, reason: 'bootstrap-node' };
110
+ }
111
+
112
+ // Case 2: Check for partition via FRET
113
+ try {
114
+ const fret = this.getFret();
115
+ if (fret.detectPartition()) {
116
+ this.log('self-coord-blocked: partition-detected');
117
+ return { allow: false, reason: 'partition-detected' };
118
+ }
119
+
120
+ // Case 3: Suspicious network shrinkage (>threshold drop)
121
+ const estimate = fret.getNetworkSizeEstimate();
122
+ const shrinkage = 1 - (estimate.size_estimate / this.networkHighWaterMark);
123
+ if (shrinkage > this.selfCoordinationConfig.shrinkageThreshold) {
124
+ this.log('self-coord-blocked: suspicious-shrinkage current=%d hwm=%d shrinkage=%f',
125
+ estimate.size_estimate, this.networkHighWaterMark, shrinkage);
126
+ return { allow: false, reason: 'suspicious-shrinkage' };
127
+ }
128
+ } catch {
129
+ // FRET not available - be conservative
130
+ const connections = this.libp2p.getConnections?.() ?? [];
131
+ if (this.networkHighWaterMark > 1 && connections.length === 0) {
132
+ // We've seen peers before but have none now - suspicious
133
+ const timeSinceConnection = Date.now() - this.lastConnectedTime;
134
+ if (timeSinceConnection < this.selfCoordinationConfig.gracePeriodMs) {
135
+ this.log('self-coord-blocked: grace-period-not-elapsed since=%dms', timeSinceConnection);
136
+ return { allow: false, reason: 'grace-period-not-elapsed' };
137
+ }
138
+ }
139
+ }
140
+
141
+ // Case 4: Recently connected (grace period not elapsed)
142
+ const timeSinceConnection = Date.now() - this.lastConnectedTime;
143
+ if (timeSinceConnection < this.selfCoordinationConfig.gracePeriodMs) {
144
+ const connections = this.libp2p.getConnections?.() ?? [];
145
+ // Only block if we have no connections but did recently
146
+ if (connections.length === 0) {
147
+ this.log('self-coord-blocked: grace-period-not-elapsed since=%dms', timeSinceConnection);
148
+ return { allow: false, reason: 'grace-period-not-elapsed' };
149
+ }
150
+ }
151
+
152
+ // Case 5: Extended isolation with gradual shrinkage - allow with warning
153
+ this.log('self-coord-allowed: extended-isolation (warn)');
154
+ return { allow: true, reason: 'extended-isolation', warn: true };
155
+ }
156
+
157
+ public recordCoordinator(key: Uint8Array, peerId: PeerId, ttlMs = 30 * 60 * 1000): void {
158
+ const k = this.toCacheKey(key)
159
+ const now = Date.now()
160
+ for (const [ck, entry] of this.coordinatorCache) {
161
+ if (entry.expires <= now) this.coordinatorCache.delete(ck)
162
+ }
163
+ this.coordinatorCache.set(k, { id: peerId, expires: now + ttlMs })
164
+ while (this.coordinatorCache.size > Libp2pKeyPeerNetwork.MAX_CACHE_ENTRIES) {
165
+ const firstKey = this.coordinatorCache.keys().next().value as string | undefined
166
+ if (firstKey == null) break
167
+ this.coordinatorCache.delete(firstKey)
168
+ }
169
+ }
170
+
171
+ private getCachedCoordinator(key: Uint8Array): PeerId | undefined {
172
+ const k = this.toCacheKey(key)
173
+ const hit = this.coordinatorCache.get(k)
174
+ if (hit && hit.expires > Date.now()) return hit.id
175
+ if (hit) this.coordinatorCache.delete(k)
176
+ return undefined
177
+ }
178
+
179
+ connect(peerId: PeerId, protocol: string, _options?: AbortOptions): Promise<Stream> {
180
+ const conns = (this.libp2p as any).getConnections?.(peerId) ?? []
181
+ if (Array.isArray(conns) && conns.length > 0 && typeof conns[0]?.newStream === 'function') {
182
+ return conns[0].newStream([protocol]) as Promise<Stream>
183
+ }
184
+ const dialOptions = { runOnLimitedConnection: true, negotiateFully: false } as const
185
+ return this.libp2p.dialProtocol(peerId, [protocol], dialOptions)
186
+ }
187
+
188
+ private getFret(): FretService {
189
+ const svc = (this.libp2p as unknown as WithFretService).services?.fret
190
+ if (svc == null) throw new Error('FRET service is not registered on this libp2p node')
191
+ return svc
192
+ }
193
+
194
+ private async getNeighborIdsForKey(key: Uint8Array, wants: number): Promise<string[]> {
195
+ const fret = this.getFret()
196
+ const coord = await hashKey(key)
197
+ const both = fret.getNeighbors(coord, 'both', wants)
198
+ return Array.from(new Set(both)).slice(0, wants)
199
+ }
200
+
201
+ async findCoordinator(key: Uint8Array, _options?: Partial<FindCoordinatorOptions>): Promise<PeerId> {
202
+ const excludedSet = new Set<string>((_options?.excludedPeers ?? []).map(p => p.toString()))
203
+ const keyStr = this.toCacheKey(key).substring(0, 12);
204
+
205
+ this.log('findCoordinator:start key=%s excluded=%o', keyStr, Array.from(excludedSet).map(s => s.substring(0, 12)))
206
+
207
+ // honor cache if not excluded
208
+ const cached = this.getCachedCoordinator(key)
209
+ if (cached != null && !excludedSet.has(cached.toString())) {
210
+ this.log('findCoordinator:cached-hit key=%s coordinator=%s', keyStr, cached.toString().substring(0, 12))
211
+ return cached
212
+ }
213
+
214
+ // Retry logic: connections can be temporarily down, so retry a few times with delay
215
+ const maxRetries = 3;
216
+ const retryDelayMs = 500;
217
+
218
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
219
+ // Get currently connected peers for filtering
220
+ const connected = (this.libp2p.getConnections?.() ?? []).map((c: any) => c.remotePeer) as PeerId[]
221
+ const connectedSet = new Set(connected.map(p => p.toString()))
222
+ this.log('findCoordinator:connected-peers key=%s count=%d peers=%o attempt=%d', keyStr, connected.length, connected.map(p => p.toString().substring(0, 12)), attempt)
223
+
224
+ // prefer FRET neighbors that are also connected, pick first non-excluded
225
+ try {
226
+ const ids = await this.getNeighborIdsForKey(key, this.clusterSize)
227
+ this.log('findCoordinator:fret-neighbors key=%s candidates=%o', keyStr, ids.map(s => s.substring(0, 12)))
228
+
229
+ // Filter to only connected FRET neighbors
230
+ const connectedFretIds = ids.filter(id => connectedSet.has(id) || id === this.libp2p.peerId.toString())
231
+ this.log('findCoordinator:fret-connected key=%s count=%d peers=%o', keyStr, connectedFretIds.length, connectedFretIds.map(s => s.substring(0, 12)))
232
+
233
+ const pick = connectedFretIds.find(id => !excludedSet.has(id))
234
+ if (pick) {
235
+ const pid = peerIdFromString(pick)
236
+ this.recordCoordinator(key, pid)
237
+ this.log('findCoordinator:fret-selected key=%s coordinator=%s', keyStr, pick.substring(0, 12))
238
+ return pid
239
+ }
240
+ } catch (err) {
241
+ this.log('findCoordinator getNeighborIdsForKey failed - %o', err)
242
+ }
243
+
244
+ // fallback: prefer any existing connected peer that's not excluded
245
+ const connectedPick = connected.find(p => !excludedSet.has(p.toString()))
246
+ if (connectedPick) {
247
+ this.recordCoordinator(key, connectedPick)
248
+ this.log('findCoordinator:connected-fallback key=%s coordinator=%s', keyStr, connectedPick.toString().substring(0, 12))
249
+ return connectedPick
250
+ }
251
+
252
+ // If no connections and not the last attempt, wait and retry
253
+ if (connected.length === 0 && attempt < maxRetries - 1) {
254
+ this.log('findCoordinator:no-connections-retry key=%s attempt=%d delay=%dms', keyStr, attempt, retryDelayMs)
255
+ await new Promise(resolve => setTimeout(resolve, retryDelayMs))
256
+ continue
257
+ }
258
+ }
259
+
260
+ // last resort: prefer self only if not excluded and guard allows
261
+ const self = this.libp2p.peerId
262
+ if (!excludedSet.has(self.toString())) {
263
+ const decision = this.shouldAllowSelfCoordination();
264
+ if (!decision.allow) {
265
+ this.log('findCoordinator:self-coord-blocked key=%s reason=%s', keyStr, decision.reason);
266
+ throw new Error(`Self-coordination blocked: ${decision.reason}. No coordinator available for key.`);
267
+ }
268
+ if (decision.warn) {
269
+ this.log('findCoordinator:self-selected-warn key=%s coordinator=%s reason=%s',
270
+ keyStr, self.toString().substring(0, 12), decision.reason);
271
+ } else {
272
+ this.log('findCoordinator:self-selected key=%s coordinator=%s reason=%s',
273
+ keyStr, self.toString().substring(0, 12), decision.reason);
274
+ }
275
+ return self
276
+ }
277
+
278
+ this.log('findCoordinator:all-excluded key=%s self=%s', keyStr, self.toString().substring(0, 12))
279
+ throw new Error('No coordinator available for key (all candidates excluded)')
280
+ }
281
+
282
+ private getConnectedAddrsByPeer(): Record<string, string[]> {
283
+ const conns = this.libp2p.getConnections()
284
+ const byPeer: Record<string, string[]> = {}
285
+ for (const c of conns) {
286
+ const id = c.remotePeer.toString()
287
+ const addr = c.remoteAddr?.toString?.()
288
+ if (addr) (byPeer[id] ??= []).push(addr)
289
+ }
290
+ return byPeer
291
+ }
292
+
293
+ private parseMultiaddrs(addrs: string[]): ReturnType<typeof multiaddr>[] {
294
+ const out: ReturnType<typeof multiaddr>[] = []
295
+ for (const a of addrs) {
296
+ try { out.push(multiaddr(a)) } catch (err) { console.warn('invalid multiaddr from connection', a, err) }
297
+ }
298
+ return out
299
+ }
300
+
301
+ async findCluster(key: Uint8Array): Promise<ClusterPeers> {
302
+ const fret = this.getFret()
303
+ const coord = await hashKey(key)
304
+ const cohort = fret.assembleCohort(coord, this.clusterSize)
305
+ const keyStr = this.toCacheKey(key).substring(0, 12);
306
+
307
+ // Include self in the cohort
308
+ const ids = Array.from(new Set([...cohort, this.libp2p.peerId.toString()]))
309
+
310
+ const connectedByPeer = this.getConnectedAddrsByPeer()
311
+ const connectedPeerIds = Object.keys(connectedByPeer)
312
+
313
+ this.log('findCluster key=%s fretCohort=%d connected=%d cohortPeers=%o',
314
+ keyStr, cohort.length, connectedPeerIds.length, ids.map(s => s.substring(0, 12)))
315
+
316
+ const peers: ClusterPeers = {}
317
+
318
+ for (const idStr of ids) {
319
+ if (idStr === this.libp2p.peerId.toString()) {
320
+ peers[idStr] = { multiaddrs: this.libp2p.getMultiaddrs(), publicKey: this.libp2p.peerId.publicKey?.raw ?? new Uint8Array() }
321
+ continue
322
+ }
323
+ const strings = connectedByPeer[idStr] ?? []
324
+ const addrs = this.parseMultiaddrs(strings)
325
+ peers[idStr] = { multiaddrs: addrs, publicKey: new Uint8Array() }
326
+ }
327
+
328
+ this.log('findCluster:result key=%s clusterSize=%d withAddrs=%d connectedInCohort=%d',
329
+ keyStr, Object.keys(peers).length,
330
+ Object.values(peers).filter(p => p.multiaddrs.length > 0).length,
331
+ ids.filter(id => connectedPeerIds.includes(id) || id === this.libp2p.peerId.toString()).length)
332
+ return peers
333
+ }
334
+ }