@optimystic/db-p2p 1.0.0-beta.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/dist/src/cluster/block-transfer-service.d.ts +73 -1
  2. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  3. package/dist/src/cluster/block-transfer-service.js +70 -3
  4. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  5. package/dist/src/cluster/block-transfer.d.ts +16 -1
  6. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  7. package/dist/src/cluster/block-transfer.js +73 -72
  8. package/dist/src/cluster/block-transfer.js.map +1 -1
  9. package/dist/src/cluster/client.d.ts +4 -6
  10. package/dist/src/cluster/client.d.ts.map +1 -1
  11. package/dist/src/cluster/client.js +8 -13
  12. package/dist/src/cluster/client.js.map +1 -1
  13. package/dist/src/cluster/cluster-policy.d.ts +2 -1
  14. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  15. package/dist/src/cluster/cluster-policy.js.map +1 -1
  16. package/dist/src/cluster/cluster-repo.d.ts +155 -9
  17. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  18. package/dist/src/cluster/cluster-repo.js +446 -58
  19. package/dist/src/cluster/cluster-repo.js.map +1 -1
  20. package/dist/src/cluster/commit-proof.js +2 -2
  21. package/dist/src/cluster/race-resolution.d.ts +51 -7
  22. package/dist/src/cluster/race-resolution.d.ts.map +1 -1
  23. package/dist/src/cluster/race-resolution.js +71 -7
  24. package/dist/src/cluster/race-resolution.js.map +1 -1
  25. package/dist/src/cluster/rebalance-monitor.d.ts +67 -5
  26. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  27. package/dist/src/cluster/rebalance-monitor.js +120 -26
  28. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  29. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  30. package/dist/src/cluster/spread-on-churn.js +34 -47
  31. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  32. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  33. package/dist/src/cohort-topic/host.js +8 -6
  34. package/dist/src/cohort-topic/host.js.map +1 -1
  35. package/dist/src/cohort-topic/membership-source.js +1 -1
  36. package/dist/src/cohort-topic/membership-source.js.map +1 -1
  37. package/dist/src/cohort-topic/peer-codec.d.ts.map +1 -1
  38. package/dist/src/cohort-topic/peer-codec.js +5 -2
  39. package/dist/src/cohort-topic/peer-codec.js.map +1 -1
  40. package/dist/src/cohort-topic/stream-util.d.ts +24 -3
  41. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  42. package/dist/src/cohort-topic/stream-util.js +36 -3
  43. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  44. package/dist/src/cohort-topic/topic-router.d.ts +9 -0
  45. package/dist/src/cohort-topic/topic-router.d.ts.map +1 -1
  46. package/dist/src/cohort-topic/topic-router.js +12 -2
  47. package/dist/src/cohort-topic/topic-router.js.map +1 -1
  48. package/dist/src/dispute/client.d.ts.map +1 -1
  49. package/dist/src/dispute/client.js +15 -3
  50. package/dist/src/dispute/client.js.map +1 -1
  51. package/dist/src/index.d.ts +5 -0
  52. package/dist/src/index.d.ts.map +1 -1
  53. package/dist/src/index.js +5 -0
  54. package/dist/src/index.js.map +1 -1
  55. package/dist/src/libp2p-key-network.d.ts +78 -17
  56. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  57. package/dist/src/libp2p-key-network.js +234 -147
  58. package/dist/src/libp2p-key-network.js.map +1 -1
  59. package/dist/src/libp2p-node-base.d.ts +35 -1
  60. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  61. package/dist/src/libp2p-node-base.js +151 -26
  62. package/dist/src/libp2p-node-base.js.map +1 -1
  63. package/dist/src/libp2p-node-rn.d.ts +2 -2
  64. package/dist/src/libp2p-node-rn.d.ts.map +1 -1
  65. package/dist/src/libp2p-node-rn.js.map +1 -1
  66. package/dist/src/libp2p-node.d.ts +2 -2
  67. package/dist/src/libp2p-node.d.ts.map +1 -1
  68. package/dist/src/libp2p-node.js.map +1 -1
  69. package/dist/src/logger.d.ts.map +1 -1
  70. package/dist/src/logger.js +6 -1
  71. package/dist/src/logger.js.map +1 -1
  72. package/dist/src/matchmaking/query-transport.d.ts +3 -4
  73. package/dist/src/matchmaking/query-transport.d.ts.map +1 -1
  74. package/dist/src/matchmaking/query-transport.js +9 -9
  75. package/dist/src/matchmaking/query-transport.js.map +1 -1
  76. package/dist/src/network/network-manager-service.d.ts +10 -5
  77. package/dist/src/network/network-manager-service.d.ts.map +1 -1
  78. package/dist/src/network/network-manager-service.js +11 -2
  79. package/dist/src/network/network-manager-service.js.map +1 -1
  80. package/dist/src/network/relay-reservation.d.ts +170 -0
  81. package/dist/src/network/relay-reservation.d.ts.map +1 -0
  82. package/dist/src/network/relay-reservation.js +527 -0
  83. package/dist/src/network/relay-reservation.js.map +1 -0
  84. package/dist/src/pend-validation.d.ts +3 -2
  85. package/dist/src/pend-validation.d.ts.map +1 -1
  86. package/dist/src/pend-validation.js +3 -2
  87. package/dist/src/pend-validation.js.map +1 -1
  88. package/dist/src/protocol-client.d.ts +8 -1
  89. package/dist/src/protocol-client.d.ts.map +1 -1
  90. package/dist/src/protocol-client.js +11 -0
  91. package/dist/src/protocol-client.js.map +1 -1
  92. package/dist/src/reactivity/forwarder-host.d.ts +2 -1
  93. package/dist/src/reactivity/forwarder-host.d.ts.map +1 -1
  94. package/dist/src/reactivity/forwarder-host.js +2 -1
  95. package/dist/src/reactivity/forwarder-host.js.map +1 -1
  96. package/dist/src/reactivity/origination-manager.d.ts +3 -3
  97. package/dist/src/reactivity/origination-manager.js +3 -3
  98. package/dist/src/reactivity/recover-transport.d.ts +22 -14
  99. package/dist/src/reactivity/recover-transport.d.ts.map +1 -1
  100. package/dist/src/reactivity/recover-transport.js +32 -19
  101. package/dist/src/reactivity/recover-transport.js.map +1 -1
  102. package/dist/src/reactivity/subscription-manager.d.ts +2 -2
  103. package/dist/src/reactivity/topic-bytes.d.ts +6 -7
  104. package/dist/src/reactivity/topic-bytes.d.ts.map +1 -1
  105. package/dist/src/reactivity/topic-bytes.js +8 -9
  106. package/dist/src/reactivity/topic-bytes.js.map +1 -1
  107. package/dist/src/repo/client.d.ts +5 -2
  108. package/dist/src/repo/client.d.ts.map +1 -1
  109. package/dist/src/repo/client.js +49 -40
  110. package/dist/src/repo/client.js.map +1 -1
  111. package/dist/src/repo/cluster-coordinator.d.ts +96 -11
  112. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  113. package/dist/src/repo/cluster-coordinator.js +164 -25
  114. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  115. package/dist/src/repo/coordinator-repo.d.ts +240 -122
  116. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  117. package/dist/src/repo/coordinator-repo.js +627 -473
  118. package/dist/src/repo/coordinator-repo.js.map +1 -1
  119. package/dist/src/repo/i-under-replication-ledger.d.ts +87 -0
  120. package/dist/src/repo/i-under-replication-ledger.d.ts.map +1 -0
  121. package/dist/src/repo/i-under-replication-ledger.js +2 -0
  122. package/dist/src/repo/i-under-replication-ledger.js.map +1 -0
  123. package/dist/src/repo/kv-under-replication-ledger.d.ts +91 -0
  124. package/dist/src/repo/kv-under-replication-ledger.d.ts.map +1 -0
  125. package/dist/src/repo/kv-under-replication-ledger.js +287 -0
  126. package/dist/src/repo/kv-under-replication-ledger.js.map +1 -0
  127. package/dist/src/repo/responsibility.d.ts +37 -0
  128. package/dist/src/repo/responsibility.d.ts.map +1 -0
  129. package/dist/src/repo/responsibility.js +37 -0
  130. package/dist/src/repo/responsibility.js.map +1 -0
  131. package/dist/src/repo/service.d.ts +44 -15
  132. package/dist/src/repo/service.d.ts.map +1 -1
  133. package/dist/src/repo/service.js +64 -27
  134. package/dist/src/repo/service.js.map +1 -1
  135. package/dist/src/repo/stuck-reservation.d.ts +127 -0
  136. package/dist/src/repo/stuck-reservation.d.ts.map +1 -0
  137. package/dist/src/repo/stuck-reservation.js +179 -0
  138. package/dist/src/repo/stuck-reservation.js.map +1 -0
  139. package/dist/src/repo/under-replication-drain.d.ts +208 -0
  140. package/dist/src/repo/under-replication-drain.d.ts.map +1 -0
  141. package/dist/src/repo/under-replication-drain.js +461 -0
  142. package/dist/src/repo/under-replication-drain.js.map +1 -0
  143. package/dist/src/rn.d.ts +5 -0
  144. package/dist/src/rn.d.ts.map +1 -1
  145. package/dist/src/rn.js +5 -0
  146. package/dist/src/rn.js.map +1 -1
  147. package/dist/src/storage/block-latch.d.ts +3 -2
  148. package/dist/src/storage/block-latch.d.ts.map +1 -1
  149. package/dist/src/storage/block-latch.js +7 -4
  150. package/dist/src/storage/block-latch.js.map +1 -1
  151. package/dist/src/storage/block-storage.d.ts +45 -3
  152. package/dist/src/storage/block-storage.d.ts.map +1 -1
  153. package/dist/src/storage/block-storage.js +156 -9
  154. package/dist/src/storage/block-storage.js.map +1 -1
  155. package/dist/src/storage/i-block-storage.d.ts +57 -5
  156. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  157. package/dist/src/storage/i-block-storage.js.map +1 -1
  158. package/dist/src/storage/pending-claim.d.ts +167 -0
  159. package/dist/src/storage/pending-claim.d.ts.map +1 -0
  160. package/dist/src/storage/pending-claim.js +156 -0
  161. package/dist/src/storage/pending-claim.js.map +1 -0
  162. package/dist/src/storage/raw-store-codec.d.ts +13 -0
  163. package/dist/src/storage/raw-store-codec.d.ts.map +1 -1
  164. package/dist/src/storage/raw-store-codec.js +24 -3
  165. package/dist/src/storage/raw-store-codec.js.map +1 -1
  166. package/dist/src/storage/restoration-coordinator.d.ts.map +1 -1
  167. package/dist/src/storage/restoration-coordinator.js +2 -1
  168. package/dist/src/storage/restoration-coordinator.js.map +1 -1
  169. package/dist/src/storage/ring-shift-coordinator.d.ts.map +1 -1
  170. package/dist/src/storage/ring-shift-coordinator.js +3 -3
  171. package/dist/src/storage/ring-shift-coordinator.js.map +1 -1
  172. package/dist/src/storage/storage-repo.d.ts +122 -5
  173. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  174. package/dist/src/storage/storage-repo.js +245 -68
  175. package/dist/src/storage/storage-repo.js.map +1 -1
  176. package/dist/src/storage/struct.d.ts +58 -0
  177. package/dist/src/storage/struct.d.ts.map +1 -1
  178. package/dist/src/testing/mesh-harness.d.ts +77 -11
  179. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  180. package/dist/src/testing/mesh-harness.js +226 -100
  181. package/dist/src/testing/mesh-harness.js.map +1 -1
  182. package/package.json +2 -2
  183. package/readme.md +27 -3
  184. package/src/cluster/block-transfer-service.ts +132 -4
  185. package/src/cluster/block-transfer.ts +79 -84
  186. package/src/cluster/client.ts +115 -119
  187. package/src/cluster/cluster-policy.ts +2 -1
  188. package/src/cluster/cluster-repo.ts +493 -65
  189. package/src/cluster/commit-proof.ts +2 -2
  190. package/src/cluster/race-resolution.ts +76 -7
  191. package/src/cluster/rebalance-monitor.ts +677 -539
  192. package/src/cluster/spread-on-churn.ts +34 -54
  193. package/src/cohort-topic/host.ts +8 -6
  194. package/src/cohort-topic/membership-source.ts +1 -1
  195. package/src/cohort-topic/peer-codec.ts +34 -31
  196. package/src/cohort-topic/stream-util.ts +39 -4
  197. package/src/cohort-topic/topic-router.ts +12 -2
  198. package/src/dispute/client.ts +17 -7
  199. package/src/index.ts +64 -59
  200. package/src/libp2p-key-network.ts +1377 -1260
  201. package/src/libp2p-node-base.ts +186 -27
  202. package/src/libp2p-node-rn.ts +31 -30
  203. package/src/libp2p-node.ts +37 -36
  204. package/src/logger.ts +6 -1
  205. package/src/matchmaking/query-transport.ts +9 -9
  206. package/src/network/network-manager-service.ts +16 -5
  207. package/src/network/relay-reservation.ts +636 -0
  208. package/src/pend-validation.ts +3 -2
  209. package/src/protocol-client.ts +13 -1
  210. package/src/reactivity/forwarder-host.ts +2 -1
  211. package/src/reactivity/origination-manager.ts +3 -3
  212. package/src/reactivity/recover-transport.ts +39 -22
  213. package/src/reactivity/subscription-manager.ts +2 -2
  214. package/src/reactivity/topic-bytes.ts +35 -37
  215. package/src/repo/client.ts +172 -173
  216. package/src/repo/cluster-coordinator.ts +183 -25
  217. package/src/repo/coordinator-repo.ts +656 -506
  218. package/src/repo/i-under-replication-ledger.ts +89 -0
  219. package/src/repo/kv-under-replication-ledger.ts +302 -0
  220. package/src/repo/responsibility.ts +48 -0
  221. package/src/repo/service.ts +76 -39
  222. package/src/repo/stuck-reservation.ts +224 -0
  223. package/src/repo/under-replication-drain.ts +582 -0
  224. package/src/rn.ts +74 -69
  225. package/src/storage/block-latch.ts +7 -4
  226. package/src/storage/block-storage.ts +161 -10
  227. package/src/storage/i-block-storage.ts +60 -5
  228. package/src/storage/pending-claim.ts +200 -0
  229. package/src/storage/raw-store-codec.ts +26 -3
  230. package/src/storage/restoration-coordinator.ts +212 -211
  231. package/src/storage/ring-shift-coordinator.ts +271 -271
  232. package/src/storage/storage-repo.ts +280 -72
  233. package/src/storage/struct.ts +58 -0
  234. package/src/testing/mesh-harness.ts +293 -112
package/readme.md CHANGED
@@ -390,7 +390,10 @@ const node = await createLibp2pNode({
390
390
  dropped from it, and `appendAnnounceAddrs` is ignored while it is non-empty. Use
391
391
  `appendAnnounceAddrs` alone when the automatically discovered addresses are still worth advertising.
392
392
  Both are straight passthroughs to libp2p's `addresses.announce` / `addresses.appendAnnounce`; an
393
- empty array means "unset".
393
+ empty array means "unset". Because it hides relayed addresses, `announceAddrs` cannot be combined
394
+ with a listen address naming a relay (the circuit address would never be advertised, so the
395
+ reservation could never be confirmed); node creation rejects that combination before anything
396
+ starts. Use `appendAnnounceAddrs` for such a node.
394
397
 
395
398
  ## Usage Examples
396
399
 
@@ -608,6 +611,13 @@ const node = await createLibp2pNode({
608
611
  });
609
612
  ```
610
613
 
614
+ To be reachable through a relay instead, listen on `<relay address>/p2p-circuit` (the relay's
615
+ full dial address, `/p2p/<relay id>` included). The node reserves a slot on that relay at start and
616
+ keeps it itself afterwards, re-reserving after the relay restarts, after the connection to it drops,
617
+ and across libp2p's routine renewal (`superviseRelayReservation` in `src/network/relay-reservation.ts`);
618
+ node creation still rejects if the relay cannot be reserved at all. A bare `/p2p-circuit` listen
619
+ address is left to the host, which then owns that reservation.
620
+
611
621
  #### React Native
612
622
 
613
623
  For React Native, import from `@optimystic/db-p2p/rn` instead of the root entrypoint.
@@ -640,12 +650,13 @@ before any other imports).
640
650
  |-----|-------------|-------|
641
651
  | `crypto.getRandomValues()` | @noble/hashes, @libp2p/crypto | RN 0.76+ New Architecture provides natively; fallback via e.g. `react-native-get-random-values` |
642
652
  | `crypto.subtle.digest()` | multiformats/hashes/sha2-browser | Async SHA-256/SHA-512 — implement via @noble/hashes |
643
- | `structuredClone()` | @optimystic/db-core | JSON round-trip is sufficient |
653
+ | `structuredClone()` | @optimystic/db-core | JSON round-trip is sufficient. Still required: db-core calls it on ordinary read and write paths (applying an update to a block, for one), though never while a module loads |
644
654
  | `Promise.withResolvers()` | @libp2p/utils, @chainsafe/libp2p-yamux, it-queue | ES2024 — simple shim |
645
655
  | `AbortSignal.prototype.throwIfAborted()` | libp2p, @libp2p/circuit-relay-v2, it-pushable | DOM spec addition — simple shim |
646
656
  | Timer `.ref()` / `.unref()` | @optimystic/db-p2p, undici | Wrap Hermes numeric timer IDs in objects with no-op `.ref()`/`.unref()` methods; patch `clearTimeout`/`clearInterval` to unwrap |
647
657
  | `Event`, `CustomEvent`, `EventTarget` | libp2p, @libp2p/interface | Custom shim or npm `event-target-polyfill` |
648
658
  | `Intl.PluralRules` | moat-maker | English-only ordinal/cardinal shim is sufficient |
659
+ | `TextDecoder` | multiformats, cborg, @optimystic/db-core, @optimystic/db-p2p, @optimystic/db-p2p-storage-rn | **multiformats and cborg construct it at module load, so without it the app fails at startup.** Optimystic's own packages construct it only on first use (see below), which does not make it optional. Expo SDK 52+ provides it; bare React Native's Hermes does not. A UTF-8-only shim is sufficient |
649
660
  | `WebSocket.prototype.bufferedAmount` | @libp2p/websockets | **RN declares the field but never assigns it, so it reads `undefined`.** See below — without this every WebSocket write hangs. |
650
661
 
651
662
  **`WebSocket.bufferedAmount` deserves its own note, because the symptom does not look like a
@@ -675,11 +686,24 @@ mechanism.
675
686
  | `stream` / `node:stream` | libp2p | `readable-stream` (npm) |
676
687
  | `buffer` / `node:buffer` | libp2p, multiformats | `buffer` (npm) |
677
688
 
689
+ These four rows are known to be enough for a React Native build to accept the code: `yarn check:rn`
690
+ (the private `packages/rn-bundle-check` workspace) bundles the `/rn` entry with exactly these aliases
691
+ and the React Native 0.83 toolchain, then compiles the bundle with legacy Hermes. Its
692
+ `test/shim-table-parity.test.mjs` fails if its aliases and this table drift apart, so change them
693
+ together. It never runs the bundle, so the global polyfills above are not verified by it.
694
+
678
695
  **Built-in (no polyfill needed):**
679
696
  - `TextEncoder` — built-in to Hermes
680
- - `TextDecoder` — built-in to Expo SDK 52+ (UTF-8 only)
681
697
  - `BigInt` — built-in to Hermes since RN 0.70
682
698
 
699
+ Optimystic's own code does not require a global `Buffer` — it encodes with `uint8arrays`, and lint (`no-restricted-globals` in `eslint.config.js`) keeps it that way.
700
+
701
+ Optimystic's own code never touches `TextDecoder` or `structuredClone` while a module is loading: each `TextDecoder` is built the first time something decodes, and then reused. So importing `@optimystic/db-p2p/rn` (or db-core, or db-p2p-storage-rn) does not throw *because of Optimystic* on a runtime whose polyfill is not installed yet. **Both are still required polyfills**, and should still be installed before any other import: the third-party dependencies in the table construct `TextDecoder` at load and fail startup without it, and Optimystic needs both as soon as it decodes or applies an update. `NO_MODULE_SCOPE_TEXT_DECODER` in `eslint.config.js` keeps `TextDecoder` construction out of module scope in every package, and `test/module-load-globals.spec.ts` checks both globals at runtime (for the `/rn` entry's load path only, which includes db-core but not db-p2p-storage-rn). The spec loads the `/rn` entry in a fresh process and fails if any first-party module uses either global during the load.
702
+
703
+ Optimystic's own code also does not call `AbortSignal.timeout` or `AbortSignal.any`, call `Promise.withResolvers`, or construct `DOMException` — none of those are guaranteed under Hermes. (It does call `signal.throwIfAborted()`, which stays a required host polyfill per the table above because libp2p needs it anyway.) Every deadline is an explicit `AbortController` plus a timer, cleared on every exit path (see `dialRelay` in `src/network/relay-reservation.ts` and `RepoClient.processRepoMessage` in `src/repo/client.ts`, which hand-rolls the "abort on any of several signals" case rather than using `AbortSignal.any` or the `any-signal` package — the code comment there explains why). `no-restricted-syntax` in `eslint.config.js` keeps it that way.
704
+
705
+ **Dormant, not yet reached:** `crypto.subtle` on React Native provides only `digest`. `@libp2p/crypto`'s ECDSA/RSA import-export and `@libp2p/keychain`'s AES-GCM call other `crypto.subtle` methods and would throw if a host application reached them; nothing in Optimystic's own code path does — peer identity uses Ed25519 through `@noble/curves`/`@noble/hashes`, not the platform `crypto.subtle`. Revisit if a host ever adds a feature that reaches `@libp2p/keychain` or a non-Ed25519 key type on React Native.
706
+
683
707
  See the [Sereus reference-app-rn](https://github.com/gotchoices/sereus/tree/master/packages/reference-app-rn/polyfills) for working polyfill implementations.
684
708
 
685
709
  ### Ring Transitions
@@ -1,5 +1,6 @@
1
1
  import type { Connection, Startable, Stream } from '@libp2p/interface';
2
- import type { IRepo, PeerId, IPeerNetwork, ActionId, ActionRev, GetBlockResult, IBlock, BlockId } from '@optimystic/db-core';
2
+ import type { IRepo, PeerId, IPeerNetwork, ActionId, ActionRev, GetBlockResult, IBlock, BlockId, BlockUnavailableReason, CollectionId } from '@optimystic/db-core';
3
+ import { peerIdFromString } from '@libp2p/peer-id';
3
4
  import { pipe } from 'it-pipe';
4
5
  import * as lp from 'it-length-prefixed';
5
6
  import { fromString as u8FromString } from 'uint8arrays/from-string';
@@ -327,7 +328,7 @@ export class BlockTransferService implements Startable {
327
328
  for (const blockId of request.blockIds) {
328
329
  const blockResult = result[blockId];
329
330
  if (blockResult?.block) {
330
- blocks[blockId] = Buffer.from(JSON.stringify(blockResult.block)).toString('base64');
331
+ blocks[blockId] = u8ToString(u8FromString(JSON.stringify(blockResult.block), 'utf8'), 'base64pad');
331
332
  } else {
332
333
  missing.push(blockId);
333
334
  }
@@ -370,7 +371,7 @@ export class BlockTransferService implements Startable {
370
371
  // Decode + parse the wire payload into an IBlock.
371
372
  let block: IBlock;
372
373
  try {
373
- block = JSON.parse(Buffer.from(data, 'base64').toString('utf8')) as IBlock;
374
+ block = JSON.parse(u8ToString(u8FromString(data, 'base64pad'), 'utf8')) as IBlock;
374
375
  } catch {
375
376
  missing.push(blockId);
376
377
  continue;
@@ -512,7 +513,7 @@ export class BlockTransferClient extends ProtocolClient {
512
513
  ): Promise<BlockTransferResponse> {
513
514
  const blockData: Record<string, string> = {};
514
515
  for (let i = 0; i < blockIds.length; i++) {
515
- blockData[blockIds[i]!] = Buffer.from(blockDataBuffers[i]!).toString('base64');
516
+ blockData[blockIds[i]!] = u8ToString(blockDataBuffers[i]!, 'base64pad');
516
517
  }
517
518
  const request: BlockTransferRequest = {
518
519
  type: 'push', blockIds, reason, blockData,
@@ -522,3 +523,130 @@ export class BlockTransferClient extends ProtocolClient {
522
523
  return await this.processMessage<BlockTransferResponse>(request, this.protocol, { ...options, maxDataLength: MAX_BLOCK_MESSAGE_BYTES });
523
524
  }
524
525
  }
526
+
527
+ // --- The one push loop ---
528
+
529
+ /** Why one peer did not confirm a pushed block. */
530
+ export type PushRefusal = {
531
+ peerId: string;
532
+ /**
533
+ * `rejected`: the peer ANSWERED and listed the block as missing — it did not persist it
534
+ * (uncertified under its `requirePushCertificate`, unparseable, or a persist fault; the receiver
535
+ * logs which). `unreachable`: the push threw — a dial failure, a deadline, a stream error.
536
+ */
537
+ reason: 'rejected' | 'unreachable';
538
+ /** The thrown error's message, for `unreachable`. */
539
+ error?: string;
540
+ };
541
+
542
+ /**
543
+ * What {@link pushBlockToPeers} found and did. `no-local-data` and `unavailable` mean nothing was
544
+ * pushed: the first is "this node holds no revision of the block", the second is "this node could
545
+ * not find out" (a failed restore, an unmaterializable history) — a caller that prunes on the first
546
+ * must NOT prune on the second, or it drops a block it may well hold.
547
+ */
548
+ export type PushBlockOutcome =
549
+ | { status: 'no-local-data' }
550
+ | { status: 'unavailable'; reason: BlockUnavailableReason }
551
+ | {
552
+ status: 'pushed';
553
+ /** The revision pushed — the source's `state.latest`. Absent for a repo that reports none. */
554
+ latest?: ActionRev;
555
+ /** From the pushed block's header, when it names a collection. */
556
+ collectionId?: CollectionId;
557
+ /** Whether a cohort commit proof travelled with the block. Without one a receiver running
558
+ * the default `requirePushCertificate` rejects it, so a `false` here with every peer
559
+ * `rejected` is "cannot place", not "cannot reach". */
560
+ certified: boolean;
561
+ /** Peers that hold the block now: each answered without listing it as missing. */
562
+ confirmed: string[];
563
+ refusals: PushRefusal[];
564
+ /** Peers not pushed to because {@link PushBlockOptions.stopAfterConfirmed} was reached first. */
565
+ skipped: string[];
566
+ };
567
+
568
+ export interface PushBlockOptions {
569
+ /** Default `'replication'`. */
570
+ reason?: BlockTransferRequest['reason'];
571
+ /** The `/optimystic/<networkName>` prefix the receivers register their handler under. */
572
+ protocolPrefix?: string;
573
+ /** Stop once this many distinct peers have confirmed; the rest are reported `skipped`. Default:
574
+ * every peer is pushed to. */
575
+ stopAfterConfirmed?: number;
576
+ /** Per-peer deadlines forwarded to {@link BlockTransferClient.pushBlocks}; omitted = uncapped. */
577
+ dialTimeoutMs?: number;
578
+ responseTimeoutMs?: number;
579
+ }
580
+
581
+ const textEncoder = new TextEncoder();
582
+
583
+ /**
584
+ * Push ONE locally held block to `peerIds`, in order: read it unpinned from `repo`, build its
585
+ * {@link PushCertification} from that same read, push it to each peer, and read each answer the one
586
+ * way that is true — a peer holds the block only when its response does NOT list the block as
587
+ * missing, because `handlePush` omits a block from `missing` only after persisting it (or already
588
+ * holding that revision). A round trip that returns is not a landed replica; a round trip that
589
+ * throws is not a refusal.
590
+ *
591
+ * This is the whole read-certify-push-interpret sequence every sender runs — the rebalance
592
+ * handoff and confirm (`BlockTransferCoordinator`), spread-on-churn, and the under-replication
593
+ * drain — so a change to how a block is placed is made here once. Retry, stopping condition and
594
+ * deadlines are the caller's: `stopAfterConfirmed` for "until one owner accepts" or "until the
595
+ * floor", the two deadlines for a sequential loop that must not hang on a silent peer, and the
596
+ * caller wraps the call in its own retry. It logs nothing per peer; the outcome carries what each
597
+ * caller's own diagnostics say.
598
+ *
599
+ * `repo` is the node's OWN store, so the read never consults the cluster and the certification
600
+ * comes from the proof this node retained; a repo without a proof accessor pushes meta-only
601
+ * (`certified: false`), which a default-configured receiver refuses.
602
+ */
603
+ export async function pushBlockToPeers(
604
+ repo: ArchiveServingRepo,
605
+ peerNetwork: IPeerNetwork,
606
+ blockId: BlockId,
607
+ peerIds: readonly string[],
608
+ options: PushBlockOptions = {}
609
+ ): Promise<PushBlockOutcome> {
610
+ const result = (await repo.get({ blockIds: [blockId] }))[blockId];
611
+ if (result?.unavailable !== undefined) {
612
+ return { status: 'unavailable', reason: result.unavailable };
613
+ }
614
+ if (!result?.block) {
615
+ return { status: 'no-local-data' };
616
+ }
617
+ const blockData = textEncoder.encode(JSON.stringify(result.block));
618
+ const certification = await sourceBlockCertification(repo, blockId, result);
619
+ const reason = options.reason ?? 'replication';
620
+ const deadlines = { dialTimeoutMs: options.dialTimeoutMs, responseTimeoutMs: options.responseTimeoutMs };
621
+ const stopAfter = options.stopAfterConfirmed ?? Number.POSITIVE_INFINITY;
622
+
623
+ const confirmed: string[] = [];
624
+ const refusals: PushRefusal[] = [];
625
+ const skipped: string[] = [];
626
+ for (const peerIdStr of peerIds) {
627
+ if (confirmed.length >= stopAfter) {
628
+ skipped.push(peerIdStr);
629
+ continue;
630
+ }
631
+ try {
632
+ const client = new BlockTransferClient(peerIdFromString(peerIdStr), peerNetwork, options.protocolPrefix);
633
+ const response = await client.pushBlocks([blockId], [blockData], reason, certification, deadlines);
634
+ if (response.missing.includes(blockId)) {
635
+ refusals.push({ peerId: peerIdStr, reason: 'rejected' });
636
+ } else {
637
+ confirmed.push(peerIdStr);
638
+ }
639
+ } catch (err) {
640
+ refusals.push({ peerId: peerIdStr, reason: 'unreachable', error: err instanceof Error ? err.message : String(err) });
641
+ }
642
+ }
643
+ return {
644
+ status: 'pushed',
645
+ latest: result.state?.latest,
646
+ collectionId: result.block.header?.collectionId,
647
+ certified: certification.blockProofs !== undefined,
648
+ confirmed,
649
+ refusals,
650
+ skipped
651
+ };
652
+ }
@@ -1,18 +1,33 @@
1
1
  import type { IPeerNetwork } from '@optimystic/db-core';
2
- import { peerIdFromString } from '@libp2p/peer-id';
3
2
  import type { PartitionDetector } from './partition-detector.js';
4
3
  import type { RestorationCoordinator } from '../storage/restoration-coordinator.js';
5
4
  import type { ProofRetainingRepo } from '../storage/block-archive.js';
6
- import { BlockTransferClient, sourceBlockCertification } from './block-transfer-service.js';
5
+ import { pushBlockToPeers, type PushBlockOutcome, type PushRefusal } from './block-transfer-service.js';
7
6
  import type { GrowthOutcome, RebalanceEvent } from './rebalance-monitor.js';
8
7
  import { createLogger } from '../logger.js';
9
8
 
10
9
  const log = createLogger('block-transfer');
11
10
 
11
+ /** One line per peer that did not confirm, under the calling path's tag (`push` / `confirm`). */
12
+ function logRefusals(path: 'push' | 'confirm', blockId: string, refusals: readonly PushRefusal[]): void {
13
+ for (const refusal of refusals) {
14
+ if (refusal.reason === 'unreachable') {
15
+ log('%s:peer-error block=%s peer=%s err=%s', path, blockId, refusal.peerId, refusal.error);
16
+ } else {
17
+ log('%s:peer-rejected block=%s peer=%s (receiver did not persist)', path, blockId, refusal.peerId);
18
+ }
19
+ }
20
+ }
21
+
12
22
  export interface BlockTransferConfig {
13
23
  /** Max concurrent transfers. Default: 4 */
14
24
  maxConcurrency?: number;
15
- /** Timeout per block transfer (ms). Default: 30000 */
25
+ /**
26
+ * Timeout per block transfer (ms). Default: 30000. A pull is bounded as a whole. A push or
27
+ * confirm is bounded PER PEER, as two deadlines of this length: one on the dial and one on the
28
+ * reply (the `BlockTransferClient.pushBlocks` deadlines, which abort the dial and tear down a
29
+ * silent stream rather than leaving them running).
30
+ */
16
31
  transferTimeoutMs?: number;
17
32
  /** Retry attempts for failed transfers. Default: 2 */
18
33
  maxRetries?: number;
@@ -347,52 +362,29 @@ export class BlockTransferCoordinator {
347
362
  this.inFlight.add(key);
348
363
 
349
364
  try {
365
+ const owners = newOwners.get(blockId);
366
+ if (!owners || owners.length === 0) {
367
+ failed.push(blockId);
368
+ return;
369
+ }
370
+
350
371
  for (let attempt = 0; ; attempt++) {
351
372
  await this.acquireSemaphore();
352
373
  let pushed = false;
353
374
  try {
354
- const owners = newOwners.get(blockId);
355
- if (!owners || owners.length === 0) {
356
- failed.push(blockId);
357
- return;
358
- }
359
-
360
- // Read block data from local storage
361
- const result = await this.repo.get({ blockIds: [blockId] });
362
- const blockResult = result[blockId];
363
- if (!blockResult?.block) {
364
- log('push:no-local-data block=%s', blockId);
375
+ // The shared read-certify-push loop, stopping at the FIRST new owner that accepts. A
376
+ // receiver running the default `requirePushCertificate` rejects a push with no proof,
377
+ // so a block whose proof this node never retained (pre-proof history, a diverged
378
+ // commit) simply fails to place here and is retried/kept as today.
379
+ const outcome = await this.pushBlock(blockId, owners, 1);
380
+ if (outcome.status !== 'pushed') {
381
+ log('push:no-local-data block=%s status=%s', blockId, outcome.status);
365
382
  failed.push(blockId);
366
383
  return;
367
384
  }
368
-
369
- const blockData = new TextEncoder().encode(JSON.stringify(blockResult.block));
370
- // Revision metadata AND the cohort proof for that revision, from this one unpinned
371
- // read. A receiver running the default `requirePushCertificate` rejects a push with
372
- // no proof, so a block whose proof this node never retained (pre-proof history, a
373
- // diverged commit) simply fails to place here and is retried/kept as today.
374
- const certification = await sourceBlockCertification(this.repo, blockId, blockResult);
375
-
376
- // Push to at least one new owner
377
- for (const ownerPeerIdStr of owners) {
378
- try {
379
- const peerId = peerIdFromString(ownerPeerIdStr);
380
- const client = new BlockTransferClient(peerId, this.peerNetwork, this.protocolPrefix);
381
- const response = await this.withTimeout(
382
- client.pushBlocks([blockId], [blockData], 'rebalance', certification),
383
- this.transferTimeoutMs
384
- );
385
-
386
- if (response && !response.missing.includes(blockId)) {
387
- pushed = true;
388
- log('push:ok block=%s peer=%s', blockId, ownerPeerIdStr);
389
- break;
390
- }
391
- } catch (err) {
392
- log('push:peer-error block=%s peer=%s err=%s',
393
- blockId, ownerPeerIdStr, (err as Error).message);
394
- }
395
- }
385
+ logRefusals('push', blockId, outcome.refusals);
386
+ pushed = outcome.confirmed.length > 0;
387
+ if (pushed) log('push:ok block=%s peer=%s', blockId, outcome.confirmed[0]);
396
388
  } finally {
397
389
  this.releaseSemaphore();
398
390
  }
@@ -449,47 +441,28 @@ export class BlockTransferCoordinator {
449
441
  await this.acquireSemaphore();
450
442
  let confirmCount = 0;
451
443
  try {
452
- // Read block data from local storage once per attempt.
453
- const result = await this.repo.get({ blockIds: [blockId] });
454
- const blockResult = result[blockId];
455
- if (!blockResult?.block) {
456
- // No local bytes to prove replication with — cannot confirm; keep serving.
457
- log('confirm:no-local-data block=%s', blockId);
444
+ // The shared read-certify-push loop, once per attempt, stopping once `floor` DISTINCT
445
+ // owners hold a current replica. A confirming holder either takes a certified replica
446
+ // or reports the block missing (see executePush).
447
+ const outcome = await this.pushBlock(blockId, candidateOwners, floor);
448
+ if (outcome.status !== 'pushed') {
449
+ // No local bytes to prove replication with — cannot confirm; keep serving. An
450
+ // `unavailable` read (this node could not find out what it holds) is folded in
451
+ // here as it always was: neither outcome can confirm anything.
452
+ log('confirm:no-local-data block=%s status=%s', blockId, outcome.status);
458
453
  return { confirmed: false, confirmedPeers: allConfirmedPeers, noLocalData: true };
459
454
  }
460
-
461
- const blockData = new TextEncoder().encode(JSON.stringify(blockResult.block));
462
- // See executePush: meta + proof are built together from this one unpinned read, so a
463
- // confirming holder either takes a certified replica or reports the block missing.
464
- const certification = await sourceBlockCertification(this.repo, blockId, blockResult);
465
-
466
- // Count DISTINCT owners that hold a current replica; stop once the floor is reached.
467
- const confirmedPeers = new Set<string>();
468
- for (const ownerPeerIdStr of candidateOwners) {
469
- if (confirmedPeers.size >= floor) break;
470
- try {
471
- const peerId = peerIdFromString(ownerPeerIdStr);
472
- const client = new BlockTransferClient(peerId, this.peerNetwork, this.protocolPrefix);
473
- const response = await this.withTimeout(
474
- client.pushBlocks([blockId], [blockData], 'rebalance', certification),
475
- this.transferTimeoutMs
476
- );
477
- if (response && !response.missing.includes(blockId)) {
478
- confirmedPeers.add(ownerPeerIdStr);
479
- // NOTE: allConfirmedPeers never un-records a peer. A holder that confirms in one
480
- // round and reports `missing` in a later one stays recorded, on the reasoning that
481
- // `handlePush` answers non-missing only after persisting. If the receiver ever
482
- // gains a path that drops a just-persisted block (an eviction sweep, a rejected
483
- // revision), the growth arm would record a peer that no longer holds a replica —
484
- // intersect against the LAST round's confirmedPeers instead of unioning.
485
- allConfirmedPeers.add(ownerPeerIdStr);
486
- }
487
- } catch (err) {
488
- log('confirm:peer-error block=%s peer=%s err=%s',
489
- blockId, ownerPeerIdStr, (err as Error).message);
490
- }
455
+ logRefusals('confirm', blockId, outcome.refusals);
456
+ for (const peerId of outcome.confirmed) {
457
+ // NOTE: allConfirmedPeers never un-records a peer. A holder that confirms in one
458
+ // round and reports `missing` in a later one stays recorded, on the reasoning that
459
+ // `handlePush` answers non-missing only after persisting. If the receiver ever
460
+ // gains a path that drops a just-persisted block (an eviction sweep, a rejected
461
+ // revision), the growth arm would record a peer that no longer holds a replica
462
+ // intersect against the LAST round's confirmed peers instead of unioning.
463
+ allConfirmedPeers.add(peerId);
491
464
  }
492
- confirmCount = confirmedPeers.size;
465
+ confirmCount = outcome.confirmed.length;
493
466
  } finally {
494
467
  this.releaseSemaphore();
495
468
  }
@@ -511,6 +484,20 @@ export class BlockTransferCoordinator {
511
484
  }
512
485
  }
513
486
 
487
+ /**
488
+ * This coordinator's binding of the shared push loop: rebalance reason, this node's protocol
489
+ * prefix, and `transferTimeoutMs` as BOTH per-peer deadlines (see {@link BlockTransferConfig}).
490
+ */
491
+ private pushBlock(blockId: string, peerIds: readonly string[], stopAfterConfirmed: number): Promise<PushBlockOutcome> {
492
+ return pushBlockToPeers(this.repo, this.peerNetwork, blockId, peerIds, {
493
+ reason: 'rebalance',
494
+ protocolPrefix: this.protocolPrefix,
495
+ stopAfterConfirmed,
496
+ dialTimeoutMs: this.transferTimeoutMs,
497
+ responseTimeoutMs: this.transferTimeoutMs
498
+ });
499
+ }
500
+
514
501
  // --- Semaphore for concurrency limiting ---
515
502
 
516
503
  private async acquireSemaphore(): Promise<void> {
@@ -538,10 +525,18 @@ export class BlockTransferCoordinator {
538
525
  return new Promise(resolve => setTimeout(resolve, ms));
539
526
  }
540
527
 
528
+ /**
529
+ * Resolve `undefined` if `promise` has not settled within `ms`. The timer is cleared on either
530
+ * outcome: left running, it outlived every pull, push and confirm by the full transfer timeout and
531
+ * held a stopped node's process open that long (see `withDeadline` in `repo/coordinator-repo.ts`).
532
+ */
541
533
  private withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | undefined> {
542
- return Promise.race([
543
- promise,
544
- new Promise<undefined>(resolve => setTimeout(() => resolve(undefined), ms))
545
- ]);
534
+ let timer: ReturnType<typeof setTimeout> | undefined;
535
+ const timeout = new Promise<undefined>(resolve => {
536
+ timer = setTimeout(() => resolve(undefined), ms);
537
+ });
538
+ return Promise.race([promise, timeout]).finally(() => {
539
+ if (timer !== undefined) clearTimeout(timer);
540
+ });
546
541
  }
547
542
  }