@optimystic/db-p2p 0.13.5 → 0.14.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 (342) hide show
  1. package/README.md +6 -6
  2. package/dist/src/cluster/block-transfer-service.d.ts +67 -8
  3. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  4. package/dist/src/cluster/block-transfer-service.js +101 -62
  5. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  6. package/dist/src/cluster/client.d.ts +18 -1
  7. package/dist/src/cluster/client.d.ts.map +1 -1
  8. package/dist/src/cluster/client.js +55 -39
  9. package/dist/src/cluster/client.js.map +1 -1
  10. package/dist/src/cluster/cluster-error.d.ts +38 -0
  11. package/dist/src/cluster/cluster-error.d.ts.map +1 -0
  12. package/dist/src/cluster/cluster-error.js +51 -0
  13. package/dist/src/cluster/cluster-error.js.map +1 -0
  14. package/dist/src/cluster/cluster-repo.d.ts +135 -3
  15. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  16. package/dist/src/cluster/cluster-repo.js +333 -58
  17. package/dist/src/cluster/cluster-repo.js.map +1 -1
  18. package/dist/src/cluster/commit-cert.d.ts +63 -0
  19. package/dist/src/cluster/commit-cert.d.ts.map +1 -0
  20. package/dist/src/cluster/commit-cert.js +108 -0
  21. package/dist/src/cluster/commit-cert.js.map +1 -0
  22. package/dist/src/cluster/service.d.ts +46 -11
  23. package/dist/src/cluster/service.d.ts.map +1 -1
  24. package/dist/src/cluster/service.js +97 -33
  25. package/dist/src/cluster/service.js.map +1 -1
  26. package/dist/src/cluster/spread-on-churn.d.ts +13 -0
  27. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  28. package/dist/src/cluster/spread-on-churn.js +34 -4
  29. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  30. package/dist/src/cohort-topic/bootstrap-evidence-builder.d.ts +59 -0
  31. package/dist/src/cohort-topic/bootstrap-evidence-builder.d.ts.map +1 -0
  32. package/dist/src/cohort-topic/bootstrap-evidence-builder.js +79 -0
  33. package/dist/src/cohort-topic/bootstrap-evidence-builder.js.map +1 -0
  34. package/dist/src/cohort-topic/bootstrap-evidence-verifiers.d.ts +81 -0
  35. package/dist/src/cohort-topic/bootstrap-evidence-verifiers.d.ts.map +1 -0
  36. package/dist/src/cohort-topic/bootstrap-evidence-verifiers.js +93 -0
  37. package/dist/src/cohort-topic/bootstrap-evidence-verifiers.js.map +1 -0
  38. package/dist/src/cohort-topic/bootstrap-parent-reference.d.ts +108 -0
  39. package/dist/src/cohort-topic/bootstrap-parent-reference.d.ts.map +1 -0
  40. package/dist/src/cohort-topic/bootstrap-parent-reference.js +108 -0
  41. package/dist/src/cohort-topic/bootstrap-parent-reference.js.map +1 -0
  42. package/dist/src/cohort-topic/change-bridge.d.ts +65 -0
  43. package/dist/src/cohort-topic/change-bridge.d.ts.map +1 -0
  44. package/dist/src/cohort-topic/change-bridge.js +76 -0
  45. package/dist/src/cohort-topic/change-bridge.js.map +1 -0
  46. package/dist/src/cohort-topic/cohort-gossip-driver.d.ts +74 -0
  47. package/dist/src/cohort-topic/cohort-gossip-driver.d.ts.map +1 -0
  48. package/dist/src/cohort-topic/cohort-gossip-driver.js +87 -0
  49. package/dist/src/cohort-topic/cohort-gossip-driver.js.map +1 -0
  50. package/dist/src/cohort-topic/cohort-gossip-transport.d.ts +42 -0
  51. package/dist/src/cohort-topic/cohort-gossip-transport.d.ts.map +1 -0
  52. package/dist/src/cohort-topic/cohort-gossip-transport.js +57 -0
  53. package/dist/src/cohort-topic/cohort-gossip-transport.js.map +1 -0
  54. package/dist/src/cohort-topic/fret-trust-anchor.d.ts +100 -0
  55. package/dist/src/cohort-topic/fret-trust-anchor.d.ts.map +1 -0
  56. package/dist/src/cohort-topic/fret-trust-anchor.js +115 -0
  57. package/dist/src/cohort-topic/fret-trust-anchor.js.map +1 -0
  58. package/dist/src/cohort-topic/host.d.ts +573 -0
  59. package/dist/src/cohort-topic/host.d.ts.map +1 -0
  60. package/dist/src/cohort-topic/host.js +1460 -0
  61. package/dist/src/cohort-topic/host.js.map +1 -0
  62. package/dist/src/cohort-topic/index.d.ts +14 -0
  63. package/dist/src/cohort-topic/index.d.ts.map +1 -0
  64. package/dist/src/cohort-topic/index.js +14 -0
  65. package/dist/src/cohort-topic/index.js.map +1 -0
  66. package/dist/src/cohort-topic/membership-publish-sink.d.ts +14 -0
  67. package/dist/src/cohort-topic/membership-publish-sink.d.ts.map +1 -0
  68. package/dist/src/cohort-topic/membership-publish-sink.js +17 -0
  69. package/dist/src/cohort-topic/membership-publish-sink.js.map +1 -0
  70. package/dist/src/cohort-topic/membership-source.d.ts +36 -0
  71. package/dist/src/cohort-topic/membership-source.d.ts.map +1 -0
  72. package/dist/src/cohort-topic/membership-source.js +58 -0
  73. package/dist/src/cohort-topic/membership-source.js.map +1 -0
  74. package/dist/src/cohort-topic/peer-codec.d.ts +18 -0
  75. package/dist/src/cohort-topic/peer-codec.d.ts.map +1 -0
  76. package/dist/src/cohort-topic/peer-codec.js +26 -0
  77. package/dist/src/cohort-topic/peer-codec.js.map +1 -0
  78. package/dist/src/cohort-topic/peer-sig.d.ts +52 -0
  79. package/dist/src/cohort-topic/peer-sig.d.ts.map +1 -0
  80. package/dist/src/cohort-topic/peer-sig.js +83 -0
  81. package/dist/src/cohort-topic/peer-sig.js.map +1 -0
  82. package/dist/src/cohort-topic/protocols.d.ts +49 -0
  83. package/dist/src/cohort-topic/protocols.d.ts.map +1 -0
  84. package/dist/src/cohort-topic/protocols.js +58 -0
  85. package/dist/src/cohort-topic/protocols.js.map +1 -0
  86. package/dist/src/cohort-topic/reactivity-membership-gate.d.ts +49 -0
  87. package/dist/src/cohort-topic/reactivity-membership-gate.d.ts.map +1 -0
  88. package/dist/src/cohort-topic/reactivity-membership-gate.js +42 -0
  89. package/dist/src/cohort-topic/reactivity-membership-gate.js.map +1 -0
  90. package/dist/src/cohort-topic/size-estimator.d.ts +16 -0
  91. package/dist/src/cohort-topic/size-estimator.d.ts.map +1 -0
  92. package/dist/src/cohort-topic/size-estimator.js +16 -0
  93. package/dist/src/cohort-topic/size-estimator.js.map +1 -0
  94. package/dist/src/cohort-topic/stream-util.d.ts +19 -0
  95. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -0
  96. package/dist/src/cohort-topic/stream-util.js +76 -0
  97. package/dist/src/cohort-topic/stream-util.js.map +1 -0
  98. package/dist/src/cohort-topic/threshold-crypto.d.ts +94 -0
  99. package/dist/src/cohort-topic/threshold-crypto.d.ts.map +1 -0
  100. package/dist/src/cohort-topic/threshold-crypto.js +178 -0
  101. package/dist/src/cohort-topic/threshold-crypto.js.map +1 -0
  102. package/dist/src/cohort-topic/topic-router.d.ts +41 -0
  103. package/dist/src/cohort-topic/topic-router.d.ts.map +1 -0
  104. package/dist/src/cohort-topic/topic-router.js +63 -0
  105. package/dist/src/cohort-topic/topic-router.js.map +1 -0
  106. package/dist/src/dispute/cascade.d.ts +179 -0
  107. package/dist/src/dispute/cascade.d.ts.map +1 -0
  108. package/dist/src/dispute/cascade.js +315 -0
  109. package/dist/src/dispute/cascade.js.map +1 -0
  110. package/dist/src/dispute/client.d.ts +2 -1
  111. package/dist/src/dispute/client.d.ts.map +1 -1
  112. package/dist/src/dispute/client.js +12 -3
  113. package/dist/src/dispute/client.js.map +1 -1
  114. package/dist/src/dispute/dispute-service.d.ts +41 -1
  115. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  116. package/dist/src/dispute/dispute-service.js +147 -21
  117. package/dist/src/dispute/dispute-service.js.map +1 -1
  118. package/dist/src/dispute/index.d.ts +2 -0
  119. package/dist/src/dispute/index.d.ts.map +1 -1
  120. package/dist/src/dispute/index.js +2 -0
  121. package/dist/src/dispute/index.js.map +1 -1
  122. package/dist/src/dispute/invalidation.d.ts +266 -0
  123. package/dist/src/dispute/invalidation.d.ts.map +1 -0
  124. package/dist/src/dispute/invalidation.js +405 -0
  125. package/dist/src/dispute/invalidation.js.map +1 -0
  126. package/dist/src/dispute/types.d.ts +14 -0
  127. package/dist/src/dispute/types.d.ts.map +1 -1
  128. package/dist/src/dispute/types.js.map +1 -1
  129. package/dist/src/index.d.ts +4 -0
  130. package/dist/src/index.d.ts.map +1 -1
  131. package/dist/src/index.js +4 -0
  132. package/dist/src/index.js.map +1 -1
  133. package/dist/src/libp2p-key-network.d.ts +7 -0
  134. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  135. package/dist/src/libp2p-key-network.js +49 -5
  136. package/dist/src/libp2p-key-network.js.map +1 -1
  137. package/dist/src/libp2p-node-base.d.ts +45 -3
  138. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  139. package/dist/src/libp2p-node-base.js +522 -5
  140. package/dist/src/libp2p-node-base.js.map +1 -1
  141. package/dist/src/matchmaking/aggregate-counts.d.ts +67 -0
  142. package/dist/src/matchmaking/aggregate-counts.d.ts.map +1 -0
  143. package/dist/src/matchmaking/aggregate-counts.js +60 -0
  144. package/dist/src/matchmaking/aggregate-counts.js.map +1 -0
  145. package/dist/src/matchmaking/index.d.ts +20 -0
  146. package/dist/src/matchmaking/index.d.ts.map +1 -0
  147. package/dist/src/matchmaking/index.js +20 -0
  148. package/dist/src/matchmaking/index.js.map +1 -0
  149. package/dist/src/matchmaking/module.d.ts +181 -0
  150. package/dist/src/matchmaking/module.d.ts.map +1 -0
  151. package/dist/src/matchmaking/module.js +221 -0
  152. package/dist/src/matchmaking/module.js.map +1 -0
  153. package/dist/src/matchmaking/protocols.d.ts +37 -0
  154. package/dist/src/matchmaking/protocols.d.ts.map +1 -0
  155. package/dist/src/matchmaking/protocols.js +42 -0
  156. package/dist/src/matchmaking/protocols.js.map +1 -0
  157. package/dist/src/matchmaking/provider-manager.d.ts +52 -0
  158. package/dist/src/matchmaking/provider-manager.d.ts.map +1 -0
  159. package/dist/src/matchmaking/provider-manager.js +74 -0
  160. package/dist/src/matchmaking/provider-manager.js.map +1 -0
  161. package/dist/src/matchmaking/query-handler.d.ts +34 -0
  162. package/dist/src/matchmaking/query-handler.d.ts.map +1 -0
  163. package/dist/src/matchmaking/query-handler.js +62 -0
  164. package/dist/src/matchmaking/query-handler.js.map +1 -0
  165. package/dist/src/matchmaking/query-transport.d.ts +180 -0
  166. package/dist/src/matchmaking/query-transport.d.ts.map +1 -0
  167. package/dist/src/matchmaking/query-transport.js +315 -0
  168. package/dist/src/matchmaking/query-transport.js.map +1 -0
  169. package/dist/src/matchmaking/seeker-manager.d.ts +39 -0
  170. package/dist/src/matchmaking/seeker-manager.d.ts.map +1 -0
  171. package/dist/src/matchmaking/seeker-manager.js +49 -0
  172. package/dist/src/matchmaking/seeker-manager.js.map +1 -0
  173. package/dist/src/matchmaking/seeker-walk-client.d.ts +127 -0
  174. package/dist/src/matchmaking/seeker-walk-client.d.ts.map +1 -0
  175. package/dist/src/matchmaking/seeker-walk-client.js +189 -0
  176. package/dist/src/matchmaking/seeker-walk-client.js.map +1 -0
  177. package/dist/src/matchmaking/traffic-validation.d.ts +128 -0
  178. package/dist/src/matchmaking/traffic-validation.d.ts.map +1 -0
  179. package/dist/src/matchmaking/traffic-validation.js +105 -0
  180. package/dist/src/matchmaking/traffic-validation.js.map +1 -0
  181. package/dist/src/network/network-manager-service.d.ts +4 -2
  182. package/dist/src/network/network-manager-service.d.ts.map +1 -1
  183. package/dist/src/network/network-manager-service.js +9 -3
  184. package/dist/src/network/network-manager-service.js.map +1 -1
  185. package/dist/src/protocol-client.d.ts +13 -0
  186. package/dist/src/protocol-client.d.ts.map +1 -1
  187. package/dist/src/protocol-client.js +74 -2
  188. package/dist/src/protocol-client.js.map +1 -1
  189. package/dist/src/reactivity/forwarder-host.d.ts +198 -0
  190. package/dist/src/reactivity/forwarder-host.d.ts.map +1 -0
  191. package/dist/src/reactivity/forwarder-host.js +355 -0
  192. package/dist/src/reactivity/forwarder-host.js.map +1 -0
  193. package/dist/src/reactivity/index.d.ts +19 -0
  194. package/dist/src/reactivity/index.d.ts.map +1 -0
  195. package/dist/src/reactivity/index.js +19 -0
  196. package/dist/src/reactivity/index.js.map +1 -0
  197. package/dist/src/reactivity/notify-transport.d.ts +70 -0
  198. package/dist/src/reactivity/notify-transport.d.ts.map +1 -0
  199. package/dist/src/reactivity/notify-transport.js +111 -0
  200. package/dist/src/reactivity/notify-transport.js.map +1 -0
  201. package/dist/src/reactivity/origination-manager.d.ts +110 -0
  202. package/dist/src/reactivity/origination-manager.d.ts.map +1 -0
  203. package/dist/src/reactivity/origination-manager.js +129 -0
  204. package/dist/src/reactivity/origination-manager.js.map +1 -0
  205. package/dist/src/reactivity/protocols.d.ts +43 -0
  206. package/dist/src/reactivity/protocols.d.ts.map +1 -0
  207. package/dist/src/reactivity/protocols.js +50 -0
  208. package/dist/src/reactivity/protocols.js.map +1 -0
  209. package/dist/src/reactivity/push-state-gossip.d.ts +132 -0
  210. package/dist/src/reactivity/push-state-gossip.d.ts.map +1 -0
  211. package/dist/src/reactivity/push-state-gossip.js +231 -0
  212. package/dist/src/reactivity/push-state-gossip.js.map +1 -0
  213. package/dist/src/reactivity/recover-transport.d.ts +167 -0
  214. package/dist/src/reactivity/recover-transport.d.ts.map +1 -0
  215. package/dist/src/reactivity/recover-transport.js +308 -0
  216. package/dist/src/reactivity/recover-transport.js.map +1 -0
  217. package/dist/src/reactivity/rotation-rereg-scheduler.d.ts +142 -0
  218. package/dist/src/reactivity/rotation-rereg-scheduler.d.ts.map +1 -0
  219. package/dist/src/reactivity/rotation-rereg-scheduler.js +223 -0
  220. package/dist/src/reactivity/rotation-rereg-scheduler.js.map +1 -0
  221. package/dist/src/reactivity/subscriber-registry.d.ts +47 -0
  222. package/dist/src/reactivity/subscriber-registry.d.ts.map +1 -0
  223. package/dist/src/reactivity/subscriber-registry.js +87 -0
  224. package/dist/src/reactivity/subscriber-registry.js.map +1 -0
  225. package/dist/src/reactivity/subscription-manager.d.ts +221 -0
  226. package/dist/src/reactivity/subscription-manager.d.ts.map +1 -0
  227. package/dist/src/reactivity/subscription-manager.js +302 -0
  228. package/dist/src/reactivity/subscription-manager.js.map +1 -0
  229. package/dist/src/reactivity/topic-bytes.d.ts +32 -0
  230. package/dist/src/reactivity/topic-bytes.d.ts.map +1 -0
  231. package/dist/src/reactivity/topic-bytes.js +34 -0
  232. package/dist/src/reactivity/topic-bytes.js.map +1 -0
  233. package/dist/src/repo/client.d.ts.map +1 -1
  234. package/dist/src/repo/client.js +51 -23
  235. package/dist/src/repo/client.js.map +1 -1
  236. package/dist/src/repo/service.d.ts +46 -1
  237. package/dist/src/repo/service.d.ts.map +1 -1
  238. package/dist/src/repo/service.js +79 -45
  239. package/dist/src/repo/service.js.map +1 -1
  240. package/dist/src/rpc-deadline.d.ts +37 -0
  241. package/dist/src/rpc-deadline.d.ts.map +1 -0
  242. package/dist/src/rpc-deadline.js +31 -0
  243. package/dist/src/rpc-deadline.js.map +1 -0
  244. package/dist/src/storage/block-storage.d.ts +2 -0
  245. package/dist/src/storage/block-storage.d.ts.map +1 -1
  246. package/dist/src/storage/block-storage.js +95 -2
  247. package/dist/src/storage/block-storage.js.map +1 -1
  248. package/dist/src/storage/i-block-storage.d.ts +25 -0
  249. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  250. package/dist/src/storage/storage-repo.d.ts +53 -2
  251. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  252. package/dist/src/storage/storage-repo.js +193 -9
  253. package/dist/src/storage/storage-repo.js.map +1 -1
  254. package/dist/src/sync/client.d.ts +4 -1
  255. package/dist/src/sync/client.d.ts.map +1 -1
  256. package/dist/src/sync/client.js +5 -2
  257. package/dist/src/sync/client.js.map +1 -1
  258. package/dist/src/testing/cohort-topic-mesh-harness.d.ts +219 -0
  259. package/dist/src/testing/cohort-topic-mesh-harness.d.ts.map +1 -0
  260. package/dist/src/testing/cohort-topic-mesh-harness.js +467 -0
  261. package/dist/src/testing/cohort-topic-mesh-harness.js.map +1 -0
  262. package/dist/src/testing/matchmaking-mesh-harness.d.ts +187 -0
  263. package/dist/src/testing/matchmaking-mesh-harness.d.ts.map +1 -0
  264. package/dist/src/testing/matchmaking-mesh-harness.js +358 -0
  265. package/dist/src/testing/matchmaking-mesh-harness.js.map +1 -0
  266. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  267. package/dist/src/testing/mesh-harness.js +23 -1
  268. package/dist/src/testing/mesh-harness.js.map +1 -1
  269. package/dist/src/testing/reactivity-mesh-harness.d.ts +298 -0
  270. package/dist/src/testing/reactivity-mesh-harness.d.ts.map +1 -0
  271. package/dist/src/testing/reactivity-mesh-harness.js +663 -0
  272. package/dist/src/testing/reactivity-mesh-harness.js.map +1 -0
  273. package/package.json +7 -4
  274. package/src/cluster/block-transfer-service.ts +133 -68
  275. package/src/cluster/client.ts +64 -45
  276. package/src/cluster/cluster-error.ts +64 -0
  277. package/src/cluster/cluster-repo.ts +405 -55
  278. package/src/cluster/commit-cert.ts +139 -0
  279. package/src/cluster/service.ts +116 -44
  280. package/src/cluster/spread-on-churn.ts +328 -285
  281. package/src/cohort-topic/bootstrap-evidence-builder.ts +122 -0
  282. package/src/cohort-topic/bootstrap-evidence-verifiers.ts +132 -0
  283. package/src/cohort-topic/bootstrap-parent-reference.ts +159 -0
  284. package/src/cohort-topic/change-bridge.ts +109 -0
  285. package/src/cohort-topic/cohort-gossip-driver.ts +144 -0
  286. package/src/cohort-topic/cohort-gossip-transport.ts +74 -0
  287. package/src/cohort-topic/fret-trust-anchor.ts +153 -0
  288. package/src/cohort-topic/host.ts +2175 -0
  289. package/src/cohort-topic/index.ts +13 -0
  290. package/src/cohort-topic/membership-publish-sink.ts +20 -0
  291. package/src/cohort-topic/membership-source.ts +68 -0
  292. package/src/cohort-topic/peer-codec.ts +31 -0
  293. package/src/cohort-topic/peer-sig.ts +86 -0
  294. package/src/cohort-topic/protocols.ts +71 -0
  295. package/src/cohort-topic/reactivity-membership-gate.ts +77 -0
  296. package/src/cohort-topic/size-estimator.ts +16 -0
  297. package/src/cohort-topic/stream-util.ts +87 -0
  298. package/src/cohort-topic/threshold-crypto.ts +239 -0
  299. package/src/cohort-topic/topic-router.ts +77 -0
  300. package/src/dispute/cascade.ts +517 -0
  301. package/src/dispute/client.ts +12 -2
  302. package/src/dispute/dispute-service.ts +171 -24
  303. package/src/dispute/index.ts +42 -0
  304. package/src/dispute/invalidation.ts +587 -0
  305. package/src/dispute/types.ts +14 -0
  306. package/src/index.ts +4 -0
  307. package/src/libp2p-key-network.ts +49 -6
  308. package/src/libp2p-node-base.ts +620 -12
  309. package/src/matchmaking/aggregate-counts.ts +104 -0
  310. package/src/matchmaking/index.ts +20 -0
  311. package/src/matchmaking/module.ts +363 -0
  312. package/src/matchmaking/protocols.ts +51 -0
  313. package/src/matchmaking/provider-manager.ts +95 -0
  314. package/src/matchmaking/query-handler.ts +88 -0
  315. package/src/matchmaking/query-transport.ts +483 -0
  316. package/src/matchmaking/seeker-manager.ts +64 -0
  317. package/src/matchmaking/seeker-walk-client.ts +293 -0
  318. package/src/matchmaking/traffic-validation.ts +195 -0
  319. package/src/network/network-manager-service.ts +9 -2
  320. package/src/protocol-client.ts +64 -3
  321. package/src/reactivity/forwarder-host.ts +438 -0
  322. package/src/reactivity/index.ts +19 -0
  323. package/src/reactivity/notify-transport.ts +144 -0
  324. package/src/reactivity/origination-manager.ts +192 -0
  325. package/src/reactivity/protocols.ts +61 -0
  326. package/src/reactivity/push-state-gossip.ts +291 -0
  327. package/src/reactivity/recover-transport.ts +408 -0
  328. package/src/reactivity/rotation-rereg-scheduler.ts +256 -0
  329. package/src/reactivity/subscriber-registry.ts +96 -0
  330. package/src/reactivity/subscription-manager.ts +450 -0
  331. package/src/reactivity/topic-bytes.ts +37 -0
  332. package/src/repo/client.ts +54 -23
  333. package/src/repo/service.ts +90 -42
  334. package/src/rpc-deadline.ts +45 -0
  335. package/src/storage/block-storage.ts +106 -3
  336. package/src/storage/i-block-storage.ts +27 -0
  337. package/src/storage/storage-repo.ts +209 -13
  338. package/src/sync/client.ts +5 -2
  339. package/src/testing/cohort-topic-mesh-harness.ts +613 -0
  340. package/src/testing/matchmaking-mesh-harness.ts +475 -0
  341. package/src/testing/mesh-harness.ts +22 -2
  342. package/src/testing/reactivity-mesh-harness.ts +922 -0
@@ -0,0 +1,408 @@
1
+ /**
2
+ * Reactivity recover RPC transport — the live libp2p backfill/resume recovery protocol
3
+ * (`docs/reactivity.md` §Backfill RPC, §Resume).
4
+ *
5
+ * This is the **pull** companion to the one-way notify transport: a subscriber that detected a gap (or
6
+ * woke from sleep) asks a serving cohort member "what did I miss?" and is brought current. The protocol is
7
+ * **request-reply** — one {@link RecoverRequestV1} frame out, one {@link RecoverReplyV1} frame back over a
8
+ * single stream — so it rides the cohort-topic {@link requestResponse} / {@link handleRequestResponse}
9
+ * helpers (NOT the one-way notify helpers, which would desync the dialer).
10
+ *
11
+ * Two pieces live here, both decoupled from the live node assembly so they unit-test in isolation:
12
+ *
13
+ * - {@link Libp2pReactivityRecoverTransport} — the **outbound** side. Supplies the two db-core seams
14
+ * (`BackfillTransport` / `ResumeTransport`) against one node: pick a target (sticky cohort-hint primary
15
+ * first for the one-RT happy path, else a cohort-walk member), frame the request, exchange it over the
16
+ * recover protocol, and return the inner reply. The wire exchange is injected as a {@link RecoverDialer}
17
+ * so the target-selection + framing logic tests without a real socket; {@link createLibp2pRecoverDialer}
18
+ * is the production libp2p-backed implementation.
19
+ * - {@link createRecoverRequestHandler} / {@link registerRecoverHandler} — the **inbound** serve handler.
20
+ * Decode (bounded) → verify the request's peer-key signature against the dialing peer → reject a
21
+ * replay/stale request → resolve the live `PushState` → `serveBackfill` / `serveResume` → reply. Any
22
+ * failure produces **no reply** (the stream aborts) rather than throwing out of the handler.
23
+ *
24
+ * ## Dial-target encoding bridge (load-bearing)
25
+ *
26
+ * The sticky {@link import("@optimystic/db-core").ReactivityCohortHint}`.primary` is **base64url of
27
+ * cohort member-id bytes** (`base64url(utf8(peerIdString))`), per its db-core JSDoc, whereas
28
+ * {@link requestResponse} needs a `peerIdFromString`-parseable peer-id **string**. Feeding the raw
29
+ * base64url straight to `peerIdFromString` throws → the dial is swallowed → recovery silently never
30
+ * reaches the cohort. {@link decodeCohortHintTarget} pins the conversion
31
+ * (`bytesToPeerIdString(b64urlToBytes(primary))`); the `resolveCohort` walk, by contrast, already returns
32
+ * peer-id strings (the cohort-topic `CohortPeerResolver` space) and is used as-is.
33
+ */
34
+
35
+ import type { Libp2p } from "libp2p";
36
+ import type { PeerId, PrivateKey } from "@libp2p/interface";
37
+ import { peerIdFromString } from "@libp2p/peer-id";
38
+ import {
39
+ b64urlToBytes,
40
+ bytesToB64url,
41
+ reactivityTopicId,
42
+ serveBackfill,
43
+ serveResume,
44
+ backfillSigningPayload,
45
+ resumeSigningPayload,
46
+ encodeRecoverRequestV1,
47
+ decodeRecoverRequestV1,
48
+ encodeRecoverReplyV1,
49
+ decodeRecoverReplyV1,
50
+ type BackfillV1,
51
+ type BackfillReplyV1,
52
+ type BackfillSignable,
53
+ type ResumeV1,
54
+ type ResumeSignable,
55
+ type RecoverKind,
56
+ type RecoverReplyV1,
57
+ type BackfillTransport,
58
+ type PushState,
59
+ type StickyCohortHintCache,
60
+ type CorrelationReplayGuard,
61
+ type RotationRedirectV1,
62
+ } from "@optimystic/db-core";
63
+ import { requestResponse, handleRequestResponse, DEFAULT_STREAM_MAX_BYTES } from "../cohort-topic/stream-util.js";
64
+ import { verifyPeerSig, signPeerSig } from "../cohort-topic/peer-sig.js";
65
+ import { peerIdToBytes, bytesToPeerIdString } from "../cohort-topic/peer-codec.js";
66
+ import { PROTOCOL_REACTIVITY_RECOVER } from "./protocols.js";
67
+ import type { ResumeTransport } from "./subscription-manager.js";
68
+ import { createLogger } from "../logger.js";
69
+
70
+ const log = createLogger("reactivity-recover");
71
+
72
+ /**
73
+ * Decode a sticky-cohort-hint `primary` (base64url of member-id bytes = `base64url(utf8(peerIdString))`)
74
+ * back to the dialable peer-id string. The inverse trap of feeding the raw value to `peerIdFromString`
75
+ * (which expects a peer-id string, not base64url-of-its-utf8) — pinned by the dial-target encoding test.
76
+ */
77
+ export function decodeCohortHintTarget(primary: string): string {
78
+ return bytesToPeerIdString(b64urlToBytes(primary));
79
+ }
80
+
81
+ // --- subscriber request signing (the manager's synchronous signer seam) ---
82
+
83
+ /**
84
+ * The synchronous request-signing seam the production subscribe factory feeds a
85
+ * {@link import("./subscription-manager.js").ReactivitySubscriptionManager}: it builds a
86
+ * {@link BackfillV1} / {@link ResumeV1} signature (base64url) over the unsigned image.
87
+ */
88
+ export interface RecoverRequestSigners {
89
+ /** Sign a {@link BackfillV1} over {@link backfillSigningPayload} of its unsigned image. */
90
+ readonly signBackfill: (unsigned: BackfillSignable) => string;
91
+ /** Sign a {@link ResumeV1} over {@link resumeSigningPayload} of its unsigned image. */
92
+ readonly signResume: (unsigned: ResumeSignable) => string;
93
+ }
94
+
95
+ /**
96
+ * Build the subscriber's recover request signers from the node's libp2p Ed25519 private key.
97
+ *
98
+ * This resolves the recover wiring's one design point: the subscription-manager `signBackfill` /
99
+ * `signResume` ports are **synchronous** (`(unsigned) => string`) — the db-core driver builds the
100
+ * unsigned image internally, so a pre-signed value is impossible — but libp2p `PrivateKey.sign` is
101
+ * **async**. Rather than make the seam (and `createBackfillRequester`'s `sign`, a db-core change) async,
102
+ * sign with the synchronous {@link signPeerSig} (noble, over the raw Ed25519 seed) over db-core's
103
+ * canonical signing payloads. The produced signature verifies on the serving side under
104
+ * {@link verifyPeerSig} over the same bytes (both noble, RFC8032), so the round trip is symmetric.
105
+ */
106
+ export function createRecoverRequestSigners(privateKey: PrivateKey): RecoverRequestSigners {
107
+ return {
108
+ signBackfill: (unsigned: BackfillSignable): string => bytesToB64url(signPeerSig(privateKey, backfillSigningPayload(unsigned))),
109
+ signResume: (unsigned: ResumeSignable): string => bytesToB64url(signPeerSig(privateKey, resumeSigningPayload(unsigned))),
110
+ };
111
+ }
112
+
113
+ // --- outbound transport ---
114
+
115
+ /**
116
+ * Thrown out of {@link Libp2pReactivityRecoverTransport.backfillTransport} / `resumeTransport` when the
117
+ * dialed cohort answered with a `kind: "rotated"` recover reply: the outgoing tail this request reached has
118
+ * rotated and is draining, so it bounced the request to the new tree (`docs/reactivity.md` §Tail rotation).
119
+ * It carries the {@link RotationRedirectV1} so the subscription manager can move itself to the successor.
120
+ *
121
+ * A `kind: "rotated"` reply is **terminal** for the cohort-walk — the dialed member spoke authoritatively for
122
+ * the cohort, so the transport stops rather than falling through to the next target (contrast a *dial
123
+ * failure*, which still falls through). The subscriber honors it like a notification-driven rotation.
124
+ */
125
+ export class RotationRedirectError extends Error {
126
+ /** The drain-window redirect the serving cohort returned (`newTailId` / `newTopicId` / `effectiveAtRevision`). */
127
+ readonly redirect: RotationRedirectV1;
128
+ constructor(redirect: RotationRedirectV1) {
129
+ super(`reactivity recover: cohort rotated to tail ${redirect.newTailId} (effectiveAtRevision ${redirect.effectiveAtRevision})`);
130
+ this.name = "RotationRedirectError";
131
+ this.redirect = redirect;
132
+ }
133
+ }
134
+
135
+ /** The wire exchange seam: open the recover protocol to `target`, send `frame`, return the bounded reply. */
136
+ export interface RecoverDialer {
137
+ /** Exchange one request frame for one reply frame with `target` (peer-id string). Rejects on a dial failure. */
138
+ exchange(target: string, frame: Uint8Array): Promise<Uint8Array>;
139
+ }
140
+
141
+ /** Build the production libp2p-backed {@link RecoverDialer} over {@link requestResponse}. */
142
+ export function createLibp2pRecoverDialer(node: Libp2p, recoverProtocol: string = PROTOCOL_REACTIVITY_RECOVER, maxBytes: number = DEFAULT_STREAM_MAX_BYTES): RecoverDialer {
143
+ return {
144
+ exchange: (target, frame) => requestResponse(node, peerIdFromString(target), recoverProtocol, frame, maxBytes),
145
+ };
146
+ }
147
+
148
+ /** Construction inputs for a {@link Libp2pReactivityRecoverTransport}. */
149
+ export interface Libp2pReactivityRecoverTransportOptions {
150
+ /** Wire exchange (e.g. {@link createLibp2pRecoverDialer} over the live node). */
151
+ readonly dialer: RecoverDialer;
152
+ /** This node's peer-id string (`node.peerId.toString()`); never dialed — a co-located serve is the node wiring's. */
153
+ readonly selfPeerId: string;
154
+ /** Sticky cohort-hint cache, shared with the subscription manager (the one-RT primary for the happy path). */
155
+ readonly cohortHintCache: StickyCohortHintCache;
156
+ /** FRET cohort-walk fallback: a topic id → dialable cohort member peer-id strings. */
157
+ readonly resolveCohort: (topicId: Uint8Array) => string[];
158
+ /** Per-frame ceiling for encode/decode; default {@link DEFAULT_STREAM_MAX_BYTES}. */
159
+ readonly maxBytes?: number;
160
+ }
161
+
162
+ /**
163
+ * The outbound recover transport: one instance per node, exposing the two db-core function seams against it.
164
+ * Each returned transport dials the **sticky primary first** (one round trip after a brief flap), falling
165
+ * back to a **cohort-walk** member on a dial failure (any member that holds the topic's gossiped `PushState`
166
+ * can answer). A kind mismatch or all-targets-failed surfaces as a rejection so the caller's retry/escalation
167
+ * policy (the subscription manager's backfill escalation, or `manager.resume()`'s caller) takes over.
168
+ */
169
+ export class Libp2pReactivityRecoverTransport {
170
+ private readonly dialer: RecoverDialer;
171
+ private readonly selfPeerId: string;
172
+ private readonly cohortHintCache: StickyCohortHintCache;
173
+ private readonly resolveCohort: (topicId: Uint8Array) => string[];
174
+ private readonly maxBytes: number;
175
+
176
+ constructor(options: Libp2pReactivityRecoverTransportOptions) {
177
+ this.dialer = options.dialer;
178
+ this.selfPeerId = options.selfPeerId;
179
+ this.cohortHintCache = options.cohortHintCache;
180
+ this.resolveCohort = options.resolveCohort;
181
+ this.maxBytes = options.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
182
+ }
183
+
184
+ /** The db-core {@link BackfillTransport} for `(topicId, collectionId)` — frames + dials a signed {@link BackfillV1}. */
185
+ backfillTransport(topicId: Uint8Array, collectionId: string): BackfillTransport {
186
+ return async (req: BackfillV1): Promise<BackfillReplyV1> => {
187
+ const frame = encodeRecoverRequestV1({ v: 1, kind: "backfill", backfill: req }, this.maxBytes);
188
+ const reply = await this.exchange("backfill", frame, topicId, collectionId);
189
+ if (reply.backfillReply === undefined) {
190
+ throw new Error("reactivity recover: backfill reply missing its body");
191
+ }
192
+ return reply.backfillReply;
193
+ };
194
+ }
195
+
196
+ /** The {@link ResumeTransport} for `(topicId, collectionId)` — frames + dials a signed {@link ResumeV1}. */
197
+ resumeTransport(topicId: Uint8Array, collectionId: string): ResumeTransport {
198
+ return async (req: ResumeV1) => {
199
+ const frame = encodeRecoverRequestV1({ v: 1, kind: "resume", resume: req }, this.maxBytes);
200
+ const reply = await this.exchange("resume", frame, topicId, collectionId);
201
+ if (reply.resumeReply === undefined) {
202
+ throw new Error("reactivity recover: resume reply missing its body");
203
+ }
204
+ return reply.resumeReply;
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Exchange one recover frame with the first reachable target: sticky primary, then each cohort-walk
210
+ * member. A **dial failure** falls through to the next candidate; a successful dial is **terminal** (the
211
+ * member answered for the cohort) — a `kind: "rotated"` redirect throws {@link RotationRedirectError}, and
212
+ * a reply decoding to the wrong `kind` is a protocol error. Throws when no candidate succeeds.
213
+ */
214
+ private async exchange(kind: RecoverKind, frame: Uint8Array, topicId: Uint8Array, collectionId: string): Promise<RecoverReplyV1> {
215
+ const targets = this.selectTargets(topicId, collectionId);
216
+ if (targets.length === 0) {
217
+ throw new Error("reactivity recover: no serving cohort target resolved");
218
+ }
219
+ let lastErr: unknown;
220
+ for (const target of targets) {
221
+ let replyFrame: Uint8Array;
222
+ try {
223
+ replyFrame = await this.dialer.exchange(target, frame);
224
+ } catch (err) {
225
+ lastErr = err; // dial failure → fall back to the next candidate (sticky → walk)
226
+ log("recover dial to %s failed, trying next target: %o", target, err);
227
+ continue;
228
+ }
229
+ const reply = decodeRecoverReplyV1(replyFrame, this.maxBytes); // a decode failure here is terminal
230
+ // A rotated cohort answered authoritatively: surface the redirect and stop the walk (terminal, never
231
+ // a fallthrough — the dialed member spoke for the cohort).
232
+ if (reply.kind === "rotated") {
233
+ throw new RotationRedirectError(reply.rotated!);
234
+ }
235
+ if (reply.kind !== kind) {
236
+ throw new Error(`reactivity recover: reply kind "${reply.kind}" does not match request kind "${kind}"`);
237
+ }
238
+ return reply;
239
+ }
240
+ throw lastErr ?? new Error("reactivity recover: all targets failed");
241
+ }
242
+
243
+ /**
244
+ * Ordered dial candidates for a recover: the sticky primary (decoded from its base64url-of-bytes form)
245
+ * first, then the cohort-walk members. Self is never dialed (a co-located serve is the node wiring's
246
+ * concern), and duplicates collapse.
247
+ */
248
+ private selectTargets(topicId: Uint8Array, collectionId: string): string[] {
249
+ const seen = new Set<string>();
250
+ const targets: string[] = [];
251
+ const add = (target: string | undefined): void => {
252
+ if (target === undefined || target === this.selfPeerId || seen.has(target)) {
253
+ return;
254
+ }
255
+ seen.add(target);
256
+ targets.push(target);
257
+ };
258
+ const hint = this.cohortHintCache.get(collectionId);
259
+ if (hint?.primary !== undefined) {
260
+ try {
261
+ add(decodeCohortHintTarget(hint.primary));
262
+ } catch (err) {
263
+ log("recover: malformed sticky primary for %s, skipping to cohort-walk: %o", collectionId, err);
264
+ }
265
+ }
266
+ for (const member of this.resolveCohort(topicId)) {
267
+ add(member);
268
+ }
269
+ return targets;
270
+ }
271
+ }
272
+
273
+ // --- inbound serve handler ---
274
+
275
+ /** Live `PushState` resolvers + replay guard the recover serve handler dispatches against. */
276
+ export interface RecoverServeDeps {
277
+ /** Resolve the served `PushState` for an exact reactivity topic id (resume's stale-tail lookup). */
278
+ readonly pushStateFor: (topicId: Uint8Array) => PushState | undefined;
279
+ /** Resolve the served `PushState` for a collection id — the current tail (backfill, and rotated resume). */
280
+ readonly pushStateForCollection: (collectionId: string) => PushState | undefined;
281
+ /** Node-level freshness + anti-replay gate keyed on the request signature bytes. */
282
+ readonly replayGuard: CorrelationReplayGuard;
283
+ /**
284
+ * The drain-window redirect for a request that reached an **outgoing (rotated)** tail still in its drain
285
+ * window, or `undefined` if the resolved topic never rotated / has drained. The node wiring binds this to
286
+ * `ReactivityForwarderHost.rotationRedirectFor`, resolving the old topic from the request: for **resume**
287
+ * the request carries `topicId = reactivityTopicId(latestKnownTailId)`; for **backfill** (no `topicId`)
288
+ * the binding resolves the collection's current served topic. When it returns a redirect the serve replies
289
+ * `kind: "rotated"` instead of serving data, moving the subscriber to the new tree. Absent ⇒ never redirect.
290
+ */
291
+ readonly rotationFor?: (req: { topicId?: Uint8Array; collectionId: string }, now: number) => RotationRedirectV1 | undefined;
292
+ /** Unix-ms clock for the replay-guard window. Default `Date.now`. */
293
+ readonly clock?: () => number;
294
+ /** Per-frame decode ceiling; default {@link DEFAULT_STREAM_MAX_BYTES}. */
295
+ readonly maxBytes?: number;
296
+ }
297
+
298
+ /**
299
+ * Verify the request's peer-key signature against the dialing peer, then admit it through the freshness +
300
+ * anti-replay guard. The dialing peer's id **is** the signer (no signer-id field on the wire); the signature
301
+ * bytes are the anti-replay key (globally unique + authenticated). Returns `false` (reject, no reply) on a
302
+ * bad signature, a stale/future timestamp, or a replay.
303
+ */
304
+ function verifyAndAdmit(deps: RecoverServeDeps, signerId: string, payload: Uint8Array, signatureB64: string, timestamp: number, now: number): boolean {
305
+ const signature = b64urlToBytes(signatureB64);
306
+ if (!verifyPeerSig(signerId, payload, signature)) {
307
+ log("recover serve: signature verification failed for %s (no reply)", signerId);
308
+ return false;
309
+ }
310
+ if (!deps.replayGuard.accept(signature, peerIdToBytes(signerId), timestamp, now)) {
311
+ log("recover serve: replay/stale request from %s (no reply)", signerId);
312
+ return false;
313
+ }
314
+ return true;
315
+ }
316
+
317
+ /**
318
+ * Serve a backfill from the collection's current served tail, or `undefined` if this node serves none. When
319
+ * the node serves **only** the outgoing (draining) tail, `rotationFor` returns the drain redirect and the
320
+ * reply is `kind: "rotated"` instead — a best-effort secondary path (the primary mechanism for an active
321
+ * subscriber is notify-driven detection). Once both tails coexist `pushStateForCollection` resolves the new
322
+ * tail, so the redirect is emitted only while the old tail is the sole served state (see §Tail rotation).
323
+ */
324
+ function serveBackfillReply(deps: RecoverServeDeps, req: BackfillV1, now: number): Uint8Array | undefined {
325
+ const maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
326
+ const redirect = deps.rotationFor?.({ collectionId: req.collectionId }, now);
327
+ if (redirect !== undefined) {
328
+ return encodeRecoverReplyV1({ v: 1, kind: "rotated", rotated: redirect }, maxBytes);
329
+ }
330
+ const ps = deps.pushStateForCollection(req.collectionId);
331
+ if (ps === undefined) {
332
+ return undefined; // not a serving member for this collection → no reply (subscriber walks/chain-reads)
333
+ }
334
+ const backfillReply = serveBackfill(ps.replayBuffer, req, ps.collectionId);
335
+ return encodeRecoverReplyV1({ v: 1, kind: "backfill", backfillReply }, maxBytes);
336
+ }
337
+
338
+ /**
339
+ * Serve a resume against the live `PushState`. A resume reaching the **outgoing (draining)** tail — its
340
+ * `latestKnownTailId` anchors a topic this node has marked rotated — is answered with the drain redirect
341
+ * (`kind: "rotated"`), moving the subscriber to the new tree. Otherwise prefer the exact topic the request's
342
+ * `latestKnownTailId` anchors (so a non-rotated subscriber classifies into backfill/checkpoint/out_of_window);
343
+ * if this node no longer serves that tail's topic, fall back to the collection's current tail so the cohort
344
+ * can still answer `tail_rotated` (its `currentTailId` differs from the request's stale tail) or, for a span
345
+ * that crosses a rotation, serve from the new tail's `inheritedCheckpoint`. `undefined` ⇒ no served state.
346
+ */
347
+ function serveResumeReply(deps: RecoverServeDeps, req: ResumeV1, now: number): Uint8Array | undefined {
348
+ const maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
349
+ const staleTopic = reactivityTopicId(b64urlToBytes(req.latestKnownTailId));
350
+ const redirect = deps.rotationFor?.({ topicId: staleTopic, collectionId: req.collectionId }, now);
351
+ if (redirect !== undefined) {
352
+ return encodeRecoverReplyV1({ v: 1, kind: "rotated", rotated: redirect }, maxBytes);
353
+ }
354
+ const ps = deps.pushStateFor(staleTopic) ?? deps.pushStateForCollection(req.collectionId);
355
+ if (ps === undefined) {
356
+ return undefined;
357
+ }
358
+ const resumeReply = serveResume(req, {
359
+ buffer: ps.replayBuffer,
360
+ checkpoint: ps.checkpoint,
361
+ inheritedCheckpoint: ps.inheritedCheckpoint,
362
+ currentTailId: ps.tailIdAtJoin,
363
+ currentRevision: ps.lastRevision,
364
+ rotationRevision: ps.lastRevision,
365
+ expectedCollectionId: ps.collectionId,
366
+ });
367
+ return encodeRecoverReplyV1({ v: 1, kind: "resume", resumeReply }, maxBytes);
368
+ }
369
+
370
+ /**
371
+ * Build the recover serve callback for {@link handleRequestResponse}: it returns the reply frame, or
372
+ * `undefined` for **no reply** (a decode/verify/replay/resolve failure aborts the stream). It never throws
373
+ * out of the handler.
374
+ */
375
+ export function createRecoverRequestHandler(deps: RecoverServeDeps): (frame: Uint8Array, fromPeer: PeerId) => Promise<Uint8Array | undefined> {
376
+ const maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
377
+ const clock = deps.clock ?? ((): number => Date.now());
378
+ return (frame: Uint8Array, fromPeer: PeerId): Promise<Uint8Array | undefined> => {
379
+ try {
380
+ const req = decodeRecoverRequestV1(frame, maxBytes);
381
+ const signerId = fromPeer.toString();
382
+ const now = clock();
383
+ if (req.kind === "backfill") {
384
+ const b = req.backfill;
385
+ if (b === undefined || !verifyAndAdmit(deps, signerId, backfillSigningPayload(b), b.signature, b.timestamp, now)) {
386
+ return Promise.resolve(undefined);
387
+ }
388
+ return Promise.resolve(serveBackfillReply(deps, b, now));
389
+ }
390
+ const r = req.resume;
391
+ if (r === undefined || !verifyAndAdmit(deps, signerId, resumeSigningPayload(r), r.signature, r.timestamp, now)) {
392
+ return Promise.resolve(undefined);
393
+ }
394
+ return Promise.resolve(serveResumeReply(deps, r, now));
395
+ } catch (err) {
396
+ // A malformed/foreign request (decode failure, foreign collectionId from serve*) must never throw
397
+ // out of the stream handler: log + no reply (the stream aborts, the subscriber falls back).
398
+ log("recover serve: dropping request (no reply): %o", err);
399
+ return Promise.resolve(undefined);
400
+ }
401
+ };
402
+ }
403
+
404
+ /** Register the inbound recover protocol handler on `node` (request-reply over the recover protocol). */
405
+ export function registerRecoverHandler(node: Libp2p, protocol: string, deps: RecoverServeDeps): void {
406
+ const maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
407
+ handleRequestResponse(node, protocol, createRecoverRequestHandler(deps), maxBytes);
408
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Reactivity — tail-rotation re-registration timer host seam (`docs/reactivity.md` §Tail rotation step 3).
3
+ *
4
+ * The {@link import("./subscription-manager.js").ReactivitySubscriptionManager} surfaces a
5
+ * {@link import("./subscription-manager.js").RotationNotice} **once per successor tail** (both the
6
+ * notify-driven pre-announce and the recover-driven `RotationRedirectError` converge on the same
7
+ * `surfaceRotation` seam), but it neither schedules the timer nor performs the move. This host component
8
+ * does: it consumes a notice, schedules a one-shot timer for `max(0, plan.fireAt - now())`, and on fire
9
+ * invokes an injected `reRegister(plan)` seam that re-subscribes the watcher at the rotated tree. The node
10
+ * composition that constructs the manager + binds this scheduler to its `onRotation` observer lives in
11
+ * `reactivity-rotation-host-wiring-e2e`; this module is the standalone, unit-testable piece.
12
+ *
13
+ * **Where the stagger lives.** The spread that keeps the new tail from being flooded is entirely in
14
+ * `plan.fireAt`, drawn by the **manager's** `rejoinJitter`. This scheduler only *consumes* `plan.fireAt`; it
15
+ * never sees the jitter. The manager uses the *single*-subscriber planner `planReRegistration` →
16
+ * `RejoinJitter.scheduleRejoin`, which draws a **uniform** offset `now + ⌊U[0, T_rejoin_jitter)⌋` — so the
17
+ * load-bearing knob for this path is the **window** `T_rejoin_jitter` (default 30 s), not a `capPromote`. Each
18
+ * subscriber jitters independently, giving the new tail an inbound rate of ≈ `subscribers / T_rejoin_jitter`
19
+ * in expectation; the burst is absorbed on the *receiving* side by the new tail cohort's `cap_promote_fast`
20
+ * fast-promotion (a cohort-topic promotion mechanism — see `docs/reactivity.md` §Tail rotation rotation-cost
21
+ * and the Worked scenario), which is independent of the jitter's `capPromote`.
22
+ *
23
+ * Note that `RejoinJitter`'s `capPromote` is consulted **only** by the *wave* planner
24
+ * `scheduleWave` / `planReRegistrationWave` (a single host staggering a whole wave with a hard per-window
25
+ * ceiling — used by the mesh harness, not by the production manager). If the composing site
26
+ * (`reactivity-rotation-host-wiring-e2e`) ever switches the manager onto the wave planner, *then* it must
27
+ * build the jitter as `createRejoinJitter({ capPromote: DEFAULT_CAP_PROMOTE_FAST })` (= 32), since the default
28
+ * `createRejoinJitter()` cap is the cohort-failure `cap_promote = 64`. For the current single-planner path,
29
+ * setting `capPromote` has **no effect** on `fireAt`.
30
+ *
31
+ * **Determinism.** An injected `setTimer(fn, delayMs) => cancel` and `now()` clock make the scheduler
32
+ * testable with a fake clock (no wall clock). They default to a production binding: an **unref'd**
33
+ * `setTimeout`/`clearTimeout` and `Date.now`. The unref is load-bearing — an idle re-registration timer must
34
+ * not pin an otherwise-idle process, mirroring the push-state-gossip driver's unref'd timer
35
+ * ({@link import("./push-state-gossip.js").ReactivityPushStateGossipDriver}).
36
+ *
37
+ * **Idempotence.** De-duped by successor `newTopicId` (base64url): a second notice for a successor already
38
+ * scheduled or fired is ignored. The manager already fires once per successor (its `rotationHandledFor`
39
+ * guard), but a redirect and a pre-announce can surface the **same** successor near-simultaneously, so the
40
+ * scheduler is independently idempotent. The de-dupe ledger (`seen`) survives the timer fire (so a late
41
+ * re-surface of an already-fired successor stays a no-op), which on the normal schedule→fire path would let it
42
+ * grow without bound across a long-lived, fast-rotating subscription. It is therefore **bounded**: capped at
43
+ * `SEEN_LEDGER_CAP` entries with oldest-first eviction (insertion order), and eviction **never** drops a key
44
+ * still in `pending` — that would break the "every pending key ∈ `seen`" invariant and could let a duplicate
45
+ * notice for a pending successor arm a second timer over the live one. A re-surface older than the cap (≥ cap
46
+ * distinct successors later) degrades to at most one harmless, idempotent re-register.
47
+ *
48
+ * **Chained rotation OLD→A→B** before A's timer fires → A and B are distinct successors → two independent
49
+ * timers; **both may fire**. Re-registering at A and then immediately rotating A→B is self-correcting via the
50
+ * manager's `rotationHandledFor` guard, so a superseded timer is intentionally **not** cancelled (a
51
+ * superseded re-register is harmless and rare).
52
+ */
53
+
54
+ import { bytesToB64url, type ReRegistrationPlan } from "@optimystic/db-core";
55
+ import type { RotationNotice } from "./subscription-manager.js";
56
+ import { createLogger } from "../logger.js";
57
+
58
+ const log = createLogger("reactivity-rotation-rereg");
59
+
60
+ /**
61
+ * Upper bound on the idempotence ledger (`seen`). Far above any realistic count of *concurrently-pending*
62
+ * successors (single digits: 1 in the normal case, a handful across a chained rotation), so it never evicts a
63
+ * still-live entry in practice while keeping the ledger's memory to tens of KB of short base64url strings at the
64
+ * ceiling. Tunable — raising it widens the late-re-surface de-dupe window at a proportional memory cost.
65
+ * Exported for the regression test that asserts the bound; production callers don't read it.
66
+ */
67
+ export const SEEN_LEDGER_CAP = 1024;
68
+
69
+ /** Cancel handle returned by an injected timer; cancels a not-yet-fired timer (safe no-op after fire/cancel). */
70
+ export type RotationTimerCancel = () => void;
71
+
72
+ /**
73
+ * Production timer binding: a one-shot `setTimeout` whose handle is **unref'd** so a pending re-registration
74
+ * never keeps an otherwise-idle process alive. The returned handle clears the timeout (idempotent — clearing
75
+ * an already-fired/cleared timeout is a no-op).
76
+ */
77
+ function defaultSetTimer(fn: () => void, delayMs: number): RotationTimerCancel {
78
+ const handle = setTimeout(fn, delayMs);
79
+ // Node timers keep the event loop alive; an idle rotation timer must not pin a process (mirror push-state gossip).
80
+ (handle as { unref?: () => void }).unref?.();
81
+ return (): void => clearTimeout(handle);
82
+ }
83
+
84
+ /** Construction inputs for a {@link RotationReRegistrationScheduler}. */
85
+ export interface RotationReRegistrationSchedulerOptions {
86
+ /**
87
+ * Perform the actual move: re-subscribe the watcher at the rotated tree (the new `topicId`, carrying the
88
+ * plan's `lastRevision`). A rejection is isolated + logged — a failed move must never throw out of the
89
+ * timer callback. Not retried this pass (see the class doc).
90
+ */
91
+ readonly reRegister: (plan: ReRegistrationPlan) => Promise<void>;
92
+ /**
93
+ * Schedule a one-shot timer; returns a cancel handle. Defaults to an **unref'd** `setTimeout`. Tests inject
94
+ * a fake timer queue for determinism.
95
+ */
96
+ readonly setTimer?: (fn: () => void, delayMs: number) => RotationTimerCancel;
97
+ /** Clock (Unix ms). Defaults to `Date.now`; tests inject a fake clock. */
98
+ readonly now?: () => number;
99
+ }
100
+
101
+ /**
102
+ * Hosts the per-successor one-shot timers that move a subscriber to the rotated tree. Construct one per
103
+ * subscription manager and bind {@link schedule} to the manager's
104
+ * {@link import("./subscription-manager.js").ReactivitySubscriptionManagerOptions.onRotation} observer.
105
+ *
106
+ * See the module doc for where the re-registration stagger actually lives (the manager's `rejoinJitter`; this
107
+ * scheduler only consumes `plan.fireAt`, it does not draw it).
108
+ */
109
+ export class RotationReRegistrationScheduler {
110
+ private readonly reRegister: (plan: ReRegistrationPlan) => Promise<void>;
111
+ private readonly setTimer: (fn: () => void, delayMs: number) => RotationTimerCancel;
112
+ private readonly now: () => number;
113
+
114
+ /** Successors with a still-pending timer, keyed by base64url `newTopicId` → its cancel handle. */
115
+ private readonly pending = new Map<string, RotationTimerCancel>();
116
+ /**
117
+ * Successors recently scheduled (pending **or** already fired) — the idempotence ledger that survives fire.
118
+ * Insertion-ordered and **bounded** at {@link SEEN_LEDGER_CAP}: {@link evictSeenOverCap} drops the oldest
119
+ * non-pending entries once over cap (never a still-pending key — see that method's doc).
120
+ */
121
+ private readonly seen = new Set<string>();
122
+ private stopped = false;
123
+
124
+ constructor(options: RotationReRegistrationSchedulerOptions) {
125
+ this.reRegister = options.reRegister;
126
+ this.setTimer = options.setTimer ?? defaultSetTimer;
127
+ this.now = options.now ?? ((): number => Date.now());
128
+ }
129
+
130
+ /** Successors with a timer still pending (not yet fired/cancelled). Diagnostic / test seam. */
131
+ get pendingCount(): number {
132
+ return this.pending.size;
133
+ }
134
+
135
+ /**
136
+ * Size of the idempotence ledger (pending + recently-fired-and-retained). Diagnostic / test seam; the
137
+ * regression test asserts this stays ≤ {@link SEEN_LEDGER_CAP} under unbounded sequential rotations.
138
+ */
139
+ get seenCount(): number {
140
+ return this.seen.size;
141
+ }
142
+
143
+ /**
144
+ * Schedule the re-registration timer for a rotation notice. De-duped by successor `newTopicId`: a second
145
+ * notice for an already-scheduled-or-fired successor is a no-op (a redirect and a pre-announce can surface
146
+ * the same successor). The delay is `max(0, plan.fireAt - now())`, so a `fireAt` already in the past fires
147
+ * on the next tick (clamped to 0, never negative). A no-op once {@link stop} has run.
148
+ */
149
+ schedule(notice: RotationNotice): void {
150
+ if (this.stopped) {
151
+ return;
152
+ }
153
+ const key = bytesToB64url(notice.plan.newTopicId);
154
+ if (this.seen.has(key)) {
155
+ log("rotation re-registration already scheduled/fired for successor topic=%s — ignoring duplicate notice (preAnnounced=%s)", key, notice.preAnnounced);
156
+ return;
157
+ }
158
+ this.seen.add(key);
159
+ const delayMs = Math.max(0, notice.plan.fireAt - this.now());
160
+ const plan = notice.plan;
161
+ const cancel = this.setTimer((): void => {
162
+ this.fire(key, plan);
163
+ }, delayMs);
164
+ this.pending.set(key, cancel);
165
+ // Bound `seen` only AFTER the new key is in `pending`, so eviction sees it as live and never drops it (the
166
+ // "every pending key ∈ seen" invariant). Were this run before `pending.set`, the just-added key would be the
167
+ // one non-pending candidate whenever `pending` is at the cap, and would be wrongly evicted then immediately
168
+ // re-added to `pending` — a pending key absent from `seen`, which a duplicate notice could then re-arm over.
169
+ this.evictSeenOverCap();
170
+ log("scheduled rotation re-registration for successor topic=%s in %dms (preAnnounced=%s)", key, delayMs, notice.preAnnounced);
171
+ }
172
+
173
+ /**
174
+ * Cancel a single pending timer (by successor `newTopicId`) or, when called with no argument, **all**
175
+ * pending timers (teardown). Cancelling an already-fired or unknown successor is a safe no-op. A cancelled
176
+ * successor is forgotten (dropped from the idempotence ledger), so a later notice for it would reschedule;
177
+ * use {@link stop} for permanent teardown.
178
+ */
179
+ cancel(newTopicId?: Uint8Array): void {
180
+ if (newTopicId === undefined) {
181
+ for (const cancelTimer of this.pending.values()) {
182
+ cancelTimer();
183
+ }
184
+ this.pending.clear();
185
+ this.seen.clear();
186
+ return;
187
+ }
188
+ const key = bytesToB64url(newTopicId);
189
+ const cancelTimer = this.pending.get(key);
190
+ if (cancelTimer !== undefined) {
191
+ cancelTimer();
192
+ this.pending.delete(key);
193
+ }
194
+ this.seen.delete(key);
195
+ }
196
+
197
+ /**
198
+ * Stop the scheduler permanently (teardown): cancel every pending timer and refuse all further scheduling
199
+ * and firing. A timer that races `stop` (its callback already queued) finds the `stopped` gate and never
200
+ * invokes `reRegister`. Idempotent.
201
+ */
202
+ stop(): void {
203
+ this.stopped = true;
204
+ for (const cancelTimer of this.pending.values()) {
205
+ cancelTimer();
206
+ }
207
+ this.pending.clear();
208
+ this.seen.clear();
209
+ }
210
+
211
+ /**
212
+ * Bound the idempotence ledger: evict the oldest entries (insertion order) that are **not** still pending,
213
+ * until back within {@link SEEN_LEDGER_CAP}. Never evicts a pending key — that would break the "every pending
214
+ * key ∈ `seen`" invariant and could double-fire a successor whose later duplicate notice then re-armed a timer
215
+ * over the live one. If every over-cap entry is somehow still pending (`pending.size > cap` — not reachable in
216
+ * practice, since pending successors are the most recently scheduled), the loop bails and the ledger is left to
217
+ * grow with `pending` this round; it returns within cap as soon as those timers fire and a later schedule runs.
218
+ */
219
+ private evictSeenOverCap(): void {
220
+ while (this.seen.size > SEEN_LEDGER_CAP) {
221
+ let evicted = false;
222
+ for (const key of this.seen) { // insertion order: oldest first
223
+ if (!this.pending.has(key)) {
224
+ this.seen.delete(key);
225
+ log("rotation re-registration ledger at cap (%d) — evicted oldest fired successor topic=%s", SEEN_LEDGER_CAP, key);
226
+ evicted = true;
227
+ break;
228
+ }
229
+ }
230
+ if (!evicted) {
231
+ break;
232
+ }
233
+ }
234
+ }
235
+
236
+ /** Timer callback: drop the pending entry, then invoke the isolated re-registration (unless stopped). */
237
+ private fire(key: string, plan: ReRegistrationPlan): void {
238
+ this.pending.delete(key);
239
+ if (this.stopped) {
240
+ return; // stop() raced the timer callback — no move after teardown.
241
+ }
242
+ // A failed move must never throw out of the timer callback — an escaping throw / unhandled rejection
243
+ // would surface on the host event loop. Guard BOTH a rejected promise and a (mis-implemented) seam that
244
+ // throws synchronously. `seen` retains the key so a duplicate notice still no-ops. No retry this pass —
245
+ // and because `seen` keeps the key, a re-notice for this *same* successor is deduped (the manager's
246
+ // `rotationHandledFor` already holds it too), so the recovery backstop for a failed move is the
247
+ // subscriber's normal recover/re-walk path, not a re-detected rotation to the same tail.
248
+ try {
249
+ void this.reRegister(plan).catch((err: unknown) => {
250
+ log("rotation re-registration rejected for successor topic=%s (isolated, not retried): %o", key, err);
251
+ });
252
+ } catch (err) {
253
+ log("rotation re-registration threw synchronously for successor topic=%s (isolated): %o", key, err);
254
+ }
255
+ }
256
+ }