@mmstack/mesh-protocol 0.2.0 → 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 +39 -24
- package/index.d.ts +151 -37
- package/index.js +227 -48
- 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
|
|
|
@@ -99,10 +113,11 @@ your codebase, not here.
|
|
|
99
113
|
|
|
100
114
|
Two boundaries to be clear about. Policy gates writes, not reads: every member of a room sees
|
|
101
115
|
the whole root, so the room is the confidentiality boundary, and data with different audiences
|
|
102
|
-
belongs in different rooms.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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.
|
|
106
121
|
|
|
107
122
|
## Adapter recipes
|
|
108
123
|
|
|
@@ -152,12 +167,12 @@ Rooms live in memory. That covers dev and single-process deployments, and when t
|
|
|
152
167
|
restarts, the first client to rejoin seeds the room from its own local state, so nothing is
|
|
153
168
|
lost as long as somebody was online. For durability beyond that, the relay exposes a seam
|
|
154
169
|
rather than a storage engine, because the envelope already is the persistence record: an
|
|
155
|
-
event-sourced journal is just `
|
|
170
|
+
event-sourced journal is just `register checkpoint + envelopes`, compacted by re-checkpointing.
|
|
156
171
|
|
|
157
|
-
`onCommit` fires after every envelope is sequenced and
|
|
158
|
-
room's current `{ seq,
|
|
159
|
-
|
|
160
|
-
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:
|
|
161
176
|
|
|
162
177
|
```ts
|
|
163
178
|
const relay = createRelay({
|
|
@@ -176,16 +191,16 @@ members, so your load can race a fast client without corrupting a live sequence
|
|
|
176
191
|
const saved = await checkpoints.get(roomName);
|
|
177
192
|
if (saved) {
|
|
178
193
|
relay.hydrate(roomName, {
|
|
179
|
-
...saved, // seq,
|
|
194
|
+
...saved, // seq, instance, registers, wm
|
|
180
195
|
journal: await journal.tail(roomName, saved.seq),
|
|
181
196
|
});
|
|
182
197
|
}
|
|
183
198
|
```
|
|
184
199
|
|
|
185
|
-
Restoring the persisted `
|
|
186
|
-
keep their sequence watermark and catch up with a cheap `delta` answer. Omit it and
|
|
187
|
-
back to a full snapshot, which is always safe. The optional journal tail is only
|
|
188
|
-
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.
|
|
189
204
|
|
|
190
205
|
## WebRTC signaling
|
|
191
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,11 +51,11 @@ 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[];
|
|
27
55
|
/**
|
|
28
56
|
* Present only on a MIGRATION envelope: the new `schemaVersion` this envelope
|
|
29
|
-
* establishes. The relay bumps the room's schema +
|
|
30
|
-
* omit it.
|
|
57
|
+
* establishes. The relay bumps the room's schema + instance when it sequences one; normal
|
|
58
|
+
* writes omit it.
|
|
31
59
|
*/
|
|
32
60
|
readonly schemaVersion?: number;
|
|
33
61
|
};
|
|
@@ -35,6 +63,31 @@ type OpEnvelope = {
|
|
|
35
63
|
type SeqEnvelope = OpEnvelope & {
|
|
36
64
|
readonly seq: number;
|
|
37
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
|
+
};
|
|
38
91
|
type PresenceState = {
|
|
39
92
|
readonly origin: string;
|
|
40
93
|
readonly writer: string; /** Consumer-defined activity payload (cursor, section, agent activity descriptor…). */
|
|
@@ -74,7 +127,7 @@ type WelcomeMsg = {
|
|
|
74
127
|
readonly seq: number;
|
|
75
128
|
/** Room-instance nonce: changes when a room is recreated (relay restart, DO eviction),
|
|
76
129
|
* so clients know their seq watermark belongs to a dead seq space. */
|
|
77
|
-
readonly
|
|
130
|
+
readonly instance: string; /** The room's current data shape. */
|
|
78
131
|
readonly schemaVersion: number;
|
|
79
132
|
readonly peers: readonly PresenceState[]; /** Origins currently in the room (membership ≠ presence) — the P2P bootstrap roster. */
|
|
80
133
|
readonly members: readonly string[];
|
|
@@ -84,8 +137,9 @@ type WelcomeMsg = {
|
|
|
84
137
|
readonly mode: 'delta';
|
|
85
138
|
readonly envs: readonly SeqEnvelope[];
|
|
86
139
|
} | {
|
|
87
|
-
readonly mode: 'snapshot';
|
|
88
|
-
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>>;
|
|
89
143
|
});
|
|
90
144
|
type ServerEnvMsg = {
|
|
91
145
|
readonly t: 'env';
|
|
@@ -123,12 +177,14 @@ type ServerSignalMsg = {
|
|
|
123
177
|
readonly from: string;
|
|
124
178
|
readonly data: unknown;
|
|
125
179
|
};
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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;
|
|
132
188
|
//#endregion
|
|
133
189
|
//#region src/lib/policy.d.ts
|
|
134
190
|
/**
|
|
@@ -148,6 +204,14 @@ type PrincipalCtx = {
|
|
|
148
204
|
*
|
|
149
205
|
* This is a WRITE ACL, not a read ACL: every room member sees the whole root, so the room is
|
|
150
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.
|
|
151
215
|
*/
|
|
152
216
|
type OpPolicy = {
|
|
153
217
|
canWrite?(ctx: PrincipalCtx, path: readonly Key[]): boolean;
|
|
@@ -155,7 +219,8 @@ type OpPolicy = {
|
|
|
155
219
|
};
|
|
156
220
|
type PolicyViolation = {
|
|
157
221
|
readonly writer: string;
|
|
158
|
-
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;
|
|
159
224
|
readonly path?: readonly Key[];
|
|
160
225
|
};
|
|
161
226
|
/** Check one envelope against a policy; `null` means clean. */
|
|
@@ -171,6 +236,43 @@ type PathAclRule = {
|
|
|
171
236
|
};
|
|
172
237
|
declare function pathPrefixAcl(rules: readonly PathAclRule[]): OpPolicy;
|
|
173
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
|
|
174
276
|
//#region src/lib/relay.d.ts
|
|
175
277
|
/** What the relay needs from a connection — implement over ws, a DO WebSocket, or a test pair. */
|
|
176
278
|
type RelaySocket = {
|
|
@@ -178,40 +280,49 @@ type RelaySocket = {
|
|
|
178
280
|
close?(): void;
|
|
179
281
|
};
|
|
180
282
|
type RelayLimits = {
|
|
181
|
-
/**
|
|
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). */
|
|
182
289
|
readonly maxEnvelopesPerSecond?: number;
|
|
183
290
|
};
|
|
184
291
|
type RelayOptions = {
|
|
185
292
|
/** Validation/ACL applied to every envelope; violations eject the writer (tripwire). */readonly policy?: OpPolicy;
|
|
186
293
|
readonly policyVersion?: number;
|
|
187
|
-
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). */
|
|
188
295
|
readonly journalLimit?: number;
|
|
189
296
|
readonly now?: () => number;
|
|
190
297
|
readonly onViolation?: (room: string, violation: PolicyViolation) => void;
|
|
191
298
|
/**
|
|
192
|
-
* The persistence egress: fired after an envelope is sequenced,
|
|
193
|
-
*
|
|
194
|
-
* `state` carries the
|
|
195
|
-
* 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}.
|
|
196
304
|
*/
|
|
197
305
|
readonly onCommit?: (room: string, env: SeqEnvelope, state: RoomState) => void;
|
|
198
306
|
};
|
|
199
307
|
/** The room's durable state at a commit: what a checkpoint needs to capture. */
|
|
200
308
|
type RoomState = {
|
|
201
309
|
readonly seq: number;
|
|
202
|
-
readonly
|
|
203
|
-
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}. */
|
|
204
313
|
readonly schemaVersion: number;
|
|
205
314
|
};
|
|
206
315
|
/** A persisted room to restore via {@link Relay.hydrate}. */
|
|
207
316
|
type RoomSnapshot = {
|
|
208
|
-
readonly seq: number;
|
|
209
|
-
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>>;
|
|
210
320
|
/**
|
|
211
|
-
* Restore the persisted
|
|
212
|
-
* 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).
|
|
213
324
|
*/
|
|
214
|
-
readonly
|
|
325
|
+
readonly instance?: string; /** Restore the persisted schema version (a compacted snapshot is post-migration). */
|
|
215
326
|
readonly schemaVersion?: number; /** Journal tail (ascending seq, entries at or below `seq`) enabling those delta answers. */
|
|
216
327
|
readonly journal?: readonly SeqEnvelope[];
|
|
217
328
|
};
|
|
@@ -235,18 +346,21 @@ type Relay = {
|
|
|
235
346
|
hydrate(name: string, snapshot: RoomSnapshot): boolean;
|
|
236
347
|
};
|
|
237
348
|
/**
|
|
238
|
-
* The reference relay core: room-scoped sequencing, journal +
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
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.
|
|
243
357
|
*
|
|
244
358
|
* Room-initialization contract: a fresh room (seq 0) answers `up-to-date`; the first client
|
|
245
|
-
* then SEEDS it with a root-set envelope so the room's
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
* 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.
|
|
249
363
|
*/
|
|
250
364
|
declare function createRelay(opt?: RelayOptions): Relay;
|
|
251
365
|
//#endregion
|
|
252
|
-
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
else base[seg] = op.next;
|
|
54
|
-
return base;
|
|
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);
|
|
55
187
|
}
|
|
56
|
-
|
|
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: room-scoped sequencing, journal +
|
|
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,14 +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
|
-
|
|
231
|
+
instance: mintInstance(),
|
|
88
232
|
schemaVersion: 0,
|
|
89
|
-
|
|
233
|
+
registers: createRegisterStore(),
|
|
234
|
+
wm: /* @__PURE__ */ new Map(),
|
|
235
|
+
frontier: void 0,
|
|
90
236
|
journal: [],
|
|
91
237
|
members: /* @__PURE__ */ new Set(),
|
|
92
238
|
presence: /* @__PURE__ */ new Map(),
|
|
@@ -97,6 +243,10 @@ function createRelay(opt = {}) {
|
|
|
97
243
|
}
|
|
98
244
|
return room;
|
|
99
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
|
+
};
|
|
100
250
|
const broadcast = (room, msg, except) => {
|
|
101
251
|
for (const member of room.members) if (member !== except) member.socket.send(msg);
|
|
102
252
|
};
|
|
@@ -150,6 +300,7 @@ function createRelay(opt = {}) {
|
|
|
150
300
|
bucket.tokens -= 1;
|
|
151
301
|
return false;
|
|
152
302
|
};
|
|
303
|
+
const laterHlc = (a, b) => !a || b.p > a.p || b.p === a.p && b.l > a.l ? b : a;
|
|
153
304
|
return {
|
|
154
305
|
room: (name) => {
|
|
155
306
|
const room = rooms.get(name);
|
|
@@ -163,8 +314,9 @@ function createRelay(opt = {}) {
|
|
|
163
314
|
const room = roomOf(name);
|
|
164
315
|
if (room.seq !== 0 || room.members.size > 0 || room.journal.length > 0) return false;
|
|
165
316
|
room.seq = snapshot.seq;
|
|
166
|
-
room.
|
|
167
|
-
|
|
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;
|
|
168
320
|
if (snapshot.schemaVersion !== void 0) room.schemaVersion = snapshot.schemaVersion;
|
|
169
321
|
if (snapshot.journal) room.journal = snapshot.journal.filter((e) => e.seq <= snapshot.seq).sort((a, b) => a.seq - b.seq).slice(-journalLimit);
|
|
170
322
|
return true;
|
|
@@ -183,6 +335,7 @@ function createRelay(opt = {}) {
|
|
|
183
335
|
origin: member.origin,
|
|
184
336
|
gone: true
|
|
185
337
|
});
|
|
338
|
+
maybeEvictEmpty(name);
|
|
186
339
|
}
|
|
187
340
|
joined.clear();
|
|
188
341
|
};
|
|
@@ -199,13 +352,14 @@ function createRelay(opt = {}) {
|
|
|
199
352
|
});
|
|
200
353
|
return;
|
|
201
354
|
}
|
|
202
|
-
if (msg.proto !==
|
|
355
|
+
if (msg.proto !== 2) {
|
|
203
356
|
socket.send({
|
|
204
357
|
t: "reject",
|
|
205
358
|
room: msg.room,
|
|
206
359
|
reason: "proto",
|
|
207
|
-
expected:
|
|
360
|
+
expected: 2
|
|
208
361
|
});
|
|
362
|
+
maybeEvictEmpty(msg.room);
|
|
209
363
|
return;
|
|
210
364
|
}
|
|
211
365
|
if (msg.policyVersion !== policyVersion) {
|
|
@@ -215,6 +369,7 @@ function createRelay(opt = {}) {
|
|
|
215
369
|
reason: "policy-version",
|
|
216
370
|
expected: policyVersion
|
|
217
371
|
});
|
|
372
|
+
maybeEvictEmpty(msg.room);
|
|
218
373
|
return;
|
|
219
374
|
}
|
|
220
375
|
if (msg.schemaVersion !== void 0 && msg.schemaVersion < room.schemaVersion) {
|
|
@@ -224,6 +379,7 @@ function createRelay(opt = {}) {
|
|
|
224
379
|
reason: "schema",
|
|
225
380
|
expected: room.schemaVersion
|
|
226
381
|
});
|
|
382
|
+
maybeEvictEmpty(msg.room);
|
|
227
383
|
return;
|
|
228
384
|
}
|
|
229
385
|
for (const prior of [...room.members]) {
|
|
@@ -249,7 +405,7 @@ function createRelay(opt = {}) {
|
|
|
249
405
|
t: "welcome",
|
|
250
406
|
room: msg.room,
|
|
251
407
|
seq: room.seq,
|
|
252
|
-
|
|
408
|
+
instance: room.instance,
|
|
253
409
|
schemaVersion: room.schemaVersion,
|
|
254
410
|
peers,
|
|
255
411
|
members: [...room.members].filter((m) => m !== member).map((m) => m.origin)
|
|
@@ -268,7 +424,8 @@ function createRelay(opt = {}) {
|
|
|
268
424
|
} else socket.send({
|
|
269
425
|
...base,
|
|
270
426
|
mode: "snapshot",
|
|
271
|
-
|
|
427
|
+
registers: room.registers.checkpoint(),
|
|
428
|
+
wm: Object.fromEntries(room.wm)
|
|
272
429
|
});
|
|
273
430
|
return;
|
|
274
431
|
}
|
|
@@ -304,9 +461,14 @@ function createRelay(opt = {}) {
|
|
|
304
461
|
return;
|
|
305
462
|
}
|
|
306
463
|
const env = msg.env;
|
|
307
|
-
const
|
|
464
|
+
const malformed = validateEnvelope(env);
|
|
465
|
+
const violation = env.policyVersion !== policyVersion || env.proto !== 2 ? {
|
|
308
466
|
writer: ctx.writer,
|
|
309
467
|
reason: "proto"
|
|
468
|
+
} : malformed !== null ? {
|
|
469
|
+
writer: ctx.writer,
|
|
470
|
+
reason: "malformed",
|
|
471
|
+
detail: malformed
|
|
310
472
|
} : env.ops.length > maxOps ? {
|
|
311
473
|
writer: ctx.writer,
|
|
312
474
|
reason: "ops-limit"
|
|
@@ -318,17 +480,33 @@ function createRelay(opt = {}) {
|
|
|
318
480
|
eject(msg.room, room, ctx.writer, violation);
|
|
319
481
|
return;
|
|
320
482
|
}
|
|
483
|
+
if (env.schemaVersion !== void 0 && env.schemaVersion < room.schemaVersion) return;
|
|
321
484
|
const seqEnv = {
|
|
322
485
|
...env,
|
|
323
486
|
seq: ++room.seq
|
|
324
487
|
};
|
|
325
488
|
room.journal.push(seqEnv);
|
|
326
|
-
room.root = applyWireOps(room.root, env.ops);
|
|
327
489
|
if (env.schemaVersion !== void 0 && env.schemaVersion > room.schemaVersion) {
|
|
328
490
|
room.schemaVersion = env.schemaVersion;
|
|
329
|
-
room.
|
|
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
|
+
}
|
|
330
509
|
}
|
|
331
|
-
if (room.journal.length > journalLimit) room.journal.shift();
|
|
332
510
|
broadcast(room, {
|
|
333
511
|
t: "env",
|
|
334
512
|
room: msg.room,
|
|
@@ -336,8 +514,9 @@ function createRelay(opt = {}) {
|
|
|
336
514
|
});
|
|
337
515
|
opt.onCommit?.(msg.room, seqEnv, {
|
|
338
516
|
seq: room.seq,
|
|
339
|
-
|
|
340
|
-
|
|
517
|
+
instance: room.instance,
|
|
518
|
+
registers: room.registers.checkpoint(),
|
|
519
|
+
wm: Object.fromEntries(room.wm),
|
|
341
520
|
schemaVersion: room.schemaVersion
|
|
342
521
|
});
|
|
343
522
|
}
|
|
@@ -346,4 +525,4 @@ function createRelay(opt = {}) {
|
|
|
346
525
|
};
|
|
347
526
|
}
|
|
348
527
|
//#endregion
|
|
349
|
-
export { MESH_PROTO_VERSION,
|
|
528
|
+
export { MESH_PROTO_VERSION, checkEnvelope, createRegisterStore, createRelay, pathPrefixAcl, validateEnvelope };
|