statewire 0.16.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 +12 -4
- 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 +232 -50
- 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 +3 -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 +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 +338 -39
- 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 +3 -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/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,6 +29,7 @@ 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";
|
|
@@ -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;
|
|
@@ -70,7 +85,7 @@ const rejectedSend = (
|
|
|
70
85
|
|
|
71
86
|
type AttachState = "detached" | "attached" | "finished";
|
|
72
87
|
|
|
73
|
-
export type StatewireSessionOptions = {
|
|
88
|
+
export type StatewireSessionOptions = StatewireProtocol.Options & {
|
|
74
89
|
sessionStore?: StatewireClient.SessionStorage | undefined;
|
|
75
90
|
onSessionLost?: StatewireClient.OnSessionLost | undefined;
|
|
76
91
|
onDone?: (() => void) | undefined;
|
|
@@ -83,10 +98,14 @@ type Shared = {
|
|
|
83
98
|
};
|
|
84
99
|
|
|
85
100
|
type Session = {
|
|
101
|
+
options: StatewireSessionOptions;
|
|
102
|
+
selection: StatewireProtocol.Selection | undefined;
|
|
103
|
+
contextDocuments: StatewireDocuments.State;
|
|
86
104
|
record: StatewireClient.SessionRecord;
|
|
87
105
|
ctx: StatewireRemoteProps;
|
|
88
106
|
recordPersisted: boolean;
|
|
89
107
|
store: StatewireClient.SessionStorage;
|
|
108
|
+
documentStore: StatewireDocuments.Storage | undefined;
|
|
90
109
|
shared: Shared | false;
|
|
91
110
|
ephemeral: boolean;
|
|
92
111
|
/** An ephemeral record died with its attach: the storage is off for good. */
|
|
@@ -104,6 +123,7 @@ type Session = {
|
|
|
104
123
|
frameFailures: number;
|
|
105
124
|
pending: Pending[];
|
|
106
125
|
canonical: unknown;
|
|
126
|
+
documentListeners: Set<() => void>;
|
|
107
127
|
documents: StatewireDocuments.State;
|
|
108
128
|
lastPublished: unknown;
|
|
109
129
|
changed: StatewirePath[];
|
|
@@ -209,6 +229,20 @@ const transact = (s: Session, fn: () => void): Promise<void> => {
|
|
|
209
229
|
adoptReset(s, stored);
|
|
210
230
|
} else if (stored !== null) {
|
|
211
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
|
+
}
|
|
212
246
|
}
|
|
213
247
|
fn();
|
|
214
248
|
}),
|
|
@@ -250,6 +284,10 @@ const retainFailedInner = (
|
|
|
250
284
|
failed: [
|
|
251
285
|
...(s.record.failed ?? []),
|
|
252
286
|
{
|
|
287
|
+
protocol: entry.command.protocol,
|
|
288
|
+
...(entry.command.produced !== undefined && {
|
|
289
|
+
produced: entry.command.produced,
|
|
290
|
+
}),
|
|
253
291
|
command: entry.command.method,
|
|
254
292
|
params: entry.command.params,
|
|
255
293
|
...(entry.command.meta !== undefined && { meta: entry.command.meta }),
|
|
@@ -462,8 +500,8 @@ const handleCmd = (s: Session, cmd: StatewireCmd) => {
|
|
|
462
500
|
if (entry) cover(s, entry);
|
|
463
501
|
return;
|
|
464
502
|
}
|
|
465
|
-
parkVerdict(s, cmd);
|
|
466
503
|
if (!entry) return;
|
|
504
|
+
parkVerdict(s, cmd);
|
|
467
505
|
cover(s, entry);
|
|
468
506
|
settleEntry(s, entry, outcomeOf(cmd));
|
|
469
507
|
};
|
|
@@ -495,14 +533,50 @@ const handleCmds = (s: Session, cmds: StatewireCmd[]) => {
|
|
|
495
533
|
};
|
|
496
534
|
|
|
497
535
|
const ingestOps = (s: Session, ops: unknown[]) => {
|
|
498
|
-
const applied = applyDocumentOps(s.documents, ops);
|
|
536
|
+
const applied = applyDocumentOps(s.documents, ops, s.selection?.protocols);
|
|
499
537
|
s.documents = applied;
|
|
500
|
-
|
|
501
|
-
|
|
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
|
+
}
|
|
502
574
|
};
|
|
503
575
|
|
|
504
576
|
const publishIngested = (s: Session) => {
|
|
505
577
|
publish(s);
|
|
578
|
+
cacheDocuments(s);
|
|
579
|
+
for (const listener of s.documentListeners) listener();
|
|
506
580
|
wake(s);
|
|
507
581
|
};
|
|
508
582
|
|
|
@@ -514,6 +588,10 @@ const snapCtxClock = (s: Session, ctxWm: number) => {
|
|
|
514
588
|
s.ctxPending.every((c, i) => c.seq === ctxWm + 1 + i) &&
|
|
515
589
|
s.record.ctxNextSeq === ctxWm + s.ctxPending.length + 1;
|
|
516
590
|
if (!ctxAligned) {
|
|
591
|
+
if (ctxWm > 0)
|
|
592
|
+
throw new Error(
|
|
593
|
+
"statewire: context history is missing; use a new client id",
|
|
594
|
+
);
|
|
517
595
|
setRecord(s, { ...s.record, ctxNextSeq: ctxWm + 1 });
|
|
518
596
|
restateContext(s);
|
|
519
597
|
}
|
|
@@ -531,11 +609,11 @@ const sessionLostInner = (s: Session, seqWm: number, ctxWm: number) => {
|
|
|
531
609
|
setRecord(s, {
|
|
532
610
|
clientId: s.record.clientId,
|
|
533
611
|
nextSeq: seqWm + 1,
|
|
534
|
-
ctxNextSeq:
|
|
612
|
+
ctxNextSeq: s.record.ctxNextSeq,
|
|
535
613
|
commands: [],
|
|
536
614
|
...(s.record.failed !== undefined && { failed: s.record.failed }),
|
|
537
615
|
});
|
|
538
|
-
|
|
616
|
+
snapCtxClock(s, ctxWm);
|
|
539
617
|
if (
|
|
540
618
|
held.length > 0 &&
|
|
541
619
|
s.onSessionLost?.(held.map((p) => p.inv)) === "requeue"
|
|
@@ -561,6 +639,10 @@ const handleHello = (
|
|
|
561
639
|
if (syn.record !== undefined && syn.record !== "ephemeral") {
|
|
562
640
|
protocolError(`unknown record: ${String(syn.record)}`);
|
|
563
641
|
}
|
|
642
|
+
s.selection =
|
|
643
|
+
syn.version === undefined || syn.protocols === undefined
|
|
644
|
+
? undefined
|
|
645
|
+
: { version: syn.version, protocols: syn.protocols };
|
|
564
646
|
s.documents = { mode: null, documents: new Map() };
|
|
565
647
|
ingestOps(s, ops);
|
|
566
648
|
s.attach = "attached";
|
|
@@ -583,6 +665,7 @@ const handleHello = (
|
|
|
583
665
|
s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
|
|
584
666
|
for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
|
|
585
667
|
snapCtxClock(s, ctxWatermark);
|
|
668
|
+
if (s.ctxOps !== undefined) persistContext(s);
|
|
586
669
|
syncBacklogInner(s);
|
|
587
670
|
s.sessionKnown = true;
|
|
588
671
|
};
|
|
@@ -620,6 +703,7 @@ const handleRefusal = (s: Session, syn: StatewireSyn) => {
|
|
|
620
703
|
if (typeof ctxSeq === "number") {
|
|
621
704
|
s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxSeq);
|
|
622
705
|
for (const c of s.ctxPending) c.posted = false;
|
|
706
|
+
if (s.ctxOps !== undefined) persistContext(s);
|
|
623
707
|
}
|
|
624
708
|
if (typeof syn.retry === "number") {
|
|
625
709
|
s.resumeAt = Date.now() + syn.retry;
|
|
@@ -699,7 +783,10 @@ const buildLanes = (s: Session): Lanes | null => {
|
|
|
699
783
|
ctx: ctxEntries.map((c) => ({ ops: c.ops, seq: c.seq })),
|
|
700
784
|
}),
|
|
701
785
|
...(cmdEntries.length > 0 && {
|
|
702
|
-
cmd: cmdEntries.map((p) =>
|
|
786
|
+
cmd: cmdEntries.map((p) => {
|
|
787
|
+
const { produced: _produced, ...command } = p.command;
|
|
788
|
+
return { ...command, seq: p.seq };
|
|
789
|
+
}),
|
|
703
790
|
}),
|
|
704
791
|
},
|
|
705
792
|
cmdEntries,
|
|
@@ -850,6 +937,7 @@ const enqueueInner = (
|
|
|
850
937
|
seq,
|
|
851
938
|
command,
|
|
852
939
|
inv: {
|
|
940
|
+
protocol: command.protocol,
|
|
853
941
|
method: command.method,
|
|
854
942
|
params,
|
|
855
943
|
ts: Date.now(),
|
|
@@ -903,6 +991,12 @@ const adoptStored = (s: Session) => {
|
|
|
903
991
|
const stored = s.record.commands.filter((c) => !known.has(c.seq));
|
|
904
992
|
if (stored.length === 0) return;
|
|
905
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;
|
|
906
1000
|
const params =
|
|
907
1001
|
command.meta === undefined
|
|
908
1002
|
? command.params
|
|
@@ -911,6 +1005,7 @@ const adoptStored = (s: Session) => {
|
|
|
911
1005
|
seq,
|
|
912
1006
|
command,
|
|
913
1007
|
inv: {
|
|
1008
|
+
protocol: command.protocol,
|
|
914
1009
|
method: command.method,
|
|
915
1010
|
params,
|
|
916
1011
|
ts: Date.now(),
|
|
@@ -933,6 +1028,7 @@ const sendCommand = (
|
|
|
933
1028
|
s: Session,
|
|
934
1029
|
method: string,
|
|
935
1030
|
params: readonly unknown[],
|
|
1031
|
+
protocol = s.options.protocol ?? DEFAULT_PROTOCOL,
|
|
936
1032
|
): Promise<unknown> => {
|
|
937
1033
|
if (!s.running) {
|
|
938
1034
|
return rejectedSend({ fate: "aborted", cause: "unmount" });
|
|
@@ -944,7 +1040,19 @@ const sendCommand = (
|
|
|
944
1040
|
const { params: encoded, meta } = encodeParams(params);
|
|
945
1041
|
enqueue(
|
|
946
1042
|
s,
|
|
947
|
-
{
|
|
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
|
+
},
|
|
948
1056
|
params,
|
|
949
1057
|
resolve,
|
|
950
1058
|
reject,
|
|
@@ -1029,6 +1137,8 @@ export const retryFailed = (
|
|
|
1029
1137
|
enqueueInner(
|
|
1030
1138
|
s,
|
|
1031
1139
|
{
|
|
1140
|
+
protocol: current.protocol,
|
|
1141
|
+
...(current.produced !== undefined && { produced: current.produced }),
|
|
1032
1142
|
method: current.command,
|
|
1033
1143
|
params: [...current.params],
|
|
1034
1144
|
...(current.meta !== undefined && { meta: current.meta }),
|
|
@@ -1053,46 +1163,181 @@ export const retryFailed = (
|
|
|
1053
1163
|
return result;
|
|
1054
1164
|
};
|
|
1055
1165
|
|
|
1056
|
-
const
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
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);
|
|
1070
1182
|
s.ctxOps = ops;
|
|
1071
|
-
|
|
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
|
+
);
|
|
1072
1218
|
const seq = s.record.ctxNextSeq;
|
|
1073
|
-
|
|
1074
|
-
|
|
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);
|
|
1075
1234
|
};
|
|
1076
1235
|
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(() => {
|
|
1236
|
+
submit();
|
|
1086
1237
|
wake(s);
|
|
1087
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
|
+
];
|
|
1088
1272
|
});
|
|
1089
1273
|
};
|
|
1090
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
|
+
|
|
1091
1335
|
export const useStatewireSession = (
|
|
1092
1336
|
options: StatewireSessionOptions,
|
|
1093
1337
|
): Session => {
|
|
1094
1338
|
const [defaultStore] = useState(createMemorySessionStore);
|
|
1095
1339
|
const mounted = useContext(SessionStorageContext);
|
|
1340
|
+
const documentStore = useContext(DocumentStorageContext);
|
|
1096
1341
|
if (options.sessionStore !== undefined && mounted !== undefined) {
|
|
1097
1342
|
throw new Error(
|
|
1098
1343
|
"statewire: session storage is set twice — drop the transport's sessionStore or the client's storage.session",
|
|
@@ -1105,7 +1350,35 @@ export const useStatewireSession = (
|
|
|
1105
1350
|
);
|
|
1106
1351
|
}
|
|
1107
1352
|
const [s] = useState((): Session => {
|
|
1108
|
-
|
|
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
|
+
}
|
|
1109
1382
|
if (stored !== null && !Array.isArray(stored.commands)) {
|
|
1110
1383
|
throw new Error(
|
|
1111
1384
|
"statewire: session storage loaded a record without a commands array",
|
|
@@ -1117,7 +1390,17 @@ export const useStatewireSession = (
|
|
|
1117
1390
|
ctxNextSeq: 1,
|
|
1118
1391
|
commands: [],
|
|
1119
1392
|
};
|
|
1393
|
+
if (stored === null) store.save(record);
|
|
1120
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
|
+
),
|
|
1121
1404
|
record,
|
|
1122
1405
|
ctx: {
|
|
1123
1406
|
get clientId() {
|
|
@@ -1134,6 +1417,7 @@ export const useStatewireSession = (
|
|
|
1134
1417
|
},
|
|
1135
1418
|
recordPersisted: stored !== null,
|
|
1136
1419
|
store,
|
|
1420
|
+
documentStore,
|
|
1137
1421
|
shared:
|
|
1138
1422
|
store.lock !== undefined
|
|
1139
1423
|
? { txq: Promise.resolve(), parked: [], unsubscribe: null }
|
|
@@ -1146,13 +1430,15 @@ export const useStatewireSession = (
|
|
|
1146
1430
|
idle: false,
|
|
1147
1431
|
onSessionLost: undefined,
|
|
1148
1432
|
onDone: undefined,
|
|
1149
|
-
ctxOps:
|
|
1150
|
-
ctxPending:
|
|
1433
|
+
ctxOps: stored?.context?.ops,
|
|
1434
|
+
ctxPending:
|
|
1435
|
+
stored?.context?.pending.map((p) => ({ ...p, posted: false })) ?? [],
|
|
1151
1436
|
lanesOnAttach: false,
|
|
1152
1437
|
attachLaneSeqs: [new Set(), new Set()],
|
|
1153
1438
|
frameFailures: 0,
|
|
1154
1439
|
pending: [],
|
|
1155
1440
|
canonical: undefined,
|
|
1441
|
+
documentListeners: new Set(),
|
|
1156
1442
|
documents: { mode: null, documents: new Map() },
|
|
1157
1443
|
lastPublished: undefined,
|
|
1158
1444
|
changed: [],
|
|
@@ -1191,9 +1477,22 @@ export const useStatewireSession = (
|
|
|
1191
1477
|
"statewire: session storage changed sharedness across renders — lock/subscribe must be present from the first render or never",
|
|
1192
1478
|
);
|
|
1193
1479
|
}
|
|
1480
|
+
s.options = options;
|
|
1194
1481
|
s.store = store;
|
|
1482
|
+
s.documentStore = documentStore;
|
|
1195
1483
|
s.onSessionLost = options.onSessionLost;
|
|
1196
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
|
+
);
|
|
1197
1496
|
useEffect(() => {
|
|
1198
1497
|
start(s);
|
|
1199
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