@optimystic/db-core 0.22.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.
- package/README.md +336 -336
- package/dist/src/cluster/structs.d.ts +39 -1
- package/dist/src/cluster/structs.d.ts.map +1 -1
- package/dist/src/cluster/structs.js +24 -0
- package/dist/src/cluster/structs.js.map +1 -1
- package/dist/src/collection/collection.d.ts +17 -0
- package/dist/src/collection/collection.d.ts.map +1 -1
- package/dist/src/collection/collection.js +24 -2
- package/dist/src/collection/collection.js.map +1 -1
- package/dist/src/collections/tree/tree.d.ts +5 -0
- package/dist/src/collections/tree/tree.d.ts.map +1 -1
- package/dist/src/collections/tree/tree.js +7 -0
- package/dist/src/collections/tree/tree.js.map +1 -1
- package/dist/src/network/i-peer-network.d.ts +16 -0
- package/dist/src/network/i-peer-network.d.ts.map +1 -1
- package/dist/src/network/struct.d.ts +39 -2
- package/dist/src/network/struct.d.ts.map +1 -1
- package/dist/src/network/struct.js +18 -0
- package/dist/src/network/struct.js.map +1 -1
- package/dist/src/testing/test-transactor.d.ts +95 -8
- package/dist/src/testing/test-transactor.d.ts.map +1 -1
- package/dist/src/testing/test-transactor.js +121 -8
- package/dist/src/testing/test-transactor.js.map +1 -1
- package/dist/src/transaction/transaction.d.ts +1 -1
- package/dist/src/transaction/transaction.js +1 -1
- package/dist/src/transactor/network-transactor.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.js +48 -13
- package/dist/src/transactor/network-transactor.js.map +1 -1
- package/dist/src/transactor/transactor-source.d.ts.map +1 -1
- package/dist/src/transactor/transactor-source.js +25 -2
- package/dist/src/transactor/transactor-source.js.map +1 -1
- package/package.json +1 -1
- package/src/cluster/membership.ts +85 -85
- package/src/cluster/structs.ts +43 -4
- package/src/cohort-topic/addressing.ts +120 -120
- package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +253 -253
- package/src/cohort-topic/antidos/bootstrap-evidence.ts +106 -106
- package/src/cohort-topic/antidos/index.ts +5 -5
- package/src/cohort-topic/antidos/rate-limiter.ts +210 -210
- package/src/cohort-topic/antidos/replay-guard.ts +146 -146
- package/src/cohort-topic/antidos/topic-budget.ts +160 -160
- package/src/cohort-topic/antiflood/index.ts +2 -2
- package/src/cohort-topic/antiflood/invariants.ts +108 -108
- package/src/cohort-topic/antiflood/jitter.ts +117 -117
- package/src/cohort-topic/coldstart.ts +237 -237
- package/src/cohort-topic/dmax.ts +88 -88
- package/src/cohort-topic/gossip/bus.ts +254 -254
- package/src/cohort-topic/gossip/index.ts +3 -3
- package/src/cohort-topic/gossip/records.ts +45 -45
- package/src/cohort-topic/gossip/view.ts +91 -91
- package/src/cohort-topic/index.ts +20 -20
- package/src/cohort-topic/load/barometer.ts +134 -134
- package/src/cohort-topic/load/index.ts +1 -1
- package/src/cohort-topic/member-engine.ts +430 -430
- package/src/cohort-topic/membership/index.ts +3 -3
- package/src/cohort-topic/membership/publisher.ts +163 -163
- package/src/cohort-topic/membership/source.ts +41 -41
- package/src/cohort-topic/membership/verifier.ts +461 -461
- package/src/cohort-topic/ports.ts +157 -157
- package/src/cohort-topic/promotion.ts +405 -405
- package/src/cohort-topic/registration/bytes.ts +37 -37
- package/src/cohort-topic/registration/handoff.ts +154 -154
- package/src/cohort-topic/registration/index.ts +6 -6
- package/src/cohort-topic/registration/renewal.ts +495 -495
- package/src/cohort-topic/registration/sharding.ts +61 -61
- package/src/cohort-topic/registration/store.ts +81 -81
- package/src/cohort-topic/registration/types.ts +91 -91
- package/src/cohort-topic/ring-hash.ts +50 -50
- package/src/cohort-topic/service.ts +416 -416
- package/src/cohort-topic/sig/index.ts +2 -2
- package/src/cohort-topic/sig/payloads.ts +59 -59
- package/src/cohort-topic/sig/threshold.ts +64 -64
- package/src/cohort-topic/tiers.ts +74 -74
- package/src/cohort-topic/traffic.ts +233 -233
- package/src/cohort-topic/walk.ts +326 -326
- package/src/cohort-topic/willingness.ts +237 -237
- package/src/cohort-topic/wire/codec.ts +216 -216
- package/src/cohort-topic/wire/index.ts +18 -18
- package/src/cohort-topic/wire/payloads.ts +126 -126
- package/src/cohort-topic/wire/primitives.ts +188 -188
- package/src/cohort-topic/wire/types.ts +475 -475
- package/src/cohort-topic/wire/validate.ts +512 -512
- package/src/collection/collection-type-registry.ts +37 -37
- package/src/collection/collection.ts +25 -2
- package/src/collections/diary/diary.ts +68 -68
- package/src/collections/tree/readme.md +4 -0
- package/src/collections/tree/tree.ts +320 -312
- package/src/matchmaking/capability-filter.ts +45 -45
- package/src/matchmaking/config.ts +98 -98
- package/src/matchmaking/index.ts +21 -21
- package/src/matchmaking/multi-cohort-seeker.ts +234 -234
- package/src/matchmaking/provider.ts +123 -123
- package/src/matchmaking/query-eval.ts +105 -105
- package/src/matchmaking/seeker-walk.ts +127 -127
- package/src/matchmaking/seeker.ts +86 -86
- package/src/matchmaking/topic-anchor.ts +90 -90
- package/src/matchmaking/voting-quorum.ts +394 -394
- package/src/matchmaking/wire.ts +603 -603
- package/src/network/i-peer-network.ts +17 -0
- package/src/network/stale-failure.ts +43 -43
- package/src/network/struct.ts +41 -2
- package/src/network/types.ts +37 -37
- package/src/reactivity/backfill.ts +220 -220
- package/src/reactivity/backpressure.ts +191 -191
- package/src/reactivity/checkpoint.ts +308 -308
- package/src/reactivity/config.ts +172 -172
- package/src/reactivity/dedupe.ts +132 -132
- package/src/reactivity/forwarder.ts +87 -87
- package/src/reactivity/index.ts +34 -34
- package/src/reactivity/notification.ts +123 -123
- package/src/reactivity/policy.ts +79 -79
- package/src/reactivity/push-state.ts +310 -310
- package/src/reactivity/recover.ts +153 -153
- package/src/reactivity/replay-buffer.ts +141 -141
- package/src/reactivity/resume.ts +549 -549
- package/src/reactivity/rotation.ts +415 -415
- package/src/reactivity/subscriber.ts +132 -132
- package/src/reactivity/subscription.ts +66 -66
- package/src/reactivity/topic-anchor.ts +71 -71
- package/src/reactivity/verify.ts +73 -73
- package/src/reactivity/wire-validate.ts +13 -13
- package/src/reactivity/wire.ts +224 -224
- package/src/testing/async-wait.ts +65 -65
- package/src/testing/index.ts +2 -2
- package/src/testing/test-transactor.ts +638 -502
- package/src/transaction/errors.ts +91 -91
- package/src/transaction/operations-hash.ts +196 -196
- package/src/transaction/read-dependency-collector.ts +78 -78
- package/src/transaction/transaction.ts +1 -1
- package/src/transactor/change-notifier.ts +80 -80
- package/src/transactor/index.ts +5 -5
- package/src/transactor/network-transactor.ts +49 -14
- package/src/transactor/transactor-source.ts +25 -2
- package/src/transform/atomic-proxy.ts +92 -92
- package/src/transform/helpers.ts +159 -159
- package/src/utility/backoff.ts +95 -95
- package/src/utility/batch-coordinator.ts +191 -191
- package/dist/src/transaction/context.d.ts +0 -60
- package/dist/src/transaction/context.d.ts.map +0 -1
- package/dist/src/transaction/context.js +0 -91
- package/dist/src/transaction/context.js.map +0 -1
|
@@ -1,310 +1,310 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reactivity — forwarder-cohort `PushState` and its intra-cohort gossip codec
|
|
3
|
-
* (`docs/reactivity.md` §Forwarder-cohort state, §Replay window).
|
|
4
|
-
*
|
|
5
|
-
* A reactivity forwarder cohort holds the cohort-topic registration records (the direct-subscriber
|
|
6
|
-
* list **is** the `RegistrationRecord` set with `appPayload.kind == "reactivity"` — reactivity reads it,
|
|
7
|
-
* it does not duplicate it) plus this per-collection {@link PushState}: topic identity, parent/child
|
|
8
|
-
* cohort refs, the {@link ReplayBuffer} ring, the {@link DedupeWindow}, and `lastRevision`.
|
|
9
|
-
*
|
|
10
|
-
* `PushState` is gossiped within the cohort (over the cohort-topic gossip channel) so any member can
|
|
11
|
-
* serve a backfill/replay if the primary is unavailable. This module defines the **full** struct and its
|
|
12
|
-
* gossip codec so all three reactivity tickets share one definition. The rolling parent {@link checkpoint}
|
|
13
|
-
* (and its derived {@link parentCheckpoint} summary) is fed by replay-ring eviction
|
|
14
|
-
* ([reactivity-backfill-resume-checkpoints]); the `perSubscriberQueue` field carries the primary-local
|
|
15
|
-
* per-subscriber {@link SubscriberBackpressure} ([reactivity-rotation-backpressure-policy]). The
|
|
16
|
-
* backpressure map is **primary-local fan-out state, never gossiped** — it is absent from
|
|
17
|
-
* {@link PushStateGossipV1}; a handoff rebuilds it empty and the replay buffer covers the gap.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import { bytesToB64url, b64urlToBytes, decodeCohortMessage, encodeCohortMessage, DEFAULT_MAX_MESSAGE_BYTES } from "../cohort-topic/wire/codec.js";
|
|
21
|
-
import { asObject, b64urlField, failWire as fail, reqFiniteNumber, reqString } from "../cohort-topic/wire/primitives.js";
|
|
22
|
-
import { DEDUPE_WINDOW_DEFAULT, QUEUE_MAX_DEFAULT, W_DEFAULT } from "./config.js";
|
|
23
|
-
import { createSubscriberBackpressure, type SubscriberBackpressure, type SubscriberEnqueueResult } from "./backpressure.js";
|
|
24
|
-
import { RollingCheckpoint, type CheckpointSummary, type DeltaCoalesce, type DigestFold } from "./checkpoint.js";
|
|
25
|
-
import { createDedupeWindow, type DedupeStateV1, type DedupeWindow } from "./dedupe.js";
|
|
26
|
-
import { createReplayBuffer, type ReplayBuffer, type ReplayBufferStateV1, type RevisionEntry } from "./replay-buffer.js";
|
|
27
|
-
import { validateNotificationV1, type NotificationV1 } from "./wire.js";
|
|
28
|
-
|
|
29
|
-
/** A reference to a parent/child cohort in the reactivity tree (`tier-(d∓1)`). */
|
|
30
|
-
export interface CohortRef {
|
|
31
|
-
/** The cohort's served ring coordinate, base64url. */
|
|
32
|
-
readonly coord: string;
|
|
33
|
-
/** The cohort's primary member id, base64url (when known). */
|
|
34
|
-
readonly primary?: string;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Construction inputs for a {@link PushState}. */
|
|
38
|
-
export interface PushStateInit {
|
|
39
|
-
/** Collection id, base64url. */
|
|
40
|
-
readonly collectionId: string;
|
|
41
|
-
/** Reactivity topic id, base64url. */
|
|
42
|
-
readonly topicId: string;
|
|
43
|
-
/** Tail block id when this cohort joined the topic, base64url. */
|
|
44
|
-
readonly tailIdAtJoin: string;
|
|
45
|
-
/** Tier-`(d−1)` parent cohort (absent at the tail/root). */
|
|
46
|
-
readonly parentCohort?: CohortRef;
|
|
47
|
-
/** Tier-`(d+1)` child cohorts. */
|
|
48
|
-
readonly childCohorts?: CohortRef[];
|
|
49
|
-
/** Replay buffer depth `W` (default {@link W_DEFAULT}). */
|
|
50
|
-
readonly w?: number;
|
|
51
|
-
/** Dedupe-window span (default {@link DEDUPE_WINDOW_DEFAULT}). */
|
|
52
|
-
readonly dedupeWindow?: number;
|
|
53
|
-
/** Parent-checkpoint span `W_checkpoint` (default {@link import("./config.js").W_CHECKPOINT_DEFAULT}). */
|
|
54
|
-
readonly wCheckpoint?: number;
|
|
55
|
-
/** Per-subscriber bounded-queue depth `queue_max` (default {@link QUEUE_MAX_DEFAULT}). */
|
|
56
|
-
readonly queueMax?: number;
|
|
57
|
-
/** `delta_max` for the checkpoint's coalesced `mergedDelta`; `0` (default) ⇒ never emit a delta. */
|
|
58
|
-
readonly deltaMaxBytes?: number;
|
|
59
|
-
/** `mergedDigest` fold override (default: deterministic running hash over per-revision commit digests). */
|
|
60
|
-
readonly checkpointFold?: DigestFold;
|
|
61
|
-
/** `mergedDelta` coalescer override (default: ordered concatenation). */
|
|
62
|
-
readonly coalesceDeltas?: DeltaCoalesce;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** The cohort-gossiped wire image of a {@link PushState}. */
|
|
66
|
-
export interface PushStateGossipV1 {
|
|
67
|
-
v: 1;
|
|
68
|
-
collectionId: string;
|
|
69
|
-
topicId: string;
|
|
70
|
-
tailIdAtJoin: string;
|
|
71
|
-
parentCohort?: CohortRef;
|
|
72
|
-
childCohorts: CohortRef[];
|
|
73
|
-
lastRevision: number;
|
|
74
|
-
replayBuffer: ReplayBufferStateV1;
|
|
75
|
-
dedupe: DedupeStateV1;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Per-collection forwarder-cohort state. Owns the live {@link ReplayBuffer} and {@link DedupeWindow}
|
|
80
|
-
* components and the scalar identity/revision state; the reserved sibling-ticket fields hang off it.
|
|
81
|
-
*/
|
|
82
|
-
export class PushState {
|
|
83
|
-
readonly collectionId: string;
|
|
84
|
-
readonly topicId: string;
|
|
85
|
-
tailIdAtJoin: string;
|
|
86
|
-
parentCohort?: CohortRef;
|
|
87
|
-
childCohorts: CohortRef[];
|
|
88
|
-
readonly replayBuffer: ReplayBuffer;
|
|
89
|
-
readonly dedupe: DedupeWindow;
|
|
90
|
-
lastRevision: number;
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* The rolling parent checkpoint ([reactivity-backfill-resume-checkpoints], `docs/reactivity.md`
|
|
94
|
-
* §Parent checkpoint summaries). Fed by replay-ring eviction — as revisions retire from the `W`-deep
|
|
95
|
-
* ring they roll into this `W_checkpoint`-span summary sitting immediately below the ring, so a resume
|
|
96
|
-
* recovers `W + W_checkpoint` revisions in one round trip (the stacked-window semantics).
|
|
97
|
-
*/
|
|
98
|
-
readonly checkpoint: RollingCheckpoint;
|
|
99
|
-
/**
|
|
100
|
-
* The forwarder primary's per-subscriber drop-oldest backpressure map
|
|
101
|
-
* ([reactivity-rotation-backpressure-policy], `docs/reactivity.md` §Slow-subscriber backpressure).
|
|
102
|
-
* Primary-local fan-out state — **not** gossiped, so it is absent from {@link PushStateGossipV1}.
|
|
103
|
-
*/
|
|
104
|
-
readonly perSubscriberQueue: SubscriberBackpressure;
|
|
105
|
-
/**
|
|
106
|
-
* The checkpoint migrated from the **outgoing** tail when this cohort became the new tail across a
|
|
107
|
-
* rotation ([reactivity-rotation-backpressure-policy], `docs/reactivity.md` §Tail rotation step 5). The
|
|
108
|
-
* new tail "holds the old checkpoint" so a `ResumeV1` whose span crosses the rotation is recoverable;
|
|
109
|
-
* `undefined` until a handoff lands via {@link adoptRotationCheckpoint}. Distinct from the live rolling
|
|
110
|
-
* {@link checkpoint}, which rolls from this cohort's *own* replay-ring eviction.
|
|
111
|
-
*/
|
|
112
|
-
private inherited?: CheckpointSummary;
|
|
113
|
-
|
|
114
|
-
constructor(init: PushStateInit) {
|
|
115
|
-
this.collectionId = init.collectionId;
|
|
116
|
-
this.topicId = init.topicId;
|
|
117
|
-
this.tailIdAtJoin = init.tailIdAtJoin;
|
|
118
|
-
this.parentCohort = init.parentCohort;
|
|
119
|
-
this.childCohorts = init.childCohorts ?? [];
|
|
120
|
-
this.checkpoint = new RollingCheckpoint({
|
|
121
|
-
collectionId: init.collectionId,
|
|
122
|
-
span: init.wCheckpoint,
|
|
123
|
-
deltaMaxBytes: init.deltaMaxBytes,
|
|
124
|
-
fold: init.checkpointFold,
|
|
125
|
-
coalesceDeltas: init.coalesceDeltas,
|
|
126
|
-
});
|
|
127
|
-
// Wire the ring's low-edge eviction into the rolling checkpoint: a revision leaving the replay
|
|
128
|
-
// window is exactly a revision that should roll into the parent-checkpoint summary.
|
|
129
|
-
this.replayBuffer = createReplayBuffer(init.w ?? W_DEFAULT, (evicted) => this.checkpoint.retire(evicted));
|
|
130
|
-
this.dedupe = createDedupeWindow(init.dedupeWindow ?? DEDUPE_WINDOW_DEFAULT);
|
|
131
|
-
this.perSubscriberQueue = createSubscriberBackpressure(init.queueMax ?? QUEUE_MAX_DEFAULT);
|
|
132
|
-
this.lastRevision = -1;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Fan one notification onto each subscriber's bounded queue (the primary's per-subscriber backpressure,
|
|
137
|
-
* `docs/reactivity.md` §Slow-subscriber backpressure). Returns only the subscribers whose queue dropped
|
|
138
|
-
* its oldest under pressure — a slow subscriber's drop never stalls fan-out to the rest of the cohort.
|
|
139
|
-
*/
|
|
140
|
-
enqueueForSubscribers(subscriberIds: Iterable<string>, n: NotificationV1): SubscriberEnqueueResult[] {
|
|
141
|
-
return this.perSubscriberQueue.fanOut(subscriberIds, n);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** The checkpoint inherited from the outgoing tail on rotation, or `undefined` until a handoff lands. */
|
|
145
|
-
get inheritedCheckpoint(): CheckpointSummary | undefined {
|
|
146
|
-
return this.inherited;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Land an outgoing tail's rotation handoff checkpoint on this (new tail) cohort
|
|
151
|
-
* (`docs/reactivity.md` §Tail rotation step 5). Idempotent: a later handoff replaces only if it covers a
|
|
152
|
-
* higher revision, so a re-delivered or older handoff never rewinds the inherited span.
|
|
153
|
-
*/
|
|
154
|
-
adoptRotationCheckpoint(summary: CheckpointSummary): void {
|
|
155
|
-
if (this.inherited === undefined || summary.toRevision > this.inherited.toRevision) {
|
|
156
|
-
this.inherited = summary;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* The current parent-checkpoint summary, or `undefined` until revisions have retired from the replay
|
|
162
|
-
* ring (`docs/reactivity.md` §Parent checkpoint summaries). Re-derived from the live rolling checkpoint
|
|
163
|
-
* on each read so it always reflects the latest retired range.
|
|
164
|
-
*/
|
|
165
|
-
get parentCheckpoint(): CheckpointSummary | undefined {
|
|
166
|
-
return this.checkpoint.summary();
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/** Snapshot the gossipable slice for cohort replication. */
|
|
170
|
-
serializeGossip(): PushStateGossipV1 {
|
|
171
|
-
const out: PushStateGossipV1 = {
|
|
172
|
-
v: 1,
|
|
173
|
-
collectionId: this.collectionId,
|
|
174
|
-
topicId: this.topicId,
|
|
175
|
-
tailIdAtJoin: this.tailIdAtJoin,
|
|
176
|
-
childCohorts: this.childCohorts,
|
|
177
|
-
lastRevision: this.lastRevision,
|
|
178
|
-
replayBuffer: this.replayBuffer.serialize(),
|
|
179
|
-
dedupe: this.dedupe.serialize(),
|
|
180
|
-
};
|
|
181
|
-
if (this.parentCohort !== undefined) {
|
|
182
|
-
out.parentCohort = this.parentCohort;
|
|
183
|
-
}
|
|
184
|
-
return out;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/** Merge a peer member's gossiped state so the whole cohort converges (any member can then serve). */
|
|
188
|
-
mergeGossip(g: PushStateGossipV1): void {
|
|
189
|
-
if (g.collectionId !== this.collectionId || g.topicId !== this.topicId) {
|
|
190
|
-
return; // gossip for a different collection/topic is not ours to merge
|
|
191
|
-
}
|
|
192
|
-
this.replayBuffer.merge(g.replayBuffer);
|
|
193
|
-
this.dedupe.merge(g.dedupe);
|
|
194
|
-
if (g.lastRevision > this.lastRevision) {
|
|
195
|
-
this.lastRevision = g.lastRevision;
|
|
196
|
-
}
|
|
197
|
-
if (g.childCohorts.length > this.childCohorts.length) {
|
|
198
|
-
this.childCohorts = g.childCohorts;
|
|
199
|
-
}
|
|
200
|
-
if (this.parentCohort === undefined && g.parentCohort !== undefined) {
|
|
201
|
-
this.parentCohort = g.parentCohort;
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// --- gossip codec (length-framed JSON, mirroring cohort-topic conventions) ---
|
|
207
|
-
// Generic structural primitives (fail/asObject/reqString/reqFiniteNumber/b64urlField) are imported from
|
|
208
|
-
// cohort-topic/wire/primitives.js; only `reqInt` stays local — it is a range-free integer check (no
|
|
209
|
-
// lower bound), unlike the shared `reqIntInRange`, so folding it in would change behavior.
|
|
210
|
-
|
|
211
|
-
function reqInt(value: number, key: string, what: string): number {
|
|
212
|
-
if (!Number.isInteger(value)) {
|
|
213
|
-
fail(`${what}: field "${key}" must be an integer, got ${value}`);
|
|
214
|
-
}
|
|
215
|
-
return value;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function validateCohortRef(value: unknown, what: string): CohortRef {
|
|
219
|
-
const obj = asObject(value, what);
|
|
220
|
-
const coord = b64urlField(reqString(obj, "coord", what), "coord", what);
|
|
221
|
-
if (obj["primary"] !== undefined) {
|
|
222
|
-
return { coord, primary: b64urlField(reqString(obj, "primary", what), "primary", what) };
|
|
223
|
-
}
|
|
224
|
-
return { coord };
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function validateRevisionEntry(value: unknown, what: string): RevisionEntry {
|
|
228
|
-
const obj = asObject(value, what);
|
|
229
|
-
return {
|
|
230
|
-
revision: reqInt(reqFiniteNumber(obj, "revision", what), "revision", what),
|
|
231
|
-
payload: validateNotificationV1(obj["payload"]),
|
|
232
|
-
receivedAt: reqFiniteNumber(obj, "receivedAt", what),
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function validateReplayBufferState(value: unknown, what: string): ReplayBufferStateV1 {
|
|
237
|
-
const obj = asObject(value, what);
|
|
238
|
-
const entries = obj["entries"];
|
|
239
|
-
if (!Array.isArray(entries)) {
|
|
240
|
-
fail(`${what}: field "entries" must be an array`);
|
|
241
|
-
}
|
|
242
|
-
return {
|
|
243
|
-
capacity: reqInt(reqFiniteNumber(obj, "capacity", what), "capacity", what),
|
|
244
|
-
entries: entries.map((e) => validateRevisionEntry(e, `${what}.entry`)),
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function validateDedupeState(value: unknown, what: string): DedupeStateV1 {
|
|
249
|
-
const obj = asObject(value, what);
|
|
250
|
-
const entries = obj["entries"];
|
|
251
|
-
if (!Array.isArray(entries)) {
|
|
252
|
-
fail(`${what}: field "entries" must be an array`);
|
|
253
|
-
}
|
|
254
|
-
return {
|
|
255
|
-
highestRevision: reqInt(reqFiniteNumber(obj, "highestRevision", what), "highestRevision", what),
|
|
256
|
-
entries: entries.map((e) => {
|
|
257
|
-
const eo = asObject(e, `${what}.entry`);
|
|
258
|
-
return { key: reqString(eo, "key", `${what}.entry`), revision: reqInt(reqFiniteNumber(eo, "revision", `${what}.entry`), "revision", `${what}.entry`) };
|
|
259
|
-
}),
|
|
260
|
-
};
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/** Narrow an already-parsed value to {@link PushStateGossipV1}, throwing on any defect. */
|
|
264
|
-
export function validatePushStateGossipV1(value: unknown): PushStateGossipV1 {
|
|
265
|
-
const what = "PushStateGossipV1";
|
|
266
|
-
const obj = asObject(value, what);
|
|
267
|
-
if (obj["v"] !== 1) {
|
|
268
|
-
fail(`${what}: expected v === 1, got ${JSON.stringify(obj["v"])}`);
|
|
269
|
-
}
|
|
270
|
-
const childCohorts = obj["childCohorts"];
|
|
271
|
-
if (!Array.isArray(childCohorts)) {
|
|
272
|
-
fail(`${what}: field "childCohorts" must be an array`);
|
|
273
|
-
}
|
|
274
|
-
const out: PushStateGossipV1 = {
|
|
275
|
-
v: 1,
|
|
276
|
-
collectionId: b64urlField(reqString(obj, "collectionId", what), "collectionId", what),
|
|
277
|
-
topicId: b64urlField(reqString(obj, "topicId", what), "topicId", what),
|
|
278
|
-
tailIdAtJoin: b64urlField(reqString(obj, "tailIdAtJoin", what), "tailIdAtJoin", what),
|
|
279
|
-
childCohorts: childCohorts.map((c) => validateCohortRef(c, `${what}.childCohort`)),
|
|
280
|
-
lastRevision: reqInt(reqFiniteNumber(obj, "lastRevision", what), "lastRevision", what),
|
|
281
|
-
replayBuffer: validateReplayBufferState(obj["replayBuffer"], `${what}.replayBuffer`),
|
|
282
|
-
dedupe: validateDedupeState(obj["dedupe"], `${what}.dedupe`),
|
|
283
|
-
};
|
|
284
|
-
if (obj["parentCohort"] !== undefined) {
|
|
285
|
-
out.parentCohort = validateCohortRef(obj["parentCohort"], `${what}.parentCohort`);
|
|
286
|
-
}
|
|
287
|
-
return out;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/** Encode a {@link PushStateGossipV1} as a length-prefixed UTF-8 JSON frame. */
|
|
291
|
-
export function encodePushStateGossipV1(msg: PushStateGossipV1, maxMessageBytes: number = DEFAULT_MAX_MESSAGE_BYTES): Uint8Array {
|
|
292
|
-
return encodeCohortMessage(validatePushStateGossipV1(msg), maxMessageBytes);
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/** Decode a length-prefixed {@link PushStateGossipV1} frame. */
|
|
296
|
-
export function decodePushStateGossipV1(bytes: Uint8Array, maxMessageBytes?: number): PushStateGossipV1 {
|
|
297
|
-
return validatePushStateGossipV1(decodeCohortMessage(bytes, maxMessageBytes));
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/** Round-trip helper for `bytesToB64url` consumers wanting the raw coord bytes of a {@link CohortRef}. */
|
|
301
|
-
export function cohortRefCoordBytes(ref: CohortRef): Uint8Array {
|
|
302
|
-
return b64urlToBytes(ref.coord);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/** Build a {@link CohortRef} from raw coord (and optional primary) bytes. */
|
|
306
|
-
export function makeCohortRef(coord: Uint8Array, primary?: Uint8Array): CohortRef {
|
|
307
|
-
return primary !== undefined
|
|
308
|
-
? { coord: bytesToB64url(coord), primary: bytesToB64url(primary) }
|
|
309
|
-
: { coord: bytesToB64url(coord) };
|
|
310
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Reactivity — forwarder-cohort `PushState` and its intra-cohort gossip codec
|
|
3
|
+
* (`docs/reactivity.md` §Forwarder-cohort state, §Replay window).
|
|
4
|
+
*
|
|
5
|
+
* A reactivity forwarder cohort holds the cohort-topic registration records (the direct-subscriber
|
|
6
|
+
* list **is** the `RegistrationRecord` set with `appPayload.kind == "reactivity"` — reactivity reads it,
|
|
7
|
+
* it does not duplicate it) plus this per-collection {@link PushState}: topic identity, parent/child
|
|
8
|
+
* cohort refs, the {@link ReplayBuffer} ring, the {@link DedupeWindow}, and `lastRevision`.
|
|
9
|
+
*
|
|
10
|
+
* `PushState` is gossiped within the cohort (over the cohort-topic gossip channel) so any member can
|
|
11
|
+
* serve a backfill/replay if the primary is unavailable. This module defines the **full** struct and its
|
|
12
|
+
* gossip codec so all three reactivity tickets share one definition. The rolling parent {@link checkpoint}
|
|
13
|
+
* (and its derived {@link parentCheckpoint} summary) is fed by replay-ring eviction
|
|
14
|
+
* ([reactivity-backfill-resume-checkpoints]); the `perSubscriberQueue` field carries the primary-local
|
|
15
|
+
* per-subscriber {@link SubscriberBackpressure} ([reactivity-rotation-backpressure-policy]). The
|
|
16
|
+
* backpressure map is **primary-local fan-out state, never gossiped** — it is absent from
|
|
17
|
+
* {@link PushStateGossipV1}; a handoff rebuilds it empty and the replay buffer covers the gap.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { bytesToB64url, b64urlToBytes, decodeCohortMessage, encodeCohortMessage, DEFAULT_MAX_MESSAGE_BYTES } from "../cohort-topic/wire/codec.js";
|
|
21
|
+
import { asObject, b64urlField, failWire as fail, reqFiniteNumber, reqString } from "../cohort-topic/wire/primitives.js";
|
|
22
|
+
import { DEDUPE_WINDOW_DEFAULT, QUEUE_MAX_DEFAULT, W_DEFAULT } from "./config.js";
|
|
23
|
+
import { createSubscriberBackpressure, type SubscriberBackpressure, type SubscriberEnqueueResult } from "./backpressure.js";
|
|
24
|
+
import { RollingCheckpoint, type CheckpointSummary, type DeltaCoalesce, type DigestFold } from "./checkpoint.js";
|
|
25
|
+
import { createDedupeWindow, type DedupeStateV1, type DedupeWindow } from "./dedupe.js";
|
|
26
|
+
import { createReplayBuffer, type ReplayBuffer, type ReplayBufferStateV1, type RevisionEntry } from "./replay-buffer.js";
|
|
27
|
+
import { validateNotificationV1, type NotificationV1 } from "./wire.js";
|
|
28
|
+
|
|
29
|
+
/** A reference to a parent/child cohort in the reactivity tree (`tier-(d∓1)`). */
|
|
30
|
+
export interface CohortRef {
|
|
31
|
+
/** The cohort's served ring coordinate, base64url. */
|
|
32
|
+
readonly coord: string;
|
|
33
|
+
/** The cohort's primary member id, base64url (when known). */
|
|
34
|
+
readonly primary?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Construction inputs for a {@link PushState}. */
|
|
38
|
+
export interface PushStateInit {
|
|
39
|
+
/** Collection id, base64url. */
|
|
40
|
+
readonly collectionId: string;
|
|
41
|
+
/** Reactivity topic id, base64url. */
|
|
42
|
+
readonly topicId: string;
|
|
43
|
+
/** Tail block id when this cohort joined the topic, base64url. */
|
|
44
|
+
readonly tailIdAtJoin: string;
|
|
45
|
+
/** Tier-`(d−1)` parent cohort (absent at the tail/root). */
|
|
46
|
+
readonly parentCohort?: CohortRef;
|
|
47
|
+
/** Tier-`(d+1)` child cohorts. */
|
|
48
|
+
readonly childCohorts?: CohortRef[];
|
|
49
|
+
/** Replay buffer depth `W` (default {@link W_DEFAULT}). */
|
|
50
|
+
readonly w?: number;
|
|
51
|
+
/** Dedupe-window span (default {@link DEDUPE_WINDOW_DEFAULT}). */
|
|
52
|
+
readonly dedupeWindow?: number;
|
|
53
|
+
/** Parent-checkpoint span `W_checkpoint` (default {@link import("./config.js").W_CHECKPOINT_DEFAULT}). */
|
|
54
|
+
readonly wCheckpoint?: number;
|
|
55
|
+
/** Per-subscriber bounded-queue depth `queue_max` (default {@link QUEUE_MAX_DEFAULT}). */
|
|
56
|
+
readonly queueMax?: number;
|
|
57
|
+
/** `delta_max` for the checkpoint's coalesced `mergedDelta`; `0` (default) ⇒ never emit a delta. */
|
|
58
|
+
readonly deltaMaxBytes?: number;
|
|
59
|
+
/** `mergedDigest` fold override (default: deterministic running hash over per-revision commit digests). */
|
|
60
|
+
readonly checkpointFold?: DigestFold;
|
|
61
|
+
/** `mergedDelta` coalescer override (default: ordered concatenation). */
|
|
62
|
+
readonly coalesceDeltas?: DeltaCoalesce;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The cohort-gossiped wire image of a {@link PushState}. */
|
|
66
|
+
export interface PushStateGossipV1 {
|
|
67
|
+
v: 1;
|
|
68
|
+
collectionId: string;
|
|
69
|
+
topicId: string;
|
|
70
|
+
tailIdAtJoin: string;
|
|
71
|
+
parentCohort?: CohortRef;
|
|
72
|
+
childCohorts: CohortRef[];
|
|
73
|
+
lastRevision: number;
|
|
74
|
+
replayBuffer: ReplayBufferStateV1;
|
|
75
|
+
dedupe: DedupeStateV1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Per-collection forwarder-cohort state. Owns the live {@link ReplayBuffer} and {@link DedupeWindow}
|
|
80
|
+
* components and the scalar identity/revision state; the reserved sibling-ticket fields hang off it.
|
|
81
|
+
*/
|
|
82
|
+
export class PushState {
|
|
83
|
+
readonly collectionId: string;
|
|
84
|
+
readonly topicId: string;
|
|
85
|
+
tailIdAtJoin: string;
|
|
86
|
+
parentCohort?: CohortRef;
|
|
87
|
+
childCohorts: CohortRef[];
|
|
88
|
+
readonly replayBuffer: ReplayBuffer;
|
|
89
|
+
readonly dedupe: DedupeWindow;
|
|
90
|
+
lastRevision: number;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The rolling parent checkpoint ([reactivity-backfill-resume-checkpoints], `docs/reactivity.md`
|
|
94
|
+
* §Parent checkpoint summaries). Fed by replay-ring eviction — as revisions retire from the `W`-deep
|
|
95
|
+
* ring they roll into this `W_checkpoint`-span summary sitting immediately below the ring, so a resume
|
|
96
|
+
* recovers `W + W_checkpoint` revisions in one round trip (the stacked-window semantics).
|
|
97
|
+
*/
|
|
98
|
+
readonly checkpoint: RollingCheckpoint;
|
|
99
|
+
/**
|
|
100
|
+
* The forwarder primary's per-subscriber drop-oldest backpressure map
|
|
101
|
+
* ([reactivity-rotation-backpressure-policy], `docs/reactivity.md` §Slow-subscriber backpressure).
|
|
102
|
+
* Primary-local fan-out state — **not** gossiped, so it is absent from {@link PushStateGossipV1}.
|
|
103
|
+
*/
|
|
104
|
+
readonly perSubscriberQueue: SubscriberBackpressure;
|
|
105
|
+
/**
|
|
106
|
+
* The checkpoint migrated from the **outgoing** tail when this cohort became the new tail across a
|
|
107
|
+
* rotation ([reactivity-rotation-backpressure-policy], `docs/reactivity.md` §Tail rotation step 5). The
|
|
108
|
+
* new tail "holds the old checkpoint" so a `ResumeV1` whose span crosses the rotation is recoverable;
|
|
109
|
+
* `undefined` until a handoff lands via {@link adoptRotationCheckpoint}. Distinct from the live rolling
|
|
110
|
+
* {@link checkpoint}, which rolls from this cohort's *own* replay-ring eviction.
|
|
111
|
+
*/
|
|
112
|
+
private inherited?: CheckpointSummary;
|
|
113
|
+
|
|
114
|
+
constructor(init: PushStateInit) {
|
|
115
|
+
this.collectionId = init.collectionId;
|
|
116
|
+
this.topicId = init.topicId;
|
|
117
|
+
this.tailIdAtJoin = init.tailIdAtJoin;
|
|
118
|
+
this.parentCohort = init.parentCohort;
|
|
119
|
+
this.childCohorts = init.childCohorts ?? [];
|
|
120
|
+
this.checkpoint = new RollingCheckpoint({
|
|
121
|
+
collectionId: init.collectionId,
|
|
122
|
+
span: init.wCheckpoint,
|
|
123
|
+
deltaMaxBytes: init.deltaMaxBytes,
|
|
124
|
+
fold: init.checkpointFold,
|
|
125
|
+
coalesceDeltas: init.coalesceDeltas,
|
|
126
|
+
});
|
|
127
|
+
// Wire the ring's low-edge eviction into the rolling checkpoint: a revision leaving the replay
|
|
128
|
+
// window is exactly a revision that should roll into the parent-checkpoint summary.
|
|
129
|
+
this.replayBuffer = createReplayBuffer(init.w ?? W_DEFAULT, (evicted) => this.checkpoint.retire(evicted));
|
|
130
|
+
this.dedupe = createDedupeWindow(init.dedupeWindow ?? DEDUPE_WINDOW_DEFAULT);
|
|
131
|
+
this.perSubscriberQueue = createSubscriberBackpressure(init.queueMax ?? QUEUE_MAX_DEFAULT);
|
|
132
|
+
this.lastRevision = -1;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Fan one notification onto each subscriber's bounded queue (the primary's per-subscriber backpressure,
|
|
137
|
+
* `docs/reactivity.md` §Slow-subscriber backpressure). Returns only the subscribers whose queue dropped
|
|
138
|
+
* its oldest under pressure — a slow subscriber's drop never stalls fan-out to the rest of the cohort.
|
|
139
|
+
*/
|
|
140
|
+
enqueueForSubscribers(subscriberIds: Iterable<string>, n: NotificationV1): SubscriberEnqueueResult[] {
|
|
141
|
+
return this.perSubscriberQueue.fanOut(subscriberIds, n);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The checkpoint inherited from the outgoing tail on rotation, or `undefined` until a handoff lands. */
|
|
145
|
+
get inheritedCheckpoint(): CheckpointSummary | undefined {
|
|
146
|
+
return this.inherited;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Land an outgoing tail's rotation handoff checkpoint on this (new tail) cohort
|
|
151
|
+
* (`docs/reactivity.md` §Tail rotation step 5). Idempotent: a later handoff replaces only if it covers a
|
|
152
|
+
* higher revision, so a re-delivered or older handoff never rewinds the inherited span.
|
|
153
|
+
*/
|
|
154
|
+
adoptRotationCheckpoint(summary: CheckpointSummary): void {
|
|
155
|
+
if (this.inherited === undefined || summary.toRevision > this.inherited.toRevision) {
|
|
156
|
+
this.inherited = summary;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The current parent-checkpoint summary, or `undefined` until revisions have retired from the replay
|
|
162
|
+
* ring (`docs/reactivity.md` §Parent checkpoint summaries). Re-derived from the live rolling checkpoint
|
|
163
|
+
* on each read so it always reflects the latest retired range.
|
|
164
|
+
*/
|
|
165
|
+
get parentCheckpoint(): CheckpointSummary | undefined {
|
|
166
|
+
return this.checkpoint.summary();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Snapshot the gossipable slice for cohort replication. */
|
|
170
|
+
serializeGossip(): PushStateGossipV1 {
|
|
171
|
+
const out: PushStateGossipV1 = {
|
|
172
|
+
v: 1,
|
|
173
|
+
collectionId: this.collectionId,
|
|
174
|
+
topicId: this.topicId,
|
|
175
|
+
tailIdAtJoin: this.tailIdAtJoin,
|
|
176
|
+
childCohorts: this.childCohorts,
|
|
177
|
+
lastRevision: this.lastRevision,
|
|
178
|
+
replayBuffer: this.replayBuffer.serialize(),
|
|
179
|
+
dedupe: this.dedupe.serialize(),
|
|
180
|
+
};
|
|
181
|
+
if (this.parentCohort !== undefined) {
|
|
182
|
+
out.parentCohort = this.parentCohort;
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Merge a peer member's gossiped state so the whole cohort converges (any member can then serve). */
|
|
188
|
+
mergeGossip(g: PushStateGossipV1): void {
|
|
189
|
+
if (g.collectionId !== this.collectionId || g.topicId !== this.topicId) {
|
|
190
|
+
return; // gossip for a different collection/topic is not ours to merge
|
|
191
|
+
}
|
|
192
|
+
this.replayBuffer.merge(g.replayBuffer);
|
|
193
|
+
this.dedupe.merge(g.dedupe);
|
|
194
|
+
if (g.lastRevision > this.lastRevision) {
|
|
195
|
+
this.lastRevision = g.lastRevision;
|
|
196
|
+
}
|
|
197
|
+
if (g.childCohorts.length > this.childCohorts.length) {
|
|
198
|
+
this.childCohorts = g.childCohorts;
|
|
199
|
+
}
|
|
200
|
+
if (this.parentCohort === undefined && g.parentCohort !== undefined) {
|
|
201
|
+
this.parentCohort = g.parentCohort;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --- gossip codec (length-framed JSON, mirroring cohort-topic conventions) ---
|
|
207
|
+
// Generic structural primitives (fail/asObject/reqString/reqFiniteNumber/b64urlField) are imported from
|
|
208
|
+
// cohort-topic/wire/primitives.js; only `reqInt` stays local — it is a range-free integer check (no
|
|
209
|
+
// lower bound), unlike the shared `reqIntInRange`, so folding it in would change behavior.
|
|
210
|
+
|
|
211
|
+
function reqInt(value: number, key: string, what: string): number {
|
|
212
|
+
if (!Number.isInteger(value)) {
|
|
213
|
+
fail(`${what}: field "${key}" must be an integer, got ${value}`);
|
|
214
|
+
}
|
|
215
|
+
return value;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function validateCohortRef(value: unknown, what: string): CohortRef {
|
|
219
|
+
const obj = asObject(value, what);
|
|
220
|
+
const coord = b64urlField(reqString(obj, "coord", what), "coord", what);
|
|
221
|
+
if (obj["primary"] !== undefined) {
|
|
222
|
+
return { coord, primary: b64urlField(reqString(obj, "primary", what), "primary", what) };
|
|
223
|
+
}
|
|
224
|
+
return { coord };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function validateRevisionEntry(value: unknown, what: string): RevisionEntry {
|
|
228
|
+
const obj = asObject(value, what);
|
|
229
|
+
return {
|
|
230
|
+
revision: reqInt(reqFiniteNumber(obj, "revision", what), "revision", what),
|
|
231
|
+
payload: validateNotificationV1(obj["payload"]),
|
|
232
|
+
receivedAt: reqFiniteNumber(obj, "receivedAt", what),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function validateReplayBufferState(value: unknown, what: string): ReplayBufferStateV1 {
|
|
237
|
+
const obj = asObject(value, what);
|
|
238
|
+
const entries = obj["entries"];
|
|
239
|
+
if (!Array.isArray(entries)) {
|
|
240
|
+
fail(`${what}: field "entries" must be an array`);
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
capacity: reqInt(reqFiniteNumber(obj, "capacity", what), "capacity", what),
|
|
244
|
+
entries: entries.map((e) => validateRevisionEntry(e, `${what}.entry`)),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function validateDedupeState(value: unknown, what: string): DedupeStateV1 {
|
|
249
|
+
const obj = asObject(value, what);
|
|
250
|
+
const entries = obj["entries"];
|
|
251
|
+
if (!Array.isArray(entries)) {
|
|
252
|
+
fail(`${what}: field "entries" must be an array`);
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
highestRevision: reqInt(reqFiniteNumber(obj, "highestRevision", what), "highestRevision", what),
|
|
256
|
+
entries: entries.map((e) => {
|
|
257
|
+
const eo = asObject(e, `${what}.entry`);
|
|
258
|
+
return { key: reqString(eo, "key", `${what}.entry`), revision: reqInt(reqFiniteNumber(eo, "revision", `${what}.entry`), "revision", `${what}.entry`) };
|
|
259
|
+
}),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Narrow an already-parsed value to {@link PushStateGossipV1}, throwing on any defect. */
|
|
264
|
+
export function validatePushStateGossipV1(value: unknown): PushStateGossipV1 {
|
|
265
|
+
const what = "PushStateGossipV1";
|
|
266
|
+
const obj = asObject(value, what);
|
|
267
|
+
if (obj["v"] !== 1) {
|
|
268
|
+
fail(`${what}: expected v === 1, got ${JSON.stringify(obj["v"])}`);
|
|
269
|
+
}
|
|
270
|
+
const childCohorts = obj["childCohorts"];
|
|
271
|
+
if (!Array.isArray(childCohorts)) {
|
|
272
|
+
fail(`${what}: field "childCohorts" must be an array`);
|
|
273
|
+
}
|
|
274
|
+
const out: PushStateGossipV1 = {
|
|
275
|
+
v: 1,
|
|
276
|
+
collectionId: b64urlField(reqString(obj, "collectionId", what), "collectionId", what),
|
|
277
|
+
topicId: b64urlField(reqString(obj, "topicId", what), "topicId", what),
|
|
278
|
+
tailIdAtJoin: b64urlField(reqString(obj, "tailIdAtJoin", what), "tailIdAtJoin", what),
|
|
279
|
+
childCohorts: childCohorts.map((c) => validateCohortRef(c, `${what}.childCohort`)),
|
|
280
|
+
lastRevision: reqInt(reqFiniteNumber(obj, "lastRevision", what), "lastRevision", what),
|
|
281
|
+
replayBuffer: validateReplayBufferState(obj["replayBuffer"], `${what}.replayBuffer`),
|
|
282
|
+
dedupe: validateDedupeState(obj["dedupe"], `${what}.dedupe`),
|
|
283
|
+
};
|
|
284
|
+
if (obj["parentCohort"] !== undefined) {
|
|
285
|
+
out.parentCohort = validateCohortRef(obj["parentCohort"], `${what}.parentCohort`);
|
|
286
|
+
}
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Encode a {@link PushStateGossipV1} as a length-prefixed UTF-8 JSON frame. */
|
|
291
|
+
export function encodePushStateGossipV1(msg: PushStateGossipV1, maxMessageBytes: number = DEFAULT_MAX_MESSAGE_BYTES): Uint8Array {
|
|
292
|
+
return encodeCohortMessage(validatePushStateGossipV1(msg), maxMessageBytes);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Decode a length-prefixed {@link PushStateGossipV1} frame. */
|
|
296
|
+
export function decodePushStateGossipV1(bytes: Uint8Array, maxMessageBytes?: number): PushStateGossipV1 {
|
|
297
|
+
return validatePushStateGossipV1(decodeCohortMessage(bytes, maxMessageBytes));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Round-trip helper for `bytesToB64url` consumers wanting the raw coord bytes of a {@link CohortRef}. */
|
|
301
|
+
export function cohortRefCoordBytes(ref: CohortRef): Uint8Array {
|
|
302
|
+
return b64urlToBytes(ref.coord);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Build a {@link CohortRef} from raw coord (and optional primary) bytes. */
|
|
306
|
+
export function makeCohortRef(coord: Uint8Array, primary?: Uint8Array): CohortRef {
|
|
307
|
+
return primary !== undefined
|
|
308
|
+
? { coord: bytesToB64url(coord), primary: bytesToB64url(primary) }
|
|
309
|
+
: { coord: bytesToB64url(coord) };
|
|
310
|
+
}
|