@optimystic/db-p2p 0.24.2 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. package/{readme.md → README.md} +688 -668
  2. package/dist/src/cluster/block-transfer-service.d.ts +133 -10
  3. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  4. package/dist/src/cluster/block-transfer-service.js +155 -9
  5. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  6. package/dist/src/cluster/block-transfer.d.ts +67 -7
  7. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  8. package/dist/src/cluster/block-transfer.js +127 -24
  9. package/dist/src/cluster/block-transfer.js.map +1 -1
  10. package/dist/src/cluster/certified-claims.d.ts +170 -0
  11. package/dist/src/cluster/certified-claims.d.ts.map +1 -0
  12. package/dist/src/cluster/certified-claims.js +200 -0
  13. package/dist/src/cluster/certified-claims.js.map +1 -0
  14. package/dist/src/cluster/client-signature-verifier.d.ts +23 -0
  15. package/dist/src/cluster/client-signature-verifier.d.ts.map +1 -0
  16. package/dist/src/cluster/client-signature-verifier.js +33 -0
  17. package/dist/src/cluster/client-signature-verifier.js.map +1 -0
  18. package/dist/src/cluster/cluster-policy.d.ts +9 -3
  19. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  20. package/dist/src/cluster/cluster-policy.js +111 -27
  21. package/dist/src/cluster/cluster-policy.js.map +1 -1
  22. package/dist/src/cluster/cluster-repo.d.ts +140 -11
  23. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  24. package/dist/src/cluster/cluster-repo.js +485 -29
  25. package/dist/src/cluster/cluster-repo.js.map +1 -1
  26. package/dist/src/cluster/commit-proof.d.ts +134 -0
  27. package/dist/src/cluster/commit-proof.d.ts.map +1 -0
  28. package/dist/src/cluster/commit-proof.js +263 -0
  29. package/dist/src/cluster/commit-proof.js.map +1 -0
  30. package/dist/src/cluster/quorum-restore.d.ts +95 -8
  31. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  32. package/dist/src/cluster/quorum-restore.js +113 -14
  33. package/dist/src/cluster/quorum-restore.js.map +1 -1
  34. package/dist/src/cluster/rebalance-monitor.d.ts +84 -0
  35. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  36. package/dist/src/cluster/rebalance-monitor.js +207 -5
  37. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  38. package/dist/src/cluster/reconcile-block.d.ts +27 -7
  39. package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
  40. package/dist/src/cluster/reconcile-block.js +179 -38
  41. package/dist/src/cluster/reconcile-block.js.map +1 -1
  42. package/dist/src/cluster/service.d.ts +13 -4
  43. package/dist/src/cluster/service.d.ts.map +1 -1
  44. package/dist/src/cluster/service.js +17 -18
  45. package/dist/src/cluster/service.js.map +1 -1
  46. package/dist/src/cluster/spread-on-churn.d.ts +8 -2
  47. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  48. package/dist/src/cluster/spread-on-churn.js +7 -7
  49. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  50. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  51. package/dist/src/cohort-topic/host.js +8 -7
  52. package/dist/src/cohort-topic/host.js.map +1 -1
  53. package/dist/src/cohort-topic/stream-util.d.ts +7 -6
  54. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  55. package/dist/src/cohort-topic/stream-util.js +14 -41
  56. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  57. package/dist/src/dispute/cascade.d.ts +7 -8
  58. package/dist/src/dispute/cascade.d.ts.map +1 -1
  59. package/dist/src/dispute/cascade.js +24 -7
  60. package/dist/src/dispute/cascade.js.map +1 -1
  61. package/dist/src/dispute/dispute-service.d.ts +5 -0
  62. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  63. package/dist/src/dispute/dispute-service.js +13 -3
  64. package/dist/src/dispute/dispute-service.js.map +1 -1
  65. package/dist/src/dispute/invalidation.d.ts +33 -15
  66. package/dist/src/dispute/invalidation.d.ts.map +1 -1
  67. package/dist/src/dispute/invalidation.js +127 -34
  68. package/dist/src/dispute/invalidation.js.map +1 -1
  69. package/dist/src/dispute/service.d.ts.map +1 -1
  70. package/dist/src/dispute/service.js +2 -1
  71. package/dist/src/dispute/service.js.map +1 -1
  72. package/dist/src/index.d.ts +7 -0
  73. package/dist/src/index.d.ts.map +1 -1
  74. package/dist/src/index.js +7 -0
  75. package/dist/src/index.js.map +1 -1
  76. package/dist/src/libp2p-key-network.d.ts +14 -10
  77. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  78. package/dist/src/libp2p-key-network.js +28 -58
  79. package/dist/src/libp2p-key-network.js.map +1 -1
  80. package/dist/src/libp2p-node-base.d.ts +38 -7
  81. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  82. package/dist/src/libp2p-node-base.js +148 -63
  83. package/dist/src/libp2p-node-base.js.map +1 -1
  84. package/dist/src/network/open-protocol-stream.d.ts +56 -0
  85. package/dist/src/network/open-protocol-stream.d.ts.map +1 -0
  86. package/dist/src/network/open-protocol-stream.js +60 -0
  87. package/dist/src/network/open-protocol-stream.js.map +1 -0
  88. package/dist/src/network/register-protocol-handler.d.ts +66 -0
  89. package/dist/src/network/register-protocol-handler.d.ts.map +1 -0
  90. package/dist/src/network/register-protocol-handler.js +51 -0
  91. package/dist/src/network/register-protocol-handler.js.map +1 -0
  92. package/dist/src/peer-address-book.d.ts +60 -2
  93. package/dist/src/peer-address-book.d.ts.map +1 -1
  94. package/dist/src/peer-address-book.js +89 -1
  95. package/dist/src/peer-address-book.js.map +1 -1
  96. package/dist/src/pend-validation.d.ts +65 -0
  97. package/dist/src/pend-validation.d.ts.map +1 -0
  98. package/dist/src/pend-validation.js +69 -0
  99. package/dist/src/pend-validation.js.map +1 -0
  100. package/dist/src/reactivity/notify-transport.d.ts.map +1 -1
  101. package/dist/src/reactivity/notify-transport.js +2 -1
  102. package/dist/src/reactivity/notify-transport.js.map +1 -1
  103. package/dist/src/reactivity/push-state-gossip.d.ts.map +1 -1
  104. package/dist/src/reactivity/push-state-gossip.js +2 -1
  105. package/dist/src/reactivity/push-state-gossip.js.map +1 -1
  106. package/dist/src/repo/cluster-coordinator.d.ts +32 -6
  107. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  108. package/dist/src/repo/cluster-coordinator.js +44 -10
  109. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  110. package/dist/src/repo/coordinator-repo.d.ts +181 -25
  111. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  112. package/dist/src/repo/coordinator-repo.js +650 -62
  113. package/dist/src/repo/coordinator-repo.js.map +1 -1
  114. package/dist/src/repo/served-repo-proxy.d.ts +30 -0
  115. package/dist/src/repo/served-repo-proxy.d.ts.map +1 -0
  116. package/dist/src/repo/served-repo-proxy.js +50 -0
  117. package/dist/src/repo/served-repo-proxy.js.map +1 -0
  118. package/dist/src/repo/service.d.ts +16 -1
  119. package/dist/src/repo/service.d.ts.map +1 -1
  120. package/dist/src/repo/service.js +18 -16
  121. package/dist/src/repo/service.js.map +1 -1
  122. package/dist/src/reputation/types.d.ts +3 -1
  123. package/dist/src/reputation/types.d.ts.map +1 -1
  124. package/dist/src/reputation/types.js +3 -1
  125. package/dist/src/reputation/types.js.map +1 -1
  126. package/dist/src/rn.d.ts +24 -2
  127. package/dist/src/rn.d.ts.map +1 -1
  128. package/dist/src/rn.js +33 -4
  129. package/dist/src/rn.js.map +1 -1
  130. package/dist/src/storage/block-archive.d.ts +153 -0
  131. package/dist/src/storage/block-archive.d.ts.map +1 -0
  132. package/dist/src/storage/block-archive.js +194 -0
  133. package/dist/src/storage/block-archive.js.map +1 -0
  134. package/dist/src/storage/block-latch.d.ts +92 -0
  135. package/dist/src/storage/block-latch.d.ts.map +1 -0
  136. package/dist/src/storage/block-latch.js +132 -0
  137. package/dist/src/storage/block-latch.js.map +1 -0
  138. package/dist/src/storage/block-storage.d.ts +169 -12
  139. package/dist/src/storage/block-storage.d.ts.map +1 -1
  140. package/dist/src/storage/block-storage.js +415 -152
  141. package/dist/src/storage/block-storage.js.map +1 -1
  142. package/dist/src/storage/cached-raw-storage.d.ts +5 -1
  143. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
  144. package/dist/src/storage/cached-raw-storage.js +16 -1
  145. package/dist/src/storage/cached-raw-storage.js.map +1 -1
  146. package/dist/src/storage/cached-store-driver.d.ts +14 -4
  147. package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
  148. package/dist/src/storage/cached-store-driver.js +39 -5
  149. package/dist/src/storage/cached-store-driver.js.map +1 -1
  150. package/dist/src/storage/i-block-storage.d.ts +70 -19
  151. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  152. package/dist/src/storage/i-block-storage.js +21 -1
  153. package/dist/src/storage/i-block-storage.js.map +1 -1
  154. package/dist/src/storage/i-raw-storage.d.ts +38 -0
  155. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  156. package/dist/src/storage/kv-raw-storage.d.ts +14 -8
  157. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
  158. package/dist/src/storage/kv-raw-storage.js +29 -9
  159. package/dist/src/storage/kv-raw-storage.js.map +1 -1
  160. package/dist/src/storage/memory-store-driver.d.ts +4 -1
  161. package/dist/src/storage/memory-store-driver.d.ts.map +1 -1
  162. package/dist/src/storage/memory-store-driver.js +22 -2
  163. package/dist/src/storage/memory-store-driver.js.map +1 -1
  164. package/dist/src/storage/raw-store-codec.js +2 -2
  165. package/dist/src/storage/raw-store-driver.d.ts +44 -3
  166. package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
  167. package/dist/src/storage/shared-cache-pool.d.ts +60 -3
  168. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
  169. package/dist/src/storage/shared-cache-pool.js +68 -4
  170. package/dist/src/storage/shared-cache-pool.js.map +1 -1
  171. package/dist/src/storage/storage-repo.d.ts +204 -26
  172. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  173. package/dist/src/storage/storage-repo.js +422 -119
  174. package/dist/src/storage/storage-repo.js.map +1 -1
  175. package/dist/src/storage/store-identity.d.ts +21 -0
  176. package/dist/src/storage/store-identity.d.ts.map +1 -0
  177. package/dist/src/storage/store-identity.js +33 -0
  178. package/dist/src/storage/store-identity.js.map +1 -0
  179. package/dist/src/storage/struct.d.ts +15 -0
  180. package/dist/src/storage/struct.d.ts.map +1 -1
  181. package/dist/src/storage/with-read-cache.d.ts +92 -0
  182. package/dist/src/storage/with-read-cache.d.ts.map +1 -0
  183. package/dist/src/storage/with-read-cache.js +132 -0
  184. package/dist/src/storage/with-read-cache.js.map +1 -0
  185. package/dist/src/sync/service.d.ts +13 -3
  186. package/dist/src/sync/service.d.ts.map +1 -1
  187. package/dist/src/sync/service.js +7 -30
  188. package/dist/src/sync/service.js.map +1 -1
  189. package/dist/src/testing/mesh-harness.d.ts +75 -2
  190. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  191. package/dist/src/testing/mesh-harness.js +193 -81
  192. package/dist/src/testing/mesh-harness.js.map +1 -1
  193. package/dist/src/testing/raw-storage-conformance.d.ts +8 -0
  194. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  195. package/dist/src/testing/raw-storage-conformance.js +97 -2
  196. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  197. package/package.json +5 -6
  198. package/src/cluster/block-transfer-service.ts +528 -312
  199. package/src/cluster/block-transfer.ts +547 -424
  200. package/src/cluster/certified-claims.ts +319 -0
  201. package/src/cluster/client-signature-verifier.ts +34 -0
  202. package/src/cluster/cluster-policy.ts +294 -203
  203. package/src/cluster/cluster-repo.ts +2501 -2034
  204. package/src/cluster/commit-proof.ts +344 -0
  205. package/src/cluster/quorum-restore.ts +348 -223
  206. package/src/cluster/rebalance-monitor.ts +539 -254
  207. package/src/cluster/reconcile-block.ts +404 -203
  208. package/src/cluster/service.ts +23 -20
  209. package/src/cluster/spread-on-churn.ts +352 -346
  210. package/src/cohort-topic/host.ts +8 -7
  211. package/src/cohort-topic/stream-util.ts +14 -43
  212. package/src/dispute/cascade.ts +31 -15
  213. package/src/dispute/dispute-service.ts +706 -696
  214. package/src/dispute/invalidation.ts +162 -52
  215. package/src/dispute/service.ts +126 -125
  216. package/src/index.ts +7 -0
  217. package/src/libp2p-key-network.ts +1204 -1235
  218. package/src/libp2p-node-base.ts +185 -70
  219. package/src/network/open-protocol-stream.ts +87 -0
  220. package/src/network/register-protocol-handler.ts +85 -0
  221. package/src/peer-address-book.ts +96 -2
  222. package/src/pend-validation.ts +89 -0
  223. package/src/reactivity/notify-transport.ts +2 -1
  224. package/src/reactivity/push-state-gossip.ts +2 -1
  225. package/src/repo/cluster-coordinator.ts +65 -11
  226. package/src/repo/coordinator-repo.ts +729 -71
  227. package/src/repo/served-repo-proxy.ts +55 -0
  228. package/src/repo/service.ts +25 -16
  229. package/src/reputation/types.ts +3 -1
  230. package/src/rn.ts +34 -4
  231. package/src/storage/block-archive.ts +242 -0
  232. package/src/storage/block-latch.ts +144 -0
  233. package/src/storage/block-storage.ts +787 -498
  234. package/src/storage/cached-raw-storage.ts +21 -1
  235. package/src/storage/cached-store-driver.ts +898 -859
  236. package/src/storage/i-block-storage.ts +172 -116
  237. package/src/storage/i-raw-storage.ts +40 -0
  238. package/src/storage/kv-raw-storage.ts +34 -9
  239. package/src/storage/memory-store-driver.ts +26 -2
  240. package/src/storage/raw-store-codec.ts +42 -42
  241. package/src/storage/raw-store-driver.ts +45 -3
  242. package/src/storage/shared-cache-pool.ts +516 -452
  243. package/src/storage/storage-repo.ts +1421 -1014
  244. package/src/storage/store-identity.ts +41 -0
  245. package/src/storage/struct.ts +52 -34
  246. package/src/storage/with-read-cache.ts +182 -0
  247. package/src/sync/service.ts +178 -198
  248. package/src/testing/mesh-harness.ts +524 -344
  249. package/src/testing/raw-storage-conformance.ts +114 -2
@@ -1,344 +1,524 @@
1
- import type { PeerId, PrivateKey } from '@libp2p/interface';
2
- import type { IKeyNetwork, ClusterPeers, ICluster, ClusterRecord, IRepo, BlockId, ActionRev, ClusterConsensusConfig, ITransactor, PeerId as DbPeerId } from '@optimystic/db-core';
3
- import type { FindCoordinatorOptions } from '@optimystic/db-core';
4
- import type { IPeerNetwork } from '@optimystic/db-core';
5
- import { NetworkTransactor, DEFAULT_SUPER_MAJORITY_THRESHOLD } from '@optimystic/db-core';
6
- import { peerIdFromPrivateKey } from '@libp2p/peer-id';
7
- import { generateKeyPair } from '@libp2p/crypto/keys';
8
- import { ClusterMember, clusterMember, type ReconcileBlockCallback } from '../cluster/cluster-repo.js';
9
- import { StorageRepo } from '../storage/storage-repo.js';
10
- import { MemoryRawStorage } from '../storage/memory-storage.js';
11
- import { BlockStorage } from '../storage/block-storage.js';
12
- import type { IRawStorage } from '../storage/i-raw-storage.js';
13
- import { coordinatorRepo, type ClusterLatestCallback } from '../repo/coordinator-repo.js';
14
- import type { CoordinatorRepo } from '../repo/coordinator-repo.js';
15
- import { sortPeersByDistance, type KnownPeer } from '../routing/responsibility.js';
16
- import { toString as u8ToString } from 'uint8arrays';
17
-
18
- export interface MeshNode {
19
- peerId: PeerId;
20
- privateKey: PrivateKey;
21
- storageRepo: StorageRepo;
22
- clusterMember: ClusterMember;
23
- coordinatorRepo: CoordinatorRepo;
24
- }
25
-
26
- export interface MeshOptions {
27
- responsibilityK: number;
28
- clusterSize?: number;
29
- superMajorityThreshold?: number;
30
- allowClusterDownsize?: boolean;
31
- /**
32
- * Optional per-node raw-storage factory. Invoked once per node (indexed from 0)
33
- * to supply the IRawStorage that backs StorageRepo. If omitted, each node gets
34
- * a fresh `MemoryRawStorage`. Used by fault-injection tests to wrap the store
35
- * with a crashing proxy, or by restart tests to rebuild over preserved state.
36
- */
37
- rawStorageFactory?: (index: number) => IRawStorage;
38
- }
39
-
40
- export interface MeshFailureConfig {
41
- /** Peers that should fail on cluster update (simulate unreachable) */
42
- failingPeers?: Set<string>;
43
- /** Make findCluster return empty (simulate DHT failure) */
44
- findClusterFails?: boolean;
45
- /**
46
- * Peers that are unreachable on the READ path: their latest-revision consult
47
- * (`ClusterLatestCallback`) REJECTS silence the coordinator must count as "did not
48
- * answer", never as the peer claiming absence — and the reconcile/acquire transfer skips
49
- * them as a source. Distinct from `failingPeers`, which fails cluster (write) updates.
50
- */
51
- silentPeers?: Set<string>;
52
- }
53
-
54
- class MockPeerNetwork implements IPeerNetwork {
55
- async connect(_peerId: PeerId, _protocol: string): Promise<any> {
56
- return {};
57
- }
58
- }
59
-
60
- /**
61
- * Mock IKeyNetwork that returns peers based on XOR distance.
62
- * With responsibilityK >= nodeCount, all nodes are returned.
63
- * Otherwise, K-nearest by XOR distance are returned.
64
- */
65
- class MockMeshKeyNetwork implements IKeyNetwork {
66
- constructor(
67
- private readonly nodes: MeshNode[],
68
- private readonly responsibilityK: number,
69
- private readonly failures: MeshFailureConfig = {}
70
- ) {}
71
-
72
- async findCoordinator(key: Uint8Array, options?: Partial<FindCoordinatorOptions>): Promise<PeerId> {
73
- const excluded = new Set((options?.excludedPeers ?? []).map(p => p.toString()));
74
- const sorted = this.sortedByDistance(key);
75
- const pick = sorted.find(n => !excluded.has(n.peerId.toString()));
76
- if (!pick) {
77
- throw new Error('No coordinator available for key (all candidates excluded)');
78
- }
79
- return pick.peerId;
80
- }
81
-
82
- async findCluster(key: Uint8Array): Promise<ClusterPeers> {
83
- if (this.failures.findClusterFails) {
84
- return {} as ClusterPeers;
85
- }
86
-
87
- const sorted = this.sortedByDistance(key);
88
- const k = Math.min(this.responsibilityK, sorted.length);
89
- const selected = sorted.slice(0, k);
90
-
91
- const peers: ClusterPeers = {};
92
- for (const node of selected) {
93
- peers[node.peerId.toString()] = {
94
- multiaddrs: ['/ip4/127.0.0.1/tcp/8000'],
95
- publicKey: u8ToString(node.peerId.publicKey!.raw, 'base64url')
96
- };
97
- }
98
- return peers;
99
- }
100
-
101
- private sortedByDistance(key: Uint8Array): MeshNode[] {
102
- const knownPeers: KnownPeer[] = this.nodes.map(n => ({
103
- id: n.peerId,
104
- addrs: ['/ip4/127.0.0.1/tcp/8000']
105
- }));
106
- const sorted = sortPeersByDistance(knownPeers, key);
107
- return sorted.map(kp => this.nodes.find(n => n.peerId.equals(kp.id))!);
108
- }
109
- }
110
-
111
- export interface Mesh {
112
- nodes: MeshNode[];
113
- failures: MeshFailureConfig;
114
- keyNetwork: IKeyNetwork;
115
- }
116
-
117
- /**
118
- * Pull a block's committed content from a sibling cohort node into `storageRepo` — the mesh analogue
119
- * of `libp2p-node-base`'s `createReconcileBlock` (SyncClient archive fetch + `saveReplicatedBlock`).
120
- *
121
- * Deliberately shared by BOTH callers, exactly as the live node shares one callback between them: the
122
- * commit path (`clusterMember.reconcileBlock`, for a block committed without a materializable base)
123
- * and the read path (`CoordinatorRepo.acquireBlockFromCohort`, for a corroborated revision the reader
124
- * cannot promote locally). Stateless, so it is simply rebuilt per caller.
125
- *
126
- * `nodes` is captured by reference and is fully populated by the time either caller invokes it.
127
- */
128
- const makeReconcileBlock = (nodes: MeshNode[], selfPeerId: string, storageRepo: StorageRepo, failures: MeshFailureConfig): ReconcileBlockCallback =>
129
- async (blockId, committed, cohortPeerIds) => {
130
- for (const peerIdStr of cohortPeerIds) {
131
- if (peerIdStr === selfPeerId) continue;
132
- // A silent peer cannot serve bytes either — without this, a test that silenced a
133
- // peer's consult could still accidentally converge THROUGH that peer.
134
- if (failures.silentPeers?.has(peerIdStr)) continue;
135
- const target = nodes.find(n => n.peerId.toString() === peerIdStr);
136
- if (!target) continue;
137
- const result = await target.storageRepo.get({ blockIds: [blockId] }, { skipClusterFetch: true } as any);
138
- const entry = result[blockId];
139
- const latest = entry?.state?.latest;
140
- if (!latest || !entry?.block || latest.rev < committed.rev) continue;
141
- await storageRepo.saveReplicatedBlock(blockId, entry.block, latest);
142
- return;
143
- }
144
- };
145
-
146
- /**
147
- * Creates N interconnected mesh nodes with real components and mock transport.
148
- * ClusterClient calls route directly to target ClusterMember instances.
149
- */
150
- export async function createMesh(nodeCount: number, options: MeshOptions): Promise<Mesh> {
151
- const failures: MeshFailureConfig = {};
152
-
153
- // Generate key pairs for all nodes
154
- const keyPairs = await Promise.all(
155
- Array.from({ length: nodeCount }, async () => {
156
- const privateKey = await generateKeyPair('Ed25519');
157
- return { peerId: peerIdFromPrivateKey(privateKey), privateKey };
158
- })
159
- );
160
-
161
- // Build nodes array (partially — coordinatorRepo added after keyNetwork is ready)
162
- const nodes: MeshNode[] = [];
163
- const peerNetwork = new MockPeerNetwork();
164
-
165
- // Phase 1: create storage + cluster members
166
- let nodeIndex = 0;
167
- for (const { peerId, privateKey } of keyPairs) {
168
- const rawStorage = options.rawStorageFactory
169
- ? options.rawStorageFactory(nodeIndex)
170
- : new MemoryRawStorage();
171
- nodeIndex++;
172
- const storageRepo = new StorageRepo(
173
- (blockId: BlockId) => new BlockStorage(blockId, rawStorage)
174
- );
175
-
176
- const consensusConfig: ClusterConsensusConfig = {
177
- superMajorityThreshold: options.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD,
178
- simpleMajorityThreshold: 0.51,
179
- minAbsoluteClusterSize: 2,
180
- allowClusterDownsize: options.allowClusterDownsize ?? true,
181
- clusterSizeTolerance: 0.5,
182
- partitionDetectionWindow: 60000,
183
- // Harness spins up small/single-node meshes on purpose; opt into running
184
- // below the safe floor without a confident network-size estimate.
185
- allowUnvalidatedSmallCluster: true
186
- };
187
-
188
- // Active reconciliation: when a member commits a block it never pended (cohort drift),
189
- // pull the committed revision from a sibling cohort node that holds it.
190
- const reconcileBlock = makeReconcileBlock(nodes, peerId.toString(), storageRepo, failures);
191
-
192
- const member = clusterMember({
193
- storageRepo,
194
- peerNetwork,
195
- peerId,
196
- privateKey,
197
- consensusConfig,
198
- reconcileBlock
199
- });
200
-
201
- nodes.push({
202
- peerId,
203
- privateKey,
204
- storageRepo,
205
- clusterMember: member,
206
- coordinatorRepo: undefined as any // filled in phase 2
207
- });
208
- }
209
-
210
- // Phase 2: create key network and coordinator repos (needs all nodes for routing)
211
- const keyNetwork = new MockMeshKeyNetwork(nodes, options.responsibilityK, failures);
212
-
213
- const createClusterClient = (targetPeerId: PeerId): ICluster => {
214
- const target = nodes.find(n => n.peerId.equals(targetPeerId));
215
- if (!target) {
216
- throw new Error(`Unknown peer: ${targetPeerId.toString()}`);
217
- }
218
- return {
219
- async update(record: ClusterRecord): Promise<ClusterRecord> {
220
- if (failures.failingPeers?.has(targetPeerId.toString())) {
221
- throw new Error(`Peer ${targetPeerId.toString()} is unreachable`);
222
- }
223
- return target.clusterMember.update(record);
224
- }
225
- };
226
- };
227
-
228
- for (const node of nodes) {
229
- // Per-node callback: reports the queried peer's latest revision, and NOTHING else. It used to
230
- // also write the peer's block into local storage ("simulate data sync"), which made every
231
- // read-repair assertion on this harness observe a convergence the production callback does not
232
- // provide masking exactly the defect that ticket `read-repair-cannot-transfer-block-content`
233
- // existed to expose. Transfer now happens where it does in production: through
234
- // `acquireBlockFromCohort` below, gated on a corroborated revision.
235
- const clusterLatestCallback: ClusterLatestCallback = async (peerId: PeerId, blockId: BlockId, context?): Promise<ActionRev | undefined> => {
236
- // Silence: the peer never answers. REJECTS, mirroring what a dial failure does to the
237
- // production callback — the coordinator must count this as "did not answer", never as
238
- // an absent claim (a resolved `undefined` remains the peer answering "I hold nothing").
239
- if (failures.silentPeers?.has(peerId.toString())) {
240
- throw new Error(`Peer ${peerId.toString()} is silent`);
241
- }
242
- const target = nodes.find(n => n.peerId.equals(peerId));
243
- if (!target) return undefined;
244
- const result = await target.storageRepo.get(
245
- { blockIds: [blockId], context },
246
- { skipClusterFetch: true } as any
247
- );
248
- return result[blockId]?.state?.latest;
249
- };
250
- // Wrap key network to include self in findCluster (matches real Libp2pKeyPeerNetwork behavior)
251
- const nodeKeyNetwork: IKeyNetwork = {
252
- findCoordinator: (key, opts) => keyNetwork.findCoordinator(key, opts),
253
- async findCluster(key) {
254
- const peers = await keyNetwork.findCluster(key);
255
- const selfStr = node.peerId.toString();
256
- if (!(selfStr in peers)) {
257
- peers[selfStr] = {
258
- multiaddrs: ['/ip4/127.0.0.1/tcp/8000'],
259
- publicKey: u8ToString(node.peerId.publicKey!.raw, 'base64url')
260
- };
261
- }
262
- return peers;
263
- }
264
- };
265
- const factory = coordinatorRepo(
266
- nodeKeyNetwork,
267
- createClusterClient,
268
- {
269
- clusterSize: options.clusterSize ?? nodeCount,
270
- superMajorityThreshold: options.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD,
271
- allowClusterDownsize: options.allowClusterDownsize ?? true,
272
- // Harness meshes run below the safe floor on purpose; opt the coordinator
273
- // side into that so validateSmallCluster admits them (fails closed by default).
274
- allowUnvalidatedSmallCluster: true
275
- }
276
- );
277
- node.coordinatorRepo = factory({
278
- storageRepo: node.storageRepo,
279
- localCluster: node.clusterMember,
280
- localPeerId: node.peerId,
281
- clusterLatestCallback,
282
- // The read path's transfer mechanism the same callback the member uses on the commit path,
283
- // mirroring how `libp2p-node-base` shares one `reconcileBlock` between both.
284
- acquireBlockFromCohort: makeReconcileBlock(nodes, node.peerId.toString(), node.storageRepo, failures)
285
- });
286
- }
287
-
288
- return { nodes, failures, keyNetwork };
289
- }
290
-
291
- /**
292
- * The nodes the key network keeps OUT of `blockId`'s cohort peers that receive none of the
293
- * block's cluster traffic, and so hold none of its content until something repairs them.
294
- *
295
- * Peer ids are generated fresh per mesh, so which node is responsible for a given block is random
296
- * from run to run: in a 3-node `responsibilityK: 1` mesh, `nodes[1]` is the block's sole responsible
297
- * peer about a third of the time, and then it receives the writer's commit directly. A test that
298
- * needs a genuinely non-responsible node has to ask the routing layer rather than assume an index.
299
- */
300
- export async function nonResponsibleNodes(mesh: Mesh, blockId: string): Promise<MeshNode[]> {
301
- const cohort = await mesh.keyNetwork.findCluster(new TextEncoder().encode(blockId));
302
- return mesh.nodes.filter(node => !(node.peerId.toString() in cohort));
303
- }
304
-
305
- export interface BuildTransactorOptions {
306
- timeoutMs?: number;
307
- abortOrCancelTimeoutMs?: number;
308
- }
309
-
310
- /**
311
- * Builds a NetworkTransactor over a mesh. All nodes share the same mock
312
- * infrastructure so a single transactor routes to every peer via `getRepo`.
313
- * Suitable for solo-mesh tests; for multi-node tests prefer
314
- * `buildNetworkTransactors` to label "which node is driving".
315
- */
316
- export const buildNetworkTransactor = (mesh: Mesh, options: BuildTransactorOptions = {}): ITransactor => {
317
- const repoByPeer = new Map<string, IRepo>();
318
- for (const node of mesh.nodes) {
319
- repoByPeer.set(node.peerId.toString(), node.coordinatorRepo as unknown as IRepo);
320
- }
321
- return new NetworkTransactor({
322
- timeoutMs: options.timeoutMs ?? 5_000,
323
- abortOrCancelTimeoutMs: options.abortOrCancelTimeoutMs ?? 5_000,
324
- keyNetwork: mesh.keyNetwork,
325
- getRepo: (peerId: DbPeerId) => {
326
- const repo = repoByPeer.get(peerId.toString());
327
- if (!repo) throw new Error(`Unknown peer ${peerId.toString()}`);
328
- return repo;
329
- }
330
- });
331
- };
332
-
333
- /**
334
- * Builds one NetworkTransactor per mesh node, keyed by peer-id string. Each
335
- * transactor shares the mesh's key network and peer→repo map the separate
336
- * instances exist so tests can semantically say "driven by node A".
337
- */
338
- export const buildNetworkTransactors = (mesh: Mesh, options: BuildTransactorOptions = {}): Map<string, ITransactor> => {
339
- const transactors = new Map<string, ITransactor>();
340
- for (const node of mesh.nodes) {
341
- transactors.set(node.peerId.toString(), buildNetworkTransactor(mesh, options));
342
- }
343
- return transactors;
344
- };
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';
3
+ import type { FindCoordinatorOptions } from '@optimystic/db-core';
4
+ import type { IPeerNetwork } from '@optimystic/db-core';
5
+ import { NetworkTransactor } from '@optimystic/db-core';
6
+ import { peerIdFromPrivateKey } from '@libp2p/peer-id';
7
+ import { generateKeyPair } from '@libp2p/crypto/keys';
8
+ import { ClusterMember, clusterMember, type ReconcileBlockCallback, type DeriveExpectedClusterCallback, type ExpectedClusterView } from '../cluster/cluster-repo.js';
9
+ import { createReconcileBlock } from '../cluster/reconcile-block.js';
10
+ import { resolveClusterPolicy, type ClusterPolicyOptions, type ResolvedClusterPolicy } from '../cluster/cluster-policy.js';
11
+ import { StorageRepo } from '../storage/storage-repo.js';
12
+ import { MemoryRawStorage } from '../storage/memory-storage.js';
13
+ import { BlockStorage } from '../storage/block-storage.js';
14
+ import type { IRawStorage } from '../storage/i-raw-storage.js';
15
+ import type { BlockArchive } from '../storage/struct.js';
16
+ import { serveBlockArchive, servableProof } from '../storage/block-archive.js';
17
+ import { coordinatorRepo, type ClusterLatestCallback, type CertifiedActionRev } from '../repo/coordinator-repo.js';
18
+ import type { CoordinatorRepo } from '../repo/coordinator-repo.js';
19
+ import { sortPeersByDistance, type KnownPeer } from '../routing/responsibility.js';
20
+ import { toString as u8ToString } from 'uint8arrays';
21
+
22
+ export interface MeshNode {
23
+ peerId: PeerId;
24
+ privateKey: PrivateKey;
25
+ storageRepo: StorageRepo;
26
+ clusterMember: ClusterMember;
27
+ coordinatorRepo: CoordinatorRepo;
28
+ }
29
+
30
+ export interface MeshOptions {
31
+ responsibilityK: number;
32
+ /**
33
+ * Replication factor. OMITTED now means what it means in production: the operator declared
34
+ * nothing, so it resolves to `DEFAULT_CLUSTER_SIZE` (10) NOT to `nodeCount`. An undeclared
35
+ * two-node mesh therefore measures its repair corroboration floor against 10 and can never
36
+ * repair (see `resolveClusterPolicy` in `cluster/cluster-policy.ts`); a mesh that genuinely is
37
+ * its node count must say so, exactly as a real deployment must.
38
+ */
39
+ clusterSize?: number;
40
+ /**
41
+ * Passed through to `resolveClusterPolicy` verbatim; this is how a mesh declares its real
42
+ * cohort size (`assumedClusterSize`), downsize policy, or small-cluster opt-in. When both a
43
+ * legacy top-level field and the matching entry here are given, the entry here wins — it is
44
+ * the production-shaped one.
45
+ */
46
+ clusterPolicy?: ClusterPolicyOptions['clusterPolicy'];
47
+ /** Legacy shorthand for `clusterPolicy.superMajorityThreshold`. */
48
+ superMajorityThreshold?: number;
49
+ /** Legacy shorthand for `clusterPolicy.allowDownsize`. */
50
+ allowClusterDownsize?: boolean;
51
+ /**
52
+ * Optional per-node raw-storage factory. Invoked once per node (indexed from 0)
53
+ * to supply the IRawStorage that backs StorageRepo. If omitted, each node gets
54
+ * a fresh `MemoryRawStorage`. Used by fault-injection tests to wrap the store
55
+ * with a crashing proxy, or by restart tests to rebuild over preserved state.
56
+ */
57
+ rawStorageFactory?: (index: number) => IRawStorage;
58
+ /**
59
+ * Per-node member-side cluster derivation for the membership admission gate — the harness
60
+ * 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).
64
+ */
65
+ deriveExpectedCluster?: (node: MeshNode, blockId: BlockId) => Promise<ExpectedClusterView>;
66
+ /**
67
+ * Per-node transaction validator — the harness analogue of `NodeOptions.validator`, which
68
+ * `libp2p-node-base` forwards straight into `clusterMember({ … validator })`. Invoked once per
69
+ * node (indexed from 0, in the order `Mesh.nodes` ends up in) during assembly.
70
+ *
71
+ * Omitted → no validator, and `ClusterMember.validatePendOperations` skips the whole validation
72
+ * step (signatures, schema hash, operations hash) today's harness behaviour, preserved as the
73
+ * default so existing meshes do not suddenly re-validate transactions they were never built to
74
+ * satisfy. That is also production's current posture: no composition root supplies
75
+ * `NodeOptions.validator` yet (backlog `feat-no-deployment-validates-transactions-at-pend`).
76
+ *
77
+ * A FACTORY rather than one shared instance because enforcement is a per-node decision, and a
78
+ * mixed mesh — some members enforcing, some not — is exactly the case worth testing.
79
+ *
80
+ * A mesh that arms validators can also set `clusterPolicy.unvalidatablePendPolicy: 'reject'` to
81
+ * 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.
83
+ */
84
+ validatorFactory?: (index: number, peerId: PeerId) => ITransactionValidator;
85
+ /**
86
+ * Per-node network-size confidence (0..1) fed to the default derivation — the FRET stand-in.
87
+ * Default 1 (confident). Evaluated per vote, so a spec may flip it mid-test (e.g. collapse a
88
+ * partition side's confidence after the mesh is built). The gate's check is STRICTLY greater
89
+ * than its threshold (0.5), so returning the threshold itself lands on the fail-closed side.
90
+ */
91
+ meshConfidence?: (node: MeshNode) => number;
92
+ }
93
+
94
+ export interface MeshFailureConfig {
95
+ /** Peers that should fail on cluster update (simulate unreachable) */
96
+ failingPeers?: Set<string>;
97
+ /** Make findCluster return empty (simulate DHT failure) */
98
+ findClusterFails?: boolean;
99
+ /**
100
+ * Peers that are unreachable on the READ path: their latest-revision consult
101
+ * (`ClusterLatestCallback`) REJECTS — silence the coordinator must count as "did not
102
+ * answer", never as the peer claiming absence — and the reconcile/acquire transfer skips
103
+ * them as a source. Distinct from `failingPeers`, which fails cluster (write) updates.
104
+ */
105
+ silentPeers?: Set<string>;
106
+ /**
107
+ * Simulated network partition: each entry is one side of the split, as a set of peer-id
108
+ * strings. While set, a node's own key-network view (`findCluster`) answers a caller on side S
109
+ * with only the members of S that would otherwise be in the cohort — an UNAUTHENTICATED
110
+ * shrunken view, exactly what the membership admission gate exists to refuse. Callers not in
111
+ * any listed side see the unpartitioned cohort.
112
+ *
113
+ * This shapes cluster VIEWS (what a coordinator declares and what a member derives), not
114
+ * transport reachability — combine with `failingPeers`/`silentPeers` to also sever traffic.
115
+ * On the write path that rarely matters: a partitioned coordinator only contacts the cohort
116
+ * it declared, which is already its own side.
117
+ */
118
+ partitionSides?: Set<string>[];
119
+ }
120
+
121
+ class MockPeerNetwork implements IPeerNetwork {
122
+ async connect(_peerId: PeerId, _protocol: string): Promise<any> {
123
+ return {};
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Mock IKeyNetwork that returns peers based on XOR distance.
129
+ * With responsibilityK >= nodeCount, all nodes are returned.
130
+ * Otherwise, K-nearest by XOR distance are returned.
131
+ */
132
+ class MockMeshKeyNetwork implements IKeyNetwork {
133
+ constructor(
134
+ private readonly nodes: MeshNode[],
135
+ private readonly responsibilityK: number,
136
+ private readonly failures: MeshFailureConfig = {}
137
+ ) {}
138
+
139
+ async findCoordinator(key: Uint8Array, options?: Partial<FindCoordinatorOptions>): Promise<PeerId> {
140
+ const excluded = new Set((options?.excludedPeers ?? []).map(p => p.toString()));
141
+ const sorted = this.sortedByDistance(key);
142
+ const pick = sorted.find(n => !excluded.has(n.peerId.toString()));
143
+ if (!pick) {
144
+ throw new Error('No coordinator available for key (all candidates excluded)');
145
+ }
146
+ return pick.peerId;
147
+ }
148
+
149
+ async findCluster(key: Uint8Array): Promise<ClusterPeers> {
150
+ if (this.failures.findClusterFails) {
151
+ return {} as ClusterPeers;
152
+ }
153
+
154
+ const sorted = this.sortedByDistance(key);
155
+ const k = Math.min(this.responsibilityK, sorted.length);
156
+ const selected = sorted.slice(0, k);
157
+
158
+ const peers: ClusterPeers = {};
159
+ for (const node of selected) {
160
+ peers[node.peerId.toString()] = {
161
+ multiaddrs: ['/ip4/127.0.0.1/tcp/8000'],
162
+ publicKey: u8ToString(node.peerId.publicKey!.raw, 'base64url')
163
+ };
164
+ }
165
+ return peers;
166
+ }
167
+
168
+ private sortedByDistance(key: Uint8Array): MeshNode[] {
169
+ const knownPeers: KnownPeer[] = this.nodes.map(n => ({
170
+ id: n.peerId,
171
+ addrs: ['/ip4/127.0.0.1/tcp/8000']
172
+ }));
173
+ const sorted = sortPeersByDistance(knownPeers, key);
174
+ return sorted.map(kp => this.nodes.find(n => n.peerId.equals(kp.id))!);
175
+ }
176
+ }
177
+
178
+ export interface Mesh {
179
+ nodes: MeshNode[];
180
+ failures: MeshFailureConfig;
181
+ keyNetwork: IKeyNetwork;
182
+ }
183
+
184
+ /**
185
+ * One cohort peer's archive for a block, read straight from the sibling's `StorageRepo` — the mesh
186
+ * analogue of `libp2p-node-base`'s `fetchArchiveFromPeer` (a SyncClient round trip). The archive
187
+ * itself comes from `serveBlockArchive`, the same function `SyncService` answers a real fetch with,
188
+ * so the harness cannot serve a shape production would not including the case that used to
189
+ * differ: a peer holding a revision whose content it cannot materialize still votes on
190
+ * `(rev, actionId)` here, exactly as it does over the wire.
191
+ *
192
+ * A silent peer serves no bytes either. `undefined` is the right answer: reconcile's `no-archive`
193
+ * outcome deliberately conflates unreachable with holds-nothing, and the production
194
+ * `fetchArchiveFromPeer` swallows every dial failure into the same `undefined`. (Contrast the read
195
+ * path's latest-revision consult, where silence REJECTS — the coordinator counts "did not answer"
196
+ * separately from "holds nothing".)
197
+ *
198
+ * `nodes` is captured by reference and is fully populated by the time this is invoked.
199
+ *
200
+ * NOTE: the archive served here always carries exactly ONE revision — the peer's current latest —
201
+ * because that is all a repo read surfaces. Enough for repair, which only ever targets one
202
+ * `(rev, actionId)`. If a spec ever needs a gap-fill across a revision RANGE, `serveBlockArchive`
203
+ * has to grow a real range and both callers get it at once.
204
+ */
205
+ const makeFetchArchive = (nodes: MeshNode[], selfPeerId: string, failures: MeshFailureConfig) =>
206
+ async (peerIdStr: string, blockId: BlockId): Promise<BlockArchive | undefined> => {
207
+ if (peerIdStr === selfPeerId) return undefined;
208
+ if (failures.silentPeers?.has(peerIdStr)) return undefined;
209
+ const target = nodes.find(n => n.peerId.toString() === peerIdStr);
210
+ if (!target) return undefined;
211
+ return await serveBlockArchive(target.storageRepo, blockId);
212
+ };
213
+
214
+ /**
215
+ * Fold the mesh's operator-facing knobs into the numbers every node runs on, through the SAME
216
+ * resolver a real node's composition root uses (`libp2p-node-base.ts`). Named and exported rather
217
+ * than inlined in `createMesh` so the precedence rule below is assertable without building a mesh.
218
+ *
219
+ * Precedence: an explicit `clusterPolicy` entry wins over the matching legacy top-level field — the
220
+ * entry is the production-shaped one.
221
+ */
222
+ export function resolveMeshPolicy(options: MeshOptions): ResolvedClusterPolicy {
223
+ return resolveClusterPolicy({
224
+ clusterSize: options.clusterSize,
225
+ clusterPolicy: {
226
+ ...options.clusterPolicy,
227
+ superMajorityThreshold: options.clusterPolicy?.superMajorityThreshold ?? options.superMajorityThreshold,
228
+ allowDownsize: options.clusterPolicy?.allowDownsize ?? options.allowClusterDownsize
229
+ // `allowUnvalidatedSmallCluster` passes through UNTOUCHED and so defaults to `false`, same
230
+ // as `resolveClusterPolicy` the membership admission gate is ARMED in the harness. A mesh
231
+ // that must transact below the safe floor says so at its own call site:
232
+ // createMesh(1, { responsibilityK: 1, clusterPolicy: { allowUnvalidatedSmallCluster: true } })
233
+ // No harness-wide re-default: a disarmed gate has to be visible where the test is read.
234
+ // (Solo cohorts never reach the gate anyway — CoordinatorRepo short-circuits peerCount <= 1
235
+ // straight to local storage so only a genuinely undersized MULTI-peer cohort needs the
236
+ // opt-in.)
237
+ }
238
+ });
239
+ }
240
+
241
+ /**
242
+ * Creates N interconnected mesh nodes with real components and mock transport.
243
+ * ClusterClient calls route directly to target ClusterMember instances.
244
+ */
245
+ export async function createMesh(nodeCount: number, options: MeshOptions): Promise<Mesh> {
246
+ // NOTE: a mesh is never shut down — `Mesh` exposes no disposal seam, so each node's
247
+ // `ClusterMember.dispose()` is never called and its two cleanup intervals tick for the rest of
248
+ // the process. Harmless today: both handles are `.unref()`ed (the process still exits) and the
249
+ // callbacks are no-ops on an idle member, at ~40 `createMesh` sites across db-p2p's suite. If a
250
+ // mesh ever holds something a timer keeps alive a real socket, a file handle, a fake clock a
251
+ // spec advances give `Mesh` a `dispose()` that walks the nodes, and make the specs use it.
252
+ const failures: MeshFailureConfig = {};
253
+
254
+ // NOTE: one policy for the whole mesh, so every node necessarily agrees on cluster size and
255
+ // thresholds. That is right for repair tests, where disagreement is not the variable. A test
256
+ // that needs nodes to DISAGREE about the cluster (a partition where each side derives its own
257
+ // view — see ticket `mesh-harness-admission-gate`) has to resolve per node instead; it cannot
258
+ // be expressed with a single shared object.
259
+ //
260
+ // Resolved once per mesh rather than per node for a second reason: the resolver's one-line
261
+ // `repair-fault-tolerance` advisory then fires once instead of N times.
262
+ const policy = resolveMeshPolicy(options);
263
+
264
+ // Generate key pairs for all nodes
265
+ const keyPairs = await Promise.all(
266
+ Array.from({ length: nodeCount }, async () => {
267
+ const privateKey = await generateKeyPair('Ed25519');
268
+ return { peerId: peerIdFromPrivateKey(privateKey), privateKey };
269
+ })
270
+ );
271
+
272
+ // Build nodes array (partially coordinatorRepo added after keyNetwork is ready)
273
+ const nodes: MeshNode[] = [];
274
+ const peerNetwork = new MockPeerNetwork();
275
+ // One real reconcile callback per node, shared between the member's commit-path `reconcileBlock`
276
+ // and the coordinator's read-path `acquireBlockFromCohort` — production shares one instance
277
+ // (`libp2p-node-base.ts`), and a spec must not be able to tell the two paths apart. Built in
278
+ // phase 1 (with the member), consumed again in phase 2 (by the coordinator), so it is stashed
279
+ // here keyed by peer id rather than widened onto the public `MeshNode` type.
280
+ const reconcileByPeer = new Map<string, ReconcileBlockCallback>();
281
+
282
+ // The mesh key network is built BEFORE the members: each member's `deriveExpectedCluster` (the
283
+ // admission gate's view) needs a per-node key network in phase 1, and constructing it here beats
284
+ // a late-bound slot a closure could fire on before it is filled. Safe because `nodes` is captured
285
+ // by reference and only consulted at call time, after the array is fully populated.
286
+ const keyNetwork = new MockMeshKeyNetwork(nodes, options.responsibilityK, failures);
287
+
288
+ /**
289
+ * One node's own view of the key network — what `Libp2pKeyPeerNetwork` gives a real node:
290
+ * - `findCluster` always includes self, so a responsible member's derived view is never empty
291
+ * (see the empty-view guard in `cluster-repo.admitMembership`);
292
+ * - under a simulated partition (`failures.partitionSides`), a caller inside a side sees only
293
+ * its side's members of the cohort the caller-aware filtering lives here, in the per-node
294
+ * wrapper, precisely so `IKeyNetwork` itself needs no "who is asking" parameter.
295
+ * The SAME instance serves both the member's admission derivation (phase 1) and the node's
296
+ * coordinator (phase 2), so the two sides of a node can never see different topologies.
297
+ */
298
+ const makeNodeKeyNetwork = (selfPeerId: PeerId): IKeyNetwork => {
299
+ const selfStr = selfPeerId.toString();
300
+ return {
301
+ findCoordinator: (key, opts) => keyNetwork.findCoordinator(key, opts),
302
+ async findCluster(key) {
303
+ const peers = await keyNetwork.findCluster(key);
304
+ const side = failures.partitionSides?.find(s => s.has(selfStr));
305
+ if (side) {
306
+ for (const id of Object.keys(peers)) {
307
+ if (!side.has(id)) delete peers[id];
308
+ }
309
+ }
310
+ if (!(selfStr in peers)) {
311
+ peers[selfStr] = {
312
+ multiaddrs: ['/ip4/127.0.0.1/tcp/8000'],
313
+ publicKey: u8ToString(selfPeerId.publicKey!.raw, 'base64url')
314
+ };
315
+ }
316
+ return peers;
317
+ }
318
+ };
319
+ };
320
+ const nodeKeyNetworkByPeer = new Map<string, IKeyNetwork>();
321
+
322
+ // Phase 1: create storage + cluster members
323
+ let nodeIndex = 0;
324
+ for (const { peerId, privateKey } of keyPairs) {
325
+ const index = nodeIndex++;
326
+ const rawStorage = options.rawStorageFactory
327
+ ? options.rawStorageFactory(index)
328
+ : new MemoryRawStorage();
329
+ const storageRepo = new StorageRepo(
330
+ (blockId: BlockId) => new BlockStorage(blockId, rawStorage)
331
+ );
332
+
333
+ // Active reconciliation: when a member commits a block it never pended (cohort drift), or a
334
+ // reader holds a corroborated revision it cannot promote locally, pull the committed revision
335
+ // from the cohort through the PRODUCTION quorum rules (`createReconcileBlock`): a quorum of
336
+ // distinct peers must agree on the target `(rev, actionId)` AND on the block content, or the
337
+ // pass declines, persisting nothing. `reputation` is omitted — no reputation subsystem in the
338
+ // harness.
339
+ const reconcileBlock = createReconcileBlock({
340
+ selfPeerId: peerId.toString(),
341
+ fetchArchive: makeFetchArchive(nodes, peerId.toString(), failures),
342
+ // Production shape: a proof reconcile verified against the agreed bytes is persisted so
343
+ // the repaired replica serves it onward.
344
+ saveReplicatedBlock: (blockId, block, source, verifiedProof) =>
345
+ storageRepo.saveReplicatedBlock(blockId, block, source, verifiedProof),
346
+ simpleMajorityThreshold: policy.simpleMajorityThreshold,
347
+ superMajorityThreshold: policy.superMajorityThreshold,
348
+ repairCorroborationClusterSize: policy.repairCorroborationClusterSize
349
+ });
350
+ reconcileByPeer.set(peerId.toString(), reconcileBlock);
351
+
352
+ const nodeKeyNetwork = makeNodeKeyNetwork(peerId);
353
+ nodeKeyNetworkByPeer.set(peerId.toString(), nodeKeyNetwork);
354
+
355
+ // The node object exists before its member so the admission derivation below can hand the
356
+ // finished MeshNode to spec-supplied callbacks; `clusterMember`/`coordinatorRepo` are
357
+ // assigned as they are built (member just below, coordinator in phase 2) and the closures
358
+ // only run at vote time, long after both are in place.
359
+ const meshNode: MeshNode = {
360
+ peerId,
361
+ privateKey,
362
+ storageRepo,
363
+ clusterMember: undefined as any,
364
+ coordinatorRepo: undefined as any
365
+ };
366
+
367
+ // Member-side cluster derivation for the membership admission gate — the production shape
368
+ // (`libp2p-node-base.deriveExpectedCluster`): the SAME per-node key network the coordinator
369
+ // selects its cohort from, plus a network-size confidence. The self-including wrapper keeps a
370
+ // responsible member's view non-empty; `meshConfidence` is the FRET stand-in (default 1, i.e.
371
+ // confident — a partition spec collapses it per side).
372
+ const deriveExpectedCluster: DeriveExpectedClusterCallback = options.deriveExpectedCluster
373
+ ? (blockId) => options.deriveExpectedCluster!(meshNode, blockId)
374
+ : async (blockId) => ({
375
+ peers: await nodeKeyNetwork.findCluster(new TextEncoder().encode(blockId)) ?? {},
376
+ confidence: options.meshConfidence?.(meshNode) ?? 1
377
+ });
378
+
379
+ meshNode.clusterMember = clusterMember({
380
+ storageRepo,
381
+ peerNetwork,
382
+ peerId,
383
+ privateKey,
384
+ consensusConfig: policy,
385
+ reconcileBlock,
386
+ deriveExpectedCluster,
387
+ // Absent by default: `undefined` here is identical to omitting the field, and
388
+ // `validatePendOperations` then skips the validation step entirely.
389
+ validator: options.validatorFactory?.(index, peerId)
390
+ });
391
+
392
+ nodes.push(meshNode);
393
+ }
394
+
395
+ // Phase 2: coordinator repos (needs all nodes for routing; key network built in phase 1)
396
+ const createClusterClient = (targetPeerId: PeerId): ICluster => {
397
+ const target = nodes.find(n => n.peerId.equals(targetPeerId));
398
+ if (!target) {
399
+ throw new Error(`Unknown peer: ${targetPeerId.toString()}`);
400
+ }
401
+ return {
402
+ async update(record: ClusterRecord): Promise<ClusterRecord> {
403
+ if (failures.failingPeers?.has(targetPeerId.toString())) {
404
+ throw new Error(`Peer ${targetPeerId.toString()} is unreachable`);
405
+ }
406
+ return target.clusterMember.update(record);
407
+ }
408
+ };
409
+ };
410
+
411
+ for (const node of nodes) {
412
+ // Per-node callback: reports the queried peer's latest revision, and NOTHING else. It used to
413
+ // also write the peer's block into local storage ("simulate data sync"), which made every
414
+ // read-repair assertion on this harness observe a convergence the production callback does not
415
+ // provide — masking exactly the defect that ticket `read-repair-cannot-transfer-block-content`
416
+ // existed to expose. Transfer now happens where it does in production: through
417
+ // `acquireBlockFromCohort` below, gated on a corroborated revision.
418
+ const clusterLatestCallback: ClusterLatestCallback = async (peerId: PeerId, blockId: BlockId, context?): Promise<CertifiedActionRev | undefined> => {
419
+ // Silence: the peer never answers. REJECTS, mirroring what a dial failure does to the
420
+ // production callback — the coordinator must count this as "did not answer", never as
421
+ // an absent claim (a resolved `undefined` remains the peer answering "I hold nothing").
422
+ if (failures.silentPeers?.has(peerId.toString())) {
423
+ throw new Error(`Peer ${peerId.toString()} is silent`);
424
+ }
425
+ const target = nodes.find(n => n.peerId.equals(peerId));
426
+ if (!target) return undefined;
427
+ const result = await target.storageRepo.get(
428
+ { blockIds: [blockId], context },
429
+ { skipClusterFetch: true } as any
430
+ );
431
+ const latest = result[blockId]?.state?.latest;
432
+ if (!latest) return undefined;
433
+ // The commit proof rides along exactly as it does in production, and through the SAME
434
+ // lookup `serveBlockArchive` uses (`servableProof`) rather than a hand-rolled one — a
435
+ // harness that attached proofs by its own rule would let every mesh-tier test exercise a
436
+ // certification path real peers do not have, or miss one they do. Production reads the
437
+ // proof out of the served archive; the harness reads the sibling's repo directly, so
438
+ // sharing the lookup is what keeps the two answers identical.
439
+ const proof = await servableProof(target.storageRepo, blockId, latest);
440
+ return proof ? { ...latest, proof } : latest;
441
+ };
442
+ // The node's own self-including (and partition-aware) key-network view, built in phase 1 —
443
+ // the SAME instance the member's admission derivation reads, matching real
444
+ // Libp2pKeyPeerNetwork behavior.
445
+ const nodeKeyNetwork = nodeKeyNetworkByPeer.get(node.peerId.toString())!;
446
+ const factory = coordinatorRepo(
447
+ nodeKeyNetwork,
448
+ createClusterClient,
449
+ // The SAME resolved policy the member above was built from, spread the way
450
+ // `libp2p-node-base` spreads it into its coordinator factory — carrying
451
+ // `repairCorroborationClusterSize` (the repair floor's yardstick, DEFAULT_CLUSTER_SIZE
452
+ // when the mesh declared nothing), the production `minAbsoluteClusterSize` (2, not the
453
+ // coordinator's own fallback of 3), and the `allowUnvalidatedSmallCluster` gate —
454
+ // ARMED (false) unless the mesh opted out at its call site.
455
+ { ...policy }
456
+ );
457
+ node.coordinatorRepo = factory({
458
+ storageRepo: node.storageRepo,
459
+ localCluster: node.clusterMember,
460
+ localPeerId: node.peerId,
461
+ clusterLatestCallback,
462
+ // The read path's transfer mechanism — the SAME instance the member uses on the commit
463
+ // path, mirroring how `libp2p-node-base` shares one `reconcileBlock` between both.
464
+ acquireBlockFromCohort: reconcileByPeer.get(node.peerId.toString())!
465
+ });
466
+ }
467
+
468
+ return { nodes, failures, keyNetwork };
469
+ }
470
+
471
+ /**
472
+ * The nodes the key network keeps OUT of `blockId`'s cohort — peers that receive none of the
473
+ * block's cluster traffic, and so hold none of its content until something repairs them.
474
+ *
475
+ * Peer ids are generated fresh per mesh, so which node is responsible for a given block is random
476
+ * from run to run: in a 3-node `responsibilityK: 1` mesh, `nodes[1]` is the block's sole responsible
477
+ * peer about a third of the time, and then it receives the writer's commit directly. A test that
478
+ * needs a genuinely non-responsible node has to ask the routing layer rather than assume an index.
479
+ */
480
+ export async function nonResponsibleNodes(mesh: Mesh, blockId: string): Promise<MeshNode[]> {
481
+ const cohort = await mesh.keyNetwork.findCluster(new TextEncoder().encode(blockId));
482
+ return mesh.nodes.filter(node => !(node.peerId.toString() in cohort));
483
+ }
484
+
485
+ export interface BuildTransactorOptions {
486
+ timeoutMs?: number;
487
+ abortOrCancelTimeoutMs?: number;
488
+ }
489
+
490
+ /**
491
+ * Builds a NetworkTransactor over a mesh. All nodes share the same mock
492
+ * infrastructure so a single transactor routes to every peer via `getRepo`.
493
+ * Suitable for solo-mesh tests; for multi-node tests prefer
494
+ * `buildNetworkTransactors` to label "which node is driving".
495
+ */
496
+ export const buildNetworkTransactor = (mesh: Mesh, options: BuildTransactorOptions = {}): ITransactor => {
497
+ const repoByPeer = new Map<string, IRepo>();
498
+ for (const node of mesh.nodes) {
499
+ repoByPeer.set(node.peerId.toString(), node.coordinatorRepo as unknown as IRepo);
500
+ }
501
+ return new NetworkTransactor({
502
+ timeoutMs: options.timeoutMs ?? 5_000,
503
+ abortOrCancelTimeoutMs: options.abortOrCancelTimeoutMs ?? 5_000,
504
+ keyNetwork: mesh.keyNetwork,
505
+ getRepo: (peerId: DbPeerId) => {
506
+ const repo = repoByPeer.get(peerId.toString());
507
+ if (!repo) throw new Error(`Unknown peer ${peerId.toString()}`);
508
+ return repo;
509
+ }
510
+ });
511
+ };
512
+
513
+ /**
514
+ * Builds one NetworkTransactor per mesh node, keyed by peer-id string. Each
515
+ * transactor shares the mesh's key network and peer→repo map — the separate
516
+ * instances exist so tests can semantically say "driven by node A".
517
+ */
518
+ export const buildNetworkTransactors = (mesh: Mesh, options: BuildTransactorOptions = {}): Map<string, ITransactor> => {
519
+ const transactors = new Map<string, ITransactor>();
520
+ for (const node of mesh.nodes) {
521
+ transactors.set(node.peerId.toString(), buildNetworkTransactor(mesh, options));
522
+ }
523
+ return transactors;
524
+ };