@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
@@ -1,8 +1,9 @@
1
1
  import type { PeerId, PrivateKey } from '@libp2p/interface';
2
- import type { IKeyNetwork, ClusterPeers, ICluster, ClusterRecord, IRepo, BlockId, ActionRev, ITransactor, ITransactionValidator, PeerId as DbPeerId } from '@optimystic/db-core';
2
+ import type { IKeyNetwork, ClusterPeers, ICluster, ClusterRecord, IRepo, BlockId, ITransactor, ITransactionValidator, PeerId as DbPeerId, RoutingKey } from '@optimystic/db-core';
3
3
  import type { FindCoordinatorOptions } from '@optimystic/db-core';
4
4
  import type { IPeerNetwork } from '@optimystic/db-core';
5
- import { NetworkTransactor } from '@optimystic/db-core';
5
+ import { NetworkTransactor, routingKeyForBlock } from '@optimystic/db-core';
6
+ import { DigitreeStore, assembleCohort, hashKey, hashPeerId } from 'p2p-fret';
6
7
  import { peerIdFromPrivateKey } from '@libp2p/peer-id';
7
8
  import { generateKeyPair, generateKeyPairFromSeed } from '@libp2p/crypto/keys';
8
9
  import { ClusterMember, clusterMember, type ReconcileBlockCallback, type DeriveExpectedClusterCallback, type ExpectedClusterView } from '../cluster/cluster-repo.js';
@@ -15,13 +16,19 @@ import type { IRawStorage } from '../storage/i-raw-storage.js';
15
16
  import type { BlockArchive } from '../storage/struct.js';
16
17
  import { serveBlockArchive, servableProof } from '../storage/block-archive.js';
17
18
  import { coordinatorRepo, type ClusterLatestCallback, type CertifiedActionRev } from '../repo/coordinator-repo.js';
19
+ import type { CommittedHolders } from '../cluster/rebalance-monitor.js';
18
20
  import type { CoordinatorRepo } from '../repo/coordinator-repo.js';
19
- import { sortPeersByDistance, type KnownPeer } from '../routing/responsibility.js';
20
21
  import { toString as u8ToString } from 'uint8arrays';
21
22
 
22
23
  export interface MeshNode {
23
24
  peerId: PeerId;
24
25
  privateKey: PrivateKey;
26
+ /**
27
+ * This node's own view of the key network — the harness analogue of a real node's `keyNetwork`
28
+ * attachment, and the instance its coordinator and its member's admission derivation both read. It
29
+ * differs from `Mesh.keyNetwork` only under a simulated partition (`MeshFailureConfig.partitionSides`).
30
+ */
31
+ keyNetwork: IKeyNetwork;
25
32
  storageRepo: StorageRepo;
26
33
  clusterMember: ClusterMember;
27
34
  coordinatorRepo: CoordinatorRepo;
@@ -58,9 +65,9 @@ export interface MeshOptions {
58
65
  /**
59
66
  * Per-node member-side cluster derivation for the membership admission gate — the harness
60
67
  * analogue of `libp2p-node-base`'s `deriveExpectedCluster` (findCluster + FRET confidence).
61
- * Omitted → each member gets the production-shaped derivation over its own self-including
62
- * key-network view (partition-aware, see `MeshFailureConfig.partitionSides`) with confidence
63
- * from `meshConfidence` (default 1).
68
+ * Omitted → each member gets the production-shaped derivation over its own key-network view
69
+ * (partition-aware, see `MeshFailureConfig.partitionSides`) with confidence from `meshConfidence`
70
+ * (default 1).
64
71
  */
65
72
  deriveExpectedCluster?: (node: MeshNode, blockId: BlockId) => Promise<ExpectedClusterView>;
66
73
  /**
@@ -79,7 +86,8 @@ export interface MeshOptions {
79
86
  *
80
87
  * A mesh that arms validators can also set `clusterPolicy.unvalidatablePendPolicy: 'reject'` to
81
88
  * refuse pends that carry no `validation` payload (the single-collection `Collection.sync`
82
- * shape, which a validator cannot re-check); the default 'accept' admits them unchecked.
89
+ * shape, and the Quereus adapter's legacy multi-tree commit neither can be re-checked); the
90
+ * default 'accept' admits them unchecked.
83
91
  */
84
92
  validatorFactory?: (index: number, peerId: PeerId) => ITransactionValidator;
85
93
  /**
@@ -104,6 +112,13 @@ export interface MeshOptions {
104
112
  * eventually crosses it. Omitted → random keys.
105
113
  */
106
114
  keySeed?: number;
115
+ /**
116
+ * Receives each node's committed-holders reports — from its member after a durable consensus apply
117
+ * and from its coordinator on acknowledging a cohort commit — the harness analogue of the sink
118
+ * `libp2p-node-base` routes to the node's rebalance monitor. The harness builds no monitor; a spec
119
+ * that wants one routes reports to its own. Omitted → nothing is reported.
120
+ */
121
+ onCommittedHolders?: (node: MeshNode, committed: CommittedHolders) => void;
107
122
  }
108
123
 
109
124
  export interface MeshFailureConfig {
@@ -131,6 +146,18 @@ export interface MeshFailureConfig {
131
146
  * it declared, which is already its own side.
132
147
  */
133
148
  partitionSides?: Set<string>[];
149
+ /**
150
+ * Observes every REMOTE cluster delivery — promise round, commit round, commit broadcast and
151
+ * scheduled commit retry alike — with the target's peer-id string and the record as sent, BEFORE
152
+ * `failingPeers` is consulted for that delivery. It exists so a spec can change reachability at an
153
+ * exact protocol step rather than between whole transactions: a member that promised and then
154
+ * dropped before voting to commit is set unreachable here, on the delivery that would have carried
155
+ * its commit vote, and that very delivery fails.
156
+ *
157
+ * A coordinator's OWN member is invoked in process, never through this path — as in production,
158
+ * where `ClusterCoordinator.updateMember` calls it directly — so the hook never sees it.
159
+ */
160
+ onClusterDelivery?: (targetPeerId: string, record: ClusterRecord) => void;
134
161
  }
135
162
 
136
163
  class MockPeerNetwork implements IPeerNetwork {
@@ -140,38 +167,46 @@ class MockPeerNetwork implements IPeerNetwork {
140
167
  }
141
168
 
142
169
  /**
143
- * Mock IKeyNetwork that returns peers based on XOR distance.
144
- * With responsibilityK >= nodeCount, all nodes are returned.
145
- * Otherwise, K-nearest by XOR distance are returned.
170
+ * Mock IKeyNetwork that places a block where production does, by construction: it ranks the mesh's nodes
171
+ * with FRET's own `assembleCohort` over a ring (`DigitreeStore`) holding every node at its real ring
172
+ * coordinate (`hashPeerId`), the walk `Libp2pKeyPeerNetwork` asks FRET for. Like production it hashes the
173
+ * routing key exactly once. Every mesh node serves, so the network-membership scoping production layers on
174
+ * top never removes anyone, and a cohort is simply the nearest `responsibilityK` in walk order.
175
+ * `test/mesh-harness-cohort-parity.spec.ts` pins the two answers equal.
176
+ *
177
+ * - `findCluster`: the nearest `responsibilityK` nodes, nearest first. Nothing is added: a node is in a
178
+ * block's cohort only when it is among the nearest, as in production.
179
+ * - `findCoordinator`: the first non-excluded node of the whole ring's walk, which is the cohort's first
180
+ * member unless excluded, else the next nearest node, cohort member or not. That mirrors production's
181
+ * connected-peer fallback: a pick outside the cohort is a routing hop, not a placement.
146
182
  */
147
183
  class MockMeshKeyNetwork implements IKeyNetwork {
184
+ /** The ring, built on first lookup (node ids hash asynchronously, and `nodes` fills after construction)
185
+ * and rebuilt only if the node count changes; a restarted node keeps its identity and so its position. */
186
+ private ring: { size: number; ready: Promise<{ store: DigitreeStore; byId: Map<string, MeshNode> }> } | undefined;
187
+
148
188
  constructor(
149
189
  private readonly nodes: MeshNode[],
150
190
  private readonly responsibilityK: number,
151
191
  private readonly failures: MeshFailureConfig = {}
152
192
  ) {}
153
193
 
154
- async findCoordinator(key: Uint8Array, options?: Partial<FindCoordinatorOptions>): Promise<PeerId> {
194
+ async findCoordinator(key: RoutingKey, options?: Partial<FindCoordinatorOptions>): Promise<PeerId> {
155
195
  const excluded = new Set((options?.excludedPeers ?? []).map(p => p.toString()));
156
- const sorted = this.sortedByDistance(key);
157
- const pick = sorted.find(n => !excluded.has(n.peerId.toString()));
196
+ const pick = (await this.nearest(key, this.nodes.length)).find(n => !excluded.has(n.peerId.toString()));
158
197
  if (!pick) {
159
198
  throw new Error('No coordinator available for key (all candidates excluded)');
160
199
  }
161
200
  return pick.peerId;
162
201
  }
163
202
 
164
- async findCluster(key: Uint8Array): Promise<ClusterPeers> {
203
+ async findCluster(key: RoutingKey): Promise<ClusterPeers> {
165
204
  if (this.failures.findClusterFails) {
166
205
  return {} as ClusterPeers;
167
206
  }
168
207
 
169
- const sorted = this.sortedByDistance(key);
170
- const k = Math.min(this.responsibilityK, sorted.length);
171
- const selected = sorted.slice(0, k);
172
-
173
208
  const peers: ClusterPeers = {};
174
- for (const node of selected) {
209
+ for (const node of await this.nearest(key, this.responsibilityK)) {
175
210
  peers[node.peerId.toString()] = {
176
211
  multiaddrs: ['/ip4/127.0.0.1/tcp/8000'],
177
212
  publicKey: u8ToString(node.peerId.publicKey!.raw, 'base64url')
@@ -180,13 +215,30 @@ class MockMeshKeyNetwork implements IKeyNetwork {
180
215
  return peers;
181
216
  }
182
217
 
183
- private sortedByDistance(key: Uint8Array): MeshNode[] {
184
- const knownPeers: KnownPeer[] = this.nodes.map(n => ({
185
- id: n.peerId,
186
- addrs: ['/ip4/127.0.0.1/tcp/8000']
187
- }));
188
- const sorted = sortPeersByDistance(knownPeers, key);
189
- return sorted.map(kp => this.nodes.find(n => n.peerId.equals(kp.id))!);
218
+ /** The `wants` nodes nearest `key`'s ring coordinate, nearest first. */
219
+ private async nearest(key: RoutingKey, wants: number): Promise<MeshNode[]> {
220
+ const { store, byId } = await this.ringStore();
221
+ const ids = assembleCohort(store, await hashKey(key), Math.min(wants, byId.size));
222
+ return ids.map(id => byId.get(id)!);
223
+ }
224
+
225
+ private ringStore(): Promise<{ store: DigitreeStore; byId: Map<string, MeshNode> }> {
226
+ if (this.ring?.size !== this.nodes.length) {
227
+ const members = [...this.nodes];
228
+ this.ring = {
229
+ size: members.length,
230
+ ready: (async () => {
231
+ const store = new DigitreeStore();
232
+ const byId = new Map<string, MeshNode>();
233
+ for (const node of members) {
234
+ store.upsert(node.peerId.toString(), await hashPeerId(node.peerId));
235
+ byId.set(node.peerId.toString(), node);
236
+ }
237
+ return { store, byId };
238
+ })()
239
+ };
240
+ }
241
+ return this.ring.ready;
190
242
  }
191
243
  }
192
244
 
@@ -194,6 +246,25 @@ export interface Mesh {
194
246
  nodes: MeshNode[];
195
247
  failures: MeshFailureConfig;
196
248
  keyNetwork: IKeyNetwork;
249
+ /**
250
+ * Restart one node the way a stopped and relaunched process comes back: same identity, same raw
251
+ * storage, and nothing else. `node`'s storage repo, cluster member and coordinator are rebuilt IN
252
+ * PLACE on the same `MeshNode` object (so a spec's reference to it stays valid), over the
253
+ * `IRawStorage` instance it was built with — for the default `MemoryRawStorage` that is exactly
254
+ * "over its own storage"; a `rawStorageFactory` store is reused as-is, never re-requested.
255
+ *
256
+ * Everything held only in memory goes with the old instance: the member's reservations and
257
+ * executed-transaction memory, the coordinator's in-flight transactions and scheduled commit
258
+ * retries, every read-repair stamp. The harness wires no `ITransactionStateStore`, so nothing is
259
+ * recovered either — a production node built with `transactionStateStore` would resume its
260
+ * persisted commit retries on restart (`ClusterCoordinator.recoverTransactions`). The old instance is made inert rather than merely forgotten —
261
+ * its member is disposed, and every outbound call it would still make (a commit retry whose timer
262
+ * fires later, a cohort consult, an archive fetch) fails the way a stopped process's dial does — so
263
+ * a retry the old process owed cannot quietly deliver after the restart and pass for healing.
264
+ *
265
+ * Reachability (`failures`) is untouched: a node that was unreachable stays unreachable.
266
+ */
267
+ restart(node: MeshNode): void;
197
268
  }
198
269
 
199
270
  /**
@@ -253,6 +324,12 @@ export function resolveMeshPolicy(options: MeshOptions): ResolvedClusterPolicy {
253
324
  });
254
325
  }
255
326
 
327
+ /** Whether one node INSTANCE is still running. Every outbound call the instance makes checks it, so an
328
+ * instance {@link Mesh.restart} replaced goes quiet instead of acting alongside the new one. */
329
+ interface NodeLifetime {
330
+ stopped: boolean;
331
+ }
332
+
256
333
  /** The 32-byte Ed25519 seed for node `index` of a `keySeed` mesh. */
257
334
  function meshKeySeed(keySeed: number, index: number): Uint8Array {
258
335
  const seed = new Uint8Array(32);
@@ -295,23 +372,27 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
295
372
  })
296
373
  );
297
374
 
298
- // Build nodes array (partially — coordinatorRepo added after keyNetwork is ready)
299
375
  const nodes: MeshNode[] = [];
300
376
  const peerNetwork = new MockPeerNetwork();
301
377
  // One real reconcile callback per node, shared between the member's commit-path `reconcileBlock`
302
378
  // and the coordinator's read-path `acquireBlockFromCohort` — production shares one instance
303
- // (`libp2p-node-base.ts`), and a spec must not be able to tell the two paths apart. Built in
304
- // phase 1 (with the member), consumed again in phase 2 (by the coordinator), so it is stashed
305
- // here keyed by peer id rather than widened onto the public `MeshNode` type.
379
+ // (`libp2p-node-base.ts`), and a spec must not be able to tell the two paths apart. Built with the
380
+ // member, consumed again by the coordinator, so it is stashed here keyed by peer id rather than
381
+ // widened onto the public `MeshNode` type.
306
382
  const reconcileByPeer = new Map<string, ReconcileBlockCallback>();
383
+ /** What outlives a node instance, by peer id: the raw storage it was built over, and its index
384
+ * (`validatorFactory` is invoked with it again on restart). */
385
+ const durableByPeer = new Map<string, { rawStorage: IRawStorage; index: number }>();
386
+ /** The CURRENT instance's lifetime, by peer id — flipped to stopped by {@link Mesh.restart}. */
387
+ const lifetimeByPeer = new Map<string, NodeLifetime>();
307
388
 
308
389
  // The mesh key network is built BEFORE the members: each member's `deriveExpectedCluster` (the
309
- // admission gate's view) needs a per-node key network in phase 1, and constructing it here beats
310
- // a late-bound slot a closure could fire on before it is filled. Safe because `nodes` is captured
311
- // by reference and only consulted at call time, after the array is fully populated.
390
+ // admission gate's view) needs a per-node key network, and constructing it here beats a late-bound
391
+ // slot a closure could fire on before it is filled. Safe because `nodes` is captured by reference
392
+ // and only consulted at call time, after the array is fully populated.
312
393
  //
313
394
  // `wrapKeyNetwork` is applied HERE, before `makeNodeKeyNetwork` closes over `keyNetwork` below and
314
- // before phase 1 builds any `deriveExpectedCluster` closure — every one of them reads the `keyNetwork`
395
+ // before any `deriveExpectedCluster` closure is built — every one of them reads the `keyNetwork`
315
396
  // binding, so a wrapper assigned to it here is what every node's coordinator, cluster member and the
316
397
  // returned `Mesh.keyNetwork` all observe. Reassigning `mesh.keyNetwork` after this function returns
317
398
  // only reaches whoever reads that property later (the transactor); it is too late for the rest.
@@ -320,13 +401,18 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
320
401
 
321
402
  /**
322
403
  * One node's own view of the key network — what `Libp2pKeyPeerNetwork` gives a real node:
323
- * - `findCluster` always includes self, so a responsible member's derived view is never empty
324
- * (see the empty-view guard in `cluster-repo.admitMembership`);
404
+ * - `findCluster` is the shared cohort, which holds this node only when it is among the block's
405
+ * nearest `responsibilityK`, so the node's coordinator judges its own responsibility as a real
406
+ * node's does;
325
407
  * - under a simulated partition (`failures.partitionSides`), a caller inside a side sees only
326
408
  * its side's members of the cohort — the caller-aware filtering lives here, in the per-node
327
- * wrapper, precisely so `IKeyNetwork` itself needs no "who is asking" parameter.
328
- * The SAME instance serves both the member's admission derivation (phase 1) and the node's
329
- * coordinator (phase 2), so the two sides of a node can never see different topologies.
409
+ * wrapper, precisely so `IKeyNetwork` itself needs no "who is asking" parameter. A side holding
410
+ * none of the cohort sees an empty view, so a node there is not responsible for the block (and
411
+ * the empty-view guard in `cluster-repo.admitMembership` treats such a view as unconfident rather
412
+ * than as a reference set).
413
+ * The SAME instance serves both the member's admission derivation and the node's coordinator, so
414
+ * the two sides of a node can never see different topologies. It holds no state, so a restarted
415
+ * node keeps it.
330
416
  */
331
417
  const makeNodeKeyNetwork = (selfPeerId: PeerId): IKeyNetwork => {
332
418
  const selfStr = selfPeerId.toString();
@@ -340,25 +426,18 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
340
426
  if (!side.has(id)) delete peers[id];
341
427
  }
342
428
  }
343
- if (!(selfStr in peers)) {
344
- peers[selfStr] = {
345
- multiaddrs: ['/ip4/127.0.0.1/tcp/8000'],
346
- publicKey: u8ToString(selfPeerId.publicKey!.raw, 'base64url')
347
- };
348
- }
349
429
  return peers;
350
430
  }
351
431
  };
352
432
  };
353
- const nodeKeyNetworkByPeer = new Map<string, IKeyNetwork>();
354
-
355
- // Phase 1: create storage + cluster members
356
- let nodeIndex = 0;
357
- for (const { peerId, privateKey } of keyPairs) {
358
- const index = nodeIndex++;
359
- const rawStorage = options.rawStorageFactory
360
- ? options.rawStorageFactory(index)
361
- : new MemoryRawStorage();
433
+
434
+ /**
435
+ * Build `meshNode`'s storage repo and cluster member over its durable raw storage, assigning both
436
+ * onto the node. Run once per node at assembly, and again by {@link Mesh.restart}.
437
+ */
438
+ const buildMember = (meshNode: MeshNode, lifetime: NodeLifetime): void => {
439
+ const { peerId, privateKey } = meshNode;
440
+ const { rawStorage, index } = durableByPeer.get(peerId.toString())!;
362
441
  const storageRepo = new StorageRepo(
363
442
  (blockId: BlockId) => new BlockStorage(blockId, rawStorage)
364
443
  );
@@ -369,9 +448,12 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
369
448
  // distinct peers must agree on the target `(rev, actionId)` AND on the block content, or the
370
449
  // pass declines, persisting nothing. `reputation` is omitted — no reputation subsystem in the
371
450
  // harness.
451
+ const fetchArchive = makeFetchArchive(nodes, peerId.toString(), failures);
372
452
  const reconcileBlock = createReconcileBlock({
373
453
  selfPeerId: peerId.toString(),
374
- fetchArchive: makeFetchArchive(nodes, peerId.toString(), failures),
454
+ // A stopped instance fetches nothing — the production fetch swallows a failed dial into
455
+ // the same `undefined`.
456
+ fetchArchive: async (peerIdStr, blockId) => lifetime.stopped ? undefined : await fetchArchive(peerIdStr, blockId),
375
457
  // Production shape: a proof reconcile verified against the agreed bytes is persisted so
376
458
  // the repaired replica serves it onward.
377
459
  saveReplicatedBlock: (blockId, block, source, verifiedProof) =>
@@ -382,33 +464,21 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
382
464
  });
383
465
  reconcileByPeer.set(peerId.toString(), reconcileBlock);
384
466
 
385
- const nodeKeyNetwork = makeNodeKeyNetwork(peerId);
386
- nodeKeyNetworkByPeer.set(peerId.toString(), nodeKeyNetwork);
387
-
388
- // The node object exists before its member so the admission derivation below can hand the
389
- // finished MeshNode to spec-supplied callbacks; `clusterMember`/`coordinatorRepo` are
390
- // assigned as they are built (member just below, coordinator in phase 2) and the closures
391
- // only run at vote time, long after both are in place.
392
- const meshNode: MeshNode = {
393
- peerId,
394
- privateKey,
395
- storageRepo,
396
- clusterMember: undefined as any,
397
- coordinatorRepo: undefined as any
398
- };
467
+ const nodeKeyNetwork = meshNode.keyNetwork;
399
468
 
400
469
  // Member-side cluster derivation for the membership admission gate — the production shape
401
470
  // (`libp2p-node-base.deriveExpectedCluster`): the SAME per-node key network the coordinator
402
- // selects its cohort from, plus a network-size confidence. The self-including wrapper keeps a
403
- // responsible member's view non-empty; `meshConfidence` is the FRET stand-in (default 1, i.e.
471
+ // selects its cohort from, plus a network-size confidence. A responsible member is in its own
472
+ // cohort, so its view is never empty; `meshConfidence` is the FRET stand-in (default 1, i.e.
404
473
  // confident — a partition spec collapses it per side).
405
474
  const deriveExpectedCluster: DeriveExpectedClusterCallback = options.deriveExpectedCluster
406
475
  ? (blockId) => options.deriveExpectedCluster!(meshNode, blockId)
407
476
  : async (blockId) => ({
408
- peers: await nodeKeyNetwork.findCluster(new TextEncoder().encode(blockId)) ?? {},
477
+ peers: await nodeKeyNetwork.findCluster(routingKeyForBlock(blockId)) ?? {},
409
478
  confidence: options.meshConfidence?.(meshNode) ?? 1
410
479
  });
411
480
 
481
+ meshNode.storageRepo = storageRepo;
412
482
  meshNode.clusterMember = clusterMember({
413
483
  storageRepo,
414
484
  peerNetwork,
@@ -419,13 +489,13 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
419
489
  deriveExpectedCluster,
420
490
  // Absent by default: `undefined` here is identical to omitting the field, and
421
491
  // `validatePendOperations` then skips the validation step entirely.
422
- validator: options.validatorFactory?.(index, peerId)
492
+ validator: options.validatorFactory?.(index, peerId),
493
+ onCommittedHolders: options.onCommittedHolders && ((committed) => options.onCommittedHolders!(meshNode, committed))
423
494
  });
495
+ };
424
496
 
425
- nodes.push(meshNode);
426
- }
427
-
428
- // Phase 2: coordinator repos (needs all nodes for routing; key network built in phase 1)
497
+ // Cluster traffic to a node, resolved per call: a delivery always reaches the target's CURRENT
498
+ // member, so a restarted node answers with its new instance.
429
499
  const createClusterClient = (targetPeerId: PeerId): ICluster => {
430
500
  const target = nodes.find(n => n.peerId.equals(targetPeerId));
431
501
  if (!target) {
@@ -433,6 +503,7 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
433
503
  }
434
504
  return {
435
505
  async update(record: ClusterRecord): Promise<ClusterRecord> {
506
+ failures.onClusterDelivery?.(targetPeerId.toString(), record);
436
507
  if (failures.failingPeers?.has(targetPeerId.toString())) {
437
508
  throw new Error(`Peer ${targetPeerId.toString()} is unreachable`);
438
509
  }
@@ -441,7 +512,13 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
441
512
  };
442
513
  };
443
514
 
444
- for (const node of nodes) {
515
+ /**
516
+ * Build `node`'s coordinator over its (already built) storage repo and member. It reaches the other
517
+ * nodes only at call time, never at construction. Run once per node at assembly, and again by
518
+ * {@link Mesh.restart}.
519
+ */
520
+ const buildCoordinator = (node: MeshNode, lifetime: NodeLifetime): void => {
521
+ const stoppedError = (): Error => new Error(`${node.peerId.toString()} was restarted; this instance is stopped`);
445
522
  // Per-node callback: reports the queried peer's latest revision, and NOTHING else. It used to
446
523
  // also write the peer's block into local storage ("simulate data sync"), which made every
447
524
  // read-repair assertion on this harness observe a convergence the production callback does not
@@ -449,6 +526,7 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
449
526
  // existed to expose. Transfer now happens where it does in production: through
450
527
  // `acquireBlockFromCohort` below, gated on a corroborated revision.
451
528
  const clusterLatestCallback: ClusterLatestCallback = async (peerId: PeerId, blockId: BlockId, context?): Promise<CertifiedActionRev | undefined> => {
529
+ if (lifetime.stopped) throw stoppedError();
452
530
  // Silence: the peer never answers. REJECTS, mirroring what a dial failure does to the
453
531
  // production callback — the coordinator must count this as "did not answer", never as
454
532
  // an absent claim (a resolved `undefined` remains the peer answering "I hold nothing").
@@ -472,19 +550,29 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
472
550
  const proof = await servableProof(target.storageRepo, blockId, latest);
473
551
  return proof ? { ...latest, proof } : latest;
474
552
  };
475
- // The node's own self-including (and partition-aware) key-network view, built in phase 1
476
- // the SAME instance the member's admission derivation reads, matching real
477
- // Libp2pKeyPeerNetwork behavior.
478
- const nodeKeyNetwork = nodeKeyNetworkByPeer.get(node.peerId.toString())!;
553
+ // The node's own (partition-aware) key-network view the SAME instance the member's admission
554
+ // derivation reads, matching real Libp2pKeyPeerNetwork behavior.
555
+ const nodeKeyNetwork = node.keyNetwork;
479
556
  const factory = coordinatorRepo(
480
557
  nodeKeyNetwork,
481
- createClusterClient,
482
- // The SAME resolved policy the member above was built from, spread the way
483
- // `libp2p-node-base` spreads it into its coordinator factory — carrying
484
- // `repairCorroborationClusterSize` (the repair floor's yardstick, DEFAULT_CLUSTER_SIZE
485
- // when the mesh declared nothing), the production `minAbsoluteClusterSize` (2, not the
486
- // coordinator's own fallback of 3), and the `allowUnvalidatedSmallCluster` gate —
487
- // ARMED (false) unless the mesh opted out at its call site.
558
+ // A stopped instance's cluster traffic fails at send — checked per delivery rather than when
559
+ // the client is made, because `ClusterCoordinator` also sends from a commit-retry timer that
560
+ // can fire long after a restart.
561
+ (targetPeerId: PeerId): ICluster => {
562
+ const client = createClusterClient(targetPeerId);
563
+ return {
564
+ async update(record: ClusterRecord): Promise<ClusterRecord> {
565
+ if (lifetime.stopped) throw stoppedError();
566
+ return await client.update(record);
567
+ }
568
+ };
569
+ },
570
+ // The SAME resolved policy the member was built from, spread the way `libp2p-node-base`
571
+ // spreads it into its coordinator factory — carrying `repairCorroborationClusterSize` (the
572
+ // repair floor's yardstick, DEFAULT_CLUSTER_SIZE when the mesh declared nothing), the
573
+ // production `minAbsoluteClusterSize` (2, not the coordinator's own fallback of 3), and the
574
+ // `allowUnvalidatedSmallCluster` gate — ARMED (false) unless the mesh opted out at its call
575
+ // site.
488
576
  { ...policy }
489
577
  );
490
578
  node.coordinatorRepo = factory({
@@ -494,27 +582,110 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
494
582
  clusterLatestCallback,
495
583
  // The read path's transfer mechanism — the SAME instance the member uses on the commit
496
584
  // path, mirroring how `libp2p-node-base` shares one `reconcileBlock` between both.
497
- acquireBlockFromCohort: reconcileByPeer.get(node.peerId.toString())!
585
+ acquireBlockFromCohort: reconcileByPeer.get(node.peerId.toString())!,
586
+ onCommittedHolders: options.onCommittedHolders && ((committed) => options.onCommittedHolders!(node, committed))
498
587
  });
588
+ };
589
+
590
+ // Phase 1: storage + cluster members, in key-pair order (the order `validatorFactory` indexes by).
591
+ for (const [index, { peerId, privateKey }] of keyPairs.entries()) {
592
+ const peerIdStr = peerId.toString();
593
+ durableByPeer.set(peerIdStr, {
594
+ rawStorage: options.rawStorageFactory ? options.rawStorageFactory(index) : new MemoryRawStorage(),
595
+ index
596
+ });
597
+ const lifetime: NodeLifetime = { stopped: false };
598
+ lifetimeByPeer.set(peerIdStr, lifetime);
599
+
600
+ // The node object exists before its member so the admission derivation can hand the finished
601
+ // MeshNode to spec-supplied callbacks; `storageRepo`/`clusterMember` are assigned by
602
+ // `buildMember` just below and `coordinatorRepo` in phase 2, and those closures only run at
603
+ // vote time, long after all three are in place.
604
+ const meshNode: MeshNode = {
605
+ peerId,
606
+ privateKey,
607
+ keyNetwork: makeNodeKeyNetwork(peerId),
608
+ storageRepo: undefined as any,
609
+ clusterMember: undefined as any,
610
+ coordinatorRepo: undefined as any
611
+ };
612
+ buildMember(meshNode, lifetime);
613
+ nodes.push(meshNode);
499
614
  }
500
615
 
501
- return { nodes, failures, keyNetwork };
616
+ // Phase 2: coordinator repos, once every member exists.
617
+ for (const node of nodes) {
618
+ buildCoordinator(node, lifetimeByPeer.get(node.peerId.toString())!);
619
+ }
620
+
621
+ const restart = (node: MeshNode): void => {
622
+ const peerIdStr = node.peerId.toString();
623
+ if (!nodes.includes(node)) {
624
+ throw new Error(`restart: ${peerIdStr} is not a node of this mesh`);
625
+ }
626
+ // NOTE: this severs the old instance's traffic to OTHER nodes, not its in-process path to its own
627
+ // member and storage repo, which still write the shared raw storage if called. After a restart
628
+ // between transactions only one thing could reach it: a scheduled commit retry aimed at the
629
+ // coordinator's OWN member, which exists only when that member threw while applying the broadcast —
630
+ // a local fault no spec injects today. If a spec ever does, or restarts a node MID-transaction,
631
+ // sever the old coordinator's local member too.
632
+ lifetimeByPeer.get(peerIdStr)!.stopped = true;
633
+ node.clusterMember.dispose();
634
+ const lifetime: NodeLifetime = { stopped: false };
635
+ lifetimeByPeer.set(peerIdStr, lifetime);
636
+ buildMember(node, lifetime);
637
+ buildCoordinator(node, lifetime);
638
+ };
639
+
640
+ return { nodes, failures, keyNetwork, restart };
502
641
  }
503
642
 
504
643
  /**
505
644
  * The nodes the key network keeps OUT of `blockId`'s cohort — peers that receive none of the
506
645
  * block's cluster traffic, and so hold none of its content until something repairs them.
507
646
  *
508
- * Peer ids are generated fresh per mesh, so which node is responsible for a given block is random
509
- * from run to run: in a 3-node `responsibilityK: 1` mesh, `nodes[1]` is the block's sole responsible
510
- * peer about a third of the time, and then it receives the writer's commit directly. A test that
511
- * needs a genuinely non-responsible node has to ask the routing layer rather than assume an index.
647
+ * Unless the mesh is seeded (`keySeed`), peer ids are generated fresh per mesh, so which node is
648
+ * responsible for a given block is random from run to run: in a 3-node `responsibilityK: 1` mesh,
649
+ * `nodes[1]` is the block's sole responsible peer about a third of the time, and then it receives the
650
+ * writer's commit directly. A test that needs a genuinely non-responsible node has to ask the routing
651
+ * layer rather than assume an index.
512
652
  */
513
653
  export async function nonResponsibleNodes(mesh: Mesh, blockId: string): Promise<MeshNode[]> {
514
- const cohort = await mesh.keyNetwork.findCluster(new TextEncoder().encode(blockId));
654
+ const cohort = await mesh.keyNetwork.findCluster(routingKeyForBlock(blockId));
515
655
  return mesh.nodes.filter(node => !(node.peerId.toString() in cohort));
516
656
  }
517
657
 
658
+ /**
659
+ * The nodes the key network places in `blockId`'s cohort, nearest first — the complement of
660
+ * {@link nonResponsibleNodes}. A node outside this list refuses a write for the block through its own
661
+ * coordinator (`CoordinatorRepo` checks responsibility), so a spec that writes through one node's
662
+ * coordinator directly picks that node from here rather than by index.
663
+ */
664
+ export async function responsibleNodes(mesh: Mesh, blockId: string): Promise<MeshNode[]> {
665
+ const cohort = Object.keys(await mesh.keyNetwork.findCluster(routingKeyForBlock(blockId)));
666
+ return cohort.map(id => mesh.nodes.find(node => node.peerId.toString() === id)!);
667
+ }
668
+
669
+ /**
670
+ * The first `count` ids of the form `${prefix}-${i}` whose cohort includes `node` — for a spec that needs
671
+ * several blocks one node is responsible for (a multi-block pend through that node's coordinator, or
672
+ * sequential writes it coordinates alone). In a `responsibilityK: 1` mesh these are blocks the node is
673
+ * the SOLE responsible peer for. Throws after `maxCandidates` ids rather than looping on a node whose ring
674
+ * arc no id lands in.
675
+ */
676
+ export async function blockIdsInCohortOf(mesh: Mesh, node: MeshNode, count: number, prefix: string, maxCandidates = 10_000): Promise<BlockId[]> {
677
+ const nodeId = node.peerId.toString();
678
+ const ids: BlockId[] = [];
679
+ for (let i = 0; ids.length < count && i < maxCandidates; i++) {
680
+ const id = `${prefix}-${i}` as BlockId;
681
+ if (nodeId in await mesh.keyNetwork.findCluster(routingKeyForBlock(id))) ids.push(id);
682
+ }
683
+ if (ids.length < count) {
684
+ throw new Error(`blockIdsInCohortOf: only ${ids.length} of ${count} ids with prefix ${prefix} place ${nodeId} in their cohort`);
685
+ }
686
+ return ids;
687
+ }
688
+
518
689
  export interface BuildTransactorOptions {
519
690
  timeoutMs?: number;
520
691
  abortOrCancelTimeoutMs?: number;
@@ -529,10 +700,30 @@ export interface BuildTransactorOptions {
529
700
  /**
530
701
  * Builds a NetworkTransactor over a mesh. All nodes share the same mock
531
702
  * infrastructure so a single transactor routes to every peer via `getRepo`.
703
+ * It runs on no node (no `localPeerId`), so a coverage tie between cohort
704
+ * members goes to the nearest — a client-only writer's shape.
532
705
  * Suitable for solo-mesh tests; for multi-node tests prefer
533
706
  * `buildNetworkTransactors` to label "which node is driving".
534
707
  */
535
- export const buildNetworkTransactor = (mesh: Mesh, options: BuildTransactorOptions = {}): ITransactor => {
708
+ export const buildNetworkTransactor = (mesh: Mesh, options: BuildTransactorOptions = {}): ITransactor =>
709
+ meshTransactor(mesh, options, undefined);
710
+
711
+ /**
712
+ * Builds one NetworkTransactor per mesh node, keyed by peer-id string. Each
713
+ * transactor shares the mesh's key network and peer→repo map, and runs on its
714
+ * node the way a production node's transactor does (`localPeerId`): when that
715
+ * node is in a block's cohort and ties another member on coverage, it
716
+ * coordinates the write itself. Reads and retries still route by proximity.
717
+ */
718
+ export const buildNetworkTransactors = (mesh: Mesh, options: BuildTransactorOptions = {}): Map<string, ITransactor> => {
719
+ const transactors = new Map<string, ITransactor>();
720
+ for (const node of mesh.nodes) {
721
+ transactors.set(node.peerId.toString(), meshTransactor(mesh, options, node));
722
+ }
723
+ return transactors;
724
+ };
725
+
726
+ function meshTransactor(mesh: Mesh, options: BuildTransactorOptions, localNode: MeshNode | undefined): ITransactor {
536
727
  const repoByPeer = new Map<string, IRepo>();
537
728
  for (const node of mesh.nodes) {
538
729
  const repo = node.coordinatorRepo as unknown as IRepo;
@@ -541,24 +732,14 @@ export const buildNetworkTransactor = (mesh: Mesh, options: BuildTransactorOptio
541
732
  return new NetworkTransactor({
542
733
  timeoutMs: options.timeoutMs ?? 5_000,
543
734
  abortOrCancelTimeoutMs: options.abortOrCancelTimeoutMs ?? 5_000,
735
+ // NOTE: every transactor, `localNode`'s included, reads the unpartitioned shared view, not `localNode.keyNetwork`;
736
+ // no spec drives `partitionSides` through a node's transactor today. If one does, pass the node's own view.
544
737
  keyNetwork: mesh.keyNetwork,
545
738
  getRepo: (peerId: DbPeerId) => {
546
739
  const repo = repoByPeer.get(peerId.toString());
547
740
  if (!repo) throw new Error(`Unknown peer ${peerId.toString()}`);
548
741
  return repo;
549
- }
742
+ },
743
+ localPeerId: localNode?.peerId
550
744
  });
551
- };
552
-
553
- /**
554
- * Builds one NetworkTransactor per mesh node, keyed by peer-id string. Each
555
- * transactor shares the mesh's key network and peer→repo map — the separate
556
- * instances exist so tests can semantically say "driven by node A".
557
- */
558
- export const buildNetworkTransactors = (mesh: Mesh, options: BuildTransactorOptions = {}): Map<string, ITransactor> => {
559
- const transactors = new Map<string, ITransactor>();
560
- for (const node of mesh.nodes) {
561
- transactors.set(node.peerId.toString(), buildNetworkTransactor(mesh, options));
562
- }
563
- return transactors;
564
- };
745
+ }