statewire 0.18.1 → 0.19.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 (86) hide show
  1. package/dist/client/connection.d.ts +8 -5
  2. package/dist/client/connection.d.ts.map +1 -1
  3. package/dist/client/connection.js +24 -13
  4. package/dist/client/connection.js.map +1 -1
  5. package/dist/client/session.d.ts +13 -15
  6. package/dist/client/session.d.ts.map +1 -1
  7. package/dist/client/session.js +296 -401
  8. package/dist/client/session.js.map +1 -1
  9. package/dist/client/timing.d.ts +2 -1
  10. package/dist/client/timing.d.ts.map +1 -1
  11. package/dist/client/timing.js +2 -1
  12. package/dist/client/timing.js.map +1 -1
  13. package/dist/host/engine.d.ts +2 -0
  14. package/dist/host/engine.d.ts.map +1 -1
  15. package/dist/host/engine.js +2 -0
  16. package/dist/host/engine.js.map +1 -1
  17. package/dist/host/host.d.ts +3 -1
  18. package/dist/host/host.d.ts.map +1 -1
  19. package/dist/host/host.js +38 -7
  20. package/dist/host/host.js.map +1 -1
  21. package/dist/host/http.d.ts.map +1 -1
  22. package/dist/host/http.js +9 -19
  23. package/dist/host/http.js.map +1 -1
  24. package/dist/host/socket.d.ts +48 -25
  25. package/dist/host/socket.d.ts.map +1 -1
  26. package/dist/host/socket.js +399 -266
  27. package/dist/host/socket.js.map +1 -1
  28. package/dist/host/synced.d.ts +9 -0
  29. package/dist/host/synced.d.ts.map +1 -0
  30. package/dist/host/synced.js +45 -0
  31. package/dist/host/synced.js.map +1 -0
  32. package/dist/host-internal.d.ts +20 -12
  33. package/dist/host-internal.d.ts.map +1 -1
  34. package/dist/host.d.ts +33 -9
  35. package/dist/host.d.ts.map +1 -1
  36. package/dist/host.js +2 -1
  37. package/dist/index.d.ts +2 -2
  38. package/dist/index.js +2 -1
  39. package/dist/statewire.d.ts +8 -20
  40. package/dist/statewire.d.ts.map +1 -1
  41. package/dist/statewire.js +0 -14
  42. package/dist/statewire.js.map +1 -1
  43. package/dist/storage.d.ts +2 -5
  44. package/dist/storage.d.ts.map +1 -1
  45. package/dist/storage.js +54 -89
  46. package/dist/storage.js.map +1 -1
  47. package/dist/transports/remote.d.ts.map +1 -1
  48. package/dist/transports/remote.js +1 -6
  49. package/dist/transports/remote.js.map +1 -1
  50. package/dist/transports/sse.d.ts +2 -5
  51. package/dist/transports/sse.d.ts.map +1 -1
  52. package/dist/transports/sse.js.map +1 -1
  53. package/dist/transports/testing.d.ts +7 -5
  54. package/dist/transports/testing.d.ts.map +1 -1
  55. package/dist/transports/testing.js +19 -18
  56. package/dist/transports/testing.js.map +1 -1
  57. package/dist/types.d.ts +3 -5
  58. package/dist/types.d.ts.map +1 -1
  59. package/dist/types.js.map +1 -1
  60. package/dist/wire/limits.d.ts +2 -2
  61. package/dist/wire/limits.js +2 -2
  62. package/dist/wire/limits.js.map +1 -1
  63. package/dist/wire/protocol.d.ts +6 -1
  64. package/dist/wire/protocol.d.ts.map +1 -1
  65. package/dist/wire/protocol.js +33 -12
  66. package/dist/wire/protocol.js.map +1 -1
  67. package/package.json +1 -1
  68. package/src/client/connection.ts +46 -33
  69. package/src/client/session.ts +442 -517
  70. package/src/client/timing.ts +2 -0
  71. package/src/host/engine.ts +3 -1
  72. package/src/host/host.ts +50 -3
  73. package/src/host/http.ts +23 -31
  74. package/src/host/socket.ts +560 -339
  75. package/src/host/synced.ts +60 -0
  76. package/src/host-internal.ts +21 -7
  77. package/src/host.ts +40 -8
  78. package/src/index.ts +1 -0
  79. package/src/statewire.ts +8 -37
  80. package/src/storage.ts +90 -157
  81. package/src/transports/remote.ts +0 -7
  82. package/src/transports/sse.ts +8 -3
  83. package/src/transports/testing.ts +34 -18
  84. package/src/types.ts +3 -5
  85. package/src/wire/limits.ts +1 -1
  86. package/src/wire/protocol.ts +40 -11
@@ -1,4 +1,3 @@
1
- import { encodeDocument } from "../document";
2
1
  import { wrapStorageError } from "./storageError";
3
2
  import {
4
3
  DEFAULT_PROTOCOL,
@@ -30,7 +29,7 @@ import {
30
29
  SessionStorageContext,
31
30
  createMemorySessionStore,
32
31
  } from "./sessionStore";
33
- import { backoff } from "./timing";
32
+ import { backoff, retryHint } from "./timing";
34
33
  import { createMethodProxy } from "../util/methodProxy";
35
34
  import { randomId as newClientId } from "../util/randomId";
36
35
  import { StatewireSendError } from "../types";
@@ -62,12 +61,6 @@ type Pending = {
62
61
  reject: (error: unknown) => void;
63
62
  };
64
63
 
65
- type CtxPending = {
66
- readonly seq: number;
67
- readonly ops: unknown[];
68
- posted: boolean;
69
- };
70
-
71
64
  const protocolError = (message: string): never => {
72
65
  throw new Error(`statewire: protocol error: ${message}`);
73
66
  };
@@ -90,14 +83,13 @@ export type StatewireSessionOptions = StatewireProtocol.Options & {
90
83
 
91
84
  type Shared = {
92
85
  txq: Promise<void>;
93
- parked: StatewireClient.SessionVerdict[];
86
+ parked: { protocol: string; verdict: StatewireClient.SessionVerdict }[];
94
87
  unsubscribe: (() => void) | null;
95
88
  };
96
89
 
97
90
  type Session = {
98
91
  options: StatewireSessionOptions;
99
92
  selection: StatewireProtocol.Selection | undefined;
100
- contextDocuments: StatewireDocuments.State;
101
93
  record: StatewireClient.SessionRecord;
102
94
  ctx: StatewireRemoteProps;
103
95
  recordPersisted: boolean;
@@ -108,21 +100,24 @@ type Session = {
108
100
  store: StatewireClient.SessionStorage;
109
101
  documentStore: StatewireDocuments.Storage | undefined;
110
102
  shared: Shared | false;
103
+ /** Every selected lane is ephemeral: the record dies with the attach. */
111
104
  ephemeral: boolean;
112
105
  /** An ephemeral record died with its attach: the storage is off for good. */
113
106
  deactivated: boolean;
114
107
  sessionKnown: boolean;
115
- durableSeq: number;
108
+ durableSeq: Record<string, number>;
109
+ /** Ephemeral lane clocks: restart at 1 on every attach. */
110
+ ephemeralSeqs: Record<string, number>;
116
111
  resumeAt: number;
117
112
  idle: boolean;
118
113
  onSessionLost: StatewireClient.OnSessionLost | undefined;
119
114
  onDone: (() => void) | undefined;
120
- ctxOps: unknown[] | undefined;
121
- ctxPending: CtxPending[];
122
115
  lanesOnAttach: boolean;
123
- attachLaneSeqs: readonly [ReadonlySet<number>, ReadonlySet<number>];
116
+ attachEntries: ReadonlySet<Pending>;
124
117
  frameFailures: number;
125
118
  pending: Pending[];
119
+ failedList: readonly StatewireClient.FailedCommand[];
120
+ failedParts: readonly (readonly StatewireClient.FailedCommand[])[];
126
121
  canonical: unknown;
127
122
  documentListeners: Set<() => void>;
128
123
  documents: StatewireDocuments.State;
@@ -140,6 +135,33 @@ type Session = {
140
135
  commands: Record<string, (...params: unknown[]) => Promise<unknown>>;
141
136
  };
142
137
 
138
+ const EMPTY_LANE: StatewireClient.SessionLane = { nextSeq: 1, commands: [] };
139
+
140
+ const laneOf = (
141
+ record: StatewireClient.SessionRecord,
142
+ protocol: string,
143
+ ): StatewireClient.SessionLane => record.lanes[protocol] ?? EMPTY_LANE;
144
+
145
+ const withLane = (
146
+ record: StatewireClient.SessionRecord,
147
+ protocol: string,
148
+ lane: StatewireClient.SessionLane,
149
+ ): StatewireClient.SessionRecord => ({
150
+ ...record,
151
+ lanes: { ...record.lanes, [protocol]: lane },
152
+ });
153
+
154
+ const isEphemeral = (s: Session, protocol: string) =>
155
+ s.selection?.protocols.some(
156
+ (p) => p.name === protocol && p.record === "ephemeral",
157
+ ) ?? false;
158
+
159
+ const durableSeqOf = (s: Session, protocol: string) =>
160
+ s.durableSeq[protocol] ?? 0;
161
+
162
+ const laneEntries = (s: Session, protocol: string) =>
163
+ s.pending.filter((p) => p.command.protocol === protocol);
164
+
143
165
  const publish = (s: Session) => {
144
166
  if (s.canonical === s.lastPublished) return;
145
167
  s.lastPublished = s.canonical;
@@ -186,43 +208,76 @@ const setRecord = (s: Session, record: StatewireClient.SessionRecord) => {
186
208
  }
187
209
  };
188
210
 
189
- const backlog = (s: Session): StatewireClient.SessionCommand[] =>
190
- s.pending
191
- .filter((p) => p.seq > s.durableSeq)
192
- .map((p) => ({ seq: p.seq, ...p.command }));
211
+ const toStored = (p: Pending): StatewireClient.SessionCommand => {
212
+ const { protocol: _protocol, ...command } = p.command;
213
+ return { seq: p.seq, ...command };
214
+ };
215
+
216
+ const backlog = (
217
+ s: Session,
218
+ protocol: string,
219
+ ): StatewireClient.SessionCommand[] =>
220
+ laneEntries(s, protocol)
221
+ .filter((p) => p.seq > durableSeqOf(s, protocol))
222
+ .map(toStored);
193
223
 
194
- const mergeBacklog = (s: Session): StatewireClient.SessionCommand[] => {
195
- const own = backlog(s);
224
+ const mergeBacklog = (
225
+ s: Session,
226
+ protocol: string,
227
+ ): StatewireClient.SessionCommand[] => {
228
+ const own = backlog(s, protocol);
196
229
  if (!s.shared) return own;
197
- const known = new Set(s.pending.map((p) => p.seq));
198
- const foreign = s.record.commands.filter(
199
- (c) => !known.has(c.seq) && c.seq > s.durableSeq,
230
+ const known = new Set(laneEntries(s, protocol).map((p) => p.seq));
231
+ const foreign = laneOf(s.record, protocol).commands.filter(
232
+ (c) => !known.has(c.seq) && c.seq > durableSeqOf(s, protocol),
200
233
  );
201
234
  return [...foreign, ...own].sort((a, b) => a.seq - b.seq);
202
235
  };
203
236
 
237
+ const durableLanes = (s: Session): string[] => [
238
+ ...new Set([
239
+ ...Object.keys(s.record.lanes),
240
+ ...s.pending
241
+ .map((p) => p.command.protocol)
242
+ .filter((protocol) => !isEphemeral(s, protocol)),
243
+ ]),
244
+ ];
245
+
204
246
  const MAX_PARKED_VERDICTS = 64;
205
247
 
206
248
  // Storage holds what a reload must resend: a command leaves at its dur, at a
207
249
  // server verdict, or at a record reset — never at a local channel death.
208
250
  const syncBacklogInner = (s: Session) => {
209
251
  const parked = s.shared ? s.shared.parked.splice(0) : [];
210
- const parkedSeqs = new Set(parked.map((v) => v.seq));
211
- const commands = mergeBacklog(s).filter((c) => !parkedSeqs.has(c.seq));
212
- const verdicts =
213
- parked.length > 0
214
- ? [...(s.record.verdicts ?? []), ...parked].slice(-MAX_PARKED_VERDICTS)
215
- : s.record.verdicts;
216
- const unchanged =
217
- verdicts === s.record.verdicts &&
218
- commands.length === s.record.commands.length &&
219
- commands.every((c, i) => c.seq === s.record.commands[i]!.seq);
220
- if (unchanged) return;
221
- setRecord(s, {
222
- ...s.record,
223
- commands,
224
- ...(verdicts !== undefined && { verdicts }),
225
- });
252
+ let lanes = s.record.lanes;
253
+ for (const protocol of durableLanes(s)) {
254
+ const lane = laneOf(s.record, protocol);
255
+ const parkedHere = parked
256
+ .filter((v) => v.protocol === protocol)
257
+ .map((v) => v.verdict);
258
+ const parkedSeqs = new Set(parkedHere.map((v) => v.seq));
259
+ const commands = mergeBacklog(s, protocol).filter(
260
+ (c) => !parkedSeqs.has(c.seq),
261
+ );
262
+ const verdicts =
263
+ parkedHere.length > 0
264
+ ? [...(lane.verdicts ?? []), ...parkedHere].slice(-MAX_PARKED_VERDICTS)
265
+ : lane.verdicts;
266
+ const unchanged =
267
+ verdicts === lane.verdicts &&
268
+ commands.length === lane.commands.length &&
269
+ commands.every((c, i) => c.seq === lane.commands[i]!.seq);
270
+ if (unchanged) continue;
271
+ lanes = {
272
+ ...lanes,
273
+ [protocol]: {
274
+ ...lane,
275
+ commands,
276
+ ...(verdicts !== undefined && { verdicts }),
277
+ },
278
+ };
279
+ }
280
+ if (lanes !== s.record.lanes) setRecord(s, { ...s.record, lanes });
226
281
  };
227
282
 
228
283
  const syncBacklog = (s: Session) => {
@@ -244,20 +299,6 @@ const transact = (s: Session, fn: () => void): Promise<void> => {
244
299
  adoptReset(s, stored);
245
300
  } else if (stored !== null) {
246
301
  s.record = stored;
247
- if (stored.context !== undefined) {
248
- const posted = new Set(
249
- s.ctxPending.filter((p) => p.posted).map((p) => p.seq),
250
- );
251
- s.ctxOps = stored.context.ops;
252
- s.contextDocuments = applyDocumentOps(
253
- { mode: "mounted", documents: new Map() },
254
- stored.context.ops,
255
- );
256
- s.ctxPending = stored.context.pending.map((p) => ({
257
- ...p,
258
- posted: posted.has(p.seq),
259
- }));
260
- }
261
302
  }
262
303
  fn();
263
304
  const persistence = s.store.flush?.();
@@ -293,35 +334,49 @@ const cover = (s: Session, entry: Pending) => {
293
334
  s.lifecycle?.onCovered?.(entry.inv);
294
335
  };
295
336
 
296
- const NO_FAILED: readonly StatewireClient.FailedCommand[] = [];
297
-
298
337
  export const failedCommands = (
299
338
  s: Session,
300
- ): readonly StatewireClient.FailedCommand[] => s.record.failed ?? NO_FAILED;
339
+ ): readonly StatewireClient.FailedCommand[] => {
340
+ const parts = Object.values(s.record.lanes).flatMap((lane) =>
341
+ lane.failed === undefined ? [] : [lane.failed],
342
+ );
343
+ const same =
344
+ parts.length === s.failedParts.length &&
345
+ parts.every((part, i) => part === s.failedParts[i]);
346
+ if (!same) {
347
+ s.failedParts = parts;
348
+ s.failedList = parts.flat();
349
+ }
350
+ return s.failedList;
351
+ };
301
352
 
302
353
  const retainFailedInner = (
303
354
  s: Session,
304
355
  entry: Pending,
305
356
  failure: StatewireClient.SendFailure,
306
357
  ) => {
307
- setRecord(s, {
308
- ...s.record,
309
- failed: [
310
- ...(s.record.failed ?? []),
311
- {
312
- protocol: entry.command.protocol,
313
- ...(entry.command.produced !== undefined && {
314
- produced: entry.command.produced,
315
- }),
316
- command: entry.command.method,
317
- params: entry.command.params,
318
- ...(entry.command.meta !== undefined && { meta: entry.command.meta }),
319
- failure,
320
- ts: entry.inv.ts,
321
- seq: entry.seq,
322
- },
323
- ],
324
- });
358
+ const lane = laneOf(s.record, entry.command.protocol);
359
+ setRecord(
360
+ s,
361
+ withLane(s.record, entry.command.protocol, {
362
+ ...lane,
363
+ failed: [
364
+ ...(lane.failed ?? []),
365
+ {
366
+ protocol: entry.command.protocol,
367
+ ...(entry.command.produced !== undefined && {
368
+ produced: entry.command.produced,
369
+ }),
370
+ command: entry.command.method,
371
+ params: entry.command.params,
372
+ ...(entry.command.meta !== undefined && { meta: entry.command.meta }),
373
+ failure,
374
+ ts: entry.inv.ts,
375
+ seq: entry.seq,
376
+ },
377
+ ],
378
+ }),
379
+ );
325
380
  s.notify();
326
381
  };
327
382
 
@@ -357,8 +412,9 @@ const settleEntry = (
357
412
  const rides =
358
413
  (outcome.failure.cause === "unmount" ||
359
414
  outcome.failure.cause === "stream-gone") &&
360
- entry.seq > s.durableSeq;
361
- if (!rides) retainFailed(s, entry, outcome.failure);
415
+ entry.seq > durableSeqOf(s, entry.command.protocol);
416
+ if (!rides && !isEphemeral(s, entry.command.protocol))
417
+ retainFailed(s, entry, outcome.failure);
362
418
  entry.reject(new StatewireSendError(outcome.failure));
363
419
  s.lifecycle?.onSettle(entry.inv, {
364
420
  type: "rejected",
@@ -374,7 +430,10 @@ const settleAll = (s: Session, detach: "unmount" | "gone") => {
374
430
  const detachFailure = (entry: Pending): StatewireClient.SendFailure => {
375
431
  if (!entry.attempted) {
376
432
  const predecessorLost = batch.some(
377
- (p) => p.seq < entry.seq && p.attempted,
433
+ (p) =>
434
+ p.command.protocol === entry.command.protocol &&
435
+ p.seq < entry.seq &&
436
+ p.attempted,
378
437
  );
379
438
  return predecessorLost
380
439
  ? { fate: "aborted", cause, predecessorLost }
@@ -388,14 +447,6 @@ const settleAll = (s: Session, detach: "unmount" | "gone") => {
388
447
  }
389
448
  };
390
449
 
391
- const restateContext = (s: Session) => {
392
- s.ctxPending = [];
393
- if (s.ctxOps === undefined) return;
394
- const seq = s.record.ctxNextSeq;
395
- setRecord(s, { ...s.record, ctxNextSeq: seq + 1 });
396
- s.ctxPending.push({ seq, ops: s.ctxOps, posted: false });
397
- };
398
-
399
450
  const settleLost = (s: Session, entries: Pending[]) => {
400
451
  for (const entry of entries) {
401
452
  s.lifecycle?.onCovered?.(entry.inv);
@@ -406,6 +457,13 @@ const settleLost = (s: Session, entries: Pending[]) => {
406
457
  }
407
458
  };
408
459
 
460
+ const requeue = (s: Session, survivors: Pending[]) => {
461
+ for (const old of survivors) {
462
+ s.lifecycle?.onCovered?.(old.inv);
463
+ enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
464
+ }
465
+ };
466
+
409
467
  // The server-side record's dedup memory is gone: history that may have
410
468
  // reached a server settles lost — the new hello's snapshot is the only
411
469
  // truth about what survived — and never-attempted statements re-stamp
@@ -418,30 +476,33 @@ const rebaseRecord = (s: Session, adopt: () => void) => {
418
476
  held.filter((p) => p.attempted || p.applied),
419
477
  );
420
478
  s.pending = [];
421
- s.durableSeq = 0;
479
+ s.durableSeq = {};
422
480
  // Failed entries are user-owned history: they ride through every record replacement.
423
- const failed = s.record.failed;
481
+ const previous = s.record.lanes;
424
482
  adopt();
425
- if (failed !== undefined && s.record.failed !== failed) {
426
- setRecord(s, {
427
- ...s.record,
428
- failed: [...(s.record.failed ?? []), ...failed],
429
- });
430
- }
431
- for (const old of survivors) {
432
- s.lifecycle?.onCovered?.(old.inv);
433
- enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
483
+ let lanes = s.record.lanes;
484
+ for (const [protocol, lane] of Object.entries(previous)) {
485
+ const failed = lane.failed;
486
+ if (failed === undefined || laneOf(s.record, protocol).failed === failed)
487
+ continue;
488
+ const current = laneOf(s.record, protocol);
489
+ lanes = {
490
+ ...lanes,
491
+ [protocol]: {
492
+ ...current,
493
+ failed: [...(current.failed ?? []), ...failed],
494
+ },
495
+ };
434
496
  }
435
- restateContext(s);
497
+ if (lanes !== s.record.lanes) setRecord(s, { ...s.record, lanes });
498
+ requeue(s, survivors);
436
499
  publish(s);
437
500
  wake(s);
438
501
  void pump(s);
439
502
  };
440
503
 
441
504
  const resetRecord = (s: Session, clientId: string) =>
442
- rebaseRecord(s, () =>
443
- setRecord(s, { clientId, nextSeq: 1, ctxNextSeq: 1, commands: [] }),
444
- );
505
+ rebaseRecord(s, () => setRecord(s, { clientId, lanes: {} }));
445
506
 
446
507
  // Another sharer replaced the record's identity: their reset settles our
447
508
  // attempted history and our survivors re-stamp on the stored clocks.
@@ -457,6 +518,23 @@ const ephemeralDetach = (s: Session) => {
457
518
  resetRecord(s, newClientId());
458
519
  };
459
520
 
521
+ // An ephemeral lane dies with its attach: attempted statements can never be
522
+ // answered, the rest re-stamp from 1 for the next attach.
523
+ const detachEphemeralLanes = (s: Session) => {
524
+ const held = s.pending.filter((p) => isEphemeral(s, p.command.protocol));
525
+ if (held.length === 0) return;
526
+ s.pending = s.pending.filter((p) => !held.includes(p));
527
+ settleLost(
528
+ s,
529
+ held.filter((p) => p.attempted),
530
+ );
531
+ requeue(
532
+ s,
533
+ held.filter((p) => !p.attempted),
534
+ );
535
+ publish(s);
536
+ };
537
+
460
538
  const rejectedFailure = (cmd: StatewireCmd): StatewireClient.SendFailure => {
461
539
  return {
462
540
  fate: "rejected",
@@ -495,58 +573,76 @@ const outcomeOf = (
495
573
  }
496
574
  };
497
575
 
576
+ // Only a result verdict carries applied effects; the rest drop the overlay unapplied.
577
+ const settleVerdict = (s: Session, entry: Pending, cmd: StatewireCmd) => {
578
+ const outcome = outcomeOf(cmd);
579
+ if (outcome.ok) cover(s, entry);
580
+ else s.lifecycle?.onCovered?.(entry.inv);
581
+ settleEntry(s, entry, outcome);
582
+ };
583
+
498
584
  const TERMINAL_CMD_TYPES: ReadonlySet<string> = new Set(TERMINAL);
499
585
 
500
586
  const parkVerdict = (s: Session, cmd: StatewireCmd) => {
501
587
  if (!s.shared || !TERMINAL_CMD_TYPES.has(cmd.type!)) return;
588
+ if (isEphemeral(s, cmd.protocol)) return;
502
589
  s.shared.parked.push({
503
- seq: cmd.seq,
504
- type: cmd.type as StatewireClient.SessionVerdict["type"],
505
- ...(cmd.code !== undefined && { code: cmd.code }),
506
- ...(cmd.message !== undefined && { message: cmd.message }),
507
- ...(cmd.payload !== undefined && { payload: cmd.payload }),
508
- ...(cmd.meta !== undefined && { meta: cmd.meta }),
590
+ protocol: cmd.protocol,
591
+ verdict: {
592
+ seq: cmd.seq,
593
+ type: cmd.type as StatewireClient.SessionVerdict["type"],
594
+ ...(cmd.code !== undefined && { code: cmd.code }),
595
+ ...(cmd.message !== undefined && { message: cmd.message }),
596
+ ...(cmd.payload !== undefined && { payload: cmd.payload }),
597
+ ...(cmd.meta !== undefined && { meta: cmd.meta }),
598
+ },
509
599
  });
510
600
  };
511
601
 
512
602
  const handleCmd = (s: Session, cmd: StatewireCmd) => {
513
- if (!Number.isSafeInteger(cmd.seq) || cmd.seq <= 0) {
514
- protocolError("cmd.seq must be a positive safe integer");
515
- }
516
- if (cmd.dur === true) {
517
- s.durableSeq = Math.max(s.durableSeq, cmd.seq);
518
- for (const entry of s.pending) {
519
- if (entry.seq <= s.durableSeq) entry.posted = true;
603
+ if (cmd.dur === true && !isEphemeral(s, cmd.protocol)) {
604
+ const watermark = Math.max(durableSeqOf(s, cmd.protocol), cmd.seq);
605
+ s.durableSeq[cmd.protocol] = watermark;
606
+ for (const entry of laneEntries(s, cmd.protocol)) {
607
+ if (entry.seq <= watermark) entry.posted = true;
520
608
  }
521
609
  }
522
610
  if (cmd.type === undefined) return;
523
- const entry = s.pending.find((p) => p.seq === cmd.seq);
611
+ const entry = s.pending.find(
612
+ (p) => p.command.protocol === cmd.protocol && p.seq === cmd.seq,
613
+ );
524
614
  if (cmd.type === "applied") {
525
615
  if (entry) cover(s, entry);
526
616
  return;
527
617
  }
528
618
  if (!entry) return;
529
619
  parkVerdict(s, cmd);
530
- cover(s, entry);
531
- settleEntry(s, entry, outcomeOf(cmd));
620
+ settleVerdict(s, entry, cmd);
532
621
  };
533
622
 
534
623
  const consumeVerdicts = (s: Session) => {
535
- const verdicts = s.record.verdicts ?? [];
536
- const consumed = new Set<number>();
537
- for (const verdict of verdicts) {
538
- const entry = s.pending.find((p) => p.seq === verdict.seq);
539
- if (!entry) continue;
540
- consumed.add(verdict.seq);
541
- cover(s, entry);
542
- settleEntry(s, entry, outcomeOf(verdict as StatewireCmd));
624
+ let lanes = s.record.lanes;
625
+ for (const [protocol, lane] of Object.entries(s.record.lanes)) {
626
+ const verdicts = lane.verdicts ?? [];
627
+ const consumed = new Set<number>();
628
+ for (const verdict of verdicts) {
629
+ const entry = laneEntries(s, protocol).find((p) => p.seq === verdict.seq);
630
+ if (!entry) continue;
631
+ consumed.add(verdict.seq);
632
+ settleVerdict(s, entry, { ...verdict, protocol });
633
+ }
634
+ if (consumed.size === 0) continue;
635
+ lanes = {
636
+ ...lanes,
637
+ [protocol]: {
638
+ ...lane,
639
+ commands: lane.commands.filter((c) => !consumed.has(c.seq)),
640
+ verdicts: verdicts.filter((v) => !consumed.has(v.seq)),
641
+ },
642
+ };
543
643
  }
544
- if (consumed.size === 0) return;
545
- setRecord(s, {
546
- ...s.record,
547
- commands: s.record.commands.filter((c) => !consumed.has(c.seq)),
548
- verdicts: verdicts.filter((v) => !consumed.has(v.seq)),
549
- });
644
+ if (lanes === s.record.lanes) return;
645
+ setRecord(s, { ...s.record, lanes });
550
646
  publish(s);
551
647
  wake(s);
552
648
  };
@@ -605,66 +701,23 @@ const publishIngested = (s: Session) => {
605
701
  wake(s);
606
702
  };
607
703
 
608
- // A hello's cmd watermark may understate a durable record (cold host, tray
609
- // unloaded): held seqs resend as-is and the frame's answer arbitrates —
610
- // absorption where the record survived, a gap refusal where it did not.
611
- const snapCtxClock = (s: Session, ctxWm: number) => {
612
- if (s.record.context?.recover === "version-1") {
613
- const document = s.contextDocuments.documents.get(0);
614
- if (s.contextDocuments.documents.size !== 1 || !document?.main)
615
- throw new Error(
616
- "statewire: version-1 context recovery requires one main document at doc 0",
617
- );
618
- const encoded = encodeDocument(document.value);
619
- const ops = [
620
- {
621
- ...(ctxWm === 0
622
- ? { op: "mount", protocol: document.protocol, main: true }
623
- : { op: "replace", path: [] }),
624
- ...encoded,
625
- },
626
- ];
627
- s.ctxPending = [{ seq: ctxWm + 1, ops, posted: false }];
628
- setRecord(s, {
629
- ...s.record,
630
- ctxNextSeq: ctxWm + 2,
631
- context: {
632
- ops: contextSnapshot(s.contextDocuments),
633
- pending: s.ctxPending,
634
- },
635
- });
636
- return;
637
- }
638
- const ctxAligned =
639
- s.ctxPending.every((c, i) => c.seq === ctxWm + 1 + i) &&
640
- s.record.ctxNextSeq === ctxWm + s.ctxPending.length + 1;
641
- if (!ctxAligned) {
642
- if (ctxWm > 0)
643
- throw new Error(
644
- "statewire: context history is missing; use a new client id",
645
- );
646
- setRecord(s, { ...s.record, ctxNextSeq: ctxWm + 1 });
647
- restateContext(s);
648
- }
649
- };
650
-
651
- // The refusal's watermarks are exact: every held statement above them was
652
- // never admitted by the live record, and the first unposted seq being beyond
704
+ // The refusal's watermark is exact: every held statement above it was never
705
+ // admitted by the live record, and the first unposted seq being beyond
653
706
  // watermark+1 proves the record that admitted the missing prefix is gone —
654
- // its history settles lost and the lane clocks restart at the watermarks.
655
- const sessionLostInner = (s: Session, seqWm: number, ctxWm: number) => {
656
- const held = [...s.pending];
707
+ // its history settles lost and the lane clock restarts at the watermark.
708
+ const sessionLostInner = (s: Session, protocol: string, seqWm: number) => {
709
+ const held = laneEntries(s, protocol);
657
710
  settleLost(s, held);
658
- s.pending = [];
659
- s.durableSeq = seqWm;
660
- setRecord(s, {
661
- clientId: s.record.clientId,
662
- nextSeq: seqWm + 1,
663
- ctxNextSeq: s.record.ctxNextSeq,
664
- commands: [],
665
- ...(s.record.failed !== undefined && { failed: s.record.failed }),
666
- });
667
- snapCtxClock(s, ctxWm);
711
+ s.durableSeq[protocol] = seqWm;
712
+ const failed = laneOf(s.record, protocol).failed;
713
+ setRecord(
714
+ s,
715
+ withLane(s.record, protocol, {
716
+ nextSeq: seqWm + 1,
717
+ commands: [],
718
+ ...(failed !== undefined && { failed }),
719
+ }),
720
+ );
668
721
  if (
669
722
  held.length > 0 &&
670
723
  s.onSessionLost?.(held.map((p) => p.inv)) === "requeue"
@@ -683,40 +736,51 @@ const sessionLostInner = (s: Session, seqWm: number, ctxWm: number) => {
683
736
  wake(s);
684
737
  };
685
738
 
739
+ const selectionOf = (
740
+ syn: StatewireSyn,
741
+ ): StatewireProtocol.Selection | undefined => {
742
+ if (syn.version === undefined || syn.protocols === undefined)
743
+ return undefined;
744
+ return {
745
+ version: syn.version,
746
+ protocols: syn.protocols.map(({ name, version, record }) => {
747
+ if (version === undefined)
748
+ return protocolError(`hello lane ${name} has no version`);
749
+ return { name, version, ...(record !== undefined && { record }) };
750
+ }),
751
+ };
752
+ };
753
+
686
754
  const handleHello = (
687
755
  s: Session,
688
756
  { ops, syn }: { ops: unknown[]; syn: StatewireSyn },
689
757
  ) => {
690
- if (syn.record !== undefined && syn.record !== "ephemeral") {
691
- protocolError(`unknown record: ${String(syn.record)}`);
692
- }
693
- s.selection =
694
- syn.version === undefined || syn.protocols === undefined
695
- ? undefined
696
- : { version: syn.version, protocols: syn.protocols };
758
+ s.selection = selectionOf(syn);
697
759
  s.documents = { mode: null, documents: new Map() };
698
760
  ingestOps(s, ops);
699
761
  s.attach = "attached";
700
762
  s.attachEpoch += 1;
701
763
  s.frameFailures = 0;
702
764
  s.resumeAt = 0;
703
- s.ephemeral = syn.record === "ephemeral";
765
+ const lanes = syn.protocols ?? [];
766
+ s.ephemeral =
767
+ lanes.length > 0 && lanes.every((lane) => lane.record === "ephemeral");
704
768
  if (s.ephemeral) s.deactivated = true;
705
- const [attachCmd, attachCtx] = s.attachLaneSeqs;
706
- s.attachLaneSeqs = [new Set(), new Set()];
769
+ const attached = s.attachEntries;
770
+ s.attachEntries = new Set();
707
771
  const reconcile = () => {
708
772
  if (s.shared) adoptStored(s);
709
773
  // Resending unsettled statements is exactly-once-safe: absorbed replays
710
774
  // re-emit their stored verdicts (applied while still in flight).
711
- s.durableSeq = syn.seq ?? 0;
712
- for (const entry of s.pending) {
713
- entry.posted = attachCmd.has(entry.seq);
775
+ for (const lane of lanes) {
776
+ if (lane.record === "ephemeral") {
777
+ s.ephemeralSeqs[lane.name] = Math.max(
778
+ s.ephemeralSeqs[lane.name] ?? 0,
779
+ ...laneEntries(s, lane.name).map((p) => p.seq),
780
+ );
781
+ } else s.durableSeq[lane.name] = lane.seq ?? 0;
714
782
  }
715
- const ctxWatermark = syn.ctxSeq ?? 0;
716
- s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
717
- for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
718
- snapCtxClock(s, ctxWatermark);
719
- if (s.ctxOps !== undefined) persistContext(s);
783
+ for (const entry of s.pending) entry.posted = attached.has(entry);
720
784
  syncBacklogInner(s);
721
785
  s.sessionKnown = true;
722
786
  };
@@ -742,42 +806,42 @@ const handleOps = (
742
806
  publishIngested(s);
743
807
  };
744
808
 
745
- // A retry-less {syn} refusal is the one session-loss verdict: a cmd gap
746
- // against its exact watermark settles the held history lost; a pushback
747
- // (retry present) or a gapless refusal only reconciles the resend queues.
809
+ // A retry-less {syn} refusal is the one session-loss verdict: a gap against
810
+ // a durable lane's exact watermark settles that lane's history lost; a
811
+ // pushback (retry present) or a gapless refusal only reconciles the resend queues.
748
812
  const handleRefusal = (s: Session, syn: StatewireSyn) => {
749
- const { seq, ctxSeq } = syn;
750
- if (typeof seq === "number") {
751
- const watermark = Math.max(seq, s.durableSeq);
752
- for (const entry of s.pending) entry.posted = entry.seq <= watermark;
753
- }
754
- if (typeof ctxSeq === "number") {
755
- s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxSeq);
756
- for (const c of s.ctxPending) c.posted = false;
757
- if (s.ctxOps !== undefined) persistContext(s);
813
+ const lanes = (syn.protocols ?? []).filter(
814
+ (lane): lane is { name: string; seq: number } => lane.seq !== undefined,
815
+ );
816
+ for (const { name, seq } of lanes) {
817
+ const watermark = isEphemeral(s, name)
818
+ ? seq
819
+ : Math.max(seq, durableSeqOf(s, name));
820
+ for (const entry of laneEntries(s, name))
821
+ entry.posted = entry.seq <= watermark;
758
822
  }
759
823
  if (typeof syn.retry === "number") {
760
- s.resumeAt = Date.now() + syn.retry;
824
+ s.resumeAt = Date.now() + retryHint(syn.retry);
761
825
  wake(s);
762
826
  void pump(s);
763
827
  return;
764
828
  }
765
- const firstCmd = s.pending.find((p) => !p.posted);
766
- const firstCtx = s.ctxPending.find((c) => !c.posted);
767
- const cmdGap =
768
- typeof seq === "number" &&
769
- firstCmd !== undefined &&
770
- firstCmd.seq > Math.max(seq, s.durableSeq) + 1;
771
- const ctxGap =
772
- typeof ctxSeq === "number" &&
773
- firstCtx !== undefined &&
774
- firstCtx.seq > ctxSeq + 1;
775
- if (cmdGap || ctxGap) {
829
+ const gaps = lanes.filter(({ name, seq }) => {
830
+ const first = laneEntries(s, name).find((p) => !p.posted);
831
+ return (
832
+ first !== undefined &&
833
+ first.seq > Math.max(seq, durableSeqOf(s, name)) + 1
834
+ );
835
+ });
836
+ for (const { name, seq } of gaps) {
837
+ if (isEphemeral(s, name))
838
+ protocolError(`gap on ephemeral lane ${name} after watermark ${seq}`);
839
+ }
840
+ if (gaps.length > 0) {
776
841
  transactBackground(
777
842
  s,
778
843
  () => {
779
- if (cmdGap) sessionLostInner(s, seq!, ctxSeq ?? 0);
780
- else snapCtxClock(s, ctxSeq!);
844
+ for (const { name, seq } of gaps) sessionLostInner(s, name, seq);
781
845
  },
782
846
  () => {
783
847
  wake(s);
@@ -792,7 +856,9 @@ const handleRefusal = (s: Session, syn: StatewireSyn) => {
792
856
 
793
857
  const handleDetach = (s: Session) => {
794
858
  if (s.attach === "attached") s.attach = "detached";
859
+ s.ephemeralSeqs = {};
795
860
  if (s.ephemeral) ephemeralDetach(s);
861
+ else detachEphemeralLanes(s);
796
862
  wake(s);
797
863
  };
798
864
 
@@ -808,72 +874,56 @@ const isFinished = (s: Session) => s.attach === "finished";
808
874
 
809
875
  type Lanes = {
810
876
  frame: StatewireFrame;
811
- cmdEntries: Pending[];
812
- ctxEntries: CtxPending[];
877
+ entries: Pending[];
813
878
  };
814
879
 
815
880
  // A lane must be strictly consecutive within a frame: the first unposted run
816
- // per lane rides now, later runs ride follow-up frames.
817
- const buildLanes = (s: Session, persisted = false): Lanes | null => {
818
- const ctxEntries: CtxPending[] = [];
819
- for (const entry of s.ctxPending) {
820
- if (entry.posted) continue;
821
- if (
822
- persisted &&
823
- (s.persistedRecord?.clientId !== s.record.clientId ||
824
- entry.seq >= s.persistedRecord.ctxNextSeq)
825
- )
826
- break;
827
- if (ctxEntries.length >= MAX_COMMANDS_PER_FRAME) break;
828
- if (ctxEntries.length > 0 && entry.seq !== ctxEntries.at(-1)!.seq + 1)
829
- break;
830
- ctxEntries.push(entry);
831
- }
832
- const cmdEntries: Pending[] = [];
881
+ // per lane rides now, later runs ride follow-up frames. Ephemeral lanes ignore
882
+ // the gate: they live on the attach and never wait for persistence.
883
+ const buildLanes = (
884
+ s: Session,
885
+ cmdGate: "persisted" | "all" | "none" = "all",
886
+ ): Lanes | null => {
887
+ const entries: Pending[] = [];
888
+ const last: Record<string, number> = {};
889
+ const stopped = new Set<string>();
833
890
  for (const entry of s.pending) {
834
- if (entry.posted) continue;
835
- if (
836
- persisted &&
837
- (s.persistedRecord?.clientId !== s.record.clientId ||
838
- entry.seq >= s.persistedRecord.nextSeq)
839
- )
840
- break;
841
- if (cmdEntries.length >= MAX_COMMANDS_PER_FRAME) break;
842
- if (cmdEntries.length > 0 && entry.seq !== cmdEntries.at(-1)!.seq + 1)
843
- break;
844
- cmdEntries.push(entry);
891
+ if (entries.length >= MAX_COMMANDS_PER_FRAME) break;
892
+ const protocol = entry.command.protocol;
893
+ if (entry.posted || stopped.has(protocol)) continue;
894
+ const gated =
895
+ !isEphemeral(s, protocol) &&
896
+ (cmdGate === "none" ||
897
+ (cmdGate === "persisted" &&
898
+ (s.persistedRecord?.clientId !== s.record.clientId ||
899
+ entry.seq >= laneOf(s.persistedRecord, protocol).nextSeq)));
900
+ if (gated || (protocol in last && entry.seq !== last[protocol]! + 1)) {
901
+ stopped.add(protocol);
902
+ continue;
903
+ }
904
+ last[protocol] = entry.seq;
905
+ entries.push(entry);
845
906
  }
846
- if (ctxEntries.length === 0 && cmdEntries.length === 0) return null;
907
+ if (entries.length === 0) return null;
847
908
  return {
848
909
  frame: {
849
- ...(ctxEntries.length > 0 && {
850
- ctx: ctxEntries.map((c) => ({ ops: c.ops, seq: c.seq })),
851
- }),
852
- ...(cmdEntries.length > 0 && {
853
- cmd: cmdEntries.map((p) => {
854
- const { produced: _produced, ...command } = p.command;
855
- return { ...command, seq: p.seq };
856
- }),
910
+ cmd: entries.map((p) => {
911
+ const { produced: _produced, ...command } = p.command;
912
+ return { ...command, seq: p.seq };
857
913
  }),
858
914
  },
859
- cmdEntries,
860
- ctxEntries,
915
+ entries,
861
916
  };
862
917
  };
863
918
 
864
919
  const takeAttachLanes = (s: Session): StatewireFrame | null => {
865
- s.attachLaneSeqs = [new Set(), new Set()];
920
+ s.attachEntries = new Set();
866
921
  const wanted = s.lanesOnAttach;
867
922
  s.lanesOnAttach = false;
868
- if (!wanted || !s.sessionKnown || s.record.context?.recover !== undefined)
869
- return null;
870
- const lanes = buildLanes(s, true);
923
+ const lanes = buildLanes(s, wanted && s.sessionKnown ? "persisted" : "none");
871
924
  if (!lanes) return null;
872
- s.attachLaneSeqs = [
873
- new Set(lanes.cmdEntries.map((p) => p.seq)),
874
- new Set(lanes.ctxEntries.map((c) => c.seq)),
875
- ];
876
- for (const entry of lanes.cmdEntries) entry.attempted = true;
925
+ s.attachEntries = new Set(lanes.entries);
926
+ for (const entry of lanes.entries) entry.attempted = true;
877
927
  return lanes.frame;
878
928
  };
879
929
 
@@ -929,7 +979,7 @@ const pump = async (s: Session) => {
929
979
  if (persistence) await persistence;
930
980
  if (!s.running || isFinished(s)) return;
931
981
  if (epoch !== s.attachEpoch || s.attach !== "attached") continue;
932
- for (const entry of lanes.cmdEntries) entry.attempted = true;
982
+ for (const entry of lanes.entries) entry.attempted = true;
933
983
  let result;
934
984
  try {
935
985
  // Shared storage: the lock across the send keeps per-lane arrival
@@ -948,13 +998,12 @@ const pump = async (s: Session) => {
948
998
  s.frameFailures = 0;
949
999
  if (result.type === "ok") {
950
1000
  if (epoch === s.attachEpoch) {
951
- for (const entry of lanes.cmdEntries) {
1001
+ for (const entry of lanes.entries) {
952
1002
  if (s.pending.includes(entry)) {
953
1003
  entry.posted = true;
954
1004
  s.lifecycle?.onPosted?.(entry.inv);
955
1005
  }
956
1006
  }
957
- for (const entry of lanes.ctxEntries) entry.posted = true;
958
1007
  }
959
1008
  } else if (result.type === "refusal") {
960
1009
  if (epoch === s.attachEpoch) handleRefusal(s, result.syn);
@@ -1042,12 +1091,19 @@ const enqueueInner = (
1042
1091
  resolve: (payload: unknown) => void,
1043
1092
  reject: (error: unknown) => void,
1044
1093
  ) => {
1045
- const seq = Math.max(s.record.nextSeq, s.durableSeq + 1);
1094
+ const protocol = command.protocol;
1095
+ const ephemeral = isEphemeral(s, protocol);
1096
+ const seq = ephemeral
1097
+ ? (s.ephemeralSeqs[protocol] ?? 0) + 1
1098
+ : Math.max(
1099
+ laneOf(s.record, protocol).nextSeq,
1100
+ durableSeqOf(s, protocol) + 1,
1101
+ );
1046
1102
  const entry: Pending = {
1047
1103
  seq,
1048
1104
  command,
1049
1105
  inv: {
1050
- protocol: command.protocol,
1106
+ protocol,
1051
1107
  method: command.method,
1052
1108
  params,
1053
1109
  ts: Date.now(),
@@ -1061,7 +1117,16 @@ const enqueueInner = (
1061
1117
  reject,
1062
1118
  };
1063
1119
  s.pending.push(entry);
1064
- setRecord(s, { ...s.record, nextSeq: seq + 1, commands: mergeBacklog(s) });
1120
+ if (ephemeral) s.ephemeralSeqs[protocol] = seq;
1121
+ else
1122
+ setRecord(
1123
+ s,
1124
+ withLane(s.record, protocol, {
1125
+ ...laneOf(s.record, protocol),
1126
+ nextSeq: seq + 1,
1127
+ commands: mergeBacklog(s, protocol),
1128
+ }),
1129
+ );
1065
1130
  s.lifecycle?.onStart(entry.inv);
1066
1131
  };
1067
1132
 
@@ -1094,44 +1159,47 @@ const enqueue = (
1094
1159
  }, reject);
1095
1160
  };
1096
1161
 
1162
+ const offered = (s: Session, protocol: string) => {
1163
+ const offers = protocolOffers(s.options);
1164
+ return offers === "*" || offers.some((offer) => offer.name === protocol);
1165
+ };
1166
+
1097
1167
  // A stored command may have reached a server: attempted settles it lost,
1098
1168
  // never aborted, when a channel death ends the session.
1099
1169
  const adoptStored = (s: Session) => {
1100
- const known = new Set(s.pending.map((p) => p.seq));
1101
- const stored = s.record.commands.filter((c) => !known.has(c.seq));
1102
- if (stored.length === 0) return;
1103
- for (const { seq, ...command } of stored) {
1104
- const offers = protocolOffers(s.options);
1105
- if (
1106
- offers !== "*" &&
1107
- !offers.some((offer) => offer.name === command.protocol)
1108
- )
1109
- continue;
1110
- const params =
1111
- command.meta === undefined
1112
- ? command.params
1113
- : (reviveValue([...command.params], command.meta) as unknown[]);
1114
- const entry: Pending = {
1115
- seq,
1116
- command,
1117
- inv: {
1118
- protocol: command.protocol,
1119
- method: command.method,
1120
- params,
1121
- ts: Date.now(),
1170
+ let adopted = false;
1171
+ for (const [protocol, lane] of Object.entries(s.record.lanes)) {
1172
+ if (!offered(s, protocol)) continue;
1173
+ const known = new Set(laneEntries(s, protocol).map((p) => p.seq));
1174
+ for (const { seq, ...command } of lane.commands) {
1175
+ if (known.has(seq)) continue;
1176
+ const params =
1177
+ command.meta === undefined
1178
+ ? command.params
1179
+ : (reviveValue([...command.params], command.meta) as unknown[]);
1180
+ const entry: Pending = {
1122
1181
  seq,
1123
- clientId: s.record.clientId,
1124
- },
1125
- attempted: true,
1126
- posted: false,
1127
- applied: false,
1128
- resolve: () => {},
1129
- reject: () => {},
1130
- };
1131
- s.pending.push(entry);
1132
- s.lifecycle?.onStart(entry.inv);
1182
+ command: { protocol, ...command },
1183
+ inv: {
1184
+ protocol,
1185
+ method: command.method,
1186
+ params,
1187
+ ts: Date.now(),
1188
+ seq,
1189
+ clientId: s.record.clientId,
1190
+ },
1191
+ attempted: true,
1192
+ posted: false,
1193
+ applied: false,
1194
+ resolve: () => {},
1195
+ reject: () => {},
1196
+ };
1197
+ s.pending.push(entry);
1198
+ s.lifecycle?.onStart(entry.inv);
1199
+ adopted = true;
1200
+ }
1133
1201
  }
1134
- s.pending.sort((a, b) => a.seq - b.seq);
1202
+ if (adopted) s.pending.sort((a, b) => a.seq - b.seq);
1135
1203
  };
1136
1204
 
1137
1205
  const sendCommand = (
@@ -1140,6 +1208,11 @@ const sendCommand = (
1140
1208
  params: readonly unknown[],
1141
1209
  protocol = s.options.protocol ?? DEFAULT_PROTOCOL,
1142
1210
  ): Promise<unknown> => {
1211
+ const negotiated = s.selection
1212
+ ? s.selection.protocols.some((p) => p.name === protocol)
1213
+ : offered(s, protocol);
1214
+ if (!negotiated)
1215
+ throw new Error(`statewire: protocol "${protocol}" is not negotiated`);
1143
1216
  if (!s.hydrated) {
1144
1217
  const result = s.ready!.then(() =>
1145
1218
  sendCommand(s, method, params, protocol),
@@ -1188,7 +1261,7 @@ const findFailed = (
1188
1261
  s: Session,
1189
1262
  entry: StatewireClient.FailedCommand,
1190
1263
  ): StatewireClient.FailedCommand | undefined =>
1191
- (s.record.failed ?? []).find(
1264
+ (laneOf(s.record, entry.protocol).failed ?? []).find(
1192
1265
  (f) =>
1193
1266
  f.seq === entry.seq && f.ts === entry.ts && f.command === entry.command,
1194
1267
  );
@@ -1200,10 +1273,16 @@ const takeFailed = (
1200
1273
  s: Session,
1201
1274
  entry: StatewireClient.FailedCommand,
1202
1275
  ): StatewireClient.FailedCommand => {
1203
- const failed = s.record.failed ?? [];
1276
+ const lane = laneOf(s.record, entry.protocol);
1204
1277
  const found = findFailed(s, entry);
1205
1278
  if (found === undefined) throw unknownFailed(entry);
1206
- setRecord(s, { ...s.record, failed: failed.filter((f) => f !== found) });
1279
+ setRecord(
1280
+ s,
1281
+ withLane(s.record, entry.protocol, {
1282
+ ...lane,
1283
+ failed: (lane.failed ?? []).filter((f) => f !== found),
1284
+ }),
1285
+ );
1207
1286
  s.notify();
1208
1287
  return found;
1209
1288
  };
@@ -1285,128 +1364,6 @@ export const retryFailed = (
1285
1364
  return result;
1286
1365
  };
1287
1366
 
1288
- const contextSnapshot = (documents: StatewireDocuments.State) =>
1289
- [...documents.documents].map(([doc, d]) => {
1290
- const encoded = encodeDocument(d.value);
1291
- return {
1292
- op: "mount",
1293
- ...(doc === 0 ? {} : { doc }),
1294
- protocol: d.protocol,
1295
- ...(d.main ? { main: true } : {}),
1296
- ...(d.key === null ? {} : { key: d.key }),
1297
- value: encoded.value,
1298
- ...(encoded.meta === undefined ? {} : { meta: encoded.meta }),
1299
- };
1300
- });
1301
-
1302
- const persistContext = (s: Session) => {
1303
- const ops = contextSnapshot(s.contextDocuments);
1304
- s.ctxOps = ops;
1305
- setRecord(s, {
1306
- ...s.record,
1307
- context: {
1308
- ops,
1309
- ...(s.record.context?.recover === undefined
1310
- ? {}
1311
- : { recover: s.record.context.recover }),
1312
- pending: s.ctxPending.map(({ seq, ops }) => ({ seq, ops })),
1313
- },
1314
- });
1315
- };
1316
-
1317
- const changeContext = (
1318
- s: Session,
1319
- build: (documents: StatewireDocuments.State) => readonly unknown[],
1320
- ): void => {
1321
- if (!s.hydrated) {
1322
- void s
1323
- .ready!.then(() => changeContext(s, build))
1324
- .catch((cause: unknown) => {
1325
- const error = wrapStorageError(cause);
1326
- s.storageError = error;
1327
- s.transport?.storageFailed(error);
1328
- });
1329
- return;
1330
- }
1331
- if (!s.running || isFinished(s)) return;
1332
- const submit = () => {
1333
- if (!s.running || isFinished(s)) return;
1334
- const previous =
1335
- s.record.context === undefined
1336
- ? s.contextDocuments
1337
- : applyDocumentOps(
1338
- { mode: "mounted", documents: new Map() },
1339
- s.record.context.ops,
1340
- );
1341
- const operations = build(previous);
1342
- if (!operations.length)
1343
- throw new Error("statewire: context operations must not be empty");
1344
- const offers = protocolOffers(s.options);
1345
- const protocols =
1346
- s.selection?.protocols ?? (offers === "*" ? undefined : offers);
1347
- s.contextDocuments = applyDocumentOps(
1348
- previous,
1349
- operations,
1350
- protocols,
1351
- true,
1352
- );
1353
- const seq = s.record.ctxNextSeq;
1354
- s.ctxPending = [
1355
- ...s.ctxPending,
1356
- {
1357
- seq,
1358
- ops: operations.map((op) => {
1359
- const value = op as Record<string, unknown>;
1360
- if (value["doc"] !== 0) return value;
1361
- const { doc: _doc, ...rest } = value;
1362
- return rest;
1363
- }),
1364
- posted: false,
1365
- },
1366
- ];
1367
- s.record = { ...s.record, ctxNextSeq: seq + 1 };
1368
- persistContext(s);
1369
- };
1370
- if (!s.shared) {
1371
- submit();
1372
- wake(s);
1373
- void pump(s);
1374
- } else
1375
- transactBackground(s, submit, () => {
1376
- wake(s);
1377
- void pump(s);
1378
- });
1379
- };
1380
-
1381
- export const updateContext = (s: Session, operations: readonly unknown[]) =>
1382
- changeContext(s, () => operations);
1383
-
1384
- const setContext = (
1385
- s: Session,
1386
- value: Record<string, unknown>,
1387
- protocol = s.options.protocol ?? DEFAULT_PROTOCOL,
1388
- ): void => {
1389
- if (typeof value !== "object" || value === null || Array.isArray(value))
1390
- throw new Error("statewire: context must be a plain object");
1391
- const encoded = encodeDocument(value);
1392
- changeContext(s, (documents) => {
1393
- const found = [...documents.documents].find(
1394
- ([, d]) => d.protocol === protocol && d.main,
1395
- );
1396
- const doc = found?.[0] ?? Math.max(-1, ...documents.documents.keys()) + 1;
1397
- return [
1398
- {
1399
- ...(found === undefined
1400
- ? { op: "mount", protocol, main: true }
1401
- : { op: "replace", path: [] }),
1402
- ...(doc === 0 ? {} : { doc }),
1403
- value: encoded.value,
1404
- ...(encoded.meta === undefined ? {} : { meta: encoded.meta }),
1405
- },
1406
- ];
1407
- });
1408
- };
1409
-
1410
1367
  export const documentSource = (s: Session): StatewireDocuments.Source => ({
1411
1368
  get: (protocol, key, prefix = s.options.keyPrefixes?.[protocol]) => {
1412
1369
  const mounted = findDocument(s.documents, protocol, key, prefix);
@@ -1446,20 +1403,20 @@ export const invokeProtocol = (
1446
1403
  ) => sendCommand(s, method, params, protocol);
1447
1404
 
1448
1405
  const storedCommandCompatible = (
1406
+ protocol: string,
1449
1407
  command: StatewireClient.SessionCommand,
1450
1408
  current: NonNullable<StatewireClient.SessionCommand["produced"]>,
1451
1409
  ) => {
1452
1410
  if (
1453
1411
  !command.produced ||
1454
- !command.protocol ||
1455
1412
  !inRange(command.produced.wire.version, current.wire)
1456
1413
  )
1457
1414
  return false;
1458
1415
  const previous = command.produced.protocols;
1459
1416
  if (current.protocols === "*") return true;
1460
1417
  if (previous === "*") return false;
1461
- const produced = previous.find((p) => p.name === command.protocol);
1462
- const offer = current.protocols.find((p) => p.name === command.protocol);
1418
+ const produced = previous.find((p) => p.name === protocol);
1419
+ const offer = current.protocols.find((p) => p.name === protocol);
1463
1420
  return (
1464
1421
  produced !== undefined &&
1465
1422
  offer !== undefined &&
@@ -1467,50 +1424,47 @@ const storedCommandCompatible = (
1467
1424
  );
1468
1425
  };
1469
1426
 
1427
+ const validateLanes = (record: StatewireClient.SessionRecord) => {
1428
+ if (
1429
+ typeof record.lanes !== "object" ||
1430
+ record.lanes === null ||
1431
+ Object.values(record.lanes).some((lane) => !Array.isArray(lane.commands))
1432
+ )
1433
+ throw new Error(
1434
+ "statewire: session storage loaded a record without command lanes",
1435
+ );
1436
+ };
1437
+
1470
1438
  const loadSessionRecord = (
1471
1439
  store: StatewireClient.SessionStorage,
1472
1440
  options: StatewireSessionOptions,
1473
1441
  ) => {
1474
1442
  let stored = store.load();
1475
- if (stored !== null && !Array.isArray(stored.commands))
1476
- throw new Error(
1477
- "statewire: session storage loaded a record without a commands array",
1478
- );
1443
+ if (stored !== null) validateLanes(stored);
1479
1444
  const current = {
1480
1445
  wire: options.wireVersion ?? { version: WIRE_VERSION },
1481
1446
  protocols: protocolOffers(options),
1482
1447
  };
1483
- if (
1484
- stored !== null &&
1485
- !stored.commands.every((command) =>
1486
- storedCommandCompatible(command, current),
1487
- )
1488
- ) {
1448
+ const compatible = (record: StatewireClient.SessionRecord) =>
1449
+ Object.entries(record.lanes).every(([protocol, lane]) =>
1450
+ lane.commands.every((command) =>
1451
+ storedCommandCompatible(protocol, command, current),
1452
+ ),
1453
+ );
1454
+ if (stored !== null && !compatible(stored)) {
1489
1455
  if (!store.migrate)
1490
1456
  throw new Error("statewire: stored commands require a storage migration");
1491
1457
  stored = store.migrate(stored, current);
1492
- if (
1493
- stored !== null &&
1494
- !stored.commands.every((command) =>
1495
- storedCommandCompatible(command, current),
1496
- )
1497
- )
1498
- throw new Error("statewire: migrated commands are incompatible");
1499
- if (stored !== null) store.save(stored);
1500
- }
1501
- if (stored !== null && !Array.isArray(stored.commands)) {
1502
- throw new Error(
1503
- "statewire: session storage loaded a record without a commands array",
1504
- );
1458
+ if (stored !== null) {
1459
+ validateLanes(stored);
1460
+ if (!compatible(stored))
1461
+ throw new Error("statewire: migrated commands are incompatible");
1462
+ store.save(stored);
1463
+ }
1505
1464
  }
1506
- const record = stored ?? {
1507
- clientId: newClientId(),
1508
- nextSeq: 1,
1509
- ctxNextSeq: 1,
1510
- commands: [],
1511
- };
1465
+ const record = stored ?? { clientId: newClientId(), lanes: {} };
1512
1466
  if (stored === null) store.save(record);
1513
- return { record, stored };
1467
+ return record;
1514
1468
  };
1515
1469
 
1516
1470
  export const useStatewireSession = (
@@ -1531,28 +1485,13 @@ export const useStatewireSession = (
1531
1485
  );
1532
1486
  }
1533
1487
  const [s] = useState((): Session => {
1534
- const { record, stored } =
1488
+ const record =
1535
1489
  store.ready === undefined
1536
1490
  ? loadSessionRecord(store, options)
1537
- : {
1538
- record: {
1539
- clientId: newClientId(),
1540
- nextSeq: 1,
1541
- ctxNextSeq: 1,
1542
- commands: [],
1543
- },
1544
- stored: null,
1545
- };
1491
+ : { clientId: newClientId(), lanes: {} };
1546
1492
  const session: Session = {
1547
1493
  options,
1548
1494
  selection: undefined,
1549
- contextDocuments:
1550
- stored?.context === undefined
1551
- ? { mode: "mounted", documents: new Map() }
1552
- : applyDocumentOps(
1553
- { mode: "mounted", documents: new Map() },
1554
- stored.context.ops,
1555
- ),
1556
1495
  record,
1557
1496
  ctx: {
1558
1497
  beforeAttach: () => persistBeforeSend(session),
@@ -1582,18 +1521,18 @@ export const useStatewireSession = (
1582
1521
  ephemeral: false,
1583
1522
  deactivated: false,
1584
1523
  sessionKnown: false,
1585
- durableSeq: 0,
1524
+ durableSeq: {},
1525
+ ephemeralSeqs: {},
1586
1526
  resumeAt: 0,
1587
1527
  idle: false,
1588
1528
  onSessionLost: undefined,
1589
1529
  onDone: undefined,
1590
- ctxOps: stored?.context?.ops,
1591
- ctxPending:
1592
- stored?.context?.pending.map((p) => ({ ...p, posted: false })) ?? [],
1593
1530
  lanesOnAttach: false,
1594
- attachLaneSeqs: [new Set(), new Set()],
1531
+ attachEntries: new Set(),
1595
1532
  frameFailures: 0,
1596
1533
  pending: [],
1534
+ failedList: [],
1535
+ failedParts: [],
1597
1536
  canonical: undefined,
1598
1537
  documentListeners: new Set(),
1599
1538
  documents: { mode: null, documents: new Map() },
@@ -1624,21 +1563,7 @@ export const useStatewireSession = (
1624
1563
  };
1625
1564
  if (store.ready !== undefined) {
1626
1565
  session.ready = store.ready.then(() => {
1627
- const { record } = loadSessionRecord(store, session.options);
1628
- session.record = record;
1629
- session.contextDocuments =
1630
- record.context === undefined
1631
- ? { mode: "mounted", documents: new Map() }
1632
- : applyDocumentOps(
1633
- { mode: "mounted", documents: new Map() },
1634
- record.context.ops,
1635
- );
1636
- session.ctxOps = record.context?.ops;
1637
- session.ctxPending =
1638
- record.context?.pending.map((entry) => ({
1639
- ...entry,
1640
- posted: false,
1641
- })) ?? [];
1566
+ session.record = loadSessionRecord(store, session.options);
1642
1567
  session.recordPersisted = true;
1643
1568
  session.hydrated = true;
1644
1569
  if (session.running) session.notify();
@@ -1686,4 +1611,4 @@ export const useStatewireSession = (
1686
1611
  return s;
1687
1612
  };
1688
1613
 
1689
- export { setContext, setTransport };
1614
+ export { setTransport };