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.
Files changed (96) hide show
  1. package/README.md +12 -4
  2. package/dist/client/connection.d.ts +5 -1
  3. package/dist/client/connection.d.ts.map +1 -1
  4. package/dist/client/connection.js +22 -6
  5. package/dist/client/connection.js.map +1 -1
  6. package/dist/client/core.d.ts.map +1 -1
  7. package/dist/client/core.js +2 -0
  8. package/dist/client/core.js.map +1 -1
  9. package/dist/client/session.d.ts +15 -4
  10. package/dist/client/session.d.ts.map +1 -1
  11. package/dist/client/session.js +235 -52
  12. package/dist/client/session.js.map +1 -1
  13. package/dist/client/sessionStore.d.ts +5 -1
  14. package/dist/client/sessionStore.d.ts.map +1 -1
  15. package/dist/client/sessionStore.js +3 -1
  16. package/dist/client/sessionStore.js.map +1 -1
  17. package/dist/document.d.ts +25 -0
  18. package/dist/document.d.ts.map +1 -0
  19. package/dist/document.js +88 -0
  20. package/dist/document.js.map +1 -0
  21. package/dist/host/draft.js +1 -1
  22. package/dist/host/host.d.ts +1 -0
  23. package/dist/host/host.d.ts.map +1 -1
  24. package/dist/host/host.js.map +1 -1
  25. package/dist/host/http.d.ts.map +1 -1
  26. package/dist/host/http.js +27 -9
  27. package/dist/host/http.js.map +1 -1
  28. package/dist/host/socket.d.ts +12 -5
  29. package/dist/host/socket.d.ts.map +1 -1
  30. package/dist/host/socket.js +107 -43
  31. package/dist/host/socket.js.map +1 -1
  32. package/dist/host-internal.d.ts +9 -1
  33. package/dist/host-internal.d.ts.map +1 -1
  34. package/dist/host-internal.js +1 -1
  35. package/dist/index.d.ts +4 -1
  36. package/dist/index.js +2 -1
  37. package/dist/statewire.d.ts +33 -2
  38. package/dist/statewire.d.ts.map +1 -1
  39. package/dist/statewire.js +34 -3
  40. package/dist/statewire.js.map +1 -1
  41. package/dist/transports/fixtures.js +1 -0
  42. package/dist/transports/fixtures.js.map +1 -1
  43. package/dist/transports/remote.d.ts.map +1 -1
  44. package/dist/transports/remote.js +15 -8
  45. package/dist/transports/remote.js.map +1 -1
  46. package/dist/transports/sse.d.ts.map +1 -1
  47. package/dist/transports/sse.js +8 -8
  48. package/dist/transports/sse.js.map +1 -1
  49. package/dist/transports/testing.d.ts +1 -1
  50. package/dist/transports/testing.d.ts.map +1 -1
  51. package/dist/transports/testing.js +12 -4
  52. package/dist/transports/testing.js.map +1 -1
  53. package/dist/transports/ws.js +2 -2
  54. package/dist/transports/ws.js.map +1 -1
  55. package/dist/types.d.ts +7 -0
  56. package/dist/types.d.ts.map +1 -1
  57. package/dist/types.js.map +1 -1
  58. package/dist/wire/documents.d.ts +25 -2
  59. package/dist/wire/documents.d.ts.map +1 -1
  60. package/dist/wire/documents.js +28 -8
  61. package/dist/wire/documents.js.map +1 -1
  62. package/dist/wire/meta.d.ts +3 -18
  63. package/dist/wire/meta.d.ts.map +1 -1
  64. package/dist/wire/meta.js +8 -57
  65. package/dist/wire/meta.js.map +1 -1
  66. package/dist/wire/ops.js +1 -1
  67. package/dist/wire/protocol.d.ts +55 -0
  68. package/dist/wire/protocol.d.ts.map +1 -0
  69. package/dist/wire/protocol.js +221 -0
  70. package/dist/wire/protocol.js.map +1 -0
  71. package/dist/wire/version.d.ts +1 -3
  72. package/dist/wire/version.d.ts.map +1 -1
  73. package/dist/wire/version.js +1 -3
  74. package/dist/wire/version.js.map +1 -1
  75. package/package.json +1 -1
  76. package/src/client/connection.ts +43 -13
  77. package/src/client/core.ts +6 -0
  78. package/src/client/session.ts +340 -45
  79. package/src/client/sessionStore.ts +8 -0
  80. package/src/document.ts +185 -0
  81. package/src/host/host.ts +1 -0
  82. package/src/host/http.ts +36 -7
  83. package/src/host/socket.ts +149 -57
  84. package/src/host-internal.ts +12 -1
  85. package/src/index.ts +5 -0
  86. package/src/statewire.ts +109 -6
  87. package/src/transports/fixtures.ts +1 -1
  88. package/src/transports/remote.ts +16 -7
  89. package/src/transports/sse.ts +18 -8
  90. package/src/transports/testing.ts +10 -2
  91. package/src/transports/ws.ts +1 -1
  92. package/src/types.ts +11 -0
  93. package/src/wire/documents.ts +126 -8
  94. package/src/wire/meta.ts +11 -94
  95. package/src/wire/protocol.ts +327 -0
  96. package/src/wire/version.ts +0 -2
@@ -1,4 +1,17 @@
1
- import { applyDocumentOps } from "../wire/documents";
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
- s.canonical = applied.documents.get(0)?.value;
501
- s.changed.push(...applied.changedPaths);
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: ctxWm + 1,
608
+ ctxNextSeq: s.record.ctxNextSeq,
535
609
  commands: [],
536
610
  ...(s.record.failed !== undefined && { failed: s.record.failed }),
537
611
  });
538
- restateContext(s);
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) => ({ ...p.command, seq: p.seq })),
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
- { method, params: encoded, ...(meta !== undefined && { meta }) },
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 setContext = (s: Session, value: Record<string, unknown>): void => {
1057
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
1058
- throw new Error("statewire: context must be a plain object");
1059
- }
1060
- if (!s.running || isFinished(s)) return;
1061
- const { value: encoded, meta } = encodeValue(value);
1062
- const ops = [
1063
- {
1064
- op: "replace",
1065
- path: [],
1066
- value: encoded,
1067
- ...(meta !== undefined && { meta }),
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
- const mint = () => {
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
- setRecord(s, { ...s.record, ctxNextSeq: seq + 1 });
1074
- s.ctxPending.push({ seq, ops, posted: false });
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
- mint();
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
- const stored = store.load();
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: undefined,
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);