statewire 0.10.0 → 0.12.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.
Files changed (51) hide show
  1. package/README.md +2 -2
  2. package/dist/client/connection.d.ts +6 -4
  3. package/dist/client/connection.d.ts.map +1 -1
  4. package/dist/client/connection.js +10 -2
  5. package/dist/client/connection.js.map +1 -1
  6. package/dist/client/core.d.ts +1 -1
  7. package/dist/client/session.d.ts +10 -2
  8. package/dist/client/session.d.ts.map +1 -1
  9. package/dist/client/session.js +246 -97
  10. package/dist/client/session.js.map +1 -1
  11. package/dist/host/host.d.ts +1 -1
  12. package/dist/host/http.js +4 -3
  13. package/dist/host/http.js.map +1 -1
  14. package/dist/host/socket.d.ts +6 -5
  15. package/dist/host/socket.d.ts.map +1 -1
  16. package/dist/host/socket.js +12 -20
  17. package/dist/host/socket.js.map +1 -1
  18. package/dist/host-internal.d.ts +2 -1
  19. package/dist/host-internal.d.ts.map +1 -1
  20. package/dist/host.d.ts +2 -2
  21. package/dist/host.d.ts.map +1 -1
  22. package/dist/index.d.ts +1 -1
  23. package/dist/statewire.d.ts +29 -3
  24. package/dist/statewire.d.ts.map +1 -1
  25. package/dist/statewire.js.map +1 -1
  26. package/dist/transports/fake-sse.js +4 -4
  27. package/dist/transports/fake-sse.js.map +1 -1
  28. package/dist/transports/fixtures.d.ts +1 -1
  29. package/dist/transports/remote.d.ts +1 -1
  30. package/dist/transports/remote.js +1 -0
  31. package/dist/transports/remote.js.map +1 -1
  32. package/dist/transports/sse.d.ts +2 -2
  33. package/dist/transports/testing.d.ts +1 -1
  34. package/dist/transports/testing.js +2 -4
  35. package/dist/transports/testing.js.map +1 -1
  36. package/dist/wire/meta.d.ts +5 -3
  37. package/dist/wire/meta.d.ts.map +1 -1
  38. package/dist/wire/meta.js +7 -4
  39. package/dist/wire/meta.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/client/connection.ts +12 -6
  42. package/src/client/session.ts +332 -114
  43. package/src/host/http.ts +10 -9
  44. package/src/host/socket.ts +18 -28
  45. package/src/host-internal.ts +2 -1
  46. package/src/host.ts +0 -1
  47. package/src/statewire.ts +33 -3
  48. package/src/transports/fake-sse.ts +6 -6
  49. package/src/transports/remote.ts +1 -0
  50. package/src/transports/testing.ts +2 -7
  51. package/src/wire/meta.ts +12 -4
@@ -1,6 +1,7 @@
1
1
  import { useContext, useEffect, useState } from "react";
2
2
  import { applyOps } from "../wire/ops";
3
3
  import type { StatewirePath } from "../wire/ops";
4
+ import { TERMINAL } from "./connection";
4
5
  import type { StatewireCmd, StatewireSyn } from "./connection";
5
6
  import {
6
7
  encodeParams,
@@ -70,14 +71,22 @@ type AttachState = "detached" | "attached" | "finished";
70
71
 
71
72
  export type StatewireSessionOptions = {
72
73
  sessionStore?: StatewireClient.SessionStorage | undefined;
74
+ onSessionLost?: StatewireClient.OnSessionLost | undefined;
73
75
  onDone?: (() => void) | undefined;
74
76
  };
75
77
 
78
+ type Shared = {
79
+ txq: Promise<void>;
80
+ parked: StatewireClient.SessionVerdict[];
81
+ unsubscribe: (() => void) | null;
82
+ };
83
+
76
84
  type Session = {
77
85
  record: StatewireClient.SessionRecord;
78
86
  ctx: StatewireRemoteProps;
79
87
  recordPersisted: boolean;
80
88
  store: StatewireClient.SessionStorage;
89
+ shared: Shared | false;
81
90
  ephemeral: boolean;
82
91
  /** An ephemeral record died with its attach: the storage is off for good. */
83
92
  deactivated: boolean;
@@ -85,6 +94,7 @@ type Session = {
85
94
  durableSeq: number;
86
95
  resumeAt: number;
87
96
  idle: boolean;
97
+ onSessionLost: StatewireClient.OnSessionLost | undefined;
88
98
  onDone: (() => void) | undefined;
89
99
  ctxOps: unknown[] | undefined;
90
100
  ctxPending: CtxPending[];
@@ -144,15 +154,68 @@ const backlog = (s: Session): StatewireClient.SessionCommand[] =>
144
154
  .filter((p) => p.seq > s.durableSeq)
145
155
  .map((p) => ({ seq: p.seq, ...p.command }));
146
156
 
157
+ const mergeBacklog = (s: Session): StatewireClient.SessionCommand[] => {
158
+ const own = backlog(s);
159
+ if (!s.shared) return own;
160
+ const known = new Set(s.pending.map((p) => p.seq));
161
+ const foreign = s.record.commands.filter(
162
+ (c) => !known.has(c.seq) && c.seq > s.durableSeq,
163
+ );
164
+ return [...foreign, ...own].sort((a, b) => a.seq - b.seq);
165
+ };
166
+
167
+ const MAX_PARKED_VERDICTS = 64;
168
+
147
169
  // Storage holds what a reload must resend: a command leaves at its dur, at a
148
170
  // server verdict, or at a record reset — never at a local channel death.
149
- const syncBacklog = (s: Session) => {
150
- const commands = backlog(s);
171
+ const syncBacklogInner = (s: Session) => {
172
+ const parked = s.shared ? s.shared.parked.splice(0) : [];
173
+ const parkedSeqs = new Set(parked.map((v) => v.seq));
174
+ const commands = mergeBacklog(s).filter((c) => !parkedSeqs.has(c.seq));
175
+ const verdicts =
176
+ parked.length > 0
177
+ ? [...(s.record.verdicts ?? []), ...parked].slice(-MAX_PARKED_VERDICTS)
178
+ : s.record.verdicts;
151
179
  const unchanged =
180
+ verdicts === s.record.verdicts &&
152
181
  commands.length === s.record.commands.length &&
153
182
  commands.every((c, i) => c.seq === s.record.commands[i]!.seq);
154
183
  if (unchanged) return;
155
- setRecord(s, { ...s.record, commands });
184
+ setRecord(s, {
185
+ ...s.record,
186
+ commands,
187
+ ...(verdicts !== undefined && { verdicts }),
188
+ });
189
+ };
190
+
191
+ const syncBacklog = (s: Session) => {
192
+ if (!s.shared) return syncBacklogInner(s);
193
+ void transact(s, () => syncBacklogInner(s));
194
+ };
195
+
196
+ // Every record mutation over shared storage is a locked read-modify-write.
197
+ const transact = (s: Session, fn: () => void): Promise<void> => {
198
+ if (!s.shared || s.deactivated) {
199
+ fn();
200
+ return Promise.resolve();
201
+ }
202
+ const shared = s.shared;
203
+ const run = shared.txq.then(() =>
204
+ s.store.lock!(async () => {
205
+ const stored = s.store.load();
206
+ if (stored !== null && stored.clientId !== s.record.clientId) {
207
+ adoptReset(s, stored);
208
+ } else if (stored !== null) {
209
+ s.record = stored;
210
+ }
211
+ fn();
212
+ }),
213
+ );
214
+ shared.txq = run.then(
215
+ () => {},
216
+ () => {},
217
+ );
218
+ return run;
156
219
  };
157
220
 
158
221
  const setIdle = (s: Session, level: boolean) => {
@@ -192,14 +255,9 @@ const settleEntry = (
192
255
  }
193
256
  };
194
257
 
195
- const settleAll = (s: Session, detach: "unmount" | "gone" | "superseded") => {
196
- const cause = (
197
- {
198
- unmount: "unmount",
199
- gone: "stream-gone",
200
- superseded: "superseded",
201
- } as const
202
- )[detach];
258
+ const settleAll = (s: Session, detach: "unmount" | "gone") => {
259
+ const cause =
260
+ detach === "unmount" ? ("unmount" as const) : ("stream-gone" as const);
203
261
  const batch = s.pending;
204
262
  const detachFailure = (entry: Pending): StatewireClient.SendFailure => {
205
263
  if (!entry.attempted) {
@@ -226,35 +284,52 @@ const restateContext = (s: Session) => {
226
284
  s.ctxPending.push({ seq, ops: s.ctxOps, posted: false });
227
285
  };
228
286
 
287
+ const settleLost = (s: Session, entries: Pending[]) => {
288
+ for (const entry of entries) {
289
+ s.lifecycle?.onCovered?.(entry.inv);
290
+ settleEntry(s, entry, {
291
+ ok: false,
292
+ failure: { fate: "lost", cause: "session-lost" },
293
+ });
294
+ }
295
+ };
296
+
229
297
  // The server-side record's dedup memory is gone: history that may have
230
298
  // reached a server settles lost — the new hello's snapshot is the only
231
299
  // truth about what survived — and never-attempted statements re-stamp
232
- // from 1 on the reset lane clocks.
233
- const resetRecord = (s: Session, clientId: string) => {
234
- const survivors: Pending[] = [];
235
- for (const entry of [...s.pending]) {
236
- if (entry.attempted || entry.applied) {
237
- s.lifecycle?.onCovered?.(entry.inv);
238
- settleEntry(s, entry, {
239
- ok: false,
240
- failure: { fate: "lost", cause: "session-lost" },
241
- });
242
- } else {
243
- survivors.push(entry);
244
- }
245
- }
300
+ // on the adopted lane clocks.
301
+ const rebaseRecord = (s: Session, adopt: () => void) => {
302
+ const held = [...s.pending];
303
+ const survivors = held.filter((p) => !p.attempted && !p.applied);
304
+ settleLost(
305
+ s,
306
+ held.filter((p) => p.attempted || p.applied),
307
+ );
246
308
  s.pending = [];
247
309
  s.durableSeq = 0;
248
- setRecord(s, { clientId, nextSeq: 1, ctxNextSeq: 1, commands: [] });
310
+ adopt();
249
311
  for (const old of survivors) {
250
312
  s.lifecycle?.onCovered?.(old.inv);
251
- enqueue(s, old.command, old.inv.params, old.resolve, old.reject);
313
+ enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
252
314
  }
253
315
  restateContext(s);
254
316
  publish(s);
255
317
  wake(s);
318
+ void pump(s);
256
319
  };
257
320
 
321
+ const resetRecord = (s: Session, clientId: string) =>
322
+ rebaseRecord(s, () =>
323
+ setRecord(s, { clientId, nextSeq: 1, ctxNextSeq: 1, commands: [] }),
324
+ );
325
+
326
+ // Another sharer replaced the record's identity: their reset settles our
327
+ // attempted history and our survivors re-stamp on the stored clocks.
328
+ const adoptReset = (s: Session, stored: StatewireClient.SessionRecord) =>
329
+ rebaseRecord(s, () => {
330
+ s.record = stored;
331
+ });
332
+
258
333
  // The record died with the attach: mint a fresh id for the next one.
259
334
  const ephemeralDetach = (s: Session) => {
260
335
  s.ephemeral = false;
@@ -272,6 +347,48 @@ const rejectedFailure = (cmd: StatewireCmd): StatewireClient.SendFailure => {
272
347
  };
273
348
  };
274
349
 
350
+ const outcomeOf = (
351
+ cmd: StatewireCmd,
352
+ ):
353
+ | { ok: true; payload: unknown }
354
+ | { ok: false; failure: StatewireClient.SendFailure } => {
355
+ switch (cmd.type) {
356
+ case "result":
357
+ return { ok: true, payload: reviveResponse(cmd).payload };
358
+ case "rejected":
359
+ return { ok: false, failure: rejectedFailure(reviveResponse(cmd)) };
360
+ case "failed":
361
+ return {
362
+ ok: false,
363
+ failure: {
364
+ fate: "failed",
365
+ cause: "server",
366
+ ...(cmd.message !== undefined && { message: cmd.message }),
367
+ },
368
+ };
369
+ case "crashed":
370
+ return { ok: false, failure: { fate: "crashed", cause: "server" } };
371
+ case "expired":
372
+ return { ok: false, failure: { fate: "expired", cause: "server" } };
373
+ default:
374
+ return protocolError(`unknown cmd type: ${String(cmd.type)}`);
375
+ }
376
+ };
377
+
378
+ const TERMINAL_CMD_TYPES: ReadonlySet<string> = new Set(TERMINAL);
379
+
380
+ const parkVerdict = (s: Session, cmd: StatewireCmd) => {
381
+ if (!s.shared || !TERMINAL_CMD_TYPES.has(cmd.type!)) return;
382
+ s.shared.parked.push({
383
+ seq: cmd.seq,
384
+ type: cmd.type as StatewireClient.SessionVerdict["type"],
385
+ ...(cmd.code !== undefined && { code: cmd.code }),
386
+ ...(cmd.message !== undefined && { message: cmd.message }),
387
+ ...(cmd.payload !== undefined && { payload: cmd.payload }),
388
+ ...(cmd.meta !== undefined && { meta: cmd.meta }),
389
+ });
390
+ };
391
+
275
392
  const handleCmd = (s: Session, cmd: StatewireCmd) => {
276
393
  if (!Number.isSafeInteger(cmd.seq) || cmd.seq <= 0) {
277
394
  protocolError("cmd.seq must be a positive safe integer");
@@ -288,37 +405,30 @@ const handleCmd = (s: Session, cmd: StatewireCmd) => {
288
405
  if (entry) cover(s, entry);
289
406
  return;
290
407
  }
408
+ parkVerdict(s, cmd);
291
409
  if (!entry) return;
292
410
  cover(s, entry);
293
- const outcome =
294
- cmd.type === "result"
295
- ? { ok: true as const, payload: reviveResponse(cmd).payload }
296
- : cmd.type === "rejected"
297
- ? { ok: false as const, failure: rejectedFailure(reviveResponse(cmd)) }
298
- : cmd.type === "failed"
299
- ? {
300
- ok: false as const,
301
- failure: {
302
- fate: "failed" as const,
303
- cause: "server" as const,
304
- ...(cmd.message !== undefined && { message: cmd.message }),
305
- },
306
- }
307
- : cmd.type === "crashed"
308
- ? {
309
- ok: false as const,
310
- failure: { fate: "crashed" as const, cause: "server" as const },
311
- }
312
- : cmd.type === "expired"
313
- ? {
314
- ok: false as const,
315
- failure: {
316
- fate: "expired" as const,
317
- cause: "server" as const,
318
- },
319
- }
320
- : protocolError(`unknown cmd type: ${String(cmd.type)}`);
321
- settleEntry(s, entry, outcome);
411
+ settleEntry(s, entry, outcomeOf(cmd));
412
+ };
413
+
414
+ const consumeVerdicts = (s: Session) => {
415
+ const verdicts = s.record.verdicts ?? [];
416
+ const consumed = new Set<number>();
417
+ for (const verdict of verdicts) {
418
+ const entry = s.pending.find((p) => p.seq === verdict.seq);
419
+ if (!entry) continue;
420
+ consumed.add(verdict.seq);
421
+ cover(s, entry);
422
+ settleEntry(s, entry, outcomeOf(verdict as StatewireCmd));
423
+ }
424
+ if (consumed.size === 0) return;
425
+ setRecord(s, {
426
+ ...s.record,
427
+ commands: s.record.commands.filter((c) => !consumed.has(c.seq)),
428
+ verdicts: verdicts.filter((v) => !consumed.has(v.seq)),
429
+ });
430
+ publish(s);
431
+ wake(s);
322
432
  };
323
433
 
324
434
  const handleCmds = (s: Session, cmds: StatewireCmd[]) => {
@@ -338,24 +448,10 @@ const publishIngested = (s: Session) => {
338
448
  wake(s);
339
449
  };
340
450
 
341
- const RECORDS = ["fresh", "resumed", "ephemeral"];
342
-
343
- // Snap a resumed session's lane clocks onto the record: seqs above the stated
344
- // watermark were never admitted, so a stale restored clock re-stamps its
345
- // never-admitted statements consecutively after the watermark.
346
- const snapClocks = (s: Session, seqWm: number, ctxWm: number) => {
347
- const tail = s.pending.filter((p) => p.seq > seqWm);
348
- const cmdAligned =
349
- tail.every((p, i) => p.seq === seqWm + 1 + i) &&
350
- s.record.nextSeq === seqWm + tail.length + 1;
351
- if (!cmdAligned) {
352
- s.pending = s.pending.filter((p) => p.seq <= seqWm);
353
- setRecord(s, { ...s.record, nextSeq: seqWm + 1 });
354
- for (const old of tail) {
355
- s.lifecycle?.onCovered?.(old.inv);
356
- enqueue(s, old.command, old.inv.params, old.resolve, old.reject);
357
- }
358
- }
451
+ // A hello's cmd watermark may understate a durable record (cold host, tray
452
+ // unloaded): held seqs resend as-is and the frame's answer arbitrates —
453
+ // absorption where the record survived, a gap refusal where it did not.
454
+ const snapCtxClock = (s: Session, ctxWm: number) => {
359
455
  const ctxAligned =
360
456
  s.ctxPending.every((c, i) => c.seq === ctxWm + 1 + i) &&
361
457
  s.record.ctxNextSeq === ctxWm + s.ctxPending.length + 1;
@@ -365,40 +461,81 @@ const snapClocks = (s: Session, seqWm: number, ctxWm: number) => {
365
461
  }
366
462
  };
367
463
 
464
+ // The refusal's watermarks are exact: every held statement above them was
465
+ // never admitted by the live record, and the first unposted seq being beyond
466
+ // watermark+1 proves the record that admitted the missing prefix is gone —
467
+ // its history settles lost and the lane clocks restart at the watermarks.
468
+ const sessionLostInner = (s: Session, seqWm: number, ctxWm: number) => {
469
+ const held = [...s.pending];
470
+ settleLost(s, held);
471
+ s.pending = [];
472
+ s.durableSeq = seqWm;
473
+ setRecord(s, {
474
+ clientId: s.record.clientId,
475
+ nextSeq: seqWm + 1,
476
+ ctxNextSeq: ctxWm + 1,
477
+ commands: [],
478
+ });
479
+ restateContext(s);
480
+ if (
481
+ held.length > 0 &&
482
+ s.onSessionLost?.(held.map((p) => p.inv)) === "requeue"
483
+ ) {
484
+ for (const old of held) {
485
+ enqueueInner(
486
+ s,
487
+ old.command,
488
+ old.inv.params,
489
+ () => {},
490
+ () => {},
491
+ );
492
+ }
493
+ }
494
+ publish(s);
495
+ wake(s);
496
+ };
497
+
368
498
  const handleHello = (
369
499
  s: Session,
370
500
  { ops, syn }: { ops: unknown[]; syn: StatewireSyn },
371
501
  ) => {
372
- const record = syn.record ?? "fresh";
373
- if (!RECORDS.includes(record)) {
374
- protocolError(`unknown record: ${String(record)}`);
502
+ if (syn.record !== undefined && syn.record !== "ephemeral") {
503
+ protocolError(`unknown record: ${String(syn.record)}`);
375
504
  }
376
505
  ingestOps(s, ops);
377
506
  s.attach = "attached";
378
507
  s.attachEpoch += 1;
379
508
  s.frameFailures = 0;
380
509
  s.resumeAt = 0;
381
- s.ephemeral = record === "ephemeral";
510
+ s.ephemeral = syn.record === "ephemeral";
382
511
  if (s.ephemeral) s.deactivated = true;
383
512
  const [attachCmd, attachCtx] = s.attachLaneSeqs;
384
513
  s.attachLaneSeqs = [new Set(), new Set()];
385
- if (record === "resumed") {
514
+ const reconcile = () => {
515
+ if (s.shared) adoptStored(s);
386
516
  // Resending unsettled statements is exactly-once-safe: absorbed replays
387
517
  // re-emit their stored verdicts (applied while still in flight).
518
+ s.durableSeq = syn.seq ?? 0;
388
519
  for (const entry of s.pending) {
389
520
  entry.posted = attachCmd.has(entry.seq);
390
521
  }
391
522
  const ctxWatermark = syn.ctxSeq ?? 0;
392
523
  s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
393
524
  for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
394
- snapClocks(s, syn.seq ?? 0, ctxWatermark);
395
- syncBacklog(s);
396
- } else {
397
- resetRecord(s, s.record.clientId);
525
+ snapCtxClock(s, ctxWatermark);
526
+ syncBacklogInner(s);
527
+ s.sessionKnown = true;
528
+ };
529
+ if (!s.shared || s.deactivated) {
530
+ reconcile();
531
+ publishIngested(s);
532
+ void pump(s);
533
+ return;
398
534
  }
399
- s.sessionKnown = true;
400
- publishIngested(s);
401
- void pump(s);
535
+ void transact(s, reconcile).then(() => {
536
+ publishIngested(s);
537
+ void pump(s);
538
+ });
402
539
  };
403
540
 
404
541
  const handleOps = (
@@ -411,8 +548,9 @@ const handleOps = (
411
548
  publishIngested(s);
412
549
  };
413
550
 
414
- // A {syn} refusal never settles: reconcile the resend queues against the
415
- // stated watermarks; retry present means wait, absent means resend now.
551
+ // A retry-less {syn} refusal is the one session-loss verdict: a cmd gap
552
+ // against its exact watermark settles the held history lost; a pushback
553
+ // (retry present) or a gapless refusal only reconciles the resend queues.
416
554
  const handleRefusal = (s: Session, syn: StatewireSyn) => {
417
555
  const { seq, ctxSeq } = syn;
418
556
  if (typeof seq === "number") {
@@ -425,19 +563,30 @@ const handleRefusal = (s: Session, syn: StatewireSyn) => {
425
563
  }
426
564
  if (typeof syn.retry === "number") {
427
565
  s.resumeAt = Date.now() + syn.retry;
566
+ wake(s);
567
+ void pump(s);
568
+ return;
428
569
  }
429
570
  const firstCmd = s.pending.find((p) => !p.posted);
430
571
  const firstCtx = s.ctxPending.find((c) => !c.posted);
431
- const clocksAhead =
432
- (typeof syn.seq === "number" &&
433
- firstCmd !== undefined &&
434
- firstCmd.seq > Math.max(syn.seq, s.durableSeq) + 1) ||
435
- (typeof syn.ctxSeq === "number" &&
436
- firstCtx !== undefined &&
437
- firstCtx.seq > syn.ctxSeq + 1);
438
- // Clocks ahead of a record that cannot admit them: reattach first and let
439
- // the hello's record arbitrate.
440
- if (clocksAhead) s.transport?.connection.reconnect();
572
+ const cmdGap =
573
+ typeof seq === "number" &&
574
+ firstCmd !== undefined &&
575
+ firstCmd.seq > Math.max(seq, s.durableSeq) + 1;
576
+ const ctxGap =
577
+ typeof ctxSeq === "number" &&
578
+ firstCtx !== undefined &&
579
+ firstCtx.seq > ctxSeq + 1;
580
+ if (cmdGap || ctxGap) {
581
+ void transact(s, () => {
582
+ if (cmdGap) sessionLostInner(s, seq!, ctxSeq ?? 0);
583
+ else snapCtxClock(s, ctxSeq!);
584
+ }).then(() => {
585
+ wake(s);
586
+ void pump(s);
587
+ });
588
+ return;
589
+ }
441
590
  wake(s);
442
591
  void pump(s);
443
592
  };
@@ -451,7 +600,7 @@ const handleDetach = (s: Session) => {
451
600
  const handleFinish = (s: Session, { reason }: { reason: string }) => {
452
601
  if (reason === "evicted" || reason === "error") return;
453
602
  s.attach = "finished";
454
- settleAll(s, reason === "superseded" ? "superseded" : "gone");
603
+ settleAll(s, "gone");
455
604
  publish(s);
456
605
  wake(s);
457
606
  };
@@ -536,7 +685,12 @@ const pump = async (s: Session) => {
536
685
  for (const entry of lanes.cmdEntries) entry.attempted = true;
537
686
  let result;
538
687
  try {
539
- result = await transport.send(lanes.frame);
688
+ // Shared storage: the lock across the send keeps per-lane arrival
689
+ // ordered across sharers during attach handover.
690
+ const send = () => transport.send(lanes.frame);
691
+ result = await (s.shared && !s.deactivated
692
+ ? s.store.lock!(send)
693
+ : send());
540
694
  } catch {
541
695
  if (!s.running || isFinished(s)) return;
542
696
  s.frameFailures += 1;
@@ -580,17 +734,34 @@ const pump = async (s: Session) => {
580
734
  };
581
735
 
582
736
  const start = (s: Session) => {
737
+ if (s.shared) {
738
+ s.shared.unsubscribe = s.store.subscribe!(() => {
739
+ void transact(s, () => consumeVerdicts(s));
740
+ });
741
+ s.running = true;
742
+ void transact(s, () => {
743
+ if (s.store.load() === null) s.store.save(s.record);
744
+ s.recordPersisted = true;
745
+ adoptStored(s);
746
+ consumeVerdicts(s);
747
+ }).then(() => void pump(s));
748
+ return;
749
+ }
583
750
  if (!s.recordPersisted) {
584
751
  s.recordPersisted = true;
585
752
  s.store.save(s.record);
586
753
  }
587
- resume(s);
754
+ adoptStored(s);
588
755
  s.running = true;
589
756
  void pump(s);
590
757
  };
591
758
 
592
759
  const stop = (s: Session) => {
593
760
  s.running = false;
761
+ if (s.shared) {
762
+ s.shared.unsubscribe?.();
763
+ s.shared.unsubscribe = null;
764
+ }
594
765
  wake(s);
595
766
  settleAll(s, "unmount");
596
767
  };
@@ -601,7 +772,7 @@ const setTransport = (s: Session, transport: RemoteLink) => {
601
772
  wake(s);
602
773
  };
603
774
 
604
- const enqueue = (
775
+ const enqueueInner = (
605
776
  s: Session,
606
777
  command: WireCommand,
607
778
  params: readonly unknown[],
@@ -626,15 +797,33 @@ const enqueue = (
626
797
  reject,
627
798
  };
628
799
  s.pending.push(entry);
629
- setRecord(s, { ...s.record, nextSeq: seq + 1, commands: backlog(s) });
800
+ setRecord(s, { ...s.record, nextSeq: seq + 1, commands: mergeBacklog(s) });
630
801
  s.lifecycle?.onStart(entry.inv);
631
- void pump(s);
632
802
  };
633
803
 
634
- // A stored command cannot be told from one that reached the server: attempted
635
- // keeps a fresh record settling it lost instead of replaying it blind.
636
- const resume = (s: Session) => {
637
- const stored = [...s.record.commands].sort((a, b) => a.seq - b.seq);
804
+ const enqueue = (
805
+ s: Session,
806
+ command: WireCommand,
807
+ params: readonly unknown[],
808
+ resolve: (payload: unknown) => void,
809
+ reject: (error: StatewireSendError) => void,
810
+ ) => {
811
+ if (!s.shared) {
812
+ enqueueInner(s, command, params, resolve, reject);
813
+ void pump(s);
814
+ return;
815
+ }
816
+ void transact(s, () =>
817
+ enqueueInner(s, command, params, resolve, reject),
818
+ ).then(() => void pump(s));
819
+ };
820
+
821
+ // A stored command may have reached a server: attempted settles it lost,
822
+ // never aborted, when a channel death ends the session.
823
+ const adoptStored = (s: Session) => {
824
+ const known = new Set(s.pending.map((p) => p.seq));
825
+ const stored = s.record.commands.filter((c) => !known.has(c.seq));
826
+ if (stored.length === 0) return;
638
827
  for (const { seq, ...command } of stored) {
639
828
  const params =
640
829
  command.meta === undefined
@@ -659,6 +848,7 @@ const resume = (s: Session) => {
659
848
  s.pending.push(entry);
660
849
  s.lifecycle?.onStart(entry.inv);
661
850
  }
851
+ s.pending.sort((a, b) => a.seq - b.seq);
662
852
  };
663
853
 
664
854
  const sendCommand = (
@@ -690,8 +880,9 @@ const setContext = (s: Session, value: Record<string, unknown>): void => {
690
880
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
691
881
  throw new Error("statewire: context must be a plain object");
692
882
  }
883
+ if (!s.running || isFinished(s)) return;
693
884
  const { value: encoded, meta } = encodeValue(value);
694
- s.ctxOps = [
885
+ const ops = [
695
886
  {
696
887
  op: "replace",
697
888
  path: [],
@@ -699,11 +890,22 @@ const setContext = (s: Session, value: Record<string, unknown>): void => {
699
890
  ...(meta !== undefined && { meta }),
700
891
  },
701
892
  ];
702
- const seq = s.record.ctxNextSeq;
703
- setRecord(s, { ...s.record, ctxNextSeq: seq + 1 });
704
- s.ctxPending.push({ seq, ops: s.ctxOps, posted: false });
705
- wake(s);
706
- void pump(s);
893
+ s.ctxOps = ops;
894
+ const mint = () => {
895
+ const seq = s.record.ctxNextSeq;
896
+ setRecord(s, { ...s.record, ctxNextSeq: seq + 1 });
897
+ s.ctxPending.push({ seq, ops, posted: false });
898
+ };
899
+ if (!s.shared) {
900
+ mint();
901
+ wake(s);
902
+ void pump(s);
903
+ return;
904
+ }
905
+ void transact(s, mint).then(() => {
906
+ wake(s);
907
+ void pump(s);
908
+ });
707
909
  };
708
910
 
709
911
  export const useStatewireSession = (
@@ -717,6 +919,11 @@ export const useStatewireSession = (
717
919
  );
718
920
  }
719
921
  const store = options.sessionStore ?? mounted ?? defaultStore;
922
+ if ((store.lock === undefined) !== (store.subscribe === undefined)) {
923
+ throw new Error(
924
+ "statewire: session storage must provide lock and subscribe together",
925
+ );
926
+ }
720
927
  const [s] = useState((): Session => {
721
928
  const stored = store.load();
722
929
  if (stored !== null && !Array.isArray(stored.commands)) {
@@ -747,12 +954,17 @@ export const useStatewireSession = (
747
954
  },
748
955
  recordPersisted: stored !== null,
749
956
  store,
957
+ shared:
958
+ store.lock !== undefined
959
+ ? { txq: Promise.resolve(), parked: [], unsubscribe: null }
960
+ : false,
750
961
  ephemeral: false,
751
962
  deactivated: false,
752
963
  sessionKnown: false,
753
964
  durableSeq: 0,
754
965
  resumeAt: 0,
755
966
  idle: false,
967
+ onSessionLost: undefined,
756
968
  onDone: undefined,
757
969
  ctxOps: undefined,
758
970
  ctxPending: [],
@@ -793,7 +1005,13 @@ export const useStatewireSession = (
793
1005
  });
794
1006
  return session;
795
1007
  });
1008
+ if ((store.lock !== undefined) !== (s.shared !== false)) {
1009
+ throw new Error(
1010
+ "statewire: session storage changed sharedness across renders — lock/subscribe must be present from the first render or never",
1011
+ );
1012
+ }
796
1013
  s.store = store;
1014
+ s.onSessionLost = options.onSessionLost;
797
1015
  s.onDone = options.onDone;
798
1016
  useEffect(() => {
799
1017
  start(s);