@optimystic/db-p2p 0.0.1

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 (189) hide show
  1. package/dist/index.min.js +52 -0
  2. package/dist/index.min.js.map +7 -0
  3. package/dist/src/cluster/client.d.ts +12 -0
  4. package/dist/src/cluster/client.d.ts.map +1 -0
  5. package/dist/src/cluster/client.js +65 -0
  6. package/dist/src/cluster/client.js.map +1 -0
  7. package/dist/src/cluster/cluster-repo.d.ts +79 -0
  8. package/dist/src/cluster/cluster-repo.d.ts.map +1 -0
  9. package/dist/src/cluster/cluster-repo.js +613 -0
  10. package/dist/src/cluster/cluster-repo.js.map +1 -0
  11. package/dist/src/cluster/partition-detector.d.ts +59 -0
  12. package/dist/src/cluster/partition-detector.d.ts.map +1 -0
  13. package/dist/src/cluster/partition-detector.js +129 -0
  14. package/dist/src/cluster/partition-detector.js.map +1 -0
  15. package/dist/src/cluster/service.d.ts +49 -0
  16. package/dist/src/cluster/service.d.ts.map +1 -0
  17. package/dist/src/cluster/service.js +107 -0
  18. package/dist/src/cluster/service.js.map +1 -0
  19. package/dist/src/index.d.ts +29 -0
  20. package/dist/src/index.d.ts.map +1 -0
  21. package/dist/src/index.js +29 -0
  22. package/dist/src/index.js.map +1 -0
  23. package/dist/src/it-utility.d.ts +4 -0
  24. package/dist/src/it-utility.d.ts.map +1 -0
  25. package/dist/src/it-utility.js +32 -0
  26. package/dist/src/it-utility.js.map +1 -0
  27. package/dist/src/libp2p-key-network.d.ts +59 -0
  28. package/dist/src/libp2p-key-network.d.ts.map +1 -0
  29. package/dist/src/libp2p-key-network.js +278 -0
  30. package/dist/src/libp2p-key-network.js.map +1 -0
  31. package/dist/src/libp2p-node.d.ts +28 -0
  32. package/dist/src/libp2p-node.d.ts.map +1 -0
  33. package/dist/src/libp2p-node.js +270 -0
  34. package/dist/src/libp2p-node.js.map +1 -0
  35. package/dist/src/logger.d.ts +3 -0
  36. package/dist/src/logger.d.ts.map +1 -0
  37. package/dist/src/logger.js +6 -0
  38. package/dist/src/logger.js.map +1 -0
  39. package/dist/src/network/get-network-manager.d.ts +4 -0
  40. package/dist/src/network/get-network-manager.d.ts.map +1 -0
  41. package/dist/src/network/get-network-manager.js +17 -0
  42. package/dist/src/network/get-network-manager.js.map +1 -0
  43. package/dist/src/network/network-manager-service.d.ts +82 -0
  44. package/dist/src/network/network-manager-service.d.ts.map +1 -0
  45. package/dist/src/network/network-manager-service.js +283 -0
  46. package/dist/src/network/network-manager-service.js.map +1 -0
  47. package/dist/src/peer-utils.d.ts +2 -0
  48. package/dist/src/peer-utils.d.ts.map +1 -0
  49. package/dist/src/peer-utils.js +28 -0
  50. package/dist/src/peer-utils.js.map +1 -0
  51. package/dist/src/protocol-client.d.ts +12 -0
  52. package/dist/src/protocol-client.d.ts.map +1 -0
  53. package/dist/src/protocol-client.js +34 -0
  54. package/dist/src/protocol-client.js.map +1 -0
  55. package/dist/src/repo/client.d.ts +17 -0
  56. package/dist/src/repo/client.d.ts.map +1 -0
  57. package/dist/src/repo/client.js +82 -0
  58. package/dist/src/repo/client.js.map +1 -0
  59. package/dist/src/repo/cluster-coordinator.d.ts +59 -0
  60. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -0
  61. package/dist/src/repo/cluster-coordinator.js +539 -0
  62. package/dist/src/repo/cluster-coordinator.js.map +1 -0
  63. package/dist/src/repo/coordinator-repo.d.ts +29 -0
  64. package/dist/src/repo/coordinator-repo.d.ts.map +1 -0
  65. package/dist/src/repo/coordinator-repo.js +102 -0
  66. package/dist/src/repo/coordinator-repo.js.map +1 -0
  67. package/dist/src/repo/redirect.d.ts +14 -0
  68. package/dist/src/repo/redirect.d.ts.map +1 -0
  69. package/dist/src/repo/redirect.js +9 -0
  70. package/dist/src/repo/redirect.js.map +1 -0
  71. package/dist/src/repo/service.d.ts +52 -0
  72. package/dist/src/repo/service.d.ts.map +1 -0
  73. package/dist/src/repo/service.js +181 -0
  74. package/dist/src/repo/service.js.map +1 -0
  75. package/dist/src/repo/types.d.ts +7 -0
  76. package/dist/src/repo/types.d.ts.map +1 -0
  77. package/dist/src/repo/types.js +2 -0
  78. package/dist/src/repo/types.js.map +1 -0
  79. package/dist/src/routing/libp2p-known-peers.d.ts +4 -0
  80. package/dist/src/routing/libp2p-known-peers.d.ts.map +1 -0
  81. package/dist/src/routing/libp2p-known-peers.js +19 -0
  82. package/dist/src/routing/libp2p-known-peers.js.map +1 -0
  83. package/dist/src/routing/responsibility.d.ts +14 -0
  84. package/dist/src/routing/responsibility.d.ts.map +1 -0
  85. package/dist/src/routing/responsibility.js +45 -0
  86. package/dist/src/routing/responsibility.js.map +1 -0
  87. package/dist/src/routing/simple-cluster-coordinator.d.ts +23 -0
  88. package/dist/src/routing/simple-cluster-coordinator.d.ts.map +1 -0
  89. package/dist/src/routing/simple-cluster-coordinator.js +59 -0
  90. package/dist/src/routing/simple-cluster-coordinator.js.map +1 -0
  91. package/dist/src/storage/arachnode-fret-adapter.d.ts +65 -0
  92. package/dist/src/storage/arachnode-fret-adapter.d.ts.map +1 -0
  93. package/dist/src/storage/arachnode-fret-adapter.js +93 -0
  94. package/dist/src/storage/arachnode-fret-adapter.js.map +1 -0
  95. package/dist/src/storage/block-storage.d.ts +31 -0
  96. package/dist/src/storage/block-storage.d.ts.map +1 -0
  97. package/dist/src/storage/block-storage.js +154 -0
  98. package/dist/src/storage/block-storage.js.map +1 -0
  99. package/dist/src/storage/file-storage.d.ts +30 -0
  100. package/dist/src/storage/file-storage.d.ts.map +1 -0
  101. package/dist/src/storage/file-storage.js +127 -0
  102. package/dist/src/storage/file-storage.js.map +1 -0
  103. package/dist/src/storage/helpers.d.ts +3 -0
  104. package/dist/src/storage/helpers.d.ts.map +1 -0
  105. package/dist/src/storage/helpers.js +28 -0
  106. package/dist/src/storage/helpers.js.map +1 -0
  107. package/dist/src/storage/i-block-storage.d.ts +32 -0
  108. package/dist/src/storage/i-block-storage.d.ts.map +1 -0
  109. package/dist/src/storage/i-block-storage.js +2 -0
  110. package/dist/src/storage/i-block-storage.js.map +1 -0
  111. package/dist/src/storage/i-raw-storage.d.ts +20 -0
  112. package/dist/src/storage/i-raw-storage.d.ts.map +1 -0
  113. package/dist/src/storage/i-raw-storage.js +2 -0
  114. package/dist/src/storage/i-raw-storage.js.map +1 -0
  115. package/dist/src/storage/memory-storage.d.ts +27 -0
  116. package/dist/src/storage/memory-storage.d.ts.map +1 -0
  117. package/dist/src/storage/memory-storage.js +87 -0
  118. package/dist/src/storage/memory-storage.js.map +1 -0
  119. package/dist/src/storage/restoration-coordinator-v2.d.ts +63 -0
  120. package/dist/src/storage/restoration-coordinator-v2.d.ts.map +1 -0
  121. package/dist/src/storage/restoration-coordinator-v2.js +157 -0
  122. package/dist/src/storage/restoration-coordinator-v2.js.map +1 -0
  123. package/dist/src/storage/ring-selector.d.ts +56 -0
  124. package/dist/src/storage/ring-selector.d.ts.map +1 -0
  125. package/dist/src/storage/ring-selector.js +118 -0
  126. package/dist/src/storage/ring-selector.js.map +1 -0
  127. package/dist/src/storage/storage-monitor.d.ts +23 -0
  128. package/dist/src/storage/storage-monitor.d.ts.map +1 -0
  129. package/dist/src/storage/storage-monitor.js +40 -0
  130. package/dist/src/storage/storage-monitor.js.map +1 -0
  131. package/dist/src/storage/storage-repo.d.ts +17 -0
  132. package/dist/src/storage/storage-repo.d.ts.map +1 -0
  133. package/dist/src/storage/storage-repo.js +267 -0
  134. package/dist/src/storage/storage-repo.js.map +1 -0
  135. package/dist/src/storage/struct.d.ts +29 -0
  136. package/dist/src/storage/struct.d.ts.map +1 -0
  137. package/dist/src/storage/struct.js +2 -0
  138. package/dist/src/storage/struct.js.map +1 -0
  139. package/dist/src/sync/client.d.ts +27 -0
  140. package/dist/src/sync/client.d.ts.map +1 -0
  141. package/dist/src/sync/client.js +32 -0
  142. package/dist/src/sync/client.js.map +1 -0
  143. package/dist/src/sync/protocol.d.ts +58 -0
  144. package/dist/src/sync/protocol.d.ts.map +1 -0
  145. package/dist/src/sync/protocol.js +12 -0
  146. package/dist/src/sync/protocol.js.map +1 -0
  147. package/dist/src/sync/service.d.ts +62 -0
  148. package/dist/src/sync/service.d.ts.map +1 -0
  149. package/dist/src/sync/service.js +168 -0
  150. package/dist/src/sync/service.js.map +1 -0
  151. package/package.json +73 -0
  152. package/readme.md +497 -0
  153. package/src/cluster/client.ts +63 -0
  154. package/src/cluster/cluster-repo.ts +711 -0
  155. package/src/cluster/partition-detector.ts +158 -0
  156. package/src/cluster/service.ts +156 -0
  157. package/src/index.ts +30 -0
  158. package/src/it-utility.ts +36 -0
  159. package/src/libp2p-key-network.ts +334 -0
  160. package/src/libp2p-node.ts +335 -0
  161. package/src/logger.ts +9 -0
  162. package/src/network/get-network-manager.ts +17 -0
  163. package/src/network/network-manager-service.ts +334 -0
  164. package/src/peer-utils.ts +24 -0
  165. package/src/protocol-client.ts +54 -0
  166. package/src/repo/client.ts +112 -0
  167. package/src/repo/cluster-coordinator.ts +592 -0
  168. package/src/repo/coordinator-repo.ts +137 -0
  169. package/src/repo/redirect.ts +17 -0
  170. package/src/repo/service.ts +219 -0
  171. package/src/repo/types.ts +7 -0
  172. package/src/routing/libp2p-known-peers.ts +26 -0
  173. package/src/routing/responsibility.ts +63 -0
  174. package/src/routing/simple-cluster-coordinator.ts +70 -0
  175. package/src/storage/arachnode-fret-adapter.ts +128 -0
  176. package/src/storage/block-storage.ts +182 -0
  177. package/src/storage/file-storage.ts +163 -0
  178. package/src/storage/helpers.ts +29 -0
  179. package/src/storage/i-block-storage.ts +40 -0
  180. package/src/storage/i-raw-storage.ts +30 -0
  181. package/src/storage/memory-storage.ts +108 -0
  182. package/src/storage/restoration-coordinator-v2.ts +191 -0
  183. package/src/storage/ring-selector.ts +155 -0
  184. package/src/storage/storage-monitor.ts +59 -0
  185. package/src/storage/storage-repo.ts +320 -0
  186. package/src/storage/struct.ts +34 -0
  187. package/src/sync/client.ts +42 -0
  188. package/src/sync/protocol.ts +71 -0
  189. package/src/sync/service.ts +229 -0
@@ -0,0 +1,711 @@
1
+ import type { IRepo, ClusterRecord, Signature, RepoMessage, ITransactionValidator } from "@optimystic/db-core";
2
+ import type { ICluster } from "@optimystic/db-core";
3
+ import type { IPeerNetwork } from "@optimystic/db-core";
4
+ import { blockIdsForTransforms } from "@optimystic/db-core";
5
+ import { ClusterClient } from "./client.js";
6
+ import type { PeerId } from "@libp2p/interface";
7
+ import { peerIdFromString } from "@libp2p/peer-id";
8
+ import { sha256 } from "multiformats/hashes/sha2";
9
+ import { toString as uint8ArrayToString } from 'uint8arrays/to-string';
10
+ import { createLogger } from '../logger.js'
11
+ import type { PartitionDetector } from "./partition-detector.js";
12
+ import type { FretService } from "p2p-fret";
13
+
14
+ const log = createLogger('cluster-member')
15
+
16
+ /** State of a transaction in the cluster */
17
+ enum TransactionPhase {
18
+ Promising, // Collecting promises from peers
19
+ OurPromiseNeeded, // We need to provide our promise
20
+ OurCommitNeeded, // We need to provide our commit
21
+ Consensus, // Transaction has reached consensus
22
+ Rejected, // Transaction was rejected
23
+ Propagating // Transaction is being propagated
24
+ }
25
+
26
+ interface TransactionState {
27
+ record: ClusterRecord;
28
+ promiseTimeout?: NodeJS.Timeout;
29
+ resolutionTimeout?: NodeJS.Timeout;
30
+ lastUpdate: number;
31
+ }
32
+
33
+ interface ClusterMemberComponents {
34
+ storageRepo: IRepo;
35
+ peerNetwork: IPeerNetwork;
36
+ peerId: PeerId;
37
+ protocolPrefix?: string;
38
+ partitionDetector?: PartitionDetector;
39
+ fretService?: FretService;
40
+ validator?: ITransactionValidator;
41
+ }
42
+
43
+ export function clusterMember(components: ClusterMemberComponents): ClusterMember {
44
+ return new ClusterMember(
45
+ components.storageRepo,
46
+ components.peerNetwork,
47
+ components.peerId,
48
+ components.protocolPrefix,
49
+ components.partitionDetector,
50
+ components.fretService,
51
+ components.validator
52
+ );
53
+ }
54
+
55
+ /**
56
+ * Handles cluster-side operations, managing promises and commits for cluster updates
57
+ * and coordinating with the local storage repo.
58
+ */
59
+ export class ClusterMember implements ICluster {
60
+ // Track active transactions by their message hash
61
+ private activeTransactions: Map<string, TransactionState> = new Map();
62
+ // Queue of transactions to clean up
63
+ private cleanupQueue: string[] = [];
64
+ // Serialize concurrent updates for the same transaction
65
+ private pendingUpdates: Map<string, Promise<ClusterRecord>> = new Map();
66
+
67
+ constructor(
68
+ private readonly storageRepo: IRepo,
69
+ private readonly peerNetwork: IPeerNetwork,
70
+ private readonly peerId: PeerId,
71
+ private readonly protocolPrefix?: string,
72
+ private readonly partitionDetector?: PartitionDetector,
73
+ private readonly fretService?: FretService,
74
+ private readonly validator?: ITransactionValidator
75
+ ) {
76
+ // Periodically clean up expired transactions
77
+ setInterval(() => this.queueExpiredTransactions(), 60000);
78
+ // Process cleanup queue
79
+ setInterval(() => this.processCleanupQueue(), 1000);
80
+ }
81
+
82
+ /**
83
+ * Handles an incoming cluster update, managing the two-phase commit process
84
+ * and coordinating with the local storage repo
85
+ */
86
+ async update(record: ClusterRecord): Promise<ClusterRecord> {
87
+ // Serialize concurrent updates for the same transaction
88
+ const existingUpdate = this.pendingUpdates.get(record.messageHash);
89
+ if (existingUpdate) {
90
+ log('cluster-member:concurrent-update-wait', { messageHash: record.messageHash });
91
+ await existingUpdate;
92
+ // After waiting, continue processing with the new incoming record
93
+ // to ensure proper merging of promises/commits from coordinator
94
+ }
95
+
96
+ // Create a promise for this update operation
97
+ const updatePromise = this.processUpdate(record);
98
+ this.pendingUpdates.set(record.messageHash, updatePromise);
99
+
100
+ try {
101
+ const result = await updatePromise;
102
+ return result;
103
+ } finally {
104
+ // Remove from pending updates after a short delay to allow concurrent calls to see it
105
+ setTimeout(() => {
106
+ this.pendingUpdates.delete(record.messageHash);
107
+ }, 100);
108
+ }
109
+ }
110
+
111
+ private async processUpdate(record: ClusterRecord): Promise<ClusterRecord> {
112
+ const ourId = this.peerId.toString();
113
+ const inboundPhase = record.commits[ourId] ? 'commit' : record.promises[ourId] ? 'promise' : 'initial';
114
+ log('cluster-member:incoming', {
115
+ messageHash: record.messageHash,
116
+ phase: inboundPhase,
117
+ peerCount: Object.keys(record.peers).length,
118
+ promiseCount: Object.keys(record.promises).length,
119
+ commitCount: Object.keys(record.commits).length,
120
+ existingTransaction: this.activeTransactions.has(record.messageHash)
121
+ });
122
+
123
+ // Report network size hint to FRET if provided
124
+ if (this.fretService && record.networkSizeHint && record.networkSizeConfidence) {
125
+ try {
126
+ this.fretService.reportNetworkSize(
127
+ record.networkSizeHint,
128
+ record.networkSizeConfidence,
129
+ 'cluster'
130
+ );
131
+ } catch (err) {
132
+ // Ignore errors reporting to FRET
133
+ }
134
+ }
135
+
136
+ // Validate the incoming record
137
+ await this.validateRecord(record);
138
+
139
+ const existingState = this.activeTransactions.get(record.messageHash);
140
+ let currentRecord = existingState?.record || record;
141
+ if (existingState) {
142
+ log('cluster-member:merge-start', {
143
+ messageHash: record.messageHash,
144
+ existingPromises: Object.keys(existingState.record.promises ?? {}),
145
+ existingCommits: Object.keys(existingState.record.commits ?? {}),
146
+ incomingPromises: Object.keys(record.promises ?? {}),
147
+ incomingCommits: Object.keys(record.commits ?? {})
148
+ });
149
+ }
150
+
151
+ // If we have an existing record, merge the signatures
152
+ if (existingState) {
153
+ currentRecord = await this.mergeRecords(existingState.record, record);
154
+ log('cluster-member:merge-complete', {
155
+ messageHash: record.messageHash,
156
+ mergedPromises: Object.keys(currentRecord.promises ?? {}),
157
+ mergedCommits: Object.keys(currentRecord.commits ?? {})
158
+ });
159
+ }
160
+
161
+ // Get the current transaction state
162
+ const phase = await this.getTransactionPhase(currentRecord);
163
+ log('cluster-member:phase', {
164
+ messageHash: record.messageHash,
165
+ phase,
166
+ promises: Object.keys(currentRecord.promises ?? {}),
167
+ commits: Object.keys(currentRecord.commits ?? {})
168
+ });
169
+ let shouldPersist = true;
170
+
171
+ // Handle the transaction based on its state
172
+ switch (phase) {
173
+ case TransactionPhase.OurPromiseNeeded:
174
+ log('cluster-member:action-promise', {
175
+ messageHash: record.messageHash
176
+ });
177
+ currentRecord = await this.handlePromiseNeeded(currentRecord);
178
+ log('cluster-member:action-promise-complete', {
179
+ messageHash: record.messageHash,
180
+ promises: Object.keys(currentRecord.promises ?? {})
181
+ });
182
+ break;
183
+ case TransactionPhase.OurCommitNeeded:
184
+ log('cluster-member:action-commit', {
185
+ messageHash: record.messageHash
186
+ });
187
+ currentRecord = await this.handleCommitNeeded(currentRecord);
188
+ log('cluster-member:action-commit-complete', {
189
+ messageHash: record.messageHash,
190
+ commits: Object.keys(currentRecord.commits ?? {})
191
+ });
192
+ // After adding our commit, clear the transaction - the coordinator will handle consensus
193
+ shouldPersist = false;
194
+ break;
195
+ case TransactionPhase.Consensus:
196
+ log('cluster-member:action-consensus', {
197
+ messageHash: record.messageHash
198
+ });
199
+ await this.handleConsensus(currentRecord);
200
+ // Don't call clearTransaction here - it happens in handleConsensus
201
+ shouldPersist = false;
202
+ break;
203
+ case TransactionPhase.Rejected:
204
+ log('cluster-member:action-rejected', {
205
+ messageHash: record.messageHash
206
+ });
207
+ // Don't call clearTransaction here - it happens in handleRejection
208
+ await this.handleRejection(currentRecord);
209
+ shouldPersist = false;
210
+ break;
211
+ case TransactionPhase.Propagating:
212
+ // Transaction is complete and propagating - clean it up
213
+ log('cluster-member:phase-propagating', {
214
+ messageHash: record.messageHash
215
+ });
216
+ shouldPersist = false;
217
+ break;
218
+ case TransactionPhase.Promising:
219
+ // Still collecting promises from peers - if we haven't added ours and there's no conflict, add it
220
+ // This state shouldn't normally be reached since OurPromiseNeeded is checked first
221
+ log('cluster-member:phase-promising-blocked', {
222
+ messageHash: record.messageHash
223
+ });
224
+ break;
225
+ }
226
+
227
+ if (shouldPersist) {
228
+ // Update transaction state
229
+ const timeouts = this.setupTimeouts(currentRecord);
230
+ this.activeTransactions.set(record.messageHash, {
231
+ record: currentRecord,
232
+ lastUpdate: Date.now(),
233
+ promiseTimeout: timeouts.promiseTimeout,
234
+ resolutionTimeout: timeouts.resolutionTimeout
235
+ });
236
+ log('cluster-member:state-persist', {
237
+ messageHash: record.messageHash,
238
+ storedPromises: Object.keys(currentRecord.promises ?? {}),
239
+ storedCommits: Object.keys(currentRecord.commits ?? {})
240
+ });
241
+ } else {
242
+ log('cluster-member:state-clear', {
243
+ messageHash: record.messageHash
244
+ });
245
+ this.clearTransaction(record.messageHash);
246
+ }
247
+
248
+ // Skip propagation - the coordinator manages distribution
249
+ // await this.propagateIfNeeded(currentRecord);
250
+
251
+ log('cluster-member:update-complete', {
252
+ messageHash: record.messageHash,
253
+ promiseCount: Object.keys(currentRecord.promises).length,
254
+ commitCount: Object.keys(currentRecord.commits).length
255
+ });
256
+ return currentRecord;
257
+ }
258
+
259
+ /**
260
+ * Merges two records, validating that non-signature fields match
261
+ */
262
+ private async mergeRecords(existing: ClusterRecord, incoming: ClusterRecord): Promise<ClusterRecord> {
263
+ log('cluster-member:merge-records', {
264
+ messageHash: existing.messageHash,
265
+ existingPromises: Object.keys(existing.promises ?? {}),
266
+ existingCommits: Object.keys(existing.commits ?? {}),
267
+ incomingPromises: Object.keys(incoming.promises ?? {}),
268
+ incomingCommits: Object.keys(incoming.commits ?? {})
269
+ });
270
+ // Verify that immutable fields match
271
+ if (existing.messageHash !== incoming.messageHash) {
272
+ throw new Error('Message hash mismatch');
273
+ }
274
+ if (JSON.stringify(existing.message) !== JSON.stringify(incoming.message)) {
275
+ throw new Error('Message content mismatch');
276
+ }
277
+ if (JSON.stringify(existing.peers) !== JSON.stringify(incoming.peers)) {
278
+ throw new Error('Peers mismatch');
279
+ }
280
+
281
+ // Merge signatures, keeping the most recent valid ones
282
+ return {
283
+ ...existing,
284
+ promises: { ...existing.promises, ...incoming.promises },
285
+ commits: { ...existing.commits, ...incoming.commits }
286
+ };
287
+ }
288
+
289
+ private async validateRecord(record: ClusterRecord): Promise<void> {
290
+ // TODO: Fix hash validation logic to match coordinator's hash generation
291
+ // The coordinator creates the hash from the message, but this tries to re-hash the hash itself
292
+
293
+ // Validate signatures
294
+ await this.validateSignatures(record);
295
+
296
+ // Validate expiration
297
+ if (record.message.expiration && record.message.expiration < Date.now()) {
298
+ throw new Error('Transaction expired');
299
+ }
300
+ }
301
+
302
+ private async computeMessageHash(record: ClusterRecord): Promise<string> {
303
+ const msgBytes = new TextEncoder().encode(record.messageHash + JSON.stringify(record.message));
304
+ const hashBytes = await sha256.digest(msgBytes);
305
+ return uint8ArrayToString(hashBytes.digest, 'base64url');
306
+ }
307
+
308
+ private async validateSignatures(record: ClusterRecord): Promise<void> {
309
+ // Validate promise signatures
310
+ const promiseHash = await this.computePromiseHash(record);
311
+ for (const [peerId, signature] of Object.entries(record.promises)) {
312
+ if (!await this.verifySignature(peerId, promiseHash, signature)) {
313
+ throw new Error(`Invalid promise signature from ${peerId}`);
314
+ }
315
+ }
316
+
317
+ // Validate commit signatures
318
+ const commitHash = await this.computeCommitHash(record);
319
+ for (const [peerId, signature] of Object.entries(record.commits)) {
320
+ if (!await this.verifySignature(peerId, commitHash, signature)) {
321
+ throw new Error(`Invalid commit signature from ${peerId}`);
322
+ }
323
+ }
324
+ }
325
+
326
+ private async computePromiseHash(record: ClusterRecord): Promise<string> {
327
+ const msgBytes = new TextEncoder().encode(record.messageHash + JSON.stringify(record.message));
328
+ const hashBytes = await sha256.digest(msgBytes);
329
+ return uint8ArrayToString(hashBytes.digest, 'base64url');
330
+ }
331
+
332
+ private async computeCommitHash(record: ClusterRecord): Promise<string> {
333
+ const msgBytes = new TextEncoder().encode(record.messageHash + JSON.stringify(record.message) + JSON.stringify(record.promises));
334
+ const hashBytes = await sha256.digest(msgBytes);
335
+ return uint8ArrayToString(hashBytes.digest, 'base64url');
336
+ }
337
+
338
+ private async verifySignature(peerId: string, hash: string, signature: Signature): Promise<boolean> {
339
+ // TODO: Implement actual signature verification
340
+ return true;
341
+ }
342
+
343
+ private async getTransactionPhase(record: ClusterRecord): Promise<TransactionPhase> {
344
+ const peerCount = Object.keys(record.peers).length;
345
+ const promiseCount = Object.keys(record.promises).length;
346
+ const commitCount = Object.keys(record.commits).length;
347
+ const ourId = this.peerId.toString();
348
+
349
+ // Check for rejections
350
+ const rejectedPromises = Object.values(record.promises).filter(s => s.type === 'reject');
351
+ const rejectedCommits = Object.values(record.commits).filter(s => s.type === 'reject');
352
+ if (rejectedPromises.length > 0 || this.hasMajority(rejectedCommits.length, peerCount)) {
353
+ return TransactionPhase.Rejected;
354
+ }
355
+
356
+ // Check if we need to promise
357
+ if (!record.promises[ourId] && !this.hasConflict(record)) {
358
+ return TransactionPhase.OurPromiseNeeded;
359
+ }
360
+
361
+ // Check if still collecting promises
362
+ if (promiseCount < peerCount) {
363
+ return TransactionPhase.Promising;
364
+ }
365
+
366
+ // Check if we need to commit
367
+ if (promiseCount === peerCount && !record.commits[ourId]) {
368
+ return TransactionPhase.OurCommitNeeded;
369
+ }
370
+
371
+ // Check for consensus
372
+ const approvedCommits = Object.values(record.commits).filter(s => s.type === 'approve');
373
+ if (this.hasMajority(approvedCommits.length, peerCount)) {
374
+ return TransactionPhase.Consensus;
375
+ }
376
+
377
+ return TransactionPhase.Propagating;
378
+ }
379
+
380
+ private hasMajority(count: number, total: number): boolean {
381
+ return count > total / 2;
382
+ }
383
+
384
+ private async handlePromiseNeeded(record: ClusterRecord): Promise<ClusterRecord> {
385
+ // Validate pend operations if we have a validator
386
+ const validationResult = await this.validatePendOperations(record);
387
+
388
+ const signature: Signature = validationResult.valid
389
+ ? { type: 'approve', signature: 'approved' }
390
+ : { type: 'reject', signature: 'rejected', rejectReason: validationResult.reason };
391
+
392
+ if (!validationResult.valid) {
393
+ log('cluster-member:validation-rejected', {
394
+ messageHash: record.messageHash,
395
+ reason: validationResult.reason
396
+ });
397
+ }
398
+
399
+ return {
400
+ ...record,
401
+ promises: {
402
+ ...record.promises,
403
+ [this.peerId.toString()]: signature
404
+ }
405
+ };
406
+ }
407
+
408
+ /**
409
+ * Validates pend operations in a cluster record using the transaction validator.
410
+ * Returns success if no validator is configured (backwards compatibility).
411
+ */
412
+ private async validatePendOperations(record: ClusterRecord): Promise<{ valid: boolean; reason?: string }> {
413
+ if (!this.validator) {
414
+ return { valid: true };
415
+ }
416
+
417
+ // Find pend operations in the message
418
+ for (const operation of record.message.operations) {
419
+ if ('pend' in operation) {
420
+ const pendRequest = operation.pend;
421
+ // Only validate if we have a transaction and operationsHash
422
+ if (pendRequest.transaction && pendRequest.operationsHash) {
423
+ const result = await this.validator.validate(pendRequest.transaction, pendRequest.operationsHash);
424
+ if (!result.valid) {
425
+ return { valid: false, reason: result.reason };
426
+ }
427
+ }
428
+ }
429
+ }
430
+
431
+ return { valid: true };
432
+ }
433
+
434
+ private async handleCommitNeeded(record: ClusterRecord): Promise<ClusterRecord> {
435
+ if (this.hasLocalCommit(record)) {
436
+ return record;
437
+ }
438
+ const signature: Signature = {
439
+ type: 'approve',
440
+ signature: 'committed' // TODO: Actually sign the commit hash
441
+ };
442
+
443
+ return {
444
+ ...record,
445
+ commits: {
446
+ ...record.commits,
447
+ [this.peerId.toString()]: signature
448
+ }
449
+ };
450
+ }
451
+
452
+ private async handleConsensus(record: ClusterRecord): Promise<void> {
453
+ // Execute the operations only if we haven't already
454
+ const state = this.activeTransactions.get(record.messageHash);
455
+ if (!this.hasLocalCommit(state?.record ?? record)) {
456
+ for (const operation of record.message.operations) {
457
+ if ('get' in operation) {
458
+ await this.storageRepo.get(operation.get);
459
+ } else if ('pend' in operation) {
460
+ await this.storageRepo.pend(operation.pend);
461
+ } else if ('commit' in operation) {
462
+ await this.storageRepo.commit(operation.commit);
463
+ } else if ('cancel' in operation) {
464
+ await this.storageRepo.cancel(operation.cancel.actionRef);
465
+ }
466
+ }
467
+ }
468
+ // Don't clear here - will be cleared by shouldPersist = false in the main flow
469
+ }
470
+
471
+ private async handleRejection(record: ClusterRecord): Promise<void> {
472
+ // Clean up any resources - will be cleared by shouldPersist = false in the main flow
473
+ }
474
+
475
+ private setupTimeouts(record: ClusterRecord): { promiseTimeout?: NodeJS.Timeout; resolutionTimeout?: NodeJS.Timeout } {
476
+ if (!record.message.expiration) {
477
+ return {};
478
+ }
479
+
480
+ return {
481
+ promiseTimeout: setTimeout(
482
+ () => this.handleExpiration(record.messageHash),
483
+ record.message.expiration - Date.now()
484
+ ),
485
+ resolutionTimeout: setTimeout(
486
+ () => this.resolveWithPeers(record.messageHash),
487
+ record.message.expiration + 5000 - Date.now()
488
+ )
489
+ };
490
+ }
491
+
492
+ private hasConflict(record: ClusterRecord): boolean {
493
+ const now = Date.now();
494
+ const staleThresholdMs = 2000; // 2 seconds - allow more time for distributed consensus
495
+
496
+ for (const [existingHash, state] of Array.from(this.activeTransactions.entries())) {
497
+ if (existingHash === record.messageHash) {
498
+ continue;
499
+ }
500
+
501
+ // Clean up stale transactions that have been around too long
502
+ if (now - state.lastUpdate > staleThresholdMs) {
503
+ log('cluster-member:stale-cleanup', {
504
+ messageHash: existingHash,
505
+ age: now - state.lastUpdate
506
+ });
507
+ this.clearTransaction(existingHash);
508
+ continue;
509
+ }
510
+
511
+ if (this.operationsConflict(state.record.message.operations, record.message.operations)) {
512
+ // Use race resolution to determine winner
513
+ const resolution = this.resolveRace(state.record, record);
514
+
515
+ if (resolution === 'keep-existing') {
516
+ log('cluster-member:race-keep-existing', {
517
+ existing: existingHash,
518
+ incoming: record.messageHash
519
+ });
520
+ return true; // Reject incoming
521
+ } else {
522
+ // Accept incoming, abort existing
523
+ log('cluster-member:race-accept-incoming', {
524
+ existing: existingHash,
525
+ incoming: record.messageHash
526
+ });
527
+ this.clearTransaction(existingHash);
528
+ continue; // Check other conflicts
529
+ }
530
+ }
531
+ }
532
+
533
+ return false; // No blocking conflicts
534
+ }
535
+
536
+ /**
537
+ * Resolve race between two conflicting transactions.
538
+ * Transaction with more promises wins. If tied, higher hash wins.
539
+ */
540
+ private resolveRace(existing: ClusterRecord, incoming: ClusterRecord): 'keep-existing' | 'accept-incoming' {
541
+ const existingCount = Object.keys(existing.promises).length;
542
+ const incomingCount = Object.keys(incoming.promises).length;
543
+
544
+ // Transaction with more promises wins
545
+ if (existingCount > incomingCount) {
546
+ return 'keep-existing';
547
+ }
548
+ if (incomingCount > existingCount) {
549
+ return 'accept-incoming';
550
+ }
551
+
552
+ // Tie-breaker: higher message hash wins (deterministic)
553
+ return existing.messageHash > incoming.messageHash ? 'keep-existing' : 'accept-incoming';
554
+ }
555
+
556
+ private operationsConflict(ops1: RepoMessage['operations'], ops2: RepoMessage['operations']): boolean {
557
+ // Check if one is a commit for the same action as a pend - these don't conflict
558
+ const actionId1 = this.getActionId(ops1);
559
+ const actionId2 = this.getActionId(ops2);
560
+ if (actionId1 && actionId2 && actionId1 === actionId2) {
561
+ // Same action - commit is resolving the pend, not conflicting
562
+ return false;
563
+ }
564
+
565
+ const blocks1 = new Set(this.getAffectedBlockIds(ops1));
566
+ const blocks2 = new Set(this.getAffectedBlockIds(ops2));
567
+
568
+ for (const block of Array.from(blocks1)) {
569
+ if (blocks2.has(block)) {
570
+ log('cluster-member:conflict-detected', {
571
+ blocks1: Array.from(blocks1),
572
+ blocks2: Array.from(blocks2),
573
+ conflictingBlock: block
574
+ });
575
+ return true;
576
+ }
577
+ }
578
+
579
+ return false;
580
+ }
581
+
582
+ private getActionId(operations: RepoMessage['operations']): string | undefined {
583
+ for (const operation of operations) {
584
+ if ('pend' in operation) {
585
+ return operation.pend.actionId;
586
+ } else if ('commit' in operation) {
587
+ return operation.commit.actionId;
588
+ } else if ('cancel' in operation) {
589
+ return operation.cancel.actionRef.actionId;
590
+ }
591
+ }
592
+ return undefined;
593
+ }
594
+
595
+ private getAffectedBlockIds(operations: RepoMessage['operations']): string[] {
596
+ const blockIds = new Set<string>();
597
+
598
+ for (const operation of operations) {
599
+ if ('get' in operation) {
600
+ operation.get.blockIds.forEach(id => blockIds.add(id));
601
+ } else if ('pend' in operation) {
602
+ // Use blockIdsForTransforms to correctly extract block IDs from Transforms structure
603
+ blockIdsForTransforms(operation.pend.transforms).forEach(id => blockIds.add(id));
604
+ } else if ('commit' in operation) {
605
+ operation.commit.blockIds.forEach(id => blockIds.add(id));
606
+ } else if ('cancel' in operation) {
607
+ operation.cancel.actionRef.blockIds.forEach(id => blockIds.add(id));
608
+ }
609
+ }
610
+
611
+ return Array.from(blockIds);
612
+ }
613
+
614
+ private async propagateIfNeeded(record: ClusterRecord): Promise<void> {
615
+ const promises = [];
616
+ for (const [peerId, peer] of Object.entries(record.peers)) {
617
+ if (peerId === this.peerId.toString()) continue;
618
+
619
+ try {
620
+ const client = ClusterClient.create(peerIdFromString(peerId), this.peerNetwork, this.protocolPrefix);
621
+ promises.push(client.update(record));
622
+ } catch (error) {
623
+ console.error(`Failed to propagate to peer ${peerId}:`, error);
624
+ }
625
+ }
626
+ await Promise.allSettled(promises);
627
+ }
628
+
629
+ private async handleExpiration(messageHash: string): Promise<void> {
630
+ const state = this.activeTransactions.get(messageHash);
631
+ if (!state) return;
632
+
633
+ if (!state.record.promises[this.peerId.toString()]) {
634
+ const signature: Signature = {
635
+ type: 'reject',
636
+ signature: 'rejected',
637
+ rejectReason: 'Transaction expired'
638
+ };
639
+
640
+ const updatedRecord = {
641
+ ...state.record,
642
+ promises: {
643
+ ...state.record.promises,
644
+ [this.peerId.toString()]: signature
645
+ }
646
+ };
647
+
648
+ this.activeTransactions.set(messageHash, {
649
+ ...state,
650
+ record: updatedRecord
651
+ });
652
+
653
+ await this.propagateIfNeeded(updatedRecord);
654
+ }
655
+ }
656
+
657
+ private async resolveWithPeers(messageHash: string): Promise<void> {
658
+ // This method is disabled - the coordinator handles all retry logic
659
+ // Keeping the skeleton in case we need peer-initiated recovery in the future
660
+ log('cluster-member:resolve-skipped', { messageHash, reason: 'coordinator-handles-retry' });
661
+ }
662
+
663
+ private queueExpiredTransactions(): void {
664
+ const now = Date.now();
665
+ for (const [messageHash, state] of Array.from(this.activeTransactions.entries())) {
666
+ if (state.record.message.expiration && state.record.message.expiration < now) {
667
+ this.cleanupQueue.push(messageHash);
668
+ }
669
+ }
670
+ }
671
+
672
+ private async processCleanupQueue(): Promise<void> {
673
+ while (this.cleanupQueue.length > 0) {
674
+ const messageHash = this.cleanupQueue.shift();
675
+ if (!messageHash) continue;
676
+
677
+ const state = this.activeTransactions.get(messageHash);
678
+ if (!state) continue;
679
+
680
+ const phase = await this.getTransactionPhase(state.record);
681
+ if (phase !== TransactionPhase.Consensus && phase !== TransactionPhase.Rejected) {
682
+ this.activeTransactions.delete(messageHash);
683
+ }
684
+ }
685
+ }
686
+
687
+ private hasLocalCommit(record: ClusterRecord): boolean {
688
+ const ourId = this.peerId.toString();
689
+ return Boolean(record.commits[ourId]);
690
+ }
691
+
692
+ private clearTransaction(messageHash: string): void {
693
+ const state = this.activeTransactions.get(messageHash);
694
+ if (!state) {
695
+ log('cluster-member:clear-miss', { messageHash });
696
+ return;
697
+ }
698
+ if (state.promiseTimeout) {
699
+ clearTimeout(state.promiseTimeout);
700
+ }
701
+ if (state.resolutionTimeout) {
702
+ clearTimeout(state.resolutionTimeout);
703
+ }
704
+ this.activeTransactions.delete(messageHash);
705
+ log('cluster-member:clear-done', {
706
+ messageHash,
707
+ remaining: Array.from(this.activeTransactions.keys())
708
+ });
709
+ }
710
+ }
711
+