@mmstack/mesh-protocol 0.1.0 → 0.2.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 +47 -2
  2. package/index.d.ts +51 -15
  3. package/index.js +32 -1
  4. package/package.json +2 -2
package/README.md CHANGED
@@ -97,6 +97,13 @@ matches a path. For richer rules, write your own `OpPolicy` with `canWrite` and
97
97
  Schema-aware validation (deriving a policy from your data model) composes on top and stays in
98
98
  your codebase, not here.
99
99
 
100
+ Two boundaries to be clear about. Policy gates writes, not reads: every member of a room sees
101
+ 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.
106
+
100
107
  ## Adapter recipes
101
108
 
102
109
  The relay is pure over injected sockets, so an adapter is a few lines of glue.
@@ -139,8 +146,46 @@ export class MeshRoom {
139
146
  }
140
147
  ```
141
148
 
142
- Durable storage (journal persistence across restarts) rides the same seam and lands with a
143
- later release. In-memory rooms cover dev and single-process deployments today.
149
+ ## Persistence
150
+
151
+ Rooms live in memory. That covers dev and single-process deployments, and when the relay
152
+ restarts, the first client to rejoin seeds the room from its own local state, so nothing is
153
+ lost as long as somebody was online. For durability beyond that, the relay exposes a seam
154
+ 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.
156
+
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:
161
+
162
+ ```ts
163
+ const relay = createRelay({
164
+ onCommit: (room, env, state) => {
165
+ journal.append(room, env); // your DB, KV, or DO storage
166
+ if (state.seq % 100 === 0) checkpoints.put(room, state);
167
+ },
168
+ });
169
+ ```
170
+
171
+ `relay.hydrate(room, snapshot)` restores a persisted room before clients join (relay boot, or
172
+ inside a Durable Object's `blockConcurrencyWhile`). It refuses once the room has state or
173
+ members, so your load can race a fast client without corrupting a live sequence space:
174
+
175
+ ```ts
176
+ const saved = await checkpoints.get(roomName);
177
+ if (saved) {
178
+ relay.hydrate(roomName, {
179
+ ...saved, // seq, epoch, root
180
+ journal: await journal.tail(roomName, saved.seq),
181
+ });
182
+ }
183
+ ```
184
+
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.
144
189
 
145
190
  ## WebRTC signaling
146
191
 
package/index.d.ts CHANGED
@@ -1,10 +1,4 @@
1
1
  //#region src/lib/wire.d.ts
2
- /**
3
- * Canonical wire types of the mmstack op protocol (op-protocol RFC §3/§6). Structurally
4
- * identical to the L0 types in `@mmstack/primitives` — deliberately NOT imported from there,
5
- * so this package stays zero-dependency and never drags Angular peers onto a server. The
6
- * client package asserts mutual assignability at compile time.
7
- */
8
2
  type Key = string | number;
9
3
  type StoreOp = {
10
4
  kind: 'set';
@@ -30,6 +24,12 @@ type OpEnvelope = {
30
24
  readonly hlc: Hlc;
31
25
  readonly policyVersion: number;
32
26
  readonly ops: readonly StoreOp[];
27
+ /**
28
+ * 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.
31
+ */
32
+ readonly schemaVersion?: number;
33
33
  };
34
34
  /** An envelope the relay has ordered: `seq` is the room-scoped total order. */
35
35
  type SeqEnvelope = OpEnvelope & {
@@ -46,7 +46,8 @@ type HelloMsg = {
46
46
  readonly origin: string;
47
47
  readonly proto: number;
48
48
  readonly policyVersion: number; /** Last room seq this client has applied — enables the delta answer on reconnect. */
49
- readonly seq?: number;
49
+ readonly seq?: number; /** The data shape this client speaks, older-than-room is rejected `schema`. */
50
+ readonly schemaVersion?: number;
50
51
  };
51
52
  type ClientEnvMsg = {
52
53
  readonly t: 'env';
@@ -66,14 +67,15 @@ type ClientSignalMsg = {
66
67
  readonly data: unknown;
67
68
  };
68
69
  type ClientMsg = HelloMsg | ClientEnvMsg | ClientPresenceMsg | ClientSignalMsg;
69
- /** The tri-state join answer (RFC §6), plus the current presence roster. */
70
+ /** The tri-state join answer, plus the current presence roster. */
70
71
  type WelcomeMsg = {
71
72
  readonly t: 'welcome';
72
73
  readonly room: string;
73
74
  readonly seq: number;
74
75
  /** Room-instance nonce: changes when a room is recreated (relay restart, DO eviction),
75
76
  * so clients know their seq watermark belongs to a dead seq space. */
76
- readonly epoch: string;
77
+ readonly epoch: string; /** The room's current data shape. */
78
+ readonly schemaVersion: number;
77
79
  readonly peers: readonly PresenceState[]; /** Origins currently in the room (membership ≠ presence) — the P2P bootstrap roster. */
78
80
  readonly members: readonly string[];
79
81
  } & ({
@@ -99,7 +101,7 @@ type ServerPresenceMsg = {
99
101
  type RejectMsg = {
100
102
  readonly t: 'reject';
101
103
  readonly room: string;
102
- readonly reason: 'proto' | 'policy-version' | 'unauthorized';
104
+ readonly reason: 'proto' | 'policy-version' | 'unauthorized' | 'schema';
103
105
  readonly expected?: number;
104
106
  };
105
107
  type EjectMsg = {
@@ -130,8 +132,7 @@ declare function applyWireOps<T>(root: T, ops: readonly StoreOp[]): T;
130
132
  //#endregion
131
133
  //#region src/lib/policy.d.ts
132
134
  /**
133
- * The principal behind a connection, as authenticated by the adapter (the relay never mints
134
- * identity — RFC §3). `kind` distinguishes non-human peers: an agent is a user (§0), just one
135
+ * The principal behind a connection, as authenticated by the adapter. `kind` distinguishes non-human peers: an agent is a user, just one
135
136
  * whose policy is usually narrower.
136
137
  */
137
138
  type PrincipalCtx = {
@@ -140,10 +141,13 @@ type PrincipalCtx = {
140
141
  readonly claims?: Readonly<Record<string, unknown>>;
141
142
  };
142
143
  /**
143
- * Pure, deterministic, versioned validation (RFC §7). Run symmetrically on emit and on
144
+ * Pure, deterministic, versioned validation. Run symmetrically on emit and on
144
145
  * apply: honest peers never emit invalid ops, so any violation observed on the wire is a
145
146
  * buggy or malicious writer — tripwire semantics eject it deterministically. Never skip an
146
147
  * op mid-log.
148
+ *
149
+ * This is a WRITE ACL, not a read ACL: every room member sees the whole root, so the room is
150
+ * the confidentiality boundary. Data with different audiences belongs in different rooms.
147
151
  */
148
152
  type OpPolicy = {
149
153
  canWrite?(ctx: PrincipalCtx, path: readonly Key[]): boolean;
@@ -184,6 +188,32 @@ type RelayOptions = {
184
188
  readonly journalLimit?: number;
185
189
  readonly now?: () => number;
186
190
  readonly onViolation?: (room: string, violation: PolicyViolation) => void;
191
+ /**
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}.
196
+ */
197
+ readonly onCommit?: (room: string, env: SeqEnvelope, state: RoomState) => void;
198
+ };
199
+ /** The room's durable state at a commit: what a checkpoint needs to capture. */
200
+ type RoomState = {
201
+ readonly seq: number;
202
+ readonly epoch: string;
203
+ readonly root: unknown; /** The room's data shape; restored via {@link Relay.hydrate}. */
204
+ readonly schemaVersion: number;
205
+ };
206
+ /** A persisted room to restore via {@link Relay.hydrate}. */
207
+ type RoomSnapshot = {
208
+ readonly seq: number;
209
+ readonly root: unknown;
210
+ /**
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).
213
+ */
214
+ readonly epoch?: string; /** Restore the persisted schema version (a compacted snapshot is post-migration). */
215
+ readonly schemaVersion?: number; /** Journal tail (ascending seq, entries at or below `seq`) enabling those delta answers. */
216
+ readonly journal?: readonly SeqEnvelope[];
187
217
  };
188
218
  type RelayConnection = {
189
219
  receive(msg: ClientMsg): void;
@@ -197,9 +227,15 @@ type RoomInfo = {
197
227
  type Relay = {
198
228
  /** Attach an authenticated connection. `ctx.writer` is the trusted principal. */connect(socket: RelaySocket, ctx: PrincipalCtx): RelayConnection;
199
229
  room(name: string): RoomInfo | undefined;
230
+ /**
231
+ * Restore a persisted room before clients join (relay boot, Durable Object wake). Refused
232
+ * (`false`) once the room has state or members: hydrating a live seq space would corrupt
233
+ * it. Load asynchronously at the adapter layer, then hydrate synchronously.
234
+ */
235
+ hydrate(name: string, snapshot: RoomSnapshot): boolean;
200
236
  };
201
237
  /**
202
- * The reference relay core (op-protocol RFC §6/§7): room-scoped sequencing, journal +
238
+ * The reference relay core: room-scoped sequencing, journal +
203
239
  * snapshot compaction, the tri-state join answer, presence fan-out, and tripwire policy
204
240
  * enforcement. Pure over injected sockets — runs identically under ws, Bun, a Durable
205
241
  * Object, or an in-memory test pair. The relay never interprets ops beyond folding them
@@ -213,4 +249,4 @@ type Relay = {
213
249
  */
214
250
  declare function createRelay(opt?: RelayOptions): Relay;
215
251
  //#endregion
216
- 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 SeqEnvelope, type ServerEnvMsg, type ServerMsg, type ServerPresenceMsg, type ServerSignalMsg, type StoreOp, type WelcomeMsg, applyWireOps, checkEnvelope, createRelay, pathPrefixAcl };
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 };
package/index.js CHANGED
@@ -60,7 +60,7 @@ function applyAt(container, path, idx, op) {
60
60
  //#region src/lib/relay.ts
61
61
  let epochCounter = 0;
62
62
  /**
63
- * The reference relay core (op-protocol RFC §6/§7): room-scoped sequencing, journal +
63
+ * The reference relay core: room-scoped sequencing, journal +
64
64
  * snapshot compaction, the tri-state join answer, presence fan-out, and tripwire policy
65
65
  * enforcement. Pure over injected sockets — runs identically under ws, Bun, a Durable
66
66
  * Object, or an in-memory test pair. The relay never interprets ops beyond folding them
@@ -85,6 +85,7 @@ function createRelay(opt = {}) {
85
85
  room = {
86
86
  seq: 0,
87
87
  epoch: `${now().toString(36)}-${(++epochCounter).toString(36)}`,
88
+ schemaVersion: 0,
88
89
  root: void 0,
89
90
  journal: [],
90
91
  members: /* @__PURE__ */ new Set(),
@@ -158,6 +159,16 @@ function createRelay(opt = {}) {
158
159
  journal: room.journal.length
159
160
  } : void 0;
160
161
  },
162
+ hydrate: (name, snapshot) => {
163
+ const room = roomOf(name);
164
+ if (room.seq !== 0 || room.members.size > 0 || room.journal.length > 0) return false;
165
+ room.seq = snapshot.seq;
166
+ room.root = snapshot.root;
167
+ if (snapshot.epoch !== void 0) room.epoch = snapshot.epoch;
168
+ if (snapshot.schemaVersion !== void 0) room.schemaVersion = snapshot.schemaVersion;
169
+ if (snapshot.journal) room.journal = snapshot.journal.filter((e) => e.seq <= snapshot.seq).sort((a, b) => a.seq - b.seq).slice(-journalLimit);
170
+ return true;
171
+ },
161
172
  connect: (socket, ctx) => {
162
173
  const joined = /* @__PURE__ */ new Map();
163
174
  const disconnect = () => {
@@ -206,6 +217,15 @@ function createRelay(opt = {}) {
206
217
  });
207
218
  return;
208
219
  }
220
+ if (msg.schemaVersion !== void 0 && msg.schemaVersion < room.schemaVersion) {
221
+ socket.send({
222
+ t: "reject",
223
+ room: msg.room,
224
+ reason: "schema",
225
+ expected: room.schemaVersion
226
+ });
227
+ return;
228
+ }
209
229
  for (const prior of [...room.members]) {
210
230
  if (prior.origin !== msg.origin) continue;
211
231
  room.members.delete(prior);
@@ -230,6 +250,7 @@ function createRelay(opt = {}) {
230
250
  room: msg.room,
231
251
  seq: room.seq,
232
252
  epoch: room.epoch,
253
+ schemaVersion: room.schemaVersion,
233
254
  peers,
234
255
  members: [...room.members].filter((m) => m !== member).map((m) => m.origin)
235
256
  };
@@ -303,12 +324,22 @@ function createRelay(opt = {}) {
303
324
  };
304
325
  room.journal.push(seqEnv);
305
326
  room.root = applyWireOps(room.root, env.ops);
327
+ if (env.schemaVersion !== void 0 && env.schemaVersion > room.schemaVersion) {
328
+ room.schemaVersion = env.schemaVersion;
329
+ room.epoch = `${now().toString(36)}-${(++epochCounter).toString(36)}`;
330
+ }
306
331
  if (room.journal.length > journalLimit) room.journal.shift();
307
332
  broadcast(room, {
308
333
  t: "env",
309
334
  room: msg.room,
310
335
  env: seqEnv
311
336
  });
337
+ opt.onCommit?.(msg.room, seqEnv, {
338
+ seq: room.seq,
339
+ epoch: room.epoch,
340
+ root: room.root,
341
+ schemaVersion: room.schemaVersion
342
+ });
312
343
  }
313
344
  };
314
345
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmstack/mesh-protocol",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "module": "./index.js",
@@ -33,4 +33,4 @@
33
33
  },
34
34
  "homepage": "https://github.com/mihajm/mmstack/blob/master/packages/mesh/protocol",
35
35
  "sideEffects": false
36
- }
36
+ }