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.
- package/dist/client/connection.d.ts +8 -5
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +24 -13
- package/dist/client/connection.js.map +1 -1
- package/dist/client/session.d.ts +13 -15
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +296 -401
- package/dist/client/session.js.map +1 -1
- package/dist/client/timing.d.ts +2 -1
- package/dist/client/timing.d.ts.map +1 -1
- package/dist/client/timing.js +2 -1
- package/dist/client/timing.js.map +1 -1
- package/dist/host/engine.d.ts +2 -0
- package/dist/host/engine.d.ts.map +1 -1
- package/dist/host/engine.js +2 -0
- package/dist/host/engine.js.map +1 -1
- package/dist/host/host.d.ts +3 -1
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js +38 -7
- package/dist/host/host.js.map +1 -1
- package/dist/host/http.d.ts.map +1 -1
- package/dist/host/http.js +9 -19
- package/dist/host/http.js.map +1 -1
- package/dist/host/socket.d.ts +48 -25
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +399 -266
- package/dist/host/socket.js.map +1 -1
- package/dist/host/synced.d.ts +9 -0
- package/dist/host/synced.d.ts.map +1 -0
- package/dist/host/synced.js +45 -0
- package/dist/host/synced.js.map +1 -0
- package/dist/host-internal.d.ts +20 -12
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/host.d.ts +33 -9
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +2 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -1
- package/dist/statewire.d.ts +8 -20
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +0 -14
- package/dist/statewire.js.map +1 -1
- package/dist/storage.d.ts +2 -5
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +54 -89
- package/dist/storage.js.map +1 -1
- package/dist/transports/remote.d.ts.map +1 -1
- package/dist/transports/remote.js +1 -6
- package/dist/transports/remote.js.map +1 -1
- package/dist/transports/sse.d.ts +2 -5
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js.map +1 -1
- package/dist/transports/testing.d.ts +7 -5
- package/dist/transports/testing.d.ts.map +1 -1
- package/dist/transports/testing.js +19 -18
- package/dist/transports/testing.js.map +1 -1
- package/dist/types.d.ts +3 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/wire/limits.d.ts +2 -2
- package/dist/wire/limits.js +2 -2
- package/dist/wire/limits.js.map +1 -1
- package/dist/wire/protocol.d.ts +6 -1
- package/dist/wire/protocol.d.ts.map +1 -1
- package/dist/wire/protocol.js +33 -12
- package/dist/wire/protocol.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +46 -33
- package/src/client/session.ts +442 -517
- package/src/client/timing.ts +2 -0
- package/src/host/engine.ts +3 -1
- package/src/host/host.ts +50 -3
- package/src/host/http.ts +23 -31
- package/src/host/socket.ts +560 -339
- package/src/host/synced.ts +60 -0
- package/src/host-internal.ts +21 -7
- package/src/host.ts +40 -8
- package/src/index.ts +1 -0
- package/src/statewire.ts +8 -37
- package/src/storage.ts +90 -157
- package/src/transports/remote.ts +0 -7
- package/src/transports/sse.ts +8 -3
- package/src/transports/testing.ts +34 -18
- package/src/types.ts +3 -5
- package/src/wire/limits.ts +1 -1
- package/src/wire/protocol.ts +40 -11
package/src/host/socket.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 !== "
|
|
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
|
|
184
|
-
if (frame
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
)
|
|
214
|
-
|
|
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 {
|
|
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):
|
|
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 {
|
|
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 {
|
|
251
|
+
return { ...at, type: "crashed" };
|
|
261
252
|
case "failed":
|
|
262
253
|
return {
|
|
263
|
-
|
|
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
|
-
|
|
265
|
+
...at,
|
|
275
266
|
type: "rejected",
|
|
276
267
|
code: failure.code,
|
|
277
268
|
...(message !== undefined && { message }),
|
|
278
269
|
};
|
|
279
270
|
}
|
|
280
271
|
return {
|
|
281
|
-
|
|
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
|
-
|
|
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,
|
|
342
|
-
pendingCmds: Map<string,
|
|
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
|
|
466
|
-
if (
|
|
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
|
|
475
|
-
for (const adm of
|
|
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,
|
|
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
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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 =
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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,
|
|
541
|
-
const
|
|
542
|
-
if (
|
|
543
|
-
else
|
|
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,
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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 = (
|
|
745
|
+
const retain = (lane: Lane, cmd: Cmd) => {
|
|
574
746
|
const now = Date.now();
|
|
575
|
-
|
|
576
|
-
const newest = Math.max(...
|
|
577
|
-
for (const [seq, entry] of
|
|
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
|
-
|
|
751
|
+
lane.results.delete(seq);
|
|
580
752
|
}
|
|
581
753
|
}
|
|
582
|
-
while (
|
|
583
|
-
|
|
754
|
+
while (lane.results.size > RESULT_LIMIT) {
|
|
755
|
+
lane.results.delete(Math.min(...lane.results.keys()));
|
|
584
756
|
}
|
|
585
757
|
};
|
|
586
758
|
|
|
587
|
-
/**
|
|
588
|
-
const admittedThrough = (
|
|
589
|
-
Math.max(
|
|
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,
|
|
595
|
-
const cached =
|
|
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(...
|
|
798
|
+
const newest = Math.max(...lane.results.keys());
|
|
598
799
|
if (seq === newest || Date.now() - cached.ts <= RESULT_TTL_MS) {
|
|
599
|
-
queueCmd(h,
|
|
800
|
+
queueCmd(h, lane, cached.cmd);
|
|
600
801
|
return;
|
|
601
802
|
}
|
|
602
|
-
|
|
803
|
+
lane.results.delete(seq);
|
|
603
804
|
notifyClientRecords(h);
|
|
604
805
|
}
|
|
605
|
-
const adm =
|
|
806
|
+
const adm = lane.inflight.get(seq);
|
|
606
807
|
if (adm) {
|
|
607
|
-
if (adm.applied) queueApplied(h,
|
|
808
|
+
if (adm.applied) queueApplied(h, lane, adm);
|
|
608
809
|
return;
|
|
609
810
|
}
|
|
610
|
-
queueCmd(h,
|
|
811
|
+
queueCmd(h, lane, { protocol: lane.protocol, seq, type: "expired" });
|
|
611
812
|
};
|
|
612
813
|
|
|
613
814
|
const settleCmd = (
|
|
614
815
|
h: SocketHostState,
|
|
615
|
-
|
|
816
|
+
lane: Lane,
|
|
616
817
|
adm: Admission,
|
|
617
|
-
cmd:
|
|
818
|
+
cmd: Cmd,
|
|
618
819
|
) => {
|
|
619
820
|
if (adm.settled) return;
|
|
620
821
|
adm.settled = true;
|
|
621
|
-
|
|
622
|
-
retain(
|
|
623
|
-
|
|
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,
|
|
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
|
-
|
|
832
|
+
lane: Lane,
|
|
632
833
|
adm: Admission,
|
|
633
834
|
outcome: CommandsOutcome,
|
|
634
|
-
) => settleCmd(h,
|
|
835
|
+
) => settleCmd(h, lane, adm, cmdOf(adm, outcome));
|
|
635
836
|
|
|
636
837
|
const callerOf = (
|
|
637
838
|
h: SocketHostState,
|
|
638
839
|
rec: ClientRec,
|
|
639
|
-
|
|
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
|
-
|
|
855
|
+
lane: Lane,
|
|
658
856
|
adm: Admission,
|
|
659
857
|
message: string,
|
|
660
858
|
) =>
|
|
661
|
-
settleCmd(h,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
927
|
+
method,
|
|
730
928
|
params,
|
|
731
929
|
clientId: rec.id,
|
|
732
|
-
caller: callerOf(h, rec,
|
|
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
|
-
|
|
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,
|
|
939
|
+
onSettle: (outcome) => settleWire(h, lane, adm, outcome),
|
|
741
940
|
}).catch((error: unknown) => {
|
|
742
|
-
if (!adm.settled) settleWire(h,
|
|
941
|
+
if (!adm.settled) settleWire(h, lane, adm, { type: "rejected", error });
|
|
743
942
|
});
|
|
744
943
|
};
|
|
745
944
|
|
|
746
|
-
// One admission law,
|
|
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
|
|
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: {
|
|
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
|
-
|
|
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
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
(
|
|
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
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
-
|
|
792
|
-
|
|
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 (
|
|
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
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
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
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
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
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1484
|
-
* arrays fanned out to every attach of a client id
|
|
1485
|
-
*
|
|
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>,
|