statewire 0.16.0 → 0.17.1
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/README.md +12 -4
- package/dist/client/connection.d.ts +5 -1
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +22 -6
- package/dist/client/connection.js.map +1 -1
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +2 -0
- package/dist/client/core.js.map +1 -1
- package/dist/client/session.d.ts +15 -4
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +235 -52
- package/dist/client/session.js.map +1 -1
- package/dist/client/sessionStore.d.ts +5 -1
- package/dist/client/sessionStore.d.ts.map +1 -1
- package/dist/client/sessionStore.js +3 -1
- package/dist/client/sessionStore.js.map +1 -1
- package/dist/document.d.ts +25 -0
- package/dist/document.d.ts.map +1 -0
- package/dist/document.js +88 -0
- package/dist/document.js.map +1 -0
- package/dist/host/draft.js +1 -1
- package/dist/host/host.d.ts +1 -0
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js.map +1 -1
- package/dist/host/http.d.ts.map +1 -1
- package/dist/host/http.js +27 -9
- package/dist/host/http.js.map +1 -1
- package/dist/host/socket.d.ts +12 -5
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +107 -43
- package/dist/host/socket.js.map +1 -1
- package/dist/host-internal.d.ts +9 -1
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/host-internal.js +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2 -1
- package/dist/statewire.d.ts +33 -2
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +34 -3
- package/dist/statewire.js.map +1 -1
- package/dist/transports/fixtures.js +1 -0
- package/dist/transports/fixtures.js.map +1 -1
- package/dist/transports/remote.d.ts.map +1 -1
- package/dist/transports/remote.js +15 -8
- package/dist/transports/remote.js.map +1 -1
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +8 -8
- package/dist/transports/sse.js.map +1 -1
- package/dist/transports/testing.d.ts +1 -1
- package/dist/transports/testing.d.ts.map +1 -1
- package/dist/transports/testing.js +12 -4
- package/dist/transports/testing.js.map +1 -1
- package/dist/transports/ws.js +2 -2
- package/dist/transports/ws.js.map +1 -1
- package/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/wire/documents.d.ts +25 -2
- package/dist/wire/documents.d.ts.map +1 -1
- package/dist/wire/documents.js +28 -8
- package/dist/wire/documents.js.map +1 -1
- package/dist/wire/meta.d.ts +3 -18
- package/dist/wire/meta.d.ts.map +1 -1
- package/dist/wire/meta.js +8 -57
- package/dist/wire/meta.js.map +1 -1
- package/dist/wire/ops.js +1 -1
- package/dist/wire/protocol.d.ts +55 -0
- package/dist/wire/protocol.d.ts.map +1 -0
- package/dist/wire/protocol.js +221 -0
- package/dist/wire/protocol.js.map +1 -0
- package/dist/wire/version.d.ts +1 -3
- package/dist/wire/version.d.ts.map +1 -1
- package/dist/wire/version.js +1 -3
- package/dist/wire/version.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +43 -13
- package/src/client/core.ts +6 -0
- package/src/client/session.ts +340 -45
- package/src/client/sessionStore.ts +8 -0
- package/src/document.ts +185 -0
- package/src/host/host.ts +1 -0
- package/src/host/http.ts +36 -7
- package/src/host/socket.ts +149 -57
- package/src/host-internal.ts +12 -1
- package/src/index.ts +5 -0
- package/src/statewire.ts +109 -6
- package/src/transports/fixtures.ts +1 -1
- package/src/transports/remote.ts +16 -7
- package/src/transports/sse.ts +18 -8
- package/src/transports/testing.ts +10 -2
- package/src/transports/ws.ts +1 -1
- package/src/types.ts +11 -0
- package/src/wire/documents.ts +126 -8
- package/src/wire/meta.ts +11 -94
- package/src/wire/protocol.ts +327 -0
- package/src/wire/version.ts +0 -2
package/src/client/session.ts
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { encodeDocument } from "../document";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_PROTOCOL,
|
|
4
|
+
WIRE_VERSION,
|
|
5
|
+
protocolOffers,
|
|
6
|
+
inRange,
|
|
7
|
+
} from "../wire/protocol";
|
|
8
|
+
import type { StatewireProtocol } from "../wire/protocol";
|
|
9
|
+
import {
|
|
10
|
+
applyDocumentOps,
|
|
11
|
+
mainDocument,
|
|
12
|
+
findDocument,
|
|
13
|
+
resolveDocumentKey,
|
|
14
|
+
} from "../wire/documents";
|
|
2
15
|
import type { StatewireDocuments } from "../wire/documents";
|
|
3
16
|
import { useContext, useEffect, useState } from "react";
|
|
4
17
|
|
|
@@ -6,17 +19,13 @@ import type { StatewirePath } from "../wire/ops";
|
|
|
6
19
|
import { MAX_COMMANDS_PER_FRAME } from "../wire/limits";
|
|
7
20
|
import { TERMINAL } from "./connection";
|
|
8
21
|
import type { StatewireCmd, StatewireSyn } from "./connection";
|
|
9
|
-
import {
|
|
10
|
-
encodeParams,
|
|
11
|
-
encodeValue,
|
|
12
|
-
reviveResponse,
|
|
13
|
-
reviveValue,
|
|
14
|
-
} from "../wire/meta";
|
|
22
|
+
import { encodeParams, reviveResponse, reviveValue } from "../wire/meta";
|
|
15
23
|
import { bindCommandsHost } from "../host/host";
|
|
16
24
|
import type { CommandsInvocation, CommandsListener } from "../host/host";
|
|
17
25
|
import type { Statewire } from "../types";
|
|
18
26
|
import type { StatewireClient } from "../statewire";
|
|
19
27
|
import {
|
|
28
|
+
DocumentStorageContext,
|
|
20
29
|
SessionStorageContext,
|
|
21
30
|
createMemorySessionStore,
|
|
22
31
|
} from "./sessionStore";
|
|
@@ -31,6 +40,8 @@ import type {
|
|
|
31
40
|
} from "../transports/sse";
|
|
32
41
|
|
|
33
42
|
type WireCommand = {
|
|
43
|
+
protocol: string;
|
|
44
|
+
produced?: NonNullable<StatewireClient.SessionCommand["produced"]>;
|
|
34
45
|
method: string;
|
|
35
46
|
params: unknown[];
|
|
36
47
|
meta?: unknown;
|
|
@@ -70,7 +81,7 @@ const rejectedSend = (
|
|
|
70
81
|
|
|
71
82
|
type AttachState = "detached" | "attached" | "finished";
|
|
72
83
|
|
|
73
|
-
export type StatewireSessionOptions = {
|
|
84
|
+
export type StatewireSessionOptions = StatewireProtocol.Options & {
|
|
74
85
|
sessionStore?: StatewireClient.SessionStorage | undefined;
|
|
75
86
|
onSessionLost?: StatewireClient.OnSessionLost | undefined;
|
|
76
87
|
onDone?: (() => void) | undefined;
|
|
@@ -83,10 +94,14 @@ type Shared = {
|
|
|
83
94
|
};
|
|
84
95
|
|
|
85
96
|
type Session = {
|
|
97
|
+
options: StatewireSessionOptions;
|
|
98
|
+
selection: StatewireProtocol.Selection | undefined;
|
|
99
|
+
contextDocuments: StatewireDocuments.State;
|
|
86
100
|
record: StatewireClient.SessionRecord;
|
|
87
101
|
ctx: StatewireRemoteProps;
|
|
88
102
|
recordPersisted: boolean;
|
|
89
103
|
store: StatewireClient.SessionStorage;
|
|
104
|
+
documentStore: StatewireDocuments.Storage | undefined;
|
|
90
105
|
shared: Shared | false;
|
|
91
106
|
ephemeral: boolean;
|
|
92
107
|
/** An ephemeral record died with its attach: the storage is off for good. */
|
|
@@ -104,6 +119,7 @@ type Session = {
|
|
|
104
119
|
frameFailures: number;
|
|
105
120
|
pending: Pending[];
|
|
106
121
|
canonical: unknown;
|
|
122
|
+
documentListeners: Set<() => void>;
|
|
107
123
|
documents: StatewireDocuments.State;
|
|
108
124
|
lastPublished: unknown;
|
|
109
125
|
changed: StatewirePath[];
|
|
@@ -209,6 +225,20 @@ const transact = (s: Session, fn: () => void): Promise<void> => {
|
|
|
209
225
|
adoptReset(s, stored);
|
|
210
226
|
} else if (stored !== null) {
|
|
211
227
|
s.record = stored;
|
|
228
|
+
if (stored.context !== undefined) {
|
|
229
|
+
const posted = new Set(
|
|
230
|
+
s.ctxPending.filter((p) => p.posted).map((p) => p.seq),
|
|
231
|
+
);
|
|
232
|
+
s.ctxOps = stored.context.ops;
|
|
233
|
+
s.contextDocuments = applyDocumentOps(
|
|
234
|
+
{ mode: "mounted", documents: new Map() },
|
|
235
|
+
stored.context.ops,
|
|
236
|
+
);
|
|
237
|
+
s.ctxPending = stored.context.pending.map((p) => ({
|
|
238
|
+
...p,
|
|
239
|
+
posted: posted.has(p.seq),
|
|
240
|
+
}));
|
|
241
|
+
}
|
|
212
242
|
}
|
|
213
243
|
fn();
|
|
214
244
|
}),
|
|
@@ -250,6 +280,10 @@ const retainFailedInner = (
|
|
|
250
280
|
failed: [
|
|
251
281
|
...(s.record.failed ?? []),
|
|
252
282
|
{
|
|
283
|
+
protocol: entry.command.protocol,
|
|
284
|
+
...(entry.command.produced !== undefined && {
|
|
285
|
+
produced: entry.command.produced,
|
|
286
|
+
}),
|
|
253
287
|
command: entry.command.method,
|
|
254
288
|
params: entry.command.params,
|
|
255
289
|
...(entry.command.meta !== undefined && { meta: entry.command.meta }),
|
|
@@ -462,8 +496,8 @@ const handleCmd = (s: Session, cmd: StatewireCmd) => {
|
|
|
462
496
|
if (entry) cover(s, entry);
|
|
463
497
|
return;
|
|
464
498
|
}
|
|
465
|
-
parkVerdict(s, cmd);
|
|
466
499
|
if (!entry) return;
|
|
500
|
+
parkVerdict(s, cmd);
|
|
467
501
|
cover(s, entry);
|
|
468
502
|
settleEntry(s, entry, outcomeOf(cmd));
|
|
469
503
|
};
|
|
@@ -495,14 +529,50 @@ const handleCmds = (s: Session, cmds: StatewireCmd[]) => {
|
|
|
495
529
|
};
|
|
496
530
|
|
|
497
531
|
const ingestOps = (s: Session, ops: unknown[]) => {
|
|
498
|
-
const applied = applyDocumentOps(s.documents, ops);
|
|
532
|
+
const applied = applyDocumentOps(s.documents, ops, s.selection?.protocols);
|
|
499
533
|
s.documents = applied;
|
|
500
|
-
|
|
501
|
-
|
|
534
|
+
const next =
|
|
535
|
+
s.selection === undefined
|
|
536
|
+
? applied.documents.get(0)?.value
|
|
537
|
+
: s.options.applicationProtocols === "*"
|
|
538
|
+
? Object.fromEntries(
|
|
539
|
+
[...applied.documents].map(([id, d]) => [
|
|
540
|
+
`${d.protocol}:${d.key === null ? id : JSON.stringify(d.key)}`,
|
|
541
|
+
d.value,
|
|
542
|
+
]),
|
|
543
|
+
)
|
|
544
|
+
: mainDocument(applied, s.options.protocol ?? DEFAULT_PROTOCOL)?.value;
|
|
545
|
+
if (next !== s.canonical)
|
|
546
|
+
s.changed.push(
|
|
547
|
+
...(applied.changedPaths.length ? applied.changedPaths : [[]]),
|
|
548
|
+
);
|
|
549
|
+
s.canonical = next;
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
const cacheDocuments = (s: Session) => {
|
|
553
|
+
if (s.documentStore && s.selection) {
|
|
554
|
+
for (const document of s.documents.documents.values()) {
|
|
555
|
+
if (!document.protocol || !document.key) continue;
|
|
556
|
+
const key = resolveDocumentKey(
|
|
557
|
+
document.key,
|
|
558
|
+
s.options.keyPrefixes?.[document.protocol],
|
|
559
|
+
);
|
|
560
|
+
const version = s.selection.protocols.find(
|
|
561
|
+
(p) => p.name === document.protocol,
|
|
562
|
+
)?.version;
|
|
563
|
+
if (key !== undefined && version !== undefined)
|
|
564
|
+
s.documentStore.save(
|
|
565
|
+
[s.documentStore.namespace ?? "", document.protocol, ...key],
|
|
566
|
+
{ version, value: document.value },
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
502
570
|
};
|
|
503
571
|
|
|
504
572
|
const publishIngested = (s: Session) => {
|
|
505
573
|
publish(s);
|
|
574
|
+
cacheDocuments(s);
|
|
575
|
+
for (const listener of s.documentListeners) listener();
|
|
506
576
|
wake(s);
|
|
507
577
|
};
|
|
508
578
|
|
|
@@ -514,6 +584,10 @@ const snapCtxClock = (s: Session, ctxWm: number) => {
|
|
|
514
584
|
s.ctxPending.every((c, i) => c.seq === ctxWm + 1 + i) &&
|
|
515
585
|
s.record.ctxNextSeq === ctxWm + s.ctxPending.length + 1;
|
|
516
586
|
if (!ctxAligned) {
|
|
587
|
+
if (ctxWm > 0)
|
|
588
|
+
throw new Error(
|
|
589
|
+
"statewire: context history is missing; use a new client id",
|
|
590
|
+
);
|
|
517
591
|
setRecord(s, { ...s.record, ctxNextSeq: ctxWm + 1 });
|
|
518
592
|
restateContext(s);
|
|
519
593
|
}
|
|
@@ -531,11 +605,11 @@ const sessionLostInner = (s: Session, seqWm: number, ctxWm: number) => {
|
|
|
531
605
|
setRecord(s, {
|
|
532
606
|
clientId: s.record.clientId,
|
|
533
607
|
nextSeq: seqWm + 1,
|
|
534
|
-
ctxNextSeq:
|
|
608
|
+
ctxNextSeq: s.record.ctxNextSeq,
|
|
535
609
|
commands: [],
|
|
536
610
|
...(s.record.failed !== undefined && { failed: s.record.failed }),
|
|
537
611
|
});
|
|
538
|
-
|
|
612
|
+
snapCtxClock(s, ctxWm);
|
|
539
613
|
if (
|
|
540
614
|
held.length > 0 &&
|
|
541
615
|
s.onSessionLost?.(held.map((p) => p.inv)) === "requeue"
|
|
@@ -561,6 +635,10 @@ const handleHello = (
|
|
|
561
635
|
if (syn.record !== undefined && syn.record !== "ephemeral") {
|
|
562
636
|
protocolError(`unknown record: ${String(syn.record)}`);
|
|
563
637
|
}
|
|
638
|
+
s.selection =
|
|
639
|
+
syn.version === undefined || syn.protocols === undefined
|
|
640
|
+
? undefined
|
|
641
|
+
: { version: syn.version, protocols: syn.protocols };
|
|
564
642
|
s.documents = { mode: null, documents: new Map() };
|
|
565
643
|
ingestOps(s, ops);
|
|
566
644
|
s.attach = "attached";
|
|
@@ -583,6 +661,7 @@ const handleHello = (
|
|
|
583
661
|
s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
|
|
584
662
|
for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
|
|
585
663
|
snapCtxClock(s, ctxWatermark);
|
|
664
|
+
if (s.ctxOps !== undefined) persistContext(s);
|
|
586
665
|
syncBacklogInner(s);
|
|
587
666
|
s.sessionKnown = true;
|
|
588
667
|
};
|
|
@@ -620,6 +699,7 @@ const handleRefusal = (s: Session, syn: StatewireSyn) => {
|
|
|
620
699
|
if (typeof ctxSeq === "number") {
|
|
621
700
|
s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxSeq);
|
|
622
701
|
for (const c of s.ctxPending) c.posted = false;
|
|
702
|
+
if (s.ctxOps !== undefined) persistContext(s);
|
|
623
703
|
}
|
|
624
704
|
if (typeof syn.retry === "number") {
|
|
625
705
|
s.resumeAt = Date.now() + syn.retry;
|
|
@@ -699,7 +779,10 @@ const buildLanes = (s: Session): Lanes | null => {
|
|
|
699
779
|
ctx: ctxEntries.map((c) => ({ ops: c.ops, seq: c.seq })),
|
|
700
780
|
}),
|
|
701
781
|
...(cmdEntries.length > 0 && {
|
|
702
|
-
cmd: cmdEntries.map((p) =>
|
|
782
|
+
cmd: cmdEntries.map((p) => {
|
|
783
|
+
const { produced: _produced, ...command } = p.command;
|
|
784
|
+
return { ...command, seq: p.seq };
|
|
785
|
+
}),
|
|
703
786
|
}),
|
|
704
787
|
},
|
|
705
788
|
cmdEntries,
|
|
@@ -850,6 +933,7 @@ const enqueueInner = (
|
|
|
850
933
|
seq,
|
|
851
934
|
command,
|
|
852
935
|
inv: {
|
|
936
|
+
protocol: command.protocol,
|
|
853
937
|
method: command.method,
|
|
854
938
|
params,
|
|
855
939
|
ts: Date.now(),
|
|
@@ -903,6 +987,12 @@ const adoptStored = (s: Session) => {
|
|
|
903
987
|
const stored = s.record.commands.filter((c) => !known.has(c.seq));
|
|
904
988
|
if (stored.length === 0) return;
|
|
905
989
|
for (const { seq, ...command } of stored) {
|
|
990
|
+
const offers = protocolOffers(s.options);
|
|
991
|
+
if (
|
|
992
|
+
offers !== "*" &&
|
|
993
|
+
!offers.some((offer) => offer.name === command.protocol)
|
|
994
|
+
)
|
|
995
|
+
continue;
|
|
906
996
|
const params =
|
|
907
997
|
command.meta === undefined
|
|
908
998
|
? command.params
|
|
@@ -911,6 +1001,7 @@ const adoptStored = (s: Session) => {
|
|
|
911
1001
|
seq,
|
|
912
1002
|
command,
|
|
913
1003
|
inv: {
|
|
1004
|
+
protocol: command.protocol,
|
|
914
1005
|
method: command.method,
|
|
915
1006
|
params,
|
|
916
1007
|
ts: Date.now(),
|
|
@@ -933,6 +1024,7 @@ const sendCommand = (
|
|
|
933
1024
|
s: Session,
|
|
934
1025
|
method: string,
|
|
935
1026
|
params: readonly unknown[],
|
|
1027
|
+
protocol = s.options.protocol ?? DEFAULT_PROTOCOL,
|
|
936
1028
|
): Promise<unknown> => {
|
|
937
1029
|
if (!s.running) {
|
|
938
1030
|
return rejectedSend({ fate: "aborted", cause: "unmount" });
|
|
@@ -944,7 +1036,19 @@ const sendCommand = (
|
|
|
944
1036
|
const { params: encoded, meta } = encodeParams(params);
|
|
945
1037
|
enqueue(
|
|
946
1038
|
s,
|
|
947
|
-
{
|
|
1039
|
+
{
|
|
1040
|
+
protocol,
|
|
1041
|
+
produced: {
|
|
1042
|
+
wire: s.options.wireVersion ?? { version: WIRE_VERSION },
|
|
1043
|
+
protocols:
|
|
1044
|
+
protocolOffers(s.options) === "*" && s.selection
|
|
1045
|
+
? s.selection.protocols
|
|
1046
|
+
: protocolOffers(s.options),
|
|
1047
|
+
},
|
|
1048
|
+
method,
|
|
1049
|
+
params: encoded,
|
|
1050
|
+
...(meta !== undefined && { meta }),
|
|
1051
|
+
},
|
|
948
1052
|
params,
|
|
949
1053
|
resolve,
|
|
950
1054
|
reject,
|
|
@@ -1029,6 +1133,8 @@ export const retryFailed = (
|
|
|
1029
1133
|
enqueueInner(
|
|
1030
1134
|
s,
|
|
1031
1135
|
{
|
|
1136
|
+
protocol: current.protocol,
|
|
1137
|
+
...(current.produced !== undefined && { produced: current.produced }),
|
|
1032
1138
|
method: current.command,
|
|
1033
1139
|
params: [...current.params],
|
|
1034
1140
|
...(current.meta !== undefined && { meta: current.meta }),
|
|
@@ -1053,46 +1159,181 @@ export const retryFailed = (
|
|
|
1053
1159
|
return result;
|
|
1054
1160
|
};
|
|
1055
1161
|
|
|
1056
|
-
const
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1162
|
+
const contextSnapshot = (documents: StatewireDocuments.State) =>
|
|
1163
|
+
[...documents.documents].map(([doc, d]) => {
|
|
1164
|
+
const encoded = encodeDocument(d.value);
|
|
1165
|
+
return {
|
|
1166
|
+
op: "mount",
|
|
1167
|
+
...(doc === 0 ? {} : { doc }),
|
|
1168
|
+
protocol: d.protocol,
|
|
1169
|
+
...(d.main ? { main: true } : {}),
|
|
1170
|
+
...(d.key === null ? {} : { key: d.key }),
|
|
1171
|
+
value: encoded.value,
|
|
1172
|
+
...(encoded.meta === undefined ? {} : { meta: encoded.meta }),
|
|
1173
|
+
};
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
const persistContext = (s: Session) => {
|
|
1177
|
+
const ops = contextSnapshot(s.contextDocuments);
|
|
1070
1178
|
s.ctxOps = ops;
|
|
1071
|
-
|
|
1179
|
+
setRecord(s, {
|
|
1180
|
+
...s.record,
|
|
1181
|
+
context: {
|
|
1182
|
+
ops,
|
|
1183
|
+
pending: s.ctxPending.map(({ seq, ops }) => ({ seq, ops })),
|
|
1184
|
+
},
|
|
1185
|
+
});
|
|
1186
|
+
};
|
|
1187
|
+
|
|
1188
|
+
const changeContext = (
|
|
1189
|
+
s: Session,
|
|
1190
|
+
build: (documents: StatewireDocuments.State) => readonly unknown[],
|
|
1191
|
+
): void => {
|
|
1192
|
+
if (!s.running || isFinished(s)) return;
|
|
1193
|
+
const submit = () => {
|
|
1194
|
+
if (!s.running || isFinished(s)) return;
|
|
1195
|
+
const previous =
|
|
1196
|
+
s.record.context === undefined
|
|
1197
|
+
? s.contextDocuments
|
|
1198
|
+
: applyDocumentOps(
|
|
1199
|
+
{ mode: "mounted", documents: new Map() },
|
|
1200
|
+
s.record.context.ops,
|
|
1201
|
+
);
|
|
1202
|
+
const operations = build(previous);
|
|
1203
|
+
if (!operations.length)
|
|
1204
|
+
throw new Error("statewire: context operations must not be empty");
|
|
1205
|
+
const offers = protocolOffers(s.options);
|
|
1206
|
+
const protocols =
|
|
1207
|
+
s.selection?.protocols ?? (offers === "*" ? undefined : offers);
|
|
1208
|
+
s.contextDocuments = applyDocumentOps(
|
|
1209
|
+
previous,
|
|
1210
|
+
operations,
|
|
1211
|
+
protocols,
|
|
1212
|
+
true,
|
|
1213
|
+
);
|
|
1072
1214
|
const seq = s.record.ctxNextSeq;
|
|
1073
|
-
|
|
1074
|
-
|
|
1215
|
+
s.ctxPending = [
|
|
1216
|
+
...s.ctxPending,
|
|
1217
|
+
{
|
|
1218
|
+
seq,
|
|
1219
|
+
ops: operations.map((op) => {
|
|
1220
|
+
const value = op as Record<string, unknown>;
|
|
1221
|
+
if (value["doc"] !== 0) return value;
|
|
1222
|
+
const { doc: _doc, ...rest } = value;
|
|
1223
|
+
return rest;
|
|
1224
|
+
}),
|
|
1225
|
+
posted: false,
|
|
1226
|
+
},
|
|
1227
|
+
];
|
|
1228
|
+
s.record = { ...s.record, ctxNextSeq: seq + 1 };
|
|
1229
|
+
persistContext(s);
|
|
1075
1230
|
};
|
|
1076
1231
|
if (!s.shared) {
|
|
1077
|
-
|
|
1078
|
-
wake(s);
|
|
1079
|
-
void pump(s);
|
|
1080
|
-
return;
|
|
1081
|
-
}
|
|
1082
|
-
void transact(s, () => {
|
|
1083
|
-
if (!s.running || isFinished(s)) return;
|
|
1084
|
-
mint();
|
|
1085
|
-
}).then(() => {
|
|
1232
|
+
submit();
|
|
1086
1233
|
wake(s);
|
|
1087
1234
|
void pump(s);
|
|
1235
|
+
} else
|
|
1236
|
+
void transact(s, submit).then(() => {
|
|
1237
|
+
wake(s);
|
|
1238
|
+
void pump(s);
|
|
1239
|
+
});
|
|
1240
|
+
};
|
|
1241
|
+
|
|
1242
|
+
export const updateContext = (s: Session, operations: readonly unknown[]) =>
|
|
1243
|
+
changeContext(s, () => operations);
|
|
1244
|
+
|
|
1245
|
+
const setContext = (
|
|
1246
|
+
s: Session,
|
|
1247
|
+
value: Record<string, unknown>,
|
|
1248
|
+
protocol = s.options.protocol ?? DEFAULT_PROTOCOL,
|
|
1249
|
+
): void => {
|
|
1250
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
1251
|
+
throw new Error("statewire: context must be a plain object");
|
|
1252
|
+
const encoded = encodeDocument(value);
|
|
1253
|
+
changeContext(s, (documents) => {
|
|
1254
|
+
const found = [...documents.documents].find(
|
|
1255
|
+
([, d]) => d.protocol === protocol && d.main,
|
|
1256
|
+
);
|
|
1257
|
+
const doc = found?.[0] ?? Math.max(-1, ...documents.documents.keys()) + 1;
|
|
1258
|
+
return [
|
|
1259
|
+
{
|
|
1260
|
+
...(found === undefined
|
|
1261
|
+
? { op: "mount", protocol, main: true }
|
|
1262
|
+
: { op: "replace", path: [] }),
|
|
1263
|
+
...(doc === 0 ? {} : { doc }),
|
|
1264
|
+
value: encoded.value,
|
|
1265
|
+
...(encoded.meta === undefined ? {} : { meta: encoded.meta }),
|
|
1266
|
+
},
|
|
1267
|
+
];
|
|
1088
1268
|
});
|
|
1089
1269
|
};
|
|
1090
1270
|
|
|
1271
|
+
export const documentSource = (s: Session): StatewireDocuments.Source => ({
|
|
1272
|
+
get: (protocol, key, prefix = s.options.keyPrefixes?.[protocol]) => {
|
|
1273
|
+
const mounted = findDocument(s.documents, protocol, key, prefix);
|
|
1274
|
+
if (mounted !== undefined) return mounted.value;
|
|
1275
|
+
if (!s.documentStore || key === undefined) return undefined;
|
|
1276
|
+
const absolute = resolveDocumentKey(key, prefix);
|
|
1277
|
+
if (absolute === undefined) return undefined;
|
|
1278
|
+
const cached = s.documentStore.load([
|
|
1279
|
+
s.documentStore.namespace ?? "",
|
|
1280
|
+
protocol,
|
|
1281
|
+
...absolute,
|
|
1282
|
+
]);
|
|
1283
|
+
if (cached === undefined) return undefined;
|
|
1284
|
+
const offers = protocolOffers(s.options);
|
|
1285
|
+
const offer =
|
|
1286
|
+
offers === "*"
|
|
1287
|
+
? s.selection?.protocols.find((p) => p.name === protocol)
|
|
1288
|
+
: offers.find((p) => p.name === protocol);
|
|
1289
|
+
return offer !== undefined && inRange(cached.version, offer)
|
|
1290
|
+
? cached.value
|
|
1291
|
+
: undefined;
|
|
1292
|
+
},
|
|
1293
|
+
list: () => s.documents.documents,
|
|
1294
|
+
subscribe: (listener) => {
|
|
1295
|
+
s.documentListeners.add(listener);
|
|
1296
|
+
return () => {
|
|
1297
|
+
s.documentListeners.delete(listener);
|
|
1298
|
+
};
|
|
1299
|
+
},
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
export const invokeProtocol = (
|
|
1303
|
+
s: Session,
|
|
1304
|
+
protocol: string,
|
|
1305
|
+
method: string,
|
|
1306
|
+
params: readonly unknown[],
|
|
1307
|
+
) => sendCommand(s, method, params, protocol);
|
|
1308
|
+
|
|
1309
|
+
const storedCommandCompatible = (
|
|
1310
|
+
command: StatewireClient.SessionCommand,
|
|
1311
|
+
current: NonNullable<StatewireClient.SessionCommand["produced"]>,
|
|
1312
|
+
) => {
|
|
1313
|
+
if (
|
|
1314
|
+
!command.produced ||
|
|
1315
|
+
!command.protocol ||
|
|
1316
|
+
!inRange(command.produced.wire.version, current.wire)
|
|
1317
|
+
)
|
|
1318
|
+
return false;
|
|
1319
|
+
const previous = command.produced.protocols;
|
|
1320
|
+
if (current.protocols === "*") return true;
|
|
1321
|
+
if (previous === "*") return false;
|
|
1322
|
+
const produced = previous.find((p) => p.name === command.protocol);
|
|
1323
|
+
const offer = current.protocols.find((p) => p.name === command.protocol);
|
|
1324
|
+
return (
|
|
1325
|
+
produced !== undefined &&
|
|
1326
|
+
offer !== undefined &&
|
|
1327
|
+
inRange(produced.version, offer)
|
|
1328
|
+
);
|
|
1329
|
+
};
|
|
1330
|
+
|
|
1091
1331
|
export const useStatewireSession = (
|
|
1092
1332
|
options: StatewireSessionOptions,
|
|
1093
1333
|
): Session => {
|
|
1094
1334
|
const [defaultStore] = useState(createMemorySessionStore);
|
|
1095
1335
|
const mounted = useContext(SessionStorageContext);
|
|
1336
|
+
const documentStore = useContext(DocumentStorageContext);
|
|
1096
1337
|
if (options.sessionStore !== undefined && mounted !== undefined) {
|
|
1097
1338
|
throw new Error(
|
|
1098
1339
|
"statewire: session storage is set twice — drop the transport's sessionStore or the client's storage.session",
|
|
@@ -1105,7 +1346,35 @@ export const useStatewireSession = (
|
|
|
1105
1346
|
);
|
|
1106
1347
|
}
|
|
1107
1348
|
const [s] = useState((): Session => {
|
|
1108
|
-
|
|
1349
|
+
let stored = store.load();
|
|
1350
|
+
if (stored !== null && !Array.isArray(stored.commands))
|
|
1351
|
+
throw new Error(
|
|
1352
|
+
"statewire: session storage loaded a record without a commands array",
|
|
1353
|
+
);
|
|
1354
|
+
const current = {
|
|
1355
|
+
wire: options.wireVersion ?? { version: WIRE_VERSION },
|
|
1356
|
+
protocols: protocolOffers(options),
|
|
1357
|
+
};
|
|
1358
|
+
if (
|
|
1359
|
+
stored !== null &&
|
|
1360
|
+
!stored.commands.every((command) =>
|
|
1361
|
+
storedCommandCompatible(command, current),
|
|
1362
|
+
)
|
|
1363
|
+
) {
|
|
1364
|
+
if (!store.migrate)
|
|
1365
|
+
throw new Error(
|
|
1366
|
+
"statewire: stored commands require a storage migration",
|
|
1367
|
+
);
|
|
1368
|
+
stored = store.migrate(stored, current);
|
|
1369
|
+
if (
|
|
1370
|
+
stored !== null &&
|
|
1371
|
+
!stored.commands.every((command) =>
|
|
1372
|
+
storedCommandCompatible(command, current),
|
|
1373
|
+
)
|
|
1374
|
+
)
|
|
1375
|
+
throw new Error("statewire: migrated commands are incompatible");
|
|
1376
|
+
if (stored !== null) store.save(stored);
|
|
1377
|
+
}
|
|
1109
1378
|
if (stored !== null && !Array.isArray(stored.commands)) {
|
|
1110
1379
|
throw new Error(
|
|
1111
1380
|
"statewire: session storage loaded a record without a commands array",
|
|
@@ -1117,7 +1386,17 @@ export const useStatewireSession = (
|
|
|
1117
1386
|
ctxNextSeq: 1,
|
|
1118
1387
|
commands: [],
|
|
1119
1388
|
};
|
|
1389
|
+
if (stored === null) store.save(record);
|
|
1120
1390
|
const session: Session = {
|
|
1391
|
+
options,
|
|
1392
|
+
selection: undefined,
|
|
1393
|
+
contextDocuments:
|
|
1394
|
+
stored?.context === undefined
|
|
1395
|
+
? { mode: "mounted", documents: new Map() }
|
|
1396
|
+
: applyDocumentOps(
|
|
1397
|
+
{ mode: "mounted", documents: new Map() },
|
|
1398
|
+
stored.context.ops,
|
|
1399
|
+
),
|
|
1121
1400
|
record,
|
|
1122
1401
|
ctx: {
|
|
1123
1402
|
get clientId() {
|
|
@@ -1134,6 +1413,7 @@ export const useStatewireSession = (
|
|
|
1134
1413
|
},
|
|
1135
1414
|
recordPersisted: stored !== null,
|
|
1136
1415
|
store,
|
|
1416
|
+
documentStore,
|
|
1137
1417
|
shared:
|
|
1138
1418
|
store.lock !== undefined
|
|
1139
1419
|
? { txq: Promise.resolve(), parked: [], unsubscribe: null }
|
|
@@ -1146,13 +1426,15 @@ export const useStatewireSession = (
|
|
|
1146
1426
|
idle: false,
|
|
1147
1427
|
onSessionLost: undefined,
|
|
1148
1428
|
onDone: undefined,
|
|
1149
|
-
ctxOps:
|
|
1150
|
-
ctxPending:
|
|
1429
|
+
ctxOps: stored?.context?.ops,
|
|
1430
|
+
ctxPending:
|
|
1431
|
+
stored?.context?.pending.map((p) => ({ ...p, posted: false })) ?? [],
|
|
1151
1432
|
lanesOnAttach: false,
|
|
1152
1433
|
attachLaneSeqs: [new Set(), new Set()],
|
|
1153
1434
|
frameFailures: 0,
|
|
1154
1435
|
pending: [],
|
|
1155
1436
|
canonical: undefined,
|
|
1437
|
+
documentListeners: new Set(),
|
|
1156
1438
|
documents: { mode: null, documents: new Map() },
|
|
1157
1439
|
lastPublished: undefined,
|
|
1158
1440
|
changed: [],
|
|
@@ -1191,9 +1473,22 @@ export const useStatewireSession = (
|
|
|
1191
1473
|
"statewire: session storage changed sharedness across renders — lock/subscribe must be present from the first render or never",
|
|
1192
1474
|
);
|
|
1193
1475
|
}
|
|
1476
|
+
s.options = options;
|
|
1194
1477
|
s.store = store;
|
|
1478
|
+
s.documentStore = documentStore;
|
|
1195
1479
|
s.onSessionLost = options.onSessionLost;
|
|
1196
1480
|
s.onDone = options.onDone;
|
|
1481
|
+
const keyPrefixes = JSON.stringify(options.keyPrefixes);
|
|
1482
|
+
useEffect(() => {
|
|
1483
|
+
cacheDocuments(s);
|
|
1484
|
+
}, [s, documentStore, keyPrefixes]);
|
|
1485
|
+
useEffect(
|
|
1486
|
+
() =>
|
|
1487
|
+
documentStore?.subscribe?.(() => {
|
|
1488
|
+
for (const listener of s.documentListeners) listener();
|
|
1489
|
+
}),
|
|
1490
|
+
[s, documentStore],
|
|
1491
|
+
);
|
|
1197
1492
|
useEffect(() => {
|
|
1198
1493
|
start(s);
|
|
1199
1494
|
return () => stop(s);
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { StatewireDocuments } from "../wire/documents";
|
|
1
2
|
import { createContext } from "react";
|
|
3
|
+
import type { StatewireProtocol } from "../wire/protocol";
|
|
2
4
|
import type { StatewireClient } from "../statewire";
|
|
3
5
|
|
|
4
6
|
/** The session storage `useStatewire` mounts, read by the transport's session. */
|
|
@@ -15,3 +17,9 @@ export const createMemorySessionStore = (): StatewireClient.SessionStorage => {
|
|
|
15
17
|
},
|
|
16
18
|
};
|
|
17
19
|
};
|
|
20
|
+
|
|
21
|
+
export const ProtocolContext = createContext<StatewireProtocol.Options>({});
|
|
22
|
+
|
|
23
|
+
export const DocumentStorageContext = createContext<
|
|
24
|
+
StatewireDocuments.Storage | undefined
|
|
25
|
+
>(undefined);
|