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