@optimystic/db-p2p 0.24.0 → 0.24.2

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 (60) hide show
  1. package/dist/src/cluster/service.d.ts +8 -0
  2. package/dist/src/cluster/service.d.ts.map +1 -1
  3. package/dist/src/cluster/service.js +16 -4
  4. package/dist/src/cluster/service.js.map +1 -1
  5. package/dist/src/cohort-topic/host.js +34 -11
  6. package/dist/src/cohort-topic/host.js.map +1 -1
  7. package/dist/src/cohort-topic/stream-util.d.ts +25 -11
  8. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  9. package/dist/src/cohort-topic/stream-util.js +31 -19
  10. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  11. package/dist/src/libp2p-key-network.d.ts +68 -0
  12. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  13. package/dist/src/libp2p-key-network.js +123 -14
  14. package/dist/src/libp2p-key-network.js.map +1 -1
  15. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  16. package/dist/src/libp2p-node-base.js +8 -5
  17. package/dist/src/libp2p-node-base.js.map +1 -1
  18. package/dist/src/logger.d.ts +2 -2
  19. package/dist/src/logger.js +2 -2
  20. package/dist/src/matchmaking/query-transport.js +3 -3
  21. package/dist/src/matchmaking/query-transport.js.map +1 -1
  22. package/dist/src/peer-address-book.d.ts +69 -0
  23. package/dist/src/peer-address-book.d.ts.map +1 -1
  24. package/dist/src/peer-address-book.js +110 -15
  25. package/dist/src/peer-address-book.js.map +1 -1
  26. package/dist/src/reactivity/notify-transport.d.ts +4 -4
  27. package/dist/src/reactivity/notify-transport.js +6 -6
  28. package/dist/src/reactivity/notify-transport.js.map +1 -1
  29. package/dist/src/reactivity/push-state-gossip.js +2 -2
  30. package/dist/src/reactivity/push-state-gossip.js.map +1 -1
  31. package/dist/src/reactivity/recover-transport.d.ts +6 -2
  32. package/dist/src/reactivity/recover-transport.d.ts.map +1 -1
  33. package/dist/src/reactivity/recover-transport.js +7 -3
  34. package/dist/src/reactivity/recover-transport.js.map +1 -1
  35. package/dist/src/repo/service.d.ts +6 -0
  36. package/dist/src/repo/service.d.ts.map +1 -1
  37. package/dist/src/repo/service.js +12 -2
  38. package/dist/src/repo/service.js.map +1 -1
  39. package/dist/src/routing/libp2p-known-peers.d.ts.map +1 -1
  40. package/dist/src/routing/libp2p-known-peers.js +5 -0
  41. package/dist/src/routing/libp2p-known-peers.js.map +1 -1
  42. package/dist/src/testing/cohort-topic-mesh-harness.d.ts +13 -6
  43. package/dist/src/testing/cohort-topic-mesh-harness.d.ts.map +1 -1
  44. package/dist/src/testing/cohort-topic-mesh-harness.js +15 -6
  45. package/dist/src/testing/cohort-topic-mesh-harness.js.map +1 -1
  46. package/package.json +3 -3
  47. package/src/cluster/service.ts +305 -293
  48. package/src/cohort-topic/host.ts +2932 -2901
  49. package/src/cohort-topic/stream-util.ts +147 -135
  50. package/src/libp2p-key-network.ts +1235 -1120
  51. package/src/libp2p-node-base.ts +1678 -1675
  52. package/src/logger.ts +27 -27
  53. package/src/matchmaking/query-transport.ts +492 -492
  54. package/src/peer-address-book.ts +266 -149
  55. package/src/reactivity/notify-transport.ts +144 -144
  56. package/src/reactivity/push-state-gossip.ts +291 -291
  57. package/src/reactivity/recover-transport.ts +412 -408
  58. package/src/repo/service.ts +323 -313
  59. package/src/routing/libp2p-known-peers.ts +31 -26
  60. package/src/testing/cohort-topic-mesh-harness.ts +673 -663
@@ -1,313 +1,323 @@
1
- import { pipe } from 'it-pipe'
2
- import { decode as lpDecode, encode as lpEncode } from 'it-length-prefixed'
3
- import type { Startable, Logger, Stream, Connection, StreamHandler, PeerId, Libp2p } from '@libp2p/interface'
4
- import type { IRepo, RepoMessage } from '@optimystic/db-core'
5
- import { blockIdsForTransforms } from '@optimystic/db-core'
6
- import { peersEqual } from '../peer-utils.js'
7
- import { encodePeers, type RedirectPayload } from './redirect.js'
8
- import { MAX_BLOCK_MESSAGE_BYTES } from '../protocol-limits.js'
9
- import type { Uint8ArrayList } from 'uint8arraylist'
10
- import { createLogger } from '../logger.js'
11
- import { createInboundStreamAuthorization, type InboundStreamAuthorization, type InboundStreamAuthorizationInit } from '../inbound-authorization.js'
12
-
13
- const debugLog = createLogger('repo-service')
14
-
15
- // Define Components interface
16
- interface BaseComponents {
17
- logger: { forComponent: (name: string) => Logger },
18
- registrar: {
19
- handle: (protocol: string, handler: StreamHandler, options: any) => Promise<void>
20
- unhandle: (protocol: string) => Promise<void>
21
- }
22
- }
23
-
24
- export interface NetworkManagerLike {
25
- getCluster(key: Uint8Array): Promise<PeerId[]>
26
- }
27
-
28
- export type RepoServiceComponents = BaseComponents & {
29
- repo: IRepo
30
- networkManager?: NetworkManagerLike
31
- peerId?: PeerId
32
- getConnectionAddrs?: (peerId: PeerId) => string[]
33
- /**
34
- * Optional libp2p node. The production wiring injects the node post-construction
35
- * via {@link RepoService.setLibp2p} (the `components.libp2p` proxy does not
36
- * reliably resolve from inside a service at request time); this field is a
37
- * best-effort fallback resolver used only when no node has been injected.
38
- */
39
- libp2p?: Libp2p
40
- }
41
-
42
- export type RepoServiceInit = InboundStreamAuthorizationInit & {
43
- protocol?: string,
44
- protocolPrefix?: string,
45
- maxInboundStreams?: number,
46
- maxOutboundStreams?: number,
47
- logPrefix?: string,
48
- kBucketSize?: number,
49
- /**
50
- * Responsibility K - the replica set size for determining cluster membership.
51
- * This is distinct from kBucketSize (DHT routing).
52
- * When set, this determines how many peers (by XOR distance) are considered
53
- * responsible for a key. If this node is not in the top responsibilityK peers,
54
- * it will redirect requests to closer peers.
55
- * Default: 1 (only the closest peer handles requests)
56
- */
57
- responsibilityK?: number,
58
- }
59
-
60
- export function repoService(init: RepoServiceInit = {}): (components: RepoServiceComponents) => RepoService {
61
- return (components: RepoServiceComponents) => new RepoService(components, init);
62
- }
63
-
64
- /**
65
- * A libp2p service that handles repo protocol messages
66
- */
67
- export class RepoService implements Startable {
68
- private readonly protocol: string
69
- private readonly maxInboundStreams: number
70
- private readonly maxOutboundStreams: number
71
- private readonly log: Logger
72
- private readonly repo: IRepo
73
- private readonly components: RepoServiceComponents
74
- private running: boolean
75
- /** Responsibility K - how many peers are responsible for a key (for redirect decisions) */
76
- private readonly responsibilityK: number
77
- /**
78
- * The libp2p node, injected post-construction by the node wiring (see
79
- * libp2p-node-base.ts, mirroring how `networkManager`/`fret` receive theirs).
80
- * The libp2p `components.libp2p` proxy does NOT reliably resolve from inside a
81
- * service at request time, so the redirect path resolves the network manager,
82
- * self identity, and connection addrs through this explicitly-set reference.
83
- */
84
- private libp2pRef: Libp2p | undefined
85
- /** Optional embedder authorization gate; `undefined` (the default) means no check runs. */
86
- private readonly authorization: InboundStreamAuthorization | undefined
87
-
88
- constructor(components: RepoServiceComponents, init: RepoServiceInit = {}) {
89
- this.components = components
90
- const computed = init.protocol ?? (init.protocolPrefix ?? '/db-p2p') + '/repo/1.0.0'
91
- this.protocol = computed
92
- this.maxInboundStreams = init.maxInboundStreams ?? 32
93
- this.maxOutboundStreams = init.maxOutboundStreams ?? 64
94
- this.log = components.logger.forComponent(init.logPrefix ?? 'db-p2p:repo-service')
95
- this.repo = components.repo
96
- this.running = false
97
- this.responsibilityK = init.responsibilityK ?? 1
98
- this.authorization = createInboundStreamAuthorization(init, this.protocol, (msg, ...args) => this.log.error(msg, ...args))
99
- }
100
-
101
- readonly [Symbol.toStringTag] = '@libp2p/repo-service'
102
-
103
- /**
104
- * Inject the running libp2p node. Called once post-construction by the node
105
- * wiring so the redirect path can resolve the network manager / self id / addrs.
106
- */
107
- setLibp2p(libp2p: Libp2p): void {
108
- this.libp2pRef = libp2p
109
- }
110
-
111
- /** Resolve the libp2p node: the injected ref first, then the (best-effort) components proxy. */
112
- private getLibp2p(): Libp2p | undefined {
113
- return this.libp2pRef ?? (this.components as any).libp2p
114
- }
115
-
116
- /**
117
- * Start the service
118
- */
119
- async start(): Promise<void> {
120
- if (this.running) {
121
- return
122
- }
123
-
124
- await this.components.registrar.handle(this.protocol, this.handleIncomingStream.bind(this), {
125
- maxInboundStreams: this.maxInboundStreams,
126
- maxOutboundStreams: this.maxOutboundStreams
127
- })
128
-
129
- this.running = true
130
- }
131
-
132
- /**
133
- * Stop the service
134
- */
135
- async stop(): Promise<void> {
136
- if (!this.running) {
137
- return
138
- }
139
-
140
- await this.components.registrar.unhandle(this.protocol)
141
- this.running = false
142
- }
143
-
144
- private getNetworkManager(): NetworkManagerLike | undefined {
145
- if (this.components.networkManager) return this.components.networkManager
146
- return (this.getLibp2p() as any)?.services?.networkManager as NetworkManagerLike | undefined
147
- }
148
-
149
- private getSelfId(): PeerId | undefined {
150
- if (this.components.peerId) return this.components.peerId
151
- return this.getLibp2p()?.peerId as PeerId | undefined
152
- }
153
-
154
- private getPeerAddrs(peerId: PeerId): string[] {
155
- if (this.components.getConnectionAddrs) return this.components.getConnectionAddrs(peerId)
156
- const libp2p = this.getLibp2p() as any
157
- if (!libp2p?.getConnections) return []
158
- const conns: any[] = libp2p.getConnections(peerId) ?? []
159
- const addrs: string[] = []
160
- for (const c of conns) {
161
- const addr = c.remoteAddr?.toString?.()
162
- if (addr) addrs.push(addr)
163
- }
164
- return addrs
165
- }
166
-
167
- /**
168
- * Derive the redirect routing key and op name for a single operation.
169
- *
170
- * The key MUST be the block the corresponding handler actually coordinates and
171
- * verifies responsibility on, so redirect routing stays consistent with where the
172
- * op is executed:
173
- * - get → blockIds[0]
174
- * - pend → blockIdsForTransforms(transforms)[0]
175
- * - cancel → actionRef.blockIds[0]
176
- * - commit → blockIds[0] (CoordinatorRepo.commit anchors consensus on
177
- * getClusterSize(blockIds[0]) / executeClusterTransaction(blockIds[0]) and guards
178
- * with verifyResponsibility(blockIds) NOT tailId; for a per-block commit batch
179
- * whose blockIds[0] !== tailId, keying on tailId redirected the commit to the
180
- * collection tail's cluster, which then fails verifyResponsibility for the non-tail block.)
181
- *
182
- * Returns blockKey === undefined when the op carries no routable key (e.g. a cancel
183
- * with an empty blockIds list), in which case the caller handles it locally without a
184
- * redirect check.
185
- */
186
- deriveBlockKey(operation: RepoMessage['operations'][number]): { blockKey: string | undefined, opName: string } {
187
- if ('get' in operation) {
188
- return { blockKey: operation.get.blockIds[0], opName: 'get' }
189
- }
190
- if ('pend' in operation) {
191
- return { blockKey: blockIdsForTransforms(operation.pend.transforms)[0], opName: 'pend' }
192
- }
193
- if ('cancel' in operation) {
194
- return { blockKey: operation.cancel.actionRef.blockIds[0], opName: 'cancel' }
195
- }
196
- if ('commit' in operation) {
197
- return { blockKey: operation.commit.blockIds[0], opName: 'commit' }
198
- }
199
- return { blockKey: undefined, opName: 'unknown' }
200
- }
201
-
202
- /**
203
- * Check if this node should redirect the request for a given key.
204
- * Returns a RedirectPayload if not responsible, null if should handle locally.
205
- * Also attaches cluster info to the message for downstream use.
206
- */
207
- async checkRedirect(blockKey: string, opName: string, message: RepoMessage): Promise<RedirectPayload | null> {
208
- const nm = this.getNetworkManager()
209
- if (!nm) return null
210
-
211
- // Pass the RAW encoded block-key bytes to getCluster. getCluster hashes
212
- // internally (hashKey == sha256), so the responsible-set coordinate becomes
213
- // hashKey(encode(blockKey)) identical to how the cluster coordinator
214
- // derives it (ClusterCoordinator.getClusterForBlock findCluster(encode(blockId))).
215
- // Pre-hashing here would double-hash (hashKey(sha256(encode(blockKey)))), placing
216
- // the cohort at an unrelated ring coordinate and redirecting requests the
217
- // coordinator legitimately routed to this peer.
218
- const key = new TextEncoder().encode(blockKey)
219
- const cluster = await nm.getCluster(key)
220
- ;(message as any).cluster = cluster.map((p: PeerId) => p.toString?.() ?? String(p))
221
-
222
- const selfId = this.getSelfId()
223
- if (!selfId) return null
224
-
225
- const isMember = cluster.some((p: PeerId) => peersEqual(p, selfId))
226
- const smallMesh = cluster.length < this.responsibilityK
227
-
228
- if (!smallMesh && !isMember) {
229
- const peers = cluster.filter((p: PeerId) => !peersEqual(p, selfId))
230
- debugLog('redirect op=%s blockKey=%s cluster=%d', opName, blockKey, cluster.length)
231
- return encodePeers(peers.map((pid: PeerId) => ({
232
- id: pid.toString(),
233
- addrs: this.getPeerAddrs(pid)
234
- })))
235
- }
236
-
237
- return null
238
- }
239
-
240
- /**
241
- * Handle incoming streams on the repo protocol
242
- */
243
- private handleIncomingStream(stream: Stream, connection?: Connection): void {
244
- const peerId = connection?.remotePeer
245
-
246
- const processStream = async function* (this: RepoService, source: AsyncIterable<Uint8ArrayList>) {
247
- for await (const msg of source) {
248
- // Decode the message
249
- const decoded = new TextDecoder().decode(msg.subarray())
250
- const message = JSON.parse(decoded) as RepoMessage
251
-
252
- // Process each operation. Derive the redirect routing key once (keyed on the
253
- // block the handler actually coordinates), redirect-check it, then dispatch.
254
- const operation = message.operations[0]
255
- const { blockKey, opName } = this.deriveBlockKey(operation)
256
- const redirect = blockKey !== undefined
257
- ? await this.checkRedirect(blockKey, opName, message)
258
- : null
259
-
260
- let response: any
261
- if (redirect) {
262
- response = redirect
263
- } else if ('get' in operation) {
264
- // No `skipClusterFetch` here: a read on this protocol comes from ANOTHER node, so
265
- // it must reach `CoordinatorRepo`'s cohort consult — answering a bare absent for
266
- // a block a cohort peer holds is an authoritative lie the transactor never
267
- // retries. Only the sync protocol keeps the flag (`sync/service.ts`, where the
268
- // consult itself lands), and that is what stops the recursion.
269
- // NOTE: this also puts lazy read-repair on remote reads of locally-present blocks
270
- // one consult per block per `readRepairWindowMs`, damped by a 1000-entry LRU of
271
- // block ids. If a working set wider than that LRU ever shows a consult on every
272
- // read, widen the LRU rather than reinstating the skip.
273
- response = await this.repo.get(operation.get, { expiration: message.expiration })
274
- } else if ('pend' in operation) {
275
- response = await this.repo.pend(operation.pend, { expiration: message.expiration })
276
- } else if ('cancel' in operation) {
277
- response = await this.repo.cancel(operation.cancel.actionRef, { expiration: message.expiration })
278
- } else if ('commit' in operation) {
279
- response = await this.repo.commit(operation.commit, { expiration: message.expiration })
280
- }
281
-
282
- // Encode and yield the response
283
- yield new TextEncoder().encode(JSON.stringify(response))
284
- // One request per stream: every real RepoClient sends exactly one request
285
- // per dial (see ProtocolClient.processMessage), so complete the generator
286
- // after the first response. A second frame a peer queued is then never read
287
- // or parsed. Mirrors sync/block-transfer.
288
- return
289
- }
290
- }
291
-
292
- void (async () => {
293
- try {
294
- // Authorization runs before ANY decoding or execution. Guarded on the field so a
295
- // node without a predicate keeps the original path untouched.
296
- if (this.authorization && await this.authorization.deny(stream, peerId?.toString())) return
297
- const responses = pipe(
298
- stream,
299
- (source) => lpDecode(source, { maxDataLength: MAX_BLOCK_MESSAGE_BYTES }),
300
- processStream.bind(this),
301
- (source) => lpEncode(source)
302
- )
303
- for await (const chunk of responses) {
304
- stream.send(chunk)
305
- }
306
- await stream.close()
307
- } catch (err) {
308
- this.log.error('error handling repo protocol message from %p - %e', peerId, err)
309
- stream.abort(err instanceof Error ? err : new Error(String(err)))
310
- }
311
- })()
312
- }
313
- }
1
+ import { pipe } from 'it-pipe'
2
+ import { decode as lpDecode, encode as lpEncode } from 'it-length-prefixed'
3
+ import type { Startable, Logger, Stream, Connection, StreamHandler, PeerId, Libp2p } from '@libp2p/interface'
4
+ import type { IRepo, RepoMessage } from '@optimystic/db-core'
5
+ import { blockIdsForTransforms } from '@optimystic/db-core'
6
+ import { peersEqual } from '../peer-utils.js'
7
+ import { encodePeers, type RedirectPayload } from './redirect.js'
8
+ import { MAX_BLOCK_MESSAGE_BYTES } from '../protocol-limits.js'
9
+ import type { Uint8ArrayList } from 'uint8arraylist'
10
+ import { createLogger } from '../logger.js'
11
+ import { publishableConnectionAddr, type AddressLog, type DirectionalConnection } from '../peer-address-book.js'
12
+ import { createInboundStreamAuthorization, type InboundStreamAuthorization, type InboundStreamAuthorizationInit } from '../inbound-authorization.js'
13
+
14
+ const debugLog = createLogger('repo-service')
15
+
16
+ // Define Components interface
17
+ interface BaseComponents {
18
+ logger: { forComponent: (name: string) => Logger },
19
+ registrar: {
20
+ handle: (protocol: string, handler: StreamHandler, options: any) => Promise<void>
21
+ unhandle: (protocol: string) => Promise<void>
22
+ }
23
+ }
24
+
25
+ export interface NetworkManagerLike {
26
+ getCluster(key: Uint8Array): Promise<PeerId[]>
27
+ }
28
+
29
+ export type RepoServiceComponents = BaseComponents & {
30
+ repo: IRepo
31
+ networkManager?: NetworkManagerLike
32
+ peerId?: PeerId
33
+ getConnectionAddrs?: (peerId: PeerId) => string[]
34
+ /**
35
+ * Optional libp2p node. The production wiring injects the node post-construction
36
+ * via {@link RepoService.setLibp2p} (the `components.libp2p` proxy does not
37
+ * reliably resolve from inside a service at request time); this field is a
38
+ * best-effort fallback resolver used only when no node has been injected.
39
+ */
40
+ libp2p?: Libp2p
41
+ }
42
+
43
+ export type RepoServiceInit = InboundStreamAuthorizationInit & {
44
+ protocol?: string,
45
+ protocolPrefix?: string,
46
+ maxInboundStreams?: number,
47
+ maxOutboundStreams?: number,
48
+ logPrefix?: string,
49
+ kBucketSize?: number,
50
+ /**
51
+ * Responsibility K - the replica set size for determining cluster membership.
52
+ * This is distinct from kBucketSize (DHT routing).
53
+ * When set, this determines how many peers (by XOR distance) are considered
54
+ * responsible for a key. If this node is not in the top responsibilityK peers,
55
+ * it will redirect requests to closer peers.
56
+ * Default: 1 (only the closest peer handles requests)
57
+ */
58
+ responsibilityK?: number,
59
+ }
60
+
61
+ export function repoService(init: RepoServiceInit = {}): (components: RepoServiceComponents) => RepoService {
62
+ return (components: RepoServiceComponents) => new RepoService(components, init);
63
+ }
64
+
65
+ /**
66
+ * A libp2p service that handles repo protocol messages
67
+ */
68
+ export class RepoService implements Startable {
69
+ private readonly protocol: string
70
+ private readonly maxInboundStreams: number
71
+ private readonly maxOutboundStreams: number
72
+ private readonly log: Logger
73
+ private readonly repo: IRepo
74
+ private readonly components: RepoServiceComponents
75
+ private running: boolean
76
+ /** Responsibility K - how many peers are responsible for a key (for redirect decisions) */
77
+ private readonly responsibilityK: number
78
+ /**
79
+ * The libp2p node, injected post-construction by the node wiring (see
80
+ * libp2p-node-base.ts, mirroring how `networkManager`/`fret` receive theirs).
81
+ * The libp2p `components.libp2p` proxy does NOT reliably resolve from inside a
82
+ * service at request time, so the redirect path resolves the network manager,
83
+ * self identity, and connection addrs through this explicitly-set reference.
84
+ */
85
+ private libp2pRef: Libp2p | undefined
86
+ /** Optional embedder authorization gate; `undefined` (the default) means no check runs. */
87
+ private readonly authorization: InboundStreamAuthorization | undefined
88
+ /**
89
+ * Sink for this service's `peer-address-book:*` lines — same reasoning as `ClusterService`'s:
90
+ * `this.log.error` would strand them under `db-p2p:repo-service:error`, outside the
91
+ * `optimystic:db-p2p:*` tree every other address-book line lives in.
92
+ */
93
+ private readonly addressLog: AddressLog
94
+
95
+ constructor(components: RepoServiceComponents, init: RepoServiceInit = {}) {
96
+ this.components = components
97
+ const computed = init.protocol ?? (init.protocolPrefix ?? '/db-p2p') + '/repo/1.0.0'
98
+ this.protocol = computed
99
+ this.maxInboundStreams = init.maxInboundStreams ?? 32
100
+ this.maxOutboundStreams = init.maxOutboundStreams ?? 64
101
+ this.log = components.logger.forComponent(init.logPrefix ?? 'db-p2p:repo-service')
102
+ this.addressLog = createLogger('peer-address-book', components.peerId?.toString())
103
+ this.repo = components.repo
104
+ this.running = false
105
+ this.responsibilityK = init.responsibilityK ?? 1
106
+ this.authorization = createInboundStreamAuthorization(init, this.protocol, (msg, ...args) => this.log.error(msg, ...args))
107
+ }
108
+
109
+ readonly [Symbol.toStringTag] = '@libp2p/repo-service'
110
+
111
+ /**
112
+ * Inject the running libp2p node. Called once post-construction by the node
113
+ * wiring so the redirect path can resolve the network manager / self id / addrs.
114
+ */
115
+ setLibp2p(libp2p: Libp2p): void {
116
+ this.libp2pRef = libp2p
117
+ }
118
+
119
+ /** Resolve the libp2p node: the injected ref first, then the (best-effort) components proxy. */
120
+ private getLibp2p(): Libp2p | undefined {
121
+ return this.libp2pRef ?? (this.components as any).libp2p
122
+ }
123
+
124
+ /**
125
+ * Start the service
126
+ */
127
+ async start(): Promise<void> {
128
+ if (this.running) {
129
+ return
130
+ }
131
+
132
+ await this.components.registrar.handle(this.protocol, this.handleIncomingStream.bind(this), {
133
+ maxInboundStreams: this.maxInboundStreams,
134
+ maxOutboundStreams: this.maxOutboundStreams
135
+ })
136
+
137
+ this.running = true
138
+ }
139
+
140
+ /**
141
+ * Stop the service
142
+ */
143
+ async stop(): Promise<void> {
144
+ if (!this.running) {
145
+ return
146
+ }
147
+
148
+ await this.components.registrar.unhandle(this.protocol)
149
+ this.running = false
150
+ }
151
+
152
+ private getNetworkManager(): NetworkManagerLike | undefined {
153
+ if (this.components.networkManager) return this.components.networkManager
154
+ return (this.getLibp2p() as any)?.services?.networkManager as NetworkManagerLike | undefined
155
+ }
156
+
157
+ private getSelfId(): PeerId | undefined {
158
+ if (this.components.peerId) return this.components.peerId
159
+ return this.getLibp2p()?.peerId as PeerId | undefined
160
+ }
161
+
162
+ private getPeerAddrs(peerId: PeerId): string[] {
163
+ if (this.components.getConnectionAddrs) return this.components.getConnectionAddrs(peerId)
164
+ const libp2p = this.getLibp2p() as any
165
+ if (!libp2p?.getConnections) return []
166
+ // A redirect payload goes to a THIRD party, so only an outbound connection's remoteAddr
167
+ // qualifies — see `publishableConnectionAddr`.
168
+ const conns: DirectionalConnection[] = libp2p.getConnections(peerId) ?? []
169
+ const addrs: string[] = []
170
+ for (const c of conns) {
171
+ const addr = publishableConnectionAddr(c, this.addressLog)
172
+ if (addr !== undefined) addrs.push(addr)
173
+ }
174
+ return addrs
175
+ }
176
+
177
+ /**
178
+ * Derive the redirect routing key and op name for a single operation.
179
+ *
180
+ * The key MUST be the block the corresponding handler actually coordinates and
181
+ * verifies responsibility on, so redirect routing stays consistent with where the
182
+ * op is executed:
183
+ * - get → blockIds[0]
184
+ * - pend → blockIdsForTransforms(transforms)[0]
185
+ * - cancel → actionRef.blockIds[0]
186
+ * - commit → blockIds[0] (CoordinatorRepo.commit anchors consensus on
187
+ * getClusterSize(blockIds[0]) / executeClusterTransaction(blockIds[0]) and guards
188
+ * with verifyResponsibility(blockIds) NOT tailId; for a per-block commit batch
189
+ * whose blockIds[0] !== tailId, keying on tailId redirected the commit to the
190
+ * collection tail's cluster, which then fails verifyResponsibility for the non-tail block.)
191
+ *
192
+ * Returns blockKey === undefined when the op carries no routable key (e.g. a cancel
193
+ * with an empty blockIds list), in which case the caller handles it locally without a
194
+ * redirect check.
195
+ */
196
+ deriveBlockKey(operation: RepoMessage['operations'][number]): { blockKey: string | undefined, opName: string } {
197
+ if ('get' in operation) {
198
+ return { blockKey: operation.get.blockIds[0], opName: 'get' }
199
+ }
200
+ if ('pend' in operation) {
201
+ return { blockKey: blockIdsForTransforms(operation.pend.transforms)[0], opName: 'pend' }
202
+ }
203
+ if ('cancel' in operation) {
204
+ return { blockKey: operation.cancel.actionRef.blockIds[0], opName: 'cancel' }
205
+ }
206
+ if ('commit' in operation) {
207
+ return { blockKey: operation.commit.blockIds[0], opName: 'commit' }
208
+ }
209
+ return { blockKey: undefined, opName: 'unknown' }
210
+ }
211
+
212
+ /**
213
+ * Check if this node should redirect the request for a given key.
214
+ * Returns a RedirectPayload if not responsible, null if should handle locally.
215
+ * Also attaches cluster info to the message for downstream use.
216
+ */
217
+ async checkRedirect(blockKey: string, opName: string, message: RepoMessage): Promise<RedirectPayload | null> {
218
+ const nm = this.getNetworkManager()
219
+ if (!nm) return null
220
+
221
+ // Pass the RAW encoded block-key bytes to getCluster. getCluster hashes
222
+ // internally (hashKey == sha256), so the responsible-set coordinate becomes
223
+ // hashKey(encode(blockKey)) identical to how the cluster coordinator
224
+ // derives it (ClusterCoordinator.getClusterForBlock → findCluster(encode(blockId))).
225
+ // Pre-hashing here would double-hash (hashKey(sha256(encode(blockKey)))), placing
226
+ // the cohort at an unrelated ring coordinate and redirecting requests the
227
+ // coordinator legitimately routed to this peer.
228
+ const key = new TextEncoder().encode(blockKey)
229
+ const cluster = await nm.getCluster(key)
230
+ ;(message as any).cluster = cluster.map((p: PeerId) => p.toString?.() ?? String(p))
231
+
232
+ const selfId = this.getSelfId()
233
+ if (!selfId) return null
234
+
235
+ const isMember = cluster.some((p: PeerId) => peersEqual(p, selfId))
236
+ const smallMesh = cluster.length < this.responsibilityK
237
+
238
+ if (!smallMesh && !isMember) {
239
+ const peers = cluster.filter((p: PeerId) => !peersEqual(p, selfId))
240
+ debugLog('redirect op=%s blockKey=%s cluster=%d', opName, blockKey, cluster.length)
241
+ return encodePeers(peers.map((pid: PeerId) => ({
242
+ id: pid.toString(),
243
+ addrs: this.getPeerAddrs(pid)
244
+ })))
245
+ }
246
+
247
+ return null
248
+ }
249
+
250
+ /**
251
+ * Handle incoming streams on the repo protocol
252
+ */
253
+ private handleIncomingStream(stream: Stream, connection?: Connection): void {
254
+ const peerId = connection?.remotePeer
255
+
256
+ const processStream = async function* (this: RepoService, source: AsyncIterable<Uint8ArrayList>) {
257
+ for await (const msg of source) {
258
+ // Decode the message
259
+ const decoded = new TextDecoder().decode(msg.subarray())
260
+ const message = JSON.parse(decoded) as RepoMessage
261
+
262
+ // Process each operation. Derive the redirect routing key once (keyed on the
263
+ // block the handler actually coordinates), redirect-check it, then dispatch.
264
+ const operation = message.operations[0]
265
+ const { blockKey, opName } = this.deriveBlockKey(operation)
266
+ const redirect = blockKey !== undefined
267
+ ? await this.checkRedirect(blockKey, opName, message)
268
+ : null
269
+
270
+ let response: any
271
+ if (redirect) {
272
+ response = redirect
273
+ } else if ('get' in operation) {
274
+ // No `skipClusterFetch` here: a read on this protocol comes from ANOTHER node, so
275
+ // it must reach `CoordinatorRepo`'s cohort consult — answering a bare absent for
276
+ // a block a cohort peer holds is an authoritative lie the transactor never
277
+ // retries. Only the sync protocol keeps the flag (`sync/service.ts`, where the
278
+ // consult itself lands), and that is what stops the recursion.
279
+ // NOTE: this also puts lazy read-repair on remote reads of locally-present blocks
280
+ // — one consult per block per `readRepairWindowMs`, damped by a 1000-entry LRU of
281
+ // block ids. If a working set wider than that LRU ever shows a consult on every
282
+ // read, widen the LRU rather than reinstating the skip.
283
+ response = await this.repo.get(operation.get, { expiration: message.expiration })
284
+ } else if ('pend' in operation) {
285
+ response = await this.repo.pend(operation.pend, { expiration: message.expiration })
286
+ } else if ('cancel' in operation) {
287
+ response = await this.repo.cancel(operation.cancel.actionRef, { expiration: message.expiration })
288
+ } else if ('commit' in operation) {
289
+ response = await this.repo.commit(operation.commit, { expiration: message.expiration })
290
+ }
291
+
292
+ // Encode and yield the response
293
+ yield new TextEncoder().encode(JSON.stringify(response))
294
+ // One request per stream: every real RepoClient sends exactly one request
295
+ // per dial (see ProtocolClient.processMessage), so complete the generator
296
+ // after the first response. A second frame a peer queued is then never read
297
+ // or parsed. Mirrors sync/block-transfer.
298
+ return
299
+ }
300
+ }
301
+
302
+ void (async () => {
303
+ try {
304
+ // Authorization runs before ANY decoding or execution. Guarded on the field so a
305
+ // node without a predicate keeps the original path untouched.
306
+ if (this.authorization && await this.authorization.deny(stream, peerId?.toString())) return
307
+ const responses = pipe(
308
+ stream,
309
+ (source) => lpDecode(source, { maxDataLength: MAX_BLOCK_MESSAGE_BYTES }),
310
+ processStream.bind(this),
311
+ (source) => lpEncode(source)
312
+ )
313
+ for await (const chunk of responses) {
314
+ stream.send(chunk)
315
+ }
316
+ await stream.close()
317
+ } catch (err) {
318
+ this.log.error('error handling repo protocol message from %p - %e', peerId, err)
319
+ stream.abort(err instanceof Error ? err : new Error(String(err)))
320
+ }
321
+ })()
322
+ }
323
+ }