@optimystic/db-p2p 0.24.2 → 0.25.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 (249) hide show
  1. package/{readme.md → README.md} +688 -668
  2. package/dist/src/cluster/block-transfer-service.d.ts +133 -10
  3. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  4. package/dist/src/cluster/block-transfer-service.js +155 -9
  5. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  6. package/dist/src/cluster/block-transfer.d.ts +67 -7
  7. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  8. package/dist/src/cluster/block-transfer.js +127 -24
  9. package/dist/src/cluster/block-transfer.js.map +1 -1
  10. package/dist/src/cluster/certified-claims.d.ts +170 -0
  11. package/dist/src/cluster/certified-claims.d.ts.map +1 -0
  12. package/dist/src/cluster/certified-claims.js +200 -0
  13. package/dist/src/cluster/certified-claims.js.map +1 -0
  14. package/dist/src/cluster/client-signature-verifier.d.ts +23 -0
  15. package/dist/src/cluster/client-signature-verifier.d.ts.map +1 -0
  16. package/dist/src/cluster/client-signature-verifier.js +33 -0
  17. package/dist/src/cluster/client-signature-verifier.js.map +1 -0
  18. package/dist/src/cluster/cluster-policy.d.ts +9 -3
  19. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  20. package/dist/src/cluster/cluster-policy.js +111 -27
  21. package/dist/src/cluster/cluster-policy.js.map +1 -1
  22. package/dist/src/cluster/cluster-repo.d.ts +140 -11
  23. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  24. package/dist/src/cluster/cluster-repo.js +485 -29
  25. package/dist/src/cluster/cluster-repo.js.map +1 -1
  26. package/dist/src/cluster/commit-proof.d.ts +134 -0
  27. package/dist/src/cluster/commit-proof.d.ts.map +1 -0
  28. package/dist/src/cluster/commit-proof.js +263 -0
  29. package/dist/src/cluster/commit-proof.js.map +1 -0
  30. package/dist/src/cluster/quorum-restore.d.ts +95 -8
  31. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  32. package/dist/src/cluster/quorum-restore.js +113 -14
  33. package/dist/src/cluster/quorum-restore.js.map +1 -1
  34. package/dist/src/cluster/rebalance-monitor.d.ts +84 -0
  35. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  36. package/dist/src/cluster/rebalance-monitor.js +207 -5
  37. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  38. package/dist/src/cluster/reconcile-block.d.ts +27 -7
  39. package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
  40. package/dist/src/cluster/reconcile-block.js +179 -38
  41. package/dist/src/cluster/reconcile-block.js.map +1 -1
  42. package/dist/src/cluster/service.d.ts +13 -4
  43. package/dist/src/cluster/service.d.ts.map +1 -1
  44. package/dist/src/cluster/service.js +17 -18
  45. package/dist/src/cluster/service.js.map +1 -1
  46. package/dist/src/cluster/spread-on-churn.d.ts +8 -2
  47. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  48. package/dist/src/cluster/spread-on-churn.js +7 -7
  49. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  50. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  51. package/dist/src/cohort-topic/host.js +8 -7
  52. package/dist/src/cohort-topic/host.js.map +1 -1
  53. package/dist/src/cohort-topic/stream-util.d.ts +7 -6
  54. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  55. package/dist/src/cohort-topic/stream-util.js +14 -41
  56. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  57. package/dist/src/dispute/cascade.d.ts +7 -8
  58. package/dist/src/dispute/cascade.d.ts.map +1 -1
  59. package/dist/src/dispute/cascade.js +24 -7
  60. package/dist/src/dispute/cascade.js.map +1 -1
  61. package/dist/src/dispute/dispute-service.d.ts +5 -0
  62. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  63. package/dist/src/dispute/dispute-service.js +13 -3
  64. package/dist/src/dispute/dispute-service.js.map +1 -1
  65. package/dist/src/dispute/invalidation.d.ts +33 -15
  66. package/dist/src/dispute/invalidation.d.ts.map +1 -1
  67. package/dist/src/dispute/invalidation.js +127 -34
  68. package/dist/src/dispute/invalidation.js.map +1 -1
  69. package/dist/src/dispute/service.d.ts.map +1 -1
  70. package/dist/src/dispute/service.js +2 -1
  71. package/dist/src/dispute/service.js.map +1 -1
  72. package/dist/src/index.d.ts +7 -0
  73. package/dist/src/index.d.ts.map +1 -1
  74. package/dist/src/index.js +7 -0
  75. package/dist/src/index.js.map +1 -1
  76. package/dist/src/libp2p-key-network.d.ts +14 -10
  77. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  78. package/dist/src/libp2p-key-network.js +28 -58
  79. package/dist/src/libp2p-key-network.js.map +1 -1
  80. package/dist/src/libp2p-node-base.d.ts +38 -7
  81. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  82. package/dist/src/libp2p-node-base.js +148 -63
  83. package/dist/src/libp2p-node-base.js.map +1 -1
  84. package/dist/src/network/open-protocol-stream.d.ts +56 -0
  85. package/dist/src/network/open-protocol-stream.d.ts.map +1 -0
  86. package/dist/src/network/open-protocol-stream.js +60 -0
  87. package/dist/src/network/open-protocol-stream.js.map +1 -0
  88. package/dist/src/network/register-protocol-handler.d.ts +66 -0
  89. package/dist/src/network/register-protocol-handler.d.ts.map +1 -0
  90. package/dist/src/network/register-protocol-handler.js +51 -0
  91. package/dist/src/network/register-protocol-handler.js.map +1 -0
  92. package/dist/src/peer-address-book.d.ts +60 -2
  93. package/dist/src/peer-address-book.d.ts.map +1 -1
  94. package/dist/src/peer-address-book.js +89 -1
  95. package/dist/src/peer-address-book.js.map +1 -1
  96. package/dist/src/pend-validation.d.ts +65 -0
  97. package/dist/src/pend-validation.d.ts.map +1 -0
  98. package/dist/src/pend-validation.js +69 -0
  99. package/dist/src/pend-validation.js.map +1 -0
  100. package/dist/src/reactivity/notify-transport.d.ts.map +1 -1
  101. package/dist/src/reactivity/notify-transport.js +2 -1
  102. package/dist/src/reactivity/notify-transport.js.map +1 -1
  103. package/dist/src/reactivity/push-state-gossip.d.ts.map +1 -1
  104. package/dist/src/reactivity/push-state-gossip.js +2 -1
  105. package/dist/src/reactivity/push-state-gossip.js.map +1 -1
  106. package/dist/src/repo/cluster-coordinator.d.ts +32 -6
  107. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  108. package/dist/src/repo/cluster-coordinator.js +44 -10
  109. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  110. package/dist/src/repo/coordinator-repo.d.ts +181 -25
  111. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  112. package/dist/src/repo/coordinator-repo.js +650 -62
  113. package/dist/src/repo/coordinator-repo.js.map +1 -1
  114. package/dist/src/repo/served-repo-proxy.d.ts +30 -0
  115. package/dist/src/repo/served-repo-proxy.d.ts.map +1 -0
  116. package/dist/src/repo/served-repo-proxy.js +50 -0
  117. package/dist/src/repo/served-repo-proxy.js.map +1 -0
  118. package/dist/src/repo/service.d.ts +16 -1
  119. package/dist/src/repo/service.d.ts.map +1 -1
  120. package/dist/src/repo/service.js +18 -16
  121. package/dist/src/repo/service.js.map +1 -1
  122. package/dist/src/reputation/types.d.ts +3 -1
  123. package/dist/src/reputation/types.d.ts.map +1 -1
  124. package/dist/src/reputation/types.js +3 -1
  125. package/dist/src/reputation/types.js.map +1 -1
  126. package/dist/src/rn.d.ts +24 -2
  127. package/dist/src/rn.d.ts.map +1 -1
  128. package/dist/src/rn.js +33 -4
  129. package/dist/src/rn.js.map +1 -1
  130. package/dist/src/storage/block-archive.d.ts +153 -0
  131. package/dist/src/storage/block-archive.d.ts.map +1 -0
  132. package/dist/src/storage/block-archive.js +194 -0
  133. package/dist/src/storage/block-archive.js.map +1 -0
  134. package/dist/src/storage/block-latch.d.ts +92 -0
  135. package/dist/src/storage/block-latch.d.ts.map +1 -0
  136. package/dist/src/storage/block-latch.js +132 -0
  137. package/dist/src/storage/block-latch.js.map +1 -0
  138. package/dist/src/storage/block-storage.d.ts +169 -12
  139. package/dist/src/storage/block-storage.d.ts.map +1 -1
  140. package/dist/src/storage/block-storage.js +415 -152
  141. package/dist/src/storage/block-storage.js.map +1 -1
  142. package/dist/src/storage/cached-raw-storage.d.ts +5 -1
  143. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
  144. package/dist/src/storage/cached-raw-storage.js +16 -1
  145. package/dist/src/storage/cached-raw-storage.js.map +1 -1
  146. package/dist/src/storage/cached-store-driver.d.ts +14 -4
  147. package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
  148. package/dist/src/storage/cached-store-driver.js +39 -5
  149. package/dist/src/storage/cached-store-driver.js.map +1 -1
  150. package/dist/src/storage/i-block-storage.d.ts +70 -19
  151. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  152. package/dist/src/storage/i-block-storage.js +21 -1
  153. package/dist/src/storage/i-block-storage.js.map +1 -1
  154. package/dist/src/storage/i-raw-storage.d.ts +38 -0
  155. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  156. package/dist/src/storage/kv-raw-storage.d.ts +14 -8
  157. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
  158. package/dist/src/storage/kv-raw-storage.js +29 -9
  159. package/dist/src/storage/kv-raw-storage.js.map +1 -1
  160. package/dist/src/storage/memory-store-driver.d.ts +4 -1
  161. package/dist/src/storage/memory-store-driver.d.ts.map +1 -1
  162. package/dist/src/storage/memory-store-driver.js +22 -2
  163. package/dist/src/storage/memory-store-driver.js.map +1 -1
  164. package/dist/src/storage/raw-store-codec.js +2 -2
  165. package/dist/src/storage/raw-store-driver.d.ts +44 -3
  166. package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
  167. package/dist/src/storage/shared-cache-pool.d.ts +60 -3
  168. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
  169. package/dist/src/storage/shared-cache-pool.js +68 -4
  170. package/dist/src/storage/shared-cache-pool.js.map +1 -1
  171. package/dist/src/storage/storage-repo.d.ts +204 -26
  172. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  173. package/dist/src/storage/storage-repo.js +422 -119
  174. package/dist/src/storage/storage-repo.js.map +1 -1
  175. package/dist/src/storage/store-identity.d.ts +21 -0
  176. package/dist/src/storage/store-identity.d.ts.map +1 -0
  177. package/dist/src/storage/store-identity.js +33 -0
  178. package/dist/src/storage/store-identity.js.map +1 -0
  179. package/dist/src/storage/struct.d.ts +15 -0
  180. package/dist/src/storage/struct.d.ts.map +1 -1
  181. package/dist/src/storage/with-read-cache.d.ts +92 -0
  182. package/dist/src/storage/with-read-cache.d.ts.map +1 -0
  183. package/dist/src/storage/with-read-cache.js +132 -0
  184. package/dist/src/storage/with-read-cache.js.map +1 -0
  185. package/dist/src/sync/service.d.ts +13 -3
  186. package/dist/src/sync/service.d.ts.map +1 -1
  187. package/dist/src/sync/service.js +7 -30
  188. package/dist/src/sync/service.js.map +1 -1
  189. package/dist/src/testing/mesh-harness.d.ts +75 -2
  190. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  191. package/dist/src/testing/mesh-harness.js +193 -81
  192. package/dist/src/testing/mesh-harness.js.map +1 -1
  193. package/dist/src/testing/raw-storage-conformance.d.ts +8 -0
  194. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  195. package/dist/src/testing/raw-storage-conformance.js +97 -2
  196. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  197. package/package.json +5 -6
  198. package/src/cluster/block-transfer-service.ts +528 -312
  199. package/src/cluster/block-transfer.ts +547 -424
  200. package/src/cluster/certified-claims.ts +319 -0
  201. package/src/cluster/client-signature-verifier.ts +34 -0
  202. package/src/cluster/cluster-policy.ts +294 -203
  203. package/src/cluster/cluster-repo.ts +2501 -2034
  204. package/src/cluster/commit-proof.ts +344 -0
  205. package/src/cluster/quorum-restore.ts +348 -223
  206. package/src/cluster/rebalance-monitor.ts +539 -254
  207. package/src/cluster/reconcile-block.ts +404 -203
  208. package/src/cluster/service.ts +23 -20
  209. package/src/cluster/spread-on-churn.ts +352 -346
  210. package/src/cohort-topic/host.ts +8 -7
  211. package/src/cohort-topic/stream-util.ts +14 -43
  212. package/src/dispute/cascade.ts +31 -15
  213. package/src/dispute/dispute-service.ts +706 -696
  214. package/src/dispute/invalidation.ts +162 -52
  215. package/src/dispute/service.ts +126 -125
  216. package/src/index.ts +7 -0
  217. package/src/libp2p-key-network.ts +1204 -1235
  218. package/src/libp2p-node-base.ts +185 -70
  219. package/src/network/open-protocol-stream.ts +87 -0
  220. package/src/network/register-protocol-handler.ts +85 -0
  221. package/src/peer-address-book.ts +96 -2
  222. package/src/pend-validation.ts +89 -0
  223. package/src/reactivity/notify-transport.ts +2 -1
  224. package/src/reactivity/push-state-gossip.ts +2 -1
  225. package/src/repo/cluster-coordinator.ts +65 -11
  226. package/src/repo/coordinator-repo.ts +729 -71
  227. package/src/repo/served-repo-proxy.ts +55 -0
  228. package/src/repo/service.ts +25 -16
  229. package/src/reputation/types.ts +3 -1
  230. package/src/rn.ts +34 -4
  231. package/src/storage/block-archive.ts +242 -0
  232. package/src/storage/block-latch.ts +144 -0
  233. package/src/storage/block-storage.ts +787 -498
  234. package/src/storage/cached-raw-storage.ts +21 -1
  235. package/src/storage/cached-store-driver.ts +898 -859
  236. package/src/storage/i-block-storage.ts +172 -116
  237. package/src/storage/i-raw-storage.ts +40 -0
  238. package/src/storage/kv-raw-storage.ts +34 -9
  239. package/src/storage/memory-store-driver.ts +26 -2
  240. package/src/storage/raw-store-codec.ts +42 -42
  241. package/src/storage/raw-store-driver.ts +45 -3
  242. package/src/storage/shared-cache-pool.ts +516 -452
  243. package/src/storage/storage-repo.ts +1421 -1014
  244. package/src/storage/store-identity.ts +41 -0
  245. package/src/storage/struct.ts +52 -34
  246. package/src/storage/with-read-cache.ts +182 -0
  247. package/src/sync/service.ts +178 -198
  248. package/src/testing/mesh-harness.ts +524 -344
  249. package/src/testing/raw-storage-conformance.ts +114 -2
@@ -0,0 +1,55 @@
1
+ import type { IRepo } from "@optimystic/db-core";
2
+ import type { ArchiveServingRepo, ProofRetainingRepo } from "../storage/block-archive.js";
3
+
4
+ /**
5
+ * The repo object a node hands its inbound protocol services (`repoService`, `syncService`) — the
6
+ * seam between "a request arrived from the network" and "which local repo answers it".
7
+ *
8
+ * Extracted from `createLibp2pNodeBase` rather than left as an inline literal there, for the reason
9
+ * `resolveClusterPolicy` was: an object built inside the composition root can only be exercised by
10
+ * booting a whole libp2p node, so nothing asserts on it, and a member silently missing from it is
11
+ * invisible. That is not hypothetical — the proxy served every repair archive without its commit
12
+ * proof for exactly as long as it was inline, because `getBlockProof` was never forwarded and every
13
+ * test read a real `StorageRepo` directly.
14
+ *
15
+ * Two delegation rules, and the difference between them is the whole point of the type:
16
+ *
17
+ * - The four `IRepo` members go to the COORDINATED repo once one exists, falling back to local
18
+ * storage before assembly finishes — a client request must get cluster-coordinated semantics.
19
+ * - {@link ArchiveServingRepo.getBlockProof} goes UNCONDITIONALLY to local storage. A peer
20
+ * answering a repair fetch reports the proof IT retained, the same reason the read behind it
21
+ * passes `skipClusterFetch`; one that re-asked its cohort would launder another peer's evidence
22
+ * as its own.
23
+ *
24
+ * @param local this node's own store. Typed {@link ProofRetainingRepo} — the accessor is REQUIRED
25
+ * here even though it is optional on `ArchiveServingRepo`, so a composition root cannot hand over
26
+ * a store that silently serves no proofs.
27
+ * @param coordinated resolves the cluster-coordinated repo, or `undefined` while the node is still
28
+ * assembling. Read per call, never captured: the coordinated repo is constructed after this proxy.
29
+ */
30
+ export function createServedRepoProxy(
31
+ local: ProofRetainingRepo,
32
+ coordinated: () => IRepo | undefined
33
+ ): ArchiveServingRepo {
34
+ const target = (): IRepo => coordinated() ?? local;
35
+ return {
36
+ async get(blockGets, options) {
37
+ return await target().get(blockGets, options);
38
+ },
39
+ async pend(request, options) {
40
+ return await target().pend(request, options);
41
+ },
42
+ async cancel(trxRef, options) {
43
+ return await target().cancel(trxRef, options);
44
+ },
45
+ async commit(request, options) {
46
+ // `target()` is already the plain `IRepo` seam: `StorageRepo.commit`'s extra optional
47
+ // proof parameter (`ICommitProofPersister`) would otherwise make the union's synthesized
48
+ // call signature reject a plain repo-level request. No proof flows through this member.
49
+ return await target().commit(request, options);
50
+ },
51
+ async getBlockProof(blockId, rev) {
52
+ return await local.getBlockProof(blockId, rev);
53
+ }
54
+ };
55
+ }
@@ -8,8 +8,9 @@ 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 { publishableConnectionAddr, type AddressLog, type DirectionalConnection } from '../peer-address-book.js'
11
+ import { publishableAddrsForPeer, type AddressLog, type DirectionalConnection } from '../peer-address-book.js'
12
12
  import { createInboundStreamAuthorization, type InboundStreamAuthorization, type InboundStreamAuthorizationInit } from '../inbound-authorization.js'
13
+ import { registerProtocolHandler } from '../network/register-protocol-handler.js'
13
14
 
14
15
  const debugLog = createLogger('repo-service')
15
16
 
@@ -30,7 +31,13 @@ export type RepoServiceComponents = BaseComponents & {
30
31
  repo: IRepo
31
32
  networkManager?: NetworkManagerLike
32
33
  peerId?: PeerId
33
- getConnectionAddrs?: (peerId: PeerId) => string[]
34
+ /**
35
+ * Optional resolver for the addresses this node may publish for a redirect target. Async
36
+ * because the answer includes the peer's own advertised addresses, which live in the
37
+ * peerStore — see `publishableAddrsForPeer`. A synchronous `string[]` is still accepted so an
38
+ * embedder's connections-only stub keeps working.
39
+ */
40
+ getConnectionAddrs?: (peerId: PeerId) => string[] | Promise<string[]>
34
41
  /**
35
42
  * Optional libp2p node. The production wiring injects the node post-construction
36
43
  * via {@link RepoService.setLibp2p} (the `components.libp2p` proxy does not
@@ -129,7 +136,7 @@ export class RepoService implements Startable {
129
136
  return
130
137
  }
131
138
 
132
- await this.components.registrar.handle(this.protocol, this.handleIncomingStream.bind(this), {
139
+ await registerProtocolHandler(this.components.registrar, this.protocol, this.handleIncomingStream.bind(this), {
133
140
  maxInboundStreams: this.maxInboundStreams,
134
141
  maxOutboundStreams: this.maxOutboundStreams
135
142
  })
@@ -159,19 +166,21 @@ export class RepoService implements Startable {
159
166
  return this.getLibp2p()?.peerId as PeerId | undefined
160
167
  }
161
168
 
162
- private getPeerAddrs(peerId: PeerId): string[] {
163
- if (this.components.getConnectionAddrs) return this.components.getConnectionAddrs(peerId)
169
+ /**
170
+ * The addresses this node may publish for `peerId` in a redirect payload.
171
+ *
172
+ * This fallback is the PRODUCTION source for the repo service: `libp2p-node-base` injects no
173
+ * `getConnectionAddrs` here (the node arrives later, via `setLibp2p`), and unlike a cluster
174
+ * redirect there is no record whose embedded multiaddrs could stand in. A redirect goes to a
175
+ * THIRD party, so it asks `publishableAddrsForPeer` — the one definition, shared with
176
+ * `ClusterService` and `findCluster` — rather than reading connections alone.
177
+ */
178
+ private async getPeerAddrs(peerId: PeerId): Promise<string[]> {
179
+ if (this.components.getConnectionAddrs) return await this.components.getConnectionAddrs(peerId)
164
180
  const libp2p = this.getLibp2p() as any
165
181
  if (!libp2p?.getConnections) return []
166
- // A redirect payload goes to a THIRD party, so only an outbound connection's remoteAddr
167
- // qualifies — see `publishableConnectionAddr`.
168
182
  const conns: DirectionalConnection[] = libp2p.getConnections(peerId) ?? []
169
- const addrs: string[] = []
170
- for (const c of conns) {
171
- const addr = publishableConnectionAddr(c, this.addressLog)
172
- if (addr !== undefined) addrs.push(addr)
173
- }
174
- return addrs
183
+ return await publishableAddrsForPeer(libp2p, conns, peerId, this.addressLog)
175
184
  }
176
185
 
177
186
  /**
@@ -238,10 +247,10 @@ export class RepoService implements Startable {
238
247
  if (!smallMesh && !isMember) {
239
248
  const peers = cluster.filter((p: PeerId) => !peersEqual(p, selfId))
240
249
  debugLog('redirect op=%s blockKey=%s cluster=%d', opName, blockKey, cluster.length)
241
- return encodePeers(peers.map((pid: PeerId) => ({
250
+ return encodePeers(await Promise.all(peers.map(async (pid: PeerId) => ({
242
251
  id: pid.toString(),
243
- addrs: this.getPeerAddrs(pid)
244
- })))
252
+ addrs: await this.getPeerAddrs(pid)
253
+ }))))
245
254
  }
246
255
 
247
256
  return null
@@ -20,7 +20,9 @@ export enum PenaltyReason {
20
20
  FalseApproval = 'false-approval',
21
21
  /** Challenger lost a dispute (their rejection was wrong) */
22
22
  DisputeLost = 'dispute-lost',
23
- /** Peer reported a block latest (rev/actionId) or served content contradicted by a cluster quorum during restoration */
23
+ /** Peer served provably-bad restoration evidence: a latest claim naming a different action at the
24
+ * quorum-agreed revision, served content contradicted by a cluster quorum, or a cohort commit
25
+ * proof that provably lies or does not cover the claim it was attached to (replay) */
24
26
  InvalidRestoration = 'invalid-restoration',
25
27
  }
26
28
 
package/src/rn.ts CHANGED
@@ -1,23 +1,46 @@
1
+ // React Native / browser entry point. `package.json` routes the `react-native` condition on `.`
2
+ // here, plus the explicit `./rn` subpath.
3
+ //
4
+ // NOTE: this entry re-exports exactly the same module set as `./index.js`, with
5
+ // `./libp2p-node.js` -> `./libp2p-node-rn.js` as the single substitution. Because the module set
6
+ // is identical, a class obtained from either entry is the *same* class object on Node, so
7
+ // `instanceof` holds across them; downstream code (e.g. sereus' `cached-storage.ts`) relies on
8
+ // that. Enforced from this side by `test/entry-parity.spec.ts` — keep the two files in step, and
9
+ // keep every line here a plain `export * from '...'` so that spec can compare them.
10
+
1
11
  export * from './cluster/client.js';
2
- export * from './cluster/cluster-policy.js';
12
+ export * from './cluster/client-signature-verifier.js';
3
13
  export * from './cluster/cluster-repo.js';
14
+ export * from './cluster/cluster-policy.js';
15
+ export * from './cluster/commit-cert.js';
16
+ export * from './cluster/commit-proof.js';
4
17
  export * from './cluster/service.js';
18
+ export * from './cluster/rebalance-monitor.js';
19
+ export * from './cluster/spread-on-churn.js';
20
+ export * from './cluster/block-transfer.js';
21
+ export * from './cluster/block-transfer-service.js';
22
+ export * from './inbound-authorization.js';
5
23
  export * from './protocol-client.js';
6
24
  export * from './repo/client.js';
7
25
  export * from './repo/cluster-coordinator.js';
8
26
  export * from './repo/coordinator-repo.js';
27
+ export * from './repo/served-repo-proxy.js';
9
28
  export * from './repo/service.js';
10
29
  export * from './storage/block-storage.js';
30
+ export * from './storage/block-latch.js';
31
+ export * from './storage/store-identity.js';
11
32
  export * from './storage/raw-store-driver.js';
12
33
  export * from './storage/kv-raw-storage.js';
13
34
  export * from './storage/shared-cache-pool.js';
14
35
  export * from './storage/cached-store-driver.js';
15
36
  export * from './storage/cached-raw-storage.js';
37
+ export * from './storage/with-read-cache.js';
16
38
  export * from './storage/memory-store-driver.js';
17
39
  export * from './storage/memory-storage.js';
18
40
  export * from './storage/i-block-storage.js';
19
41
  export * from './storage/i-raw-storage.js';
20
42
  export * from './storage/struct.js';
43
+ export * from './storage/block-archive.js';
21
44
  export * from './storage/storage-repo.js';
22
45
  export * from './storage/restoration-coordinator.js';
23
46
  export * from './storage/ring-selector.js';
@@ -34,6 +57,13 @@ export * from './routing/responsibility.js';
34
57
  export * from './routing/libp2p-known-peers.js';
35
58
  export * from './network/network-manager-service.js';
36
59
  export * from './network/get-network-manager.js';
37
- // Browser-safe peer signing seam. The rest of ./cohort-topic pulls node-heavy host.js,
38
- // so only peer-sig (@noble/curves + peer-id) is surfaced through the RN/browser entry.
39
- export * from './cohort-topic/peer-sig.js';
60
+ export * from './reputation/index.js';
61
+ export * from './dispute/index.js';
62
+ export * from './cohort-topic/index.js';
63
+ export * from './matchmaking/index.js';
64
+ export * from './reactivity/index.js';
65
+ export * from './cluster/i-transaction-state-store.js';
66
+ export * from './cluster/memory-transaction-state-store.js';
67
+ export * from './cluster/persistent-transaction-state-store.js';
68
+ export * from './storage/i-kv-store.js';
69
+ export * from './storage/memory-kv-store.js';
@@ -0,0 +1,242 @@
1
+ import type { ActionContext, ActionRev, BlockId, IBlock, IRepo } from "@optimystic/db-core";
2
+ import type { BlockArchive } from "./struct.js";
3
+ import { proofClaimsCommit, type BlockCommitProof } from "../cluster/commit-proof.js";
4
+ import { createLogger } from "../logger.js";
5
+
6
+ const log = createLogger('block-archive');
7
+
8
+ /**
9
+ * The archive shape every block-repair path exchanges: ONE revision — the one being served —
10
+ * carrying its action, the block itself when the serving repo materialized it, and the cohort's
11
+ * commit proof for that revision when the serving repo retained one.
12
+ *
13
+ * One function rather than the shape re-typed at each site, because three sites had already drifted
14
+ * while each claimed to mirror the others: the sync service served `block: undefined` for a block
15
+ * with no materialized content, the mesh test harness served no archive at all for that same repo
16
+ * state (turning a corroborable revision claim into a phantom non-holder), and the reconcile unit
17
+ * spec's stand-in emitted a third shape again. `createReconcileBlock` reads
18
+ * `revisions[rev].action.actionId` and `revisions[rev].block`, and the difference between "absent
19
+ * block" and "absent archive" decides whether a peer votes in the revision quorum at all — so the
20
+ * shape is a contract, not a detail.
21
+ *
22
+ * `block` stays optional on purpose: a revision whose content the serving repo cannot materialize
23
+ * (a deleted block — see `GetBlockResult.block`) is still a revision that peer legitimately claims.
24
+ * It votes on `(rev, actionId)` and abstains from the content quorum, which is exactly the evidence
25
+ * it holds.
26
+ *
27
+ * `proof` is optional for the same kind of reason (see `ArchiveRevisions.proof`): a pre-proof
28
+ * revision, a diverged member, or an un-upgraded peer serves none, and every consumer must behave
29
+ * exactly as it did before proofs existed when it is absent.
30
+ *
31
+ * NOTE: the `range` emitted here is a COMPATIBILITY CONTRACT with `BlockStorage.vetRestoredArchive`,
32
+ * which refuses an archive whose declared range does not start at its lowest revision, end past its
33
+ * highest, or which is open-ended. `[rev, rev + 1]` around a single revision satisfies all three
34
+ * trivially, and this is currently the ONLY producer of an archive that crosses the restore wire —
35
+ * which is why no test drives a refusal end to end through `RestorationCoordinator`. If a second
36
+ * producer ever appears (a multi-revision or sparse archive, say), that contract stops being
37
+ * trivially true and needs a test pinning the two ends together.
38
+ */
39
+ export function singleRevisionArchive(
40
+ blockId: BlockId,
41
+ source: ActionRev,
42
+ block: IBlock | undefined,
43
+ proof?: BlockCommitProof
44
+ ): BlockArchive {
45
+ return {
46
+ blockId,
47
+ revisions: {
48
+ [source.rev]: {
49
+ action: { actionId: source.actionId, transform: { insert: block } },
50
+ ...(block ? { block } : {}),
51
+ ...(proof ? { proof } : {})
52
+ }
53
+ },
54
+ range: [source.rev, source.rev + 1]
55
+ };
56
+ }
57
+
58
+ /**
59
+ * A peer's answer to the latest-revision consult: the `(rev, actionId)` it claims, plus the cohort's
60
+ * commit proof for that revision when it retained one.
61
+ *
62
+ * Defined HERE, alongside the archive shape, rather than beside the callback that returns it
63
+ * (`ClusterLatestCallback` in `repo/coordinator-repo.ts`, which re-exports this name): a remote
64
+ * peer's answer IS a projection of the archive it served, so keeping the two in one file is what
65
+ * stops the projection from drifting from the shape — the same reason `singleRevisionArchive` is a
66
+ * function rather than a shape re-typed at each site.
67
+ *
68
+ * The proof is OPTIONAL and means nothing until a consumer verifies it. A pre-proof revision, a
69
+ * diverged member, and an un-upgraded peer all legitimately answer without one, and a peer is free
70
+ * to attach whatever it likes — verification, never presence, is what makes a proof evidence.
71
+ */
72
+ export type CertifiedActionRev = ActionRev & { proof?: BlockCommitProof };
73
+
74
+ /**
75
+ * Highest revision an archive covers, or `undefined` when it covers none.
76
+ *
77
+ * ONE implementation for every site that asks an untrusted archive this question — the two repair
78
+ * wires ({@link latestClaimFromArchive}) and the reconcile pass (`cluster/reconcile-block.ts`) — for
79
+ * the same reason {@link singleRevisionArchive} is one function: two copies is how one of them ends
80
+ * up without the guards below.
81
+ *
82
+ * Both guards matter on input a remote peer chose. `Object.keys` on a JSON-parsed archive yields
83
+ * strings, so a non-numeric key coerces to `NaN` and is skipped rather than poisoning the maximum.
84
+ * And the fold is deliberate rather than `Math.max(...keys)`: the spread passes one ARGUMENT per
85
+ * revision, which throws `RangeError: Maximum call stack size exceeded` past ~125k arguments —
86
+ * comfortably inside the 8 MiB `MAX_BLOCK_MESSAGE_BYTES` a sync response may carry (130k minimal
87
+ * revision entries serialize to ~6.3 MiB), so a peer could otherwise choose to make this throw.
88
+ */
89
+ export function maxArchiveRevision(revisions: BlockArchive['revisions'] | undefined): number | undefined {
90
+ let max: number | undefined;
91
+ for (const key of Object.keys(revisions ?? {})) {
92
+ const rev = Number(key);
93
+ if (Number.isFinite(rev) && (max === undefined || rev > max)) max = rev;
94
+ }
95
+ return max;
96
+ }
97
+
98
+ /**
99
+ * The highest-revision claim an archive carries, or `undefined` when it holds no usable revision.
100
+ * `undefined` is the peer having ANSWERED without data — an absent claim, never silence (see
101
+ * `ClusterLatestCallback`'s three-way contract).
102
+ *
103
+ * The proof is read from the SAME revision entry as the `(rev, actionId)`, so a serving peer cannot
104
+ * pair a genuine proof with a revision it does not certify by choosing a different layout. Nothing
105
+ * here verifies anything: the result is the peer's unverified assertion until a caller checks it.
106
+ */
107
+ export function latestClaimFromArchive(archive: BlockArchive): CertifiedActionRev | undefined {
108
+ const maxRev = maxArchiveRevision(archive.revisions);
109
+ if (maxRev === undefined) return undefined;
110
+ const entry = archive.revisions[maxRev];
111
+ if (!entry?.action) return undefined;
112
+ return {
113
+ actionId: entry.action.actionId,
114
+ rev: maxRev,
115
+ ...(entry.proof ? { proof: entry.proof } : {})
116
+ };
117
+ }
118
+
119
+ /**
120
+ * What {@link serveBlockArchive} needs of the repo it reads: `IRepo`, plus — OPTIONALLY — the
121
+ * revision-keyed commit-proof accessor `StorageRepo` implements.
122
+ *
123
+ * Optional rather than required so the unit-test doubles and any other plain `IRepo` that serves
124
+ * archives keep compiling and keep working (they simply serve no proof). A repo that CAN serve
125
+ * proofs and does not is not a type error here — it is the pre-proof behaviour, which stays valid.
126
+ */
127
+ export type ArchiveServingRepo = IRepo & {
128
+ getBlockProof?(blockId: BlockId, rev: number): Promise<BlockCommitProof | undefined>;
129
+ };
130
+
131
+ /**
132
+ * An {@link ArchiveServingRepo} that definitely CAN serve proofs — the accessor required rather
133
+ * than optional.
134
+ *
135
+ * The optionality above exists for a serving repo that legitimately has no proofs (a test double, a
136
+ * plain-`IRepo` embedder). It is the wrong default for a node's OWN store: forgetting the accessor
137
+ * there degrades every archive it serves to proof-less, silently and without a type error. Naming
138
+ * the stronger shape lets a composition root demand it (`createServedRepoProxy`).
139
+ */
140
+ export type ProofRetainingRepo = IRepo & Required<Pick<ArchiveServingRepo, 'getBlockProof'>>;
141
+
142
+ /**
143
+ * Serve `blockId` out of a local repo as a {@link singleRevisionArchive} — what a peer answers a
144
+ * block-repair fetch with. `undefined` when the repo holds no revision of the block at all, which
145
+ * callers report as "holds nothing" (`ReconcileBlockDeps.fetchArchive`'s contract folds
146
+ * "unreachable" into that same answer).
147
+ *
148
+ * The read skips the cluster deliberately: a peer answering a repair fetch reports what IT holds,
149
+ * and one that re-asked its own cohort would launder another peer's claim as its own.
150
+ *
151
+ * `rev` pins the read: `StorageRepo.get` materializes the highest committed revision of the block
152
+ * at or below it and reports that revision as `GetBlockResult.materialized`, which is what the
153
+ * archive is labelled with — its revision number, its action id, and its proof all belong to the
154
+ * bytes actually served. `state.latest` (the repo's NEWEST revision) is never the label: a caller
155
+ * asking for an older revision (`RestorationCoordinator` is the one that does) is answered with
156
+ * that revision as itself, or with nothing. Never with old bytes under a newer label — that pairing
157
+ * is what a receiver keyed by action id writes over its own good copy of the newer revision.
158
+ *
159
+ * The proof served is looked up for the revision ACTUALLY served, never for one chosen
160
+ * independently, so the archive can never publish a proof paired with a revision it does not
161
+ * certify. (A newer revision's proof over older bytes would pass `verifyBlockCommitProofClaim` and
162
+ * fail `verifyBlockCommitProofContent` — strictly worse than no proof.)
163
+ *
164
+ * NOTE: a single-revision archive's proof is a rounding error against the wire cap. A sync
165
+ * *response* is bounded by `MAX_BLOCK_MESSAGE_BYTES` (8 MiB — `SyncClient.requestBlock` sets the
166
+ * response decoder's `maxDataLength`; the 1 MiB `MAX_CONTROL_MESSAGE_BYTES` bounds the inbound
167
+ * REQUEST, not this). A proof's serialized size is dominated by two signatures plus a peer id per
168
+ * cohort member: a whole proof-carrying single-revision archive measured 4801 bytes at a 10-peer
169
+ * cohort and 8851 bytes at 20 (`test/block-archive-proof.spec.ts`, "far below the sync response
170
+ * cap", which prints both), i.e. ~405 bytes per additional peer. Reaching 8 MiB would take a cohort in the tens of thousands, so
171
+ * no plausible cluster size puts this near the cap — the block bytes the archive already carries
172
+ * are the term that matters. Revisit only if a proof ever grows a per-peer payload beyond its two
173
+ * signatures.
174
+ */
175
+ export async function serveBlockArchive(repo: ArchiveServingRepo, blockId: BlockId, rev?: number): Promise<BlockArchive | undefined> {
176
+ // `ActionContext` is `{ committed, rev, actionId? }` — nothing else. `committed: []` claims no
177
+ // uncommitted-but-known action, and omitting `actionId` is what keeps this off `StorageRepo`'s
178
+ // pending-overlay path, so the read is purely "the highest COMMITTED revision at or below `rev`".
179
+ const context: ActionContext | undefined = rev !== undefined ? { rev, committed: [] } : undefined;
180
+ const result = await repo.get({ blockIds: [blockId], context }, { skipClusterFetch: true } as any);
181
+ const entry = result[blockId];
182
+ const latest = entry?.state?.latest;
183
+ if (!latest) return undefined;
184
+ // The revision the content in hand IS. A repo that reports `materialized` (`StorageRepo`) has
185
+ // served the highest committed revision at or below the pin, and that — never `state.latest`,
186
+ // the repo's NEWEST revision — is the archive's label. A repo that does not report it (a plain
187
+ // `IRepo`) can only be describing its latest.
188
+ const served = entry.materialized ?? latest;
189
+ // Fail closed rather than mislabel. A served revision ABOVE the pin is never a right answer:
190
+ // either the repo could not say what it materialized and its latest is newer than what was
191
+ // asked for (so the bytes may be pinned or may not — and the only label in hand is wrong for
192
+ // one of them), or the repo misreported. Labelling old bytes with a newer revision's number and
193
+ // action id is what the asker's `saveRestored` — keyed by action id — then writes over the good
194
+ // copy it already holds. Serve nothing instead: every caller already handles "holds nothing",
195
+ // and `restoreRevision` turns it into a loud "not found during restore attempt". A served
196
+ // revision AT OR BELOW the pin is the block unchanged since the pin, served exactly as before.
197
+ if (rev !== undefined && served.rev > rev) {
198
+ log('serve:skip blockId=%s served=%d requested=%d latest=%d (pinned read — refusing to mislabel content)',
199
+ blockId, served.rev, rev, latest.rev);
200
+ return undefined;
201
+ }
202
+ const proof = await servableProof(repo, blockId, served);
203
+ return singleRevisionArchive(blockId, served, entry.block, proof);
204
+ }
205
+
206
+ /**
207
+ * The stored proof for the revision being served, or `undefined` — including whenever anything is
208
+ * off about it. Serving no proof is always safe (every consumer must already handle its absence),
209
+ * so this fails closed on all three unhappy paths:
210
+ *
211
+ * - the repo has no proof accessor at all (a plain `IRepo`, a test double);
212
+ * - the lookup throws — a storage fault must not turn a servable archive into "holds nothing",
213
+ * because that would recreate the phantom-non-holder bug the archive shape exists to prevent;
214
+ * - the stored proof's own message does not name this `(blockId, rev, actionId)`. That is a local
215
+ * storage-integrity fault, not a peer's doing, and publishing a mis-paired proof would hand
216
+ * every receiver an artifact that cannot verify. Logged loudly, because it means a proof was
217
+ * written under a key its content contradicts.
218
+ *
219
+ * Exported for the SAME reason {@link singleRevisionArchive} is: the mesh test harness answers the
220
+ * latest-revision consult out of a sibling's repo directly rather than over the sync protocol, and
221
+ * a harness that attached proofs by a different rule than a real peer would let every mesh-tier
222
+ * test silently exercise a path production does not have.
223
+ */
224
+ export async function servableProof(
225
+ repo: ArchiveServingRepo, blockId: BlockId, latest: ActionRev
226
+ ): Promise<BlockCommitProof | undefined> {
227
+ if (typeof repo.getBlockProof !== 'function') return undefined;
228
+ let proof: BlockCommitProof | undefined;
229
+ try {
230
+ proof = await repo.getBlockProof(blockId, latest.rev);
231
+ } catch (error) {
232
+ log('serve:proof-lookup-failed blockId=%s rev=%d error=%s', blockId, latest.rev,
233
+ error instanceof Error ? error.message : String(error));
234
+ return undefined;
235
+ }
236
+ if (!proof) return undefined;
237
+ if (!proofClaimsCommit(proof, { blockId, rev: latest.rev, actionId: latest.actionId })) {
238
+ log('serve:proof-claim-mismatch blockId=%s rev=%d actionId=%s', blockId, latest.rev, latest.actionId);
239
+ return undefined;
240
+ }
241
+ return proof;
242
+ }
@@ -0,0 +1,144 @@
1
+ import type { BlockId } from "@optimystic/db-core";
2
+ import { Latches } from "@optimystic/db-core";
3
+
4
+ /**
5
+ * The ONE per-block write lock.
6
+ *
7
+ * A block's metadata is stored as a single blob — `{ latest, ranges }` — read and written whole, so
8
+ * any read-modify-write of it overwrites `latest` whether it meant to or not. The invariant is
9
+ * therefore stated over the whole blob, not over `latest`:
10
+ *
11
+ * > A block's metadata, revision records, action transforms, pending records, and stored proofs are
12
+ * > only ever written while holding {@link blockWriteLatchKey}`(blockId)`.
13
+ *
14
+ * This module is the single acquirer of that key. The check, which deliberately matches the call
15
+ * shape (the escapes keep this very comment from matching):
16
+ *
17
+ * > grep -rnE "Latches\.acquire\(" packages/db-p2p/src
18
+ *
19
+ * That must return exactly one line — the call in `acquireBlockWriteLatch` below. A second hit
20
+ * anywhere means a caller has started taking the key directly and the token discipline has a hole.
21
+ * Every writing method on `IBlockStorage` demands a
22
+ * {@link BlockWriteLatch} token, which only this module can mint, so an unlatched write does not
23
+ * type-check rather than merely being documented as forbidden.
24
+ *
25
+ * `Latches` is a plain FIFO promise-chain mutex — no owner tracking, no re-entrancy — so a holder
26
+ * must never call back into something that acquires the same block's key. The token is what lets a
27
+ * callee prove it is already inside the latch instead of re-acquiring.
28
+ */
29
+ export const blockWriteLatchKey = (blockId: BlockId): string => `Block.write:${blockId}`;
30
+
31
+ let mint!: (blockId: BlockId) => BlockWriteLatch;
32
+ let expire!: (latch: BlockWriteLatch) => void;
33
+
34
+ /**
35
+ * Opaque proof that the bearer is executing inside {@link blockWriteLatchKey}`(blockId)`. Only
36
+ * {@link acquireBlockWriteLatch} (and {@link withBlockWriteLatch} through it) can construct one:
37
+ * the constructor is private and the module-scoped minter is assigned from a static block, where
38
+ * the private constructor is callable — no cast, nothing outside this module can build a token.
39
+ *
40
+ * A token is only valid while the latch it proves is actually held: releasing expires it, so a
41
+ * callback that stashes its token and writes after its scope closed is rejected instead of silently
42
+ * writing unlatched. `live` is the check; only this module can clear it.
43
+ *
44
+ * `BlockStorage` checks `latch.blockId` against its own id on every write, so a token for one block
45
+ * cannot be presented for another.
46
+ */
47
+ export class BlockWriteLatch {
48
+ #live = true;
49
+
50
+ private constructor(readonly blockId: BlockId) { }
51
+
52
+ /** False once the latch this token proves has been released. */
53
+ get live(): boolean {
54
+ return this.#live;
55
+ }
56
+
57
+ static {
58
+ mint = (blockId) => new BlockWriteLatch(blockId);
59
+ expire = (latch) => { latch.#live = false; };
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Acquire the write latch for `blockId`. The non-scoped single-block form is the building block for
65
+ * {@link acquireBlockWriteLatches} and for a caller that must hold one latch across control flow a
66
+ * callback cannot express; everything else should prefer {@link withBlockWriteLatch}. A caller that
67
+ * wants N latches must go through {@link acquireBlockWriteLatches}, not loop over this. The caller
68
+ * MUST call `release` exactly once, in a `finally`. Releasing expires the token, so a write attempted
69
+ * with it afterwards is rejected rather than running outside the latch.
70
+ */
71
+ export async function acquireBlockWriteLatch(blockId: BlockId): Promise<{ latch: BlockWriteLatch; release: () => void }> {
72
+ const releaseLatch = await Latches.acquire(blockWriteLatchKey(blockId));
73
+ const latch = mint(blockId);
74
+ return {
75
+ latch,
76
+ release: () => {
77
+ expire(latch);
78
+ releaseLatch();
79
+ }
80
+ };
81
+ }
82
+
83
+ /** The N-latch hold {@link acquireBlockWriteLatches} returns: a token per block, and one release. */
84
+ export type BlockWriteLatches = {
85
+ /** Token per requested block id, so each write can prove it runs inside that block's latch. */
86
+ readonly latches: ReadonlyMap<BlockId, BlockWriteLatch>;
87
+ /** Releases every latch, in reverse acquisition order. Call exactly once, in a `finally`. */
88
+ readonly release: () => void;
89
+ };
90
+
91
+ /**
92
+ * Acquire the write latches for a whole set of blocks at once — the only sanctioned way to hold more
93
+ * than one. Two callers need it (`StorageRepo.commit` and `applyInvalidation`), and the property that
94
+ * keeps them from deadlocking against each other is not local to either: it is that EVERY multi-latch
95
+ * holder acquires in the one global order. Owning that here makes it a property of the module that
96
+ * owns the key rather than a rule each call site restates and a third one could get wrong.
97
+ *
98
+ * Three things this does that a hand-rolled loop keeps getting wrong:
99
+ * - **Dedups** the ids. `Latches` is a plain FIFO mutex with no re-entrancy, so a repeated id in the
100
+ * request would deadlock the acquirer against itself.
101
+ * - **Sorts** them, so any two multi-latch holders acquire in the same order and no cycle exists.
102
+ * - **Releases what it already took** if an acquisition partway through the set throws, rather than
103
+ * stranding those latches forever.
104
+ *
105
+ * The returned map is keyed by the ids actually held, so a caller that wants its own (e.g. request)
106
+ * ordering can iterate its own list and look each token up.
107
+ */
108
+ export async function acquireBlockWriteLatches(blockIds: Iterable<BlockId>): Promise<BlockWriteLatches> {
109
+ const ordered = Array.from(new Set(blockIds)).sort();
110
+ const releases: (() => void)[] = [];
111
+ const release = () => {
112
+ for (let i = releases.length - 1; i >= 0; i--) {
113
+ releases[i]!();
114
+ }
115
+ releases.length = 0;
116
+ };
117
+ const latches = new Map<BlockId, BlockWriteLatch>();
118
+ try {
119
+ for (const id of ordered) {
120
+ const acquired = await acquireBlockWriteLatch(id);
121
+ releases.push(acquired.release);
122
+ latches.set(id, acquired.latch);
123
+ }
124
+ } catch (err) {
125
+ release();
126
+ throw err;
127
+ }
128
+ return { latches, release };
129
+ }
130
+
131
+ /**
132
+ * Run `fn` while holding the write latch for `blockId`, handing it the token to pass down to the
133
+ * storage writes it makes. Acquire/release is per call, so a caller holds at most one block latch at
134
+ * a time and cannot deadlock against a sorted, up-front {@link acquireBlockWriteLatches} hold — as
135
+ * long as `fn` does not itself acquire another block's latch (nothing in this package does).
136
+ */
137
+ export async function withBlockWriteLatch<T>(blockId: BlockId, fn: (latch: BlockWriteLatch) => Promise<T>): Promise<T> {
138
+ const { latch, release } = await acquireBlockWriteLatch(blockId);
139
+ try {
140
+ return await fn(latch);
141
+ } finally {
142
+ release();
143
+ }
144
+ }