statewire 0.17.2 → 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 = (
@@ -697,7 +726,7 @@ const handleHello = (
697
726
  void pump(s);
698
727
  return;
699
728
  }
700
- void transact(s, reconcile).then(() => {
729
+ transactBackground(s, reconcile, () => {
701
730
  publishIngested(s);
702
731
  void pump(s);
703
732
  });
@@ -744,13 +773,17 @@ const handleRefusal = (s: Session, syn: StatewireSyn) => {
744
773
  firstCtx !== undefined &&
745
774
  firstCtx.seq > ctxSeq + 1;
746
775
  if (cmdGap || ctxGap) {
747
- void transact(s, () => {
748
- if (cmdGap) sessionLostInner(s, seq!, ctxSeq ?? 0);
749
- else snapCtxClock(s, ctxSeq!);
750
- }).then(() => {
751
- wake(s);
752
- void pump(s);
753
- });
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
+ );
754
787
  return;
755
788
  }
756
789
  wake(s);
@@ -781,10 +814,16 @@ type Lanes = {
781
814
 
782
815
  // A lane must be strictly consecutive within a frame: the first unposted run
783
816
  // per lane rides now, later runs ride follow-up frames.
784
- const buildLanes = (s: Session): Lanes | null => {
817
+ const buildLanes = (s: Session, persisted = false): Lanes | null => {
785
818
  const ctxEntries: CtxPending[] = [];
786
819
  for (const entry of s.ctxPending) {
787
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;
788
827
  if (ctxEntries.length >= MAX_COMMANDS_PER_FRAME) break;
789
828
  if (ctxEntries.length > 0 && entry.seq !== ctxEntries.at(-1)!.seq + 1)
790
829
  break;
@@ -793,6 +832,12 @@ const buildLanes = (s: Session): Lanes | null => {
793
832
  const cmdEntries: Pending[] = [];
794
833
  for (const entry of s.pending) {
795
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;
796
841
  if (cmdEntries.length >= MAX_COMMANDS_PER_FRAME) break;
797
842
  if (cmdEntries.length > 0 && entry.seq !== cmdEntries.at(-1)!.seq + 1)
798
843
  break;
@@ -822,7 +867,7 @@ const takeAttachLanes = (s: Session): StatewireFrame | null => {
822
867
  s.lanesOnAttach = false;
823
868
  if (!wanted || !s.sessionKnown || s.record.context?.recover !== undefined)
824
869
  return null;
825
- const lanes = buildLanes(s);
870
+ const lanes = buildLanes(s, true);
826
871
  if (!lanes) return null;
827
872
  s.attachLaneSeqs = [
828
873
  new Set(lanes.cmdEntries.map((p) => p.seq)),
@@ -832,16 +877,38 @@ const takeAttachLanes = (s: Session): StatewireFrame | null => {
832
877
  return lanes.frame;
833
878
  };
834
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
+
835
901
  const pump = async (s: Session) => {
836
902
  if (s.pumping) return;
837
903
  s.pumping = true;
838
904
  try {
839
905
  while (s.running && !isFinished(s)) {
906
+ if (!s.hydrated) await s.ready;
840
907
  const transport = s.transport;
841
908
  if (buildLanes(s) === null) return;
842
909
  if (s.attach !== "attached" || !transport) {
843
910
  if (
844
- (transport?.connection.status === "disconnected" &&
911
+ (transport?.connection.status === "standby" &&
845
912
  transport.connection.reason === "idle") ||
846
913
  (transport?.parked && s.pending.length > 0)
847
914
  ) {
@@ -858,6 +925,10 @@ const pump = async (s: Session) => {
858
925
  const lanes = buildLanes(s);
859
926
  if (!lanes) continue;
860
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;
861
932
  for (const entry of lanes.cmdEntries) entry.attempted = true;
862
933
  let result;
863
934
  try {
@@ -904,23 +975,39 @@ const pump = async (s: Session) => {
904
975
  }
905
976
  }
906
977
  }
978
+ } catch (error) {
979
+ storageFailed(s, error);
907
980
  } finally {
908
981
  s.pumping = false;
909
982
  }
910
983
  };
911
984
 
912
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
+ }
913
996
  if (s.shared) {
914
997
  s.shared.unsubscribe = s.store.subscribe!(() => {
915
- void transact(s, () => consumeVerdicts(s));
998
+ transactBackground(s, () => consumeVerdicts(s));
916
999
  });
917
1000
  s.running = true;
918
- void transact(s, () => {
919
- if (s.store.load() === null) s.store.save(s.record);
920
- s.recordPersisted = true;
921
- adoptStored(s);
922
- consumeVerdicts(s);
923
- }).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
+ );
924
1011
  return;
925
1012
  }
926
1013
  if (!s.recordPersisted) {
@@ -953,7 +1040,7 @@ const enqueueInner = (
953
1040
  command: WireCommand,
954
1041
  params: readonly unknown[],
955
1042
  resolve: (payload: unknown) => void,
956
- reject: (error: StatewireSendError) => void,
1043
+ reject: (error: unknown) => void,
957
1044
  ) => {
958
1045
  const seq = s.record.nextSeq;
959
1046
  const entry: Pending = {
@@ -983,7 +1070,7 @@ const enqueue = (
983
1070
  command: WireCommand,
984
1071
  params: readonly unknown[],
985
1072
  resolve: (payload: unknown) => void,
986
- reject: (error: StatewireSendError) => void,
1073
+ reject: (error: unknown) => void,
987
1074
  ) => {
988
1075
  if (!s.shared) {
989
1076
  enqueueInner(s, command, params, resolve, reject);
@@ -1004,7 +1091,7 @@ const enqueue = (
1004
1091
  }).then(() => {
1005
1092
  wake(s);
1006
1093
  void pump(s);
1007
- });
1094
+ }, reject);
1008
1095
  };
1009
1096
 
1010
1097
  // A stored command may have reached a server: attempted settles it lost,
@@ -1053,6 +1140,18 @@ const sendCommand = (
1053
1140
  params: readonly unknown[],
1054
1141
  protocol = s.options.protocol ?? DEFAULT_PROTOCOL,
1055
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
+ }
1056
1155
  if (!s.running) {
1057
1156
  return rejectedSend({ fate: "aborted", cause: "unmount" });
1058
1157
  }
@@ -1118,7 +1217,7 @@ export const discardFailed = (
1118
1217
  return;
1119
1218
  }
1120
1219
  if (findFailed(s, entry) === undefined) throw unknownFailed(entry);
1121
- void transact(s, () => {
1220
+ transactBackground(s, () => {
1122
1221
  if (findFailed(s, entry) !== undefined) takeFailed(s, entry);
1123
1222
  });
1124
1223
  };
@@ -1219,6 +1318,16 @@ const changeContext = (
1219
1318
  s: Session,
1220
1319
  build: (documents: StatewireDocuments.State) => readonly unknown[],
1221
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
+ }
1222
1331
  if (!s.running || isFinished(s)) return;
1223
1332
  const submit = () => {
1224
1333
  if (!s.running || isFinished(s)) return;
@@ -1263,7 +1372,7 @@ const changeContext = (
1263
1372
  wake(s);
1264
1373
  void pump(s);
1265
1374
  } else
1266
- void transact(s, submit).then(() => {
1375
+ transactBackground(s, submit, () => {
1267
1376
  wake(s);
1268
1377
  void pump(s);
1269
1378
  });
@@ -1358,6 +1467,52 @@ const storedCommandCompatible = (
1358
1467
  );
1359
1468
  };
1360
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
+
1361
1516
  export const useStatewireSession = (
1362
1517
  options: StatewireSessionOptions,
1363
1518
  ): Session => {
@@ -1376,47 +1531,18 @@ export const useStatewireSession = (
1376
1531
  );
1377
1532
  }
1378
1533
  const [s] = useState((): Session => {
1379
- let stored = store.load();
1380
- if (stored !== null && !Array.isArray(stored.commands))
1381
- throw new Error(
1382
- "statewire: session storage loaded a record without a commands array",
1383
- );
1384
- const current = {
1385
- wire: options.wireVersion ?? { version: WIRE_VERSION },
1386
- protocols: protocolOffers(options),
1387
- };
1388
- if (
1389
- stored !== null &&
1390
- !stored.commands.every((command) =>
1391
- storedCommandCompatible(command, current),
1392
- )
1393
- ) {
1394
- if (!store.migrate)
1395
- throw new Error(
1396
- "statewire: stored commands require a storage migration",
1397
- );
1398
- stored = store.migrate(stored, current);
1399
- if (
1400
- stored !== null &&
1401
- !stored.commands.every((command) =>
1402
- storedCommandCompatible(command, current),
1403
- )
1404
- )
1405
- throw new Error("statewire: migrated commands are incompatible");
1406
- if (stored !== null) store.save(stored);
1407
- }
1408
- if (stored !== null && !Array.isArray(stored.commands)) {
1409
- throw new Error(
1410
- "statewire: session storage loaded a record without a commands array",
1411
- );
1412
- }
1413
- const record = stored ?? {
1414
- clientId: newClientId(),
1415
- nextSeq: 1,
1416
- ctxNextSeq: 1,
1417
- commands: [],
1418
- };
1419
- 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
+ };
1420
1546
  const session: Session = {
1421
1547
  options,
1422
1548
  selection: undefined,
@@ -1429,6 +1555,7 @@ export const useStatewireSession = (
1429
1555
  ),
1430
1556
  record,
1431
1557
  ctx: {
1558
+ beforeAttach: () => persistBeforeSend(session),
1432
1559
  get clientId() {
1433
1560
  return session.record.clientId;
1434
1561
  },
@@ -1441,7 +1568,11 @@ export const useStatewireSession = (
1441
1568
  onDetach: () => handleDetach(session),
1442
1569
  onFinish: (args) => handleFinish(session, args),
1443
1570
  },
1444
- 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,
1445
1576
  store,
1446
1577
  documentStore,
1447
1578
  shared:
@@ -1491,6 +1622,35 @@ export const useStatewireSession = (
1491
1622
  sendCommand(session, method, params),
1492
1623
  ),
1493
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
+ }
1494
1654
  bindCommandsHost(session.commands, {
1495
1655
  setListener: (listener) => {
1496
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
 
@@ -459,8 +460,12 @@ export namespace StatewireClient {
459
460
  meta?: unknown;
460
461
  };
461
462
 
462
- /** Session storage seam; `save` must persist synchronously in the caller's flow. */
463
+ /** Session storage seam with optional asynchronous hydration and persistence. */
463
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>;
464
469
  load: () => SessionRecord | null;
465
470
  save: (record: SessionRecord) => void;
466
471
  /** Upgrade incompatible stored commands; null discards the old record and starts a new client identity. */
@@ -475,7 +480,7 @@ export namespace StatewireClient {
475
480
  };
476
481
 
477
482
  /** Static headers, or a resolver invoked per request/attach; `staleAuth` is true right after an auth rejection. */
478
- export type ConnectionError = Error & { fin: StatewireHost.Fin };
483
+ export type ConnectionError = Error & { fin?: StatewireHost.Fin };
479
484
 
480
485
  export type HeadersOption =
481
486
  | HeadersInit