statewire 0.9.2 → 0.10.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.
@@ -12,8 +12,9 @@ import { createMacrotaskPoster } from "../util/macrotask";
12
12
  import type { StatewireMetaEntry } from "../wire/meta";
13
13
  import type { StatewirePath } from "../wire/ops";
14
14
  import type { StatewireCmd, StatewireEnvelope } from "../client/connection";
15
+ import { frames as serveFrames, stream as serveStream } from "./http";
15
16
 
16
- const CLIENT_ID_RE = /^[A-Za-z0-9._~-]{1,256}$/;
17
+ export const CLIENT_ID_RE = /^[A-Za-z0-9._~-]{1,256}$/;
17
18
  const RESULT_TTL_MS = 30_000;
18
19
  const RESULT_LIMIT = 256;
19
20
  const CLIENT_RECORD_LIMIT = 1024;
@@ -33,17 +34,18 @@ type Admission = {
33
34
  settled: boolean;
34
35
  };
35
36
 
36
- type ClientRec = {
37
+ export type ClientRec = {
37
38
  id: string;
38
39
  lastSeq: number;
39
40
  ctxSeq: number;
41
+ lease: string | null;
40
42
  touched: number;
41
43
  context: Record<string, unknown> | null;
42
44
  results: Map<number, { ts: number; cmd: StatewireCmd }>;
43
45
  inflight: Map<number, Admission>;
44
46
  };
45
47
 
46
- type Conn = {
48
+ export type Conn = {
47
49
  clientId: string;
48
50
  socket: StatewireHostInternal.Socket;
49
51
  open: boolean;
@@ -70,7 +72,7 @@ type CmdStatement = {
70
72
  meta?: unknown;
71
73
  };
72
74
 
73
- type ParsedFrame = {
75
+ export type ParsedFrame = {
74
76
  headers: Record<string, string> | null;
75
77
  ctx: CtxStatement[];
76
78
  cmd: CmdStatement[];
@@ -121,13 +123,10 @@ const contextValue = (ops: unknown): Record<string, unknown> => {
121
123
  return value;
122
124
  };
123
125
 
124
- const parseFrame = (raw: string, first: boolean): ParsedFrame => {
125
- let frame: unknown;
126
- try {
127
- frame = JSON.parse(raw);
128
- } catch {
129
- return violation("malformed frame");
130
- }
126
+ export const parseFrameValue = (
127
+ frame: unknown,
128
+ first: boolean,
129
+ ): ParsedFrame => {
131
130
  if (!isPlainObject(frame)) return violation("malformed frame");
132
131
  if (
133
132
  frame["headers"] === undefined &&
@@ -179,6 +178,16 @@ const parseFrame = (raw: string, first: boolean): ParsedFrame => {
179
178
  return { headers, ctx, cmd };
180
179
  };
181
180
 
181
+ export const parseFrame = (raw: string, first: boolean): ParsedFrame => {
182
+ let frame: unknown;
183
+ try {
184
+ frame = JSON.parse(raw);
185
+ } catch {
186
+ return violation("malformed frame");
187
+ }
188
+ return parseFrameValue(frame, first);
189
+ };
190
+
182
191
  // ─── verdict encoding ───────────────────────────────────
183
192
 
184
193
  const encodePayload = (
@@ -236,10 +245,11 @@ const cmdOf = (adm: Admission, outcome: CommandsOutcome): StatewireCmd => {
236
245
 
237
246
  // ─── host ───────────────────────────────────────────────
238
247
 
239
- type Host = {
248
+ export type SocketHostState = {
240
249
  heartbeatMs: number;
241
250
  clientRecordLimit: number;
242
251
  finOnIdle: boolean;
252
+ authorize: StatewireHostInternal.SocketHostOptions["authorize"];
243
253
  clients: Map<string, ClientRec>;
244
254
  conns: Set<Conn>;
245
255
  pendingOps: WireOp[];
@@ -259,16 +269,18 @@ type Host = {
259
269
  socket: StatewireHostInternal.Socket,
260
270
  ) => StatewireHostInternal.SocketConnection;
261
271
  finish: (fin: StatewireHost.Fin) => void;
272
+ stream: (request: Request) => Promise<Response>;
273
+ frames: (request: Request) => Promise<Response>;
262
274
  };
263
275
 
264
- const scheduleFlush = (h: Host) => {
276
+ const scheduleFlush = (h: SocketHostState) => {
265
277
  if (h.flushScheduled || h.disposed) return;
266
278
  h.flushScheduled = true;
267
279
  h.post();
268
280
  };
269
281
 
270
282
  const recordOp = (
271
- h: Host,
283
+ h: SocketHostState,
272
284
  op: {
273
285
  op: string;
274
286
  path?: StatewirePath;
@@ -284,7 +296,7 @@ const recordOp = (
284
296
  scheduleFlush(h);
285
297
  };
286
298
 
287
- const attach = (h: Host, next: Statewire<unknown>) => {
299
+ const attach = (h: SocketHostState, next: Statewire<unknown>) => {
288
300
  if (next === h.current) return;
289
301
  h.current = next;
290
302
  const nextState =
@@ -305,7 +317,12 @@ const attach = (h: Host, next: Statewire<unknown>) => {
305
317
  h.commandsHost = nextCommands;
306
318
  };
307
319
 
308
- const closeConn = (h: Host, conn: Conn, code?: number, reason?: string) => {
320
+ export const closeConn = (
321
+ h: SocketHostState,
322
+ conn: Conn,
323
+ code?: number,
324
+ reason?: string,
325
+ ) => {
309
326
  if (!conn.open) return;
310
327
  conn.open = false;
311
328
  h.conns.delete(conn);
@@ -320,7 +337,7 @@ const closeConn = (h: Host, conn: Conn, code?: number, reason?: string) => {
320
337
  }
321
338
  };
322
339
 
323
- const sendTo = (h: Host, conn: Conn, data: string) => {
340
+ export const sendTo = (h: SocketHostState, conn: Conn, data: string) => {
324
341
  if (!conn.open) return;
325
342
  conn.lastSentAt = Date.now();
326
343
  try {
@@ -330,7 +347,7 @@ const sendTo = (h: Host, conn: Conn, data: string) => {
330
347
  }
331
348
  };
332
349
 
333
- const ensureHeartbeat = (h: Host) => {
350
+ const ensureHeartbeat = (h: SocketHostState) => {
334
351
  if (h.heartbeatTimer !== null || h.heartbeatMs <= 0) return;
335
352
  h.heartbeatTimer = setInterval(() => {
336
353
  const now = Date.now();
@@ -341,7 +358,7 @@ const ensureHeartbeat = (h: Host) => {
341
358
  (h.heartbeatTimer as unknown as { unref?: () => void }).unref?.();
342
359
  };
343
360
 
344
- const setHeartbeatMs = (h: Host, ms: number) => {
361
+ const setHeartbeatMs = (h: SocketHostState, ms: number) => {
345
362
  if (ms === h.heartbeatMs) return;
346
363
  h.heartbeatMs = ms;
347
364
  if (h.heartbeatTimer !== null) {
@@ -351,14 +368,14 @@ const setHeartbeatMs = (h: Host, ms: number) => {
351
368
  }
352
369
  };
353
370
 
354
- const idleLevel = (h: Host): boolean => {
371
+ const idleLevel = (h: SocketHostState): boolean => {
355
372
  for (const rec of h.clients.values()) {
356
373
  if (rec.inflight.size > 0) return false;
357
374
  }
358
375
  return true;
359
376
  };
360
377
 
361
- const flush = (h: Host, fin?: StatewireHost.Fin) => {
378
+ export const flush = (h: SocketHostState, fin?: StatewireHost.Fin) => {
362
379
  flushTapSync(() => {});
363
380
  h.stateHost?.takeChanged();
364
381
  for (const rec of h.clients.values()) {
@@ -421,14 +438,14 @@ const flush = (h: Host, fin?: StatewireHost.Fin) => {
421
438
  }
422
439
  };
423
440
 
424
- const queueApplied = (h: Host, clientId: string, seq: number) => {
441
+ const queueApplied = (h: SocketHostState, clientId: string, seq: number) => {
425
442
  const list = h.pendingApplied.get(clientId);
426
443
  if (list) list.push(seq);
427
444
  else h.pendingApplied.set(clientId, [seq]);
428
445
  scheduleFlush(h);
429
446
  };
430
447
 
431
- const queueCmd = (h: Host, clientId: string, cmd: StatewireCmd) => {
448
+ const queueCmd = (h: SocketHostState, clientId: string, cmd: StatewireCmd) => {
432
449
  const list = h.pendingCmds.get(clientId);
433
450
  if (list) list.push(cmd);
434
451
  else h.pendingCmds.set(clientId, [cmd]);
@@ -456,7 +473,7 @@ const admittedThrough = (rec: ClientRec): number =>
456
473
  // A re-asked seq never re-runs: the stored verdict re-emits, an applied
457
474
  // inflight run re-answers applied; one not yet applied answers nothing (its
458
475
  // answers ride the stream when ready), an evicted verdict answers expired.
459
- const reemit = (h: Host, rec: ClientRec, seq: number) => {
476
+ const reemit = (h: SocketHostState, rec: ClientRec, seq: number) => {
460
477
  const cached = rec.results.get(seq);
461
478
  if (cached) {
462
479
  const newest = Math.max(...rec.results.keys());
@@ -475,7 +492,7 @@ const reemit = (h: Host, rec: ClientRec, seq: number) => {
475
492
  };
476
493
 
477
494
  const settleCmd = (
478
- h: Host,
495
+ h: SocketHostState,
479
496
  rec: ClientRec,
480
497
  adm: Admission,
481
498
  cmd: StatewireCmd,
@@ -490,13 +507,16 @@ const settleCmd = (
490
507
  };
491
508
 
492
509
  const settleWire = (
493
- h: Host,
510
+ h: SocketHostState,
494
511
  rec: ClientRec,
495
512
  adm: Admission,
496
513
  outcome: CommandsOutcome,
497
514
  ) => settleCmd(h, rec, adm, cmdOf(adm, outcome));
498
515
 
499
- const callerOf = (h: Host, rec: ClientRec): StatewireHost.Caller => ({
516
+ const callerOf = (
517
+ h: SocketHostState,
518
+ rec: ClientRec,
519
+ ): StatewireHost.Caller => ({
500
520
  clientId: rec.id,
501
521
  get context() {
502
522
  return rec.context;
@@ -511,7 +531,7 @@ const callerOf = (h: Host, rec: ClientRec): StatewireHost.Caller => ({
511
531
  });
512
532
 
513
533
  const rejectStatement = (
514
- h: Host,
534
+ h: SocketHostState,
515
535
  rec: ClientRec,
516
536
  adm: Admission,
517
537
  message: string,
@@ -523,7 +543,11 @@ const rejectStatement = (
523
543
  message,
524
544
  });
525
545
 
526
- const admitCmd = (h: Host, rec: ClientRec, statement: CmdStatement) => {
546
+ const admitCmd = (
547
+ h: SocketHostState,
548
+ rec: ClientRec,
549
+ statement: CmdStatement,
550
+ ) => {
527
551
  const adm: Admission = {
528
552
  seq: statement.seq,
529
553
  method: statement.method,
@@ -582,8 +606,15 @@ const admitCmd = (h: Host, rec: ClientRec, statement: CmdStatement) => {
582
606
  // One admission law, both lanes: absorbed statements answer from the record,
583
607
  // then the new suffix is admitted whole or the frame is refused whole via a
584
608
  // {syn} stating both watermarks, nothing consumed. A gap is never a close.
585
- const admitFrame = (h: Host, conn: Conn, frame: ParsedFrame) => {
586
- const rec = h.clients.get(conn.clientId)!;
609
+ export type FrameAdmission =
610
+ | { type: "ok" }
611
+ | { type: "refused"; syn: { syn: { seq: number; ctxSeq: number } } };
612
+
613
+ export const admitFrame = (
614
+ h: SocketHostState,
615
+ rec: ClientRec,
616
+ frame: ParsedFrame,
617
+ ): FrameAdmission => {
587
618
  rec.touched = Date.now();
588
619
  const through = admittedThrough(rec);
589
620
  for (const statement of frame.cmd) {
@@ -595,12 +626,10 @@ const admitFrame = (h: Host, conn: Conn, frame: ParsedFrame) => {
595
626
  (ctxNew.length > 0 && ctxNew[0]!.seq !== rec.ctxSeq + 1) ||
596
627
  (cmdNew.length > 0 && cmdNew[0]!.seq !== through + 1)
597
628
  ) {
598
- sendTo(
599
- h,
600
- conn,
601
- JSON.stringify({ syn: { seq: through, ctxSeq: rec.ctxSeq } }),
602
- );
603
- return;
629
+ return {
630
+ type: "refused",
631
+ syn: { syn: { seq: through, ctxSeq: rec.ctxSeq } },
632
+ };
604
633
  }
605
634
  for (const statement of ctxNew) {
606
635
  rec.context = statement.value;
@@ -612,9 +641,10 @@ const admitFrame = (h: Host, conn: Conn, frame: ParsedFrame) => {
612
641
  queueCmd(h, rec.id, { seq: cmdNew.at(-1)!.seq, dur: true });
613
642
  }
614
643
  scheduleFlush(h);
644
+ return { type: "ok" };
615
645
  };
616
646
 
617
- const message = (h: Host, conn: Conn, data: string) => {
647
+ const message = (h: SocketHostState, conn: Conn, data: string) => {
618
648
  if (!conn.open || h.disposed || h.finished !== null) return;
619
649
  const first = !conn.sawFrame;
620
650
  conn.sawFrame = true;
@@ -628,10 +658,14 @@ const message = (h: Host, conn: Conn, data: string) => {
628
658
  return;
629
659
  }
630
660
  if (frame.ctx.length === 0 && frame.cmd.length === 0) return;
631
- admitFrame(h, conn, frame);
661
+ const rec = h.clients.get(conn.clientId)!;
662
+ const admission = admitFrame(h, rec, frame);
663
+ if (admission.type === "refused") {
664
+ sendTo(h, conn, JSON.stringify(admission.syn));
665
+ }
632
666
  };
633
667
 
634
- const gcClients = (h: Host) => {
668
+ const gcClients = (h: SocketHostState) => {
635
669
  let excess = h.clients.size - h.clientRecordLimit;
636
670
  if (excess <= 0) return;
637
671
  const live = new Set([...h.conns].map((conn) => conn.clientId));
@@ -644,23 +678,24 @@ const gcClients = (h: Host) => {
644
678
  }
645
679
  };
646
680
 
647
- const connect = (
648
- h: Host,
681
+ export type Registration = {
682
+ rec: ClientRec;
683
+ conn: Conn;
684
+ connection: StatewireHostInternal.SocketConnection;
685
+ };
686
+
687
+ export const register = (
688
+ h: SocketHostState,
649
689
  clientId: string,
650
690
  socket: StatewireHostInternal.Socket,
651
- ): StatewireHostInternal.SocketConnection => {
652
- const dead: StatewireHostInternal.SocketConnection = {
653
- clientId,
654
- message: () => {},
655
- close: () => {},
656
- };
691
+ ): Registration | null => {
657
692
  const refuse = (code: number, reason: string) => {
658
693
  try {
659
694
  socket.close(code, reason);
660
695
  } catch {
661
696
  /* peer already gone */
662
697
  }
663
- return dead;
698
+ return null;
664
699
  };
665
700
  if (h.disposed) return refuse(1001, "disposed");
666
701
  if (!CLIENT_ID_RE.test(clientId)) return refuse(1008, "invalid client id");
@@ -671,7 +706,8 @@ const connect = (
671
706
  } catch {
672
707
  /* peer already gone */
673
708
  }
674
- return refuse(1000, "finished");
709
+ refuse(1000, "finished");
710
+ return null;
675
711
  }
676
712
  const known = h.clients.has(clientId);
677
713
  const rec = known
@@ -681,6 +717,7 @@ const connect = (
681
717
  id: clientId,
682
718
  lastSeq: 0,
683
719
  ctxSeq: 0,
720
+ lease: null,
684
721
  touched: Date.now(),
685
722
  context: null,
686
723
  results: new Map(),
@@ -690,6 +727,7 @@ const connect = (
690
727
  return created;
691
728
  })();
692
729
  rec.touched = Date.now();
730
+ rec.lease = null;
693
731
  for (const other of [...h.conns]) {
694
732
  if (other.clientId !== clientId) continue;
695
733
  sendTo(h, other, JSON.stringify({ fin: { reason: "superseded" } }));
@@ -723,25 +761,41 @@ const connect = (
723
761
  },
724
762
  };
725
763
  sendTo(h, conn, JSON.stringify(hello));
726
- return {
764
+ const connection: StatewireHostInternal.SocketConnection = {
727
765
  clientId,
728
766
  message: (data) => message(h, conn, data),
729
767
  close: () => closeConn(h, conn),
730
768
  };
769
+ return { rec, conn, connection };
770
+ };
771
+
772
+ const connect = (
773
+ h: SocketHostState,
774
+ clientId: string,
775
+ socket: StatewireHostInternal.Socket,
776
+ ): StatewireHostInternal.SocketConnection => {
777
+ const registration = register(h, clientId, socket);
778
+ return (
779
+ registration?.connection ?? {
780
+ clientId,
781
+ message: () => {},
782
+ close: () => {},
783
+ }
784
+ );
731
785
  };
732
786
 
733
- const finish = (h: Host, fin: StatewireHost.Fin) => {
787
+ const finish = (h: SocketHostState, fin: StatewireHost.Fin) => {
734
788
  if (h.finished !== null || h.disposed) return;
735
789
  h.finished = fin;
736
790
  flush(h, fin);
737
791
  };
738
792
 
739
- const resume = (h: Host) => {
793
+ const resume = (h: SocketHostState) => {
740
794
  h.disposed = false;
741
795
  h.stateHost?.setOnOp((op) => recordOp(h, op));
742
796
  };
743
797
 
744
- const suspend = (h: Host) => {
798
+ const suspend = (h: SocketHostState) => {
745
799
  h.disposed = true;
746
800
  if (h.heartbeatTimer !== null) {
747
801
  clearInterval(h.heartbeatTimer);
@@ -753,12 +807,16 @@ const suspend = (h: Host) => {
753
807
  const useSocketHost = (
754
808
  transport: ResourceElement<Statewire>,
755
809
  options?: StatewireHostInternal.SocketHostOptions,
756
- ): Pick<StatewireHostInternal.SocketHost, "connect" | "finish"> => {
757
- const [h] = useState((): Host => {
758
- const host: Host = {
810
+ ): Pick<
811
+ StatewireHostInternal.SocketHost,
812
+ "connect" | "finish" | "stream" | "frames"
813
+ > => {
814
+ const [h] = useState((): SocketHostState => {
815
+ const host: SocketHostState = {
759
816
  heartbeatMs: 15_000,
760
817
  clientRecordLimit: CLIENT_RECORD_LIMIT,
761
818
  finOnIdle: false,
819
+ authorize: undefined,
762
820
  clients: new Map(),
763
821
  conns: new Set(),
764
822
  pendingOps: [],
@@ -778,12 +836,15 @@ const useSocketHost = (
778
836
  }),
779
837
  connect: (clientId, socket) => connect(host, clientId, socket),
780
838
  finish: (fin) => finish(host, fin),
839
+ stream: (request) => serveStream(host, request),
840
+ frames: (request) => serveFrames(host, request),
781
841
  };
782
842
  return host;
783
843
  });
784
844
  setHeartbeatMs(h, options?.heartbeatIntervalMs ?? 15_000);
785
845
  h.clientRecordLimit = options?.clientRecordLimit ?? CLIENT_RECORD_LIMIT;
786
846
  h.finOnIdle = options?.finOnIdle ?? false;
847
+ h.authorize = options?.authorize;
787
848
  const value = useResource(transport) as Statewire<unknown>;
788
849
  attach(h, value);
789
850
  useEffect(() => {
@@ -813,6 +874,8 @@ export const StatewireSocketHost = (
813
874
  return {
814
875
  connect: (clientId, socket) =>
815
876
  flushTapSync(() => root.getValue().connect(clientId, socket)),
877
+ stream: (request) => flushTapSync(() => root.getValue().stream(request)),
878
+ frames: (request) => flushTapSync(() => root.getValue().frames(request)),
816
879
  finish: (fin) => flushTapSync(() => root.getValue().finish(fin)),
817
880
  dispose: () => {
818
881
  if (disposed) return;
@@ -16,11 +16,16 @@ export namespace StatewireHostInternal {
16
16
 
17
17
  export type SocketHost = {
18
18
  connect: (clientId: string, socket: Socket) => SocketConnection;
19
+ stream: (request: Request) => Promise<Response>;
20
+ frames: (request: Request) => Promise<Response>;
19
21
  finish: (fin: StatewireHost.Fin) => void;
20
22
  dispose: () => void;
21
23
  };
22
24
 
23
25
  export type SocketHostOptions = {
26
+ authorize?: (ctx: {
27
+ headers: Record<string, string>;
28
+ }) => void | Promise<void>;
24
29
  heartbeatIntervalMs?: number;
25
30
  /** LRU cap on stored client records; live or in-flight records are exempt. */
26
31
  clientRecordLimit?: number;
package/src/statewire.ts CHANGED
@@ -3,12 +3,31 @@ import {
3
3
  createTapRoot,
4
4
  flushTapSync,
5
5
  resource,
6
+ useContextProvider,
6
7
  useResource,
8
+ useResources,
9
+ withKey,
7
10
  } from "@assistant-ui/tap";
8
11
  import type { ResourceElement } from "@assistant-ui/tap";
9
12
  import { LIVE_CONNECTION, useStatewireCore } from "./client/core";
10
13
  import type { Statewire } from "./types";
11
- import type { StatewireSessionStoreOptions } from "./client/sessionStore";
14
+ import { SessionStorageContext } from "./client/sessionStore";
15
+
16
+ const useSessionTransport = ({
17
+ transport,
18
+ session,
19
+ }: {
20
+ transport: ResourceElement<Statewire<any, any>>;
21
+ session: StatewireClient.SessionStorage | undefined;
22
+ }): Statewire<unknown> =>
23
+ useContextProvider(SessionStorageContext, session, () =>
24
+ // oxlint-disable-next-line react-hooks/rules-of-hooks
25
+ useResource(transport),
26
+ );
27
+
28
+ // The transport owns the session; it reads the storage off the context this
29
+ // wrapper's own fiber provides, so plain-React callers get a provider too.
30
+ const SessionTransport = resource(useSessionTransport);
12
31
 
13
32
  export const useStatewire = <
14
33
  TState,
@@ -19,7 +38,14 @@ export const useStatewire = <
19
38
  const latest = useRef(options);
20
39
  latest.current = options;
21
40
 
22
- const value = useResource(options.transport) as Statewire<unknown>;
41
+ const session = useResources(
42
+ options.storage?.session !== undefined
43
+ ? [withKey("session", options.storage.session)]
44
+ : [],
45
+ )[0];
46
+ const value = useResource(
47
+ SessionTransport({ transport: options.transport, session }),
48
+ );
23
49
  const core = useStatewireCore(() => latest.current, value);
24
50
  const [subscribe] = useState(
25
51
  () => (listener: () => void) =>
@@ -111,6 +137,10 @@ export namespace StatewireClient {
111
137
  TCommands extends Statewire.Commands = Statewire.Commands,
112
138
  > = {
113
139
  transport: ResourceElement<Statewire<TState, TCommands>>;
140
+ /** Durable session: the mounted storage holds this client's lane identity and its un-durable commands, which resend on the next attach. */
141
+ storage?:
142
+ | { session?: ResourceElement<SessionStorage> | undefined }
143
+ | undefined;
114
144
  /** Served as the canonical state until the attach snapshot; own commands fold over it. */
115
145
  initialState?: TState;
116
146
  optimistic?: Optimistic<NonNullable<TState>, TCommands>;
@@ -187,10 +217,12 @@ export namespace StatewireClient {
187
217
  /**
188
218
  * fate: "aborted" = never sent, "rejected" = the server refused it
189
219
  * (`code` "unknown-command" | "invalid-params" for the static rejects,
190
- * code-less for handler rejects), "lost" = sent but never covered by an
191
- * applied, "failed" = the handler ran and threw, "crashed" = the handler
192
- * died involuntarily, "expired" = concluded but the outcome is
193
- * unavailable.
220
+ * code-less for handler rejects), "lost" = sent but no verdict will ever
221
+ * arrive — the record is gone (`record: "fresh"`) or the channel died: it
222
+ * may or may not have run, "failed" = the handler ran and threw,
223
+ * "crashed" = the handler died involuntarily, "expired" = settled —
224
+ * result, rejected, failed, or crashed — but the verdict outlived the
225
+ * server's retention.
194
226
  * cause is which channel settled it: "server" = the server's answer;
195
227
  * the rest are channel deaths the client settled locally.
196
228
  */
@@ -215,12 +247,35 @@ export namespace StatewireClient {
215
247
  cause: "unmount" | "stream-gone" | "session-lost" | "superseded";
216
248
  };
217
249
 
250
+ /** A command as persisted while un-durable: its lane seq plus the wire statement. */
251
+ export type SessionCommand = {
252
+ seq: number;
253
+ method: string;
254
+ params: unknown[];
255
+ meta?: unknown;
256
+ };
257
+
258
+ /** The persisted session: the lane identity, the lane clocks, and the commands the server has not marked durable. */
259
+ export type SessionRecord = {
260
+ clientId: string;
261
+ nextSeq: number;
262
+ ctxNextSeq: number;
263
+ commands: readonly SessionCommand[];
264
+ };
265
+
266
+ /** Session storage seam; `save` must persist synchronously in the caller's flow. */
267
+ export type SessionStorage = {
268
+ load: () => SessionRecord | null;
269
+ save: (record: SessionRecord) => void;
270
+ };
271
+
218
272
  /** Static headers, or a resolver invoked per request/attach; `staleAuth` is true right after an auth rejection. */
219
273
  export type HeadersOption =
220
274
  | HeadersInit
221
275
  | ((ctx: { staleAuth: boolean }) => HeadersInit | Promise<HeadersInit>);
222
276
 
223
- export type SSEOptions = StatewireSessionStoreOptions & {
277
+ export type SSEOptions = {
278
+ sessionStore?: SessionStorage | undefined;
224
279
  url: string;
225
280
  headers?: HeadersOption;
226
281
  fetch?: typeof globalThis.fetch;
@@ -229,7 +284,8 @@ export namespace StatewireClient {
229
284
  onDone?: () => void;
230
285
  };
231
286
 
232
- export type WSOptions = StatewireSessionStoreOptions & {
287
+ export type WSOptions = {
288
+ sessionStore?: SessionStorage | undefined;
233
289
  url: string;
234
290
  headers?: HeadersOption;
235
291
  protocols?: string | string[];
package/src/types.ts CHANGED
@@ -53,7 +53,7 @@ export interface Statewire<
53
53
  commands: Statewire.CommandsProxy<TCommands>;
54
54
  connection?: StatewireClient.Connection;
55
55
  source?: Statewire.StateSource;
56
- /** Highest command seq released from the resend queue by the server's dur flag. */
56
+ /** Highest command seq the server's dur flag states durably admitted — never lost, never run again — and released from the resend queue. */
57
57
  durableSeq?: number;
58
58
  /** Negation of the server's broadcast idle level; absent means always busy. */
59
59
  isBusy?: boolean;