@optimystic/db-core 0.22.0 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +336 -336
  2. package/dist/src/cluster/structs.d.ts +39 -1
  3. package/dist/src/cluster/structs.d.ts.map +1 -1
  4. package/dist/src/cluster/structs.js +24 -0
  5. package/dist/src/cluster/structs.js.map +1 -1
  6. package/dist/src/collection/collection.d.ts +17 -0
  7. package/dist/src/collection/collection.d.ts.map +1 -1
  8. package/dist/src/collection/collection.js +24 -2
  9. package/dist/src/collection/collection.js.map +1 -1
  10. package/dist/src/collections/tree/tree.d.ts +5 -0
  11. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  12. package/dist/src/collections/tree/tree.js +7 -0
  13. package/dist/src/collections/tree/tree.js.map +1 -1
  14. package/dist/src/network/i-peer-network.d.ts +16 -0
  15. package/dist/src/network/i-peer-network.d.ts.map +1 -1
  16. package/dist/src/network/struct.d.ts +39 -2
  17. package/dist/src/network/struct.d.ts.map +1 -1
  18. package/dist/src/network/struct.js +18 -0
  19. package/dist/src/network/struct.js.map +1 -1
  20. package/dist/src/testing/test-transactor.d.ts +95 -8
  21. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  22. package/dist/src/testing/test-transactor.js +121 -8
  23. package/dist/src/testing/test-transactor.js.map +1 -1
  24. package/dist/src/transaction/transaction.d.ts +1 -1
  25. package/dist/src/transaction/transaction.js +1 -1
  26. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  27. package/dist/src/transactor/network-transactor.js +48 -13
  28. package/dist/src/transactor/network-transactor.js.map +1 -1
  29. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  30. package/dist/src/transactor/transactor-source.js +25 -2
  31. package/dist/src/transactor/transactor-source.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/cluster/membership.ts +85 -85
  34. package/src/cluster/structs.ts +43 -4
  35. package/src/cohort-topic/addressing.ts +120 -120
  36. package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +253 -253
  37. package/src/cohort-topic/antidos/bootstrap-evidence.ts +106 -106
  38. package/src/cohort-topic/antidos/index.ts +5 -5
  39. package/src/cohort-topic/antidos/rate-limiter.ts +210 -210
  40. package/src/cohort-topic/antidos/replay-guard.ts +146 -146
  41. package/src/cohort-topic/antidos/topic-budget.ts +160 -160
  42. package/src/cohort-topic/antiflood/index.ts +2 -2
  43. package/src/cohort-topic/antiflood/invariants.ts +108 -108
  44. package/src/cohort-topic/antiflood/jitter.ts +117 -117
  45. package/src/cohort-topic/coldstart.ts +237 -237
  46. package/src/cohort-topic/dmax.ts +88 -88
  47. package/src/cohort-topic/gossip/bus.ts +254 -254
  48. package/src/cohort-topic/gossip/index.ts +3 -3
  49. package/src/cohort-topic/gossip/records.ts +45 -45
  50. package/src/cohort-topic/gossip/view.ts +91 -91
  51. package/src/cohort-topic/index.ts +20 -20
  52. package/src/cohort-topic/load/barometer.ts +134 -134
  53. package/src/cohort-topic/load/index.ts +1 -1
  54. package/src/cohort-topic/member-engine.ts +430 -430
  55. package/src/cohort-topic/membership/index.ts +3 -3
  56. package/src/cohort-topic/membership/publisher.ts +163 -163
  57. package/src/cohort-topic/membership/source.ts +41 -41
  58. package/src/cohort-topic/membership/verifier.ts +461 -461
  59. package/src/cohort-topic/ports.ts +157 -157
  60. package/src/cohort-topic/promotion.ts +405 -405
  61. package/src/cohort-topic/registration/bytes.ts +37 -37
  62. package/src/cohort-topic/registration/handoff.ts +154 -154
  63. package/src/cohort-topic/registration/index.ts +6 -6
  64. package/src/cohort-topic/registration/renewal.ts +495 -495
  65. package/src/cohort-topic/registration/sharding.ts +61 -61
  66. package/src/cohort-topic/registration/store.ts +81 -81
  67. package/src/cohort-topic/registration/types.ts +91 -91
  68. package/src/cohort-topic/ring-hash.ts +50 -50
  69. package/src/cohort-topic/service.ts +416 -416
  70. package/src/cohort-topic/sig/index.ts +2 -2
  71. package/src/cohort-topic/sig/payloads.ts +59 -59
  72. package/src/cohort-topic/sig/threshold.ts +64 -64
  73. package/src/cohort-topic/tiers.ts +74 -74
  74. package/src/cohort-topic/traffic.ts +233 -233
  75. package/src/cohort-topic/walk.ts +326 -326
  76. package/src/cohort-topic/willingness.ts +237 -237
  77. package/src/cohort-topic/wire/codec.ts +216 -216
  78. package/src/cohort-topic/wire/index.ts +18 -18
  79. package/src/cohort-topic/wire/payloads.ts +126 -126
  80. package/src/cohort-topic/wire/primitives.ts +188 -188
  81. package/src/cohort-topic/wire/types.ts +475 -475
  82. package/src/cohort-topic/wire/validate.ts +512 -512
  83. package/src/collection/collection-type-registry.ts +37 -37
  84. package/src/collection/collection.ts +25 -2
  85. package/src/collections/diary/diary.ts +68 -68
  86. package/src/collections/tree/readme.md +4 -0
  87. package/src/collections/tree/tree.ts +320 -312
  88. package/src/matchmaking/capability-filter.ts +45 -45
  89. package/src/matchmaking/config.ts +98 -98
  90. package/src/matchmaking/index.ts +21 -21
  91. package/src/matchmaking/multi-cohort-seeker.ts +234 -234
  92. package/src/matchmaking/provider.ts +123 -123
  93. package/src/matchmaking/query-eval.ts +105 -105
  94. package/src/matchmaking/seeker-walk.ts +127 -127
  95. package/src/matchmaking/seeker.ts +86 -86
  96. package/src/matchmaking/topic-anchor.ts +90 -90
  97. package/src/matchmaking/voting-quorum.ts +394 -394
  98. package/src/matchmaking/wire.ts +603 -603
  99. package/src/network/i-peer-network.ts +17 -0
  100. package/src/network/stale-failure.ts +43 -43
  101. package/src/network/struct.ts +41 -2
  102. package/src/network/types.ts +37 -37
  103. package/src/reactivity/backfill.ts +220 -220
  104. package/src/reactivity/backpressure.ts +191 -191
  105. package/src/reactivity/checkpoint.ts +308 -308
  106. package/src/reactivity/config.ts +172 -172
  107. package/src/reactivity/dedupe.ts +132 -132
  108. package/src/reactivity/forwarder.ts +87 -87
  109. package/src/reactivity/index.ts +34 -34
  110. package/src/reactivity/notification.ts +123 -123
  111. package/src/reactivity/policy.ts +79 -79
  112. package/src/reactivity/push-state.ts +310 -310
  113. package/src/reactivity/recover.ts +153 -153
  114. package/src/reactivity/replay-buffer.ts +141 -141
  115. package/src/reactivity/resume.ts +549 -549
  116. package/src/reactivity/rotation.ts +415 -415
  117. package/src/reactivity/subscriber.ts +132 -132
  118. package/src/reactivity/subscription.ts +66 -66
  119. package/src/reactivity/topic-anchor.ts +71 -71
  120. package/src/reactivity/verify.ts +73 -73
  121. package/src/reactivity/wire-validate.ts +13 -13
  122. package/src/reactivity/wire.ts +224 -224
  123. package/src/testing/async-wait.ts +65 -65
  124. package/src/testing/index.ts +2 -2
  125. package/src/testing/test-transactor.ts +638 -502
  126. package/src/transaction/errors.ts +91 -91
  127. package/src/transaction/operations-hash.ts +196 -196
  128. package/src/transaction/read-dependency-collector.ts +78 -78
  129. package/src/transaction/transaction.ts +1 -1
  130. package/src/transactor/change-notifier.ts +80 -80
  131. package/src/transactor/index.ts +5 -5
  132. package/src/transactor/network-transactor.ts +49 -14
  133. package/src/transactor/transactor-source.ts +25 -2
  134. package/src/transform/atomic-proxy.ts +92 -92
  135. package/src/transform/helpers.ts +159 -159
  136. package/src/utility/backoff.ts +95 -95
  137. package/src/utility/batch-coordinator.ts +191 -191
  138. package/dist/src/transaction/context.d.ts +0 -60
  139. package/dist/src/transaction/context.d.ts.map +0 -1
  140. package/dist/src/transaction/context.js +0 -91
  141. package/dist/src/transaction/context.js.map +0 -1
@@ -1,153 +1,153 @@
1
- /**
2
- * Reactivity — recover RPC envelope (`docs/reactivity.md` §Backfill RPC, §Resume, §Wire formats).
3
- *
4
- * The db-p2p recover transport runs **one** request-reply protocol
5
- * (`/optimystic/reactivity/1.0.0/recover`) carrying either a backfill or a resume exchange. Rather than
6
- * disambiguate two payload shapes structurally off the wire, this module owns an explicit discriminated
7
- * wrapper so the `kind` discriminant is authoritative: a `kind: "backfill"` frame MUST carry a `backfill`
8
- * body and no `resume` body (and vice-versa). db-core owns every reactivity wire codec, so the envelope
9
- * lives here next to the {@link import("./backfill.js").BackfillV1} / {@link import("./resume.js").ResumeV1}
10
- * payloads it wraps and reuses their per-message validators for the inner branches.
11
- *
12
- * **Reply-only `"rotated"` kind.** A subscriber only ever *asks* for a backfill or a resume, so
13
- * {@link RecoverKind} (the request discriminant) is the narrower `"backfill" | "resume"`. A *reply*,
14
- * however, may carry a third kind — `"rotated"`, the drain-window {@link RotationRedirectV1} a serving
15
- * cohort hands a request that arrived at an outgoing tail after it rotated (`docs/reactivity.md` §Tail
16
- * rotation). The recover reply is the only reactivity surface a subscriber reaches a serving cohort on (a
17
- * fresh subscribe rides generic cohort-topic `service.register`, whose walk only understands tier-`Promoted`,
18
- * never a topic redirect), so the redirect rides the recover reply rather than registration.
19
- *
20
- * **Forward-compat (fail-closed).** A decoder that predates the `"rotated"` kind rejects it outright — its
21
- * `kind` narrower only admits `backfill`/`resume` — so an older peer cannot misread a redirect; it simply
22
- * treats the reply as malformed (no reply / decode failure) and the subscriber falls back to a chain read,
23
- * which is always safe. A redirect is therefore an *optional optimization* a newer serving cohort offers,
24
- * never a correctness dependency.
25
- *
26
- * Wire conventions match the rest of reactivity: JSON, byte fields base64url, `v: 1`, per-message
27
- * structural validation on decode (a `kind`/branch mismatch is `failWire`).
28
- */
29
-
30
- import {
31
- decodeCohortMessage,
32
- encodeCohortMessage,
33
- DEFAULT_MAX_MESSAGE_BYTES,
34
- } from "../cohort-topic/wire/codec.js";
35
- import { asObject, failWire, requireV1 } from "./wire-validate.js";
36
- import { validateBackfillV1, validateBackfillReplyV1, type BackfillV1, type BackfillReplyV1 } from "./backfill.js";
37
- import { validateResumeV1, validateResumeReplyV1, type ResumeV1, type ResumeReplyV1 } from "./resume.js";
38
- import { validateRotationRedirectV1, type RotationRedirectV1 } from "./rotation.js";
39
-
40
- /** The recover RPC **request** discriminant: which of the two recovery exchanges a subscriber asks for. */
41
- export type RecoverKind = "backfill" | "resume";
42
-
43
- /**
44
- * The recover RPC **reply** discriminant. A superset of {@link RecoverKind}: a reply may additionally be a
45
- * `"rotated"` {@link RotationRedirectV1} (a subscriber never *asks* for a redirect, so the request kind
46
- * stays narrower).
47
- */
48
- export type RecoverReplyKind = RecoverKind | "rotated";
49
-
50
- /** A subscriber's recover request — exactly one of {@link backfill} / {@link resume}, per {@link kind}. */
51
- export interface RecoverRequestV1 {
52
- v: 1;
53
- kind: RecoverKind;
54
- /** Present iff `kind === "backfill"`. */
55
- backfill?: BackfillV1;
56
- /** Present iff `kind === "resume"`. */
57
- resume?: ResumeV1;
58
- }
59
-
60
- /**
61
- * The cohort's recover reply — exactly one of {@link backfillReply} / {@link resumeReply} / {@link rotated},
62
- * per {@link kind}.
63
- */
64
- export interface RecoverReplyV1 {
65
- v: 1;
66
- kind: RecoverReplyKind;
67
- /** Present iff `kind === "backfill"`. */
68
- backfillReply?: BackfillReplyV1;
69
- /** Present iff `kind === "resume"`. */
70
- resumeReply?: ResumeReplyV1;
71
- /** Present iff `kind === "rotated"` — the drain-window redirect to the rotated tree. */
72
- rotated?: RotationRedirectV1;
73
- }
74
-
75
- /** Narrow `obj.kind` to a {@link RecoverKind} (request side), throwing on any other value. */
76
- function reqKind(obj: Record<string, unknown>, what: string): RecoverKind {
77
- const kind = obj["kind"];
78
- if (kind !== "backfill" && kind !== "resume") {
79
- failWire(`${what}: field "kind" must be "backfill" or "resume"`);
80
- }
81
- return kind;
82
- }
83
-
84
- /** Narrow `obj.kind` to a {@link RecoverReplyKind} (reply side — also admits `"rotated"`). */
85
- function reqReplyKind(obj: Record<string, unknown>, what: string): RecoverReplyKind {
86
- const kind = obj["kind"];
87
- if (kind !== "backfill" && kind !== "resume" && kind !== "rotated") {
88
- failWire(`${what}: field "kind" must be "backfill", "resume", or "rotated"`);
89
- }
90
- return kind;
91
- }
92
-
93
- /** Reject a frame carrying `branch`, the body that does not belong to the declared `kind`. */
94
- function rejectStrayBranch(obj: Record<string, unknown>, branch: string, what: string): void {
95
- if (obj[branch] !== undefined) {
96
- failWire(`${what}: a "${branch}" body is present but does not match the declared kind`);
97
- }
98
- }
99
-
100
- /** Narrow an already-parsed value to {@link RecoverRequestV1}, throwing on any defect or kind/branch mismatch. */
101
- export function validateRecoverRequestV1(value: unknown): RecoverRequestV1 {
102
- const what = "RecoverRequestV1";
103
- const obj = asObject(value, what);
104
- requireV1(obj, what);
105
- const kind = reqKind(obj, what);
106
- if (kind === "backfill") {
107
- rejectStrayBranch(obj, "resume", what);
108
- return { v: 1, kind, backfill: validateBackfillV1(obj["backfill"]) };
109
- }
110
- rejectStrayBranch(obj, "backfill", what);
111
- return { v: 1, kind, resume: validateResumeV1(obj["resume"]) };
112
- }
113
-
114
- /** Narrow an already-parsed value to {@link RecoverReplyV1}, throwing on any defect or kind/branch mismatch. */
115
- export function validateRecoverReplyV1(value: unknown): RecoverReplyV1 {
116
- const what = "RecoverReplyV1";
117
- const obj = asObject(value, what);
118
- requireV1(obj, what);
119
- const kind = reqReplyKind(obj, what);
120
- if (kind === "backfill") {
121
- rejectStrayBranch(obj, "resumeReply", what);
122
- rejectStrayBranch(obj, "rotated", what);
123
- return { v: 1, kind, backfillReply: validateBackfillReplyV1(obj["backfillReply"]) };
124
- }
125
- if (kind === "resume") {
126
- rejectStrayBranch(obj, "backfillReply", what);
127
- rejectStrayBranch(obj, "rotated", what);
128
- return { v: 1, kind, resumeReply: validateResumeReplyV1(obj["resumeReply"]) };
129
- }
130
- rejectStrayBranch(obj, "backfillReply", what);
131
- rejectStrayBranch(obj, "resumeReply", what);
132
- return { v: 1, kind, rotated: validateRotationRedirectV1(obj["rotated"]) };
133
- }
134
-
135
- /** Encode a {@link RecoverRequestV1} as a length-prefixed UTF-8 JSON frame. */
136
- export function encodeRecoverRequestV1(msg: RecoverRequestV1, maxMessageBytes: number = DEFAULT_MAX_MESSAGE_BYTES): Uint8Array {
137
- return encodeCohortMessage(validateRecoverRequestV1(msg), maxMessageBytes);
138
- }
139
-
140
- /** Decode a length-prefixed {@link RecoverRequestV1} frame. */
141
- export function decodeRecoverRequestV1(bytes: Uint8Array, maxMessageBytes?: number): RecoverRequestV1 {
142
- return validateRecoverRequestV1(decodeCohortMessage(bytes, maxMessageBytes));
143
- }
144
-
145
- /** Encode a {@link RecoverReplyV1} as a length-prefixed UTF-8 JSON frame. */
146
- export function encodeRecoverReplyV1(msg: RecoverReplyV1, maxMessageBytes: number = DEFAULT_MAX_MESSAGE_BYTES): Uint8Array {
147
- return encodeCohortMessage(validateRecoverReplyV1(msg), maxMessageBytes);
148
- }
149
-
150
- /** Decode a length-prefixed {@link RecoverReplyV1} frame. */
151
- export function decodeRecoverReplyV1(bytes: Uint8Array, maxMessageBytes?: number): RecoverReplyV1 {
152
- return validateRecoverReplyV1(decodeCohortMessage(bytes, maxMessageBytes));
153
- }
1
+ /**
2
+ * Reactivity — recover RPC envelope (`docs/reactivity.md` §Backfill RPC, §Resume, §Wire formats).
3
+ *
4
+ * The db-p2p recover transport runs **one** request-reply protocol
5
+ * (`/optimystic/reactivity/1.0.0/recover`) carrying either a backfill or a resume exchange. Rather than
6
+ * disambiguate two payload shapes structurally off the wire, this module owns an explicit discriminated
7
+ * wrapper so the `kind` discriminant is authoritative: a `kind: "backfill"` frame MUST carry a `backfill`
8
+ * body and no `resume` body (and vice-versa). db-core owns every reactivity wire codec, so the envelope
9
+ * lives here next to the {@link import("./backfill.js").BackfillV1} / {@link import("./resume.js").ResumeV1}
10
+ * payloads it wraps and reuses their per-message validators for the inner branches.
11
+ *
12
+ * **Reply-only `"rotated"` kind.** A subscriber only ever *asks* for a backfill or a resume, so
13
+ * {@link RecoverKind} (the request discriminant) is the narrower `"backfill" | "resume"`. A *reply*,
14
+ * however, may carry a third kind — `"rotated"`, the drain-window {@link RotationRedirectV1} a serving
15
+ * cohort hands a request that arrived at an outgoing tail after it rotated (`docs/reactivity.md` §Tail
16
+ * rotation). The recover reply is the only reactivity surface a subscriber reaches a serving cohort on (a
17
+ * fresh subscribe rides generic cohort-topic `service.register`, whose walk only understands tier-`Promoted`,
18
+ * never a topic redirect), so the redirect rides the recover reply rather than registration.
19
+ *
20
+ * **Forward-compat (fail-closed).** A decoder that predates the `"rotated"` kind rejects it outright — its
21
+ * `kind` narrower only admits `backfill`/`resume` — so an older peer cannot misread a redirect; it simply
22
+ * treats the reply as malformed (no reply / decode failure) and the subscriber falls back to a chain read,
23
+ * which is always safe. A redirect is therefore an *optional optimization* a newer serving cohort offers,
24
+ * never a correctness dependency.
25
+ *
26
+ * Wire conventions match the rest of reactivity: JSON, byte fields base64url, `v: 1`, per-message
27
+ * structural validation on decode (a `kind`/branch mismatch is `failWire`).
28
+ */
29
+
30
+ import {
31
+ decodeCohortMessage,
32
+ encodeCohortMessage,
33
+ DEFAULT_MAX_MESSAGE_BYTES,
34
+ } from "../cohort-topic/wire/codec.js";
35
+ import { asObject, failWire, requireV1 } from "./wire-validate.js";
36
+ import { validateBackfillV1, validateBackfillReplyV1, type BackfillV1, type BackfillReplyV1 } from "./backfill.js";
37
+ import { validateResumeV1, validateResumeReplyV1, type ResumeV1, type ResumeReplyV1 } from "./resume.js";
38
+ import { validateRotationRedirectV1, type RotationRedirectV1 } from "./rotation.js";
39
+
40
+ /** The recover RPC **request** discriminant: which of the two recovery exchanges a subscriber asks for. */
41
+ export type RecoverKind = "backfill" | "resume";
42
+
43
+ /**
44
+ * The recover RPC **reply** discriminant. A superset of {@link RecoverKind}: a reply may additionally be a
45
+ * `"rotated"` {@link RotationRedirectV1} (a subscriber never *asks* for a redirect, so the request kind
46
+ * stays narrower).
47
+ */
48
+ export type RecoverReplyKind = RecoverKind | "rotated";
49
+
50
+ /** A subscriber's recover request — exactly one of {@link backfill} / {@link resume}, per {@link kind}. */
51
+ export interface RecoverRequestV1 {
52
+ v: 1;
53
+ kind: RecoverKind;
54
+ /** Present iff `kind === "backfill"`. */
55
+ backfill?: BackfillV1;
56
+ /** Present iff `kind === "resume"`. */
57
+ resume?: ResumeV1;
58
+ }
59
+
60
+ /**
61
+ * The cohort's recover reply — exactly one of {@link backfillReply} / {@link resumeReply} / {@link rotated},
62
+ * per {@link kind}.
63
+ */
64
+ export interface RecoverReplyV1 {
65
+ v: 1;
66
+ kind: RecoverReplyKind;
67
+ /** Present iff `kind === "backfill"`. */
68
+ backfillReply?: BackfillReplyV1;
69
+ /** Present iff `kind === "resume"`. */
70
+ resumeReply?: ResumeReplyV1;
71
+ /** Present iff `kind === "rotated"` — the drain-window redirect to the rotated tree. */
72
+ rotated?: RotationRedirectV1;
73
+ }
74
+
75
+ /** Narrow `obj.kind` to a {@link RecoverKind} (request side), throwing on any other value. */
76
+ function reqKind(obj: Record<string, unknown>, what: string): RecoverKind {
77
+ const kind = obj["kind"];
78
+ if (kind !== "backfill" && kind !== "resume") {
79
+ failWire(`${what}: field "kind" must be "backfill" or "resume"`);
80
+ }
81
+ return kind;
82
+ }
83
+
84
+ /** Narrow `obj.kind` to a {@link RecoverReplyKind} (reply side — also admits `"rotated"`). */
85
+ function reqReplyKind(obj: Record<string, unknown>, what: string): RecoverReplyKind {
86
+ const kind = obj["kind"];
87
+ if (kind !== "backfill" && kind !== "resume" && kind !== "rotated") {
88
+ failWire(`${what}: field "kind" must be "backfill", "resume", or "rotated"`);
89
+ }
90
+ return kind;
91
+ }
92
+
93
+ /** Reject a frame carrying `branch`, the body that does not belong to the declared `kind`. */
94
+ function rejectStrayBranch(obj: Record<string, unknown>, branch: string, what: string): void {
95
+ if (obj[branch] !== undefined) {
96
+ failWire(`${what}: a "${branch}" body is present but does not match the declared kind`);
97
+ }
98
+ }
99
+
100
+ /** Narrow an already-parsed value to {@link RecoverRequestV1}, throwing on any defect or kind/branch mismatch. */
101
+ export function validateRecoverRequestV1(value: unknown): RecoverRequestV1 {
102
+ const what = "RecoverRequestV1";
103
+ const obj = asObject(value, what);
104
+ requireV1(obj, what);
105
+ const kind = reqKind(obj, what);
106
+ if (kind === "backfill") {
107
+ rejectStrayBranch(obj, "resume", what);
108
+ return { v: 1, kind, backfill: validateBackfillV1(obj["backfill"]) };
109
+ }
110
+ rejectStrayBranch(obj, "backfill", what);
111
+ return { v: 1, kind, resume: validateResumeV1(obj["resume"]) };
112
+ }
113
+
114
+ /** Narrow an already-parsed value to {@link RecoverReplyV1}, throwing on any defect or kind/branch mismatch. */
115
+ export function validateRecoverReplyV1(value: unknown): RecoverReplyV1 {
116
+ const what = "RecoverReplyV1";
117
+ const obj = asObject(value, what);
118
+ requireV1(obj, what);
119
+ const kind = reqReplyKind(obj, what);
120
+ if (kind === "backfill") {
121
+ rejectStrayBranch(obj, "resumeReply", what);
122
+ rejectStrayBranch(obj, "rotated", what);
123
+ return { v: 1, kind, backfillReply: validateBackfillReplyV1(obj["backfillReply"]) };
124
+ }
125
+ if (kind === "resume") {
126
+ rejectStrayBranch(obj, "backfillReply", what);
127
+ rejectStrayBranch(obj, "rotated", what);
128
+ return { v: 1, kind, resumeReply: validateResumeReplyV1(obj["resumeReply"]) };
129
+ }
130
+ rejectStrayBranch(obj, "backfillReply", what);
131
+ rejectStrayBranch(obj, "resumeReply", what);
132
+ return { v: 1, kind, rotated: validateRotationRedirectV1(obj["rotated"]) };
133
+ }
134
+
135
+ /** Encode a {@link RecoverRequestV1} as a length-prefixed UTF-8 JSON frame. */
136
+ export function encodeRecoverRequestV1(msg: RecoverRequestV1, maxMessageBytes: number = DEFAULT_MAX_MESSAGE_BYTES): Uint8Array {
137
+ return encodeCohortMessage(validateRecoverRequestV1(msg), maxMessageBytes);
138
+ }
139
+
140
+ /** Decode a length-prefixed {@link RecoverRequestV1} frame. */
141
+ export function decodeRecoverRequestV1(bytes: Uint8Array, maxMessageBytes?: number): RecoverRequestV1 {
142
+ return validateRecoverRequestV1(decodeCohortMessage(bytes, maxMessageBytes));
143
+ }
144
+
145
+ /** Encode a {@link RecoverReplyV1} as a length-prefixed UTF-8 JSON frame. */
146
+ export function encodeRecoverReplyV1(msg: RecoverReplyV1, maxMessageBytes: number = DEFAULT_MAX_MESSAGE_BYTES): Uint8Array {
147
+ return encodeCohortMessage(validateRecoverReplyV1(msg), maxMessageBytes);
148
+ }
149
+
150
+ /** Decode a length-prefixed {@link RecoverReplyV1} frame. */
151
+ export function decodeRecoverReplyV1(bytes: Uint8Array, maxMessageBytes?: number): RecoverReplyV1 {
152
+ return validateRecoverReplyV1(decodeCohortMessage(bytes, maxMessageBytes));
153
+ }
@@ -1,141 +1,141 @@
1
- /**
2
- * Reactivity — replay window (`docs/reactivity.md` §Replay window).
3
- *
4
- * Each forwarder and the tail cohort keep a per-collection ring buffer of the last `W` notifications
5
- * (default 256). Entries are gossiped across the cohort ({@link ReplayBuffer.serialize} /
6
- * {@link ReplayBuffer.merge}) so **any** cohort member — not just the primary — can serve a replay if
7
- * the primary is unavailable. This buffer is the substrate the backfill/resume ticket reads from; this
8
- * ticket owns its construction, ring semantics, gossip replication, and the any-member-serves property.
9
- *
10
- * The ring is keyed by revision, not by insertion slot, so a retransmit that arrives out of order (the
11
- * recovery case the dedupe window admits) lands at its correct revision. Capacity `W` bounds the number
12
- * of *distinct revisions* retained; the lowest revision is dropped when a new one overflows the ring.
13
- */
14
-
15
- import { W_DEFAULT } from "./config.js";
16
- import type { NotificationV1 } from "./wire.js";
17
-
18
- /** One replay-buffer entry: the full signed notification plus its local receive time. */
19
- export interface RevisionEntry {
20
- readonly revision: number;
21
- /** The full signed notification (retains the original threshold signature; backfills verify end-to-end). */
22
- readonly payload: NotificationV1;
23
- /** Unix ms. */
24
- readonly receivedAt: number;
25
- }
26
-
27
- /** Serializable replay-buffer state for intra-cohort gossip replication. */
28
- export interface ReplayBufferStateV1 {
29
- readonly capacity: number;
30
- readonly entries: readonly RevisionEntry[];
31
- }
32
-
33
- /** A per-collection ring buffer of the last `W` notifications, gossip-replicated across the cohort. */
34
- export interface ReplayBuffer {
35
- /** Configured ring capacity `W`. */
36
- readonly capacity: number;
37
- /** Number of distinct revisions currently retained. */
38
- readonly size: number;
39
- /** Lowest retained revision, or `undefined` when empty. */
40
- readonly lowRevision: number | undefined;
41
- /** Highest retained revision, or `undefined` when empty. */
42
- readonly highRevision: number | undefined;
43
- /** Append (or replace, on a retransmit) the entry for its revision, evicting the lowest on overflow. */
44
- append(entry: RevisionEntry): void;
45
- /** The entry for `revision`, or `undefined` if not in the window. */
46
- get(revision: number): RevisionEntry | undefined;
47
- /** Entries with `from <= revision <= to`, ascending — the intersection with the window. */
48
- range(from: number, to: number): RevisionEntry[];
49
- /** All retained entries, ascending by revision. */
50
- entries(): RevisionEntry[];
51
- /** Snapshot for gossip. */
52
- serialize(): ReplayBufferStateV1;
53
- /** Merge another member's gossiped buffer (newer `receivedAt` wins a per-revision tie). */
54
- merge(state: ReplayBufferStateV1): void;
55
- }
56
-
57
- class RingReplayBuffer implements ReplayBuffer {
58
- readonly capacity: number;
59
- /** revision → entry; trimmed to the highest `capacity` revisions. */
60
- private readonly byRevision = new Map<number, RevisionEntry>();
61
- private readonly onEvict?: (entry: RevisionEntry) => void;
62
-
63
- constructor(capacity: number, onEvict?: (entry: RevisionEntry) => void) {
64
- if (!Number.isInteger(capacity) || capacity < 1) {
65
- throw new RangeError(`reactivity replay buffer: capacity must be an integer >= 1, got ${capacity}`);
66
- }
67
- this.capacity = capacity;
68
- this.onEvict = onEvict;
69
- }
70
-
71
- get size(): number {
72
- return this.byRevision.size;
73
- }
74
-
75
- get lowRevision(): number | undefined {
76
- return this.size === 0 ? undefined : Math.min(...this.byRevision.keys());
77
- }
78
-
79
- get highRevision(): number | undefined {
80
- return this.size === 0 ? undefined : Math.max(...this.byRevision.keys());
81
- }
82
-
83
- append(entry: RevisionEntry): void {
84
- this.byRevision.set(entry.revision, entry);
85
- this.trim();
86
- }
87
-
88
- get(revision: number): RevisionEntry | undefined {
89
- return this.byRevision.get(revision);
90
- }
91
-
92
- range(from: number, to: number): RevisionEntry[] {
93
- // Iterate the retained entries (≤ capacity) rather than every integer in `[from, to]`: a backfill
94
- // request carries a subscriber-supplied range that may be arbitrarily wide, so a per-integer scan
95
- // would be an unbounded loop on attacker-controlled input. The intersection with the window is
96
- // bounded by `capacity` regardless.
97
- return this.entries().filter((e) => e.revision >= from && e.revision <= to);
98
- }
99
-
100
- entries(): RevisionEntry[] {
101
- return [...this.byRevision.values()].sort((a, b) => a.revision - b.revision);
102
- }
103
-
104
- serialize(): ReplayBufferStateV1 {
105
- return { capacity: this.capacity, entries: this.entries() };
106
- }
107
-
108
- merge(state: ReplayBufferStateV1): void {
109
- for (const entry of state.entries) {
110
- const existing = this.byRevision.get(entry.revision);
111
- // Per-revision convergence: the freshest receiver's copy wins (they carry the same end-to-end
112
- // signature, so this only breaks ties on `receivedAt`, never trusts a forged payload).
113
- if (existing === undefined || entry.receivedAt > existing.receivedAt) {
114
- this.byRevision.set(entry.revision, entry);
115
- }
116
- }
117
- this.trim();
118
- }
119
-
120
- /** Evict the lowest revisions until at most `capacity` distinct revisions remain. */
121
- private trim(): void {
122
- while (this.byRevision.size > this.capacity) {
123
- const lowest = Math.min(...this.byRevision.keys());
124
- const evicted = this.byRevision.get(lowest)!;
125
- this.byRevision.delete(lowest);
126
- // Hand the retired revision to the (optional) observer — the rolling parent checkpoint
127
- // (`docs/reactivity.md` §Parent checkpoint summaries) feeds on exactly these evictions so its
128
- // span tracks the window immediately below the ring's low edge.
129
- this.onEvict?.(evicted);
130
- }
131
- }
132
- }
133
-
134
- /**
135
- * Build a {@link ReplayBuffer} with capacity `W` (default {@link W_DEFAULT}). `onEvict`, when supplied, is
136
- * invoked with each entry retired from the ring's low edge (the {@link PushState} wires it to the rolling
137
- * checkpoint so revisions leaving the replay window roll into the parent-checkpoint summary).
138
- */
139
- export function createReplayBuffer(capacity: number = W_DEFAULT, onEvict?: (entry: RevisionEntry) => void): ReplayBuffer {
140
- return new RingReplayBuffer(capacity, onEvict);
141
- }
1
+ /**
2
+ * Reactivity — replay window (`docs/reactivity.md` §Replay window).
3
+ *
4
+ * Each forwarder and the tail cohort keep a per-collection ring buffer of the last `W` notifications
5
+ * (default 256). Entries are gossiped across the cohort ({@link ReplayBuffer.serialize} /
6
+ * {@link ReplayBuffer.merge}) so **any** cohort member — not just the primary — can serve a replay if
7
+ * the primary is unavailable. This buffer is the substrate the backfill/resume ticket reads from; this
8
+ * ticket owns its construction, ring semantics, gossip replication, and the any-member-serves property.
9
+ *
10
+ * The ring is keyed by revision, not by insertion slot, so a retransmit that arrives out of order (the
11
+ * recovery case the dedupe window admits) lands at its correct revision. Capacity `W` bounds the number
12
+ * of *distinct revisions* retained; the lowest revision is dropped when a new one overflows the ring.
13
+ */
14
+
15
+ import { W_DEFAULT } from "./config.js";
16
+ import type { NotificationV1 } from "./wire.js";
17
+
18
+ /** One replay-buffer entry: the full signed notification plus its local receive time. */
19
+ export interface RevisionEntry {
20
+ readonly revision: number;
21
+ /** The full signed notification (retains the original threshold signature; backfills verify end-to-end). */
22
+ readonly payload: NotificationV1;
23
+ /** Unix ms. */
24
+ readonly receivedAt: number;
25
+ }
26
+
27
+ /** Serializable replay-buffer state for intra-cohort gossip replication. */
28
+ export interface ReplayBufferStateV1 {
29
+ readonly capacity: number;
30
+ readonly entries: readonly RevisionEntry[];
31
+ }
32
+
33
+ /** A per-collection ring buffer of the last `W` notifications, gossip-replicated across the cohort. */
34
+ export interface ReplayBuffer {
35
+ /** Configured ring capacity `W`. */
36
+ readonly capacity: number;
37
+ /** Number of distinct revisions currently retained. */
38
+ readonly size: number;
39
+ /** Lowest retained revision, or `undefined` when empty. */
40
+ readonly lowRevision: number | undefined;
41
+ /** Highest retained revision, or `undefined` when empty. */
42
+ readonly highRevision: number | undefined;
43
+ /** Append (or replace, on a retransmit) the entry for its revision, evicting the lowest on overflow. */
44
+ append(entry: RevisionEntry): void;
45
+ /** The entry for `revision`, or `undefined` if not in the window. */
46
+ get(revision: number): RevisionEntry | undefined;
47
+ /** Entries with `from <= revision <= to`, ascending — the intersection with the window. */
48
+ range(from: number, to: number): RevisionEntry[];
49
+ /** All retained entries, ascending by revision. */
50
+ entries(): RevisionEntry[];
51
+ /** Snapshot for gossip. */
52
+ serialize(): ReplayBufferStateV1;
53
+ /** Merge another member's gossiped buffer (newer `receivedAt` wins a per-revision tie). */
54
+ merge(state: ReplayBufferStateV1): void;
55
+ }
56
+
57
+ class RingReplayBuffer implements ReplayBuffer {
58
+ readonly capacity: number;
59
+ /** revision → entry; trimmed to the highest `capacity` revisions. */
60
+ private readonly byRevision = new Map<number, RevisionEntry>();
61
+ private readonly onEvict?: (entry: RevisionEntry) => void;
62
+
63
+ constructor(capacity: number, onEvict?: (entry: RevisionEntry) => void) {
64
+ if (!Number.isInteger(capacity) || capacity < 1) {
65
+ throw new RangeError(`reactivity replay buffer: capacity must be an integer >= 1, got ${capacity}`);
66
+ }
67
+ this.capacity = capacity;
68
+ this.onEvict = onEvict;
69
+ }
70
+
71
+ get size(): number {
72
+ return this.byRevision.size;
73
+ }
74
+
75
+ get lowRevision(): number | undefined {
76
+ return this.size === 0 ? undefined : Math.min(...this.byRevision.keys());
77
+ }
78
+
79
+ get highRevision(): number | undefined {
80
+ return this.size === 0 ? undefined : Math.max(...this.byRevision.keys());
81
+ }
82
+
83
+ append(entry: RevisionEntry): void {
84
+ this.byRevision.set(entry.revision, entry);
85
+ this.trim();
86
+ }
87
+
88
+ get(revision: number): RevisionEntry | undefined {
89
+ return this.byRevision.get(revision);
90
+ }
91
+
92
+ range(from: number, to: number): RevisionEntry[] {
93
+ // Iterate the retained entries (≤ capacity) rather than every integer in `[from, to]`: a backfill
94
+ // request carries a subscriber-supplied range that may be arbitrarily wide, so a per-integer scan
95
+ // would be an unbounded loop on attacker-controlled input. The intersection with the window is
96
+ // bounded by `capacity` regardless.
97
+ return this.entries().filter((e) => e.revision >= from && e.revision <= to);
98
+ }
99
+
100
+ entries(): RevisionEntry[] {
101
+ return [...this.byRevision.values()].sort((a, b) => a.revision - b.revision);
102
+ }
103
+
104
+ serialize(): ReplayBufferStateV1 {
105
+ return { capacity: this.capacity, entries: this.entries() };
106
+ }
107
+
108
+ merge(state: ReplayBufferStateV1): void {
109
+ for (const entry of state.entries) {
110
+ const existing = this.byRevision.get(entry.revision);
111
+ // Per-revision convergence: the freshest receiver's copy wins (they carry the same end-to-end
112
+ // signature, so this only breaks ties on `receivedAt`, never trusts a forged payload).
113
+ if (existing === undefined || entry.receivedAt > existing.receivedAt) {
114
+ this.byRevision.set(entry.revision, entry);
115
+ }
116
+ }
117
+ this.trim();
118
+ }
119
+
120
+ /** Evict the lowest revisions until at most `capacity` distinct revisions remain. */
121
+ private trim(): void {
122
+ while (this.byRevision.size > this.capacity) {
123
+ const lowest = Math.min(...this.byRevision.keys());
124
+ const evicted = this.byRevision.get(lowest)!;
125
+ this.byRevision.delete(lowest);
126
+ // Hand the retired revision to the (optional) observer — the rolling parent checkpoint
127
+ // (`docs/reactivity.md` §Parent checkpoint summaries) feeds on exactly these evictions so its
128
+ // span tracks the window immediately below the ring's low edge.
129
+ this.onEvict?.(evicted);
130
+ }
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Build a {@link ReplayBuffer} with capacity `W` (default {@link W_DEFAULT}). `onEvict`, when supplied, is
136
+ * invoked with each entry retired from the ring's low edge (the {@link PushState} wires it to the rolling
137
+ * checkpoint so revisions leaving the replay window roll into the parent-checkpoint summary).
138
+ */
139
+ export function createReplayBuffer(capacity: number = W_DEFAULT, onEvict?: (entry: RevisionEntry) => void): ReplayBuffer {
140
+ return new RingReplayBuffer(capacity, onEvict);
141
+ }