@optimystic/db-p2p 0.16.2 → 0.17.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 (70) hide show
  1. package/dist/src/cluster/block-transfer-service.d.ts +4 -1
  2. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  3. package/dist/src/cluster/block-transfer-service.js +11 -3
  4. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  5. package/dist/src/cluster/cluster-repo.d.ts +6 -2
  6. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  7. package/dist/src/cluster/cluster-repo.js +28 -4
  8. package/dist/src/cluster/cluster-repo.js.map +1 -1
  9. package/dist/src/cluster/quorum-restore.d.ts +42 -14
  10. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  11. package/dist/src/cluster/quorum-restore.js +0 -0
  12. package/dist/src/cluster/quorum-restore.js.map +1 -1
  13. package/dist/src/cluster/reconcile-block.d.ts +52 -0
  14. package/dist/src/cluster/reconcile-block.d.ts.map +1 -0
  15. package/dist/src/cluster/reconcile-block.js +113 -0
  16. package/dist/src/cluster/reconcile-block.js.map +1 -0
  17. package/dist/src/cluster/service.d.ts +4 -1
  18. package/dist/src/cluster/service.d.ts.map +1 -1
  19. package/dist/src/cluster/service.js +9 -1
  20. package/dist/src/cluster/service.js.map +1 -1
  21. package/dist/src/inbound-authorization.d.ts +111 -0
  22. package/dist/src/inbound-authorization.d.ts.map +1 -0
  23. package/dist/src/inbound-authorization.js +143 -0
  24. package/dist/src/inbound-authorization.js.map +1 -0
  25. package/dist/src/index.d.ts +1 -0
  26. package/dist/src/index.d.ts.map +1 -1
  27. package/dist/src/index.js +1 -0
  28. package/dist/src/index.js.map +1 -1
  29. package/dist/src/libp2p-node-base.d.ts +42 -0
  30. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  31. package/dist/src/libp2p-node-base.js +69 -70
  32. package/dist/src/libp2p-node-base.js.map +1 -1
  33. package/dist/src/repo/coordinator-repo.d.ts +83 -10
  34. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  35. package/dist/src/repo/coordinator-repo.js +176 -24
  36. package/dist/src/repo/coordinator-repo.js.map +1 -1
  37. package/dist/src/repo/service.d.ts +4 -1
  38. package/dist/src/repo/service.d.ts.map +1 -1
  39. package/dist/src/repo/service.js +9 -1
  40. package/dist/src/repo/service.js.map +1 -1
  41. package/dist/src/storage/block-storage.d.ts.map +1 -1
  42. package/dist/src/storage/block-storage.js +11 -0
  43. package/dist/src/storage/block-storage.js.map +1 -1
  44. package/dist/src/storage/storage-repo.d.ts +47 -0
  45. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  46. package/dist/src/storage/storage-repo.js +99 -9
  47. package/dist/src/storage/storage-repo.js.map +1 -1
  48. package/dist/src/sync/service.d.ts +4 -7
  49. package/dist/src/sync/service.d.ts.map +1 -1
  50. package/dist/src/sync/service.js +13 -10
  51. package/dist/src/sync/service.js.map +1 -1
  52. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  53. package/dist/src/testing/mesh-harness.js +41 -48
  54. package/dist/src/testing/mesh-harness.js.map +1 -1
  55. package/package.json +2 -2
  56. package/{README.md → readme.md} +19 -2
  57. package/src/cluster/block-transfer-service.ts +12 -5
  58. package/src/cluster/cluster-repo.ts +28 -4
  59. package/src/cluster/quorum-restore.ts +0 -0
  60. package/src/cluster/reconcile-block.ts +158 -0
  61. package/src/cluster/service.ts +10 -3
  62. package/src/inbound-authorization.ts +184 -0
  63. package/src/index.ts +1 -0
  64. package/src/libp2p-node-base.ts +116 -74
  65. package/src/repo/coordinator-repo.ts +224 -25
  66. package/src/repo/service.ts +10 -3
  67. package/src/storage/block-storage.ts +11 -0
  68. package/src/storage/storage-repo.ts +114 -9
  69. package/src/sync/service.ts +14 -12
  70. package/src/testing/mesh-harness.ts +40 -48
@@ -8,6 +8,7 @@ import { encodePeers, type RedirectPayload } from './redirect.js'
8
8
  import { MAX_BLOCK_MESSAGE_BYTES } from '../protocol-limits.js'
9
9
  import type { Uint8ArrayList } from 'uint8arraylist'
10
10
  import { createLogger } from '../logger.js'
11
+ import { createInboundStreamAuthorization, type InboundStreamAuthorization, type InboundStreamAuthorizationInit } from '../inbound-authorization.js'
11
12
 
12
13
  const debugLog = createLogger('repo-service')
13
14
 
@@ -38,7 +39,7 @@ export type RepoServiceComponents = BaseComponents & {
38
39
  libp2p?: Libp2p
39
40
  }
40
41
 
41
- export type RepoServiceInit = {
42
+ export type RepoServiceInit = InboundStreamAuthorizationInit & {
42
43
  protocol?: string,
43
44
  protocolPrefix?: string,
44
45
  maxInboundStreams?: number,
@@ -81,6 +82,8 @@ export class RepoService implements Startable {
81
82
  * self identity, and connection addrs through this explicitly-set reference.
82
83
  */
83
84
  private libp2pRef: Libp2p | undefined
85
+ /** Optional embedder authorization gate; `undefined` (the default) means no check runs. */
86
+ private readonly authorization: InboundStreamAuthorization | undefined
84
87
 
85
88
  constructor(components: RepoServiceComponents, init: RepoServiceInit = {}) {
86
89
  this.components = components
@@ -92,6 +95,7 @@ export class RepoService implements Startable {
92
95
  this.repo = components.repo
93
96
  this.running = false
94
97
  this.responsibilityK = init.responsibilityK ?? 1
98
+ this.authorization = createInboundStreamAuthorization(init, this.protocol, (msg, ...args) => this.log.error(msg, ...args))
95
99
  }
96
100
 
97
101
  readonly [Symbol.toStringTag] = '@libp2p/repo-service'
@@ -236,8 +240,8 @@ export class RepoService implements Startable {
236
240
  /**
237
241
  * Handle incoming streams on the repo protocol
238
242
  */
239
- private handleIncomingStream(stream: Stream, connection: Connection): void {
240
- const peerId = connection.remotePeer
243
+ private handleIncomingStream(stream: Stream, connection?: Connection): void {
244
+ const peerId = connection?.remotePeer
241
245
 
242
246
  const processStream = async function* (this: RepoService, source: AsyncIterable<Uint8ArrayList>) {
243
247
  for await (const msg of source) {
@@ -278,6 +282,9 @@ export class RepoService implements Startable {
278
282
 
279
283
  void (async () => {
280
284
  try {
285
+ // Authorization runs before ANY decoding or execution. Guarded on the field so a
286
+ // node without a predicate keeps the original path untouched.
287
+ if (this.authorization && await this.authorization.deny(stream, peerId?.toString())) return
281
288
  const responses = pipe(
282
289
  stream,
283
290
  (source) => lpDecode(source, { maxDataLength: MAX_BLOCK_MESSAGE_BYTES }),
@@ -36,6 +36,17 @@ export class BlockStorage implements IBlockStorage {
36
36
  async getBlock(rev?: number): Promise<{ block: IBlock, actionRev: ActionRev } | undefined> {
37
37
  const meta = await this.storage.getMetadata(this.blockId);
38
38
  if (!meta) {
39
+ // No metadata at all ⇒ this node has never seen the block, and reads report it absent
40
+ // WITHOUT consulting `restoreCallback`. That is deliberate, not an oversight: `restoreCallback`
41
+ // is reachable only from ensureRevision below, so a never-seen block is never fetched HERE.
42
+ // Attempting a fetch at this layer would turn every read of a genuinely non-existent block —
43
+ // the common case for an insert probing for a collision — into a network round trip, because
44
+ // storage cannot tell "nobody has this" from "I don't have this".
45
+ //
46
+ // The layer that CAN tell them apart makes that call instead: `CoordinatorRepo` acquires the
47
+ // block only once the cohort has corroborated a `(rev, actionId)` for it
48
+ // (`restoreCorroborated` → `acquireBlockFromCohort`), so an id no peer claims still costs
49
+ // nothing beyond the latest-query it already performed. Keep this early return as-is.
39
50
  return undefined;
40
51
  }
41
52
 
@@ -44,6 +44,37 @@ export async function withBlockCommitLatch<T>(blockId: BlockId, fn: () => Promis
44
44
  }
45
45
  }
46
46
 
47
+ /**
48
+ * Stable, greppable prefix on the failure reason a commit carries when this node cannot materialize
49
+ * the revision it was asked to record. It is a STRING marker rather than only an error class because
50
+ * {@link StorageRepo.commit} reports per-block faults as `StaleFailure.reason` (a plain string that
51
+ * also crosses the wire), so the class identity is lost by the time a caller inspects the result.
52
+ */
53
+ export const MISSING_BASE_REVISION_REASON = 'missing-base-revision';
54
+
55
+ /**
56
+ * This node was asked to commit revision N of a block it holds no materializable base for, so
57
+ * applying the transform would materialize nothing while `latest` advanced to N — a block that is
58
+ * then unreadable locally, unservable to peers, and that rejects every later write (see
59
+ * {@link StorageRepo.internalCommit}). The commit is refused instead; the caller heals the node
60
+ * out-of-band (`ClusterMember` pulls the committed revision from a cohort peer) and retries.
61
+ */
62
+ export class MissingBaseRevisionError extends Error {
63
+ constructor(readonly blockId: BlockId, readonly rev: number, detail: string) {
64
+ super(`${MISSING_BASE_REVISION_REASON}: block ${blockId} cannot materialize rev ${rev} — ${detail}`);
65
+ this.name = 'MissingBaseRevisionError';
66
+ }
67
+ }
68
+
69
+ /**
70
+ * True when a {@link CommitResult} failed because this node holds no materializable base for one of
71
+ * the committed blocks. Distinguishes that recoverable divergence (heal by fetching the block from a
72
+ * cohort peer) from a genuine storage fault, which must still propagate.
73
+ */
74
+ export function isMissingBaseRevisionFailure(result: CommitResult): boolean {
75
+ return !result.success && (result.reason?.startsWith(MISSING_BASE_REVISION_REASON) ?? false);
76
+ }
77
+
47
78
  export type StorageRepoOptions = {
48
79
  /** Optional hook to validate transactions in PendRequests */
49
80
  validatePend?: PendValidationHook;
@@ -185,14 +216,26 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
185
216
  // Sort a COPY: when `latest` is undefined, `missing` aliases the caller's
186
217
  // `context.committed` array, and an in-place `.sort()` would reorder the shared
187
218
  // request context under the caller's feet.
188
- for (const { actionId, rev } of [...missing].sort((a, b) => a.rev - b.rev)) {
189
- const pending = await blockStorage.getPendingTransaction(actionId);
190
- if (pending) {
191
- const collectionId = await this.internalCommit(blockId, actionId, rev, blockStorage);
192
- if (collectionId !== undefined) {
193
- promotions.push({ collectionId, blockId, actionId, rev });
219
+ try {
220
+ for (const { actionId, rev } of [...missing].sort((a, b) => a.rev - b.rev)) {
221
+ const pending = await blockStorage.getPendingTransaction(actionId);
222
+ if (pending) {
223
+ const collectionId = await this.internalCommit(blockId, actionId, rev, blockStorage);
224
+ if (collectionId !== undefined) {
225
+ promotions.push({ collectionId, blockId, actionId, rev });
226
+ }
194
227
  }
195
228
  }
229
+ } catch (err) {
230
+ // This node holds no materializable base for the block, so NO context revision
231
+ // can be promoted here (each builds on the one before). Leave `latest` where it
232
+ // is — the invariant internalCommit just enforced — and let the commit-path
233
+ // healing supply the content; a read must not fail for it. Every other fault
234
+ // still propagates.
235
+ if (!(err instanceof MissingBaseRevisionError)) {
236
+ throw err;
237
+ }
238
+ log('get:promote-skipped-missing-base blockId=%s rev=%d reason=%s', blockId, err.rev, err.message);
196
239
  }
197
240
  });
198
241
  }
@@ -639,14 +682,24 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
639
682
 
640
683
  // Get prior materialized block if it exists
641
684
  const latest = await storage.getLatest();
642
- const priorBlock = latest
643
- ? (await storage.getBlock(latest.rev))?.block
644
- : undefined;
685
+ const priorBlock = await this.readCommitBase(blockId, actionId, rev, storage, latest);
645
686
 
646
687
  // Apply transform and save materialized block
647
688
  // applyTransform handles undefined priorBlock correctly for inserts
648
689
  const newBlock = applyTransform(priorBlock, transform);
649
690
 
691
+ // INVARIANT: `latest` must never advance past a revision this node can materialize.
692
+ // `applyTransform` silently drops `updates` when there is no block to apply them to, so a
693
+ // member that missed the block's CREATING revision would otherwise record rev N while storing
694
+ // nothing to serve it from. `latest === undefined` is precisely the "nothing below to fall
695
+ // back to" case: materializeBlock's descending walk needs some materialization at or below the
696
+ // target, and with no prior revision there is none. With a prior `latest` an absent newBlock is
697
+ // a legitimate tombstone (the walk resolves to an earlier materialization), so it stays allowed.
698
+ if (!newBlock && latest === undefined) {
699
+ return await this.refuseMissingBase(blockId, actionId, rev, storage,
700
+ 'no committed revision to apply the transform to');
701
+ }
702
+
650
703
  if (newBlock) {
651
704
  await storage.saveMaterializedBlock(actionId, newBlock);
652
705
  }
@@ -682,6 +735,58 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
682
735
  // undefined so the caller skips it rather than emitting a bogus event.
683
736
  return newBlock?.header.collectionId ?? priorBlock?.header.collectionId;
684
737
  }
738
+
739
+ /**
740
+ * The materialization this commit builds on: the block at `latest`, or `undefined` when the block
741
+ * holds no committed revision yet (the normal insert case).
742
+ *
743
+ * `getBlock` THROWS when this node holds a `latest` it cannot materialize — a block already wedged
744
+ * by a pre-fix commit, or by truncated history. That is the same divergence as having no base at
745
+ * all, so it is translated into {@link MissingBaseRevisionError} rather than surfacing as an opaque
746
+ * storage fault: the healing path can then repair the block instead of the fault resetting the
747
+ * cluster stream, and a wedged node recovers on the next write touching the block.
748
+ */
749
+ private async readCommitBase(
750
+ blockId: BlockId,
751
+ actionId: ActionId,
752
+ rev: number,
753
+ storage: IBlockStorage,
754
+ latest: ActionRev | undefined
755
+ ): Promise<IBlock | undefined> {
756
+ if (!latest) {
757
+ return undefined;
758
+ }
759
+ try {
760
+ return (await storage.getBlock(latest.rev))?.block;
761
+ } catch (err) {
762
+ log('commit:unmaterializable-base blockId=%s baseRev=%d error=%s', blockId, latest.rev,
763
+ err instanceof Error ? err.message : String(err));
764
+ return await this.refuseMissingBase(blockId, actionId, rev, storage,
765
+ `local rev ${latest.rev} is not materializable here`);
766
+ }
767
+ }
768
+
769
+ /**
770
+ * Refuse a commit this node cannot materialize. Always throws {@link MissingBaseRevisionError};
771
+ * nothing durable has been written at this point, so the block is left exactly as it was minus the
772
+ * pending record.
773
+ *
774
+ * The pending is dropped because it can never be promoted here: promotion needs a base this node
775
+ * must obtain out-of-band, and once the healing path lands that revision `latest` is already >= rev,
776
+ * so a commit retry partitions the block as already-done/stale and never revisits the pending.
777
+ * Leaving it would also report a phantom conflicting action from {@link pend} for every later write.
778
+ */
779
+ private async refuseMissingBase(
780
+ blockId: BlockId,
781
+ actionId: ActionId,
782
+ rev: number,
783
+ storage: IBlockStorage,
784
+ detail: string
785
+ ): Promise<never> {
786
+ await storage.deletePendingTransaction(actionId);
787
+ log('commit:missing-base blockId=%s rev=%d actionId=%s detail=%s', blockId, rev, actionId, detail);
788
+ throw new MissingBaseRevisionError(blockId, rev, detail);
789
+ }
685
790
  }
686
791
 
687
792
  /**
@@ -1,4 +1,4 @@
1
- import type { ComponentLogger, Startable } from '@libp2p/interface';
1
+ import type { ComponentLogger, Connection, Startable } from '@libp2p/interface';
2
2
  import type { IRepo } from '@optimystic/db-core';
3
3
  import { buildSyncProtocol, type SyncRequest, type SyncResponse } from './protocol.js';
4
4
  import { pipe } from 'it-pipe';
@@ -6,8 +6,9 @@ import { toString as u8ToString } from 'uint8arrays/to-string';
6
6
  import * as lp from 'it-length-prefixed';
7
7
  import { MAX_CONTROL_MESSAGE_BYTES } from '../protocol-limits.js';
8
8
  import type { Uint8ArrayList } from 'uint8arraylist';
9
+ import { createInboundStreamAuthorization, type InboundStreamAuthorization, type InboundStreamAuthorizationInit } from '../inbound-authorization.js';
9
10
 
10
- export interface SyncServiceInit {
11
+ export interface SyncServiceInit extends InboundStreamAuthorizationInit {
11
12
  protocolPrefix?: string;
12
13
  }
13
14
 
@@ -35,6 +36,8 @@ export class SyncService implements Startable {
35
36
  private readonly protocol: string;
36
37
  private readonly repo: IRepo;
37
38
  private readonly registrar: { handle: (...args: any[]) => Promise<void>, unhandle: (...args: any[]) => Promise<void> };
39
+ /** Optional embedder authorization gate; `undefined` (the default) means no check runs. */
40
+ private readonly authorization: InboundStreamAuthorization | undefined;
38
41
 
39
42
  constructor(
40
43
  components: SyncServiceComponents,
@@ -44,6 +47,7 @@ export class SyncService implements Startable {
44
47
  this.protocol = buildSyncProtocol(init.protocolPrefix ?? '');
45
48
  this.repo = components.repo;
46
49
  this.registrar = components.registrar;
50
+ this.authorization = createInboundStreamAuthorization(init, this.protocol, (msg, ...args) => this.log.error(msg, ...args));
47
51
  }
48
52
 
49
53
  async start(): Promise<void> {
@@ -62,21 +66,22 @@ export class SyncService implements Startable {
62
66
  this.log('Sync service stopped');
63
67
  }
64
68
 
65
- /**
66
- * Handle an incoming sync request stream.
67
- * Uses a streaming pipeline (like the repo service) to process the
68
- * first request and yield a response without waiting for the client
69
- * to close its write side — avoids a read/write deadlock.
70
- */
71
69
  /**
72
70
  * Handle an incoming sync request stream.
73
71
  * Uses a streaming pipeline (like the repo service) to process the
74
72
  * request and yield a response immediately — avoids a read/write deadlock.
75
73
  */
76
- private handleSyncRequest(stream: any): void {
74
+ private handleSyncRequest(stream: any, connection?: Connection): void {
77
75
  const self = this;
76
+ // The registrar handler receives the stream (or, in an older shape, a `{ stream }`
77
+ // wrapper) directly; resolve it once so the authorization abort, the pipeline and the
78
+ // error path all act on the same object.
79
+ const actualStream = stream.stream ?? stream;
78
80
  void (async () => {
79
81
  try {
82
+ // Authorization runs before ANY decoding or execution. Guarded on the field so a
83
+ // node without a predicate keeps the original path untouched.
84
+ if (self.authorization && await self.authorization.deny(actualStream, connection?.remotePeer?.toString())) return;
80
85
  const processStream = async function* (source: AsyncIterable<Uint8ArrayList> | Iterable<Uint8ArrayList>) {
81
86
  for await (const msg of source) {
82
87
  const json = u8ToString(msg.subarray(), 'utf8');
@@ -111,8 +116,6 @@ export class SyncService implements Startable {
111
116
  };
112
117
 
113
118
  // Use the same streaming pipeline pattern as the repo service.
114
- // The registrar handler receives the stream (or data object) directly.
115
- const actualStream = stream.stream ?? stream;
116
119
  const responses = pipe(
117
120
  actualStream,
118
121
  (source: any) => lp.decode(source, { maxDataLength: MAX_CONTROL_MESSAGE_BYTES }),
@@ -125,7 +128,6 @@ export class SyncService implements Startable {
125
128
  await actualStream.close();
126
129
  } catch (error) {
127
130
  self.log.error('Error handling sync request:', error);
128
- const actualStream = stream.stream ?? stream;
129
131
  try { actualStream.abort(error instanceof Error ? error : new Error(String(error))); } catch { /* ignore */ }
130
132
  }
131
133
  })();
@@ -107,6 +107,32 @@ export interface Mesh {
107
107
  keyNetwork: IKeyNetwork;
108
108
  }
109
109
 
110
+ /**
111
+ * Pull a block's committed content from a sibling cohort node into `storageRepo` — the mesh analogue
112
+ * of `libp2p-node-base`'s `createReconcileBlock` (SyncClient archive fetch + `saveReplicatedBlock`).
113
+ *
114
+ * Deliberately shared by BOTH callers, exactly as the live node shares one callback between them: the
115
+ * commit path (`clusterMember.reconcileBlock`, for a block committed without a materializable base)
116
+ * and the read path (`CoordinatorRepo.acquireBlockFromCohort`, for a corroborated revision the reader
117
+ * cannot promote locally). Stateless, so it is simply rebuilt per caller.
118
+ *
119
+ * `nodes` is captured by reference and is fully populated by the time either caller invokes it.
120
+ */
121
+ const makeReconcileBlock = (nodes: MeshNode[], selfPeerId: string, storageRepo: StorageRepo): ReconcileBlockCallback =>
122
+ async (blockId, committed, cohortPeerIds) => {
123
+ for (const peerIdStr of cohortPeerIds) {
124
+ if (peerIdStr === selfPeerId) continue;
125
+ const target = nodes.find(n => n.peerId.toString() === peerIdStr);
126
+ if (!target) continue;
127
+ const result = await target.storageRepo.get({ blockIds: [blockId] }, { skipClusterFetch: true } as any);
128
+ const entry = result[blockId];
129
+ const latest = entry?.state?.latest;
130
+ if (!latest || !entry?.block || latest.rev < committed.rev) continue;
131
+ await storageRepo.saveReplicatedBlock(blockId, entry.block, latest);
132
+ return;
133
+ }
134
+ };
135
+
110
136
  /**
111
137
  * Creates N interconnected mesh nodes with real components and mock transport.
112
138
  * ClusterClient calls route directly to target ClusterMember instances.
@@ -125,8 +151,6 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
125
151
  // Build nodes array (partially — coordinatorRepo added after keyNetwork is ready)
126
152
  const nodes: MeshNode[] = [];
127
153
  const peerNetwork = new MockPeerNetwork();
128
- // Map peerId → rawStorage for data sync simulation in clusterLatestCallback
129
- const rawStorages = new Map<string, IRawStorage>();
130
154
 
131
155
  // Phase 1: create storage + cluster members
132
156
  let nodeIndex = 0;
@@ -134,7 +158,6 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
134
158
  const rawStorage = options.rawStorageFactory
135
159
  ? options.rawStorageFactory(nodeIndex)
136
160
  : new MemoryRawStorage();
137
- rawStorages.set(peerId.toString(), rawStorage);
138
161
  nodeIndex++;
139
162
  const storageRepo = new StorageRepo(
140
163
  (blockId: BlockId) => new BlockStorage(blockId, rawStorage)
@@ -152,24 +175,9 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
152
175
  allowUnvalidatedSmallCluster: true
153
176
  };
154
177
 
155
- // Active reconciliation simulation: when a member commits a block it never
156
- // pended (cohort drift), pull the committed revision from a sibling cohort node
157
- // that holds it and persist it locally — the mesh analogue of the SyncClient
158
- // fetch + saveReplicatedBlock path wired in `libp2p-node-base`. `nodes` is captured
159
- // by reference and fully populated by the time the callback is actually invoked.
160
- const reconcileBlock: ReconcileBlockCallback = async (blockId, committed, cohortPeerIds) => {
161
- for (const peerIdStr of cohortPeerIds) {
162
- if (peerIdStr === peerId.toString()) continue;
163
- const target = nodes.find(n => n.peerId.toString() === peerIdStr);
164
- if (!target) continue;
165
- const result = await target.storageRepo.get({ blockIds: [blockId] }, { skipClusterFetch: true } as any);
166
- const entry = result[blockId];
167
- const latest = entry?.state?.latest;
168
- if (!latest || !entry?.block || latest.rev < committed.rev) continue;
169
- await storageRepo.saveReplicatedBlock(blockId, entry.block, latest);
170
- return;
171
- }
172
- };
178
+ // Active reconciliation: when a member commits a block it never pended (cohort drift),
179
+ // pull the committed revision from a sibling cohort node that holds it.
180
+ const reconcileBlock = makeReconcileBlock(nodes, peerId.toString(), storageRepo);
173
181
 
174
182
  const member = clusterMember({
175
183
  storageRepo,
@@ -208,10 +216,12 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
208
216
  };
209
217
 
210
218
  for (const node of nodes) {
211
- const localRawStorage = rawStorages.get(node.peerId.toString())!;
212
-
213
- // Per-node callback: queries remote peer and replicates committed data locally
214
- // (simulates what SyncClient does in production)
219
+ // Per-node callback: reports the queried peer's latest revision, and NOTHING else. It used to
220
+ // also write the peer's block into local storage ("simulate data sync"), which made every
221
+ // read-repair assertion on this harness observe a convergence the production callback does not
222
+ // provide masking exactly the defect that ticket `read-repair-cannot-transfer-block-content`
223
+ // existed to expose. Transfer now happens where it does in production: through
224
+ // `acquireBlockFromCohort` below, gated on a corroborated revision.
215
225
  const clusterLatestCallback: ClusterLatestCallback = async (peerId: PeerId, blockId: BlockId, context?): Promise<ActionRev | undefined> => {
216
226
  const target = nodes.find(n => n.peerId.equals(peerId));
217
227
  if (!target) return undefined;
@@ -219,28 +229,7 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
219
229
  { blockIds: [blockId], context },
220
230
  { skipClusterFetch: true } as any
221
231
  );
222
- const entry = result[blockId];
223
- const latest = entry?.state?.latest;
224
-
225
- // Simulate data sync: replicate committed block data to local storage
226
- if (latest && entry?.block) {
227
- const localBlockStorage = new BlockStorage(blockId as BlockId, localRawStorage);
228
- const localLatest = await localBlockStorage.getLatest();
229
- if (!localLatest || localLatest.rev < latest.rev) {
230
- // Ensure metadata exists
231
- const meta = await localRawStorage.getMetadata(blockId as BlockId);
232
- if (!meta) {
233
- // Seed empty ranges (honest: nothing reconstructible yet); the setLatest
234
- // below merges [latest.rev, latest.rev+1] once the revision is persisted.
235
- await localRawStorage.saveMetadata(blockId as BlockId, { latest: undefined, ranges: [] });
236
- }
237
- await localBlockStorage.saveMaterializedBlock(latest.actionId, entry.block);
238
- await localBlockStorage.saveRevision(latest.rev, latest.actionId);
239
- await localBlockStorage.setLatest(latest);
240
- }
241
- }
242
-
243
- return latest;
232
+ return result[blockId]?.state?.latest;
244
233
  };
245
234
  // Wrap key network to include self in findCluster (matches real Libp2pKeyPeerNetwork behavior)
246
235
  const nodeKeyNetwork: IKeyNetwork = {
@@ -273,7 +262,10 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
273
262
  storageRepo: node.storageRepo,
274
263
  localCluster: node.clusterMember,
275
264
  localPeerId: node.peerId,
276
- clusterLatestCallback
265
+ clusterLatestCallback,
266
+ // The read path's transfer mechanism — the same callback the member uses on the commit path,
267
+ // mirroring how `libp2p-node-base` shares one `reconcileBlock` between both.
268
+ acquireBlockFromCohort: makeReconcileBlock(nodes, node.peerId.toString(), node.storageRepo)
277
269
  });
278
270
  }
279
271