@optimystic/db-core 0.21.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/README.md +336 -336
  2. package/dist/src/btree/btree.d.ts +2 -1
  3. package/dist/src/btree/btree.d.ts.map +1 -1
  4. package/dist/src/btree/btree.js +1 -1
  5. package/dist/src/btree/btree.js.map +1 -1
  6. package/dist/src/chain/chain.d.ts +1 -1
  7. package/dist/src/chain/chain.d.ts.map +1 -1
  8. package/dist/src/chain/chain.js +1 -1
  9. package/dist/src/chain/chain.js.map +1 -1
  10. package/dist/src/cluster/structs.d.ts +39 -1
  11. package/dist/src/cluster/structs.d.ts.map +1 -1
  12. package/dist/src/cluster/structs.js +24 -0
  13. package/dist/src/cluster/structs.js.map +1 -1
  14. package/dist/src/collection/collection.d.ts +20 -1
  15. package/dist/src/collection/collection.d.ts.map +1 -1
  16. package/dist/src/collection/collection.js +31 -4
  17. package/dist/src/collection/collection.js.map +1 -1
  18. package/dist/src/collections/diary/diary.d.ts.map +1 -1
  19. package/dist/src/collections/diary/diary.js +2 -1
  20. package/dist/src/collections/diary/diary.js.map +1 -1
  21. package/dist/src/collections/diary/struct.js +1 -1
  22. package/dist/src/collections/diary/struct.js.map +1 -1
  23. package/dist/src/collections/tree/collection-trunk.js +1 -1
  24. package/dist/src/collections/tree/collection-trunk.js.map +1 -1
  25. package/dist/src/collections/tree/struct.d.ts +1 -1
  26. package/dist/src/collections/tree/struct.d.ts.map +1 -1
  27. package/dist/src/collections/tree/struct.js +2 -1
  28. package/dist/src/collections/tree/struct.js.map +1 -1
  29. package/dist/src/collections/tree/tree.d.ts +5 -0
  30. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  31. package/dist/src/collections/tree/tree.js +7 -0
  32. package/dist/src/collections/tree/tree.js.map +1 -1
  33. package/dist/src/log/log.d.ts +1 -1
  34. package/dist/src/log/log.d.ts.map +1 -1
  35. package/dist/src/log/log.js +2 -2
  36. package/dist/src/log/log.js.map +1 -1
  37. package/dist/src/network/i-peer-network.d.ts +16 -0
  38. package/dist/src/network/i-peer-network.d.ts.map +1 -1
  39. package/dist/src/network/struct.d.ts +39 -2
  40. package/dist/src/network/struct.d.ts.map +1 -1
  41. package/dist/src/network/struct.js +18 -0
  42. package/dist/src/network/struct.js.map +1 -1
  43. package/dist/src/testing/test-transactor.d.ts +95 -8
  44. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  45. package/dist/src/testing/test-transactor.js +133 -12
  46. package/dist/src/testing/test-transactor.js.map +1 -1
  47. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  48. package/dist/src/transaction/coordinator.js +2 -1
  49. package/dist/src/transaction/coordinator.js.map +1 -1
  50. package/dist/src/transaction/transaction.d.ts +1 -1
  51. package/dist/src/transaction/transaction.js +1 -1
  52. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  53. package/dist/src/transactor/network-transactor.js +57 -18
  54. package/dist/src/transactor/network-transactor.js.map +1 -1
  55. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  56. package/dist/src/transactor/transactor-source.js +25 -2
  57. package/dist/src/transactor/transactor-source.js.map +1 -1
  58. package/dist/src/transform/cache-source.js +1 -1
  59. package/dist/src/transform/cache-source.js.map +1 -1
  60. package/dist/src/transform/helpers.d.ts +6 -1
  61. package/dist/src/transform/helpers.d.ts.map +1 -1
  62. package/dist/src/transform/helpers.js +7 -6
  63. package/dist/src/transform/helpers.js.map +1 -1
  64. package/dist/src/transform/tracker.d.ts.map +1 -1
  65. package/dist/src/transform/tracker.js +2 -1
  66. package/dist/src/transform/tracker.js.map +1 -1
  67. package/package.json +1 -1
  68. package/src/btree/btree.ts +2 -1
  69. package/src/chain/chain.ts +2 -1
  70. package/src/cluster/membership.ts +85 -85
  71. package/src/cluster/structs.ts +43 -4
  72. package/src/cohort-topic/addressing.ts +120 -120
  73. package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +253 -253
  74. package/src/cohort-topic/antidos/bootstrap-evidence.ts +106 -106
  75. package/src/cohort-topic/antidos/index.ts +5 -5
  76. package/src/cohort-topic/antidos/rate-limiter.ts +210 -210
  77. package/src/cohort-topic/antidos/replay-guard.ts +146 -146
  78. package/src/cohort-topic/antidos/topic-budget.ts +160 -160
  79. package/src/cohort-topic/antiflood/index.ts +2 -2
  80. package/src/cohort-topic/antiflood/invariants.ts +108 -108
  81. package/src/cohort-topic/antiflood/jitter.ts +117 -117
  82. package/src/cohort-topic/coldstart.ts +237 -237
  83. package/src/cohort-topic/dmax.ts +88 -88
  84. package/src/cohort-topic/gossip/bus.ts +254 -254
  85. package/src/cohort-topic/gossip/index.ts +3 -3
  86. package/src/cohort-topic/gossip/records.ts +45 -45
  87. package/src/cohort-topic/gossip/view.ts +91 -91
  88. package/src/cohort-topic/index.ts +20 -20
  89. package/src/cohort-topic/load/barometer.ts +134 -134
  90. package/src/cohort-topic/load/index.ts +1 -1
  91. package/src/cohort-topic/member-engine.ts +430 -430
  92. package/src/cohort-topic/membership/index.ts +3 -3
  93. package/src/cohort-topic/membership/publisher.ts +163 -163
  94. package/src/cohort-topic/membership/source.ts +41 -41
  95. package/src/cohort-topic/membership/verifier.ts +461 -461
  96. package/src/cohort-topic/ports.ts +157 -157
  97. package/src/cohort-topic/promotion.ts +405 -405
  98. package/src/cohort-topic/registration/bytes.ts +37 -37
  99. package/src/cohort-topic/registration/handoff.ts +154 -154
  100. package/src/cohort-topic/registration/index.ts +6 -6
  101. package/src/cohort-topic/registration/renewal.ts +495 -495
  102. package/src/cohort-topic/registration/sharding.ts +61 -61
  103. package/src/cohort-topic/registration/store.ts +81 -81
  104. package/src/cohort-topic/registration/types.ts +91 -91
  105. package/src/cohort-topic/ring-hash.ts +50 -50
  106. package/src/cohort-topic/service.ts +416 -416
  107. package/src/cohort-topic/sig/index.ts +2 -2
  108. package/src/cohort-topic/sig/payloads.ts +59 -59
  109. package/src/cohort-topic/sig/threshold.ts +64 -64
  110. package/src/cohort-topic/tiers.ts +74 -74
  111. package/src/cohort-topic/traffic.ts +233 -233
  112. package/src/cohort-topic/walk.ts +326 -326
  113. package/src/cohort-topic/willingness.ts +237 -237
  114. package/src/cohort-topic/wire/codec.ts +216 -216
  115. package/src/cohort-topic/wire/index.ts +18 -18
  116. package/src/cohort-topic/wire/payloads.ts +126 -126
  117. package/src/cohort-topic/wire/primitives.ts +188 -188
  118. package/src/cohort-topic/wire/types.ts +475 -475
  119. package/src/cohort-topic/wire/validate.ts +512 -512
  120. package/src/collection/collection-type-registry.ts +37 -37
  121. package/src/collection/collection.ts +32 -4
  122. package/src/collections/diary/diary.ts +68 -67
  123. package/src/collections/diary/struct.ts +1 -1
  124. package/src/collections/tree/collection-trunk.ts +1 -1
  125. package/src/collections/tree/readme.md +4 -0
  126. package/src/collections/tree/struct.ts +3 -1
  127. package/src/collections/tree/tree.ts +320 -312
  128. package/src/log/log.ts +2 -2
  129. package/src/matchmaking/capability-filter.ts +45 -45
  130. package/src/matchmaking/config.ts +98 -98
  131. package/src/matchmaking/index.ts +21 -21
  132. package/src/matchmaking/multi-cohort-seeker.ts +234 -234
  133. package/src/matchmaking/provider.ts +123 -123
  134. package/src/matchmaking/query-eval.ts +105 -105
  135. package/src/matchmaking/seeker-walk.ts +127 -127
  136. package/src/matchmaking/seeker.ts +86 -86
  137. package/src/matchmaking/topic-anchor.ts +90 -90
  138. package/src/matchmaking/voting-quorum.ts +394 -394
  139. package/src/matchmaking/wire.ts +603 -603
  140. package/src/network/i-peer-network.ts +17 -0
  141. package/src/network/stale-failure.ts +43 -43
  142. package/src/network/struct.ts +41 -2
  143. package/src/network/types.ts +37 -37
  144. package/src/reactivity/backfill.ts +220 -220
  145. package/src/reactivity/backpressure.ts +191 -191
  146. package/src/reactivity/checkpoint.ts +308 -308
  147. package/src/reactivity/config.ts +172 -172
  148. package/src/reactivity/dedupe.ts +132 -132
  149. package/src/reactivity/forwarder.ts +87 -87
  150. package/src/reactivity/index.ts +34 -34
  151. package/src/reactivity/notification.ts +123 -123
  152. package/src/reactivity/policy.ts +79 -79
  153. package/src/reactivity/push-state.ts +310 -310
  154. package/src/reactivity/recover.ts +153 -153
  155. package/src/reactivity/replay-buffer.ts +141 -141
  156. package/src/reactivity/resume.ts +549 -549
  157. package/src/reactivity/rotation.ts +415 -415
  158. package/src/reactivity/subscriber.ts +132 -132
  159. package/src/reactivity/subscription.ts +66 -66
  160. package/src/reactivity/topic-anchor.ts +71 -71
  161. package/src/reactivity/verify.ts +73 -73
  162. package/src/reactivity/wire-validate.ts +13 -13
  163. package/src/reactivity/wire.ts +224 -224
  164. package/src/testing/async-wait.ts +65 -65
  165. package/src/testing/index.ts +2 -2
  166. package/src/testing/test-transactor.ts +638 -489
  167. package/src/transaction/coordinator.ts +2 -1
  168. package/src/transaction/errors.ts +91 -91
  169. package/src/transaction/operations-hash.ts +196 -196
  170. package/src/transaction/read-dependency-collector.ts +78 -78
  171. package/src/transaction/transaction.ts +1 -1
  172. package/src/transactor/change-notifier.ts +80 -80
  173. package/src/transactor/index.ts +5 -5
  174. package/src/transactor/network-transactor.ts +58 -19
  175. package/src/transactor/transactor-source.ts +25 -2
  176. package/src/transform/atomic-proxy.ts +92 -92
  177. package/src/transform/cache-source.ts +1 -1
  178. package/src/transform/helpers.ts +159 -158
  179. package/src/transform/tracker.ts +2 -1
  180. package/src/utility/backoff.ts +95 -95
  181. package/src/utility/batch-coordinator.ts +191 -191
  182. package/dist/src/transaction/context.d.ts +0 -60
  183. package/dist/src/transaction/context.d.ts.map +0 -1
  184. package/dist/src/transaction/context.js +0 -91
  185. package/dist/src/transaction/context.js.map +0 -1
@@ -1,254 +1,254 @@
1
- /**
2
- * Cohort-topic substrate — intra-cohort gossip bus.
3
- *
4
- * Per `docs/cohort-topic.md` §Cohort gossip. Each member periodically broadcasts a `CohortGossipV1`
5
- * carrying its willingness vector, load buckets, exact per-topic summaries, and any registration
6
- * record deltas (new/touched records + evictions). The bus:
7
- *
8
- * - **broadcasts** outbound gossip over the injected {@link ICohortGossipTransport} (FRET cohort
9
- * gossip underneath — db-core never imports FRET);
10
- * - **routes by coord**: a delivered frame is fanned to every coord engine's bus on a node, so each
11
- * bus merges only the gossip whose `coord` names its own cohort (epoch alone never isolates two
12
- * coords — they can share a member set, hence an epoch);
13
- * - **merges inbound** gossip — record deltas into the {@link RegistrationStore} (last-writer-wins
14
- * by `lastPing`; evictions remove), and willingness/load/summaries into the per-member
15
- * {@link CohortView};
16
- * - **detects epoch drift**: an inbound `cohortEpoch` that differs from the local epoch fires the
17
- * drift handlers, and record deltas under a foreign epoch are *not* merged (their slot
18
- * assignments belong to a different membership snapshot);
19
- * - **optionally authenticates** inbound transport frames via an injected {@link GossipInboundVerifier}
20
- * (db-p2p binds peer-sig + cohort membership), dropping forged or non-member gossip before any merge.
21
- *
22
- * Convergence target (doc): a single gossip round spreads a touched record / willingness flip to all
23
- * members — one {@link CohortGossipBus.applyInbound} of that gossip makes it visible locally.
24
- */
25
-
26
- import { bytesEqual, bytesKey } from "../registration/bytes.js";
27
- import type { RegistrationStore } from "../registration/types.js";
28
- import { clampTtl } from "../registration/types.js";
29
- import type { ICohortGossipTransport, PeerRef, RingCoord } from "../ports.js";
30
- import { b64urlToBytes, decodeCohortGossipV1, encodeCohortMessage } from "../wire/codec.js";
31
- import type { ChildLinkRefV1, CohortGossipV1 } from "../wire/types.js";
32
- import { fromGossipRecord } from "./records.js";
33
- import { createCohortView, type CohortView, type MutableCohortView } from "./view.js";
34
-
35
- /** Handler for inbound gossip (after merge). */
36
- export type GossipHandler = (g: CohortGossipV1) => void;
37
- /** Handler for a detected cohort-epoch drift on an inbound gossip. */
38
- export type DriftHandler = (inboundEpoch: Uint8Array, localEpoch: Uint8Array, from: string) => void;
39
- /** Authenticity gate for an inbound gossip arriving over the transport — `false` drops the frame. */
40
- export type GossipInboundVerifier = (g: CohortGossipV1) => boolean;
41
-
42
- export interface CohortGossipBusDeps {
43
- /** FRET-backed cohort gossip transport (broadcast / subscribe). */
44
- transport: ICohortGossipTransport;
45
- /** Local registration store record deltas merge into. */
46
- store: RegistrationStore;
47
- /** This cohort's coord — the broadcast key **and** the inbound routing key (see {@link CohortGossipV1.coord}). */
48
- coord: RingCoord;
49
- /** The local cohort epoch, read fresh each merge so rotation is observed. */
50
- localEpoch: () => Uint8Array;
51
- /** Wall clock for inbound merges via the transport path; injectable for tests. */
52
- now?: () => number;
53
- /** Frame ceiling for encode/decode (defaults to the codec default). */
54
- maxMessageBytes?: number;
55
- /**
56
- * Optional authenticity gate run on every gossip arriving over the transport (db-p2p binds it to the
57
- * peer-sig primitive: verify `fromMember`'s signature over the gossip image **and** that `fromMember`
58
- * is a member of this coord's cohort). A `false` verdict drops the frame — no merge, no handler fire —
59
- * so a forged or non-member gossip can neither spoof willingness/load nor replicate fake records.
60
- * Absent → the gate is skipped (unit tests / key-less interim mode); the `coord` routing check below
61
- * still runs regardless. Not applied to direct {@link CohortGossipBus.applyInbound} calls (the trusted
62
- * test/internal merge entry point).
63
- */
64
- verifyInbound?: GossipInboundVerifier;
65
- /**
66
- * Optional hook fired after a merge that deleted one or more records via gossiped evictions, carrying
67
- * the **distinct** topic ids touched. db-p2p wires it to re-`touch` the per-cohort topic budget down
68
- * (`topicBudget.touch(t, store.directParticipants(t))`) so a topic whose participants drain on a
69
- * *sibling* (the drain arrives here as a gossip eviction, never this member's own TTL sweep) still
70
- * releases its budget slot — mirroring the engine's own `sweepStale` re-touch. Kept as a decoupled
71
- * callback so this gossip/replication module carries no anti-DoS dependency. Absent → skipped.
72
- */
73
- onRecordsEvicted?: (topicIds: readonly Uint8Array[]) => void;
74
- /**
75
- * Optional hook fired for the child-cohort link/unlink deltas on an inbound frame (db-p2p merges them into
76
- * this coord's child registry, last-writer-wins by `effectiveAt`), so every parent member converges on the
77
- * same child set — not only the FRET-routed member that recorded the child. Fired for **our-coord** frames
78
- * regardless of epoch match: the child set is keyed by child coord, not the parent's epoch, so a parent
79
- * membership rotation (an epoch drift) must not skip the merge (unlike record deltas, whose slot
80
- * assignments are epoch-scoped). The merge is a direct registry write — it does **not** re-enqueue the
81
- * delta, so a received link/unlink is not re-gossiped (one broadcast reaches the whole cohort). Kept as a
82
- * decoupled callback so this module carries no child-registry dependency. Absent → skipped.
83
- */
84
- onChildDeltas?: (childLinks: readonly ChildLinkRefV1[], childUnlinks: readonly ChildLinkRefV1[]) => void;
85
- }
86
-
87
- /** Intra-cohort gossip bus (merge logic over an injected transport). */
88
- export interface CohortGossipBus {
89
- /** Broadcast `g` to the cohort. */
90
- broadcast(g: CohortGossipV1): void;
91
- /** Subscribe to inbound gossip (fired after merge); returns an unsubscribe handle. */
92
- onGossip(handler: GossipHandler): () => void;
93
- /** Subscribe to epoch-drift detections; returns an unsubscribe handle. */
94
- onDrift(handler: DriftHandler): () => void;
95
- /** Merge one inbound gossip: records/willingness/load, with epoch drift detection. */
96
- applyInbound(g: CohortGossipV1, now: number): void;
97
- /** The merged per-member view (willingness / load / summaries). */
98
- view(): CohortView;
99
- /** Detach the transport subscription. */
100
- close(): void;
101
- }
102
-
103
- class TransportCohortGossipBus implements CohortGossipBus {
104
- private readonly gossipHandlers = new Set<GossipHandler>();
105
- private readonly driftHandlers = new Set<DriftHandler>();
106
- private readonly memberView: MutableCohortView = createCohortView();
107
- private readonly now: () => number;
108
- private readonly detach: () => void;
109
-
110
- constructor(private readonly deps: CohortGossipBusDeps) {
111
- this.now = deps.now ?? ((): number => Date.now());
112
- this.detach = deps.transport.onMessage((_from: PeerRef, msg: Uint8Array) => this.onInbound(msg));
113
- }
114
-
115
- broadcast(g: CohortGossipV1): void {
116
- this.deps.transport.broadcast(this.deps.coord, encodeCohortMessage(g, this.deps.maxMessageBytes));
117
- }
118
-
119
- onGossip(handler: GossipHandler): () => void {
120
- this.gossipHandlers.add(handler);
121
- return () => this.gossipHandlers.delete(handler);
122
- }
123
-
124
- onDrift(handler: DriftHandler): () => void {
125
- this.driftHandlers.add(handler);
126
- return () => this.driftHandlers.delete(handler);
127
- }
128
-
129
- applyInbound(g: CohortGossipV1, now: number): void {
130
- // Coord routing: a frame is fanned to every coord engine's bus, but it belongs only to the bus
131
- // whose coord it names. Drop any gossip for another cohort so it cannot merge into this store/view
132
- // (two coords on one node can even share a member set — and thus an epoch — so epoch alone never
133
- // isolates them).
134
- if (!this.isOurCoord(g)) {
135
- return;
136
- }
137
- const localEpoch = this.deps.localEpoch();
138
- const inboundEpoch = b64urlToBytes(g.cohortEpoch);
139
- const epochMatches = bytesEqual(inboundEpoch, localEpoch);
140
- if (!epochMatches) {
141
- for (const handler of this.driftHandlers) {
142
- handler(inboundEpoch, localEpoch, g.fromMember);
143
- }
144
- }
145
- this.mergeView(g, inboundEpoch);
146
- if (epochMatches) {
147
- this.mergeRecords(g, now);
148
- }
149
- // Child link/unlink deltas merge regardless of epoch match: the child set is keyed by child coord, not
150
- // the parent's epoch, so a rotation (epoch drift) must not drop it. Freshness (last-writer-wins by
151
- // effectiveAt) lives in the registry the callback writes.
152
- const childLinks = g.childLinks ?? [];
153
- const childUnlinks = g.childUnlinks ?? [];
154
- if (this.deps.onChildDeltas !== undefined && (childLinks.length > 0 || childUnlinks.length > 0)) {
155
- this.deps.onChildDeltas(childLinks, childUnlinks);
156
- }
157
- }
158
-
159
- view(): CohortView {
160
- return this.memberView;
161
- }
162
-
163
- close(): void {
164
- this.detach();
165
- }
166
-
167
- private onInbound(msg: Uint8Array): void {
168
- const g = decodeCohortGossipV1(msg, this.deps.maxMessageBytes);
169
- // Cheap routing check first so a non-matching coord skips the (expensive) signature verify.
170
- if (!this.isOurCoord(g)) {
171
- return;
172
- }
173
- // Authenticity gate (db-p2p binds peer-sig + cohort membership); a forged/non-member gossip is
174
- // dropped before any merge or handler fire. Absent in key-less/unit composition.
175
- if (this.deps.verifyInbound !== undefined && !this.deps.verifyInbound(g)) {
176
- return;
177
- }
178
- this.applyInbound(g, this.now());
179
- for (const handler of this.gossipHandlers) {
180
- handler(g);
181
- }
182
- }
183
-
184
- /** True iff `g` names this bus's cohort coord (the inbound routing key). */
185
- private isOurCoord(g: CohortGossipV1): boolean {
186
- return bytesEqual(b64urlToBytes(g.coord), this.deps.coord);
187
- }
188
-
189
- private mergeView(g: CohortGossipV1, inboundEpoch: Uint8Array): void {
190
- this.memberView.merge(g.fromMember, {
191
- cohortEpoch: inboundEpoch,
192
- willingness: parseInt(g.willingnessBits, 16),
193
- loadBuckets: g.loadBuckets,
194
- windowSeconds: g.windowSeconds,
195
- topicSummaries: g.topicSummaries,
196
- timestamp: g.timestamp,
197
- });
198
- }
199
-
200
- private mergeRecords(g: CohortGossipV1, now: number): void {
201
- for (const gr of g.records ?? []) {
202
- const incoming = fromGossipRecord(gr);
203
- // Gossip is a second admission path into the store — a peer (buggy, unpatched, or hostile)
204
- // can replicate a record whose TTL never passed local `accept()`'s clamp. Re-clamp here so
205
- // no replica can hold an unbounded lifetime that wedges the topic budget. Clamp BEFORE the
206
- // dead-check below so a poison TTL cannot dodge staleness eviction.
207
- incoming.ttl = clampTtl(incoming.ttl);
208
- // A record already past its TTL is dead; merging it would resurrect a registration the
209
- // owner has (or soon will have) evicted. Drop it, matching `store.evictStale`'s predicate
210
- // so replication can never reintroduce what local eviction removes.
211
- if (now - incoming.lastPing > incoming.ttl) {
212
- continue;
213
- }
214
- const held = this.deps.store.getByParticipant(incoming.topicId, incoming.participantId);
215
- // Last-writer-wins by lastPing: a touch (newer lastPing) replaces an older replica.
216
- if (held === undefined || incoming.lastPing >= held.lastPing) {
217
- this.deps.store.put(incoming);
218
- }
219
- }
220
- // Collect the distinct topics a gossiped eviction drained so the budget can be re-touched *after*
221
- // the deletes (the count must be post-drain). This is the sibling-drain half of the topic-budget
222
- // leak fix: a topic whose participants are sharded onto a sibling primary drains into this store as
223
- // a gossip eviction, never via this member's own TTL sweep, so the `onRecordsEvicted` callback must
224
- // re-`touch` the budget down here too — otherwise the slot leaks for any multi-member topic.
225
- let evictedTopics: Map<string, Uint8Array> | undefined;
226
- for (const ref of g.evicted ?? []) {
227
- const topicId = b64urlToBytes(ref.topicId);
228
- const participantId = b64urlToBytes(ref.participantId);
229
- // NOTE: freshness guard adds one getByParticipant per eviction ref (on top of the delete's own
230
- // lookup); evictions are low-volume, but if this path ever shows as hot, fold the guard into a
231
- // single conditional-delete store op.
232
- const held = this.deps.store.getByParticipant(topicId, participantId);
233
- // A stale eviction (older than the held record) must not delete a fresher re-registration.
234
- // Evictions are otherwise NOT last-writer-wins like merges are, so a reordered/slow delta would
235
- // win against a record the participant already renewed. Skip it — and, since nothing drained,
236
- // do NOT add the topic to `evictedTopics` (no delete → no budget re-touch).
237
- if (held !== undefined && held.lastPing > ref.lastPing) {
238
- continue;
239
- }
240
- this.deps.store.delete(topicId, participantId);
241
- if (this.deps.onRecordsEvicted !== undefined) {
242
- (evictedTopics ??= new Map()).set(bytesKey(topicId), topicId);
243
- }
244
- }
245
- if (evictedTopics !== undefined && evictedTopics.size > 0) {
246
- this.deps.onRecordsEvicted!([...evictedTopics.values()]);
247
- }
248
- }
249
- }
250
-
251
- /** Build a {@link CohortGossipBus} over the injected transport + store. */
252
- export function createCohortGossipBus(deps: CohortGossipBusDeps): CohortGossipBus {
253
- return new TransportCohortGossipBus(deps);
254
- }
1
+ /**
2
+ * Cohort-topic substrate — intra-cohort gossip bus.
3
+ *
4
+ * Per `docs/cohort-topic.md` §Cohort gossip. Each member periodically broadcasts a `CohortGossipV1`
5
+ * carrying its willingness vector, load buckets, exact per-topic summaries, and any registration
6
+ * record deltas (new/touched records + evictions). The bus:
7
+ *
8
+ * - **broadcasts** outbound gossip over the injected {@link ICohortGossipTransport} (FRET cohort
9
+ * gossip underneath — db-core never imports FRET);
10
+ * - **routes by coord**: a delivered frame is fanned to every coord engine's bus on a node, so each
11
+ * bus merges only the gossip whose `coord` names its own cohort (epoch alone never isolates two
12
+ * coords — they can share a member set, hence an epoch);
13
+ * - **merges inbound** gossip — record deltas into the {@link RegistrationStore} (last-writer-wins
14
+ * by `lastPing`; evictions remove), and willingness/load/summaries into the per-member
15
+ * {@link CohortView};
16
+ * - **detects epoch drift**: an inbound `cohortEpoch` that differs from the local epoch fires the
17
+ * drift handlers, and record deltas under a foreign epoch are *not* merged (their slot
18
+ * assignments belong to a different membership snapshot);
19
+ * - **optionally authenticates** inbound transport frames via an injected {@link GossipInboundVerifier}
20
+ * (db-p2p binds peer-sig + cohort membership), dropping forged or non-member gossip before any merge.
21
+ *
22
+ * Convergence target (doc): a single gossip round spreads a touched record / willingness flip to all
23
+ * members — one {@link CohortGossipBus.applyInbound} of that gossip makes it visible locally.
24
+ */
25
+
26
+ import { bytesEqual, bytesKey } from "../registration/bytes.js";
27
+ import type { RegistrationStore } from "../registration/types.js";
28
+ import { clampTtl } from "../registration/types.js";
29
+ import type { ICohortGossipTransport, PeerRef, RingCoord } from "../ports.js";
30
+ import { b64urlToBytes, decodeCohortGossipV1, encodeCohortMessage } from "../wire/codec.js";
31
+ import type { ChildLinkRefV1, CohortGossipV1 } from "../wire/types.js";
32
+ import { fromGossipRecord } from "./records.js";
33
+ import { createCohortView, type CohortView, type MutableCohortView } from "./view.js";
34
+
35
+ /** Handler for inbound gossip (after merge). */
36
+ export type GossipHandler = (g: CohortGossipV1) => void;
37
+ /** Handler for a detected cohort-epoch drift on an inbound gossip. */
38
+ export type DriftHandler = (inboundEpoch: Uint8Array, localEpoch: Uint8Array, from: string) => void;
39
+ /** Authenticity gate for an inbound gossip arriving over the transport — `false` drops the frame. */
40
+ export type GossipInboundVerifier = (g: CohortGossipV1) => boolean;
41
+
42
+ export interface CohortGossipBusDeps {
43
+ /** FRET-backed cohort gossip transport (broadcast / subscribe). */
44
+ transport: ICohortGossipTransport;
45
+ /** Local registration store record deltas merge into. */
46
+ store: RegistrationStore;
47
+ /** This cohort's coord — the broadcast key **and** the inbound routing key (see {@link CohortGossipV1.coord}). */
48
+ coord: RingCoord;
49
+ /** The local cohort epoch, read fresh each merge so rotation is observed. */
50
+ localEpoch: () => Uint8Array;
51
+ /** Wall clock for inbound merges via the transport path; injectable for tests. */
52
+ now?: () => number;
53
+ /** Frame ceiling for encode/decode (defaults to the codec default). */
54
+ maxMessageBytes?: number;
55
+ /**
56
+ * Optional authenticity gate run on every gossip arriving over the transport (db-p2p binds it to the
57
+ * peer-sig primitive: verify `fromMember`'s signature over the gossip image **and** that `fromMember`
58
+ * is a member of this coord's cohort). A `false` verdict drops the frame — no merge, no handler fire —
59
+ * so a forged or non-member gossip can neither spoof willingness/load nor replicate fake records.
60
+ * Absent → the gate is skipped (unit tests / key-less interim mode); the `coord` routing check below
61
+ * still runs regardless. Not applied to direct {@link CohortGossipBus.applyInbound} calls (the trusted
62
+ * test/internal merge entry point).
63
+ */
64
+ verifyInbound?: GossipInboundVerifier;
65
+ /**
66
+ * Optional hook fired after a merge that deleted one or more records via gossiped evictions, carrying
67
+ * the **distinct** topic ids touched. db-p2p wires it to re-`touch` the per-cohort topic budget down
68
+ * (`topicBudget.touch(t, store.directParticipants(t))`) so a topic whose participants drain on a
69
+ * *sibling* (the drain arrives here as a gossip eviction, never this member's own TTL sweep) still
70
+ * releases its budget slot — mirroring the engine's own `sweepStale` re-touch. Kept as a decoupled
71
+ * callback so this gossip/replication module carries no anti-DoS dependency. Absent → skipped.
72
+ */
73
+ onRecordsEvicted?: (topicIds: readonly Uint8Array[]) => void;
74
+ /**
75
+ * Optional hook fired for the child-cohort link/unlink deltas on an inbound frame (db-p2p merges them into
76
+ * this coord's child registry, last-writer-wins by `effectiveAt`), so every parent member converges on the
77
+ * same child set — not only the FRET-routed member that recorded the child. Fired for **our-coord** frames
78
+ * regardless of epoch match: the child set is keyed by child coord, not the parent's epoch, so a parent
79
+ * membership rotation (an epoch drift) must not skip the merge (unlike record deltas, whose slot
80
+ * assignments are epoch-scoped). The merge is a direct registry write — it does **not** re-enqueue the
81
+ * delta, so a received link/unlink is not re-gossiped (one broadcast reaches the whole cohort). Kept as a
82
+ * decoupled callback so this module carries no child-registry dependency. Absent → skipped.
83
+ */
84
+ onChildDeltas?: (childLinks: readonly ChildLinkRefV1[], childUnlinks: readonly ChildLinkRefV1[]) => void;
85
+ }
86
+
87
+ /** Intra-cohort gossip bus (merge logic over an injected transport). */
88
+ export interface CohortGossipBus {
89
+ /** Broadcast `g` to the cohort. */
90
+ broadcast(g: CohortGossipV1): void;
91
+ /** Subscribe to inbound gossip (fired after merge); returns an unsubscribe handle. */
92
+ onGossip(handler: GossipHandler): () => void;
93
+ /** Subscribe to epoch-drift detections; returns an unsubscribe handle. */
94
+ onDrift(handler: DriftHandler): () => void;
95
+ /** Merge one inbound gossip: records/willingness/load, with epoch drift detection. */
96
+ applyInbound(g: CohortGossipV1, now: number): void;
97
+ /** The merged per-member view (willingness / load / summaries). */
98
+ view(): CohortView;
99
+ /** Detach the transport subscription. */
100
+ close(): void;
101
+ }
102
+
103
+ class TransportCohortGossipBus implements CohortGossipBus {
104
+ private readonly gossipHandlers = new Set<GossipHandler>();
105
+ private readonly driftHandlers = new Set<DriftHandler>();
106
+ private readonly memberView: MutableCohortView = createCohortView();
107
+ private readonly now: () => number;
108
+ private readonly detach: () => void;
109
+
110
+ constructor(private readonly deps: CohortGossipBusDeps) {
111
+ this.now = deps.now ?? ((): number => Date.now());
112
+ this.detach = deps.transport.onMessage((_from: PeerRef, msg: Uint8Array) => this.onInbound(msg));
113
+ }
114
+
115
+ broadcast(g: CohortGossipV1): void {
116
+ this.deps.transport.broadcast(this.deps.coord, encodeCohortMessage(g, this.deps.maxMessageBytes));
117
+ }
118
+
119
+ onGossip(handler: GossipHandler): () => void {
120
+ this.gossipHandlers.add(handler);
121
+ return () => this.gossipHandlers.delete(handler);
122
+ }
123
+
124
+ onDrift(handler: DriftHandler): () => void {
125
+ this.driftHandlers.add(handler);
126
+ return () => this.driftHandlers.delete(handler);
127
+ }
128
+
129
+ applyInbound(g: CohortGossipV1, now: number): void {
130
+ // Coord routing: a frame is fanned to every coord engine's bus, but it belongs only to the bus
131
+ // whose coord it names. Drop any gossip for another cohort so it cannot merge into this store/view
132
+ // (two coords on one node can even share a member set — and thus an epoch — so epoch alone never
133
+ // isolates them).
134
+ if (!this.isOurCoord(g)) {
135
+ return;
136
+ }
137
+ const localEpoch = this.deps.localEpoch();
138
+ const inboundEpoch = b64urlToBytes(g.cohortEpoch);
139
+ const epochMatches = bytesEqual(inboundEpoch, localEpoch);
140
+ if (!epochMatches) {
141
+ for (const handler of this.driftHandlers) {
142
+ handler(inboundEpoch, localEpoch, g.fromMember);
143
+ }
144
+ }
145
+ this.mergeView(g, inboundEpoch);
146
+ if (epochMatches) {
147
+ this.mergeRecords(g, now);
148
+ }
149
+ // Child link/unlink deltas merge regardless of epoch match: the child set is keyed by child coord, not
150
+ // the parent's epoch, so a rotation (epoch drift) must not drop it. Freshness (last-writer-wins by
151
+ // effectiveAt) lives in the registry the callback writes.
152
+ const childLinks = g.childLinks ?? [];
153
+ const childUnlinks = g.childUnlinks ?? [];
154
+ if (this.deps.onChildDeltas !== undefined && (childLinks.length > 0 || childUnlinks.length > 0)) {
155
+ this.deps.onChildDeltas(childLinks, childUnlinks);
156
+ }
157
+ }
158
+
159
+ view(): CohortView {
160
+ return this.memberView;
161
+ }
162
+
163
+ close(): void {
164
+ this.detach();
165
+ }
166
+
167
+ private onInbound(msg: Uint8Array): void {
168
+ const g = decodeCohortGossipV1(msg, this.deps.maxMessageBytes);
169
+ // Cheap routing check first so a non-matching coord skips the (expensive) signature verify.
170
+ if (!this.isOurCoord(g)) {
171
+ return;
172
+ }
173
+ // Authenticity gate (db-p2p binds peer-sig + cohort membership); a forged/non-member gossip is
174
+ // dropped before any merge or handler fire. Absent in key-less/unit composition.
175
+ if (this.deps.verifyInbound !== undefined && !this.deps.verifyInbound(g)) {
176
+ return;
177
+ }
178
+ this.applyInbound(g, this.now());
179
+ for (const handler of this.gossipHandlers) {
180
+ handler(g);
181
+ }
182
+ }
183
+
184
+ /** True iff `g` names this bus's cohort coord (the inbound routing key). */
185
+ private isOurCoord(g: CohortGossipV1): boolean {
186
+ return bytesEqual(b64urlToBytes(g.coord), this.deps.coord);
187
+ }
188
+
189
+ private mergeView(g: CohortGossipV1, inboundEpoch: Uint8Array): void {
190
+ this.memberView.merge(g.fromMember, {
191
+ cohortEpoch: inboundEpoch,
192
+ willingness: parseInt(g.willingnessBits, 16),
193
+ loadBuckets: g.loadBuckets,
194
+ windowSeconds: g.windowSeconds,
195
+ topicSummaries: g.topicSummaries,
196
+ timestamp: g.timestamp,
197
+ });
198
+ }
199
+
200
+ private mergeRecords(g: CohortGossipV1, now: number): void {
201
+ for (const gr of g.records ?? []) {
202
+ const incoming = fromGossipRecord(gr);
203
+ // Gossip is a second admission path into the store — a peer (buggy, unpatched, or hostile)
204
+ // can replicate a record whose TTL never passed local `accept()`'s clamp. Re-clamp here so
205
+ // no replica can hold an unbounded lifetime that wedges the topic budget. Clamp BEFORE the
206
+ // dead-check below so a poison TTL cannot dodge staleness eviction.
207
+ incoming.ttl = clampTtl(incoming.ttl);
208
+ // A record already past its TTL is dead; merging it would resurrect a registration the
209
+ // owner has (or soon will have) evicted. Drop it, matching `store.evictStale`'s predicate
210
+ // so replication can never reintroduce what local eviction removes.
211
+ if (now - incoming.lastPing > incoming.ttl) {
212
+ continue;
213
+ }
214
+ const held = this.deps.store.getByParticipant(incoming.topicId, incoming.participantId);
215
+ // Last-writer-wins by lastPing: a touch (newer lastPing) replaces an older replica.
216
+ if (held === undefined || incoming.lastPing >= held.lastPing) {
217
+ this.deps.store.put(incoming);
218
+ }
219
+ }
220
+ // Collect the distinct topics a gossiped eviction drained so the budget can be re-touched *after*
221
+ // the deletes (the count must be post-drain). This is the sibling-drain half of the topic-budget
222
+ // leak fix: a topic whose participants are sharded onto a sibling primary drains into this store as
223
+ // a gossip eviction, never via this member's own TTL sweep, so the `onRecordsEvicted` callback must
224
+ // re-`touch` the budget down here too — otherwise the slot leaks for any multi-member topic.
225
+ let evictedTopics: Map<string, Uint8Array> | undefined;
226
+ for (const ref of g.evicted ?? []) {
227
+ const topicId = b64urlToBytes(ref.topicId);
228
+ const participantId = b64urlToBytes(ref.participantId);
229
+ // NOTE: freshness guard adds one getByParticipant per eviction ref (on top of the delete's own
230
+ // lookup); evictions are low-volume, but if this path ever shows as hot, fold the guard into a
231
+ // single conditional-delete store op.
232
+ const held = this.deps.store.getByParticipant(topicId, participantId);
233
+ // A stale eviction (older than the held record) must not delete a fresher re-registration.
234
+ // Evictions are otherwise NOT last-writer-wins like merges are, so a reordered/slow delta would
235
+ // win against a record the participant already renewed. Skip it — and, since nothing drained,
236
+ // do NOT add the topic to `evictedTopics` (no delete → no budget re-touch).
237
+ if (held !== undefined && held.lastPing > ref.lastPing) {
238
+ continue;
239
+ }
240
+ this.deps.store.delete(topicId, participantId);
241
+ if (this.deps.onRecordsEvicted !== undefined) {
242
+ (evictedTopics ??= new Map()).set(bytesKey(topicId), topicId);
243
+ }
244
+ }
245
+ if (evictedTopics !== undefined && evictedTopics.size > 0) {
246
+ this.deps.onRecordsEvicted!([...evictedTopics.values()]);
247
+ }
248
+ }
249
+ }
250
+
251
+ /** Build a {@link CohortGossipBus} over the injected transport + store. */
252
+ export function createCohortGossipBus(deps: CohortGossipBusDeps): CohortGossipBus {
253
+ return new TransportCohortGossipBus(deps);
254
+ }
@@ -1,3 +1,3 @@
1
- export * from "./records.js";
2
- export * from "./view.js";
3
- export * from "./bus.js";
1
+ export * from "./records.js";
2
+ export * from "./view.js";
3
+ export * from "./bus.js";
@@ -1,45 +1,45 @@
1
- /**
2
- * Cohort-topic substrate — translation between the local {@link RegistrationRecord} (raw bytes)
3
- * and the wire {@link GossipRecordV1} (base64url). The gossip bus carries record deltas so backups
4
- * hold the record before the primary fails (per `docs/cohort-topic.md` §Registration record).
5
- */
6
-
7
- import { b64urlToBytes, bytesToB64url } from "../wire/codec.js";
8
- import type { GossipRecordV1 } from "../wire/types.js";
9
- import type { RegistrationRecord } from "../registration/types.js";
10
-
11
- /** Encode a local registration record for cross-member gossip. */
12
- export function toGossipRecord(rec: RegistrationRecord): GossipRecordV1 {
13
- const out: GossipRecordV1 = {
14
- topicId: bytesToB64url(rec.topicId),
15
- participantId: bytesToB64url(rec.participantId),
16
- tier: rec.tier,
17
- primary: bytesToB64url(rec.primary),
18
- backups: rec.backups.map(bytesToB64url),
19
- attachedAt: rec.attachedAt,
20
- lastPing: rec.lastPing,
21
- ttl: rec.ttl,
22
- };
23
- if (rec.appState !== undefined) {
24
- out.appState = bytesToB64url(rec.appState);
25
- }
26
- return out;
27
- }
28
-
29
- /** Decode a gossiped record back into the local store shape. */
30
- export function fromGossipRecord(g: GossipRecordV1): RegistrationRecord {
31
- const out: RegistrationRecord = {
32
- topicId: b64urlToBytes(g.topicId),
33
- participantId: b64urlToBytes(g.participantId),
34
- tier: g.tier,
35
- primary: b64urlToBytes(g.primary),
36
- backups: g.backups.map(b64urlToBytes),
37
- attachedAt: g.attachedAt,
38
- lastPing: g.lastPing,
39
- ttl: g.ttl,
40
- };
41
- if (g.appState !== undefined) {
42
- out.appState = b64urlToBytes(g.appState);
43
- }
44
- return out;
45
- }
1
+ /**
2
+ * Cohort-topic substrate — translation between the local {@link RegistrationRecord} (raw bytes)
3
+ * and the wire {@link GossipRecordV1} (base64url). The gossip bus carries record deltas so backups
4
+ * hold the record before the primary fails (per `docs/cohort-topic.md` §Registration record).
5
+ */
6
+
7
+ import { b64urlToBytes, bytesToB64url } from "../wire/codec.js";
8
+ import type { GossipRecordV1 } from "../wire/types.js";
9
+ import type { RegistrationRecord } from "../registration/types.js";
10
+
11
+ /** Encode a local registration record for cross-member gossip. */
12
+ export function toGossipRecord(rec: RegistrationRecord): GossipRecordV1 {
13
+ const out: GossipRecordV1 = {
14
+ topicId: bytesToB64url(rec.topicId),
15
+ participantId: bytesToB64url(rec.participantId),
16
+ tier: rec.tier,
17
+ primary: bytesToB64url(rec.primary),
18
+ backups: rec.backups.map(bytesToB64url),
19
+ attachedAt: rec.attachedAt,
20
+ lastPing: rec.lastPing,
21
+ ttl: rec.ttl,
22
+ };
23
+ if (rec.appState !== undefined) {
24
+ out.appState = bytesToB64url(rec.appState);
25
+ }
26
+ return out;
27
+ }
28
+
29
+ /** Decode a gossiped record back into the local store shape. */
30
+ export function fromGossipRecord(g: GossipRecordV1): RegistrationRecord {
31
+ const out: RegistrationRecord = {
32
+ topicId: b64urlToBytes(g.topicId),
33
+ participantId: b64urlToBytes(g.participantId),
34
+ tier: g.tier,
35
+ primary: b64urlToBytes(g.primary),
36
+ backups: g.backups.map(b64urlToBytes),
37
+ attachedAt: g.attachedAt,
38
+ lastPing: g.lastPing,
39
+ ttl: g.ttl,
40
+ };
41
+ if (g.appState !== undefined) {
42
+ out.appState = b64urlToBytes(g.appState);
43
+ }
44
+ return out;
45
+ }