@optimystic/db-core 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +336 -336
- package/dist/src/cluster/structs.d.ts +39 -1
- package/dist/src/cluster/structs.d.ts.map +1 -1
- package/dist/src/cluster/structs.js +24 -0
- package/dist/src/cluster/structs.js.map +1 -1
- package/dist/src/collection/collection.d.ts +17 -0
- package/dist/src/collection/collection.d.ts.map +1 -1
- package/dist/src/collection/collection.js +24 -2
- package/dist/src/collection/collection.js.map +1 -1
- package/dist/src/collections/tree/tree.d.ts +5 -0
- package/dist/src/collections/tree/tree.d.ts.map +1 -1
- package/dist/src/collections/tree/tree.js +7 -0
- package/dist/src/collections/tree/tree.js.map +1 -1
- package/dist/src/network/i-peer-network.d.ts +16 -0
- package/dist/src/network/i-peer-network.d.ts.map +1 -1
- package/dist/src/network/struct.d.ts +39 -2
- package/dist/src/network/struct.d.ts.map +1 -1
- package/dist/src/network/struct.js +18 -0
- package/dist/src/network/struct.js.map +1 -1
- package/dist/src/testing/test-transactor.d.ts +95 -8
- package/dist/src/testing/test-transactor.d.ts.map +1 -1
- package/dist/src/testing/test-transactor.js +121 -8
- package/dist/src/testing/test-transactor.js.map +1 -1
- package/dist/src/transaction/transaction.d.ts +1 -1
- package/dist/src/transaction/transaction.js +1 -1
- package/dist/src/transactor/network-transactor.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.js +48 -13
- package/dist/src/transactor/network-transactor.js.map +1 -1
- package/dist/src/transactor/transactor-source.d.ts.map +1 -1
- package/dist/src/transactor/transactor-source.js +25 -2
- package/dist/src/transactor/transactor-source.js.map +1 -1
- package/package.json +1 -1
- package/src/cluster/membership.ts +85 -85
- package/src/cluster/structs.ts +43 -4
- package/src/cohort-topic/addressing.ts +120 -120
- package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +253 -253
- package/src/cohort-topic/antidos/bootstrap-evidence.ts +106 -106
- package/src/cohort-topic/antidos/index.ts +5 -5
- package/src/cohort-topic/antidos/rate-limiter.ts +210 -210
- package/src/cohort-topic/antidos/replay-guard.ts +146 -146
- package/src/cohort-topic/antidos/topic-budget.ts +160 -160
- package/src/cohort-topic/antiflood/index.ts +2 -2
- package/src/cohort-topic/antiflood/invariants.ts +108 -108
- package/src/cohort-topic/antiflood/jitter.ts +117 -117
- package/src/cohort-topic/coldstart.ts +237 -237
- package/src/cohort-topic/dmax.ts +88 -88
- package/src/cohort-topic/gossip/bus.ts +254 -254
- package/src/cohort-topic/gossip/index.ts +3 -3
- package/src/cohort-topic/gossip/records.ts +45 -45
- package/src/cohort-topic/gossip/view.ts +91 -91
- package/src/cohort-topic/index.ts +20 -20
- package/src/cohort-topic/load/barometer.ts +134 -134
- package/src/cohort-topic/load/index.ts +1 -1
- package/src/cohort-topic/member-engine.ts +430 -430
- package/src/cohort-topic/membership/index.ts +3 -3
- package/src/cohort-topic/membership/publisher.ts +163 -163
- package/src/cohort-topic/membership/source.ts +41 -41
- package/src/cohort-topic/membership/verifier.ts +461 -461
- package/src/cohort-topic/ports.ts +157 -157
- package/src/cohort-topic/promotion.ts +405 -405
- package/src/cohort-topic/registration/bytes.ts +37 -37
- package/src/cohort-topic/registration/handoff.ts +154 -154
- package/src/cohort-topic/registration/index.ts +6 -6
- package/src/cohort-topic/registration/renewal.ts +495 -495
- package/src/cohort-topic/registration/sharding.ts +61 -61
- package/src/cohort-topic/registration/store.ts +81 -81
- package/src/cohort-topic/registration/types.ts +91 -91
- package/src/cohort-topic/ring-hash.ts +50 -50
- package/src/cohort-topic/service.ts +416 -416
- package/src/cohort-topic/sig/index.ts +2 -2
- package/src/cohort-topic/sig/payloads.ts +59 -59
- package/src/cohort-topic/sig/threshold.ts +64 -64
- package/src/cohort-topic/tiers.ts +74 -74
- package/src/cohort-topic/traffic.ts +233 -233
- package/src/cohort-topic/walk.ts +326 -326
- package/src/cohort-topic/willingness.ts +237 -237
- package/src/cohort-topic/wire/codec.ts +216 -216
- package/src/cohort-topic/wire/index.ts +18 -18
- package/src/cohort-topic/wire/payloads.ts +126 -126
- package/src/cohort-topic/wire/primitives.ts +188 -188
- package/src/cohort-topic/wire/types.ts +475 -475
- package/src/cohort-topic/wire/validate.ts +512 -512
- package/src/collection/collection-type-registry.ts +37 -37
- package/src/collection/collection.ts +25 -2
- package/src/collections/diary/diary.ts +68 -68
- package/src/collections/tree/readme.md +4 -0
- package/src/collections/tree/tree.ts +320 -312
- package/src/matchmaking/capability-filter.ts +45 -45
- package/src/matchmaking/config.ts +98 -98
- package/src/matchmaking/index.ts +21 -21
- package/src/matchmaking/multi-cohort-seeker.ts +234 -234
- package/src/matchmaking/provider.ts +123 -123
- package/src/matchmaking/query-eval.ts +105 -105
- package/src/matchmaking/seeker-walk.ts +127 -127
- package/src/matchmaking/seeker.ts +86 -86
- package/src/matchmaking/topic-anchor.ts +90 -90
- package/src/matchmaking/voting-quorum.ts +394 -394
- package/src/matchmaking/wire.ts +603 -603
- package/src/network/i-peer-network.ts +17 -0
- package/src/network/stale-failure.ts +43 -43
- package/src/network/struct.ts +41 -2
- package/src/network/types.ts +37 -37
- package/src/reactivity/backfill.ts +220 -220
- package/src/reactivity/backpressure.ts +191 -191
- package/src/reactivity/checkpoint.ts +308 -308
- package/src/reactivity/config.ts +172 -172
- package/src/reactivity/dedupe.ts +132 -132
- package/src/reactivity/forwarder.ts +87 -87
- package/src/reactivity/index.ts +34 -34
- package/src/reactivity/notification.ts +123 -123
- package/src/reactivity/policy.ts +79 -79
- package/src/reactivity/push-state.ts +310 -310
- package/src/reactivity/recover.ts +153 -153
- package/src/reactivity/replay-buffer.ts +141 -141
- package/src/reactivity/resume.ts +549 -549
- package/src/reactivity/rotation.ts +415 -415
- package/src/reactivity/subscriber.ts +132 -132
- package/src/reactivity/subscription.ts +66 -66
- package/src/reactivity/topic-anchor.ts +71 -71
- package/src/reactivity/verify.ts +73 -73
- package/src/reactivity/wire-validate.ts +13 -13
- package/src/reactivity/wire.ts +224 -224
- package/src/testing/async-wait.ts +65 -65
- package/src/testing/index.ts +2 -2
- package/src/testing/test-transactor.ts +638 -502
- package/src/transaction/errors.ts +91 -91
- package/src/transaction/operations-hash.ts +196 -196
- package/src/transaction/read-dependency-collector.ts +78 -78
- package/src/transaction/transaction.ts +1 -1
- package/src/transactor/change-notifier.ts +80 -80
- package/src/transactor/index.ts +5 -5
- package/src/transactor/network-transactor.ts +49 -14
- package/src/transactor/transactor-source.ts +25 -2
- package/src/transform/atomic-proxy.ts +92 -92
- package/src/transform/helpers.ts +159 -159
- package/src/utility/backoff.ts +95 -95
- package/src/utility/batch-coordinator.ts +191 -191
- package/dist/src/transaction/context.d.ts +0 -60
- package/dist/src/transaction/context.d.ts.map +0 -1
- package/dist/src/transaction/context.js +0 -91
- package/dist/src/transaction/context.js.map +0 -1
|
@@ -1,126 +1,126 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cohort-topic substrate — canonical participant-signature payloads.
|
|
3
|
-
*
|
|
4
|
-
* A participant peer-key-signs its outbound `RegisterV1` / `RenewV1` bodies (db-p2p supplies the
|
|
5
|
-
* libp2p peer key); a cohort member recomputes the identical byte image to verify that signature
|
|
6
|
-
* before admitting a register (anti-DoS) or honoring a crash-failover `reattach` attestation (so a
|
|
7
|
-
* stray/MITM'd ping cannot usurp a live primary — `docs/cohort-topic.md` §TTL and renewal).
|
|
8
|
-
*
|
|
9
|
-
* Signer and verifier must agree byte-for-byte. Exactly like {@link import("../sig/payloads.js")}
|
|
10
|
-
* for the threshold-signed notices, determinism comes from encoding an explicitly-ordered JSON
|
|
11
|
-
* array (array order is stable, unlike object key order) as UTF-8 — never the `signature` envelope.
|
|
12
|
-
* Optional fields are normalized to a fixed placeholder (`bootstrap`→`false`, `probe`→`false`,
|
|
13
|
-
* `followOn`→`false`, `appPayload`→`null`, `bootstrapEvidence`→`null` with an empty string treated as
|
|
14
|
-
* absent, `reattach`→`false`, `withdraw`→`false`) so an absent optional and a present-but-default
|
|
15
|
-
* optional can never disagree across the wire round-trip.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import type { CohortGossipV1, RegisterV1, RenewV1 } from "./types.js";
|
|
19
|
-
|
|
20
|
-
const utf8 = new TextEncoder();
|
|
21
|
-
|
|
22
|
-
/** A `RegisterV1` minus its `signature` envelope — the bytes the participant peer-key-signs. */
|
|
23
|
-
export type RegisterSignable = Omit<RegisterV1, "signature">;
|
|
24
|
-
|
|
25
|
-
/** A `RenewV1` minus its `signature` envelope — the bytes the participant peer-key-signs. */
|
|
26
|
-
export type RenewSignable = Omit<RenewV1, "signature">;
|
|
27
|
-
|
|
28
|
-
/** Normalize the optional bootstrap-evidence slot: absent and empty-string both map to `null`. */
|
|
29
|
-
function normalizeEvidence(bootstrapEvidence: string | undefined): string | null {
|
|
30
|
-
return bootstrapEvidence === undefined || bootstrapEvidence === "" ? null : bootstrapEvidence;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Canonical signed byte image of a `RegisterV1` body (every field except `signature`). */
|
|
34
|
-
export function registerSigningPayload(body: RegisterSignable): Uint8Array {
|
|
35
|
-
return utf8.encode(JSON.stringify([
|
|
36
|
-
"RegisterV1",
|
|
37
|
-
body.v,
|
|
38
|
-
body.topicId,
|
|
39
|
-
body.tier,
|
|
40
|
-
body.treeTier,
|
|
41
|
-
body.participantCoord,
|
|
42
|
-
body.ttl,
|
|
43
|
-
body.bootstrap ?? false,
|
|
44
|
-
body.appPayload ?? null,
|
|
45
|
-
normalizeEvidence(body.bootstrapEvidence),
|
|
46
|
-
body.timestamp,
|
|
47
|
-
body.correlationId,
|
|
48
|
-
// A read-only probe signs `probe: true`; a normal register signs `false`. Both sides recompute
|
|
49
|
-
// this image identically, so a probe and a register over the same fields produce distinct signatures.
|
|
50
|
-
body.probe ?? false,
|
|
51
|
-
// A follow-on cold-start re-issue signs `followOn: true`, everything else `false`. Strictly appended
|
|
52
|
-
// (sibling to `probe`/`withdraw`) so a MITM cannot strip or flip the follow-on assertion and the
|
|
53
|
-
// signer (participant) + verifier (db-p2p cohort) agree byte-for-byte. Safe to append: no register
|
|
54
|
-
// signatures are persisted (each is recomputed per verify), so there is no cross-version image concern.
|
|
55
|
-
body.followOn ?? false,
|
|
56
|
-
]));
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Canonical signed byte image of a `RenewV1` body (every field except `signature`). */
|
|
60
|
-
export function renewSigningPayload(body: RenewSignable): Uint8Array {
|
|
61
|
-
return utf8.encode(JSON.stringify([
|
|
62
|
-
"RenewV1",
|
|
63
|
-
body.v,
|
|
64
|
-
body.topicId,
|
|
65
|
-
body.participantId,
|
|
66
|
-
body.correlationId,
|
|
67
|
-
body.timestamp,
|
|
68
|
-
body.reattach ?? false,
|
|
69
|
-
// A withdraw tombstone signs `withdraw: true`, a ping/reattach signs `false`. Strictly appended
|
|
70
|
-
// (array length 7→8) so the reattach-vs-ping distinction is preserved and a third party cannot
|
|
71
|
-
// evict someone else's registration. Signer (participant) + verifier (db-p2p) move together; no
|
|
72
|
-
// signatures are persisted, so there is no cross-version concern.
|
|
73
|
-
body.withdraw ?? false,
|
|
74
|
-
]));
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** A `CohortGossipV1` minus its `signature` envelope — the bytes the gossiping member peer-key-signs. */
|
|
78
|
-
export type CohortGossipSignable = Omit<CohortGossipV1, "signature">;
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Canonical signed byte image of a `CohortGossipV1` (every field except `signature`). Intra-cohort
|
|
82
|
-
* gossip is peer-key-signed by the originating member so a receiver can drop a frame whose `fromMember`
|
|
83
|
-
* signature does not verify or that comes from a non-cohort member (it can never spoof willingness/load
|
|
84
|
-
* or replicate forged records). Like the other payload helpers, determinism comes from an
|
|
85
|
-
* explicitly-ordered array — nested records/summaries/evictions are emitted as fixed ordered tuples so
|
|
86
|
-
* the signer and the receiver (which re-derives this image from the validated frame) agree byte-for-byte.
|
|
87
|
-
* Absent optionals (`records`/`evicted`/`childLinks`/`childUnlinks`) normalize to `[]`, and `appState` to
|
|
88
|
-
* `null`. `childLinks`/`childUnlinks` are covered so a MITM cannot strip or inject a child link/unlink.
|
|
89
|
-
*/
|
|
90
|
-
export function cohortGossipSigningPayload(g: CohortGossipSignable): Uint8Array {
|
|
91
|
-
return utf8.encode(JSON.stringify([
|
|
92
|
-
"CohortGossipV1",
|
|
93
|
-
g.v,
|
|
94
|
-
g.fromMember,
|
|
95
|
-
g.coord,
|
|
96
|
-
g.cohortEpoch,
|
|
97
|
-
g.treeTier,
|
|
98
|
-
g.willingnessBits,
|
|
99
|
-
g.loadBuckets,
|
|
100
|
-
g.windowSeconds,
|
|
101
|
-
g.topicSummaries.map((s) => [
|
|
102
|
-
s.topicId,
|
|
103
|
-
s.tier,
|
|
104
|
-
s.directParticipants,
|
|
105
|
-
s.arrivalsPerMin,
|
|
106
|
-
s.queriesPerMin,
|
|
107
|
-
s.promoted,
|
|
108
|
-
s.childCohortCount,
|
|
109
|
-
]),
|
|
110
|
-
(g.records ?? []).map((r) => [
|
|
111
|
-
r.topicId,
|
|
112
|
-
r.participantId,
|
|
113
|
-
r.tier,
|
|
114
|
-
r.primary,
|
|
115
|
-
r.backups,
|
|
116
|
-
r.attachedAt,
|
|
117
|
-
r.lastPing,
|
|
118
|
-
r.ttl,
|
|
119
|
-
r.appState ?? null,
|
|
120
|
-
]),
|
|
121
|
-
(g.evicted ?? []).map((e) => [e.topicId, e.participantId, e.lastPing]),
|
|
122
|
-
(g.childLinks ?? []).map((c) => [c.topicId, c.childCohortCoord, c.effectiveAt]),
|
|
123
|
-
(g.childUnlinks ?? []).map((c) => [c.topicId, c.childCohortCoord, c.effectiveAt]),
|
|
124
|
-
g.timestamp,
|
|
125
|
-
]));
|
|
126
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Cohort-topic substrate — canonical participant-signature payloads.
|
|
3
|
+
*
|
|
4
|
+
* A participant peer-key-signs its outbound `RegisterV1` / `RenewV1` bodies (db-p2p supplies the
|
|
5
|
+
* libp2p peer key); a cohort member recomputes the identical byte image to verify that signature
|
|
6
|
+
* before admitting a register (anti-DoS) or honoring a crash-failover `reattach` attestation (so a
|
|
7
|
+
* stray/MITM'd ping cannot usurp a live primary — `docs/cohort-topic.md` §TTL and renewal).
|
|
8
|
+
*
|
|
9
|
+
* Signer and verifier must agree byte-for-byte. Exactly like {@link import("../sig/payloads.js")}
|
|
10
|
+
* for the threshold-signed notices, determinism comes from encoding an explicitly-ordered JSON
|
|
11
|
+
* array (array order is stable, unlike object key order) as UTF-8 — never the `signature` envelope.
|
|
12
|
+
* Optional fields are normalized to a fixed placeholder (`bootstrap`→`false`, `probe`→`false`,
|
|
13
|
+
* `followOn`→`false`, `appPayload`→`null`, `bootstrapEvidence`→`null` with an empty string treated as
|
|
14
|
+
* absent, `reattach`→`false`, `withdraw`→`false`) so an absent optional and a present-but-default
|
|
15
|
+
* optional can never disagree across the wire round-trip.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { CohortGossipV1, RegisterV1, RenewV1 } from "./types.js";
|
|
19
|
+
|
|
20
|
+
const utf8 = new TextEncoder();
|
|
21
|
+
|
|
22
|
+
/** A `RegisterV1` minus its `signature` envelope — the bytes the participant peer-key-signs. */
|
|
23
|
+
export type RegisterSignable = Omit<RegisterV1, "signature">;
|
|
24
|
+
|
|
25
|
+
/** A `RenewV1` minus its `signature` envelope — the bytes the participant peer-key-signs. */
|
|
26
|
+
export type RenewSignable = Omit<RenewV1, "signature">;
|
|
27
|
+
|
|
28
|
+
/** Normalize the optional bootstrap-evidence slot: absent and empty-string both map to `null`. */
|
|
29
|
+
function normalizeEvidence(bootstrapEvidence: string | undefined): string | null {
|
|
30
|
+
return bootstrapEvidence === undefined || bootstrapEvidence === "" ? null : bootstrapEvidence;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Canonical signed byte image of a `RegisterV1` body (every field except `signature`). */
|
|
34
|
+
export function registerSigningPayload(body: RegisterSignable): Uint8Array {
|
|
35
|
+
return utf8.encode(JSON.stringify([
|
|
36
|
+
"RegisterV1",
|
|
37
|
+
body.v,
|
|
38
|
+
body.topicId,
|
|
39
|
+
body.tier,
|
|
40
|
+
body.treeTier,
|
|
41
|
+
body.participantCoord,
|
|
42
|
+
body.ttl,
|
|
43
|
+
body.bootstrap ?? false,
|
|
44
|
+
body.appPayload ?? null,
|
|
45
|
+
normalizeEvidence(body.bootstrapEvidence),
|
|
46
|
+
body.timestamp,
|
|
47
|
+
body.correlationId,
|
|
48
|
+
// A read-only probe signs `probe: true`; a normal register signs `false`. Both sides recompute
|
|
49
|
+
// this image identically, so a probe and a register over the same fields produce distinct signatures.
|
|
50
|
+
body.probe ?? false,
|
|
51
|
+
// A follow-on cold-start re-issue signs `followOn: true`, everything else `false`. Strictly appended
|
|
52
|
+
// (sibling to `probe`/`withdraw`) so a MITM cannot strip or flip the follow-on assertion and the
|
|
53
|
+
// signer (participant) + verifier (db-p2p cohort) agree byte-for-byte. Safe to append: no register
|
|
54
|
+
// signatures are persisted (each is recomputed per verify), so there is no cross-version image concern.
|
|
55
|
+
body.followOn ?? false,
|
|
56
|
+
]));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Canonical signed byte image of a `RenewV1` body (every field except `signature`). */
|
|
60
|
+
export function renewSigningPayload(body: RenewSignable): Uint8Array {
|
|
61
|
+
return utf8.encode(JSON.stringify([
|
|
62
|
+
"RenewV1",
|
|
63
|
+
body.v,
|
|
64
|
+
body.topicId,
|
|
65
|
+
body.participantId,
|
|
66
|
+
body.correlationId,
|
|
67
|
+
body.timestamp,
|
|
68
|
+
body.reattach ?? false,
|
|
69
|
+
// A withdraw tombstone signs `withdraw: true`, a ping/reattach signs `false`. Strictly appended
|
|
70
|
+
// (array length 7→8) so the reattach-vs-ping distinction is preserved and a third party cannot
|
|
71
|
+
// evict someone else's registration. Signer (participant) + verifier (db-p2p) move together; no
|
|
72
|
+
// signatures are persisted, so there is no cross-version concern.
|
|
73
|
+
body.withdraw ?? false,
|
|
74
|
+
]));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A `CohortGossipV1` minus its `signature` envelope — the bytes the gossiping member peer-key-signs. */
|
|
78
|
+
export type CohortGossipSignable = Omit<CohortGossipV1, "signature">;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Canonical signed byte image of a `CohortGossipV1` (every field except `signature`). Intra-cohort
|
|
82
|
+
* gossip is peer-key-signed by the originating member so a receiver can drop a frame whose `fromMember`
|
|
83
|
+
* signature does not verify or that comes from a non-cohort member (it can never spoof willingness/load
|
|
84
|
+
* or replicate forged records). Like the other payload helpers, determinism comes from an
|
|
85
|
+
* explicitly-ordered array — nested records/summaries/evictions are emitted as fixed ordered tuples so
|
|
86
|
+
* the signer and the receiver (which re-derives this image from the validated frame) agree byte-for-byte.
|
|
87
|
+
* Absent optionals (`records`/`evicted`/`childLinks`/`childUnlinks`) normalize to `[]`, and `appState` to
|
|
88
|
+
* `null`. `childLinks`/`childUnlinks` are covered so a MITM cannot strip or inject a child link/unlink.
|
|
89
|
+
*/
|
|
90
|
+
export function cohortGossipSigningPayload(g: CohortGossipSignable): Uint8Array {
|
|
91
|
+
return utf8.encode(JSON.stringify([
|
|
92
|
+
"CohortGossipV1",
|
|
93
|
+
g.v,
|
|
94
|
+
g.fromMember,
|
|
95
|
+
g.coord,
|
|
96
|
+
g.cohortEpoch,
|
|
97
|
+
g.treeTier,
|
|
98
|
+
g.willingnessBits,
|
|
99
|
+
g.loadBuckets,
|
|
100
|
+
g.windowSeconds,
|
|
101
|
+
g.topicSummaries.map((s) => [
|
|
102
|
+
s.topicId,
|
|
103
|
+
s.tier,
|
|
104
|
+
s.directParticipants,
|
|
105
|
+
s.arrivalsPerMin,
|
|
106
|
+
s.queriesPerMin,
|
|
107
|
+
s.promoted,
|
|
108
|
+
s.childCohortCount,
|
|
109
|
+
]),
|
|
110
|
+
(g.records ?? []).map((r) => [
|
|
111
|
+
r.topicId,
|
|
112
|
+
r.participantId,
|
|
113
|
+
r.tier,
|
|
114
|
+
r.primary,
|
|
115
|
+
r.backups,
|
|
116
|
+
r.attachedAt,
|
|
117
|
+
r.lastPing,
|
|
118
|
+
r.ttl,
|
|
119
|
+
r.appState ?? null,
|
|
120
|
+
]),
|
|
121
|
+
(g.evicted ?? []).map((e) => [e.topicId, e.participantId, e.lastPing]),
|
|
122
|
+
(g.childLinks ?? []).map((c) => [c.topicId, c.childCohortCoord, c.effectiveAt]),
|
|
123
|
+
(g.childUnlinks ?? []).map((c) => [c.topicId, c.childCohortCoord, c.effectiveAt]),
|
|
124
|
+
g.timestamp,
|
|
125
|
+
]));
|
|
126
|
+
}
|
|
@@ -1,188 +1,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
|
-
/** 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
|
+
/** 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
|
+
}
|