@optimystic/db-p2p 0.22.0 → 0.24.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 (194) hide show
  1. package/dist/src/cluster/client.d.ts +10 -0
  2. package/dist/src/cluster/client.d.ts.map +1 -1
  3. package/dist/src/cluster/client.js +30 -1
  4. package/dist/src/cluster/client.js.map +1 -1
  5. package/dist/src/cluster/cluster-repo.d.ts +39 -14
  6. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  7. package/dist/src/cluster/cluster-repo.js +226 -119
  8. package/dist/src/cluster/cluster-repo.js.map +1 -1
  9. package/dist/src/cluster/service.d.ts +32 -1
  10. package/dist/src/cluster/service.d.ts.map +1 -1
  11. package/dist/src/cluster/service.js +43 -2
  12. package/dist/src/cluster/service.js.map +1 -1
  13. package/dist/src/cohort-topic/host.js +34 -11
  14. package/dist/src/cohort-topic/host.js.map +1 -1
  15. package/dist/src/cohort-topic/stream-util.d.ts +37 -7
  16. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  17. package/dist/src/cohort-topic/stream-util.js +77 -19
  18. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  19. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  20. package/dist/src/dispute/dispute-service.js +9 -3
  21. package/dist/src/dispute/dispute-service.js.map +1 -1
  22. package/dist/src/index.d.ts +3 -0
  23. package/dist/src/index.d.ts.map +1 -1
  24. package/dist/src/index.js +3 -0
  25. package/dist/src/index.js.map +1 -1
  26. package/dist/src/libp2p-key-network.d.ts +88 -2
  27. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  28. package/dist/src/libp2p-key-network.js +134 -28
  29. package/dist/src/libp2p-key-network.js.map +1 -1
  30. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  31. package/dist/src/libp2p-node-base.js +25 -1
  32. package/dist/src/libp2p-node-base.js.map +1 -1
  33. package/dist/src/logger.d.ts +17 -1
  34. package/dist/src/logger.d.ts.map +1 -1
  35. package/dist/src/logger.js +19 -2
  36. package/dist/src/logger.js.map +1 -1
  37. package/dist/src/matchmaking/query-transport.js +3 -3
  38. package/dist/src/matchmaking/query-transport.js.map +1 -1
  39. package/dist/src/owned-block-seed.d.ts +6 -3
  40. package/dist/src/owned-block-seed.d.ts.map +1 -1
  41. package/dist/src/owned-block-seed.js +16 -3
  42. package/dist/src/owned-block-seed.js.map +1 -1
  43. package/dist/src/peer-address-book.d.ts +72 -0
  44. package/dist/src/peer-address-book.d.ts.map +1 -0
  45. package/dist/src/peer-address-book.js +123 -0
  46. package/dist/src/peer-address-book.js.map +1 -0
  47. package/dist/src/reactivity/notify-transport.d.ts +4 -4
  48. package/dist/src/reactivity/notify-transport.js +6 -6
  49. package/dist/src/reactivity/notify-transport.js.map +1 -1
  50. package/dist/src/reactivity/push-state-gossip.js +2 -2
  51. package/dist/src/reactivity/push-state-gossip.js.map +1 -1
  52. package/dist/src/reactivity/recover-transport.d.ts +6 -2
  53. package/dist/src/reactivity/recover-transport.d.ts.map +1 -1
  54. package/dist/src/reactivity/recover-transport.js +7 -3
  55. package/dist/src/reactivity/recover-transport.js.map +1 -1
  56. package/dist/src/repo/client.d.ts.map +1 -1
  57. package/dist/src/repo/client.js +11 -2
  58. package/dist/src/repo/client.js.map +1 -1
  59. package/dist/src/repo/cluster-coordinator.d.ts +30 -0
  60. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  61. package/dist/src/repo/cluster-coordinator.js +95 -3
  62. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  63. package/dist/src/repo/coordinator-repo.d.ts +62 -9
  64. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  65. package/dist/src/repo/coordinator-repo.js +242 -73
  66. package/dist/src/repo/coordinator-repo.js.map +1 -1
  67. package/dist/src/rn.d.ts +3 -0
  68. package/dist/src/rn.d.ts.map +1 -1
  69. package/dist/src/rn.js +3 -0
  70. package/dist/src/rn.js.map +1 -1
  71. package/dist/src/storage/cached-raw-storage.d.ts +83 -0
  72. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -0
  73. package/dist/src/storage/cached-raw-storage.js +152 -0
  74. package/dist/src/storage/cached-raw-storage.js.map +1 -0
  75. package/dist/src/storage/cached-store-driver.d.ts +186 -0
  76. package/dist/src/storage/cached-store-driver.d.ts.map +1 -0
  77. package/dist/src/storage/cached-store-driver.js +775 -0
  78. package/dist/src/storage/cached-store-driver.js.map +1 -0
  79. package/dist/src/storage/i-raw-storage.d.ts +12 -5
  80. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  81. package/dist/src/storage/shared-cache-pool.d.ts +234 -0
  82. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -0
  83. package/dist/src/storage/shared-cache-pool.js +354 -0
  84. package/dist/src/storage/shared-cache-pool.js.map +1 -0
  85. package/dist/src/testing/cohort-topic-mesh-harness.d.ts +13 -6
  86. package/dist/src/testing/cohort-topic-mesh-harness.d.ts.map +1 -1
  87. package/dist/src/testing/cohort-topic-mesh-harness.js +15 -6
  88. package/dist/src/testing/cohort-topic-mesh-harness.js.map +1 -1
  89. package/dist/src/testing/raw-storage-conformance.d.ts +2 -1
  90. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  91. package/dist/src/testing/raw-storage-conformance.js +35 -2
  92. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  93. package/package.json +3 -3
  94. package/readme.md +668 -668
  95. package/src/cluster/block-transfer.ts +424 -424
  96. package/src/cluster/client.ts +119 -88
  97. package/src/cluster/cluster-error.ts +64 -64
  98. package/src/cluster/cluster-policy.ts +203 -203
  99. package/src/cluster/cluster-repo.ts +242 -122
  100. package/src/cluster/cluster-size-coupling.ts +45 -45
  101. package/src/cluster/commit-cert.ts +139 -139
  102. package/src/cluster/i-transaction-state-store.ts +43 -43
  103. package/src/cluster/memory-transaction-state-store.ts +56 -56
  104. package/src/cluster/peer-key-binding.ts +37 -37
  105. package/src/cluster/persistent-transaction-state-store.ts +92 -92
  106. package/src/cluster/quorum-restore.ts +223 -223
  107. package/src/cluster/reconcile-block.ts +203 -203
  108. package/src/cluster/service.ts +293 -241
  109. package/src/cluster/supermajority-coupling.ts +37 -37
  110. package/src/cohort-topic/bootstrap-evidence-builder.ts +122 -122
  111. package/src/cohort-topic/bootstrap-evidence-verifiers.ts +132 -132
  112. package/src/cohort-topic/bootstrap-parent-reference.ts +159 -159
  113. package/src/cohort-topic/change-bridge.ts +109 -109
  114. package/src/cohort-topic/cohort-gossip-driver.ts +231 -231
  115. package/src/cohort-topic/cohort-gossip-transport.ts +84 -84
  116. package/src/cohort-topic/fret-trust-anchor.ts +153 -153
  117. package/src/cohort-topic/host.ts +42 -11
  118. package/src/cohort-topic/index.ts +13 -13
  119. package/src/cohort-topic/membership-publish-sink.ts +20 -20
  120. package/src/cohort-topic/membership-source.ts +68 -68
  121. package/src/cohort-topic/peer-codec.ts +31 -31
  122. package/src/cohort-topic/peer-sig.ts +86 -86
  123. package/src/cohort-topic/protocols.ts +71 -71
  124. package/src/cohort-topic/reactivity-membership-gate.ts +77 -77
  125. package/src/cohort-topic/size-estimator.ts +16 -16
  126. package/src/cohort-topic/stream-util.ts +79 -19
  127. package/src/cohort-topic/threshold-crypto.ts +239 -239
  128. package/src/cohort-topic/topic-router.ts +77 -77
  129. package/src/dispute/arbitrator-selection.ts +138 -138
  130. package/src/dispute/cascade.ts +524 -524
  131. package/src/dispute/dispute-service.ts +11 -5
  132. package/src/dispute/invalidation.ts +625 -625
  133. package/src/inbound-authorization.ts +190 -190
  134. package/src/index.ts +52 -49
  135. package/src/libp2p-key-network.ts +1120 -990
  136. package/src/libp2p-node-base.ts +1675 -1651
  137. package/src/libp2p-node-rn.ts +30 -30
  138. package/src/libp2p-node.ts +36 -36
  139. package/src/logger.ts +19 -2
  140. package/src/matchmaking/aggregate-counts.ts +104 -104
  141. package/src/matchmaking/index.ts +20 -20
  142. package/src/matchmaking/module.ts +363 -363
  143. package/src/matchmaking/protocols.ts +51 -51
  144. package/src/matchmaking/provider-manager.ts +95 -95
  145. package/src/matchmaking/query-handler.ts +88 -88
  146. package/src/matchmaking/query-transport.ts +3 -3
  147. package/src/matchmaking/seeker-manager.ts +64 -64
  148. package/src/matchmaking/seeker-walk-client.ts +293 -293
  149. package/src/matchmaking/traffic-validation.ts +195 -195
  150. package/src/optimystic-node.ts +36 -36
  151. package/src/owned-block-seed.ts +53 -40
  152. package/src/peer-address-book.ts +149 -0
  153. package/src/protocol-limits.ts +33 -33
  154. package/src/reactivity/forwarder-host.ts +438 -438
  155. package/src/reactivity/index.ts +19 -19
  156. package/src/reactivity/notify-transport.ts +144 -144
  157. package/src/reactivity/origination-manager.ts +192 -192
  158. package/src/reactivity/protocols.ts +61 -61
  159. package/src/reactivity/push-state-gossip.ts +291 -291
  160. package/src/reactivity/recover-transport.ts +7 -3
  161. package/src/reactivity/rotation-rereg-scheduler.ts +256 -256
  162. package/src/reactivity/subscriber-registry.ts +96 -96
  163. package/src/reactivity/subscription-manager.ts +450 -450
  164. package/src/reactivity/topic-bytes.ts +37 -37
  165. package/src/repo/client.ts +12 -2
  166. package/src/repo/cluster-coordinator.ts +99 -3
  167. package/src/repo/coordinator-repo.ts +281 -74
  168. package/src/repo/types.ts +7 -7
  169. package/src/rn.ts +39 -36
  170. package/src/rpc-deadline.ts +45 -45
  171. package/src/storage/arachnode-partition.ts +74 -74
  172. package/src/storage/cached-raw-storage.ts +180 -0
  173. package/src/storage/cached-store-driver.ts +859 -0
  174. package/src/storage/i-kv-store.ts +8 -8
  175. package/src/storage/i-raw-storage.ts +12 -5
  176. package/src/storage/kv-raw-storage.ts +135 -135
  177. package/src/storage/memory-kv-store.ts +28 -28
  178. package/src/storage/memory-storage.ts +25 -25
  179. package/src/storage/memory-store-driver.ts +157 -157
  180. package/src/storage/raw-store-codec.ts +42 -42
  181. package/src/storage/raw-store-driver.ts +80 -80
  182. package/src/storage/ring-selector.ts +317 -317
  183. package/src/storage/ring-shift-coordinator.ts +271 -271
  184. package/src/storage/shared-cache-pool.ts +452 -0
  185. package/src/storage/storage-repo.ts +1014 -1014
  186. package/src/testing/cohort-topic-mesh-harness.ts +673 -663
  187. package/src/testing/index.ts +8 -8
  188. package/src/testing/matchmaking-mesh-harness.ts +475 -475
  189. package/src/testing/raw-storage-conformance.ts +453 -417
  190. package/src/testing/reactivity-mesh-harness.ts +922 -922
  191. package/dist/src/storage/restoration-coordinator-v2.d.ts +0 -67
  192. package/dist/src/storage/restoration-coordinator-v2.d.ts.map +0 -1
  193. package/dist/src/storage/restoration-coordinator-v2.js +0 -172
  194. package/dist/src/storage/restoration-coordinator-v2.js.map +0 -1
@@ -1,291 +1,291 @@
1
- /**
2
- * Reactivity — `PushState` intra-cohort gossip driver (`docs/reactivity.md` §Replay window,
3
- * §Forwarder-cohort state).
4
- *
5
- * The forwarder-cohort replay ring + dedupe window are the substrate that makes the doc's "**any** cohort
6
- * member — not just the primary — can serve a replay/backfill" property real: a member that missed an
7
- * origin dial still ends up holding the entry, so a backfill (12.4) survives a primary failover. The
8
- * db-core {@link PushState} already owns the codec ({@link encodePushStateGossipV1} /
9
- * {@link decodePushStateGossipV1}, {@link PushState.serializeGossip} / {@link PushState.mergeGossip}); this
10
- * driver is the **replication** layer that actually broadcasts and merges that state over libp2p on a
11
- * periodic cadence — the backstop to the live fan-out ({@link import("./forwarder-host.js")}).
12
- *
13
- * **Transport reuse.** Rather than stand up a new transport, this rides the cohort gossip transport's
14
- * {@link import("../cohort-topic/cohort-gossip-transport.js").FretCohortGossipTransport.broadcastOver}
15
- * seam — the same one the promote-notice broadcast reuses — so the cohort peer resolution, self-exclusion,
16
- * and per-peer failure swallowing are shared. Frames ride the dedicated
17
- * {@link PROTOCOL_REACTIVITY_PUSH_STATE_GOSSIP} (one-way), fanned to the FRET-assembled cohort around each
18
- * served collection's cohort coord. Inbound frames arrive on this node's `push-state-gossip` protocol
19
- * handler (registered by the node-wiring ticket) and route to {@link ReactivityPushStateGossipDriver.deliver}.
20
- *
21
- * **Cadence.** Its own unref'd timer (parallel to the cohort host's single `setInterval`), reusing
22
- * {@link DEFAULT_GOSSIP_INTERVAL_MS} and the host's re-entrancy / `stopped` / `unref` discipline verbatim.
23
- * Reactivity collections are not 1:1 with the host's per-`CoordEngine` tick and the forwarder host owns the
24
- * live {@link PushState} set, so a separate timer is the clean seam now; a future consolidation could fold
25
- * this into the host tick via a hook, but a separate unref'd timer cannot pin an idle process.
26
- *
27
- * The **driver** touches neither the libp2p node assembly nor FRET cohort assembly — it depends only on the
28
- * `broadcastOver` seam + db-core codec, so it is unit-testable with a fake transport and real `PushState`s.
29
- * The co-located {@link registerPushStateGossipHandler} (the inbound protocol-handler registration) does
30
- * bind libp2p, mirroring {@link import("./notify-transport.js").registerNotifyHandler} — the driver class
31
- * stays pure; only that one helper reaches the node.
32
- */
33
-
34
- import {
35
- encodePushStateGossipV1,
36
- decodePushStateGossipV1,
37
- type PushState,
38
- type PushStateGossipV1,
39
- type RingCoord,
40
- } from "@optimystic/db-core";
41
- import type { Libp2p } from "libp2p";
42
- import type { Connection, Stream } from "@libp2p/interface";
43
- import { readAllBounded } from "p2p-fret";
44
- import type { FretCohortGossipTransport } from "../cohort-topic/cohort-gossip-transport.js";
45
- import { DEFAULT_GOSSIP_INTERVAL_MS } from "../cohort-topic/cohort-gossip-driver.js";
46
- import { DEFAULT_STREAM_MAX_BYTES } from "../cohort-topic/stream-util.js";
47
- import { PROTOCOL_REACTIVITY_PUSH_STATE_GOSSIP } from "./protocols.js";
48
- import { createLogger } from "../logger.js";
49
-
50
- const log = createLogger("reactivity-push-state-gossip");
51
-
52
- /** Encode a probe frame with no size ceiling so the driver can measure its true length before bounding. */
53
- const NO_FRAME_LIMIT = Number.MAX_SAFE_INTEGER;
54
-
55
- /** A live forwarder collection to gossip: its {@link PushState} and the cohort coord to broadcast to. */
56
- export interface ReactivityGossipCollection {
57
- readonly pushState: PushState;
58
- readonly cohortCoord: RingCoord;
59
- }
60
-
61
- /** A truncation event: a collection's serialized replay ring exceeded the frame bound and was clipped. */
62
- export interface PushStateGossipTruncation {
63
- readonly collectionId: string;
64
- readonly topicId: string;
65
- /** Most-recent replay entries kept in the broadcast frame. */
66
- readonly kept: number;
67
- /** Replay entries the ring actually held this round. */
68
- readonly total: number;
69
- /** Bytes of the broadcast (clipped) frame. */
70
- readonly frameBytes: number;
71
- /** The configured per-frame ceiling. */
72
- readonly maxBytes: number;
73
- }
74
-
75
- /** Construction inputs for a {@link ReactivityPushStateGossipDriver}. */
76
- export interface ReactivityPushStateGossipDriverDeps {
77
- /** Reuse the node's cohort gossip transport (`broadcastOver`) — no new transport. */
78
- readonly gossipTransport: Pick<FretCohortGossipTransport, "broadcastOver">;
79
- /** The live forwarder collections to gossip each round, with the cohort coord to broadcast to. */
80
- readonly liveCollections: () => Iterable<ReactivityGossipCollection>;
81
- /** Resolve the collection owning an inbound frame (by collectionId/topicId) → its {@link PushState}, or undefined. */
82
- readonly pushStateForGossip: (g: PushStateGossipV1) => PushState | undefined;
83
- /** Inbound authenticity gate: is `fromPeerId` a member of the cohort for the frame's coord? Absent ⇒ accept all. */
84
- readonly isCohortMember?: (fromPeerId: string, g: PushStateGossipV1) => boolean;
85
- /** Cadence interval. Default {@link DEFAULT_GOSSIP_INTERVAL_MS}. */
86
- readonly intervalMs?: number;
87
- /** Per-frame ceiling (frame, including the length prefix, stays within this). Default {@link DEFAULT_STREAM_MAX_BYTES}. */
88
- readonly maxBytes?: number;
89
- /** Reserved for parity with the cohort host's tick discipline; unused today (gossip carries its own `receivedAt`). */
90
- readonly clock?: () => number;
91
- /** Observe a clipped (over-bound) frame, in addition to the "no silent cap" log. Test/diagnostic seam. */
92
- readonly onTruncate?: (info: PushStateGossipTruncation) => void;
93
- }
94
-
95
- /**
96
- * Drives {@link PushState} convergence across a forwarder cohort: each round it broadcasts every live
97
- * collection's serialized push-state to the cohort (clipped to the frame bound), and each inbound frame is
98
- * gated, resolved to the owning collection, and merged. Build one per node and bind its inbound
99
- * {@link ReactivityPushStateGossipDriver.deliver} to the `push-state-gossip` protocol handler.
100
- */
101
- export class ReactivityPushStateGossipDriver {
102
- private readonly gossipTransport: Pick<FretCohortGossipTransport, "broadcastOver">;
103
- private readonly liveCollections: () => Iterable<ReactivityGossipCollection>;
104
- private readonly pushStateForGossip: (g: PushStateGossipV1) => PushState | undefined;
105
- private readonly isCohortMember?: (fromPeerId: string, g: PushStateGossipV1) => boolean;
106
- private readonly intervalMs: number;
107
- private readonly maxBytes: number;
108
- private readonly onTruncate?: (info: PushStateGossipTruncation) => void;
109
-
110
- private timer?: ReturnType<typeof setInterval>;
111
- private stopped = false;
112
- /** Re-entrancy guard mirroring the host tick: skip a round that overlaps a slow prior one. */
113
- private rounding = false;
114
-
115
- constructor(deps: ReactivityPushStateGossipDriverDeps) {
116
- this.gossipTransport = deps.gossipTransport;
117
- this.liveCollections = deps.liveCollections;
118
- this.pushStateForGossip = deps.pushStateForGossip;
119
- this.isCohortMember = deps.isCohortMember;
120
- this.intervalMs = deps.intervalMs ?? DEFAULT_GOSSIP_INTERVAL_MS;
121
- this.maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
122
- this.onTruncate = deps.onTruncate;
123
- }
124
-
125
- /** Begin the unref'd cadence timer. Idempotent; a no-op once {@link stop} has run. */
126
- start(): void {
127
- if (this.stopped || this.timer !== undefined) {
128
- return;
129
- }
130
- const timer = setInterval((): void => {
131
- this.round();
132
- }, this.intervalMs);
133
- // Node timers keep the event loop alive; push-state gossip must not pin a process that is otherwise idle.
134
- (timer as { unref?: () => void }).unref?.();
135
- this.timer = timer;
136
- }
137
-
138
- /**
139
- * One round: broadcast each live collection's serialized {@link PushState} to its cohort, clipped to the
140
- * frame bound. Per-collection isolated (one collection's fault never skips the rest) and `stopped`-gated
141
- * mid-loop, mirroring the host tick.
142
- */
143
- round(): void {
144
- if (this.stopped || this.rounding) {
145
- return;
146
- }
147
- this.rounding = true;
148
- try {
149
- for (const { pushState, cohortCoord } of this.liveCollections()) {
150
- if (this.stopped) {
151
- break;
152
- }
153
- try {
154
- this.broadcastOne(pushState, cohortCoord);
155
- } catch (err) {
156
- log("push-state gossip round failed for a collection (isolated): %o", err);
157
- }
158
- }
159
- } finally {
160
- this.rounding = false;
161
- }
162
- }
163
-
164
- /**
165
- * Inbound handler body: decode → membership gate → resolve owning collection → merge. Never throws on a
166
- * bad frame (a malformed/forged/foreign frame is logged and dropped), so a stream handler can call it
167
- * directly. `mergeGossip` independently guards a collection/topic mismatch, so the resolve step is a fast
168
- * pre-filter, not the only line of defense.
169
- */
170
- deliver(fromPeerId: string, frame: Uint8Array): void {
171
- let g: PushStateGossipV1;
172
- try {
173
- g = decodePushStateGossipV1(frame, this.maxBytes);
174
- } catch (err) {
175
- log("dropped an undecodable push-state gossip frame from %s: %o", fromPeerId, err);
176
- return;
177
- }
178
- if (this.isCohortMember !== undefined && !this.isCohortMember(fromPeerId, g)) {
179
- log("dropped push-state gossip from non-member %s for collection=%s topic=%s", fromPeerId, g.collectionId, g.topicId);
180
- return;
181
- }
182
- const pushState = this.pushStateForGossip(g);
183
- if (pushState === undefined) {
184
- return; // gossip for a collection this node does not serve — nothing to merge into.
185
- }
186
- try {
187
- pushState.mergeGossip(g);
188
- } catch (err) {
189
- log("push-state mergeGossip threw (isolated) for collection=%s: %o", g.collectionId, err);
190
- }
191
- }
192
-
193
- /** Stop the cadence: short-circuit any in-flight/future round and clear the timer. */
194
- stop(): void {
195
- this.stopped = true;
196
- if (this.timer !== undefined) {
197
- clearInterval(this.timer);
198
- this.timer = undefined;
199
- }
200
- }
201
-
202
- /** Serialize one collection, clip to the frame bound, and broadcast to its cohort. */
203
- private broadcastOne(pushState: PushState, cohortCoord: RingCoord): void {
204
- const g = pushState.serializeGossip();
205
- const { frame, kept, total } = this.boundedFrame(g);
206
- if (kept < total) {
207
- // "No silent caps" (AGENTS.md): a clipped ring is always surfaced. Convergence still completes —
208
- // each entry replicates while it is within the most-recent window across successive rounds, and
209
- // `mergeGossip` unions ring entries so a partial frame loses nothing already held by a peer.
210
- log("push-state gossip frame clipped for collection=%s: kept %d/%d most-recent replay entries to fit maxBytes=%d", g.collectionId, kept, total, this.maxBytes);
211
- this.onTruncate?.({ collectionId: g.collectionId, topicId: g.topicId, kept, total, frameBytes: frame.length, maxBytes: this.maxBytes });
212
- }
213
- this.gossipTransport.broadcastOver(PROTOCOL_REACTIVITY_PUSH_STATE_GOSSIP, cohortCoord, frame);
214
- }
215
-
216
- /**
217
- * Build the broadcast frame for one serialized push-state, kept within {@link maxBytes}.
218
- *
219
- * The whole replay ring (`W` up to 256 full `NotificationV1`s) can exceed the frame bound. When it does,
220
- * keep the full (small) dedupe window and the **most-recent-N** replay entries that fit — sliced from the
221
- * high-revision end, since a lagging subscriber backfills the newest gap first and older revisions roll
222
- * to the parent checkpoint anyway. Frame size is monotone in the entry count, so binary-search the
223
- * high-water N rather than re-encoding per entry.
224
- */
225
- private boundedFrame(g: PushStateGossipV1): { frame: Uint8Array; kept: number; total: number } {
226
- const total = g.replayBuffer.entries.length;
227
- const full = encodePushStateGossipV1(g, NO_FRAME_LIMIT);
228
- if (full.length <= this.maxBytes) {
229
- return { frame: full, kept: total, total };
230
- }
231
- // Largest most-recent-N replay slice whose frame still fits. `encodeSlice(g, 0)` (dedupe window only)
232
- // is the floor we ship if even that overruns the bound (pathological — the dedupe window is small).
233
- let lo = 0;
234
- let hi = total;
235
- let bestN = 0;
236
- let bestFrame = this.encodeSlice(g, 0);
237
- while (lo <= hi) {
238
- const mid = (lo + hi) >>> 1;
239
- const frame = this.encodeSlice(g, mid);
240
- if (frame.length <= this.maxBytes) {
241
- bestN = mid;
242
- bestFrame = frame;
243
- lo = mid + 1;
244
- } else {
245
- hi = mid - 1;
246
- }
247
- }
248
- return { frame: bestFrame, kept: bestN, total };
249
- }
250
-
251
- /** Encode `g` with only its most-recent `keep` replay entries retained (the full dedupe window is kept). */
252
- private encodeSlice(g: PushStateGossipV1, keep: number): Uint8Array {
253
- const entries = g.replayBuffer.entries;
254
- const kept = keep >= entries.length ? entries : entries.slice(entries.length - keep);
255
- const sliced: PushStateGossipV1 = {
256
- ...g,
257
- replayBuffer: { capacity: g.replayBuffer.capacity, entries: kept },
258
- };
259
- return encodePushStateGossipV1(sliced, NO_FRAME_LIMIT);
260
- }
261
- }
262
-
263
- /**
264
- * Register the inbound push-state-gossip protocol handler: read one bounded frame and hand it to
265
- * {@link ReactivityPushStateGossipDriver.deliver} (which decodes, membership-gates, and merges), then close.
266
- * One-way — no reply frame, matching the cohort-topic gossip handlers. A read error aborts the stream and
267
- * {@link ReactivityPushStateGossipDriver.deliver} swallows a decode/forge/foreign failure, so the handler
268
- * never throws on the stream. Mirrors {@link import("./notify-transport.js").registerNotifyHandler}.
269
- */
270
- export function registerPushStateGossipHandler(
271
- node: Libp2p,
272
- protocol: string,
273
- driver: Pick<ReactivityPushStateGossipDriver, "deliver">,
274
- maxBytes = DEFAULT_STREAM_MAX_BYTES,
275
- ): void {
276
- void node.handle(protocol, (stream: Stream, connection: Connection) => {
277
- void (async (): Promise<void> => {
278
- try {
279
- const frame = await readAllBounded(stream, maxBytes);
280
- driver.deliver(connection.remotePeer.toString(), frame);
281
- await stream.close();
282
- } catch {
283
- try {
284
- stream.abort(new Error("reactivity push-state gossip stream handler error"));
285
- } catch {
286
- /* already aborted */
287
- }
288
- }
289
- })();
290
- });
291
- }
1
+ /**
2
+ * Reactivity — `PushState` intra-cohort gossip driver (`docs/reactivity.md` §Replay window,
3
+ * §Forwarder-cohort state).
4
+ *
5
+ * The forwarder-cohort replay ring + dedupe window are the substrate that makes the doc's "**any** cohort
6
+ * member — not just the primary — can serve a replay/backfill" property real: a member that missed an
7
+ * origin dial still ends up holding the entry, so a backfill (12.4) survives a primary failover. The
8
+ * db-core {@link PushState} already owns the codec ({@link encodePushStateGossipV1} /
9
+ * {@link decodePushStateGossipV1}, {@link PushState.serializeGossip} / {@link PushState.mergeGossip}); this
10
+ * driver is the **replication** layer that actually broadcasts and merges that state over libp2p on a
11
+ * periodic cadence — the backstop to the live fan-out ({@link import("./forwarder-host.js")}).
12
+ *
13
+ * **Transport reuse.** Rather than stand up a new transport, this rides the cohort gossip transport's
14
+ * {@link import("../cohort-topic/cohort-gossip-transport.js").FretCohortGossipTransport.broadcastOver}
15
+ * seam — the same one the promote-notice broadcast reuses — so the cohort peer resolution, self-exclusion,
16
+ * and per-peer failure swallowing are shared. Frames ride the dedicated
17
+ * {@link PROTOCOL_REACTIVITY_PUSH_STATE_GOSSIP} (one-way), fanned to the FRET-assembled cohort around each
18
+ * served collection's cohort coord. Inbound frames arrive on this node's `push-state-gossip` protocol
19
+ * handler (registered by the node-wiring ticket) and route to {@link ReactivityPushStateGossipDriver.deliver}.
20
+ *
21
+ * **Cadence.** Its own unref'd timer (parallel to the cohort host's single `setInterval`), reusing
22
+ * {@link DEFAULT_GOSSIP_INTERVAL_MS} and the host's re-entrancy / `stopped` / `unref` discipline verbatim.
23
+ * Reactivity collections are not 1:1 with the host's per-`CoordEngine` tick and the forwarder host owns the
24
+ * live {@link PushState} set, so a separate timer is the clean seam now; a future consolidation could fold
25
+ * this into the host tick via a hook, but a separate unref'd timer cannot pin an idle process.
26
+ *
27
+ * The **driver** touches neither the libp2p node assembly nor FRET cohort assembly — it depends only on the
28
+ * `broadcastOver` seam + db-core codec, so it is unit-testable with a fake transport and real `PushState`s.
29
+ * The co-located {@link registerPushStateGossipHandler} (the inbound protocol-handler registration) does
30
+ * bind libp2p, mirroring {@link import("./notify-transport.js").registerNotifyHandler} — the driver class
31
+ * stays pure; only that one helper reaches the node.
32
+ */
33
+
34
+ import {
35
+ encodePushStateGossipV1,
36
+ decodePushStateGossipV1,
37
+ type PushState,
38
+ type PushStateGossipV1,
39
+ type RingCoord,
40
+ } from "@optimystic/db-core";
41
+ import type { Libp2p } from "libp2p";
42
+ import type { Connection, Stream } from "@libp2p/interface";
43
+ import { readFramed } from "p2p-fret";
44
+ import type { FretCohortGossipTransport } from "../cohort-topic/cohort-gossip-transport.js";
45
+ import { DEFAULT_GOSSIP_INTERVAL_MS } from "../cohort-topic/cohort-gossip-driver.js";
46
+ import { DEFAULT_STREAM_MAX_BYTES } from "../cohort-topic/stream-util.js";
47
+ import { PROTOCOL_REACTIVITY_PUSH_STATE_GOSSIP } from "./protocols.js";
48
+ import { createLogger } from "../logger.js";
49
+
50
+ const log = createLogger("reactivity-push-state-gossip");
51
+
52
+ /** Encode a probe frame with no size ceiling so the driver can measure its true length before bounding. */
53
+ const NO_FRAME_LIMIT = Number.MAX_SAFE_INTEGER;
54
+
55
+ /** A live forwarder collection to gossip: its {@link PushState} and the cohort coord to broadcast to. */
56
+ export interface ReactivityGossipCollection {
57
+ readonly pushState: PushState;
58
+ readonly cohortCoord: RingCoord;
59
+ }
60
+
61
+ /** A truncation event: a collection's serialized replay ring exceeded the frame bound and was clipped. */
62
+ export interface PushStateGossipTruncation {
63
+ readonly collectionId: string;
64
+ readonly topicId: string;
65
+ /** Most-recent replay entries kept in the broadcast frame. */
66
+ readonly kept: number;
67
+ /** Replay entries the ring actually held this round. */
68
+ readonly total: number;
69
+ /** Bytes of the broadcast (clipped) frame. */
70
+ readonly frameBytes: number;
71
+ /** The configured per-frame ceiling. */
72
+ readonly maxBytes: number;
73
+ }
74
+
75
+ /** Construction inputs for a {@link ReactivityPushStateGossipDriver}. */
76
+ export interface ReactivityPushStateGossipDriverDeps {
77
+ /** Reuse the node's cohort gossip transport (`broadcastOver`) — no new transport. */
78
+ readonly gossipTransport: Pick<FretCohortGossipTransport, "broadcastOver">;
79
+ /** The live forwarder collections to gossip each round, with the cohort coord to broadcast to. */
80
+ readonly liveCollections: () => Iterable<ReactivityGossipCollection>;
81
+ /** Resolve the collection owning an inbound frame (by collectionId/topicId) → its {@link PushState}, or undefined. */
82
+ readonly pushStateForGossip: (g: PushStateGossipV1) => PushState | undefined;
83
+ /** Inbound authenticity gate: is `fromPeerId` a member of the cohort for the frame's coord? Absent ⇒ accept all. */
84
+ readonly isCohortMember?: (fromPeerId: string, g: PushStateGossipV1) => boolean;
85
+ /** Cadence interval. Default {@link DEFAULT_GOSSIP_INTERVAL_MS}. */
86
+ readonly intervalMs?: number;
87
+ /** Per-frame ceiling (frame, including the length prefix, stays within this). Default {@link DEFAULT_STREAM_MAX_BYTES}. */
88
+ readonly maxBytes?: number;
89
+ /** Reserved for parity with the cohort host's tick discipline; unused today (gossip carries its own `receivedAt`). */
90
+ readonly clock?: () => number;
91
+ /** Observe a clipped (over-bound) frame, in addition to the "no silent cap" log. Test/diagnostic seam. */
92
+ readonly onTruncate?: (info: PushStateGossipTruncation) => void;
93
+ }
94
+
95
+ /**
96
+ * Drives {@link PushState} convergence across a forwarder cohort: each round it broadcasts every live
97
+ * collection's serialized push-state to the cohort (clipped to the frame bound), and each inbound frame is
98
+ * gated, resolved to the owning collection, and merged. Build one per node and bind its inbound
99
+ * {@link ReactivityPushStateGossipDriver.deliver} to the `push-state-gossip` protocol handler.
100
+ */
101
+ export class ReactivityPushStateGossipDriver {
102
+ private readonly gossipTransport: Pick<FretCohortGossipTransport, "broadcastOver">;
103
+ private readonly liveCollections: () => Iterable<ReactivityGossipCollection>;
104
+ private readonly pushStateForGossip: (g: PushStateGossipV1) => PushState | undefined;
105
+ private readonly isCohortMember?: (fromPeerId: string, g: PushStateGossipV1) => boolean;
106
+ private readonly intervalMs: number;
107
+ private readonly maxBytes: number;
108
+ private readonly onTruncate?: (info: PushStateGossipTruncation) => void;
109
+
110
+ private timer?: ReturnType<typeof setInterval>;
111
+ private stopped = false;
112
+ /** Re-entrancy guard mirroring the host tick: skip a round that overlaps a slow prior one. */
113
+ private rounding = false;
114
+
115
+ constructor(deps: ReactivityPushStateGossipDriverDeps) {
116
+ this.gossipTransport = deps.gossipTransport;
117
+ this.liveCollections = deps.liveCollections;
118
+ this.pushStateForGossip = deps.pushStateForGossip;
119
+ this.isCohortMember = deps.isCohortMember;
120
+ this.intervalMs = deps.intervalMs ?? DEFAULT_GOSSIP_INTERVAL_MS;
121
+ this.maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
122
+ this.onTruncate = deps.onTruncate;
123
+ }
124
+
125
+ /** Begin the unref'd cadence timer. Idempotent; a no-op once {@link stop} has run. */
126
+ start(): void {
127
+ if (this.stopped || this.timer !== undefined) {
128
+ return;
129
+ }
130
+ const timer = setInterval((): void => {
131
+ this.round();
132
+ }, this.intervalMs);
133
+ // Node timers keep the event loop alive; push-state gossip must not pin a process that is otherwise idle.
134
+ (timer as { unref?: () => void }).unref?.();
135
+ this.timer = timer;
136
+ }
137
+
138
+ /**
139
+ * One round: broadcast each live collection's serialized {@link PushState} to its cohort, clipped to the
140
+ * frame bound. Per-collection isolated (one collection's fault never skips the rest) and `stopped`-gated
141
+ * mid-loop, mirroring the host tick.
142
+ */
143
+ round(): void {
144
+ if (this.stopped || this.rounding) {
145
+ return;
146
+ }
147
+ this.rounding = true;
148
+ try {
149
+ for (const { pushState, cohortCoord } of this.liveCollections()) {
150
+ if (this.stopped) {
151
+ break;
152
+ }
153
+ try {
154
+ this.broadcastOne(pushState, cohortCoord);
155
+ } catch (err) {
156
+ log("push-state gossip round failed for a collection (isolated): %o", err);
157
+ }
158
+ }
159
+ } finally {
160
+ this.rounding = false;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Inbound handler body: decode → membership gate → resolve owning collection → merge. Never throws on a
166
+ * bad frame (a malformed/forged/foreign frame is logged and dropped), so a stream handler can call it
167
+ * directly. `mergeGossip` independently guards a collection/topic mismatch, so the resolve step is a fast
168
+ * pre-filter, not the only line of defense.
169
+ */
170
+ deliver(fromPeerId: string, frame: Uint8Array): void {
171
+ let g: PushStateGossipV1;
172
+ try {
173
+ g = decodePushStateGossipV1(frame, this.maxBytes);
174
+ } catch (err) {
175
+ log("dropped an undecodable push-state gossip frame from %s: %o", fromPeerId, err);
176
+ return;
177
+ }
178
+ if (this.isCohortMember !== undefined && !this.isCohortMember(fromPeerId, g)) {
179
+ log("dropped push-state gossip from non-member %s for collection=%s topic=%s", fromPeerId, g.collectionId, g.topicId);
180
+ return;
181
+ }
182
+ const pushState = this.pushStateForGossip(g);
183
+ if (pushState === undefined) {
184
+ return; // gossip for a collection this node does not serve — nothing to merge into.
185
+ }
186
+ try {
187
+ pushState.mergeGossip(g);
188
+ } catch (err) {
189
+ log("push-state mergeGossip threw (isolated) for collection=%s: %o", g.collectionId, err);
190
+ }
191
+ }
192
+
193
+ /** Stop the cadence: short-circuit any in-flight/future round and clear the timer. */
194
+ stop(): void {
195
+ this.stopped = true;
196
+ if (this.timer !== undefined) {
197
+ clearInterval(this.timer);
198
+ this.timer = undefined;
199
+ }
200
+ }
201
+
202
+ /** Serialize one collection, clip to the frame bound, and broadcast to its cohort. */
203
+ private broadcastOne(pushState: PushState, cohortCoord: RingCoord): void {
204
+ const g = pushState.serializeGossip();
205
+ const { frame, kept, total } = this.boundedFrame(g);
206
+ if (kept < total) {
207
+ // "No silent caps" (AGENTS.md): a clipped ring is always surfaced. Convergence still completes —
208
+ // each entry replicates while it is within the most-recent window across successive rounds, and
209
+ // `mergeGossip` unions ring entries so a partial frame loses nothing already held by a peer.
210
+ log("push-state gossip frame clipped for collection=%s: kept %d/%d most-recent replay entries to fit maxBytes=%d", g.collectionId, kept, total, this.maxBytes);
211
+ this.onTruncate?.({ collectionId: g.collectionId, topicId: g.topicId, kept, total, frameBytes: frame.length, maxBytes: this.maxBytes });
212
+ }
213
+ this.gossipTransport.broadcastOver(PROTOCOL_REACTIVITY_PUSH_STATE_GOSSIP, cohortCoord, frame);
214
+ }
215
+
216
+ /**
217
+ * Build the broadcast frame for one serialized push-state, kept within {@link maxBytes}.
218
+ *
219
+ * The whole replay ring (`W` up to 256 full `NotificationV1`s) can exceed the frame bound. When it does,
220
+ * keep the full (small) dedupe window and the **most-recent-N** replay entries that fit — sliced from the
221
+ * high-revision end, since a lagging subscriber backfills the newest gap first and older revisions roll
222
+ * to the parent checkpoint anyway. Frame size is monotone in the entry count, so binary-search the
223
+ * high-water N rather than re-encoding per entry.
224
+ */
225
+ private boundedFrame(g: PushStateGossipV1): { frame: Uint8Array; kept: number; total: number } {
226
+ const total = g.replayBuffer.entries.length;
227
+ const full = encodePushStateGossipV1(g, NO_FRAME_LIMIT);
228
+ if (full.length <= this.maxBytes) {
229
+ return { frame: full, kept: total, total };
230
+ }
231
+ // Largest most-recent-N replay slice whose frame still fits. `encodeSlice(g, 0)` (dedupe window only)
232
+ // is the floor we ship if even that overruns the bound (pathological — the dedupe window is small).
233
+ let lo = 0;
234
+ let hi = total;
235
+ let bestN = 0;
236
+ let bestFrame = this.encodeSlice(g, 0);
237
+ while (lo <= hi) {
238
+ const mid = (lo + hi) >>> 1;
239
+ const frame = this.encodeSlice(g, mid);
240
+ if (frame.length <= this.maxBytes) {
241
+ bestN = mid;
242
+ bestFrame = frame;
243
+ lo = mid + 1;
244
+ } else {
245
+ hi = mid - 1;
246
+ }
247
+ }
248
+ return { frame: bestFrame, kept: bestN, total };
249
+ }
250
+
251
+ /** Encode `g` with only its most-recent `keep` replay entries retained (the full dedupe window is kept). */
252
+ private encodeSlice(g: PushStateGossipV1, keep: number): Uint8Array {
253
+ const entries = g.replayBuffer.entries;
254
+ const kept = keep >= entries.length ? entries : entries.slice(entries.length - keep);
255
+ const sliced: PushStateGossipV1 = {
256
+ ...g,
257
+ replayBuffer: { capacity: g.replayBuffer.capacity, entries: kept },
258
+ };
259
+ return encodePushStateGossipV1(sliced, NO_FRAME_LIMIT);
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Register the inbound push-state-gossip protocol handler: read one bounded frame and hand it to
265
+ * {@link ReactivityPushStateGossipDriver.deliver} (which decodes, membership-gates, and merges), then close.
266
+ * One-way — no reply frame, matching the cohort-topic gossip handlers. A read error aborts the stream and
267
+ * {@link ReactivityPushStateGossipDriver.deliver} swallows a decode/forge/foreign failure, so the handler
268
+ * never throws on the stream. Mirrors {@link import("./notify-transport.js").registerNotifyHandler}.
269
+ */
270
+ export function registerPushStateGossipHandler(
271
+ node: Libp2p,
272
+ protocol: string,
273
+ driver: Pick<ReactivityPushStateGossipDriver, "deliver">,
274
+ maxBytes = DEFAULT_STREAM_MAX_BYTES,
275
+ ): void {
276
+ void node.handle(protocol, (stream: Stream, connection: Connection) => {
277
+ void (async (): Promise<void> => {
278
+ try {
279
+ const frame = await readFramed(stream, maxBytes);
280
+ driver.deliver(connection.remotePeer.toString(), frame);
281
+ await stream.close();
282
+ } catch {
283
+ try {
284
+ stream.abort(new Error("reactivity push-state gossip stream handler error"));
285
+ } catch {
286
+ /* already aborted */
287
+ }
288
+ }
289
+ })();
290
+ });
291
+ }
@@ -369,8 +369,12 @@ function serveResumeReply(deps: RecoverServeDeps, req: ResumeV1, now: number): U
369
369
 
370
370
  /**
371
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.
372
+ * `undefined` on a decode/verify/replay/resolve failure. It never throws out of the handler.
373
+ *
374
+ * NOTE: `undefined` reaches the dialer as an explicit zero-length frame, and
375
+ * {@link Libp2pReactivityRecoverTransport.exchange} decodes that as a *terminal* protocol error rather
376
+ * than falling through to the next cohort candidate — one member declining ends the whole recover walk.
377
+ * Tracked as `debt-stream-reply-no-result-untyped`.
374
378
  */
375
379
  export function createRecoverRequestHandler(deps: RecoverServeDeps): (frame: Uint8Array, fromPeer: PeerId) => Promise<Uint8Array | undefined> {
376
380
  const maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
@@ -394,7 +398,7 @@ export function createRecoverRequestHandler(deps: RecoverServeDeps): (frame: Uin
394
398
  return Promise.resolve(serveResumeReply(deps, r, now));
395
399
  } catch (err) {
396
400
  // 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).
401
+ // out of the stream handler: log + no reply (a zero-length reply frame; see the note above).
398
402
  log("recover serve: dropping request (no reply): %o", err);
399
403
  return Promise.resolve(undefined);
400
404
  }