statewire 0.18.1 → 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 +296 -401
  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 +9 -19
  23. package/dist/host/http.js.map +1 -1
  24. package/dist/host/socket.d.ts +48 -25
  25. package/dist/host/socket.d.ts.map +1 -1
  26. package/dist/host/socket.js +399 -266
  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 -12
  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 -20
  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 +54 -89
  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 -18
  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 +442 -517
  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 +23 -31
  74. package/src/host/socket.ts +560 -339
  75. package/src/host/synced.ts +60 -0
  76. package/src/host-internal.ts +21 -7
  77. package/src/host.ts +40 -8
  78. package/src/index.ts +1 -0
  79. package/src/statewire.ts +8 -37
  80. package/src/storage.ts +90 -157
  81. package/src/transports/remote.ts +0 -7
  82. package/src/transports/sse.ts +8 -3
  83. package/src/transports/testing.ts +34 -18
  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,29 +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
- ctxSeq: number;
98
111
  touched: number;
99
- context: Record<string, unknown> | null;
100
- contextDocuments: StatewireDocuments.State;
101
- results: Map<number, { ts: number; cmd: StatewireCmd }>;
102
- inflight: Map<number, Admission>;
112
+ lanes: Map<string, Lane>;
103
113
  /** The auth captured at the record's first attach; later attaches must match. */
104
114
  principal: unknown;
105
115
  };
106
116
 
117
+ // A Conn outlives its socket so an admitted command keeps its attach view.
107
118
  export type Conn = {
108
119
  selection: StatewireProtocol.Selection;
109
120
  clientId: string;
110
121
  socket: StatewireHostInternal.Socket;
111
122
  open: boolean;
123
+ ready: boolean;
112
124
  sawFrame: boolean;
113
125
  lastSentAt: number;
114
126
  lease: string | null;
115
127
  /** The awaited `authorize` return captured at this attach's authorization. */
116
128
  auth: unknown;
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;
117
137
  };
118
138
 
119
139
  type PendingConn = {
@@ -136,7 +156,6 @@ const violationReason = (error: unknown): string | null =>
136
156
  ? error.message
137
157
  : null;
138
158
 
139
- type CtxStatement = { seq: number; ops: unknown[] };
140
159
  type CmdStatement = {
141
160
  protocol: string;
142
161
  seq: number;
@@ -145,84 +164,55 @@ type CmdStatement = {
145
164
  meta?: unknown;
146
165
  };
147
166
 
148
- export type ParsedFrame = {
149
- ctx: CtxStatement[];
150
- cmd: CmdStatement[];
151
- };
167
+ export type ParsedFrame = { cmd: CmdStatement[] };
152
168
 
153
169
  const isPlainObject = (value: unknown): value is Record<string, unknown> =>
154
170
  typeof value === "object" && value !== null && !Array.isArray(value);
155
171
 
156
- const parseLane = (value: unknown): Record<string, unknown>[] => {
157
- if (!Array.isArray(value)) return violation("malformed frame");
158
- if (value.length === 0) return violation("empty lane");
159
- const statements: Record<string, unknown>[] = [];
160
- let prev: number | null = null;
161
- for (const statement of value) {
162
- if (!isPlainObject(statement)) return violation("malformed statement");
163
- const seq = statement["seq"];
164
- if (typeof seq !== "number" || !Number.isInteger(seq) || seq < 1) {
165
- return violation("malformed statement");
166
- }
167
- if (prev !== null && seq !== prev + 1) return violation("malformed frame");
168
- prev = seq;
169
- statements.push(statement);
170
- }
171
- return statements;
172
- };
173
-
174
172
  const frameEncoder = new TextEncoder();
175
173
 
176
174
  export const parseFrameValue = (frame: unknown): ParsedFrame => {
177
175
  if (!isPlainObject(frame)) return violation("malformed frame");
178
176
  const keys = Object.keys(frame);
179
177
  if (keys.length === 0) return violation("malformed frame");
180
- const unknown = keys.filter((key) => key !== "ctx" && key !== "cmd").sort();
178
+ const unknown = keys.filter((key) => key !== "cmd").sort();
181
179
  if (unknown.length > 0)
182
180
  return violation(`unknown frame key: '${unknown[0]}'`);
183
- const ctx: CtxStatement[] = [];
184
- if (frame["ctx"] !== undefined) {
185
- const lane = frame["ctx"];
186
- if (Array.isArray(lane) && lane.length > MAX_COMMANDS_PER_FRAME) {
187
- return violation("too many context statements");
188
- }
189
- for (const statement of parseLane(lane)) {
190
- const ops = statement["ops"];
191
- if (!Array.isArray(ops) || !ops.length)
192
- return violation("invalid-context");
193
- if (
194
- frameEncoder.encode(JSON.stringify(ops)).byteLength >
195
- MAX_CONTEXT_VALUE_BYTES
196
- )
197
- return violation("oversized-context");
198
- ctx.push({ seq: statement["seq"] as number, ops });
199
- }
200
- }
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>();
201
187
  const cmd: CmdStatement[] = [];
202
- if (frame["cmd"] !== undefined) {
203
- const lane = frame["cmd"];
204
- if (Array.isArray(lane) && lane.length > MAX_COMMANDS_PER_FRAME) {
205
- return violation("too many commands");
206
- }
207
- for (const statement of parseLane(lane)) {
208
- const method = statement["method"];
209
- if (
210
- typeof method !== "string" ||
211
- typeof statement["protocol"] !== "string" ||
212
- !("params" in statement)
213
- ) {
214
- return violation("malformed statement");
215
- }
216
- cmd.push({
217
- protocol: statement["protocol"] as string,
218
- seq: statement["seq"] as number,
219
- method,
220
- params: statement["params"],
221
- ...(statement["meta"] !== undefined && { meta: statement["meta"] }),
222
- });
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");
223
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
+ });
224
214
  }
225
- return { ctx, cmd };
215
+ return { cmd };
226
216
  };
227
217
 
228
218
  export const parseFrame = (raw: string): ParsedFrame => {
@@ -249,18 +239,19 @@ const encodePayload = (
249
239
  return { payload: value, ...(meta !== undefined && { meta }) };
250
240
  };
251
241
 
252
- const cmdOf = (adm: Admission, outcome: CommandsOutcome): StatewireCmd => {
242
+ const cmdOf = (adm: Admission, outcome: CommandsOutcome): Cmd => {
243
+ const at = { protocol: adm.protocol, seq: adm.seq };
253
244
  if (outcome.type === "accepted") {
254
- return { seq: adm.seq, type: "result", ...encodePayload(outcome.payload) };
245
+ return { ...at, type: "result", ...encodePayload(outcome.payload) };
255
246
  }
256
247
  const failure =
257
248
  outcome.error instanceof StatewireSendError ? outcome.error.failure : null;
258
249
  switch (failure?.fate) {
259
250
  case "crashed":
260
- return { seq: adm.seq, type: "crashed" };
251
+ return { ...at, type: "crashed" };
261
252
  case "failed":
262
253
  return {
263
- seq: adm.seq,
254
+ ...at,
264
255
  type: "failed",
265
256
  ...(failure.message !== undefined && { message: failure.message }),
266
257
  };
@@ -271,14 +262,14 @@ const cmdOf = (adm: Admission, outcome: CommandsOutcome): StatewireCmd => {
271
262
  ? `unknown method: '${adm.method}'`
272
263
  : failure.message;
273
264
  return {
274
- seq: adm.seq,
265
+ ...at,
275
266
  type: "rejected",
276
267
  code: failure.code,
277
268
  ...(message !== undefined && { message }),
278
269
  };
279
270
  }
280
271
  return {
281
- seq: adm.seq,
272
+ ...at,
282
273
  type: "rejected",
283
274
  ...(failure.message !== undefined && { message: failure.message }),
284
275
  ...encodePayload(failure.payload),
@@ -286,7 +277,7 @@ const cmdOf = (adm: Admission, outcome: CommandsOutcome): StatewireCmd => {
286
277
  }
287
278
  default:
288
279
  return {
289
- seq: adm.seq,
280
+ ...at,
290
281
  type: "failed",
291
282
  message:
292
283
  outcome.error instanceof Error
@@ -314,9 +305,7 @@ const parseInitialFrame = (raw: string) => {
314
305
  }
315
306
  return {
316
307
  headers: headers as Record<string, string>,
317
- frame: Object.keys(lanes).length
318
- ? parseFrameValue(lanes)
319
- : { ctx: [], cmd: [] },
308
+ frame: Object.keys(lanes).length ? parseFrameValue(lanes) : { cmd: [] },
320
309
  };
321
310
  };
322
311
 
@@ -338,8 +327,9 @@ export type SocketHostState = {
338
327
  conns: Set<Conn>;
339
328
  pendingConns: Set<PendingConn>;
340
329
  pendingOps: WireOp[];
341
- pendingApplied: Map<string, number[]>;
342
- pendingCmds: Map<string, StatewireCmd[]>;
330
+ pendingApplied: Map<string, Cmd[]>;
331
+ pendingCmds: Map<string, Cmd[]>;
332
+ viewsDirty: boolean;
343
333
  idleWire: boolean;
344
334
  finished: StatewireHost.Fin | null;
345
335
  disposed: boolean;
@@ -366,6 +356,11 @@ const scheduleFlush = (h: SocketHostState) => {
366
356
  h.post();
367
357
  };
368
358
 
359
+ const markViews = (h: SocketHostState) => {
360
+ h.viewsDirty = true;
361
+ scheduleFlush(h);
362
+ };
363
+
369
364
  const recordOp = (
370
365
  h: SocketHostState,
371
366
  op: {
@@ -398,8 +393,10 @@ const attach = (h: SocketHostState, next: Statewire<unknown>) => {
398
393
  }
399
394
  if (nextState !== h.stateHost) {
400
395
  h.stateHost?.setOnOp(null);
396
+ h.stateHost?.setOnDocuments(null);
401
397
  h.stateHost = nextState;
402
398
  h.stateHost.setOnOp((op) => recordOp(h, op));
399
+ h.stateHost.setOnDocuments(() => markViews(h));
403
400
  }
404
401
  h.commandsHost = nextCommands;
405
402
  };
@@ -418,6 +415,7 @@ export const closeConn = (
418
415
  } catch {
419
416
  /* peer already gone */
420
417
  }
418
+ h.current?.detached?.(conn.attach);
421
419
  if (h.conns.size === 0 && h.heartbeatTimer !== null) {
422
420
  clearInterval(h.heartbeatTimer);
423
421
  h.heartbeatTimer = null;
@@ -434,6 +432,15 @@ export const sendTo = (h: SocketHostState, conn: Conn, data: string) => {
434
432
  }
435
433
  };
436
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
+
437
444
  export const atSubscriberLimit = (h: SocketHostState): boolean =>
438
445
  h.conns.size +
439
446
  [...h.pendingConns].filter((conn) => conn.authorizing).length >=
@@ -460,22 +467,163 @@ const setHeartbeatMs = (h: SocketHostState, ms: number) => {
460
467
  }
461
468
  };
462
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
+
463
479
  const idleLevel = (h: SocketHostState): boolean => {
464
480
  if (h.current?.work?.isIdle === false) return false;
465
- for (const rec of h.clients.values()) {
466
- if (rec.inflight.size > 0) return false;
481
+ for (const lane of allLanes(h)) {
482
+ if (lane.inflight.size > 0) return false;
467
483
  }
468
484
  return true;
469
485
  };
470
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
+
471
619
  export const flush = (h: SocketHostState, fin?: StatewireHost.Fin) => {
472
620
  flushTapSync(() => {});
473
621
  h.stateHost?.takeChanged();
474
- for (const rec of h.clients.values()) {
475
- for (const adm of rec.inflight.values()) {
622
+ for (const lane of allLanes(h)) {
623
+ for (const adm of lane.inflight.values()) {
476
624
  if (adm.applied && !adm.settled && !adm.appliedSent) {
477
625
  adm.appliedSent = true;
478
- queueApplied(h, rec.id, adm.seq);
626
+ queueApplied(h, lane, adm);
479
627
  }
480
628
  }
481
629
  }
@@ -485,16 +633,21 @@ export const flush = (h: SocketHostState, fin?: StatewireHost.Fin) => {
485
633
  h.pendingOps.length === 0 &&
486
634
  h.pendingApplied.size === 0 &&
487
635
  h.pendingCmds.size === 0 &&
636
+ !h.viewsDirty &&
488
637
  fin === undefined &&
489
- !edge
638
+ !edge &&
639
+ ![...h.conns].some(
640
+ (conn) => conn.pendingApplied.length > 0 || conn.pendingCmds.length > 0,
641
+ )
490
642
  ) {
491
643
  return;
492
644
  }
493
645
  h.idleWire = level;
646
+ h.viewsDirty = false;
494
647
  if (fin === undefined && edge && level && h.finOnIdle) {
495
648
  fin = { reason: "idle" };
496
649
  }
497
- const ops = h.pendingOps;
650
+ const mainOps = h.pendingOps;
498
651
  const applied = h.pendingApplied;
499
652
  const cmds = h.pendingCmds;
500
653
  h.pendingOps = [];
@@ -504,26 +657,34 @@ export const flush = (h: SocketHostState, fin?: StatewireHost.Fin) => {
504
657
  // when one is flushing, standalone otherwise. Terminal verdicts ride their
505
658
  // own packet.
506
659
  for (const conn of [...h.conns]) {
507
- const appliedEntries: StatewireCmd[] = (
508
- applied.get(conn.clientId) ?? []
509
- ).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
+ ];
510
671
  if (ops.length > 0) {
511
- const packet: StatewireEnvelope = {
512
- ops: conn.selection.protocols.some((p) => p.name === h.protocol.name)
513
- ? ops
514
- : [],
515
- ...(appliedEntries.length > 0 && { cmd: appliedEntries }),
516
- ...(edge && { idle: level }),
517
- };
518
- 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
+ );
519
681
  } else if (appliedEntries.length > 0) {
520
682
  sendTo(h, conn, JSON.stringify({ cmd: appliedEntries }));
521
683
  }
522
- const verdicts = (cmds.get(conn.clientId) ?? []).map((cmd) =>
523
- conn.selection.protocols.some((p) => p.name === h.protocol.name)
524
- ? cmd
525
- : { seq: cmd.seq, ...(cmd.dur === undefined ? {} : { dur: cmd.dur }) },
526
- );
684
+ const verdicts = [
685
+ ...forConn(cmds.get(conn.clientId)),
686
+ ...conn.pendingCmds.splice(0),
687
+ ];
527
688
  if (verdicts.length > 0) {
528
689
  sendTo(h, conn, JSON.stringify({ cmd: verdicts }));
529
690
  }
@@ -537,17 +698,24 @@ export const flush = (h: SocketHostState, fin?: StatewireHost.Fin) => {
537
698
  }
538
699
  };
539
700
 
540
- const queueApplied = (h: SocketHostState, clientId: string, seq: number) => {
541
- const list = h.pendingApplied.get(clientId);
542
- if (list) list.push(seq);
543
- 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
+ }
544
709
  scheduleFlush(h);
545
710
  };
546
711
 
547
- const queueCmd = (h: SocketHostState, clientId: string, cmd: StatewireCmd) => {
548
- const list = h.pendingCmds.get(clientId);
549
- if (list) list.push(cmd);
550
- 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
+ }
551
719
  scheduleFlush(h);
552
720
  };
553
721
 
@@ -555,94 +723,124 @@ const notifyClientRecords = (h: SocketHostState) => {
555
723
  h.persistence?.onClientRecordsChange?.(
556
724
  [...h.clients.values()].map((rec) => ({
557
725
  id: rec.id,
558
- lastSeq: admittedThrough(rec),
559
- ctxSeq: rec.ctxSeq,
560
726
  touched: rec.touched,
561
- context: rec.context,
562
- contextDocuments: [...rec.contextDocuments.documents],
563
- results: [...rec.results].map(([seq, entry]) => ({
564
- seq,
565
- ts: entry.ts,
566
- cmd: entry.cmd,
567
- })),
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
+ ),
568
740
  principal: rec.principal,
569
741
  })),
570
742
  );
571
743
  };
572
744
 
573
- const retain = (rec: ClientRec, cmd: StatewireCmd) => {
745
+ const retain = (lane: Lane, cmd: Cmd) => {
574
746
  const now = Date.now();
575
- rec.results.set(cmd.seq, { ts: now, cmd });
576
- const newest = Math.max(...rec.results.keys());
577
- 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) {
578
750
  if (seq !== newest && now - entry.ts > RESULT_TTL_MS) {
579
- rec.results.delete(seq);
751
+ lane.results.delete(seq);
580
752
  }
581
753
  }
582
- while (rec.results.size > RESULT_LIMIT) {
583
- rec.results.delete(Math.min(...rec.results.keys()));
754
+ while (lane.results.size > RESULT_LIMIT) {
755
+ lane.results.delete(Math.min(...lane.results.keys()));
584
756
  }
585
757
  };
586
758
 
587
- /** Cmd-lane watermark: the last statement admitted, settled or not. */
588
- const admittedThrough = (rec: ClientRec): number =>
589
- 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
+ };
590
791
 
591
792
  // A re-asked seq never re-runs: the stored verdict re-emits, an applied
592
793
  // inflight run re-answers applied; one not yet applied answers nothing (its
593
794
  // answers ride the stream when ready), an evicted verdict answers expired.
594
- const reemit = (h: SocketHostState, rec: ClientRec, seq: number) => {
595
- const cached = rec.results.get(seq);
795
+ const reemit = (h: SocketHostState, lane: Lane, seq: number) => {
796
+ const cached = lane.results.get(seq);
596
797
  if (cached) {
597
- const newest = Math.max(...rec.results.keys());
798
+ const newest = Math.max(...lane.results.keys());
598
799
  if (seq === newest || Date.now() - cached.ts <= RESULT_TTL_MS) {
599
- queueCmd(h, rec.id, cached.cmd);
800
+ queueCmd(h, lane, cached.cmd);
600
801
  return;
601
802
  }
602
- rec.results.delete(seq);
803
+ lane.results.delete(seq);
603
804
  notifyClientRecords(h);
604
805
  }
605
- const adm = rec.inflight.get(seq);
806
+ const adm = lane.inflight.get(seq);
606
807
  if (adm) {
607
- if (adm.applied) queueApplied(h, rec.id, seq);
808
+ if (adm.applied) queueApplied(h, lane, adm);
608
809
  return;
609
810
  }
610
- queueCmd(h, rec.id, { seq, type: "expired" });
811
+ queueCmd(h, lane, { protocol: lane.protocol, seq, type: "expired" });
611
812
  };
612
813
 
613
814
  const settleCmd = (
614
815
  h: SocketHostState,
615
- rec: ClientRec,
816
+ lane: Lane,
616
817
  adm: Admission,
617
- cmd: StatewireCmd,
818
+ cmd: Cmd,
618
819
  ) => {
619
820
  if (adm.settled) return;
620
821
  adm.settled = true;
621
- rec.inflight.delete(adm.seq);
622
- retain(rec, cmd);
623
- 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);
624
825
  adm.applied = true;
625
- queueCmd(h, rec.id, cmd);
626
- notifyClientRecords(h);
826
+ queueCmd(h, lane, cmd);
827
+ if (lane.conn === null) notifyClientRecords(h);
627
828
  };
628
829
 
629
830
  const settleWire = (
630
831
  h: SocketHostState,
631
- rec: ClientRec,
832
+ lane: Lane,
632
833
  adm: Admission,
633
834
  outcome: CommandsOutcome,
634
- ) => settleCmd(h, rec, adm, cmdOf(adm, outcome));
835
+ ) => settleCmd(h, lane, adm, cmdOf(adm, outcome));
635
836
 
636
837
  const callerOf = (
637
838
  h: SocketHostState,
638
839
  rec: ClientRec,
639
- auth: unknown,
840
+ conn: Conn,
640
841
  ): StatewireHost.Caller => ({
641
842
  clientId: rec.id,
642
- auth,
643
- get context() {
644
- return rec.context;
645
- },
843
+ auth: conn.auth,
646
844
  get isConnected() {
647
845
  if (h.clients.get(rec.id) !== rec) return false;
648
846
  for (const conn of h.conns) {
@@ -654,11 +852,12 @@ const callerOf = (
654
852
 
655
853
  const rejectStatement = (
656
854
  h: SocketHostState,
657
- rec: ClientRec,
855
+ lane: Lane,
658
856
  adm: Admission,
659
857
  message: string,
660
858
  ) =>
661
- settleCmd(h, rec, adm, {
859
+ settleCmd(h, lane, adm, {
860
+ protocol: adm.protocol,
662
861
  seq: adm.seq,
663
862
  type: "rejected",
664
863
  code: "invalid-params",
@@ -667,34 +866,23 @@ const rejectStatement = (
667
866
 
668
867
  const admitCmd = (
669
868
  h: SocketHostState,
670
- rec: ClientRec,
869
+ lane: Lane,
870
+ conn: Conn,
671
871
  statement: CmdStatement,
672
- auth: unknown,
673
- selection: StatewireProtocol.Selection,
674
872
  ) => {
873
+ const rec = h.clients.get(conn.clientId)!;
675
874
  const adm: Admission = {
875
+ protocol: statement.protocol,
676
876
  seq: statement.seq,
677
877
  method: statement.method,
678
878
  applied: false,
679
879
  appliedSent: false,
680
880
  settled: false,
681
881
  };
682
- rec.inflight.set(statement.seq, adm);
683
- notifyClientRecords(h);
684
- if (
685
- statement.protocol !== h.protocol.name ||
686
- !selection.protocols.some((p) => p.name === statement.protocol)
687
- ) {
688
- settleCmd(h, rec, adm, {
689
- seq: adm.seq,
690
- type: "rejected",
691
- code: "unsupported-protocol",
692
- message: "command protocol was not negotiated",
693
- });
694
- return;
695
- }
882
+ lane.inflight.set(statement.seq, adm);
883
+ if (lane.conn === null) notifyClientRecords(h);
696
884
  if (!Array.isArray(statement.params)) {
697
- rejectStatement(h, rec, adm, "params must be an array");
885
+ rejectStatement(h, lane, adm, "params must be an array");
698
886
  return;
699
887
  }
700
888
  let params = statement.params;
@@ -706,7 +894,7 @@ const admitCmd = (
706
894
  } catch (error) {
707
895
  rejectStatement(
708
896
  h,
709
- rec,
897
+ lane,
710
898
  adm,
711
899
  `invalid params: ${(error as Error).message}`,
712
900
  );
@@ -715,132 +903,133 @@ const admitCmd = (
715
903
  }
716
904
  if (statement.method === "statewire/commands") {
717
905
  if (params.length > 0) {
718
- rejectStatement(h, rec, adm, "statewire/commands takes no params");
906
+ rejectStatement(h, lane, adm, "statewire/commands takes no params");
719
907
  return;
720
908
  }
721
909
  const names = [
722
910
  ...(h.commandsHost?.methods?.() ?? []),
723
911
  "statewire/commands",
724
912
  ].sort();
725
- 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
+ });
726
919
  return;
727
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}`;
728
926
  h.commandsHost!.submit!({
729
- method: statement.method,
927
+ method,
730
928
  params,
731
929
  clientId: rec.id,
732
- caller: callerOf(h, rec, auth),
930
+ caller: callerOf(h, rec, conn),
931
+ attach: conn.attach,
733
932
  onApplied: () => {
734
933
  if (adm.settled || adm.applied) return;
735
934
  adm.applied = true;
736
- rec.lastSeq = Math.max(rec.lastSeq, adm.seq);
737
- notifyClientRecords(h);
935
+ lane.lastSeq = Math.max(lane.lastSeq, adm.seq);
936
+ if (lane.conn === null) notifyClientRecords(h);
738
937
  scheduleFlush(h);
739
938
  },
740
- onSettle: (outcome) => settleWire(h, rec, adm, outcome),
939
+ onSettle: (outcome) => settleWire(h, lane, adm, outcome),
741
940
  }).catch((error: unknown) => {
742
- if (!adm.settled) settleWire(h, rec, adm, { type: "rejected", error });
941
+ if (!adm.settled) settleWire(h, lane, adm, { type: "rejected", error });
743
942
  });
744
943
  };
745
944
 
746
- // One admission law, both lanes: absorbed statements answer from the record,
945
+ // One admission law, every lane: absorbed statements answer from their lane,
747
946
  // then the new suffix is admitted whole or the frame is refused whole via a
748
- // {syn} stating both watermarks, nothing consumed. A gap is never a close.
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).
749
949
  export type FrameAdmission =
750
950
  | { type: "ok" }
751
951
  | {
752
952
  type: "refused";
753
953
  syn: {
754
- syn: { seq: number; ctxSeq: number; retry?: number };
954
+ syn: { protocols: { name: string; seq: number }[]; retry?: number };
755
955
  };
756
956
  };
757
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
+
758
975
  export const admitFrame = (
759
976
  h: SocketHostState,
760
- rec: ClientRec,
977
+ conn: Conn,
761
978
  frame: ParsedFrame,
762
- auth: unknown,
763
- selection: StatewireProtocol.Selection = {
764
- version: h.wireVersion.version,
765
- protocols: [{ name: h.protocol.name, version: h.protocol.version }],
766
- },
767
979
  ): FrameAdmission => {
980
+ const rec = h.clients.get(conn.clientId)!;
768
981
  rec.touched = Date.now();
769
- const through = admittedThrough(rec);
770
- for (const statement of frame.cmd) {
771
- if (statement.seq <= through) reemit(h, rec, statement.seq);
772
- }
773
- const ctxNew = frame.ctx.filter((s) => s.seq > rec.ctxSeq);
774
- const cmdNew = frame.cmd.filter((s) => s.seq > through);
775
- if (
776
- (ctxNew.length > 0 && ctxNew[0]!.seq !== rec.ctxSeq + 1) ||
777
- (cmdNew.length > 0 && cmdNew[0]!.seq !== through + 1)
778
- ) {
779
- notifyClientRecords(h);
780
- return {
781
- type: "refused",
782
- syn: { syn: { seq: through, ctxSeq: rec.ctxSeq } },
783
- };
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);
784
992
  }
785
- if (cmdNew.length > 0) {
786
- let hostCommandLoad = 0;
787
- for (const client of h.clients.values()) {
788
- 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);
789
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;
790
1010
  if (
791
- rec.inflight.size + cmdNew.length > MAX_INFLIGHT_COMMANDS_PER_CLIENT ||
792
- hostCommandLoad + cmdNew.length > h.maxInflightCommands
1011
+ clientLoad + durableNew > MAX_INFLIGHT_COMMANDS_PER_CLIENT ||
1012
+ hostLoad + durableNew > h.maxInflightCommands
793
1013
  ) {
794
- return {
795
- type: "refused",
796
- syn: {
797
- syn: {
798
- seq: through,
799
- ctxSeq: rec.ctxSeq,
800
- retry: COMMAND_CAPACITY_RETRY_MS,
801
- },
802
- },
803
- };
1014
+ return refusal(h, conn, COMMAND_CAPACITY_RETRY_MS);
804
1015
  }
805
1016
  }
806
- if ((ctxNew.length || cmdNew.length) && h.pushback) {
1017
+ if (fresh.length > 0 && h.pushback) {
807
1018
  const retry = h.pushback();
808
1019
  if (retry !== null) {
809
1020
  if (!Number.isSafeInteger(retry) || retry < 0)
810
1021
  throw new TypeError(
811
1022
  "pushback must return a nonnegative safe integer or null",
812
1023
  );
813
- return {
814
- type: "refused",
815
- syn: { syn: { seq: through, ctxSeq: rec.ctxSeq, retry } },
816
- };
1024
+ return refusal(h, conn, retry);
817
1025
  }
818
1026
  }
819
- let contextDocuments = rec.contextDocuments;
820
- try {
821
- for (const statement of ctxNew)
822
- contextDocuments = applyDocumentOps(
823
- contextDocuments,
824
- statement.ops,
825
- selection.protocols,
826
- true,
827
- );
828
- if (
829
- frameEncoder.encode(JSON.stringify([...contextDocuments.documents]))
830
- .byteLength > MAX_CONTEXT_VALUE_BYTES
831
- )
832
- return violation("oversized-context");
833
- } catch {
834
- return violation("invalid-context");
835
- }
836
- rec.contextDocuments = contextDocuments;
837
- rec.context = (mainDocument(contextDocuments, h.protocol.name)?.value ??
838
- null) as Record<string, unknown> | null;
839
- if (ctxNew.length) rec.ctxSeq = ctxNew.at(-1)!.seq;
840
- for (const statement of cmdNew) admitCmd(h, rec, statement, auth, selection);
841
- 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);
842
1030
  // Memory host: admitted means retained — dur rides the admitting flush.
843
- 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 });
844
1033
  }
845
1034
  notifyClientRecords(h);
846
1035
  scheduleFlush(h);
@@ -865,39 +1054,15 @@ const message = (h: SocketHostState, conn: Conn, data: string) => {
865
1054
  }
866
1055
  const first = !conn.sawFrame;
867
1056
  conn.sawFrame = true;
868
- let frame: ParsedFrame;
869
- try {
870
- frame = first ? parseInitialFrame(data).frame : parseFrame(data);
871
- } catch (error) {
872
- const reason = violationReason(error);
873
- if (reason === null) throw error;
874
- sendTo(
875
- h,
876
- conn,
877
- JSON.stringify({
878
- fin: {
879
- reason: "protocol-error",
880
- message: reason,
881
- },
882
- }),
883
- );
884
- closeConn(h, conn, 1008, reason);
885
- return;
886
- }
887
- if (frame.ctx.length === 0 && frame.cmd.length === 0) return;
888
- const rec = h.clients.get(conn.clientId)!;
889
1057
  let admission: ReturnType<typeof admitFrame>;
890
1058
  try {
891
- admission = admitFrame(h, rec, frame, conn.auth, conn.selection);
1059
+ const frame = first ? parseInitialFrame(data).frame : parseFrame(data);
1060
+ if (frame.cmd.length === 0) return;
1061
+ admission = admitFrame(h, conn, frame);
892
1062
  } catch (error) {
893
1063
  const reason = violationReason(error);
894
1064
  if (reason === null) throw error;
895
- sendTo(
896
- h,
897
- conn,
898
- JSON.stringify({ fin: { reason: "protocol-error", message: reason } }),
899
- );
900
- closeConn(h, conn, 1008, reason);
1065
+ violate(h, conn, reason);
901
1066
  return;
902
1067
  }
903
1068
  if (admission.type === "refused") {
@@ -1152,26 +1317,23 @@ const connectAuthorized = (
1152
1317
  pending.open = false;
1153
1318
  h.pendingConns.delete(pending);
1154
1319
  if (pending.timer !== null) clearTimeout(pending.timer);
1155
- const registration = register(
1156
- h,
1157
- clientId,
1158
- socket,
1159
- true,
1160
- auth,
1161
- selection,
1162
- );
1163
- if (registration === null) return;
1164
- pending.connection = registration.connection;
1165
- if (frame.ctx.length || frame.cmd.length) {
1166
- message(
1320
+ let registration: Registration | null;
1321
+ try {
1322
+ registration = register(
1167
1323
  h,
1168
- registration.conn,
1169
- JSON.stringify({
1170
- ...(frame.ctx.length ? { ctx: frame.ctx } : {}),
1171
- ...(frame.cmd.length ? { cmd: frame.cmd } : {}),
1172
- }),
1324
+ clientId,
1325
+ socket,
1326
+ true,
1327
+ auth,
1328
+ selection,
1329
+ frame,
1173
1330
  );
1331
+ } catch (error) {
1332
+ if (violationReason(error) === null) throw error;
1333
+ return;
1174
1334
  }
1335
+ if (registration === null) return;
1336
+ pending.connection = registration.connection;
1175
1337
  for (const data of pending.queued.splice(0))
1176
1338
  registration.connection.message(data);
1177
1339
  })
@@ -1194,7 +1356,11 @@ const gcClients = (h: SocketHostState) => {
1194
1356
  const oldest = [...h.clients.values()].sort((a, b) => a.touched - b.touched);
1195
1357
  for (const rec of oldest) {
1196
1358
  if (excess <= 0) break;
1197
- 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;
1198
1364
  h.clients.delete(rec.id);
1199
1365
  changed = true;
1200
1366
  excess -= 1;
@@ -1203,11 +1369,15 @@ const gcClients = (h: SocketHostState) => {
1203
1369
  };
1204
1370
 
1205
1371
  export type Registration = {
1206
- rec: ClientRec;
1207
1372
  conn: Conn;
1208
1373
  connection: StatewireHostInternal.SocketConnection;
1209
1374
  };
1210
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
+ */
1211
1381
  export const register = (
1212
1382
  h: SocketHostState,
1213
1383
  clientId: string,
@@ -1218,6 +1388,7 @@ export const register = (
1218
1388
  version: h.wireVersion.version,
1219
1389
  protocols: [{ name: h.protocol.name, version: h.protocol.version }],
1220
1390
  },
1391
+ frame: ParsedFrame = { cmd: [] },
1221
1392
  ): Registration | null => {
1222
1393
  const refuse = (code: number, reason: string) => {
1223
1394
  try {
@@ -1264,13 +1435,8 @@ export const register = (
1264
1435
  (() => {
1265
1436
  const created: ClientRec = {
1266
1437
  id: clientId,
1267
- lastSeq: 0,
1268
- ctxSeq: 0,
1269
1438
  touched: Date.now(),
1270
- context: null,
1271
- contextDocuments: { mode: "mounted", documents: new Map() },
1272
- results: new Map(),
1273
- inflight: new Map(),
1439
+ lanes: new Map(),
1274
1440
  principal: auth,
1275
1441
  };
1276
1442
  h.clients.set(clientId, created);
@@ -1282,42 +1448,84 @@ export const register = (
1282
1448
  clientId,
1283
1449
  socket,
1284
1450
  open: true,
1451
+ ready: false,
1285
1452
  sawFrame,
1286
1453
  lastSentAt: 0,
1287
1454
  lease: null,
1288
1455
  auth,
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!,
1289
1464
  };
1465
+ conn.attach = attachOf(h, conn);
1290
1466
  h.conns.add(conn);
1291
1467
  gcClients(h);
1292
- notifyClientRecords(h);
1293
1468
  ensureHeartbeat(h);
1294
- const { value, meta } = encodeDocument(h.stateHost!.snapshot());
1295
- const hello: StatewireEnvelope = {
1296
- ops: [
1297
- {
1298
- op: "mount",
1299
- protocol: h.protocol.name,
1300
- main: true,
1301
- value,
1302
- ...(meta !== undefined && { meta }),
1303
- },
1304
- ],
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,
1305
1506
  syn: {
1306
- ...selection,
1307
- seq: admittedThrough(rec),
1308
- ctxSeq: rec.ctxSeq,
1507
+ version: selection.version,
1508
+ protocols: selection.protocols.map((p) => ({
1509
+ ...p,
1510
+ seq: p.record === "ephemeral" ? 0 : watermark(h, conn, p.name),
1511
+ })),
1309
1512
  ...(h.idleWire && { idle: true }),
1310
1513
  },
1514
+ ...(answers.length > 0 && { cmd: answers }),
1311
1515
  };
1312
- if (!selection.protocols.some((p) => p.name === h.protocol.name))
1313
- hello.ops = [];
1516
+ conn.ready = true;
1314
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));
1315
1523
  const connection: StatewireHostInternal.SocketConnection = {
1316
1524
  clientId,
1317
1525
  message: (data) => message(h, conn, data),
1318
1526
  close: () => closeConn(h, conn),
1319
1527
  };
1320
- return { rec, conn, connection };
1528
+ return { conn, connection };
1321
1529
  };
1322
1530
 
1323
1531
  const connect = (
@@ -1349,6 +1557,7 @@ const finish = (h: SocketHostState, fin: StatewireHost.Fin) => {
1349
1557
  const resume = (h: SocketHostState) => {
1350
1558
  h.disposed = false;
1351
1559
  h.stateHost?.setOnOp((op) => recordOp(h, op));
1560
+ h.stateHost?.setOnDocuments(() => markViews(h));
1352
1561
  };
1353
1562
 
1354
1563
  const suspend = (h: SocketHostState) => {
@@ -1358,6 +1567,7 @@ const suspend = (h: SocketHostState) => {
1358
1567
  h.heartbeatTimer = null;
1359
1568
  }
1360
1569
  h.stateHost?.setOnOp(null);
1570
+ h.stateHost?.setOnDocuments(null);
1361
1571
  };
1362
1572
 
1363
1573
  const restoreClientRecords = (
@@ -1367,15 +1577,24 @@ const restoreClientRecords = (
1367
1577
  records.map((record) => [
1368
1578
  record.id,
1369
1579
  {
1370
- ...record,
1371
- contextDocuments: {
1372
- mode: "mounted",
1373
- documents: new Map(record.contextDocuments ?? []),
1374
- },
1375
- results: new Map(
1376
- record.results.map(({ seq, ts, cmd }) => [seq, { ts, cmd }]),
1580
+ id: record.id,
1581
+ touched: record.touched,
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
+ ]),
1377
1596
  ),
1378
- inflight: new Map(),
1597
+ principal: record.principal,
1379
1598
  },
1380
1599
  ]),
1381
1600
  );
@@ -1407,6 +1626,7 @@ const useSocketHost = (
1407
1626
  pendingOps: [],
1408
1627
  pendingApplied: new Map(),
1409
1628
  pendingCmds: new Map(),
1629
+ viewsDirty: false,
1410
1630
  idleWire: true,
1411
1631
  finished: null,
1412
1632
  disposed: false,
@@ -1480,9 +1700,10 @@ const useSocketHost = (
1480
1700
  * Transport-agnostic WS binding over a statewire/host transport:
1481
1701
  * feed it sockets (anything with `send`/`close`) and raw text frames, and it
1482
1702
  * speaks the Statewire packet protocol — snapshot-first hello with `syn`,
1483
- * statement-lane client frames under one admission law, `{cmd}` answer
1484
- * arrays fanned out to every attach of a client id, idle edges, and `fin` on
1485
- * 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.
1486
1707
  */
1487
1708
  export const StatewireSocketHost = (
1488
1709
  transport: ResourceElement<Statewire>,