statewire 0.9.2 → 0.11.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 +1 -0
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +8 -1
- package/dist/client/connection.js.map +1 -1
- package/dist/client/session.d.ts +13 -4
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +263 -66
- package/dist/client/session.js.map +1 -1
- package/dist/client/sessionStore.d.ts +5 -14
- package/dist/client/sessionStore.d.ts.map +1 -1
- package/dist/client/sessionStore.js +4 -1
- package/dist/client/sessionStore.js.map +1 -1
- package/dist/host/http.d.ts +7 -0
- package/dist/host/http.d.ts.map +1 -0
- package/dist/host/http.js +175 -0
- package/dist/host/http.js.map +1 -0
- package/dist/host/socket.d.ts +102 -1
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +48 -27
- package/dist/host/socket.js.map +1 -1
- package/dist/host-internal.d.ts +5 -0
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/statewire.d.ts +48 -7
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +9 -2
- package/dist/statewire.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +13 -1
- package/src/client/session.ts +352 -70
- package/src/client/sessionStore.ts +8 -15
- package/src/host/http.ts +280 -0
- package/src/host/socket.ts +120 -57
- package/src/host-internal.ts +5 -0
- package/src/statewire.ts +80 -8
- package/src/types.ts +1 -1
package/src/client/session.ts
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
1
|
+
import { useContext, useEffect, useState } from "react";
|
|
2
2
|
import { applyOps } from "../wire/ops";
|
|
3
3
|
import type { StatewirePath } from "../wire/ops";
|
|
4
4
|
import type { StatewireCmd, StatewireSyn } from "./connection";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
encodeParams,
|
|
7
|
+
encodeValue,
|
|
8
|
+
reviveResponse,
|
|
9
|
+
reviveValue,
|
|
10
|
+
} from "../wire/meta";
|
|
6
11
|
import { bindCommandsHost } from "../host/host";
|
|
7
12
|
import type { CommandsInvocation, CommandsListener } from "../host/host";
|
|
8
13
|
import type { Statewire } from "../types";
|
|
9
14
|
import type { StatewireClient } from "../statewire";
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
StatewireSessionStore,
|
|
14
|
-
StatewireSessionStoreOptions,
|
|
15
|
+
import {
|
|
16
|
+
SessionStorageContext,
|
|
17
|
+
createMemorySessionStore,
|
|
15
18
|
} from "./sessionStore";
|
|
16
19
|
import { backoff } from "./timing";
|
|
17
20
|
import { createMethodProxy } from "../util/methodProxy";
|
|
@@ -65,16 +68,26 @@ const rejectedSend = (
|
|
|
65
68
|
|
|
66
69
|
type AttachState = "detached" | "attached" | "finished";
|
|
67
70
|
|
|
68
|
-
export type StatewireSessionOptions =
|
|
71
|
+
export type StatewireSessionOptions = {
|
|
72
|
+
sessionStore?: StatewireClient.SessionStorage | undefined;
|
|
69
73
|
onDone?: (() => void) | undefined;
|
|
70
74
|
};
|
|
71
75
|
|
|
76
|
+
type Shared = {
|
|
77
|
+
txq: Promise<void>;
|
|
78
|
+
parked: StatewireClient.SessionVerdict[];
|
|
79
|
+
unsubscribe: (() => void) | null;
|
|
80
|
+
};
|
|
81
|
+
|
|
72
82
|
type Session = {
|
|
73
|
-
record:
|
|
83
|
+
record: StatewireClient.SessionRecord;
|
|
74
84
|
ctx: StatewireRemoteProps;
|
|
75
85
|
recordPersisted: boolean;
|
|
76
|
-
store:
|
|
86
|
+
store: StatewireClient.SessionStorage;
|
|
87
|
+
shared: Shared | false;
|
|
77
88
|
ephemeral: boolean;
|
|
89
|
+
/** An ephemeral record died with its attach: the storage is off for good. */
|
|
90
|
+
deactivated: boolean;
|
|
78
91
|
sessionKnown: boolean;
|
|
79
92
|
durableSeq: number;
|
|
80
93
|
resumeAt: number;
|
|
@@ -128,9 +141,78 @@ const sleepUntilWake = (s: Session, delayMs: number) =>
|
|
|
128
141
|
s.waiters.push(waiter);
|
|
129
142
|
});
|
|
130
143
|
|
|
131
|
-
const setRecord = (s: Session, record:
|
|
144
|
+
const setRecord = (s: Session, record: StatewireClient.SessionRecord) => {
|
|
132
145
|
s.record = record;
|
|
133
|
-
if (!s.
|
|
146
|
+
if (!s.deactivated) s.store.save(record);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const backlog = (s: Session): StatewireClient.SessionCommand[] =>
|
|
150
|
+
s.pending
|
|
151
|
+
.filter((p) => p.seq > s.durableSeq)
|
|
152
|
+
.map((p) => ({ seq: p.seq, ...p.command }));
|
|
153
|
+
|
|
154
|
+
const mergeBacklog = (s: Session): StatewireClient.SessionCommand[] => {
|
|
155
|
+
const own = backlog(s);
|
|
156
|
+
if (!s.shared) return own;
|
|
157
|
+
const known = new Set(s.pending.map((p) => p.seq));
|
|
158
|
+
const foreign = s.record.commands.filter(
|
|
159
|
+
(c) => !known.has(c.seq) && c.seq > s.durableSeq,
|
|
160
|
+
);
|
|
161
|
+
return [...foreign, ...own].sort((a, b) => a.seq - b.seq);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const MAX_PARKED_VERDICTS = 64;
|
|
165
|
+
|
|
166
|
+
// Storage holds what a reload must resend: a command leaves at its dur, at a
|
|
167
|
+
// server verdict, or at a record reset — never at a local channel death.
|
|
168
|
+
const syncBacklogInner = (s: Session) => {
|
|
169
|
+
const parked = s.shared ? s.shared.parked.splice(0) : [];
|
|
170
|
+
const parkedSeqs = new Set(parked.map((v) => v.seq));
|
|
171
|
+
const commands = mergeBacklog(s).filter((c) => !parkedSeqs.has(c.seq));
|
|
172
|
+
const verdicts =
|
|
173
|
+
parked.length > 0
|
|
174
|
+
? [...(s.record.verdicts ?? []), ...parked].slice(-MAX_PARKED_VERDICTS)
|
|
175
|
+
: s.record.verdicts;
|
|
176
|
+
const unchanged =
|
|
177
|
+
verdicts === s.record.verdicts &&
|
|
178
|
+
commands.length === s.record.commands.length &&
|
|
179
|
+
commands.every((c, i) => c.seq === s.record.commands[i]!.seq);
|
|
180
|
+
if (unchanged) return;
|
|
181
|
+
setRecord(s, {
|
|
182
|
+
...s.record,
|
|
183
|
+
commands,
|
|
184
|
+
...(verdicts !== undefined && { verdicts }),
|
|
185
|
+
});
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const syncBacklog = (s: Session) => {
|
|
189
|
+
if (!s.shared) return syncBacklogInner(s);
|
|
190
|
+
void transact(s, () => syncBacklogInner(s));
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// Every record mutation over shared storage is a locked read-modify-write.
|
|
194
|
+
const transact = (s: Session, fn: () => void): Promise<void> => {
|
|
195
|
+
if (!s.shared || s.deactivated) {
|
|
196
|
+
fn();
|
|
197
|
+
return Promise.resolve();
|
|
198
|
+
}
|
|
199
|
+
const shared = s.shared;
|
|
200
|
+
const run = shared.txq.then(() =>
|
|
201
|
+
s.store.lock!(async () => {
|
|
202
|
+
const stored = s.store.load();
|
|
203
|
+
if (stored !== null && stored.clientId !== s.record.clientId) {
|
|
204
|
+
adoptReset(s, stored);
|
|
205
|
+
} else if (stored !== null) {
|
|
206
|
+
s.record = stored;
|
|
207
|
+
}
|
|
208
|
+
fn();
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
shared.txq = run.then(
|
|
212
|
+
() => {},
|
|
213
|
+
() => {},
|
|
214
|
+
);
|
|
215
|
+
return run;
|
|
134
216
|
};
|
|
135
217
|
|
|
136
218
|
const setIdle = (s: Session, level: boolean) => {
|
|
@@ -223,14 +305,43 @@ const resetRecord = (s: Session, clientId: string) => {
|
|
|
223
305
|
}
|
|
224
306
|
s.pending = [];
|
|
225
307
|
s.durableSeq = 0;
|
|
226
|
-
setRecord(s, { clientId, nextSeq: 1, ctxNextSeq: 1 });
|
|
308
|
+
setRecord(s, { clientId, nextSeq: 1, ctxNextSeq: 1, commands: [] });
|
|
309
|
+
for (const old of survivors) {
|
|
310
|
+
s.lifecycle?.onCovered?.(old.inv);
|
|
311
|
+
enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
|
|
312
|
+
}
|
|
313
|
+
restateContext(s);
|
|
314
|
+
publish(s);
|
|
315
|
+
wake(s);
|
|
316
|
+
void pump(s);
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// Another sharer replaced the record's identity: their reset settles our
|
|
320
|
+
// attempted history and our survivors re-stamp on the stored clocks.
|
|
321
|
+
const adoptReset = (s: Session, stored: StatewireClient.SessionRecord) => {
|
|
322
|
+
const survivors: Pending[] = [];
|
|
323
|
+
for (const entry of [...s.pending]) {
|
|
324
|
+
if (entry.attempted || entry.applied) {
|
|
325
|
+
s.lifecycle?.onCovered?.(entry.inv);
|
|
326
|
+
settleEntry(s, entry, {
|
|
327
|
+
ok: false,
|
|
328
|
+
failure: { fate: "lost", cause: "session-lost" },
|
|
329
|
+
});
|
|
330
|
+
} else {
|
|
331
|
+
survivors.push(entry);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
s.pending = [];
|
|
335
|
+
s.durableSeq = 0;
|
|
336
|
+
s.record = stored;
|
|
227
337
|
for (const old of survivors) {
|
|
228
338
|
s.lifecycle?.onCovered?.(old.inv);
|
|
229
|
-
|
|
339
|
+
enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
|
|
230
340
|
}
|
|
231
341
|
restateContext(s);
|
|
232
342
|
publish(s);
|
|
233
343
|
wake(s);
|
|
344
|
+
void pump(s);
|
|
234
345
|
};
|
|
235
346
|
|
|
236
347
|
// The record died with the attach: mint a fresh id for the next one.
|
|
@@ -250,6 +361,59 @@ const rejectedFailure = (cmd: StatewireCmd): StatewireClient.SendFailure => {
|
|
|
250
361
|
};
|
|
251
362
|
};
|
|
252
363
|
|
|
364
|
+
const outcomeOf = (
|
|
365
|
+
cmd: StatewireCmd,
|
|
366
|
+
):
|
|
367
|
+
| { ok: true; payload: unknown }
|
|
368
|
+
| { ok: false; failure: StatewireClient.SendFailure } =>
|
|
369
|
+
cmd.type === "result"
|
|
370
|
+
? { ok: true as const, payload: reviveResponse(cmd).payload }
|
|
371
|
+
: cmd.type === "rejected"
|
|
372
|
+
? { ok: false as const, failure: rejectedFailure(reviveResponse(cmd)) }
|
|
373
|
+
: cmd.type === "failed"
|
|
374
|
+
? {
|
|
375
|
+
ok: false as const,
|
|
376
|
+
failure: {
|
|
377
|
+
fate: "failed" as const,
|
|
378
|
+
cause: "server" as const,
|
|
379
|
+
...(cmd.message !== undefined && { message: cmd.message }),
|
|
380
|
+
},
|
|
381
|
+
}
|
|
382
|
+
: cmd.type === "crashed"
|
|
383
|
+
? {
|
|
384
|
+
ok: false as const,
|
|
385
|
+
failure: { fate: "crashed" as const, cause: "server" as const },
|
|
386
|
+
}
|
|
387
|
+
: cmd.type === "expired"
|
|
388
|
+
? {
|
|
389
|
+
ok: false as const,
|
|
390
|
+
failure: {
|
|
391
|
+
fate: "expired" as const,
|
|
392
|
+
cause: "server" as const,
|
|
393
|
+
},
|
|
394
|
+
}
|
|
395
|
+
: protocolError(`unknown cmd type: ${String(cmd.type)}`);
|
|
396
|
+
|
|
397
|
+
const TERMINAL_CMD_TYPES = new Set([
|
|
398
|
+
"result",
|
|
399
|
+
"rejected",
|
|
400
|
+
"failed",
|
|
401
|
+
"crashed",
|
|
402
|
+
"expired",
|
|
403
|
+
]);
|
|
404
|
+
|
|
405
|
+
const parkVerdict = (s: Session, cmd: StatewireCmd) => {
|
|
406
|
+
if (!s.shared || !TERMINAL_CMD_TYPES.has(cmd.type!)) return;
|
|
407
|
+
s.shared.parked.push({
|
|
408
|
+
seq: cmd.seq,
|
|
409
|
+
type: cmd.type as StatewireClient.SessionVerdict["type"],
|
|
410
|
+
...(cmd.code !== undefined && { code: cmd.code }),
|
|
411
|
+
...(cmd.message !== undefined && { message: cmd.message }),
|
|
412
|
+
...(cmd.payload !== undefined && { payload: cmd.payload }),
|
|
413
|
+
...(cmd.meta !== undefined && { meta: cmd.meta }),
|
|
414
|
+
});
|
|
415
|
+
};
|
|
416
|
+
|
|
253
417
|
const handleCmd = (s: Session, cmd: StatewireCmd) => {
|
|
254
418
|
if (!Number.isSafeInteger(cmd.seq) || cmd.seq <= 0) {
|
|
255
419
|
protocolError("cmd.seq must be a positive safe integer");
|
|
@@ -266,41 +430,35 @@ const handleCmd = (s: Session, cmd: StatewireCmd) => {
|
|
|
266
430
|
if (entry) cover(s, entry);
|
|
267
431
|
return;
|
|
268
432
|
}
|
|
433
|
+
parkVerdict(s, cmd);
|
|
269
434
|
if (!entry) return;
|
|
270
435
|
cover(s, entry);
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
ok: false as const,
|
|
293
|
-
failure: {
|
|
294
|
-
fate: "expired" as const,
|
|
295
|
-
cause: "server" as const,
|
|
296
|
-
},
|
|
297
|
-
}
|
|
298
|
-
: protocolError(`unknown cmd type: ${String(cmd.type)}`);
|
|
299
|
-
settleEntry(s, entry, outcome);
|
|
436
|
+
settleEntry(s, entry, outcomeOf(cmd));
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const consumeVerdicts = (s: Session) => {
|
|
440
|
+
const verdicts = s.record.verdicts ?? [];
|
|
441
|
+
const consumed = new Set<number>();
|
|
442
|
+
for (const verdict of verdicts) {
|
|
443
|
+
const entry = s.pending.find((p) => p.seq === verdict.seq);
|
|
444
|
+
if (!entry) continue;
|
|
445
|
+
consumed.add(verdict.seq);
|
|
446
|
+
cover(s, entry);
|
|
447
|
+
settleEntry(s, entry, outcomeOf(verdict as StatewireCmd));
|
|
448
|
+
}
|
|
449
|
+
if (consumed.size === 0) return;
|
|
450
|
+
setRecord(s, {
|
|
451
|
+
...s.record,
|
|
452
|
+
commands: s.record.commands.filter((c) => !consumed.has(c.seq)),
|
|
453
|
+
verdicts: verdicts.filter((v) => !consumed.has(v.seq)),
|
|
454
|
+
});
|
|
455
|
+
publish(s);
|
|
456
|
+
wake(s);
|
|
300
457
|
};
|
|
301
458
|
|
|
302
459
|
const handleCmds = (s: Session, cmds: StatewireCmd[]) => {
|
|
303
460
|
for (const cmd of cmds) handleCmd(s, cmd);
|
|
461
|
+
syncBacklog(s);
|
|
304
462
|
wake(s);
|
|
305
463
|
};
|
|
306
464
|
|
|
@@ -330,7 +488,7 @@ const snapClocks = (s: Session, seqWm: number, ctxWm: number) => {
|
|
|
330
488
|
setRecord(s, { ...s.record, nextSeq: seqWm + 1 });
|
|
331
489
|
for (const old of tail) {
|
|
332
490
|
s.lifecycle?.onCovered?.(old.inv);
|
|
333
|
-
|
|
491
|
+
enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
|
|
334
492
|
}
|
|
335
493
|
}
|
|
336
494
|
const ctxAligned =
|
|
@@ -356,24 +514,37 @@ const handleHello = (
|
|
|
356
514
|
s.frameFailures = 0;
|
|
357
515
|
s.resumeAt = 0;
|
|
358
516
|
s.ephemeral = record === "ephemeral";
|
|
517
|
+
if (s.ephemeral) s.deactivated = true;
|
|
359
518
|
const [attachCmd, attachCtx] = s.attachLaneSeqs;
|
|
360
519
|
s.attachLaneSeqs = [new Set(), new Set()];
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
520
|
+
const reconcile = () => {
|
|
521
|
+
if (record === "resumed") {
|
|
522
|
+
if (s.shared) adoptStored(s);
|
|
523
|
+
// Resending unsettled statements is exactly-once-safe: absorbed replays
|
|
524
|
+
// re-emit their stored verdicts (applied while still in flight).
|
|
525
|
+
for (const entry of s.pending) {
|
|
526
|
+
entry.posted = attachCmd.has(entry.seq);
|
|
527
|
+
}
|
|
528
|
+
const ctxWatermark = syn.ctxSeq ?? 0;
|
|
529
|
+
s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
|
|
530
|
+
for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
|
|
531
|
+
snapClocks(s, syn.seq ?? 0, ctxWatermark);
|
|
532
|
+
syncBacklogInner(s);
|
|
533
|
+
} else {
|
|
534
|
+
resetRecord(s, s.record.clientId);
|
|
366
535
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
536
|
+
s.sessionKnown = true;
|
|
537
|
+
};
|
|
538
|
+
if (!s.shared || s.deactivated) {
|
|
539
|
+
reconcile();
|
|
540
|
+
publishIngested(s);
|
|
541
|
+
void pump(s);
|
|
542
|
+
return;
|
|
373
543
|
}
|
|
374
|
-
s.
|
|
375
|
-
|
|
376
|
-
|
|
544
|
+
void transact(s, reconcile).then(() => {
|
|
545
|
+
publishIngested(s);
|
|
546
|
+
void pump(s);
|
|
547
|
+
});
|
|
377
548
|
};
|
|
378
549
|
|
|
379
550
|
const handleOps = (
|
|
@@ -382,6 +553,7 @@ const handleOps = (
|
|
|
382
553
|
) => {
|
|
383
554
|
ingestOps(s, ops);
|
|
384
555
|
for (const cmd of cmds) handleCmd(s, cmd);
|
|
556
|
+
syncBacklog(s);
|
|
385
557
|
publishIngested(s);
|
|
386
558
|
};
|
|
387
559
|
|
|
@@ -425,7 +597,11 @@ const handleDetach = (s: Session) => {
|
|
|
425
597
|
const handleFinish = (s: Session, { reason }: { reason: string }) => {
|
|
426
598
|
if (reason === "evicted" || reason === "error") return;
|
|
427
599
|
s.attach = "finished";
|
|
428
|
-
|
|
600
|
+
// Superseded over shared storage: the superseder resends our backlog and
|
|
601
|
+
// parks the verdicts; our promises settle from the observed record.
|
|
602
|
+
if (!(s.shared && reason === "superseded")) {
|
|
603
|
+
settleAll(s, reason === "superseded" ? "superseded" : "gone");
|
|
604
|
+
}
|
|
429
605
|
publish(s);
|
|
430
606
|
wake(s);
|
|
431
607
|
};
|
|
@@ -510,7 +686,12 @@ const pump = async (s: Session) => {
|
|
|
510
686
|
for (const entry of lanes.cmdEntries) entry.attempted = true;
|
|
511
687
|
let result;
|
|
512
688
|
try {
|
|
513
|
-
|
|
689
|
+
// Shared storage: the lock across the send keeps per-lane arrival
|
|
690
|
+
// ordered across sharers during attach handover.
|
|
691
|
+
const send = () => transport.send(lanes.frame);
|
|
692
|
+
result = await (s.shared && !s.deactivated
|
|
693
|
+
? s.store.lock!(send)
|
|
694
|
+
: send());
|
|
514
695
|
} catch {
|
|
515
696
|
if (!s.running || isFinished(s)) return;
|
|
516
697
|
s.frameFailures += 1;
|
|
@@ -554,16 +735,34 @@ const pump = async (s: Session) => {
|
|
|
554
735
|
};
|
|
555
736
|
|
|
556
737
|
const start = (s: Session) => {
|
|
738
|
+
if (s.shared) {
|
|
739
|
+
s.shared.unsubscribe = s.store.subscribe!(() => {
|
|
740
|
+
void transact(s, () => consumeVerdicts(s));
|
|
741
|
+
});
|
|
742
|
+
s.running = true;
|
|
743
|
+
void transact(s, () => {
|
|
744
|
+
if (s.store.load() === null) s.store.save(s.record);
|
|
745
|
+
s.recordPersisted = true;
|
|
746
|
+
adoptStored(s);
|
|
747
|
+
consumeVerdicts(s);
|
|
748
|
+
}).then(() => void pump(s));
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
557
751
|
if (!s.recordPersisted) {
|
|
558
752
|
s.recordPersisted = true;
|
|
559
753
|
s.store.save(s.record);
|
|
560
754
|
}
|
|
755
|
+
adoptStored(s);
|
|
561
756
|
s.running = true;
|
|
562
757
|
void pump(s);
|
|
563
758
|
};
|
|
564
759
|
|
|
565
760
|
const stop = (s: Session) => {
|
|
566
761
|
s.running = false;
|
|
762
|
+
if (s.shared) {
|
|
763
|
+
s.shared.unsubscribe?.();
|
|
764
|
+
s.shared.unsubscribe = null;
|
|
765
|
+
}
|
|
567
766
|
wake(s);
|
|
568
767
|
settleAll(s, "unmount");
|
|
569
768
|
};
|
|
@@ -574,7 +773,7 @@ const setTransport = (s: Session, transport: RemoteLink) => {
|
|
|
574
773
|
wake(s);
|
|
575
774
|
};
|
|
576
775
|
|
|
577
|
-
const
|
|
776
|
+
const enqueueInner = (
|
|
578
777
|
s: Session,
|
|
579
778
|
command: WireCommand,
|
|
580
779
|
params: readonly unknown[],
|
|
@@ -582,7 +781,6 @@ const enqueue = (
|
|
|
582
781
|
reject: (error: StatewireSendError) => void,
|
|
583
782
|
) => {
|
|
584
783
|
const seq = s.record.nextSeq;
|
|
585
|
-
setRecord(s, { ...s.record, nextSeq: seq + 1 });
|
|
586
784
|
const entry: Pending = {
|
|
587
785
|
seq,
|
|
588
786
|
command,
|
|
@@ -600,8 +798,58 @@ const enqueue = (
|
|
|
600
798
|
reject,
|
|
601
799
|
};
|
|
602
800
|
s.pending.push(entry);
|
|
801
|
+
setRecord(s, { ...s.record, nextSeq: seq + 1, commands: mergeBacklog(s) });
|
|
603
802
|
s.lifecycle?.onStart(entry.inv);
|
|
604
|
-
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
const enqueue = (
|
|
806
|
+
s: Session,
|
|
807
|
+
command: WireCommand,
|
|
808
|
+
params: readonly unknown[],
|
|
809
|
+
resolve: (payload: unknown) => void,
|
|
810
|
+
reject: (error: StatewireSendError) => void,
|
|
811
|
+
) => {
|
|
812
|
+
if (!s.shared) {
|
|
813
|
+
enqueueInner(s, command, params, resolve, reject);
|
|
814
|
+
void pump(s);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
void transact(s, () =>
|
|
818
|
+
enqueueInner(s, command, params, resolve, reject),
|
|
819
|
+
).then(() => void pump(s));
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
// A stored command cannot be told from one that reached the server: attempted
|
|
823
|
+
// keeps a fresh record settling it lost instead of replaying it blind.
|
|
824
|
+
const adoptStored = (s: Session) => {
|
|
825
|
+
const known = new Set(s.pending.map((p) => p.seq));
|
|
826
|
+
const stored = s.record.commands.filter((c) => !known.has(c.seq));
|
|
827
|
+
if (stored.length === 0) return;
|
|
828
|
+
for (const { seq, ...command } of stored) {
|
|
829
|
+
const params =
|
|
830
|
+
command.meta === undefined
|
|
831
|
+
? command.params
|
|
832
|
+
: (reviveValue([...command.params], command.meta) as unknown[]);
|
|
833
|
+
const entry: Pending = {
|
|
834
|
+
seq,
|
|
835
|
+
command,
|
|
836
|
+
inv: {
|
|
837
|
+
method: command.method,
|
|
838
|
+
params,
|
|
839
|
+
ts: Date.now(),
|
|
840
|
+
seq,
|
|
841
|
+
clientId: s.record.clientId,
|
|
842
|
+
},
|
|
843
|
+
attempted: true,
|
|
844
|
+
posted: false,
|
|
845
|
+
applied: false,
|
|
846
|
+
resolve: () => {},
|
|
847
|
+
reject: () => {},
|
|
848
|
+
};
|
|
849
|
+
s.pending.push(entry);
|
|
850
|
+
s.lifecycle?.onStart(entry.inv);
|
|
851
|
+
}
|
|
852
|
+
s.pending.sort((a, b) => a.seq - b.seq);
|
|
605
853
|
};
|
|
606
854
|
|
|
607
855
|
const sendCommand = (
|
|
@@ -633,8 +881,9 @@ const setContext = (s: Session, value: Record<string, unknown>): void => {
|
|
|
633
881
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
634
882
|
throw new Error("statewire: context must be a plain object");
|
|
635
883
|
}
|
|
884
|
+
if (!s.running || isFinished(s)) return;
|
|
636
885
|
const { value: encoded, meta } = encodeValue(value);
|
|
637
|
-
|
|
886
|
+
const ops = [
|
|
638
887
|
{
|
|
639
888
|
op: "replace",
|
|
640
889
|
path: [],
|
|
@@ -642,24 +891,52 @@ const setContext = (s: Session, value: Record<string, unknown>): void => {
|
|
|
642
891
|
...(meta !== undefined && { meta }),
|
|
643
892
|
},
|
|
644
893
|
];
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
894
|
+
s.ctxOps = ops;
|
|
895
|
+
const mint = () => {
|
|
896
|
+
const seq = s.record.ctxNextSeq;
|
|
897
|
+
setRecord(s, { ...s.record, ctxNextSeq: seq + 1 });
|
|
898
|
+
s.ctxPending.push({ seq, ops, posted: false });
|
|
899
|
+
};
|
|
900
|
+
if (!s.shared) {
|
|
901
|
+
mint();
|
|
902
|
+
wake(s);
|
|
903
|
+
void pump(s);
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
void transact(s, mint).then(() => {
|
|
907
|
+
wake(s);
|
|
908
|
+
void pump(s);
|
|
909
|
+
});
|
|
650
910
|
};
|
|
651
911
|
|
|
652
912
|
export const useStatewireSession = (
|
|
653
913
|
options: StatewireSessionOptions,
|
|
654
914
|
): Session => {
|
|
655
915
|
const [defaultStore] = useState(createMemorySessionStore);
|
|
656
|
-
const
|
|
916
|
+
const mounted = useContext(SessionStorageContext);
|
|
917
|
+
if (options.sessionStore !== undefined && mounted !== undefined) {
|
|
918
|
+
throw new Error(
|
|
919
|
+
"statewire: session storage is set twice — drop the transport's sessionStore or the client's storage.session",
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
const store = options.sessionStore ?? mounted ?? defaultStore;
|
|
923
|
+
if ((store.lock === undefined) !== (store.subscribe === undefined)) {
|
|
924
|
+
throw new Error(
|
|
925
|
+
"statewire: session storage must provide lock and subscribe together",
|
|
926
|
+
);
|
|
927
|
+
}
|
|
657
928
|
const [s] = useState((): Session => {
|
|
658
929
|
const stored = store.load();
|
|
930
|
+
if (stored !== null && !Array.isArray(stored.commands)) {
|
|
931
|
+
throw new Error(
|
|
932
|
+
"statewire: session storage loaded a record without a commands array",
|
|
933
|
+
);
|
|
934
|
+
}
|
|
659
935
|
const record = stored ?? {
|
|
660
936
|
clientId: newClientId(),
|
|
661
937
|
nextSeq: 1,
|
|
662
938
|
ctxNextSeq: 1,
|
|
939
|
+
commands: [],
|
|
663
940
|
};
|
|
664
941
|
const session: Session = {
|
|
665
942
|
record,
|
|
@@ -678,7 +955,12 @@ export const useStatewireSession = (
|
|
|
678
955
|
},
|
|
679
956
|
recordPersisted: stored !== null,
|
|
680
957
|
store,
|
|
958
|
+
shared:
|
|
959
|
+
store.lock !== undefined
|
|
960
|
+
? { txq: Promise.resolve(), parked: [], unsubscribe: null }
|
|
961
|
+
: false,
|
|
681
962
|
ephemeral: false,
|
|
963
|
+
deactivated: false,
|
|
682
964
|
sessionKnown: false,
|
|
683
965
|
durableSeq: 0,
|
|
684
966
|
resumeAt: 0,
|
|
@@ -1,20 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
nextSeq: number;
|
|
4
|
-
ctxNextSeq: number;
|
|
5
|
-
};
|
|
6
|
-
|
|
7
|
-
export type StatewireSessionStore = {
|
|
8
|
-
load: () => StatewireSessionRecord | null;
|
|
9
|
-
save: (record: StatewireSessionRecord) => void;
|
|
10
|
-
};
|
|
1
|
+
import { createContext } from "react";
|
|
2
|
+
import type { StatewireClient } from "../statewire";
|
|
11
3
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
4
|
+
/** The session storage `useStatewire` mounts, read by the transport's session. */
|
|
5
|
+
export const SessionStorageContext = createContext<
|
|
6
|
+
StatewireClient.SessionStorage | undefined
|
|
7
|
+
>(undefined);
|
|
15
8
|
|
|
16
|
-
export const createMemorySessionStore = ():
|
|
17
|
-
let record:
|
|
9
|
+
export const createMemorySessionStore = (): StatewireClient.SessionStorage => {
|
|
10
|
+
let record: StatewireClient.SessionRecord | null = null;
|
|
18
11
|
return {
|
|
19
12
|
load: () => record,
|
|
20
13
|
save: (next) => {
|