statewire 0.9.2 → 0.11.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.
@@ -1,17 +1,20 @@
1
- import { useEffect, useState } from "react";
1
+ import { useContext, useEffect, useState } from "react";
2
2
  import { applyOps } from "../wire/ops";
3
3
  import type { StatewirePath } from "../wire/ops";
4
4
  import type { StatewireCmd, StatewireSyn } from "./connection";
5
- import { encodeParams, encodeValue, reviveResponse } from "../wire/meta";
5
+ import {
6
+ encodeParams,
7
+ encodeValue,
8
+ reviveResponse,
9
+ reviveValue,
10
+ } from "../wire/meta";
6
11
  import { bindCommandsHost } from "../host/host";
7
12
  import type { CommandsInvocation, CommandsListener } from "../host/host";
8
13
  import type { Statewire } from "../types";
9
14
  import type { StatewireClient } from "../statewire";
10
- import { createMemorySessionStore } from "./sessionStore";
11
- import type {
12
- StatewireSessionRecord,
13
- StatewireSessionStore,
14
- StatewireSessionStoreOptions,
15
+ import {
16
+ SessionStorageContext,
17
+ createMemorySessionStore,
15
18
  } from "./sessionStore";
16
19
  import { backoff } from "./timing";
17
20
  import { createMethodProxy } from "../util/methodProxy";
@@ -65,16 +68,26 @@ const rejectedSend = (
65
68
 
66
69
  type AttachState = "detached" | "attached" | "finished";
67
70
 
68
- export type StatewireSessionOptions = StatewireSessionStoreOptions & {
71
+ export type StatewireSessionOptions = {
72
+ sessionStore?: StatewireClient.SessionStorage | undefined;
69
73
  onDone?: (() => void) | undefined;
70
74
  };
71
75
 
76
+ type Shared = {
77
+ txq: Promise<void>;
78
+ parked: StatewireClient.SessionVerdict[];
79
+ unsubscribe: (() => void) | null;
80
+ };
81
+
72
82
  type Session = {
73
- record: StatewireSessionRecord;
83
+ record: StatewireClient.SessionRecord;
74
84
  ctx: StatewireRemoteProps;
75
85
  recordPersisted: boolean;
76
- store: StatewireSessionStore;
86
+ store: StatewireClient.SessionStorage;
87
+ shared: Shared | false;
77
88
  ephemeral: boolean;
89
+ /** An ephemeral record died with its attach: the storage is off for good. */
90
+ deactivated: boolean;
78
91
  sessionKnown: boolean;
79
92
  durableSeq: number;
80
93
  resumeAt: number;
@@ -128,9 +141,78 @@ const sleepUntilWake = (s: Session, delayMs: number) =>
128
141
  s.waiters.push(waiter);
129
142
  });
130
143
 
131
- const setRecord = (s: Session, record: StatewireSessionRecord) => {
144
+ const setRecord = (s: Session, record: StatewireClient.SessionRecord) => {
132
145
  s.record = record;
133
- if (!s.ephemeral) s.store.save(record);
146
+ if (!s.deactivated) s.store.save(record);
147
+ };
148
+
149
+ const backlog = (s: Session): StatewireClient.SessionCommand[] =>
150
+ s.pending
151
+ .filter((p) => p.seq > s.durableSeq)
152
+ .map((p) => ({ seq: p.seq, ...p.command }));
153
+
154
+ const mergeBacklog = (s: Session): StatewireClient.SessionCommand[] => {
155
+ const own = backlog(s);
156
+ if (!s.shared) return own;
157
+ const known = new Set(s.pending.map((p) => p.seq));
158
+ const foreign = s.record.commands.filter(
159
+ (c) => !known.has(c.seq) && c.seq > s.durableSeq,
160
+ );
161
+ return [...foreign, ...own].sort((a, b) => a.seq - b.seq);
162
+ };
163
+
164
+ const MAX_PARKED_VERDICTS = 64;
165
+
166
+ // Storage holds what a reload must resend: a command leaves at its dur, at a
167
+ // server verdict, or at a record reset — never at a local channel death.
168
+ const syncBacklogInner = (s: Session) => {
169
+ const parked = s.shared ? s.shared.parked.splice(0) : [];
170
+ const parkedSeqs = new Set(parked.map((v) => v.seq));
171
+ const commands = mergeBacklog(s).filter((c) => !parkedSeqs.has(c.seq));
172
+ const verdicts =
173
+ parked.length > 0
174
+ ? [...(s.record.verdicts ?? []), ...parked].slice(-MAX_PARKED_VERDICTS)
175
+ : s.record.verdicts;
176
+ const unchanged =
177
+ verdicts === s.record.verdicts &&
178
+ commands.length === s.record.commands.length &&
179
+ commands.every((c, i) => c.seq === s.record.commands[i]!.seq);
180
+ if (unchanged) return;
181
+ setRecord(s, {
182
+ ...s.record,
183
+ commands,
184
+ ...(verdicts !== undefined && { verdicts }),
185
+ });
186
+ };
187
+
188
+ const syncBacklog = (s: Session) => {
189
+ if (!s.shared) return syncBacklogInner(s);
190
+ void transact(s, () => syncBacklogInner(s));
191
+ };
192
+
193
+ // Every record mutation over shared storage is a locked read-modify-write.
194
+ const transact = (s: Session, fn: () => void): Promise<void> => {
195
+ if (!s.shared || s.deactivated) {
196
+ fn();
197
+ return Promise.resolve();
198
+ }
199
+ const shared = s.shared;
200
+ const run = shared.txq.then(() =>
201
+ s.store.lock!(async () => {
202
+ const stored = s.store.load();
203
+ if (stored !== null && stored.clientId !== s.record.clientId) {
204
+ adoptReset(s, stored);
205
+ } else if (stored !== null) {
206
+ s.record = stored;
207
+ }
208
+ fn();
209
+ }),
210
+ );
211
+ shared.txq = run.then(
212
+ () => {},
213
+ () => {},
214
+ );
215
+ return run;
134
216
  };
135
217
 
136
218
  const setIdle = (s: Session, level: boolean) => {
@@ -223,14 +305,43 @@ const resetRecord = (s: Session, clientId: string) => {
223
305
  }
224
306
  s.pending = [];
225
307
  s.durableSeq = 0;
226
- setRecord(s, { clientId, nextSeq: 1, ctxNextSeq: 1 });
308
+ setRecord(s, { clientId, nextSeq: 1, ctxNextSeq: 1, commands: [] });
309
+ for (const old of survivors) {
310
+ s.lifecycle?.onCovered?.(old.inv);
311
+ enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
312
+ }
313
+ restateContext(s);
314
+ publish(s);
315
+ wake(s);
316
+ void pump(s);
317
+ };
318
+
319
+ // Another sharer replaced the record's identity: their reset settles our
320
+ // attempted history and our survivors re-stamp on the stored clocks.
321
+ const adoptReset = (s: Session, stored: StatewireClient.SessionRecord) => {
322
+ const survivors: Pending[] = [];
323
+ for (const entry of [...s.pending]) {
324
+ if (entry.attempted || entry.applied) {
325
+ s.lifecycle?.onCovered?.(entry.inv);
326
+ settleEntry(s, entry, {
327
+ ok: false,
328
+ failure: { fate: "lost", cause: "session-lost" },
329
+ });
330
+ } else {
331
+ survivors.push(entry);
332
+ }
333
+ }
334
+ s.pending = [];
335
+ s.durableSeq = 0;
336
+ s.record = stored;
227
337
  for (const old of survivors) {
228
338
  s.lifecycle?.onCovered?.(old.inv);
229
- enqueue(s, old.command, old.inv.params, old.resolve, old.reject);
339
+ enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
230
340
  }
231
341
  restateContext(s);
232
342
  publish(s);
233
343
  wake(s);
344
+ void pump(s);
234
345
  };
235
346
 
236
347
  // The record died with the attach: mint a fresh id for the next one.
@@ -250,6 +361,59 @@ const rejectedFailure = (cmd: StatewireCmd): StatewireClient.SendFailure => {
250
361
  };
251
362
  };
252
363
 
364
+ const outcomeOf = (
365
+ cmd: StatewireCmd,
366
+ ):
367
+ | { ok: true; payload: unknown }
368
+ | { ok: false; failure: StatewireClient.SendFailure } =>
369
+ cmd.type === "result"
370
+ ? { ok: true as const, payload: reviveResponse(cmd).payload }
371
+ : cmd.type === "rejected"
372
+ ? { ok: false as const, failure: rejectedFailure(reviveResponse(cmd)) }
373
+ : cmd.type === "failed"
374
+ ? {
375
+ ok: false as const,
376
+ failure: {
377
+ fate: "failed" as const,
378
+ cause: "server" as const,
379
+ ...(cmd.message !== undefined && { message: cmd.message }),
380
+ },
381
+ }
382
+ : cmd.type === "crashed"
383
+ ? {
384
+ ok: false as const,
385
+ failure: { fate: "crashed" as const, cause: "server" as const },
386
+ }
387
+ : cmd.type === "expired"
388
+ ? {
389
+ ok: false as const,
390
+ failure: {
391
+ fate: "expired" as const,
392
+ cause: "server" as const,
393
+ },
394
+ }
395
+ : protocolError(`unknown cmd type: ${String(cmd.type)}`);
396
+
397
+ const TERMINAL_CMD_TYPES = new Set([
398
+ "result",
399
+ "rejected",
400
+ "failed",
401
+ "crashed",
402
+ "expired",
403
+ ]);
404
+
405
+ const parkVerdict = (s: Session, cmd: StatewireCmd) => {
406
+ if (!s.shared || !TERMINAL_CMD_TYPES.has(cmd.type!)) return;
407
+ s.shared.parked.push({
408
+ seq: cmd.seq,
409
+ type: cmd.type as StatewireClient.SessionVerdict["type"],
410
+ ...(cmd.code !== undefined && { code: cmd.code }),
411
+ ...(cmd.message !== undefined && { message: cmd.message }),
412
+ ...(cmd.payload !== undefined && { payload: cmd.payload }),
413
+ ...(cmd.meta !== undefined && { meta: cmd.meta }),
414
+ });
415
+ };
416
+
253
417
  const handleCmd = (s: Session, cmd: StatewireCmd) => {
254
418
  if (!Number.isSafeInteger(cmd.seq) || cmd.seq <= 0) {
255
419
  protocolError("cmd.seq must be a positive safe integer");
@@ -266,41 +430,35 @@ const handleCmd = (s: Session, cmd: StatewireCmd) => {
266
430
  if (entry) cover(s, entry);
267
431
  return;
268
432
  }
433
+ parkVerdict(s, cmd);
269
434
  if (!entry) return;
270
435
  cover(s, entry);
271
- const outcome =
272
- cmd.type === "result"
273
- ? { ok: true as const, payload: reviveResponse(cmd).payload }
274
- : cmd.type === "rejected"
275
- ? { ok: false as const, failure: rejectedFailure(reviveResponse(cmd)) }
276
- : cmd.type === "failed"
277
- ? {
278
- ok: false as const,
279
- failure: {
280
- fate: "failed" as const,
281
- cause: "server" as const,
282
- ...(cmd.message !== undefined && { message: cmd.message }),
283
- },
284
- }
285
- : cmd.type === "crashed"
286
- ? {
287
- ok: false as const,
288
- failure: { fate: "crashed" as const, cause: "server" as const },
289
- }
290
- : cmd.type === "expired"
291
- ? {
292
- ok: false as const,
293
- failure: {
294
- fate: "expired" as const,
295
- cause: "server" as const,
296
- },
297
- }
298
- : protocolError(`unknown cmd type: ${String(cmd.type)}`);
299
- settleEntry(s, entry, outcome);
436
+ settleEntry(s, entry, outcomeOf(cmd));
437
+ };
438
+
439
+ const consumeVerdicts = (s: Session) => {
440
+ const verdicts = s.record.verdicts ?? [];
441
+ const consumed = new Set<number>();
442
+ for (const verdict of verdicts) {
443
+ const entry = s.pending.find((p) => p.seq === verdict.seq);
444
+ if (!entry) continue;
445
+ consumed.add(verdict.seq);
446
+ cover(s, entry);
447
+ settleEntry(s, entry, outcomeOf(verdict as StatewireCmd));
448
+ }
449
+ if (consumed.size === 0) return;
450
+ setRecord(s, {
451
+ ...s.record,
452
+ commands: s.record.commands.filter((c) => !consumed.has(c.seq)),
453
+ verdicts: verdicts.filter((v) => !consumed.has(v.seq)),
454
+ });
455
+ publish(s);
456
+ wake(s);
300
457
  };
301
458
 
302
459
  const handleCmds = (s: Session, cmds: StatewireCmd[]) => {
303
460
  for (const cmd of cmds) handleCmd(s, cmd);
461
+ syncBacklog(s);
304
462
  wake(s);
305
463
  };
306
464
 
@@ -330,7 +488,7 @@ const snapClocks = (s: Session, seqWm: number, ctxWm: number) => {
330
488
  setRecord(s, { ...s.record, nextSeq: seqWm + 1 });
331
489
  for (const old of tail) {
332
490
  s.lifecycle?.onCovered?.(old.inv);
333
- enqueue(s, old.command, old.inv.params, old.resolve, old.reject);
491
+ enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
334
492
  }
335
493
  }
336
494
  const ctxAligned =
@@ -356,24 +514,37 @@ const handleHello = (
356
514
  s.frameFailures = 0;
357
515
  s.resumeAt = 0;
358
516
  s.ephemeral = record === "ephemeral";
517
+ if (s.ephemeral) s.deactivated = true;
359
518
  const [attachCmd, attachCtx] = s.attachLaneSeqs;
360
519
  s.attachLaneSeqs = [new Set(), new Set()];
361
- if (record === "resumed") {
362
- // Resending unsettled statements is exactly-once-safe: absorbed replays
363
- // re-emit their stored verdicts (applied while still in flight).
364
- for (const entry of s.pending) {
365
- entry.posted = attachCmd.has(entry.seq);
520
+ const reconcile = () => {
521
+ if (record === "resumed") {
522
+ if (s.shared) adoptStored(s);
523
+ // Resending unsettled statements is exactly-once-safe: absorbed replays
524
+ // re-emit their stored verdicts (applied while still in flight).
525
+ for (const entry of s.pending) {
526
+ entry.posted = attachCmd.has(entry.seq);
527
+ }
528
+ const ctxWatermark = syn.ctxSeq ?? 0;
529
+ s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
530
+ for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
531
+ snapClocks(s, syn.seq ?? 0, ctxWatermark);
532
+ syncBacklogInner(s);
533
+ } else {
534
+ resetRecord(s, s.record.clientId);
366
535
  }
367
- const ctxWatermark = syn.ctxSeq ?? 0;
368
- s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
369
- for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
370
- snapClocks(s, syn.seq ?? 0, ctxWatermark);
371
- } else {
372
- resetRecord(s, s.record.clientId);
536
+ s.sessionKnown = true;
537
+ };
538
+ if (!s.shared || s.deactivated) {
539
+ reconcile();
540
+ publishIngested(s);
541
+ void pump(s);
542
+ return;
373
543
  }
374
- s.sessionKnown = true;
375
- publishIngested(s);
376
- void pump(s);
544
+ void transact(s, reconcile).then(() => {
545
+ publishIngested(s);
546
+ void pump(s);
547
+ });
377
548
  };
378
549
 
379
550
  const handleOps = (
@@ -382,6 +553,7 @@ const handleOps = (
382
553
  ) => {
383
554
  ingestOps(s, ops);
384
555
  for (const cmd of cmds) handleCmd(s, cmd);
556
+ syncBacklog(s);
385
557
  publishIngested(s);
386
558
  };
387
559
 
@@ -425,7 +597,11 @@ const handleDetach = (s: Session) => {
425
597
  const handleFinish = (s: Session, { reason }: { reason: string }) => {
426
598
  if (reason === "evicted" || reason === "error") return;
427
599
  s.attach = "finished";
428
- settleAll(s, reason === "superseded" ? "superseded" : "gone");
600
+ // Superseded over shared storage: the superseder resends our backlog and
601
+ // parks the verdicts; our promises settle from the observed record.
602
+ if (!(s.shared && reason === "superseded")) {
603
+ settleAll(s, reason === "superseded" ? "superseded" : "gone");
604
+ }
429
605
  publish(s);
430
606
  wake(s);
431
607
  };
@@ -510,7 +686,12 @@ const pump = async (s: Session) => {
510
686
  for (const entry of lanes.cmdEntries) entry.attempted = true;
511
687
  let result;
512
688
  try {
513
- result = await transport.send(lanes.frame);
689
+ // Shared storage: the lock across the send keeps per-lane arrival
690
+ // ordered across sharers during attach handover.
691
+ const send = () => transport.send(lanes.frame);
692
+ result = await (s.shared && !s.deactivated
693
+ ? s.store.lock!(send)
694
+ : send());
514
695
  } catch {
515
696
  if (!s.running || isFinished(s)) return;
516
697
  s.frameFailures += 1;
@@ -554,16 +735,34 @@ const pump = async (s: Session) => {
554
735
  };
555
736
 
556
737
  const start = (s: Session) => {
738
+ if (s.shared) {
739
+ s.shared.unsubscribe = s.store.subscribe!(() => {
740
+ void transact(s, () => consumeVerdicts(s));
741
+ });
742
+ s.running = true;
743
+ void transact(s, () => {
744
+ if (s.store.load() === null) s.store.save(s.record);
745
+ s.recordPersisted = true;
746
+ adoptStored(s);
747
+ consumeVerdicts(s);
748
+ }).then(() => void pump(s));
749
+ return;
750
+ }
557
751
  if (!s.recordPersisted) {
558
752
  s.recordPersisted = true;
559
753
  s.store.save(s.record);
560
754
  }
755
+ adoptStored(s);
561
756
  s.running = true;
562
757
  void pump(s);
563
758
  };
564
759
 
565
760
  const stop = (s: Session) => {
566
761
  s.running = false;
762
+ if (s.shared) {
763
+ s.shared.unsubscribe?.();
764
+ s.shared.unsubscribe = null;
765
+ }
567
766
  wake(s);
568
767
  settleAll(s, "unmount");
569
768
  };
@@ -574,7 +773,7 @@ const setTransport = (s: Session, transport: RemoteLink) => {
574
773
  wake(s);
575
774
  };
576
775
 
577
- const enqueue = (
776
+ const enqueueInner = (
578
777
  s: Session,
579
778
  command: WireCommand,
580
779
  params: readonly unknown[],
@@ -582,7 +781,6 @@ const enqueue = (
582
781
  reject: (error: StatewireSendError) => void,
583
782
  ) => {
584
783
  const seq = s.record.nextSeq;
585
- setRecord(s, { ...s.record, nextSeq: seq + 1 });
586
784
  const entry: Pending = {
587
785
  seq,
588
786
  command,
@@ -600,8 +798,58 @@ const enqueue = (
600
798
  reject,
601
799
  };
602
800
  s.pending.push(entry);
801
+ setRecord(s, { ...s.record, nextSeq: seq + 1, commands: mergeBacklog(s) });
603
802
  s.lifecycle?.onStart(entry.inv);
604
- void pump(s);
803
+ };
804
+
805
+ const enqueue = (
806
+ s: Session,
807
+ command: WireCommand,
808
+ params: readonly unknown[],
809
+ resolve: (payload: unknown) => void,
810
+ reject: (error: StatewireSendError) => void,
811
+ ) => {
812
+ if (!s.shared) {
813
+ enqueueInner(s, command, params, resolve, reject);
814
+ void pump(s);
815
+ return;
816
+ }
817
+ void transact(s, () =>
818
+ enqueueInner(s, command, params, resolve, reject),
819
+ ).then(() => void pump(s));
820
+ };
821
+
822
+ // A stored command cannot be told from one that reached the server: attempted
823
+ // keeps a fresh record settling it lost instead of replaying it blind.
824
+ const adoptStored = (s: Session) => {
825
+ const known = new Set(s.pending.map((p) => p.seq));
826
+ const stored = s.record.commands.filter((c) => !known.has(c.seq));
827
+ if (stored.length === 0) return;
828
+ for (const { seq, ...command } of stored) {
829
+ const params =
830
+ command.meta === undefined
831
+ ? command.params
832
+ : (reviveValue([...command.params], command.meta) as unknown[]);
833
+ const entry: Pending = {
834
+ seq,
835
+ command,
836
+ inv: {
837
+ method: command.method,
838
+ params,
839
+ ts: Date.now(),
840
+ seq,
841
+ clientId: s.record.clientId,
842
+ },
843
+ attempted: true,
844
+ posted: false,
845
+ applied: false,
846
+ resolve: () => {},
847
+ reject: () => {},
848
+ };
849
+ s.pending.push(entry);
850
+ s.lifecycle?.onStart(entry.inv);
851
+ }
852
+ s.pending.sort((a, b) => a.seq - b.seq);
605
853
  };
606
854
 
607
855
  const sendCommand = (
@@ -633,8 +881,9 @@ const setContext = (s: Session, value: Record<string, unknown>): void => {
633
881
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
634
882
  throw new Error("statewire: context must be a plain object");
635
883
  }
884
+ if (!s.running || isFinished(s)) return;
636
885
  const { value: encoded, meta } = encodeValue(value);
637
- s.ctxOps = [
886
+ const ops = [
638
887
  {
639
888
  op: "replace",
640
889
  path: [],
@@ -642,24 +891,52 @@ const setContext = (s: Session, value: Record<string, unknown>): void => {
642
891
  ...(meta !== undefined && { meta }),
643
892
  },
644
893
  ];
645
- const seq = s.record.ctxNextSeq;
646
- setRecord(s, { ...s.record, ctxNextSeq: seq + 1 });
647
- s.ctxPending.push({ seq, ops: s.ctxOps, posted: false });
648
- wake(s);
649
- void pump(s);
894
+ s.ctxOps = ops;
895
+ const mint = () => {
896
+ const seq = s.record.ctxNextSeq;
897
+ setRecord(s, { ...s.record, ctxNextSeq: seq + 1 });
898
+ s.ctxPending.push({ seq, ops, posted: false });
899
+ };
900
+ if (!s.shared) {
901
+ mint();
902
+ wake(s);
903
+ void pump(s);
904
+ return;
905
+ }
906
+ void transact(s, mint).then(() => {
907
+ wake(s);
908
+ void pump(s);
909
+ });
650
910
  };
651
911
 
652
912
  export const useStatewireSession = (
653
913
  options: StatewireSessionOptions,
654
914
  ): Session => {
655
915
  const [defaultStore] = useState(createMemorySessionStore);
656
- const store = options.sessionStore ?? defaultStore;
916
+ const mounted = useContext(SessionStorageContext);
917
+ if (options.sessionStore !== undefined && mounted !== undefined) {
918
+ throw new Error(
919
+ "statewire: session storage is set twice — drop the transport's sessionStore or the client's storage.session",
920
+ );
921
+ }
922
+ const store = options.sessionStore ?? mounted ?? defaultStore;
923
+ if ((store.lock === undefined) !== (store.subscribe === undefined)) {
924
+ throw new Error(
925
+ "statewire: session storage must provide lock and subscribe together",
926
+ );
927
+ }
657
928
  const [s] = useState((): Session => {
658
929
  const stored = store.load();
930
+ if (stored !== null && !Array.isArray(stored.commands)) {
931
+ throw new Error(
932
+ "statewire: session storage loaded a record without a commands array",
933
+ );
934
+ }
659
935
  const record = stored ?? {
660
936
  clientId: newClientId(),
661
937
  nextSeq: 1,
662
938
  ctxNextSeq: 1,
939
+ commands: [],
663
940
  };
664
941
  const session: Session = {
665
942
  record,
@@ -678,7 +955,12 @@ export const useStatewireSession = (
678
955
  },
679
956
  recordPersisted: stored !== null,
680
957
  store,
958
+ shared:
959
+ store.lock !== undefined
960
+ ? { txq: Promise.resolve(), parked: [], unsubscribe: null }
961
+ : false,
681
962
  ephemeral: false,
963
+ deactivated: false,
682
964
  sessionKnown: false,
683
965
  durableSeq: 0,
684
966
  resumeAt: 0,
@@ -1,20 +1,13 @@
1
- export type StatewireSessionRecord = {
2
- clientId: string;
3
- nextSeq: number;
4
- ctxNextSeq: number;
5
- };
6
-
7
- export type StatewireSessionStore = {
8
- load: () => StatewireSessionRecord | null;
9
- save: (record: StatewireSessionRecord) => void;
10
- };
1
+ import { createContext } from "react";
2
+ import type { StatewireClient } from "../statewire";
11
3
 
12
- export type StatewireSessionStoreOptions = {
13
- sessionStore?: StatewireSessionStore | undefined;
14
- };
4
+ /** The session storage `useStatewire` mounts, read by the transport's session. */
5
+ export const SessionStorageContext = createContext<
6
+ StatewireClient.SessionStorage | undefined
7
+ >(undefined);
15
8
 
16
- export const createMemorySessionStore = (): StatewireSessionStore => {
17
- let record: StatewireSessionRecord | null = null;
9
+ export const createMemorySessionStore = (): StatewireClient.SessionStorage => {
10
+ let record: StatewireClient.SessionRecord | null = null;
18
11
  return {
19
12
  load: () => record,
20
13
  save: (next) => {