statewire 0.18.2 → 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 +291 -256
  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 +8 -17
  23. package/dist/host/http.js.map +1 -1
  24. package/dist/host/socket.d.ts +47 -23
  25. package/dist/host/socket.d.ts.map +1 -1
  26. package/dist/host/socket.js +389 -245
  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 -8
  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 -10
  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 +55 -41
  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 -19
  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 +434 -365
  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 +22 -23
  74. package/src/host/socket.ts +550 -321
  75. package/src/host/synced.ts +60 -0
  76. package/src/host-internal.ts +21 -3
  77. package/src/host.ts +40 -8
  78. package/src/index.ts +1 -0
  79. package/src/statewire.ts +8 -30
  80. package/src/storage.ts +96 -79
  81. package/src/transports/remote.ts +0 -7
  82. package/src/transports/sse.ts +8 -3
  83. package/src/transports/testing.ts +34 -19
  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
- ctxNextSeq: number;
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) => {
@@ -279,35 +334,49 @@ const cover = (s: Session, entry: Pending) => {
279
334
  s.lifecycle?.onCovered?.(entry.inv);
280
335
  };
281
336
 
282
- const NO_FAILED: readonly StatewireClient.FailedCommand[] = [];
283
-
284
337
  export const failedCommands = (
285
338
  s: Session,
286
- ): 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
+ };
287
352
 
288
353
  const retainFailedInner = (
289
354
  s: Session,
290
355
  entry: Pending,
291
356
  failure: StatewireClient.SendFailure,
292
357
  ) => {
293
- setRecord(s, {
294
- ...s.record,
295
- failed: [
296
- ...(s.record.failed ?? []),
297
- {
298
- protocol: entry.command.protocol,
299
- ...(entry.command.produced !== undefined && {
300
- produced: entry.command.produced,
301
- }),
302
- command: entry.command.method,
303
- params: entry.command.params,
304
- ...(entry.command.meta !== undefined && { meta: entry.command.meta }),
305
- failure,
306
- ts: entry.inv.ts,
307
- seq: entry.seq,
308
- },
309
- ],
310
- });
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
+ );
311
380
  s.notify();
312
381
  };
313
382
 
@@ -343,8 +412,9 @@ const settleEntry = (
343
412
  const rides =
344
413
  (outcome.failure.cause === "unmount" ||
345
414
  outcome.failure.cause === "stream-gone") &&
346
- entry.seq > s.durableSeq;
347
- 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);
348
418
  entry.reject(new StatewireSendError(outcome.failure));
349
419
  s.lifecycle?.onSettle(entry.inv, {
350
420
  type: "rejected",
@@ -360,7 +430,10 @@ const settleAll = (s: Session, detach: "unmount" | "gone") => {
360
430
  const detachFailure = (entry: Pending): StatewireClient.SendFailure => {
361
431
  if (!entry.attempted) {
362
432
  const predecessorLost = batch.some(
363
- (p) => p.seq < entry.seq && p.attempted,
433
+ (p) =>
434
+ p.command.protocol === entry.command.protocol &&
435
+ p.seq < entry.seq &&
436
+ p.attempted,
364
437
  );
365
438
  return predecessorLost
366
439
  ? { fate: "aborted", cause, predecessorLost }
@@ -384,6 +457,13 @@ const settleLost = (s: Session, entries: Pending[]) => {
384
457
  }
385
458
  };
386
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
+
387
467
  // The server-side record's dedup memory is gone: history that may have
388
468
  // reached a server settles lost — the new hello's snapshot is the only
389
469
  // truth about what survived — and never-attempted statements re-stamp
@@ -396,27 +476,33 @@ const rebaseRecord = (s: Session, adopt: () => void) => {
396
476
  held.filter((p) => p.attempted || p.applied),
397
477
  );
398
478
  s.pending = [];
399
- s.durableSeq = 0;
479
+ s.durableSeq = {};
400
480
  // Failed entries are user-owned history: they ride through every record replacement.
401
- const failed = s.record.failed;
481
+ const previous = s.record.lanes;
402
482
  adopt();
403
- if (failed !== undefined && s.record.failed !== failed) {
404
- setRecord(s, {
405
- ...s.record,
406
- failed: [...(s.record.failed ?? []), ...failed],
407
- });
408
- }
409
- for (const old of survivors) {
410
- s.lifecycle?.onCovered?.(old.inv);
411
- 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
+ };
412
496
  }
497
+ if (lanes !== s.record.lanes) setRecord(s, { ...s.record, lanes });
498
+ requeue(s, survivors);
413
499
  publish(s);
414
500
  wake(s);
415
501
  void pump(s);
416
502
  };
417
503
 
418
504
  const resetRecord = (s: Session, clientId: string) =>
419
- rebaseRecord(s, () => setRecord(s, { clientId, nextSeq: 1, commands: [] }));
505
+ rebaseRecord(s, () => setRecord(s, { clientId, lanes: {} }));
420
506
 
421
507
  // Another sharer replaced the record's identity: their reset settles our
422
508
  // attempted history and our survivors re-stamp on the stored clocks.
@@ -432,6 +518,23 @@ const ephemeralDetach = (s: Session) => {
432
518
  resetRecord(s, newClientId());
433
519
  };
434
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
+
435
538
  const rejectedFailure = (cmd: StatewireCmd): StatewireClient.SendFailure => {
436
539
  return {
437
540
  fate: "rejected",
@@ -470,58 +573,76 @@ const outcomeOf = (
470
573
  }
471
574
  };
472
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
+
473
584
  const TERMINAL_CMD_TYPES: ReadonlySet<string> = new Set(TERMINAL);
474
585
 
475
586
  const parkVerdict = (s: Session, cmd: StatewireCmd) => {
476
587
  if (!s.shared || !TERMINAL_CMD_TYPES.has(cmd.type!)) return;
588
+ if (isEphemeral(s, cmd.protocol)) return;
477
589
  s.shared.parked.push({
478
- seq: cmd.seq,
479
- type: cmd.type as StatewireClient.SessionVerdict["type"],
480
- ...(cmd.code !== undefined && { code: cmd.code }),
481
- ...(cmd.message !== undefined && { message: cmd.message }),
482
- ...(cmd.payload !== undefined && { payload: cmd.payload }),
483
- ...(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
+ },
484
599
  });
485
600
  };
486
601
 
487
602
  const handleCmd = (s: Session, cmd: StatewireCmd) => {
488
- if (!Number.isSafeInteger(cmd.seq) || cmd.seq <= 0) {
489
- protocolError("cmd.seq must be a positive safe integer");
490
- }
491
- if (cmd.dur === true) {
492
- s.durableSeq = Math.max(s.durableSeq, cmd.seq);
493
- for (const entry of s.pending) {
494
- 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;
495
608
  }
496
609
  }
497
610
  if (cmd.type === undefined) return;
498
- 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
+ );
499
614
  if (cmd.type === "applied") {
500
615
  if (entry) cover(s, entry);
501
616
  return;
502
617
  }
503
618
  if (!entry) return;
504
619
  parkVerdict(s, cmd);
505
- cover(s, entry);
506
- settleEntry(s, entry, outcomeOf(cmd));
620
+ settleVerdict(s, entry, cmd);
507
621
  };
508
622
 
509
623
  const consumeVerdicts = (s: Session) => {
510
- const verdicts = s.record.verdicts ?? [];
511
- const consumed = new Set<number>();
512
- for (const verdict of verdicts) {
513
- const entry = s.pending.find((p) => p.seq === verdict.seq);
514
- if (!entry) continue;
515
- consumed.add(verdict.seq);
516
- cover(s, entry);
517
- 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
+ };
518
643
  }
519
- if (consumed.size === 0) return;
520
- setRecord(s, {
521
- ...s.record,
522
- commands: s.record.commands.filter((c) => !consumed.has(c.seq)),
523
- verdicts: verdicts.filter((v) => !consumed.has(v.seq)),
524
- });
644
+ if (lanes === s.record.lanes) return;
645
+ setRecord(s, { ...s.record, lanes });
525
646
  publish(s);
526
647
  wake(s);
527
648
  };
@@ -583,18 +704,20 @@ const publishIngested = (s: Session) => {
583
704
  // The refusal's watermark is exact: every held statement above it was never
584
705
  // admitted by the live record, and the first unposted seq being beyond
585
706
  // watermark+1 proves the record that admitted the missing prefix is gone —
586
- // its history settles lost and the cmd clock restarts at the watermark.
587
- const sessionLostInner = (s: Session, seqWm: number) => {
588
- 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);
589
710
  settleLost(s, held);
590
- s.pending = [];
591
- s.durableSeq = seqWm;
592
- setRecord(s, {
593
- clientId: s.record.clientId,
594
- nextSeq: seqWm + 1,
595
- commands: [],
596
- ...(s.record.failed !== undefined && { failed: s.record.failed }),
597
- });
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
+ );
598
721
  if (
599
722
  held.length > 0 &&
600
723
  s.onSessionLost?.(held.map((p) => p.inv)) === "requeue"
@@ -613,39 +736,51 @@ const sessionLostInner = (s: Session, seqWm: number) => {
613
736
  wake(s);
614
737
  };
615
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
+
616
754
  const handleHello = (
617
755
  s: Session,
618
756
  { ops, syn }: { ops: unknown[]; syn: StatewireSyn },
619
757
  ) => {
620
- if (syn.record !== undefined && syn.record !== "ephemeral") {
621
- protocolError(`unknown record: ${String(syn.record)}`);
622
- }
623
- if (syn.ctxSeq !== undefined && syn.ctxSeq !== 0) {
624
- protocolError(`hello ctxSeq must be 0, got ${syn.ctxSeq}`);
625
- }
626
- s.selection =
627
- syn.version === undefined || syn.protocols === undefined
628
- ? undefined
629
- : { version: syn.version, protocols: syn.protocols };
758
+ s.selection = selectionOf(syn);
630
759
  s.documents = { mode: null, documents: new Map() };
631
760
  ingestOps(s, ops);
632
761
  s.attach = "attached";
633
762
  s.attachEpoch += 1;
634
763
  s.frameFailures = 0;
635
764
  s.resumeAt = 0;
636
- s.ephemeral = syn.record === "ephemeral";
765
+ const lanes = syn.protocols ?? [];
766
+ s.ephemeral =
767
+ lanes.length > 0 && lanes.every((lane) => lane.record === "ephemeral");
637
768
  if (s.ephemeral) s.deactivated = true;
638
- const [attachCmd, attachCtx] = s.attachLaneSeqs;
639
- s.attachLaneSeqs = [new Set(), new Set()];
769
+ const attached = s.attachEntries;
770
+ s.attachEntries = new Set();
640
771
  const reconcile = () => {
641
772
  if (s.shared) adoptStored(s);
642
773
  // Resending unsettled statements is exactly-once-safe: absorbed replays
643
774
  // re-emit their stored verdicts (applied while still in flight).
644
- s.durableSeq = syn.seq ?? 0;
645
- for (const entry of s.pending) {
646
- 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;
647
782
  }
648
- for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
783
+ for (const entry of s.pending) entry.posted = attached.has(entry);
649
784
  syncBacklogInner(s);
650
785
  s.sessionKnown = true;
651
786
  };
@@ -671,40 +806,43 @@ const handleOps = (
671
806
  publishIngested(s);
672
807
  };
673
808
 
674
- // A retry-less {syn} refusal is the one session-loss verdict: a cmd gap
675
- // against its exact watermark settles the held history lost; a pushback
676
- // (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.
677
812
  const handleRefusal = (s: Session, syn: StatewireSyn) => {
678
- const { seq, ctxSeq } = syn;
679
- if (typeof seq === "number") {
680
- const watermark = Math.max(seq, s.durableSeq);
681
- for (const entry of s.pending) entry.posted = entry.seq <= watermark;
682
- }
683
- if (typeof ctxSeq === "number") {
684
- s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxSeq);
685
- for (const c of s.ctxPending) c.posted = false;
686
- const firstCtx = s.ctxPending.find((c) => !c.posted);
687
- if (firstCtx !== undefined && firstCtx.seq > ctxSeq + 1) {
688
- protocolError(
689
- `ctx gap on a live attach: seq ${firstCtx.seq} after watermark ${ctxSeq}`,
690
- );
691
- }
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;
692
822
  }
693
823
  if (typeof syn.retry === "number") {
694
- s.resumeAt = Date.now() + syn.retry;
824
+ s.resumeAt = Date.now() + retryHint(syn.retry);
695
825
  wake(s);
696
826
  void pump(s);
697
827
  return;
698
828
  }
699
- const firstCmd = s.pending.find((p) => !p.posted);
700
- const cmdGap =
701
- typeof seq === "number" &&
702
- firstCmd !== undefined &&
703
- firstCmd.seq > Math.max(seq, s.durableSeq) + 1;
704
- if (cmdGap) {
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) {
705
841
  transactBackground(
706
842
  s,
707
- () => sessionLostInner(s, seq),
843
+ () => {
844
+ for (const { name, seq } of gaps) sessionLostInner(s, name, seq);
845
+ },
708
846
  () => {
709
847
  wake(s);
710
848
  void pump(s);
@@ -718,7 +856,9 @@ const handleRefusal = (s: Session, syn: StatewireSyn) => {
718
856
 
719
857
  const handleDetach = (s: Session) => {
720
858
  if (s.attach === "attached") s.attach = "detached";
859
+ s.ephemeralSeqs = {};
721
860
  if (s.ephemeral) ephemeralDetach(s);
861
+ else detachEphemeralLanes(s);
722
862
  wake(s);
723
863
  };
724
864
 
@@ -734,77 +874,56 @@ const isFinished = (s: Session) => s.attach === "finished";
734
874
 
735
875
  type Lanes = {
736
876
  frame: StatewireFrame;
737
- cmdEntries: Pending[];
738
- ctxEntries: CtxPending[];
877
+ entries: Pending[];
739
878
  };
740
879
 
741
880
  // A lane must be strictly consecutive within a frame: the first unposted run
742
- // per lane rides now, later runs ride follow-up frames.
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.
743
883
  const buildLanes = (
744
884
  s: Session,
745
885
  cmdGate: "persisted" | "all" | "none" = "all",
746
886
  ): Lanes | null => {
747
- const ctxEntries: CtxPending[] = [];
748
- for (const entry of s.ctxPending) {
749
- if (entry.posted) continue;
750
- if (ctxEntries.length >= MAX_COMMANDS_PER_FRAME) break;
751
- if (ctxEntries.length > 0 && entry.seq !== ctxEntries.at(-1)!.seq + 1)
752
- break;
753
- ctxEntries.push(entry);
754
- }
755
- const cmdEntries: Pending[] = [];
887
+ const entries: Pending[] = [];
888
+ const last: Record<string, number> = {};
889
+ const stopped = new Set<string>();
756
890
  for (const entry of s.pending) {
757
- if (cmdGate === "none") break;
758
- if (entry.posted) continue;
759
- if (
760
- cmdGate === "persisted" &&
761
- (s.persistedRecord?.clientId !== s.record.clientId ||
762
- entry.seq >= s.persistedRecord.nextSeq)
763
- )
764
- break;
765
- if (cmdEntries.length >= MAX_COMMANDS_PER_FRAME) break;
766
- if (cmdEntries.length > 0 && entry.seq !== cmdEntries.at(-1)!.seq + 1)
767
- break;
768
- 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);
769
906
  }
770
- if (ctxEntries.length === 0 && cmdEntries.length === 0) return null;
907
+ if (entries.length === 0) return null;
771
908
  return {
772
909
  frame: {
773
- ...(ctxEntries.length > 0 && {
774
- ctx: ctxEntries.map((c) => ({ ops: c.ops, seq: c.seq })),
775
- }),
776
- ...(cmdEntries.length > 0 && {
777
- cmd: cmdEntries.map((p) => {
778
- const { produced: _produced, ...command } = p.command;
779
- return { ...command, seq: p.seq };
780
- }),
910
+ cmd: entries.map((p) => {
911
+ const { produced: _produced, ...command } = p.command;
912
+ return { ...command, seq: p.seq };
781
913
  }),
782
914
  },
783
- cmdEntries,
784
- ctxEntries,
915
+ entries,
785
916
  };
786
917
  };
787
918
 
788
- // Context is attach scoped: every attach starts at ctx seq 0 with an empty
789
- // table, so the full context restates at seq 1 on the attach frame.
790
- const resetContextLane = (s: Session) => {
791
- const ops = contextSnapshot(s.contextDocuments);
792
- s.ctxPending = ops.length === 0 ? [] : [{ seq: 1, ops, posted: false }];
793
- s.ctxNextSeq = s.ctxPending.length + 1;
794
- };
795
-
796
919
  const takeAttachLanes = (s: Session): StatewireFrame | null => {
797
- s.attachLaneSeqs = [new Set(), new Set()];
798
- resetContextLane(s);
920
+ s.attachEntries = new Set();
799
921
  const wanted = s.lanesOnAttach;
800
922
  s.lanesOnAttach = false;
801
923
  const lanes = buildLanes(s, wanted && s.sessionKnown ? "persisted" : "none");
802
924
  if (!lanes) return null;
803
- s.attachLaneSeqs = [
804
- new Set(lanes.cmdEntries.map((p) => p.seq)),
805
- new Set(lanes.ctxEntries.map((c) => c.seq)),
806
- ];
807
- 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;
808
927
  return lanes.frame;
809
928
  };
810
929
 
@@ -860,7 +979,7 @@ const pump = async (s: Session) => {
860
979
  if (persistence) await persistence;
861
980
  if (!s.running || isFinished(s)) return;
862
981
  if (epoch !== s.attachEpoch || s.attach !== "attached") continue;
863
- for (const entry of lanes.cmdEntries) entry.attempted = true;
982
+ for (const entry of lanes.entries) entry.attempted = true;
864
983
  let result;
865
984
  try {
866
985
  // Shared storage: the lock across the send keeps per-lane arrival
@@ -879,13 +998,12 @@ const pump = async (s: Session) => {
879
998
  s.frameFailures = 0;
880
999
  if (result.type === "ok") {
881
1000
  if (epoch === s.attachEpoch) {
882
- for (const entry of lanes.cmdEntries) {
1001
+ for (const entry of lanes.entries) {
883
1002
  if (s.pending.includes(entry)) {
884
1003
  entry.posted = true;
885
1004
  s.lifecycle?.onPosted?.(entry.inv);
886
1005
  }
887
1006
  }
888
- for (const entry of lanes.ctxEntries) entry.posted = true;
889
1007
  }
890
1008
  } else if (result.type === "refusal") {
891
1009
  if (epoch === s.attachEpoch) handleRefusal(s, result.syn);
@@ -973,12 +1091,19 @@ const enqueueInner = (
973
1091
  resolve: (payload: unknown) => void,
974
1092
  reject: (error: unknown) => void,
975
1093
  ) => {
976
- 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
+ );
977
1102
  const entry: Pending = {
978
1103
  seq,
979
1104
  command,
980
1105
  inv: {
981
- protocol: command.protocol,
1106
+ protocol,
982
1107
  method: command.method,
983
1108
  params,
984
1109
  ts: Date.now(),
@@ -992,7 +1117,16 @@ const enqueueInner = (
992
1117
  reject,
993
1118
  };
994
1119
  s.pending.push(entry);
995
- 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
+ );
996
1130
  s.lifecycle?.onStart(entry.inv);
997
1131
  };
998
1132
 
@@ -1025,44 +1159,47 @@ const enqueue = (
1025
1159
  }, reject);
1026
1160
  };
1027
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
+
1028
1167
  // A stored command may have reached a server: attempted settles it lost,
1029
1168
  // never aborted, when a channel death ends the session.
1030
1169
  const adoptStored = (s: Session) => {
1031
- const known = new Set(s.pending.map((p) => p.seq));
1032
- const stored = s.record.commands.filter((c) => !known.has(c.seq));
1033
- if (stored.length === 0) return;
1034
- for (const { seq, ...command } of stored) {
1035
- const offers = protocolOffers(s.options);
1036
- if (
1037
- offers !== "*" &&
1038
- !offers.some((offer) => offer.name === command.protocol)
1039
- )
1040
- continue;
1041
- const params =
1042
- command.meta === undefined
1043
- ? command.params
1044
- : (reviveValue([...command.params], command.meta) as unknown[]);
1045
- const entry: Pending = {
1046
- seq,
1047
- command,
1048
- inv: {
1049
- protocol: command.protocol,
1050
- method: command.method,
1051
- params,
1052
- 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 = {
1053
1181
  seq,
1054
- clientId: s.record.clientId,
1055
- },
1056
- attempted: true,
1057
- posted: false,
1058
- applied: false,
1059
- resolve: () => {},
1060
- reject: () => {},
1061
- };
1062
- s.pending.push(entry);
1063
- 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
+ }
1064
1201
  }
1065
- s.pending.sort((a, b) => a.seq - b.seq);
1202
+ if (adopted) s.pending.sort((a, b) => a.seq - b.seq);
1066
1203
  };
1067
1204
 
1068
1205
  const sendCommand = (
@@ -1071,6 +1208,11 @@ const sendCommand = (
1071
1208
  params: readonly unknown[],
1072
1209
  protocol = s.options.protocol ?? DEFAULT_PROTOCOL,
1073
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`);
1074
1216
  if (!s.hydrated) {
1075
1217
  const result = s.ready!.then(() =>
1076
1218
  sendCommand(s, method, params, protocol),
@@ -1119,7 +1261,7 @@ const findFailed = (
1119
1261
  s: Session,
1120
1262
  entry: StatewireClient.FailedCommand,
1121
1263
  ): StatewireClient.FailedCommand | undefined =>
1122
- (s.record.failed ?? []).find(
1264
+ (laneOf(s.record, entry.protocol).failed ?? []).find(
1123
1265
  (f) =>
1124
1266
  f.seq === entry.seq && f.ts === entry.ts && f.command === entry.command,
1125
1267
  );
@@ -1131,10 +1273,16 @@ const takeFailed = (
1131
1273
  s: Session,
1132
1274
  entry: StatewireClient.FailedCommand,
1133
1275
  ): StatewireClient.FailedCommand => {
1134
- const failed = s.record.failed ?? [];
1276
+ const lane = laneOf(s.record, entry.protocol);
1135
1277
  const found = findFailed(s, entry);
1136
1278
  if (found === undefined) throw unknownFailed(entry);
1137
- 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
+ );
1138
1286
  s.notify();
1139
1287
  return found;
1140
1288
  };
@@ -1216,83 +1364,6 @@ export const retryFailed = (
1216
1364
  return result;
1217
1365
  };
1218
1366
 
1219
- const contextSnapshot = (documents: StatewireDocuments.State) =>
1220
- [...documents.documents].map(([doc, d]) => {
1221
- const encoded = encodeDocument(d.value);
1222
- return {
1223
- op: "mount",
1224
- ...(doc === 0 ? {} : { doc }),
1225
- protocol: d.protocol,
1226
- ...(d.main ? { main: true } : {}),
1227
- ...(d.key === null ? {} : { key: d.key }),
1228
- value: encoded.value,
1229
- ...(encoded.meta === undefined ? {} : { meta: encoded.meta }),
1230
- };
1231
- });
1232
-
1233
- const changeContext = (
1234
- s: Session,
1235
- build: (documents: StatewireDocuments.State) => readonly unknown[],
1236
- ): void => {
1237
- if (!s.running || isFinished(s)) return;
1238
- const operations = build(s.contextDocuments);
1239
- if (!operations.length)
1240
- throw new Error("statewire: context operations must not be empty");
1241
- const offers = protocolOffers(s.options);
1242
- const protocols =
1243
- s.selection?.protocols ?? (offers === "*" ? undefined : offers);
1244
- s.contextDocuments = applyDocumentOps(
1245
- s.contextDocuments,
1246
- operations,
1247
- protocols,
1248
- true,
1249
- );
1250
- s.ctxPending = [
1251
- ...s.ctxPending,
1252
- {
1253
- seq: s.ctxNextSeq++,
1254
- ops: operations.map((op) => {
1255
- const value = op as Record<string, unknown>;
1256
- if (value["doc"] !== 0) return value;
1257
- const { doc: _doc, ...rest } = value;
1258
- return rest;
1259
- }),
1260
- posted: false,
1261
- },
1262
- ];
1263
- wake(s);
1264
- void pump(s);
1265
- };
1266
-
1267
- export const updateContext = (s: Session, operations: readonly unknown[]) =>
1268
- changeContext(s, () => operations);
1269
-
1270
- const setContext = (
1271
- s: Session,
1272
- value: Record<string, unknown>,
1273
- protocol = s.options.protocol ?? DEFAULT_PROTOCOL,
1274
- ): void => {
1275
- if (typeof value !== "object" || value === null || Array.isArray(value))
1276
- throw new Error("statewire: context must be a plain object");
1277
- const encoded = encodeDocument(value);
1278
- changeContext(s, (documents) => {
1279
- const found = [...documents.documents].find(
1280
- ([, d]) => d.protocol === protocol && d.main,
1281
- );
1282
- const doc = found?.[0] ?? Math.max(-1, ...documents.documents.keys()) + 1;
1283
- return [
1284
- {
1285
- ...(found === undefined
1286
- ? { op: "mount", protocol, main: true }
1287
- : { op: "replace", path: [] }),
1288
- ...(doc === 0 ? {} : { doc }),
1289
- value: encoded.value,
1290
- ...(encoded.meta === undefined ? {} : { meta: encoded.meta }),
1291
- },
1292
- ];
1293
- });
1294
- };
1295
-
1296
1367
  export const documentSource = (s: Session): StatewireDocuments.Source => ({
1297
1368
  get: (protocol, key, prefix = s.options.keyPrefixes?.[protocol]) => {
1298
1369
  const mounted = findDocument(s.documents, protocol, key, prefix);
@@ -1332,20 +1403,20 @@ export const invokeProtocol = (
1332
1403
  ) => sendCommand(s, method, params, protocol);
1333
1404
 
1334
1405
  const storedCommandCompatible = (
1406
+ protocol: string,
1335
1407
  command: StatewireClient.SessionCommand,
1336
1408
  current: NonNullable<StatewireClient.SessionCommand["produced"]>,
1337
1409
  ) => {
1338
1410
  if (
1339
1411
  !command.produced ||
1340
- !command.protocol ||
1341
1412
  !inRange(command.produced.wire.version, current.wire)
1342
1413
  )
1343
1414
  return false;
1344
1415
  const previous = command.produced.protocols;
1345
1416
  if (current.protocols === "*") return true;
1346
1417
  if (previous === "*") return false;
1347
- const produced = previous.find((p) => p.name === command.protocol);
1348
- 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);
1349
1420
  return (
1350
1421
  produced !== undefined &&
1351
1422
  offer !== undefined &&
@@ -1353,47 +1424,45 @@ const storedCommandCompatible = (
1353
1424
  );
1354
1425
  };
1355
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
+
1356
1438
  const loadSessionRecord = (
1357
1439
  store: StatewireClient.SessionStorage,
1358
1440
  options: StatewireSessionOptions,
1359
1441
  ) => {
1360
1442
  let stored = store.load();
1361
- if (stored !== null && !Array.isArray(stored.commands))
1362
- throw new Error(
1363
- "statewire: session storage loaded a record without a commands array",
1364
- );
1443
+ if (stored !== null) validateLanes(stored);
1365
1444
  const current = {
1366
1445
  wire: options.wireVersion ?? { version: WIRE_VERSION },
1367
1446
  protocols: protocolOffers(options),
1368
1447
  };
1369
- if (
1370
- stored !== null &&
1371
- !stored.commands.every((command) =>
1372
- storedCommandCompatible(command, current),
1373
- )
1374
- ) {
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)) {
1375
1455
  if (!store.migrate)
1376
1456
  throw new Error("statewire: stored commands require a storage migration");
1377
1457
  stored = store.migrate(stored, current);
1378
- if (
1379
- stored !== null &&
1380
- !stored.commands.every((command) =>
1381
- storedCommandCompatible(command, current),
1382
- )
1383
- )
1384
- throw new Error("statewire: migrated commands are incompatible");
1385
- if (stored !== null) store.save(stored);
1386
- }
1387
- if (stored !== null && !Array.isArray(stored.commands)) {
1388
- throw new Error(
1389
- "statewire: session storage loaded a record without a commands array",
1390
- );
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
+ }
1391
1464
  }
1392
- const record = stored ?? {
1393
- clientId: newClientId(),
1394
- nextSeq: 1,
1395
- commands: [],
1396
- };
1465
+ const record = stored ?? { clientId: newClientId(), lanes: {} };
1397
1466
  if (stored === null) store.save(record);
1398
1467
  return record;
1399
1468
  };
@@ -1419,11 +1488,10 @@ export const useStatewireSession = (
1419
1488
  const record =
1420
1489
  store.ready === undefined
1421
1490
  ? loadSessionRecord(store, options)
1422
- : { clientId: newClientId(), nextSeq: 1, commands: [] };
1491
+ : { clientId: newClientId(), lanes: {} };
1423
1492
  const session: Session = {
1424
1493
  options,
1425
1494
  selection: undefined,
1426
- contextDocuments: { mode: "mounted", documents: new Map() },
1427
1495
  record,
1428
1496
  ctx: {
1429
1497
  beforeAttach: () => persistBeforeSend(session),
@@ -1453,17 +1521,18 @@ export const useStatewireSession = (
1453
1521
  ephemeral: false,
1454
1522
  deactivated: false,
1455
1523
  sessionKnown: false,
1456
- durableSeq: 0,
1524
+ durableSeq: {},
1525
+ ephemeralSeqs: {},
1457
1526
  resumeAt: 0,
1458
1527
  idle: false,
1459
1528
  onSessionLost: undefined,
1460
1529
  onDone: undefined,
1461
- ctxNextSeq: 1,
1462
- ctxPending: [],
1463
1530
  lanesOnAttach: false,
1464
- attachLaneSeqs: [new Set(), new Set()],
1531
+ attachEntries: new Set(),
1465
1532
  frameFailures: 0,
1466
1533
  pending: [],
1534
+ failedList: [],
1535
+ failedParts: [],
1467
1536
  canonical: undefined,
1468
1537
  documentListeners: new Set(),
1469
1538
  documents: { mode: null, documents: new Map() },
@@ -1542,4 +1611,4 @@ export const useStatewireSession = (
1542
1611
  return s;
1543
1612
  };
1544
1613
 
1545
- export { setContext, setTransport };
1614
+ export { setTransport };