statewire 0.10.0 → 0.11.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.
@@ -73,11 +73,18 @@ export type StatewireSessionOptions = {
73
73
  onDone?: (() => void) | undefined;
74
74
  };
75
75
 
76
+ type Shared = {
77
+ txq: Promise<void>;
78
+ parked: StatewireClient.SessionVerdict[];
79
+ unsubscribe: (() => void) | null;
80
+ };
81
+
76
82
  type Session = {
77
83
  record: StatewireClient.SessionRecord;
78
84
  ctx: StatewireRemoteProps;
79
85
  recordPersisted: boolean;
80
86
  store: StatewireClient.SessionStorage;
87
+ shared: Shared | false;
81
88
  ephemeral: boolean;
82
89
  /** An ephemeral record died with its attach: the storage is off for good. */
83
90
  deactivated: boolean;
@@ -144,15 +151,68 @@ const backlog = (s: Session): StatewireClient.SessionCommand[] =>
144
151
  .filter((p) => p.seq > s.durableSeq)
145
152
  .map((p) => ({ seq: p.seq, ...p.command }));
146
153
 
154
+ const mergeBacklog = (s: Session): StatewireClient.SessionCommand[] => {
155
+ const own = backlog(s);
156
+ if (!s.shared) return own;
157
+ const known = new Set(s.pending.map((p) => p.seq));
158
+ const foreign = s.record.commands.filter(
159
+ (c) => !known.has(c.seq) && c.seq > s.durableSeq,
160
+ );
161
+ return [...foreign, ...own].sort((a, b) => a.seq - b.seq);
162
+ };
163
+
164
+ const MAX_PARKED_VERDICTS = 64;
165
+
147
166
  // Storage holds what a reload must resend: a command leaves at its dur, at a
148
167
  // server verdict, or at a record reset — never at a local channel death.
149
- const syncBacklog = (s: Session) => {
150
- const commands = backlog(s);
168
+ const syncBacklogInner = (s: Session) => {
169
+ const parked = s.shared ? s.shared.parked.splice(0) : [];
170
+ const parkedSeqs = new Set(parked.map((v) => v.seq));
171
+ const commands = mergeBacklog(s).filter((c) => !parkedSeqs.has(c.seq));
172
+ const verdicts =
173
+ parked.length > 0
174
+ ? [...(s.record.verdicts ?? []), ...parked].slice(-MAX_PARKED_VERDICTS)
175
+ : s.record.verdicts;
151
176
  const unchanged =
177
+ verdicts === s.record.verdicts &&
152
178
  commands.length === s.record.commands.length &&
153
179
  commands.every((c, i) => c.seq === s.record.commands[i]!.seq);
154
180
  if (unchanged) return;
155
- setRecord(s, { ...s.record, commands });
181
+ setRecord(s, {
182
+ ...s.record,
183
+ commands,
184
+ ...(verdicts !== undefined && { verdicts }),
185
+ });
186
+ };
187
+
188
+ const syncBacklog = (s: Session) => {
189
+ if (!s.shared) return syncBacklogInner(s);
190
+ void transact(s, () => syncBacklogInner(s));
191
+ };
192
+
193
+ // Every record mutation over shared storage is a locked read-modify-write.
194
+ const transact = (s: Session, fn: () => void): Promise<void> => {
195
+ if (!s.shared || s.deactivated) {
196
+ fn();
197
+ return Promise.resolve();
198
+ }
199
+ const shared = s.shared;
200
+ const run = shared.txq.then(() =>
201
+ s.store.lock!(async () => {
202
+ const stored = s.store.load();
203
+ if (stored !== null && stored.clientId !== s.record.clientId) {
204
+ adoptReset(s, stored);
205
+ } else if (stored !== null) {
206
+ s.record = stored;
207
+ }
208
+ fn();
209
+ }),
210
+ );
211
+ shared.txq = run.then(
212
+ () => {},
213
+ () => {},
214
+ );
215
+ return run;
156
216
  };
157
217
 
158
218
  const setIdle = (s: Session, level: boolean) => {
@@ -248,11 +308,40 @@ const resetRecord = (s: Session, clientId: string) => {
248
308
  setRecord(s, { clientId, nextSeq: 1, ctxNextSeq: 1, commands: [] });
249
309
  for (const old of survivors) {
250
310
  s.lifecycle?.onCovered?.(old.inv);
251
- enqueue(s, old.command, old.inv.params, old.resolve, old.reject);
311
+ enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
312
+ }
313
+ restateContext(s);
314
+ publish(s);
315
+ wake(s);
316
+ void pump(s);
317
+ };
318
+
319
+ // Another sharer replaced the record's identity: their reset settles our
320
+ // attempted history and our survivors re-stamp on the stored clocks.
321
+ const adoptReset = (s: Session, stored: StatewireClient.SessionRecord) => {
322
+ const survivors: Pending[] = [];
323
+ for (const entry of [...s.pending]) {
324
+ if (entry.attempted || entry.applied) {
325
+ s.lifecycle?.onCovered?.(entry.inv);
326
+ settleEntry(s, entry, {
327
+ ok: false,
328
+ failure: { fate: "lost", cause: "session-lost" },
329
+ });
330
+ } else {
331
+ survivors.push(entry);
332
+ }
333
+ }
334
+ s.pending = [];
335
+ s.durableSeq = 0;
336
+ s.record = stored;
337
+ for (const old of survivors) {
338
+ s.lifecycle?.onCovered?.(old.inv);
339
+ enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
252
340
  }
253
341
  restateContext(s);
254
342
  publish(s);
255
343
  wake(s);
344
+ void pump(s);
256
345
  };
257
346
 
258
347
  // The record died with the attach: mint a fresh id for the next one.
@@ -272,6 +361,59 @@ const rejectedFailure = (cmd: StatewireCmd): StatewireClient.SendFailure => {
272
361
  };
273
362
  };
274
363
 
364
+ const outcomeOf = (
365
+ cmd: StatewireCmd,
366
+ ):
367
+ | { ok: true; payload: unknown }
368
+ | { ok: false; failure: StatewireClient.SendFailure } =>
369
+ cmd.type === "result"
370
+ ? { ok: true as const, payload: reviveResponse(cmd).payload }
371
+ : cmd.type === "rejected"
372
+ ? { ok: false as const, failure: rejectedFailure(reviveResponse(cmd)) }
373
+ : cmd.type === "failed"
374
+ ? {
375
+ ok: false as const,
376
+ failure: {
377
+ fate: "failed" as const,
378
+ cause: "server" as const,
379
+ ...(cmd.message !== undefined && { message: cmd.message }),
380
+ },
381
+ }
382
+ : cmd.type === "crashed"
383
+ ? {
384
+ ok: false as const,
385
+ failure: { fate: "crashed" as const, cause: "server" as const },
386
+ }
387
+ : cmd.type === "expired"
388
+ ? {
389
+ ok: false as const,
390
+ failure: {
391
+ fate: "expired" as const,
392
+ cause: "server" as const,
393
+ },
394
+ }
395
+ : protocolError(`unknown cmd type: ${String(cmd.type)}`);
396
+
397
+ const TERMINAL_CMD_TYPES = new Set([
398
+ "result",
399
+ "rejected",
400
+ "failed",
401
+ "crashed",
402
+ "expired",
403
+ ]);
404
+
405
+ const parkVerdict = (s: Session, cmd: StatewireCmd) => {
406
+ if (!s.shared || !TERMINAL_CMD_TYPES.has(cmd.type!)) return;
407
+ s.shared.parked.push({
408
+ seq: cmd.seq,
409
+ type: cmd.type as StatewireClient.SessionVerdict["type"],
410
+ ...(cmd.code !== undefined && { code: cmd.code }),
411
+ ...(cmd.message !== undefined && { message: cmd.message }),
412
+ ...(cmd.payload !== undefined && { payload: cmd.payload }),
413
+ ...(cmd.meta !== undefined && { meta: cmd.meta }),
414
+ });
415
+ };
416
+
275
417
  const handleCmd = (s: Session, cmd: StatewireCmd) => {
276
418
  if (!Number.isSafeInteger(cmd.seq) || cmd.seq <= 0) {
277
419
  protocolError("cmd.seq must be a positive safe integer");
@@ -288,37 +430,30 @@ const handleCmd = (s: Session, cmd: StatewireCmd) => {
288
430
  if (entry) cover(s, entry);
289
431
  return;
290
432
  }
433
+ parkVerdict(s, cmd);
291
434
  if (!entry) return;
292
435
  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);
436
+ settleEntry(s, entry, outcomeOf(cmd));
437
+ };
438
+
439
+ const consumeVerdicts = (s: Session) => {
440
+ const verdicts = s.record.verdicts ?? [];
441
+ const consumed = new Set<number>();
442
+ for (const verdict of verdicts) {
443
+ const entry = s.pending.find((p) => p.seq === verdict.seq);
444
+ if (!entry) continue;
445
+ consumed.add(verdict.seq);
446
+ cover(s, entry);
447
+ settleEntry(s, entry, outcomeOf(verdict as StatewireCmd));
448
+ }
449
+ if (consumed.size === 0) return;
450
+ setRecord(s, {
451
+ ...s.record,
452
+ commands: s.record.commands.filter((c) => !consumed.has(c.seq)),
453
+ verdicts: verdicts.filter((v) => !consumed.has(v.seq)),
454
+ });
455
+ publish(s);
456
+ wake(s);
322
457
  };
323
458
 
324
459
  const handleCmds = (s: Session, cmds: StatewireCmd[]) => {
@@ -353,7 +488,7 @@ const snapClocks = (s: Session, seqWm: number, ctxWm: number) => {
353
488
  setRecord(s, { ...s.record, nextSeq: seqWm + 1 });
354
489
  for (const old of tail) {
355
490
  s.lifecycle?.onCovered?.(old.inv);
356
- enqueue(s, old.command, old.inv.params, old.resolve, old.reject);
491
+ enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
357
492
  }
358
493
  }
359
494
  const ctxAligned =
@@ -382,23 +517,34 @@ const handleHello = (
382
517
  if (s.ephemeral) s.deactivated = true;
383
518
  const [attachCmd, attachCtx] = s.attachLaneSeqs;
384
519
  s.attachLaneSeqs = [new Set(), new Set()];
385
- if (record === "resumed") {
386
- // Resending unsettled statements is exactly-once-safe: absorbed replays
387
- // re-emit their stored verdicts (applied while still in flight).
388
- for (const entry of s.pending) {
389
- entry.posted = attachCmd.has(entry.seq);
520
+ const reconcile = () => {
521
+ if (record === "resumed") {
522
+ if (s.shared) adoptStored(s);
523
+ // Resending unsettled statements is exactly-once-safe: absorbed replays
524
+ // re-emit their stored verdicts (applied while still in flight).
525
+ for (const entry of s.pending) {
526
+ entry.posted = attachCmd.has(entry.seq);
527
+ }
528
+ const ctxWatermark = syn.ctxSeq ?? 0;
529
+ s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
530
+ for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
531
+ snapClocks(s, syn.seq ?? 0, ctxWatermark);
532
+ syncBacklogInner(s);
533
+ } else {
534
+ resetRecord(s, s.record.clientId);
390
535
  }
391
- const ctxWatermark = syn.ctxSeq ?? 0;
392
- s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
393
- 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);
536
+ s.sessionKnown = true;
537
+ };
538
+ if (!s.shared || s.deactivated) {
539
+ reconcile();
540
+ publishIngested(s);
541
+ void pump(s);
542
+ return;
398
543
  }
399
- s.sessionKnown = true;
400
- publishIngested(s);
401
- void pump(s);
544
+ void transact(s, reconcile).then(() => {
545
+ publishIngested(s);
546
+ void pump(s);
547
+ });
402
548
  };
403
549
 
404
550
  const handleOps = (
@@ -451,7 +597,11 @@ const handleDetach = (s: Session) => {
451
597
  const handleFinish = (s: Session, { reason }: { reason: string }) => {
452
598
  if (reason === "evicted" || reason === "error") return;
453
599
  s.attach = "finished";
454
- settleAll(s, reason === "superseded" ? "superseded" : "gone");
600
+ // Superseded over shared storage: the superseder resends our backlog and
601
+ // parks the verdicts; our promises settle from the observed record.
602
+ if (!(s.shared && reason === "superseded")) {
603
+ settleAll(s, reason === "superseded" ? "superseded" : "gone");
604
+ }
455
605
  publish(s);
456
606
  wake(s);
457
607
  };
@@ -536,7 +686,12 @@ const pump = async (s: Session) => {
536
686
  for (const entry of lanes.cmdEntries) entry.attempted = true;
537
687
  let result;
538
688
  try {
539
- result = await transport.send(lanes.frame);
689
+ // Shared storage: the lock across the send keeps per-lane arrival
690
+ // ordered across sharers during attach handover.
691
+ const send = () => transport.send(lanes.frame);
692
+ result = await (s.shared && !s.deactivated
693
+ ? s.store.lock!(send)
694
+ : send());
540
695
  } catch {
541
696
  if (!s.running || isFinished(s)) return;
542
697
  s.frameFailures += 1;
@@ -580,17 +735,34 @@ const pump = async (s: Session) => {
580
735
  };
581
736
 
582
737
  const start = (s: Session) => {
738
+ if (s.shared) {
739
+ s.shared.unsubscribe = s.store.subscribe!(() => {
740
+ void transact(s, () => consumeVerdicts(s));
741
+ });
742
+ s.running = true;
743
+ void transact(s, () => {
744
+ if (s.store.load() === null) s.store.save(s.record);
745
+ s.recordPersisted = true;
746
+ adoptStored(s);
747
+ consumeVerdicts(s);
748
+ }).then(() => void pump(s));
749
+ return;
750
+ }
583
751
  if (!s.recordPersisted) {
584
752
  s.recordPersisted = true;
585
753
  s.store.save(s.record);
586
754
  }
587
- resume(s);
755
+ adoptStored(s);
588
756
  s.running = true;
589
757
  void pump(s);
590
758
  };
591
759
 
592
760
  const stop = (s: Session) => {
593
761
  s.running = false;
762
+ if (s.shared) {
763
+ s.shared.unsubscribe?.();
764
+ s.shared.unsubscribe = null;
765
+ }
594
766
  wake(s);
595
767
  settleAll(s, "unmount");
596
768
  };
@@ -601,7 +773,7 @@ const setTransport = (s: Session, transport: RemoteLink) => {
601
773
  wake(s);
602
774
  };
603
775
 
604
- const enqueue = (
776
+ const enqueueInner = (
605
777
  s: Session,
606
778
  command: WireCommand,
607
779
  params: readonly unknown[],
@@ -626,15 +798,33 @@ const enqueue = (
626
798
  reject,
627
799
  };
628
800
  s.pending.push(entry);
629
- setRecord(s, { ...s.record, nextSeq: seq + 1, commands: backlog(s) });
801
+ setRecord(s, { ...s.record, nextSeq: seq + 1, commands: mergeBacklog(s) });
630
802
  s.lifecycle?.onStart(entry.inv);
631
- void pump(s);
803
+ };
804
+
805
+ const enqueue = (
806
+ s: Session,
807
+ command: WireCommand,
808
+ params: readonly unknown[],
809
+ resolve: (payload: unknown) => void,
810
+ reject: (error: StatewireSendError) => void,
811
+ ) => {
812
+ if (!s.shared) {
813
+ enqueueInner(s, command, params, resolve, reject);
814
+ void pump(s);
815
+ return;
816
+ }
817
+ void transact(s, () =>
818
+ enqueueInner(s, command, params, resolve, reject),
819
+ ).then(() => void pump(s));
632
820
  };
633
821
 
634
822
  // A stored command cannot be told from one that reached the server: attempted
635
823
  // 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);
824
+ const adoptStored = (s: Session) => {
825
+ const known = new Set(s.pending.map((p) => p.seq));
826
+ const stored = s.record.commands.filter((c) => !known.has(c.seq));
827
+ if (stored.length === 0) return;
638
828
  for (const { seq, ...command } of stored) {
639
829
  const params =
640
830
  command.meta === undefined
@@ -659,6 +849,7 @@ const resume = (s: Session) => {
659
849
  s.pending.push(entry);
660
850
  s.lifecycle?.onStart(entry.inv);
661
851
  }
852
+ s.pending.sort((a, b) => a.seq - b.seq);
662
853
  };
663
854
 
664
855
  const sendCommand = (
@@ -690,8 +881,9 @@ const setContext = (s: Session, value: Record<string, unknown>): void => {
690
881
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
691
882
  throw new Error("statewire: context must be a plain object");
692
883
  }
884
+ if (!s.running || isFinished(s)) return;
693
885
  const { value: encoded, meta } = encodeValue(value);
694
- s.ctxOps = [
886
+ const ops = [
695
887
  {
696
888
  op: "replace",
697
889
  path: [],
@@ -699,11 +891,22 @@ const setContext = (s: Session, value: Record<string, unknown>): void => {
699
891
  ...(meta !== undefined && { meta }),
700
892
  },
701
893
  ];
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);
894
+ s.ctxOps = ops;
895
+ const mint = () => {
896
+ const seq = s.record.ctxNextSeq;
897
+ setRecord(s, { ...s.record, ctxNextSeq: seq + 1 });
898
+ s.ctxPending.push({ seq, ops, posted: false });
899
+ };
900
+ if (!s.shared) {
901
+ mint();
902
+ wake(s);
903
+ void pump(s);
904
+ return;
905
+ }
906
+ void transact(s, mint).then(() => {
907
+ wake(s);
908
+ void pump(s);
909
+ });
707
910
  };
708
911
 
709
912
  export const useStatewireSession = (
@@ -717,6 +920,11 @@ export const useStatewireSession = (
717
920
  );
718
921
  }
719
922
  const store = options.sessionStore ?? mounted ?? defaultStore;
923
+ if ((store.lock === undefined) !== (store.subscribe === undefined)) {
924
+ throw new Error(
925
+ "statewire: session storage must provide lock and subscribe together",
926
+ );
927
+ }
720
928
  const [s] = useState((): Session => {
721
929
  const stored = store.load();
722
930
  if (stored !== null && !Array.isArray(stored.commands)) {
@@ -747,6 +955,10 @@ export const useStatewireSession = (
747
955
  },
748
956
  recordPersisted: stored !== null,
749
957
  store,
958
+ shared:
959
+ store.lock !== undefined
960
+ ? { txq: Promise.resolve(), parked: [], unsubscribe: null }
961
+ : false,
750
962
  ephemeral: false,
751
963
  deactivated: false,
752
964
  sessionKnown: false,
package/src/statewire.ts CHANGED
@@ -261,12 +261,28 @@ export namespace StatewireClient {
261
261
  nextSeq: number;
262
262
  ctxNextSeq: number;
263
263
  commands: readonly SessionCommand[];
264
+ /** Terminal verdicts parked for the sharer that minted the seq (shared storage only). */
265
+ verdicts?: readonly SessionVerdict[];
266
+ };
267
+
268
+ /** A terminal verdict one sharer's connection carried, parked for the sharer awaiting the seq. */
269
+ export type SessionVerdict = {
270
+ seq: number;
271
+ type: "result" | "rejected" | "failed" | "crashed" | "expired";
272
+ code?: string;
273
+ message?: string;
274
+ payload?: unknown;
275
+ meta?: unknown;
264
276
  };
265
277
 
266
278
  /** Session storage seam; `save` must persist synchronously in the caller's flow. */
267
279
  export type SessionStorage = {
268
280
  load: () => SessionRecord | null;
269
281
  save: (record: SessionRecord) => void;
282
+ /** Serializes record mutations across sharers; presence marks the storage shared. */
283
+ lock?: <T>(fn: () => T | Promise<T>) => Promise<T>;
284
+ /** Notifies of record saves by other sharers; returns unsubscribe. Required alongside `lock`. */
285
+ subscribe?: (listener: () => void) => () => void;
270
286
  };
271
287
 
272
288
  /** Static headers, or a resolver invoked per request/attach; `staleAuth` is true right after an auth rejection. */