statewire 0.18.2 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +291 -256
- 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 +8 -17
- package/dist/host/http.js.map +1 -1
- package/dist/host/socket.d.ts +47 -23
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +389 -245
- 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 -8
- 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 -10
- 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 +55 -41
- 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 -19
- 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 +434 -365
- 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 +22 -23
- package/src/host/socket.ts +550 -321
- package/src/host/synced.ts +60 -0
- package/src/host-internal.ts +21 -3
- package/src/host.ts +40 -8
- package/src/index.ts +1 -0
- package/src/statewire.ts +8 -30
- package/src/storage.ts +96 -79
- package/src/transports/remote.ts +0 -7
- package/src/transports/sse.ts +8 -3
- package/src/transports/testing.ts +34 -19
- package/src/types.ts +3 -5
- package/src/wire/limits.ts +1 -1
- package/src/wire/protocol.ts +40 -11
package/src/client/session.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { encodeDocument } from "../document";
|
|
2
1
|
import { wrapStorageError } from "./storageError";
|
|
3
2
|
import {
|
|
4
3
|
DEFAULT_PROTOCOL,
|
|
@@ -30,7 +29,7 @@ import {
|
|
|
30
29
|
SessionStorageContext,
|
|
31
30
|
createMemorySessionStore,
|
|
32
31
|
} from "./sessionStore";
|
|
33
|
-
import { backoff } from "./timing";
|
|
32
|
+
import { backoff, retryHint } from "./timing";
|
|
34
33
|
import { createMethodProxy } from "../util/methodProxy";
|
|
35
34
|
import { randomId as newClientId } from "../util/randomId";
|
|
36
35
|
import { StatewireSendError } from "../types";
|
|
@@ -62,12 +61,6 @@ type Pending = {
|
|
|
62
61
|
reject: (error: unknown) => void;
|
|
63
62
|
};
|
|
64
63
|
|
|
65
|
-
type CtxPending = {
|
|
66
|
-
readonly seq: number;
|
|
67
|
-
readonly ops: unknown[];
|
|
68
|
-
posted: boolean;
|
|
69
|
-
};
|
|
70
|
-
|
|
71
64
|
const protocolError = (message: string): never => {
|
|
72
65
|
throw new Error(`statewire: protocol error: ${message}`);
|
|
73
66
|
};
|
|
@@ -90,14 +83,13 @@ export type StatewireSessionOptions = StatewireProtocol.Options & {
|
|
|
90
83
|
|
|
91
84
|
type Shared = {
|
|
92
85
|
txq: Promise<void>;
|
|
93
|
-
parked: StatewireClient.SessionVerdict[];
|
|
86
|
+
parked: { protocol: string; verdict: StatewireClient.SessionVerdict }[];
|
|
94
87
|
unsubscribe: (() => void) | null;
|
|
95
88
|
};
|
|
96
89
|
|
|
97
90
|
type Session = {
|
|
98
91
|
options: StatewireSessionOptions;
|
|
99
92
|
selection: StatewireProtocol.Selection | undefined;
|
|
100
|
-
contextDocuments: StatewireDocuments.State;
|
|
101
93
|
record: StatewireClient.SessionRecord;
|
|
102
94
|
ctx: StatewireRemoteProps;
|
|
103
95
|
recordPersisted: boolean;
|
|
@@ -108,21 +100,24 @@ type Session = {
|
|
|
108
100
|
store: StatewireClient.SessionStorage;
|
|
109
101
|
documentStore: StatewireDocuments.Storage | undefined;
|
|
110
102
|
shared: Shared | false;
|
|
103
|
+
/** Every selected lane is ephemeral: the record dies with the attach. */
|
|
111
104
|
ephemeral: boolean;
|
|
112
105
|
/** An ephemeral record died with its attach: the storage is off for good. */
|
|
113
106
|
deactivated: boolean;
|
|
114
107
|
sessionKnown: boolean;
|
|
115
|
-
durableSeq: number
|
|
108
|
+
durableSeq: Record<string, number>;
|
|
109
|
+
/** Ephemeral lane clocks: restart at 1 on every attach. */
|
|
110
|
+
ephemeralSeqs: Record<string, number>;
|
|
116
111
|
resumeAt: number;
|
|
117
112
|
idle: boolean;
|
|
118
113
|
onSessionLost: StatewireClient.OnSessionLost | undefined;
|
|
119
114
|
onDone: (() => void) | undefined;
|
|
120
|
-
ctxNextSeq: number;
|
|
121
|
-
ctxPending: CtxPending[];
|
|
122
115
|
lanesOnAttach: boolean;
|
|
123
|
-
|
|
116
|
+
attachEntries: ReadonlySet<Pending>;
|
|
124
117
|
frameFailures: number;
|
|
125
118
|
pending: Pending[];
|
|
119
|
+
failedList: readonly StatewireClient.FailedCommand[];
|
|
120
|
+
failedParts: readonly (readonly StatewireClient.FailedCommand[])[];
|
|
126
121
|
canonical: unknown;
|
|
127
122
|
documentListeners: Set<() => void>;
|
|
128
123
|
documents: StatewireDocuments.State;
|
|
@@ -140,6 +135,33 @@ type Session = {
|
|
|
140
135
|
commands: Record<string, (...params: unknown[]) => Promise<unknown>>;
|
|
141
136
|
};
|
|
142
137
|
|
|
138
|
+
const EMPTY_LANE: StatewireClient.SessionLane = { nextSeq: 1, commands: [] };
|
|
139
|
+
|
|
140
|
+
const laneOf = (
|
|
141
|
+
record: StatewireClient.SessionRecord,
|
|
142
|
+
protocol: string,
|
|
143
|
+
): StatewireClient.SessionLane => record.lanes[protocol] ?? EMPTY_LANE;
|
|
144
|
+
|
|
145
|
+
const withLane = (
|
|
146
|
+
record: StatewireClient.SessionRecord,
|
|
147
|
+
protocol: string,
|
|
148
|
+
lane: StatewireClient.SessionLane,
|
|
149
|
+
): StatewireClient.SessionRecord => ({
|
|
150
|
+
...record,
|
|
151
|
+
lanes: { ...record.lanes, [protocol]: lane },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const isEphemeral = (s: Session, protocol: string) =>
|
|
155
|
+
s.selection?.protocols.some(
|
|
156
|
+
(p) => p.name === protocol && p.record === "ephemeral",
|
|
157
|
+
) ?? false;
|
|
158
|
+
|
|
159
|
+
const durableSeqOf = (s: Session, protocol: string) =>
|
|
160
|
+
s.durableSeq[protocol] ?? 0;
|
|
161
|
+
|
|
162
|
+
const laneEntries = (s: Session, protocol: string) =>
|
|
163
|
+
s.pending.filter((p) => p.command.protocol === protocol);
|
|
164
|
+
|
|
143
165
|
const publish = (s: Session) => {
|
|
144
166
|
if (s.canonical === s.lastPublished) return;
|
|
145
167
|
s.lastPublished = s.canonical;
|
|
@@ -186,43 +208,76 @@ const setRecord = (s: Session, record: StatewireClient.SessionRecord) => {
|
|
|
186
208
|
}
|
|
187
209
|
};
|
|
188
210
|
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
211
|
+
const toStored = (p: Pending): StatewireClient.SessionCommand => {
|
|
212
|
+
const { protocol: _protocol, ...command } = p.command;
|
|
213
|
+
return { seq: p.seq, ...command };
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const backlog = (
|
|
217
|
+
s: Session,
|
|
218
|
+
protocol: string,
|
|
219
|
+
): StatewireClient.SessionCommand[] =>
|
|
220
|
+
laneEntries(s, protocol)
|
|
221
|
+
.filter((p) => p.seq > durableSeqOf(s, protocol))
|
|
222
|
+
.map(toStored);
|
|
193
223
|
|
|
194
|
-
const mergeBacklog = (
|
|
195
|
-
|
|
224
|
+
const mergeBacklog = (
|
|
225
|
+
s: Session,
|
|
226
|
+
protocol: string,
|
|
227
|
+
): StatewireClient.SessionCommand[] => {
|
|
228
|
+
const own = backlog(s, protocol);
|
|
196
229
|
if (!s.shared) return own;
|
|
197
|
-
const known = new Set(s.
|
|
198
|
-
const foreign = s.record.commands.filter(
|
|
199
|
-
(c) => !known.has(c.seq) && c.seq > s
|
|
230
|
+
const known = new Set(laneEntries(s, protocol).map((p) => p.seq));
|
|
231
|
+
const foreign = laneOf(s.record, protocol).commands.filter(
|
|
232
|
+
(c) => !known.has(c.seq) && c.seq > durableSeqOf(s, protocol),
|
|
200
233
|
);
|
|
201
234
|
return [...foreign, ...own].sort((a, b) => a.seq - b.seq);
|
|
202
235
|
};
|
|
203
236
|
|
|
237
|
+
const durableLanes = (s: Session): string[] => [
|
|
238
|
+
...new Set([
|
|
239
|
+
...Object.keys(s.record.lanes),
|
|
240
|
+
...s.pending
|
|
241
|
+
.map((p) => p.command.protocol)
|
|
242
|
+
.filter((protocol) => !isEphemeral(s, protocol)),
|
|
243
|
+
]),
|
|
244
|
+
];
|
|
245
|
+
|
|
204
246
|
const MAX_PARKED_VERDICTS = 64;
|
|
205
247
|
|
|
206
248
|
// Storage holds what a reload must resend: a command leaves at its dur, at a
|
|
207
249
|
// server verdict, or at a record reset — never at a local channel death.
|
|
208
250
|
const syncBacklogInner = (s: Session) => {
|
|
209
251
|
const parked = s.shared ? s.shared.parked.splice(0) : [];
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
252
|
+
let lanes = s.record.lanes;
|
|
253
|
+
for (const protocol of durableLanes(s)) {
|
|
254
|
+
const lane = laneOf(s.record, protocol);
|
|
255
|
+
const parkedHere = parked
|
|
256
|
+
.filter((v) => v.protocol === protocol)
|
|
257
|
+
.map((v) => v.verdict);
|
|
258
|
+
const parkedSeqs = new Set(parkedHere.map((v) => v.seq));
|
|
259
|
+
const commands = mergeBacklog(s, protocol).filter(
|
|
260
|
+
(c) => !parkedSeqs.has(c.seq),
|
|
261
|
+
);
|
|
262
|
+
const verdicts =
|
|
263
|
+
parkedHere.length > 0
|
|
264
|
+
? [...(lane.verdicts ?? []), ...parkedHere].slice(-MAX_PARKED_VERDICTS)
|
|
265
|
+
: lane.verdicts;
|
|
266
|
+
const unchanged =
|
|
267
|
+
verdicts === lane.verdicts &&
|
|
268
|
+
commands.length === lane.commands.length &&
|
|
269
|
+
commands.every((c, i) => c.seq === lane.commands[i]!.seq);
|
|
270
|
+
if (unchanged) continue;
|
|
271
|
+
lanes = {
|
|
272
|
+
...lanes,
|
|
273
|
+
[protocol]: {
|
|
274
|
+
...lane,
|
|
275
|
+
commands,
|
|
276
|
+
...(verdicts !== undefined && { verdicts }),
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
if (lanes !== s.record.lanes) setRecord(s, { ...s.record, lanes });
|
|
226
281
|
};
|
|
227
282
|
|
|
228
283
|
const syncBacklog = (s: Session) => {
|
|
@@ -279,35 +334,49 @@ const cover = (s: Session, entry: Pending) => {
|
|
|
279
334
|
s.lifecycle?.onCovered?.(entry.inv);
|
|
280
335
|
};
|
|
281
336
|
|
|
282
|
-
const NO_FAILED: readonly StatewireClient.FailedCommand[] = [];
|
|
283
|
-
|
|
284
337
|
export const failedCommands = (
|
|
285
338
|
s: Session,
|
|
286
|
-
): readonly StatewireClient.FailedCommand[] =>
|
|
339
|
+
): readonly StatewireClient.FailedCommand[] => {
|
|
340
|
+
const parts = Object.values(s.record.lanes).flatMap((lane) =>
|
|
341
|
+
lane.failed === undefined ? [] : [lane.failed],
|
|
342
|
+
);
|
|
343
|
+
const same =
|
|
344
|
+
parts.length === s.failedParts.length &&
|
|
345
|
+
parts.every((part, i) => part === s.failedParts[i]);
|
|
346
|
+
if (!same) {
|
|
347
|
+
s.failedParts = parts;
|
|
348
|
+
s.failedList = parts.flat();
|
|
349
|
+
}
|
|
350
|
+
return s.failedList;
|
|
351
|
+
};
|
|
287
352
|
|
|
288
353
|
const retainFailedInner = (
|
|
289
354
|
s: Session,
|
|
290
355
|
entry: Pending,
|
|
291
356
|
failure: StatewireClient.SendFailure,
|
|
292
357
|
) => {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
...(
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
358
|
+
const lane = laneOf(s.record, entry.command.protocol);
|
|
359
|
+
setRecord(
|
|
360
|
+
s,
|
|
361
|
+
withLane(s.record, entry.command.protocol, {
|
|
362
|
+
...lane,
|
|
363
|
+
failed: [
|
|
364
|
+
...(lane.failed ?? []),
|
|
365
|
+
{
|
|
366
|
+
protocol: entry.command.protocol,
|
|
367
|
+
...(entry.command.produced !== undefined && {
|
|
368
|
+
produced: entry.command.produced,
|
|
369
|
+
}),
|
|
370
|
+
command: entry.command.method,
|
|
371
|
+
params: entry.command.params,
|
|
372
|
+
...(entry.command.meta !== undefined && { meta: entry.command.meta }),
|
|
373
|
+
failure,
|
|
374
|
+
ts: entry.inv.ts,
|
|
375
|
+
seq: entry.seq,
|
|
376
|
+
},
|
|
377
|
+
],
|
|
378
|
+
}),
|
|
379
|
+
);
|
|
311
380
|
s.notify();
|
|
312
381
|
};
|
|
313
382
|
|
|
@@ -343,8 +412,9 @@ const settleEntry = (
|
|
|
343
412
|
const rides =
|
|
344
413
|
(outcome.failure.cause === "unmount" ||
|
|
345
414
|
outcome.failure.cause === "stream-gone") &&
|
|
346
|
-
entry.seq > s.
|
|
347
|
-
if (!rides
|
|
415
|
+
entry.seq > durableSeqOf(s, entry.command.protocol);
|
|
416
|
+
if (!rides && !isEphemeral(s, entry.command.protocol))
|
|
417
|
+
retainFailed(s, entry, outcome.failure);
|
|
348
418
|
entry.reject(new StatewireSendError(outcome.failure));
|
|
349
419
|
s.lifecycle?.onSettle(entry.inv, {
|
|
350
420
|
type: "rejected",
|
|
@@ -360,7 +430,10 @@ const settleAll = (s: Session, detach: "unmount" | "gone") => {
|
|
|
360
430
|
const detachFailure = (entry: Pending): StatewireClient.SendFailure => {
|
|
361
431
|
if (!entry.attempted) {
|
|
362
432
|
const predecessorLost = batch.some(
|
|
363
|
-
(p) =>
|
|
433
|
+
(p) =>
|
|
434
|
+
p.command.protocol === entry.command.protocol &&
|
|
435
|
+
p.seq < entry.seq &&
|
|
436
|
+
p.attempted,
|
|
364
437
|
);
|
|
365
438
|
return predecessorLost
|
|
366
439
|
? { fate: "aborted", cause, predecessorLost }
|
|
@@ -384,6 +457,13 @@ const settleLost = (s: Session, entries: Pending[]) => {
|
|
|
384
457
|
}
|
|
385
458
|
};
|
|
386
459
|
|
|
460
|
+
const requeue = (s: Session, survivors: Pending[]) => {
|
|
461
|
+
for (const old of survivors) {
|
|
462
|
+
s.lifecycle?.onCovered?.(old.inv);
|
|
463
|
+
enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
|
|
387
467
|
// The server-side record's dedup memory is gone: history that may have
|
|
388
468
|
// reached a server settles lost — the new hello's snapshot is the only
|
|
389
469
|
// truth about what survived — and never-attempted statements re-stamp
|
|
@@ -396,27 +476,33 @@ const rebaseRecord = (s: Session, adopt: () => void) => {
|
|
|
396
476
|
held.filter((p) => p.attempted || p.applied),
|
|
397
477
|
);
|
|
398
478
|
s.pending = [];
|
|
399
|
-
s.durableSeq =
|
|
479
|
+
s.durableSeq = {};
|
|
400
480
|
// Failed entries are user-owned history: they ride through every record replacement.
|
|
401
|
-
const
|
|
481
|
+
const previous = s.record.lanes;
|
|
402
482
|
adopt();
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
483
|
+
let lanes = s.record.lanes;
|
|
484
|
+
for (const [protocol, lane] of Object.entries(previous)) {
|
|
485
|
+
const failed = lane.failed;
|
|
486
|
+
if (failed === undefined || laneOf(s.record, protocol).failed === failed)
|
|
487
|
+
continue;
|
|
488
|
+
const current = laneOf(s.record, protocol);
|
|
489
|
+
lanes = {
|
|
490
|
+
...lanes,
|
|
491
|
+
[protocol]: {
|
|
492
|
+
...current,
|
|
493
|
+
failed: [...(current.failed ?? []), ...failed],
|
|
494
|
+
},
|
|
495
|
+
};
|
|
412
496
|
}
|
|
497
|
+
if (lanes !== s.record.lanes) setRecord(s, { ...s.record, lanes });
|
|
498
|
+
requeue(s, survivors);
|
|
413
499
|
publish(s);
|
|
414
500
|
wake(s);
|
|
415
501
|
void pump(s);
|
|
416
502
|
};
|
|
417
503
|
|
|
418
504
|
const resetRecord = (s: Session, clientId: string) =>
|
|
419
|
-
rebaseRecord(s, () => setRecord(s, { clientId,
|
|
505
|
+
rebaseRecord(s, () => setRecord(s, { clientId, lanes: {} }));
|
|
420
506
|
|
|
421
507
|
// Another sharer replaced the record's identity: their reset settles our
|
|
422
508
|
// attempted history and our survivors re-stamp on the stored clocks.
|
|
@@ -432,6 +518,23 @@ const ephemeralDetach = (s: Session) => {
|
|
|
432
518
|
resetRecord(s, newClientId());
|
|
433
519
|
};
|
|
434
520
|
|
|
521
|
+
// An ephemeral lane dies with its attach: attempted statements can never be
|
|
522
|
+
// answered, the rest re-stamp from 1 for the next attach.
|
|
523
|
+
const detachEphemeralLanes = (s: Session) => {
|
|
524
|
+
const held = s.pending.filter((p) => isEphemeral(s, p.command.protocol));
|
|
525
|
+
if (held.length === 0) return;
|
|
526
|
+
s.pending = s.pending.filter((p) => !held.includes(p));
|
|
527
|
+
settleLost(
|
|
528
|
+
s,
|
|
529
|
+
held.filter((p) => p.attempted),
|
|
530
|
+
);
|
|
531
|
+
requeue(
|
|
532
|
+
s,
|
|
533
|
+
held.filter((p) => !p.attempted),
|
|
534
|
+
);
|
|
535
|
+
publish(s);
|
|
536
|
+
};
|
|
537
|
+
|
|
435
538
|
const rejectedFailure = (cmd: StatewireCmd): StatewireClient.SendFailure => {
|
|
436
539
|
return {
|
|
437
540
|
fate: "rejected",
|
|
@@ -470,58 +573,76 @@ const outcomeOf = (
|
|
|
470
573
|
}
|
|
471
574
|
};
|
|
472
575
|
|
|
576
|
+
// Only a result verdict carries applied effects; the rest drop the overlay unapplied.
|
|
577
|
+
const settleVerdict = (s: Session, entry: Pending, cmd: StatewireCmd) => {
|
|
578
|
+
const outcome = outcomeOf(cmd);
|
|
579
|
+
if (outcome.ok) cover(s, entry);
|
|
580
|
+
else s.lifecycle?.onCovered?.(entry.inv);
|
|
581
|
+
settleEntry(s, entry, outcome);
|
|
582
|
+
};
|
|
583
|
+
|
|
473
584
|
const TERMINAL_CMD_TYPES: ReadonlySet<string> = new Set(TERMINAL);
|
|
474
585
|
|
|
475
586
|
const parkVerdict = (s: Session, cmd: StatewireCmd) => {
|
|
476
587
|
if (!s.shared || !TERMINAL_CMD_TYPES.has(cmd.type!)) return;
|
|
588
|
+
if (isEphemeral(s, cmd.protocol)) return;
|
|
477
589
|
s.shared.parked.push({
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
590
|
+
protocol: cmd.protocol,
|
|
591
|
+
verdict: {
|
|
592
|
+
seq: cmd.seq,
|
|
593
|
+
type: cmd.type as StatewireClient.SessionVerdict["type"],
|
|
594
|
+
...(cmd.code !== undefined && { code: cmd.code }),
|
|
595
|
+
...(cmd.message !== undefined && { message: cmd.message }),
|
|
596
|
+
...(cmd.payload !== undefined && { payload: cmd.payload }),
|
|
597
|
+
...(cmd.meta !== undefined && { meta: cmd.meta }),
|
|
598
|
+
},
|
|
484
599
|
});
|
|
485
600
|
};
|
|
486
601
|
|
|
487
602
|
const handleCmd = (s: Session, cmd: StatewireCmd) => {
|
|
488
|
-
if (
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
for (const entry of s.pending) {
|
|
494
|
-
if (entry.seq <= s.durableSeq) entry.posted = true;
|
|
603
|
+
if (cmd.dur === true && !isEphemeral(s, cmd.protocol)) {
|
|
604
|
+
const watermark = Math.max(durableSeqOf(s, cmd.protocol), cmd.seq);
|
|
605
|
+
s.durableSeq[cmd.protocol] = watermark;
|
|
606
|
+
for (const entry of laneEntries(s, cmd.protocol)) {
|
|
607
|
+
if (entry.seq <= watermark) entry.posted = true;
|
|
495
608
|
}
|
|
496
609
|
}
|
|
497
610
|
if (cmd.type === undefined) return;
|
|
498
|
-
const entry = s.pending.find(
|
|
611
|
+
const entry = s.pending.find(
|
|
612
|
+
(p) => p.command.protocol === cmd.protocol && p.seq === cmd.seq,
|
|
613
|
+
);
|
|
499
614
|
if (cmd.type === "applied") {
|
|
500
615
|
if (entry) cover(s, entry);
|
|
501
616
|
return;
|
|
502
617
|
}
|
|
503
618
|
if (!entry) return;
|
|
504
619
|
parkVerdict(s, cmd);
|
|
505
|
-
|
|
506
|
-
settleEntry(s, entry, outcomeOf(cmd));
|
|
620
|
+
settleVerdict(s, entry, cmd);
|
|
507
621
|
};
|
|
508
622
|
|
|
509
623
|
const consumeVerdicts = (s: Session) => {
|
|
510
|
-
|
|
511
|
-
const
|
|
512
|
-
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
624
|
+
let lanes = s.record.lanes;
|
|
625
|
+
for (const [protocol, lane] of Object.entries(s.record.lanes)) {
|
|
626
|
+
const verdicts = lane.verdicts ?? [];
|
|
627
|
+
const consumed = new Set<number>();
|
|
628
|
+
for (const verdict of verdicts) {
|
|
629
|
+
const entry = laneEntries(s, protocol).find((p) => p.seq === verdict.seq);
|
|
630
|
+
if (!entry) continue;
|
|
631
|
+
consumed.add(verdict.seq);
|
|
632
|
+
settleVerdict(s, entry, { ...verdict, protocol });
|
|
633
|
+
}
|
|
634
|
+
if (consumed.size === 0) continue;
|
|
635
|
+
lanes = {
|
|
636
|
+
...lanes,
|
|
637
|
+
[protocol]: {
|
|
638
|
+
...lane,
|
|
639
|
+
commands: lane.commands.filter((c) => !consumed.has(c.seq)),
|
|
640
|
+
verdicts: verdicts.filter((v) => !consumed.has(v.seq)),
|
|
641
|
+
},
|
|
642
|
+
};
|
|
518
643
|
}
|
|
519
|
-
if (
|
|
520
|
-
setRecord(s, {
|
|
521
|
-
...s.record,
|
|
522
|
-
commands: s.record.commands.filter((c) => !consumed.has(c.seq)),
|
|
523
|
-
verdicts: verdicts.filter((v) => !consumed.has(v.seq)),
|
|
524
|
-
});
|
|
644
|
+
if (lanes === s.record.lanes) return;
|
|
645
|
+
setRecord(s, { ...s.record, lanes });
|
|
525
646
|
publish(s);
|
|
526
647
|
wake(s);
|
|
527
648
|
};
|
|
@@ -583,18 +704,20 @@ const publishIngested = (s: Session) => {
|
|
|
583
704
|
// The refusal's watermark is exact: every held statement above it was never
|
|
584
705
|
// admitted by the live record, and the first unposted seq being beyond
|
|
585
706
|
// watermark+1 proves the record that admitted the missing prefix is gone —
|
|
586
|
-
// its history settles lost and the
|
|
587
|
-
const sessionLostInner = (s: Session, seqWm: number) => {
|
|
588
|
-
const held =
|
|
707
|
+
// its history settles lost and the lane clock restarts at the watermark.
|
|
708
|
+
const sessionLostInner = (s: Session, protocol: string, seqWm: number) => {
|
|
709
|
+
const held = laneEntries(s, protocol);
|
|
589
710
|
settleLost(s, held);
|
|
590
|
-
s.
|
|
591
|
-
s.
|
|
592
|
-
setRecord(
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
711
|
+
s.durableSeq[protocol] = seqWm;
|
|
712
|
+
const failed = laneOf(s.record, protocol).failed;
|
|
713
|
+
setRecord(
|
|
714
|
+
s,
|
|
715
|
+
withLane(s.record, protocol, {
|
|
716
|
+
nextSeq: seqWm + 1,
|
|
717
|
+
commands: [],
|
|
718
|
+
...(failed !== undefined && { failed }),
|
|
719
|
+
}),
|
|
720
|
+
);
|
|
598
721
|
if (
|
|
599
722
|
held.length > 0 &&
|
|
600
723
|
s.onSessionLost?.(held.map((p) => p.inv)) === "requeue"
|
|
@@ -613,39 +736,51 @@ const sessionLostInner = (s: Session, seqWm: number) => {
|
|
|
613
736
|
wake(s);
|
|
614
737
|
};
|
|
615
738
|
|
|
739
|
+
const selectionOf = (
|
|
740
|
+
syn: StatewireSyn,
|
|
741
|
+
): StatewireProtocol.Selection | undefined => {
|
|
742
|
+
if (syn.version === undefined || syn.protocols === undefined)
|
|
743
|
+
return undefined;
|
|
744
|
+
return {
|
|
745
|
+
version: syn.version,
|
|
746
|
+
protocols: syn.protocols.map(({ name, version, record }) => {
|
|
747
|
+
if (version === undefined)
|
|
748
|
+
return protocolError(`hello lane ${name} has no version`);
|
|
749
|
+
return { name, version, ...(record !== undefined && { record }) };
|
|
750
|
+
}),
|
|
751
|
+
};
|
|
752
|
+
};
|
|
753
|
+
|
|
616
754
|
const handleHello = (
|
|
617
755
|
s: Session,
|
|
618
756
|
{ ops, syn }: { ops: unknown[]; syn: StatewireSyn },
|
|
619
757
|
) => {
|
|
620
|
-
|
|
621
|
-
protocolError(`unknown record: ${String(syn.record)}`);
|
|
622
|
-
}
|
|
623
|
-
if (syn.ctxSeq !== undefined && syn.ctxSeq !== 0) {
|
|
624
|
-
protocolError(`hello ctxSeq must be 0, got ${syn.ctxSeq}`);
|
|
625
|
-
}
|
|
626
|
-
s.selection =
|
|
627
|
-
syn.version === undefined || syn.protocols === undefined
|
|
628
|
-
? undefined
|
|
629
|
-
: { version: syn.version, protocols: syn.protocols };
|
|
758
|
+
s.selection = selectionOf(syn);
|
|
630
759
|
s.documents = { mode: null, documents: new Map() };
|
|
631
760
|
ingestOps(s, ops);
|
|
632
761
|
s.attach = "attached";
|
|
633
762
|
s.attachEpoch += 1;
|
|
634
763
|
s.frameFailures = 0;
|
|
635
764
|
s.resumeAt = 0;
|
|
636
|
-
|
|
765
|
+
const lanes = syn.protocols ?? [];
|
|
766
|
+
s.ephemeral =
|
|
767
|
+
lanes.length > 0 && lanes.every((lane) => lane.record === "ephemeral");
|
|
637
768
|
if (s.ephemeral) s.deactivated = true;
|
|
638
|
-
const
|
|
639
|
-
s.
|
|
769
|
+
const attached = s.attachEntries;
|
|
770
|
+
s.attachEntries = new Set();
|
|
640
771
|
const reconcile = () => {
|
|
641
772
|
if (s.shared) adoptStored(s);
|
|
642
773
|
// Resending unsettled statements is exactly-once-safe: absorbed replays
|
|
643
774
|
// re-emit their stored verdicts (applied while still in flight).
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
775
|
+
for (const lane of lanes) {
|
|
776
|
+
if (lane.record === "ephemeral") {
|
|
777
|
+
s.ephemeralSeqs[lane.name] = Math.max(
|
|
778
|
+
s.ephemeralSeqs[lane.name] ?? 0,
|
|
779
|
+
...laneEntries(s, lane.name).map((p) => p.seq),
|
|
780
|
+
);
|
|
781
|
+
} else s.durableSeq[lane.name] = lane.seq ?? 0;
|
|
647
782
|
}
|
|
648
|
-
for (const
|
|
783
|
+
for (const entry of s.pending) entry.posted = attached.has(entry);
|
|
649
784
|
syncBacklogInner(s);
|
|
650
785
|
s.sessionKnown = true;
|
|
651
786
|
};
|
|
@@ -671,40 +806,43 @@ const handleOps = (
|
|
|
671
806
|
publishIngested(s);
|
|
672
807
|
};
|
|
673
808
|
|
|
674
|
-
// A retry-less {syn} refusal is the one session-loss verdict: a
|
|
675
|
-
//
|
|
676
|
-
// (retry present) or a gapless refusal only reconciles the resend queues.
|
|
809
|
+
// A retry-less {syn} refusal is the one session-loss verdict: a gap against
|
|
810
|
+
// a durable lane's exact watermark settles that lane's history lost; a
|
|
811
|
+
// pushback (retry present) or a gapless refusal only reconciles the resend queues.
|
|
677
812
|
const handleRefusal = (s: Session, syn: StatewireSyn) => {
|
|
678
|
-
const
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
for (const
|
|
686
|
-
|
|
687
|
-
if (firstCtx !== undefined && firstCtx.seq > ctxSeq + 1) {
|
|
688
|
-
protocolError(
|
|
689
|
-
`ctx gap on a live attach: seq ${firstCtx.seq} after watermark ${ctxSeq}`,
|
|
690
|
-
);
|
|
691
|
-
}
|
|
813
|
+
const lanes = (syn.protocols ?? []).filter(
|
|
814
|
+
(lane): lane is { name: string; seq: number } => lane.seq !== undefined,
|
|
815
|
+
);
|
|
816
|
+
for (const { name, seq } of lanes) {
|
|
817
|
+
const watermark = isEphemeral(s, name)
|
|
818
|
+
? seq
|
|
819
|
+
: Math.max(seq, durableSeqOf(s, name));
|
|
820
|
+
for (const entry of laneEntries(s, name))
|
|
821
|
+
entry.posted = entry.seq <= watermark;
|
|
692
822
|
}
|
|
693
823
|
if (typeof syn.retry === "number") {
|
|
694
|
-
s.resumeAt = Date.now() + syn.retry;
|
|
824
|
+
s.resumeAt = Date.now() + retryHint(syn.retry);
|
|
695
825
|
wake(s);
|
|
696
826
|
void pump(s);
|
|
697
827
|
return;
|
|
698
828
|
}
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
829
|
+
const gaps = lanes.filter(({ name, seq }) => {
|
|
830
|
+
const first = laneEntries(s, name).find((p) => !p.posted);
|
|
831
|
+
return (
|
|
832
|
+
first !== undefined &&
|
|
833
|
+
first.seq > Math.max(seq, durableSeqOf(s, name)) + 1
|
|
834
|
+
);
|
|
835
|
+
});
|
|
836
|
+
for (const { name, seq } of gaps) {
|
|
837
|
+
if (isEphemeral(s, name))
|
|
838
|
+
protocolError(`gap on ephemeral lane ${name} after watermark ${seq}`);
|
|
839
|
+
}
|
|
840
|
+
if (gaps.length > 0) {
|
|
705
841
|
transactBackground(
|
|
706
842
|
s,
|
|
707
|
-
() =>
|
|
843
|
+
() => {
|
|
844
|
+
for (const { name, seq } of gaps) sessionLostInner(s, name, seq);
|
|
845
|
+
},
|
|
708
846
|
() => {
|
|
709
847
|
wake(s);
|
|
710
848
|
void pump(s);
|
|
@@ -718,7 +856,9 @@ const handleRefusal = (s: Session, syn: StatewireSyn) => {
|
|
|
718
856
|
|
|
719
857
|
const handleDetach = (s: Session) => {
|
|
720
858
|
if (s.attach === "attached") s.attach = "detached";
|
|
859
|
+
s.ephemeralSeqs = {};
|
|
721
860
|
if (s.ephemeral) ephemeralDetach(s);
|
|
861
|
+
else detachEphemeralLanes(s);
|
|
722
862
|
wake(s);
|
|
723
863
|
};
|
|
724
864
|
|
|
@@ -734,77 +874,56 @@ const isFinished = (s: Session) => s.attach === "finished";
|
|
|
734
874
|
|
|
735
875
|
type Lanes = {
|
|
736
876
|
frame: StatewireFrame;
|
|
737
|
-
|
|
738
|
-
ctxEntries: CtxPending[];
|
|
877
|
+
entries: Pending[];
|
|
739
878
|
};
|
|
740
879
|
|
|
741
880
|
// A lane must be strictly consecutive within a frame: the first unposted run
|
|
742
|
-
// per lane rides now, later runs ride follow-up frames.
|
|
881
|
+
// per lane rides now, later runs ride follow-up frames. Ephemeral lanes ignore
|
|
882
|
+
// the gate: they live on the attach and never wait for persistence.
|
|
743
883
|
const buildLanes = (
|
|
744
884
|
s: Session,
|
|
745
885
|
cmdGate: "persisted" | "all" | "none" = "all",
|
|
746
886
|
): Lanes | null => {
|
|
747
|
-
const
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
if (ctxEntries.length >= MAX_COMMANDS_PER_FRAME) break;
|
|
751
|
-
if (ctxEntries.length > 0 && entry.seq !== ctxEntries.at(-1)!.seq + 1)
|
|
752
|
-
break;
|
|
753
|
-
ctxEntries.push(entry);
|
|
754
|
-
}
|
|
755
|
-
const cmdEntries: Pending[] = [];
|
|
887
|
+
const entries: Pending[] = [];
|
|
888
|
+
const last: Record<string, number> = {};
|
|
889
|
+
const stopped = new Set<string>();
|
|
756
890
|
for (const entry of s.pending) {
|
|
757
|
-
if (
|
|
758
|
-
|
|
759
|
-
if (
|
|
760
|
-
|
|
761
|
-
(s
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
if (
|
|
767
|
-
|
|
768
|
-
|
|
891
|
+
if (entries.length >= MAX_COMMANDS_PER_FRAME) break;
|
|
892
|
+
const protocol = entry.command.protocol;
|
|
893
|
+
if (entry.posted || stopped.has(protocol)) continue;
|
|
894
|
+
const gated =
|
|
895
|
+
!isEphemeral(s, protocol) &&
|
|
896
|
+
(cmdGate === "none" ||
|
|
897
|
+
(cmdGate === "persisted" &&
|
|
898
|
+
(s.persistedRecord?.clientId !== s.record.clientId ||
|
|
899
|
+
entry.seq >= laneOf(s.persistedRecord, protocol).nextSeq)));
|
|
900
|
+
if (gated || (protocol in last && entry.seq !== last[protocol]! + 1)) {
|
|
901
|
+
stopped.add(protocol);
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
last[protocol] = entry.seq;
|
|
905
|
+
entries.push(entry);
|
|
769
906
|
}
|
|
770
|
-
if (
|
|
907
|
+
if (entries.length === 0) return null;
|
|
771
908
|
return {
|
|
772
909
|
frame: {
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
...(cmdEntries.length > 0 && {
|
|
777
|
-
cmd: cmdEntries.map((p) => {
|
|
778
|
-
const { produced: _produced, ...command } = p.command;
|
|
779
|
-
return { ...command, seq: p.seq };
|
|
780
|
-
}),
|
|
910
|
+
cmd: entries.map((p) => {
|
|
911
|
+
const { produced: _produced, ...command } = p.command;
|
|
912
|
+
return { ...command, seq: p.seq };
|
|
781
913
|
}),
|
|
782
914
|
},
|
|
783
|
-
|
|
784
|
-
ctxEntries,
|
|
915
|
+
entries,
|
|
785
916
|
};
|
|
786
917
|
};
|
|
787
918
|
|
|
788
|
-
// Context is attach scoped: every attach starts at ctx seq 0 with an empty
|
|
789
|
-
// table, so the full context restates at seq 1 on the attach frame.
|
|
790
|
-
const resetContextLane = (s: Session) => {
|
|
791
|
-
const ops = contextSnapshot(s.contextDocuments);
|
|
792
|
-
s.ctxPending = ops.length === 0 ? [] : [{ seq: 1, ops, posted: false }];
|
|
793
|
-
s.ctxNextSeq = s.ctxPending.length + 1;
|
|
794
|
-
};
|
|
795
|
-
|
|
796
919
|
const takeAttachLanes = (s: Session): StatewireFrame | null => {
|
|
797
|
-
s.
|
|
798
|
-
resetContextLane(s);
|
|
920
|
+
s.attachEntries = new Set();
|
|
799
921
|
const wanted = s.lanesOnAttach;
|
|
800
922
|
s.lanesOnAttach = false;
|
|
801
923
|
const lanes = buildLanes(s, wanted && s.sessionKnown ? "persisted" : "none");
|
|
802
924
|
if (!lanes) return null;
|
|
803
|
-
s.
|
|
804
|
-
|
|
805
|
-
new Set(lanes.ctxEntries.map((c) => c.seq)),
|
|
806
|
-
];
|
|
807
|
-
for (const entry of lanes.cmdEntries) entry.attempted = true;
|
|
925
|
+
s.attachEntries = new Set(lanes.entries);
|
|
926
|
+
for (const entry of lanes.entries) entry.attempted = true;
|
|
808
927
|
return lanes.frame;
|
|
809
928
|
};
|
|
810
929
|
|
|
@@ -860,7 +979,7 @@ const pump = async (s: Session) => {
|
|
|
860
979
|
if (persistence) await persistence;
|
|
861
980
|
if (!s.running || isFinished(s)) return;
|
|
862
981
|
if (epoch !== s.attachEpoch || s.attach !== "attached") continue;
|
|
863
|
-
for (const entry of lanes.
|
|
982
|
+
for (const entry of lanes.entries) entry.attempted = true;
|
|
864
983
|
let result;
|
|
865
984
|
try {
|
|
866
985
|
// Shared storage: the lock across the send keeps per-lane arrival
|
|
@@ -879,13 +998,12 @@ const pump = async (s: Session) => {
|
|
|
879
998
|
s.frameFailures = 0;
|
|
880
999
|
if (result.type === "ok") {
|
|
881
1000
|
if (epoch === s.attachEpoch) {
|
|
882
|
-
for (const entry of lanes.
|
|
1001
|
+
for (const entry of lanes.entries) {
|
|
883
1002
|
if (s.pending.includes(entry)) {
|
|
884
1003
|
entry.posted = true;
|
|
885
1004
|
s.lifecycle?.onPosted?.(entry.inv);
|
|
886
1005
|
}
|
|
887
1006
|
}
|
|
888
|
-
for (const entry of lanes.ctxEntries) entry.posted = true;
|
|
889
1007
|
}
|
|
890
1008
|
} else if (result.type === "refusal") {
|
|
891
1009
|
if (epoch === s.attachEpoch) handleRefusal(s, result.syn);
|
|
@@ -973,12 +1091,19 @@ const enqueueInner = (
|
|
|
973
1091
|
resolve: (payload: unknown) => void,
|
|
974
1092
|
reject: (error: unknown) => void,
|
|
975
1093
|
) => {
|
|
976
|
-
const
|
|
1094
|
+
const protocol = command.protocol;
|
|
1095
|
+
const ephemeral = isEphemeral(s, protocol);
|
|
1096
|
+
const seq = ephemeral
|
|
1097
|
+
? (s.ephemeralSeqs[protocol] ?? 0) + 1
|
|
1098
|
+
: Math.max(
|
|
1099
|
+
laneOf(s.record, protocol).nextSeq,
|
|
1100
|
+
durableSeqOf(s, protocol) + 1,
|
|
1101
|
+
);
|
|
977
1102
|
const entry: Pending = {
|
|
978
1103
|
seq,
|
|
979
1104
|
command,
|
|
980
1105
|
inv: {
|
|
981
|
-
protocol
|
|
1106
|
+
protocol,
|
|
982
1107
|
method: command.method,
|
|
983
1108
|
params,
|
|
984
1109
|
ts: Date.now(),
|
|
@@ -992,7 +1117,16 @@ const enqueueInner = (
|
|
|
992
1117
|
reject,
|
|
993
1118
|
};
|
|
994
1119
|
s.pending.push(entry);
|
|
995
|
-
|
|
1120
|
+
if (ephemeral) s.ephemeralSeqs[protocol] = seq;
|
|
1121
|
+
else
|
|
1122
|
+
setRecord(
|
|
1123
|
+
s,
|
|
1124
|
+
withLane(s.record, protocol, {
|
|
1125
|
+
...laneOf(s.record, protocol),
|
|
1126
|
+
nextSeq: seq + 1,
|
|
1127
|
+
commands: mergeBacklog(s, protocol),
|
|
1128
|
+
}),
|
|
1129
|
+
);
|
|
996
1130
|
s.lifecycle?.onStart(entry.inv);
|
|
997
1131
|
};
|
|
998
1132
|
|
|
@@ -1025,44 +1159,47 @@ const enqueue = (
|
|
|
1025
1159
|
}, reject);
|
|
1026
1160
|
};
|
|
1027
1161
|
|
|
1162
|
+
const offered = (s: Session, protocol: string) => {
|
|
1163
|
+
const offers = protocolOffers(s.options);
|
|
1164
|
+
return offers === "*" || offers.some((offer) => offer.name === protocol);
|
|
1165
|
+
};
|
|
1166
|
+
|
|
1028
1167
|
// A stored command may have reached a server: attempted settles it lost,
|
|
1029
1168
|
// never aborted, when a channel death ends the session.
|
|
1030
1169
|
const adoptStored = (s: Session) => {
|
|
1031
|
-
|
|
1032
|
-
const
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
const
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
command.meta === undefined
|
|
1043
|
-
? command.params
|
|
1044
|
-
: (reviveValue([...command.params], command.meta) as unknown[]);
|
|
1045
|
-
const entry: Pending = {
|
|
1046
|
-
seq,
|
|
1047
|
-
command,
|
|
1048
|
-
inv: {
|
|
1049
|
-
protocol: command.protocol,
|
|
1050
|
-
method: command.method,
|
|
1051
|
-
params,
|
|
1052
|
-
ts: Date.now(),
|
|
1170
|
+
let adopted = false;
|
|
1171
|
+
for (const [protocol, lane] of Object.entries(s.record.lanes)) {
|
|
1172
|
+
if (!offered(s, protocol)) continue;
|
|
1173
|
+
const known = new Set(laneEntries(s, protocol).map((p) => p.seq));
|
|
1174
|
+
for (const { seq, ...command } of lane.commands) {
|
|
1175
|
+
if (known.has(seq)) continue;
|
|
1176
|
+
const params =
|
|
1177
|
+
command.meta === undefined
|
|
1178
|
+
? command.params
|
|
1179
|
+
: (reviveValue([...command.params], command.meta) as unknown[]);
|
|
1180
|
+
const entry: Pending = {
|
|
1053
1181
|
seq,
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1182
|
+
command: { protocol, ...command },
|
|
1183
|
+
inv: {
|
|
1184
|
+
protocol,
|
|
1185
|
+
method: command.method,
|
|
1186
|
+
params,
|
|
1187
|
+
ts: Date.now(),
|
|
1188
|
+
seq,
|
|
1189
|
+
clientId: s.record.clientId,
|
|
1190
|
+
},
|
|
1191
|
+
attempted: true,
|
|
1192
|
+
posted: false,
|
|
1193
|
+
applied: false,
|
|
1194
|
+
resolve: () => {},
|
|
1195
|
+
reject: () => {},
|
|
1196
|
+
};
|
|
1197
|
+
s.pending.push(entry);
|
|
1198
|
+
s.lifecycle?.onStart(entry.inv);
|
|
1199
|
+
adopted = true;
|
|
1200
|
+
}
|
|
1064
1201
|
}
|
|
1065
|
-
s.pending.sort((a, b) => a.seq - b.seq);
|
|
1202
|
+
if (adopted) s.pending.sort((a, b) => a.seq - b.seq);
|
|
1066
1203
|
};
|
|
1067
1204
|
|
|
1068
1205
|
const sendCommand = (
|
|
@@ -1071,6 +1208,11 @@ const sendCommand = (
|
|
|
1071
1208
|
params: readonly unknown[],
|
|
1072
1209
|
protocol = s.options.protocol ?? DEFAULT_PROTOCOL,
|
|
1073
1210
|
): Promise<unknown> => {
|
|
1211
|
+
const negotiated = s.selection
|
|
1212
|
+
? s.selection.protocols.some((p) => p.name === protocol)
|
|
1213
|
+
: offered(s, protocol);
|
|
1214
|
+
if (!negotiated)
|
|
1215
|
+
throw new Error(`statewire: protocol "${protocol}" is not negotiated`);
|
|
1074
1216
|
if (!s.hydrated) {
|
|
1075
1217
|
const result = s.ready!.then(() =>
|
|
1076
1218
|
sendCommand(s, method, params, protocol),
|
|
@@ -1119,7 +1261,7 @@ const findFailed = (
|
|
|
1119
1261
|
s: Session,
|
|
1120
1262
|
entry: StatewireClient.FailedCommand,
|
|
1121
1263
|
): StatewireClient.FailedCommand | undefined =>
|
|
1122
|
-
(s.record.failed ?? []).find(
|
|
1264
|
+
(laneOf(s.record, entry.protocol).failed ?? []).find(
|
|
1123
1265
|
(f) =>
|
|
1124
1266
|
f.seq === entry.seq && f.ts === entry.ts && f.command === entry.command,
|
|
1125
1267
|
);
|
|
@@ -1131,10 +1273,16 @@ const takeFailed = (
|
|
|
1131
1273
|
s: Session,
|
|
1132
1274
|
entry: StatewireClient.FailedCommand,
|
|
1133
1275
|
): StatewireClient.FailedCommand => {
|
|
1134
|
-
const
|
|
1276
|
+
const lane = laneOf(s.record, entry.protocol);
|
|
1135
1277
|
const found = findFailed(s, entry);
|
|
1136
1278
|
if (found === undefined) throw unknownFailed(entry);
|
|
1137
|
-
setRecord(
|
|
1279
|
+
setRecord(
|
|
1280
|
+
s,
|
|
1281
|
+
withLane(s.record, entry.protocol, {
|
|
1282
|
+
...lane,
|
|
1283
|
+
failed: (lane.failed ?? []).filter((f) => f !== found),
|
|
1284
|
+
}),
|
|
1285
|
+
);
|
|
1138
1286
|
s.notify();
|
|
1139
1287
|
return found;
|
|
1140
1288
|
};
|
|
@@ -1216,83 +1364,6 @@ export const retryFailed = (
|
|
|
1216
1364
|
return result;
|
|
1217
1365
|
};
|
|
1218
1366
|
|
|
1219
|
-
const contextSnapshot = (documents: StatewireDocuments.State) =>
|
|
1220
|
-
[...documents.documents].map(([doc, d]) => {
|
|
1221
|
-
const encoded = encodeDocument(d.value);
|
|
1222
|
-
return {
|
|
1223
|
-
op: "mount",
|
|
1224
|
-
...(doc === 0 ? {} : { doc }),
|
|
1225
|
-
protocol: d.protocol,
|
|
1226
|
-
...(d.main ? { main: true } : {}),
|
|
1227
|
-
...(d.key === null ? {} : { key: d.key }),
|
|
1228
|
-
value: encoded.value,
|
|
1229
|
-
...(encoded.meta === undefined ? {} : { meta: encoded.meta }),
|
|
1230
|
-
};
|
|
1231
|
-
});
|
|
1232
|
-
|
|
1233
|
-
const changeContext = (
|
|
1234
|
-
s: Session,
|
|
1235
|
-
build: (documents: StatewireDocuments.State) => readonly unknown[],
|
|
1236
|
-
): void => {
|
|
1237
|
-
if (!s.running || isFinished(s)) return;
|
|
1238
|
-
const operations = build(s.contextDocuments);
|
|
1239
|
-
if (!operations.length)
|
|
1240
|
-
throw new Error("statewire: context operations must not be empty");
|
|
1241
|
-
const offers = protocolOffers(s.options);
|
|
1242
|
-
const protocols =
|
|
1243
|
-
s.selection?.protocols ?? (offers === "*" ? undefined : offers);
|
|
1244
|
-
s.contextDocuments = applyDocumentOps(
|
|
1245
|
-
s.contextDocuments,
|
|
1246
|
-
operations,
|
|
1247
|
-
protocols,
|
|
1248
|
-
true,
|
|
1249
|
-
);
|
|
1250
|
-
s.ctxPending = [
|
|
1251
|
-
...s.ctxPending,
|
|
1252
|
-
{
|
|
1253
|
-
seq: s.ctxNextSeq++,
|
|
1254
|
-
ops: operations.map((op) => {
|
|
1255
|
-
const value = op as Record<string, unknown>;
|
|
1256
|
-
if (value["doc"] !== 0) return value;
|
|
1257
|
-
const { doc: _doc, ...rest } = value;
|
|
1258
|
-
return rest;
|
|
1259
|
-
}),
|
|
1260
|
-
posted: false,
|
|
1261
|
-
},
|
|
1262
|
-
];
|
|
1263
|
-
wake(s);
|
|
1264
|
-
void pump(s);
|
|
1265
|
-
};
|
|
1266
|
-
|
|
1267
|
-
export const updateContext = (s: Session, operations: readonly unknown[]) =>
|
|
1268
|
-
changeContext(s, () => operations);
|
|
1269
|
-
|
|
1270
|
-
const setContext = (
|
|
1271
|
-
s: Session,
|
|
1272
|
-
value: Record<string, unknown>,
|
|
1273
|
-
protocol = s.options.protocol ?? DEFAULT_PROTOCOL,
|
|
1274
|
-
): void => {
|
|
1275
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
1276
|
-
throw new Error("statewire: context must be a plain object");
|
|
1277
|
-
const encoded = encodeDocument(value);
|
|
1278
|
-
changeContext(s, (documents) => {
|
|
1279
|
-
const found = [...documents.documents].find(
|
|
1280
|
-
([, d]) => d.protocol === protocol && d.main,
|
|
1281
|
-
);
|
|
1282
|
-
const doc = found?.[0] ?? Math.max(-1, ...documents.documents.keys()) + 1;
|
|
1283
|
-
return [
|
|
1284
|
-
{
|
|
1285
|
-
...(found === undefined
|
|
1286
|
-
? { op: "mount", protocol, main: true }
|
|
1287
|
-
: { op: "replace", path: [] }),
|
|
1288
|
-
...(doc === 0 ? {} : { doc }),
|
|
1289
|
-
value: encoded.value,
|
|
1290
|
-
...(encoded.meta === undefined ? {} : { meta: encoded.meta }),
|
|
1291
|
-
},
|
|
1292
|
-
];
|
|
1293
|
-
});
|
|
1294
|
-
};
|
|
1295
|
-
|
|
1296
1367
|
export const documentSource = (s: Session): StatewireDocuments.Source => ({
|
|
1297
1368
|
get: (protocol, key, prefix = s.options.keyPrefixes?.[protocol]) => {
|
|
1298
1369
|
const mounted = findDocument(s.documents, protocol, key, prefix);
|
|
@@ -1332,20 +1403,20 @@ export const invokeProtocol = (
|
|
|
1332
1403
|
) => sendCommand(s, method, params, protocol);
|
|
1333
1404
|
|
|
1334
1405
|
const storedCommandCompatible = (
|
|
1406
|
+
protocol: string,
|
|
1335
1407
|
command: StatewireClient.SessionCommand,
|
|
1336
1408
|
current: NonNullable<StatewireClient.SessionCommand["produced"]>,
|
|
1337
1409
|
) => {
|
|
1338
1410
|
if (
|
|
1339
1411
|
!command.produced ||
|
|
1340
|
-
!command.protocol ||
|
|
1341
1412
|
!inRange(command.produced.wire.version, current.wire)
|
|
1342
1413
|
)
|
|
1343
1414
|
return false;
|
|
1344
1415
|
const previous = command.produced.protocols;
|
|
1345
1416
|
if (current.protocols === "*") return true;
|
|
1346
1417
|
if (previous === "*") return false;
|
|
1347
|
-
const produced = previous.find((p) => p.name ===
|
|
1348
|
-
const offer = current.protocols.find((p) => p.name ===
|
|
1418
|
+
const produced = previous.find((p) => p.name === protocol);
|
|
1419
|
+
const offer = current.protocols.find((p) => p.name === protocol);
|
|
1349
1420
|
return (
|
|
1350
1421
|
produced !== undefined &&
|
|
1351
1422
|
offer !== undefined &&
|
|
@@ -1353,47 +1424,45 @@ const storedCommandCompatible = (
|
|
|
1353
1424
|
);
|
|
1354
1425
|
};
|
|
1355
1426
|
|
|
1427
|
+
const validateLanes = (record: StatewireClient.SessionRecord) => {
|
|
1428
|
+
if (
|
|
1429
|
+
typeof record.lanes !== "object" ||
|
|
1430
|
+
record.lanes === null ||
|
|
1431
|
+
Object.values(record.lanes).some((lane) => !Array.isArray(lane.commands))
|
|
1432
|
+
)
|
|
1433
|
+
throw new Error(
|
|
1434
|
+
"statewire: session storage loaded a record without command lanes",
|
|
1435
|
+
);
|
|
1436
|
+
};
|
|
1437
|
+
|
|
1356
1438
|
const loadSessionRecord = (
|
|
1357
1439
|
store: StatewireClient.SessionStorage,
|
|
1358
1440
|
options: StatewireSessionOptions,
|
|
1359
1441
|
) => {
|
|
1360
1442
|
let stored = store.load();
|
|
1361
|
-
if (stored !== null
|
|
1362
|
-
throw new Error(
|
|
1363
|
-
"statewire: session storage loaded a record without a commands array",
|
|
1364
|
-
);
|
|
1443
|
+
if (stored !== null) validateLanes(stored);
|
|
1365
1444
|
const current = {
|
|
1366
1445
|
wire: options.wireVersion ?? { version: WIRE_VERSION },
|
|
1367
1446
|
protocols: protocolOffers(options),
|
|
1368
1447
|
};
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1448
|
+
const compatible = (record: StatewireClient.SessionRecord) =>
|
|
1449
|
+
Object.entries(record.lanes).every(([protocol, lane]) =>
|
|
1450
|
+
lane.commands.every((command) =>
|
|
1451
|
+
storedCommandCompatible(protocol, command, current),
|
|
1452
|
+
),
|
|
1453
|
+
);
|
|
1454
|
+
if (stored !== null && !compatible(stored)) {
|
|
1375
1455
|
if (!store.migrate)
|
|
1376
1456
|
throw new Error("statewire: stored commands require a storage migration");
|
|
1377
1457
|
stored = store.migrate(stored, current);
|
|
1378
|
-
if (
|
|
1379
|
-
stored
|
|
1380
|
-
!stored
|
|
1381
|
-
|
|
1382
|
-
)
|
|
1383
|
-
|
|
1384
|
-
throw new Error("statewire: migrated commands are incompatible");
|
|
1385
|
-
if (stored !== null) store.save(stored);
|
|
1386
|
-
}
|
|
1387
|
-
if (stored !== null && !Array.isArray(stored.commands)) {
|
|
1388
|
-
throw new Error(
|
|
1389
|
-
"statewire: session storage loaded a record without a commands array",
|
|
1390
|
-
);
|
|
1458
|
+
if (stored !== null) {
|
|
1459
|
+
validateLanes(stored);
|
|
1460
|
+
if (!compatible(stored))
|
|
1461
|
+
throw new Error("statewire: migrated commands are incompatible");
|
|
1462
|
+
store.save(stored);
|
|
1463
|
+
}
|
|
1391
1464
|
}
|
|
1392
|
-
const record = stored ?? {
|
|
1393
|
-
clientId: newClientId(),
|
|
1394
|
-
nextSeq: 1,
|
|
1395
|
-
commands: [],
|
|
1396
|
-
};
|
|
1465
|
+
const record = stored ?? { clientId: newClientId(), lanes: {} };
|
|
1397
1466
|
if (stored === null) store.save(record);
|
|
1398
1467
|
return record;
|
|
1399
1468
|
};
|
|
@@ -1419,11 +1488,10 @@ export const useStatewireSession = (
|
|
|
1419
1488
|
const record =
|
|
1420
1489
|
store.ready === undefined
|
|
1421
1490
|
? loadSessionRecord(store, options)
|
|
1422
|
-
: { clientId: newClientId(),
|
|
1491
|
+
: { clientId: newClientId(), lanes: {} };
|
|
1423
1492
|
const session: Session = {
|
|
1424
1493
|
options,
|
|
1425
1494
|
selection: undefined,
|
|
1426
|
-
contextDocuments: { mode: "mounted", documents: new Map() },
|
|
1427
1495
|
record,
|
|
1428
1496
|
ctx: {
|
|
1429
1497
|
beforeAttach: () => persistBeforeSend(session),
|
|
@@ -1453,17 +1521,18 @@ export const useStatewireSession = (
|
|
|
1453
1521
|
ephemeral: false,
|
|
1454
1522
|
deactivated: false,
|
|
1455
1523
|
sessionKnown: false,
|
|
1456
|
-
durableSeq:
|
|
1524
|
+
durableSeq: {},
|
|
1525
|
+
ephemeralSeqs: {},
|
|
1457
1526
|
resumeAt: 0,
|
|
1458
1527
|
idle: false,
|
|
1459
1528
|
onSessionLost: undefined,
|
|
1460
1529
|
onDone: undefined,
|
|
1461
|
-
ctxNextSeq: 1,
|
|
1462
|
-
ctxPending: [],
|
|
1463
1530
|
lanesOnAttach: false,
|
|
1464
|
-
|
|
1531
|
+
attachEntries: new Set(),
|
|
1465
1532
|
frameFailures: 0,
|
|
1466
1533
|
pending: [],
|
|
1534
|
+
failedList: [],
|
|
1535
|
+
failedParts: [],
|
|
1467
1536
|
canonical: undefined,
|
|
1468
1537
|
documentListeners: new Set(),
|
|
1469
1538
|
documents: { mode: null, documents: new Map() },
|
|
@@ -1542,4 +1611,4 @@ export const useStatewireSession = (
|
|
|
1542
1611
|
return s;
|
|
1543
1612
|
};
|
|
1544
1613
|
|
|
1545
|
-
export {
|
|
1614
|
+
export { setTransport };
|