statewire 0.15.0 → 0.17.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/README.md +14 -6
- package/dist/client/connection.d.ts +5 -1
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +19 -4
- 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 +233 -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/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 +10 -3
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +103 -39
- 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 +5 -3
- package/dist/index.js +3 -3
- 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 +2 -2
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +11 -11
- 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 +14 -6
- package/dist/transports/testing.js.map +1 -1
- package/dist/transports/ws.d.ts +2 -2
- package/dist/transports/ws.d.ts.map +1 -1
- package/dist/transports/ws.js +5 -5
- 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/util/randomId.js +1 -1
- package/dist/util/randomId.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 +27 -7
- package/dist/wire/documents.js.map +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 +339 -42
- package/src/client/sessionStore.ts +8 -0
- package/src/host/host.ts +1 -0
- package/src/host/http.ts +36 -7
- package/src/host/socket.ts +141 -50
- package/src/host-internal.ts +12 -1
- package/src/index.ts +5 -2
- 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 +28 -18
- package/src/transports/testing.ts +12 -4
- package/src/transports/ws.ts +8 -8
- package/src/types.ts +11 -0
- package/src/util/randomId.ts +3 -3
- package/src/wire/documents.ts +126 -8
- package/src/wire/protocol.ts +327 -0
- package/src/wire/version.ts +0 -2
package/src/client/session.ts
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_PROTOCOL,
|
|
3
|
+
WIRE_VERSION,
|
|
4
|
+
protocolOffers,
|
|
5
|
+
inRange,
|
|
6
|
+
} from "../wire/protocol";
|
|
7
|
+
import type { StatewireProtocol } from "../wire/protocol";
|
|
8
|
+
import {
|
|
9
|
+
applyDocumentOps,
|
|
10
|
+
mainDocument,
|
|
11
|
+
findDocument,
|
|
12
|
+
resolveDocumentKey,
|
|
13
|
+
} from "../wire/documents";
|
|
2
14
|
import type { StatewireDocuments } from "../wire/documents";
|
|
3
15
|
import { useContext, useEffect, useState } from "react";
|
|
4
16
|
|
|
@@ -17,12 +29,13 @@ import type { CommandsInvocation, CommandsListener } from "../host/host";
|
|
|
17
29
|
import type { Statewire } from "../types";
|
|
18
30
|
import type { StatewireClient } from "../statewire";
|
|
19
31
|
import {
|
|
32
|
+
DocumentStorageContext,
|
|
20
33
|
SessionStorageContext,
|
|
21
34
|
createMemorySessionStore,
|
|
22
35
|
} from "./sessionStore";
|
|
23
36
|
import { backoff } from "./timing";
|
|
24
37
|
import { createMethodProxy } from "../util/methodProxy";
|
|
25
|
-
import { randomId } from "../util/randomId";
|
|
38
|
+
import { randomId as newClientId } from "../util/randomId";
|
|
26
39
|
import { StatewireSendError } from "../types";
|
|
27
40
|
import type {
|
|
28
41
|
RemoteLink,
|
|
@@ -31,6 +44,8 @@ import type {
|
|
|
31
44
|
} from "../transports/sse";
|
|
32
45
|
|
|
33
46
|
type WireCommand = {
|
|
47
|
+
protocol: string;
|
|
48
|
+
produced?: NonNullable<StatewireClient.SessionCommand["produced"]>;
|
|
34
49
|
method: string;
|
|
35
50
|
params: unknown[];
|
|
36
51
|
meta?: unknown;
|
|
@@ -60,8 +75,6 @@ const protocolError = (message: string): never => {
|
|
|
60
75
|
throw new Error(`statewire: protocol error: ${message}`);
|
|
61
76
|
};
|
|
62
77
|
|
|
63
|
-
const newClientId = () => `c-${randomId()}`;
|
|
64
|
-
|
|
65
78
|
const rejectedSend = (
|
|
66
79
|
failure: StatewireClient.SendFailure,
|
|
67
80
|
): Promise<unknown> => {
|
|
@@ -72,7 +85,7 @@ const rejectedSend = (
|
|
|
72
85
|
|
|
73
86
|
type AttachState = "detached" | "attached" | "finished";
|
|
74
87
|
|
|
75
|
-
export type StatewireSessionOptions = {
|
|
88
|
+
export type StatewireSessionOptions = StatewireProtocol.Options & {
|
|
76
89
|
sessionStore?: StatewireClient.SessionStorage | undefined;
|
|
77
90
|
onSessionLost?: StatewireClient.OnSessionLost | undefined;
|
|
78
91
|
onDone?: (() => void) | undefined;
|
|
@@ -85,10 +98,14 @@ type Shared = {
|
|
|
85
98
|
};
|
|
86
99
|
|
|
87
100
|
type Session = {
|
|
101
|
+
options: StatewireSessionOptions;
|
|
102
|
+
selection: StatewireProtocol.Selection | undefined;
|
|
103
|
+
contextDocuments: StatewireDocuments.State;
|
|
88
104
|
record: StatewireClient.SessionRecord;
|
|
89
105
|
ctx: StatewireRemoteProps;
|
|
90
106
|
recordPersisted: boolean;
|
|
91
107
|
store: StatewireClient.SessionStorage;
|
|
108
|
+
documentStore: StatewireDocuments.Storage | undefined;
|
|
92
109
|
shared: Shared | false;
|
|
93
110
|
ephemeral: boolean;
|
|
94
111
|
/** An ephemeral record died with its attach: the storage is off for good. */
|
|
@@ -106,6 +123,7 @@ type Session = {
|
|
|
106
123
|
frameFailures: number;
|
|
107
124
|
pending: Pending[];
|
|
108
125
|
canonical: unknown;
|
|
126
|
+
documentListeners: Set<() => void>;
|
|
109
127
|
documents: StatewireDocuments.State;
|
|
110
128
|
lastPublished: unknown;
|
|
111
129
|
changed: StatewirePath[];
|
|
@@ -211,6 +229,20 @@ const transact = (s: Session, fn: () => void): Promise<void> => {
|
|
|
211
229
|
adoptReset(s, stored);
|
|
212
230
|
} else if (stored !== null) {
|
|
213
231
|
s.record = stored;
|
|
232
|
+
if (stored.context !== undefined) {
|
|
233
|
+
const posted = new Set(
|
|
234
|
+
s.ctxPending.filter((p) => p.posted).map((p) => p.seq),
|
|
235
|
+
);
|
|
236
|
+
s.ctxOps = stored.context.ops;
|
|
237
|
+
s.contextDocuments = applyDocumentOps(
|
|
238
|
+
{ mode: "mounted", documents: new Map() },
|
|
239
|
+
stored.context.ops,
|
|
240
|
+
);
|
|
241
|
+
s.ctxPending = stored.context.pending.map((p) => ({
|
|
242
|
+
...p,
|
|
243
|
+
posted: posted.has(p.seq),
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
214
246
|
}
|
|
215
247
|
fn();
|
|
216
248
|
}),
|
|
@@ -252,6 +284,10 @@ const retainFailedInner = (
|
|
|
252
284
|
failed: [
|
|
253
285
|
...(s.record.failed ?? []),
|
|
254
286
|
{
|
|
287
|
+
protocol: entry.command.protocol,
|
|
288
|
+
...(entry.command.produced !== undefined && {
|
|
289
|
+
produced: entry.command.produced,
|
|
290
|
+
}),
|
|
255
291
|
command: entry.command.method,
|
|
256
292
|
params: entry.command.params,
|
|
257
293
|
...(entry.command.meta !== undefined && { meta: entry.command.meta }),
|
|
@@ -464,8 +500,8 @@ const handleCmd = (s: Session, cmd: StatewireCmd) => {
|
|
|
464
500
|
if (entry) cover(s, entry);
|
|
465
501
|
return;
|
|
466
502
|
}
|
|
467
|
-
parkVerdict(s, cmd);
|
|
468
503
|
if (!entry) return;
|
|
504
|
+
parkVerdict(s, cmd);
|
|
469
505
|
cover(s, entry);
|
|
470
506
|
settleEntry(s, entry, outcomeOf(cmd));
|
|
471
507
|
};
|
|
@@ -497,14 +533,50 @@ const handleCmds = (s: Session, cmds: StatewireCmd[]) => {
|
|
|
497
533
|
};
|
|
498
534
|
|
|
499
535
|
const ingestOps = (s: Session, ops: unknown[]) => {
|
|
500
|
-
const applied = applyDocumentOps(s.documents, ops);
|
|
536
|
+
const applied = applyDocumentOps(s.documents, ops, s.selection?.protocols);
|
|
501
537
|
s.documents = applied;
|
|
502
|
-
|
|
503
|
-
|
|
538
|
+
const next =
|
|
539
|
+
s.selection === undefined
|
|
540
|
+
? applied.documents.get(0)?.value
|
|
541
|
+
: s.options.applicationProtocols === "*"
|
|
542
|
+
? Object.fromEntries(
|
|
543
|
+
[...applied.documents].map(([id, d]) => [
|
|
544
|
+
`${d.protocol}:${d.key === null ? id : JSON.stringify(d.key)}`,
|
|
545
|
+
d.value,
|
|
546
|
+
]),
|
|
547
|
+
)
|
|
548
|
+
: mainDocument(applied, s.options.protocol ?? DEFAULT_PROTOCOL)?.value;
|
|
549
|
+
if (next !== s.canonical)
|
|
550
|
+
s.changed.push(
|
|
551
|
+
...(applied.changedPaths.length ? applied.changedPaths : [[]]),
|
|
552
|
+
);
|
|
553
|
+
s.canonical = next;
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
const cacheDocuments = (s: Session) => {
|
|
557
|
+
if (s.documentStore && s.selection) {
|
|
558
|
+
for (const document of s.documents.documents.values()) {
|
|
559
|
+
if (!document.protocol || !document.key) continue;
|
|
560
|
+
const key = resolveDocumentKey(
|
|
561
|
+
document.key,
|
|
562
|
+
s.options.keyPrefixes?.[document.protocol],
|
|
563
|
+
);
|
|
564
|
+
const version = s.selection.protocols.find(
|
|
565
|
+
(p) => p.name === document.protocol,
|
|
566
|
+
)?.version;
|
|
567
|
+
if (key !== undefined && version !== undefined)
|
|
568
|
+
s.documentStore.save(
|
|
569
|
+
[s.documentStore.namespace ?? "", document.protocol, ...key],
|
|
570
|
+
{ version, value: document.value },
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
504
574
|
};
|
|
505
575
|
|
|
506
576
|
const publishIngested = (s: Session) => {
|
|
507
577
|
publish(s);
|
|
578
|
+
cacheDocuments(s);
|
|
579
|
+
for (const listener of s.documentListeners) listener();
|
|
508
580
|
wake(s);
|
|
509
581
|
};
|
|
510
582
|
|
|
@@ -516,6 +588,10 @@ const snapCtxClock = (s: Session, ctxWm: number) => {
|
|
|
516
588
|
s.ctxPending.every((c, i) => c.seq === ctxWm + 1 + i) &&
|
|
517
589
|
s.record.ctxNextSeq === ctxWm + s.ctxPending.length + 1;
|
|
518
590
|
if (!ctxAligned) {
|
|
591
|
+
if (ctxWm > 0)
|
|
592
|
+
throw new Error(
|
|
593
|
+
"statewire: context history is missing; use a new client id",
|
|
594
|
+
);
|
|
519
595
|
setRecord(s, { ...s.record, ctxNextSeq: ctxWm + 1 });
|
|
520
596
|
restateContext(s);
|
|
521
597
|
}
|
|
@@ -533,11 +609,11 @@ const sessionLostInner = (s: Session, seqWm: number, ctxWm: number) => {
|
|
|
533
609
|
setRecord(s, {
|
|
534
610
|
clientId: s.record.clientId,
|
|
535
611
|
nextSeq: seqWm + 1,
|
|
536
|
-
ctxNextSeq:
|
|
612
|
+
ctxNextSeq: s.record.ctxNextSeq,
|
|
537
613
|
commands: [],
|
|
538
614
|
...(s.record.failed !== undefined && { failed: s.record.failed }),
|
|
539
615
|
});
|
|
540
|
-
|
|
616
|
+
snapCtxClock(s, ctxWm);
|
|
541
617
|
if (
|
|
542
618
|
held.length > 0 &&
|
|
543
619
|
s.onSessionLost?.(held.map((p) => p.inv)) === "requeue"
|
|
@@ -563,6 +639,10 @@ const handleHello = (
|
|
|
563
639
|
if (syn.record !== undefined && syn.record !== "ephemeral") {
|
|
564
640
|
protocolError(`unknown record: ${String(syn.record)}`);
|
|
565
641
|
}
|
|
642
|
+
s.selection =
|
|
643
|
+
syn.version === undefined || syn.protocols === undefined
|
|
644
|
+
? undefined
|
|
645
|
+
: { version: syn.version, protocols: syn.protocols };
|
|
566
646
|
s.documents = { mode: null, documents: new Map() };
|
|
567
647
|
ingestOps(s, ops);
|
|
568
648
|
s.attach = "attached";
|
|
@@ -585,6 +665,7 @@ const handleHello = (
|
|
|
585
665
|
s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
|
|
586
666
|
for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
|
|
587
667
|
snapCtxClock(s, ctxWatermark);
|
|
668
|
+
if (s.ctxOps !== undefined) persistContext(s);
|
|
588
669
|
syncBacklogInner(s);
|
|
589
670
|
s.sessionKnown = true;
|
|
590
671
|
};
|
|
@@ -622,6 +703,7 @@ const handleRefusal = (s: Session, syn: StatewireSyn) => {
|
|
|
622
703
|
if (typeof ctxSeq === "number") {
|
|
623
704
|
s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxSeq);
|
|
624
705
|
for (const c of s.ctxPending) c.posted = false;
|
|
706
|
+
if (s.ctxOps !== undefined) persistContext(s);
|
|
625
707
|
}
|
|
626
708
|
if (typeof syn.retry === "number") {
|
|
627
709
|
s.resumeAt = Date.now() + syn.retry;
|
|
@@ -701,7 +783,10 @@ const buildLanes = (s: Session): Lanes | null => {
|
|
|
701
783
|
ctx: ctxEntries.map((c) => ({ ops: c.ops, seq: c.seq })),
|
|
702
784
|
}),
|
|
703
785
|
...(cmdEntries.length > 0 && {
|
|
704
|
-
cmd: cmdEntries.map((p) =>
|
|
786
|
+
cmd: cmdEntries.map((p) => {
|
|
787
|
+
const { produced: _produced, ...command } = p.command;
|
|
788
|
+
return { ...command, seq: p.seq };
|
|
789
|
+
}),
|
|
705
790
|
}),
|
|
706
791
|
},
|
|
707
792
|
cmdEntries,
|
|
@@ -852,6 +937,7 @@ const enqueueInner = (
|
|
|
852
937
|
seq,
|
|
853
938
|
command,
|
|
854
939
|
inv: {
|
|
940
|
+
protocol: command.protocol,
|
|
855
941
|
method: command.method,
|
|
856
942
|
params,
|
|
857
943
|
ts: Date.now(),
|
|
@@ -905,6 +991,12 @@ const adoptStored = (s: Session) => {
|
|
|
905
991
|
const stored = s.record.commands.filter((c) => !known.has(c.seq));
|
|
906
992
|
if (stored.length === 0) return;
|
|
907
993
|
for (const { seq, ...command } of stored) {
|
|
994
|
+
const offers = protocolOffers(s.options);
|
|
995
|
+
if (
|
|
996
|
+
offers !== "*" &&
|
|
997
|
+
!offers.some((offer) => offer.name === command.protocol)
|
|
998
|
+
)
|
|
999
|
+
continue;
|
|
908
1000
|
const params =
|
|
909
1001
|
command.meta === undefined
|
|
910
1002
|
? command.params
|
|
@@ -913,6 +1005,7 @@ const adoptStored = (s: Session) => {
|
|
|
913
1005
|
seq,
|
|
914
1006
|
command,
|
|
915
1007
|
inv: {
|
|
1008
|
+
protocol: command.protocol,
|
|
916
1009
|
method: command.method,
|
|
917
1010
|
params,
|
|
918
1011
|
ts: Date.now(),
|
|
@@ -935,6 +1028,7 @@ const sendCommand = (
|
|
|
935
1028
|
s: Session,
|
|
936
1029
|
method: string,
|
|
937
1030
|
params: readonly unknown[],
|
|
1031
|
+
protocol = s.options.protocol ?? DEFAULT_PROTOCOL,
|
|
938
1032
|
): Promise<unknown> => {
|
|
939
1033
|
if (!s.running) {
|
|
940
1034
|
return rejectedSend({ fate: "aborted", cause: "unmount" });
|
|
@@ -946,7 +1040,19 @@ const sendCommand = (
|
|
|
946
1040
|
const { params: encoded, meta } = encodeParams(params);
|
|
947
1041
|
enqueue(
|
|
948
1042
|
s,
|
|
949
|
-
{
|
|
1043
|
+
{
|
|
1044
|
+
protocol,
|
|
1045
|
+
produced: {
|
|
1046
|
+
wire: s.options.wireVersion ?? { version: WIRE_VERSION },
|
|
1047
|
+
protocols:
|
|
1048
|
+
protocolOffers(s.options) === "*" && s.selection
|
|
1049
|
+
? s.selection.protocols
|
|
1050
|
+
: protocolOffers(s.options),
|
|
1051
|
+
},
|
|
1052
|
+
method,
|
|
1053
|
+
params: encoded,
|
|
1054
|
+
...(meta !== undefined && { meta }),
|
|
1055
|
+
},
|
|
950
1056
|
params,
|
|
951
1057
|
resolve,
|
|
952
1058
|
reject,
|
|
@@ -1031,6 +1137,8 @@ export const retryFailed = (
|
|
|
1031
1137
|
enqueueInner(
|
|
1032
1138
|
s,
|
|
1033
1139
|
{
|
|
1140
|
+
protocol: current.protocol,
|
|
1141
|
+
...(current.produced !== undefined && { produced: current.produced }),
|
|
1034
1142
|
method: current.command,
|
|
1035
1143
|
params: [...current.params],
|
|
1036
1144
|
...(current.meta !== undefined && { meta: current.meta }),
|
|
@@ -1055,46 +1163,181 @@ export const retryFailed = (
|
|
|
1055
1163
|
return result;
|
|
1056
1164
|
};
|
|
1057
1165
|
|
|
1058
|
-
const
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1166
|
+
const contextSnapshot = (documents: StatewireDocuments.State) =>
|
|
1167
|
+
[...documents.documents].map(([doc, d]) => {
|
|
1168
|
+
const encoded = encodeValue(d.value);
|
|
1169
|
+
return {
|
|
1170
|
+
op: "mount",
|
|
1171
|
+
...(doc === 0 ? {} : { doc }),
|
|
1172
|
+
protocol: d.protocol,
|
|
1173
|
+
...(d.main ? { main: true } : {}),
|
|
1174
|
+
...(d.key === null ? {} : { key: d.key }),
|
|
1175
|
+
value: encoded.value,
|
|
1176
|
+
...(encoded.meta === undefined ? {} : { meta: encoded.meta }),
|
|
1177
|
+
};
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
const persistContext = (s: Session) => {
|
|
1181
|
+
const ops = contextSnapshot(s.contextDocuments);
|
|
1072
1182
|
s.ctxOps = ops;
|
|
1073
|
-
|
|
1183
|
+
setRecord(s, {
|
|
1184
|
+
...s.record,
|
|
1185
|
+
context: {
|
|
1186
|
+
ops,
|
|
1187
|
+
pending: s.ctxPending.map(({ seq, ops }) => ({ seq, ops })),
|
|
1188
|
+
},
|
|
1189
|
+
});
|
|
1190
|
+
};
|
|
1191
|
+
|
|
1192
|
+
const changeContext = (
|
|
1193
|
+
s: Session,
|
|
1194
|
+
build: (documents: StatewireDocuments.State) => readonly unknown[],
|
|
1195
|
+
): void => {
|
|
1196
|
+
if (!s.running || isFinished(s)) return;
|
|
1197
|
+
const submit = () => {
|
|
1198
|
+
if (!s.running || isFinished(s)) return;
|
|
1199
|
+
const previous =
|
|
1200
|
+
s.record.context === undefined
|
|
1201
|
+
? s.contextDocuments
|
|
1202
|
+
: applyDocumentOps(
|
|
1203
|
+
{ mode: "mounted", documents: new Map() },
|
|
1204
|
+
s.record.context.ops,
|
|
1205
|
+
);
|
|
1206
|
+
const operations = build(previous);
|
|
1207
|
+
if (!operations.length)
|
|
1208
|
+
throw new Error("statewire: context operations must not be empty");
|
|
1209
|
+
const offers = protocolOffers(s.options);
|
|
1210
|
+
const protocols =
|
|
1211
|
+
s.selection?.protocols ?? (offers === "*" ? undefined : offers);
|
|
1212
|
+
s.contextDocuments = applyDocumentOps(
|
|
1213
|
+
previous,
|
|
1214
|
+
operations,
|
|
1215
|
+
protocols,
|
|
1216
|
+
true,
|
|
1217
|
+
);
|
|
1074
1218
|
const seq = s.record.ctxNextSeq;
|
|
1075
|
-
|
|
1076
|
-
|
|
1219
|
+
s.ctxPending = [
|
|
1220
|
+
...s.ctxPending,
|
|
1221
|
+
{
|
|
1222
|
+
seq,
|
|
1223
|
+
ops: operations.map((op) => {
|
|
1224
|
+
const value = op as Record<string, unknown>;
|
|
1225
|
+
if (value["doc"] !== 0) return value;
|
|
1226
|
+
const { doc: _doc, ...rest } = value;
|
|
1227
|
+
return rest;
|
|
1228
|
+
}),
|
|
1229
|
+
posted: false,
|
|
1230
|
+
},
|
|
1231
|
+
];
|
|
1232
|
+
s.record = { ...s.record, ctxNextSeq: seq + 1 };
|
|
1233
|
+
persistContext(s);
|
|
1077
1234
|
};
|
|
1078
1235
|
if (!s.shared) {
|
|
1079
|
-
|
|
1080
|
-
wake(s);
|
|
1081
|
-
void pump(s);
|
|
1082
|
-
return;
|
|
1083
|
-
}
|
|
1084
|
-
void transact(s, () => {
|
|
1085
|
-
if (!s.running || isFinished(s)) return;
|
|
1086
|
-
mint();
|
|
1087
|
-
}).then(() => {
|
|
1236
|
+
submit();
|
|
1088
1237
|
wake(s);
|
|
1089
1238
|
void pump(s);
|
|
1239
|
+
} else
|
|
1240
|
+
void transact(s, submit).then(() => {
|
|
1241
|
+
wake(s);
|
|
1242
|
+
void pump(s);
|
|
1243
|
+
});
|
|
1244
|
+
};
|
|
1245
|
+
|
|
1246
|
+
export const updateContext = (s: Session, operations: readonly unknown[]) =>
|
|
1247
|
+
changeContext(s, () => operations);
|
|
1248
|
+
|
|
1249
|
+
const setContext = (
|
|
1250
|
+
s: Session,
|
|
1251
|
+
value: Record<string, unknown>,
|
|
1252
|
+
protocol = s.options.protocol ?? DEFAULT_PROTOCOL,
|
|
1253
|
+
): void => {
|
|
1254
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
1255
|
+
throw new Error("statewire: context must be a plain object");
|
|
1256
|
+
const encoded = encodeValue(value);
|
|
1257
|
+
changeContext(s, (documents) => {
|
|
1258
|
+
const found = [...documents.documents].find(
|
|
1259
|
+
([, d]) => d.protocol === protocol && d.main,
|
|
1260
|
+
);
|
|
1261
|
+
const doc = found?.[0] ?? Math.max(-1, ...documents.documents.keys()) + 1;
|
|
1262
|
+
return [
|
|
1263
|
+
{
|
|
1264
|
+
...(found === undefined
|
|
1265
|
+
? { op: "mount", protocol, main: true }
|
|
1266
|
+
: { op: "replace", path: [] }),
|
|
1267
|
+
...(doc === 0 ? {} : { doc }),
|
|
1268
|
+
value: encoded.value,
|
|
1269
|
+
...(encoded.meta === undefined ? {} : { meta: encoded.meta }),
|
|
1270
|
+
},
|
|
1271
|
+
];
|
|
1090
1272
|
});
|
|
1091
1273
|
};
|
|
1092
1274
|
|
|
1275
|
+
export const documentSource = (s: Session): StatewireDocuments.Source => ({
|
|
1276
|
+
get: (protocol, key, prefix = s.options.keyPrefixes?.[protocol]) => {
|
|
1277
|
+
const mounted = findDocument(s.documents, protocol, key, prefix);
|
|
1278
|
+
if (mounted !== undefined) return mounted.value;
|
|
1279
|
+
if (!s.documentStore || key === undefined) return undefined;
|
|
1280
|
+
const absolute = resolveDocumentKey(key, prefix);
|
|
1281
|
+
if (absolute === undefined) return undefined;
|
|
1282
|
+
const cached = s.documentStore.load([
|
|
1283
|
+
s.documentStore.namespace ?? "",
|
|
1284
|
+
protocol,
|
|
1285
|
+
...absolute,
|
|
1286
|
+
]);
|
|
1287
|
+
if (cached === undefined) return undefined;
|
|
1288
|
+
const offers = protocolOffers(s.options);
|
|
1289
|
+
const offer =
|
|
1290
|
+
offers === "*"
|
|
1291
|
+
? s.selection?.protocols.find((p) => p.name === protocol)
|
|
1292
|
+
: offers.find((p) => p.name === protocol);
|
|
1293
|
+
return offer !== undefined && inRange(cached.version, offer)
|
|
1294
|
+
? cached.value
|
|
1295
|
+
: undefined;
|
|
1296
|
+
},
|
|
1297
|
+
list: () => s.documents.documents,
|
|
1298
|
+
subscribe: (listener) => {
|
|
1299
|
+
s.documentListeners.add(listener);
|
|
1300
|
+
return () => {
|
|
1301
|
+
s.documentListeners.delete(listener);
|
|
1302
|
+
};
|
|
1303
|
+
},
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
export const invokeProtocol = (
|
|
1307
|
+
s: Session,
|
|
1308
|
+
protocol: string,
|
|
1309
|
+
method: string,
|
|
1310
|
+
params: readonly unknown[],
|
|
1311
|
+
) => sendCommand(s, method, params, protocol);
|
|
1312
|
+
|
|
1313
|
+
const storedCommandCompatible = (
|
|
1314
|
+
command: StatewireClient.SessionCommand,
|
|
1315
|
+
current: NonNullable<StatewireClient.SessionCommand["produced"]>,
|
|
1316
|
+
) => {
|
|
1317
|
+
if (
|
|
1318
|
+
!command.produced ||
|
|
1319
|
+
!command.protocol ||
|
|
1320
|
+
!inRange(command.produced.wire.version, current.wire)
|
|
1321
|
+
)
|
|
1322
|
+
return false;
|
|
1323
|
+
const previous = command.produced.protocols;
|
|
1324
|
+
if (current.protocols === "*") return true;
|
|
1325
|
+
if (previous === "*") return false;
|
|
1326
|
+
const produced = previous.find((p) => p.name === command.protocol);
|
|
1327
|
+
const offer = current.protocols.find((p) => p.name === command.protocol);
|
|
1328
|
+
return (
|
|
1329
|
+
produced !== undefined &&
|
|
1330
|
+
offer !== undefined &&
|
|
1331
|
+
inRange(produced.version, offer)
|
|
1332
|
+
);
|
|
1333
|
+
};
|
|
1334
|
+
|
|
1093
1335
|
export const useStatewireSession = (
|
|
1094
1336
|
options: StatewireSessionOptions,
|
|
1095
1337
|
): Session => {
|
|
1096
1338
|
const [defaultStore] = useState(createMemorySessionStore);
|
|
1097
1339
|
const mounted = useContext(SessionStorageContext);
|
|
1340
|
+
const documentStore = useContext(DocumentStorageContext);
|
|
1098
1341
|
if (options.sessionStore !== undefined && mounted !== undefined) {
|
|
1099
1342
|
throw new Error(
|
|
1100
1343
|
"statewire: session storage is set twice — drop the transport's sessionStore or the client's storage.session",
|
|
@@ -1107,7 +1350,35 @@ export const useStatewireSession = (
|
|
|
1107
1350
|
);
|
|
1108
1351
|
}
|
|
1109
1352
|
const [s] = useState((): Session => {
|
|
1110
|
-
|
|
1353
|
+
let stored = store.load();
|
|
1354
|
+
if (stored !== null && !Array.isArray(stored.commands))
|
|
1355
|
+
throw new Error(
|
|
1356
|
+
"statewire: session storage loaded a record without a commands array",
|
|
1357
|
+
);
|
|
1358
|
+
const current = {
|
|
1359
|
+
wire: options.wireVersion ?? { version: WIRE_VERSION },
|
|
1360
|
+
protocols: protocolOffers(options),
|
|
1361
|
+
};
|
|
1362
|
+
if (
|
|
1363
|
+
stored !== null &&
|
|
1364
|
+
!stored.commands.every((command) =>
|
|
1365
|
+
storedCommandCompatible(command, current),
|
|
1366
|
+
)
|
|
1367
|
+
) {
|
|
1368
|
+
if (!store.migrate)
|
|
1369
|
+
throw new Error(
|
|
1370
|
+
"statewire: stored commands require a storage migration",
|
|
1371
|
+
);
|
|
1372
|
+
stored = store.migrate(stored, current);
|
|
1373
|
+
if (
|
|
1374
|
+
stored !== null &&
|
|
1375
|
+
!stored.commands.every((command) =>
|
|
1376
|
+
storedCommandCompatible(command, current),
|
|
1377
|
+
)
|
|
1378
|
+
)
|
|
1379
|
+
throw new Error("statewire: migrated commands are incompatible");
|
|
1380
|
+
if (stored !== null) store.save(stored);
|
|
1381
|
+
}
|
|
1111
1382
|
if (stored !== null && !Array.isArray(stored.commands)) {
|
|
1112
1383
|
throw new Error(
|
|
1113
1384
|
"statewire: session storage loaded a record without a commands array",
|
|
@@ -1119,7 +1390,17 @@ export const useStatewireSession = (
|
|
|
1119
1390
|
ctxNextSeq: 1,
|
|
1120
1391
|
commands: [],
|
|
1121
1392
|
};
|
|
1393
|
+
if (stored === null) store.save(record);
|
|
1122
1394
|
const session: Session = {
|
|
1395
|
+
options,
|
|
1396
|
+
selection: undefined,
|
|
1397
|
+
contextDocuments:
|
|
1398
|
+
stored?.context === undefined
|
|
1399
|
+
? { mode: "mounted", documents: new Map() }
|
|
1400
|
+
: applyDocumentOps(
|
|
1401
|
+
{ mode: "mounted", documents: new Map() },
|
|
1402
|
+
stored.context.ops,
|
|
1403
|
+
),
|
|
1123
1404
|
record,
|
|
1124
1405
|
ctx: {
|
|
1125
1406
|
get clientId() {
|
|
@@ -1136,6 +1417,7 @@ export const useStatewireSession = (
|
|
|
1136
1417
|
},
|
|
1137
1418
|
recordPersisted: stored !== null,
|
|
1138
1419
|
store,
|
|
1420
|
+
documentStore,
|
|
1139
1421
|
shared:
|
|
1140
1422
|
store.lock !== undefined
|
|
1141
1423
|
? { txq: Promise.resolve(), parked: [], unsubscribe: null }
|
|
@@ -1148,13 +1430,15 @@ export const useStatewireSession = (
|
|
|
1148
1430
|
idle: false,
|
|
1149
1431
|
onSessionLost: undefined,
|
|
1150
1432
|
onDone: undefined,
|
|
1151
|
-
ctxOps:
|
|
1152
|
-
ctxPending:
|
|
1433
|
+
ctxOps: stored?.context?.ops,
|
|
1434
|
+
ctxPending:
|
|
1435
|
+
stored?.context?.pending.map((p) => ({ ...p, posted: false })) ?? [],
|
|
1153
1436
|
lanesOnAttach: false,
|
|
1154
1437
|
attachLaneSeqs: [new Set(), new Set()],
|
|
1155
1438
|
frameFailures: 0,
|
|
1156
1439
|
pending: [],
|
|
1157
1440
|
canonical: undefined,
|
|
1441
|
+
documentListeners: new Set(),
|
|
1158
1442
|
documents: { mode: null, documents: new Map() },
|
|
1159
1443
|
lastPublished: undefined,
|
|
1160
1444
|
changed: [],
|
|
@@ -1193,9 +1477,22 @@ export const useStatewireSession = (
|
|
|
1193
1477
|
"statewire: session storage changed sharedness across renders — lock/subscribe must be present from the first render or never",
|
|
1194
1478
|
);
|
|
1195
1479
|
}
|
|
1480
|
+
s.options = options;
|
|
1196
1481
|
s.store = store;
|
|
1482
|
+
s.documentStore = documentStore;
|
|
1197
1483
|
s.onSessionLost = options.onSessionLost;
|
|
1198
1484
|
s.onDone = options.onDone;
|
|
1485
|
+
const keyPrefixes = JSON.stringify(options.keyPrefixes);
|
|
1486
|
+
useEffect(() => {
|
|
1487
|
+
cacheDocuments(s);
|
|
1488
|
+
}, [s, documentStore, keyPrefixes]);
|
|
1489
|
+
useEffect(
|
|
1490
|
+
() =>
|
|
1491
|
+
documentStore?.subscribe?.(() => {
|
|
1492
|
+
for (const listener of s.documentListeners) listener();
|
|
1493
|
+
}),
|
|
1494
|
+
[s, documentStore],
|
|
1495
|
+
);
|
|
1199
1496
|
useEffect(() => {
|
|
1200
1497
|
start(s);
|
|
1201
1498
|
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);
|
package/src/host/host.ts
CHANGED