statewire 0.17.1 → 0.18.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,4 +1,5 @@
1
1
  import { encodeDocument } from "../document";
2
+ import { wrapStorageError } from "./storageError";
2
3
  import {
3
4
  DEFAULT_PROTOCOL,
4
5
  WIRE_VERSION,
@@ -58,7 +59,7 @@ type Pending = {
58
59
  /** The server acknowledged admission; the terminal verdict is still due. */
59
60
  applied: boolean;
60
61
  resolve: (payload: unknown) => void;
61
- reject: (error: StatewireSendError) => void;
62
+ reject: (error: unknown) => void;
62
63
  };
63
64
 
64
65
  type CtxPending = {
@@ -100,6 +101,10 @@ type Session = {
100
101
  record: StatewireClient.SessionRecord;
101
102
  ctx: StatewireRemoteProps;
102
103
  recordPersisted: boolean;
104
+ hydrated: boolean;
105
+ ready: Promise<void> | undefined;
106
+ storageError: unknown;
107
+ persistedRecord: StatewireClient.SessionRecord | null;
103
108
  store: StatewireClient.SessionStorage;
104
109
  documentStore: StatewireDocuments.Storage | undefined;
105
110
  shared: Shared | false;
@@ -162,9 +167,23 @@ const sleepUntilWake = (s: Session, delayMs: number) =>
162
167
  s.waiters.push(waiter);
163
168
  });
164
169
 
170
+ const storageFailed = (s: Session, cause: unknown) => {
171
+ const error = wrapStorageError(cause);
172
+ s.storageError = error;
173
+ for (const entry of s.pending) entry.reject(error);
174
+ s.transport?.storageFailed(error);
175
+ };
176
+
165
177
  const setRecord = (s: Session, record: StatewireClient.SessionRecord) => {
166
178
  s.record = record;
167
- if (!s.deactivated) s.store.save(record);
179
+ if (!s.deactivated) {
180
+ try {
181
+ s.store.save(record);
182
+ } catch (cause) {
183
+ storageFailed(s, cause);
184
+ throw s.storageError;
185
+ }
186
+ }
168
187
  };
169
188
 
170
189
  const backlog = (s: Session): StatewireClient.SessionCommand[] =>
@@ -208,7 +227,7 @@ const syncBacklogInner = (s: Session) => {
208
227
 
209
228
  const syncBacklog = (s: Session) => {
210
229
  if (!s.shared) return syncBacklogInner(s);
211
- void transact(s, () => syncBacklogInner(s));
230
+ transactBackground(s, () => syncBacklogInner(s));
212
231
  };
213
232
 
214
233
  // Every record mutation over shared storage is a locked read-modify-write.
@@ -241,13 +260,23 @@ const transact = (s: Session, fn: () => void): Promise<void> => {
241
260
  }
242
261
  }
243
262
  fn();
263
+ const persistence = s.store.flush?.();
264
+ if (persistence) await persistence;
244
265
  }),
245
266
  );
246
- shared.txq = run.then(
267
+ const result = run.catch((cause: unknown) => {
268
+ storageFailed(s, cause);
269
+ throw s.storageError;
270
+ });
271
+ shared.txq = result.then(
247
272
  () => {},
248
273
  () => {},
249
274
  );
250
- return run;
275
+ return result;
276
+ };
277
+
278
+ const transactBackground = (s: Session, fn: () => void, done?: () => void) => {
279
+ void transact(s, fn).then(done, () => {});
251
280
  };
252
281
 
253
282
  const setIdle = (s: Session, level: boolean) => {
@@ -305,7 +334,7 @@ const retainFailed = (
305
334
  retainFailedInner(s, entry, failure);
306
335
  return;
307
336
  }
308
- void transact(s, () => retainFailedInner(s, entry, failure));
337
+ transactBackground(s, () => retainFailedInner(s, entry, failure));
309
338
  };
310
339
 
311
340
  const settleEntry = (
@@ -580,6 +609,32 @@ const publishIngested = (s: Session) => {
580
609
  // unloaded): held seqs resend as-is and the frame's answer arbitrates —
581
610
  // absorption where the record survived, a gap refusal where it did not.
582
611
  const snapCtxClock = (s: Session, ctxWm: number) => {
612
+ if (s.record.context?.recover === "version-1") {
613
+ const document = s.contextDocuments.documents.get(0);
614
+ if (s.contextDocuments.documents.size !== 1 || !document?.main)
615
+ throw new Error(
616
+ "statewire: version-1 context recovery requires one main document at doc 0",
617
+ );
618
+ const encoded = encodeDocument(document.value);
619
+ const ops = [
620
+ {
621
+ ...(ctxWm === 0
622
+ ? { op: "mount", protocol: document.protocol, main: true }
623
+ : { op: "replace", path: [] }),
624
+ ...encoded,
625
+ },
626
+ ];
627
+ s.ctxPending = [{ seq: ctxWm + 1, ops, posted: false }];
628
+ setRecord(s, {
629
+ ...s.record,
630
+ ctxNextSeq: ctxWm + 2,
631
+ context: {
632
+ ops: contextSnapshot(s.contextDocuments),
633
+ pending: s.ctxPending,
634
+ },
635
+ });
636
+ return;
637
+ }
583
638
  const ctxAligned =
584
639
  s.ctxPending.every((c, i) => c.seq === ctxWm + 1 + i) &&
585
640
  s.record.ctxNextSeq === ctxWm + s.ctxPending.length + 1;
@@ -671,7 +726,7 @@ const handleHello = (
671
726
  void pump(s);
672
727
  return;
673
728
  }
674
- void transact(s, reconcile).then(() => {
729
+ transactBackground(s, reconcile, () => {
675
730
  publishIngested(s);
676
731
  void pump(s);
677
732
  });
@@ -718,13 +773,17 @@ const handleRefusal = (s: Session, syn: StatewireSyn) => {
718
773
  firstCtx !== undefined &&
719
774
  firstCtx.seq > ctxSeq + 1;
720
775
  if (cmdGap || ctxGap) {
721
- void transact(s, () => {
722
- if (cmdGap) sessionLostInner(s, seq!, ctxSeq ?? 0);
723
- else snapCtxClock(s, ctxSeq!);
724
- }).then(() => {
725
- wake(s);
726
- void pump(s);
727
- });
776
+ transactBackground(
777
+ s,
778
+ () => {
779
+ if (cmdGap) sessionLostInner(s, seq!, ctxSeq ?? 0);
780
+ else snapCtxClock(s, ctxSeq!);
781
+ },
782
+ () => {
783
+ wake(s);
784
+ void pump(s);
785
+ },
786
+ );
728
787
  return;
729
788
  }
730
789
  wake(s);
@@ -755,10 +814,16 @@ type Lanes = {
755
814
 
756
815
  // A lane must be strictly consecutive within a frame: the first unposted run
757
816
  // per lane rides now, later runs ride follow-up frames.
758
- const buildLanes = (s: Session): Lanes | null => {
817
+ const buildLanes = (s: Session, persisted = false): Lanes | null => {
759
818
  const ctxEntries: CtxPending[] = [];
760
819
  for (const entry of s.ctxPending) {
761
820
  if (entry.posted) continue;
821
+ if (
822
+ persisted &&
823
+ (s.persistedRecord?.clientId !== s.record.clientId ||
824
+ entry.seq >= s.persistedRecord.ctxNextSeq)
825
+ )
826
+ break;
762
827
  if (ctxEntries.length >= MAX_COMMANDS_PER_FRAME) break;
763
828
  if (ctxEntries.length > 0 && entry.seq !== ctxEntries.at(-1)!.seq + 1)
764
829
  break;
@@ -767,6 +832,12 @@ const buildLanes = (s: Session): Lanes | null => {
767
832
  const cmdEntries: Pending[] = [];
768
833
  for (const entry of s.pending) {
769
834
  if (entry.posted) continue;
835
+ if (
836
+ persisted &&
837
+ (s.persistedRecord?.clientId !== s.record.clientId ||
838
+ entry.seq >= s.persistedRecord.nextSeq)
839
+ )
840
+ break;
770
841
  if (cmdEntries.length >= MAX_COMMANDS_PER_FRAME) break;
771
842
  if (cmdEntries.length > 0 && entry.seq !== cmdEntries.at(-1)!.seq + 1)
772
843
  break;
@@ -794,8 +865,9 @@ const takeAttachLanes = (s: Session): StatewireFrame | null => {
794
865
  s.attachLaneSeqs = [new Set(), new Set()];
795
866
  const wanted = s.lanesOnAttach;
796
867
  s.lanesOnAttach = false;
797
- if (!wanted || !s.sessionKnown) return null;
798
- const lanes = buildLanes(s);
868
+ if (!wanted || !s.sessionKnown || s.record.context?.recover !== undefined)
869
+ return null;
870
+ const lanes = buildLanes(s, true);
799
871
  if (!lanes) return null;
800
872
  s.attachLaneSeqs = [
801
873
  new Set(lanes.cmdEntries.map((p) => p.seq)),
@@ -805,16 +877,38 @@ const takeAttachLanes = (s: Session): StatewireFrame | null => {
805
877
  return lanes.frame;
806
878
  };
807
879
 
880
+ const persistBeforeSend = (s: Session): void | Promise<void> => {
881
+ if (s.storageError !== undefined) throw s.storageError;
882
+ if (!s.hydrated) return s.ready!.then(() => persistBeforeSend(s));
883
+ const record = s.record;
884
+ try {
885
+ const pending = s.store.flush?.();
886
+ if (pending)
887
+ return pending.then(
888
+ () => {
889
+ s.persistedRecord = record;
890
+ },
891
+ (cause: unknown) => {
892
+ throw wrapStorageError(cause);
893
+ },
894
+ );
895
+ s.persistedRecord = record;
896
+ } catch (cause) {
897
+ throw wrapStorageError(cause);
898
+ }
899
+ };
900
+
808
901
  const pump = async (s: Session) => {
809
902
  if (s.pumping) return;
810
903
  s.pumping = true;
811
904
  try {
812
905
  while (s.running && !isFinished(s)) {
906
+ if (!s.hydrated) await s.ready;
813
907
  const transport = s.transport;
814
908
  if (buildLanes(s) === null) return;
815
909
  if (s.attach !== "attached" || !transport) {
816
910
  if (
817
- (transport?.connection.status === "disconnected" &&
911
+ (transport?.connection.status === "standby" &&
818
912
  transport.connection.reason === "idle") ||
819
913
  (transport?.parked && s.pending.length > 0)
820
914
  ) {
@@ -831,6 +925,10 @@ const pump = async (s: Session) => {
831
925
  const lanes = buildLanes(s);
832
926
  if (!lanes) continue;
833
927
  const epoch = s.attachEpoch;
928
+ const persistence = persistBeforeSend(s);
929
+ if (persistence) await persistence;
930
+ if (!s.running || isFinished(s)) return;
931
+ if (epoch !== s.attachEpoch || s.attach !== "attached") continue;
834
932
  for (const entry of lanes.cmdEntries) entry.attempted = true;
835
933
  let result;
836
934
  try {
@@ -877,23 +975,39 @@ const pump = async (s: Session) => {
877
975
  }
878
976
  }
879
977
  }
978
+ } catch (error) {
979
+ storageFailed(s, error);
880
980
  } finally {
881
981
  s.pumping = false;
882
982
  }
883
983
  };
884
984
 
885
985
  const start = (s: Session) => {
986
+ s.running = true;
987
+ if (!s.hydrated) {
988
+ void s.ready!.then(
989
+ () => {
990
+ if (s.running) start(s);
991
+ },
992
+ () => {},
993
+ );
994
+ return;
995
+ }
886
996
  if (s.shared) {
887
997
  s.shared.unsubscribe = s.store.subscribe!(() => {
888
- void transact(s, () => consumeVerdicts(s));
998
+ transactBackground(s, () => consumeVerdicts(s));
889
999
  });
890
1000
  s.running = true;
891
- void transact(s, () => {
892
- if (s.store.load() === null) s.store.save(s.record);
893
- s.recordPersisted = true;
894
- adoptStored(s);
895
- consumeVerdicts(s);
896
- }).then(() => void pump(s));
1001
+ transactBackground(
1002
+ s,
1003
+ () => {
1004
+ if (s.store.load() === null) s.store.save(s.record);
1005
+ s.recordPersisted = true;
1006
+ adoptStored(s);
1007
+ consumeVerdicts(s);
1008
+ },
1009
+ () => void pump(s),
1010
+ );
897
1011
  return;
898
1012
  }
899
1013
  if (!s.recordPersisted) {
@@ -926,7 +1040,7 @@ const enqueueInner = (
926
1040
  command: WireCommand,
927
1041
  params: readonly unknown[],
928
1042
  resolve: (payload: unknown) => void,
929
- reject: (error: StatewireSendError) => void,
1043
+ reject: (error: unknown) => void,
930
1044
  ) => {
931
1045
  const seq = s.record.nextSeq;
932
1046
  const entry: Pending = {
@@ -956,7 +1070,7 @@ const enqueue = (
956
1070
  command: WireCommand,
957
1071
  params: readonly unknown[],
958
1072
  resolve: (payload: unknown) => void,
959
- reject: (error: StatewireSendError) => void,
1073
+ reject: (error: unknown) => void,
960
1074
  ) => {
961
1075
  if (!s.shared) {
962
1076
  enqueueInner(s, command, params, resolve, reject);
@@ -977,7 +1091,7 @@ const enqueue = (
977
1091
  }).then(() => {
978
1092
  wake(s);
979
1093
  void pump(s);
980
- });
1094
+ }, reject);
981
1095
  };
982
1096
 
983
1097
  // A stored command may have reached a server: attempted settles it lost,
@@ -1026,6 +1140,18 @@ const sendCommand = (
1026
1140
  params: readonly unknown[],
1027
1141
  protocol = s.options.protocol ?? DEFAULT_PROTOCOL,
1028
1142
  ): Promise<unknown> => {
1143
+ if (!s.hydrated) {
1144
+ const result = s.ready!.then(() =>
1145
+ sendCommand(s, method, params, protocol),
1146
+ );
1147
+ result.catch(() => {});
1148
+ return result;
1149
+ }
1150
+ if (s.storageError !== undefined) {
1151
+ const result = Promise.reject(s.storageError);
1152
+ result.catch(() => {});
1153
+ return result;
1154
+ }
1029
1155
  if (!s.running) {
1030
1156
  return rejectedSend({ fate: "aborted", cause: "unmount" });
1031
1157
  }
@@ -1091,7 +1217,7 @@ export const discardFailed = (
1091
1217
  return;
1092
1218
  }
1093
1219
  if (findFailed(s, entry) === undefined) throw unknownFailed(entry);
1094
- void transact(s, () => {
1220
+ transactBackground(s, () => {
1095
1221
  if (findFailed(s, entry) !== undefined) takeFailed(s, entry);
1096
1222
  });
1097
1223
  };
@@ -1180,6 +1306,9 @@ const persistContext = (s: Session) => {
1180
1306
  ...s.record,
1181
1307
  context: {
1182
1308
  ops,
1309
+ ...(s.record.context?.recover === undefined
1310
+ ? {}
1311
+ : { recover: s.record.context.recover }),
1183
1312
  pending: s.ctxPending.map(({ seq, ops }) => ({ seq, ops })),
1184
1313
  },
1185
1314
  });
@@ -1189,6 +1318,16 @@ const changeContext = (
1189
1318
  s: Session,
1190
1319
  build: (documents: StatewireDocuments.State) => readonly unknown[],
1191
1320
  ): void => {
1321
+ if (!s.hydrated) {
1322
+ void s
1323
+ .ready!.then(() => changeContext(s, build))
1324
+ .catch((cause: unknown) => {
1325
+ const error = wrapStorageError(cause);
1326
+ s.storageError = error;
1327
+ s.transport?.storageFailed(error);
1328
+ });
1329
+ return;
1330
+ }
1192
1331
  if (!s.running || isFinished(s)) return;
1193
1332
  const submit = () => {
1194
1333
  if (!s.running || isFinished(s)) return;
@@ -1233,7 +1372,7 @@ const changeContext = (
1233
1372
  wake(s);
1234
1373
  void pump(s);
1235
1374
  } else
1236
- void transact(s, submit).then(() => {
1375
+ transactBackground(s, submit, () => {
1237
1376
  wake(s);
1238
1377
  void pump(s);
1239
1378
  });
@@ -1328,6 +1467,52 @@ const storedCommandCompatible = (
1328
1467
  );
1329
1468
  };
1330
1469
 
1470
+ const loadSessionRecord = (
1471
+ store: StatewireClient.SessionStorage,
1472
+ options: StatewireSessionOptions,
1473
+ ) => {
1474
+ let stored = store.load();
1475
+ if (stored !== null && !Array.isArray(stored.commands))
1476
+ throw new Error(
1477
+ "statewire: session storage loaded a record without a commands array",
1478
+ );
1479
+ const current = {
1480
+ wire: options.wireVersion ?? { version: WIRE_VERSION },
1481
+ protocols: protocolOffers(options),
1482
+ };
1483
+ if (
1484
+ stored !== null &&
1485
+ !stored.commands.every((command) =>
1486
+ storedCommandCompatible(command, current),
1487
+ )
1488
+ ) {
1489
+ if (!store.migrate)
1490
+ throw new Error("statewire: stored commands require a storage migration");
1491
+ stored = store.migrate(stored, current);
1492
+ if (
1493
+ stored !== null &&
1494
+ !stored.commands.every((command) =>
1495
+ storedCommandCompatible(command, current),
1496
+ )
1497
+ )
1498
+ throw new Error("statewire: migrated commands are incompatible");
1499
+ if (stored !== null) store.save(stored);
1500
+ }
1501
+ if (stored !== null && !Array.isArray(stored.commands)) {
1502
+ throw new Error(
1503
+ "statewire: session storage loaded a record without a commands array",
1504
+ );
1505
+ }
1506
+ const record = stored ?? {
1507
+ clientId: newClientId(),
1508
+ nextSeq: 1,
1509
+ ctxNextSeq: 1,
1510
+ commands: [],
1511
+ };
1512
+ if (stored === null) store.save(record);
1513
+ return { record, stored };
1514
+ };
1515
+
1331
1516
  export const useStatewireSession = (
1332
1517
  options: StatewireSessionOptions,
1333
1518
  ): Session => {
@@ -1346,47 +1531,18 @@ export const useStatewireSession = (
1346
1531
  );
1347
1532
  }
1348
1533
  const [s] = useState((): Session => {
1349
- let stored = store.load();
1350
- if (stored !== null && !Array.isArray(stored.commands))
1351
- throw new Error(
1352
- "statewire: session storage loaded a record without a commands array",
1353
- );
1354
- const current = {
1355
- wire: options.wireVersion ?? { version: WIRE_VERSION },
1356
- protocols: protocolOffers(options),
1357
- };
1358
- if (
1359
- stored !== null &&
1360
- !stored.commands.every((command) =>
1361
- storedCommandCompatible(command, current),
1362
- )
1363
- ) {
1364
- if (!store.migrate)
1365
- throw new Error(
1366
- "statewire: stored commands require a storage migration",
1367
- );
1368
- stored = store.migrate(stored, current);
1369
- if (
1370
- stored !== null &&
1371
- !stored.commands.every((command) =>
1372
- storedCommandCompatible(command, current),
1373
- )
1374
- )
1375
- throw new Error("statewire: migrated commands are incompatible");
1376
- if (stored !== null) store.save(stored);
1377
- }
1378
- if (stored !== null && !Array.isArray(stored.commands)) {
1379
- throw new Error(
1380
- "statewire: session storage loaded a record without a commands array",
1381
- );
1382
- }
1383
- const record = stored ?? {
1384
- clientId: newClientId(),
1385
- nextSeq: 1,
1386
- ctxNextSeq: 1,
1387
- commands: [],
1388
- };
1389
- if (stored === null) store.save(record);
1534
+ const { record, stored } =
1535
+ store.ready === undefined
1536
+ ? loadSessionRecord(store, options)
1537
+ : {
1538
+ record: {
1539
+ clientId: newClientId(),
1540
+ nextSeq: 1,
1541
+ ctxNextSeq: 1,
1542
+ commands: [],
1543
+ },
1544
+ stored: null,
1545
+ };
1390
1546
  const session: Session = {
1391
1547
  options,
1392
1548
  selection: undefined,
@@ -1399,6 +1555,7 @@ export const useStatewireSession = (
1399
1555
  ),
1400
1556
  record,
1401
1557
  ctx: {
1558
+ beforeAttach: () => persistBeforeSend(session),
1402
1559
  get clientId() {
1403
1560
  return session.record.clientId;
1404
1561
  },
@@ -1411,7 +1568,11 @@ export const useStatewireSession = (
1411
1568
  onDetach: () => handleDetach(session),
1412
1569
  onFinish: (args) => handleFinish(session, args),
1413
1570
  },
1414
- recordPersisted: stored !== null,
1571
+ recordPersisted: store.ready === undefined,
1572
+ hydrated: store.ready === undefined,
1573
+ ready: undefined,
1574
+ storageError: undefined,
1575
+ persistedRecord: store.ready === undefined ? record : null,
1415
1576
  store,
1416
1577
  documentStore,
1417
1578
  shared:
@@ -1461,6 +1622,35 @@ export const useStatewireSession = (
1461
1622
  sendCommand(session, method, params),
1462
1623
  ),
1463
1624
  };
1625
+ if (store.ready !== undefined) {
1626
+ session.ready = store.ready.then(() => {
1627
+ const { record } = loadSessionRecord(store, session.options);
1628
+ session.record = record;
1629
+ session.contextDocuments =
1630
+ record.context === undefined
1631
+ ? { mode: "mounted", documents: new Map() }
1632
+ : applyDocumentOps(
1633
+ { mode: "mounted", documents: new Map() },
1634
+ record.context.ops,
1635
+ );
1636
+ session.ctxOps = record.context?.ops;
1637
+ session.ctxPending =
1638
+ record.context?.pending.map((entry) => ({
1639
+ ...entry,
1640
+ posted: false,
1641
+ })) ?? [];
1642
+ session.recordPersisted = true;
1643
+ session.hydrated = true;
1644
+ if (session.running) session.notify();
1645
+ });
1646
+ session.ready = session.ready.catch((cause: unknown) => {
1647
+ const error = wrapStorageError(cause);
1648
+ session.storageError = error;
1649
+ session.transport?.storageFailed(error);
1650
+ throw error;
1651
+ });
1652
+ session.ready.catch(() => {});
1653
+ }
1464
1654
  bindCommandsHost(session.commands, {
1465
1655
  setListener: (listener) => {
1466
1656
  session.lifecycle = listener;
@@ -0,0 +1,9 @@
1
+ export const isStorageError = (error: unknown): error is Error =>
2
+ error instanceof Error && error.name === "StatewireStorageError";
3
+
4
+ export const wrapStorageError = (cause: unknown): Error => {
5
+ if (isStorageError(cause)) return cause;
6
+ const error = new Error("statewire: session storage failed", { cause });
7
+ error.name = "StatewireStorageError";
8
+ return error;
9
+ };
package/src/statewire.ts CHANGED
@@ -303,36 +303,37 @@ export namespace StatewireClient {
303
303
  discardFailed: (entry: FailedCommand) => void;
304
304
  };
305
305
 
306
- /** `cause`/`nextRetryAt`/`lastError` are present only while waiting out the backoff timer; a dial in flight carries `attempt` alone. */
306
+ /** Connection lifecycle and whether its current state warrants a problem indicator. */
307
307
  export type ConnectionState =
308
+ | { status: "standby"; reason: "deferred" | "idle"; degraded: false }
309
+ | { status: "connecting"; degraded: false }
310
+ | { status: "connected"; degraded: false }
308
311
  | {
309
- status: "disconnected";
310
- reason:
311
- | "deferred"
312
- | "idle"
313
- | "gone"
314
- | "forbidden"
315
- | "protocol-error"
316
- | "unauthorized";
317
- message?: string;
318
- payload?: unknown;
319
- }
320
- | {
321
- status: "connecting";
322
- /** 0 = the episode's first dial, >0 = retry. */
312
+ status: "retrying";
313
+ degraded: true;
323
314
  attempt: number;
315
+ /** Backoff details are absent while the retry dial is in flight. */
324
316
  cause?: "dropped" | "reconnect" | "error" | "unauthorized";
325
317
  code?: string;
326
318
  nextRetryAt?: number;
327
319
  lastError?: string;
328
320
  message?: string;
329
321
  }
330
- | { status: "connected" };
322
+ | {
323
+ status: "stopped";
324
+ degraded: true;
325
+ reason:
326
+ | "gone"
327
+ | "forbidden"
328
+ | "protocol-error"
329
+ | "unauthorized"
330
+ | "storage-error";
331
+ message?: string;
332
+ payload?: unknown;
333
+ };
331
334
  export type Connection = ConnectionState & {
332
335
  error?: ConnectionError;
333
336
  reconnect: () => void;
334
- /** True while retrying (`connecting` with `attempt > 0`) or parked `gone`. */
335
- degraded: boolean;
336
337
  };
337
338
  export type ConnectionStatus = Connection["status"];
338
339
 
@@ -436,7 +437,12 @@ export namespace StatewireClient {
436
437
  clientId: string;
437
438
  nextSeq: number;
438
439
  ctxNextSeq: number;
439
- context?: { ops: unknown[]; pending: { seq: number; ops: unknown[] }[] };
440
+ context?: {
441
+ ops: unknown[];
442
+ pending: { seq: number; ops: unknown[] }[];
443
+ /** Recover the original wire's unpersisted single-document context after the next hello. */
444
+ recover?: "version-1";
445
+ };
440
446
  commands: readonly SessionCommand[];
441
447
  /** Terminal verdicts parked for the sharer that minted the seq (shared storage only). */
442
448
  verdicts?: readonly SessionVerdict[];
@@ -454,8 +460,12 @@ export namespace StatewireClient {
454
460
  meta?: unknown;
455
461
  };
456
462
 
457
- /** Session storage seam; `save` must persist synchronously in the caller's flow. */
463
+ /** Session storage seam with optional asynchronous hydration and persistence. */
458
464
  export type SessionStorage = {
465
+ /** Resolves when a synchronous load can return the hydrated record. */
466
+ ready?: Promise<void>;
467
+ /** Resolves after all queued saves are persisted; awaited before sending statements. */
468
+ flush?: () => void | Promise<void>;
459
469
  load: () => SessionRecord | null;
460
470
  save: (record: SessionRecord) => void;
461
471
  /** Upgrade incompatible stored commands; null discards the old record and starts a new client identity. */
@@ -470,7 +480,7 @@ export namespace StatewireClient {
470
480
  };
471
481
 
472
482
  /** Static headers, or a resolver invoked per request/attach; `staleAuth` is true right after an auth rejection. */
473
- export type ConnectionError = Error & { fin: StatewireHost.Fin };
483
+ export type ConnectionError = Error & { fin?: StatewireHost.Fin };
474
484
 
475
485
  export type HeadersOption =
476
486
  | HeadersInit