statewire 0.18.2 → 0.19.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 (86) hide show
  1. package/dist/client/connection.d.ts +8 -5
  2. package/dist/client/connection.d.ts.map +1 -1
  3. package/dist/client/connection.js +24 -13
  4. package/dist/client/connection.js.map +1 -1
  5. package/dist/client/session.d.ts +13 -15
  6. package/dist/client/session.d.ts.map +1 -1
  7. package/dist/client/session.js +291 -256
  8. package/dist/client/session.js.map +1 -1
  9. package/dist/client/timing.d.ts +2 -1
  10. package/dist/client/timing.d.ts.map +1 -1
  11. package/dist/client/timing.js +2 -1
  12. package/dist/client/timing.js.map +1 -1
  13. package/dist/host/engine.d.ts +2 -0
  14. package/dist/host/engine.d.ts.map +1 -1
  15. package/dist/host/engine.js +2 -0
  16. package/dist/host/engine.js.map +1 -1
  17. package/dist/host/host.d.ts +3 -1
  18. package/dist/host/host.d.ts.map +1 -1
  19. package/dist/host/host.js +38 -7
  20. package/dist/host/host.js.map +1 -1
  21. package/dist/host/http.d.ts.map +1 -1
  22. package/dist/host/http.js +8 -17
  23. package/dist/host/http.js.map +1 -1
  24. package/dist/host/socket.d.ts +47 -23
  25. package/dist/host/socket.d.ts.map +1 -1
  26. package/dist/host/socket.js +389 -245
  27. package/dist/host/socket.js.map +1 -1
  28. package/dist/host/synced.d.ts +9 -0
  29. package/dist/host/synced.d.ts.map +1 -0
  30. package/dist/host/synced.js +45 -0
  31. package/dist/host/synced.js.map +1 -0
  32. package/dist/host-internal.d.ts +20 -8
  33. package/dist/host-internal.d.ts.map +1 -1
  34. package/dist/host.d.ts +33 -9
  35. package/dist/host.d.ts.map +1 -1
  36. package/dist/host.js +2 -1
  37. package/dist/index.d.ts +2 -2
  38. package/dist/index.js +2 -1
  39. package/dist/statewire.d.ts +8 -10
  40. package/dist/statewire.d.ts.map +1 -1
  41. package/dist/statewire.js +0 -14
  42. package/dist/statewire.js.map +1 -1
  43. package/dist/storage.d.ts +2 -5
  44. package/dist/storage.d.ts.map +1 -1
  45. package/dist/storage.js +55 -41
  46. package/dist/storage.js.map +1 -1
  47. package/dist/transports/remote.d.ts.map +1 -1
  48. package/dist/transports/remote.js +1 -6
  49. package/dist/transports/remote.js.map +1 -1
  50. package/dist/transports/sse.d.ts +2 -5
  51. package/dist/transports/sse.d.ts.map +1 -1
  52. package/dist/transports/sse.js.map +1 -1
  53. package/dist/transports/testing.d.ts +7 -5
  54. package/dist/transports/testing.d.ts.map +1 -1
  55. package/dist/transports/testing.js +19 -19
  56. package/dist/transports/testing.js.map +1 -1
  57. package/dist/types.d.ts +3 -5
  58. package/dist/types.d.ts.map +1 -1
  59. package/dist/types.js.map +1 -1
  60. package/dist/wire/limits.d.ts +2 -2
  61. package/dist/wire/limits.js +2 -2
  62. package/dist/wire/limits.js.map +1 -1
  63. package/dist/wire/protocol.d.ts +6 -1
  64. package/dist/wire/protocol.d.ts.map +1 -1
  65. package/dist/wire/protocol.js +33 -12
  66. package/dist/wire/protocol.js.map +1 -1
  67. package/package.json +1 -1
  68. package/src/client/connection.ts +46 -33
  69. package/src/client/session.ts +434 -365
  70. package/src/client/timing.ts +2 -0
  71. package/src/host/engine.ts +3 -1
  72. package/src/host/host.ts +50 -3
  73. package/src/host/http.ts +22 -23
  74. package/src/host/socket.ts +550 -321
  75. package/src/host/synced.ts +60 -0
  76. package/src/host-internal.ts +21 -3
  77. package/src/host.ts +40 -8
  78. package/src/index.ts +1 -0
  79. package/src/statewire.ts +8 -30
  80. package/src/storage.ts +96 -79
  81. package/src/transports/remote.ts +0 -7
  82. package/src/transports/sse.ts +8 -3
  83. package/src/transports/testing.ts +34 -19
  84. package/src/types.ts +3 -5
  85. package/src/wire/limits.ts +1 -1
  86. package/src/wire/protocol.ts +40 -11
@@ -5,22 +5,21 @@ import {
5
5
  negotiateProtocols,
6
6
  } from "../wire/protocol";
7
7
  import type { StatewireProtocol } from "../wire/protocol";
8
- import { applyDocumentOps, mainDocument } from "../wire/documents";
9
- import type { StatewireDocuments } from "../wire/documents";
10
8
  import { useEffect, useState } from "react";
11
9
  import { createTapRoot, flushTapSync, useResource } from "@assistant-ui/tap";
12
10
  import type { ResourceElement } from "@assistant-ui/tap";
13
- import { getCommandsHost, getStateHost } from "./host";
11
+ import { documentIdentity, getCommandsHost, getStateHost } from "./host";
14
12
  import type { CommandsHost, CommandsOutcome } from "./host";
15
13
  import { StatewireSendError } from "../types";
16
14
  import type { Statewire } from "../types";
17
15
  import type { StatewireHost } from "../host";
18
16
  import type { StatewireHostInternal } from "../host-internal";
19
17
  import { reviveValue } from "../wire/meta";
18
+ import { sanitizeValue } from "../wire/ops";
20
19
  import {
21
20
  COMMAND_CAPACITY_RETRY_MS,
22
21
  MAX_COMMANDS_PER_FRAME,
23
- MAX_CONTEXT_VALUE_BYTES,
22
+ MAX_EPHEMERAL_DOCUMENT_BYTES,
24
23
  MAX_FRAME_BODY_BYTES,
25
24
  MAX_INFLIGHT_COMMANDS_PER_CLIENT,
26
25
  MAX_INFLIGHT_COMMANDS_PER_HOST,
@@ -28,7 +27,6 @@ import {
28
27
  import { createMacrotaskPoster } from "../util/macrotask";
29
28
  import type { StatewireDocument } from "../document";
30
29
  import type { StatewirePath } from "../wire/ops";
31
- import type { StatewireCmd, StatewireEnvelope } from "../client/connection";
32
30
  import { frames as serveFrames, stream as serveStream } from "./http";
33
31
 
34
32
  export const CLIENT_ID_RE = /^[A-Za-z0-9._~-]{1,256}$/;
@@ -76,14 +74,21 @@ const MAX_SUBSCRIBERS = 1024;
76
74
  export const SUBSCRIBER_LIMIT_MESSAGE = "stream subscriber limit reached";
77
75
  const STREAM_QUEUE_SIZE = 256;
78
76
 
77
+ type Cmd = StatewireHostInternal.Cmd;
78
+
79
79
  type WireOp = {
80
80
  op: string;
81
+ doc?: number;
81
82
  path?: StatewirePath;
82
83
  value?: unknown;
83
84
  meta?: StatewireDocument.MetaEntry[];
85
+ protocol?: string;
86
+ key?: readonly string[];
87
+ main?: boolean;
84
88
  };
85
89
 
86
90
  type Admission = {
91
+ protocol: string;
87
92
  seq: number;
88
93
  method: string;
89
94
  applied: boolean;
@@ -91,31 +96,44 @@ type Admission = {
91
96
  settled: boolean;
92
97
  };
93
98
 
99
+ /** One statement lane: durable lanes live on the client record, ephemeral lanes on the attach. */
100
+ type Lane = {
101
+ protocol: string;
102
+ clientId: string;
103
+ conn: Conn | null;
104
+ lastSeq: number;
105
+ results: Map<number, { ts: number; cmd: Cmd }>;
106
+ inflight: Map<number, Admission>;
107
+ };
108
+
94
109
  export type ClientRec = {
95
110
  id: string;
96
- lastSeq: number;
97
111
  touched: number;
98
- results: Map<number, { ts: number; cmd: StatewireCmd }>;
99
- inflight: Map<number, Admission>;
112
+ lanes: Map<string, Lane>;
100
113
  /** The auth captured at the record's first attach; later attaches must match. */
101
114
  principal: unknown;
102
115
  };
103
116
 
104
- // The ctx lane is attach scoped: a Conn is born with an empty context table
105
- // and outlives its socket so an admitted command keeps reading its last value.
117
+ // A Conn outlives its socket so an admitted command keeps its attach view.
106
118
  export type Conn = {
107
119
  selection: StatewireProtocol.Selection;
108
120
  clientId: string;
109
121
  socket: StatewireHostInternal.Socket;
110
122
  open: boolean;
123
+ ready: boolean;
111
124
  sawFrame: boolean;
112
125
  lastSentAt: number;
113
126
  lease: string | null;
114
127
  /** The awaited `authorize` return captured at this attach's authorization. */
115
128
  auth: unknown;
116
- ctxSeq: number;
117
- context: Record<string, unknown> | null;
118
- contextDocuments: StatewireDocuments.State;
129
+ lanes: Map<string, Lane>;
130
+ documents: Map<string, StatewireHost.Document>;
131
+ included: Set<string>;
132
+ view: Map<string, { doc: number; value: unknown }>;
133
+ nextDoc: number;
134
+ pendingApplied: Cmd[];
135
+ pendingCmds: Cmd[];
136
+ attach: StatewireHost.Attach;
119
137
  };
120
138
 
121
139
  type PendingConn = {
@@ -138,7 +156,6 @@ const violationReason = (error: unknown): string | null =>
138
156
  ? error.message
139
157
  : null;
140
158
 
141
- type CtxStatement = { seq: number; ops: unknown[] };
142
159
  type CmdStatement = {
143
160
  protocol: string;
144
161
  seq: number;
@@ -147,84 +164,55 @@ type CmdStatement = {
147
164
  meta?: unknown;
148
165
  };
149
166
 
150
- export type ParsedFrame = {
151
- ctx: CtxStatement[];
152
- cmd: CmdStatement[];
153
- };
167
+ export type ParsedFrame = { cmd: CmdStatement[] };
154
168
 
155
169
  const isPlainObject = (value: unknown): value is Record<string, unknown> =>
156
170
  typeof value === "object" && value !== null && !Array.isArray(value);
157
171
 
158
- const parseLane = (value: unknown): Record<string, unknown>[] => {
159
- if (!Array.isArray(value)) return violation("malformed frame");
160
- if (value.length === 0) return violation("empty lane");
161
- const statements: Record<string, unknown>[] = [];
162
- let prev: number | null = null;
163
- for (const statement of value) {
164
- if (!isPlainObject(statement)) return violation("malformed statement");
165
- const seq = statement["seq"];
166
- if (typeof seq !== "number" || !Number.isInteger(seq) || seq < 1) {
167
- return violation("malformed statement");
168
- }
169
- if (prev !== null && seq !== prev + 1) return violation("malformed frame");
170
- prev = seq;
171
- statements.push(statement);
172
- }
173
- return statements;
174
- };
175
-
176
172
  const frameEncoder = new TextEncoder();
177
173
 
178
174
  export const parseFrameValue = (frame: unknown): ParsedFrame => {
179
175
  if (!isPlainObject(frame)) return violation("malformed frame");
180
176
  const keys = Object.keys(frame);
181
177
  if (keys.length === 0) return violation("malformed frame");
182
- const unknown = keys.filter((key) => key !== "ctx" && key !== "cmd").sort();
178
+ const unknown = keys.filter((key) => key !== "cmd").sort();
183
179
  if (unknown.length > 0)
184
180
  return violation(`unknown frame key: '${unknown[0]}'`);
185
- const ctx: CtxStatement[] = [];
186
- if (frame["ctx"] !== undefined) {
187
- const lane = frame["ctx"];
188
- if (Array.isArray(lane) && lane.length > MAX_COMMANDS_PER_FRAME) {
189
- return violation("too many context statements");
190
- }
191
- for (const statement of parseLane(lane)) {
192
- const ops = statement["ops"];
193
- if (!Array.isArray(ops) || !ops.length)
194
- return violation("invalid-context");
195
- if (
196
- frameEncoder.encode(JSON.stringify(ops)).byteLength >
197
- MAX_CONTEXT_VALUE_BYTES
198
- )
199
- return violation("oversized-context");
200
- ctx.push({ seq: statement["seq"] as number, ops });
201
- }
202
- }
181
+ const lane = frame["cmd"];
182
+ if (!Array.isArray(lane)) return violation("malformed frame");
183
+ if (lane.length === 0) return violation("empty lane");
184
+ if (lane.length > MAX_COMMANDS_PER_FRAME)
185
+ return violation("too many commands");
186
+ const prev = new Map<string, number>();
203
187
  const cmd: CmdStatement[] = [];
204
- if (frame["cmd"] !== undefined) {
205
- const lane = frame["cmd"];
206
- if (Array.isArray(lane) && lane.length > MAX_COMMANDS_PER_FRAME) {
207
- return violation("too many commands");
208
- }
209
- for (const statement of parseLane(lane)) {
210
- const method = statement["method"];
211
- if (
212
- typeof method !== "string" ||
213
- typeof statement["protocol"] !== "string" ||
214
- !("params" in statement)
215
- ) {
216
- return violation("malformed statement");
217
- }
218
- cmd.push({
219
- protocol: statement["protocol"] as string,
220
- seq: statement["seq"] as number,
221
- method,
222
- params: statement["params"],
223
- ...(statement["meta"] !== undefined && { meta: statement["meta"] }),
224
- });
188
+ for (const statement of lane) {
189
+ if (!isPlainObject(statement)) return violation("malformed statement");
190
+ const seq = statement["seq"];
191
+ const method = statement["method"];
192
+ const protocol = statement["protocol"];
193
+ if (
194
+ typeof seq !== "number" ||
195
+ !Number.isInteger(seq) ||
196
+ seq < 1 ||
197
+ typeof method !== "string" ||
198
+ typeof protocol !== "string" ||
199
+ !("params" in statement)
200
+ ) {
201
+ return violation("malformed statement");
225
202
  }
203
+ const last = prev.get(protocol);
204
+ if (last !== undefined && seq !== last + 1)
205
+ return violation("malformed frame");
206
+ prev.set(protocol, seq);
207
+ cmd.push({
208
+ protocol,
209
+ seq,
210
+ method,
211
+ params: statement["params"],
212
+ ...(statement["meta"] !== undefined && { meta: statement["meta"] }),
213
+ });
226
214
  }
227
- return { ctx, cmd };
215
+ return { cmd };
228
216
  };
229
217
 
230
218
  export const parseFrame = (raw: string): ParsedFrame => {
@@ -251,18 +239,19 @@ const encodePayload = (
251
239
  return { payload: value, ...(meta !== undefined && { meta }) };
252
240
  };
253
241
 
254
- const cmdOf = (adm: Admission, outcome: CommandsOutcome): StatewireCmd => {
242
+ const cmdOf = (adm: Admission, outcome: CommandsOutcome): Cmd => {
243
+ const at = { protocol: adm.protocol, seq: adm.seq };
255
244
  if (outcome.type === "accepted") {
256
- return { seq: adm.seq, type: "result", ...encodePayload(outcome.payload) };
245
+ return { ...at, type: "result", ...encodePayload(outcome.payload) };
257
246
  }
258
247
  const failure =
259
248
  outcome.error instanceof StatewireSendError ? outcome.error.failure : null;
260
249
  switch (failure?.fate) {
261
250
  case "crashed":
262
- return { seq: adm.seq, type: "crashed" };
251
+ return { ...at, type: "crashed" };
263
252
  case "failed":
264
253
  return {
265
- seq: adm.seq,
254
+ ...at,
266
255
  type: "failed",
267
256
  ...(failure.message !== undefined && { message: failure.message }),
268
257
  };
@@ -273,14 +262,14 @@ const cmdOf = (adm: Admission, outcome: CommandsOutcome): StatewireCmd => {
273
262
  ? `unknown method: '${adm.method}'`
274
263
  : failure.message;
275
264
  return {
276
- seq: adm.seq,
265
+ ...at,
277
266
  type: "rejected",
278
267
  code: failure.code,
279
268
  ...(message !== undefined && { message }),
280
269
  };
281
270
  }
282
271
  return {
283
- seq: adm.seq,
272
+ ...at,
284
273
  type: "rejected",
285
274
  ...(failure.message !== undefined && { message: failure.message }),
286
275
  ...encodePayload(failure.payload),
@@ -288,7 +277,7 @@ const cmdOf = (adm: Admission, outcome: CommandsOutcome): StatewireCmd => {
288
277
  }
289
278
  default:
290
279
  return {
291
- seq: adm.seq,
280
+ ...at,
292
281
  type: "failed",
293
282
  message:
294
283
  outcome.error instanceof Error
@@ -316,9 +305,7 @@ const parseInitialFrame = (raw: string) => {
316
305
  }
317
306
  return {
318
307
  headers: headers as Record<string, string>,
319
- frame: Object.keys(lanes).length
320
- ? parseFrameValue(lanes)
321
- : { ctx: [], cmd: [] },
308
+ frame: Object.keys(lanes).length ? parseFrameValue(lanes) : { cmd: [] },
322
309
  };
323
310
  };
324
311
 
@@ -340,8 +327,9 @@ export type SocketHostState = {
340
327
  conns: Set<Conn>;
341
328
  pendingConns: Set<PendingConn>;
342
329
  pendingOps: WireOp[];
343
- pendingApplied: Map<string, number[]>;
344
- pendingCmds: Map<string, StatewireCmd[]>;
330
+ pendingApplied: Map<string, Cmd[]>;
331
+ pendingCmds: Map<string, Cmd[]>;
332
+ viewsDirty: boolean;
345
333
  idleWire: boolean;
346
334
  finished: StatewireHost.Fin | null;
347
335
  disposed: boolean;
@@ -368,6 +356,11 @@ const scheduleFlush = (h: SocketHostState) => {
368
356
  h.post();
369
357
  };
370
358
 
359
+ const markViews = (h: SocketHostState) => {
360
+ h.viewsDirty = true;
361
+ scheduleFlush(h);
362
+ };
363
+
371
364
  const recordOp = (
372
365
  h: SocketHostState,
373
366
  op: {
@@ -400,8 +393,10 @@ const attach = (h: SocketHostState, next: Statewire<unknown>) => {
400
393
  }
401
394
  if (nextState !== h.stateHost) {
402
395
  h.stateHost?.setOnOp(null);
396
+ h.stateHost?.setOnDocuments(null);
403
397
  h.stateHost = nextState;
404
398
  h.stateHost.setOnOp((op) => recordOp(h, op));
399
+ h.stateHost.setOnDocuments(() => markViews(h));
405
400
  }
406
401
  h.commandsHost = nextCommands;
407
402
  };
@@ -420,6 +415,7 @@ export const closeConn = (
420
415
  } catch {
421
416
  /* peer already gone */
422
417
  }
418
+ h.current?.detached?.(conn.attach);
423
419
  if (h.conns.size === 0 && h.heartbeatTimer !== null) {
424
420
  clearInterval(h.heartbeatTimer);
425
421
  h.heartbeatTimer = null;
@@ -436,6 +432,15 @@ export const sendTo = (h: SocketHostState, conn: Conn, data: string) => {
436
432
  }
437
433
  };
438
434
 
435
+ const violate = (h: SocketHostState, conn: Conn, reason: string) => {
436
+ sendTo(
437
+ h,
438
+ conn,
439
+ JSON.stringify({ fin: { reason: "protocol-error", message: reason } }),
440
+ );
441
+ closeConn(h, conn, 1008, reason);
442
+ };
443
+
439
444
  export const atSubscriberLimit = (h: SocketHostState): boolean =>
440
445
  h.conns.size +
441
446
  [...h.pendingConns].filter((conn) => conn.authorizing).length >=
@@ -462,22 +467,163 @@ const setHeartbeatMs = (h: SocketHostState, ms: number) => {
462
467
  }
463
468
  };
464
469
 
470
+ const durableLanes = function* (h: SocketHostState) {
471
+ for (const rec of h.clients.values()) yield* rec.lanes.values();
472
+ };
473
+
474
+ const allLanes = function* (h: SocketHostState) {
475
+ yield* durableLanes(h);
476
+ for (const conn of h.conns) yield* conn.lanes.values();
477
+ };
478
+
465
479
  const idleLevel = (h: SocketHostState): boolean => {
466
480
  if (h.current?.work?.isIdle === false) return false;
467
- for (const rec of h.clients.values()) {
468
- if (rec.inflight.size > 0) return false;
481
+ for (const lane of allLanes(h)) {
482
+ if (lane.inflight.size > 0) return false;
469
483
  }
470
484
  return true;
471
485
  };
472
486
 
487
+ const selects = (conn: Conn, protocol: string) =>
488
+ conn.selection.protocols.some((p) => p.name === protocol);
489
+
490
+ // ─── views ──────────────────────────────────────────────
491
+
492
+ const viewOf = (
493
+ h: SocketHostState,
494
+ conn: Conn,
495
+ ): Map<string, StatewireHost.Document> => {
496
+ const view = new Map<string, StatewireHost.Document>();
497
+ const shared = h.stateHost!.documents();
498
+ const hostMain = documentIdentity(h.protocol.name);
499
+ for (const p of conn.selection.protocols) {
500
+ if (p.record === "ephemeral") {
501
+ for (const [id, doc] of conn.documents)
502
+ if (doc.protocol === p.name) view.set(id, doc);
503
+ } else {
504
+ for (const [id, doc] of shared) {
505
+ if (
506
+ doc.protocol === p.name &&
507
+ id !== hostMain &&
508
+ (doc.main || conn.included.has(id))
509
+ )
510
+ view.set(id, doc);
511
+ }
512
+ }
513
+ }
514
+ return view;
515
+ };
516
+
517
+ const diffView = (h: SocketHostState, conn: Conn): WireOp[] => {
518
+ const current = viewOf(h, conn);
519
+ const ops: WireOp[] = [];
520
+ for (const [id, entry] of conn.view) {
521
+ if (current.has(id)) continue;
522
+ conn.view.delete(id);
523
+ ops.push({ op: "unmount", doc: entry.doc });
524
+ }
525
+ for (const [id, doc] of current) {
526
+ const entry = conn.view.get(id);
527
+ if (entry !== undefined && entry.value === doc.value) continue;
528
+ const { value, meta } = encodeDocument(doc.value);
529
+ if (entry === undefined) {
530
+ const number = conn.nextDoc++;
531
+ conn.view.set(id, { doc: number, value: doc.value });
532
+ ops.push({
533
+ op: "mount",
534
+ doc: number,
535
+ protocol: doc.protocol,
536
+ ...(doc.key !== null && { key: doc.key }),
537
+ ...(doc.main && { main: true }),
538
+ value,
539
+ ...(meta !== undefined && { meta }),
540
+ });
541
+ } else {
542
+ entry.value = doc.value;
543
+ ops.push({
544
+ op: "replace",
545
+ doc: entry.doc,
546
+ path: [],
547
+ value,
548
+ ...(meta !== undefined && { meta }),
549
+ });
550
+ }
551
+ }
552
+ return ops;
553
+ };
554
+
555
+ const attachOf = (h: SocketHostState, conn: Conn): StatewireHost.Attach => {
556
+ const requireSelected = (protocol: string) => {
557
+ const entry = conn.selection.protocols.find((p) => p.name === protocol);
558
+ if (entry === undefined)
559
+ throw new Error(`statewire: protocol ${protocol} was not negotiated`);
560
+ return entry;
561
+ };
562
+ return {
563
+ document: (protocol, key) => {
564
+ if (key === undefined && protocol === h.protocol.name)
565
+ return h.stateHost!.snapshot();
566
+ const view = viewOf(h, conn);
567
+ if (key !== undefined)
568
+ return view.get(documentIdentity(protocol, key))?.value;
569
+ for (const doc of view.values())
570
+ if (doc.protocol === protocol && doc.main) return doc.value;
571
+ return undefined;
572
+ },
573
+ mount: (protocol, value, key, main = key === undefined) => {
574
+ if (requireSelected(protocol).record !== "ephemeral")
575
+ throw new Error(`statewire: protocol ${protocol} is not ephemeral`);
576
+ const encoded = encodeDocument(value);
577
+ if (
578
+ frameEncoder.encode(JSON.stringify(encoded)).byteLength >
579
+ MAX_EPHEMERAL_DOCUMENT_BYTES
580
+ )
581
+ throw new Error("statewire: oversized ephemeral document");
582
+ const id = documentIdentity(protocol, key);
583
+ if (main) {
584
+ for (const [other, doc] of conn.documents) {
585
+ if (doc.protocol === protocol && doc.main && other !== id)
586
+ throw new Error(
587
+ `statewire: ${protocol} already has a main document`,
588
+ );
589
+ }
590
+ }
591
+ conn.documents.set(id, {
592
+ protocol,
593
+ key: key === undefined ? null : [...key],
594
+ main,
595
+ value: sanitizeValue(value),
596
+ });
597
+ markViews(h);
598
+ },
599
+ unmount: (protocol, key) => {
600
+ if (!conn.documents.delete(documentIdentity(protocol, key)))
601
+ throw new Error("statewire: document is not mounted");
602
+ markViews(h);
603
+ },
604
+ include: (protocol, key) => {
605
+ if (requireSelected(protocol).record !== undefined)
606
+ throw new Error(`statewire: protocol ${protocol} is not durable`);
607
+ conn.included.add(documentIdentity(protocol, key));
608
+ markViews(h);
609
+ },
610
+ exclude: (protocol, key) => {
611
+ conn.included.delete(documentIdentity(protocol, key));
612
+ markViews(h);
613
+ },
614
+ };
615
+ };
616
+
617
+ // ─── flush ──────────────────────────────────────────────
618
+
473
619
  export const flush = (h: SocketHostState, fin?: StatewireHost.Fin) => {
474
620
  flushTapSync(() => {});
475
621
  h.stateHost?.takeChanged();
476
- for (const rec of h.clients.values()) {
477
- for (const adm of rec.inflight.values()) {
622
+ for (const lane of allLanes(h)) {
623
+ for (const adm of lane.inflight.values()) {
478
624
  if (adm.applied && !adm.settled && !adm.appliedSent) {
479
625
  adm.appliedSent = true;
480
- queueApplied(h, rec.id, adm.seq);
626
+ queueApplied(h, lane, adm);
481
627
  }
482
628
  }
483
629
  }
@@ -487,16 +633,21 @@ export const flush = (h: SocketHostState, fin?: StatewireHost.Fin) => {
487
633
  h.pendingOps.length === 0 &&
488
634
  h.pendingApplied.size === 0 &&
489
635
  h.pendingCmds.size === 0 &&
636
+ !h.viewsDirty &&
490
637
  fin === undefined &&
491
- !edge
638
+ !edge &&
639
+ ![...h.conns].some(
640
+ (conn) => conn.pendingApplied.length > 0 || conn.pendingCmds.length > 0,
641
+ )
492
642
  ) {
493
643
  return;
494
644
  }
495
645
  h.idleWire = level;
646
+ h.viewsDirty = false;
496
647
  if (fin === undefined && edge && level && h.finOnIdle) {
497
648
  fin = { reason: "idle" };
498
649
  }
499
- const ops = h.pendingOps;
650
+ const mainOps = h.pendingOps;
500
651
  const applied = h.pendingApplied;
501
652
  const cmds = h.pendingCmds;
502
653
  h.pendingOps = [];
@@ -506,26 +657,34 @@ export const flush = (h: SocketHostState, fin?: StatewireHost.Fin) => {
506
657
  // when one is flushing, standalone otherwise. Terminal verdicts ride their
507
658
  // own packet.
508
659
  for (const conn of [...h.conns]) {
509
- const appliedEntries: StatewireCmd[] = (
510
- applied.get(conn.clientId) ?? []
511
- ).map((seq) => ({ seq, type: "applied" }));
660
+ if (!conn.ready) continue;
661
+ const forConn = (list: Cmd[] | undefined) =>
662
+ (list ?? []).filter((cmd) => selects(conn, cmd.protocol));
663
+ const ops = [
664
+ ...(selects(conn, h.protocol.name) ? mainOps : []),
665
+ ...diffView(h, conn),
666
+ ];
667
+ const appliedEntries = [
668
+ ...forConn(applied.get(conn.clientId)),
669
+ ...conn.pendingApplied.splice(0),
670
+ ];
512
671
  if (ops.length > 0) {
513
- const packet: StatewireEnvelope = {
514
- ops: conn.selection.protocols.some((p) => p.name === h.protocol.name)
515
- ? ops
516
- : [],
517
- ...(appliedEntries.length > 0 && { cmd: appliedEntries }),
518
- ...(edge && { idle: level }),
519
- };
520
- sendTo(h, conn, JSON.stringify(packet));
672
+ sendTo(
673
+ h,
674
+ conn,
675
+ JSON.stringify({
676
+ ops,
677
+ ...(appliedEntries.length > 0 && { cmd: appliedEntries }),
678
+ ...(edge && { idle: level }),
679
+ }),
680
+ );
521
681
  } else if (appliedEntries.length > 0) {
522
682
  sendTo(h, conn, JSON.stringify({ cmd: appliedEntries }));
523
683
  }
524
- const verdicts = (cmds.get(conn.clientId) ?? []).map((cmd) =>
525
- conn.selection.protocols.some((p) => p.name === h.protocol.name)
526
- ? cmd
527
- : { seq: cmd.seq, ...(cmd.dur === undefined ? {} : { dur: cmd.dur }) },
528
- );
684
+ const verdicts = [
685
+ ...forConn(cmds.get(conn.clientId)),
686
+ ...conn.pendingCmds.splice(0),
687
+ ];
529
688
  if (verdicts.length > 0) {
530
689
  sendTo(h, conn, JSON.stringify({ cmd: verdicts }));
531
690
  }
@@ -539,17 +698,24 @@ export const flush = (h: SocketHostState, fin?: StatewireHost.Fin) => {
539
698
  }
540
699
  };
541
700
 
542
- const queueApplied = (h: SocketHostState, clientId: string, seq: number) => {
543
- const list = h.pendingApplied.get(clientId);
544
- if (list) list.push(seq);
545
- else h.pendingApplied.set(clientId, [seq]);
701
+ const queueApplied = (h: SocketHostState, lane: Lane, adm: Admission) => {
702
+ const cmd: Cmd = { protocol: adm.protocol, seq: adm.seq, type: "applied" };
703
+ if (lane.conn !== null) lane.conn.pendingApplied.push(cmd);
704
+ else {
705
+ const list = h.pendingApplied.get(lane.clientId);
706
+ if (list) list.push(cmd);
707
+ else h.pendingApplied.set(lane.clientId, [cmd]);
708
+ }
546
709
  scheduleFlush(h);
547
710
  };
548
711
 
549
- const queueCmd = (h: SocketHostState, clientId: string, cmd: StatewireCmd) => {
550
- const list = h.pendingCmds.get(clientId);
551
- if (list) list.push(cmd);
552
- else h.pendingCmds.set(clientId, [cmd]);
712
+ const queueCmd = (h: SocketHostState, lane: Lane, cmd: Cmd) => {
713
+ if (lane.conn !== null) lane.conn.pendingCmds.push(cmd);
714
+ else {
715
+ const list = h.pendingCmds.get(lane.clientId);
716
+ if (list) list.push(cmd);
717
+ else h.pendingCmds.set(lane.clientId, [cmd]);
718
+ }
553
719
  scheduleFlush(h);
554
720
  };
555
721
 
@@ -557,80 +723,116 @@ const notifyClientRecords = (h: SocketHostState) => {
557
723
  h.persistence?.onClientRecordsChange?.(
558
724
  [...h.clients.values()].map((rec) => ({
559
725
  id: rec.id,
560
- lastSeq: admittedThrough(rec),
561
726
  touched: rec.touched,
562
- results: [...rec.results].map(([seq, entry]) => ({
563
- seq,
564
- ts: entry.ts,
565
- cmd: entry.cmd,
566
- })),
727
+ lanes: Object.fromEntries(
728
+ [...rec.lanes.values()].map((lane) => [
729
+ lane.protocol,
730
+ {
731
+ lastSeq: admittedThrough(lane),
732
+ results: [...lane.results].map(([seq, entry]) => ({
733
+ seq,
734
+ ts: entry.ts,
735
+ cmd: entry.cmd,
736
+ })),
737
+ },
738
+ ]),
739
+ ),
567
740
  principal: rec.principal,
568
741
  })),
569
742
  );
570
743
  };
571
744
 
572
- const retain = (rec: ClientRec, cmd: StatewireCmd) => {
745
+ const retain = (lane: Lane, cmd: Cmd) => {
573
746
  const now = Date.now();
574
- rec.results.set(cmd.seq, { ts: now, cmd });
575
- const newest = Math.max(...rec.results.keys());
576
- for (const [seq, entry] of rec.results) {
747
+ lane.results.set(cmd.seq, { ts: now, cmd });
748
+ const newest = Math.max(...lane.results.keys());
749
+ for (const [seq, entry] of lane.results) {
577
750
  if (seq !== newest && now - entry.ts > RESULT_TTL_MS) {
578
- rec.results.delete(seq);
751
+ lane.results.delete(seq);
579
752
  }
580
753
  }
581
- while (rec.results.size > RESULT_LIMIT) {
582
- rec.results.delete(Math.min(...rec.results.keys()));
754
+ while (lane.results.size > RESULT_LIMIT) {
755
+ lane.results.delete(Math.min(...lane.results.keys()));
583
756
  }
584
757
  };
585
758
 
586
- /** Cmd-lane watermark: the last statement admitted, settled or not. */
587
- const admittedThrough = (rec: ClientRec): number =>
588
- Math.max(rec.lastSeq, ...rec.inflight.keys());
759
+ /** Lane watermark: the last statement admitted, settled or not. */
760
+ const admittedThrough = (lane: Lane): number =>
761
+ Math.max(lane.lastSeq, ...lane.inflight.keys());
762
+
763
+ const watermark = (h: SocketHostState, conn: Conn, protocol: string) => {
764
+ const entry = conn.selection.protocols.find((p) => p.name === protocol)!;
765
+ const lane =
766
+ entry.record === "ephemeral"
767
+ ? conn.lanes.get(protocol)
768
+ : h.clients.get(conn.clientId)?.lanes.get(protocol);
769
+ return lane === undefined ? 0 : admittedThrough(lane);
770
+ };
771
+
772
+ const laneOf = (h: SocketHostState, conn: Conn, protocol: string): Lane => {
773
+ const entry = conn.selection.protocols.find((p) => p.name === protocol);
774
+ if (entry === undefined) return violation("unsupported protocol");
775
+ const ephemeral = entry.record === "ephemeral";
776
+ const lanes = ephemeral ? conn.lanes : h.clients.get(conn.clientId)!.lanes;
777
+ let lane = lanes.get(protocol);
778
+ if (lane === undefined) {
779
+ lane = {
780
+ protocol,
781
+ clientId: conn.clientId,
782
+ conn: ephemeral ? conn : null,
783
+ lastSeq: 0,
784
+ results: new Map(),
785
+ inflight: new Map(),
786
+ };
787
+ lanes.set(protocol, lane);
788
+ }
789
+ return lane;
790
+ };
589
791
 
590
792
  // A re-asked seq never re-runs: the stored verdict re-emits, an applied
591
793
  // inflight run re-answers applied; one not yet applied answers nothing (its
592
794
  // answers ride the stream when ready), an evicted verdict answers expired.
593
- const reemit = (h: SocketHostState, rec: ClientRec, seq: number) => {
594
- const cached = rec.results.get(seq);
795
+ const reemit = (h: SocketHostState, lane: Lane, seq: number) => {
796
+ const cached = lane.results.get(seq);
595
797
  if (cached) {
596
- const newest = Math.max(...rec.results.keys());
798
+ const newest = Math.max(...lane.results.keys());
597
799
  if (seq === newest || Date.now() - cached.ts <= RESULT_TTL_MS) {
598
- queueCmd(h, rec.id, cached.cmd);
800
+ queueCmd(h, lane, cached.cmd);
599
801
  return;
600
802
  }
601
- rec.results.delete(seq);
803
+ lane.results.delete(seq);
602
804
  notifyClientRecords(h);
603
805
  }
604
- const adm = rec.inflight.get(seq);
806
+ const adm = lane.inflight.get(seq);
605
807
  if (adm) {
606
- if (adm.applied) queueApplied(h, rec.id, seq);
808
+ if (adm.applied) queueApplied(h, lane, adm);
607
809
  return;
608
810
  }
609
- queueCmd(h, rec.id, { seq, type: "expired" });
811
+ queueCmd(h, lane, { protocol: lane.protocol, seq, type: "expired" });
610
812
  };
611
813
 
612
814
  const settleCmd = (
613
815
  h: SocketHostState,
614
- rec: ClientRec,
816
+ lane: Lane,
615
817
  adm: Admission,
616
- cmd: StatewireCmd,
818
+ cmd: Cmd,
617
819
  ) => {
618
820
  if (adm.settled) return;
619
821
  adm.settled = true;
620
- rec.inflight.delete(adm.seq);
621
- retain(rec, cmd);
622
- rec.lastSeq = Math.max(rec.lastSeq, adm.seq);
822
+ lane.inflight.delete(adm.seq);
823
+ retain(lane, cmd);
824
+ lane.lastSeq = Math.max(lane.lastSeq, adm.seq);
623
825
  adm.applied = true;
624
- queueCmd(h, rec.id, cmd);
625
- notifyClientRecords(h);
826
+ queueCmd(h, lane, cmd);
827
+ if (lane.conn === null) notifyClientRecords(h);
626
828
  };
627
829
 
628
830
  const settleWire = (
629
831
  h: SocketHostState,
630
- rec: ClientRec,
832
+ lane: Lane,
631
833
  adm: Admission,
632
834
  outcome: CommandsOutcome,
633
- ) => settleCmd(h, rec, adm, cmdOf(adm, outcome));
835
+ ) => settleCmd(h, lane, adm, cmdOf(adm, outcome));
634
836
 
635
837
  const callerOf = (
636
838
  h: SocketHostState,
@@ -639,9 +841,6 @@ const callerOf = (
639
841
  ): StatewireHost.Caller => ({
640
842
  clientId: rec.id,
641
843
  auth: conn.auth,
642
- get context() {
643
- return conn.context;
644
- },
645
844
  get isConnected() {
646
845
  if (h.clients.get(rec.id) !== rec) return false;
647
846
  for (const conn of h.conns) {
@@ -653,11 +852,12 @@ const callerOf = (
653
852
 
654
853
  const rejectStatement = (
655
854
  h: SocketHostState,
656
- rec: ClientRec,
855
+ lane: Lane,
657
856
  adm: Admission,
658
857
  message: string,
659
858
  ) =>
660
- settleCmd(h, rec, adm, {
859
+ settleCmd(h, lane, adm, {
860
+ protocol: adm.protocol,
661
861
  seq: adm.seq,
662
862
  type: "rejected",
663
863
  code: "invalid-params",
@@ -666,33 +866,23 @@ const rejectStatement = (
666
866
 
667
867
  const admitCmd = (
668
868
  h: SocketHostState,
669
- rec: ClientRec,
869
+ lane: Lane,
670
870
  conn: Conn,
671
871
  statement: CmdStatement,
672
872
  ) => {
873
+ const rec = h.clients.get(conn.clientId)!;
673
874
  const adm: Admission = {
875
+ protocol: statement.protocol,
674
876
  seq: statement.seq,
675
877
  method: statement.method,
676
878
  applied: false,
677
879
  appliedSent: false,
678
880
  settled: false,
679
881
  };
680
- rec.inflight.set(statement.seq, adm);
681
- notifyClientRecords(h);
682
- if (
683
- statement.protocol !== h.protocol.name ||
684
- !conn.selection.protocols.some((p) => p.name === statement.protocol)
685
- ) {
686
- settleCmd(h, rec, adm, {
687
- seq: adm.seq,
688
- type: "rejected",
689
- code: "unsupported-protocol",
690
- message: "command protocol was not negotiated",
691
- });
692
- return;
693
- }
882
+ lane.inflight.set(statement.seq, adm);
883
+ if (lane.conn === null) notifyClientRecords(h);
694
884
  if (!Array.isArray(statement.params)) {
695
- rejectStatement(h, rec, adm, "params must be an array");
885
+ rejectStatement(h, lane, adm, "params must be an array");
696
886
  return;
697
887
  }
698
888
  let params = statement.params;
@@ -704,7 +894,7 @@ const admitCmd = (
704
894
  } catch (error) {
705
895
  rejectStatement(
706
896
  h,
707
- rec,
897
+ lane,
708
898
  adm,
709
899
  `invalid params: ${(error as Error).message}`,
710
900
  );
@@ -713,47 +903,75 @@ const admitCmd = (
713
903
  }
714
904
  if (statement.method === "statewire/commands") {
715
905
  if (params.length > 0) {
716
- rejectStatement(h, rec, adm, "statewire/commands takes no params");
906
+ rejectStatement(h, lane, adm, "statewire/commands takes no params");
717
907
  return;
718
908
  }
719
909
  const names = [
720
910
  ...(h.commandsHost?.methods?.() ?? []),
721
911
  "statewire/commands",
722
912
  ].sort();
723
- settleCmd(h, rec, adm, { seq: adm.seq, type: "result", payload: names });
913
+ settleCmd(h, lane, adm, {
914
+ protocol: adm.protocol,
915
+ seq: adm.seq,
916
+ type: "result",
917
+ payload: names,
918
+ });
724
919
  return;
725
920
  }
921
+ // Subprotocol methods are registered under their full wire name.
922
+ const method =
923
+ statement.protocol === h.protocol.name
924
+ ? statement.method
925
+ : `${statement.protocol}/${statement.method}`;
726
926
  h.commandsHost!.submit!({
727
- method: statement.method,
927
+ method,
728
928
  params,
729
929
  clientId: rec.id,
730
930
  caller: callerOf(h, rec, conn),
931
+ attach: conn.attach,
731
932
  onApplied: () => {
732
933
  if (adm.settled || adm.applied) return;
733
934
  adm.applied = true;
734
- rec.lastSeq = Math.max(rec.lastSeq, adm.seq);
735
- notifyClientRecords(h);
935
+ lane.lastSeq = Math.max(lane.lastSeq, adm.seq);
936
+ if (lane.conn === null) notifyClientRecords(h);
736
937
  scheduleFlush(h);
737
938
  },
738
- onSettle: (outcome) => settleWire(h, rec, adm, outcome),
939
+ onSettle: (outcome) => settleWire(h, lane, adm, outcome),
739
940
  }).catch((error: unknown) => {
740
- if (!adm.settled) settleWire(h, rec, adm, { type: "rejected", error });
941
+ if (!adm.settled) settleWire(h, lane, adm, { type: "rejected", error });
741
942
  });
742
943
  };
743
944
 
744
- // One admission law, both lanes: absorbed statements answer from the record
745
- // (cmd) or the attach (ctx), then the new suffix is admitted whole or the
746
- // frame is refused whole via a {syn} stating both watermarks, nothing
747
- // consumed. A gap is never a close.
945
+ // One admission law, every lane: absorbed statements answer from their lane,
946
+ // then the new suffix is admitted whole or the frame is refused whole via a
947
+ // {syn} stating every lane's watermark, nothing consumed. A durable gap is
948
+ // never a close; an ephemeral gap is a violation (the lane has no replay).
748
949
  export type FrameAdmission =
749
950
  | { type: "ok" }
750
951
  | {
751
952
  type: "refused";
752
953
  syn: {
753
- syn: { seq: number; ctxSeq: number; retry?: number };
954
+ syn: { protocols: { name: string; seq: number }[]; retry?: number };
754
955
  };
755
956
  };
756
957
 
958
+ const refusal = (
959
+ h: SocketHostState,
960
+ conn: Conn,
961
+ retry?: number,
962
+ ): FrameAdmission => ({
963
+ type: "refused",
964
+ syn: {
965
+ syn: {
966
+ protocols: conn.selection.protocols.map((p) => ({
967
+ name: p.name,
968
+ seq: watermark(h, conn, p.name),
969
+ })),
970
+ ...(retry !== undefined && { retry }),
971
+ },
972
+ },
973
+ });
974
+
757
975
  export const admitFrame = (
758
976
  h: SocketHostState,
759
977
  conn: Conn,
@@ -761,81 +979,57 @@ export const admitFrame = (
761
979
  ): FrameAdmission => {
762
980
  const rec = h.clients.get(conn.clientId)!;
763
981
  rec.touched = Date.now();
764
- const through = admittedThrough(rec);
765
- for (const statement of frame.cmd) {
766
- if (statement.seq <= through) reemit(h, rec, statement.seq);
767
- }
768
- const ctxNew = frame.ctx.filter((s) => s.seq > conn.ctxSeq);
769
- const cmdNew = frame.cmd.filter((s) => s.seq > through);
770
- if (
771
- (ctxNew.length > 0 && ctxNew[0]!.seq !== conn.ctxSeq + 1) ||
772
- (cmdNew.length > 0 && cmdNew[0]!.seq !== through + 1)
773
- ) {
774
- notifyClientRecords(h);
775
- return {
776
- type: "refused",
777
- syn: { syn: { seq: through, ctxSeq: conn.ctxSeq } },
778
- };
982
+ const lanes = new Map(
983
+ frame.cmd.map((s) => [s.protocol, laneOf(h, conn, s.protocol)]),
984
+ );
985
+ const through = new Map(
986
+ [...lanes].map(([protocol, lane]) => [protocol, admittedThrough(lane)]),
987
+ );
988
+ const fresh = frame.cmd.filter((s) => s.seq > through.get(s.protocol)!);
989
+ for (const s of frame.cmd) {
990
+ if (s.seq <= through.get(s.protocol)!)
991
+ reemit(h, lanes.get(s.protocol)!, s.seq);
779
992
  }
780
- if (cmdNew.length > 0) {
781
- let hostCommandLoad = 0;
782
- for (const client of h.clients.values()) {
783
- hostCommandLoad += client.inflight.size;
993
+ let durableNew = 0;
994
+ for (const [protocol, lane] of lanes) {
995
+ const first = fresh.find((s) => s.protocol === protocol);
996
+ if (first === undefined) continue;
997
+ if (first.seq !== through.get(protocol)! + 1) {
998
+ if (lane.conn !== null) return violation("ephemeral lane gap");
999
+ notifyClientRecords(h);
1000
+ return refusal(h, conn);
784
1001
  }
1002
+ if (lane.conn === null)
1003
+ durableNew += fresh.filter((s) => s.protocol === protocol).length;
1004
+ }
1005
+ if (durableNew > 0) {
1006
+ let clientLoad = 0;
1007
+ for (const lane of rec.lanes.values()) clientLoad += lane.inflight.size;
1008
+ let hostLoad = 0;
1009
+ for (const lane of durableLanes(h)) hostLoad += lane.inflight.size;
785
1010
  if (
786
- rec.inflight.size + cmdNew.length > MAX_INFLIGHT_COMMANDS_PER_CLIENT ||
787
- hostCommandLoad + cmdNew.length > h.maxInflightCommands
1011
+ clientLoad + durableNew > MAX_INFLIGHT_COMMANDS_PER_CLIENT ||
1012
+ hostLoad + durableNew > h.maxInflightCommands
788
1013
  ) {
789
- return {
790
- type: "refused",
791
- syn: {
792
- syn: {
793
- seq: through,
794
- ctxSeq: conn.ctxSeq,
795
- retry: COMMAND_CAPACITY_RETRY_MS,
796
- },
797
- },
798
- };
1014
+ return refusal(h, conn, COMMAND_CAPACITY_RETRY_MS);
799
1015
  }
800
1016
  }
801
- if ((ctxNew.length || cmdNew.length) && h.pushback) {
1017
+ if (fresh.length > 0 && h.pushback) {
802
1018
  const retry = h.pushback();
803
1019
  if (retry !== null) {
804
1020
  if (!Number.isSafeInteger(retry) || retry < 0)
805
1021
  throw new TypeError(
806
1022
  "pushback must return a nonnegative safe integer or null",
807
1023
  );
808
- return {
809
- type: "refused",
810
- syn: { syn: { seq: through, ctxSeq: conn.ctxSeq, retry } },
811
- };
1024
+ return refusal(h, conn, retry);
812
1025
  }
813
1026
  }
814
- let contextDocuments = conn.contextDocuments;
815
- try {
816
- for (const statement of ctxNew)
817
- contextDocuments = applyDocumentOps(
818
- contextDocuments,
819
- statement.ops,
820
- conn.selection.protocols,
821
- true,
822
- );
823
- if (
824
- frameEncoder.encode(JSON.stringify([...contextDocuments.documents]))
825
- .byteLength > MAX_CONTEXT_VALUE_BYTES
826
- )
827
- return violation("oversized-context");
828
- } catch {
829
- return violation("invalid-context");
830
- }
831
- conn.contextDocuments = contextDocuments;
832
- conn.context = (mainDocument(contextDocuments, h.protocol.name)?.value ??
833
- null) as Record<string, unknown> | null;
834
- if (ctxNew.length) conn.ctxSeq = ctxNew.at(-1)!.seq;
835
- for (const statement of cmdNew) admitCmd(h, rec, conn, statement);
836
- if (cmdNew.length > 0) {
1027
+ for (const s of fresh) admitCmd(h, lanes.get(s.protocol)!, conn, s);
1028
+ for (const [protocol, lane] of lanes) {
1029
+ const last = fresh.findLast((s) => s.protocol === protocol);
837
1030
  // Memory host: admitted means retained — dur rides the admitting flush.
838
- queueCmd(h, rec.id, { seq: cmdNew.at(-1)!.seq, dur: true });
1031
+ if (last !== undefined && lane.conn === null)
1032
+ queueCmd(h, lane, { protocol, seq: last.seq, dur: true });
839
1033
  }
840
1034
  notifyClientRecords(h);
841
1035
  scheduleFlush(h);
@@ -860,38 +1054,15 @@ const message = (h: SocketHostState, conn: Conn, data: string) => {
860
1054
  }
861
1055
  const first = !conn.sawFrame;
862
1056
  conn.sawFrame = true;
863
- let frame: ParsedFrame;
864
- try {
865
- frame = first ? parseInitialFrame(data).frame : parseFrame(data);
866
- } catch (error) {
867
- const reason = violationReason(error);
868
- if (reason === null) throw error;
869
- sendTo(
870
- h,
871
- conn,
872
- JSON.stringify({
873
- fin: {
874
- reason: "protocol-error",
875
- message: reason,
876
- },
877
- }),
878
- );
879
- closeConn(h, conn, 1008, reason);
880
- return;
881
- }
882
- if (frame.ctx.length === 0 && frame.cmd.length === 0) return;
883
1057
  let admission: ReturnType<typeof admitFrame>;
884
1058
  try {
1059
+ const frame = first ? parseInitialFrame(data).frame : parseFrame(data);
1060
+ if (frame.cmd.length === 0) return;
885
1061
  admission = admitFrame(h, conn, frame);
886
1062
  } catch (error) {
887
1063
  const reason = violationReason(error);
888
1064
  if (reason === null) throw error;
889
- sendTo(
890
- h,
891
- conn,
892
- JSON.stringify({ fin: { reason: "protocol-error", message: reason } }),
893
- );
894
- closeConn(h, conn, 1008, reason);
1065
+ violate(h, conn, reason);
895
1066
  return;
896
1067
  }
897
1068
  if (admission.type === "refused") {
@@ -1146,26 +1317,23 @@ const connectAuthorized = (
1146
1317
  pending.open = false;
1147
1318
  h.pendingConns.delete(pending);
1148
1319
  if (pending.timer !== null) clearTimeout(pending.timer);
1149
- const registration = register(
1150
- h,
1151
- clientId,
1152
- socket,
1153
- true,
1154
- auth,
1155
- selection,
1156
- );
1157
- if (registration === null) return;
1158
- pending.connection = registration.connection;
1159
- if (frame.ctx.length || frame.cmd.length) {
1160
- message(
1320
+ let registration: Registration | null;
1321
+ try {
1322
+ registration = register(
1161
1323
  h,
1162
- registration.conn,
1163
- JSON.stringify({
1164
- ...(frame.ctx.length ? { ctx: frame.ctx } : {}),
1165
- ...(frame.cmd.length ? { cmd: frame.cmd } : {}),
1166
- }),
1324
+ clientId,
1325
+ socket,
1326
+ true,
1327
+ auth,
1328
+ selection,
1329
+ frame,
1167
1330
  );
1331
+ } catch (error) {
1332
+ if (violationReason(error) === null) throw error;
1333
+ return;
1168
1334
  }
1335
+ if (registration === null) return;
1336
+ pending.connection = registration.connection;
1169
1337
  for (const data of pending.queued.splice(0))
1170
1338
  registration.connection.message(data);
1171
1339
  })
@@ -1188,7 +1356,11 @@ const gcClients = (h: SocketHostState) => {
1188
1356
  const oldest = [...h.clients.values()].sort((a, b) => a.touched - b.touched);
1189
1357
  for (const rec of oldest) {
1190
1358
  if (excess <= 0) break;
1191
- if (live.has(rec.id) || rec.inflight.size > 0) continue;
1359
+ if (
1360
+ live.has(rec.id) ||
1361
+ [...rec.lanes.values()].some((lane) => lane.inflight.size > 0)
1362
+ )
1363
+ continue;
1192
1364
  h.clients.delete(rec.id);
1193
1365
  changed = true;
1194
1366
  excess -= 1;
@@ -1201,6 +1373,11 @@ export type Registration = {
1201
1373
  connection: StatewireHostInternal.SocketConnection;
1202
1374
  };
1203
1375
 
1376
+ /**
1377
+ * Attach a socket: ephemeral-lane statements of the attach frame are admitted
1378
+ * before the snapshot (their documents and answers ride the hello), durable
1379
+ * ones after it. A violation in the attach frame closes the attach and throws.
1380
+ */
1204
1381
  export const register = (
1205
1382
  h: SocketHostState,
1206
1383
  clientId: string,
@@ -1211,6 +1388,7 @@ export const register = (
1211
1388
  version: h.wireVersion.version,
1212
1389
  protocols: [{ name: h.protocol.name, version: h.protocol.version }],
1213
1390
  },
1391
+ frame: ParsedFrame = { cmd: [] },
1214
1392
  ): Registration | null => {
1215
1393
  const refuse = (code: number, reason: string) => {
1216
1394
  try {
@@ -1257,10 +1435,8 @@ export const register = (
1257
1435
  (() => {
1258
1436
  const created: ClientRec = {
1259
1437
  id: clientId,
1260
- lastSeq: 0,
1261
1438
  touched: Date.now(),
1262
- results: new Map(),
1263
- inflight: new Map(),
1439
+ lanes: new Map(),
1264
1440
  principal: auth,
1265
1441
  };
1266
1442
  h.clients.set(clientId, created);
@@ -1272,39 +1448,78 @@ export const register = (
1272
1448
  clientId,
1273
1449
  socket,
1274
1450
  open: true,
1451
+ ready: false,
1275
1452
  sawFrame,
1276
1453
  lastSentAt: 0,
1277
1454
  lease: null,
1278
1455
  auth,
1279
- ctxSeq: 0,
1280
- context: null,
1281
- contextDocuments: { mode: "mounted", documents: new Map() },
1456
+ lanes: new Map(),
1457
+ documents: new Map(),
1458
+ included: new Set(),
1459
+ view: new Map(),
1460
+ nextDoc: 1,
1461
+ pendingApplied: [],
1462
+ pendingCmds: [],
1463
+ attach: undefined!,
1282
1464
  };
1465
+ conn.attach = attachOf(h, conn);
1283
1466
  h.conns.add(conn);
1284
1467
  gcClients(h);
1285
- notifyClientRecords(h);
1286
1468
  ensureHeartbeat(h);
1287
- const { value, meta } = encodeDocument(h.stateHost!.snapshot());
1288
- const hello: StatewireEnvelope = {
1289
- ops: [
1290
- {
1291
- op: "mount",
1292
- protocol: h.protocol.name,
1293
- main: true,
1294
- value,
1295
- ...(meta !== undefined && { meta }),
1296
- },
1297
- ],
1469
+ const isEphemeral = (s: CmdStatement) =>
1470
+ selection.protocols.find((p) => p.name === s.protocol)?.record ===
1471
+ "ephemeral";
1472
+ const early = frame.cmd.filter(isEphemeral);
1473
+ const late = frame.cmd.filter((s) => !isEphemeral(s));
1474
+ const admit = (statements: CmdStatement[]) => {
1475
+ if (statements.length === 0) return null;
1476
+ try {
1477
+ return admitFrame(h, conn, { cmd: statements });
1478
+ } catch (error) {
1479
+ const reason = violationReason(error);
1480
+ if (reason === null) throw error;
1481
+ violate(h, conn, reason);
1482
+ throw error;
1483
+ }
1484
+ };
1485
+ const earlyAdmission = admit(early);
1486
+ if (early.length > 0) flush(h);
1487
+ notifyClientRecords(h);
1488
+ const ops: WireOp[] = [];
1489
+ if (selects(conn, h.protocol.name)) {
1490
+ const { value, meta } = encodeDocument(h.stateHost!.snapshot());
1491
+ ops.push({
1492
+ op: "mount",
1493
+ protocol: h.protocol.name,
1494
+ main: true,
1495
+ value,
1496
+ ...(meta !== undefined && { meta }),
1497
+ });
1498
+ }
1499
+ ops.push(...diffView(h, conn));
1500
+ const answers = [
1501
+ ...conn.pendingApplied.splice(0),
1502
+ ...conn.pendingCmds.splice(0),
1503
+ ];
1504
+ const hello = {
1505
+ ops,
1298
1506
  syn: {
1299
- ...selection,
1300
- seq: admittedThrough(rec),
1301
- ctxSeq: 0,
1507
+ version: selection.version,
1508
+ protocols: selection.protocols.map((p) => ({
1509
+ ...p,
1510
+ seq: p.record === "ephemeral" ? 0 : watermark(h, conn, p.name),
1511
+ })),
1302
1512
  ...(h.idleWire && { idle: true }),
1303
1513
  },
1514
+ ...(answers.length > 0 && { cmd: answers }),
1304
1515
  };
1305
- if (!selection.protocols.some((p) => p.name === h.protocol.name))
1306
- hello.ops = [];
1516
+ conn.ready = true;
1307
1517
  sendTo(h, conn, JSON.stringify(hello));
1518
+ if (earlyAdmission?.type === "refused")
1519
+ sendTo(h, conn, JSON.stringify(earlyAdmission.syn));
1520
+ const lateAdmission = admit(late);
1521
+ if (lateAdmission?.type === "refused")
1522
+ sendTo(h, conn, JSON.stringify(lateAdmission.syn));
1308
1523
  const connection: StatewireHostInternal.SocketConnection = {
1309
1524
  clientId,
1310
1525
  message: (data) => message(h, conn, data),
@@ -1342,6 +1557,7 @@ const finish = (h: SocketHostState, fin: StatewireHost.Fin) => {
1342
1557
  const resume = (h: SocketHostState) => {
1343
1558
  h.disposed = false;
1344
1559
  h.stateHost?.setOnOp((op) => recordOp(h, op));
1560
+ h.stateHost?.setOnDocuments(() => markViews(h));
1345
1561
  };
1346
1562
 
1347
1563
  const suspend = (h: SocketHostState) => {
@@ -1351,6 +1567,7 @@ const suspend = (h: SocketHostState) => {
1351
1567
  h.heartbeatTimer = null;
1352
1568
  }
1353
1569
  h.stateHost?.setOnOp(null);
1570
+ h.stateHost?.setOnDocuments(null);
1354
1571
  };
1355
1572
 
1356
1573
  const restoreClientRecords = (
@@ -1361,12 +1578,22 @@ const restoreClientRecords = (
1361
1578
  record.id,
1362
1579
  {
1363
1580
  id: record.id,
1364
- lastSeq: record.lastSeq,
1365
1581
  touched: record.touched,
1366
- results: new Map(
1367
- record.results.map(({ seq, ts, cmd }) => [seq, { ts, cmd }]),
1582
+ lanes: new Map(
1583
+ Object.entries(record.lanes).map(([protocol, lane]) => [
1584
+ protocol,
1585
+ {
1586
+ protocol,
1587
+ clientId: record.id,
1588
+ conn: null,
1589
+ lastSeq: lane.lastSeq,
1590
+ results: new Map(
1591
+ lane.results.map(({ seq, ts, cmd }) => [seq, { ts, cmd }]),
1592
+ ),
1593
+ inflight: new Map(),
1594
+ },
1595
+ ]),
1368
1596
  ),
1369
- inflight: new Map(),
1370
1597
  principal: record.principal,
1371
1598
  },
1372
1599
  ]),
@@ -1399,6 +1626,7 @@ const useSocketHost = (
1399
1626
  pendingOps: [],
1400
1627
  pendingApplied: new Map(),
1401
1628
  pendingCmds: new Map(),
1629
+ viewsDirty: false,
1402
1630
  idleWire: true,
1403
1631
  finished: null,
1404
1632
  disposed: false,
@@ -1472,9 +1700,10 @@ const useSocketHost = (
1472
1700
  * Transport-agnostic WS binding over a statewire/host transport:
1473
1701
  * feed it sockets (anything with `send`/`close`) and raw text frames, and it
1474
1702
  * speaks the Statewire packet protocol — snapshot-first hello with `syn`,
1475
- * statement-lane client frames under one admission law, `{cmd}` answer
1476
- * arrays fanned out to every attach of a client id, idle edges, and `fin` on
1477
- * finish.
1703
+ * per-protocol statement lanes under one admission law, `{cmd}` answer
1704
+ * arrays fanned out to every attach of a client id (durable lanes) or the
1705
+ * admitting attach (ephemeral lanes), per-attach document views, idle edges,
1706
+ * and `fin` on finish.
1478
1707
  */
1479
1708
  export const StatewireSocketHost = (
1480
1709
  transport: ResourceElement<Statewire>,