@mmstack/mesh-protocol 0.1.1 → 0.3.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 +42 -20
- package/index.d.ts +167 -41
- package/index.js +243 -47
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,11 +23,13 @@ change; the relay assigns each one a room-scoped sequence number and fans it out
|
|
|
23
23
|
relay only orders and stores opaque ops, it needs to understand nothing about your data. That
|
|
24
24
|
is the point: a smart client with a dumb server.
|
|
25
25
|
|
|
26
|
-
The relay is deliberately small. It sequences, keeps a journal,
|
|
27
|
-
joining client with whatever it is missing, routes presence, and enforces an
|
|
28
|
-
It does not merge, validate schemas, or hold application logic. Conflict
|
|
29
|
-
client's job
|
|
30
|
-
|
|
26
|
+
The relay is deliberately small. It sequences, keeps a journal, retains per-path register
|
|
27
|
+
state, answers a joining client with whatever it is missing, routes presence, and enforces an
|
|
28
|
+
optional policy. It does not merge, validate schemas, or hold application logic. Conflict
|
|
29
|
+
resolution is the client's job: how concurrent writes fold into a value is client-configured
|
|
30
|
+
policy, which is exactly why the relay never materializes a value of its own. It retains the
|
|
31
|
+
concurrent writes and their supersession watermarks per path, and every client folds that same
|
|
32
|
+
register state with its own rules.
|
|
31
33
|
|
|
32
34
|
## The envelope
|
|
33
35
|
|
|
@@ -39,10 +41,16 @@ type OpEnvelope = {
|
|
|
39
41
|
version: number; // per-origin counter, for gap detection
|
|
40
42
|
hlc: { p: number; l: number }; // hybrid logical clock, for last-writer-wins ordering
|
|
41
43
|
policyVersion: number; // the room policy this writer validated against
|
|
42
|
-
ops: readonly
|
|
44
|
+
ops: readonly SyncOp[];
|
|
43
45
|
};
|
|
44
46
|
```
|
|
45
47
|
|
|
48
|
+
Each op is a structural `set`, `delete`, or `clear` plus two pieces of causal metadata: `cites`
|
|
49
|
+
lists the sibling writes the emitter observed at that path (exactly those get superseded; an
|
|
50
|
+
uncited concurrent write survives as a sibling), and `epoch` is the op's precedence term. An op
|
|
51
|
+
without citations cannot be merged soundly, so envelopes from another protocol version are
|
|
52
|
+
rejected outright rather than silently mixed into a room.
|
|
53
|
+
|
|
46
54
|
`origin` and `writer` are separate on purpose. Two tabs of one signed-in user share a `writer`
|
|
47
55
|
but differ by `origin`. `writer` is a stable, opaque pseudonym: the protocol forbids putting a
|
|
48
56
|
real name in the envelope, so a person's display name lives in a mutable directory outside the
|
|
@@ -58,10 +66,14 @@ const relay = createRelay({
|
|
|
58
66
|
policyVersion: 1,
|
|
59
67
|
policy: myOpPolicy, // optional, see below
|
|
60
68
|
limits: { maxOpsPerEnvelope: 1024, maxEnvelopesPerSecond: 50 },
|
|
61
|
-
journalLimit: 1000, //
|
|
69
|
+
journalLimit: 1000, // envelopes kept for delta catch-up before compacting into register state
|
|
62
70
|
});
|
|
63
71
|
```
|
|
64
72
|
|
|
73
|
+
One sizing note: a subtree replace legitimately emits one `set` plus one `clear` per observed
|
|
74
|
+
live descendant register in a single envelope, so a tightened `maxOpsPerEnvelope` must still
|
|
75
|
+
accommodate honest clear-groups.
|
|
76
|
+
|
|
65
77
|
`relay.connect(socket, ctx)` attaches one authenticated connection and returns
|
|
66
78
|
`{ receive, disconnect }`. You pump inbound frames into `receive` and call `disconnect` on
|
|
67
79
|
close. The `socket` is anything with `send(msg)` and `close()`, so the same relay drives a
|
|
@@ -71,9 +83,11 @@ When a client joins, the relay answers with one of three shapes:
|
|
|
71
83
|
|
|
72
84
|
- `up-to-date` when the client already has the latest sequence,
|
|
73
85
|
- `delta` with just the envelopes it missed, for a quick reconnect,
|
|
74
|
-
- `snapshot` with the
|
|
75
|
-
|
|
76
|
-
|
|
86
|
+
- `snapshot` with the room's register state, when the client is too far behind for the journal
|
|
87
|
+
to cover. The client folds the registers with its own merge policy to derive its root, so a
|
|
88
|
+
late joiner ends up with exactly the state (and the supersession knowledge) of a peer that
|
|
89
|
+
saw every envelope. Deletes ride along as tombstone registers, so a late joiner never
|
|
90
|
+
resurrects a removed key.
|
|
77
91
|
|
|
78
92
|
## Trust: `OpPolicy` and the tripwire
|
|
79
93
|
|
|
@@ -97,6 +111,14 @@ matches a path. For richer rules, write your own `OpPolicy` with `canWrite` and
|
|
|
97
111
|
Schema-aware validation (deriving a policy from your data model) composes on top and stays in
|
|
98
112
|
your codebase, not here.
|
|
99
113
|
|
|
114
|
+
Two boundaries to be clear about. Policy gates writes, not reads: every member of a room sees
|
|
115
|
+
the whole root, so the room is the confidentiality boundary, and data with different audiences
|
|
116
|
+
belongs in different rooms. A `clear` op counts as a write at its path, so ACLs see a subtree
|
|
117
|
+
replace's clear-group like any other write. And because the relay compacts envelopes into
|
|
118
|
+
register state, it reads plaintext; end-to-end encryption where the server sees only
|
|
119
|
+
ciphertext is incompatible with server-side compaction as designed. Encrypt the transport and
|
|
120
|
+
the stored data, but treat the relay as inside the trust boundary.
|
|
121
|
+
|
|
100
122
|
## Adapter recipes
|
|
101
123
|
|
|
102
124
|
The relay is pure over injected sockets, so an adapter is a few lines of glue.
|
|
@@ -145,12 +167,12 @@ Rooms live in memory. That covers dev and single-process deployments, and when t
|
|
|
145
167
|
restarts, the first client to rejoin seeds the room from its own local state, so nothing is
|
|
146
168
|
lost as long as somebody was online. For durability beyond that, the relay exposes a seam
|
|
147
169
|
rather than a storage engine, because the envelope already is the persistence record: an
|
|
148
|
-
event-sourced journal is just `
|
|
170
|
+
event-sourced journal is just `register checkpoint + envelopes`, compacted by re-checkpointing.
|
|
149
171
|
|
|
150
|
-
`onCommit` fires after every envelope is sequenced and
|
|
151
|
-
room's current `{ seq,
|
|
152
|
-
|
|
153
|
-
your adapter:
|
|
172
|
+
`onCommit` fires after every envelope is sequenced and retained, with the envelope and the
|
|
173
|
+
room's current `{ seq, instance, registers, wm, schemaVersion }`. Append the envelope to your
|
|
174
|
+
journal, and checkpoint the register state as often as you like. The relay never awaits it, so
|
|
175
|
+
batching and backpressure belong to your adapter:
|
|
154
176
|
|
|
155
177
|
```ts
|
|
156
178
|
const relay = createRelay({
|
|
@@ -169,16 +191,16 @@ members, so your load can race a fast client without corrupting a live sequence
|
|
|
169
191
|
const saved = await checkpoints.get(roomName);
|
|
170
192
|
if (saved) {
|
|
171
193
|
relay.hydrate(roomName, {
|
|
172
|
-
...saved, // seq,
|
|
194
|
+
...saved, // seq, instance, registers, wm
|
|
173
195
|
journal: await journal.tail(roomName, saved.seq),
|
|
174
196
|
});
|
|
175
197
|
}
|
|
176
198
|
```
|
|
177
199
|
|
|
178
|
-
Restoring the persisted `
|
|
179
|
-
keep their sequence watermark and catch up with a cheap `delta` answer. Omit it and
|
|
180
|
-
back to a full snapshot, which is always safe. The optional journal tail is only
|
|
181
|
-
those delta answers possible; the room is complete without it.
|
|
200
|
+
Restoring the persisted `instance` nonce is what lets clients that were connected before the
|
|
201
|
+
restart keep their sequence watermark and catch up with a cheap `delta` answer. Omit it and
|
|
202
|
+
they fall back to a full snapshot, which is always safe. The optional journal tail is only
|
|
203
|
+
there to make those delta answers possible; the room is complete without it.
|
|
182
204
|
|
|
183
205
|
## WebRTC signaling
|
|
184
206
|
|
package/index.d.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
//#region src/lib/wire.d.ts
|
|
2
2
|
type Key = string | number;
|
|
3
|
+
/**
|
|
4
|
+
* One structural operation. `set` and `delete` change a value at a path; `clear` retires a
|
|
5
|
+
* per-path register without contributing a value (the observed-remove half of a subtree
|
|
6
|
+
* replace). A `clear` is still a WRITE at its path for policy purposes.
|
|
7
|
+
*/
|
|
3
8
|
type StoreOp = {
|
|
4
9
|
kind: 'set';
|
|
5
10
|
path: readonly Key[];
|
|
@@ -9,13 +14,36 @@ type StoreOp = {
|
|
|
9
14
|
kind: 'delete';
|
|
10
15
|
path: readonly Key[];
|
|
11
16
|
prev: unknown;
|
|
17
|
+
} | {
|
|
18
|
+
kind: 'clear';
|
|
19
|
+
path: readonly Key[];
|
|
12
20
|
};
|
|
13
21
|
/** Hybrid logical clock stamp: physical epoch ms + logical counter. */
|
|
14
22
|
type Hlc = {
|
|
15
23
|
readonly p: number;
|
|
16
24
|
readonly l: number;
|
|
17
25
|
};
|
|
18
|
-
|
|
26
|
+
/** The identity of one write at one path: the emitting replica plus its clock stamp. */
|
|
27
|
+
type Dot = {
|
|
28
|
+
readonly origin: string;
|
|
29
|
+
readonly hlc: Hlc;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* A wire op: a structural {@link StoreOp} plus the causal metadata the per-path register
|
|
33
|
+
* needs. `cites` lists the sibling dot(s) the writer observed at the op's path when it wrote
|
|
34
|
+
* (exactly those get superseded); `epoch` is the op's precedence term, stamped at emission.
|
|
35
|
+
*/
|
|
36
|
+
type SyncOp = StoreOp & {
|
|
37
|
+
readonly cites: readonly Dot[];
|
|
38
|
+
readonly epoch: number;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Wire protocol version. Version 2 ops carry `cites` + `epoch`: an op without citations
|
|
42
|
+
* cannot be merged soundly (it would supersede nothing and its siblings would accumulate
|
|
43
|
+
* forever), so the relay rejects envelopes from any other protocol version outright rather
|
|
44
|
+
* than silently mixing pre-citation emitters into a room.
|
|
45
|
+
*/
|
|
46
|
+
declare const MESH_PROTO_VERSION = 2;
|
|
19
47
|
type OpEnvelope = {
|
|
20
48
|
readonly proto: number;
|
|
21
49
|
readonly origin: string;
|
|
@@ -23,12 +51,43 @@ type OpEnvelope = {
|
|
|
23
51
|
readonly version: number;
|
|
24
52
|
readonly hlc: Hlc;
|
|
25
53
|
readonly policyVersion: number;
|
|
26
|
-
readonly ops: readonly
|
|
54
|
+
readonly ops: readonly SyncOp[];
|
|
55
|
+
/**
|
|
56
|
+
* Present only on a MIGRATION envelope: the new `schemaVersion` this envelope
|
|
57
|
+
* establishes. The relay bumps the room's schema + instance when it sequences one; normal
|
|
58
|
+
* writes omit it.
|
|
59
|
+
*/
|
|
60
|
+
readonly schemaVersion?: number;
|
|
27
61
|
};
|
|
28
62
|
/** An envelope the relay has ordered: `seq` is the room-scoped total order. */
|
|
29
63
|
type SeqEnvelope = OpEnvelope & {
|
|
30
64
|
readonly seq: number;
|
|
31
65
|
};
|
|
66
|
+
/**
|
|
67
|
+
* One retained concurrent write at a path. A register keeps at most one sibling per origin
|
|
68
|
+
* (a replica's newer op replaces its own older one), so state stays bounded by the
|
|
69
|
+
* concurrent-writer count, not the op count.
|
|
70
|
+
*/
|
|
71
|
+
type SyncSibling = {
|
|
72
|
+
readonly kind: 'set' | 'delete' | 'clear'; /** The written value for a `set`; absent for `delete`/`clear`. */
|
|
73
|
+
readonly value?: unknown; /** The emitter's inversion hint, kept for value-merging folds on the client. */
|
|
74
|
+
readonly prev?: unknown;
|
|
75
|
+
readonly writer: string;
|
|
76
|
+
readonly origin: string;
|
|
77
|
+
readonly hlc: Hlc;
|
|
78
|
+
readonly epoch: number;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Serializable per-path register state: the retained siblings plus the per-origin
|
|
82
|
+
* supersession watermarks. This is what a snapshot ships, never a folded value: the fold is
|
|
83
|
+
* client-configured policy, so a joiner seeded with a bare value could neither supersede nor
|
|
84
|
+
* be superseded correctly afterwards.
|
|
85
|
+
*/
|
|
86
|
+
type RegisterCheckpoint = {
|
|
87
|
+
readonly path: readonly Key[];
|
|
88
|
+
readonly siblings: readonly SyncSibling[];
|
|
89
|
+
readonly water: Readonly<Record<string, Hlc>>;
|
|
90
|
+
};
|
|
32
91
|
type PresenceState = {
|
|
33
92
|
readonly origin: string;
|
|
34
93
|
readonly writer: string; /** Consumer-defined activity payload (cursor, section, agent activity descriptor…). */
|
|
@@ -40,7 +99,8 @@ type HelloMsg = {
|
|
|
40
99
|
readonly origin: string;
|
|
41
100
|
readonly proto: number;
|
|
42
101
|
readonly policyVersion: number; /** Last room seq this client has applied — enables the delta answer on reconnect. */
|
|
43
|
-
readonly seq?: number;
|
|
102
|
+
readonly seq?: number; /** The data shape this client speaks, older-than-room is rejected `schema`. */
|
|
103
|
+
readonly schemaVersion?: number;
|
|
44
104
|
};
|
|
45
105
|
type ClientEnvMsg = {
|
|
46
106
|
readonly t: 'env';
|
|
@@ -60,14 +120,15 @@ type ClientSignalMsg = {
|
|
|
60
120
|
readonly data: unknown;
|
|
61
121
|
};
|
|
62
122
|
type ClientMsg = HelloMsg | ClientEnvMsg | ClientPresenceMsg | ClientSignalMsg;
|
|
63
|
-
/** The tri-state join answer
|
|
123
|
+
/** The tri-state join answer, plus the current presence roster. */
|
|
64
124
|
type WelcomeMsg = {
|
|
65
125
|
readonly t: 'welcome';
|
|
66
126
|
readonly room: string;
|
|
67
127
|
readonly seq: number;
|
|
68
128
|
/** Room-instance nonce: changes when a room is recreated (relay restart, DO eviction),
|
|
69
129
|
* so clients know their seq watermark belongs to a dead seq space. */
|
|
70
|
-
readonly
|
|
130
|
+
readonly instance: string; /** The room's current data shape. */
|
|
131
|
+
readonly schemaVersion: number;
|
|
71
132
|
readonly peers: readonly PresenceState[]; /** Origins currently in the room (membership ≠ presence) — the P2P bootstrap roster. */
|
|
72
133
|
readonly members: readonly string[];
|
|
73
134
|
} & ({
|
|
@@ -76,8 +137,9 @@ type WelcomeMsg = {
|
|
|
76
137
|
readonly mode: 'delta';
|
|
77
138
|
readonly envs: readonly SeqEnvelope[];
|
|
78
139
|
} | {
|
|
79
|
-
readonly mode: 'snapshot';
|
|
80
|
-
readonly
|
|
140
|
+
readonly mode: 'snapshot'; /** The room's retained register state; the client folds it with its own policy. */
|
|
141
|
+
readonly registers: readonly RegisterCheckpoint[]; /** Per-origin envelope-version high-water marks at the snapshot point. */
|
|
142
|
+
readonly wm: Readonly<Record<string, number>>;
|
|
81
143
|
});
|
|
82
144
|
type ServerEnvMsg = {
|
|
83
145
|
readonly t: 'env';
|
|
@@ -93,7 +155,7 @@ type ServerPresenceMsg = {
|
|
|
93
155
|
type RejectMsg = {
|
|
94
156
|
readonly t: 'reject';
|
|
95
157
|
readonly room: string;
|
|
96
|
-
readonly reason: 'proto' | 'policy-version' | 'unauthorized';
|
|
158
|
+
readonly reason: 'proto' | 'policy-version' | 'unauthorized' | 'schema';
|
|
97
159
|
readonly expected?: number;
|
|
98
160
|
};
|
|
99
161
|
type EjectMsg = {
|
|
@@ -115,17 +177,18 @@ type ServerSignalMsg = {
|
|
|
115
177
|
readonly from: string;
|
|
116
178
|
readonly data: unknown;
|
|
117
179
|
};
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
180
|
+
/** The room's stability frontier advanced (the relay compacted past it). A client may reclaim its
|
|
181
|
+
* own register state at or below this stamp; a straggler below it is rejected at ingest. */
|
|
182
|
+
type FrontierMsg = {
|
|
183
|
+
readonly t: 'frontier';
|
|
184
|
+
readonly room: string;
|
|
185
|
+
readonly frontier: Hlc;
|
|
186
|
+
};
|
|
187
|
+
type ServerMsg = WelcomeMsg | ServerEnvMsg | ServerPresenceMsg | RejectMsg | EjectMsg | MemberMsg | ServerSignalMsg | FrontierMsg;
|
|
124
188
|
//#endregion
|
|
125
189
|
//#region src/lib/policy.d.ts
|
|
126
190
|
/**
|
|
127
|
-
* The principal behind a connection, as authenticated by the adapter
|
|
128
|
-
* identity — RFC §3). `kind` distinguishes non-human peers: an agent is a user (§0), just one
|
|
191
|
+
* The principal behind a connection, as authenticated by the adapter. `kind` distinguishes non-human peers: an agent is a user, just one
|
|
129
192
|
* whose policy is usually narrower.
|
|
130
193
|
*/
|
|
131
194
|
type PrincipalCtx = {
|
|
@@ -134,10 +197,21 @@ type PrincipalCtx = {
|
|
|
134
197
|
readonly claims?: Readonly<Record<string, unknown>>;
|
|
135
198
|
};
|
|
136
199
|
/**
|
|
137
|
-
* Pure, deterministic, versioned validation
|
|
200
|
+
* Pure, deterministic, versioned validation. Run symmetrically on emit and on
|
|
138
201
|
* apply: honest peers never emit invalid ops, so any violation observed on the wire is a
|
|
139
202
|
* buggy or malicious writer — tripwire semantics eject it deterministically. Never skip an
|
|
140
203
|
* op mid-log.
|
|
204
|
+
*
|
|
205
|
+
* This is a WRITE ACL, not a read ACL: every room member sees the whole root, so the room is
|
|
206
|
+
* the confidentiality boundary. Data with different audiences belongs in different rooms.
|
|
207
|
+
*
|
|
208
|
+
* A `clear` op counts as a write at its path (it retires that path's register as part of a
|
|
209
|
+
* subtree replace), so `canWrite` and `validate` see clears like any other op.
|
|
210
|
+
*
|
|
211
|
+
* This gates SHAPE and PATH access on a trusted `ctx.writer` (your adapter authenticates it). It
|
|
212
|
+
* does not by itself check the precedence an op claims or the authenticity of its citations; a rule
|
|
213
|
+
* like "only this role may override" belongs in `validate` against that trusted writer. A direct
|
|
214
|
+
* peer-to-peer connection bypasses the relay, so a peer-to-peer room is trust-full for authority.
|
|
141
215
|
*/
|
|
142
216
|
type OpPolicy = {
|
|
143
217
|
canWrite?(ctx: PrincipalCtx, path: readonly Key[]): boolean;
|
|
@@ -145,7 +219,8 @@ type OpPolicy = {
|
|
|
145
219
|
};
|
|
146
220
|
type PolicyViolation = {
|
|
147
221
|
readonly writer: string;
|
|
148
|
-
readonly reason: 'can-write' | 'validate' | 'writer-mismatch' | 'proto' | 'ops-limit' | 'rate';
|
|
222
|
+
readonly reason: 'can-write' | 'validate' | 'malformed' | 'writer-mismatch' | 'proto' | 'ops-limit' | 'rate'; /** the specific well-formedness failure, when `reason` is `'malformed'`. */
|
|
223
|
+
readonly detail?: string;
|
|
149
224
|
readonly path?: readonly Key[];
|
|
150
225
|
};
|
|
151
226
|
/** Check one envelope against a policy; `null` means clean. */
|
|
@@ -161,6 +236,43 @@ type PathAclRule = {
|
|
|
161
236
|
};
|
|
162
237
|
declare function pathPrefixAcl(rules: readonly PathAclRule[]): OpPolicy;
|
|
163
238
|
//#endregion
|
|
239
|
+
//#region src/lib/register.d.ts
|
|
240
|
+
/**
|
|
241
|
+
* The relay's per-room register retention. It runs the same pure ingest rules a client's
|
|
242
|
+
* register runs (per path, keep the best op per origin plus the per-origin citation
|
|
243
|
+
* watermarks; live means above-watermark) and NOTHING else: it never resolves a conflict
|
|
244
|
+
* and never materializes a value. Conflict resolution (the fold) is client-configured
|
|
245
|
+
* policy, so a relay that folded values would seed joiners with one client's semantics;
|
|
246
|
+
* retention is uniform and policy-free, and identical register state folds identically on
|
|
247
|
+
* every client.
|
|
248
|
+
*/
|
|
249
|
+
type RegisterStore = {
|
|
250
|
+
/** Fold one sequenced envelope's ops into the per-path registers (retention only). */ingest(env: OpEnvelope): void; /** Serializable register state for a welcome snapshot or a persistence checkpoint. */
|
|
251
|
+
checkpoint(): RegisterCheckpoint[]; /** Merge checkpointed register state in (idempotent): the hydrate path. */
|
|
252
|
+
load(registers: readonly RegisterCheckpoint[]): void;
|
|
253
|
+
/**
|
|
254
|
+
* Compaction at the retention frontier (the stamp the journal no longer covers): drops
|
|
255
|
+
* superseded siblings and stale watermarks at or below it, and drops a register whose live
|
|
256
|
+
* set is a lone tombstone below it, but only when nothing else still materializes that
|
|
257
|
+
* key (no live descendant register, and no live ancestor `set` value containing it), so a
|
|
258
|
+
* dropped tombstone can never resurrect the value it deleted.
|
|
259
|
+
*/
|
|
260
|
+
compact(frontier: Hlc): void; /** Drop all register state (a migration establishes a fresh retention window). */
|
|
261
|
+
reset(): void;
|
|
262
|
+
};
|
|
263
|
+
declare function createRegisterStore(): RegisterStore;
|
|
264
|
+
//#endregion
|
|
265
|
+
//#region src/lib/validate.d.ts
|
|
266
|
+
/**
|
|
267
|
+
* Deterministic, total well-formedness check for a received envelope: returns a short reason string
|
|
268
|
+
* when it must be rejected WHOLE, or `null` when it is well-formed. It reads only the envelope, so
|
|
269
|
+
* the relay and every client accept or reject a given envelope identically. This is the STRUCTURAL
|
|
270
|
+
* TWIN of the client's `validateEnvelope` in @mmstack/primitives; the two must stay byte-identical
|
|
271
|
+
* in their accept/reject decisions (a parity property in the mesh client spec pins this). It
|
|
272
|
+
* validates SHAPE, not authority: authority and access control stay in the relay's policy check.
|
|
273
|
+
*/
|
|
274
|
+
declare function validateEnvelope(env: OpEnvelope): string | null;
|
|
275
|
+
//#endregion
|
|
164
276
|
//#region src/lib/relay.d.ts
|
|
165
277
|
/** What the relay needs from a connection — implement over ws, a DO WebSocket, or a test pair. */
|
|
166
278
|
type RelaySocket = {
|
|
@@ -168,39 +280,50 @@ type RelaySocket = {
|
|
|
168
280
|
close?(): void;
|
|
169
281
|
};
|
|
170
282
|
type RelayLimits = {
|
|
171
|
-
/**
|
|
283
|
+
/**
|
|
284
|
+
* Ops per envelope; a larger envelope is a violation (default 1024). A subtree replace
|
|
285
|
+
* legitimately emits one `set` plus one `clear` per observed live descendant register in a
|
|
286
|
+
* single envelope, so a tightened limit must still accommodate honest clear-groups.
|
|
287
|
+
*/
|
|
288
|
+
readonly maxOpsPerEnvelope?: number; /** Sustained envelopes/second per writer (token bucket, burst = 2×; off by default). */
|
|
172
289
|
readonly maxEnvelopesPerSecond?: number;
|
|
173
290
|
};
|
|
174
291
|
type RelayOptions = {
|
|
175
292
|
/** Validation/ACL applied to every envelope; violations eject the writer (tripwire). */readonly policy?: OpPolicy;
|
|
176
293
|
readonly policyVersion?: number;
|
|
177
|
-
readonly limits?: RelayLimits; /** Seq-envelopes retained per room for delta answers; older
|
|
294
|
+
readonly limits?: RelayLimits; /** Seq-envelopes retained per room for delta answers; older compact into register state (default 1000). */
|
|
178
295
|
readonly journalLimit?: number;
|
|
179
296
|
readonly now?: () => number;
|
|
180
297
|
readonly onViolation?: (room: string, violation: PolicyViolation) => void;
|
|
181
298
|
/**
|
|
182
|
-
* The persistence egress: fired after an envelope is sequenced,
|
|
183
|
-
*
|
|
184
|
-
* `state` carries the
|
|
185
|
-
* awaited: batch, debounce, and store at the adapter layer. Pair
|
|
299
|
+
* The persistence egress: fired after an envelope is sequenced, retained into the room's
|
|
300
|
+
* register state, and broadcast. The envelope is the persistence record (append it to a
|
|
301
|
+
* journal); `state` carries the retained register state for throttled checkpoints. Called
|
|
302
|
+
* synchronously and never awaited: batch, debounce, and store at the adapter layer. Pair
|
|
303
|
+
* with {@link Relay.hydrate}.
|
|
186
304
|
*/
|
|
187
305
|
readonly onCommit?: (room: string, env: SeqEnvelope, state: RoomState) => void;
|
|
188
306
|
};
|
|
189
307
|
/** The room's durable state at a commit: what a checkpoint needs to capture. */
|
|
190
308
|
type RoomState = {
|
|
191
309
|
readonly seq: number;
|
|
192
|
-
readonly
|
|
193
|
-
readonly
|
|
310
|
+
readonly instance: string; /** The room's retained per-path register state, never a folded value. */
|
|
311
|
+
readonly registers: readonly RegisterCheckpoint[]; /** Per-origin envelope-version high-water marks. */
|
|
312
|
+
readonly wm: Readonly<Record<string, number>>; /** The room's data shape; restored via {@link Relay.hydrate}. */
|
|
313
|
+
readonly schemaVersion: number;
|
|
194
314
|
};
|
|
195
315
|
/** A persisted room to restore via {@link Relay.hydrate}. */
|
|
196
316
|
type RoomSnapshot = {
|
|
197
|
-
readonly seq: number;
|
|
198
|
-
readonly
|
|
317
|
+
readonly seq: number; /** The retained register state captured at the checkpoint. */
|
|
318
|
+
readonly registers?: readonly RegisterCheckpoint[]; /** Per-origin envelope-version high-water marks captured at the checkpoint. */
|
|
319
|
+
readonly wm?: Readonly<Record<string, number>>;
|
|
199
320
|
/**
|
|
200
|
-
* Restore the persisted
|
|
201
|
-
* watermark and get a `delta` answer; omit to mint a fresh one (they re-snapshot
|
|
321
|
+
* Restore the persisted instance nonce so clients reconnecting across the restart keep
|
|
322
|
+
* their seq watermark and get a `delta` answer; omit to mint a fresh one (they re-snapshot
|
|
323
|
+
* instead).
|
|
202
324
|
*/
|
|
203
|
-
readonly
|
|
325
|
+
readonly instance?: string; /** Restore the persisted schema version (a compacted snapshot is post-migration). */
|
|
326
|
+
readonly schemaVersion?: number; /** Journal tail (ascending seq, entries at or below `seq`) enabling those delta answers. */
|
|
204
327
|
readonly journal?: readonly SeqEnvelope[];
|
|
205
328
|
};
|
|
206
329
|
type RelayConnection = {
|
|
@@ -223,18 +346,21 @@ type Relay = {
|
|
|
223
346
|
hydrate(name: string, snapshot: RoomSnapshot): boolean;
|
|
224
347
|
};
|
|
225
348
|
/**
|
|
226
|
-
* The reference relay core
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
349
|
+
* The reference relay core: room-scoped sequencing, journal + register-state compaction, the
|
|
350
|
+
* tri-state join answer, presence fan-out, and tripwire policy enforcement. Pure over
|
|
351
|
+
* injected sockets — runs identically under ws, Bun, a Durable Object, or an in-memory test
|
|
352
|
+
* pair. The relay RETAINS ops (per-path registers, the same pure ingest rules every client
|
|
353
|
+
* runs) but never resolves them: conflict resolution is client-configured policy, so a relay
|
|
354
|
+
* that folded values would seed late joiners into permanent divergence from established
|
|
355
|
+
* peers. Snapshots therefore ship register state, never a value tree. It also never mints
|
|
356
|
+
* identity: `writer` comes from the adapter's auth.
|
|
231
357
|
*
|
|
232
358
|
* Room-initialization contract: a fresh room (seq 0) answers `up-to-date`; the first client
|
|
233
|
-
* then SEEDS it with a root-set envelope so the room's
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
* overwhelmingly common case) are unaffected.
|
|
359
|
+
* then SEEDS it with a root-set envelope so the room's register state is complete (joiners
|
|
360
|
+
* hydrate from it). Near-simultaneous first-joins of a brand-new room may race their seeds
|
|
361
|
+
* (the register retains both as concurrent siblings); rooms created by a single client first
|
|
362
|
+
* (the overwhelmingly common case) are unaffected.
|
|
237
363
|
*/
|
|
238
364
|
declare function createRelay(opt?: RelayOptions): Relay;
|
|
239
365
|
//#endregion
|
|
240
|
-
export { type ClientEnvMsg, type ClientMsg, type ClientPresenceMsg, type ClientSignalMsg, type EjectMsg, type HelloMsg, type Hlc, type Key, MESH_PROTO_VERSION, type MemberMsg, type OpEnvelope, type OpPolicy, type PathAclRule, type PolicyViolation, type PresenceState, type PrincipalCtx, type RejectMsg, type Relay, type RelayConnection, type RelayLimits, type RelayOptions, type RelaySocket, type RoomInfo, type RoomSnapshot, type RoomState, type SeqEnvelope, type ServerEnvMsg, type ServerMsg, type ServerPresenceMsg, type ServerSignalMsg, type StoreOp, type
|
|
366
|
+
export { type ClientEnvMsg, type ClientMsg, type ClientPresenceMsg, type ClientSignalMsg, type Dot, type EjectMsg, type HelloMsg, type Hlc, type Key, MESH_PROTO_VERSION, type MemberMsg, type OpEnvelope, type OpPolicy, type PathAclRule, type PolicyViolation, type PresenceState, type PrincipalCtx, type RegisterCheckpoint, type RegisterStore, type RejectMsg, type Relay, type RelayConnection, type RelayLimits, type RelayOptions, type RelaySocket, type RoomInfo, type RoomSnapshot, type RoomState, type SeqEnvelope, type ServerEnvMsg, type ServerMsg, type ServerPresenceMsg, type ServerSignalMsg, type StoreOp, type SyncOp, type SyncSibling, type WelcomeMsg, checkEnvelope, createRegisterStore, createRelay, pathPrefixAcl, validateEnvelope };
|
package/index.js
CHANGED
|
@@ -28,49 +28,192 @@ function pathPrefixAcl(rules) {
|
|
|
28
28
|
}) };
|
|
29
29
|
}
|
|
30
30
|
//#endregion
|
|
31
|
-
//#region src/lib/
|
|
32
|
-
const
|
|
31
|
+
//#region src/lib/register.ts
|
|
32
|
+
const SEP = "";
|
|
33
|
+
const keyOf = (path) => path.map(String).join(SEP);
|
|
34
|
+
const compareHlc = (a, b) => a.p !== b.p ? a.p - b.p : a.l - b.l;
|
|
35
|
+
function createRegisterStore() {
|
|
36
|
+
const registers = /* @__PURE__ */ new Map();
|
|
37
|
+
const regAt = (path) => {
|
|
38
|
+
const key = keyOf(path);
|
|
39
|
+
let reg = registers.get(key);
|
|
40
|
+
if (!reg) {
|
|
41
|
+
reg = {
|
|
42
|
+
path,
|
|
43
|
+
siblings: /* @__PURE__ */ new Map(),
|
|
44
|
+
water: /* @__PURE__ */ new Map()
|
|
45
|
+
};
|
|
46
|
+
registers.set(key, reg);
|
|
47
|
+
}
|
|
48
|
+
return reg;
|
|
49
|
+
};
|
|
50
|
+
const liveOf = (reg) => {
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const [origin, s] of reg.siblings) {
|
|
53
|
+
const w = reg.water.get(origin);
|
|
54
|
+
if (!w || compareHlc(s.hlc, w) > 0) out.push(s);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
};
|
|
58
|
+
const isContainer = (v) => typeof v === "object" && v !== null;
|
|
59
|
+
/** Does `value` (an ancestor sibling's set value) still contain the key at `rel`? */
|
|
60
|
+
const contains = (value, rel) => {
|
|
61
|
+
let cur = value;
|
|
62
|
+
for (let i = 0; i < rel.length; i++) {
|
|
63
|
+
if (!isContainer(cur) || !Object.hasOwn(cur, String(rel[i]))) return false;
|
|
64
|
+
cur = cur[String(rel[i])];
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
};
|
|
68
|
+
/** A lone tombstone is droppable only if nothing else still materializes its key. */
|
|
69
|
+
const tombstoneDroppable = (key, reg) => {
|
|
70
|
+
for (const [k, other] of registers) {
|
|
71
|
+
if (k === key) continue;
|
|
72
|
+
if (k.startsWith(key + SEP)) {
|
|
73
|
+
if (liveOf(other).length > 0) return false;
|
|
74
|
+
} else if (key.startsWith(k === "" ? "" : k + SEP)) {
|
|
75
|
+
const rel = reg.path.slice(other.path.length);
|
|
76
|
+
for (const s of liveOf(other)) if (s.kind === "set" && contains(s.value, rel)) return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
};
|
|
81
|
+
return {
|
|
82
|
+
ingest: (env) => {
|
|
83
|
+
for (const op of env.ops) {
|
|
84
|
+
if (!op.path.length && op.kind !== "set") continue;
|
|
85
|
+
const reg = regAt(op.path);
|
|
86
|
+
for (const c of op.cites ?? []) {
|
|
87
|
+
if (c.origin === env.origin && compareHlc(c.hlc, env.hlc) === 0) continue;
|
|
88
|
+
const cur = reg.water.get(c.origin);
|
|
89
|
+
if (!cur || compareHlc(c.hlc, cur) > 0) reg.water.set(c.origin, c.hlc);
|
|
90
|
+
}
|
|
91
|
+
const best = reg.siblings.get(env.origin);
|
|
92
|
+
if (!best || compareHlc(env.hlc, best.hlc) > 0) {
|
|
93
|
+
const sib = {
|
|
94
|
+
kind: op.kind,
|
|
95
|
+
writer: env.writer,
|
|
96
|
+
origin: env.origin,
|
|
97
|
+
hlc: env.hlc,
|
|
98
|
+
epoch: op.epoch ?? 0
|
|
99
|
+
};
|
|
100
|
+
if (op.kind === "set") sib.value = op.next;
|
|
101
|
+
if (op.kind !== "clear" && Object.hasOwn(op, "prev")) sib.prev = op.prev;
|
|
102
|
+
reg.siblings.set(env.origin, sib);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
checkpoint: () => {
|
|
107
|
+
const out = [];
|
|
108
|
+
for (const reg of registers.values()) out.push({
|
|
109
|
+
path: reg.path,
|
|
110
|
+
siblings: [...reg.siblings.values()],
|
|
111
|
+
water: Object.fromEntries(reg.water)
|
|
112
|
+
});
|
|
113
|
+
return out;
|
|
114
|
+
},
|
|
115
|
+
load: (regs) => {
|
|
116
|
+
for (const r of regs) {
|
|
117
|
+
const reg = regAt(r.path);
|
|
118
|
+
for (const s of r.siblings) {
|
|
119
|
+
const cur = reg.siblings.get(s.origin);
|
|
120
|
+
if (!cur || compareHlc(s.hlc, cur.hlc) > 0) reg.siblings.set(s.origin, s);
|
|
121
|
+
}
|
|
122
|
+
for (const [origin, h] of Object.entries(r.water)) {
|
|
123
|
+
const cur = reg.water.get(origin);
|
|
124
|
+
if (!cur || compareHlc(h, cur) > 0) reg.water.set(origin, h);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
compact: (frontier) => {
|
|
129
|
+
for (const [key, reg] of [...registers]) {
|
|
130
|
+
for (const [origin, s] of [...reg.siblings]) {
|
|
131
|
+
const w = reg.water.get(origin);
|
|
132
|
+
if (compareHlc(s.hlc, frontier) <= 0 && w && compareHlc(s.hlc, w) <= 0) reg.siblings.delete(origin);
|
|
133
|
+
}
|
|
134
|
+
for (const [origin, h] of [...reg.water]) if (compareHlc(h, frontier) <= 0) reg.water.delete(origin);
|
|
135
|
+
if (reg.siblings.size === 0 && reg.water.size === 0) registers.delete(key);
|
|
136
|
+
}
|
|
137
|
+
const byDepth = [...registers.entries()].sort((a, b) => b[1].path.length - a[1].path.length);
|
|
138
|
+
for (const [key, reg] of byDepth) {
|
|
139
|
+
const live = liveOf(reg);
|
|
140
|
+
if (live.length === 1 && live[0].kind === "delete" && reg.siblings.size === 1 && compareHlc(live[0].hlc, frontier) <= 0 && tombstoneDroppable(key, reg)) registers.delete(key);
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
reset: () => registers.clear()
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
//#endregion
|
|
147
|
+
//#region src/lib/validate.ts
|
|
148
|
+
const hasControlChar = (s) => {
|
|
149
|
+
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) < 32) return true;
|
|
150
|
+
return false;
|
|
151
|
+
};
|
|
152
|
+
const isCleanId = (v) => typeof v === "string" && v.length > 0 && !hasControlChar(v);
|
|
153
|
+
const isFiniteHlc = (h) => !!h && typeof h === "object" && Number.isFinite(h.p) && Number.isFinite(h.l);
|
|
33
154
|
/**
|
|
34
|
-
*
|
|
35
|
-
* `
|
|
155
|
+
* Deterministic, total well-formedness check for a received envelope: returns a short reason string
|
|
156
|
+
* when it must be rejected WHOLE, or `null` when it is well-formed. It reads only the envelope, so
|
|
157
|
+
* the relay and every client accept or reject a given envelope identically. This is the STRUCTURAL
|
|
158
|
+
* TWIN of the client's `validateEnvelope` in @mmstack/primitives; the two must stay byte-identical
|
|
159
|
+
* in their accept/reject decisions (a parity property in the mesh client spec pins this). It
|
|
160
|
+
* validates SHAPE, not authority: authority and access control stay in the relay's policy check.
|
|
36
161
|
*/
|
|
37
|
-
function
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
162
|
+
function validateEnvelope(env) {
|
|
163
|
+
if (!env || typeof env !== "object") return "envelope";
|
|
164
|
+
if (!isCleanId(env.origin)) return "origin";
|
|
165
|
+
if (!isCleanId(env.writer)) return "writer";
|
|
166
|
+
if (!isFiniteHlc(env.hlc)) return "hlc";
|
|
167
|
+
if (!Number.isInteger(env.version) || env.version <= 0) return "version";
|
|
168
|
+
if (!Array.isArray(env.ops)) return "ops";
|
|
169
|
+
const seenPaths = /* @__PURE__ */ new Set();
|
|
170
|
+
for (const op of env.ops) {
|
|
171
|
+
if (!op || typeof op !== "object") return "op";
|
|
172
|
+
if (op.kind !== "set" && op.kind !== "delete" && op.kind !== "clear") return "kind";
|
|
173
|
+
if (!Array.isArray(op.path)) return "path";
|
|
174
|
+
for (const seg of op.path) {
|
|
175
|
+
if (typeof seg === "string" && hasControlChar(seg)) return "path-control";
|
|
176
|
+
if (seg === "__proto__") return "path-proto";
|
|
43
177
|
}
|
|
44
|
-
|
|
178
|
+
if (op.path.length === 0 && op.kind !== "set") return "root-op";
|
|
179
|
+
const epoch = op.epoch;
|
|
180
|
+
if (typeof epoch !== "number" || !Number.isFinite(epoch) || epoch < 0) return "epoch";
|
|
181
|
+
const cites = op.cites;
|
|
182
|
+
if (!Array.isArray(cites)) return "cites";
|
|
183
|
+
for (const c of cites) if (!c || typeof c !== "object" || !isCleanId(c.origin) || !isFiniteHlc(c.hlc)) return "cites";
|
|
184
|
+
const key = op.path.map(String).join(String.fromCharCode(31));
|
|
185
|
+
if (seenPaths.has(key)) return "dup-path";
|
|
186
|
+
seenPaths.add(key);
|
|
45
187
|
}
|
|
46
|
-
return
|
|
47
|
-
}
|
|
48
|
-
function applyAt(container, path, idx, op) {
|
|
49
|
-
const seg = path[idx];
|
|
50
|
-
const base = Array.isArray(container) ? container.slice() : container !== null && typeof container === "object" ? { ...container } : typeof seg === "number" ? [] : {};
|
|
51
|
-
if (idx === path.length - 1) {
|
|
52
|
-
if (op.kind === "delete") delete base[seg];
|
|
53
|
-
else base[seg] = op.next;
|
|
54
|
-
return base;
|
|
55
|
-
}
|
|
56
|
-
base[seg] = applyAt(base[seg], path, idx + 1, op);
|
|
57
|
-
return base;
|
|
188
|
+
return null;
|
|
58
189
|
}
|
|
59
190
|
//#endregion
|
|
191
|
+
//#region src/lib/wire.ts
|
|
192
|
+
/**
|
|
193
|
+
* Wire protocol version. Version 2 ops carry `cites` + `epoch`: an op without citations
|
|
194
|
+
* cannot be merged soundly (it would supersede nothing and its siblings would accumulate
|
|
195
|
+
* forever), so the relay rejects envelopes from any other protocol version outright rather
|
|
196
|
+
* than silently mixing pre-citation emitters into a room.
|
|
197
|
+
*/
|
|
198
|
+
const MESH_PROTO_VERSION = 2;
|
|
199
|
+
//#endregion
|
|
60
200
|
//#region src/lib/relay.ts
|
|
61
|
-
let
|
|
201
|
+
let instanceCounter = 0;
|
|
62
202
|
/**
|
|
63
|
-
* The reference relay core
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
203
|
+
* The reference relay core: room-scoped sequencing, journal + register-state compaction, the
|
|
204
|
+
* tri-state join answer, presence fan-out, and tripwire policy enforcement. Pure over
|
|
205
|
+
* injected sockets — runs identically under ws, Bun, a Durable Object, or an in-memory test
|
|
206
|
+
* pair. The relay RETAINS ops (per-path registers, the same pure ingest rules every client
|
|
207
|
+
* runs) but never resolves them: conflict resolution is client-configured policy, so a relay
|
|
208
|
+
* that folded values would seed late joiners into permanent divergence from established
|
|
209
|
+
* peers. Snapshots therefore ship register state, never a value tree. It also never mints
|
|
210
|
+
* identity: `writer` comes from the adapter's auth.
|
|
68
211
|
*
|
|
69
212
|
* Room-initialization contract: a fresh room (seq 0) answers `up-to-date`; the first client
|
|
70
|
-
* then SEEDS it with a root-set envelope so the room's
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* overwhelmingly common case) are unaffected.
|
|
213
|
+
* then SEEDS it with a root-set envelope so the room's register state is complete (joiners
|
|
214
|
+
* hydrate from it). Near-simultaneous first-joins of a brand-new room may race their seeds
|
|
215
|
+
* (the register retains both as concurrent siblings); rooms created by a single client first
|
|
216
|
+
* (the overwhelmingly common case) are unaffected.
|
|
74
217
|
*/
|
|
75
218
|
function createRelay(opt = {}) {
|
|
76
219
|
const rooms = /* @__PURE__ */ new Map();
|
|
@@ -79,13 +222,17 @@ function createRelay(opt = {}) {
|
|
|
79
222
|
const maxOps = opt.limits?.maxOpsPerEnvelope ?? 1024;
|
|
80
223
|
const rate = opt.limits?.maxEnvelopesPerSecond;
|
|
81
224
|
const now = opt.now ?? Date.now;
|
|
225
|
+
const mintInstance = () => `${now().toString(36)}-${(++instanceCounter).toString(36)}`;
|
|
82
226
|
const roomOf = (name) => {
|
|
83
227
|
let room = rooms.get(name);
|
|
84
228
|
if (!room) {
|
|
85
229
|
room = {
|
|
86
230
|
seq: 0,
|
|
87
|
-
|
|
88
|
-
|
|
231
|
+
instance: mintInstance(),
|
|
232
|
+
schemaVersion: 0,
|
|
233
|
+
registers: createRegisterStore(),
|
|
234
|
+
wm: /* @__PURE__ */ new Map(),
|
|
235
|
+
frontier: void 0,
|
|
89
236
|
journal: [],
|
|
90
237
|
members: /* @__PURE__ */ new Set(),
|
|
91
238
|
presence: /* @__PURE__ */ new Map(),
|
|
@@ -96,6 +243,10 @@ function createRelay(opt = {}) {
|
|
|
96
243
|
}
|
|
97
244
|
return room;
|
|
98
245
|
};
|
|
246
|
+
const maybeEvictEmpty = (name) => {
|
|
247
|
+
const room = rooms.get(name);
|
|
248
|
+
if (room && room.members.size === 0 && room.seq === 0 && room.ejected.size === 0) rooms.delete(name);
|
|
249
|
+
};
|
|
99
250
|
const broadcast = (room, msg, except) => {
|
|
100
251
|
for (const member of room.members) if (member !== except) member.socket.send(msg);
|
|
101
252
|
};
|
|
@@ -149,6 +300,7 @@ function createRelay(opt = {}) {
|
|
|
149
300
|
bucket.tokens -= 1;
|
|
150
301
|
return false;
|
|
151
302
|
};
|
|
303
|
+
const laterHlc = (a, b) => !a || b.p > a.p || b.p === a.p && b.l > a.l ? b : a;
|
|
152
304
|
return {
|
|
153
305
|
room: (name) => {
|
|
154
306
|
const room = rooms.get(name);
|
|
@@ -162,8 +314,10 @@ function createRelay(opt = {}) {
|
|
|
162
314
|
const room = roomOf(name);
|
|
163
315
|
if (room.seq !== 0 || room.members.size > 0 || room.journal.length > 0) return false;
|
|
164
316
|
room.seq = snapshot.seq;
|
|
165
|
-
room.
|
|
166
|
-
|
|
317
|
+
room.registers.load(snapshot.registers ?? []);
|
|
318
|
+
for (const [origin, v] of Object.entries(snapshot.wm ?? {})) room.wm.set(origin, Math.max(room.wm.get(origin) ?? 0, v));
|
|
319
|
+
if (snapshot.instance !== void 0) room.instance = snapshot.instance;
|
|
320
|
+
if (snapshot.schemaVersion !== void 0) room.schemaVersion = snapshot.schemaVersion;
|
|
167
321
|
if (snapshot.journal) room.journal = snapshot.journal.filter((e) => e.seq <= snapshot.seq).sort((a, b) => a.seq - b.seq).slice(-journalLimit);
|
|
168
322
|
return true;
|
|
169
323
|
},
|
|
@@ -181,6 +335,7 @@ function createRelay(opt = {}) {
|
|
|
181
335
|
origin: member.origin,
|
|
182
336
|
gone: true
|
|
183
337
|
});
|
|
338
|
+
maybeEvictEmpty(name);
|
|
184
339
|
}
|
|
185
340
|
joined.clear();
|
|
186
341
|
};
|
|
@@ -197,13 +352,14 @@ function createRelay(opt = {}) {
|
|
|
197
352
|
});
|
|
198
353
|
return;
|
|
199
354
|
}
|
|
200
|
-
if (msg.proto !==
|
|
355
|
+
if (msg.proto !== 2) {
|
|
201
356
|
socket.send({
|
|
202
357
|
t: "reject",
|
|
203
358
|
room: msg.room,
|
|
204
359
|
reason: "proto",
|
|
205
|
-
expected:
|
|
360
|
+
expected: 2
|
|
206
361
|
});
|
|
362
|
+
maybeEvictEmpty(msg.room);
|
|
207
363
|
return;
|
|
208
364
|
}
|
|
209
365
|
if (msg.policyVersion !== policyVersion) {
|
|
@@ -213,6 +369,17 @@ function createRelay(opt = {}) {
|
|
|
213
369
|
reason: "policy-version",
|
|
214
370
|
expected: policyVersion
|
|
215
371
|
});
|
|
372
|
+
maybeEvictEmpty(msg.room);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (msg.schemaVersion !== void 0 && msg.schemaVersion < room.schemaVersion) {
|
|
376
|
+
socket.send({
|
|
377
|
+
t: "reject",
|
|
378
|
+
room: msg.room,
|
|
379
|
+
reason: "schema",
|
|
380
|
+
expected: room.schemaVersion
|
|
381
|
+
});
|
|
382
|
+
maybeEvictEmpty(msg.room);
|
|
216
383
|
return;
|
|
217
384
|
}
|
|
218
385
|
for (const prior of [...room.members]) {
|
|
@@ -238,7 +405,8 @@ function createRelay(opt = {}) {
|
|
|
238
405
|
t: "welcome",
|
|
239
406
|
room: msg.room,
|
|
240
407
|
seq: room.seq,
|
|
241
|
-
|
|
408
|
+
instance: room.instance,
|
|
409
|
+
schemaVersion: room.schemaVersion,
|
|
242
410
|
peers,
|
|
243
411
|
members: [...room.members].filter((m) => m !== member).map((m) => m.origin)
|
|
244
412
|
};
|
|
@@ -256,7 +424,8 @@ function createRelay(opt = {}) {
|
|
|
256
424
|
} else socket.send({
|
|
257
425
|
...base,
|
|
258
426
|
mode: "snapshot",
|
|
259
|
-
|
|
427
|
+
registers: room.registers.checkpoint(),
|
|
428
|
+
wm: Object.fromEntries(room.wm)
|
|
260
429
|
});
|
|
261
430
|
return;
|
|
262
431
|
}
|
|
@@ -292,9 +461,14 @@ function createRelay(opt = {}) {
|
|
|
292
461
|
return;
|
|
293
462
|
}
|
|
294
463
|
const env = msg.env;
|
|
295
|
-
const
|
|
464
|
+
const malformed = validateEnvelope(env);
|
|
465
|
+
const violation = env.policyVersion !== policyVersion || env.proto !== 2 ? {
|
|
296
466
|
writer: ctx.writer,
|
|
297
467
|
reason: "proto"
|
|
468
|
+
} : malformed !== null ? {
|
|
469
|
+
writer: ctx.writer,
|
|
470
|
+
reason: "malformed",
|
|
471
|
+
detail: malformed
|
|
298
472
|
} : env.ops.length > maxOps ? {
|
|
299
473
|
writer: ctx.writer,
|
|
300
474
|
reason: "ops-limit"
|
|
@@ -306,13 +480,33 @@ function createRelay(opt = {}) {
|
|
|
306
480
|
eject(msg.room, room, ctx.writer, violation);
|
|
307
481
|
return;
|
|
308
482
|
}
|
|
483
|
+
if (env.schemaVersion !== void 0 && env.schemaVersion < room.schemaVersion) return;
|
|
309
484
|
const seqEnv = {
|
|
310
485
|
...env,
|
|
311
486
|
seq: ++room.seq
|
|
312
487
|
};
|
|
313
488
|
room.journal.push(seqEnv);
|
|
314
|
-
|
|
315
|
-
|
|
489
|
+
if (env.schemaVersion !== void 0 && env.schemaVersion > room.schemaVersion) {
|
|
490
|
+
room.schemaVersion = env.schemaVersion;
|
|
491
|
+
room.instance = mintInstance();
|
|
492
|
+
room.registers.reset();
|
|
493
|
+
room.frontier = void 0;
|
|
494
|
+
room.journal = [seqEnv];
|
|
495
|
+
}
|
|
496
|
+
room.registers.ingest(env);
|
|
497
|
+
room.wm.set(env.origin, Math.max(room.wm.get(env.origin) ?? 0, env.version));
|
|
498
|
+
if (room.journal.length > journalLimit) {
|
|
499
|
+
const trimmed = room.journal.shift();
|
|
500
|
+
if (trimmed) {
|
|
501
|
+
room.frontier = laterHlc(room.frontier, trimmed.hlc);
|
|
502
|
+
room.registers.compact(room.frontier);
|
|
503
|
+
broadcast(room, {
|
|
504
|
+
t: "frontier",
|
|
505
|
+
room: msg.room,
|
|
506
|
+
frontier: room.frontier
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
}
|
|
316
510
|
broadcast(room, {
|
|
317
511
|
t: "env",
|
|
318
512
|
room: msg.room,
|
|
@@ -320,8 +514,10 @@ function createRelay(opt = {}) {
|
|
|
320
514
|
});
|
|
321
515
|
opt.onCommit?.(msg.room, seqEnv, {
|
|
322
516
|
seq: room.seq,
|
|
323
|
-
|
|
324
|
-
|
|
517
|
+
instance: room.instance,
|
|
518
|
+
registers: room.registers.checkpoint(),
|
|
519
|
+
wm: Object.fromEntries(room.wm),
|
|
520
|
+
schemaVersion: room.schemaVersion
|
|
325
521
|
});
|
|
326
522
|
}
|
|
327
523
|
};
|
|
@@ -329,4 +525,4 @@ function createRelay(opt = {}) {
|
|
|
329
525
|
};
|
|
330
526
|
}
|
|
331
527
|
//#endregion
|
|
332
|
-
export { MESH_PROTO_VERSION,
|
|
528
|
+
export { MESH_PROTO_VERSION, checkEnvelope, createRegisterStore, createRelay, pathPrefixAcl, validateEnvelope };
|