@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,415 +1,415 @@
1
- /**
2
- * Reactivity — tail rotation lifecycle (`docs/reactivity.md` §Rotating tail anchor, §Tail rotation).
3
- *
4
- * `topicId = H(tailId ‖ "reactivity")` is derived per emission ({@link reactivityTopicId}). When the tail
5
- * block fills (`block_fill_size` transactions, default 64) a new tail block is born and `tailId` — hence
6
- * `topicId` — changes; the cohort-topic layer treats the new `topicId` as an **entirely new topic** (fresh
7
- * tree, new ring coord). Reactivity manages the subscriber/replay-state migration explicitly. This module
8
- * owns that lifecycle:
9
- *
10
- * - **Pre-announce** — the block-filling commit's notification carries {@link RotationHintV1}; built here
11
- * by {@link buildRotationHint} and detected subscriber-side by {@link detectRotation}.
12
- * - **Block-fill tracking** — {@link BlockFillTracker} counts transactions in the current tail block and
13
- * fires anticipatory **warm-up** at `block_fill_size − warm_threshold` and the **filling** signal at
14
- * `block_fill_size` (the commit that carries the hint).
15
- * - **Drain** — {@link TailDrainGate} keeps the outgoing tail serving renewals/replays for `T_drain` while
16
- * bouncing *new* subscriptions with a `Promoted`-shaped {@link RotationRedirectV1} to the new tree.
17
- * - **Jittered re-registration** — {@link planReRegistration} / {@link planReRegistrationWave} schedule a
18
- * subscriber's move to the new `topicId` over `T_rejoin_jitter` (the cohort-topic {@link RejoinJitter}),
19
- * carrying its existing `lastRevision` (revisions are continuous across rotations).
20
- * - **Buffer-to-checkpoint handoff** — {@link buildRotationHandoffCheckpoint} folds the outgoing tail's
21
- * replay buffer into a final {@link CheckpointSummary} covering `[lastCheckpoint.toRevision + 1,
22
- * rotationRevision]`, the **only** state migrated across a rotation; {@link applyRotationHandoff} lands it
23
- * at the new tail so a `ResumeV1` spanning the rotation is recoverable.
24
- *
25
- * Forwarder draining is *emergent*: forwarders under the old tail watch their direct-subscriber count drop
26
- * as subscribers re-register elsewhere and demote naturally per the cohort-topic demotion protocol (no
27
- * state migrates; the new tree rebuilds via re-registration). It needs no code here.
28
- *
29
- * Coordination with [reactivity-backfill-resume-checkpoints]: that ticket owns the `ResumeReplyV1.TailRotated`
30
- * variant and the `latestKnownTailId`-staleness classification; this ticket produces the handoff checkpoint
31
- * and the rotation *condition*. The new tail's {@link PushState.inheritedCheckpoint} (set by
32
- * {@link applyRotationHandoff}) is the seam the resume classifier consults to answer a checkpoint-window
33
- * resume whose span crosses the rotation: `classifyResume`/`serveResume` now read it (after the rolling
34
- * `checkpoint` misses) and serve the inherited summary, so a cross-rotation resume no longer falls to
35
- * `out_of_window` (`docs/reactivity.md` §Resume, §Tail rotation step 5). The drain-window redirect a new
36
- * subscription receives, {@link RotationRedirectV1}, is serialized by {@link validateRotationRedirectV1} and
37
- * rides the recover reply envelope as `kind: "rotated"` ({@link import("./recover.js").RecoverReplyV1}).
38
- */
39
-
40
- import { bytesToB64url, b64urlToBytes } from "../cohort-topic/wire/codec.js";
41
- import { createRingHash } from "../cohort-topic/ring-hash.js";
42
- import type { IRingHash } from "../cohort-topic/ports.js";
43
- import type { RejoinJitter } from "../cohort-topic/antiflood/jitter.js";
44
- import { BLOCK_FILL_SIZE_DEFAULT, T_DRAIN_MS, WARM_THRESHOLD_DEFAULT } from "./config.js";
45
- import { buildCheckpointSummary, type CheckpointSummary } from "./checkpoint.js";
46
- import { reactivityTopicId } from "./topic-anchor.js";
47
- import type { PushState } from "./push-state.js";
48
- import type { NotificationV1, RotationHintV1 } from "./wire.js";
49
- import { asObject, b64urlField, failWire, reqIntInRange, reqString, requireV1 } from "./wire-validate.js";
50
-
51
- // --- pre-announce + subscriber-side detection --------------------------------
52
-
53
- /**
54
- * Build the rotation pre-announce embedded in the block-filling commit's notification. The new tail block
55
- * id is known at the filling commit; the rotation becomes effective at the next revision, so
56
- * `effectiveAtRevision = fillingRevision + 1` (`docs/reactivity.md` §Tail rotation, worked scenario:
57
- * revision 5400 carries `{ newTailId: T_6, effectiveAtRevision: 5401 }`).
58
- */
59
- export function buildRotationHint(newTailId: string, fillingRevision: number): RotationHintV1 {
60
- return { newTailId, effectiveAtRevision: fillingRevision + 1 };
61
- }
62
-
63
- /** The subscriber-side rotation verdict for one delivered notification. */
64
- export interface RotationDetection {
65
- /** True iff the subscriber's tree has rotated (delivered tail differs) or a rotation is pre-announced. */
66
- readonly rotated: boolean;
67
- /** The tail id to re-register under, base64url — present iff `rotated`. */
68
- readonly newTailId?: string;
69
- /**
70
- * True iff this is a **pre-announce**: the delivered `tailId` still matches `tailIdAtAttach`, but the
71
- * notification's `rotationHint.newTailId` names a different successor. (False when the delivered tail
72
- * already differs — the tree has *already* migrated.)
73
- */
74
- readonly preAnnounced: boolean;
75
- }
76
-
77
- /**
78
- * Detect tail rotation for a subscriber attached at `tailIdAtAttach` (base64url) from a delivered
79
- * notification. Rotation is signaled when the delivered `tailId` **or** the `rotationHint.newTailId`
80
- * differs from `tailIdAtAttach` (`docs/reactivity.md` §Rotating tail anchor). An already-rotated delivery
81
- * (different `tailId`) takes precedence over a pre-announce.
82
- */
83
- export function detectRotation(tailIdAtAttach: string, n: Pick<NotificationV1, "tailId" | "rotationHint">): RotationDetection {
84
- if (n.tailId !== tailIdAtAttach) {
85
- // The delivered notification rides the *new* tree already — a hard rotation, not a pre-announce.
86
- return { rotated: true, newTailId: n.tailId, preAnnounced: false };
87
- }
88
- if (n.rotationHint !== undefined && n.rotationHint.newTailId !== tailIdAtAttach) {
89
- return { rotated: true, newTailId: n.rotationHint.newTailId, preAnnounced: true };
90
- }
91
- return { rotated: false, preAnnounced: false };
92
- }
93
-
94
- // --- block-fill tracking (warm-up + filling signal) --------------------------
95
-
96
- /** The fill signal for one committed transaction in the current tail block. */
97
- export type BlockFillSignal =
98
- /** Below the warm-up threshold — nothing to do. */
99
- | { readonly kind: "none"; readonly count: number }
100
- /**
101
- * Anticipatory warm-up: the block has reached `block_fill_size − warm_threshold` transactions with
102
- * `remaining` to go. The outgoing tail opportunistically biases FRET pre-dialing toward likely-successor
103
- * coords (best-effort; the next `tailId` is not yet knowable). No state migrates.
104
- */
105
- | { readonly kind: "warmup"; readonly count: number; readonly remaining: number }
106
- /**
107
- * The **block-filling** transaction: this commit's notification carries the {@link RotationHintV1} and
108
- * triggers the rotation. The tracker resets for the next block after emitting this.
109
- */
110
- | { readonly kind: "filling"; readonly count: number };
111
-
112
- /** Construction inputs for a {@link BlockFillTracker}. */
113
- export interface BlockFillTrackerInit {
114
- /** Transactions per block before the tail rotates (default {@link BLOCK_FILL_SIZE_DEFAULT}). */
115
- readonly blockFillSize?: number;
116
- /** Transactions remaining in the tail when anticipatory warm-up fires (default {@link WARM_THRESHOLD_DEFAULT}). */
117
- readonly warmThreshold?: number;
118
- }
119
-
120
- /**
121
- * Counts transactions committed into the current tail block and emits the {@link BlockFillSignal} that
122
- * drives anticipatory warm-up and the block-filling rotation trigger (`docs/reactivity.md` §Tail rotation,
123
- * §Anticipatory warm-up). One tracker per collection on the tail-cohort primary.
124
- */
125
- export class BlockFillTracker {
126
- readonly blockFillSize: number;
127
- readonly warmThreshold: number;
128
- private count = 0;
129
-
130
- constructor(init: BlockFillTrackerInit = {}) {
131
- const blockFillSize = init.blockFillSize ?? BLOCK_FILL_SIZE_DEFAULT;
132
- const warmThreshold = init.warmThreshold ?? WARM_THRESHOLD_DEFAULT;
133
- if (!Number.isInteger(blockFillSize) || blockFillSize < 1) {
134
- throw new RangeError(`reactivity rotation: blockFillSize must be an integer >= 1, got ${blockFillSize}`);
135
- }
136
- if (!Number.isInteger(warmThreshold) || warmThreshold < 0 || warmThreshold >= blockFillSize) {
137
- throw new RangeError(`reactivity rotation: warmThreshold must be an integer in 0..${blockFillSize - 1}, got ${warmThreshold}`);
138
- }
139
- this.blockFillSize = blockFillSize;
140
- this.warmThreshold = warmThreshold;
141
- }
142
-
143
- /** Transactions committed into the current (not-yet-rotated) block. */
144
- get transactionsInBlock(): number {
145
- return this.count;
146
- }
147
-
148
- /** The fill count at which anticipatory warm-up fires (`block_fill_size − warm_threshold`). */
149
- get warmAt(): number {
150
- return this.blockFillSize - this.warmThreshold;
151
- }
152
-
153
- /**
154
- * Record one committed transaction and return its fill signal. Emits `warmup` exactly when the block
155
- * reaches `block_fill_size − warm_threshold`, and `filling` when it reaches `block_fill_size` — after
156
- * which the counter resets for the next block.
157
- */
158
- onCommit(): BlockFillSignal {
159
- this.count += 1;
160
- if (this.count >= this.blockFillSize) {
161
- const count = this.count;
162
- this.count = 0; // a new tail block is born; start counting it
163
- return { kind: "filling", count };
164
- }
165
- if (this.count === this.warmAt && this.warmThreshold > 0) {
166
- return { kind: "warmup", count: this.count, remaining: this.blockFillSize - this.count };
167
- }
168
- return { kind: "none", count: this.count };
169
- }
170
-
171
- /** Reset the block counter (e.g. an externally-observed rotation the tracker did not drive). */
172
- reset(): void {
173
- this.count = 0;
174
- }
175
- }
176
-
177
- // --- drain gate (old tail serves renewals/replays, bounces new subscriptions) -
178
-
179
- /** The kind of request arriving at the outgoing tail during its drain window. */
180
- export type DrainOp =
181
- /** A *fresh* subscription (`lastKnownRev == 0` or a never-seen subscriber) — bounced to the new tree. */
182
- | "new_subscribe"
183
- /** A renewal of an existing registration — served through the drain. */
184
- | "renew"
185
- /** A replay/backfill/resume request — served through the drain. */
186
- | "replay";
187
-
188
- /**
189
- * A `Promoted`-shaped redirect to the rotated tree (`docs/reactivity.md` §Tail rotation step 2). Unlike the
190
- * cohort-topic tier-based `Promoted`, this redirects to an entirely **new topic** — the new tail's tree at
191
- * `coord_0(_, newTopicId)` — so it carries the successor's `newTailId` + derived `newTopicId`.
192
- */
193
- export interface RotationRedirectV1 {
194
- readonly v: 1;
195
- readonly result: "rotated";
196
- /** New tail block id the topic anchor rotated to, base64url. */
197
- readonly newTailId: string;
198
- /** `H(newTailId ‖ "reactivity")` — the new tree's topic id, base64url (the redirect target). */
199
- readonly newTopicId: string;
200
- /** Revision at which the rotation took effect. */
201
- readonly effectiveAtRevision: number;
202
- }
203
-
204
- /**
205
- * Narrow an already-parsed value to a {@link RotationRedirectV1}, throwing {@link CohortWireError} on any
206
- * defect. The redirect rides the recover reply envelope as `kind: "rotated"`
207
- * ({@link import("./recover.js").RecoverReplyV1}); it carries no signature — like the other recover reply
208
- * variants it is an unauthenticated routing hint (the subscriber re-walks and verifies the new tree on
209
- * arrival), so its trust comes from the new tree, not the redirect.
210
- */
211
- export function validateRotationRedirectV1(value: unknown): RotationRedirectV1 {
212
- const what = "RotationRedirectV1";
213
- const obj = asObject(value, what);
214
- requireV1(obj, what);
215
- if (obj["result"] !== "rotated") {
216
- failWire(`${what}: field "result" must be "rotated"`);
217
- }
218
- return {
219
- v: 1,
220
- result: "rotated",
221
- newTailId: b64urlField(reqString(obj, "newTailId", what), "newTailId", what),
222
- newTopicId: b64urlField(reqString(obj, "newTopicId", what), "newTopicId", what),
223
- effectiveAtRevision: reqIntInRange(obj, "effectiveAtRevision", what, 0),
224
- };
225
- }
226
-
227
- /** The drain gate's decision for one inbound request. */
228
- export type DrainDecision =
229
- /** Serve normally (renewals / replays during the drain window). */
230
- | { readonly kind: "serve" }
231
- /** Bounce to the new tree (a new subscription during the drain window). */
232
- | { readonly kind: "redirect"; readonly redirect: RotationRedirectV1 }
233
- /** The drain window has elapsed — the outgoing tail holds nothing; the caller re-registers from `d_max`. */
234
- | { readonly kind: "drained" };
235
-
236
- /** Construction inputs for a {@link TailDrainGate}. */
237
- export interface TailDrainGateInit {
238
- /** Unix ms the rotation took effect (drain starts here). */
239
- readonly rotatedAt: number;
240
- /** New tail block id the topic rotated to, base64url. */
241
- readonly newTailId: string;
242
- /** Revision at which the rotation took effect. */
243
- readonly effectiveAtRevision: number;
244
- /** Drain duration `T_drain` (ms, default {@link T_DRAIN_MS}). */
245
- readonly tDrainMs?: number;
246
- /** Ring hash for the `newTopicId` derivation (must match cohort-topic routing). Default db-core SHA-256. */
247
- readonly hash?: IRingHash;
248
- }
249
-
250
- /**
251
- * The outgoing tail's drain state machine. For `T_drain` after a rotation it accepts **renewals** and
252
- * serves **replays**, but bounces **new subscriptions** with a {@link RotationRedirectV1} to the new tree;
253
- * after `T_drain` it reports `drained` for everything (the subscriber re-registers from `d_max`).
254
- */
255
- export class TailDrainGate {
256
- readonly rotatedAt: number;
257
- readonly tDrainMs: number;
258
- private readonly redirect: RotationRedirectV1;
259
-
260
- constructor(init: TailDrainGateInit) {
261
- const tDrainMs = init.tDrainMs ?? T_DRAIN_MS;
262
- if (!(tDrainMs > 0)) {
263
- throw new RangeError(`reactivity rotation: tDrainMs must be > 0, got ${tDrainMs}`);
264
- }
265
- this.rotatedAt = init.rotatedAt;
266
- this.tDrainMs = tDrainMs;
267
- const hash = init.hash ?? createRingHash();
268
- const newTopicId = bytesToB64url(reactivityTopicId(b64urlToBytes(init.newTailId), hash));
269
- this.redirect = {
270
- v: 1,
271
- result: "rotated",
272
- newTailId: init.newTailId,
273
- newTopicId,
274
- effectiveAtRevision: init.effectiveAtRevision,
275
- };
276
- }
277
-
278
- /** Unix ms the drain window closes. */
279
- get drainEndsAt(): number {
280
- return this.rotatedAt + this.tDrainMs;
281
- }
282
-
283
- /** True while the outgoing tail is still draining (within `T_drain` of the rotation). */
284
- isDraining(now: number): boolean {
285
- return now < this.drainEndsAt;
286
- }
287
-
288
- /** The redirect this gate hands a new subscription (exposed for the redirect transport / tests). */
289
- get rotationRedirect(): RotationRedirectV1 {
290
- return this.redirect;
291
- }
292
-
293
- /**
294
- * Classify an inbound request. Within the drain window: `new_subscribe` → redirect, `renew`/`replay` →
295
- * serve. After the window: `drained` (the old tail has released its forwarder state).
296
- */
297
- classify(op: DrainOp, now: number): DrainDecision {
298
- if (!this.isDraining(now)) {
299
- return { kind: "drained" };
300
- }
301
- return op === "new_subscribe" ? { kind: "redirect", redirect: this.redirect } : { kind: "serve" };
302
- }
303
- }
304
-
305
- // --- jittered re-registration (subscriber moves to the new tree) -------------
306
-
307
- /** A subscriber's planned re-registration at the rotated tree. */
308
- export interface ReRegistrationPlan {
309
- /** New tail block id (raw bytes) to attach under. */
310
- readonly newTailId: Uint8Array;
311
- /** `H(newTailId ‖ "reactivity")` (raw bytes) — the new tree's topic id to register at. */
312
- readonly newTopicId: Uint8Array;
313
- /** The subscriber's existing `lastRevision`, carried so revisions stay continuous across the rotation. */
314
- readonly lastRevision: number;
315
- /** Unix ms at which to fire the re-registration (jittered over `T_rejoin_jitter`). */
316
- readonly fireAt: number;
317
- }
318
-
319
- /** Inputs shared by the single and wave re-registration planners. */
320
- interface ReRegistrationTarget {
321
- /** The rotation hint (or any object carrying the successor `newTailId`), base64url. */
322
- readonly hint: Pick<RotationHintV1, "newTailId">;
323
- /** Ring hash for the `newTopicId` derivation. Default db-core SHA-256. */
324
- readonly hash?: IRingHash;
325
- }
326
-
327
- /** Resolve `(newTailId, newTopicId)` raw bytes for a rotation target. */
328
- function resolveRotationTarget(target: ReRegistrationTarget): { newTailId: Uint8Array; newTopicId: Uint8Array } {
329
- const newTailId = b64urlToBytes(target.hint.newTailId);
330
- const newTopicId = reactivityTopicId(newTailId, target.hash ?? createRingHash());
331
- return { newTailId, newTopicId };
332
- }
333
-
334
- /**
335
- * Plan **one** subscriber's re-registration at the rotated tree: derive the new `topicId` from the hint's
336
- * `newTailId`, carry the subscriber's `lastRevision`, and draw a jittered `fireAt` over `T_rejoin_jitter`
337
- * via the cohort-topic {@link RejoinJitter} (`scheduleRejoin` — a uniform offset, decorrelating this
338
- * subscriber's re-join from its peers).
339
- */
340
- export function planReRegistration(opts: ReRegistrationTarget & { lastRevision: number; now: number; jitter: RejoinJitter }): ReRegistrationPlan {
341
- const { newTailId, newTopicId } = resolveRotationTarget(opts);
342
- return { newTailId, newTopicId, lastRevision: opts.lastRevision, fireAt: opts.jitter.scheduleRejoin(opts.now) };
343
- }
344
-
345
- /**
346
- * Plan a **whole wave** of subscribers' re-registrations at the rotated tree. Uses the
347
- * {@link RejoinJitter.scheduleWave} hard-bound staggering so any `T_rejoin_jitter`-long window holds at
348
- * most the injected jitter's `capPromote` arrivals — the new tail never sees more than
349
- * `capPromote / T_rejoin_jitter` re-registrations per second.
350
- *
351
- * **Caller contract:** rotation is governed by the *fast-promote* bound, so the host MUST build this `jitter`
352
- * with `capPromote = cap_promote_fast` ({@link import("../cohort-topic/promotion.js").DEFAULT_CAP_PROMOTE_FAST}
353
- * = 32), **not** the default `createRejoinJitter()` cap (the cohort-failure `cap_promote = 64`). With the
354
- * default cap the wave bounds to 64/window and silently overruns the documented rotation burst ceiling
355
- * (`docs/reactivity.md` §Tail rotation rotation-cost). Returns one plan per subscriber, ascending by `fireAt`.
356
- */
357
- export function planReRegistrationWave(
358
- opts: ReRegistrationTarget & { subscribers: readonly { readonly lastRevision: number }[]; now: number; jitter: RejoinJitter },
359
- ): ReRegistrationPlan[] {
360
- const { newTailId, newTopicId } = resolveRotationTarget(opts);
361
- const fireAts = opts.jitter.scheduleWave(opts.subscribers.length, opts.now);
362
- return opts.subscribers.map((s, i) => ({ newTailId, newTopicId, lastRevision: s.lastRevision, fireAt: fireAts[i]! }));
363
- }
364
-
365
- // --- buffer-to-checkpoint handoff (the only state migrated across a rotation) -
366
-
367
- /** The state migrated from the outgoing tail to the new tail on rotation. */
368
- export interface RotationHandoff {
369
- /** The final checkpoint covering `[lastCheckpoint.toRevision + 1, rotationRevision]`. */
370
- readonly checkpoint: CheckpointSummary;
371
- /** The revision the rotation took effect at (the high edge the handoff checkpoint covers). */
372
- readonly rotationRevision: number;
373
- }
374
-
375
- /**
376
- * Fold the outgoing tail's replay buffer into the final {@link CheckpointSummary} handed to the new tail
377
- * (`docs/reactivity.md` §Tail rotation step 5 — the **only** state migration across a rotation). The
378
- * handoff covers `[lastCheckpoint.toRevision + 1, rotationRevision]`: the revisions still live in the
379
- * replay ring (above the rolling checkpoint's high edge) up to the rotation revision. Returns `undefined`
380
- * when the outgoing tail's ring is empty (nothing to migrate). Reuses the rolling checkpoint's own fold
381
- * options so the merged digest is byte-identical to a steady-state checkpoint.
382
- */
383
- export function buildRotationHandoffCheckpoint(state: PushState, opts: { rotationRevision?: number } = {}): RotationHandoff | undefined {
384
- const ringLow = state.replayBuffer.lowRevision;
385
- const ringHigh = state.replayBuffer.highRevision;
386
- if (ringLow === undefined || ringHigh === undefined) {
387
- return undefined; // nothing live in the ring to migrate
388
- }
389
- const rotationRevision = opts.rotationRevision ?? ringHigh;
390
- // The handoff continues immediately above the rolling checkpoint's high edge; when no checkpoint has
391
- // formed yet, that is the ring's low edge. The two windows stack, so `from` abuts `lastCheckpoint`.
392
- const priorCheckpointTo = state.checkpoint.toRevision;
393
- const from = priorCheckpointTo !== undefined ? priorCheckpointTo + 1 : ringLow;
394
- const entries = state.replayBuffer.range(from, rotationRevision);
395
- const checkpoint = buildCheckpointSummary(entries, state.checkpoint.foldOptions);
396
- if (checkpoint === undefined) {
397
- return undefined;
398
- }
399
- return { checkpoint, rotationRevision };
400
- }
401
-
402
- /**
403
- * Land a {@link RotationHandoff} at the new tail's {@link PushState}: record the inherited checkpoint so a
404
- * `ResumeV1` whose span crosses the rotation is recoverable from the new tail (the new tail "holds the old
405
- * checkpoint", `docs/reactivity.md` §Tail rotation step 5). The handoff is a one-time migration — it does
406
- * not feed the new tail's rolling checkpoint (that rolls from the new tree's own replay-ring eviction).
407
- */
408
- export function applyRotationHandoff(newTailState: PushState, handoff: RotationHandoff): void {
409
- newTailState.adoptRotationCheckpoint(handoff.checkpoint);
410
- }
411
-
412
- /** True iff `summary` covers `revision` (its inclusive `[fromRevision, toRevision]` span). */
413
- export function checkpointCovers(summary: CheckpointSummary, revision: number): boolean {
414
- return revision >= summary.fromRevision && revision <= summary.toRevision;
415
- }
1
+ /**
2
+ * Reactivity — tail rotation lifecycle (`docs/reactivity.md` §Rotating tail anchor, §Tail rotation).
3
+ *
4
+ * `topicId = H(tailId ‖ "reactivity")` is derived per emission ({@link reactivityTopicId}). When the tail
5
+ * block fills (`block_fill_size` transactions, default 64) a new tail block is born and `tailId` — hence
6
+ * `topicId` — changes; the cohort-topic layer treats the new `topicId` as an **entirely new topic** (fresh
7
+ * tree, new ring coord). Reactivity manages the subscriber/replay-state migration explicitly. This module
8
+ * owns that lifecycle:
9
+ *
10
+ * - **Pre-announce** — the block-filling commit's notification carries {@link RotationHintV1}; built here
11
+ * by {@link buildRotationHint} and detected subscriber-side by {@link detectRotation}.
12
+ * - **Block-fill tracking** — {@link BlockFillTracker} counts transactions in the current tail block and
13
+ * fires anticipatory **warm-up** at `block_fill_size − warm_threshold` and the **filling** signal at
14
+ * `block_fill_size` (the commit that carries the hint).
15
+ * - **Drain** — {@link TailDrainGate} keeps the outgoing tail serving renewals/replays for `T_drain` while
16
+ * bouncing *new* subscriptions with a `Promoted`-shaped {@link RotationRedirectV1} to the new tree.
17
+ * - **Jittered re-registration** — {@link planReRegistration} / {@link planReRegistrationWave} schedule a
18
+ * subscriber's move to the new `topicId` over `T_rejoin_jitter` (the cohort-topic {@link RejoinJitter}),
19
+ * carrying its existing `lastRevision` (revisions are continuous across rotations).
20
+ * - **Buffer-to-checkpoint handoff** — {@link buildRotationHandoffCheckpoint} folds the outgoing tail's
21
+ * replay buffer into a final {@link CheckpointSummary} covering `[lastCheckpoint.toRevision + 1,
22
+ * rotationRevision]`, the **only** state migrated across a rotation; {@link applyRotationHandoff} lands it
23
+ * at the new tail so a `ResumeV1` spanning the rotation is recoverable.
24
+ *
25
+ * Forwarder draining is *emergent*: forwarders under the old tail watch their direct-subscriber count drop
26
+ * as subscribers re-register elsewhere and demote naturally per the cohort-topic demotion protocol (no
27
+ * state migrates; the new tree rebuilds via re-registration). It needs no code here.
28
+ *
29
+ * Coordination with [reactivity-backfill-resume-checkpoints]: that ticket owns the `ResumeReplyV1.TailRotated`
30
+ * variant and the `latestKnownTailId`-staleness classification; this ticket produces the handoff checkpoint
31
+ * and the rotation *condition*. The new tail's {@link PushState.inheritedCheckpoint} (set by
32
+ * {@link applyRotationHandoff}) is the seam the resume classifier consults to answer a checkpoint-window
33
+ * resume whose span crosses the rotation: `classifyResume`/`serveResume` now read it (after the rolling
34
+ * `checkpoint` misses) and serve the inherited summary, so a cross-rotation resume no longer falls to
35
+ * `out_of_window` (`docs/reactivity.md` §Resume, §Tail rotation step 5). The drain-window redirect a new
36
+ * subscription receives, {@link RotationRedirectV1}, is serialized by {@link validateRotationRedirectV1} and
37
+ * rides the recover reply envelope as `kind: "rotated"` ({@link import("./recover.js").RecoverReplyV1}).
38
+ */
39
+
40
+ import { bytesToB64url, b64urlToBytes } from "../cohort-topic/wire/codec.js";
41
+ import { createRingHash } from "../cohort-topic/ring-hash.js";
42
+ import type { IRingHash } from "../cohort-topic/ports.js";
43
+ import type { RejoinJitter } from "../cohort-topic/antiflood/jitter.js";
44
+ import { BLOCK_FILL_SIZE_DEFAULT, T_DRAIN_MS, WARM_THRESHOLD_DEFAULT } from "./config.js";
45
+ import { buildCheckpointSummary, type CheckpointSummary } from "./checkpoint.js";
46
+ import { reactivityTopicId } from "./topic-anchor.js";
47
+ import type { PushState } from "./push-state.js";
48
+ import type { NotificationV1, RotationHintV1 } from "./wire.js";
49
+ import { asObject, b64urlField, failWire, reqIntInRange, reqString, requireV1 } from "./wire-validate.js";
50
+
51
+ // --- pre-announce + subscriber-side detection --------------------------------
52
+
53
+ /**
54
+ * Build the rotation pre-announce embedded in the block-filling commit's notification. The new tail block
55
+ * id is known at the filling commit; the rotation becomes effective at the next revision, so
56
+ * `effectiveAtRevision = fillingRevision + 1` (`docs/reactivity.md` §Tail rotation, worked scenario:
57
+ * revision 5400 carries `{ newTailId: T_6, effectiveAtRevision: 5401 }`).
58
+ */
59
+ export function buildRotationHint(newTailId: string, fillingRevision: number): RotationHintV1 {
60
+ return { newTailId, effectiveAtRevision: fillingRevision + 1 };
61
+ }
62
+
63
+ /** The subscriber-side rotation verdict for one delivered notification. */
64
+ export interface RotationDetection {
65
+ /** True iff the subscriber's tree has rotated (delivered tail differs) or a rotation is pre-announced. */
66
+ readonly rotated: boolean;
67
+ /** The tail id to re-register under, base64url — present iff `rotated`. */
68
+ readonly newTailId?: string;
69
+ /**
70
+ * True iff this is a **pre-announce**: the delivered `tailId` still matches `tailIdAtAttach`, but the
71
+ * notification's `rotationHint.newTailId` names a different successor. (False when the delivered tail
72
+ * already differs — the tree has *already* migrated.)
73
+ */
74
+ readonly preAnnounced: boolean;
75
+ }
76
+
77
+ /**
78
+ * Detect tail rotation for a subscriber attached at `tailIdAtAttach` (base64url) from a delivered
79
+ * notification. Rotation is signaled when the delivered `tailId` **or** the `rotationHint.newTailId`
80
+ * differs from `tailIdAtAttach` (`docs/reactivity.md` §Rotating tail anchor). An already-rotated delivery
81
+ * (different `tailId`) takes precedence over a pre-announce.
82
+ */
83
+ export function detectRotation(tailIdAtAttach: string, n: Pick<NotificationV1, "tailId" | "rotationHint">): RotationDetection {
84
+ if (n.tailId !== tailIdAtAttach) {
85
+ // The delivered notification rides the *new* tree already — a hard rotation, not a pre-announce.
86
+ return { rotated: true, newTailId: n.tailId, preAnnounced: false };
87
+ }
88
+ if (n.rotationHint !== undefined && n.rotationHint.newTailId !== tailIdAtAttach) {
89
+ return { rotated: true, newTailId: n.rotationHint.newTailId, preAnnounced: true };
90
+ }
91
+ return { rotated: false, preAnnounced: false };
92
+ }
93
+
94
+ // --- block-fill tracking (warm-up + filling signal) --------------------------
95
+
96
+ /** The fill signal for one committed transaction in the current tail block. */
97
+ export type BlockFillSignal =
98
+ /** Below the warm-up threshold — nothing to do. */
99
+ | { readonly kind: "none"; readonly count: number }
100
+ /**
101
+ * Anticipatory warm-up: the block has reached `block_fill_size − warm_threshold` transactions with
102
+ * `remaining` to go. The outgoing tail opportunistically biases FRET pre-dialing toward likely-successor
103
+ * coords (best-effort; the next `tailId` is not yet knowable). No state migrates.
104
+ */
105
+ | { readonly kind: "warmup"; readonly count: number; readonly remaining: number }
106
+ /**
107
+ * The **block-filling** transaction: this commit's notification carries the {@link RotationHintV1} and
108
+ * triggers the rotation. The tracker resets for the next block after emitting this.
109
+ */
110
+ | { readonly kind: "filling"; readonly count: number };
111
+
112
+ /** Construction inputs for a {@link BlockFillTracker}. */
113
+ export interface BlockFillTrackerInit {
114
+ /** Transactions per block before the tail rotates (default {@link BLOCK_FILL_SIZE_DEFAULT}). */
115
+ readonly blockFillSize?: number;
116
+ /** Transactions remaining in the tail when anticipatory warm-up fires (default {@link WARM_THRESHOLD_DEFAULT}). */
117
+ readonly warmThreshold?: number;
118
+ }
119
+
120
+ /**
121
+ * Counts transactions committed into the current tail block and emits the {@link BlockFillSignal} that
122
+ * drives anticipatory warm-up and the block-filling rotation trigger (`docs/reactivity.md` §Tail rotation,
123
+ * §Anticipatory warm-up). One tracker per collection on the tail-cohort primary.
124
+ */
125
+ export class BlockFillTracker {
126
+ readonly blockFillSize: number;
127
+ readonly warmThreshold: number;
128
+ private count = 0;
129
+
130
+ constructor(init: BlockFillTrackerInit = {}) {
131
+ const blockFillSize = init.blockFillSize ?? BLOCK_FILL_SIZE_DEFAULT;
132
+ const warmThreshold = init.warmThreshold ?? WARM_THRESHOLD_DEFAULT;
133
+ if (!Number.isInteger(blockFillSize) || blockFillSize < 1) {
134
+ throw new RangeError(`reactivity rotation: blockFillSize must be an integer >= 1, got ${blockFillSize}`);
135
+ }
136
+ if (!Number.isInteger(warmThreshold) || warmThreshold < 0 || warmThreshold >= blockFillSize) {
137
+ throw new RangeError(`reactivity rotation: warmThreshold must be an integer in 0..${blockFillSize - 1}, got ${warmThreshold}`);
138
+ }
139
+ this.blockFillSize = blockFillSize;
140
+ this.warmThreshold = warmThreshold;
141
+ }
142
+
143
+ /** Transactions committed into the current (not-yet-rotated) block. */
144
+ get transactionsInBlock(): number {
145
+ return this.count;
146
+ }
147
+
148
+ /** The fill count at which anticipatory warm-up fires (`block_fill_size − warm_threshold`). */
149
+ get warmAt(): number {
150
+ return this.blockFillSize - this.warmThreshold;
151
+ }
152
+
153
+ /**
154
+ * Record one committed transaction and return its fill signal. Emits `warmup` exactly when the block
155
+ * reaches `block_fill_size − warm_threshold`, and `filling` when it reaches `block_fill_size` — after
156
+ * which the counter resets for the next block.
157
+ */
158
+ onCommit(): BlockFillSignal {
159
+ this.count += 1;
160
+ if (this.count >= this.blockFillSize) {
161
+ const count = this.count;
162
+ this.count = 0; // a new tail block is born; start counting it
163
+ return { kind: "filling", count };
164
+ }
165
+ if (this.count === this.warmAt && this.warmThreshold > 0) {
166
+ return { kind: "warmup", count: this.count, remaining: this.blockFillSize - this.count };
167
+ }
168
+ return { kind: "none", count: this.count };
169
+ }
170
+
171
+ /** Reset the block counter (e.g. an externally-observed rotation the tracker did not drive). */
172
+ reset(): void {
173
+ this.count = 0;
174
+ }
175
+ }
176
+
177
+ // --- drain gate (old tail serves renewals/replays, bounces new subscriptions) -
178
+
179
+ /** The kind of request arriving at the outgoing tail during its drain window. */
180
+ export type DrainOp =
181
+ /** A *fresh* subscription (`lastKnownRev == 0` or a never-seen subscriber) — bounced to the new tree. */
182
+ | "new_subscribe"
183
+ /** A renewal of an existing registration — served through the drain. */
184
+ | "renew"
185
+ /** A replay/backfill/resume request — served through the drain. */
186
+ | "replay";
187
+
188
+ /**
189
+ * A `Promoted`-shaped redirect to the rotated tree (`docs/reactivity.md` §Tail rotation step 2). Unlike the
190
+ * cohort-topic tier-based `Promoted`, this redirects to an entirely **new topic** — the new tail's tree at
191
+ * `coord_0(_, newTopicId)` — so it carries the successor's `newTailId` + derived `newTopicId`.
192
+ */
193
+ export interface RotationRedirectV1 {
194
+ readonly v: 1;
195
+ readonly result: "rotated";
196
+ /** New tail block id the topic anchor rotated to, base64url. */
197
+ readonly newTailId: string;
198
+ /** `H(newTailId ‖ "reactivity")` — the new tree's topic id, base64url (the redirect target). */
199
+ readonly newTopicId: string;
200
+ /** Revision at which the rotation took effect. */
201
+ readonly effectiveAtRevision: number;
202
+ }
203
+
204
+ /**
205
+ * Narrow an already-parsed value to a {@link RotationRedirectV1}, throwing {@link CohortWireError} on any
206
+ * defect. The redirect rides the recover reply envelope as `kind: "rotated"`
207
+ * ({@link import("./recover.js").RecoverReplyV1}); it carries no signature — like the other recover reply
208
+ * variants it is an unauthenticated routing hint (the subscriber re-walks and verifies the new tree on
209
+ * arrival), so its trust comes from the new tree, not the redirect.
210
+ */
211
+ export function validateRotationRedirectV1(value: unknown): RotationRedirectV1 {
212
+ const what = "RotationRedirectV1";
213
+ const obj = asObject(value, what);
214
+ requireV1(obj, what);
215
+ if (obj["result"] !== "rotated") {
216
+ failWire(`${what}: field "result" must be "rotated"`);
217
+ }
218
+ return {
219
+ v: 1,
220
+ result: "rotated",
221
+ newTailId: b64urlField(reqString(obj, "newTailId", what), "newTailId", what),
222
+ newTopicId: b64urlField(reqString(obj, "newTopicId", what), "newTopicId", what),
223
+ effectiveAtRevision: reqIntInRange(obj, "effectiveAtRevision", what, 0),
224
+ };
225
+ }
226
+
227
+ /** The drain gate's decision for one inbound request. */
228
+ export type DrainDecision =
229
+ /** Serve normally (renewals / replays during the drain window). */
230
+ | { readonly kind: "serve" }
231
+ /** Bounce to the new tree (a new subscription during the drain window). */
232
+ | { readonly kind: "redirect"; readonly redirect: RotationRedirectV1 }
233
+ /** The drain window has elapsed — the outgoing tail holds nothing; the caller re-registers from `d_max`. */
234
+ | { readonly kind: "drained" };
235
+
236
+ /** Construction inputs for a {@link TailDrainGate}. */
237
+ export interface TailDrainGateInit {
238
+ /** Unix ms the rotation took effect (drain starts here). */
239
+ readonly rotatedAt: number;
240
+ /** New tail block id the topic rotated to, base64url. */
241
+ readonly newTailId: string;
242
+ /** Revision at which the rotation took effect. */
243
+ readonly effectiveAtRevision: number;
244
+ /** Drain duration `T_drain` (ms, default {@link T_DRAIN_MS}). */
245
+ readonly tDrainMs?: number;
246
+ /** Ring hash for the `newTopicId` derivation (must match cohort-topic routing). Default db-core SHA-256. */
247
+ readonly hash?: IRingHash;
248
+ }
249
+
250
+ /**
251
+ * The outgoing tail's drain state machine. For `T_drain` after a rotation it accepts **renewals** and
252
+ * serves **replays**, but bounces **new subscriptions** with a {@link RotationRedirectV1} to the new tree;
253
+ * after `T_drain` it reports `drained` for everything (the subscriber re-registers from `d_max`).
254
+ */
255
+ export class TailDrainGate {
256
+ readonly rotatedAt: number;
257
+ readonly tDrainMs: number;
258
+ private readonly redirect: RotationRedirectV1;
259
+
260
+ constructor(init: TailDrainGateInit) {
261
+ const tDrainMs = init.tDrainMs ?? T_DRAIN_MS;
262
+ if (!(tDrainMs > 0)) {
263
+ throw new RangeError(`reactivity rotation: tDrainMs must be > 0, got ${tDrainMs}`);
264
+ }
265
+ this.rotatedAt = init.rotatedAt;
266
+ this.tDrainMs = tDrainMs;
267
+ const hash = init.hash ?? createRingHash();
268
+ const newTopicId = bytesToB64url(reactivityTopicId(b64urlToBytes(init.newTailId), hash));
269
+ this.redirect = {
270
+ v: 1,
271
+ result: "rotated",
272
+ newTailId: init.newTailId,
273
+ newTopicId,
274
+ effectiveAtRevision: init.effectiveAtRevision,
275
+ };
276
+ }
277
+
278
+ /** Unix ms the drain window closes. */
279
+ get drainEndsAt(): number {
280
+ return this.rotatedAt + this.tDrainMs;
281
+ }
282
+
283
+ /** True while the outgoing tail is still draining (within `T_drain` of the rotation). */
284
+ isDraining(now: number): boolean {
285
+ return now < this.drainEndsAt;
286
+ }
287
+
288
+ /** The redirect this gate hands a new subscription (exposed for the redirect transport / tests). */
289
+ get rotationRedirect(): RotationRedirectV1 {
290
+ return this.redirect;
291
+ }
292
+
293
+ /**
294
+ * Classify an inbound request. Within the drain window: `new_subscribe` → redirect, `renew`/`replay` →
295
+ * serve. After the window: `drained` (the old tail has released its forwarder state).
296
+ */
297
+ classify(op: DrainOp, now: number): DrainDecision {
298
+ if (!this.isDraining(now)) {
299
+ return { kind: "drained" };
300
+ }
301
+ return op === "new_subscribe" ? { kind: "redirect", redirect: this.redirect } : { kind: "serve" };
302
+ }
303
+ }
304
+
305
+ // --- jittered re-registration (subscriber moves to the new tree) -------------
306
+
307
+ /** A subscriber's planned re-registration at the rotated tree. */
308
+ export interface ReRegistrationPlan {
309
+ /** New tail block id (raw bytes) to attach under. */
310
+ readonly newTailId: Uint8Array;
311
+ /** `H(newTailId ‖ "reactivity")` (raw bytes) — the new tree's topic id to register at. */
312
+ readonly newTopicId: Uint8Array;
313
+ /** The subscriber's existing `lastRevision`, carried so revisions stay continuous across the rotation. */
314
+ readonly lastRevision: number;
315
+ /** Unix ms at which to fire the re-registration (jittered over `T_rejoin_jitter`). */
316
+ readonly fireAt: number;
317
+ }
318
+
319
+ /** Inputs shared by the single and wave re-registration planners. */
320
+ interface ReRegistrationTarget {
321
+ /** The rotation hint (or any object carrying the successor `newTailId`), base64url. */
322
+ readonly hint: Pick<RotationHintV1, "newTailId">;
323
+ /** Ring hash for the `newTopicId` derivation. Default db-core SHA-256. */
324
+ readonly hash?: IRingHash;
325
+ }
326
+
327
+ /** Resolve `(newTailId, newTopicId)` raw bytes for a rotation target. */
328
+ function resolveRotationTarget(target: ReRegistrationTarget): { newTailId: Uint8Array; newTopicId: Uint8Array } {
329
+ const newTailId = b64urlToBytes(target.hint.newTailId);
330
+ const newTopicId = reactivityTopicId(newTailId, target.hash ?? createRingHash());
331
+ return { newTailId, newTopicId };
332
+ }
333
+
334
+ /**
335
+ * Plan **one** subscriber's re-registration at the rotated tree: derive the new `topicId` from the hint's
336
+ * `newTailId`, carry the subscriber's `lastRevision`, and draw a jittered `fireAt` over `T_rejoin_jitter`
337
+ * via the cohort-topic {@link RejoinJitter} (`scheduleRejoin` — a uniform offset, decorrelating this
338
+ * subscriber's re-join from its peers).
339
+ */
340
+ export function planReRegistration(opts: ReRegistrationTarget & { lastRevision: number; now: number; jitter: RejoinJitter }): ReRegistrationPlan {
341
+ const { newTailId, newTopicId } = resolveRotationTarget(opts);
342
+ return { newTailId, newTopicId, lastRevision: opts.lastRevision, fireAt: opts.jitter.scheduleRejoin(opts.now) };
343
+ }
344
+
345
+ /**
346
+ * Plan a **whole wave** of subscribers' re-registrations at the rotated tree. Uses the
347
+ * {@link RejoinJitter.scheduleWave} hard-bound staggering so any `T_rejoin_jitter`-long window holds at
348
+ * most the injected jitter's `capPromote` arrivals — the new tail never sees more than
349
+ * `capPromote / T_rejoin_jitter` re-registrations per second.
350
+ *
351
+ * **Caller contract:** rotation is governed by the *fast-promote* bound, so the host MUST build this `jitter`
352
+ * with `capPromote = cap_promote_fast` ({@link import("../cohort-topic/promotion.js").DEFAULT_CAP_PROMOTE_FAST}
353
+ * = 32), **not** the default `createRejoinJitter()` cap (the cohort-failure `cap_promote = 64`). With the
354
+ * default cap the wave bounds to 64/window and silently overruns the documented rotation burst ceiling
355
+ * (`docs/reactivity.md` §Tail rotation rotation-cost). Returns one plan per subscriber, ascending by `fireAt`.
356
+ */
357
+ export function planReRegistrationWave(
358
+ opts: ReRegistrationTarget & { subscribers: readonly { readonly lastRevision: number }[]; now: number; jitter: RejoinJitter },
359
+ ): ReRegistrationPlan[] {
360
+ const { newTailId, newTopicId } = resolveRotationTarget(opts);
361
+ const fireAts = opts.jitter.scheduleWave(opts.subscribers.length, opts.now);
362
+ return opts.subscribers.map((s, i) => ({ newTailId, newTopicId, lastRevision: s.lastRevision, fireAt: fireAts[i]! }));
363
+ }
364
+
365
+ // --- buffer-to-checkpoint handoff (the only state migrated across a rotation) -
366
+
367
+ /** The state migrated from the outgoing tail to the new tail on rotation. */
368
+ export interface RotationHandoff {
369
+ /** The final checkpoint covering `[lastCheckpoint.toRevision + 1, rotationRevision]`. */
370
+ readonly checkpoint: CheckpointSummary;
371
+ /** The revision the rotation took effect at (the high edge the handoff checkpoint covers). */
372
+ readonly rotationRevision: number;
373
+ }
374
+
375
+ /**
376
+ * Fold the outgoing tail's replay buffer into the final {@link CheckpointSummary} handed to the new tail
377
+ * (`docs/reactivity.md` §Tail rotation step 5 — the **only** state migration across a rotation). The
378
+ * handoff covers `[lastCheckpoint.toRevision + 1, rotationRevision]`: the revisions still live in the
379
+ * replay ring (above the rolling checkpoint's high edge) up to the rotation revision. Returns `undefined`
380
+ * when the outgoing tail's ring is empty (nothing to migrate). Reuses the rolling checkpoint's own fold
381
+ * options so the merged digest is byte-identical to a steady-state checkpoint.
382
+ */
383
+ export function buildRotationHandoffCheckpoint(state: PushState, opts: { rotationRevision?: number } = {}): RotationHandoff | undefined {
384
+ const ringLow = state.replayBuffer.lowRevision;
385
+ const ringHigh = state.replayBuffer.highRevision;
386
+ if (ringLow === undefined || ringHigh === undefined) {
387
+ return undefined; // nothing live in the ring to migrate
388
+ }
389
+ const rotationRevision = opts.rotationRevision ?? ringHigh;
390
+ // The handoff continues immediately above the rolling checkpoint's high edge; when no checkpoint has
391
+ // formed yet, that is the ring's low edge. The two windows stack, so `from` abuts `lastCheckpoint`.
392
+ const priorCheckpointTo = state.checkpoint.toRevision;
393
+ const from = priorCheckpointTo !== undefined ? priorCheckpointTo + 1 : ringLow;
394
+ const entries = state.replayBuffer.range(from, rotationRevision);
395
+ const checkpoint = buildCheckpointSummary(entries, state.checkpoint.foldOptions);
396
+ if (checkpoint === undefined) {
397
+ return undefined;
398
+ }
399
+ return { checkpoint, rotationRevision };
400
+ }
401
+
402
+ /**
403
+ * Land a {@link RotationHandoff} at the new tail's {@link PushState}: record the inherited checkpoint so a
404
+ * `ResumeV1` whose span crosses the rotation is recoverable from the new tail (the new tail "holds the old
405
+ * checkpoint", `docs/reactivity.md` §Tail rotation step 5). The handoff is a one-time migration — it does
406
+ * not feed the new tail's rolling checkpoint (that rolls from the new tree's own replay-ring eviction).
407
+ */
408
+ export function applyRotationHandoff(newTailState: PushState, handoff: RotationHandoff): void {
409
+ newTailState.adoptRotationCheckpoint(handoff.checkpoint);
410
+ }
411
+
412
+ /** True iff `summary` covers `revision` (its inclusive `[fromRevision, toRevision]` span). */
413
+ export function checkpointCovers(summary: CheckpointSummary, revision: number): boolean {
414
+ return revision >= summary.fromRevision && revision <= summary.toRevision;
415
+ }