@optimystic/db-p2p 0.24.1 → 0.25.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 (257) hide show
  1. package/{readme.md → README.md} +688 -668
  2. package/dist/src/cluster/block-transfer-service.d.ts +133 -10
  3. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  4. package/dist/src/cluster/block-transfer-service.js +155 -9
  5. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  6. package/dist/src/cluster/block-transfer.d.ts +67 -7
  7. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  8. package/dist/src/cluster/block-transfer.js +127 -24
  9. package/dist/src/cluster/block-transfer.js.map +1 -1
  10. package/dist/src/cluster/certified-claims.d.ts +170 -0
  11. package/dist/src/cluster/certified-claims.d.ts.map +1 -0
  12. package/dist/src/cluster/certified-claims.js +200 -0
  13. package/dist/src/cluster/certified-claims.js.map +1 -0
  14. package/dist/src/cluster/client-signature-verifier.d.ts +23 -0
  15. package/dist/src/cluster/client-signature-verifier.d.ts.map +1 -0
  16. package/dist/src/cluster/client-signature-verifier.js +33 -0
  17. package/dist/src/cluster/client-signature-verifier.js.map +1 -0
  18. package/dist/src/cluster/cluster-policy.d.ts +9 -3
  19. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  20. package/dist/src/cluster/cluster-policy.js +111 -27
  21. package/dist/src/cluster/cluster-policy.js.map +1 -1
  22. package/dist/src/cluster/cluster-repo.d.ts +140 -11
  23. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  24. package/dist/src/cluster/cluster-repo.js +485 -29
  25. package/dist/src/cluster/cluster-repo.js.map +1 -1
  26. package/dist/src/cluster/commit-proof.d.ts +134 -0
  27. package/dist/src/cluster/commit-proof.d.ts.map +1 -0
  28. package/dist/src/cluster/commit-proof.js +263 -0
  29. package/dist/src/cluster/commit-proof.js.map +1 -0
  30. package/dist/src/cluster/quorum-restore.d.ts +95 -8
  31. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  32. package/dist/src/cluster/quorum-restore.js +113 -14
  33. package/dist/src/cluster/quorum-restore.js.map +1 -1
  34. package/dist/src/cluster/rebalance-monitor.d.ts +84 -0
  35. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  36. package/dist/src/cluster/rebalance-monitor.js +207 -5
  37. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  38. package/dist/src/cluster/reconcile-block.d.ts +27 -7
  39. package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
  40. package/dist/src/cluster/reconcile-block.js +179 -38
  41. package/dist/src/cluster/reconcile-block.js.map +1 -1
  42. package/dist/src/cluster/service.d.ts +21 -4
  43. package/dist/src/cluster/service.d.ts.map +1 -1
  44. package/dist/src/cluster/service.js +28 -17
  45. package/dist/src/cluster/service.js.map +1 -1
  46. package/dist/src/cluster/spread-on-churn.d.ts +8 -2
  47. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  48. package/dist/src/cluster/spread-on-churn.js +7 -7
  49. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  50. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  51. package/dist/src/cohort-topic/host.js +8 -7
  52. package/dist/src/cohort-topic/host.js.map +1 -1
  53. package/dist/src/cohort-topic/stream-util.d.ts +7 -6
  54. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  55. package/dist/src/cohort-topic/stream-util.js +14 -41
  56. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  57. package/dist/src/dispute/cascade.d.ts +7 -8
  58. package/dist/src/dispute/cascade.d.ts.map +1 -1
  59. package/dist/src/dispute/cascade.js +24 -7
  60. package/dist/src/dispute/cascade.js.map +1 -1
  61. package/dist/src/dispute/dispute-service.d.ts +5 -0
  62. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  63. package/dist/src/dispute/dispute-service.js +13 -3
  64. package/dist/src/dispute/dispute-service.js.map +1 -1
  65. package/dist/src/dispute/invalidation.d.ts +33 -15
  66. package/dist/src/dispute/invalidation.d.ts.map +1 -1
  67. package/dist/src/dispute/invalidation.js +127 -34
  68. package/dist/src/dispute/invalidation.js.map +1 -1
  69. package/dist/src/dispute/service.d.ts.map +1 -1
  70. package/dist/src/dispute/service.js +2 -1
  71. package/dist/src/dispute/service.js.map +1 -1
  72. package/dist/src/index.d.ts +7 -0
  73. package/dist/src/index.d.ts.map +1 -1
  74. package/dist/src/index.js +7 -0
  75. package/dist/src/index.js.map +1 -1
  76. package/dist/src/libp2p-key-network.d.ts +78 -6
  77. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  78. package/dist/src/libp2p-key-network.js +136 -57
  79. package/dist/src/libp2p-key-network.js.map +1 -1
  80. package/dist/src/libp2p-node-base.d.ts +38 -7
  81. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  82. package/dist/src/libp2p-node-base.js +150 -62
  83. package/dist/src/libp2p-node-base.js.map +1 -1
  84. package/dist/src/logger.d.ts +2 -2
  85. package/dist/src/logger.js +2 -2
  86. package/dist/src/network/open-protocol-stream.d.ts +56 -0
  87. package/dist/src/network/open-protocol-stream.d.ts.map +1 -0
  88. package/dist/src/network/open-protocol-stream.js +60 -0
  89. package/dist/src/network/open-protocol-stream.js.map +1 -0
  90. package/dist/src/network/register-protocol-handler.d.ts +66 -0
  91. package/dist/src/network/register-protocol-handler.d.ts.map +1 -0
  92. package/dist/src/network/register-protocol-handler.js +51 -0
  93. package/dist/src/network/register-protocol-handler.js.map +1 -0
  94. package/dist/src/peer-address-book.d.ts +128 -1
  95. package/dist/src/peer-address-book.d.ts.map +1 -1
  96. package/dist/src/peer-address-book.js +198 -15
  97. package/dist/src/peer-address-book.js.map +1 -1
  98. package/dist/src/pend-validation.d.ts +65 -0
  99. package/dist/src/pend-validation.d.ts.map +1 -0
  100. package/dist/src/pend-validation.js +69 -0
  101. package/dist/src/pend-validation.js.map +1 -0
  102. package/dist/src/reactivity/notify-transport.d.ts.map +1 -1
  103. package/dist/src/reactivity/notify-transport.js +2 -1
  104. package/dist/src/reactivity/notify-transport.js.map +1 -1
  105. package/dist/src/reactivity/push-state-gossip.d.ts.map +1 -1
  106. package/dist/src/reactivity/push-state-gossip.js +2 -1
  107. package/dist/src/reactivity/push-state-gossip.js.map +1 -1
  108. package/dist/src/repo/cluster-coordinator.d.ts +32 -6
  109. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  110. package/dist/src/repo/cluster-coordinator.js +44 -10
  111. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  112. package/dist/src/repo/coordinator-repo.d.ts +181 -25
  113. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  114. package/dist/src/repo/coordinator-repo.js +650 -62
  115. package/dist/src/repo/coordinator-repo.js.map +1 -1
  116. package/dist/src/repo/served-repo-proxy.d.ts +30 -0
  117. package/dist/src/repo/served-repo-proxy.d.ts.map +1 -0
  118. package/dist/src/repo/served-repo-proxy.js +50 -0
  119. package/dist/src/repo/served-repo-proxy.js.map +1 -0
  120. package/dist/src/repo/service.d.ts +22 -1
  121. package/dist/src/repo/service.d.ts.map +1 -1
  122. package/dist/src/repo/service.js +25 -13
  123. package/dist/src/repo/service.js.map +1 -1
  124. package/dist/src/reputation/types.d.ts +3 -1
  125. package/dist/src/reputation/types.d.ts.map +1 -1
  126. package/dist/src/reputation/types.js +3 -1
  127. package/dist/src/reputation/types.js.map +1 -1
  128. package/dist/src/rn.d.ts +24 -2
  129. package/dist/src/rn.d.ts.map +1 -1
  130. package/dist/src/rn.js +33 -4
  131. package/dist/src/rn.js.map +1 -1
  132. package/dist/src/routing/libp2p-known-peers.d.ts.map +1 -1
  133. package/dist/src/routing/libp2p-known-peers.js +5 -0
  134. package/dist/src/routing/libp2p-known-peers.js.map +1 -1
  135. package/dist/src/storage/block-archive.d.ts +153 -0
  136. package/dist/src/storage/block-archive.d.ts.map +1 -0
  137. package/dist/src/storage/block-archive.js +194 -0
  138. package/dist/src/storage/block-archive.js.map +1 -0
  139. package/dist/src/storage/block-latch.d.ts +92 -0
  140. package/dist/src/storage/block-latch.d.ts.map +1 -0
  141. package/dist/src/storage/block-latch.js +132 -0
  142. package/dist/src/storage/block-latch.js.map +1 -0
  143. package/dist/src/storage/block-storage.d.ts +169 -12
  144. package/dist/src/storage/block-storage.d.ts.map +1 -1
  145. package/dist/src/storage/block-storage.js +415 -152
  146. package/dist/src/storage/block-storage.js.map +1 -1
  147. package/dist/src/storage/cached-raw-storage.d.ts +5 -1
  148. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
  149. package/dist/src/storage/cached-raw-storage.js +16 -1
  150. package/dist/src/storage/cached-raw-storage.js.map +1 -1
  151. package/dist/src/storage/cached-store-driver.d.ts +14 -4
  152. package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
  153. package/dist/src/storage/cached-store-driver.js +39 -5
  154. package/dist/src/storage/cached-store-driver.js.map +1 -1
  155. package/dist/src/storage/i-block-storage.d.ts +70 -19
  156. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  157. package/dist/src/storage/i-block-storage.js +21 -1
  158. package/dist/src/storage/i-block-storage.js.map +1 -1
  159. package/dist/src/storage/i-raw-storage.d.ts +38 -0
  160. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  161. package/dist/src/storage/kv-raw-storage.d.ts +14 -8
  162. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
  163. package/dist/src/storage/kv-raw-storage.js +29 -9
  164. package/dist/src/storage/kv-raw-storage.js.map +1 -1
  165. package/dist/src/storage/memory-store-driver.d.ts +4 -1
  166. package/dist/src/storage/memory-store-driver.d.ts.map +1 -1
  167. package/dist/src/storage/memory-store-driver.js +22 -2
  168. package/dist/src/storage/memory-store-driver.js.map +1 -1
  169. package/dist/src/storage/raw-store-codec.js +2 -2
  170. package/dist/src/storage/raw-store-driver.d.ts +44 -3
  171. package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
  172. package/dist/src/storage/shared-cache-pool.d.ts +60 -3
  173. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
  174. package/dist/src/storage/shared-cache-pool.js +68 -4
  175. package/dist/src/storage/shared-cache-pool.js.map +1 -1
  176. package/dist/src/storage/storage-repo.d.ts +204 -26
  177. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  178. package/dist/src/storage/storage-repo.js +422 -119
  179. package/dist/src/storage/storage-repo.js.map +1 -1
  180. package/dist/src/storage/store-identity.d.ts +21 -0
  181. package/dist/src/storage/store-identity.d.ts.map +1 -0
  182. package/dist/src/storage/store-identity.js +33 -0
  183. package/dist/src/storage/store-identity.js.map +1 -0
  184. package/dist/src/storage/struct.d.ts +15 -0
  185. package/dist/src/storage/struct.d.ts.map +1 -1
  186. package/dist/src/storage/with-read-cache.d.ts +92 -0
  187. package/dist/src/storage/with-read-cache.d.ts.map +1 -0
  188. package/dist/src/storage/with-read-cache.js +132 -0
  189. package/dist/src/storage/with-read-cache.js.map +1 -0
  190. package/dist/src/sync/service.d.ts +13 -3
  191. package/dist/src/sync/service.d.ts.map +1 -1
  192. package/dist/src/sync/service.js +7 -30
  193. package/dist/src/sync/service.js.map +1 -1
  194. package/dist/src/testing/mesh-harness.d.ts +75 -2
  195. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  196. package/dist/src/testing/mesh-harness.js +193 -81
  197. package/dist/src/testing/mesh-harness.js.map +1 -1
  198. package/dist/src/testing/raw-storage-conformance.d.ts +8 -0
  199. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  200. package/dist/src/testing/raw-storage-conformance.js +97 -2
  201. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  202. package/package.json +5 -6
  203. package/src/cluster/block-transfer-service.ts +528 -312
  204. package/src/cluster/block-transfer.ts +547 -424
  205. package/src/cluster/certified-claims.ts +319 -0
  206. package/src/cluster/client-signature-verifier.ts +34 -0
  207. package/src/cluster/cluster-policy.ts +294 -203
  208. package/src/cluster/cluster-repo.ts +2501 -2034
  209. package/src/cluster/commit-proof.ts +344 -0
  210. package/src/cluster/quorum-restore.ts +348 -223
  211. package/src/cluster/rebalance-monitor.ts +539 -254
  212. package/src/cluster/reconcile-block.ts +404 -203
  213. package/src/cluster/service.ts +308 -293
  214. package/src/cluster/spread-on-churn.ts +352 -346
  215. package/src/cohort-topic/host.ts +8 -7
  216. package/src/cohort-topic/stream-util.ts +14 -43
  217. package/src/dispute/cascade.ts +31 -15
  218. package/src/dispute/dispute-service.ts +706 -696
  219. package/src/dispute/invalidation.ts +162 -52
  220. package/src/dispute/service.ts +126 -125
  221. package/src/index.ts +7 -0
  222. package/src/libp2p-key-network.ts +142 -58
  223. package/src/libp2p-node-base.ts +1793 -1675
  224. package/src/logger.ts +27 -27
  225. package/src/network/open-protocol-stream.ts +87 -0
  226. package/src/network/register-protocol-handler.ts +85 -0
  227. package/src/peer-address-book.ts +360 -149
  228. package/src/pend-validation.ts +89 -0
  229. package/src/reactivity/notify-transport.ts +145 -144
  230. package/src/reactivity/push-state-gossip.ts +292 -291
  231. package/src/repo/cluster-coordinator.ts +65 -11
  232. package/src/repo/coordinator-repo.ts +729 -71
  233. package/src/repo/served-repo-proxy.ts +55 -0
  234. package/src/repo/service.ts +332 -313
  235. package/src/reputation/types.ts +3 -1
  236. package/src/rn.ts +34 -4
  237. package/src/routing/libp2p-known-peers.ts +31 -26
  238. package/src/storage/block-archive.ts +242 -0
  239. package/src/storage/block-latch.ts +144 -0
  240. package/src/storage/block-storage.ts +787 -498
  241. package/src/storage/cached-raw-storage.ts +21 -1
  242. package/src/storage/cached-store-driver.ts +898 -859
  243. package/src/storage/i-block-storage.ts +172 -116
  244. package/src/storage/i-raw-storage.ts +40 -0
  245. package/src/storage/kv-raw-storage.ts +34 -9
  246. package/src/storage/memory-store-driver.ts +26 -2
  247. package/src/storage/raw-store-codec.ts +42 -42
  248. package/src/storage/raw-store-driver.ts +45 -3
  249. package/src/storage/shared-cache-pool.ts +516 -452
  250. package/src/storage/storage-repo.ts +1421 -1014
  251. package/src/storage/store-identity.ts +41 -0
  252. package/src/storage/struct.ts +52 -34
  253. package/src/storage/with-read-cache.ts +182 -0
  254. package/src/sync/service.ts +178 -198
  255. package/src/testing/cohort-topic-mesh-harness.ts +673 -673
  256. package/src/testing/mesh-harness.ts +524 -344
  257. package/src/testing/raw-storage-conformance.ts +114 -2
@@ -1,293 +1,308 @@
1
- import { pipe } from 'it-pipe';
2
- import { decode as lpDecode, encode as lpEncode } from 'it-length-prefixed';
3
- import { peerIdFromString } from '@libp2p/peer-id';
4
- import type { Startable, Logger, Stream, Connection, StreamHandler, PeerId } from '@libp2p/interface';
5
- import type { ICluster, ClusterRecord } from '@optimystic/db-core';
6
- import { encodePeers, type RedirectPayload } from '../repo/redirect.js';
7
- import { toClusterErrorEnvelope } from './cluster-error.js';
8
- import { mergeRecordPeerAddresses } from '../peer-address-book.js';
9
- import { MAX_CONTROL_MESSAGE_BYTES } from '../protocol-limits.js';
10
- import type { Uint8ArrayList } from 'uint8arraylist';
11
- import { createInboundStreamAuthorization, type InboundStreamAuthorization, type InboundStreamAuthorizationInit } from '../inbound-authorization.js';
12
-
13
- interface BaseComponents {
14
- logger: { forComponent: (name: string) => Logger },
15
- registrar: {
16
- handle: (protocol: string, handler: StreamHandler, options: any) => Promise<void>,
17
- unhandle: (protocol: string) => Promise<void>
18
- }
19
- }
20
-
21
- export interface ClusterServiceComponents extends BaseComponents {
22
- cluster: ICluster
23
- /**
24
- * This node's own peer id, used to decide whether we are a member of a
25
- * cluster record's peer set. When absent the service cannot scope membership
26
- * and processes every update locally (no redirect).
27
- */
28
- peerId?: PeerId
29
- /**
30
- * Optional resolver for a peer's dialable multiaddrs, used as a fallback when
31
- * a redirect target has no multiaddrs embedded in `record.peers`.
32
- */
33
- getConnectionAddrs?: (peerId: PeerId) => string[]
34
- /**
35
- * Optional sink for dialable addresses carried by an inbound cluster record, so this
36
- * node can later dial a cohort sibling it has never had a connection to. Omitted →
37
- * no address learning (the pre-existing behavior).
38
- */
39
- recordPeerAddresses?: (peerId: PeerId, multiaddrs: string[]) => void
40
- }
41
-
42
- export interface ClusterServiceInit extends InboundStreamAuthorizationInit {
43
- protocol?: string,
44
- protocolPrefix?: string,
45
- maxInboundStreams?: number,
46
- maxOutboundStreams?: number,
47
- logPrefix?: string,
48
- /**
49
- * Responsibility K - the replica set size for determining cluster membership.
50
- * When the cluster record's peer set is smaller than this, the mesh is treated
51
- * as "small" and the update is processed locally regardless of membership. When
52
- * the peer set is at least this size and we are not a member, the update is
53
- * redirected to the responsible peers.
54
- * Default: 1 (only members process; any larger non-member set redirects)
55
- */
56
- responsibilityK?: number,
57
- }
58
-
59
- export function clusterService(init: ClusterServiceInit = {}): (components: ClusterServiceComponents) => ClusterService {
60
- return (components: ClusterServiceComponents) => new ClusterService(components, init);
61
- }
62
-
63
- /**
64
- * A libp2p service that handles cluster protocol messages
65
- */
66
- export class ClusterService implements Startable {
67
- private readonly protocol: string;
68
- private readonly maxInboundStreams: number;
69
- private readonly maxOutboundStreams: number;
70
- private readonly log: Logger;
71
- private readonly cluster: ICluster;
72
- private readonly components: ClusterServiceComponents;
73
- private running: boolean;
74
- /** Responsibility K - small-mesh bypass threshold for redirect decisions */
75
- private readonly responsibilityK: number;
76
- /** Optional embedder authorization gate; `undefined` (the default) means no check runs. */
77
- private readonly authorization: InboundStreamAuthorization | undefined;
78
-
79
- constructor(components: ClusterServiceComponents, init: ClusterServiceInit = {}) {
80
- this.components = components;
81
- this.protocol = init.protocol ?? (init.protocolPrefix ?? '/db-p2p') + '/cluster/1.0.0';
82
- this.maxInboundStreams = init.maxInboundStreams ?? 32;
83
- this.maxOutboundStreams = init.maxOutboundStreams ?? 64;
84
- this.log = components.logger.forComponent(init.logPrefix ?? 'db-p2p:cluster');
85
- this.cluster = components.cluster;
86
- this.running = false;
87
- this.responsibilityK = init.responsibilityK ?? 1;
88
- this.authorization = createInboundStreamAuthorization(init, this.protocol, (msg, ...args) => this.log.error(msg, ...args));
89
- }
90
-
91
- readonly [Symbol.toStringTag] = '@libp2p/cluster';
92
-
93
- /**
94
- * Best-effort read of `components.libp2p`. When `components` is libp2p's own Proxy, the getter
95
- * THROWS `MissingServiceError('libp2p not set')` for any key it does not hold — and `libp2p` is
96
- * not a component — so the read itself must be guarded; `?.` and a following null check are both
97
- * too late. Every fallback below is a convenience for embedders that register this service
98
- * directly; the production wiring supplies `peerId`/`getConnectionAddrs` explicitly.
99
- */
100
- private getLibp2p(): any {
101
- try {
102
- return (this.components as any).libp2p;
103
- } catch {
104
- return undefined;
105
- }
106
- }
107
-
108
- private getSelfId(): PeerId | undefined {
109
- if (this.components.peerId) return this.components.peerId;
110
- return this.getLibp2p()?.peerId as PeerId | undefined;
111
- }
112
-
113
- private getPeerAddrs(id: string): string[] {
114
- let pid: PeerId;
115
- try {
116
- pid = peerIdFromString(id);
117
- } catch {
118
- return [];
119
- }
120
- if (this.components.getConnectionAddrs) return this.components.getConnectionAddrs(pid);
121
- const libp2p = this.getLibp2p();
122
- if (!libp2p?.getConnections) return [];
123
- const conns: any[] = libp2p.getConnections(pid) ?? [];
124
- const addrs: string[] = [];
125
- for (const c of conns) {
126
- const addr = c.remoteAddr?.toString?.();
127
- if (addr) addrs.push(addr);
128
- }
129
- return addrs;
130
- }
131
-
132
- /**
133
- * Decide whether this node should redirect a cluster update instead of
134
- * participating in its consensus.
135
- *
136
- * Membership is scoped against `record.peers` — the authoritative set the
137
- * coordinator already computed and embedded (it only ever dials peers in this
138
- * set). Using it directly (rather than independently recomputing the cluster
139
- * from the key) is regression-proof against the "empty promises" symptom: a
140
- * peer the coordinator legitimately included is, by construction, present in
141
- * `record.peers` and is therefore never redirected.
142
- *
143
- * Returns a {@link RedirectPayload} when this node is not responsible, or null
144
- * when the update should be processed locally (we are a member, the mesh is too
145
- * small to scope, or we lack the identity/peer set to make a decision).
146
- */
147
- checkRedirect(record: ClusterRecord): RedirectPayload | null {
148
- const selfId = this.getSelfId();
149
- if (!selfId) return null; // no identity → can't scope, process locally
150
-
151
- const peers = record.peers ?? {};
152
- const peerIds = Object.keys(peers);
153
- if (peerIds.length === 0) return null; // nothing to scope against → process locally
154
-
155
- const selfStr = selfId.toString();
156
- const isMember = peerIds.includes(selfStr);
157
- const smallMesh = peerIds.length < this.responsibilityK;
158
-
159
- if (!smallMesh && !isMember) {
160
- const others = peerIds.filter(id => id !== selfStr);
161
- return encodePeers(others.map(id => {
162
- const recAddrs = peers[id]?.multiaddrs ?? [];
163
- const addrs = recAddrs.length > 0 ? recAddrs : this.getPeerAddrs(id);
164
- return { id, addrs };
165
- }));
166
- }
167
-
168
- return null;
169
- }
170
-
171
- async start(): Promise<void> {
172
- if (this.running) {
173
- return;
174
- }
175
-
176
- await this.components.registrar.handle(this.protocol, this.handleIncomingStream.bind(this), {
177
- maxInboundStreams: this.maxInboundStreams,
178
- maxOutboundStreams: this.maxOutboundStreams
179
- });
180
-
181
- this.running = true;
182
- }
183
-
184
- async stop(): Promise<void> {
185
- if (!this.running) {
186
- return;
187
- }
188
-
189
- await this.components.registrar.unhandle(this.protocol);
190
- this.running = false;
191
- }
192
-
193
- /**
194
- * Run a single decoded protocol message. An application-level throw
195
- * (validation / signature / merge / consensus failure inside `cluster.update`)
196
- * propagates to the caller, which turns it into a structured error envelope;
197
- * a redirect or a successful {@link ClusterRecord} is returned as-is.
198
- *
199
- * Public for the same reason {@link checkRedirect} is: it is the whole wire-ingress decision
200
- * for a cluster update, and a test that reconstructs it by hand stops proving anything about
201
- * the real ordering (address learning before redirect before consensus).
202
- */
203
- async processOperation(message: { operation: string; record: ClusterRecord }): Promise<unknown> {
204
- if (message.operation === 'update') {
205
- // Learn the cohort's addresses FIRST — before both the redirect decision and
206
- // local consensus, since either can go on to dial these same peers. libp2p only
207
- // tells us the addresses of peers we are directly connected to, so for a cohort
208
- // picked by key position this record is often the only place a relay-only
209
- // sibling's address ever reaches us.
210
- this.learnPeerAddresses(message.record);
211
- // Scope consensus to responsible peers: redirect when we are not a
212
- // member of the record's authoritative peer set, otherwise process.
213
- const redirect = this.checkRedirect(message.record);
214
- return redirect ?? await this.cluster.update(message.record);
215
- }
216
- throw new Error(`Unknown operation: ${message.operation}`);
217
- }
218
-
219
- /**
220
- * Offer every address the record carries for its cohort members to the node's address book.
221
- *
222
- * This runs on a record NOTHING has validated yet before {@link checkRedirect} and before
223
- * `cluster.update` checks a signature and inbound stream authorization is opt-in, so the
224
- * peer map here is whatever the dialer chose to send. The traversal (and the cap on how many
225
- * peers one record may introduce) is therefore shared with `ClusterClient`, in
226
- * `peer-address-book.ts`, along with the per-address validation and the trust boundary.
227
- */
228
- private learnPeerAddresses(record: ClusterRecord): void {
229
- const sink = this.components.recordPeerAddresses;
230
- if (!sink) return;
231
- mergeRecordPeerAddresses(
232
- record.peers,
233
- sink,
234
- (fmt, ...args) => this.log.error(fmt, ...args),
235
- this.getSelfId()?.toString()
236
- );
237
- }
238
-
239
- private handleIncomingStream(stream: Stream, connection?: Connection): void {
240
- const peerId = connection?.remotePeer;
241
-
242
- const processStream = async function* (this: ClusterService, source: AsyncIterable<Uint8ArrayList>) {
243
- for await (const msg of source) {
244
- // Decode the framing. A malformed/undecodable message is a transport
245
- // fault handled by the outer abort path, not an application error.
246
- const decoded = new TextDecoder().decode(msg.subarray());
247
- const message = JSON.parse(decoded) as { operation: string; record: ClusterRecord };
248
-
249
- // Application-level processing: surface any throw to the coordinator as
250
- // a structured error envelope (closing the stream normally) instead of
251
- // aborting, so the real cause — not an opaque StreamResetError — reaches
252
- // the coordinator, which already enables debug logging. The abort path
253
- // is reserved for genuinely unrecoverable framing/transport faults.
254
- let response: unknown;
255
- try {
256
- response = await this.processOperation(message);
257
- } catch (err) {
258
- this.log.error('error processing cluster %s from %p - %e', message.operation, peerId, err);
259
- response = toClusterErrorEnvelope(err);
260
- }
261
-
262
- // Encode and yield the response
263
- yield new TextEncoder().encode(JSON.stringify(response));
264
- // One request per stream: every real ClusterClient sends exactly one
265
- // request per dial (see ProtocolClient.processMessage), so complete the
266
- // generator after the first response. A second frame a peer queued is
267
- // then never read or parsed. Mirrors sync/block-transfer.
268
- return;
269
- }
270
- };
271
-
272
- void (async () => {
273
- try {
274
- // Authorization runs before ANY decoding or execution. Guarded on the field so a
275
- // node without a predicate keeps the original path untouched.
276
- if (this.authorization && await this.authorization.deny(stream, peerId?.toString())) return;
277
- const responses = pipe(
278
- stream,
279
- (source) => lpDecode(source, { maxDataLength: MAX_CONTROL_MESSAGE_BYTES }),
280
- processStream.bind(this),
281
- (source) => lpEncode(source)
282
- );
283
- for await (const chunk of responses) {
284
- stream.send(chunk);
285
- }
286
- await stream.close();
287
- } catch (err) {
288
- this.log.error('error handling cluster protocol message from %p - %e', peerId, err);
289
- stream.abort(err instanceof Error ? err : new Error(String(err)));
290
- }
291
- })();
292
- }
293
- }
1
+ import { pipe } from 'it-pipe';
2
+ import { decode as lpDecode, encode as lpEncode } from 'it-length-prefixed';
3
+ import { peerIdFromString } from '@libp2p/peer-id';
4
+ import type { Startable, Logger, Stream, Connection, StreamHandler, PeerId } from '@libp2p/interface';
5
+ import type { ICluster, ClusterRecord } from '@optimystic/db-core';
6
+ import { encodePeers, type RedirectPayload } from '../repo/redirect.js';
7
+ import { toClusterErrorEnvelope } from './cluster-error.js';
8
+ import { mergeRecordPeerAddresses, publishableAddrsForPeer, type AddressLog, type DirectionalConnection } from '../peer-address-book.js';
9
+ import { MAX_CONTROL_MESSAGE_BYTES } from '../protocol-limits.js';
10
+ import type { Uint8ArrayList } from 'uint8arraylist';
11
+ import { createLogger } from '../logger.js';
12
+ import { createInboundStreamAuthorization, type InboundStreamAuthorization, type InboundStreamAuthorizationInit } from '../inbound-authorization.js';
13
+ import { registerProtocolHandler } from '../network/register-protocol-handler.js';
14
+
15
+ interface BaseComponents {
16
+ logger: { forComponent: (name: string) => Logger },
17
+ registrar: {
18
+ handle: (protocol: string, handler: StreamHandler, options: any) => Promise<void>,
19
+ unhandle: (protocol: string) => Promise<void>
20
+ }
21
+ }
22
+
23
+ export interface ClusterServiceComponents extends BaseComponents {
24
+ cluster: ICluster
25
+ /**
26
+ * This node's own peer id, used to decide whether we are a member of a
27
+ * cluster record's peer set. When absent the service cannot scope membership
28
+ * and processes every update locally (no redirect).
29
+ */
30
+ peerId?: PeerId
31
+ /**
32
+ * Optional resolver for the addresses this node may publish for a peer, used as a fallback
33
+ * when a redirect target has no multiaddrs embedded in `record.peers`. Async because the
34
+ * answer includes the peer's own advertised addresses, which live in the peerStore — see
35
+ * `publishableAddrsForPeer`. A synchronous `string[]` is still accepted so an embedder's
36
+ * connections-only stub keeps working.
37
+ */
38
+ getConnectionAddrs?: (peerId: PeerId) => string[] | Promise<string[]>
39
+ /**
40
+ * Optional sink for dialable addresses carried by an inbound cluster record, so this
41
+ * node can later dial a cohort sibling it has never had a connection to. Omitted →
42
+ * no address learning (the pre-existing behavior).
43
+ */
44
+ recordPeerAddresses?: (peerId: PeerId, multiaddrs: string[]) => void
45
+ }
46
+
47
+ export interface ClusterServiceInit extends InboundStreamAuthorizationInit {
48
+ protocol?: string,
49
+ protocolPrefix?: string,
50
+ maxInboundStreams?: number,
51
+ maxOutboundStreams?: number,
52
+ logPrefix?: string,
53
+ /**
54
+ * Responsibility K - the replica set size for determining cluster membership.
55
+ * When the cluster record's peer set is smaller than this, the mesh is treated
56
+ * as "small" and the update is processed locally regardless of membership. When
57
+ * the peer set is at least this size and we are not a member, the update is
58
+ * redirected to the responsible peers.
59
+ * Default: 1 (only members process; any larger non-member set redirects)
60
+ */
61
+ responsibilityK?: number,
62
+ }
63
+
64
+ export function clusterService(init: ClusterServiceInit = {}): (components: ClusterServiceComponents) => ClusterService {
65
+ return (components: ClusterServiceComponents) => new ClusterService(components, init);
66
+ }
67
+
68
+ /**
69
+ * A libp2p service that handles cluster protocol messages
70
+ */
71
+ export class ClusterService implements Startable {
72
+ private readonly protocol: string;
73
+ private readonly maxInboundStreams: number;
74
+ private readonly maxOutboundStreams: number;
75
+ private readonly log: Logger;
76
+ /**
77
+ * Sink for this service's `peer-address-book:*` lines. Deliberately NOT `this.log.error`, which
78
+ * lands them under libp2p's `db-p2p:cluster:error` namespace — invisible to the
79
+ * `DEBUG=optimystic:db-p2p:*` filter this package's docs recommend, and the reason
80
+ * gotchoices/Optimystic#12 read a zero log count as proof the mechanism never ran. One tag
81
+ * family, one namespace tree.
82
+ */
83
+ private readonly addressLog: AddressLog;
84
+ private readonly cluster: ICluster;
85
+ private readonly components: ClusterServiceComponents;
86
+ private running: boolean;
87
+ /** Responsibility K - small-mesh bypass threshold for redirect decisions */
88
+ private readonly responsibilityK: number;
89
+ /** Optional embedder authorization gate; `undefined` (the default) means no check runs. */
90
+ private readonly authorization: InboundStreamAuthorization | undefined;
91
+
92
+ constructor(components: ClusterServiceComponents, init: ClusterServiceInit = {}) {
93
+ this.components = components;
94
+ this.protocol = init.protocol ?? (init.protocolPrefix ?? '/db-p2p') + '/cluster/1.0.0';
95
+ this.maxInboundStreams = init.maxInboundStreams ?? 32;
96
+ this.maxOutboundStreams = init.maxOutboundStreams ?? 64;
97
+ this.log = components.logger.forComponent(init.logPrefix ?? 'db-p2p:cluster');
98
+ this.addressLog = createLogger('peer-address-book', components.peerId?.toString());
99
+ this.cluster = components.cluster;
100
+ this.running = false;
101
+ this.responsibilityK = init.responsibilityK ?? 1;
102
+ this.authorization = createInboundStreamAuthorization(init, this.protocol, (msg, ...args) => this.log.error(msg, ...args));
103
+ }
104
+
105
+ readonly [Symbol.toStringTag] = '@libp2p/cluster';
106
+
107
+ /**
108
+ * Best-effort read of `components.libp2p`. When `components` is libp2p's own Proxy, the getter
109
+ * THROWS `MissingServiceError('libp2p not set')` for any key it does not hold — and `libp2p` is
110
+ * not a component so the read itself must be guarded; `?.` and a following null check are both
111
+ * too late. Every fallback below is a convenience for embedders that register this service
112
+ * directly; the production wiring supplies `peerId`/`getConnectionAddrs` explicitly.
113
+ */
114
+ private getLibp2p(): any {
115
+ try {
116
+ return (this.components as any).libp2p;
117
+ } catch {
118
+ return undefined;
119
+ }
120
+ }
121
+
122
+ private getSelfId(): PeerId | undefined {
123
+ if (this.components.peerId) return this.components.peerId;
124
+ return this.getLibp2p()?.peerId as PeerId | undefined;
125
+ }
126
+
127
+ /**
128
+ * The addresses this node may publish for `id` in a redirect payload, when the record carried
129
+ * none. A redirect goes to a THIRD party, so it asks `publishableAddrsForPeer` — the one
130
+ * definition, shared with `RepoService` and `findCluster` — rather than reading connections
131
+ * alone: a cohort sibling that only ever dialed US has its real address in the peerStore only.
132
+ */
133
+ private async getPeerAddrs(id: string): Promise<string[]> {
134
+ let pid: PeerId;
135
+ try {
136
+ pid = peerIdFromString(id);
137
+ } catch {
138
+ return [];
139
+ }
140
+ if (this.components.getConnectionAddrs) return await this.components.getConnectionAddrs(pid);
141
+ const libp2p = this.getLibp2p();
142
+ if (!libp2p?.getConnections) return [];
143
+ const conns: DirectionalConnection[] = libp2p.getConnections(pid) ?? [];
144
+ return await publishableAddrsForPeer(libp2p, conns, pid, this.addressLog);
145
+ }
146
+
147
+ /**
148
+ * Decide whether this node should redirect a cluster update instead of
149
+ * participating in its consensus.
150
+ *
151
+ * Membership is scoped against `record.peers` the authoritative set the
152
+ * coordinator already computed and embedded (it only ever dials peers in this
153
+ * set). Using it directly (rather than independently recomputing the cluster
154
+ * from the key) is regression-proof against the "empty promises" symptom: a
155
+ * peer the coordinator legitimately included is, by construction, present in
156
+ * `record.peers` and is therefore never redirected.
157
+ *
158
+ * Returns a {@link RedirectPayload} when this node is not responsible, or null
159
+ * when the update should be processed locally (we are a member, the mesh is too
160
+ * small to scope, or we lack the identity/peer set to make a decision).
161
+ */
162
+ async checkRedirect(record: ClusterRecord): Promise<RedirectPayload | null> {
163
+ const selfId = this.getSelfId();
164
+ if (!selfId) return null; // no identity → can't scope, process locally
165
+
166
+ const peers = record.peers ?? {};
167
+ const peerIds = Object.keys(peers);
168
+ if (peerIds.length === 0) return null; // nothing to scope against → process locally
169
+
170
+ const selfStr = selfId.toString();
171
+ const isMember = peerIds.includes(selfStr);
172
+ const smallMesh = peerIds.length < this.responsibilityK;
173
+
174
+ if (!smallMesh && !isMember) {
175
+ const others = peerIds.filter(id => id !== selfStr);
176
+ return encodePeers(await Promise.all(others.map(async id => {
177
+ const recAddrs = peers[id]?.multiaddrs ?? [];
178
+ const addrs = recAddrs.length > 0 ? recAddrs : await this.getPeerAddrs(id);
179
+ return { id, addrs };
180
+ })));
181
+ }
182
+
183
+ return null;
184
+ }
185
+
186
+ async start(): Promise<void> {
187
+ if (this.running) {
188
+ return;
189
+ }
190
+
191
+ await registerProtocolHandler(this.components.registrar, this.protocol, this.handleIncomingStream.bind(this), {
192
+ maxInboundStreams: this.maxInboundStreams,
193
+ maxOutboundStreams: this.maxOutboundStreams
194
+ });
195
+
196
+ this.running = true;
197
+ }
198
+
199
+ async stop(): Promise<void> {
200
+ if (!this.running) {
201
+ return;
202
+ }
203
+
204
+ await this.components.registrar.unhandle(this.protocol);
205
+ this.running = false;
206
+ }
207
+
208
+ /**
209
+ * Run a single decoded protocol message. An application-level throw
210
+ * (validation / signature / merge / consensus failure inside `cluster.update`)
211
+ * propagates to the caller, which turns it into a structured error envelope;
212
+ * a redirect or a successful {@link ClusterRecord} is returned as-is.
213
+ *
214
+ * Public for the same reason {@link checkRedirect} is: it is the whole wire-ingress decision
215
+ * for a cluster update, and a test that reconstructs it by hand stops proving anything about
216
+ * the real ordering (address learning before redirect before consensus).
217
+ */
218
+ async processOperation(message: { operation: string; record: ClusterRecord }): Promise<unknown> {
219
+ if (message.operation === 'update') {
220
+ // Learn the cohort's addresses FIRST before both the redirect decision and
221
+ // local consensus, since either can go on to dial these same peers. libp2p only
222
+ // tells us the addresses of peers we are directly connected to, so for a cohort
223
+ // picked by key position this record is often the only place a relay-only
224
+ // sibling's address ever reaches us.
225
+ this.learnPeerAddresses(message.record);
226
+ // Scope consensus to responsible peers: redirect when we are not a
227
+ // member of the record's authoritative peer set, otherwise process.
228
+ const redirect = await this.checkRedirect(message.record);
229
+ return redirect ?? await this.cluster.update(message.record);
230
+ }
231
+ throw new Error(`Unknown operation: ${message.operation}`);
232
+ }
233
+
234
+ /**
235
+ * Offer every address the record carries for its cohort members to the node's address book.
236
+ *
237
+ * This runs on a record NOTHING has validated yet — before {@link checkRedirect} and before
238
+ * `cluster.update` checks a signature — and inbound stream authorization is opt-in, so the
239
+ * peer map here is whatever the dialer chose to send. The traversal (and the cap on how many
240
+ * peers one record may introduce) is therefore shared with `ClusterClient`, in
241
+ * `peer-address-book.ts`, along with the per-address validation and the trust boundary.
242
+ */
243
+ private learnPeerAddresses(record: ClusterRecord): void {
244
+ const sink = this.components.recordPeerAddresses;
245
+ if (!sink) return;
246
+ mergeRecordPeerAddresses(
247
+ record.peers,
248
+ sink,
249
+ this.addressLog,
250
+ this.getSelfId()?.toString()
251
+ );
252
+ }
253
+
254
+ private handleIncomingStream(stream: Stream, connection?: Connection): void {
255
+ const peerId = connection?.remotePeer;
256
+
257
+ const processStream = async function* (this: ClusterService, source: AsyncIterable<Uint8ArrayList>) {
258
+ for await (const msg of source) {
259
+ // Decode the framing. A malformed/undecodable message is a transport
260
+ // fault handled by the outer abort path, not an application error.
261
+ const decoded = new TextDecoder().decode(msg.subarray());
262
+ const message = JSON.parse(decoded) as { operation: string; record: ClusterRecord };
263
+
264
+ // Application-level processing: surface any throw to the coordinator as
265
+ // a structured error envelope (closing the stream normally) instead of
266
+ // aborting, so the real cause not an opaque StreamResetError reaches
267
+ // the coordinator, which already enables debug logging. The abort path
268
+ // is reserved for genuinely unrecoverable framing/transport faults.
269
+ let response: unknown;
270
+ try {
271
+ response = await this.processOperation(message);
272
+ } catch (err) {
273
+ this.log.error('error processing cluster %s from %p - %e', message.operation, peerId, err);
274
+ response = toClusterErrorEnvelope(err);
275
+ }
276
+
277
+ // Encode and yield the response
278
+ yield new TextEncoder().encode(JSON.stringify(response));
279
+ // One request per stream: every real ClusterClient sends exactly one
280
+ // request per dial (see ProtocolClient.processMessage), so complete the
281
+ // generator after the first response. A second frame a peer queued is
282
+ // then never read or parsed. Mirrors sync/block-transfer.
283
+ return;
284
+ }
285
+ };
286
+
287
+ void (async () => {
288
+ try {
289
+ // Authorization runs before ANY decoding or execution. Guarded on the field so a
290
+ // node without a predicate keeps the original path untouched.
291
+ if (this.authorization && await this.authorization.deny(stream, peerId?.toString())) return;
292
+ const responses = pipe(
293
+ stream,
294
+ (source) => lpDecode(source, { maxDataLength: MAX_CONTROL_MESSAGE_BYTES }),
295
+ processStream.bind(this),
296
+ (source) => lpEncode(source)
297
+ );
298
+ for await (const chunk of responses) {
299
+ stream.send(chunk);
300
+ }
301
+ await stream.close();
302
+ } catch (err) {
303
+ this.log.error('error handling cluster protocol message from %p - %e', peerId, err);
304
+ stream.abort(err instanceof Error ? err : new Error(String(err)));
305
+ }
306
+ })();
307
+ }
308
+ }