@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.
Files changed (4) hide show
  1. package/README.md +42 -20
  2. package/index.d.ts +167 -41
  3. package/index.js +243 -47
  4. 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, compacts a snapshot, answers a
27
- joining client with whatever it is missing, routes presence, and enforces an optional policy.
28
- It does not merge, validate schemas, or hold application logic. Conflict resolution is the
29
- client's job, and it happens per path, so two people editing different fields of the same
30
- record never collide.
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 StoreOp[];
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, // ops kept for delta catch-up before folding to a snapshot
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 full root, when the client is too far behind for the journal to cover.
75
-
76
- Deletes fold correctly into the snapshot, so a late joiner never resurrects a removed key.
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 `snapshot + envelopes`, compacted by re-snapshotting.
170
+ event-sourced journal is just `register checkpoint + envelopes`, compacted by re-checkpointing.
149
171
 
150
- `onCommit` fires after every envelope is sequenced and folded, with the envelope and the
151
- room's current `{ seq, epoch, root }`. Append the envelope to your journal, and checkpoint the
152
- root as often as you like. The relay never awaits it, so batching and backpressure belong to
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, epoch, root
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 `epoch` is what lets clients that were connected before the restart
179
- keep their sequence watermark and catch up with a cheap `delta` answer. Omit it and they fall
180
- back to a full snapshot, which is always safe. The optional journal tail is only there to make
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
- declare const MESH_PROTO_VERSION = 1;
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 StoreOp[];
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 (RFC §6), plus the current presence roster. */
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 epoch: string;
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 root: unknown;
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
- type ServerMsg = WelcomeMsg | ServerEnvMsg | ServerPresenceMsg | RejectMsg | EjectMsg | MemberMsg | ServerSignalMsg;
119
- /**
120
- * Minimal pure op application for the relay's snapshot compaction — the same fold the L0
121
- * `applyOps` performs, owned here so the protocol package stays dependency-free.
122
- */
123
- declare function applyWireOps<T>(root: T, ops: readonly StoreOp[]): T;
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 (the relay never mints
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 (RFC §7). Run symmetrically on emit and on
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
- /** Ops per envelope; a larger envelope is a violation (default 1024). */readonly maxOpsPerEnvelope?: number; /** Sustained envelopes/second per writer (token bucket, burst = 2×; off by default). */
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 fold into the snapshot (default 1000). */
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, folded into the room
183
- * snapshot, and broadcast. The envelope is the persistence record (append it to a journal);
184
- * `state` carries the folded root for throttled checkpoints. Called synchronously and never
185
- * awaited: batch, debounce, and store at the adapter layer. Pair with {@link Relay.hydrate}.
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 epoch: string;
193
- readonly root: unknown;
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 root: unknown;
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 epoch so clients reconnecting across the restart keep their seq
201
- * watermark and get a `delta` answer; omit to mint a fresh one (they re-snapshot instead).
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 epoch?: string; /** Journal tail (ascending seq, entries at or below `seq`) enabling those delta answers. */
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 (op-protocol RFC §6/§7): room-scoped sequencing, journal +
227
- * snapshot compaction, the tri-state join answer, presence fan-out, and tripwire policy
228
- * enforcement. Pure over injected sockets — runs identically under ws, Bun, a Durable
229
- * Object, or an in-memory test pair. The relay never interprets ops beyond folding them
230
- * for snapshots, and never mints identity: `writer` comes from the adapter's auth.
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 snapshot root is complete (deletes
234
- * fold correctly, joiners replace-hydrate). Near-simultaneous first-joins of a brand-new
235
- * room may last-writer-wins their seeds; rooms created by a single client first (the
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 WelcomeMsg, applyWireOps, checkEnvelope, createRelay, pathPrefixAcl };
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/wire.ts
32
- const MESH_PROTO_VERSION = 1;
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
- * Minimal pure op application for the relay's snapshot compaction the same fold the L0
35
- * `applyOps` performs, owned here so the protocol package stays dependency-free.
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 applyWireOps(root, ops) {
38
- let next = root;
39
- for (const op of ops) {
40
- if (op.path.length === 0) {
41
- if (op.kind === "set") next = op.next;
42
- continue;
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
- next = applyAt(next, op.path, 0, op);
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 next;
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 epochCounter = 0;
201
+ let instanceCounter = 0;
62
202
  /**
63
- * The reference relay core (op-protocol RFC §6/§7): room-scoped sequencing, journal +
64
- * snapshot compaction, the tri-state join answer, presence fan-out, and tripwire policy
65
- * enforcement. Pure over injected sockets — runs identically under ws, Bun, a Durable
66
- * Object, or an in-memory test pair. The relay never interprets ops beyond folding them
67
- * for snapshots, and never mints identity: `writer` comes from the adapter's auth.
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 snapshot root is complete (deletes
71
- * fold correctly, joiners replace-hydrate). Near-simultaneous first-joins of a brand-new
72
- * room may last-writer-wins their seeds; rooms created by a single client first (the
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
- epoch: `${now().toString(36)}-${(++epochCounter).toString(36)}`,
88
- root: void 0,
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.root = snapshot.root;
166
- if (snapshot.epoch !== void 0) room.epoch = snapshot.epoch;
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 !== 1) {
355
+ if (msg.proto !== 2) {
201
356
  socket.send({
202
357
  t: "reject",
203
358
  room: msg.room,
204
359
  reason: "proto",
205
- expected: 1
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
- epoch: room.epoch,
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
- root: room.root
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 violation = env.policyVersion !== policyVersion || env.proto !== 1 ? {
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
- room.root = applyWireOps(room.root, env.ops);
315
- if (room.journal.length > journalLimit) room.journal.shift();
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
- epoch: room.epoch,
324
- root: room.root
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, applyWireOps, checkEnvelope, createRelay, pathPrefixAcl };
528
+ export { MESH_PROTO_VERSION, checkEnvelope, createRegisterStore, createRelay, pathPrefixAcl, validateEnvelope };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmstack/mesh-protocol",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "module": "./index.js",