@optimystic/db-core 0.25.0 → 0.26.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/dist/src/cohort-topic/membership/verifier.d.ts +8 -0
- package/dist/src/cohort-topic/membership/verifier.d.ts.map +1 -1
- package/dist/src/cohort-topic/membership/verifier.js +17 -0
- package/dist/src/cohort-topic/membership/verifier.js.map +1 -1
- package/dist/src/cohort-topic/promotion.d.ts +25 -0
- package/dist/src/cohort-topic/promotion.d.ts.map +1 -1
- package/dist/src/cohort-topic/promotion.js +58 -11
- package/dist/src/cohort-topic/promotion.js.map +1 -1
- package/dist/src/cohort-topic/wire/primitives.d.ts +6 -0
- package/dist/src/cohort-topic/wire/primitives.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/primitives.js +6 -0
- package/dist/src/cohort-topic/wire/primitives.js.map +1 -1
- package/dist/src/cohort-topic/wire/types.d.ts +5 -0
- package/dist/src/cohort-topic/wire/types.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/validate.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/validate.js +10 -19
- package/dist/src/cohort-topic/wire/validate.js.map +1 -1
- package/dist/src/collection/collection.d.ts +8 -1
- package/dist/src/collection/collection.d.ts.map +1 -1
- package/dist/src/collection/collection.js +19 -3
- package/dist/src/collection/collection.js.map +1 -1
- package/dist/src/matchmaking/wire.d.ts +2 -2
- package/dist/src/matchmaking/wire.d.ts.map +1 -1
- package/dist/src/matchmaking/wire.js +3 -3
- package/dist/src/matchmaking/wire.js.map +1 -1
- package/dist/src/network/struct.d.ts +5 -5
- package/dist/src/network/struct.d.ts.map +1 -1
- package/dist/src/testing/index.d.ts +1 -0
- package/dist/src/testing/index.d.ts.map +1 -1
- package/dist/src/testing/index.js +1 -0
- package/dist/src/testing/index.js.map +1 -1
- package/dist/src/testing/refresh-probe.d.ts +31 -0
- package/dist/src/testing/refresh-probe.d.ts.map +1 -0
- package/dist/src/testing/refresh-probe.js +39 -0
- package/dist/src/testing/refresh-probe.js.map +1 -0
- package/dist/src/testing/test-transactor.d.ts +58 -5
- package/dist/src/testing/test-transactor.d.ts.map +1 -1
- package/dist/src/testing/test-transactor.js +87 -4
- package/dist/src/testing/test-transactor.js.map +1 -1
- package/dist/src/transaction/coordinator.d.ts +90 -6
- package/dist/src/transaction/coordinator.d.ts.map +1 -1
- package/dist/src/transaction/coordinator.js +311 -90
- package/dist/src/transaction/coordinator.js.map +1 -1
- package/dist/src/transaction/errors.d.ts +42 -0
- package/dist/src/transaction/errors.d.ts.map +1 -1
- package/dist/src/transaction/errors.js +50 -0
- package/dist/src/transaction/errors.js.map +1 -1
- package/dist/src/transaction/index.d.ts +1 -1
- package/dist/src/transaction/index.d.ts.map +1 -1
- package/dist/src/transaction/index.js +1 -1
- package/dist/src/transaction/index.js.map +1 -1
- package/dist/src/transaction/session.d.ts +7 -3
- package/dist/src/transaction/session.d.ts.map +1 -1
- package/dist/src/transaction/session.js +7 -3
- package/dist/src/transaction/session.js.map +1 -1
- package/dist/src/transaction/transaction.d.ts +12 -2
- package/dist/src/transaction/transaction.d.ts.map +1 -1
- package/dist/src/transaction/transaction.js.map +1 -1
- package/dist/src/transactor/network-transactor.d.ts +1 -1
- package/dist/src/transactor/network-transactor.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.js +6 -26
- package/dist/src/transactor/network-transactor.js.map +1 -1
- package/dist/src/transactor/transactor-source.d.ts +4 -6
- package/dist/src/transactor/transactor-source.d.ts.map +1 -1
- package/dist/src/transactor/transactor-source.js +4 -6
- package/dist/src/transactor/transactor-source.js.map +1 -1
- package/dist/src/transform/atomic.d.ts.map +1 -1
- package/dist/src/transform/atomic.js +7 -0
- package/dist/src/transform/atomic.js.map +1 -1
- package/dist/src/transform/base-pins.d.ts +48 -0
- package/dist/src/transform/base-pins.d.ts.map +1 -0
- package/dist/src/transform/base-pins.js +65 -0
- package/dist/src/transform/base-pins.js.map +1 -0
- package/dist/src/transform/digest.d.ts +4 -2
- package/dist/src/transform/digest.d.ts.map +1 -1
- package/dist/src/transform/digest.js +14 -19
- package/dist/src/transform/digest.js.map +1 -1
- package/dist/src/transform/tracker.d.ts +31 -3
- package/dist/src/transform/tracker.d.ts.map +1 -1
- package/dist/src/transform/tracker.js +105 -13
- package/dist/src/transform/tracker.js.map +1 -1
- package/package.json +1 -1
- package/src/cohort-topic/membership/verifier.ts +487 -461
- package/src/cohort-topic/promotion.ts +86 -14
- package/src/cohort-topic/wire/primitives.ts +195 -188
- package/src/cohort-topic/wire/types.ts +480 -475
- package/src/cohort-topic/wire/validate.ts +504 -512
- package/src/collection/collection.ts +19 -3
- package/src/matchmaking/wire.ts +605 -603
- package/src/network/struct.ts +5 -5
- package/src/testing/index.ts +1 -0
- package/src/testing/refresh-probe.ts +52 -0
- package/src/testing/test-transactor.ts +100 -4
- package/src/transaction/coordinator.ts +312 -99
- package/src/transaction/errors.ts +141 -91
- package/src/transaction/index.ts +1 -1
- package/src/transaction/session.ts +7 -3
- package/src/transaction/transaction.ts +12 -2
- package/src/transactor/network-transactor.ts +1025 -1045
- package/src/transactor/transactor-source.ts +170 -172
- package/src/transform/atomic.ts +6 -0
- package/src/transform/base-pins.ts +83 -0
- package/src/transform/digest.ts +14 -19
- package/src/transform/tracker.ts +108 -15
|
@@ -42,6 +42,9 @@
|
|
|
42
42
|
* idempotent and `effectiveAt`-ordered via the monotonic {@link PromotionState.lastEffectiveAt}
|
|
43
43
|
* high-water mark, so re-applying a notice (including this member's own echoed broadcast) or a replayed
|
|
44
44
|
* older notice is a no-op — a stale promotion can never un-demote a cohort that has since demoted.
|
|
45
|
+
* A freshly created lifecycle can seed that per-topic ordering from a longer-lived node-level record via
|
|
46
|
+
* the optional {@link PromotionDeps.seedTransition}, so recreating an engine (e.g. after a memory-pressure
|
|
47
|
+
* eviction) does not reopen the replay window.
|
|
45
48
|
*/
|
|
46
49
|
|
|
47
50
|
import { b64urlToBytes, bytesToB64url } from "./wire/codec.js";
|
|
@@ -103,9 +106,14 @@ interface PromotionState {
|
|
|
103
106
|
/**
|
|
104
107
|
* `effectiveAt` of the most recent promotion/demotion this cohort has adopted — locally originated
|
|
105
108
|
* (`promote()` / `demote()`) or remotely applied (`applyPromotionNotice` / `applyDemotionNotice`).
|
|
106
|
-
* Monotonic and
|
|
107
|
-
* is the high-water mark the remote-apply path orders
|
|
108
|
-
* strictly newer is a no-op — re-applied (incl. this
|
|
109
|
+
* Monotonic and never cleared within the lifecycle's lifetime (a demotion clears
|
|
110
|
+
* `promoted`/`promotedAt` but keeps this), so it is the high-water mark the remote-apply path orders
|
|
111
|
+
* against: a notice whose `effectiveAt` is not strictly newer is a no-op — re-applied (incl. this
|
|
112
|
+
* member's own echoed broadcast) or stale-replayed. It is the **in-engine layer** of the replay
|
|
113
|
+
* ordering, not the durable anchor: the lifecycle lives inside an engine the host may evict under
|
|
114
|
+
* memory pressure, so on first touch of a topic it is seeded from the node-level adopted-transition
|
|
115
|
+
* record ({@link PromotionDeps.seedTransition} — db-p2p's promote-gate `transitions` map), which
|
|
116
|
+
* outlives the engine.
|
|
109
117
|
*/
|
|
110
118
|
lastEffectiveAt?: number;
|
|
111
119
|
}
|
|
@@ -135,6 +143,16 @@ export interface PromotionDeps {
|
|
|
135
143
|
cohortEpoch: () => Uint8Array;
|
|
136
144
|
/** Threshold signer (the gossip ticket's `k − x` cohort signer). */
|
|
137
145
|
signer: CohortSigner;
|
|
146
|
+
/**
|
|
147
|
+
* The node-level record of the last adopted promotion/demotion transition for `topicId` at this
|
|
148
|
+
* cohort's `(coord, tier)` — db-p2p's promote-gate `transitions` map, which outlives the engine this
|
|
149
|
+
* lifecycle lives in. Read once per topic, on first state creation, to seed
|
|
150
|
+
* {@link PromotionState.lastEffectiveAt} and the promoted direction — so a recreated engine (after a
|
|
151
|
+
* memory-pressure eviction) neither re-adopts a replayed stale notice nor forgets a correct
|
|
152
|
+
* `promoted = true`. Optional: absent (key-less / unit composition) a fresh lifecycle starts cold,
|
|
153
|
+
* exactly as before.
|
|
154
|
+
*/
|
|
155
|
+
seedTransition?: (topicId: Uint8Array) => { readonly effectiveAt: number; readonly promoted: boolean } | undefined;
|
|
138
156
|
config?: PromotionConfig;
|
|
139
157
|
}
|
|
140
158
|
|
|
@@ -171,6 +189,15 @@ export interface PromotionLifecycle {
|
|
|
171
189
|
* `effectiveAt` ordering as {@link applyPromotionNotice} — a stale promotion can never un-demote.
|
|
172
190
|
*/
|
|
173
191
|
applyDemotionNotice(n: DemotionNoticeV1, now: number): void;
|
|
192
|
+
/**
|
|
193
|
+
* True iff any topic carries adopted promotion/demotion state — a `promoted` flag or a
|
|
194
|
+
* `lastEffectiveAt` high-water. Growth samples / `lowLoadSince` alone do not count: they are
|
|
195
|
+
* reconstructed from the store on the next {@link onParticipantCountChange}. Read by the host's
|
|
196
|
+
* engine-eviction ranking, which prefers to keep an engine holding an adopted transition. A ranking
|
|
197
|
+
* preference, not the safety mechanism: the node-level adopted-transition record
|
|
198
|
+
* ({@link PromotionDeps.seedTransition}) outlives the engine and re-seeds a recreated one.
|
|
199
|
+
*/
|
|
200
|
+
hasAdoptedState(): boolean;
|
|
174
201
|
}
|
|
175
202
|
|
|
176
203
|
class CohortPromotionLifecycle implements PromotionLifecycle {
|
|
@@ -197,14 +224,14 @@ class CohortPromotionLifecycle implements PromotionLifecycle {
|
|
|
197
224
|
}
|
|
198
225
|
|
|
199
226
|
async onParticipantCountChange(topicId: Uint8Array, now: number): Promise<PromotionNoticeV1 | undefined> {
|
|
200
|
-
const state = this.stateFor(topicId);
|
|
227
|
+
const state = this.stateFor(topicId, now);
|
|
201
228
|
const count = this.deps.store.directParticipants(topicId);
|
|
202
229
|
this.pushGrowthSample(state, count, now);
|
|
203
230
|
this.refreshLowLoadClock(state, count, now);
|
|
204
231
|
if (state.promoted) {
|
|
205
232
|
return undefined; // already promoted — sticky until a demotion clears it
|
|
206
233
|
}
|
|
207
|
-
if (!this.promotionTriggered(topicId, state, count
|
|
234
|
+
if (!this.promotionTriggered(topicId, state, count)) {
|
|
208
235
|
return undefined;
|
|
209
236
|
}
|
|
210
237
|
return this.promote(topicId, state, now);
|
|
@@ -222,13 +249,41 @@ class CohortPromotionLifecycle implements PromotionLifecycle {
|
|
|
222
249
|
}
|
|
223
250
|
|
|
224
251
|
isPromoted(topicId: Uint8Array): boolean {
|
|
225
|
-
|
|
252
|
+
const state = this.states.get(bytesKey(topicId));
|
|
253
|
+
if (state !== undefined) {
|
|
254
|
+
// In-engine state wins when present: it was itself seed-initialized on creation, so any divergence
|
|
255
|
+
// from the node-level record means a newer transition landed here first.
|
|
256
|
+
return state.promoted;
|
|
257
|
+
}
|
|
258
|
+
// Seed peek, not a state creation. `isPromoted` has no `now` to arm the sticky-window anchor
|
|
259
|
+
// (`promotedAt`), and creating promoted state without it would skip the sticky gate and allow an
|
|
260
|
+
// early demotion — so the direction is read straight off the node-level record. Nothing else calls
|
|
261
|
+
// `stateFor` on a freshly recreated engine before `isPromoted` is first read, so without this peek a
|
|
262
|
+
// correct `promoted = true` would be invisible until some notice or count change re-created the state.
|
|
263
|
+
return this.deps.seedTransition?.(topicId)?.promoted ?? false;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
hasAdoptedState(): boolean {
|
|
267
|
+
// Adopted transition state only: a promoted flag or the lastEffectiveAt high-water. Growth samples /
|
|
268
|
+
// lowLoadSince are rebuilt from the store on the next onParticipantCountChange, so they never count.
|
|
269
|
+
// NOTE: this reports only in-engine state, so a freshly recreated engine whose promoted mode is
|
|
270
|
+
// currently visible ONLY through the `isPromoted` seed peek (no `stateFor` call yet) answers false and
|
|
271
|
+
// ranks 0 in the host's eviction ranking — as evictable as an engine holding nothing. Correctness is
|
|
272
|
+
// unaffected (the node-level record survives eviction and re-seeds again), so this is a ranking
|
|
273
|
+
// preference lost, not state lost. If recreated-promoted engines are ever observed thrashing through
|
|
274
|
+
// eviction, give the peek a `now` and let it create the seeded state instead of peeking.
|
|
275
|
+
for (const state of this.states.values()) {
|
|
276
|
+
if (state.promoted || state.lastEffectiveAt !== undefined) {
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return false;
|
|
226
281
|
}
|
|
227
282
|
|
|
228
283
|
// --- remote apply (verified notices this member did not originate) ---
|
|
229
284
|
|
|
230
285
|
applyPromotionNotice(n: PromotionNoticeV1, now: number): void {
|
|
231
|
-
const state = this.stateFor(b64urlToBytes(n.topicId));
|
|
286
|
+
const state = this.stateFor(b64urlToBytes(n.topicId), now);
|
|
232
287
|
if (!this.isNewerTransition(state, n.effectiveAt)) {
|
|
233
288
|
return; // already adopted, our own echoed broadcast, or a stale replay
|
|
234
289
|
}
|
|
@@ -237,8 +292,8 @@ class CohortPromotionLifecycle implements PromotionLifecycle {
|
|
|
237
292
|
state.lastEffectiveAt = n.effectiveAt;
|
|
238
293
|
}
|
|
239
294
|
|
|
240
|
-
applyDemotionNotice(n: DemotionNoticeV1,
|
|
241
|
-
const state = this.stateFor(b64urlToBytes(n.topicId));
|
|
295
|
+
applyDemotionNotice(n: DemotionNoticeV1, now: number): void {
|
|
296
|
+
const state = this.stateFor(b64urlToBytes(n.topicId), now);
|
|
242
297
|
if (!this.isNewerTransition(state, n.effectiveAt)) {
|
|
243
298
|
return;
|
|
244
299
|
}
|
|
@@ -256,18 +311,22 @@ class CohortPromotionLifecycle implements PromotionLifecycle {
|
|
|
256
311
|
|
|
257
312
|
// --- promotion ---
|
|
258
313
|
|
|
259
|
-
private promotionTriggered(topicId: Uint8Array, state: PromotionState, count: number
|
|
314
|
+
private promotionTriggered(topicId: Uint8Array, state: PromotionState, count: number): boolean {
|
|
260
315
|
if (count >= this.capPromote) {
|
|
261
316
|
return true;
|
|
262
317
|
}
|
|
263
318
|
if (this.deps.loadBucket(topicId) >= this.bucketOverload && count >= this.capPromoteFast) {
|
|
264
319
|
return true;
|
|
265
320
|
}
|
|
266
|
-
return this.slopePredictsCrossing(state, count
|
|
321
|
+
return this.slopePredictsCrossing(state, count);
|
|
267
322
|
}
|
|
268
323
|
|
|
269
|
-
/**
|
|
270
|
-
|
|
324
|
+
/**
|
|
325
|
+
* Linear extrapolation over the growth window: will `directParticipants` cross `cap_promote` within
|
|
326
|
+
* lookahead? Needs no `now` — the caller stamps the current `(now, count)` as the last growth sample
|
|
327
|
+
* before asking, so `samples[last].t` *is* `now`.
|
|
328
|
+
*/
|
|
329
|
+
private slopePredictsCrossing(state: PromotionState, count: number): boolean {
|
|
271
330
|
const samples = state.samples;
|
|
272
331
|
if (samples.length < 2) {
|
|
273
332
|
return false;
|
|
@@ -372,11 +431,24 @@ class CohortPromotionLifecycle implements PromotionLifecycle {
|
|
|
372
431
|
|
|
373
432
|
// --- bookkeeping ---
|
|
374
433
|
|
|
375
|
-
private stateFor(topicId: Uint8Array): PromotionState {
|
|
434
|
+
private stateFor(topicId: Uint8Array, now: number): PromotionState {
|
|
376
435
|
const key = bytesKey(topicId);
|
|
377
436
|
let state = this.states.get(key);
|
|
378
437
|
if (state === undefined) {
|
|
379
438
|
state = { promoted: false, samples: [] };
|
|
439
|
+
// Seed the replay ordering (and direction) from the node-level adopted-transition record, so a
|
|
440
|
+
// recreated engine picks up where the evicted one left off. The original sticky-window anchor is
|
|
441
|
+
// unrecoverable, so a seeded promotion re-arms `promotedAt` at `now` — that only delays demotion
|
|
442
|
+
// (the conservative direction), never allows an early one. `lowLoadSince` stays undefined: it is
|
|
443
|
+
// rebuilt from the store on the next `onParticipantCountChange`.
|
|
444
|
+
const seed = this.deps.seedTransition?.(topicId);
|
|
445
|
+
if (seed !== undefined) {
|
|
446
|
+
state.lastEffectiveAt = seed.effectiveAt;
|
|
447
|
+
state.promoted = seed.promoted;
|
|
448
|
+
if (seed.promoted) {
|
|
449
|
+
state.promotedAt = now;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
380
452
|
this.states.set(key, state);
|
|
381
453
|
}
|
|
382
454
|
return state;
|
|
@@ -1,188 +1,195 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cohort-topic wire substrate — shared structural-validation primitives.
|
|
3
|
-
*
|
|
4
|
-
* The generic per-field checks every wire codec across the substrate (cohort-topic, matchmaking,
|
|
5
|
-
* reactivity) uses to narrow an already-parsed `unknown` (the output of `JSON.parse` on a decoded
|
|
6
|
-
* frame) into a validated V1 shape. Each helper throws {@link CohortWireError} on a structural defect:
|
|
7
|
-
* missing required field, wrong `v`, out-of-enum discriminant, a byte field that does not decode as
|
|
8
|
-
* base64url, or an out-of-range numeric.
|
|
9
|
-
*
|
|
10
|
-
* These are the *only* generic primitives — domain-specific narrowing (the `validate*V1` functions, the
|
|
11
|
-
* cohort-topic `tier` / `treeTier` semantics, matchmaking utf8 coders, reactivity notification shapes)
|
|
12
|
-
* lives in the modules that own those message shapes. Keeping the primitives in one place means a
|
|
13
|
-
* hardening tweak to a check (e.g. the fixed-length byte-width guard in {@link b64urlFixedLen}) lands
|
|
14
|
-
* once and every consumer inherits it.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { b64urlToBytes } from "./codec.js";
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
export function
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Require `obj
|
|
55
|
-
export function
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
*
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Cohort-topic wire substrate — shared structural-validation primitives.
|
|
3
|
+
*
|
|
4
|
+
* The generic per-field checks every wire codec across the substrate (cohort-topic, matchmaking,
|
|
5
|
+
* reactivity) uses to narrow an already-parsed `unknown` (the output of `JSON.parse` on a decoded
|
|
6
|
+
* frame) into a validated V1 shape. Each helper throws {@link CohortWireError} on a structural defect:
|
|
7
|
+
* missing required field, wrong `v`, out-of-enum discriminant, a byte field that does not decode as
|
|
8
|
+
* base64url, or an out-of-range numeric.
|
|
9
|
+
*
|
|
10
|
+
* These are the *only* generic primitives — domain-specific narrowing (the `validate*V1` functions, the
|
|
11
|
+
* cohort-topic `tier` / `treeTier` semantics, matchmaking utf8 coders, reactivity notification shapes)
|
|
12
|
+
* lives in the modules that own those message shapes. Keeping the primitives in one place means a
|
|
13
|
+
* hardening tweak to a check (e.g. the fixed-length byte-width guard in {@link b64urlFixedLen}) lands
|
|
14
|
+
* once and every consumer inherits it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { b64urlToBytes } from "./codec.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Ring-coord / topic-id / cohort-epoch byte width (SHA-256 truncated to the ring width). Lives here,
|
|
21
|
+
* not in a single validator module, because both wires built on these primitives — cohort-topic and
|
|
22
|
+
* matchmaking — pin the same hash-derived fields to it.
|
|
23
|
+
*/
|
|
24
|
+
export const COORD_BYTES = 32;
|
|
25
|
+
|
|
26
|
+
/** Thrown for any malformed, oversized, or structurally invalid cohort-topic frame. */
|
|
27
|
+
export class CohortWireError extends Error {
|
|
28
|
+
constructor(message: string) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "CohortWireError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Throw a {@link CohortWireError} with `message`. */
|
|
35
|
+
export function failWire(message: string): never {
|
|
36
|
+
throw new CohortWireError(message);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Set `obj[key]` only when `value` is defined — keeps absent optionals off the decoded object. */
|
|
40
|
+
export function assignDefined<T extends object, K extends keyof T>(obj: T, key: K, value: T[K] | undefined): void {
|
|
41
|
+
if (value !== undefined) {
|
|
42
|
+
obj[key] = value;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Narrow `value` to a plain object (not null, not an array), or throw. */
|
|
47
|
+
export function asObject(value: unknown, what: string): Record<string, unknown> {
|
|
48
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
49
|
+
failWire(`${what}: expected an object`);
|
|
50
|
+
}
|
|
51
|
+
return value as Record<string, unknown>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Require `obj.v === 1`. */
|
|
55
|
+
export function requireV1(obj: Record<string, unknown>, what: string): void {
|
|
56
|
+
if (obj["v"] !== 1) {
|
|
57
|
+
failWire(`${what}: expected v === 1, got ${JSON.stringify(obj["v"])}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Require `obj[key]` to be a string; returns it. */
|
|
62
|
+
export function reqString(obj: Record<string, unknown>, key: string, what: string): string {
|
|
63
|
+
const value = obj[key];
|
|
64
|
+
if (typeof value !== "string") {
|
|
65
|
+
failWire(`${what}: field "${key}" must be a string`);
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Return `obj[key]` when it is a string, `undefined` when absent; throw on any other type. */
|
|
71
|
+
export function optString(obj: Record<string, unknown>, key: string, what: string): string | undefined {
|
|
72
|
+
const value = obj[key];
|
|
73
|
+
if (value === undefined) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
if (typeof value !== "string") {
|
|
77
|
+
failWire(`${what}: field "${key}" must be a string when present`);
|
|
78
|
+
}
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Require `obj[key]` to be a finite number; returns it. */
|
|
83
|
+
export function reqFiniteNumber(obj: Record<string, unknown>, key: string, what: string): number {
|
|
84
|
+
const value = obj[key];
|
|
85
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
86
|
+
failWire(`${what}: field "${key}" must be a finite number`);
|
|
87
|
+
}
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Return `obj[key]` when it is a finite number, `undefined` when absent; throw on any other type. */
|
|
92
|
+
export function optFiniteNumber(obj: Record<string, unknown>, key: string, what: string): number | undefined {
|
|
93
|
+
const value = obj[key];
|
|
94
|
+
if (value === undefined) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
98
|
+
failWire(`${what}: field "${key}" must be a finite number when present`);
|
|
99
|
+
}
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Require `obj[key]` to be a boolean; returns it. */
|
|
104
|
+
export function reqBool(obj: Record<string, unknown>, key: string, what: string): boolean {
|
|
105
|
+
const value = obj[key];
|
|
106
|
+
if (typeof value !== "boolean") {
|
|
107
|
+
failWire(`${what}: field "${key}" must be a boolean`);
|
|
108
|
+
}
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Return `obj[key]` when it is a boolean, `undefined` when absent; throw on any other type. */
|
|
113
|
+
export function optBool(obj: Record<string, unknown>, key: string, what: string): boolean | undefined {
|
|
114
|
+
const value = obj[key];
|
|
115
|
+
if (value === undefined) {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
if (typeof value !== "boolean") {
|
|
119
|
+
failWire(`${what}: field "${key}" must be a boolean when present`);
|
|
120
|
+
}
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Require `obj[key]` to be an integer `>= min` (and `<= max` when supplied); returns it. Reads and
|
|
126
|
+
* finite-checks the field itself (via {@link reqFiniteNumber}) so callers pass the object + key rather
|
|
127
|
+
* than a pre-extracted value — this composes with the other `req*` helpers.
|
|
128
|
+
*/
|
|
129
|
+
export function reqIntInRange(obj: Record<string, unknown>, key: string, what: string, min: number, max?: number): number {
|
|
130
|
+
const value = reqFiniteNumber(obj, key, what);
|
|
131
|
+
if (!Number.isInteger(value) || value < min || (max !== undefined && value > max)) {
|
|
132
|
+
const bound = max === undefined ? `>= ${min}` : `in ${min}..${max}`;
|
|
133
|
+
failWire(`${what}: field "${key}" must be an integer ${bound}, got ${value}`);
|
|
134
|
+
}
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Require `obj[key]` to be an array of strings; returns it. */
|
|
139
|
+
export function reqStringArray(obj: Record<string, unknown>, key: string, what: string): string[] {
|
|
140
|
+
const value = obj[key];
|
|
141
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
|
|
142
|
+
failWire(`${what}: field "${key}" must be an array of strings`);
|
|
143
|
+
}
|
|
144
|
+
return value as string[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Return `obj[key]` when it is an array of strings, `undefined` when absent; throw on any other type. */
|
|
148
|
+
export function optStringArray(obj: Record<string, unknown>, key: string, what: string): string[] | undefined {
|
|
149
|
+
if (obj[key] === undefined) {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
return reqStringArray(obj, key, what);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Require `obj[key]` to be one of `allowed`; returns it narrowed to the enum type. */
|
|
156
|
+
export function reqEnum<T extends string>(obj: Record<string, unknown>, key: string, allowed: readonly T[], what: string): T {
|
|
157
|
+
const value = obj[key];
|
|
158
|
+
if (typeof value !== "string" || !(allowed as readonly string[]).includes(value)) {
|
|
159
|
+
failWire(`${what}: field "${key}" must be one of ${allowed.join(" | ")}`);
|
|
160
|
+
}
|
|
161
|
+
return value as T;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Assert a base64url string decodes cleanly; returns it unchanged. Used for variable-width fields —
|
|
166
|
+
* peer ids (multihash-encoded, not 32 raw bytes), signatures, and opaque application payloads.
|
|
167
|
+
*
|
|
168
|
+
* NOTE: no max-length bound here. A hostile peer can still bloat one of these variable-width fields
|
|
169
|
+
* (e.g. `participantCoord`, a signature) into a large map key in the store / rate limiter / replay
|
|
170
|
+
* guard. Their widths aren't pinned by the spec, so a ceiling would be a chosen policy value rather
|
|
171
|
+
* than a decode of the format. If a bloated one is ever seen as a map key in practice, add a
|
|
172
|
+
* `b64urlMaxLen` ceiling here. (Fixed-width hash-derived fields go through `b64urlFixedLen` instead.)
|
|
173
|
+
*/
|
|
174
|
+
export function b64urlField(value: string, key: string, what: string): string {
|
|
175
|
+
try {
|
|
176
|
+
b64urlToBytes(value);
|
|
177
|
+
} catch {
|
|
178
|
+
failWire(`${what}: field "${key}" is not valid base64url`);
|
|
179
|
+
}
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Assert a base64url string decodes cleanly to exactly `len` bytes; returns it unchanged. */
|
|
184
|
+
export function b64urlFixedLen(value: string, key: string, len: number, what: string): string {
|
|
185
|
+
let bytes: Uint8Array;
|
|
186
|
+
try {
|
|
187
|
+
bytes = b64urlToBytes(value);
|
|
188
|
+
} catch {
|
|
189
|
+
return failWire(`${what}: field "${key}" is not valid base64url`);
|
|
190
|
+
}
|
|
191
|
+
if (bytes.length !== len) {
|
|
192
|
+
failWire(`${what}: field "${key}" must decode to ${len} bytes, got ${bytes.length}`);
|
|
193
|
+
}
|
|
194
|
+
return value;
|
|
195
|
+
}
|