@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.
Files changed (104) hide show
  1. package/dist/src/cohort-topic/membership/verifier.d.ts +8 -0
  2. package/dist/src/cohort-topic/membership/verifier.d.ts.map +1 -1
  3. package/dist/src/cohort-topic/membership/verifier.js +17 -0
  4. package/dist/src/cohort-topic/membership/verifier.js.map +1 -1
  5. package/dist/src/cohort-topic/promotion.d.ts +25 -0
  6. package/dist/src/cohort-topic/promotion.d.ts.map +1 -1
  7. package/dist/src/cohort-topic/promotion.js +58 -11
  8. package/dist/src/cohort-topic/promotion.js.map +1 -1
  9. package/dist/src/cohort-topic/wire/primitives.d.ts +6 -0
  10. package/dist/src/cohort-topic/wire/primitives.d.ts.map +1 -1
  11. package/dist/src/cohort-topic/wire/primitives.js +6 -0
  12. package/dist/src/cohort-topic/wire/primitives.js.map +1 -1
  13. package/dist/src/cohort-topic/wire/types.d.ts +5 -0
  14. package/dist/src/cohort-topic/wire/types.d.ts.map +1 -1
  15. package/dist/src/cohort-topic/wire/validate.d.ts.map +1 -1
  16. package/dist/src/cohort-topic/wire/validate.js +10 -19
  17. package/dist/src/cohort-topic/wire/validate.js.map +1 -1
  18. package/dist/src/collection/collection.d.ts +8 -1
  19. package/dist/src/collection/collection.d.ts.map +1 -1
  20. package/dist/src/collection/collection.js +19 -3
  21. package/dist/src/collection/collection.js.map +1 -1
  22. package/dist/src/matchmaking/wire.d.ts +2 -2
  23. package/dist/src/matchmaking/wire.d.ts.map +1 -1
  24. package/dist/src/matchmaking/wire.js +3 -3
  25. package/dist/src/matchmaking/wire.js.map +1 -1
  26. package/dist/src/network/struct.d.ts +5 -5
  27. package/dist/src/network/struct.d.ts.map +1 -1
  28. package/dist/src/testing/index.d.ts +1 -0
  29. package/dist/src/testing/index.d.ts.map +1 -1
  30. package/dist/src/testing/index.js +1 -0
  31. package/dist/src/testing/index.js.map +1 -1
  32. package/dist/src/testing/refresh-probe.d.ts +31 -0
  33. package/dist/src/testing/refresh-probe.d.ts.map +1 -0
  34. package/dist/src/testing/refresh-probe.js +39 -0
  35. package/dist/src/testing/refresh-probe.js.map +1 -0
  36. package/dist/src/testing/test-transactor.d.ts +58 -5
  37. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  38. package/dist/src/testing/test-transactor.js +87 -4
  39. package/dist/src/testing/test-transactor.js.map +1 -1
  40. package/dist/src/transaction/coordinator.d.ts +90 -6
  41. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  42. package/dist/src/transaction/coordinator.js +311 -90
  43. package/dist/src/transaction/coordinator.js.map +1 -1
  44. package/dist/src/transaction/errors.d.ts +42 -0
  45. package/dist/src/transaction/errors.d.ts.map +1 -1
  46. package/dist/src/transaction/errors.js +50 -0
  47. package/dist/src/transaction/errors.js.map +1 -1
  48. package/dist/src/transaction/index.d.ts +1 -1
  49. package/dist/src/transaction/index.d.ts.map +1 -1
  50. package/dist/src/transaction/index.js +1 -1
  51. package/dist/src/transaction/index.js.map +1 -1
  52. package/dist/src/transaction/session.d.ts +7 -3
  53. package/dist/src/transaction/session.d.ts.map +1 -1
  54. package/dist/src/transaction/session.js +7 -3
  55. package/dist/src/transaction/session.js.map +1 -1
  56. package/dist/src/transaction/transaction.d.ts +12 -2
  57. package/dist/src/transaction/transaction.d.ts.map +1 -1
  58. package/dist/src/transaction/transaction.js.map +1 -1
  59. package/dist/src/transactor/network-transactor.d.ts +1 -1
  60. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  61. package/dist/src/transactor/network-transactor.js +6 -26
  62. package/dist/src/transactor/network-transactor.js.map +1 -1
  63. package/dist/src/transactor/transactor-source.d.ts +4 -6
  64. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  65. package/dist/src/transactor/transactor-source.js +4 -6
  66. package/dist/src/transactor/transactor-source.js.map +1 -1
  67. package/dist/src/transform/atomic.d.ts.map +1 -1
  68. package/dist/src/transform/atomic.js +7 -0
  69. package/dist/src/transform/atomic.js.map +1 -1
  70. package/dist/src/transform/base-pins.d.ts +48 -0
  71. package/dist/src/transform/base-pins.d.ts.map +1 -0
  72. package/dist/src/transform/base-pins.js +65 -0
  73. package/dist/src/transform/base-pins.js.map +1 -0
  74. package/dist/src/transform/digest.d.ts +4 -2
  75. package/dist/src/transform/digest.d.ts.map +1 -1
  76. package/dist/src/transform/digest.js +14 -19
  77. package/dist/src/transform/digest.js.map +1 -1
  78. package/dist/src/transform/tracker.d.ts +31 -3
  79. package/dist/src/transform/tracker.d.ts.map +1 -1
  80. package/dist/src/transform/tracker.js +105 -13
  81. package/dist/src/transform/tracker.js.map +1 -1
  82. package/package.json +1 -1
  83. package/src/cohort-topic/membership/verifier.ts +487 -461
  84. package/src/cohort-topic/promotion.ts +86 -14
  85. package/src/cohort-topic/wire/primitives.ts +195 -188
  86. package/src/cohort-topic/wire/types.ts +480 -475
  87. package/src/cohort-topic/wire/validate.ts +504 -512
  88. package/src/collection/collection.ts +19 -3
  89. package/src/matchmaking/wire.ts +605 -603
  90. package/src/network/struct.ts +5 -5
  91. package/src/testing/index.ts +1 -0
  92. package/src/testing/refresh-probe.ts +52 -0
  93. package/src/testing/test-transactor.ts +100 -4
  94. package/src/transaction/coordinator.ts +312 -99
  95. package/src/transaction/errors.ts +141 -91
  96. package/src/transaction/index.ts +1 -1
  97. package/src/transaction/session.ts +7 -3
  98. package/src/transaction/transaction.ts +12 -2
  99. package/src/transactor/network-transactor.ts +1025 -1045
  100. package/src/transactor/transactor-source.ts +170 -172
  101. package/src/transform/atomic.ts +6 -0
  102. package/src/transform/base-pins.ts +83 -0
  103. package/src/transform/digest.ts +14 -19
  104. 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 **never cleared** (a demotion clears `promoted`/`promotedAt` but keeps this), so it
107
- * is the high-water mark the remote-apply path orders against: a notice whose `effectiveAt` is not
108
- * strictly newer is a no-op — re-applied (incl. this member's own echoed broadcast) or stale-replayed.
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, now)) {
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
- return this.states.get(bytesKey(topicId))?.promoted ?? false;
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, _now: number): void {
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, now: number): boolean {
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, now);
321
+ return this.slopePredictsCrossing(state, count);
267
322
  }
268
323
 
269
- /** Linear extrapolation over the growth window: will `directParticipants` cross `cap_promote` within lookahead? */
270
- private slopePredictsCrossing(state: PromotionState, count: number, now: number): boolean {
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
- /** Thrown for any malformed, oversized, or structurally invalid cohort-topic frame. */
20
- export class CohortWireError extends Error {
21
- constructor(message: string) {
22
- super(message);
23
- this.name = "CohortWireError";
24
- }
25
- }
26
-
27
- /** Throw a {@link CohortWireError} with `message`. */
28
- export function failWire(message: string): never {
29
- throw new CohortWireError(message);
30
- }
31
-
32
- /** Set `obj[key]` only when `value` is defined — keeps absent optionals off the decoded object. */
33
- export function assignDefined<T extends object, K extends keyof T>(obj: T, key: K, value: T[K] | undefined): void {
34
- if (value !== undefined) {
35
- obj[key] = value;
36
- }
37
- }
38
-
39
- /** Narrow `value` to a plain object (not null, not an array), or throw. */
40
- export function asObject(value: unknown, what: string): Record<string, unknown> {
41
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
42
- failWire(`${what}: expected an object`);
43
- }
44
- return value as Record<string, unknown>;
45
- }
46
-
47
- /** Require `obj.v === 1`. */
48
- export function requireV1(obj: Record<string, unknown>, what: string): void {
49
- if (obj["v"] !== 1) {
50
- failWire(`${what}: expected v === 1, got ${JSON.stringify(obj["v"])}`);
51
- }
52
- }
53
-
54
- /** Require `obj[key]` to be a string; returns it. */
55
- export function reqString(obj: Record<string, unknown>, key: string, what: string): string {
56
- const value = obj[key];
57
- if (typeof value !== "string") {
58
- failWire(`${what}: field "${key}" must be a string`);
59
- }
60
- return value;
61
- }
62
-
63
- /** Return `obj[key]` when it is a string, `undefined` when absent; throw on any other type. */
64
- export function optString(obj: Record<string, unknown>, key: string, what: string): string | undefined {
65
- const value = obj[key];
66
- if (value === undefined) {
67
- return undefined;
68
- }
69
- if (typeof value !== "string") {
70
- failWire(`${what}: field "${key}" must be a string when present`);
71
- }
72
- return value;
73
- }
74
-
75
- /** Require `obj[key]` to be a finite number; returns it. */
76
- export function reqFiniteNumber(obj: Record<string, unknown>, key: string, what: string): number {
77
- const value = obj[key];
78
- if (typeof value !== "number" || !Number.isFinite(value)) {
79
- failWire(`${what}: field "${key}" must be a finite number`);
80
- }
81
- return value;
82
- }
83
-
84
- /** Return `obj[key]` when it is a finite number, `undefined` when absent; throw on any other type. */
85
- export function optFiniteNumber(obj: Record<string, unknown>, key: string, what: string): number | undefined {
86
- const value = obj[key];
87
- if (value === undefined) {
88
- return undefined;
89
- }
90
- if (typeof value !== "number" || !Number.isFinite(value)) {
91
- failWire(`${what}: field "${key}" must be a finite number when present`);
92
- }
93
- return value;
94
- }
95
-
96
- /** Require `obj[key]` to be a boolean; returns it. */
97
- export function reqBool(obj: Record<string, unknown>, key: string, what: string): boolean {
98
- const value = obj[key];
99
- if (typeof value !== "boolean") {
100
- failWire(`${what}: field "${key}" must be a boolean`);
101
- }
102
- return value;
103
- }
104
-
105
- /** Return `obj[key]` when it is a boolean, `undefined` when absent; throw on any other type. */
106
- export function optBool(obj: Record<string, unknown>, key: string, what: string): boolean | undefined {
107
- const value = obj[key];
108
- if (value === undefined) {
109
- return undefined;
110
- }
111
- if (typeof value !== "boolean") {
112
- failWire(`${what}: field "${key}" must be a boolean when present`);
113
- }
114
- return value;
115
- }
116
-
117
- /**
118
- * Require `obj[key]` to be an integer `>= min` (and `<= max` when supplied); returns it. Reads and
119
- * finite-checks the field itself (via {@link reqFiniteNumber}) so callers pass the object + key rather
120
- * than a pre-extracted value — this composes with the other `req*` helpers.
121
- */
122
- export function reqIntInRange(obj: Record<string, unknown>, key: string, what: string, min: number, max?: number): number {
123
- const value = reqFiniteNumber(obj, key, what);
124
- if (!Number.isInteger(value) || value < min || (max !== undefined && value > max)) {
125
- const bound = max === undefined ? `>= ${min}` : `in ${min}..${max}`;
126
- failWire(`${what}: field "${key}" must be an integer ${bound}, got ${value}`);
127
- }
128
- return value;
129
- }
130
-
131
- /** Require `obj[key]` to be an array of strings; returns it. */
132
- export function reqStringArray(obj: Record<string, unknown>, key: string, what: string): string[] {
133
- const value = obj[key];
134
- if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
135
- failWire(`${what}: field "${key}" must be an array of strings`);
136
- }
137
- return value as string[];
138
- }
139
-
140
- /** Return `obj[key]` when it is an array of strings, `undefined` when absent; throw on any other type. */
141
- export function optStringArray(obj: Record<string, unknown>, key: string, what: string): string[] | undefined {
142
- if (obj[key] === undefined) {
143
- return undefined;
144
- }
145
- return reqStringArray(obj, key, what);
146
- }
147
-
148
- /** Require `obj[key]` to be one of `allowed`; returns it narrowed to the enum type. */
149
- export function reqEnum<T extends string>(obj: Record<string, unknown>, key: string, allowed: readonly T[], what: string): T {
150
- const value = obj[key];
151
- if (typeof value !== "string" || !(allowed as readonly string[]).includes(value)) {
152
- failWire(`${what}: field "${key}" must be one of ${allowed.join(" | ")}`);
153
- }
154
- return value as T;
155
- }
156
-
157
- /**
158
- * Assert a base64url string decodes cleanly; returns it unchanged. Used for variable-width fields —
159
- * peer ids (multihash-encoded, not 32 raw bytes), signatures, and opaque application payloads.
160
- *
161
- * NOTE: no max-length bound here. A hostile peer can still bloat one of these variable-width fields
162
- * (e.g. `participantCoord`, a signature) into a large map key in the store / rate limiter / replay
163
- * guard. Their widths aren't pinned by the spec, so a ceiling would be a chosen policy value rather
164
- * than a decode of the format. If a bloated one is ever seen as a map key in practice, add a
165
- * `b64urlMaxLen` ceiling here. (Fixed-width hash-derived fields go through `b64urlFixedLen` instead.)
166
- */
167
- export function b64urlField(value: string, key: string, what: string): string {
168
- try {
169
- b64urlToBytes(value);
170
- } catch {
171
- failWire(`${what}: field "${key}" is not valid base64url`);
172
- }
173
- return value;
174
- }
175
-
176
- /** Assert a base64url string decodes cleanly to exactly `len` bytes; returns it unchanged. */
177
- export function b64urlFixedLen(value: string, key: string, len: number, what: string): string {
178
- let bytes: Uint8Array;
179
- try {
180
- bytes = b64urlToBytes(value);
181
- } catch {
182
- return failWire(`${what}: field "${key}" is not valid base64url`);
183
- }
184
- if (bytes.length !== len) {
185
- failWire(`${what}: field "${key}" must decode to ${len} bytes, got ${bytes.length}`);
186
- }
187
- return value;
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
+ }