@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.
- 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
package/src/reactivity/resume.ts
CHANGED
|
@@ -1,549 +1,549 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reactivity — Resume RPC: classification, serving, and subscriber-side apply
|
|
3
|
-
* (`docs/reactivity.md` §Resume, §Parent checkpoint summaries, §Wire formats Resume).
|
|
4
|
-
*
|
|
5
|
-
* A subscriber waking from sleep sends one {@link ResumeV1} to its cached primary (or any cohort member).
|
|
6
|
-
* The serving cohort classifies the request against its **stacked** recovery windows — the `W`-deep replay
|
|
7
|
-
* ring sits on top of the `W_checkpoint`-span parent checkpoint immediately below it, so a single round
|
|
8
|
-
* trip recovers `W + W_checkpoint` revisions — and answers with one of four {@link ResumeReplyV1} variants
|
|
9
|
-
* (`docs/reactivity.md` §Replay window, the authoritative stacked semantics):
|
|
10
|
-
*
|
|
11
|
-
* - **Backfill** — `fromRevision` is within the replay ring (`fromRevision ≥ ringLow`). Returns the
|
|
12
|
-
* `[fromRevision, currentRevision]` slice + `currentRevision`. One RT.
|
|
13
|
-
* - **CheckpointWindow** — `fromRevision` is below the ring but within the parent checkpoint span
|
|
14
|
-
* (`chain low ≤ fromRevision < ringLow`). Returns an ordered, contiguous **chain** of
|
|
15
|
-
* {@link CheckpointSummary}s (`checkpoints`, low→high) + the ring's `recentEntries`. The subscriber
|
|
16
|
-
* verifies every link's endpoints, applies each link's merged digest, advances its contiguity head past
|
|
17
|
-
* the whole chain, then replays the recent entries deduped against `lastRevision`. One RT. The chain is a
|
|
18
|
-
* single link in steady state; a new tail that took over across a rotation serves a **two-link**
|
|
19
|
-
* `[inherited, rolling]` chain when a `fromRevision` below both the ring and the rolling checkpoint falls
|
|
20
|
-
* inside the inherited handoff window — so the full stacked range recovers in one reply regardless of
|
|
21
|
-
* where the new tail's own rolling checkpoint has formed (`docs/reactivity.md` §Tail rotation step 5 —
|
|
22
|
-
* "the new tail holds the old checkpoint").
|
|
23
|
-
* - **OutOfWindow** — older than even the checkpoint. Returns `currentTailId` + `currentRevision`; the
|
|
24
|
-
* subscriber falls back to a chain read, then a fresh subscribe.
|
|
25
|
-
* - **TailRotated** — the request's `latestKnownTailId` does not match the cohort's current tail (the
|
|
26
|
-
* subscriber slept across a rotation). Returns `newTailId` + `newRevisionAtRotation`; the subscriber
|
|
27
|
-
* re-registers under the new tail. This is checked **first**: a stale tail means the whole tree moved,
|
|
28
|
-
* so the lag-against-windows classification is moot. The rotation *lifecycle* is owned by
|
|
29
|
-
* [reactivity-rotation-backpressure-policy]; this module only **detects** the stale tail and emits the
|
|
30
|
-
* reply so resume composes with rotation.
|
|
31
|
-
*
|
|
32
|
-
* Wire conventions match the rest of reactivity: JSON, byte fields base64url, unix-ms timestamps, `v: 1`,
|
|
33
|
-
* per-message structural validation on decode. `ResumeV1` is signed by the subscriber's peer key
|
|
34
|
-
* (`signature`); replay protection rides the request envelope as elsewhere in the substrate.
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
import {
|
|
38
|
-
decodeCohortMessage,
|
|
39
|
-
encodeCohortMessage,
|
|
40
|
-
DEFAULT_MAX_MESSAGE_BYTES,
|
|
41
|
-
} from "../cohort-topic/wire/codec.js";
|
|
42
|
-
import {
|
|
43
|
-
asObject,
|
|
44
|
-
b64urlField,
|
|
45
|
-
failWire,
|
|
46
|
-
reqFiniteNumber,
|
|
47
|
-
reqIntInRange,
|
|
48
|
-
reqString,
|
|
49
|
-
requireV1,
|
|
50
|
-
} from "./wire-validate.js";
|
|
51
|
-
import { validateCheckpointSummary, verifyCheckpointEndpoints, type CheckpointSummary, type RollingCheckpoint } from "./checkpoint.js";
|
|
52
|
-
import { checkpointCovers } from "./rotation.js";
|
|
53
|
-
import type { ReplayBuffer } from "./replay-buffer.js";
|
|
54
|
-
import type { ReactivitySubscriber } from "./subscriber.js";
|
|
55
|
-
import type { NotificationVerifier } from "./verify.js";
|
|
56
|
-
import { validateNotificationArray, type NotificationV1 } from "./wire.js";
|
|
57
|
-
|
|
58
|
-
/** The four resume classifications (`docs/reactivity.md` §Resume). */
|
|
59
|
-
export type ResumeResult = "backfill" | "checkpoint_window" | "out_of_window" | "tail_rotated";
|
|
60
|
-
|
|
61
|
-
/** A subscriber's resume request after waking from sleep. */
|
|
62
|
-
export interface ResumeV1 {
|
|
63
|
-
v: 1;
|
|
64
|
-
/** Collection id, base64url. */
|
|
65
|
-
collectionId: string;
|
|
66
|
-
/** First revision the subscriber still needs (`lastRevision + 1`). */
|
|
67
|
-
fromRevision: number;
|
|
68
|
-
/** Tail block id the subscriber believes is current, base64url (the stale-tail detector). */
|
|
69
|
-
latestKnownTailId: string;
|
|
70
|
-
/** The subscriber's ring coordinate, base64url (for the cohort to route the reply). */
|
|
71
|
-
subscriberCoord: string;
|
|
72
|
-
/** Unix ms. */
|
|
73
|
-
timestamp: number;
|
|
74
|
-
/** Subscriber peer-key signature over the request. */
|
|
75
|
-
signature: string;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** The cohort's classified reply. Fields are populated per the `result` discriminant. */
|
|
79
|
-
export interface ResumeReplyV1 {
|
|
80
|
-
v: 1;
|
|
81
|
-
result: ResumeResult;
|
|
82
|
-
// --- backfill ---
|
|
83
|
-
/** The `[fromRevision, currentRevision]` slice held in the replay ring (backfill). */
|
|
84
|
-
entries?: NotificationV1[];
|
|
85
|
-
/** Highest committed revision the serving cohort knows (backfill / checkpoint_window / out_of_window). */
|
|
86
|
-
currentRevision?: number;
|
|
87
|
-
// --- checkpoint_window ---
|
|
88
|
-
/**
|
|
89
|
-
* The parent-checkpoint chain spanning `[chain[0].fromRevision, ringLow − 1]` (checkpoint_window), ordered
|
|
90
|
-
* low→high and **contiguous**: each `checkpoints[i].fromRevision === checkpoints[i-1].toRevision + 1`. A
|
|
91
|
-
* single link in steady state; a cross-rotation resume carries the two-link `[inherited, rolling]` bridge.
|
|
92
|
-
* The subscriber verifies and applies every link in sequence (see {@link applyResumeReply}).
|
|
93
|
-
*/
|
|
94
|
-
checkpoints?: CheckpointSummary[];
|
|
95
|
-
/** The replay ring's entries, replayed deduped against `lastRevision` after the digests apply. */
|
|
96
|
-
recentEntries?: NotificationV1[];
|
|
97
|
-
// --- out_of_window ---
|
|
98
|
-
/** Current tail block id, base64url — the subscriber re-subscribes after a chain read (out_of_window). */
|
|
99
|
-
currentTailId?: string;
|
|
100
|
-
// --- tail_rotated ---
|
|
101
|
-
/** New tail block id the topic rotated to, base64url (tail_rotated). */
|
|
102
|
-
newTailId?: string;
|
|
103
|
-
/** Revision at which the rotation took effect (tail_rotated). */
|
|
104
|
-
newRevisionAtRotation?: number;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// --- validation ---
|
|
108
|
-
|
|
109
|
-
/** Narrow an already-parsed value to {@link ResumeV1}, throwing on any defect. */
|
|
110
|
-
export function validateResumeV1(value: unknown): ResumeV1 {
|
|
111
|
-
const what = "ResumeV1";
|
|
112
|
-
const obj = asObject(value, what);
|
|
113
|
-
requireV1(obj, what);
|
|
114
|
-
return {
|
|
115
|
-
v: 1,
|
|
116
|
-
collectionId: b64urlField(reqString(obj, "collectionId", what), "collectionId", what),
|
|
117
|
-
fromRevision: reqIntInRange(obj, "fromRevision", what, 0),
|
|
118
|
-
latestKnownTailId: b64urlField(reqString(obj, "latestKnownTailId", what), "latestKnownTailId", what),
|
|
119
|
-
subscriberCoord: b64urlField(reqString(obj, "subscriberCoord", what), "subscriberCoord", what),
|
|
120
|
-
timestamp: reqFiniteNumber(obj, "timestamp", what),
|
|
121
|
-
signature: b64urlField(reqString(obj, "signature", what), "signature", what),
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Narrow an already-parsed value to {@link ResumeReplyV1}, throwing on any defect. */
|
|
126
|
-
export function validateResumeReplyV1(value: unknown): ResumeReplyV1 {
|
|
127
|
-
const what = "ResumeReplyV1";
|
|
128
|
-
const obj = asObject(value, what);
|
|
129
|
-
requireV1(obj, what);
|
|
130
|
-
const result = obj["result"];
|
|
131
|
-
if (result !== "backfill" && result !== "checkpoint_window" && result !== "out_of_window" && result !== "tail_rotated") {
|
|
132
|
-
failWire(`${what}: field "result" must be one of backfill|checkpoint_window|out_of_window|tail_rotated`);
|
|
133
|
-
}
|
|
134
|
-
const out: ResumeReplyV1 = { v: 1, result };
|
|
135
|
-
switch (result) {
|
|
136
|
-
case "backfill": {
|
|
137
|
-
out.entries = validateNotificationArray(obj["entries"], what);
|
|
138
|
-
out.currentRevision = reqIntInRange(obj, "currentRevision", what, 0);
|
|
139
|
-
break;
|
|
140
|
-
}
|
|
141
|
-
case "checkpoint_window": {
|
|
142
|
-
out.checkpoints = validateCheckpointChain(obj["checkpoints"], `${what}.checkpoints`);
|
|
143
|
-
out.recentEntries = validateNotificationArray(obj["recentEntries"], what);
|
|
144
|
-
if (obj["currentRevision"] !== undefined) {
|
|
145
|
-
out.currentRevision = reqIntInRange(obj, "currentRevision", what, 0);
|
|
146
|
-
}
|
|
147
|
-
break;
|
|
148
|
-
}
|
|
149
|
-
case "out_of_window": {
|
|
150
|
-
out.currentTailId = b64urlField(reqString(obj, "currentTailId", what), "currentTailId", what);
|
|
151
|
-
out.currentRevision = reqIntInRange(obj, "currentRevision", what, 0);
|
|
152
|
-
break;
|
|
153
|
-
}
|
|
154
|
-
case "tail_rotated": {
|
|
155
|
-
out.newTailId = b64urlField(reqString(obj, "newTailId", what), "newTailId", what);
|
|
156
|
-
out.newRevisionAtRotation = reqIntInRange(obj, "newRevisionAtRotation", what, 0);
|
|
157
|
-
break;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
return out;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Validate a `checkpoint_window` reply's checkpoint chain: a **non-empty**, **ascending**, internally
|
|
165
|
-
* **contiguous** list of {@link CheckpointSummary}s sharing one `collectionId`. Each link is validated via
|
|
166
|
-
* {@link validateCheckpointSummary}, and for `i ≥ 1` the chain must satisfy
|
|
167
|
-
* `checkpoints[i].fromRevision === checkpoints[i-1].toRevision + 1` (a gapped, overlapping, or misordered
|
|
168
|
-
* chain is rejected). Runs on decode, so a malformed chain never reaches {@link applyResumeReply} over the
|
|
169
|
-
* wire.
|
|
170
|
-
*/
|
|
171
|
-
function validateCheckpointChain(value: unknown, what: string): CheckpointSummary[] {
|
|
172
|
-
if (!Array.isArray(value) || value.length === 0) {
|
|
173
|
-
failWire(`${what}: must be a non-empty array of checkpoint summaries`);
|
|
174
|
-
}
|
|
175
|
-
const chain = value.map((el, i) => validateCheckpointSummary(el, `${what}[${i}]`));
|
|
176
|
-
for (let i = 0; i < chain.length; i++) {
|
|
177
|
-
if (chain[i]!.collectionId !== chain[0]!.collectionId) {
|
|
178
|
-
failWire(`${what}: all checkpoints must share one collectionId`);
|
|
179
|
-
}
|
|
180
|
-
if (i >= 1 && chain[i]!.fromRevision !== chain[i - 1]!.toRevision + 1) {
|
|
181
|
-
failWire(`${what}: chain must be ascending and contiguous (checkpoints[${i}].fromRevision must equal checkpoints[${i - 1}].toRevision + 1)`);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
return chain;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// --- canonical signing payload ---
|
|
188
|
-
|
|
189
|
-
const utf8 = new TextEncoder();
|
|
190
|
-
|
|
191
|
-
/** A {@link ResumeV1} minus its `signature` — the canonical bytes the subscriber peer-key-signs. */
|
|
192
|
-
export type ResumeSignable = Omit<ResumeV1, "signature">;
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Canonical signed byte image of a {@link ResumeV1} (every field except `signature`). Mirrors
|
|
196
|
-
* {@link import("./backfill.js").backfillSigningPayload}: an explicitly-ordered, type-tagged JSON array
|
|
197
|
-
* encoded as UTF-8. The `"ResumeV1"` tag means a resume image can never collide with the `"BackfillV1"`
|
|
198
|
-
* image. `subscriberCoord` is part of the image, so a verifier recomputes over whatever coordinate the
|
|
199
|
-
* request carries — the field order is the byte-for-byte contract between signer and verifier.
|
|
200
|
-
*/
|
|
201
|
-
export function resumeSigningPayload(body: ResumeSignable): Uint8Array {
|
|
202
|
-
return utf8.encode(JSON.stringify([
|
|
203
|
-
"ResumeV1",
|
|
204
|
-
body.v,
|
|
205
|
-
body.collectionId,
|
|
206
|
-
body.fromRevision,
|
|
207
|
-
body.latestKnownTailId,
|
|
208
|
-
body.subscriberCoord,
|
|
209
|
-
body.timestamp,
|
|
210
|
-
]));
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// --- codecs (length-framed JSON, mirroring the rest of reactivity) ---
|
|
214
|
-
|
|
215
|
-
/** Encode a {@link ResumeV1} as a length-prefixed UTF-8 JSON frame. */
|
|
216
|
-
export function encodeResumeV1(msg: ResumeV1, maxMessageBytes: number = DEFAULT_MAX_MESSAGE_BYTES): Uint8Array {
|
|
217
|
-
return encodeCohortMessage(validateResumeV1(msg), maxMessageBytes);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/** Decode a length-prefixed {@link ResumeV1} frame. */
|
|
221
|
-
export function decodeResumeV1(bytes: Uint8Array, maxMessageBytes?: number): ResumeV1 {
|
|
222
|
-
return validateResumeV1(decodeCohortMessage(bytes, maxMessageBytes));
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/** Encode a {@link ResumeReplyV1} as a length-prefixed UTF-8 JSON frame. */
|
|
226
|
-
export function encodeResumeReplyV1(msg: ResumeReplyV1, maxMessageBytes: number = DEFAULT_MAX_MESSAGE_BYTES): Uint8Array {
|
|
227
|
-
return encodeCohortMessage(validateResumeReplyV1(msg), maxMessageBytes);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/** Decode a length-prefixed {@link ResumeReplyV1} frame. */
|
|
231
|
-
export function decodeResumeReplyV1(bytes: Uint8Array, maxMessageBytes?: number): ResumeReplyV1 {
|
|
232
|
-
return validateResumeReplyV1(decodeCohortMessage(bytes, maxMessageBytes));
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// --- serving-cohort classification ---
|
|
236
|
-
|
|
237
|
-
/** The rolling checkpoint's summary; non-undefined because the caller already proved it abuts the ring (so non-empty). */
|
|
238
|
-
function requireRollingSummary(rolling: RollingCheckpoint): CheckpointSummary {
|
|
239
|
-
const summary = rolling.summary();
|
|
240
|
-
if (summary === undefined) {
|
|
241
|
-
throw new Error("resumeCheckpointChain: rolling checkpoint abuts the ring yet produced no summary (invariant violation)");
|
|
242
|
-
}
|
|
243
|
-
return summary;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Build the served checkpoint chain for a `checkpoint_window` resume: the **shortest** contiguous low→high
|
|
248
|
-
* chain of {@link CheckpointSummary}s that both covers `fromRevision` and **abuts the ring's low edge**
|
|
249
|
-
* (`top.toRevision === ringLow − 1`), or `undefined` when no gap-free chain exists. Shared by
|
|
250
|
-
* {@link classifyResume} and {@link serveResume} so they cannot disagree about the served shape.
|
|
251
|
-
*
|
|
252
|
-
* Cases are evaluated in precedence order (this ordering preserves every existing classification test):
|
|
253
|
-
*
|
|
254
|
-
* - **A — rolling wins.** The rolling checkpoint abuts the ring (it is fed by ring eviction, so it always
|
|
255
|
-
* does) and covers `fromRevision`: the fresher, narrower single link `[rolling]`.
|
|
256
|
-
* - **B1 — inherited abuts the ring directly.** No new-tail rolling checkpoint has formed between the
|
|
257
|
-
* inherited handoff and the ring yet (the `12.51` abut case): the single link `[inherited]`.
|
|
258
|
-
* - **B2 — the bridge.** The inherited handoff abuts the new tail's rolling checkpoint, which abuts the
|
|
259
|
-
* ring: the two-link `[inherited, rolling]` chain spans inherited → rolling → ring with no gap.
|
|
260
|
-
*
|
|
261
|
-
* Any other shape (a gap below `fromRevision`, or a gap between the windows) returns `undefined` → the
|
|
262
|
-
* request falls to `out_of_window` (an honest chain read). `ringLow === undefined` (no live ring to abut /
|
|
263
|
-
* no `recentEntries`) also returns `undefined`.
|
|
264
|
-
*/
|
|
265
|
-
function resumeCheckpointChain(
|
|
266
|
-
fromRevision: number,
|
|
267
|
-
ringLow: number | undefined,
|
|
268
|
-
rolling: RollingCheckpoint | undefined,
|
|
269
|
-
inherited: CheckpointSummary | undefined,
|
|
270
|
-
): CheckpointSummary[] | undefined {
|
|
271
|
-
if (ringLow === undefined) {
|
|
272
|
-
return undefined;
|
|
273
|
-
}
|
|
274
|
-
const ringAbut = ringLow - 1;
|
|
275
|
-
|
|
276
|
-
// Case A — the rolling checkpoint wins (fresher, narrower). `toRevision === ringAbut` implies it is
|
|
277
|
-
// non-empty, so the summary is defined (asserted in requireRollingSummary).
|
|
278
|
-
if (rolling !== undefined && rolling.toRevision === ringAbut && rolling.covers(fromRevision)) {
|
|
279
|
-
return [requireRollingSummary(rolling)];
|
|
280
|
-
}
|
|
281
|
-
// Case B1 — the inherited handoff abuts the ring directly (the 12.51 abut case): a single link.
|
|
282
|
-
if (inherited !== undefined && inherited.toRevision === ringAbut && checkpointCovers(inherited, fromRevision)) {
|
|
283
|
-
return [inherited];
|
|
284
|
-
}
|
|
285
|
-
// Case B2 — the bridge: inherited abuts the rolling checkpoint, which abuts the ring. The two-link chain
|
|
286
|
-
// carries inherited → rolling → ring with no gap, and each link keeps its own already-correct merged
|
|
287
|
-
// digest (nothing is re-folded, so a per-collection override fold composes trivially).
|
|
288
|
-
if (
|
|
289
|
-
inherited !== undefined &&
|
|
290
|
-
rolling !== undefined &&
|
|
291
|
-
rolling.toRevision === ringAbut &&
|
|
292
|
-
inherited.toRevision + 1 === rolling.fromRevision &&
|
|
293
|
-
checkpointCovers(inherited, fromRevision)
|
|
294
|
-
) {
|
|
295
|
-
return [inherited, requireRollingSummary(rolling)];
|
|
296
|
-
}
|
|
297
|
-
return undefined;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Classify a resume request against the serving cohort's stacked windows. The decision order is:
|
|
302
|
-
*
|
|
303
|
-
* 1. **tail_rotated** — `req.latestKnownTailId !== currentTailId`. A stale tail means the tree migrated,
|
|
304
|
-
* so the lag classification below is moot.
|
|
305
|
-
* 2. **backfill** — `fromRevision` is at/above the replay ring's low edge (within, or already current).
|
|
306
|
-
* 3. **checkpoint_window** — below the ring but a gap-free {@link resumeCheckpointChain} covers it: the
|
|
307
|
-
* rolling checkpoint (steady state), the inherited cross-rotation handoff (`docs/reactivity.md` §Tail
|
|
308
|
-
* rotation step 5 — "the new tail holds the old checkpoint"), or the two-link `[inherited, rolling]`
|
|
309
|
-
* bridge spanning both when the new tail's own rolling checkpoint has formed between the handoff and the
|
|
310
|
-
* ring.
|
|
311
|
-
* 4. **out_of_window** — older than even the deepest window, or a gap leaves no contiguous chain to the
|
|
312
|
-
* ring (a chain read), or every window is empty/absent.
|
|
313
|
-
*/
|
|
314
|
-
export function classifyResume(
|
|
315
|
-
req: ResumeV1,
|
|
316
|
-
buffer: ReplayBuffer,
|
|
317
|
-
checkpoint: RollingCheckpoint | undefined,
|
|
318
|
-
currentTailId: string,
|
|
319
|
-
inherited?: CheckpointSummary,
|
|
320
|
-
): ResumeResult {
|
|
321
|
-
if (req.latestKnownTailId !== currentTailId) {
|
|
322
|
-
return "tail_rotated";
|
|
323
|
-
}
|
|
324
|
-
const low = buffer.lowRevision;
|
|
325
|
-
if (low !== undefined && req.fromRevision >= low) {
|
|
326
|
-
return "backfill";
|
|
327
|
-
}
|
|
328
|
-
return resumeCheckpointChain(req.fromRevision, low, checkpoint, inherited) !== undefined
|
|
329
|
-
? "checkpoint_window"
|
|
330
|
-
: "out_of_window";
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/** Construction inputs for {@link serveResume}. */
|
|
334
|
-
export interface ResumeServingDeps {
|
|
335
|
-
/** The cohort's replay ring (the live `PushState.replayBuffer`). */
|
|
336
|
-
readonly buffer: ReplayBuffer;
|
|
337
|
-
/** The cohort's rolling parent checkpoint (the live `PushState.checkpoint`). */
|
|
338
|
-
readonly checkpoint?: RollingCheckpoint;
|
|
339
|
-
/**
|
|
340
|
-
* The checkpoint migrated from the **outgoing** tail when this cohort became the new tail across a
|
|
341
|
-
* rotation (the live `PushState.inheritedCheckpoint`). Consulted when the rolling {@link checkpoint} does
|
|
342
|
-
* not cover `fromRevision`: a resume whose span crosses the rotation is answered from the inherited window
|
|
343
|
-
* — either alone (it abuts the ring directly) or as the lower link of the two-link `[inherited, rolling]`
|
|
344
|
-
* bridge when the new tail's own rolling checkpoint has formed between the handoff and the ring (see
|
|
345
|
-
* {@link resumeCheckpointChain}) — instead of falling to `out_of_window` (`docs/reactivity.md` §Tail
|
|
346
|
-
* rotation step 5).
|
|
347
|
-
*/
|
|
348
|
-
readonly inheritedCheckpoint?: CheckpointSummary;
|
|
349
|
-
/** The cohort's current tail block id, base64url. */
|
|
350
|
-
readonly currentTailId: string;
|
|
351
|
-
/** Highest committed revision the cohort knows (the live `PushState.lastRevision`). */
|
|
352
|
-
readonly currentRevision: number;
|
|
353
|
-
/** Revision the current tail became effective (for a tail_rotated reply); defaults to `currentRevision`. */
|
|
354
|
-
readonly rotationRevision?: number;
|
|
355
|
-
/** Collection this cohort serves, base64url — a foreign-collection request is rejected. */
|
|
356
|
-
readonly expectedCollectionId: string;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* Serve a {@link ResumeV1} from a cohort's stacked windows, producing the classified {@link ResumeReplyV1}.
|
|
361
|
-
* Pure over the supplied snapshot — no I/O, no clock. A request whose `collectionId` does not match
|
|
362
|
-
* `expectedCollectionId` is rejected (a resume is collection-scoped).
|
|
363
|
-
*/
|
|
364
|
-
export function serveResume(req: ResumeV1, deps: ResumeServingDeps): ResumeReplyV1 {
|
|
365
|
-
if (req.collectionId !== deps.expectedCollectionId) {
|
|
366
|
-
failWire(`serveResume: request collectionId does not match this cohort's collection`);
|
|
367
|
-
}
|
|
368
|
-
const result = classifyResume(req, deps.buffer, deps.checkpoint, deps.currentTailId, deps.inheritedCheckpoint);
|
|
369
|
-
switch (result) {
|
|
370
|
-
case "backfill": {
|
|
371
|
-
const high = deps.buffer.highRevision ?? deps.currentRevision;
|
|
372
|
-
const entries = deps.buffer.range(req.fromRevision, high).map((e) => e.payload);
|
|
373
|
-
return { v: 1, result, entries, currentRevision: deps.currentRevision };
|
|
374
|
-
}
|
|
375
|
-
case "checkpoint_window": {
|
|
376
|
-
// The contiguous chain the helper built — rolling alone, inherited alone, or the two-link bridge.
|
|
377
|
-
// classifyResume returned checkpoint_window iff this chain is present, so it is non-undefined here
|
|
378
|
-
// (the two calls share inputs, so they cannot disagree).
|
|
379
|
-
const chain = resumeCheckpointChain(req.fromRevision, deps.buffer.lowRevision, deps.checkpoint, deps.inheritedCheckpoint);
|
|
380
|
-
if (chain === undefined) {
|
|
381
|
-
throw new Error("serveResume: checkpoint_window classification produced no checkpoint chain (invariant violation)");
|
|
382
|
-
}
|
|
383
|
-
const recentEntries = deps.buffer.entries().map((e) => e.payload);
|
|
384
|
-
return { v: 1, result, checkpoints: chain, recentEntries, currentRevision: deps.currentRevision };
|
|
385
|
-
}
|
|
386
|
-
case "out_of_window":
|
|
387
|
-
return { v: 1, result, currentTailId: deps.currentTailId, currentRevision: deps.currentRevision };
|
|
388
|
-
case "tail_rotated":
|
|
389
|
-
return {
|
|
390
|
-
v: 1,
|
|
391
|
-
result,
|
|
392
|
-
newTailId: deps.currentTailId,
|
|
393
|
-
newRevisionAtRotation: deps.rotationRevision ?? deps.currentRevision,
|
|
394
|
-
};
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
// --- subscriber-side apply ---
|
|
399
|
-
|
|
400
|
-
/** The outcome of applying a {@link ResumeReplyV1} subscriber-side. */
|
|
401
|
-
export type ResumeApplyOutcome =
|
|
402
|
-
/** Backfill entries were replayed through the delivery path; the subscriber is current. */
|
|
403
|
-
| "backfilled"
|
|
404
|
-
/** Checkpoint endpoints verified, digest applied, recent entries replayed; the subscriber is current. */
|
|
405
|
-
| "checkpoint_applied"
|
|
406
|
-
/** Checkpoint bracketing endpoints failed to verify (forged/tampered) — the subscriber must chain-read. */
|
|
407
|
-
| "checkpoint_untrusted"
|
|
408
|
-
/** Out of every window — the subscriber must chain-read then re-subscribe. */
|
|
409
|
-
| "out_of_window"
|
|
410
|
-
/** Tail rotated — the subscriber must re-register under the new tail. */
|
|
411
|
-
| "tail_rotated";
|
|
412
|
-
|
|
413
|
-
/** Sinks for {@link applyResumeReply} — each non-backfill variant escalates to the application. */
|
|
414
|
-
export interface ResumeApplyDeps {
|
|
415
|
-
/** Feeds replayed entries through the verify → contiguity → deliver path (closing the gap, deduping). */
|
|
416
|
-
readonly subscriber: ReactivitySubscriber;
|
|
417
|
-
/** Verifies a checkpoint's bracketing endpoints are real committed revisions. */
|
|
418
|
-
readonly verifier: NotificationVerifier;
|
|
419
|
-
/** Apply a verified checkpoint's merged digest (e.g. invalidate the changed keys). */
|
|
420
|
-
readonly onCheckpointDigest?: (summary: CheckpointSummary) => void;
|
|
421
|
-
/** Fall back to a chain read + fresh subscribe (out_of_window, or an untrusted checkpoint). */
|
|
422
|
-
readonly onChainRead?: (currentTailId: string | undefined, currentRevision: number | undefined) => void;
|
|
423
|
-
/** Re-register under the rotated tail (tail_rotated). */
|
|
424
|
-
readonly onTailRotated?: (newTailId: string, newRevisionAtRotation: number) => void;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
/**
|
|
428
|
-
* Apply a {@link ResumeReplyV1} on the subscriber side, returning the {@link ResumeApplyOutcome}. Backfill
|
|
429
|
-
* and checkpoint-window entries re-enter through {@link ReactivitySubscriber.onNotification} (so they are
|
|
430
|
-
* verified, contiguity-checked, and deduped exactly like live notifications); the other variants escalate
|
|
431
|
-
* to the application via the supplied sinks. A checkpoint whose bracketing endpoints do not verify is
|
|
432
|
-
* **not** applied — the subscriber chain-reads instead (a forged checkpoint must never advance state).
|
|
433
|
-
*/
|
|
434
|
-
export async function applyResumeReply(reply: ResumeReplyV1, deps: ResumeApplyDeps): Promise<ResumeApplyOutcome> {
|
|
435
|
-
switch (reply.result) {
|
|
436
|
-
case "backfill": {
|
|
437
|
-
for (const n of reply.entries ?? []) {
|
|
438
|
-
await deps.subscriber.onNotification(n);
|
|
439
|
-
}
|
|
440
|
-
return "backfilled";
|
|
441
|
-
}
|
|
442
|
-
case "checkpoint_window": {
|
|
443
|
-
const summaries = reply.checkpoints;
|
|
444
|
-
if (summaries === undefined || summaries.length === 0) {
|
|
445
|
-
failWire(`applyResumeReply: checkpoint_window reply is missing its checkpoint chain`);
|
|
446
|
-
}
|
|
447
|
-
// Low-edge guard on the **lowest** link: endpoint verification proves each link's bounds are *real*
|
|
448
|
-
// committed revisions, but not that the chain connects to the subscriber's contiguity head. If the
|
|
449
|
-
// lowest `fromRevision` sits above `lastRevision + 1` there is an un-summarized gap below the chain;
|
|
450
|
-
// rebaselining would silently skip those revisions. The honest cohort never sends such a reply
|
|
451
|
-
// (`classifyResume` only picks `checkpoint_window` when the chain covers `lastRevision + 1`), so a
|
|
452
|
-
// non-abutting low edge is a forged/buggy reply — chain-read rather than advance past a gap.
|
|
453
|
-
if (summaries[0]!.fromRevision > deps.subscriber.lastRevision + 1) {
|
|
454
|
-
deps.onChainRead?.(reply.currentTailId, reply.currentRevision);
|
|
455
|
-
return "checkpoint_untrusted";
|
|
456
|
-
}
|
|
457
|
-
// Intra-chain contiguity (defense in depth: the codec already checks every *decoded* reply, but an
|
|
458
|
-
// in-process reply may bypass the codec). A gap/overlap between links would skip or double revisions.
|
|
459
|
-
for (let i = 1; i < summaries.length; i++) {
|
|
460
|
-
if (summaries[i]!.fromRevision !== summaries[i - 1]!.toRevision + 1) {
|
|
461
|
-
deps.onChainRead?.(reply.currentTailId, reply.currentRevision);
|
|
462
|
-
return "checkpoint_untrusted";
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
// Verify EVERY link's bracketing endpoints **before applying anything** — a single forged link kills
|
|
466
|
-
// the whole reply, so nothing is delivered and `lastRevision` is unchanged (no partial advance).
|
|
467
|
-
for (const summary of summaries) {
|
|
468
|
-
const verdict = await verifyCheckpointEndpoints(summary, deps.verifier);
|
|
469
|
-
if (verdict !== "verified") {
|
|
470
|
-
deps.onChainRead?.(reply.currentTailId, reply.currentRevision);
|
|
471
|
-
return "checkpoint_untrusted";
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
// Apply in order: the application sees each link's merged-digest hint, then the contiguity head
|
|
475
|
-
// advances past that link. `rebaseline` is monotone, so the head lands at the top link's
|
|
476
|
-
// `toRevision`; the recent entries (immediately above it) then replay gap-free — any recent entry
|
|
477
|
-
// at/below the head dedupes (`docs/reactivity.md` §Parent checkpoint summaries).
|
|
478
|
-
for (const summary of summaries) {
|
|
479
|
-
deps.onCheckpointDigest?.(summary);
|
|
480
|
-
deps.subscriber.rebaseline(summary.toRevision);
|
|
481
|
-
}
|
|
482
|
-
for (const n of reply.recentEntries ?? []) {
|
|
483
|
-
await deps.subscriber.onNotification(n);
|
|
484
|
-
}
|
|
485
|
-
return "checkpoint_applied";
|
|
486
|
-
}
|
|
487
|
-
case "out_of_window": {
|
|
488
|
-
deps.onChainRead?.(reply.currentTailId, reply.currentRevision);
|
|
489
|
-
return "out_of_window";
|
|
490
|
-
}
|
|
491
|
-
case "tail_rotated": {
|
|
492
|
-
deps.onTailRotated?.(reply.newTailId ?? "", reply.newRevisionAtRotation ?? 0);
|
|
493
|
-
return "tail_rotated";
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
// --- Edge sticky cohort-hint cache (one-RT resume after a flap) ---
|
|
499
|
-
|
|
500
|
-
/**
|
|
501
|
-
* A cached cohort hint for fast reactivity re-attach (`docs/reactivity.md` §Edge profile: "`cohortHint`
|
|
502
|
-
* is sticky-cached across reconnects so brief network flaps don't trigger re-walk"). Distinct from the
|
|
503
|
-
* cohort-topic `CohortHint` (the full resolved cohort) — this is the minimal sticky-cached slice the
|
|
504
|
-
* subscriber dials a resume at directly. All ids base64url.
|
|
505
|
-
*/
|
|
506
|
-
export interface ReactivityCohortHint {
|
|
507
|
-
/** The collection's tail-anchored topic id at cache time. */
|
|
508
|
-
readonly topicId: string;
|
|
509
|
-
/** The cached serving primary. */
|
|
510
|
-
readonly primary: string;
|
|
511
|
-
/** The cohort member set for fast re-attach. */
|
|
512
|
-
readonly cohortHint: readonly string[];
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
/**
|
|
516
|
-
* A sticky per-collection cache of the last-known serving cohort, keyed by collection id. **Sticky** means
|
|
517
|
-
* it survives reconnects — a brief network flap reuses the cached primary so resume is one round trip
|
|
518
|
-
* instead of a re-walk from `d_max`. It is invalidated only on an explicit signal (a `TailRotated` reply,
|
|
519
|
-
* or a confirmed-stale primary), never merely on a transient failure.
|
|
520
|
-
*/
|
|
521
|
-
export interface StickyCohortHintCache {
|
|
522
|
-
/** The cached hint for `collectionId`, or `undefined` if none. */
|
|
523
|
-
get(collectionId: string): ReactivityCohortHint | undefined;
|
|
524
|
-
/** Cache (or refresh) the hint for `collectionId`. */
|
|
525
|
-
set(collectionId: string, hint: ReactivityCohortHint): void;
|
|
526
|
-
/** Drop the cached hint for `collectionId` (on rotation / confirmed staleness). */
|
|
527
|
-
invalidate(collectionId: string): void;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
class MapStickyCohortHintCache implements StickyCohortHintCache {
|
|
531
|
-
private readonly byCollection = new Map<string, ReactivityCohortHint>();
|
|
532
|
-
|
|
533
|
-
get(collectionId: string): ReactivityCohortHint | undefined {
|
|
534
|
-
return this.byCollection.get(collectionId);
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
set(collectionId: string, hint: ReactivityCohortHint): void {
|
|
538
|
-
this.byCollection.set(collectionId, hint);
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
invalidate(collectionId: string): void {
|
|
542
|
-
this.byCollection.delete(collectionId);
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
/** Build a {@link StickyCohortHintCache} (a plain per-collection map; sticky across reconnects). */
|
|
547
|
-
export function createStickyCohortHintCache(): StickyCohortHintCache {
|
|
548
|
-
return new MapStickyCohortHintCache();
|
|
549
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Reactivity — Resume RPC: classification, serving, and subscriber-side apply
|
|
3
|
+
* (`docs/reactivity.md` §Resume, §Parent checkpoint summaries, §Wire formats Resume).
|
|
4
|
+
*
|
|
5
|
+
* A subscriber waking from sleep sends one {@link ResumeV1} to its cached primary (or any cohort member).
|
|
6
|
+
* The serving cohort classifies the request against its **stacked** recovery windows — the `W`-deep replay
|
|
7
|
+
* ring sits on top of the `W_checkpoint`-span parent checkpoint immediately below it, so a single round
|
|
8
|
+
* trip recovers `W + W_checkpoint` revisions — and answers with one of four {@link ResumeReplyV1} variants
|
|
9
|
+
* (`docs/reactivity.md` §Replay window, the authoritative stacked semantics):
|
|
10
|
+
*
|
|
11
|
+
* - **Backfill** — `fromRevision` is within the replay ring (`fromRevision ≥ ringLow`). Returns the
|
|
12
|
+
* `[fromRevision, currentRevision]` slice + `currentRevision`. One RT.
|
|
13
|
+
* - **CheckpointWindow** — `fromRevision` is below the ring but within the parent checkpoint span
|
|
14
|
+
* (`chain low ≤ fromRevision < ringLow`). Returns an ordered, contiguous **chain** of
|
|
15
|
+
* {@link CheckpointSummary}s (`checkpoints`, low→high) + the ring's `recentEntries`. The subscriber
|
|
16
|
+
* verifies every link's endpoints, applies each link's merged digest, advances its contiguity head past
|
|
17
|
+
* the whole chain, then replays the recent entries deduped against `lastRevision`. One RT. The chain is a
|
|
18
|
+
* single link in steady state; a new tail that took over across a rotation serves a **two-link**
|
|
19
|
+
* `[inherited, rolling]` chain when a `fromRevision` below both the ring and the rolling checkpoint falls
|
|
20
|
+
* inside the inherited handoff window — so the full stacked range recovers in one reply regardless of
|
|
21
|
+
* where the new tail's own rolling checkpoint has formed (`docs/reactivity.md` §Tail rotation step 5 —
|
|
22
|
+
* "the new tail holds the old checkpoint").
|
|
23
|
+
* - **OutOfWindow** — older than even the checkpoint. Returns `currentTailId` + `currentRevision`; the
|
|
24
|
+
* subscriber falls back to a chain read, then a fresh subscribe.
|
|
25
|
+
* - **TailRotated** — the request's `latestKnownTailId` does not match the cohort's current tail (the
|
|
26
|
+
* subscriber slept across a rotation). Returns `newTailId` + `newRevisionAtRotation`; the subscriber
|
|
27
|
+
* re-registers under the new tail. This is checked **first**: a stale tail means the whole tree moved,
|
|
28
|
+
* so the lag-against-windows classification is moot. The rotation *lifecycle* is owned by
|
|
29
|
+
* [reactivity-rotation-backpressure-policy]; this module only **detects** the stale tail and emits the
|
|
30
|
+
* reply so resume composes with rotation.
|
|
31
|
+
*
|
|
32
|
+
* Wire conventions match the rest of reactivity: JSON, byte fields base64url, unix-ms timestamps, `v: 1`,
|
|
33
|
+
* per-message structural validation on decode. `ResumeV1` is signed by the subscriber's peer key
|
|
34
|
+
* (`signature`); replay protection rides the request envelope as elsewhere in the substrate.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import {
|
|
38
|
+
decodeCohortMessage,
|
|
39
|
+
encodeCohortMessage,
|
|
40
|
+
DEFAULT_MAX_MESSAGE_BYTES,
|
|
41
|
+
} from "../cohort-topic/wire/codec.js";
|
|
42
|
+
import {
|
|
43
|
+
asObject,
|
|
44
|
+
b64urlField,
|
|
45
|
+
failWire,
|
|
46
|
+
reqFiniteNumber,
|
|
47
|
+
reqIntInRange,
|
|
48
|
+
reqString,
|
|
49
|
+
requireV1,
|
|
50
|
+
} from "./wire-validate.js";
|
|
51
|
+
import { validateCheckpointSummary, verifyCheckpointEndpoints, type CheckpointSummary, type RollingCheckpoint } from "./checkpoint.js";
|
|
52
|
+
import { checkpointCovers } from "./rotation.js";
|
|
53
|
+
import type { ReplayBuffer } from "./replay-buffer.js";
|
|
54
|
+
import type { ReactivitySubscriber } from "./subscriber.js";
|
|
55
|
+
import type { NotificationVerifier } from "./verify.js";
|
|
56
|
+
import { validateNotificationArray, type NotificationV1 } from "./wire.js";
|
|
57
|
+
|
|
58
|
+
/** The four resume classifications (`docs/reactivity.md` §Resume). */
|
|
59
|
+
export type ResumeResult = "backfill" | "checkpoint_window" | "out_of_window" | "tail_rotated";
|
|
60
|
+
|
|
61
|
+
/** A subscriber's resume request after waking from sleep. */
|
|
62
|
+
export interface ResumeV1 {
|
|
63
|
+
v: 1;
|
|
64
|
+
/** Collection id, base64url. */
|
|
65
|
+
collectionId: string;
|
|
66
|
+
/** First revision the subscriber still needs (`lastRevision + 1`). */
|
|
67
|
+
fromRevision: number;
|
|
68
|
+
/** Tail block id the subscriber believes is current, base64url (the stale-tail detector). */
|
|
69
|
+
latestKnownTailId: string;
|
|
70
|
+
/** The subscriber's ring coordinate, base64url (for the cohort to route the reply). */
|
|
71
|
+
subscriberCoord: string;
|
|
72
|
+
/** Unix ms. */
|
|
73
|
+
timestamp: number;
|
|
74
|
+
/** Subscriber peer-key signature over the request. */
|
|
75
|
+
signature: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The cohort's classified reply. Fields are populated per the `result` discriminant. */
|
|
79
|
+
export interface ResumeReplyV1 {
|
|
80
|
+
v: 1;
|
|
81
|
+
result: ResumeResult;
|
|
82
|
+
// --- backfill ---
|
|
83
|
+
/** The `[fromRevision, currentRevision]` slice held in the replay ring (backfill). */
|
|
84
|
+
entries?: NotificationV1[];
|
|
85
|
+
/** Highest committed revision the serving cohort knows (backfill / checkpoint_window / out_of_window). */
|
|
86
|
+
currentRevision?: number;
|
|
87
|
+
// --- checkpoint_window ---
|
|
88
|
+
/**
|
|
89
|
+
* The parent-checkpoint chain spanning `[chain[0].fromRevision, ringLow − 1]` (checkpoint_window), ordered
|
|
90
|
+
* low→high and **contiguous**: each `checkpoints[i].fromRevision === checkpoints[i-1].toRevision + 1`. A
|
|
91
|
+
* single link in steady state; a cross-rotation resume carries the two-link `[inherited, rolling]` bridge.
|
|
92
|
+
* The subscriber verifies and applies every link in sequence (see {@link applyResumeReply}).
|
|
93
|
+
*/
|
|
94
|
+
checkpoints?: CheckpointSummary[];
|
|
95
|
+
/** The replay ring's entries, replayed deduped against `lastRevision` after the digests apply. */
|
|
96
|
+
recentEntries?: NotificationV1[];
|
|
97
|
+
// --- out_of_window ---
|
|
98
|
+
/** Current tail block id, base64url — the subscriber re-subscribes after a chain read (out_of_window). */
|
|
99
|
+
currentTailId?: string;
|
|
100
|
+
// --- tail_rotated ---
|
|
101
|
+
/** New tail block id the topic rotated to, base64url (tail_rotated). */
|
|
102
|
+
newTailId?: string;
|
|
103
|
+
/** Revision at which the rotation took effect (tail_rotated). */
|
|
104
|
+
newRevisionAtRotation?: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --- validation ---
|
|
108
|
+
|
|
109
|
+
/** Narrow an already-parsed value to {@link ResumeV1}, throwing on any defect. */
|
|
110
|
+
export function validateResumeV1(value: unknown): ResumeV1 {
|
|
111
|
+
const what = "ResumeV1";
|
|
112
|
+
const obj = asObject(value, what);
|
|
113
|
+
requireV1(obj, what);
|
|
114
|
+
return {
|
|
115
|
+
v: 1,
|
|
116
|
+
collectionId: b64urlField(reqString(obj, "collectionId", what), "collectionId", what),
|
|
117
|
+
fromRevision: reqIntInRange(obj, "fromRevision", what, 0),
|
|
118
|
+
latestKnownTailId: b64urlField(reqString(obj, "latestKnownTailId", what), "latestKnownTailId", what),
|
|
119
|
+
subscriberCoord: b64urlField(reqString(obj, "subscriberCoord", what), "subscriberCoord", what),
|
|
120
|
+
timestamp: reqFiniteNumber(obj, "timestamp", what),
|
|
121
|
+
signature: b64urlField(reqString(obj, "signature", what), "signature", what),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Narrow an already-parsed value to {@link ResumeReplyV1}, throwing on any defect. */
|
|
126
|
+
export function validateResumeReplyV1(value: unknown): ResumeReplyV1 {
|
|
127
|
+
const what = "ResumeReplyV1";
|
|
128
|
+
const obj = asObject(value, what);
|
|
129
|
+
requireV1(obj, what);
|
|
130
|
+
const result = obj["result"];
|
|
131
|
+
if (result !== "backfill" && result !== "checkpoint_window" && result !== "out_of_window" && result !== "tail_rotated") {
|
|
132
|
+
failWire(`${what}: field "result" must be one of backfill|checkpoint_window|out_of_window|tail_rotated`);
|
|
133
|
+
}
|
|
134
|
+
const out: ResumeReplyV1 = { v: 1, result };
|
|
135
|
+
switch (result) {
|
|
136
|
+
case "backfill": {
|
|
137
|
+
out.entries = validateNotificationArray(obj["entries"], what);
|
|
138
|
+
out.currentRevision = reqIntInRange(obj, "currentRevision", what, 0);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case "checkpoint_window": {
|
|
142
|
+
out.checkpoints = validateCheckpointChain(obj["checkpoints"], `${what}.checkpoints`);
|
|
143
|
+
out.recentEntries = validateNotificationArray(obj["recentEntries"], what);
|
|
144
|
+
if (obj["currentRevision"] !== undefined) {
|
|
145
|
+
out.currentRevision = reqIntInRange(obj, "currentRevision", what, 0);
|
|
146
|
+
}
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
case "out_of_window": {
|
|
150
|
+
out.currentTailId = b64urlField(reqString(obj, "currentTailId", what), "currentTailId", what);
|
|
151
|
+
out.currentRevision = reqIntInRange(obj, "currentRevision", what, 0);
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
case "tail_rotated": {
|
|
155
|
+
out.newTailId = b64urlField(reqString(obj, "newTailId", what), "newTailId", what);
|
|
156
|
+
out.newRevisionAtRotation = reqIntInRange(obj, "newRevisionAtRotation", what, 0);
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Validate a `checkpoint_window` reply's checkpoint chain: a **non-empty**, **ascending**, internally
|
|
165
|
+
* **contiguous** list of {@link CheckpointSummary}s sharing one `collectionId`. Each link is validated via
|
|
166
|
+
* {@link validateCheckpointSummary}, and for `i ≥ 1` the chain must satisfy
|
|
167
|
+
* `checkpoints[i].fromRevision === checkpoints[i-1].toRevision + 1` (a gapped, overlapping, or misordered
|
|
168
|
+
* chain is rejected). Runs on decode, so a malformed chain never reaches {@link applyResumeReply} over the
|
|
169
|
+
* wire.
|
|
170
|
+
*/
|
|
171
|
+
function validateCheckpointChain(value: unknown, what: string): CheckpointSummary[] {
|
|
172
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
173
|
+
failWire(`${what}: must be a non-empty array of checkpoint summaries`);
|
|
174
|
+
}
|
|
175
|
+
const chain = value.map((el, i) => validateCheckpointSummary(el, `${what}[${i}]`));
|
|
176
|
+
for (let i = 0; i < chain.length; i++) {
|
|
177
|
+
if (chain[i]!.collectionId !== chain[0]!.collectionId) {
|
|
178
|
+
failWire(`${what}: all checkpoints must share one collectionId`);
|
|
179
|
+
}
|
|
180
|
+
if (i >= 1 && chain[i]!.fromRevision !== chain[i - 1]!.toRevision + 1) {
|
|
181
|
+
failWire(`${what}: chain must be ascending and contiguous (checkpoints[${i}].fromRevision must equal checkpoints[${i - 1}].toRevision + 1)`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return chain;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- canonical signing payload ---
|
|
188
|
+
|
|
189
|
+
const utf8 = new TextEncoder();
|
|
190
|
+
|
|
191
|
+
/** A {@link ResumeV1} minus its `signature` — the canonical bytes the subscriber peer-key-signs. */
|
|
192
|
+
export type ResumeSignable = Omit<ResumeV1, "signature">;
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Canonical signed byte image of a {@link ResumeV1} (every field except `signature`). Mirrors
|
|
196
|
+
* {@link import("./backfill.js").backfillSigningPayload}: an explicitly-ordered, type-tagged JSON array
|
|
197
|
+
* encoded as UTF-8. The `"ResumeV1"` tag means a resume image can never collide with the `"BackfillV1"`
|
|
198
|
+
* image. `subscriberCoord` is part of the image, so a verifier recomputes over whatever coordinate the
|
|
199
|
+
* request carries — the field order is the byte-for-byte contract between signer and verifier.
|
|
200
|
+
*/
|
|
201
|
+
export function resumeSigningPayload(body: ResumeSignable): Uint8Array {
|
|
202
|
+
return utf8.encode(JSON.stringify([
|
|
203
|
+
"ResumeV1",
|
|
204
|
+
body.v,
|
|
205
|
+
body.collectionId,
|
|
206
|
+
body.fromRevision,
|
|
207
|
+
body.latestKnownTailId,
|
|
208
|
+
body.subscriberCoord,
|
|
209
|
+
body.timestamp,
|
|
210
|
+
]));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// --- codecs (length-framed JSON, mirroring the rest of reactivity) ---
|
|
214
|
+
|
|
215
|
+
/** Encode a {@link ResumeV1} as a length-prefixed UTF-8 JSON frame. */
|
|
216
|
+
export function encodeResumeV1(msg: ResumeV1, maxMessageBytes: number = DEFAULT_MAX_MESSAGE_BYTES): Uint8Array {
|
|
217
|
+
return encodeCohortMessage(validateResumeV1(msg), maxMessageBytes);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Decode a length-prefixed {@link ResumeV1} frame. */
|
|
221
|
+
export function decodeResumeV1(bytes: Uint8Array, maxMessageBytes?: number): ResumeV1 {
|
|
222
|
+
return validateResumeV1(decodeCohortMessage(bytes, maxMessageBytes));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Encode a {@link ResumeReplyV1} as a length-prefixed UTF-8 JSON frame. */
|
|
226
|
+
export function encodeResumeReplyV1(msg: ResumeReplyV1, maxMessageBytes: number = DEFAULT_MAX_MESSAGE_BYTES): Uint8Array {
|
|
227
|
+
return encodeCohortMessage(validateResumeReplyV1(msg), maxMessageBytes);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Decode a length-prefixed {@link ResumeReplyV1} frame. */
|
|
231
|
+
export function decodeResumeReplyV1(bytes: Uint8Array, maxMessageBytes?: number): ResumeReplyV1 {
|
|
232
|
+
return validateResumeReplyV1(decodeCohortMessage(bytes, maxMessageBytes));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// --- serving-cohort classification ---
|
|
236
|
+
|
|
237
|
+
/** The rolling checkpoint's summary; non-undefined because the caller already proved it abuts the ring (so non-empty). */
|
|
238
|
+
function requireRollingSummary(rolling: RollingCheckpoint): CheckpointSummary {
|
|
239
|
+
const summary = rolling.summary();
|
|
240
|
+
if (summary === undefined) {
|
|
241
|
+
throw new Error("resumeCheckpointChain: rolling checkpoint abuts the ring yet produced no summary (invariant violation)");
|
|
242
|
+
}
|
|
243
|
+
return summary;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Build the served checkpoint chain for a `checkpoint_window` resume: the **shortest** contiguous low→high
|
|
248
|
+
* chain of {@link CheckpointSummary}s that both covers `fromRevision` and **abuts the ring's low edge**
|
|
249
|
+
* (`top.toRevision === ringLow − 1`), or `undefined` when no gap-free chain exists. Shared by
|
|
250
|
+
* {@link classifyResume} and {@link serveResume} so they cannot disagree about the served shape.
|
|
251
|
+
*
|
|
252
|
+
* Cases are evaluated in precedence order (this ordering preserves every existing classification test):
|
|
253
|
+
*
|
|
254
|
+
* - **A — rolling wins.** The rolling checkpoint abuts the ring (it is fed by ring eviction, so it always
|
|
255
|
+
* does) and covers `fromRevision`: the fresher, narrower single link `[rolling]`.
|
|
256
|
+
* - **B1 — inherited abuts the ring directly.** No new-tail rolling checkpoint has formed between the
|
|
257
|
+
* inherited handoff and the ring yet (the `12.51` abut case): the single link `[inherited]`.
|
|
258
|
+
* - **B2 — the bridge.** The inherited handoff abuts the new tail's rolling checkpoint, which abuts the
|
|
259
|
+
* ring: the two-link `[inherited, rolling]` chain spans inherited → rolling → ring with no gap.
|
|
260
|
+
*
|
|
261
|
+
* Any other shape (a gap below `fromRevision`, or a gap between the windows) returns `undefined` → the
|
|
262
|
+
* request falls to `out_of_window` (an honest chain read). `ringLow === undefined` (no live ring to abut /
|
|
263
|
+
* no `recentEntries`) also returns `undefined`.
|
|
264
|
+
*/
|
|
265
|
+
function resumeCheckpointChain(
|
|
266
|
+
fromRevision: number,
|
|
267
|
+
ringLow: number | undefined,
|
|
268
|
+
rolling: RollingCheckpoint | undefined,
|
|
269
|
+
inherited: CheckpointSummary | undefined,
|
|
270
|
+
): CheckpointSummary[] | undefined {
|
|
271
|
+
if (ringLow === undefined) {
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
const ringAbut = ringLow - 1;
|
|
275
|
+
|
|
276
|
+
// Case A — the rolling checkpoint wins (fresher, narrower). `toRevision === ringAbut` implies it is
|
|
277
|
+
// non-empty, so the summary is defined (asserted in requireRollingSummary).
|
|
278
|
+
if (rolling !== undefined && rolling.toRevision === ringAbut && rolling.covers(fromRevision)) {
|
|
279
|
+
return [requireRollingSummary(rolling)];
|
|
280
|
+
}
|
|
281
|
+
// Case B1 — the inherited handoff abuts the ring directly (the 12.51 abut case): a single link.
|
|
282
|
+
if (inherited !== undefined && inherited.toRevision === ringAbut && checkpointCovers(inherited, fromRevision)) {
|
|
283
|
+
return [inherited];
|
|
284
|
+
}
|
|
285
|
+
// Case B2 — the bridge: inherited abuts the rolling checkpoint, which abuts the ring. The two-link chain
|
|
286
|
+
// carries inherited → rolling → ring with no gap, and each link keeps its own already-correct merged
|
|
287
|
+
// digest (nothing is re-folded, so a per-collection override fold composes trivially).
|
|
288
|
+
if (
|
|
289
|
+
inherited !== undefined &&
|
|
290
|
+
rolling !== undefined &&
|
|
291
|
+
rolling.toRevision === ringAbut &&
|
|
292
|
+
inherited.toRevision + 1 === rolling.fromRevision &&
|
|
293
|
+
checkpointCovers(inherited, fromRevision)
|
|
294
|
+
) {
|
|
295
|
+
return [inherited, requireRollingSummary(rolling)];
|
|
296
|
+
}
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Classify a resume request against the serving cohort's stacked windows. The decision order is:
|
|
302
|
+
*
|
|
303
|
+
* 1. **tail_rotated** — `req.latestKnownTailId !== currentTailId`. A stale tail means the tree migrated,
|
|
304
|
+
* so the lag classification below is moot.
|
|
305
|
+
* 2. **backfill** — `fromRevision` is at/above the replay ring's low edge (within, or already current).
|
|
306
|
+
* 3. **checkpoint_window** — below the ring but a gap-free {@link resumeCheckpointChain} covers it: the
|
|
307
|
+
* rolling checkpoint (steady state), the inherited cross-rotation handoff (`docs/reactivity.md` §Tail
|
|
308
|
+
* rotation step 5 — "the new tail holds the old checkpoint"), or the two-link `[inherited, rolling]`
|
|
309
|
+
* bridge spanning both when the new tail's own rolling checkpoint has formed between the handoff and the
|
|
310
|
+
* ring.
|
|
311
|
+
* 4. **out_of_window** — older than even the deepest window, or a gap leaves no contiguous chain to the
|
|
312
|
+
* ring (a chain read), or every window is empty/absent.
|
|
313
|
+
*/
|
|
314
|
+
export function classifyResume(
|
|
315
|
+
req: ResumeV1,
|
|
316
|
+
buffer: ReplayBuffer,
|
|
317
|
+
checkpoint: RollingCheckpoint | undefined,
|
|
318
|
+
currentTailId: string,
|
|
319
|
+
inherited?: CheckpointSummary,
|
|
320
|
+
): ResumeResult {
|
|
321
|
+
if (req.latestKnownTailId !== currentTailId) {
|
|
322
|
+
return "tail_rotated";
|
|
323
|
+
}
|
|
324
|
+
const low = buffer.lowRevision;
|
|
325
|
+
if (low !== undefined && req.fromRevision >= low) {
|
|
326
|
+
return "backfill";
|
|
327
|
+
}
|
|
328
|
+
return resumeCheckpointChain(req.fromRevision, low, checkpoint, inherited) !== undefined
|
|
329
|
+
? "checkpoint_window"
|
|
330
|
+
: "out_of_window";
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Construction inputs for {@link serveResume}. */
|
|
334
|
+
export interface ResumeServingDeps {
|
|
335
|
+
/** The cohort's replay ring (the live `PushState.replayBuffer`). */
|
|
336
|
+
readonly buffer: ReplayBuffer;
|
|
337
|
+
/** The cohort's rolling parent checkpoint (the live `PushState.checkpoint`). */
|
|
338
|
+
readonly checkpoint?: RollingCheckpoint;
|
|
339
|
+
/**
|
|
340
|
+
* The checkpoint migrated from the **outgoing** tail when this cohort became the new tail across a
|
|
341
|
+
* rotation (the live `PushState.inheritedCheckpoint`). Consulted when the rolling {@link checkpoint} does
|
|
342
|
+
* not cover `fromRevision`: a resume whose span crosses the rotation is answered from the inherited window
|
|
343
|
+
* — either alone (it abuts the ring directly) or as the lower link of the two-link `[inherited, rolling]`
|
|
344
|
+
* bridge when the new tail's own rolling checkpoint has formed between the handoff and the ring (see
|
|
345
|
+
* {@link resumeCheckpointChain}) — instead of falling to `out_of_window` (`docs/reactivity.md` §Tail
|
|
346
|
+
* rotation step 5).
|
|
347
|
+
*/
|
|
348
|
+
readonly inheritedCheckpoint?: CheckpointSummary;
|
|
349
|
+
/** The cohort's current tail block id, base64url. */
|
|
350
|
+
readonly currentTailId: string;
|
|
351
|
+
/** Highest committed revision the cohort knows (the live `PushState.lastRevision`). */
|
|
352
|
+
readonly currentRevision: number;
|
|
353
|
+
/** Revision the current tail became effective (for a tail_rotated reply); defaults to `currentRevision`. */
|
|
354
|
+
readonly rotationRevision?: number;
|
|
355
|
+
/** Collection this cohort serves, base64url — a foreign-collection request is rejected. */
|
|
356
|
+
readonly expectedCollectionId: string;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Serve a {@link ResumeV1} from a cohort's stacked windows, producing the classified {@link ResumeReplyV1}.
|
|
361
|
+
* Pure over the supplied snapshot — no I/O, no clock. A request whose `collectionId` does not match
|
|
362
|
+
* `expectedCollectionId` is rejected (a resume is collection-scoped).
|
|
363
|
+
*/
|
|
364
|
+
export function serveResume(req: ResumeV1, deps: ResumeServingDeps): ResumeReplyV1 {
|
|
365
|
+
if (req.collectionId !== deps.expectedCollectionId) {
|
|
366
|
+
failWire(`serveResume: request collectionId does not match this cohort's collection`);
|
|
367
|
+
}
|
|
368
|
+
const result = classifyResume(req, deps.buffer, deps.checkpoint, deps.currentTailId, deps.inheritedCheckpoint);
|
|
369
|
+
switch (result) {
|
|
370
|
+
case "backfill": {
|
|
371
|
+
const high = deps.buffer.highRevision ?? deps.currentRevision;
|
|
372
|
+
const entries = deps.buffer.range(req.fromRevision, high).map((e) => e.payload);
|
|
373
|
+
return { v: 1, result, entries, currentRevision: deps.currentRevision };
|
|
374
|
+
}
|
|
375
|
+
case "checkpoint_window": {
|
|
376
|
+
// The contiguous chain the helper built — rolling alone, inherited alone, or the two-link bridge.
|
|
377
|
+
// classifyResume returned checkpoint_window iff this chain is present, so it is non-undefined here
|
|
378
|
+
// (the two calls share inputs, so they cannot disagree).
|
|
379
|
+
const chain = resumeCheckpointChain(req.fromRevision, deps.buffer.lowRevision, deps.checkpoint, deps.inheritedCheckpoint);
|
|
380
|
+
if (chain === undefined) {
|
|
381
|
+
throw new Error("serveResume: checkpoint_window classification produced no checkpoint chain (invariant violation)");
|
|
382
|
+
}
|
|
383
|
+
const recentEntries = deps.buffer.entries().map((e) => e.payload);
|
|
384
|
+
return { v: 1, result, checkpoints: chain, recentEntries, currentRevision: deps.currentRevision };
|
|
385
|
+
}
|
|
386
|
+
case "out_of_window":
|
|
387
|
+
return { v: 1, result, currentTailId: deps.currentTailId, currentRevision: deps.currentRevision };
|
|
388
|
+
case "tail_rotated":
|
|
389
|
+
return {
|
|
390
|
+
v: 1,
|
|
391
|
+
result,
|
|
392
|
+
newTailId: deps.currentTailId,
|
|
393
|
+
newRevisionAtRotation: deps.rotationRevision ?? deps.currentRevision,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// --- subscriber-side apply ---
|
|
399
|
+
|
|
400
|
+
/** The outcome of applying a {@link ResumeReplyV1} subscriber-side. */
|
|
401
|
+
export type ResumeApplyOutcome =
|
|
402
|
+
/** Backfill entries were replayed through the delivery path; the subscriber is current. */
|
|
403
|
+
| "backfilled"
|
|
404
|
+
/** Checkpoint endpoints verified, digest applied, recent entries replayed; the subscriber is current. */
|
|
405
|
+
| "checkpoint_applied"
|
|
406
|
+
/** Checkpoint bracketing endpoints failed to verify (forged/tampered) — the subscriber must chain-read. */
|
|
407
|
+
| "checkpoint_untrusted"
|
|
408
|
+
/** Out of every window — the subscriber must chain-read then re-subscribe. */
|
|
409
|
+
| "out_of_window"
|
|
410
|
+
/** Tail rotated — the subscriber must re-register under the new tail. */
|
|
411
|
+
| "tail_rotated";
|
|
412
|
+
|
|
413
|
+
/** Sinks for {@link applyResumeReply} — each non-backfill variant escalates to the application. */
|
|
414
|
+
export interface ResumeApplyDeps {
|
|
415
|
+
/** Feeds replayed entries through the verify → contiguity → deliver path (closing the gap, deduping). */
|
|
416
|
+
readonly subscriber: ReactivitySubscriber;
|
|
417
|
+
/** Verifies a checkpoint's bracketing endpoints are real committed revisions. */
|
|
418
|
+
readonly verifier: NotificationVerifier;
|
|
419
|
+
/** Apply a verified checkpoint's merged digest (e.g. invalidate the changed keys). */
|
|
420
|
+
readonly onCheckpointDigest?: (summary: CheckpointSummary) => void;
|
|
421
|
+
/** Fall back to a chain read + fresh subscribe (out_of_window, or an untrusted checkpoint). */
|
|
422
|
+
readonly onChainRead?: (currentTailId: string | undefined, currentRevision: number | undefined) => void;
|
|
423
|
+
/** Re-register under the rotated tail (tail_rotated). */
|
|
424
|
+
readonly onTailRotated?: (newTailId: string, newRevisionAtRotation: number) => void;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Apply a {@link ResumeReplyV1} on the subscriber side, returning the {@link ResumeApplyOutcome}. Backfill
|
|
429
|
+
* and checkpoint-window entries re-enter through {@link ReactivitySubscriber.onNotification} (so they are
|
|
430
|
+
* verified, contiguity-checked, and deduped exactly like live notifications); the other variants escalate
|
|
431
|
+
* to the application via the supplied sinks. A checkpoint whose bracketing endpoints do not verify is
|
|
432
|
+
* **not** applied — the subscriber chain-reads instead (a forged checkpoint must never advance state).
|
|
433
|
+
*/
|
|
434
|
+
export async function applyResumeReply(reply: ResumeReplyV1, deps: ResumeApplyDeps): Promise<ResumeApplyOutcome> {
|
|
435
|
+
switch (reply.result) {
|
|
436
|
+
case "backfill": {
|
|
437
|
+
for (const n of reply.entries ?? []) {
|
|
438
|
+
await deps.subscriber.onNotification(n);
|
|
439
|
+
}
|
|
440
|
+
return "backfilled";
|
|
441
|
+
}
|
|
442
|
+
case "checkpoint_window": {
|
|
443
|
+
const summaries = reply.checkpoints;
|
|
444
|
+
if (summaries === undefined || summaries.length === 0) {
|
|
445
|
+
failWire(`applyResumeReply: checkpoint_window reply is missing its checkpoint chain`);
|
|
446
|
+
}
|
|
447
|
+
// Low-edge guard on the **lowest** link: endpoint verification proves each link's bounds are *real*
|
|
448
|
+
// committed revisions, but not that the chain connects to the subscriber's contiguity head. If the
|
|
449
|
+
// lowest `fromRevision` sits above `lastRevision + 1` there is an un-summarized gap below the chain;
|
|
450
|
+
// rebaselining would silently skip those revisions. The honest cohort never sends such a reply
|
|
451
|
+
// (`classifyResume` only picks `checkpoint_window` when the chain covers `lastRevision + 1`), so a
|
|
452
|
+
// non-abutting low edge is a forged/buggy reply — chain-read rather than advance past a gap.
|
|
453
|
+
if (summaries[0]!.fromRevision > deps.subscriber.lastRevision + 1) {
|
|
454
|
+
deps.onChainRead?.(reply.currentTailId, reply.currentRevision);
|
|
455
|
+
return "checkpoint_untrusted";
|
|
456
|
+
}
|
|
457
|
+
// Intra-chain contiguity (defense in depth: the codec already checks every *decoded* reply, but an
|
|
458
|
+
// in-process reply may bypass the codec). A gap/overlap between links would skip or double revisions.
|
|
459
|
+
for (let i = 1; i < summaries.length; i++) {
|
|
460
|
+
if (summaries[i]!.fromRevision !== summaries[i - 1]!.toRevision + 1) {
|
|
461
|
+
deps.onChainRead?.(reply.currentTailId, reply.currentRevision);
|
|
462
|
+
return "checkpoint_untrusted";
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// Verify EVERY link's bracketing endpoints **before applying anything** — a single forged link kills
|
|
466
|
+
// the whole reply, so nothing is delivered and `lastRevision` is unchanged (no partial advance).
|
|
467
|
+
for (const summary of summaries) {
|
|
468
|
+
const verdict = await verifyCheckpointEndpoints(summary, deps.verifier);
|
|
469
|
+
if (verdict !== "verified") {
|
|
470
|
+
deps.onChainRead?.(reply.currentTailId, reply.currentRevision);
|
|
471
|
+
return "checkpoint_untrusted";
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
// Apply in order: the application sees each link's merged-digest hint, then the contiguity head
|
|
475
|
+
// advances past that link. `rebaseline` is monotone, so the head lands at the top link's
|
|
476
|
+
// `toRevision`; the recent entries (immediately above it) then replay gap-free — any recent entry
|
|
477
|
+
// at/below the head dedupes (`docs/reactivity.md` §Parent checkpoint summaries).
|
|
478
|
+
for (const summary of summaries) {
|
|
479
|
+
deps.onCheckpointDigest?.(summary);
|
|
480
|
+
deps.subscriber.rebaseline(summary.toRevision);
|
|
481
|
+
}
|
|
482
|
+
for (const n of reply.recentEntries ?? []) {
|
|
483
|
+
await deps.subscriber.onNotification(n);
|
|
484
|
+
}
|
|
485
|
+
return "checkpoint_applied";
|
|
486
|
+
}
|
|
487
|
+
case "out_of_window": {
|
|
488
|
+
deps.onChainRead?.(reply.currentTailId, reply.currentRevision);
|
|
489
|
+
return "out_of_window";
|
|
490
|
+
}
|
|
491
|
+
case "tail_rotated": {
|
|
492
|
+
deps.onTailRotated?.(reply.newTailId ?? "", reply.newRevisionAtRotation ?? 0);
|
|
493
|
+
return "tail_rotated";
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// --- Edge sticky cohort-hint cache (one-RT resume after a flap) ---
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* A cached cohort hint for fast reactivity re-attach (`docs/reactivity.md` §Edge profile: "`cohortHint`
|
|
502
|
+
* is sticky-cached across reconnects so brief network flaps don't trigger re-walk"). Distinct from the
|
|
503
|
+
* cohort-topic `CohortHint` (the full resolved cohort) — this is the minimal sticky-cached slice the
|
|
504
|
+
* subscriber dials a resume at directly. All ids base64url.
|
|
505
|
+
*/
|
|
506
|
+
export interface ReactivityCohortHint {
|
|
507
|
+
/** The collection's tail-anchored topic id at cache time. */
|
|
508
|
+
readonly topicId: string;
|
|
509
|
+
/** The cached serving primary. */
|
|
510
|
+
readonly primary: string;
|
|
511
|
+
/** The cohort member set for fast re-attach. */
|
|
512
|
+
readonly cohortHint: readonly string[];
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* A sticky per-collection cache of the last-known serving cohort, keyed by collection id. **Sticky** means
|
|
517
|
+
* it survives reconnects — a brief network flap reuses the cached primary so resume is one round trip
|
|
518
|
+
* instead of a re-walk from `d_max`. It is invalidated only on an explicit signal (a `TailRotated` reply,
|
|
519
|
+
* or a confirmed-stale primary), never merely on a transient failure.
|
|
520
|
+
*/
|
|
521
|
+
export interface StickyCohortHintCache {
|
|
522
|
+
/** The cached hint for `collectionId`, or `undefined` if none. */
|
|
523
|
+
get(collectionId: string): ReactivityCohortHint | undefined;
|
|
524
|
+
/** Cache (or refresh) the hint for `collectionId`. */
|
|
525
|
+
set(collectionId: string, hint: ReactivityCohortHint): void;
|
|
526
|
+
/** Drop the cached hint for `collectionId` (on rotation / confirmed staleness). */
|
|
527
|
+
invalidate(collectionId: string): void;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
class MapStickyCohortHintCache implements StickyCohortHintCache {
|
|
531
|
+
private readonly byCollection = new Map<string, ReactivityCohortHint>();
|
|
532
|
+
|
|
533
|
+
get(collectionId: string): ReactivityCohortHint | undefined {
|
|
534
|
+
return this.byCollection.get(collectionId);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
set(collectionId: string, hint: ReactivityCohortHint): void {
|
|
538
|
+
this.byCollection.set(collectionId, hint);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
invalidate(collectionId: string): void {
|
|
542
|
+
this.byCollection.delete(collectionId);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Build a {@link StickyCohortHintCache} (a plain per-collection map; sticky across reconnects). */
|
|
547
|
+
export function createStickyCohortHintCache(): StickyCohortHintCache {
|
|
548
|
+
return new MapStickyCohortHintCache();
|
|
549
|
+
}
|