@optimystic/db-p2p 1.0.0-beta.3 → 1.1.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 (234) hide show
  1. package/dist/src/cluster/block-transfer-service.d.ts +73 -1
  2. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  3. package/dist/src/cluster/block-transfer-service.js +70 -3
  4. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  5. package/dist/src/cluster/block-transfer.d.ts +16 -1
  6. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  7. package/dist/src/cluster/block-transfer.js +73 -72
  8. package/dist/src/cluster/block-transfer.js.map +1 -1
  9. package/dist/src/cluster/client.d.ts +4 -6
  10. package/dist/src/cluster/client.d.ts.map +1 -1
  11. package/dist/src/cluster/client.js +8 -13
  12. package/dist/src/cluster/client.js.map +1 -1
  13. package/dist/src/cluster/cluster-policy.d.ts +2 -1
  14. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  15. package/dist/src/cluster/cluster-policy.js.map +1 -1
  16. package/dist/src/cluster/cluster-repo.d.ts +155 -9
  17. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  18. package/dist/src/cluster/cluster-repo.js +446 -58
  19. package/dist/src/cluster/cluster-repo.js.map +1 -1
  20. package/dist/src/cluster/commit-proof.js +2 -2
  21. package/dist/src/cluster/race-resolution.d.ts +51 -7
  22. package/dist/src/cluster/race-resolution.d.ts.map +1 -1
  23. package/dist/src/cluster/race-resolution.js +71 -7
  24. package/dist/src/cluster/race-resolution.js.map +1 -1
  25. package/dist/src/cluster/rebalance-monitor.d.ts +67 -5
  26. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  27. package/dist/src/cluster/rebalance-monitor.js +120 -26
  28. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  29. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  30. package/dist/src/cluster/spread-on-churn.js +34 -47
  31. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  32. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  33. package/dist/src/cohort-topic/host.js +8 -6
  34. package/dist/src/cohort-topic/host.js.map +1 -1
  35. package/dist/src/cohort-topic/membership-source.js +1 -1
  36. package/dist/src/cohort-topic/membership-source.js.map +1 -1
  37. package/dist/src/cohort-topic/peer-codec.d.ts.map +1 -1
  38. package/dist/src/cohort-topic/peer-codec.js +5 -2
  39. package/dist/src/cohort-topic/peer-codec.js.map +1 -1
  40. package/dist/src/cohort-topic/stream-util.d.ts +24 -3
  41. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  42. package/dist/src/cohort-topic/stream-util.js +36 -3
  43. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  44. package/dist/src/cohort-topic/topic-router.d.ts +9 -0
  45. package/dist/src/cohort-topic/topic-router.d.ts.map +1 -1
  46. package/dist/src/cohort-topic/topic-router.js +12 -2
  47. package/dist/src/cohort-topic/topic-router.js.map +1 -1
  48. package/dist/src/dispute/client.d.ts.map +1 -1
  49. package/dist/src/dispute/client.js +15 -3
  50. package/dist/src/dispute/client.js.map +1 -1
  51. package/dist/src/index.d.ts +5 -0
  52. package/dist/src/index.d.ts.map +1 -1
  53. package/dist/src/index.js +5 -0
  54. package/dist/src/index.js.map +1 -1
  55. package/dist/src/libp2p-key-network.d.ts +78 -17
  56. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  57. package/dist/src/libp2p-key-network.js +234 -147
  58. package/dist/src/libp2p-key-network.js.map +1 -1
  59. package/dist/src/libp2p-node-base.d.ts +35 -1
  60. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  61. package/dist/src/libp2p-node-base.js +151 -26
  62. package/dist/src/libp2p-node-base.js.map +1 -1
  63. package/dist/src/libp2p-node-rn.d.ts +2 -2
  64. package/dist/src/libp2p-node-rn.d.ts.map +1 -1
  65. package/dist/src/libp2p-node-rn.js.map +1 -1
  66. package/dist/src/libp2p-node.d.ts +2 -2
  67. package/dist/src/libp2p-node.d.ts.map +1 -1
  68. package/dist/src/libp2p-node.js.map +1 -1
  69. package/dist/src/logger.d.ts.map +1 -1
  70. package/dist/src/logger.js +6 -1
  71. package/dist/src/logger.js.map +1 -1
  72. package/dist/src/matchmaking/query-transport.d.ts +3 -4
  73. package/dist/src/matchmaking/query-transport.d.ts.map +1 -1
  74. package/dist/src/matchmaking/query-transport.js +9 -9
  75. package/dist/src/matchmaking/query-transport.js.map +1 -1
  76. package/dist/src/network/network-manager-service.d.ts +10 -5
  77. package/dist/src/network/network-manager-service.d.ts.map +1 -1
  78. package/dist/src/network/network-manager-service.js +11 -2
  79. package/dist/src/network/network-manager-service.js.map +1 -1
  80. package/dist/src/network/relay-reservation.d.ts +170 -0
  81. package/dist/src/network/relay-reservation.d.ts.map +1 -0
  82. package/dist/src/network/relay-reservation.js +527 -0
  83. package/dist/src/network/relay-reservation.js.map +1 -0
  84. package/dist/src/pend-validation.d.ts +3 -2
  85. package/dist/src/pend-validation.d.ts.map +1 -1
  86. package/dist/src/pend-validation.js +3 -2
  87. package/dist/src/pend-validation.js.map +1 -1
  88. package/dist/src/protocol-client.d.ts +8 -1
  89. package/dist/src/protocol-client.d.ts.map +1 -1
  90. package/dist/src/protocol-client.js +11 -0
  91. package/dist/src/protocol-client.js.map +1 -1
  92. package/dist/src/reactivity/forwarder-host.d.ts +2 -1
  93. package/dist/src/reactivity/forwarder-host.d.ts.map +1 -1
  94. package/dist/src/reactivity/forwarder-host.js +2 -1
  95. package/dist/src/reactivity/forwarder-host.js.map +1 -1
  96. package/dist/src/reactivity/origination-manager.d.ts +3 -3
  97. package/dist/src/reactivity/origination-manager.js +3 -3
  98. package/dist/src/reactivity/recover-transport.d.ts +22 -14
  99. package/dist/src/reactivity/recover-transport.d.ts.map +1 -1
  100. package/dist/src/reactivity/recover-transport.js +32 -19
  101. package/dist/src/reactivity/recover-transport.js.map +1 -1
  102. package/dist/src/reactivity/subscription-manager.d.ts +2 -2
  103. package/dist/src/reactivity/topic-bytes.d.ts +6 -7
  104. package/dist/src/reactivity/topic-bytes.d.ts.map +1 -1
  105. package/dist/src/reactivity/topic-bytes.js +8 -9
  106. package/dist/src/reactivity/topic-bytes.js.map +1 -1
  107. package/dist/src/repo/client.d.ts +5 -2
  108. package/dist/src/repo/client.d.ts.map +1 -1
  109. package/dist/src/repo/client.js +49 -40
  110. package/dist/src/repo/client.js.map +1 -1
  111. package/dist/src/repo/cluster-coordinator.d.ts +96 -11
  112. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  113. package/dist/src/repo/cluster-coordinator.js +164 -25
  114. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  115. package/dist/src/repo/coordinator-repo.d.ts +240 -122
  116. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  117. package/dist/src/repo/coordinator-repo.js +627 -473
  118. package/dist/src/repo/coordinator-repo.js.map +1 -1
  119. package/dist/src/repo/i-under-replication-ledger.d.ts +87 -0
  120. package/dist/src/repo/i-under-replication-ledger.d.ts.map +1 -0
  121. package/dist/src/repo/i-under-replication-ledger.js +2 -0
  122. package/dist/src/repo/i-under-replication-ledger.js.map +1 -0
  123. package/dist/src/repo/kv-under-replication-ledger.d.ts +91 -0
  124. package/dist/src/repo/kv-under-replication-ledger.d.ts.map +1 -0
  125. package/dist/src/repo/kv-under-replication-ledger.js +287 -0
  126. package/dist/src/repo/kv-under-replication-ledger.js.map +1 -0
  127. package/dist/src/repo/responsibility.d.ts +37 -0
  128. package/dist/src/repo/responsibility.d.ts.map +1 -0
  129. package/dist/src/repo/responsibility.js +37 -0
  130. package/dist/src/repo/responsibility.js.map +1 -0
  131. package/dist/src/repo/service.d.ts +44 -15
  132. package/dist/src/repo/service.d.ts.map +1 -1
  133. package/dist/src/repo/service.js +64 -27
  134. package/dist/src/repo/service.js.map +1 -1
  135. package/dist/src/repo/stuck-reservation.d.ts +127 -0
  136. package/dist/src/repo/stuck-reservation.d.ts.map +1 -0
  137. package/dist/src/repo/stuck-reservation.js +179 -0
  138. package/dist/src/repo/stuck-reservation.js.map +1 -0
  139. package/dist/src/repo/under-replication-drain.d.ts +208 -0
  140. package/dist/src/repo/under-replication-drain.d.ts.map +1 -0
  141. package/dist/src/repo/under-replication-drain.js +461 -0
  142. package/dist/src/repo/under-replication-drain.js.map +1 -0
  143. package/dist/src/rn.d.ts +5 -0
  144. package/dist/src/rn.d.ts.map +1 -1
  145. package/dist/src/rn.js +5 -0
  146. package/dist/src/rn.js.map +1 -1
  147. package/dist/src/storage/block-latch.d.ts +3 -2
  148. package/dist/src/storage/block-latch.d.ts.map +1 -1
  149. package/dist/src/storage/block-latch.js +7 -4
  150. package/dist/src/storage/block-latch.js.map +1 -1
  151. package/dist/src/storage/block-storage.d.ts +45 -3
  152. package/dist/src/storage/block-storage.d.ts.map +1 -1
  153. package/dist/src/storage/block-storage.js +156 -9
  154. package/dist/src/storage/block-storage.js.map +1 -1
  155. package/dist/src/storage/i-block-storage.d.ts +57 -5
  156. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  157. package/dist/src/storage/i-block-storage.js.map +1 -1
  158. package/dist/src/storage/pending-claim.d.ts +167 -0
  159. package/dist/src/storage/pending-claim.d.ts.map +1 -0
  160. package/dist/src/storage/pending-claim.js +156 -0
  161. package/dist/src/storage/pending-claim.js.map +1 -0
  162. package/dist/src/storage/raw-store-codec.d.ts +13 -0
  163. package/dist/src/storage/raw-store-codec.d.ts.map +1 -1
  164. package/dist/src/storage/raw-store-codec.js +24 -3
  165. package/dist/src/storage/raw-store-codec.js.map +1 -1
  166. package/dist/src/storage/restoration-coordinator.d.ts.map +1 -1
  167. package/dist/src/storage/restoration-coordinator.js +2 -1
  168. package/dist/src/storage/restoration-coordinator.js.map +1 -1
  169. package/dist/src/storage/ring-shift-coordinator.d.ts.map +1 -1
  170. package/dist/src/storage/ring-shift-coordinator.js +3 -3
  171. package/dist/src/storage/ring-shift-coordinator.js.map +1 -1
  172. package/dist/src/storage/storage-repo.d.ts +122 -5
  173. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  174. package/dist/src/storage/storage-repo.js +245 -68
  175. package/dist/src/storage/storage-repo.js.map +1 -1
  176. package/dist/src/storage/struct.d.ts +58 -0
  177. package/dist/src/storage/struct.d.ts.map +1 -1
  178. package/dist/src/testing/mesh-harness.d.ts +77 -11
  179. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  180. package/dist/src/testing/mesh-harness.js +226 -100
  181. package/dist/src/testing/mesh-harness.js.map +1 -1
  182. package/package.json +2 -2
  183. package/readme.md +27 -3
  184. package/src/cluster/block-transfer-service.ts +132 -4
  185. package/src/cluster/block-transfer.ts +79 -84
  186. package/src/cluster/client.ts +115 -119
  187. package/src/cluster/cluster-policy.ts +2 -1
  188. package/src/cluster/cluster-repo.ts +493 -65
  189. package/src/cluster/commit-proof.ts +2 -2
  190. package/src/cluster/race-resolution.ts +76 -7
  191. package/src/cluster/rebalance-monitor.ts +677 -539
  192. package/src/cluster/spread-on-churn.ts +34 -54
  193. package/src/cohort-topic/host.ts +8 -6
  194. package/src/cohort-topic/membership-source.ts +1 -1
  195. package/src/cohort-topic/peer-codec.ts +34 -31
  196. package/src/cohort-topic/stream-util.ts +39 -4
  197. package/src/cohort-topic/topic-router.ts +12 -2
  198. package/src/dispute/client.ts +17 -7
  199. package/src/index.ts +64 -59
  200. package/src/libp2p-key-network.ts +1377 -1260
  201. package/src/libp2p-node-base.ts +186 -27
  202. package/src/libp2p-node-rn.ts +31 -30
  203. package/src/libp2p-node.ts +37 -36
  204. package/src/logger.ts +6 -1
  205. package/src/matchmaking/query-transport.ts +9 -9
  206. package/src/network/network-manager-service.ts +16 -5
  207. package/src/network/relay-reservation.ts +636 -0
  208. package/src/pend-validation.ts +3 -2
  209. package/src/protocol-client.ts +13 -1
  210. package/src/reactivity/forwarder-host.ts +2 -1
  211. package/src/reactivity/origination-manager.ts +3 -3
  212. package/src/reactivity/recover-transport.ts +39 -22
  213. package/src/reactivity/subscription-manager.ts +2 -2
  214. package/src/reactivity/topic-bytes.ts +35 -37
  215. package/src/repo/client.ts +172 -173
  216. package/src/repo/cluster-coordinator.ts +183 -25
  217. package/src/repo/coordinator-repo.ts +656 -506
  218. package/src/repo/i-under-replication-ledger.ts +89 -0
  219. package/src/repo/kv-under-replication-ledger.ts +302 -0
  220. package/src/repo/responsibility.ts +48 -0
  221. package/src/repo/service.ts +76 -39
  222. package/src/repo/stuck-reservation.ts +224 -0
  223. package/src/repo/under-replication-drain.ts +582 -0
  224. package/src/rn.ts +74 -69
  225. package/src/storage/block-latch.ts +7 -4
  226. package/src/storage/block-storage.ts +161 -10
  227. package/src/storage/i-block-storage.ts +60 -5
  228. package/src/storage/pending-claim.ts +200 -0
  229. package/src/storage/raw-store-codec.ts +26 -3
  230. package/src/storage/restoration-coordinator.ts +212 -211
  231. package/src/storage/ring-shift-coordinator.ts +271 -271
  232. package/src/storage/storage-repo.ts +280 -72
  233. package/src/storage/struct.ts +58 -0
  234. package/src/testing/mesh-harness.ts +293 -112
@@ -174,9 +174,9 @@ export class ReactivityOriginationManager {
174
174
  * recover serve begins redirecting to the new tree.
175
175
  *
176
176
  * **Encoding contract.** `ctx.tailId` is the reactivity tail anchor bytes the node resolved
177
- * (`reactivityTailBytes(event.tailId)` in production — the SAME utf8 encoding origination's membership gate
178
- * and a subscriber's `reactivityTopicId(reactivityTailBytes(tail))` use, NOT the double-hashing
179
- * `blockIdToBytes`). So `oldTopicId = reactivityTopicId(oldAnchorBytes)` is byte-identical to the topic a
177
+ * (`reactivityTailBytes(event.tailId)` in production — the SAME raw utf8 encoding origination's membership
178
+ * gate and a subscriber's `reactivityTopicId(reactivityTailBytes(tail))` use, never a pre-hashed digest).
179
+ * So `oldTopicId = reactivityTopicId(oldAnchorBytes)` is byte-identical to the topic a
180
180
  * subscriber subscribed under — a mismatch would silently never redirect.
181
181
  */
182
182
  private detectTailRotation(event: CollectionChangeEvent, ctx: OriginationCollectionContext): void {
@@ -19,7 +19,8 @@
19
19
  * - {@link createRecoverRequestHandler} / {@link registerRecoverHandler} — the **inbound** serve handler.
20
20
  * Decode (bounded) → verify the request's peer-key signature against the dialing peer → reject a
21
21
  * replay/stale request → resolve the live `PushState` → `serveBackfill` / `serveResume` → reply. Any
22
- * failure produces **no reply** (the stream aborts) rather than throwing out of the handler.
22
+ * failure **declines** rather than throwing out of the handler: the member sends a zero-length reply (no
23
+ * result), and the dialing transport falls through to the next cohort candidate.
23
24
  *
24
25
  * ## Dial-target encoding bridge (load-bearing)
25
26
  *
@@ -60,7 +61,7 @@ import {
60
61
  type CorrelationReplayGuard,
61
62
  type RotationRedirectV1,
62
63
  } from "@optimystic/db-core";
63
- import { requestResponse, handleRequestResponse, DEFAULT_STREAM_MAX_BYTES } from "../cohort-topic/stream-util.js";
64
+ import { requestResponse, handleRequestResponse, NoResultReplyError, DEFAULT_STREAM_MAX_BYTES } from "../cohort-topic/stream-util.js";
64
65
  import { verifyPeerSig, signPeerSig } from "../cohort-topic/peer-sig.js";
65
66
  import { peerIdToBytes, bytesToPeerIdString } from "../cohort-topic/peer-codec.js";
66
67
  import { PROTOCOL_REACTIVITY_RECOVER } from "./protocols.js";
@@ -134,8 +135,12 @@ export class RotationRedirectError extends Error {
134
135
 
135
136
  /** The wire exchange seam: open the recover protocol to `target`, send `frame`, return the bounded reply. */
136
137
  export interface RecoverDialer {
137
- /** Exchange one request frame for one reply frame with `target` (peer-id string). Rejects on a dial failure. */
138
- exchange(target: string, frame: Uint8Array): Promise<Uint8Array>;
138
+ /**
139
+ * Exchange one request frame for one reply frame with `target` (peer-id string). Resolves `undefined` when
140
+ * the member declined (no served state, a failed signature / replay check, an undecodable request) — the
141
+ * same contract as {@link requestResponse}. Rejects on a dial failure.
142
+ */
143
+ exchange(target: string, frame: Uint8Array): Promise<Uint8Array | undefined>;
139
144
  }
140
145
 
141
146
  /** Build the production libp2p-backed {@link RecoverDialer} over {@link requestResponse}. */
@@ -162,9 +167,11 @@ export interface Libp2pReactivityRecoverTransportOptions {
162
167
  /**
163
168
  * The outbound recover transport: one instance per node, exposing the two db-core function seams against it.
164
169
  * Each returned transport dials the **sticky primary first** (one round trip after a brief flap), falling
165
- * back to a **cohort-walk** member on a dial failure (any member that holds the topic's gossiped `PushState`
166
- * can answer). A kind mismatch or all-targets-failed surfaces as a rejection so the caller's retry/escalation
167
- * policy (the subscription manager's backfill escalation, or `manager.resume()`'s caller) takes over.
170
+ * back to a **cohort-walk** member on a dial failure *or* when the dialed member declines (any member that
171
+ * holds the topic's gossiped `PushState` can answer, so one member with nothing to serve must not end the
172
+ * walk). A kind mismatch, an undecodable reply, or every target failing / declining surfaces as a rejection
173
+ * so the caller's retry/escalation policy (the subscription manager's backfill escalation, or
174
+ * `manager.resume()`'s caller) takes over.
168
175
  */
169
176
  export class Libp2pReactivityRecoverTransport {
170
177
  private readonly dialer: RecoverDialer;
@@ -206,10 +213,13 @@ export class Libp2pReactivityRecoverTransport {
206
213
  }
207
214
 
208
215
  /**
209
- * Exchange one recover frame with the first reachable target: sticky primary, then each cohort-walk
210
- * member. A **dial failure** falls through to the next candidate; a successful dial is **terminal** (the
211
- * member answered for the cohort) a `kind: "rotated"` redirect throws {@link RotationRedirectError}, and
212
- * a reply decoding to the wrong `kind` is a protocol error. Throws when no candidate succeeds.
216
+ * Exchange one recover frame with the first target that answers: sticky primary, then each cohort-walk
217
+ * member. A **dial failure** and a **declined** request (the member had no result no served state, a
218
+ * failed signature / replay check, an undecodable request) both fall through to the next candidate. A
219
+ * reply frame is **terminal** (the member answered for the cohort): a `kind: "rotated"` redirect throws
220
+ * {@link RotationRedirectError}, and a reply that fails to decode or decodes to the wrong `kind` is a
221
+ * protocol error. When no candidate answers, throws the last candidate's outcome — a
222
+ * {@link NoResultReplyError} if it declined, its dial error if it failed to dial.
213
223
  */
214
224
  private async exchange(kind: RecoverKind, frame: Uint8Array, topicId: Uint8Array, collectionId: string): Promise<RecoverReplyV1> {
215
225
  const targets = this.selectTargets(topicId, collectionId);
@@ -218,7 +228,7 @@ export class Libp2pReactivityRecoverTransport {
218
228
  }
219
229
  let lastErr: unknown;
220
230
  for (const target of targets) {
221
- let replyFrame: Uint8Array;
231
+ let replyFrame: Uint8Array | undefined;
222
232
  try {
223
233
  replyFrame = await this.dialer.exchange(target, frame);
224
234
  } catch (err) {
@@ -226,6 +236,15 @@ export class Libp2pReactivityRecoverTransport {
226
236
  log("recover dial to %s failed, trying next target: %o", target, err);
227
237
  continue;
228
238
  }
239
+ if (replyFrame === undefined) {
240
+ // Declined → fall back to the next candidate, resending the same signed frame.
241
+ // NOTE: the resend is admissible only because each node's `CorrelationReplayGuard` is node-local
242
+ // (`RecoverServeDeps.replayGuard`). If the guard is ever shared across a cohort, the retried frame
243
+ // would be rejected everywhere as a replay, and each fallthrough would need a freshly signed request.
244
+ lastErr = new NoResultReplyError(`reactivity recover ${kind} from ${target}`);
245
+ log("recover %s declined by %s, trying next target", kind, target);
246
+ continue;
247
+ }
229
248
  const reply = decodeRecoverReplyV1(replyFrame, this.maxBytes); // a decode failure here is terminal
230
249
  // A rotated cohort answered authoritatively: surface the redirect and stop the walk (terminal, never
231
250
  // a fallthrough — the dialed member spoke for the cohort).
@@ -298,17 +317,17 @@ export interface RecoverServeDeps {
298
317
  /**
299
318
  * Verify the request's peer-key signature against the dialing peer, then admit it through the freshness +
300
319
  * anti-replay guard. The dialing peer's id **is** the signer (no signer-id field on the wire); the signature
301
- * bytes are the anti-replay key (globally unique + authenticated). Returns `false` (reject, no reply) on a
320
+ * bytes are the anti-replay key (globally unique + authenticated). Returns `false` (decline) on a
302
321
  * bad signature, a stale/future timestamp, or a replay.
303
322
  */
304
323
  function verifyAndAdmit(deps: RecoverServeDeps, signerId: string, payload: Uint8Array, signatureB64: string, timestamp: number, now: number): boolean {
305
324
  const signature = b64urlToBytes(signatureB64);
306
325
  if (!verifyPeerSig(signerId, payload, signature)) {
307
- log("recover serve: signature verification failed for %s (no reply)", signerId);
326
+ log("recover serve: signature verification failed for %s (declined)", signerId);
308
327
  return false;
309
328
  }
310
329
  if (!deps.replayGuard.accept(signature, peerIdToBytes(signerId), timestamp, now)) {
311
- log("recover serve: replay/stale request from %s (no reply)", signerId);
330
+ log("recover serve: replay/stale request from %s (declined)", signerId);
312
331
  return false;
313
332
  }
314
333
  return true;
@@ -329,7 +348,7 @@ function serveBackfillReply(deps: RecoverServeDeps, req: BackfillV1, now: number
329
348
  }
330
349
  const ps = deps.pushStateForCollection(req.collectionId);
331
350
  if (ps === undefined) {
332
- return undefined; // not a serving member for this collection → no reply (subscriber walks/chain-reads)
351
+ return undefined; // not a serving member for this collection → decline (the subscriber tries the next member)
333
352
  }
334
353
  const backfillReply = serveBackfill(ps.replayBuffer, req, ps.collectionId);
335
354
  return encodeRecoverReplyV1({ v: 1, kind: "backfill", backfillReply }, maxBytes);
@@ -371,10 +390,8 @@ function serveResumeReply(deps: RecoverServeDeps, req: ResumeV1, now: number): U
371
390
  * Build the recover serve callback for {@link handleRequestResponse}: it returns the reply frame, or
372
391
  * `undefined` on a decode/verify/replay/resolve failure. It never throws out of the handler.
373
392
  *
374
- * NOTE: `undefined` reaches the dialer as an explicit zero-length frame, and
375
- * {@link Libp2pReactivityRecoverTransport.exchange} decodes that as a *terminal* protocol error rather
376
- * than falling through to the next cohort candidate — one member declining ends the whole recover walk.
377
- * Tracked as `debt-stream-reply-no-result-untyped`.
393
+ * `undefined` reaches the dialer as a zero-length frame, which {@link requestResponse} resolves as no result
394
+ * and {@link Libp2pReactivityRecoverTransport} treats as a decline it tries the next cohort candidate.
378
395
  */
379
396
  export function createRecoverRequestHandler(deps: RecoverServeDeps): (frame: Uint8Array, fromPeer: PeerId) => Promise<Uint8Array | undefined> {
380
397
  const maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
@@ -398,8 +415,8 @@ export function createRecoverRequestHandler(deps: RecoverServeDeps): (frame: Uin
398
415
  return Promise.resolve(serveResumeReply(deps, r, now));
399
416
  } catch (err) {
400
417
  // A malformed/foreign request (decode failure, foreign collectionId from serve*) must never throw
401
- // out of the stream handler: log + no reply (a zero-length reply frame; see the note above).
402
- log("recover serve: dropping request (no reply): %o", err);
418
+ // out of the stream handler: log + decline (a zero-length reply; the dialer tries the next member).
419
+ log("recover serve: dropping request (declined): %o", err);
403
420
  return Promise.resolve(undefined);
404
421
  }
405
422
  };
@@ -82,8 +82,8 @@ export interface ReactivitySubscriptionManagerOptions {
82
82
  *
83
83
  * **Load-bearing encoding contract.** When a production subscribe factory converts a `BlockId` tail to
84
84
  * these bytes it MUST use `reactivityTailBytes(tailId)` (`reactivity/topic-bytes.ts`) — the SAME function
85
- * origination's membership gate uses — NOT db-core's `blockIdToBytes` (which `sha256`s first a double
86
- * hash). Origination derives the topic's `coord_0` cohort from `reactivityTopicId(reactivityTailBytes(
85
+ * origination's membership gate uses — never a pre-hashed digest of the id, which would double-hash.
86
+ * Origination derives the topic's `coord_0` cohort from `reactivityTopicId(reactivityTailBytes(
87
87
  * tailId))`; if this side feeds differently-encoded bytes it subscribes to a *different* coord and
88
88
  * origination silently never reaches it (the `topic-bytes-encoding` spec pins the coord-equality).
89
89
  */
@@ -1,37 +1,35 @@
1
- /**
2
- * Reactivity — the one pinned `BlockId` → raw tail-bytes encoding, shared by origination and subscription.
3
- *
4
- * `reactivityTopicId` (db-core) hashes `H(tailBytes ‖ "reactivity")` to derive the topic's `coord_0`
5
- * cohort. Origination (the cohort-change membership gate, {@link
6
- * import("../cohort-topic/reactivity-membership-gate.js").createReactivitySelfMembershipGate}) and the
7
- * subscriber side (the production subscribe factory that converts a `BlockId` `tailIdAtAttach` to the bytes
8
- * {@link import("./subscription-manager.js").ReactivitySubscriptionManager} feeds `reactivityTopicId`) MUST
9
- * feed it the **same** bytes for a given tail, or they resolve different coords and origination silently
10
- * never reaches subscribers (green tests, dead feature).
11
- *
12
- * This module is that single source of truth: `reactivityTailBytes(tailId) = TextEncoder().encode(tailId)`,
13
- * the synchronous block-id→bytes convention the rest of db-p2p already uses for ring hashing of tail ids
14
- * (`cluster/client.ts`, `repo/client.ts`). db-core's async `blockIdToBytes` (which `sha256`s the utf8 bytes
15
- * first) is deliberately NOT used: it would (a) double-hash relative to the spec's `H(tailId ‖ "reactivity")`
16
- * and (b) force the synchronous origination gate to become async.
17
- *
18
- * It lives in the reactivity surface (not buried in the gate) so the subscribe path and the gate import the
19
- * exact same function rather than re-deriving the encoding independently. The
20
- * `cohort-topic/reactivity-membership-gate.ts` module re-exports it for its existing callers.
21
- */
22
-
23
- import type { BlockId } from "@optimystic/db-core";
24
-
25
- const utf8 = new TextEncoder();
26
-
27
- /**
28
- * The pinned `BlockId` → raw tail bytes encoding fed into `reactivityTopicId`: the **raw** utf8 bytes of the
29
- * tail block-id string (NOT a pre-hashed routing key, NOT db-core's double-hashing `blockIdToBytes`).
30
- *
31
- * Load-bearing: origination and the subscriber side must call this **same** function for a given tail (see
32
- * the module doc) — a mismatch resolves different `coord_0`s and origination silently never reaches
33
- * subscribers. The `topic-bytes-encoding` spec pins the coord-equality both ways.
34
- */
35
- export function reactivityTailBytes(tailId: BlockId): Uint8Array {
36
- return utf8.encode(tailId);
37
- }
1
+ /**
2
+ * Reactivity — the one pinned `BlockId` → raw tail-bytes encoding, shared by origination and subscription.
3
+ *
4
+ * `reactivityTopicId` (db-core) hashes `H(tailBytes ‖ "reactivity")` to derive the topic's `coord_0`
5
+ * cohort. Origination (the cohort-change membership gate, {@link
6
+ * import("../cohort-topic/reactivity-membership-gate.js").createReactivitySelfMembershipGate}) and the
7
+ * subscriber side (the production subscribe factory that converts a `BlockId` `tailIdAtAttach` to the bytes
8
+ * {@link import("./subscription-manager.js").ReactivitySubscriptionManager} feeds `reactivityTopicId`) MUST
9
+ * feed it the **same** bytes for a given tail, or they resolve different coords and origination silently
10
+ * never reaches subscribers (green tests, dead feature).
11
+ *
12
+ * This module is that single source of truth: `reactivityTailBytes(tailId)` is the tail's routing key
13
+ * (db-core's `routingKeyForBlock`, the raw utf8 of the id) the same bytes every cohort lookup for that
14
+ * block is keyed on. It must stay raw: a pre-hashed digest would double-hash relative to the spec's
15
+ * `H(tailId ‖ "reactivity")`.
16
+ *
17
+ * It lives in the reactivity surface (not buried in the gate) so the subscribe path and the gate import the
18
+ * exact same function rather than re-deriving the encoding independently. The
19
+ * `cohort-topic/reactivity-membership-gate.ts` module re-exports it for its existing callers.
20
+ */
21
+
22
+ import type { BlockId } from "@optimystic/db-core";
23
+ import { routingKeyForBlock } from "@optimystic/db-core";
24
+
25
+ /**
26
+ * The pinned `BlockId` → raw tail bytes encoding fed into `reactivityTopicId`: the tail's routing key, i.e.
27
+ * the **raw** utf8 bytes of the tail block-id string (never a pre-hashed digest).
28
+ *
29
+ * Load-bearing: origination and the subscriber side must call this **same** function for a given tail (see
30
+ * the module doc) — a mismatch resolves different `coord_0`s and origination silently never reaches
31
+ * subscribers. The `topic-bytes-encoding` spec pins the coord-equality both ways.
32
+ */
33
+ export function reactivityTailBytes(tailId: BlockId): Uint8Array {
34
+ return routingKeyForBlock(tailId);
35
+ }
@@ -1,173 +1,172 @@
1
- import type {
2
- IRepo, GetBlockResults, PendSuccess, StaleFailure, ActionBlocks, MessageOptions, CommitResult,
3
- PendRequest, CommitRequest, BlockGets, IPeerNetwork, PeerId
4
- } from "@optimystic/db-core";
5
- import type { RepoMessage } from "@optimystic/db-core";
6
- import { blockIdsForTransforms, blockIdToBytes } from "@optimystic/db-core";
7
- import { ProtocolClient } from "../protocol-client.js";
8
- import type { RedirectPayload } from "./redirect.js";
9
- import { MAX_BLOCK_MESSAGE_BYTES } from "../protocol-limits.js";
10
- import { peerIdFromString } from "@libp2p/peer-id";
11
-
12
- export class RepoClient extends ProtocolClient implements IRepo {
13
- private constructor(peerId: PeerId, peerNetwork: IPeerNetwork, readonly protocolPrefix?: string) {
14
- super(peerId, peerNetwork);
15
- }
16
-
17
- /** Create a new client instance */
18
- public static create(peerId: PeerId, peerNetwork: IPeerNetwork, protocolPrefix?: string): RepoClient {
19
- return new RepoClient(peerId, peerNetwork, protocolPrefix);
20
- }
21
-
22
- async get(blockGets: BlockGets, options: MessageOptions): Promise<GetBlockResults> {
23
- return this.processRepoMessage<GetBlockResults>(
24
- [{ get: blockGets }],
25
- options
26
- );
27
- }
28
-
29
- async pend(request: PendRequest, options: MessageOptions): Promise<PendSuccess | StaleFailure> {
30
- return this.processRepoMessage<PendSuccess | StaleFailure>(
31
- [{ pend: request }],
32
- options
33
- );
34
- }
35
-
36
- async cancel(actionRef: ActionBlocks, options: MessageOptions): Promise<void> {
37
- return this.processRepoMessage<void>(
38
- [{ cancel: { actionRef } }],
39
- options
40
- );
41
- }
42
-
43
- async commit(request: CommitRequest, options: MessageOptions): Promise<CommitResult> {
44
- return this.processRepoMessage<CommitResult>(
45
- [{ commit: request }],
46
- options
47
- );
48
- }
49
-
50
- private extractCorrelationId(operations: RepoMessage['operations']): string | undefined {
51
- const op = operations[0];
52
- if (!op) return undefined;
53
- if ('pend' in op) return op.pend.actionId;
54
- if ('commit' in op) return op.commit.actionId;
55
- if ('cancel' in op) return op.cancel.actionRef.actionId;
56
- return undefined;
57
- }
58
-
59
- private async processRepoMessage<T>(
60
- operations: RepoMessage['operations'],
61
- options: MessageOptions,
62
- hop: number = 0
63
- ): Promise<T> {
64
- const message: RepoMessage = {
65
- operations,
66
- expiration: options.expiration,
67
- };
68
- const correlationId = this.extractCorrelationId(operations);
69
- const deadline = options.expiration ?? (Date.now() + 30_000)
70
- const deadlineMs = Math.max(1, deadline - Date.now())
71
- const preferred = (this.protocolPrefix ?? '/db-p2p') + '/repo/1.0.0'
72
-
73
- // Drive the remaining `expiration` budget through an AbortController whose
74
- // abort *reason* is the caller-facing `'RepoClient timeout'` Error, combined
75
- // with any caller signal. processMessage forwards `signal` to both the dial
76
- // and the response-read and calls `stream.abort(signal.reason)` on abort, so —
77
- // unlike the old `Promise.race`, whose losing branch left the inner read
78
- // running and leaked a pending read + stream on every timed-out RPC to a silent
79
- // peer — the deadline now genuinely cancels the inner read while the caller
80
- // still observes an error whose `.message === 'RepoClient timeout'`.
81
- //
82
- // Intentionally no `responseTimeoutMs`: the combined signal already bounds the
83
- // read at `deadlineMs`. A second, shorter cap would surface as
84
- // ResponseTimeoutError and mask the caller-facing 'RepoClient timeout' message.
85
- const deadlineController = new AbortController()
86
- const timer = setTimeout(
87
- () => deadlineController.abort(new Error('RepoClient timeout')),
88
- deadlineMs
89
- )
90
- const combinedSignal = options?.signal
91
- ? AbortSignal.any([options.signal, deadlineController.signal])
92
- : deadlineController.signal
93
- let response: any
94
- try {
95
- response = await super.processMessage<any>(message, preferred, {
96
- signal: combinedSignal,
97
- correlationId,
98
- dialTimeoutMs: options?.dialTimeoutMs,
99
- // A get response carries block data block cap (not control).
100
- maxDataLength: MAX_BLOCK_MESSAGE_BYTES,
101
- })
102
- } finally {
103
- clearTimeout(timer)
104
- }
105
-
106
- // Type the redirect branch against the payload the service actually produces
107
- // (`RedirectPayload`) rather than reading it off the `any` response — the untyped read
108
- // is what let the `addrs` the sender embedded fall on the floor.
109
- const redirectResponse = response as Partial<RedirectPayload>
110
- if (redirectResponse?.redirect?.peers?.length) {
111
- if (hop >= 2) {
112
- throw new Error('Redirect loop detected in RepoClient (max hops reached)')
113
- }
114
- const currentIdStr = this.peerId.toString()
115
- const peers = redirectResponse.redirect.peers
116
- const next = peers.find((p) => p.id !== currentIdStr) ?? peers[0]!
117
- const nextId = peerIdFromString(next.id)
118
- if (next.id === currentIdStr) {
119
- throw new Error('Redirect loop detected in RepoClient (same peer)')
120
- }
121
- // Learn the redirect target's addresses BEFORE dialing it: the redirect is the only
122
- // notice we get that this peer matters, and if it is relay-only we have no address
123
- // for it at all. Merging after the dial would help only some later hop.
124
- this.peerNetwork.recordPeerAddresses?.(nextId, next.addrs ?? [])
125
- // cache hint await so the recorded key bytes match what findCoordinator
126
- // later looks up (blockIdToBytes is async); deterministic ordering also
127
- // keeps the hint testable. The redirect retry below is async anyway.
128
- await this.recordCoordinatorForOpsIfSupported(operations, nextId)
129
- // single-hop retry against target peer using repo protocol
130
- const nextClient = RepoClient.create(nextId, this.peerNetwork, this.protocolPrefix)
131
- return await nextClient.processRepoMessage<T>(operations, options, hop + 1)
132
- }
133
- return response as T;
134
- }
135
-
136
- private async extractKeyFromOperations(ops: RepoMessage['operations']): Promise<Uint8Array | undefined> {
137
- const op = ops[0];
138
- // The recorded key MUST be the sha256 digest produced by blockIdToBytes — the
139
- // exact bytes findCoordinator/recordCoordinator key the coordinator cache on
140
- // (NetworkTransactor passes blockIdToBytes(blockId)). Raw utf8 of the id would
141
- // never match, so the hint would silently never be retrieved.
142
- if ('get' in op) {
143
- const id = op.get.blockIds[0];
144
- return id ? await blockIdToBytes(id) : undefined;
145
- }
146
- if ('pend' in op) {
147
- // Key on a real block id the pend touches, NOT a structural transforms field
148
- // name ('inserts'/'updates'/'deletes'); see RepoService.deriveBlockKey.
149
- const id = blockIdsForTransforms(op.pend.transforms)[0];
150
- return id ? await blockIdToBytes(id) : undefined;
151
- }
152
- if ('commit' in op) {
153
- // Anchor on blockIds[0] (where CoordinatorRepo.commit runs consensus +
154
- // verifyResponsibility), NOT tailId — they differ for a non-tail batch.
155
- const id = op.commit.blockIds[0];
156
- return id ? await blockIdToBytes(id) : undefined;
157
- }
158
- if ('cancel' in op) {
159
- const id = op.cancel.actionRef.blockIds[0];
160
- return id ? await blockIdToBytes(id) : undefined;
161
- }
162
- return undefined;
163
- }
164
-
165
- private async recordCoordinatorForOpsIfSupported(ops: RepoMessage['operations'], peerId: PeerId): Promise<void> {
166
- const keyBytes = await this.extractKeyFromOperations(ops)
167
- const pn: any = this.peerNetwork as any
168
- if (keyBytes != null && typeof pn?.recordCoordinator === 'function') {
169
- pn.recordCoordinator(keyBytes, peerId)
170
- }
171
- }
172
-
173
- }
1
+ import type {
2
+ IRepo, GetBlockResults, PendSuccess, StaleFailure, ActionBlocks, MessageOptions, CommitResult,
3
+ PendRequest, CommitRequest, BlockGets, IPeerNetwork, PeerId, BlockId
4
+ } from "@optimystic/db-core";
5
+ import type { RepoMessage } from "@optimystic/db-core";
6
+ import { blockIdsForTransforms } from "@optimystic/db-core";
7
+ import { ProtocolClient } from "../protocol-client.js";
8
+ import type { RedirectPayload } from "./redirect.js";
9
+ import { MAX_BLOCK_MESSAGE_BYTES } from "../protocol-limits.js";
10
+ import { peerIdFromString } from "@libp2p/peer-id";
11
+
12
+ export class RepoClient extends ProtocolClient implements IRepo {
13
+ private constructor(peerId: PeerId, peerNetwork: IPeerNetwork, readonly protocolPrefix?: string) {
14
+ super(peerId, peerNetwork);
15
+ }
16
+
17
+ /** Create a new client instance */
18
+ public static create(peerId: PeerId, peerNetwork: IPeerNetwork, protocolPrefix?: string): RepoClient {
19
+ return new RepoClient(peerId, peerNetwork, protocolPrefix);
20
+ }
21
+
22
+ async get(blockGets: BlockGets, options: MessageOptions): Promise<GetBlockResults> {
23
+ return this.processRepoMessage<GetBlockResults>(
24
+ [{ get: blockGets }],
25
+ options
26
+ );
27
+ }
28
+
29
+ async pend(request: PendRequest, options: MessageOptions): Promise<PendSuccess | StaleFailure> {
30
+ return this.processRepoMessage<PendSuccess | StaleFailure>(
31
+ [{ pend: request }],
32
+ options
33
+ );
34
+ }
35
+
36
+ async cancel(actionRef: ActionBlocks, options: MessageOptions): Promise<void> {
37
+ return this.processRepoMessage<void>(
38
+ [{ cancel: { actionRef } }],
39
+ options
40
+ );
41
+ }
42
+
43
+ async commit(request: CommitRequest, options: MessageOptions): Promise<CommitResult> {
44
+ return this.processRepoMessage<CommitResult>(
45
+ [{ commit: request }],
46
+ options
47
+ );
48
+ }
49
+
50
+ private extractCorrelationId(operations: RepoMessage['operations']): string | undefined {
51
+ const op = operations[0];
52
+ if (!op) return undefined;
53
+ if ('pend' in op) return op.pend.actionId;
54
+ if ('commit' in op) return op.commit.actionId;
55
+ if ('cancel' in op) return op.cancel.actionRef.actionId;
56
+ return undefined;
57
+ }
58
+
59
+ private async processRepoMessage<T>(
60
+ operations: RepoMessage['operations'],
61
+ options: MessageOptions,
62
+ hop: number = 0
63
+ ): Promise<T> {
64
+ const message: RepoMessage = {
65
+ operations,
66
+ expiration: options.expiration,
67
+ };
68
+ const correlationId = this.extractCorrelationId(operations);
69
+ const deadline = options.expiration ?? (Date.now() + 30_000)
70
+ const deadlineMs = Math.max(1, deadline - Date.now())
71
+ const preferred = (this.protocolPrefix ?? '/db-p2p') + '/repo/1.0.0'
72
+
73
+ // Drive the remaining `expiration` budget through an AbortController whose
74
+ // abort *reason* is the caller-facing `'RepoClient timeout'` Error, combined
75
+ // with any caller signal. processMessage forwards `signal` to both the dial
76
+ // and the response-read and calls `stream.abort(signal.reason)` on abort, so —
77
+ // unlike the old `Promise.race`, whose losing branch left the inner read
78
+ // running and leaked a pending read + stream on every timed-out RPC to a silent
79
+ // peer — the deadline now genuinely cancels the inner read while the caller
80
+ // still observes an error whose `.message === 'RepoClient timeout'`.
81
+ //
82
+ // Intentionally no `responseTimeoutMs`: the combined signal already bounds the
83
+ // read at `deadlineMs`. A second, shorter cap would surface as
84
+ // ResponseTimeoutError and mask the caller-facing 'RepoClient timeout' message.
85
+ const deadlineController = new AbortController()
86
+ const timer = setTimeout(
87
+ () => deadlineController.abort(new Error('RepoClient timeout')),
88
+ deadlineMs
89
+ )
90
+ // Explicit combinator rather than the native `AbortSignal.any`, which Hermes
91
+ // (React Native's JS engine) does not provide. This has to forward whichever
92
+ // source signal's *reason* fired — that's how the caller-facing 'RepoClient
93
+ // timeout' message above (and any reason a caller's own `options.signal` carries)
94
+ // survives the combine. `any-signal` (the package libp2p itself uses for this) was
95
+ // tried and rejected here: it calls the composite `AbortController.abort()` with no
96
+ // argument, which discards the source reason and replaces it with a generic
97
+ // AbortError — confirmed against both Node's native `AbortSignal.any` and
98
+ // `any-signal@4.1.1` directly. Listeners are removed in the `finally` below so a
99
+ // long-lived caller `options.signal` never accumulates one per call.
100
+ const abortController = new AbortController()
101
+ const forwardAbort = (source: AbortSignal) => (): void => abortController.abort(source.reason)
102
+ const onCallerAbort = options?.signal ? forwardAbort(options.signal) : undefined
103
+ const onDeadlineAbort = forwardAbort(deadlineController.signal)
104
+ deadlineController.signal.addEventListener('abort', onDeadlineAbort, { once: true })
105
+ if (options?.signal) {
106
+ if (options.signal.aborted) abortController.abort(options.signal.reason)
107
+ else options.signal.addEventListener('abort', onCallerAbort!, { once: true })
108
+ }
109
+ const combinedSignal = abortController.signal
110
+ let response: any
111
+ try {
112
+ response = await super.processMessage<any>(message, preferred, {
113
+ signal: combinedSignal,
114
+ correlationId,
115
+ dialTimeoutMs: options?.dialTimeoutMs,
116
+ // A get response carries block data block cap (not control).
117
+ maxDataLength: MAX_BLOCK_MESSAGE_BYTES,
118
+ })
119
+ } finally {
120
+ clearTimeout(timer)
121
+ deadlineController.signal.removeEventListener('abort', onDeadlineAbort)
122
+ if (onCallerAbort) options?.signal?.removeEventListener('abort', onCallerAbort)
123
+ }
124
+
125
+ // Type the redirect branch against the payload the service actually produces
126
+ // (`RedirectPayload`) rather than reading it off the `any` response — the untyped read
127
+ // is what let the `addrs` the sender embedded fall on the floor.
128
+ const redirectResponse = response as Partial<RedirectPayload>
129
+ if (redirectResponse?.redirect?.peers?.length) {
130
+ if (hop >= 2) {
131
+ throw new Error('Redirect loop detected in RepoClient (max hops reached)')
132
+ }
133
+ const currentIdStr = this.peerId.toString()
134
+ const peers = redirectResponse.redirect.peers
135
+ const next = peers.find((p) => p.id !== currentIdStr) ?? peers[0]!
136
+ const nextId = peerIdFromString(next.id)
137
+ if (next.id === currentIdStr) {
138
+ throw new Error('Redirect loop detected in RepoClient (same peer)')
139
+ }
140
+ // Learn the redirect target's addresses BEFORE dialing it: the redirect is the only
141
+ // notice we get that this peer matters, and if it is relay-only we have no address
142
+ // for it at all. Merging after the dial would help only some later hop.
143
+ this.peerNetwork.recordPeerAddresses?.(nextId, next.addrs ?? [])
144
+ // Cache hint: a follow-up op on this block can dial the target directly.
145
+ const coordinated = this.coordinatedBlockId(operations)
146
+ if (coordinated) this.recordCoordinatorHint(coordinated, nextId)
147
+ // single-hop retry against target peer using repo protocol
148
+ const nextClient = RepoClient.create(nextId, this.peerNetwork, this.protocolPrefix)
149
+ return await nextClient.processRepoMessage<T>(operations, options, hop + 1)
150
+ }
151
+ return response as T;
152
+ }
153
+
154
+ /**
155
+ * The block a redirected op is coordinated on — the block `RepoService.deriveBlockKey` redirected
156
+ * it by, and so the block its coordinator hint belongs to.
157
+ */
158
+ private coordinatedBlockId(ops: RepoMessage['operations']): BlockId | undefined {
159
+ const op = ops[0];
160
+ if (!op) return undefined;
161
+ if ('get' in op) return op.get.blockIds[0];
162
+ // Key on a real block id the pend touches, NOT a structural transforms field
163
+ // name ('inserts'/'updates'/'deletes'); see RepoService.deriveBlockKey.
164
+ if ('pend' in op) return blockIdsForTransforms(op.pend.transforms)[0];
165
+ // Anchor on blockIds[0] (where CoordinatorRepo.commit runs consensus +
166
+ // verifyResponsibility), NOT tailId — they differ for a non-tail batch.
167
+ if ('commit' in op) return op.commit.blockIds[0];
168
+ if ('cancel' in op) return op.cancel.actionRef.blockIds[0];
169
+ return undefined;
170
+ }
171
+
172
+ }