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,13 +1,13 @@
1
- import { encodeDocument, reviveDocumentValue } from "../document.js";
1
+ import { reviveDocumentValue } from "../document.js";
2
2
  import { inRange, protocolOffers } from "../wire/protocol.js";
3
- import { encodeParams, reviveResponse } from "../wire/meta.js";
4
- import { applyDocumentOps, findDocument, mainDocument, resolveDocumentKey } from "../wire/documents.js";
5
3
  import { createMethodProxy } from "../util/methodProxy.js";
6
4
  import { StatewireSendError } from "../types.js";
7
5
  import { bindCommandsHost } from "../host/host.js";
6
+ import { encodeParams, reviveResponse } from "../wire/meta.js";
8
7
  import "../wire/limits.js";
8
+ import { applyDocumentOps, findDocument, mainDocument, resolveDocumentKey } from "../wire/documents.js";
9
9
  import { DocumentStorageContext, SessionStorageContext, createMemorySessionStore } from "./sessionStore.js";
10
- import { backoff } from "./timing.js";
10
+ import { backoff, retryHint } from "./timing.js";
11
11
  import { wrapStorageError } from "./storageError.js";
12
12
  import { TERMINAL } from "./connection.js";
13
13
  import { randomId } from "../util/randomId.js";
@@ -21,6 +21,21 @@ const rejectedSend = (failure) => {
21
21
  result.catch(() => {});
22
22
  return result;
23
23
  };
24
+ const EMPTY_LANE = {
25
+ nextSeq: 1,
26
+ commands: []
27
+ };
28
+ const laneOf = (record, protocol) => record.lanes[protocol] ?? EMPTY_LANE;
29
+ const withLane = (record, protocol, lane) => ({
30
+ ...record,
31
+ lanes: {
32
+ ...record.lanes,
33
+ [protocol]: lane
34
+ }
35
+ });
36
+ const isEphemeral = (s, protocol) => s.selection?.protocols.some((p) => p.name === protocol && p.record === "ephemeral") ?? false;
37
+ const durableSeqOf = (s, protocol) => s.durableSeq[protocol] ?? 0;
38
+ const laneEntries = (s, protocol) => s.pending.filter((p) => p.command.protocol === protocol);
24
39
  const publish = (s) => {
25
40
  if (s.canonical === s.lastPublished) return;
26
41
  s.lastPublished = s.canonical;
@@ -58,26 +73,43 @@ const setRecord = (s, record) => {
58
73
  throw s.storageError;
59
74
  }
60
75
  };
61
- const backlog = (s) => s.pending.filter((p) => p.seq > s.durableSeq).map((p) => ({
62
- seq: p.seq,
63
- ...p.command
64
- }));
65
- const mergeBacklog = (s) => {
66
- const own = backlog(s);
76
+ const toStored = (p) => {
77
+ const { protocol: _protocol, ...command } = p.command;
78
+ return {
79
+ seq: p.seq,
80
+ ...command
81
+ };
82
+ };
83
+ const backlog = (s, protocol) => laneEntries(s, protocol).filter((p) => p.seq > durableSeqOf(s, protocol)).map(toStored);
84
+ const mergeBacklog = (s, protocol) => {
85
+ const own = backlog(s, protocol);
67
86
  if (!s.shared) return own;
68
- const known = new Set(s.pending.map((p) => p.seq));
69
- return [...s.record.commands.filter((c) => !known.has(c.seq) && c.seq > s.durableSeq), ...own].sort((a, b) => a.seq - b.seq);
87
+ const known = new Set(laneEntries(s, protocol).map((p) => p.seq));
88
+ return [...laneOf(s.record, protocol).commands.filter((c) => !known.has(c.seq) && c.seq > durableSeqOf(s, protocol)), ...own].sort((a, b) => a.seq - b.seq);
70
89
  };
90
+ const durableLanes = (s) => [.../* @__PURE__ */ new Set([...Object.keys(s.record.lanes), ...s.pending.map((p) => p.command.protocol).filter((protocol) => !isEphemeral(s, protocol))])];
71
91
  const syncBacklogInner = (s) => {
72
92
  const parked = s.shared ? s.shared.parked.splice(0) : [];
73
- const parkedSeqs = new Set(parked.map((v) => v.seq));
74
- const commands = mergeBacklog(s).filter((c) => !parkedSeqs.has(c.seq));
75
- const verdicts = parked.length > 0 ? [...s.record.verdicts ?? [], ...parked].slice(-64) : s.record.verdicts;
76
- if (verdicts === s.record.verdicts && commands.length === s.record.commands.length && commands.every((c, i) => c.seq === s.record.commands[i].seq)) return;
77
- setRecord(s, {
93
+ let lanes = s.record.lanes;
94
+ for (const protocol of durableLanes(s)) {
95
+ const lane = laneOf(s.record, protocol);
96
+ const parkedHere = parked.filter((v) => v.protocol === protocol).map((v) => v.verdict);
97
+ const parkedSeqs = new Set(parkedHere.map((v) => v.seq));
98
+ const commands = mergeBacklog(s, protocol).filter((c) => !parkedSeqs.has(c.seq));
99
+ const verdicts = parkedHere.length > 0 ? [...lane.verdicts ?? [], ...parkedHere].slice(-64) : lane.verdicts;
100
+ if (verdicts === lane.verdicts && commands.length === lane.commands.length && commands.every((c, i) => c.seq === lane.commands[i].seq)) continue;
101
+ lanes = {
102
+ ...lanes,
103
+ [protocol]: {
104
+ ...lane,
105
+ commands,
106
+ ...verdicts !== void 0 && { verdicts }
107
+ }
108
+ };
109
+ }
110
+ if (lanes !== s.record.lanes) setRecord(s, {
78
111
  ...s.record,
79
- commands,
80
- ...verdicts !== void 0 && { verdicts }
112
+ lanes
81
113
  });
82
114
  };
83
115
  const syncBacklog = (s) => {
@@ -119,12 +151,19 @@ const cover = (s, entry) => {
119
151
  s.lifecycle?.onApplied(entry.inv);
120
152
  s.lifecycle?.onCovered?.(entry.inv);
121
153
  };
122
- const NO_FAILED = [];
123
- const failedCommands = (s) => s.record.failed ?? NO_FAILED;
154
+ const failedCommands = (s) => {
155
+ const parts = Object.values(s.record.lanes).flatMap((lane) => lane.failed === void 0 ? [] : [lane.failed]);
156
+ if (!(parts.length === s.failedParts.length && parts.every((part, i) => part === s.failedParts[i]))) {
157
+ s.failedParts = parts;
158
+ s.failedList = parts.flat();
159
+ }
160
+ return s.failedList;
161
+ };
124
162
  const retainFailedInner = (s, entry, failure) => {
125
- setRecord(s, {
126
- ...s.record,
127
- failed: [...s.record.failed ?? [], {
163
+ const lane = laneOf(s.record, entry.command.protocol);
164
+ setRecord(s, withLane(s.record, entry.command.protocol, {
165
+ ...lane,
166
+ failed: [...lane.failed ?? [], {
128
167
  protocol: entry.command.protocol,
129
168
  ...entry.command.produced !== void 0 && { produced: entry.command.produced },
130
169
  command: entry.command.method,
@@ -134,7 +173,7 @@ const retainFailedInner = (s, entry, failure) => {
134
173
  ts: entry.inv.ts,
135
174
  seq: entry.seq
136
175
  }]
137
- });
176
+ }));
138
177
  s.notify();
139
178
  };
140
179
  const retainFailed = (s, entry, failure) => {
@@ -153,7 +192,7 @@ const settleEntry = (s, entry, outcome) => {
153
192
  payload: outcome.payload
154
193
  });
155
194
  } else {
156
- if (!((outcome.failure.cause === "unmount" || outcome.failure.cause === "stream-gone") && entry.seq > s.durableSeq)) retainFailed(s, entry, outcome.failure);
195
+ if (!((outcome.failure.cause === "unmount" || outcome.failure.cause === "stream-gone") && entry.seq > durableSeqOf(s, entry.command.protocol)) && !isEphemeral(s, entry.command.protocol)) retainFailed(s, entry, outcome.failure);
157
196
  entry.reject(new StatewireSendError(outcome.failure));
158
197
  s.lifecycle?.onSettle(entry.inv, {
159
198
  type: "rejected",
@@ -166,7 +205,7 @@ const settleAll = (s, detach) => {
166
205
  const batch = s.pending;
167
206
  const detachFailure = (entry) => {
168
207
  if (!entry.attempted) {
169
- const predecessorLost = batch.some((p) => p.seq < entry.seq && p.attempted);
208
+ const predecessorLost = batch.some((p) => p.command.protocol === entry.command.protocol && p.seq < entry.seq && p.attempted);
170
209
  return predecessorLost ? {
171
210
  fate: "aborted",
172
211
  cause,
@@ -201,30 +240,45 @@ const settleLost = (s, entries) => {
201
240
  });
202
241
  }
203
242
  };
243
+ const requeue = (s, survivors) => {
244
+ for (const old of survivors) {
245
+ s.lifecycle?.onCovered?.(old.inv);
246
+ enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
247
+ }
248
+ };
204
249
  const rebaseRecord = (s, adopt) => {
205
250
  const held = [...s.pending];
206
251
  const survivors = held.filter((p) => !p.attempted && !p.applied);
207
252
  settleLost(s, held.filter((p) => p.attempted || p.applied));
208
253
  s.pending = [];
209
- s.durableSeq = 0;
210
- const failed = s.record.failed;
254
+ s.durableSeq = {};
255
+ const previous = s.record.lanes;
211
256
  adopt();
212
- if (failed !== void 0 && s.record.failed !== failed) setRecord(s, {
257
+ let lanes = s.record.lanes;
258
+ for (const [protocol, lane] of Object.entries(previous)) {
259
+ const failed = lane.failed;
260
+ if (failed === void 0 || laneOf(s.record, protocol).failed === failed) continue;
261
+ const current = laneOf(s.record, protocol);
262
+ lanes = {
263
+ ...lanes,
264
+ [protocol]: {
265
+ ...current,
266
+ failed: [...current.failed ?? [], ...failed]
267
+ }
268
+ };
269
+ }
270
+ if (lanes !== s.record.lanes) setRecord(s, {
213
271
  ...s.record,
214
- failed: [...s.record.failed ?? [], ...failed]
272
+ lanes
215
273
  });
216
- for (const old of survivors) {
217
- s.lifecycle?.onCovered?.(old.inv);
218
- enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
219
- }
274
+ requeue(s, survivors);
220
275
  publish(s);
221
276
  wake(s);
222
277
  pump(s);
223
278
  };
224
279
  const resetRecord = (s, clientId) => rebaseRecord(s, () => setRecord(s, {
225
280
  clientId,
226
- nextSeq: 1,
227
- commands: []
281
+ lanes: {}
228
282
  }));
229
283
  const adoptReset = (s, stored) => rebaseRecord(s, () => {
230
284
  s.record = stored;
@@ -234,6 +288,14 @@ const ephemeralDetach = (s) => {
234
288
  s.sessionKnown = false;
235
289
  resetRecord(s, randomId());
236
290
  };
291
+ const detachEphemeralLanes = (s) => {
292
+ const held = s.pending.filter((p) => isEphemeral(s, p.command.protocol));
293
+ if (held.length === 0) return;
294
+ s.pending = s.pending.filter((p) => !held.includes(p));
295
+ settleLost(s, held.filter((p) => p.attempted));
296
+ requeue(s, held.filter((p) => !p.attempted));
297
+ publish(s);
298
+ };
237
299
  const rejectedFailure = (cmd) => {
238
300
  return {
239
301
  fate: "rejected",
@@ -278,50 +340,72 @@ const outcomeOf = (cmd) => {
278
340
  default: return protocolError(`unknown cmd type: ${String(cmd.type)}`);
279
341
  }
280
342
  };
343
+ const settleVerdict = (s, entry, cmd) => {
344
+ const outcome = outcomeOf(cmd);
345
+ if (outcome.ok) cover(s, entry);
346
+ else s.lifecycle?.onCovered?.(entry.inv);
347
+ settleEntry(s, entry, outcome);
348
+ };
281
349
  const TERMINAL_CMD_TYPES = new Set(TERMINAL);
282
350
  const parkVerdict = (s, cmd) => {
283
351
  if (!s.shared || !TERMINAL_CMD_TYPES.has(cmd.type)) return;
352
+ if (isEphemeral(s, cmd.protocol)) return;
284
353
  s.shared.parked.push({
285
- seq: cmd.seq,
286
- type: cmd.type,
287
- ...cmd.code !== void 0 && { code: cmd.code },
288
- ...cmd.message !== void 0 && { message: cmd.message },
289
- ...cmd.payload !== void 0 && { payload: cmd.payload },
290
- ...cmd.meta !== void 0 && { meta: cmd.meta }
354
+ protocol: cmd.protocol,
355
+ verdict: {
356
+ seq: cmd.seq,
357
+ type: cmd.type,
358
+ ...cmd.code !== void 0 && { code: cmd.code },
359
+ ...cmd.message !== void 0 && { message: cmd.message },
360
+ ...cmd.payload !== void 0 && { payload: cmd.payload },
361
+ ...cmd.meta !== void 0 && { meta: cmd.meta }
362
+ }
291
363
  });
292
364
  };
293
365
  const handleCmd = (s, cmd) => {
294
- if (!Number.isSafeInteger(cmd.seq) || cmd.seq <= 0) protocolError("cmd.seq must be a positive safe integer");
295
- if (cmd.dur === true) {
296
- s.durableSeq = Math.max(s.durableSeq, cmd.seq);
297
- for (const entry of s.pending) if (entry.seq <= s.durableSeq) entry.posted = true;
366
+ if (cmd.dur === true && !isEphemeral(s, cmd.protocol)) {
367
+ const watermark = Math.max(durableSeqOf(s, cmd.protocol), cmd.seq);
368
+ s.durableSeq[cmd.protocol] = watermark;
369
+ for (const entry of laneEntries(s, cmd.protocol)) if (entry.seq <= watermark) entry.posted = true;
298
370
  }
299
371
  if (cmd.type === void 0) return;
300
- const entry = s.pending.find((p) => p.seq === cmd.seq);
372
+ const entry = s.pending.find((p) => p.command.protocol === cmd.protocol && p.seq === cmd.seq);
301
373
  if (cmd.type === "applied") {
302
374
  if (entry) cover(s, entry);
303
375
  return;
304
376
  }
305
377
  if (!entry) return;
306
378
  parkVerdict(s, cmd);
307
- cover(s, entry);
308
- settleEntry(s, entry, outcomeOf(cmd));
379
+ settleVerdict(s, entry, cmd);
309
380
  };
310
381
  const consumeVerdicts = (s) => {
311
- const verdicts = s.record.verdicts ?? [];
312
- const consumed = /* @__PURE__ */ new Set();
313
- for (const verdict of verdicts) {
314
- const entry = s.pending.find((p) => p.seq === verdict.seq);
315
- if (!entry) continue;
316
- consumed.add(verdict.seq);
317
- cover(s, entry);
318
- settleEntry(s, entry, outcomeOf(verdict));
382
+ let lanes = s.record.lanes;
383
+ for (const [protocol, lane] of Object.entries(s.record.lanes)) {
384
+ const verdicts = lane.verdicts ?? [];
385
+ const consumed = /* @__PURE__ */ new Set();
386
+ for (const verdict of verdicts) {
387
+ const entry = laneEntries(s, protocol).find((p) => p.seq === verdict.seq);
388
+ if (!entry) continue;
389
+ consumed.add(verdict.seq);
390
+ settleVerdict(s, entry, {
391
+ ...verdict,
392
+ protocol
393
+ });
394
+ }
395
+ if (consumed.size === 0) continue;
396
+ lanes = {
397
+ ...lanes,
398
+ [protocol]: {
399
+ ...lane,
400
+ commands: lane.commands.filter((c) => !consumed.has(c.seq)),
401
+ verdicts: verdicts.filter((v) => !consumed.has(v.seq))
402
+ }
403
+ };
319
404
  }
320
- if (consumed.size === 0) return;
405
+ if (lanes === s.record.lanes) return;
321
406
  setRecord(s, {
322
407
  ...s.record,
323
- commands: s.record.commands.filter((c) => !consumed.has(c.seq)),
324
- verdicts: verdicts.filter((v) => !consumed.has(v.seq))
408
+ lanes
325
409
  });
326
410
  publish(s);
327
411
  wake(s);
@@ -359,28 +443,36 @@ const publishIngested = (s) => {
359
443
  for (const listener of s.documentListeners) listener();
360
444
  wake(s);
361
445
  };
362
- const sessionLostInner = (s, seqWm) => {
363
- const held = [...s.pending];
446
+ const sessionLostInner = (s, protocol, seqWm) => {
447
+ const held = laneEntries(s, protocol);
364
448
  settleLost(s, held);
365
- s.pending = [];
366
- s.durableSeq = seqWm;
367
- setRecord(s, {
368
- clientId: s.record.clientId,
449
+ s.durableSeq[protocol] = seqWm;
450
+ const failed = laneOf(s.record, protocol).failed;
451
+ setRecord(s, withLane(s.record, protocol, {
369
452
  nextSeq: seqWm + 1,
370
453
  commands: [],
371
- ...s.record.failed !== void 0 && { failed: s.record.failed }
372
- });
454
+ ...failed !== void 0 && { failed }
455
+ }));
373
456
  if (held.length > 0 && s.onSessionLost?.(held.map((p) => p.inv)) === "requeue") for (const old of held) enqueueInner(s, old.command, old.inv.params, () => {}, () => {});
374
457
  publish(s);
375
458
  wake(s);
376
459
  };
377
- const handleHello = (s, { ops, syn }) => {
378
- if (syn.record !== void 0 && syn.record !== "ephemeral") protocolError(`unknown record: ${String(syn.record)}`);
379
- if (syn.ctxSeq !== void 0 && syn.ctxSeq !== 0) protocolError(`hello ctxSeq must be 0, got ${syn.ctxSeq}`);
380
- s.selection = syn.version === void 0 || syn.protocols === void 0 ? void 0 : {
460
+ const selectionOf = (syn) => {
461
+ if (syn.version === void 0 || syn.protocols === void 0) return void 0;
462
+ return {
381
463
  version: syn.version,
382
- protocols: syn.protocols
464
+ protocols: syn.protocols.map(({ name, version, record }) => {
465
+ if (version === void 0) return protocolError(`hello lane ${name} has no version`);
466
+ return {
467
+ name,
468
+ version,
469
+ ...record !== void 0 && { record }
470
+ };
471
+ })
383
472
  };
473
+ };
474
+ const handleHello = (s, { ops, syn }) => {
475
+ s.selection = selectionOf(syn);
384
476
  s.documents = {
385
477
  mode: null,
386
478
  documents: /* @__PURE__ */ new Map()
@@ -390,15 +482,16 @@ const handleHello = (s, { ops, syn }) => {
390
482
  s.attachEpoch += 1;
391
483
  s.frameFailures = 0;
392
484
  s.resumeAt = 0;
393
- s.ephemeral = syn.record === "ephemeral";
485
+ const lanes = syn.protocols ?? [];
486
+ s.ephemeral = lanes.length > 0 && lanes.every((lane) => lane.record === "ephemeral");
394
487
  if (s.ephemeral) s.deactivated = true;
395
- const [attachCmd, attachCtx] = s.attachLaneSeqs;
396
- s.attachLaneSeqs = [/* @__PURE__ */ new Set(), /* @__PURE__ */ new Set()];
488
+ const attached = s.attachEntries;
489
+ s.attachEntries = /* @__PURE__ */ new Set();
397
490
  const reconcile = () => {
398
491
  if (s.shared) adoptStored(s);
399
- s.durableSeq = syn.seq ?? 0;
400
- for (const entry of s.pending) entry.posted = attachCmd.has(entry.seq);
401
- for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
492
+ for (const lane of lanes) if (lane.record === "ephemeral") s.ephemeralSeqs[lane.name] = Math.max(s.ephemeralSeqs[lane.name] ?? 0, ...laneEntries(s, lane.name).map((p) => p.seq));
493
+ else s.durableSeq[lane.name] = lane.seq ?? 0;
494
+ for (const entry of s.pending) entry.posted = attached.has(entry);
402
495
  syncBacklogInner(s);
403
496
  s.sessionKnown = true;
404
497
  };
@@ -420,26 +513,26 @@ const handleOps = (s, { ops, cmds }) => {
420
513
  publishIngested(s);
421
514
  };
422
515
  const handleRefusal = (s, syn) => {
423
- const { seq, ctxSeq } = syn;
424
- if (typeof seq === "number") {
425
- const watermark = Math.max(seq, s.durableSeq);
426
- for (const entry of s.pending) entry.posted = entry.seq <= watermark;
427
- }
428
- if (typeof ctxSeq === "number") {
429
- s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxSeq);
430
- for (const c of s.ctxPending) c.posted = false;
431
- const firstCtx = s.ctxPending.find((c) => !c.posted);
432
- if (firstCtx !== void 0 && firstCtx.seq > ctxSeq + 1) protocolError(`ctx gap on a live attach: seq ${firstCtx.seq} after watermark ${ctxSeq}`);
516
+ const lanes = (syn.protocols ?? []).filter((lane) => lane.seq !== void 0);
517
+ for (const { name, seq } of lanes) {
518
+ const watermark = isEphemeral(s, name) ? seq : Math.max(seq, durableSeqOf(s, name));
519
+ for (const entry of laneEntries(s, name)) entry.posted = entry.seq <= watermark;
433
520
  }
434
521
  if (typeof syn.retry === "number") {
435
- s.resumeAt = Date.now() + syn.retry;
522
+ s.resumeAt = Date.now() + retryHint(syn.retry);
436
523
  wake(s);
437
524
  pump(s);
438
525
  return;
439
526
  }
440
- const firstCmd = s.pending.find((p) => !p.posted);
441
- if (typeof seq === "number" && firstCmd !== void 0 && firstCmd.seq > Math.max(seq, s.durableSeq) + 1) {
442
- transactBackground(s, () => sessionLostInner(s, seq), () => {
527
+ const gaps = lanes.filter(({ name, seq }) => {
528
+ const first = laneEntries(s, name).find((p) => !p.posted);
529
+ return first !== void 0 && first.seq > Math.max(seq, durableSeqOf(s, name)) + 1;
530
+ });
531
+ for (const { name, seq } of gaps) if (isEphemeral(s, name)) protocolError(`gap on ephemeral lane ${name} after watermark ${seq}`);
532
+ if (gaps.length > 0) {
533
+ transactBackground(s, () => {
534
+ for (const { name, seq } of gaps) sessionLostInner(s, name, seq);
535
+ }, () => {
443
536
  wake(s);
444
537
  pump(s);
445
538
  });
@@ -450,7 +543,9 @@ const handleRefusal = (s, syn) => {
450
543
  };
451
544
  const handleDetach = (s) => {
452
545
  if (s.attach === "attached") s.attach = "detached";
546
+ s.ephemeralSeqs = {};
453
547
  if (s.ephemeral) ephemeralDetach(s);
548
+ else detachEphemeralLanes(s);
454
549
  wake(s);
455
550
  };
456
551
  const handleFinish = (s, { reason }) => {
@@ -462,59 +557,40 @@ const handleFinish = (s, { reason }) => {
462
557
  };
463
558
  const isFinished = (s) => s.attach === "finished";
464
559
  const buildLanes = (s, cmdGate = "all") => {
465
- const ctxEntries = [];
466
- for (const entry of s.ctxPending) {
467
- if (entry.posted) continue;
468
- if (ctxEntries.length >= 256) break;
469
- if (ctxEntries.length > 0 && entry.seq !== ctxEntries.at(-1).seq + 1) break;
470
- ctxEntries.push(entry);
471
- }
472
- const cmdEntries = [];
560
+ const entries = [];
561
+ const last = {};
562
+ const stopped = /* @__PURE__ */ new Set();
473
563
  for (const entry of s.pending) {
474
- if (cmdGate === "none") break;
475
- if (entry.posted) continue;
476
- if (cmdGate === "persisted" && (s.persistedRecord?.clientId !== s.record.clientId || entry.seq >= s.persistedRecord.nextSeq)) break;
477
- if (cmdEntries.length >= 256) break;
478
- if (cmdEntries.length > 0 && entry.seq !== cmdEntries.at(-1).seq + 1) break;
479
- cmdEntries.push(entry);
564
+ if (entries.length >= 256) break;
565
+ const protocol = entry.command.protocol;
566
+ if (entry.posted || stopped.has(protocol)) continue;
567
+ if (!isEphemeral(s, protocol) && (cmdGate === "none" || cmdGate === "persisted" && (s.persistedRecord?.clientId !== s.record.clientId || entry.seq >= laneOf(s.persistedRecord, protocol).nextSeq)) || protocol in last && entry.seq !== last[protocol] + 1) {
568
+ stopped.add(protocol);
569
+ continue;
570
+ }
571
+ last[protocol] = entry.seq;
572
+ entries.push(entry);
480
573
  }
481
- if (ctxEntries.length === 0 && cmdEntries.length === 0) return null;
574
+ if (entries.length === 0) return null;
482
575
  return {
483
- frame: {
484
- ...ctxEntries.length > 0 && { ctx: ctxEntries.map((c) => ({
485
- ops: c.ops,
486
- seq: c.seq
487
- })) },
488
- ...cmdEntries.length > 0 && { cmd: cmdEntries.map((p) => {
489
- const { produced: _produced, ...command } = p.command;
490
- return {
491
- ...command,
492
- seq: p.seq
493
- };
494
- }) }
495
- },
496
- cmdEntries,
497
- ctxEntries
576
+ frame: { cmd: entries.map((p) => {
577
+ const { produced: _produced, ...command } = p.command;
578
+ return {
579
+ ...command,
580
+ seq: p.seq
581
+ };
582
+ }) },
583
+ entries
498
584
  };
499
585
  };
500
- const resetContextLane = (s) => {
501
- const ops = contextSnapshot(s.contextDocuments);
502
- s.ctxPending = ops.length === 0 ? [] : [{
503
- seq: 1,
504
- ops,
505
- posted: false
506
- }];
507
- s.ctxNextSeq = s.ctxPending.length + 1;
508
- };
509
586
  const takeAttachLanes = (s) => {
510
- s.attachLaneSeqs = [/* @__PURE__ */ new Set(), /* @__PURE__ */ new Set()];
511
- resetContextLane(s);
587
+ s.attachEntries = /* @__PURE__ */ new Set();
512
588
  const wanted = s.lanesOnAttach;
513
589
  s.lanesOnAttach = false;
514
590
  const lanes = buildLanes(s, wanted && s.sessionKnown ? "persisted" : "none");
515
591
  if (!lanes) return null;
516
- s.attachLaneSeqs = [new Set(lanes.cmdEntries.map((p) => p.seq)), new Set(lanes.ctxEntries.map((c) => c.seq))];
517
- for (const entry of lanes.cmdEntries) entry.attempted = true;
592
+ s.attachEntries = new Set(lanes.entries);
593
+ for (const entry of lanes.entries) entry.attempted = true;
518
594
  return lanes.frame;
519
595
  };
520
596
  const persistBeforeSend = (s) => {
@@ -558,7 +634,7 @@ const pump = async (s) => {
558
634
  if (persistence) await persistence;
559
635
  if (!s.running || isFinished(s)) return;
560
636
  if (epoch !== s.attachEpoch || s.attach !== "attached") continue;
561
- for (const entry of lanes.cmdEntries) entry.attempted = true;
637
+ for (const entry of lanes.entries) entry.attempted = true;
562
638
  let result;
563
639
  try {
564
640
  const send = () => transport.send(lanes.frame);
@@ -573,11 +649,10 @@ const pump = async (s) => {
573
649
  s.frameFailures = 0;
574
650
  if (result.type === "ok") {
575
651
  if (epoch === s.attachEpoch) {
576
- for (const entry of lanes.cmdEntries) if (s.pending.includes(entry)) {
652
+ for (const entry of lanes.entries) if (s.pending.includes(entry)) {
577
653
  entry.posted = true;
578
654
  s.lifecycle?.onPosted?.(entry.inv);
579
655
  }
580
- for (const entry of lanes.ctxEntries) entry.posted = true;
581
656
  }
582
657
  } else if (result.type === "refusal") {
583
658
  if (epoch === s.attachEpoch) handleRefusal(s, result.syn);
@@ -644,12 +719,14 @@ const setTransport = (s, transport) => {
644
719
  wake(s);
645
720
  };
646
721
  const enqueueInner = (s, command, params, resolve, reject) => {
647
- const seq = Math.max(s.record.nextSeq, s.durableSeq + 1);
722
+ const protocol = command.protocol;
723
+ const ephemeral = isEphemeral(s, protocol);
724
+ const seq = ephemeral ? (s.ephemeralSeqs[protocol] ?? 0) + 1 : Math.max(laneOf(s.record, protocol).nextSeq, durableSeqOf(s, protocol) + 1);
648
725
  const entry = {
649
726
  seq,
650
727
  command,
651
728
  inv: {
652
- protocol: command.protocol,
729
+ protocol,
653
730
  method: command.method,
654
731
  params,
655
732
  ts: Date.now(),
@@ -663,11 +740,12 @@ const enqueueInner = (s, command, params, resolve, reject) => {
663
740
  reject
664
741
  };
665
742
  s.pending.push(entry);
666
- setRecord(s, {
667
- ...s.record,
743
+ if (ephemeral) s.ephemeralSeqs[protocol] = seq;
744
+ else setRecord(s, withLane(s.record, protocol, {
745
+ ...laneOf(s.record, protocol),
668
746
  nextSeq: seq + 1,
669
- commands: mergeBacklog(s)
670
- });
747
+ commands: mergeBacklog(s, protocol)
748
+ }));
671
749
  s.lifecycle?.onStart(entry.inv);
672
750
  };
673
751
  const enqueue = (s, command, params, resolve, reject) => {
@@ -698,37 +776,47 @@ const enqueue = (s, command, params, resolve, reject) => {
698
776
  pump(s);
699
777
  }, reject);
700
778
  };
779
+ const offered = (s, protocol) => {
780
+ const offers = protocolOffers(s.options);
781
+ return offers === "*" || offers.some((offer) => offer.name === protocol);
782
+ };
701
783
  const adoptStored = (s) => {
702
- const known = new Set(s.pending.map((p) => p.seq));
703
- const stored = s.record.commands.filter((c) => !known.has(c.seq));
704
- if (stored.length === 0) return;
705
- for (const { seq, ...command } of stored) {
706
- const offers = protocolOffers(s.options);
707
- if (offers !== "*" && !offers.some((offer) => offer.name === command.protocol)) continue;
708
- const params = command.meta === void 0 ? command.params : reviveDocumentValue([...command.params], command.meta);
709
- const entry = {
710
- seq,
711
- command,
712
- inv: {
713
- protocol: command.protocol,
714
- method: command.method,
715
- params,
716
- ts: Date.now(),
784
+ let adopted = false;
785
+ for (const [protocol, lane] of Object.entries(s.record.lanes)) {
786
+ if (!offered(s, protocol)) continue;
787
+ const known = new Set(laneEntries(s, protocol).map((p) => p.seq));
788
+ for (const { seq, ...command } of lane.commands) {
789
+ if (known.has(seq)) continue;
790
+ const params = command.meta === void 0 ? command.params : reviveDocumentValue([...command.params], command.meta);
791
+ const entry = {
717
792
  seq,
718
- clientId: s.record.clientId
719
- },
720
- attempted: true,
721
- posted: false,
722
- applied: false,
723
- resolve: () => {},
724
- reject: () => {}
725
- };
726
- s.pending.push(entry);
727
- s.lifecycle?.onStart(entry.inv);
793
+ command: {
794
+ protocol,
795
+ ...command
796
+ },
797
+ inv: {
798
+ protocol,
799
+ method: command.method,
800
+ params,
801
+ ts: Date.now(),
802
+ seq,
803
+ clientId: s.record.clientId
804
+ },
805
+ attempted: true,
806
+ posted: false,
807
+ applied: false,
808
+ resolve: () => {},
809
+ reject: () => {}
810
+ };
811
+ s.pending.push(entry);
812
+ s.lifecycle?.onStart(entry.inv);
813
+ adopted = true;
814
+ }
728
815
  }
729
- s.pending.sort((a, b) => a.seq - b.seq);
816
+ if (adopted) s.pending.sort((a, b) => a.seq - b.seq);
730
817
  };
731
818
  const sendCommand = (s, method, params, protocol = s.options.protocol ?? "default") => {
819
+ if (!(s.selection ? s.selection.protocols.some((p) => p.name === protocol) : offered(s, protocol))) throw new Error(`statewire: protocol "${protocol}" is not negotiated`);
732
820
  if (!s.hydrated) {
733
821
  const result = s.ready.then(() => sendCommand(s, method, params, protocol));
734
822
  result.catch(() => {});
@@ -752,7 +840,7 @@ const sendCommand = (s, method, params, protocol = s.options.protocol ?? "defaul
752
840
  enqueue(s, {
753
841
  protocol,
754
842
  produced: {
755
- wire: s.options.wireVersion ?? { version: "2026-09-07" },
843
+ wire: s.options.wireVersion ?? { version: "2026-09-13" },
756
844
  protocols: protocolOffers(s.options) === "*" && s.selection ? s.selection.protocols : protocolOffers(s.options)
757
845
  },
758
846
  method,
@@ -763,16 +851,16 @@ const sendCommand = (s, method, params, protocol = s.options.protocol ?? "defaul
763
851
  result.catch(() => {});
764
852
  return result;
765
853
  };
766
- const findFailed = (s, entry) => (s.record.failed ?? []).find((f) => f.seq === entry.seq && f.ts === entry.ts && f.command === entry.command);
854
+ const findFailed = (s, entry) => (laneOf(s.record, entry.protocol).failed ?? []).find((f) => f.seq === entry.seq && f.ts === entry.ts && f.command === entry.command);
767
855
  const unknownFailed = (entry) => /* @__PURE__ */ new Error(`statewire: unknown failed command "${entry.command}"`);
768
856
  const takeFailed = (s, entry) => {
769
- const failed = s.record.failed ?? [];
857
+ const lane = laneOf(s.record, entry.protocol);
770
858
  const found = findFailed(s, entry);
771
859
  if (found === void 0) throw unknownFailed(entry);
772
- setRecord(s, {
773
- ...s.record,
774
- failed: failed.filter((f) => f !== found)
775
- });
860
+ setRecord(s, withLane(s.record, entry.protocol, {
861
+ ...lane,
862
+ failed: (lane.failed ?? []).filter((f) => f !== found)
863
+ }));
776
864
  s.notify();
777
865
  return found;
778
866
  };
@@ -842,60 +930,6 @@ const retryFailed = (s, entry) => {
842
930
  result.catch(() => {});
843
931
  return result;
844
932
  };
845
- const contextSnapshot = (documents) => [...documents.documents].map(([doc, d]) => {
846
- const encoded = encodeDocument(d.value);
847
- return {
848
- op: "mount",
849
- ...doc === 0 ? {} : { doc },
850
- protocol: d.protocol,
851
- ...d.main ? { main: true } : {},
852
- ...d.key === null ? {} : { key: d.key },
853
- value: encoded.value,
854
- ...encoded.meta === void 0 ? {} : { meta: encoded.meta }
855
- };
856
- });
857
- const changeContext = (s, build) => {
858
- if (!s.running || isFinished(s)) return;
859
- const operations = build(s.contextDocuments);
860
- if (!operations.length) throw new Error("statewire: context operations must not be empty");
861
- const offers = protocolOffers(s.options);
862
- const protocols = s.selection?.protocols ?? (offers === "*" ? void 0 : offers);
863
- s.contextDocuments = applyDocumentOps(s.contextDocuments, operations, protocols, true);
864
- s.ctxPending = [...s.ctxPending, {
865
- seq: s.ctxNextSeq++,
866
- ops: operations.map((op) => {
867
- const value = op;
868
- if (value["doc"] !== 0) return value;
869
- const { doc: _doc, ...rest } = value;
870
- return rest;
871
- }),
872
- posted: false
873
- }];
874
- wake(s);
875
- pump(s);
876
- };
877
- const updateContext = (s, operations) => changeContext(s, () => operations);
878
- const setContext = (s, value, protocol = s.options.protocol ?? "default") => {
879
- if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("statewire: context must be a plain object");
880
- const encoded = encodeDocument(value);
881
- changeContext(s, (documents) => {
882
- const found = [...documents.documents].find(([, d]) => d.protocol === protocol && d.main);
883
- const doc = found?.[0] ?? Math.max(-1, ...documents.documents.keys()) + 1;
884
- return [{
885
- ...found === void 0 ? {
886
- op: "mount",
887
- protocol,
888
- main: true
889
- } : {
890
- op: "replace",
891
- path: []
892
- },
893
- ...doc === 0 ? {} : { doc },
894
- value: encoded.value,
895
- ...encoded.meta === void 0 ? {} : { meta: encoded.meta }
896
- }];
897
- });
898
- };
899
933
  const documentSource = (s) => ({
900
934
  get: (protocol, key, prefix = s.options.keyPrefixes?.[protocol]) => {
901
935
  const mounted = findDocument(s.documents, protocol, key, prefix);
@@ -922,33 +956,38 @@ const documentSource = (s) => ({
922
956
  }
923
957
  });
924
958
  const invokeProtocol = (s, protocol, method, params) => sendCommand(s, method, params, protocol);
925
- const storedCommandCompatible = (command, current) => {
926
- if (!command.produced || !command.protocol || !inRange(command.produced.wire.version, current.wire)) return false;
959
+ const storedCommandCompatible = (protocol, command, current) => {
960
+ if (!command.produced || !inRange(command.produced.wire.version, current.wire)) return false;
927
961
  const previous = command.produced.protocols;
928
962
  if (current.protocols === "*") return true;
929
963
  if (previous === "*") return false;
930
- const produced = previous.find((p) => p.name === command.protocol);
931
- const offer = current.protocols.find((p) => p.name === command.protocol);
964
+ const produced = previous.find((p) => p.name === protocol);
965
+ const offer = current.protocols.find((p) => p.name === protocol);
932
966
  return produced !== void 0 && offer !== void 0 && inRange(produced.version, offer);
933
967
  };
968
+ const validateLanes = (record) => {
969
+ if (typeof record.lanes !== "object" || record.lanes === null || Object.values(record.lanes).some((lane) => !Array.isArray(lane.commands))) throw new Error("statewire: session storage loaded a record without command lanes");
970
+ };
934
971
  const loadSessionRecord = (store, options) => {
935
972
  let stored = store.load();
936
- if (stored !== null && !Array.isArray(stored.commands)) throw new Error("statewire: session storage loaded a record without a commands array");
973
+ if (stored !== null) validateLanes(stored);
937
974
  const current = {
938
- wire: options.wireVersion ?? { version: "2026-09-07" },
975
+ wire: options.wireVersion ?? { version: "2026-09-13" },
939
976
  protocols: protocolOffers(options)
940
977
  };
941
- if (stored !== null && !stored.commands.every((command) => storedCommandCompatible(command, current))) {
978
+ const compatible = (record) => Object.entries(record.lanes).every(([protocol, lane]) => lane.commands.every((command) => storedCommandCompatible(protocol, command, current)));
979
+ if (stored !== null && !compatible(stored)) {
942
980
  if (!store.migrate) throw new Error("statewire: stored commands require a storage migration");
943
981
  stored = store.migrate(stored, current);
944
- if (stored !== null && !stored.commands.every((command) => storedCommandCompatible(command, current))) throw new Error("statewire: migrated commands are incompatible");
945
- if (stored !== null) store.save(stored);
982
+ if (stored !== null) {
983
+ validateLanes(stored);
984
+ if (!compatible(stored)) throw new Error("statewire: migrated commands are incompatible");
985
+ store.save(stored);
986
+ }
946
987
  }
947
- if (stored !== null && !Array.isArray(stored.commands)) throw new Error("statewire: session storage loaded a record without a commands array");
948
988
  const record = stored ?? {
949
989
  clientId: randomId(),
950
- nextSeq: 1,
951
- commands: []
990
+ lanes: {}
952
991
  };
953
992
  if (stored === null) store.save(record);
954
993
  return record;
@@ -963,16 +1002,11 @@ const useStatewireSession = (options) => {
963
1002
  const [s] = useState(() => {
964
1003
  const record = store.ready === void 0 ? loadSessionRecord(store, options) : {
965
1004
  clientId: randomId(),
966
- nextSeq: 1,
967
- commands: []
1005
+ lanes: {}
968
1006
  };
969
1007
  const session = {
970
1008
  options,
971
1009
  selection: void 0,
972
- contextDocuments: {
973
- mode: "mounted",
974
- documents: /* @__PURE__ */ new Map()
975
- },
976
1010
  record,
977
1011
  ctx: {
978
1012
  beforeAttach: () => persistBeforeSend(session),
@@ -1003,17 +1037,18 @@ const useStatewireSession = (options) => {
1003
1037
  ephemeral: false,
1004
1038
  deactivated: false,
1005
1039
  sessionKnown: false,
1006
- durableSeq: 0,
1040
+ durableSeq: {},
1041
+ ephemeralSeqs: {},
1007
1042
  resumeAt: 0,
1008
1043
  idle: false,
1009
1044
  onSessionLost: void 0,
1010
1045
  onDone: void 0,
1011
- ctxNextSeq: 1,
1012
- ctxPending: [],
1013
1046
  lanesOnAttach: false,
1014
- attachLaneSeqs: [/* @__PURE__ */ new Set(), /* @__PURE__ */ new Set()],
1047
+ attachEntries: /* @__PURE__ */ new Set(),
1015
1048
  frameFailures: 0,
1016
1049
  pending: [],
1050
+ failedList: [],
1051
+ failedParts: [],
1017
1052
  canonical: void 0,
1018
1053
  documentListeners: /* @__PURE__ */ new Set(),
1019
1054
  documents: {
@@ -1087,6 +1122,6 @@ const useStatewireSession = (options) => {
1087
1122
  return s;
1088
1123
  };
1089
1124
  //#endregion
1090
- export { discardFailed, documentSource, failedCommands, invokeProtocol, retryFailed, setContext, setTransport, updateContext, useStatewireSession };
1125
+ export { discardFailed, documentSource, failedCommands, invokeProtocol, retryFailed, setTransport, useStatewireSession };
1091
1126
 
1092
1127
  //# sourceMappingURL=session.js.map