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.
@@ -8,6 +8,7 @@ import { bindCommandsHost } from "../host/host.js";
8
8
  import "../wire/limits.js";
9
9
  import { DocumentStorageContext, SessionStorageContext, createMemorySessionStore } from "./sessionStore.js";
10
10
  import { backoff } from "./timing.js";
11
+ import { wrapStorageError } from "./storageError.js";
11
12
  import { TERMINAL } from "./connection.js";
12
13
  import { randomId } from "../util/randomId.js";
13
14
  import { useContext, useEffect, useState } from "@assistant-ui/tap/react-shim";
@@ -42,9 +43,20 @@ const sleepUntilWake = (s, delayMs) => new Promise((resolve) => {
42
43
  }, delayMs);
43
44
  s.waiters.push(waiter);
44
45
  });
46
+ const storageFailed = (s, cause) => {
47
+ const error = wrapStorageError(cause);
48
+ s.storageError = error;
49
+ for (const entry of s.pending) entry.reject(error);
50
+ s.transport?.storageFailed(error);
51
+ };
45
52
  const setRecord = (s, record) => {
46
53
  s.record = record;
47
- if (!s.deactivated) s.store.save(record);
54
+ if (!s.deactivated) try {
55
+ s.store.save(record);
56
+ } catch (cause) {
57
+ storageFailed(s, cause);
58
+ throw s.storageError;
59
+ }
48
60
  };
49
61
  const backlog = (s) => s.pending.filter((p) => p.seq > s.durableSeq).map((p) => ({
50
62
  seq: p.seq,
@@ -70,7 +82,7 @@ const syncBacklogInner = (s) => {
70
82
  };
71
83
  const syncBacklog = (s) => {
72
84
  if (!s.shared) return syncBacklogInner(s);
73
- transact(s, () => syncBacklogInner(s));
85
+ transactBackground(s, () => syncBacklogInner(s));
74
86
  };
75
87
  const transact = (s, fn) => {
76
88
  if (!s.shared || s.deactivated) {
@@ -78,7 +90,7 @@ const transact = (s, fn) => {
78
90
  return Promise.resolve();
79
91
  }
80
92
  const shared = s.shared;
81
- const run = shared.txq.then(() => s.store.lock(async () => {
93
+ const result = shared.txq.then(() => s.store.lock(async () => {
82
94
  const stored = s.store.load();
83
95
  if (stored !== null && stored.clientId !== s.record.clientId) adoptReset(s, stored);
84
96
  else if (stored !== null) {
@@ -97,9 +109,17 @@ const transact = (s, fn) => {
97
109
  }
98
110
  }
99
111
  fn();
100
- }));
101
- shared.txq = run.then(() => {}, () => {});
102
- return run;
112
+ const persistence = s.store.flush?.();
113
+ if (persistence) await persistence;
114
+ })).catch((cause) => {
115
+ storageFailed(s, cause);
116
+ throw s.storageError;
117
+ });
118
+ shared.txq = result.then(() => {}, () => {});
119
+ return result;
120
+ };
121
+ const transactBackground = (s, fn, done) => {
122
+ transact(s, fn).then(done, () => {});
103
123
  };
104
124
  const setIdle = (s, level) => {
105
125
  if (level === s.idle) return;
@@ -136,7 +156,7 @@ const retainFailed = (s, entry, failure) => {
136
156
  retainFailedInner(s, entry, failure);
137
157
  return;
138
158
  }
139
- transact(s, () => retainFailedInner(s, entry, failure));
159
+ transactBackground(s, () => retainFailedInner(s, entry, failure));
140
160
  };
141
161
  const settleEntry = (s, entry, outcome) => {
142
162
  s.pending = s.pending.filter((p) => p !== entry);
@@ -370,6 +390,36 @@ const publishIngested = (s) => {
370
390
  wake(s);
371
391
  };
372
392
  const snapCtxClock = (s, ctxWm) => {
393
+ if (s.record.context?.recover === "version-1") {
394
+ const document = s.contextDocuments.documents.get(0);
395
+ if (s.contextDocuments.documents.size !== 1 || !document?.main) throw new Error("statewire: version-1 context recovery requires one main document at doc 0");
396
+ const encoded = encodeDocument(document.value);
397
+ const ops = [{
398
+ ...ctxWm === 0 ? {
399
+ op: "mount",
400
+ protocol: document.protocol,
401
+ main: true
402
+ } : {
403
+ op: "replace",
404
+ path: []
405
+ },
406
+ ...encoded
407
+ }];
408
+ s.ctxPending = [{
409
+ seq: ctxWm + 1,
410
+ ops,
411
+ posted: false
412
+ }];
413
+ setRecord(s, {
414
+ ...s.record,
415
+ ctxNextSeq: ctxWm + 2,
416
+ context: {
417
+ ops: contextSnapshot(s.contextDocuments),
418
+ pending: s.ctxPending
419
+ }
420
+ });
421
+ return;
422
+ }
373
423
  if (!(s.ctxPending.every((c, i) => c.seq === ctxWm + 1 + i) && s.record.ctxNextSeq === ctxWm + s.ctxPending.length + 1)) {
374
424
  if (ctxWm > 0) throw new Error("statewire: context history is missing; use a new client id");
375
425
  setRecord(s, {
@@ -433,7 +483,7 @@ const handleHello = (s, { ops, syn }) => {
433
483
  pump(s);
434
484
  return;
435
485
  }
436
- transact(s, reconcile).then(() => {
486
+ transactBackground(s, reconcile, () => {
437
487
  publishIngested(s);
438
488
  pump(s);
439
489
  });
@@ -466,10 +516,10 @@ const handleRefusal = (s, syn) => {
466
516
  const cmdGap = typeof seq === "number" && firstCmd !== void 0 && firstCmd.seq > Math.max(seq, s.durableSeq) + 1;
467
517
  const ctxGap = typeof ctxSeq === "number" && firstCtx !== void 0 && firstCtx.seq > ctxSeq + 1;
468
518
  if (cmdGap || ctxGap) {
469
- transact(s, () => {
519
+ transactBackground(s, () => {
470
520
  if (cmdGap) sessionLostInner(s, seq, ctxSeq ?? 0);
471
521
  else snapCtxClock(s, ctxSeq);
472
- }).then(() => {
522
+ }, () => {
473
523
  wake(s);
474
524
  pump(s);
475
525
  });
@@ -491,10 +541,11 @@ const handleFinish = (s, { reason }) => {
491
541
  wake(s);
492
542
  };
493
543
  const isFinished = (s) => s.attach === "finished";
494
- const buildLanes = (s) => {
544
+ const buildLanes = (s, persisted = false) => {
495
545
  const ctxEntries = [];
496
546
  for (const entry of s.ctxPending) {
497
547
  if (entry.posted) continue;
548
+ if (persisted && (s.persistedRecord?.clientId !== s.record.clientId || entry.seq >= s.persistedRecord.ctxNextSeq)) break;
498
549
  if (ctxEntries.length >= 256) break;
499
550
  if (ctxEntries.length > 0 && entry.seq !== ctxEntries.at(-1).seq + 1) break;
500
551
  ctxEntries.push(entry);
@@ -502,6 +553,7 @@ const buildLanes = (s) => {
502
553
  const cmdEntries = [];
503
554
  for (const entry of s.pending) {
504
555
  if (entry.posted) continue;
556
+ if (persisted && (s.persistedRecord?.clientId !== s.record.clientId || entry.seq >= s.persistedRecord.nextSeq)) break;
505
557
  if (cmdEntries.length >= 256) break;
506
558
  if (cmdEntries.length > 0 && entry.seq !== cmdEntries.at(-1).seq + 1) break;
507
559
  cmdEntries.push(entry);
@@ -529,22 +581,39 @@ const takeAttachLanes = (s) => {
529
581
  s.attachLaneSeqs = [/* @__PURE__ */ new Set(), /* @__PURE__ */ new Set()];
530
582
  const wanted = s.lanesOnAttach;
531
583
  s.lanesOnAttach = false;
532
- if (!wanted || !s.sessionKnown) return null;
533
- const lanes = buildLanes(s);
584
+ if (!wanted || !s.sessionKnown || s.record.context?.recover !== void 0) return null;
585
+ const lanes = buildLanes(s, true);
534
586
  if (!lanes) return null;
535
587
  s.attachLaneSeqs = [new Set(lanes.cmdEntries.map((p) => p.seq)), new Set(lanes.ctxEntries.map((c) => c.seq))];
536
588
  for (const entry of lanes.cmdEntries) entry.attempted = true;
537
589
  return lanes.frame;
538
590
  };
591
+ const persistBeforeSend = (s) => {
592
+ if (s.storageError !== void 0) throw s.storageError;
593
+ if (!s.hydrated) return s.ready.then(() => persistBeforeSend(s));
594
+ const record = s.record;
595
+ try {
596
+ const pending = s.store.flush?.();
597
+ if (pending) return pending.then(() => {
598
+ s.persistedRecord = record;
599
+ }, (cause) => {
600
+ throw wrapStorageError(cause);
601
+ });
602
+ s.persistedRecord = record;
603
+ } catch (cause) {
604
+ throw wrapStorageError(cause);
605
+ }
606
+ };
539
607
  const pump = async (s) => {
540
608
  if (s.pumping) return;
541
609
  s.pumping = true;
542
610
  try {
543
611
  while (s.running && !isFinished(s)) {
612
+ if (!s.hydrated) await s.ready;
544
613
  const transport = s.transport;
545
614
  if (buildLanes(s) === null) return;
546
615
  if (s.attach !== "attached" || !transport) {
547
- if (transport?.connection.status === "disconnected" && transport.connection.reason === "idle" || transport?.parked && s.pending.length > 0) transport.connection.reconnect();
616
+ if (transport?.connection.status === "standby" && transport.connection.reason === "idle" || transport?.parked && s.pending.length > 0) transport.connection.reconnect();
548
617
  await waitForWake(s);
549
618
  continue;
550
619
  }
@@ -556,6 +625,10 @@ const pump = async (s) => {
556
625
  const lanes = buildLanes(s);
557
626
  if (!lanes) continue;
558
627
  const epoch = s.attachEpoch;
628
+ const persistence = persistBeforeSend(s);
629
+ if (persistence) await persistence;
630
+ if (!s.running || isFinished(s)) return;
631
+ if (epoch !== s.attachEpoch || s.attach !== "attached") continue;
559
632
  for (const entry of lanes.cmdEntries) entry.attempted = true;
560
633
  let result;
561
634
  try {
@@ -592,22 +665,31 @@ const pump = async (s) => {
592
665
  }
593
666
  }
594
667
  }
668
+ } catch (error) {
669
+ storageFailed(s, error);
595
670
  } finally {
596
671
  s.pumping = false;
597
672
  }
598
673
  };
599
674
  const start = (s) => {
675
+ s.running = true;
676
+ if (!s.hydrated) {
677
+ s.ready.then(() => {
678
+ if (s.running) start(s);
679
+ }, () => {});
680
+ return;
681
+ }
600
682
  if (s.shared) {
601
683
  s.shared.unsubscribe = s.store.subscribe(() => {
602
- transact(s, () => consumeVerdicts(s));
684
+ transactBackground(s, () => consumeVerdicts(s));
603
685
  });
604
686
  s.running = true;
605
- transact(s, () => {
687
+ transactBackground(s, () => {
606
688
  if (s.store.load() === null) s.store.save(s.record);
607
689
  s.recordPersisted = true;
608
690
  adoptStored(s);
609
691
  consumeVerdicts(s);
610
- }).then(() => void pump(s));
692
+ }, () => void pump(s));
611
693
  return;
612
694
  }
613
695
  if (!s.recordPersisted) {
@@ -685,7 +767,7 @@ const enqueue = (s, command, params, resolve, reject) => {
685
767
  }).then(() => {
686
768
  wake(s);
687
769
  pump(s);
688
- });
770
+ }, reject);
689
771
  };
690
772
  const adoptStored = (s) => {
691
773
  const known = new Set(s.pending.map((p) => p.seq));
@@ -718,6 +800,16 @@ const adoptStored = (s) => {
718
800
  s.pending.sort((a, b) => a.seq - b.seq);
719
801
  };
720
802
  const sendCommand = (s, method, params, protocol = s.options.protocol ?? "default") => {
803
+ if (!s.hydrated) {
804
+ const result = s.ready.then(() => sendCommand(s, method, params, protocol));
805
+ result.catch(() => {});
806
+ return result;
807
+ }
808
+ if (s.storageError !== void 0) {
809
+ const result = Promise.reject(s.storageError);
810
+ result.catch(() => {});
811
+ return result;
812
+ }
721
813
  if (!s.running) return rejectedSend({
722
814
  fate: "aborted",
723
815
  cause: "unmount"
@@ -761,7 +853,7 @@ const discardFailed = (s, entry) => {
761
853
  return;
762
854
  }
763
855
  if (findFailed(s, entry) === void 0) throw unknownFailed(entry);
764
- transact(s, () => {
856
+ transactBackground(s, () => {
765
857
  if (findFailed(s, entry) !== void 0) takeFailed(s, entry);
766
858
  });
767
859
  };
@@ -840,6 +932,7 @@ const persistContext = (s) => {
840
932
  ...s.record,
841
933
  context: {
842
934
  ops,
935
+ ...s.record.context?.recover === void 0 ? {} : { recover: s.record.context.recover },
843
936
  pending: s.ctxPending.map(({ seq, ops }) => ({
844
937
  seq,
845
938
  ops
@@ -848,6 +941,14 @@ const persistContext = (s) => {
848
941
  });
849
942
  };
850
943
  const changeContext = (s, build) => {
944
+ if (!s.hydrated) {
945
+ s.ready.then(() => changeContext(s, build)).catch((cause) => {
946
+ const error = wrapStorageError(cause);
947
+ s.storageError = error;
948
+ s.transport?.storageFailed(error);
949
+ });
950
+ return;
951
+ }
851
952
  if (!s.running || isFinished(s)) return;
852
953
  const submit = () => {
853
954
  if (!s.running || isFinished(s)) return;
@@ -881,7 +982,7 @@ const changeContext = (s, build) => {
881
982
  submit();
882
983
  wake(s);
883
984
  pump(s);
884
- } else transact(s, submit).then(() => {
985
+ } else transactBackground(s, submit, () => {
885
986
  wake(s);
886
987
  pump(s);
887
988
  });
@@ -943,6 +1044,32 @@ const storedCommandCompatible = (command, current) => {
943
1044
  const offer = current.protocols.find((p) => p.name === command.protocol);
944
1045
  return produced !== void 0 && offer !== void 0 && inRange(produced.version, offer);
945
1046
  };
1047
+ const loadSessionRecord = (store, options) => {
1048
+ let stored = store.load();
1049
+ if (stored !== null && !Array.isArray(stored.commands)) throw new Error("statewire: session storage loaded a record without a commands array");
1050
+ const current = {
1051
+ wire: options.wireVersion ?? { version: "2026-09-07" },
1052
+ protocols: protocolOffers(options)
1053
+ };
1054
+ if (stored !== null && !stored.commands.every((command) => storedCommandCompatible(command, current))) {
1055
+ if (!store.migrate) throw new Error("statewire: stored commands require a storage migration");
1056
+ stored = store.migrate(stored, current);
1057
+ if (stored !== null && !stored.commands.every((command) => storedCommandCompatible(command, current))) throw new Error("statewire: migrated commands are incompatible");
1058
+ if (stored !== null) store.save(stored);
1059
+ }
1060
+ if (stored !== null && !Array.isArray(stored.commands)) throw new Error("statewire: session storage loaded a record without a commands array");
1061
+ const record = stored ?? {
1062
+ clientId: randomId(),
1063
+ nextSeq: 1,
1064
+ ctxNextSeq: 1,
1065
+ commands: []
1066
+ };
1067
+ if (stored === null) store.save(record);
1068
+ return {
1069
+ record,
1070
+ stored
1071
+ };
1072
+ };
946
1073
  const useStatewireSession = (options) => {
947
1074
  const [defaultStore] = useState(createMemorySessionStore);
948
1075
  const mounted = useContext(SessionStorageContext);
@@ -951,26 +1078,15 @@ const useStatewireSession = (options) => {
951
1078
  const store = options.sessionStore ?? mounted ?? defaultStore;
952
1079
  if (store.lock === void 0 !== (store.subscribe === void 0)) throw new Error("statewire: session storage must provide lock and subscribe together");
953
1080
  const [s] = useState(() => {
954
- let stored = store.load();
955
- if (stored !== null && !Array.isArray(stored.commands)) throw new Error("statewire: session storage loaded a record without a commands array");
956
- const current = {
957
- wire: options.wireVersion ?? { version: "2026-09-07" },
958
- protocols: protocolOffers(options)
959
- };
960
- if (stored !== null && !stored.commands.every((command_0) => storedCommandCompatible(command_0, current))) {
961
- if (!store.migrate) throw new Error("statewire: stored commands require a storage migration");
962
- stored = store.migrate(stored, current);
963
- if (stored !== null && !stored.commands.every((command) => storedCommandCompatible(command, current))) throw new Error("statewire: migrated commands are incompatible");
964
- if (stored !== null) store.save(stored);
965
- }
966
- if (stored !== null && !Array.isArray(stored.commands)) throw new Error("statewire: session storage loaded a record without a commands array");
967
- const record = stored ?? {
968
- clientId: randomId(),
969
- nextSeq: 1,
970
- ctxNextSeq: 1,
971
- commands: []
1081
+ const { record, stored } = store.ready === void 0 ? loadSessionRecord(store, options) : {
1082
+ record: {
1083
+ clientId: randomId(),
1084
+ nextSeq: 1,
1085
+ ctxNextSeq: 1,
1086
+ commands: []
1087
+ },
1088
+ stored: null
972
1089
  };
973
- if (stored === null) store.save(record);
974
1090
  const session = {
975
1091
  options,
976
1092
  selection: void 0,
@@ -983,6 +1099,7 @@ const useStatewireSession = (options) => {
983
1099
  }, stored.context.ops),
984
1100
  record,
985
1101
  ctx: {
1102
+ beforeAttach: () => persistBeforeSend(session),
986
1103
  get clientId() {
987
1104
  return session.record.clientId;
988
1105
  },
@@ -995,7 +1112,11 @@ const useStatewireSession = (options) => {
995
1112
  onDetach: () => handleDetach(session),
996
1113
  onFinish: (args_1) => handleFinish(session, args_1)
997
1114
  },
998
- recordPersisted: stored !== null,
1115
+ recordPersisted: store.ready === void 0,
1116
+ hydrated: store.ready === void 0,
1117
+ ready: void 0,
1118
+ storageError: void 0,
1119
+ persistedRecord: store.ready === void 0 ? record : null,
999
1120
  store,
1000
1121
  documentStore,
1001
1122
  shared: store.lock !== void 0 ? {
@@ -1049,6 +1170,34 @@ const useStatewireSession = (options) => {
1049
1170
  notify: () => {},
1050
1171
  commands: createMethodProxy((method, params) => sendCommand(session, method, params))
1051
1172
  };
1173
+ if (store.ready !== void 0) {
1174
+ session.ready = store.ready.then(() => {
1175
+ const { record: record_0 } = loadSessionRecord(store, session.options);
1176
+ session.record = record_0;
1177
+ session.contextDocuments = record_0.context === void 0 ? {
1178
+ mode: "mounted",
1179
+ documents: /* @__PURE__ */ new Map()
1180
+ } : applyDocumentOps({
1181
+ mode: "mounted",
1182
+ documents: /* @__PURE__ */ new Map()
1183
+ }, record_0.context.ops);
1184
+ session.ctxOps = record_0.context?.ops;
1185
+ session.ctxPending = record_0.context?.pending.map((entry) => ({
1186
+ ...entry,
1187
+ posted: false
1188
+ })) ?? [];
1189
+ session.recordPersisted = true;
1190
+ session.hydrated = true;
1191
+ if (session.running) session.notify();
1192
+ });
1193
+ session.ready = session.ready.catch((cause) => {
1194
+ const error = wrapStorageError(cause);
1195
+ session.storageError = error;
1196
+ session.transport?.storageFailed(error);
1197
+ throw error;
1198
+ });
1199
+ session.ready.catch(() => {});
1200
+ }
1052
1201
  bindCommandsHost(session.commands, { setListener: (listener_0) => {
1053
1202
  session.lifecycle = listener_0;
1054
1203
  } });