@optimystic/db-p2p 0.11.1 → 0.11.3

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 (33) hide show
  1. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  2. package/dist/src/repo/coordinator-repo.js +31 -8
  3. package/dist/src/repo/coordinator-repo.js.map +1 -1
  4. package/dist/src/storage/block-storage.d.ts +4 -0
  5. package/dist/src/storage/block-storage.d.ts.map +1 -1
  6. package/dist/src/storage/block-storage.js +35 -3
  7. package/dist/src/storage/block-storage.js.map +1 -1
  8. package/dist/src/storage/i-block-storage.d.ts +24 -1
  9. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  10. package/dist/src/storage/memory-storage.d.ts +17 -0
  11. package/dist/src/storage/memory-storage.d.ts.map +1 -1
  12. package/dist/src/storage/memory-storage.js +20 -2
  13. package/dist/src/storage/memory-storage.js.map +1 -1
  14. package/dist/src/storage/storage-repo.d.ts +8 -0
  15. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  16. package/dist/src/storage/storage-repo.js +44 -13
  17. package/dist/src/storage/storage-repo.js.map +1 -1
  18. package/dist/src/testing/index.d.ts +2 -0
  19. package/dist/src/testing/index.d.ts.map +1 -0
  20. package/dist/src/testing/index.js +2 -0
  21. package/dist/src/testing/index.js.map +1 -0
  22. package/dist/src/testing/mesh-harness.d.ts +60 -0
  23. package/dist/src/testing/mesh-harness.d.ts.map +1 -0
  24. package/dist/src/testing/mesh-harness.js +220 -0
  25. package/dist/src/testing/mesh-harness.js.map +1 -0
  26. package/package.json +8 -3
  27. package/src/repo/coordinator-repo.ts +31 -9
  28. package/src/storage/block-storage.ts +38 -3
  29. package/src/storage/i-block-storage.ts +22 -1
  30. package/src/storage/memory-storage.ts +20 -2
  31. package/src/storage/storage-repo.ts +46 -13
  32. package/src/testing/index.ts +1 -0
  33. package/src/testing/mesh-harness.ts +294 -0
@@ -0,0 +1,60 @@
1
+ import type { PeerId, PrivateKey } from '@libp2p/interface';
2
+ import type { IKeyNetwork, ITransactor } from '@optimystic/db-core';
3
+ import { ClusterMember } from '../cluster/cluster-repo.js';
4
+ import { StorageRepo } from '../storage/storage-repo.js';
5
+ import type { IRawStorage } from '../storage/i-raw-storage.js';
6
+ import type { CoordinatorRepo } from '../repo/coordinator-repo.js';
7
+ export interface MeshNode {
8
+ peerId: PeerId;
9
+ privateKey: PrivateKey;
10
+ storageRepo: StorageRepo;
11
+ clusterMember: ClusterMember;
12
+ coordinatorRepo: CoordinatorRepo;
13
+ }
14
+ export interface MeshOptions {
15
+ responsibilityK: number;
16
+ clusterSize?: number;
17
+ superMajorityThreshold?: number;
18
+ allowClusterDownsize?: boolean;
19
+ /**
20
+ * Optional per-node raw-storage factory. Invoked once per node (indexed from 0)
21
+ * to supply the IRawStorage that backs StorageRepo. If omitted, each node gets
22
+ * a fresh `MemoryRawStorage`. Used by fault-injection tests to wrap the store
23
+ * with a crashing proxy, or by restart tests to rebuild over preserved state.
24
+ */
25
+ rawStorageFactory?: (index: number) => IRawStorage;
26
+ }
27
+ export interface MeshFailureConfig {
28
+ /** Peers that should fail on cluster update (simulate unreachable) */
29
+ failingPeers?: Set<string>;
30
+ /** Make findCluster return empty (simulate DHT failure) */
31
+ findClusterFails?: boolean;
32
+ }
33
+ export interface Mesh {
34
+ nodes: MeshNode[];
35
+ failures: MeshFailureConfig;
36
+ keyNetwork: IKeyNetwork;
37
+ }
38
+ /**
39
+ * Creates N interconnected mesh nodes with real components and mock transport.
40
+ * ClusterClient calls route directly to target ClusterMember instances.
41
+ */
42
+ export declare function createMesh(nodeCount: number, options: MeshOptions): Promise<Mesh>;
43
+ export interface BuildTransactorOptions {
44
+ timeoutMs?: number;
45
+ abortOrCancelTimeoutMs?: number;
46
+ }
47
+ /**
48
+ * Builds a NetworkTransactor over a mesh. All nodes share the same mock
49
+ * infrastructure so a single transactor routes to every peer via `getRepo`.
50
+ * Suitable for solo-mesh tests; for multi-node tests prefer
51
+ * `buildNetworkTransactors` to label "which node is driving".
52
+ */
53
+ export declare const buildNetworkTransactor: (mesh: Mesh, options?: BuildTransactorOptions) => ITransactor;
54
+ /**
55
+ * Builds one NetworkTransactor per mesh node, keyed by peer-id string. Each
56
+ * transactor shares the mesh's key network and peer→repo map — the separate
57
+ * instances exist so tests can semantically say "driven by node A".
58
+ */
59
+ export declare const buildNetworkTransactors: (mesh: Mesh, options?: BuildTransactorOptions) => Map<string, ITransactor>;
60
+ //# sourceMappingURL=mesh-harness.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mesh-harness.d.ts","sourceRoot":"","sources":["../../../src/testing/mesh-harness.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAC5D,OAAO,KAAK,EAAE,WAAW,EAA4F,WAAW,EAAsB,MAAM,qBAAqB,CAAC;AAMlL,OAAO,EAAE,aAAa,EAAiB,MAAM,4BAA4B,CAAC;AAC1E,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAGzD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,6BAA6B,CAAC;AAE/D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,6BAA6B,CAAC;AAInE,MAAM,WAAW,QAAQ;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,UAAU,CAAC;IACvB,WAAW,EAAE,WAAW,CAAC;IACzB,aAAa,EAAE,aAAa,CAAC;IAC7B,eAAe,EAAE,eAAe,CAAC;CACjC;AAED,MAAM,WAAW,WAAW;IAC3B,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,WAAW,CAAC;CACnD;AAED,MAAM,WAAW,iBAAiB;IACjC,sEAAsE;IACtE,YAAY,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC3B,2DAA2D;IAC3D,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC3B;AA2DD,MAAM,WAAW,IAAI;IACpB,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,QAAQ,EAAE,iBAAiB,CAAC;IAC5B,UAAU,EAAE,WAAW,CAAC;CACxB;AAED;;;GAGG;AACH,wBAAsB,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CA2IvF;AAED,MAAM,WAAW,sBAAsB;IACtC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,sBAAsB,CAAC,EAAE,MAAM,CAAC;CAChC;AAED;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,GAAI,MAAM,IAAI,EAAE,UAAS,sBAA2B,KAAG,WAezF,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,GAAI,MAAM,IAAI,EAAE,UAAS,sBAA2B,KAAG,GAAG,CAAC,MAAM,EAAE,WAAW,CAMjH,CAAC"}
@@ -0,0 +1,220 @@
1
+ import { NetworkTransactor } from '@optimystic/db-core';
2
+ import { peerIdFromPrivateKey } from '@libp2p/peer-id';
3
+ import { generateKeyPair } from '@libp2p/crypto/keys';
4
+ import { clusterMember } from '../cluster/cluster-repo.js';
5
+ import { StorageRepo } from '../storage/storage-repo.js';
6
+ import { MemoryRawStorage } from '../storage/memory-storage.js';
7
+ import { BlockStorage } from '../storage/block-storage.js';
8
+ import { coordinatorRepo } from '../repo/coordinator-repo.js';
9
+ import { sortPeersByDistance } from '../routing/responsibility.js';
10
+ import { toString as u8ToString } from 'uint8arrays';
11
+ class MockPeerNetwork {
12
+ async connect(_peerId, _protocol) {
13
+ return {};
14
+ }
15
+ }
16
+ /**
17
+ * Mock IKeyNetwork that returns peers based on XOR distance.
18
+ * With responsibilityK >= nodeCount, all nodes are returned.
19
+ * Otherwise, K-nearest by XOR distance are returned.
20
+ */
21
+ class MockMeshKeyNetwork {
22
+ nodes;
23
+ responsibilityK;
24
+ failures;
25
+ constructor(nodes, responsibilityK, failures = {}) {
26
+ this.nodes = nodes;
27
+ this.responsibilityK = responsibilityK;
28
+ this.failures = failures;
29
+ }
30
+ async findCoordinator(key, options) {
31
+ const excluded = new Set((options?.excludedPeers ?? []).map(p => p.toString()));
32
+ const sorted = this.sortedByDistance(key);
33
+ const pick = sorted.find(n => !excluded.has(n.peerId.toString()));
34
+ if (!pick) {
35
+ throw new Error('No coordinator available for key (all candidates excluded)');
36
+ }
37
+ return pick.peerId;
38
+ }
39
+ async findCluster(key) {
40
+ if (this.failures.findClusterFails) {
41
+ return {};
42
+ }
43
+ const sorted = this.sortedByDistance(key);
44
+ const k = Math.min(this.responsibilityK, sorted.length);
45
+ const selected = sorted.slice(0, k);
46
+ const peers = {};
47
+ for (const node of selected) {
48
+ peers[node.peerId.toString()] = {
49
+ multiaddrs: ['/ip4/127.0.0.1/tcp/8000'],
50
+ publicKey: u8ToString(node.peerId.publicKey.raw, 'base64url')
51
+ };
52
+ }
53
+ return peers;
54
+ }
55
+ sortedByDistance(key) {
56
+ const knownPeers = this.nodes.map(n => ({
57
+ id: n.peerId,
58
+ addrs: ['/ip4/127.0.0.1/tcp/8000']
59
+ }));
60
+ const sorted = sortPeersByDistance(knownPeers, key);
61
+ return sorted.map(kp => this.nodes.find(n => n.peerId.equals(kp.id)));
62
+ }
63
+ }
64
+ /**
65
+ * Creates N interconnected mesh nodes with real components and mock transport.
66
+ * ClusterClient calls route directly to target ClusterMember instances.
67
+ */
68
+ export async function createMesh(nodeCount, options) {
69
+ const failures = {};
70
+ // Generate key pairs for all nodes
71
+ const keyPairs = await Promise.all(Array.from({ length: nodeCount }, async () => {
72
+ const privateKey = await generateKeyPair('Ed25519');
73
+ return { peerId: peerIdFromPrivateKey(privateKey), privateKey };
74
+ }));
75
+ // Build nodes array (partially — coordinatorRepo added after keyNetwork is ready)
76
+ const nodes = [];
77
+ const peerNetwork = new MockPeerNetwork();
78
+ // Map peerId → rawStorage for data sync simulation in clusterLatestCallback
79
+ const rawStorages = new Map();
80
+ // Phase 1: create storage + cluster members
81
+ let nodeIndex = 0;
82
+ for (const { peerId, privateKey } of keyPairs) {
83
+ const rawStorage = options.rawStorageFactory
84
+ ? options.rawStorageFactory(nodeIndex)
85
+ : new MemoryRawStorage();
86
+ rawStorages.set(peerId.toString(), rawStorage);
87
+ nodeIndex++;
88
+ const storageRepo = new StorageRepo((blockId) => new BlockStorage(blockId, rawStorage));
89
+ const consensusConfig = {
90
+ superMajorityThreshold: options.superMajorityThreshold ?? 0.75,
91
+ simpleMajorityThreshold: 0.51,
92
+ minAbsoluteClusterSize: 2,
93
+ allowClusterDownsize: options.allowClusterDownsize ?? true,
94
+ clusterSizeTolerance: 0.5,
95
+ partitionDetectionWindow: 60000
96
+ };
97
+ const member = clusterMember({
98
+ storageRepo,
99
+ peerNetwork,
100
+ peerId,
101
+ privateKey,
102
+ consensusConfig
103
+ });
104
+ nodes.push({
105
+ peerId,
106
+ privateKey,
107
+ storageRepo,
108
+ clusterMember: member,
109
+ coordinatorRepo: undefined // filled in phase 2
110
+ });
111
+ }
112
+ // Phase 2: create key network and coordinator repos (needs all nodes for routing)
113
+ const keyNetwork = new MockMeshKeyNetwork(nodes, options.responsibilityK, failures);
114
+ const createClusterClient = (targetPeerId) => {
115
+ const target = nodes.find(n => n.peerId.equals(targetPeerId));
116
+ if (!target) {
117
+ throw new Error(`Unknown peer: ${targetPeerId.toString()}`);
118
+ }
119
+ return {
120
+ async update(record) {
121
+ if (failures.failingPeers?.has(targetPeerId.toString())) {
122
+ throw new Error(`Peer ${targetPeerId.toString()} is unreachable`);
123
+ }
124
+ return target.clusterMember.update(record);
125
+ }
126
+ };
127
+ };
128
+ for (const node of nodes) {
129
+ const localRawStorage = rawStorages.get(node.peerId.toString());
130
+ // Per-node callback: queries remote peer and replicates committed data locally
131
+ // (simulates what SyncClient does in production)
132
+ const clusterLatestCallback = async (peerId, blockId, context) => {
133
+ const target = nodes.find(n => n.peerId.equals(peerId));
134
+ if (!target)
135
+ return undefined;
136
+ const result = await target.storageRepo.get({ blockIds: [blockId], context }, { skipClusterFetch: true });
137
+ const entry = result[blockId];
138
+ const latest = entry?.state?.latest;
139
+ // Simulate data sync: replicate committed block data to local storage
140
+ if (latest && entry?.block) {
141
+ const localBlockStorage = new BlockStorage(blockId, localRawStorage);
142
+ const localLatest = await localBlockStorage.getLatest();
143
+ if (!localLatest || localLatest.rev < latest.rev) {
144
+ // Ensure metadata exists
145
+ const meta = await localRawStorage.getMetadata(blockId);
146
+ if (!meta) {
147
+ await localRawStorage.saveMetadata(blockId, { latest: undefined, ranges: [[0]] });
148
+ }
149
+ await localBlockStorage.saveMaterializedBlock(latest.actionId, entry.block);
150
+ await localBlockStorage.saveRevision(latest.rev, latest.actionId);
151
+ await localBlockStorage.setLatest(latest);
152
+ }
153
+ }
154
+ return latest;
155
+ };
156
+ // Wrap key network to include self in findCluster (matches real Libp2pKeyPeerNetwork behavior)
157
+ const nodeKeyNetwork = {
158
+ findCoordinator: (key, opts) => keyNetwork.findCoordinator(key, opts),
159
+ async findCluster(key) {
160
+ const peers = await keyNetwork.findCluster(key);
161
+ const selfStr = node.peerId.toString();
162
+ if (!(selfStr in peers)) {
163
+ peers[selfStr] = {
164
+ multiaddrs: ['/ip4/127.0.0.1/tcp/8000'],
165
+ publicKey: u8ToString(node.peerId.publicKey.raw, 'base64url')
166
+ };
167
+ }
168
+ return peers;
169
+ }
170
+ };
171
+ const factory = coordinatorRepo(nodeKeyNetwork, (peerId) => createClusterClient(peerId), {
172
+ clusterSize: options.clusterSize ?? nodeCount,
173
+ superMajorityThreshold: options.superMajorityThreshold ?? 0.75,
174
+ allowClusterDownsize: options.allowClusterDownsize ?? true
175
+ });
176
+ node.coordinatorRepo = factory({
177
+ storageRepo: node.storageRepo,
178
+ localCluster: node.clusterMember,
179
+ localPeerId: node.peerId,
180
+ clusterLatestCallback
181
+ });
182
+ }
183
+ return { nodes, failures, keyNetwork };
184
+ }
185
+ /**
186
+ * Builds a NetworkTransactor over a mesh. All nodes share the same mock
187
+ * infrastructure so a single transactor routes to every peer via `getRepo`.
188
+ * Suitable for solo-mesh tests; for multi-node tests prefer
189
+ * `buildNetworkTransactors` to label "which node is driving".
190
+ */
191
+ export const buildNetworkTransactor = (mesh, options = {}) => {
192
+ const repoByPeer = new Map();
193
+ for (const node of mesh.nodes) {
194
+ repoByPeer.set(node.peerId.toString(), node.coordinatorRepo);
195
+ }
196
+ return new NetworkTransactor({
197
+ timeoutMs: options.timeoutMs ?? 5_000,
198
+ abortOrCancelTimeoutMs: options.abortOrCancelTimeoutMs ?? 5_000,
199
+ keyNetwork: mesh.keyNetwork,
200
+ getRepo: (peerId) => {
201
+ const repo = repoByPeer.get(peerId.toString());
202
+ if (!repo)
203
+ throw new Error(`Unknown peer ${peerId.toString()}`);
204
+ return repo;
205
+ }
206
+ });
207
+ };
208
+ /**
209
+ * Builds one NetworkTransactor per mesh node, keyed by peer-id string. Each
210
+ * transactor shares the mesh's key network and peer→repo map — the separate
211
+ * instances exist so tests can semantically say "driven by node A".
212
+ */
213
+ export const buildNetworkTransactors = (mesh, options = {}) => {
214
+ const transactors = new Map();
215
+ for (const node of mesh.nodes) {
216
+ transactors.set(node.peerId.toString(), buildNetworkTransactor(mesh, options));
217
+ }
218
+ return transactors;
219
+ };
220
+ //# sourceMappingURL=mesh-harness.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mesh-harness.js","sourceRoot":"","sources":["../../../src/testing/mesh-harness.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optimystic/db-p2p",
3
- "version": "0.11.1",
3
+ "version": "0.11.3",
4
4
  "type": "module",
5
5
  "description": "P2P database functionality for Optimystic",
6
6
  "main": "dist/src/index.js",
@@ -23,6 +23,10 @@
23
23
  "./rn": {
24
24
  "types": "./dist/src/rn.d.ts",
25
25
  "import": "./dist/src/rn.js"
26
+ },
27
+ "./testing": {
28
+ "types": "./dist/src/testing/index.d.ts",
29
+ "import": "./dist/src/testing/index.js"
26
30
  }
27
31
  },
28
32
  "repository": {
@@ -46,7 +50,8 @@
46
50
  "clean": "rimraf dist",
47
51
  "build": "tsc",
48
52
  "test": "node --import ./register.mjs node_modules/mocha/bin/mocha.js \"test/**/*.spec.ts\" --colors --reporter min",
49
- "test:verbose": "node --import ./register.mjs node_modules/mocha/bin/mocha.js \"test/**/*.spec.ts\" --colors --reporter spec"
53
+ "test:verbose": "node --import ./register.mjs node_modules/mocha/bin/mocha.js \"test/**/*.spec.ts\" --colors --reporter spec",
54
+ "test:integration": "OPTIMYSTIC_INTEGRATION=1 node --import ./register.mjs node_modules/mocha/bin/mocha.js \"test/**/*.integration.spec.ts\" --colors --reporter spec"
50
55
  },
51
56
  "devDependencies": {
52
57
  "@types/chai": "^5.2.3",
@@ -74,7 +79,7 @@
74
79
  "@libp2p/tcp": "^11.0.10",
75
80
  "@libp2p/websockets": "^10.1.3",
76
81
  "@multiformats/multiaddr": "^13.0.1",
77
- "@optimystic/db-core": "^0.11.1",
82
+ "@optimystic/db-core": "^0.11.3",
78
83
  "async-mutex": "^0.5.0",
79
84
  "debug": "^4.4.3",
80
85
  "it-all": "^3.0.9",
@@ -1,5 +1,5 @@
1
- import type { PendRequest, ActionBlocks, IRepo, MessageOptions, CommitResult, GetBlockResults, PendResult, BlockGets, CommitRequest, RepoMessage, IKeyNetwork, ICluster, ClusterConsensusConfig, BlockId, ActionRev, ActionContext } from "@optimystic/db-core";
2
- import { LruMap } from "@optimystic/db-core";
1
+ import type { PendRequest, ActionBlocks, IRepo, MessageOptions, CommitResult, GetBlockResults, PendResult, BlockGets, CommitRequest, RepoMessage, IKeyNetwork, ICluster, ClusterConsensusConfig, BlockId, ActionRev, ActionContext, ClusterRecord } from "@optimystic/db-core";
2
+ import { LruMap, blockIdsForTransforms } from "@optimystic/db-core";
3
3
  import { ClusterCoordinator } from "./cluster-coordinator.js";
4
4
  import type { ClusterClient } from "../cluster/client.js";
5
5
  import type { PeerId } from "@libp2p/interface";
@@ -237,7 +237,7 @@ export class CoordinatorRepo implements IRepo {
237
237
  }
238
238
 
239
239
  async pend(request: PendRequest, options?: MessageOptions): Promise<PendResult> {
240
- const allBlockIds = Object.keys(request.transforms);
240
+ const allBlockIds = blockIdsForTransforms(request.transforms);
241
241
  await this.verifyResponsibility(allBlockIds);
242
242
  const coordinatingBlockIds = (options as any)?.coordinatingBlockIds ?? allBlockIds;
243
243
 
@@ -273,7 +273,7 @@ export class CoordinatorRepo implements IRepo {
273
273
  return {
274
274
  success: true,
275
275
  pending: [],
276
- blockIds: Object.keys(request.transforms)
276
+ blockIds: allBlockIds
277
277
  };
278
278
  } catch (error) {
279
279
  log('coordinator-repo:pend-error', { actionId: request.actionId, error: (error as Error).message });
@@ -326,16 +326,38 @@ export class CoordinatorRepo implements IRepo {
326
326
  };
327
327
 
328
328
  try {
329
- const { localExecuted } = await this.coordinator.executeClusterTransaction(blockIds[0]!, message, options);
330
- // Only call storageRepo if local cluster didn't already execute during consensus
331
- if (!localExecuted) {
329
+ const { record, localExecuted } = await this.coordinator.executeClusterTransaction(blockIds[0]!, message, options);
330
+ if (localExecuted) {
331
+ return { success: true };
332
+ }
333
+ // Local cluster didn't execute during consensus. Attempt a local commit,
334
+ // but tolerate failure (e.g., "pending action not found") when the cluster
335
+ // already reached consensus — this coordinator was likely picked for commit
336
+ // after missing the pend phase (unreachable during pend, fresh join, etc.).
337
+ // The cluster's majority is authoritative; this peer will catch up via sync.
338
+ try {
332
339
  return await this.storageRepo.commit(request, options);
340
+ } catch (err) {
341
+ if (clusterReachedCommitConsensus(record)) {
342
+ log('coordinator-repo:commit-local-failed-cluster-succeeded', {
343
+ actionId: request.actionId,
344
+ error: (err as Error).message
345
+ });
346
+ return { success: true };
347
+ }
348
+ throw err;
333
349
  }
334
- // Local cluster already executed - return success
335
- return { success: true };
336
350
  } catch (error) {
337
351
  log('coordinator-repo:commit-error', { actionId: request.actionId, error: (error as Error).message });
338
352
  throw error;
339
353
  }
340
354
  }
341
355
  }
356
+
357
+ /** True if a simple majority of cluster peers signed an approving commit. */
358
+ function clusterReachedCommitConsensus(record: ClusterRecord): boolean {
359
+ const peerCount = Object.keys(record.peers).length;
360
+ if (peerCount === 0) return false;
361
+ const approvedCommits = Object.values(record.commits).filter(s => s.type === 'approve').length;
362
+ return approvedCommits > peerCount / 2;
363
+ }
@@ -26,11 +26,14 @@ export class BlockStorage implements IBlockStorage {
26
26
  return undefined;
27
27
  }
28
28
 
29
- const targetRev = rev ?? meta.latest?.rev;
30
- if (targetRev === undefined) {
31
- throw new Error(`No revision specified and no latest revision exists for block ${this.blockId}`);
29
+ // Pending-only state: metadata was seeded by savePendingTransaction but no
30
+ // revision has been committed yet. Treat as "doesn't exist" for the default
31
+ // request path matches StorageRepo.get()'s contract that undefined => empty.
32
+ if (rev === undefined && meta.latest === undefined) {
33
+ return undefined;
32
34
  }
33
35
 
36
+ const targetRev = rev ?? meta.latest!.rev;
34
37
  await this.ensureRevision(meta, targetRev);
35
38
  return await this.materializeBlock(meta, targetRev);
36
39
  }
@@ -88,6 +91,38 @@ export class BlockStorage implements IBlockStorage {
88
91
  await this.storage.saveMetadata(this.blockId, meta);
89
92
  }
90
93
 
94
+ async recover(): Promise<{ reconciled: boolean; latest?: ActionRev }> {
95
+ const meta = await this.storage.getMetadata(this.blockId);
96
+ if (!meta) {
97
+ return { reconciled: false };
98
+ }
99
+
100
+ const currentRev = meta.latest?.rev ?? 0;
101
+ let maxRev = currentRev;
102
+ let maxActionId = meta.latest?.actionId;
103
+
104
+ // Probe forward until we hit a gap or a revision whose action is not yet
105
+ // in the committed log (Crash-D2 state — retry-commit owns that advance).
106
+ for (let next = currentRev + 1; ; next++) {
107
+ const actionId = await this.storage.getRevision(this.blockId, next);
108
+ if (actionId === undefined) break;
109
+ const promoted = await this.storage.getTransaction(this.blockId, actionId);
110
+ if (promoted === undefined) break;
111
+ maxRev = next;
112
+ maxActionId = actionId;
113
+ }
114
+
115
+ if (maxRev > currentRev && maxActionId !== undefined) {
116
+ const advanced: ActionRev = { rev: maxRev, actionId: maxActionId };
117
+ meta.latest = advanced;
118
+ await this.storage.saveMetadata(this.blockId, meta);
119
+ log('recover blockId=%s advanced latest from rev=%d to rev=%d', this.blockId, currentRev, maxRev);
120
+ return { reconciled: true, latest: advanced };
121
+ }
122
+
123
+ return { reconciled: false, latest: meta.latest };
124
+ }
125
+
91
126
  private async ensureRevision(meta: BlockMetadata, rev: number): Promise<void> {
92
127
  if (this.inRanges(rev, meta.ranges)) {
93
128
  return;
@@ -5,7 +5,13 @@ export interface IBlockStorage {
5
5
  /** Gets the latest revision information for this block */
6
6
  getLatest(): Promise<ActionRev | undefined>;
7
7
 
8
- /** Gets a materialized block at the given revision */
8
+ /**
9
+ * Gets a materialized block at the given revision.
10
+ * Returns undefined when the block has no materialized content yet — either
11
+ * no metadata exists, or metadata exists (seeded by a pending transaction)
12
+ * but no revision has been committed. Throws only when a specific `rev` was
13
+ * requested but cannot be located.
14
+ */
9
15
  getBlock(rev?: number): Promise<{ block: IBlock, actionRev: ActionRev } | undefined>;
10
16
 
11
17
  /** Gets an action by ID */
@@ -37,4 +43,19 @@ export interface IBlockStorage {
37
43
 
38
44
  /** Sets the latest revision information */
39
45
  setLatest(latest: ActionRev): Promise<void>;
46
+
47
+ /**
48
+ * Reconciles `metadata.latest` with the highest contiguous fully-promoted revision in
49
+ * the revisions table. Intended for post-crash recovery of the Crash-D3 gap, where
50
+ * `promotePendingTransaction` succeeded but `setLatest` did not: the revision and
51
+ * committed-log entry are durable, but `meta.latest` still points at the prior rev
52
+ * (or is undefined), and retry-commit is rejected because the pending record is gone.
53
+ *
54
+ * Stops at the first rev whose action is not yet in the committed log, preserving the
55
+ * Crash-D2 invariant that retry-commit — not recovery — owns advancement past a half-
56
+ * promoted state.
57
+ *
58
+ * Idempotent and monotonic (latest only advances forward).
59
+ */
60
+ recover(): Promise<{ reconciled: boolean; latest?: ActionRev }>;
40
61
  }
@@ -17,12 +17,30 @@ export class MemoryRawStorage implements IRawStorage {
17
17
  return `${blockId}:${actionId}`;
18
18
  }
19
19
 
20
+ /**
21
+ * Retrieves metadata for a block.
22
+ *
23
+ * @pitfall **MUST return a clone** - `BlockStorage.setLatest` mutates the returned
24
+ * metadata in place (`meta.latest = latest`) before calling `saveMetadata`. Returning
25
+ * the stored reference leaks that mutation into RAM even when a subsequent
26
+ * `saveMetadata` call fails, masking mid-commit crashes that a persistent store
27
+ * (file/sqlite/leveldb) would surface correctly.
28
+ * @see docs/internals.md "Storage Returns References" pitfall
29
+ */
20
30
  async getMetadata(blockId: BlockId): Promise<BlockMetadata | undefined> {
21
- return this.metadata.get(blockId);
31
+ const meta = this.metadata.get(blockId);
32
+ return meta ? structuredClone(meta) : undefined;
22
33
  }
23
34
 
35
+ /**
36
+ * Stores metadata for a block.
37
+ *
38
+ * @pitfall **MUST store a clone** - callers may continue mutating the metadata object
39
+ * after saving; storing the reference lets those mutations corrupt persisted state.
40
+ * @see docs/internals.md "Storage Returns References" pitfall
41
+ */
24
42
  async saveMetadata(blockId: BlockId, metadata: BlockMetadata): Promise<void> {
25
- this.metadata.set(blockId, metadata);
43
+ this.metadata.set(blockId, structuredClone(metadata));
26
44
  }
27
45
 
28
46
  async getRevision(blockId: BlockId, rev: number): Promise<ActionId | undefined> {
@@ -51,17 +51,16 @@ export class StorageRepo implements IRepo {
51
51
  }
52
52
 
53
53
  const blockRev = await blockStorage.getBlock(context?.rev);
54
- if (!blockRev) {
55
- return [blockId, { state: {} } as GetBlockResult];
56
- }
57
54
 
58
- // Include pending action if requested
55
+ // Include pending action if requested — handled first so a pending-only
56
+ // insert (no committed revision yet) can still be served by applying the
57
+ // pending transform to an undefined prior block.
59
58
  if (context?.actionId !== undefined) {
60
59
  const pendingTransform = await blockStorage.getPendingTransaction(context.actionId);
61
60
  if (!pendingTransform) {
62
61
  throw new Error(`Pending action ${context.actionId} not found`);
63
62
  }
64
- const block = applyTransform(blockRev.block, pendingTransform);
63
+ const block = applyTransform(blockRev?.block, pendingTransform);
65
64
  return [blockId, {
66
65
  block,
67
66
  state: {
@@ -71,6 +70,10 @@ export class StorageRepo implements IRepo {
71
70
  }];
72
71
  }
73
72
 
73
+ if (!blockRev) {
74
+ return [blockId, { state: {} } as GetBlockResult];
75
+ }
76
+
74
77
  const pendings = await asyncIteratorToArray(blockStorage.listPendingTransactions());
75
78
  return [blockId, {
76
79
  block: blockRev.block,
@@ -208,11 +211,23 @@ export class StorageRepo implements IRepo {
208
211
  storage: this.createBlockStorage(blockId)
209
212
  }));
210
213
 
211
- // Check for stale revisions and collect missing actions
214
+ // Partition blocks into:
215
+ // - alreadyDone: latest.rev === request.rev && latest.actionId === request.actionId
216
+ // (idempotent retry — a prior commit of this same action already landed here;
217
+ // skip rather than treat as a conflict. Needed to rollforward stranded blocks
218
+ // after a mid-batch crash committed some but not all blocks.)
219
+ // - missedCommits: latest.rev >= request.rev but not the same actionId → real stale conflict.
220
+ // - toCommit: latest.rev < request.rev or no latest yet → run internalCommit.
221
+ const toCommit: { blockId: BlockId, storage: IBlockStorage }[] = [];
212
222
  const missedCommits: { blockId: BlockId, transforms: ActionTransform[] }[] = [];
213
- for (const { blockId, storage } of blockStorages) {
223
+ for (const entry of blockStorages) {
224
+ const { blockId, storage } = entry;
214
225
  const latest = await storage.getLatest();
215
226
  if (latest && latest.rev >= request.rev) {
227
+ if (latest.rev === request.rev && latest.actionId === request.actionId) {
228
+ // Idempotent no-op for this block — already committed with this exact (actionId, rev).
229
+ continue;
230
+ }
216
231
  const transforms: ActionTransform[] = [];
217
232
  for await (const actionRev of storage.listRevisions(request.rev, latest.rev)) {
218
233
  const transform = await storage.getTransaction(actionRev.actionId);
@@ -226,7 +241,9 @@ export class StorageRepo implements IRepo {
226
241
  });
227
242
  }
228
243
  missedCommits.push({ blockId, transforms }); // Push, even if transforms is empty, because we want to reject the older version
244
+ continue;
229
245
  }
246
+ toCommit.push(entry);
230
247
  }
231
248
 
232
249
  if (missedCommits.length) {
@@ -237,9 +254,11 @@ export class StorageRepo implements IRepo {
237
254
  };
238
255
  }
239
256
 
240
- // Check for missing pending actions
257
+ // Check for missing pending actions only on blocks that still need to commit.
258
+ // Already-done blocks will have had their pending promoted, so skipping them here
259
+ // is what makes the idempotent rollforward work.
241
260
  const missingPends: { blockId: BlockId, actionId: ActionId }[] = [];
242
- for (const { blockId, storage } of blockStorages) {
261
+ for (const { blockId, storage } of toCommit) {
243
262
  const pendingAction = await storage.getPendingTransaction(request.actionId);
244
263
  if (!pendingAction) {
245
264
  missingPends.push({ blockId, actionId: request.actionId });
@@ -250,14 +269,15 @@ export class StorageRepo implements IRepo {
250
269
  throw new Error(`Pending action ${request.actionId} not found for block(s): ${missingPends.map(p => p.blockId).join(', ')}`);
251
270
  }
252
271
 
253
- // Commit the action for each block
254
- // This loop will execute atomically for all blocks due to the acquired locks
255
- for (const { blockId, storage } of blockStorages) {
272
+ // Commit the action for each block that still needs it.
273
+ // This loop will execute atomically for all blocks due to the acquired locks.
274
+ for (const { blockId, storage } of toCommit) {
256
275
  try {
257
276
  // internalCommit will throw if it encounters an issue
258
277
  await this.internalCommit(blockId, request.actionId, request.rev, storage);
259
278
  } catch (err) {
260
- // TODO: Recover as best we can. Rollback or handle partial commit? For now, return failure.
279
+ // Partial-commit recovery: a retry with the same (actionId, rev) will treat
280
+ // already-done blocks as idempotent no-ops and advance the remainder.
261
281
  return {
262
282
  success: false,
263
283
  reason: err instanceof Error ? err.message : 'Unknown error during commit'
@@ -273,6 +293,19 @@ export class StorageRepo implements IRepo {
273
293
  return { success: true };
274
294
  }
275
295
 
296
+ /**
297
+ * Reconciles `metadata.latest` for a single block with the highest contiguous
298
+ * fully-promoted revision in durable storage. Use after a crash between
299
+ * `promotePendingTransaction` and `setLatest` when retry-commit cannot help
300
+ * (the pending record is already gone) but the revision and committed-log entry
301
+ * are durable. Idempotent and monotonic.
302
+ */
303
+ async recoverBlock(blockId: BlockId): Promise<void> {
304
+ log('recoverBlock blockId=%s', blockId);
305
+ const storage = this.createBlockStorage(blockId);
306
+ await storage.recover();
307
+ }
308
+
276
309
  private async internalCommit(blockId: BlockId, actionId: ActionId, rev: number, storage: IBlockStorage): Promise<void> {
277
310
  // Note: This method is called within the locked critical section of commit()
278
311
  // So, operations like getPendingTransaction, getLatest, getBlock, saveMaterializedBlock,
@@ -0,0 +1 @@
1
+ export * from './mesh-harness.js';