statewire 0.23.0 → 0.24.1

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 (43) hide show
  1. package/dist/client/core.d.ts +4 -3
  2. package/dist/client/core.d.ts.map +1 -1
  3. package/dist/client/core.js +56 -45
  4. package/dist/client/core.js.map +1 -1
  5. package/dist/client/session.d.ts +56 -12
  6. package/dist/client/session.d.ts.map +1 -1
  7. package/dist/client/session.js +632 -260
  8. package/dist/client/session.js.map +1 -1
  9. package/dist/client/sessionStore.d.ts +1 -0
  10. package/dist/client/sessionStore.d.ts.map +1 -1
  11. package/dist/client/sessionStore.js +1 -0
  12. package/dist/client/sessionStore.js.map +1 -1
  13. package/dist/host/host.d.ts +6 -5
  14. package/dist/host/host.d.ts.map +1 -1
  15. package/dist/host/host.js.map +1 -1
  16. package/dist/indexedDb.d.ts +15 -0
  17. package/dist/indexedDb.d.ts.map +1 -0
  18. package/dist/indexedDb.js +171 -0
  19. package/dist/indexedDb.js.map +1 -0
  20. package/dist/statewire.d.ts +117 -59
  21. package/dist/statewire.d.ts.map +1 -1
  22. package/dist/statewire.js +13 -25
  23. package/dist/statewire.js.map +1 -1
  24. package/dist/storage.d.ts +42 -0
  25. package/dist/storage.d.ts.map +1 -1
  26. package/dist/storage.js +387 -125
  27. package/dist/storage.js.map +1 -1
  28. package/dist/transports/remote.d.ts.map +1 -1
  29. package/dist/transports/remote.js +7 -9
  30. package/dist/transports/remote.js.map +1 -1
  31. package/dist/types.d.ts +6 -7
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/types.js.map +1 -1
  34. package/package.json +2 -1
  35. package/src/client/core.ts +81 -64
  36. package/src/client/session.ts +886 -424
  37. package/src/client/sessionStore.ts +2 -0
  38. package/src/host/host.ts +10 -3
  39. package/src/indexedDb.ts +167 -0
  40. package/src/statewire.ts +133 -86
  41. package/src/storage.ts +548 -112
  42. package/src/transports/remote.ts +13 -14
  43. package/src/types.ts +6 -7
@@ -2,6 +2,7 @@ import { encodeContext } from "../wire/context";
2
2
  import { wrapStorageError } from "./storageError";
3
3
  import {
4
4
  DEFAULT_PROTOCOL,
5
+ GAP_FILL_VERSION,
5
6
  WIRE_VERSION,
6
7
  protocolOffers,
7
8
  inRange,
@@ -52,13 +53,11 @@ type Invocation = {
52
53
  -readonly [K in keyof CommandsInvocation]: CommandsInvocation[K];
53
54
  };
54
55
 
55
- type Hold = StatewireClient.OutboxCommand["hold"];
56
-
57
56
  type Pending = {
58
57
  readonly id: string;
59
58
  /** Unset while in the outbox, or while another sharer owns its stamp. */
60
59
  seq: number | undefined;
61
- readonly command: WireCommand;
60
+ command: WireCommand;
62
61
  readonly inv: Invocation;
63
62
  /** Enqueued by another sharer: this client holds no promise for it. */
64
63
  readonly adopted: boolean;
@@ -96,6 +95,8 @@ export type StatewireSessionOptions = StatewireProtocol.Options & {
96
95
  context?: Readonly<Record<string, Statewire.Context>>;
97
96
  sessionStore?: StatewireClient.SessionStorage | undefined;
98
97
  onSessionLost?: StatewireClient.OnSessionLost | undefined;
98
+ onDeprecatedPath?: StatewireClient.OnDeprecatedPath | undefined;
99
+ onSettled?: StatewireClient.OnSettled | undefined;
99
100
  onFlush?: StatewireClient.OnFlush | undefined;
100
101
  canOvertake?:
101
102
  | Readonly<Record<string, StatewireClient.CanOvertake>>
@@ -107,18 +108,35 @@ type Shared = {
107
108
  txq: Promise<void>;
108
109
  syncQueued: boolean;
109
110
  parked: { protocol: string; verdict: StatewireClient.SessionVerdict }[];
111
+ /** Own commands settled since the last backlog sync: verdicts parked for them are moot. */
112
+ settled: Set<string>;
113
+ /** Settled updates waiting for the next backlog sync. */
114
+ notices: Notice[];
115
+ /** A locked transaction is running its mutation. */
116
+ inTx: boolean;
110
117
  unsubscribe: (() => void) | null;
111
118
  /** Owners whose departure this sharer awaits. */
112
119
  watched: Set<string>;
113
120
  abort: AbortController | null;
114
121
  };
115
122
 
123
+ type Patch = {
124
+ held?: boolean;
125
+ localMeta?: { value: unknown };
126
+ params?: readonly unknown[];
127
+ };
128
+
116
129
  type Flushing = {
117
130
  ids: ReadonlySet<string>;
118
- held: Map<string, NonNullable<Hold>>;
131
+ patches: Map<string, Patch>;
119
132
  removed: Set<string>;
120
133
  };
121
134
 
135
+ type Waiter = {
136
+ resolve: (payload: unknown) => void;
137
+ reject: (error: unknown) => void;
138
+ };
139
+
122
140
  type Session = {
123
141
  options: StatewireSessionOptions;
124
142
  configuredContext: StatewireSessionOptions["context"];
@@ -147,8 +165,16 @@ type Session = {
147
165
  resumeAt: number;
148
166
  idle: boolean;
149
167
  onSessionLost: StatewireClient.OnSessionLost | undefined;
168
+ onDeprecatedPath: StatewireClient.OnDeprecatedPath | undefined;
169
+ onSettled: StatewireClient.OnSettled | undefined;
150
170
  onFlush: StatewireClient.OnFlush | undefined;
151
171
  onDone: (() => void) | undefined;
172
+ /** `outbox.release` promises by entry id. */
173
+ releases: Map<string, Waiter[]>;
174
+ delivering: boolean;
175
+ /** Notices whose handler threw on this mount. */
176
+ skipped: Set<string>;
177
+ views: WeakMap<object, StatewireClient.OutboxEntry>;
152
178
  /** Outbox and stamped ids stored before this client mounted. */
153
179
  mountIds: Set<string>;
154
180
  flushing: Flushing | null;
@@ -157,9 +183,11 @@ type Session = {
157
183
  lanesOnAttach: boolean;
158
184
  attachEntries: ReadonlySet<Pending>;
159
185
  frameFailures: number;
186
+ /** A later hello decides stamps this one could not: another live sharer's stamp sat below them. */
187
+ recheck: ReturnType<typeof setTimeout> | undefined;
188
+ /** Hellos in a row that left stamps waiting at the same watermarks: the recheck backs off. */
189
+ rechecks: { watermarks: string; count: number };
160
190
  pending: Pending[];
161
- failedList: readonly StatewireClient.FailedCommand[];
162
- failedParts: readonly (readonly StatewireClient.FailedCommand[])[];
163
191
  canonical: unknown;
164
192
  documentListeners: Set<() => void>;
165
193
  documents: StatewireDocuments.State;
@@ -225,11 +253,12 @@ type RemovalMark = NonNullable<StatewireClient.SessionLane["removed"]>[number];
225
253
  const marks = (
226
254
  s: Session,
227
255
  lane: StatewireClient.SessionLane,
228
- removed: readonly StatewireClient.OutboxCommand[],
256
+ removed: readonly { id: string; by?: string | undefined }[],
257
+ failure?: StatewireClient.SendFailure,
229
258
  ): { removed?: readonly RemovalMark[] } => {
230
259
  const added = removed
231
260
  .filter((c) => c.by !== undefined && c.by !== s.store.owner?.id)
232
- .map((c) => ({ id: c.id, by: c.by! }));
261
+ .map((c) => ({ id: c.id, by: c.by!, ...(failure && { failure }) }));
233
262
  const next = [...(lane.removed ?? []), ...added];
234
263
  return next.length > 0 ? { removed: next } : {};
235
264
  };
@@ -244,15 +273,12 @@ const withOutbox = (
244
273
 
245
274
  const outboxOf = (lane: StatewireClient.SessionLane) => lane.outbox ?? [];
246
275
 
247
- const sameHold = (a: Hold, b: Hold) =>
248
- a === b || JSON.stringify(a) === JSON.stringify(b);
249
-
250
276
  const changed = (s: Session, p: Pending) => s.lifecycle?.onChange?.(p.inv);
251
277
 
252
278
  const stamp = (s: Session, p: Pending, seq: number) => {
253
279
  p.seq = seq;
254
280
  p.inv.seq = seq;
255
- delete p.inv.hold;
281
+ delete p.inv.held;
256
282
  p.away = false;
257
283
  p.attempted = false;
258
284
  p.posted = false;
@@ -268,13 +294,41 @@ const unstamp = (s: Session, p: Pending) => {
268
294
  changed(s, p);
269
295
  };
270
296
 
271
- const setHold = (s: Session, p: Pending, hold: Hold) => {
272
- if (sameHold(p.inv.hold, hold)) return;
273
- if (hold === undefined) delete p.inv.hold;
274
- else p.inv.hold = hold;
275
- changed(s, p);
297
+ // The pending invocation follows its stored entry: hold, local meta, params.
298
+ const follow = (
299
+ s: Session,
300
+ p: Pending,
301
+ stored: StatewireClient.OutboxCommand,
302
+ ) => {
303
+ let dirty = false;
304
+ if ((p.inv.held === true) !== (stored.held === true)) {
305
+ if (stored.held) p.inv.held = true;
306
+ else delete p.inv.held;
307
+ dirty = true;
308
+ }
309
+ if (p.inv.localMeta !== stored.localMeta) {
310
+ if (stored.localMeta === undefined) delete p.inv.localMeta;
311
+ else p.inv.localMeta = stored.localMeta;
312
+ dirty = true;
313
+ }
314
+ if (p.command.params !== stored.params) {
315
+ p.command = {
316
+ ...p.command,
317
+ params: stored.params,
318
+ ...(stored.meta === undefined ? {} : { meta: stored.meta }),
319
+ };
320
+ if (stored.meta === undefined) delete p.command.meta;
321
+ p.inv.params = paramsOf(stored);
322
+ dirty = true;
323
+ }
324
+ if (dirty) changed(s, p);
276
325
  };
277
326
 
327
+ const paramsOf = (stored: { params: unknown[]; meta?: unknown }) =>
328
+ stored.meta === undefined
329
+ ? stored.params
330
+ : (reviveValue(structuredClone(stored.params), stored.meta) as unknown[]);
331
+
278
332
  const dropPending = (s: Session, p: Pending) => {
279
333
  s.pending = s.pending.filter((q) => q !== p);
280
334
  s.lifecycle?.onCovered?.(p.inv);
@@ -319,11 +373,13 @@ const setRecord = (s: Session, record: StatewireClient.SessionRecord) => {
319
373
  try {
320
374
  s.store.save(record);
321
375
  } catch (cause) {
376
+ if (isQuota(cause)) throw cause;
322
377
  storageFailed(s, cause);
323
378
  throw s.storageError;
324
379
  }
325
380
  }
326
381
  s.record = record;
382
+ s.notify();
327
383
  };
328
384
 
329
385
  const toOutbox = (p: Pending): StatewireClient.OutboxCommand => {
@@ -333,7 +389,8 @@ const toOutbox = (p: Pending): StatewireClient.OutboxCommand => {
333
389
  ...command,
334
390
  ts: p.inv.ts,
335
391
  ...(p.by !== undefined && { by: p.by }),
336
- ...(p.inv.hold !== undefined && { hold: p.inv.hold }),
392
+ ...(p.inv.held && { held: true as const }),
393
+ ...(p.inv.localMeta !== undefined && { localMeta: p.inv.localMeta }),
337
394
  };
338
395
  };
339
396
 
@@ -346,6 +403,7 @@ const toStored = (s: Session, p: Stamped): StatewireClient.SessionCommand => {
346
403
  ...(p.by !== undefined && { by: p.by }),
347
404
  ...command,
348
405
  ts: p.inv.ts,
406
+ ...(p.inv.localMeta !== undefined && { localMeta: p.inv.localMeta }),
349
407
  };
350
408
  };
351
409
 
@@ -383,7 +441,11 @@ const MAX_PARKED_VERDICTS = 64;
383
441
  // Storage holds what a reload must resend: a command leaves at its dur, at a
384
442
  // server verdict, or at a record reset — never at a local channel death.
385
443
  const syncBacklogInner = (s: Session) => {
444
+ if (s.shared && s.shared.notices.length > 0)
445
+ noticeInner(s, s.shared.notices.splice(0));
386
446
  const parked = s.shared ? s.shared.parked.splice(0) : [];
447
+ const settled = s.shared ? new Set(s.shared.settled) : new Set<string>();
448
+ if (s.shared) s.shared.settled.clear();
387
449
  let lanes = s.record.lanes;
388
450
  for (const protocol of durableLanes(s)) {
389
451
  const lane = laneOf(s.record, protocol);
@@ -391,18 +453,23 @@ const syncBacklogInner = (s: Session) => {
391
453
  // stamp; the enqueuer finds the verdict by that id.
392
454
  const parkedHere = parked
393
455
  .filter((v) => v.protocol === protocol)
394
- .map(({ verdict }) => {
456
+ .flatMap(({ verdict }) => {
395
457
  const id =
396
458
  verdict.id ?? lane.commands.find((c) => c.seq === verdict.seq)?.id;
397
- return id === undefined ? verdict : { ...verdict, id };
459
+ // No stamp left to map it: its owner already settled the command.
460
+ if (id === undefined || settled.has(id)) return [];
461
+ return [{ ...verdict, id }];
398
462
  });
399
463
  const parkedSeqs = new Set(parkedHere.map((v) => v.seq));
400
464
  const commands = mergeBacklog(s, protocol).filter(
401
465
  (c) => !parkedSeqs.has(c.seq),
402
466
  );
467
+ const kept = (lane.verdicts ?? []).filter(
468
+ (v) => v.id === undefined || !settled.has(v.id),
469
+ );
403
470
  const verdicts =
404
- parkedHere.length > 0
405
- ? [...(lane.verdicts ?? []), ...parkedHere].slice(-MAX_PARKED_VERDICTS)
471
+ parkedHere.length > 0 || kept.length !== (lane.verdicts ?? []).length
472
+ ? [...kept, ...parkedHere].slice(-MAX_PARKED_VERDICTS)
406
473
  : lane.verdicts;
407
474
  const unchanged =
408
475
  verdicts === lane.verdicts &&
@@ -443,8 +510,14 @@ const transact = (s: Session, fn: () => void): Promise<void> => {
443
510
  const shared = s.shared;
444
511
  const run = shared.txq.then(() =>
445
512
  s.store.lock!(async () => {
446
- refresh(s);
447
- fn();
513
+ const stored = await readStored(s);
514
+ shared.inTx = true;
515
+ try {
516
+ refresh(s, stored);
517
+ fn();
518
+ } finally {
519
+ shared.inTx = false;
520
+ }
448
521
  watchOwners(s);
449
522
  s.notify();
450
523
  const persistence = s.store.flush?.();
@@ -462,16 +535,19 @@ const transact = (s: Session, fn: () => void): Promise<void> => {
462
535
  return result;
463
536
  };
464
537
 
538
+ const readStored = async (s: Session) =>
539
+ s.store.read ? await s.store.read() : s.store.load();
540
+
465
541
  // Under the lock: the stored record is the truth about which sharer stamped
466
542
  // what; this client's entries follow it by id.
467
- const refresh = (s: Session) => {
468
- const stored = s.store.load();
543
+ const refresh = (s: Session, stored: StatewireClient.SessionRecord | null) => {
469
544
  if (stored !== null && stored.clientId !== s.record.clientId) {
470
545
  adoptReset(s, stored);
471
546
  return;
472
547
  }
473
- if (stored !== null) s.record = boundRecord(stored);
548
+ if (stored !== null) s.record = boundInbox(s.store, stored);
474
549
  reconcileLocal(s);
550
+ followReleases(s);
475
551
  };
476
552
 
477
553
  const reconcileLocal = (s: Session) => {
@@ -490,7 +566,7 @@ const reconcileLocal = (s: Session) => {
490
566
  }
491
567
  unstamp(s, p);
492
568
  }
493
- setHold(s, p, queued.hold);
569
+ follow(s, p, queued);
494
570
  } else if (stamped && stamped.owner !== me) {
495
571
  if (p.adopted) dropPending(s, p);
496
572
  else if (!p.away) {
@@ -499,14 +575,24 @@ const reconcileLocal = (s: Session) => {
499
575
  }
500
576
  } else if (stamped) {
501
577
  if (p.seq !== stamped.seq) stamp(s, p, stamped.seq);
502
- } else if (isOutboxed(p)) {
503
- if (!lane.removed?.some((m) => m.id === p.id)) {
578
+ } else if (!p.adopted && (isOutboxed(p) || p.away)) {
579
+ const mark = lane.removed?.find((m) => m.id === p.id);
580
+ if (mark === undefined) {
504
581
  p.away = true;
505
582
  continue;
506
583
  }
507
- settleRemoved(s, p);
584
+ s.lifecycle?.onCovered?.(p.inv);
585
+ settleEntry(
586
+ s,
587
+ p,
588
+ {
589
+ ok: false,
590
+ failure: mark.failure ?? { fate: "aborted", cause: "removed" },
591
+ },
592
+ mark.failure ? "noticed" : "memory",
593
+ );
508
594
  const { removed: _removed, ...rest } = lane;
509
- const left = lane.removed.filter((m) => m.id !== p.id);
595
+ const left = lane.removed!.filter((m) => m.id !== p.id);
510
596
  setRecord(
511
597
  s,
512
598
  withLane(s.record, protocol, {
@@ -518,6 +604,73 @@ const reconcileLocal = (s: Session) => {
518
604
  }
519
605
  };
520
606
 
607
+ const locate = (
608
+ s: Session,
609
+ id: string,
610
+ ):
611
+ | { protocol: string; stored: StatewireClient.OutboxCommand }
612
+ | { protocol: string; stored?: undefined }
613
+ | undefined => {
614
+ for (const [protocol, lane] of Object.entries(s.record.lanes)) {
615
+ const stored = outboxOf(lane).find((c) => c.id === id);
616
+ if (stored) return { protocol, stored };
617
+ if (lane.commands.some((c) => c.id === id)) return { protocol };
618
+ }
619
+ const p = s.pending.find((q) => q.id === id);
620
+ if (p === undefined) return undefined;
621
+ return isOutboxed(p) && isEphemeral(s, p.command.protocol)
622
+ ? { protocol: p.command.protocol, stored: toOutbox(p) }
623
+ : { protocol: p.command.protocol };
624
+ };
625
+
626
+ const settleReleases = (
627
+ s: Session,
628
+ id: string,
629
+ outcome:
630
+ | { ok: true; payload: unknown }
631
+ | { ok: false; failure: StatewireClient.SendFailure },
632
+ ) => {
633
+ const waiters = s.releases.get(id);
634
+ if (waiters === undefined) return;
635
+ s.releases.delete(id);
636
+ for (const waiter of waiters) {
637
+ if (outcome.ok) waiter.resolve(outcome.payload);
638
+ else waiter.reject(new StatewireSendError(outcome.failure));
639
+ }
640
+ };
641
+
642
+ // A release whose command another sharer settled: the record shows only that
643
+ // it left, failed (a notice) or was taken unsent (a mark).
644
+ const followReleases = (s: Session) => {
645
+ for (const id of [...s.releases.keys()]) {
646
+ if (locate(s, id) !== undefined) continue;
647
+ const lanes = Object.values(s.record.lanes);
648
+ const notice = lanes
649
+ .flatMap((lane) => lane.inbox ?? [])
650
+ .find((n) => n.id === id);
651
+ const mark = lanes
652
+ .flatMap((lane) => lane.removed ?? [])
653
+ .find((m) => m.id === id);
654
+ const failure = notice?.failure ?? mark?.failure;
655
+ settleReleases(
656
+ s,
657
+ id,
658
+ failure
659
+ ? { ok: false, failure }
660
+ : {
661
+ ok: true,
662
+ payload:
663
+ notice?.result === undefined
664
+ ? undefined
665
+ : paramsOf({
666
+ params: notice.result.value,
667
+ meta: notice.result.meta,
668
+ })[0],
669
+ },
670
+ );
671
+ }
672
+ };
673
+
521
674
  // A sharer that stamped commands and left: once its owner lock frees,
522
675
  // another sharer takes its stamps over and reattaches, so the hello's
523
676
  // watermark settles them.
@@ -659,113 +812,159 @@ const cover = (s: Session, entry: Pending) => {
659
812
  s.lifecycle?.onCovered?.(entry.inv);
660
813
  };
661
814
 
662
- export const failedCommands = (
663
- s: Session,
664
- ): readonly StatewireClient.FailedCommand[] => {
665
- const parts = Object.values(s.record.lanes).flatMap((lane) =>
666
- lane.failed === undefined ? [] : [lane.failed],
667
- );
668
- const same =
669
- parts.length === s.failedParts.length &&
670
- parts.every((part, i) => part === s.failedParts[i]);
671
- if (!same) {
672
- s.failedParts = parts;
673
- s.failedList = parts.flat();
674
- }
675
- return s.failedList;
676
- };
815
+ export const MAX_INBOX = 256;
677
816
 
678
- export const MAX_FAILED_RETAINED = 256;
817
+ type Outcome =
818
+ | { ok: true; payload: unknown }
819
+ | { ok: false; failure: StatewireClient.SendFailure };
679
820
 
680
- const boundFailed = (failed: readonly StatewireClient.FailedCommand[]) =>
681
- failed.length > MAX_FAILED_RETAINED
682
- ? [...failed].sort((a, b) => a.ts - b.ts).slice(-MAX_FAILED_RETAINED)
683
- : failed;
821
+ const updateOf = (
822
+ protocol: string,
823
+ stored: StatewireClient.StoredSettled,
824
+ ): Statewire.CommandUpdate => ({
825
+ protocol,
826
+ id: stored.id,
827
+ method: stored.method,
828
+ params: paramsOf(stored) as never[],
829
+ ...(stored.seq !== undefined && { seq: stored.seq }),
830
+ status: "settled",
831
+ ...(stored.result !== undefined && {
832
+ result: paramsOf({
833
+ params: stored.result.value,
834
+ meta: stored.result.meta,
835
+ })[0],
836
+ }),
837
+ ...(stored.failure !== undefined && { failure: stored.failure }),
838
+ ...(stored.localMeta !== undefined && { localMeta: stored.localMeta }),
839
+ });
684
840
 
685
- const boundRecord = (record: StatewireClient.SessionRecord) => {
841
+ // The inbox keeps the newest notices; the rest are reported as evicted.
842
+ const boundInbox = (
843
+ store: StatewireClient.SessionStorage,
844
+ record: StatewireClient.SessionRecord,
845
+ ): StatewireClient.SessionRecord => {
686
846
  let lanes = record.lanes;
847
+ const dropped: Statewire.CommandUpdate[] = [];
687
848
  for (const [protocol, lane] of Object.entries(lanes)) {
688
- if (lane.failed !== undefined && lane.failed.length > MAX_FAILED_RETAINED)
689
- lanes = {
690
- ...lanes,
691
- [protocol]: { ...lane, failed: boundFailed(lane.failed) },
692
- };
849
+ const inbox = lane.inbox;
850
+ if (inbox === undefined || inbox.length <= MAX_INBOX) continue;
851
+ const sorted = [...inbox].sort((x, y) => x.ts - y.ts);
852
+ const cut = sorted.length - MAX_INBOX;
853
+ dropped.push(...sorted.slice(0, cut).map((n) => updateOf(protocol, n)));
854
+ lanes = { ...lanes, [protocol]: { ...lane, inbox: sorted.slice(cut) } };
693
855
  }
856
+ if (dropped.length > 0) store.onEvict?.({ reason: "inbox", dropped });
694
857
  return lanes === record.lanes ? record : { ...record, lanes };
695
858
  };
696
859
 
697
- const retainFailedInner = (
698
- s: Session,
699
- entry: Stamped,
700
- failure: StatewireClient.SendFailure,
701
- ) => {
702
- const lane = laneOf(s.record, entry.command.protocol);
703
- setRecord(
704
- s,
705
- withLane(s.record, entry.command.protocol, {
706
- ...lane,
707
- failed: boundFailed([
708
- ...(lane.failed ?? []),
709
- {
710
- protocol: entry.command.protocol,
711
- ...(entry.command.produced !== undefined && {
712
- produced: entry.command.produced,
713
- }),
714
- command: entry.command.method,
715
- params: entry.command.params,
716
- ...(entry.command.meta !== undefined && { meta: entry.command.meta }),
717
- failure,
718
- ts: entry.inv.ts,
719
- seq: entry.seq,
720
- },
721
- ]),
860
+ const toNotice = (
861
+ stored: Omit<StatewireClient.SessionCommand, "seq"> & { seq?: number },
862
+ outcome: Outcome,
863
+ ): StatewireClient.StoredSettled => {
864
+ const result = outcome.ok
865
+ ? encodeParams(outcome.payload === undefined ? [] : [outcome.payload])
866
+ : undefined;
867
+ return {
868
+ id: stored.id,
869
+ method: stored.method,
870
+ params: stored.params,
871
+ ...(stored.meta !== undefined && { meta: stored.meta }),
872
+ ts: stored.ts,
873
+ ...(stored.seq !== undefined && { seq: stored.seq }),
874
+ ...(stored.localMeta !== undefined && { localMeta: stored.localMeta }),
875
+ ...(result !== undefined && {
876
+ result: {
877
+ value: result.params,
878
+ ...(result.meta !== undefined && { meta: result.meta }),
879
+ },
722
880
  }),
723
- );
724
- s.notify();
881
+ ...(!outcome.ok && { failure: outcome.failure }),
882
+ };
725
883
  };
726
884
 
727
- const retainFailed = (
728
- s: Session,
729
- entry: Stamped,
730
- failure: StatewireClient.SendFailure,
731
- ) => {
732
- if (!s.shared) {
733
- retainFailedInner(s, entry, failure);
885
+ const lostNotice = (
886
+ stored: Omit<StatewireClient.SessionCommand, "seq"> & { seq?: number },
887
+ ) => toNotice(stored, { ok: false, failure: LOST });
888
+
889
+ const LOST = { fate: "lost", cause: "session-lost" } as const;
890
+
891
+ const withNotices = (
892
+ lane: StatewireClient.SessionLane,
893
+ notices: readonly StatewireClient.StoredSettled[],
894
+ ): StatewireClient.SessionLane => {
895
+ const known = new Set((lane.inbox ?? []).map((n) => n.id));
896
+ const added = notices.filter((n) => !known.has(n.id));
897
+ return added.length === 0
898
+ ? lane
899
+ : { ...lane, inbox: [...(lane.inbox ?? []), ...added] };
900
+ };
901
+
902
+ // The settled update lands in the same write that drops the stamp: a reload
903
+ // either resends the command or delivers the update.
904
+ type Notice = { entry: Stamped; outcome: Outcome };
905
+
906
+ const noticeInner = (s: Session, notices: readonly Notice[]) => {
907
+ let record = s.record;
908
+ for (const { entry, outcome } of notices) {
909
+ const protocol = entry.command.protocol;
910
+ const lane = laneOf(record, protocol);
911
+ record = withLane(record, protocol, {
912
+ ...withNotices(lane, [toNotice(toStored(s, entry), outcome)]),
913
+ commands: lane.commands.filter((c) => c.id !== entry.id),
914
+ });
915
+ }
916
+ setRecord(s, boundInbox(s.store, record));
917
+ deliver(s);
918
+ };
919
+
920
+ // Shared storage: the update rides the next backlog sync's locked write.
921
+ const notice = (s: Session, entry: Stamped, outcome: Outcome) => {
922
+ if (!s.shared || s.shared.inTx) {
923
+ noticeInner(s, [{ entry, outcome }]);
734
924
  return;
735
925
  }
736
- transactBackground(s, () => retainFailedInner(s, entry, failure));
926
+ s.shared.notices.push({ entry, outcome });
927
+ syncBacklog(s);
737
928
  };
738
929
 
930
+ /**
931
+ * "notice": a stamped durable command persists its settled update; "noticed":
932
+ * the caller already did; "memory": the update is best-effort only.
933
+ */
934
+ type SettleMode = "notice" | "noticed" | "memory";
935
+
739
936
  const settleEntry = (
740
937
  s: Session,
741
938
  entry: Pending,
742
- outcome:
743
- | { ok: true; payload: unknown }
744
- | { ok: false; failure: StatewireClient.SendFailure },
939
+ outcome: Outcome,
940
+ mode: SettleMode = "notice",
745
941
  ) => {
746
942
  s.pending = s.pending.filter((p) => p !== entry);
943
+ if (s.shared && !entry.adopted) s.shared.settled.add(entry.id);
944
+ settleReleases(s, entry.id, outcome);
945
+ // The record keeps every unsettled command for the next attach; only a
946
+ // command it no longer owns becomes a durable settled update.
947
+ const durable =
948
+ mode === "noticed" ||
949
+ (mode === "notice" &&
950
+ (outcome.ok || outcome.failure.cause !== "unmount") &&
951
+ isStamped(entry) &&
952
+ !isEphemeral(s, entry.command.protocol));
953
+ if (durable && mode === "notice") notice(s, entry as Stamped, outcome);
747
954
  if (outcome.ok) {
748
955
  entry.resolve(outcome.payload);
749
- s.lifecycle?.onSettle(entry.inv, {
750
- type: "accepted",
751
- payload: outcome.payload,
752
- });
956
+ s.lifecycle?.onSettle(
957
+ entry.inv,
958
+ { type: "accepted", payload: outcome.payload },
959
+ durable,
960
+ );
753
961
  } else {
754
- // The record keeps every unsettled command for the next attach; only
755
- // invocations it no longer owns become failed history.
756
- const rides = outcome.failure.cause === "unmount";
757
- if (
758
- !rides &&
759
- isStamped(entry) &&
760
- !entry.adopted &&
761
- !isEphemeral(s, entry.command.protocol)
762
- )
763
- retainFailed(s, entry, outcome.failure);
764
962
  entry.reject(new StatewireSendError(outcome.failure));
765
- s.lifecycle?.onSettle(entry.inv, {
766
- type: "rejected",
767
- error: new StatewireSendError(outcome.failure),
768
- });
963
+ s.lifecycle?.onSettle(
964
+ entry.inv,
965
+ { type: "rejected", error: new StatewireSendError(outcome.failure) },
966
+ durable,
967
+ );
769
968
  }
770
969
  };
771
970
 
@@ -817,14 +1016,10 @@ const settleRemoved = (s: Session, entry: Pending) => {
817
1016
  const requeue = (s: Session, survivors: Pending[]) => {
818
1017
  for (const old of survivors) {
819
1018
  s.lifecycle?.onCovered?.(old.inv);
820
- enqueueInner(
821
- s,
822
- old.command,
823
- old.inv.params,
824
- old.resolve,
825
- old.reject,
826
- old.inv.hold,
827
- );
1019
+ enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject, {
1020
+ ...(old.inv.held && { hold: true }),
1021
+ ...(old.inv.localMeta !== undefined && { localMeta: old.inv.localMeta }),
1022
+ });
828
1023
  }
829
1024
  };
830
1025
 
@@ -842,22 +1037,14 @@ const rebaseRecord = (s: Session, adopt: () => void) => {
842
1037
  for (const p of s.pending) if (p.adopted) s.lifecycle?.onCovered?.(p.inv);
843
1038
  s.pending = [];
844
1039
  s.durableSeq = {};
845
- // Failed entries are user-owned history: they ride through every record replacement.
1040
+ // Failure notices ride through every record replacement.
846
1041
  const previous = s.record.lanes;
847
1042
  adopt();
848
1043
  let lanes = s.record.lanes;
849
1044
  for (const [protocol, lane] of Object.entries(previous)) {
850
- const failed = lane.failed;
851
- if (failed === undefined || laneOf(s.record, protocol).failed === failed)
852
- continue;
853
- const current = laneOf(s.record, protocol);
854
- lanes = {
855
- ...lanes,
856
- [protocol]: {
857
- ...current,
858
- failed: boundFailed([...(current.failed ?? []), ...failed]),
859
- },
860
- };
1045
+ const current = laneOf({ ...s.record, lanes }, protocol);
1046
+ if (!lane.inbox?.length || current.inbox === lane.inbox) continue;
1047
+ lanes = { ...lanes, [protocol]: withNotices(current, lane.inbox) };
861
1048
  }
862
1049
  if (lanes !== s.record.lanes) setRecord(s, { ...s.record, lanes });
863
1050
  requeue(s, survivors);
@@ -873,7 +1060,7 @@ const resetRecord = (s: Session, clientId: string) =>
873
1060
  // attempted history and our survivors re-stamp on the stored clocks.
874
1061
  const adoptReset = (s: Session, stored: StatewireClient.SessionRecord) => {
875
1062
  rebaseRecord(s, () => {
876
- s.record = boundRecord(stored);
1063
+ s.record = boundInbox(s.store, stored);
877
1064
  });
878
1065
  // The host attributes frames by lease, and the lease is bound to the id
879
1066
  // this attach was opened with.
@@ -1090,21 +1277,11 @@ const publishIngested = (s: Session) => {
1090
1277
  // watermark+1 proves the record that admitted the missing prefix is gone —
1091
1278
  // its history settles lost and the lane clock restarts at the watermark.
1092
1279
  const sessionLostInner = (s: Session, protocol: string, seqWm: number) => {
1280
+ const lane = laneOf(s.record, protocol);
1281
+ const failure = { fate: "lost", cause: "session-lost" } as const;
1093
1282
  const held = laneEntries(s, protocol);
1094
- settleLost(s, held);
1095
1283
  s.durableSeq[protocol] = seqWm;
1096
- const { failed, outbox, removed } = laneOf(s.record, protocol);
1097
- setRecord(
1098
- s,
1099
- withLane(s.record, protocol, {
1100
- nextSeq: seqWm + 1,
1101
- commands: [],
1102
- ...(outbox !== undefined && { outbox }),
1103
- ...(removed !== undefined && { removed }),
1104
- ...(failed !== undefined && { failed }),
1105
- }),
1106
- );
1107
- if (
1284
+ const requeue =
1108
1285
  held.length > 0 &&
1109
1286
  s.onSessionLost?.(
1110
1287
  held.map((p) => ({
@@ -1115,30 +1292,85 @@ const sessionLostInner = (s: Session, protocol: string, seqWm: number) => {
1115
1292
  seq: p.seq,
1116
1293
  ...(p.inv.clientId !== undefined && { clientId: p.inv.clientId }),
1117
1294
  })),
1118
- ) === "requeue"
1119
- ) {
1120
- for (const old of held) {
1295
+ ) === "requeue";
1296
+ if (requeue) {
1297
+ if (s.onDeprecatedPath)
1298
+ s.onDeprecatedPath({ kind: "session-lost-requeue", count: held.length });
1299
+ else
1300
+ console.warn(
1301
+ `statewire: onSessionLost "requeue" re-enqueued ${held.length} commands; it is deprecated and will be removed`,
1302
+ );
1303
+ for (const p of held) {
1304
+ s.lifecycle?.onCovered?.(p.inv);
1305
+ settleEntry(s, p, { ok: false, failure }, "memory");
1306
+ }
1307
+ setRecord(
1308
+ s,
1309
+ withLane(s.record, protocol, {
1310
+ ...lane,
1311
+ nextSeq: seqWm + 1,
1312
+ commands: [],
1313
+ }),
1314
+ );
1315
+ for (const old of held)
1121
1316
  enqueueInner(
1122
1317
  s,
1123
1318
  old.command,
1124
1319
  old.inv.params,
1125
1320
  () => {},
1126
1321
  () => {},
1322
+ {
1323
+ ...(old.inv.localMeta !== undefined && {
1324
+ localMeta: old.inv.localMeta,
1325
+ }),
1326
+ },
1127
1327
  );
1328
+ } else {
1329
+ // Default: the whole queue goes, each command a failure notice.
1330
+ const stamps = mergeBacklog(s, protocol);
1331
+ const queued = outboxOf(lane);
1332
+ const { outbox: _outbox, ...rest } = lane;
1333
+ setRecord(
1334
+ s,
1335
+ withLane(s.record, protocol, {
1336
+ ...withNotices(rest, [
1337
+ ...stamps.map((c) => lostNotice(c)),
1338
+ ...queued.map((c) => lostNotice(c)),
1339
+ ]),
1340
+ nextSeq: seqWm + 1,
1341
+ commands: [],
1342
+ ...marks(
1343
+ s,
1344
+ lane,
1345
+ [
1346
+ ...stamps.map((c) => ({ id: c.id, by: c.by ?? c.owner })),
1347
+ ...queued,
1348
+ ],
1349
+ failure,
1350
+ ),
1351
+ }),
1352
+ );
1353
+ for (const p of s.pending.filter((q) => q.command.protocol === protocol)) {
1354
+ s.lifecycle?.onCovered?.(p.inv);
1355
+ settleEntry(s, p, { ok: false, failure }, "noticed");
1128
1356
  }
1357
+ deliver(s);
1129
1358
  }
1130
1359
  publish(s);
1131
1360
  wake(s);
1132
1361
  };
1133
1362
 
1134
- // Every stamp above the watermark, whichever sharer made it, returns to the
1135
- // front of the outbox. A stamp past watermark+1, or one this client saw
1136
- // admitted, proves the server record that admitted it is gone.
1363
+ // Every stamp above the watermark returns to the front of the outbox. A stamp
1364
+ // past watermark+1, or one this client saw admitted, proves the server record
1365
+ // that admitted it is gone. Another live sharer's stamp may still be on its
1366
+ // way on that sharer's attach: it and every stamp after it wait for a later
1367
+ // hello; the stamps before it never arrived and resend under their seqs.
1137
1368
  const demote = (
1138
1369
  s: Session,
1139
1370
  protocol: string,
1140
1371
  watermark: number,
1141
1372
  demoted: Set<string>,
1373
+ waiting: Set<Pending>,
1142
1374
  ) => {
1143
1375
  const lane = laneOf(s.record, protocol);
1144
1376
  const local = laneEntries(s, protocol).filter((p) => p.seq > watermark);
@@ -1156,6 +1388,17 @@ const demote = (
1156
1388
  sessionLostInner(s, protocol, watermark);
1157
1389
  return;
1158
1390
  }
1391
+ const me = s.store.owner?.id;
1392
+ const foreign = moved.find(
1393
+ (c) =>
1394
+ c.owner !== undefined &&
1395
+ c.owner !== me &&
1396
+ !local.some((p) => p.id === c.id),
1397
+ );
1398
+ if (foreign !== undefined) {
1399
+ for (const p of local) if (p.seq > foreign.seq) waiting.add(p);
1400
+ return;
1401
+ }
1159
1402
  setRecord(
1160
1403
  s,
1161
1404
  withLane(s.record, protocol, {
@@ -1175,6 +1418,24 @@ const demote = (
1175
1418
  sortPending(s);
1176
1419
  };
1177
1420
 
1421
+ const recheckLater = (s: Session, watermarks: string) => {
1422
+ if (s.rechecks.watermarks !== watermarks)
1423
+ s.rechecks = { watermarks, count: 0 };
1424
+ s.rechecks.count += 1;
1425
+ if (s.recheck !== undefined) return;
1426
+ s.recheck = setTimeout(
1427
+ () => {
1428
+ s.recheck = undefined;
1429
+ if (s.running && s.attach === "attached")
1430
+ s.transport?.connection.reconnect();
1431
+ },
1432
+ backoff(s.rechecks.count + 1),
1433
+ );
1434
+ };
1435
+
1436
+ const gapFill = (s: Session) =>
1437
+ s.selection !== undefined && s.selection.version >= GAP_FILL_VERSION;
1438
+
1178
1439
  const selectionOf = (
1179
1440
  syn: StatewireSyn,
1180
1441
  ): StatewireProtocol.Selection | undefined => {
@@ -1222,6 +1483,7 @@ const handleHello = (
1222
1483
  // verdicts (the server never re-runs them); seqs above it never arrived
1223
1484
  // and go back to the outbox for fresh ones.
1224
1485
  const demoted = new Set<string>();
1486
+ const waiting = new Set<Pending>();
1225
1487
  for (const lane of lanes) {
1226
1488
  if (lane.record === "ephemeral") {
1227
1489
  s.ephemeralSeqs[lane.name] = Math.max(
@@ -1232,17 +1494,24 @@ const handleHello = (
1232
1494
  );
1233
1495
  continue;
1234
1496
  }
1235
- demote(s, lane.name, lane.seq ?? 0, demoted);
1497
+ demote(s, lane.name, lane.seq ?? 0, demoted, waiting);
1236
1498
  s.durableSeq[lane.name] = lane.seq ?? 0;
1237
1499
  }
1238
1500
  for (const entry of s.pending) {
1239
1501
  entry.posted =
1240
1502
  attached.has(entry) ||
1503
+ waiting.has(entry) ||
1241
1504
  (isStamped(entry) &&
1242
1505
  entry.seq <= durableSeqOf(s, entry.command.protocol) &&
1243
1506
  !isEphemeral(s, entry.command.protocol));
1244
1507
  entry.stale = false;
1245
1508
  }
1509
+ if (waiting.size > 0)
1510
+ recheckLater(
1511
+ s,
1512
+ JSON.stringify(lanes.map((lane) => [lane.name, lane.seq])),
1513
+ );
1514
+ else s.rechecks = { watermarks: "", count: 0 };
1246
1515
  syncBacklogInner(s);
1247
1516
  s.sessionKnown = true;
1248
1517
  flushInner(s, demoted);
@@ -1251,11 +1520,13 @@ const handleHello = (
1251
1520
  reconcile();
1252
1521
  publishIngested(s);
1253
1522
  void pump(s);
1523
+ deliver(s);
1254
1524
  return;
1255
1525
  }
1256
1526
  transactBackground(s, reconcile, () => {
1257
1527
  publishIngested(s);
1258
1528
  void pump(s);
1529
+ deliver(s);
1259
1530
  });
1260
1531
  };
1261
1532
 
@@ -1458,22 +1729,23 @@ const pump = async (s: Session) => {
1458
1729
  if (!s.hydrated) await s.ready;
1459
1730
  const transport = s.transport;
1460
1731
  const flushable = flushCandidates(s).length > 0;
1461
- const queued = s.pending.some(
1462
- (p) => isOutboxed(p) && p.inv.hold === undefined,
1463
- );
1732
+ const queued =
1733
+ s.pending.some((p) => isOutboxed(p) && !p.inv.held) ||
1734
+ queuedInRecord(s);
1464
1735
  if (
1465
1736
  buildLanes(s) === null &&
1466
1737
  !flushable &&
1467
1738
  (!queued || s.attach === "attached")
1468
1739
  )
1469
1740
  return;
1470
- if (transport?.parked && s.pending.length === 0) return;
1741
+ const waiting = s.pending.length > 0 || queued;
1742
+ if (transport?.parked && !waiting) return;
1471
1743
  if (s.attach !== "attached" || !transport) {
1472
- if (s.pending.length === 0) return;
1744
+ if (!waiting) return;
1473
1745
  if (
1474
1746
  (transport?.connection.status === "standby" &&
1475
1747
  transport.connection.reason === "idle") ||
1476
- (transport?.parked && s.pending.length > 0)
1748
+ transport?.parked
1477
1749
  ) {
1478
1750
  transport.connection.reconnect();
1479
1751
  }
@@ -1510,8 +1782,8 @@ const pump = async (s: Session) => {
1510
1782
  return transport.send(built.frame);
1511
1783
  };
1512
1784
  result = await (s.shared && !s.deactivated && lanes.entries.length > 0
1513
- ? s.store.lock!(() => {
1514
- refresh(s);
1785
+ ? s.store.lock!(async () => {
1786
+ refresh(s, await readStored(s));
1515
1787
  lanes = buildLanes(s);
1516
1788
  if (!lanes || epoch !== s.attachEpoch) return null;
1517
1789
  return send(lanes);
@@ -1519,6 +1791,18 @@ const pump = async (s: Session) => {
1519
1791
  : send(lanes));
1520
1792
  } catch {
1521
1793
  if (!s.running || isFinished(s)) return;
1794
+ // The frame may have arrived. Under the gap-fill wire its seqs never
1795
+ // go out again: the next hello answers what arrived and frees the rest.
1796
+ if (lanes !== null && lanes.entries.length > 0 && gapFill(s)) {
1797
+ if (epoch !== s.attachEpoch) continue;
1798
+ for (const entry of lanes.entries)
1799
+ if (s.pending.includes(entry)) entry.posted = true;
1800
+ s.frameFailures += 1;
1801
+ await sleepUntilWake(s, backoff(s.frameFailures));
1802
+ if (s.running && epoch === s.attachEpoch && s.attach === "attached")
1803
+ transport.connection.reconnect();
1804
+ continue;
1805
+ }
1522
1806
  if (lanes?.frame.ctx?.length) {
1523
1807
  s.lanesOnAttach = true;
1524
1808
  transport.connection.reconnect();
@@ -1596,6 +1880,7 @@ const start = (s: Session) => {
1596
1880
  () => {
1597
1881
  wake(s);
1598
1882
  void pump(s);
1883
+ deliver(s);
1599
1884
  },
1600
1885
  );
1601
1886
  });
@@ -1608,7 +1893,10 @@ const start = (s: Session) => {
1608
1893
  adoptOwner(s, undefined);
1609
1894
  consumeVerdicts(s);
1610
1895
  },
1611
- () => void pump(s),
1896
+ () => {
1897
+ void pump(s);
1898
+ deliver(s);
1899
+ },
1612
1900
  );
1613
1901
  return;
1614
1902
  }
@@ -1619,10 +1907,13 @@ const start = (s: Session) => {
1619
1907
  adoptOwner(s, undefined);
1620
1908
  s.running = true;
1621
1909
  void pump(s);
1910
+ deliver(s);
1622
1911
  };
1623
1912
 
1624
1913
  const stop = (s: Session) => {
1625
1914
  s.running = false;
1915
+ clearTimeout(s.recheck);
1916
+ s.recheck = undefined;
1626
1917
  if (s.shared) {
1627
1918
  s.shared.unsubscribe?.();
1628
1919
  s.shared.unsubscribe = null;
@@ -1640,13 +1931,23 @@ const setTransport = (s: Session, transport: RemoteLink) => {
1640
1931
  wake(s);
1641
1932
  };
1642
1933
 
1934
+ type EnqueueOptions = {
1935
+ hold?: boolean;
1936
+ localMeta?: unknown;
1937
+ };
1938
+
1939
+ const QUOTA: StatewireClient.SendFailure = { fate: "aborted", cause: "quota" };
1940
+
1941
+ const isQuota = (error: unknown) =>
1942
+ error instanceof Error && error.name === "StatewireQuotaError";
1943
+
1643
1944
  const enqueueInner = (
1644
1945
  s: Session,
1645
1946
  command: WireCommand,
1646
1947
  params: readonly unknown[],
1647
1948
  resolve: (payload: unknown) => void,
1648
1949
  reject: (error: unknown) => void,
1649
- hold?: Hold,
1950
+ options: EnqueueOptions = {},
1650
1951
  ) => {
1651
1952
  const protocol = command.protocol;
1652
1953
  const id = newClientId();
@@ -1661,7 +1962,8 @@ const enqueueInner = (
1661
1962
  ts: Date.now(),
1662
1963
  id,
1663
1964
  clientId: s.record.clientId,
1664
- ...(hold !== undefined && { hold }),
1965
+ ...(options.hold && { held: true as const }),
1966
+ ...(options.localMeta !== undefined && { localMeta: options.localMeta }),
1665
1967
  },
1666
1968
  adopted: false,
1667
1969
  by: s.store.owner?.id,
@@ -1673,19 +1975,25 @@ const enqueueInner = (
1673
1975
  resolve,
1674
1976
  reject,
1675
1977
  };
1676
- s.pending.push(entry);
1677
1978
  if (!isEphemeral(s, protocol)) {
1678
1979
  const lane = laneOf(s.record, protocol);
1679
- setRecord(
1680
- s,
1681
- withLane(
1682
- s.record,
1683
- protocol,
1684
- withOutbox(lane, [...outboxOf(lane), toOutbox(entry)]),
1685
- ),
1686
- );
1980
+ try {
1981
+ setRecord(
1982
+ s,
1983
+ withLane(
1984
+ s.record,
1985
+ protocol,
1986
+ withOutbox(lane, [...outboxOf(lane), toOutbox(entry)]),
1987
+ ),
1988
+ );
1989
+ } catch (error) {
1990
+ if (!isQuota(error)) throw error;
1991
+ reject(new StatewireSendError(QUOTA));
1992
+ return;
1993
+ }
1994
+ s.pending.push(entry);
1687
1995
  reorder(s, protocol);
1688
- }
1996
+ } else s.pending.push(entry);
1689
1997
  s.lifecycle?.onStart(entry.inv);
1690
1998
  s.notify();
1691
1999
  };
@@ -1698,33 +2006,26 @@ type Candidate = {
1698
2006
  stored: StatewireClient.OutboxCommand;
1699
2007
  };
1700
2008
 
1701
- const views = new WeakMap<
1702
- StatewireClient.OutboxCommand,
1703
- StatewireClient.OutboxEntry
1704
- >();
1705
-
1706
2009
  const viewOf = (
2010
+ s: Session,
1707
2011
  protocol: string,
1708
- stored: StatewireClient.OutboxCommand,
2012
+ stored: StatewireClient.OutboxCommand | StatewireClient.SessionCommand,
2013
+ state?: "sending",
1709
2014
  ): StatewireClient.OutboxEntry => {
1710
- const cached = views.get(stored);
2015
+ const cached = s.views.get(stored);
1711
2016
  if (cached?.protocol === protocol) return cached;
1712
2017
  const view: StatewireClient.OutboxEntry = {
1713
2018
  id: stored.id,
1714
2019
  protocol,
1715
- command: {
1716
- method: stored.method,
1717
- params:
1718
- stored.meta === undefined
1719
- ? stored.params
1720
- : (reviveValue(
1721
- structuredClone(stored.params),
1722
- stored.meta,
1723
- ) as unknown[]),
1724
- },
1725
- held: stored.hold === undefined ? null : { meta: stored.hold.meta },
2020
+ command: { method: stored.method, params: paramsOf(stored) },
2021
+ ts: stored.ts,
2022
+ state:
2023
+ state ??
2024
+ ((stored as StatewireClient.OutboxCommand).held ? "held" : "queued"),
2025
+ localMeta: stored.localMeta,
2026
+ durable: s.store.durable !== false && !isEphemeral(s, protocol),
1726
2027
  };
1727
- views.set(stored, view);
2028
+ s.views.set(stored, view);
1728
2029
  return view;
1729
2030
  };
1730
2031
 
@@ -1751,7 +2052,7 @@ const bubble = (
1751
2052
  const order: { stored: StatewireClient.OutboxCommand; blocked: boolean }[] =
1752
2053
  [];
1753
2054
  for (const stored of queue) {
1754
- if (stored.hold !== undefined) {
2055
+ if (stored.held) {
1755
2056
  order.push({ stored, blocked: true });
1756
2057
  continue;
1757
2058
  }
@@ -1760,7 +2061,10 @@ const bubble = (
1760
2061
  at > 0 &&
1761
2062
  order[at - 1]!.blocked &&
1762
2063
  may !== undefined &&
1763
- may(viewOf(protocol, stored), viewOf(protocol, order[at - 1]!.stored))
2064
+ may(
2065
+ viewOf(s, protocol, stored),
2066
+ viewOf(s, protocol, order[at - 1]!.stored),
2067
+ )
1764
2068
  )
1765
2069
  at--;
1766
2070
  order.splice(at, 0, { stored, blocked: at > 0 && order[at - 1]!.blocked });
@@ -1796,6 +2100,16 @@ const flushCandidates = (s: Session): Candidate[] =>
1796
2100
  return order.slice(0, free).map((stored) => ({ protocol, stored }));
1797
2101
  });
1798
2102
 
2103
+ // An entry the record holds that a flush would stamp once attached, whether
2104
+ // or not this page made the send: inserted, or kept from an earlier mount or
2105
+ // another sharer. It dials a parked or idle transport as a send does.
2106
+ const queuedInRecord = (s: Session) =>
2107
+ Object.keys(s.record.lanes).some(
2108
+ (protocol) =>
2109
+ (s.selection ? negotiated(s, protocol) : offered(s, protocol)) &&
2110
+ bubble(s, protocol, laneQueue(s, protocol)).free > 0,
2111
+ );
2112
+
1799
2113
  const reasonOf = (
1800
2114
  s: Session,
1801
2115
  stored: StatewireClient.OutboxCommand,
@@ -1811,6 +2125,34 @@ const reasonOf = (
1811
2125
 
1812
2126
  const NO_IDS: ReadonlySet<string> = new Set();
1813
2127
 
2128
+ const patched = (
2129
+ stored: StatewireClient.OutboxCommand,
2130
+ patch: Patch,
2131
+ ): StatewireClient.OutboxCommand => {
2132
+ const {
2133
+ held: _held,
2134
+ released: _released,
2135
+ localMeta: _meta,
2136
+ ...rest
2137
+ } = stored;
2138
+ const held = patch.held ?? stored.held === true;
2139
+ const localMeta =
2140
+ patch.localMeta === undefined ? stored.localMeta : patch.localMeta.value;
2141
+ let next: StatewireClient.OutboxCommand = {
2142
+ ...rest,
2143
+ ...(held && { held: true as const }),
2144
+ ...(!held &&
2145
+ (stored.held || stored.released) && { released: true as const }),
2146
+ ...(localMeta !== undefined && { localMeta }),
2147
+ };
2148
+ if (patch.params !== undefined) {
2149
+ const { params, meta } = encodeParams(patch.params);
2150
+ const { meta: _old, ...plain } = next;
2151
+ next = { ...plain, params, ...(meta !== undefined && { meta }) };
2152
+ }
2153
+ return next;
2154
+ };
2155
+
1814
2156
  // Seqs are allocated here, under the lock, against the attach's watermark:
1815
2157
  // each outbox entry is stamped by exactly one sharer, in the bubbled order.
1816
2158
  const flushInner = (s: Session, demoted: ReadonlySet<string> = NO_IDS) => {
@@ -1826,12 +2168,12 @@ const flushInner = (s: Session, demoted: ReadonlySet<string> = NO_IDS) => {
1826
2168
  const reason = reasonOf(s, stored, demoted);
1827
2169
  groups.set(reason, [
1828
2170
  ...(groups.get(reason) ?? []),
1829
- viewOf(protocol, stored),
2171
+ viewOf(s, protocol, stored),
1830
2172
  ]);
1831
2173
  }
1832
2174
  const flushing: Flushing = {
1833
2175
  ids: new Set(candidates.map((c) => c.stored.id)),
1834
- held: new Map(),
2176
+ patches: new Map(),
1835
2177
  removed: new Set(),
1836
2178
  };
1837
2179
  s.flushing = flushing;
@@ -1859,14 +2201,14 @@ const flushInner = (s: Session, demoted: ReadonlySet<string> = NO_IDS) => {
1859
2201
  if (own) settleRemoved(s, own);
1860
2202
  continue;
1861
2203
  }
1862
- const hold = flushing.held.get(stored.id);
1863
- if (hold === undefined) {
2204
+ const patch = flushing.patches.get(stored.id);
2205
+ if (patch === undefined) {
1864
2206
  queue.push(stored);
1865
2207
  continue;
1866
2208
  }
1867
- const { released: _released, ...rest } = stored;
1868
- queue.push({ ...rest, hold });
1869
- if (own) setHold(s, own, hold);
2209
+ const next = patched(stored, patch);
2210
+ queue.push(next);
2211
+ if (own) follow(s, own, next);
1870
2212
  }
1871
2213
  const { order, free } = bubble(s, protocol, queue);
1872
2214
  let nextSeq = lane.nextSeq;
@@ -1885,7 +2227,7 @@ const flushInner = (s: Session, demoted: ReadonlySet<string> = NO_IDS) => {
1885
2227
  }
1886
2228
  const seq = Math.max(nextSeq, durableSeqOf(s, protocol) + 1);
1887
2229
  nextSeq = seq + 1;
1888
- const { hold: _hold, released: _released, ...command } = stored;
2230
+ const { held: _held, released: _released, ...command } = stored;
1889
2231
  const stamped: StatewireClient.SessionCommand = {
1890
2232
  ...command,
1891
2233
  seq,
@@ -1932,12 +2274,14 @@ export const outboxEntries = (
1932
2274
  s: Session,
1933
2275
  ): readonly StatewireClient.OutboxEntry[] => {
1934
2276
  const local = s.pending.filter(
1935
- (p) => isOutboxed(p) && isEphemeral(s, p.command.protocol),
2277
+ (p) => isEphemeral(s, p.command.protocol) && !p.away,
1936
2278
  );
1937
2279
  const parts: unknown[] = [
1938
- ...Object.values(s.record.lanes).map((lane) => lane.outbox),
1939
- ...local.map((p) => p.inv.hold),
1940
- ...local,
2280
+ ...Object.values(s.record.lanes).flatMap((lane) => [
2281
+ lane.commands,
2282
+ lane.outbox,
2283
+ ]),
2284
+ ...local.flatMap((p) => [p, p.seq, p.inv.held, p.inv.localMeta]),
1941
2285
  ];
1942
2286
  const same =
1943
2287
  parts.length === s.outboxParts.length &&
@@ -1945,130 +2289,342 @@ export const outboxEntries = (
1945
2289
  if (!same) {
1946
2290
  s.outboxParts = parts;
1947
2291
  s.outboxList = [
1948
- ...Object.entries(s.record.lanes).flatMap(([protocol, lane]) =>
1949
- outboxOf(lane).map((c) => viewOf(protocol, c)),
1950
- ),
1951
- ...local.map((p) => viewOf(p.command.protocol, toOutbox(p))),
2292
+ ...Object.entries(s.record.lanes).flatMap(([protocol, lane]) => [
2293
+ ...[...lane.commands]
2294
+ .sort((a, b) => a.seq - b.seq)
2295
+ .map((c) => viewOf(s, protocol, c, "sending")),
2296
+ ...outboxOf(lane).map((c) => viewOf(s, protocol, c)),
2297
+ ]),
2298
+ ...[...local]
2299
+ .sort((a, b) => (a.seq ?? Infinity) - (b.seq ?? Infinity))
2300
+ .map((p) =>
2301
+ isStamped(p)
2302
+ ? viewOf(s, p.command.protocol, toStored(s, p), "sending")
2303
+ : viewOf(s, p.command.protocol, toOutbox(p)),
2304
+ ),
1952
2305
  ];
1953
2306
  }
1954
2307
  return s.outboxList;
1955
2308
  };
1956
2309
 
1957
2310
  type OutboxOp =
1958
- | { type: "hold"; hold: NonNullable<Hold> }
1959
- | { type: "release" }
2311
+ | { type: "patch"; patch: (entry: StatewireClient.OutboxEntry) => Patch }
1960
2312
  | { type: "remove" };
1961
2313
 
1962
- type OutboxStatus = "held" | "released" | "removed" | "stamped";
2314
+ const entryOf = (s: Session, id: string) => {
2315
+ const at = locate(s, id);
2316
+ if (at === undefined) throw new Error(`outbox: no entry ${id}`);
2317
+ if (at.stored === undefined) throw new Error(`outbox: ${id} is in flight`);
2318
+ return { protocol: at.protocol, stored: at.stored };
2319
+ };
1963
2320
 
1964
- const applyOutboxOp = (
1965
- s: Session,
1966
- protocol: string,
1967
- id: string,
1968
- op: OutboxOp,
1969
- ): OutboxStatus => {
1970
- const done: OutboxStatus =
1971
- op.type === "hold"
1972
- ? "held"
1973
- : op.type === "release"
1974
- ? "released"
1975
- : "removed";
2321
+ const applyOutboxOp = (s: Session, id: string, op: OutboxOp) => {
2322
+ const at = locate(s, id);
2323
+ if (at?.stored === undefined) return;
2324
+ const { protocol, stored } = at;
1976
2325
  const own = s.pending.find((p) => p.id === id && isOutboxed(p));
1977
- const lane = laneOf(s.record, protocol);
1978
- const stored = outboxOf(lane).find((c) => c.id === id);
1979
- if (!stored) {
1980
- if (op.type === "remove" && lane.removed?.some((m) => m.id === id))
1981
- return "removed";
1982
- if (!own || !isEphemeral(s, protocol)) return "stamped";
1983
- } else if (op.type === "remove") {
1984
- setRecord(
1985
- s,
1986
- withLane(s.record, protocol, {
1987
- ...withOutbox(
1988
- lane,
1989
- outboxOf(lane).filter((c) => c.id !== id),
1990
- ),
1991
- ...marks(s, lane, [stored]),
1992
- }),
1993
- );
2326
+ if (op.type === "remove") {
2327
+ if (!isEphemeral(s, protocol)) {
2328
+ const lane = laneOf(s.record, protocol);
2329
+ setRecord(
2330
+ s,
2331
+ withLane(s.record, protocol, {
2332
+ ...withOutbox(
2333
+ lane,
2334
+ outboxOf(lane).filter((c) => c.id !== id),
2335
+ ),
2336
+ ...marks(s, lane, [stored]),
2337
+ }),
2338
+ );
2339
+ }
2340
+ if (own) settleRemoved(s, own);
1994
2341
  } else {
1995
- const { hold: _hold, released: _released, ...queued } = stored;
1996
- const next: StatewireClient.OutboxCommand =
1997
- op.type === "hold"
1998
- ? { ...queued, hold: op.hold }
1999
- : stored.hold === undefined && !stored.released
2000
- ? queued
2001
- : { ...queued, released: true };
2002
- setRecord(
2003
- s,
2004
- withLane(
2005
- s.record,
2006
- protocol,
2007
- withOutbox(
2008
- lane,
2009
- outboxOf(lane).map((c) => (c.id === id ? next : c)),
2342
+ const next = patched(stored, op.patch(viewOf(s, protocol, stored)));
2343
+ if (!isEphemeral(s, protocol)) {
2344
+ const lane = laneOf(s.record, protocol);
2345
+ setRecord(
2346
+ s,
2347
+ withLane(
2348
+ s.record,
2349
+ protocol,
2350
+ withOutbox(
2351
+ lane,
2352
+ outboxOf(lane).map((c) => (c.id === id ? next : c)),
2353
+ ),
2010
2354
  ),
2011
- ),
2012
- );
2013
- }
2014
- if (own) {
2015
- if (op.type === "remove") settleRemoved(s, own);
2016
- else setHold(s, own, op.type === "hold" ? op.hold : undefined);
2355
+ );
2356
+ }
2357
+ if (own) follow(s, own, next);
2017
2358
  }
2018
2359
  reorder(s, protocol);
2019
2360
  s.notify();
2020
- return done;
2021
2361
  };
2022
2362
 
2023
- const outboxOp = (
2024
- s: Session,
2025
- entry: StatewireClient.OutboxEntry,
2026
- op: OutboxOp,
2027
- ): Promise<OutboxStatus> => {
2363
+ const toPatch = (patch: StatewireClient.OutboxPatch): Patch => {
2364
+ if (typeof patch !== "object" || patch === null)
2365
+ throw new Error("outbox: update must return a patch object");
2366
+ if (patch.held !== undefined && typeof patch.held !== "boolean")
2367
+ throw new Error("outbox: held must be a boolean");
2368
+ if (
2369
+ patch.command !== undefined &&
2370
+ (Object.keys(patch.command).some((k) => k !== "params") ||
2371
+ !Array.isArray(patch.command.params))
2372
+ )
2373
+ throw new Error("outbox: a patch may only replace command.params");
2374
+ return {
2375
+ ...(patch.held !== undefined && { held: patch.held }),
2376
+ ...("localMeta" in patch && { localMeta: { value: patch.localMeta } }),
2377
+ ...(patch.command !== undefined && { params: patch.command.params }),
2378
+ };
2379
+ };
2380
+
2381
+ const outboxOp = (s: Session, id: string, op: OutboxOp): Promise<void> => {
2382
+ entryOf(s, id);
2028
2383
  const flushing = s.flushing;
2029
- if (flushing?.ids.has(entry.id)) {
2030
- if (op.type === "remove") flushing.removed.add(entry.id);
2031
- else if (op.type === "hold") flushing.held.set(entry.id, op.hold);
2032
- else flushing.held.delete(entry.id);
2033
- return Promise.resolve(
2034
- op.type === "hold"
2035
- ? "held"
2036
- : op.type === "release"
2037
- ? "released"
2038
- : "removed",
2039
- );
2384
+ if (flushing?.ids.has(id)) {
2385
+ if (op.type === "remove") flushing.removed.add(id);
2386
+ else {
2387
+ const at = entryOf(s, id);
2388
+ const patch = op.patch(viewOf(s, at.protocol, at.stored));
2389
+ flushing.patches.set(id, { ...flushing.patches.get(id), ...patch });
2390
+ }
2391
+ return Promise.resolve();
2040
2392
  }
2041
- let status: OutboxStatus = "stamped";
2393
+ const done = () => {
2394
+ wake(s);
2395
+ void pump(s);
2396
+ };
2397
+ if (!s.shared) {
2398
+ applyOutboxOp(s, id, op);
2399
+ done();
2400
+ return Promise.resolve();
2401
+ }
2402
+ return transact(s, () => applyOutboxOp(s, id, op)).then(done);
2403
+ };
2404
+
2405
+ const background = (promise: Promise<void>) => {
2406
+ promise.catch(() => {});
2407
+ };
2408
+
2409
+ export const holdOutbox = (s: Session, id: string) =>
2410
+ background(outboxOp(s, id, { type: "patch", patch: () => ({ held: true }) }));
2411
+
2412
+ export const releaseOutbox = (s: Session, id: string): Promise<unknown> => {
2413
+ entryOf(s, id);
2414
+ const result = new Promise<unknown>((resolve, reject) => {
2415
+ const waiters = s.releases.get(id) ?? [];
2416
+ waiters.push({ resolve, reject });
2417
+ s.releases.set(id, waiters);
2418
+ });
2419
+ result.catch(() => {});
2420
+ outboxOp(s, id, { type: "patch", patch: () => ({ held: false }) }).catch(
2421
+ (error: unknown) => settleWaiters(s, id, error),
2422
+ );
2423
+ return result;
2424
+ };
2425
+
2426
+ const settleWaiters = (s: Session, id: string, error: unknown) => {
2427
+ const waiters = s.releases.get(id) ?? [];
2428
+ s.releases.delete(id);
2429
+ for (const waiter of waiters) waiter.reject(error);
2430
+ };
2431
+
2432
+ export const removeOutbox = (s: Session, id: string) =>
2433
+ background(outboxOp(s, id, { type: "remove" }));
2434
+
2435
+ export const updateOutbox = (
2436
+ s: Session,
2437
+ id: string,
2438
+ fn: (entry: StatewireClient.OutboxEntry) => StatewireClient.OutboxPatch,
2439
+ ) =>
2440
+ background(
2441
+ outboxOp(s, id, { type: "patch", patch: (entry) => toPatch(fn(entry)) }),
2442
+ );
2443
+
2444
+ const producedOf = (
2445
+ s: Session,
2446
+ ): NonNullable<StatewireClient.SessionCommand["produced"]> => ({
2447
+ wire: s.options.wireVersion ?? { version: WIRE_VERSION },
2448
+ protocols:
2449
+ protocolOffers(s.options) === "*" && s.selection
2450
+ ? s.selection.protocols
2451
+ : protocolOffers(s.options),
2452
+ });
2453
+
2454
+ const placed = (
2455
+ queue: readonly StatewireClient.OutboxCommand[],
2456
+ entry: StatewireClient.OutboxCommand,
2457
+ { after, before }: { after?: string; before?: string },
2458
+ ) => {
2459
+ const anchor = after ?? before;
2460
+ if (anchor === undefined) return [...queue, entry];
2461
+ const index = queue.findIndex((c) => c.id === anchor);
2462
+ if (index === -1) return [...queue, entry];
2463
+ const at = after !== undefined ? index + 1 : index;
2464
+ return [...queue.slice(0, at), entry, ...queue.slice(at)];
2465
+ };
2466
+
2467
+ export const insertOutbox = (
2468
+ s: Session,
2469
+ entry: StatewireClient.OutboxInsert,
2470
+ ): string => {
2471
+ if (!s.hydrated) throw new Error("outbox: storage is not hydrated yet");
2472
+ if (s.storageError !== undefined) throw s.storageError;
2473
+ if (entry.after !== undefined && entry.before !== undefined)
2474
+ throw new Error("outbox: after and before are exclusive");
2475
+ for (const anchor of [entry.after, entry.before])
2476
+ if (anchor !== undefined) entryOf(s, anchor);
2477
+ const { protocol } = entry;
2478
+ if (!(s.selection ? negotiated(s, protocol) : offered(s, protocol)))
2479
+ throw new Error(`statewire: protocol "${protocol}" is not negotiated`);
2480
+ const { params, meta } = encodeParams(entry.command.params);
2481
+ const id = entry.from === undefined ? newClientId() : `${entry.from}~`;
2482
+ const stored: StatewireClient.OutboxCommand = {
2483
+ id,
2484
+ produced: producedOf(s),
2485
+ method: entry.command.method,
2486
+ params,
2487
+ ...(meta !== undefined && { meta }),
2488
+ ts: Date.now(),
2489
+ ...(entry.held && { held: true as const }),
2490
+ ...(entry.localMeta !== undefined && { localMeta: entry.localMeta }),
2491
+ ...(s.store.owner !== undefined && { by: s.store.owner.id }),
2492
+ };
2042
2493
  const run = () => {
2043
- status = applyOutboxOp(s, entry.protocol, entry.id, op);
2494
+ if (isEphemeral(s, protocol)) {
2495
+ enqueueInner(
2496
+ s,
2497
+ {
2498
+ protocol,
2499
+ produced: stored.produced!,
2500
+ method: stored.method,
2501
+ params,
2502
+ ...(meta !== undefined && { meta }),
2503
+ },
2504
+ entry.command.params,
2505
+ () => {},
2506
+ () => {},
2507
+ {
2508
+ ...(entry.held && { hold: true }),
2509
+ ...(entry.localMeta !== undefined && { localMeta: entry.localMeta }),
2510
+ },
2511
+ );
2512
+ return;
2513
+ }
2514
+ let base = s.record;
2515
+ if (entry.from !== undefined) {
2516
+ // Re-inserting a settled update consumes it in the same write: a
2517
+ // redelivery finds it gone.
2518
+ const source = Object.entries(base.lanes).find(([, l]) =>
2519
+ l.inbox?.some((n) => n.id === entry.from),
2520
+ );
2521
+ if (source === undefined || locate(s, id) !== undefined) return;
2522
+ base = withLane(base, source[0], withoutNotice(source[1], entry.from));
2523
+ }
2524
+ const lane = laneOf(base, protocol);
2525
+ try {
2526
+ setRecord(
2527
+ s,
2528
+ withLane(
2529
+ base,
2530
+ protocol,
2531
+ withOutbox(lane, placed(outboxOf(lane), stored, entry)),
2532
+ ),
2533
+ );
2534
+ } catch (error) {
2535
+ if (!isQuota(error)) throw error;
2536
+ // The update stays unacked for the next mount.
2537
+ if (entry.from !== undefined) s.skipped.add(entry.from);
2538
+ settleReleases(s, id, { ok: false, failure: QUOTA });
2539
+ return;
2540
+ }
2541
+ s.mountIds.delete(id);
2542
+ reorder(s, protocol);
2543
+ s.notify();
2044
2544
  };
2045
2545
  const done = () => {
2046
2546
  wake(s);
2047
2547
  void pump(s);
2048
- return status;
2049
2548
  };
2050
2549
  if (!s.shared) {
2051
2550
  run();
2052
- return Promise.resolve(done());
2551
+ done();
2552
+ } else background(transact(s, run).then(done));
2553
+ return id;
2554
+ };
2555
+
2556
+ // One sharer at a time runs the handler; the update leaves the record only
2557
+ // once it resolved, so a crash mid-handler redelivers it.
2558
+ const nextNotice = (s: Session) => {
2559
+ for (const [protocol, lane] of Object.entries(s.record.lanes)) {
2560
+ const found = (lane.inbox ?? []).find((n) => !s.skipped.has(n.id));
2561
+ if (found) return { protocol, found };
2053
2562
  }
2054
- return transact(s, run).then(done);
2563
+ return undefined;
2055
2564
  };
2056
2565
 
2057
- export const holdOutbox = (
2058
- s: Session,
2059
- entry: StatewireClient.OutboxEntry,
2060
- meta?: unknown,
2061
- ) =>
2062
- outboxOp(s, entry, {
2063
- type: "hold",
2064
- hold: meta === undefined ? {} : { meta },
2065
- }) as Promise<"held" | "stamped">;
2566
+ const withoutNotice = (
2567
+ lane: StatewireClient.SessionLane,
2568
+ id: string,
2569
+ ): StatewireClient.SessionLane => {
2570
+ const inbox = (lane.inbox ?? []).filter((n) => n.id !== id);
2571
+ const { inbox: _inbox, ...rest } = lane;
2572
+ return inbox.length > 0 ? { ...rest, inbox } : rest;
2573
+ };
2066
2574
 
2067
- export const releaseOutbox = (s: Session, entry: StatewireClient.OutboxEntry) =>
2068
- outboxOp(s, entry, { type: "release" }) as Promise<"released" | "stamped">;
2575
+ const ack = (s: Session, protocol: string, id: string) => {
2576
+ const lane = laneOf(s.record, protocol);
2577
+ if (s.skipped.has(id) || !lane.inbox?.some((n) => n.id === id)) return;
2578
+ setRecord(s, withLane(s.record, protocol, withoutNotice(lane, id)));
2579
+ };
2069
2580
 
2070
- export const removeOutbox = (s: Session, entry: StatewireClient.OutboxEntry) =>
2071
- outboxOp(s, entry, { type: "remove" }) as Promise<"removed" | "stamped">;
2581
+ const deliver = (s: Session) => {
2582
+ const handler = s.onSettled;
2583
+ // Handlers get ctx.state: delivery waits for the first hello.
2584
+ if (
2585
+ handler === undefined ||
2586
+ s.delivering ||
2587
+ !s.running ||
2588
+ !s.hydrated ||
2589
+ !s.sessionKnown
2590
+ )
2591
+ return;
2592
+ if (nextNotice(s) === undefined) return;
2593
+ s.delivering = true;
2594
+ const run = async () => {
2595
+ for (;;) {
2596
+ if (s.shared) await transact(s, () => {});
2597
+ else await null;
2598
+ const next = nextNotice(s);
2599
+ if (next === undefined || !s.running) return;
2600
+ const { protocol, found } = next;
2601
+ try {
2602
+ await handler(updateOf(protocol, found));
2603
+ } catch (error) {
2604
+ console.error(
2605
+ "statewire: onCommandChange threw on a settled update; it stays for the next mount",
2606
+ error,
2607
+ );
2608
+ s.skipped.add(found.id);
2609
+ continue;
2610
+ }
2611
+ if (s.shared) await transact(s, () => ack(s, protocol, found.id));
2612
+ else ack(s, protocol, found.id);
2613
+ s.notify();
2614
+ }
2615
+ };
2616
+ const locked = s.store.notifyLock ? s.store.notifyLock(run) : run();
2617
+ locked.then(
2618
+ () => {
2619
+ s.delivering = false;
2620
+ deliver(s);
2621
+ },
2622
+ (error: unknown) => {
2623
+ s.delivering = false;
2624
+ console.error("statewire: settled update delivery stopped", error);
2625
+ },
2626
+ );
2627
+ };
2072
2628
 
2073
2629
  const enqueue = (
2074
2630
  s: Session,
@@ -2076,10 +2632,10 @@ const enqueue = (
2076
2632
  params: readonly unknown[],
2077
2633
  resolve: (payload: unknown) => void,
2078
2634
  reject: (error: unknown) => void,
2079
- hold?: Hold,
2635
+ options?: EnqueueOptions,
2080
2636
  ) => {
2081
2637
  if (!s.shared) {
2082
- enqueueInner(s, command, params, resolve, reject, hold);
2638
+ enqueueInner(s, command, params, resolve, reject, options);
2083
2639
  wake(s);
2084
2640
  void pump(s);
2085
2641
  return;
@@ -2091,7 +2647,7 @@ const enqueue = (
2091
2647
  reject(new StatewireSendError({ fate: "aborted", cause: "stream-gone" }));
2092
2648
  return;
2093
2649
  }
2094
- enqueueInner(s, command, params, resolve, reject, hold);
2650
+ enqueueInner(s, command, params, resolve, reject, options);
2095
2651
  }).then(() => {
2096
2652
  wake(s);
2097
2653
  void pump(s);
@@ -2115,6 +2671,8 @@ const sendCommand = (
2115
2671
  : offered(s, protocol);
2116
2672
  if (!negotiated)
2117
2673
  throw new Error(`statewire: protocol "${protocol}" is not negotiated`);
2674
+ if (options?.hold !== undefined && typeof options.hold !== "boolean")
2675
+ throw new Error("statewire: invoke hold must be a boolean");
2118
2676
  if (!s.hydrated) {
2119
2677
  const result = s.ready!.then(() =>
2120
2678
  sendCommand(s, method, params, protocol, options),
@@ -2139,13 +2697,7 @@ const sendCommand = (
2139
2697
  s,
2140
2698
  {
2141
2699
  protocol,
2142
- produced: {
2143
- wire: s.options.wireVersion ?? { version: WIRE_VERSION },
2144
- protocols:
2145
- protocolOffers(s.options) === "*" && s.selection
2146
- ? s.selection.protocols
2147
- : protocolOffers(s.options),
2148
- },
2700
+ produced: producedOf(s),
2149
2701
  method,
2150
2702
  params: encoded,
2151
2703
  ...(meta !== undefined && { meta }),
@@ -2153,120 +2705,18 @@ const sendCommand = (
2153
2705
  params,
2154
2706
  resolve,
2155
2707
  reject,
2156
- options?.hold,
2708
+ {
2709
+ ...(options?.hold && { hold: true }),
2710
+ ...(options?.localMeta !== undefined && {
2711
+ localMeta: options.localMeta,
2712
+ }),
2713
+ },
2157
2714
  );
2158
2715
  });
2159
2716
  result.catch(() => {});
2160
2717
  return result;
2161
2718
  };
2162
2719
 
2163
- const findFailed = (
2164
- s: Session,
2165
- entry: StatewireClient.FailedCommand,
2166
- ): StatewireClient.FailedCommand | undefined =>
2167
- (laneOf(s.record, entry.protocol).failed ?? []).find(
2168
- (f) =>
2169
- f.seq === entry.seq && f.ts === entry.ts && f.command === entry.command,
2170
- );
2171
-
2172
- const unknownFailed = (entry: StatewireClient.FailedCommand) =>
2173
- new Error(`statewire: unknown failed command "${entry.command}"`);
2174
-
2175
- const takeFailed = (
2176
- s: Session,
2177
- entry: StatewireClient.FailedCommand,
2178
- ): StatewireClient.FailedCommand => {
2179
- const lane = laneOf(s.record, entry.protocol);
2180
- const found = findFailed(s, entry);
2181
- if (found === undefined) throw unknownFailed(entry);
2182
- setRecord(
2183
- s,
2184
- withLane(s.record, entry.protocol, {
2185
- ...lane,
2186
- failed: (lane.failed ?? []).filter((f) => f !== found),
2187
- }),
2188
- );
2189
- s.notify();
2190
- return found;
2191
- };
2192
-
2193
- export const discardFailed = (
2194
- s: Session,
2195
- entry: StatewireClient.FailedCommand,
2196
- ): void => {
2197
- if (!s.shared) {
2198
- takeFailed(s, entry);
2199
- return;
2200
- }
2201
- if (findFailed(s, entry) === undefined) throw unknownFailed(entry);
2202
- transactBackground(s, () => {
2203
- if (findFailed(s, entry) !== undefined) takeFailed(s, entry);
2204
- });
2205
- };
2206
-
2207
- export const retryFailed = (
2208
- s: Session,
2209
- entry: StatewireClient.FailedCommand,
2210
- ): Promise<unknown> => {
2211
- if (!s.running) {
2212
- return rejectedSend({ fate: "aborted", cause: "unmount" });
2213
- }
2214
- if (isFinished(s)) {
2215
- return rejectedSend({ fate: "aborted", cause: "stream-gone" });
2216
- }
2217
- const found = findFailed(s, entry);
2218
- if (found === undefined) throw unknownFailed(entry);
2219
- const params =
2220
- found.meta === undefined
2221
- ? found.params
2222
- : (reviveValue(structuredClone(found.params), found.meta) as unknown[]);
2223
- const result = new Promise<unknown>((resolve, reject) => {
2224
- const retry = () => {
2225
- if (!s.running) {
2226
- reject(new StatewireSendError({ fate: "aborted", cause: "unmount" }));
2227
- return;
2228
- }
2229
- if (isFinished(s)) {
2230
- reject(
2231
- new StatewireSendError({ fate: "aborted", cause: "stream-gone" }),
2232
- );
2233
- return;
2234
- }
2235
- const current = findFailed(s, entry);
2236
- if (current === undefined) {
2237
- reject(unknownFailed(entry));
2238
- return;
2239
- }
2240
- takeFailed(s, current);
2241
- enqueueInner(
2242
- s,
2243
- {
2244
- protocol: current.protocol,
2245
- ...(current.produced !== undefined && { produced: current.produced }),
2246
- method: current.command,
2247
- params: [...current.params],
2248
- ...(current.meta !== undefined && { meta: current.meta }),
2249
- },
2250
- params,
2251
- resolve,
2252
- reject,
2253
- );
2254
- };
2255
- if (!s.shared) {
2256
- retry();
2257
- wake(s);
2258
- void pump(s);
2259
- return;
2260
- }
2261
- void transact(s, retry).then(() => {
2262
- wake(s);
2263
- void pump(s);
2264
- }, reject);
2265
- });
2266
- result.catch(() => {});
2267
- return result;
2268
- };
2269
-
2270
2720
  export const documentSource = (s: Session): StatewireDocuments.Source => ({
2271
2721
  get: (protocol, key, prefix = s.options.keyPrefixes?.[protocol]) => {
2272
2722
  const mounted = findDocument(s.documents, protocol, key, prefix);
@@ -2373,7 +2823,7 @@ const loadSessionRecord = (
2373
2823
  const record =
2374
2824
  stored === null
2375
2825
  ? { clientId: newClientId(), lanes: {} }
2376
- : boundRecord(stored);
2826
+ : boundInbox(store, stored);
2377
2827
  if (record !== stored) store.save(record);
2378
2828
  return record;
2379
2829
  };
@@ -2417,10 +2867,11 @@ export const useStatewireSession = (
2417
2867
  const store = options.sessionStore ?? mounted ?? defaultStore;
2418
2868
  if (
2419
2869
  (store.lock === undefined) !== (store.subscribe === undefined) ||
2420
- (store.lock === undefined) !== (store.owner === undefined)
2870
+ (store.lock === undefined) !== (store.owner === undefined) ||
2871
+ (store.lock === undefined) !== (store.notifyLock === undefined)
2421
2872
  ) {
2422
2873
  throw new Error(
2423
- "statewire: session storage must provide lock, subscribe and owner together",
2874
+ "statewire: session storage must provide lock, subscribe, owner and notifyLock together",
2424
2875
  );
2425
2876
  }
2426
2877
  const [s] = useState((): Session => {
@@ -2468,6 +2919,9 @@ export const useStatewireSession = (
2468
2919
  txq: Promise.resolve(),
2469
2920
  syncQueued: false,
2470
2921
  parked: [],
2922
+ settled: new Set(),
2923
+ notices: [],
2924
+ inTx: false,
2471
2925
  unsubscribe: null,
2472
2926
  watched: new Set(),
2473
2927
  abort: null,
@@ -2481,6 +2935,12 @@ export const useStatewireSession = (
2481
2935
  resumeAt: 0,
2482
2936
  idle: false,
2483
2937
  onSessionLost: undefined,
2938
+ onDeprecatedPath: undefined,
2939
+ onSettled: undefined,
2940
+ releases: new Map(),
2941
+ delivering: false,
2942
+ skipped: new Set(),
2943
+ views: new WeakMap(),
2484
2944
  onFlush: undefined,
2485
2945
  onDone: undefined,
2486
2946
  mountIds: storedIds(record),
@@ -2490,9 +2950,9 @@ export const useStatewireSession = (
2490
2950
  lanesOnAttach: false,
2491
2951
  attachEntries: new Set(),
2492
2952
  frameFailures: 0,
2953
+ recheck: undefined,
2954
+ rechecks: { watermarks: "", count: 0 },
2493
2955
  pending: [],
2494
- failedList: [],
2495
- failedParts: [],
2496
2956
  canonical: undefined,
2497
2957
  documentListeners: new Set(),
2498
2958
  documents: { mode: null, documents: new Map() },
@@ -2559,6 +3019,8 @@ export const useStatewireSession = (
2559
3019
  s.store = store;
2560
3020
  s.documentStore = documentStore;
2561
3021
  s.onSessionLost = options.onSessionLost;
3022
+ s.onDeprecatedPath = options.onDeprecatedPath;
3023
+ s.onSettled = options.onSettled;
2562
3024
  s.onFlush = options.onFlush;
2563
3025
  s.onDone = options.onDone;
2564
3026
  const keyPrefixes = JSON.stringify(options.keyPrefixes);