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.
Files changed (83) 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 +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 +232 -50
  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 +3 -1
  31. package/dist/statewire.d.ts +33 -2
  32. package/dist/statewire.d.ts.map +1 -1
  33. package/dist/statewire.js +34 -3
  34. package/dist/statewire.js.map +1 -1
  35. package/dist/transports/fixtures.js +1 -0
  36. package/dist/transports/fixtures.js.map +1 -1
  37. package/dist/transports/remote.d.ts.map +1 -1
  38. package/dist/transports/remote.js +15 -8
  39. package/dist/transports/remote.js.map +1 -1
  40. package/dist/transports/sse.d.ts.map +1 -1
  41. package/dist/transports/sse.js +8 -8
  42. package/dist/transports/sse.js.map +1 -1
  43. package/dist/transports/testing.d.ts +1 -1
  44. package/dist/transports/testing.d.ts.map +1 -1
  45. package/dist/transports/testing.js +12 -4
  46. package/dist/transports/testing.js.map +1 -1
  47. package/dist/transports/ws.js +2 -2
  48. package/dist/transports/ws.js.map +1 -1
  49. package/dist/types.d.ts +7 -0
  50. package/dist/types.d.ts.map +1 -1
  51. package/dist/types.js.map +1 -1
  52. package/dist/wire/documents.d.ts +25 -2
  53. package/dist/wire/documents.d.ts.map +1 -1
  54. package/dist/wire/documents.js +27 -7
  55. package/dist/wire/documents.js.map +1 -1
  56. package/dist/wire/protocol.d.ts +55 -0
  57. package/dist/wire/protocol.d.ts.map +1 -0
  58. package/dist/wire/protocol.js +221 -0
  59. package/dist/wire/protocol.js.map +1 -0
  60. package/dist/wire/version.d.ts +1 -3
  61. package/dist/wire/version.d.ts.map +1 -1
  62. package/dist/wire/version.js +1 -3
  63. package/dist/wire/version.js.map +1 -1
  64. package/package.json +1 -1
  65. package/src/client/connection.ts +43 -13
  66. package/src/client/core.ts +6 -0
  67. package/src/client/session.ts +338 -39
  68. package/src/client/sessionStore.ts +8 -0
  69. package/src/host/host.ts +1 -0
  70. package/src/host/http.ts +36 -7
  71. package/src/host/socket.ts +141 -50
  72. package/src/host-internal.ts +12 -1
  73. package/src/index.ts +3 -0
  74. package/src/statewire.ts +109 -6
  75. package/src/transports/fixtures.ts +1 -1
  76. package/src/transports/remote.ts +16 -7
  77. package/src/transports/sse.ts +18 -8
  78. package/src/transports/testing.ts +10 -2
  79. package/src/transports/ws.ts +1 -1
  80. package/src/types.ts +11 -0
  81. package/src/wire/documents.ts +126 -8
  82. package/src/wire/protocol.ts +327 -0
  83. 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,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
- s.canonical = applied.documents.get(0)?.value;
501
- 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
+ }
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: ctxWm + 1,
612
+ ctxNextSeq: s.record.ctxNextSeq,
535
613
  commands: [],
536
614
  ...(s.record.failed !== undefined && { failed: s.record.failed }),
537
615
  });
538
- restateContext(s);
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) => ({ ...p.command, seq: p.seq })),
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
- { 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
+ },
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 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
- ];
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
- 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
+ );
1072
1218
  const seq = s.record.ctxNextSeq;
1073
- setRecord(s, { ...s.record, ctxNextSeq: seq + 1 });
1074
- 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);
1075
1234
  };
1076
1235
  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(() => {
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
- 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
+ }
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: undefined,
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
@@ -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;