@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.
Files changed (4) hide show
  1. package/README.md +39 -24
  2. package/index.d.ts +151 -37
  3. package/index.js +227 -48
  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
 
@@ -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. And because the relay folds envelopes into snapshots, it reads
103
- plaintext; end-to-end encryption where the server sees only ciphertext is incompatible with
104
- server-side compaction as designed. Encrypt the transport and the stored data, but treat the
105
- relay as inside the trust boundary.
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 `snapshot + envelopes`, compacted by re-snapshotting.
170
+ event-sourced journal is just `register checkpoint + envelopes`, compacted by re-checkpointing.
156
171
 
157
- `onCommit` fires after every envelope is sequenced and folded, with the envelope and the
158
- room's current `{ seq, epoch, root }`. Append the envelope to your journal, and checkpoint the
159
- root as often as you like. The relay never awaits it, so batching and backpressure belong to
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, epoch, root
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 `epoch` is what lets clients that were connected before the restart
186
- keep their sequence watermark and catch up with a cheap `delta` answer. Omit it and they fall
187
- back to a full snapshot, which is always safe. The optional journal tail is only there to make
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
- 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,11 +51,11 @@ 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[];
27
55
  /**
28
56
  * Present only on a MIGRATION envelope: the new `schemaVersion` this envelope
29
- * establishes. The relay bumps the room's schema + epoch when it sequences one; normal writes
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 epoch: string; /** The room's current data shape. */
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 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>>;
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
- type ServerMsg = WelcomeMsg | ServerEnvMsg | ServerPresenceMsg | RejectMsg | EjectMsg | MemberMsg | ServerSignalMsg;
127
- /**
128
- * Minimal pure op application for the relay's snapshot compaction — the same fold the L0
129
- * `applyOps` performs, owned here so the protocol package stays dependency-free.
130
- */
131
- 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;
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
- /** 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). */
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 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). */
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, folded into the room
193
- * snapshot, and broadcast. The envelope is the persistence record (append it to a journal);
194
- * `state` carries the folded root for throttled checkpoints. Called synchronously and never
195
- * 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}.
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 epoch: string;
203
- readonly root: unknown; /** The room's data shape; restored via {@link Relay.hydrate}. */
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 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>>;
210
320
  /**
211
- * Restore the persisted epoch so clients reconnecting across the restart keep their seq
212
- * 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).
213
324
  */
214
- readonly epoch?: string; /** Restore the persisted schema version (a compacted snapshot is post-migration). */
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
- * snapshot compaction, the tri-state join answer, presence fan-out, and tripwire policy
240
- * enforcement. Pure over injected sockets — runs identically under ws, Bun, a Durable
241
- * Object, or an in-memory test pair. The relay never interprets ops beyond folding them
242
- * 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.
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 snapshot root is complete (deletes
246
- * fold correctly, joiners replace-hydrate). Near-simultaneous first-joins of a brand-new
247
- * room may last-writer-wins their seeds; rooms created by a single client first (the
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 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);
45
- }
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;
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
- 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: 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,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
- epoch: `${now().toString(36)}-${(++epochCounter).toString(36)}`,
231
+ instance: mintInstance(),
88
232
  schemaVersion: 0,
89
- root: void 0,
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.root = snapshot.root;
167
- 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;
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 !== 1) {
355
+ if (msg.proto !== 2) {
203
356
  socket.send({
204
357
  t: "reject",
205
358
  room: msg.room,
206
359
  reason: "proto",
207
- expected: 1
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
- epoch: room.epoch,
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
- root: room.root
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 violation = env.policyVersion !== policyVersion || env.proto !== 1 ? {
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.epoch = `${now().toString(36)}-${(++epochCounter).toString(36)}`;
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
- epoch: room.epoch,
340
- root: room.root,
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, 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.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "module": "./index.js",