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/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
|
-
ctxOps: unknown[] | undefined;
|
|
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) => {
|
|
@@ -244,20 +299,6 @@ const transact = (s: Session, fn: () => void): Promise<void> => {
|
|
|
244
299
|
adoptReset(s, stored);
|
|
245
300
|
} else if (stored !== null) {
|
|
246
301
|
s.record = stored;
|
|
247
|
-
if (stored.context !== undefined) {
|
|
248
|
-
const posted = new Set(
|
|
249
|
-
s.ctxPending.filter((p) => p.posted).map((p) => p.seq),
|
|
250
|
-
);
|
|
251
|
-
s.ctxOps = stored.context.ops;
|
|
252
|
-
s.contextDocuments = applyDocumentOps(
|
|
253
|
-
{ mode: "mounted", documents: new Map() },
|
|
254
|
-
stored.context.ops,
|
|
255
|
-
);
|
|
256
|
-
s.ctxPending = stored.context.pending.map((p) => ({
|
|
257
|
-
...p,
|
|
258
|
-
posted: posted.has(p.seq),
|
|
259
|
-
}));
|
|
260
|
-
}
|
|
261
302
|
}
|
|
262
303
|
fn();
|
|
263
304
|
const persistence = s.store.flush?.();
|
|
@@ -293,35 +334,49 @@ const cover = (s: Session, entry: Pending) => {
|
|
|
293
334
|
s.lifecycle?.onCovered?.(entry.inv);
|
|
294
335
|
};
|
|
295
336
|
|
|
296
|
-
const NO_FAILED: readonly StatewireClient.FailedCommand[] = [];
|
|
297
|
-
|
|
298
337
|
export const failedCommands = (
|
|
299
338
|
s: Session,
|
|
300
|
-
): 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
|
+
};
|
|
301
352
|
|
|
302
353
|
const retainFailedInner = (
|
|
303
354
|
s: Session,
|
|
304
355
|
entry: Pending,
|
|
305
356
|
failure: StatewireClient.SendFailure,
|
|
306
357
|
) => {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
...(
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
+
);
|
|
325
380
|
s.notify();
|
|
326
381
|
};
|
|
327
382
|
|
|
@@ -357,8 +412,9 @@ const settleEntry = (
|
|
|
357
412
|
const rides =
|
|
358
413
|
(outcome.failure.cause === "unmount" ||
|
|
359
414
|
outcome.failure.cause === "stream-gone") &&
|
|
360
|
-
entry.seq > s.
|
|
361
|
-
if (!rides
|
|
415
|
+
entry.seq > durableSeqOf(s, entry.command.protocol);
|
|
416
|
+
if (!rides && !isEphemeral(s, entry.command.protocol))
|
|
417
|
+
retainFailed(s, entry, outcome.failure);
|
|
362
418
|
entry.reject(new StatewireSendError(outcome.failure));
|
|
363
419
|
s.lifecycle?.onSettle(entry.inv, {
|
|
364
420
|
type: "rejected",
|
|
@@ -374,7 +430,10 @@ const settleAll = (s: Session, detach: "unmount" | "gone") => {
|
|
|
374
430
|
const detachFailure = (entry: Pending): StatewireClient.SendFailure => {
|
|
375
431
|
if (!entry.attempted) {
|
|
376
432
|
const predecessorLost = batch.some(
|
|
377
|
-
(p) =>
|
|
433
|
+
(p) =>
|
|
434
|
+
p.command.protocol === entry.command.protocol &&
|
|
435
|
+
p.seq < entry.seq &&
|
|
436
|
+
p.attempted,
|
|
378
437
|
);
|
|
379
438
|
return predecessorLost
|
|
380
439
|
? { fate: "aborted", cause, predecessorLost }
|
|
@@ -388,14 +447,6 @@ const settleAll = (s: Session, detach: "unmount" | "gone") => {
|
|
|
388
447
|
}
|
|
389
448
|
};
|
|
390
449
|
|
|
391
|
-
const restateContext = (s: Session) => {
|
|
392
|
-
s.ctxPending = [];
|
|
393
|
-
if (s.ctxOps === undefined) return;
|
|
394
|
-
const seq = s.record.ctxNextSeq;
|
|
395
|
-
setRecord(s, { ...s.record, ctxNextSeq: seq + 1 });
|
|
396
|
-
s.ctxPending.push({ seq, ops: s.ctxOps, posted: false });
|
|
397
|
-
};
|
|
398
|
-
|
|
399
450
|
const settleLost = (s: Session, entries: Pending[]) => {
|
|
400
451
|
for (const entry of entries) {
|
|
401
452
|
s.lifecycle?.onCovered?.(entry.inv);
|
|
@@ -406,6 +457,13 @@ const settleLost = (s: Session, entries: Pending[]) => {
|
|
|
406
457
|
}
|
|
407
458
|
};
|
|
408
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
|
+
|
|
409
467
|
// The server-side record's dedup memory is gone: history that may have
|
|
410
468
|
// reached a server settles lost — the new hello's snapshot is the only
|
|
411
469
|
// truth about what survived — and never-attempted statements re-stamp
|
|
@@ -418,30 +476,33 @@ const rebaseRecord = (s: Session, adopt: () => void) => {
|
|
|
418
476
|
held.filter((p) => p.attempted || p.applied),
|
|
419
477
|
);
|
|
420
478
|
s.pending = [];
|
|
421
|
-
s.durableSeq =
|
|
479
|
+
s.durableSeq = {};
|
|
422
480
|
// Failed entries are user-owned history: they ride through every record replacement.
|
|
423
|
-
const
|
|
481
|
+
const previous = s.record.lanes;
|
|
424
482
|
adopt();
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
+
};
|
|
434
496
|
}
|
|
435
|
-
|
|
497
|
+
if (lanes !== s.record.lanes) setRecord(s, { ...s.record, lanes });
|
|
498
|
+
requeue(s, survivors);
|
|
436
499
|
publish(s);
|
|
437
500
|
wake(s);
|
|
438
501
|
void pump(s);
|
|
439
502
|
};
|
|
440
503
|
|
|
441
504
|
const resetRecord = (s: Session, clientId: string) =>
|
|
442
|
-
rebaseRecord(s, () =>
|
|
443
|
-
setRecord(s, { clientId, nextSeq: 1, ctxNextSeq: 1, commands: [] }),
|
|
444
|
-
);
|
|
505
|
+
rebaseRecord(s, () => setRecord(s, { clientId, lanes: {} }));
|
|
445
506
|
|
|
446
507
|
// Another sharer replaced the record's identity: their reset settles our
|
|
447
508
|
// attempted history and our survivors re-stamp on the stored clocks.
|
|
@@ -457,6 +518,23 @@ const ephemeralDetach = (s: Session) => {
|
|
|
457
518
|
resetRecord(s, newClientId());
|
|
458
519
|
};
|
|
459
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
|
+
|
|
460
538
|
const rejectedFailure = (cmd: StatewireCmd): StatewireClient.SendFailure => {
|
|
461
539
|
return {
|
|
462
540
|
fate: "rejected",
|
|
@@ -495,58 +573,76 @@ const outcomeOf = (
|
|
|
495
573
|
}
|
|
496
574
|
};
|
|
497
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
|
+
|
|
498
584
|
const TERMINAL_CMD_TYPES: ReadonlySet<string> = new Set(TERMINAL);
|
|
499
585
|
|
|
500
586
|
const parkVerdict = (s: Session, cmd: StatewireCmd) => {
|
|
501
587
|
if (!s.shared || !TERMINAL_CMD_TYPES.has(cmd.type!)) return;
|
|
588
|
+
if (isEphemeral(s, cmd.protocol)) return;
|
|
502
589
|
s.shared.parked.push({
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
+
},
|
|
509
599
|
});
|
|
510
600
|
};
|
|
511
601
|
|
|
512
602
|
const handleCmd = (s: Session, cmd: StatewireCmd) => {
|
|
513
|
-
if (
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
for (const entry of s.pending) {
|
|
519
|
-
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;
|
|
520
608
|
}
|
|
521
609
|
}
|
|
522
610
|
if (cmd.type === undefined) return;
|
|
523
|
-
const entry = s.pending.find(
|
|
611
|
+
const entry = s.pending.find(
|
|
612
|
+
(p) => p.command.protocol === cmd.protocol && p.seq === cmd.seq,
|
|
613
|
+
);
|
|
524
614
|
if (cmd.type === "applied") {
|
|
525
615
|
if (entry) cover(s, entry);
|
|
526
616
|
return;
|
|
527
617
|
}
|
|
528
618
|
if (!entry) return;
|
|
529
619
|
parkVerdict(s, cmd);
|
|
530
|
-
|
|
531
|
-
settleEntry(s, entry, outcomeOf(cmd));
|
|
620
|
+
settleVerdict(s, entry, cmd);
|
|
532
621
|
};
|
|
533
622
|
|
|
534
623
|
const consumeVerdicts = (s: Session) => {
|
|
535
|
-
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
+
};
|
|
543
643
|
}
|
|
544
|
-
if (
|
|
545
|
-
setRecord(s, {
|
|
546
|
-
...s.record,
|
|
547
|
-
commands: s.record.commands.filter((c) => !consumed.has(c.seq)),
|
|
548
|
-
verdicts: verdicts.filter((v) => !consumed.has(v.seq)),
|
|
549
|
-
});
|
|
644
|
+
if (lanes === s.record.lanes) return;
|
|
645
|
+
setRecord(s, { ...s.record, lanes });
|
|
550
646
|
publish(s);
|
|
551
647
|
wake(s);
|
|
552
648
|
};
|
|
@@ -605,66 +701,23 @@ const publishIngested = (s: Session) => {
|
|
|
605
701
|
wake(s);
|
|
606
702
|
};
|
|
607
703
|
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
// absorption where the record survived, a gap refusal where it did not.
|
|
611
|
-
const snapCtxClock = (s: Session, ctxWm: number) => {
|
|
612
|
-
if (s.record.context?.recover === "version-1") {
|
|
613
|
-
const document = s.contextDocuments.documents.get(0);
|
|
614
|
-
if (s.contextDocuments.documents.size !== 1 || !document?.main)
|
|
615
|
-
throw new Error(
|
|
616
|
-
"statewire: version-1 context recovery requires one main document at doc 0",
|
|
617
|
-
);
|
|
618
|
-
const encoded = encodeDocument(document.value);
|
|
619
|
-
const ops = [
|
|
620
|
-
{
|
|
621
|
-
...(ctxWm === 0
|
|
622
|
-
? { op: "mount", protocol: document.protocol, main: true }
|
|
623
|
-
: { op: "replace", path: [] }),
|
|
624
|
-
...encoded,
|
|
625
|
-
},
|
|
626
|
-
];
|
|
627
|
-
s.ctxPending = [{ seq: ctxWm + 1, ops, posted: false }];
|
|
628
|
-
setRecord(s, {
|
|
629
|
-
...s.record,
|
|
630
|
-
ctxNextSeq: ctxWm + 2,
|
|
631
|
-
context: {
|
|
632
|
-
ops: contextSnapshot(s.contextDocuments),
|
|
633
|
-
pending: s.ctxPending,
|
|
634
|
-
},
|
|
635
|
-
});
|
|
636
|
-
return;
|
|
637
|
-
}
|
|
638
|
-
const ctxAligned =
|
|
639
|
-
s.ctxPending.every((c, i) => c.seq === ctxWm + 1 + i) &&
|
|
640
|
-
s.record.ctxNextSeq === ctxWm + s.ctxPending.length + 1;
|
|
641
|
-
if (!ctxAligned) {
|
|
642
|
-
if (ctxWm > 0)
|
|
643
|
-
throw new Error(
|
|
644
|
-
"statewire: context history is missing; use a new client id",
|
|
645
|
-
);
|
|
646
|
-
setRecord(s, { ...s.record, ctxNextSeq: ctxWm + 1 });
|
|
647
|
-
restateContext(s);
|
|
648
|
-
}
|
|
649
|
-
};
|
|
650
|
-
|
|
651
|
-
// The refusal's watermarks are exact: every held statement above them was
|
|
652
|
-
// never admitted by the live record, and the first unposted seq being beyond
|
|
704
|
+
// The refusal's watermark is exact: every held statement above it was never
|
|
705
|
+
// admitted by the live record, and the first unposted seq being beyond
|
|
653
706
|
// watermark+1 proves the record that admitted the missing prefix is gone —
|
|
654
|
-
// its history settles lost and the lane
|
|
655
|
-
const sessionLostInner = (s: Session,
|
|
656
|
-
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);
|
|
657
710
|
settleLost(s, held);
|
|
658
|
-
s.
|
|
659
|
-
s.
|
|
660
|
-
setRecord(
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
+
);
|
|
668
721
|
if (
|
|
669
722
|
held.length > 0 &&
|
|
670
723
|
s.onSessionLost?.(held.map((p) => p.inv)) === "requeue"
|
|
@@ -683,40 +736,51 @@ const sessionLostInner = (s: Session, seqWm: number, ctxWm: number) => {
|
|
|
683
736
|
wake(s);
|
|
684
737
|
};
|
|
685
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
|
+
|
|
686
754
|
const handleHello = (
|
|
687
755
|
s: Session,
|
|
688
756
|
{ ops, syn }: { ops: unknown[]; syn: StatewireSyn },
|
|
689
757
|
) => {
|
|
690
|
-
|
|
691
|
-
protocolError(`unknown record: ${String(syn.record)}`);
|
|
692
|
-
}
|
|
693
|
-
s.selection =
|
|
694
|
-
syn.version === undefined || syn.protocols === undefined
|
|
695
|
-
? undefined
|
|
696
|
-
: { version: syn.version, protocols: syn.protocols };
|
|
758
|
+
s.selection = selectionOf(syn);
|
|
697
759
|
s.documents = { mode: null, documents: new Map() };
|
|
698
760
|
ingestOps(s, ops);
|
|
699
761
|
s.attach = "attached";
|
|
700
762
|
s.attachEpoch += 1;
|
|
701
763
|
s.frameFailures = 0;
|
|
702
764
|
s.resumeAt = 0;
|
|
703
|
-
|
|
765
|
+
const lanes = syn.protocols ?? [];
|
|
766
|
+
s.ephemeral =
|
|
767
|
+
lanes.length > 0 && lanes.every((lane) => lane.record === "ephemeral");
|
|
704
768
|
if (s.ephemeral) s.deactivated = true;
|
|
705
|
-
const
|
|
706
|
-
s.
|
|
769
|
+
const attached = s.attachEntries;
|
|
770
|
+
s.attachEntries = new Set();
|
|
707
771
|
const reconcile = () => {
|
|
708
772
|
if (s.shared) adoptStored(s);
|
|
709
773
|
// Resending unsettled statements is exactly-once-safe: absorbed replays
|
|
710
774
|
// re-emit their stored verdicts (applied while still in flight).
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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;
|
|
714
782
|
}
|
|
715
|
-
const
|
|
716
|
-
s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
|
|
717
|
-
for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
|
|
718
|
-
snapCtxClock(s, ctxWatermark);
|
|
719
|
-
if (s.ctxOps !== undefined) persistContext(s);
|
|
783
|
+
for (const entry of s.pending) entry.posted = attached.has(entry);
|
|
720
784
|
syncBacklogInner(s);
|
|
721
785
|
s.sessionKnown = true;
|
|
722
786
|
};
|
|
@@ -742,42 +806,42 @@ const handleOps = (
|
|
|
742
806
|
publishIngested(s);
|
|
743
807
|
};
|
|
744
808
|
|
|
745
|
-
// A retry-less {syn} refusal is the one session-loss verdict: a
|
|
746
|
-
//
|
|
747
|
-
// (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.
|
|
748
812
|
const handleRefusal = (s: Session, syn: StatewireSyn) => {
|
|
749
|
-
const
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
for (const
|
|
757
|
-
|
|
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;
|
|
758
822
|
}
|
|
759
823
|
if (typeof syn.retry === "number") {
|
|
760
|
-
s.resumeAt = Date.now() + syn.retry;
|
|
824
|
+
s.resumeAt = Date.now() + retryHint(syn.retry);
|
|
761
825
|
wake(s);
|
|
762
826
|
void pump(s);
|
|
763
827
|
return;
|
|
764
828
|
}
|
|
765
|
-
const
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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) {
|
|
776
841
|
transactBackground(
|
|
777
842
|
s,
|
|
778
843
|
() => {
|
|
779
|
-
|
|
780
|
-
else snapCtxClock(s, ctxSeq!);
|
|
844
|
+
for (const { name, seq } of gaps) sessionLostInner(s, name, seq);
|
|
781
845
|
},
|
|
782
846
|
() => {
|
|
783
847
|
wake(s);
|
|
@@ -792,7 +856,9 @@ const handleRefusal = (s: Session, syn: StatewireSyn) => {
|
|
|
792
856
|
|
|
793
857
|
const handleDetach = (s: Session) => {
|
|
794
858
|
if (s.attach === "attached") s.attach = "detached";
|
|
859
|
+
s.ephemeralSeqs = {};
|
|
795
860
|
if (s.ephemeral) ephemeralDetach(s);
|
|
861
|
+
else detachEphemeralLanes(s);
|
|
796
862
|
wake(s);
|
|
797
863
|
};
|
|
798
864
|
|
|
@@ -808,72 +874,56 @@ const isFinished = (s: Session) => s.attach === "finished";
|
|
|
808
874
|
|
|
809
875
|
type Lanes = {
|
|
810
876
|
frame: StatewireFrame;
|
|
811
|
-
|
|
812
|
-
ctxEntries: CtxPending[];
|
|
877
|
+
entries: Pending[];
|
|
813
878
|
};
|
|
814
879
|
|
|
815
880
|
// A lane must be strictly consecutive within a frame: the first unposted run
|
|
816
|
-
// per lane rides now, later runs ride follow-up frames.
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
)
|
|
826
|
-
break;
|
|
827
|
-
if (ctxEntries.length >= MAX_COMMANDS_PER_FRAME) break;
|
|
828
|
-
if (ctxEntries.length > 0 && entry.seq !== ctxEntries.at(-1)!.seq + 1)
|
|
829
|
-
break;
|
|
830
|
-
ctxEntries.push(entry);
|
|
831
|
-
}
|
|
832
|
-
const cmdEntries: Pending[] = [];
|
|
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.
|
|
883
|
+
const buildLanes = (
|
|
884
|
+
s: Session,
|
|
885
|
+
cmdGate: "persisted" | "all" | "none" = "all",
|
|
886
|
+
): Lanes | null => {
|
|
887
|
+
const entries: Pending[] = [];
|
|
888
|
+
const last: Record<string, number> = {};
|
|
889
|
+
const stopped = new Set<string>();
|
|
833
890
|
for (const entry of s.pending) {
|
|
834
|
-
if (
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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);
|
|
845
906
|
}
|
|
846
|
-
if (
|
|
907
|
+
if (entries.length === 0) return null;
|
|
847
908
|
return {
|
|
848
909
|
frame: {
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
...(cmdEntries.length > 0 && {
|
|
853
|
-
cmd: cmdEntries.map((p) => {
|
|
854
|
-
const { produced: _produced, ...command } = p.command;
|
|
855
|
-
return { ...command, seq: p.seq };
|
|
856
|
-
}),
|
|
910
|
+
cmd: entries.map((p) => {
|
|
911
|
+
const { produced: _produced, ...command } = p.command;
|
|
912
|
+
return { ...command, seq: p.seq };
|
|
857
913
|
}),
|
|
858
914
|
},
|
|
859
|
-
|
|
860
|
-
ctxEntries,
|
|
915
|
+
entries,
|
|
861
916
|
};
|
|
862
917
|
};
|
|
863
918
|
|
|
864
919
|
const takeAttachLanes = (s: Session): StatewireFrame | null => {
|
|
865
|
-
s.
|
|
920
|
+
s.attachEntries = new Set();
|
|
866
921
|
const wanted = s.lanesOnAttach;
|
|
867
922
|
s.lanesOnAttach = false;
|
|
868
|
-
|
|
869
|
-
return null;
|
|
870
|
-
const lanes = buildLanes(s, true);
|
|
923
|
+
const lanes = buildLanes(s, wanted && s.sessionKnown ? "persisted" : "none");
|
|
871
924
|
if (!lanes) return null;
|
|
872
|
-
s.
|
|
873
|
-
|
|
874
|
-
new Set(lanes.ctxEntries.map((c) => c.seq)),
|
|
875
|
-
];
|
|
876
|
-
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;
|
|
877
927
|
return lanes.frame;
|
|
878
928
|
};
|
|
879
929
|
|
|
@@ -929,7 +979,7 @@ const pump = async (s: Session) => {
|
|
|
929
979
|
if (persistence) await persistence;
|
|
930
980
|
if (!s.running || isFinished(s)) return;
|
|
931
981
|
if (epoch !== s.attachEpoch || s.attach !== "attached") continue;
|
|
932
|
-
for (const entry of lanes.
|
|
982
|
+
for (const entry of lanes.entries) entry.attempted = true;
|
|
933
983
|
let result;
|
|
934
984
|
try {
|
|
935
985
|
// Shared storage: the lock across the send keeps per-lane arrival
|
|
@@ -948,13 +998,12 @@ const pump = async (s: Session) => {
|
|
|
948
998
|
s.frameFailures = 0;
|
|
949
999
|
if (result.type === "ok") {
|
|
950
1000
|
if (epoch === s.attachEpoch) {
|
|
951
|
-
for (const entry of lanes.
|
|
1001
|
+
for (const entry of lanes.entries) {
|
|
952
1002
|
if (s.pending.includes(entry)) {
|
|
953
1003
|
entry.posted = true;
|
|
954
1004
|
s.lifecycle?.onPosted?.(entry.inv);
|
|
955
1005
|
}
|
|
956
1006
|
}
|
|
957
|
-
for (const entry of lanes.ctxEntries) entry.posted = true;
|
|
958
1007
|
}
|
|
959
1008
|
} else if (result.type === "refusal") {
|
|
960
1009
|
if (epoch === s.attachEpoch) handleRefusal(s, result.syn);
|
|
@@ -1042,12 +1091,19 @@ const enqueueInner = (
|
|
|
1042
1091
|
resolve: (payload: unknown) => void,
|
|
1043
1092
|
reject: (error: unknown) => void,
|
|
1044
1093
|
) => {
|
|
1045
|
-
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
|
+
);
|
|
1046
1102
|
const entry: Pending = {
|
|
1047
1103
|
seq,
|
|
1048
1104
|
command,
|
|
1049
1105
|
inv: {
|
|
1050
|
-
protocol
|
|
1106
|
+
protocol,
|
|
1051
1107
|
method: command.method,
|
|
1052
1108
|
params,
|
|
1053
1109
|
ts: Date.now(),
|
|
@@ -1061,7 +1117,16 @@ const enqueueInner = (
|
|
|
1061
1117
|
reject,
|
|
1062
1118
|
};
|
|
1063
1119
|
s.pending.push(entry);
|
|
1064
|
-
|
|
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
|
+
);
|
|
1065
1130
|
s.lifecycle?.onStart(entry.inv);
|
|
1066
1131
|
};
|
|
1067
1132
|
|
|
@@ -1094,44 +1159,47 @@ const enqueue = (
|
|
|
1094
1159
|
}, reject);
|
|
1095
1160
|
};
|
|
1096
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
|
+
|
|
1097
1167
|
// A stored command may have reached a server: attempted settles it lost,
|
|
1098
1168
|
// never aborted, when a channel death ends the session.
|
|
1099
1169
|
const adoptStored = (s: Session) => {
|
|
1100
|
-
|
|
1101
|
-
const
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
const
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
command.meta === undefined
|
|
1112
|
-
? command.params
|
|
1113
|
-
: (reviveValue([...command.params], command.meta) as unknown[]);
|
|
1114
|
-
const entry: Pending = {
|
|
1115
|
-
seq,
|
|
1116
|
-
command,
|
|
1117
|
-
inv: {
|
|
1118
|
-
protocol: command.protocol,
|
|
1119
|
-
method: command.method,
|
|
1120
|
-
params,
|
|
1121
|
-
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 = {
|
|
1122
1181
|
seq,
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
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
|
+
}
|
|
1133
1201
|
}
|
|
1134
|
-
s.pending.sort((a, b) => a.seq - b.seq);
|
|
1202
|
+
if (adopted) s.pending.sort((a, b) => a.seq - b.seq);
|
|
1135
1203
|
};
|
|
1136
1204
|
|
|
1137
1205
|
const sendCommand = (
|
|
@@ -1140,6 +1208,11 @@ const sendCommand = (
|
|
|
1140
1208
|
params: readonly unknown[],
|
|
1141
1209
|
protocol = s.options.protocol ?? DEFAULT_PROTOCOL,
|
|
1142
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`);
|
|
1143
1216
|
if (!s.hydrated) {
|
|
1144
1217
|
const result = s.ready!.then(() =>
|
|
1145
1218
|
sendCommand(s, method, params, protocol),
|
|
@@ -1188,7 +1261,7 @@ const findFailed = (
|
|
|
1188
1261
|
s: Session,
|
|
1189
1262
|
entry: StatewireClient.FailedCommand,
|
|
1190
1263
|
): StatewireClient.FailedCommand | undefined =>
|
|
1191
|
-
(s.record.failed ?? []).find(
|
|
1264
|
+
(laneOf(s.record, entry.protocol).failed ?? []).find(
|
|
1192
1265
|
(f) =>
|
|
1193
1266
|
f.seq === entry.seq && f.ts === entry.ts && f.command === entry.command,
|
|
1194
1267
|
);
|
|
@@ -1200,10 +1273,16 @@ const takeFailed = (
|
|
|
1200
1273
|
s: Session,
|
|
1201
1274
|
entry: StatewireClient.FailedCommand,
|
|
1202
1275
|
): StatewireClient.FailedCommand => {
|
|
1203
|
-
const
|
|
1276
|
+
const lane = laneOf(s.record, entry.protocol);
|
|
1204
1277
|
const found = findFailed(s, entry);
|
|
1205
1278
|
if (found === undefined) throw unknownFailed(entry);
|
|
1206
|
-
setRecord(
|
|
1279
|
+
setRecord(
|
|
1280
|
+
s,
|
|
1281
|
+
withLane(s.record, entry.protocol, {
|
|
1282
|
+
...lane,
|
|
1283
|
+
failed: (lane.failed ?? []).filter((f) => f !== found),
|
|
1284
|
+
}),
|
|
1285
|
+
);
|
|
1207
1286
|
s.notify();
|
|
1208
1287
|
return found;
|
|
1209
1288
|
};
|
|
@@ -1285,128 +1364,6 @@ export const retryFailed = (
|
|
|
1285
1364
|
return result;
|
|
1286
1365
|
};
|
|
1287
1366
|
|
|
1288
|
-
const contextSnapshot = (documents: StatewireDocuments.State) =>
|
|
1289
|
-
[...documents.documents].map(([doc, d]) => {
|
|
1290
|
-
const encoded = encodeDocument(d.value);
|
|
1291
|
-
return {
|
|
1292
|
-
op: "mount",
|
|
1293
|
-
...(doc === 0 ? {} : { doc }),
|
|
1294
|
-
protocol: d.protocol,
|
|
1295
|
-
...(d.main ? { main: true } : {}),
|
|
1296
|
-
...(d.key === null ? {} : { key: d.key }),
|
|
1297
|
-
value: encoded.value,
|
|
1298
|
-
...(encoded.meta === undefined ? {} : { meta: encoded.meta }),
|
|
1299
|
-
};
|
|
1300
|
-
});
|
|
1301
|
-
|
|
1302
|
-
const persistContext = (s: Session) => {
|
|
1303
|
-
const ops = contextSnapshot(s.contextDocuments);
|
|
1304
|
-
s.ctxOps = ops;
|
|
1305
|
-
setRecord(s, {
|
|
1306
|
-
...s.record,
|
|
1307
|
-
context: {
|
|
1308
|
-
ops,
|
|
1309
|
-
...(s.record.context?.recover === undefined
|
|
1310
|
-
? {}
|
|
1311
|
-
: { recover: s.record.context.recover }),
|
|
1312
|
-
pending: s.ctxPending.map(({ seq, ops }) => ({ seq, ops })),
|
|
1313
|
-
},
|
|
1314
|
-
});
|
|
1315
|
-
};
|
|
1316
|
-
|
|
1317
|
-
const changeContext = (
|
|
1318
|
-
s: Session,
|
|
1319
|
-
build: (documents: StatewireDocuments.State) => readonly unknown[],
|
|
1320
|
-
): void => {
|
|
1321
|
-
if (!s.hydrated) {
|
|
1322
|
-
void s
|
|
1323
|
-
.ready!.then(() => changeContext(s, build))
|
|
1324
|
-
.catch((cause: unknown) => {
|
|
1325
|
-
const error = wrapStorageError(cause);
|
|
1326
|
-
s.storageError = error;
|
|
1327
|
-
s.transport?.storageFailed(error);
|
|
1328
|
-
});
|
|
1329
|
-
return;
|
|
1330
|
-
}
|
|
1331
|
-
if (!s.running || isFinished(s)) return;
|
|
1332
|
-
const submit = () => {
|
|
1333
|
-
if (!s.running || isFinished(s)) return;
|
|
1334
|
-
const previous =
|
|
1335
|
-
s.record.context === undefined
|
|
1336
|
-
? s.contextDocuments
|
|
1337
|
-
: applyDocumentOps(
|
|
1338
|
-
{ mode: "mounted", documents: new Map() },
|
|
1339
|
-
s.record.context.ops,
|
|
1340
|
-
);
|
|
1341
|
-
const operations = build(previous);
|
|
1342
|
-
if (!operations.length)
|
|
1343
|
-
throw new Error("statewire: context operations must not be empty");
|
|
1344
|
-
const offers = protocolOffers(s.options);
|
|
1345
|
-
const protocols =
|
|
1346
|
-
s.selection?.protocols ?? (offers === "*" ? undefined : offers);
|
|
1347
|
-
s.contextDocuments = applyDocumentOps(
|
|
1348
|
-
previous,
|
|
1349
|
-
operations,
|
|
1350
|
-
protocols,
|
|
1351
|
-
true,
|
|
1352
|
-
);
|
|
1353
|
-
const seq = s.record.ctxNextSeq;
|
|
1354
|
-
s.ctxPending = [
|
|
1355
|
-
...s.ctxPending,
|
|
1356
|
-
{
|
|
1357
|
-
seq,
|
|
1358
|
-
ops: operations.map((op) => {
|
|
1359
|
-
const value = op as Record<string, unknown>;
|
|
1360
|
-
if (value["doc"] !== 0) return value;
|
|
1361
|
-
const { doc: _doc, ...rest } = value;
|
|
1362
|
-
return rest;
|
|
1363
|
-
}),
|
|
1364
|
-
posted: false,
|
|
1365
|
-
},
|
|
1366
|
-
];
|
|
1367
|
-
s.record = { ...s.record, ctxNextSeq: seq + 1 };
|
|
1368
|
-
persistContext(s);
|
|
1369
|
-
};
|
|
1370
|
-
if (!s.shared) {
|
|
1371
|
-
submit();
|
|
1372
|
-
wake(s);
|
|
1373
|
-
void pump(s);
|
|
1374
|
-
} else
|
|
1375
|
-
transactBackground(s, submit, () => {
|
|
1376
|
-
wake(s);
|
|
1377
|
-
void pump(s);
|
|
1378
|
-
});
|
|
1379
|
-
};
|
|
1380
|
-
|
|
1381
|
-
export const updateContext = (s: Session, operations: readonly unknown[]) =>
|
|
1382
|
-
changeContext(s, () => operations);
|
|
1383
|
-
|
|
1384
|
-
const setContext = (
|
|
1385
|
-
s: Session,
|
|
1386
|
-
value: Record<string, unknown>,
|
|
1387
|
-
protocol = s.options.protocol ?? DEFAULT_PROTOCOL,
|
|
1388
|
-
): void => {
|
|
1389
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
1390
|
-
throw new Error("statewire: context must be a plain object");
|
|
1391
|
-
const encoded = encodeDocument(value);
|
|
1392
|
-
changeContext(s, (documents) => {
|
|
1393
|
-
const found = [...documents.documents].find(
|
|
1394
|
-
([, d]) => d.protocol === protocol && d.main,
|
|
1395
|
-
);
|
|
1396
|
-
const doc = found?.[0] ?? Math.max(-1, ...documents.documents.keys()) + 1;
|
|
1397
|
-
return [
|
|
1398
|
-
{
|
|
1399
|
-
...(found === undefined
|
|
1400
|
-
? { op: "mount", protocol, main: true }
|
|
1401
|
-
: { op: "replace", path: [] }),
|
|
1402
|
-
...(doc === 0 ? {} : { doc }),
|
|
1403
|
-
value: encoded.value,
|
|
1404
|
-
...(encoded.meta === undefined ? {} : { meta: encoded.meta }),
|
|
1405
|
-
},
|
|
1406
|
-
];
|
|
1407
|
-
});
|
|
1408
|
-
};
|
|
1409
|
-
|
|
1410
1367
|
export const documentSource = (s: Session): StatewireDocuments.Source => ({
|
|
1411
1368
|
get: (protocol, key, prefix = s.options.keyPrefixes?.[protocol]) => {
|
|
1412
1369
|
const mounted = findDocument(s.documents, protocol, key, prefix);
|
|
@@ -1446,20 +1403,20 @@ export const invokeProtocol = (
|
|
|
1446
1403
|
) => sendCommand(s, method, params, protocol);
|
|
1447
1404
|
|
|
1448
1405
|
const storedCommandCompatible = (
|
|
1406
|
+
protocol: string,
|
|
1449
1407
|
command: StatewireClient.SessionCommand,
|
|
1450
1408
|
current: NonNullable<StatewireClient.SessionCommand["produced"]>,
|
|
1451
1409
|
) => {
|
|
1452
1410
|
if (
|
|
1453
1411
|
!command.produced ||
|
|
1454
|
-
!command.protocol ||
|
|
1455
1412
|
!inRange(command.produced.wire.version, current.wire)
|
|
1456
1413
|
)
|
|
1457
1414
|
return false;
|
|
1458
1415
|
const previous = command.produced.protocols;
|
|
1459
1416
|
if (current.protocols === "*") return true;
|
|
1460
1417
|
if (previous === "*") return false;
|
|
1461
|
-
const produced = previous.find((p) => p.name ===
|
|
1462
|
-
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);
|
|
1463
1420
|
return (
|
|
1464
1421
|
produced !== undefined &&
|
|
1465
1422
|
offer !== undefined &&
|
|
@@ -1467,50 +1424,47 @@ const storedCommandCompatible = (
|
|
|
1467
1424
|
);
|
|
1468
1425
|
};
|
|
1469
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
|
+
|
|
1470
1438
|
const loadSessionRecord = (
|
|
1471
1439
|
store: StatewireClient.SessionStorage,
|
|
1472
1440
|
options: StatewireSessionOptions,
|
|
1473
1441
|
) => {
|
|
1474
1442
|
let stored = store.load();
|
|
1475
|
-
if (stored !== null
|
|
1476
|
-
throw new Error(
|
|
1477
|
-
"statewire: session storage loaded a record without a commands array",
|
|
1478
|
-
);
|
|
1443
|
+
if (stored !== null) validateLanes(stored);
|
|
1479
1444
|
const current = {
|
|
1480
1445
|
wire: options.wireVersion ?? { version: WIRE_VERSION },
|
|
1481
1446
|
protocols: protocolOffers(options),
|
|
1482
1447
|
};
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
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)) {
|
|
1489
1455
|
if (!store.migrate)
|
|
1490
1456
|
throw new Error("statewire: stored commands require a storage migration");
|
|
1491
1457
|
stored = store.migrate(stored, current);
|
|
1492
|
-
if (
|
|
1493
|
-
stored
|
|
1494
|
-
!stored
|
|
1495
|
-
|
|
1496
|
-
)
|
|
1497
|
-
|
|
1498
|
-
throw new Error("statewire: migrated commands are incompatible");
|
|
1499
|
-
if (stored !== null) store.save(stored);
|
|
1500
|
-
}
|
|
1501
|
-
if (stored !== null && !Array.isArray(stored.commands)) {
|
|
1502
|
-
throw new Error(
|
|
1503
|
-
"statewire: session storage loaded a record without a commands array",
|
|
1504
|
-
);
|
|
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
|
+
}
|
|
1505
1464
|
}
|
|
1506
|
-
const record = stored ?? {
|
|
1507
|
-
clientId: newClientId(),
|
|
1508
|
-
nextSeq: 1,
|
|
1509
|
-
ctxNextSeq: 1,
|
|
1510
|
-
commands: [],
|
|
1511
|
-
};
|
|
1465
|
+
const record = stored ?? { clientId: newClientId(), lanes: {} };
|
|
1512
1466
|
if (stored === null) store.save(record);
|
|
1513
|
-
return
|
|
1467
|
+
return record;
|
|
1514
1468
|
};
|
|
1515
1469
|
|
|
1516
1470
|
export const useStatewireSession = (
|
|
@@ -1531,28 +1485,13 @@ export const useStatewireSession = (
|
|
|
1531
1485
|
);
|
|
1532
1486
|
}
|
|
1533
1487
|
const [s] = useState((): Session => {
|
|
1534
|
-
const
|
|
1488
|
+
const record =
|
|
1535
1489
|
store.ready === undefined
|
|
1536
1490
|
? loadSessionRecord(store, options)
|
|
1537
|
-
: {
|
|
1538
|
-
record: {
|
|
1539
|
-
clientId: newClientId(),
|
|
1540
|
-
nextSeq: 1,
|
|
1541
|
-
ctxNextSeq: 1,
|
|
1542
|
-
commands: [],
|
|
1543
|
-
},
|
|
1544
|
-
stored: null,
|
|
1545
|
-
};
|
|
1491
|
+
: { clientId: newClientId(), lanes: {} };
|
|
1546
1492
|
const session: Session = {
|
|
1547
1493
|
options,
|
|
1548
1494
|
selection: undefined,
|
|
1549
|
-
contextDocuments:
|
|
1550
|
-
stored?.context === undefined
|
|
1551
|
-
? { mode: "mounted", documents: new Map() }
|
|
1552
|
-
: applyDocumentOps(
|
|
1553
|
-
{ mode: "mounted", documents: new Map() },
|
|
1554
|
-
stored.context.ops,
|
|
1555
|
-
),
|
|
1556
1495
|
record,
|
|
1557
1496
|
ctx: {
|
|
1558
1497
|
beforeAttach: () => persistBeforeSend(session),
|
|
@@ -1582,18 +1521,18 @@ export const useStatewireSession = (
|
|
|
1582
1521
|
ephemeral: false,
|
|
1583
1522
|
deactivated: false,
|
|
1584
1523
|
sessionKnown: false,
|
|
1585
|
-
durableSeq:
|
|
1524
|
+
durableSeq: {},
|
|
1525
|
+
ephemeralSeqs: {},
|
|
1586
1526
|
resumeAt: 0,
|
|
1587
1527
|
idle: false,
|
|
1588
1528
|
onSessionLost: undefined,
|
|
1589
1529
|
onDone: undefined,
|
|
1590
|
-
ctxOps: stored?.context?.ops,
|
|
1591
|
-
ctxPending:
|
|
1592
|
-
stored?.context?.pending.map((p) => ({ ...p, posted: false })) ?? [],
|
|
1593
1530
|
lanesOnAttach: false,
|
|
1594
|
-
|
|
1531
|
+
attachEntries: new Set(),
|
|
1595
1532
|
frameFailures: 0,
|
|
1596
1533
|
pending: [],
|
|
1534
|
+
failedList: [],
|
|
1535
|
+
failedParts: [],
|
|
1597
1536
|
canonical: undefined,
|
|
1598
1537
|
documentListeners: new Set(),
|
|
1599
1538
|
documents: { mode: null, documents: new Map() },
|
|
@@ -1624,21 +1563,7 @@ export const useStatewireSession = (
|
|
|
1624
1563
|
};
|
|
1625
1564
|
if (store.ready !== undefined) {
|
|
1626
1565
|
session.ready = store.ready.then(() => {
|
|
1627
|
-
|
|
1628
|
-
session.record = record;
|
|
1629
|
-
session.contextDocuments =
|
|
1630
|
-
record.context === undefined
|
|
1631
|
-
? { mode: "mounted", documents: new Map() }
|
|
1632
|
-
: applyDocumentOps(
|
|
1633
|
-
{ mode: "mounted", documents: new Map() },
|
|
1634
|
-
record.context.ops,
|
|
1635
|
-
);
|
|
1636
|
-
session.ctxOps = record.context?.ops;
|
|
1637
|
-
session.ctxPending =
|
|
1638
|
-
record.context?.pending.map((entry) => ({
|
|
1639
|
-
...entry,
|
|
1640
|
-
posted: false,
|
|
1641
|
-
})) ?? [];
|
|
1566
|
+
session.record = loadSessionRecord(store, session.options);
|
|
1642
1567
|
session.recordPersisted = true;
|
|
1643
1568
|
session.hydrated = true;
|
|
1644
1569
|
if (session.running) session.notify();
|
|
@@ -1686,4 +1611,4 @@ export const useStatewireSession = (
|
|
|
1686
1611
|
return s;
|
|
1687
1612
|
};
|
|
1688
1613
|
|
|
1689
|
-
export {
|
|
1614
|
+
export { setTransport };
|