statewire 0.24.1 → 0.24.3

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.
@@ -2,8 +2,8 @@ import { encodeContext } from "../wire/context";
2
2
  import { wrapStorageError } from "./storageError";
3
3
  import {
4
4
  DEFAULT_PROTOCOL,
5
+ CLIENT_WIRE,
5
6
  GAP_FILL_VERSION,
6
- WIRE_VERSION,
7
7
  protocolOffers,
8
8
  inRange,
9
9
  } from "../wire/protocol";
@@ -163,6 +163,8 @@ type Session = {
163
163
  /** Ephemeral lane clocks: restart at 1 on every attach. */
164
164
  ephemeralSeqs: Record<string, number>;
165
165
  resumeAt: number;
166
+ /** A refusal named sent durable stamps: a reattach's hello decides them, after `resumeAt`. */
167
+ reattach: boolean;
166
168
  idle: boolean;
167
169
  onSessionLost: StatewireClient.OnSessionLost | undefined;
168
170
  onDeprecatedPath: StatewireClient.OnDeprecatedPath | undefined;
@@ -1326,14 +1328,16 @@ const sessionLostInner = (s: Session, protocol: string, seqWm: number) => {
1326
1328
  },
1327
1329
  );
1328
1330
  } else {
1329
- // Default: the whole queue goes, each command a failure notice.
1331
+ // Default: the queue goes, each command a failure notice. Held entries
1332
+ // never went out: they stay held where they are.
1330
1333
  const stamps = mergeBacklog(s, protocol);
1331
- const queued = outboxOf(lane);
1332
- const { outbox: _outbox, ...rest } = lane;
1334
+ const held = outboxOf(lane).filter((c) => c.held);
1335
+ const queued = outboxOf(lane).filter((c) => !c.held);
1336
+ const keep = new Set(held.map((c) => c.id));
1333
1337
  setRecord(
1334
1338
  s,
1335
1339
  withLane(s.record, protocol, {
1336
- ...withNotices(rest, [
1340
+ ...withNotices(withOutbox(lane, held), [
1337
1341
  ...stamps.map((c) => lostNotice(c)),
1338
1342
  ...queued.map((c) => lostNotice(c)),
1339
1343
  ]),
@@ -1350,7 +1354,9 @@ const sessionLostInner = (s: Session, protocol: string, seqWm: number) => {
1350
1354
  ),
1351
1355
  }),
1352
1356
  );
1353
- for (const p of s.pending.filter((q) => q.command.protocol === protocol)) {
1357
+ for (const p of s.pending.filter(
1358
+ (q) => q.command.protocol === protocol && !keep.has(q.id),
1359
+ )) {
1354
1360
  s.lifecycle?.onCovered?.(p.inv);
1355
1361
  settleEntry(s, p, { ok: false, failure }, "noticed");
1356
1362
  }
@@ -1451,6 +1457,21 @@ const selectionOf = (
1451
1457
  };
1452
1458
  };
1453
1459
 
1460
+ // The host admits an attach's durable statements right after its hello.
1461
+ const carriedThrough = (
1462
+ lane: { name: string; seq?: number },
1463
+ attached: ReadonlySet<Pending>,
1464
+ ) => {
1465
+ const carried = new Set(
1466
+ [...attached]
1467
+ .filter((p) => p.command.protocol === lane.name)
1468
+ .map((p) => p.seq),
1469
+ );
1470
+ let seq = lane.seq ?? 0;
1471
+ while (carried.has(seq + 1)) seq += 1;
1472
+ return seq;
1473
+ };
1474
+
1454
1475
  const handleHello = (
1455
1476
  s: Session,
1456
1477
  { ops, syn }: { ops: unknown[]; syn: StatewireSyn },
@@ -1472,6 +1493,7 @@ const handleHello = (
1472
1493
  s.attachEpoch += 1;
1473
1494
  s.frameFailures = 0;
1474
1495
  s.resumeAt = 0;
1496
+ s.reattach = false;
1475
1497
  const lanes = syn.protocols ?? [];
1476
1498
  s.ephemeral =
1477
1499
  lanes.length > 0 && lanes.every((lane) => lane.record === "ephemeral");
@@ -1494,7 +1516,7 @@ const handleHello = (
1494
1516
  );
1495
1517
  continue;
1496
1518
  }
1497
- demote(s, lane.name, lane.seq ?? 0, demoted, waiting);
1519
+ demote(s, lane.name, carriedThrough(lane, attached), demoted, waiting);
1498
1520
  s.durableSeq[lane.name] = lane.seq ?? 0;
1499
1521
  }
1500
1522
  for (const entry of s.pending) {
@@ -1543,10 +1565,26 @@ const handleOps = (
1543
1565
  // A retry-less {syn} refusal is the one session-loss verdict: a gap against
1544
1566
  // a durable lane's exact watermark settles that lane's history lost; a
1545
1567
  // pushback (retry present) or a gapless refusal only reconciles the resend queues.
1568
+ // Under the gap-fill wire a refusal can predate frames sent since: durable
1569
+ // lanes never resend from it, a reattach's hello decides them.
1546
1570
  const handleRefusal = (s: Session, syn: StatewireSyn) => {
1547
- const lanes = (syn.protocols ?? []).filter(
1571
+ const stated = (syn.protocols ?? []).filter(
1548
1572
  (lane): lane is { name: string; seq: number } => lane.seq !== undefined,
1549
1573
  );
1574
+ const lanes = gapFill(s)
1575
+ ? stated.filter(({ name }) => isEphemeral(s, name))
1576
+ : stated;
1577
+ if (
1578
+ gapFill(s) &&
1579
+ s.pending.some((p) => {
1580
+ if (!isStamped(p) || !p.attempted) return false;
1581
+ const protocol = p.command.protocol;
1582
+ if (isEphemeral(s, protocol)) return false;
1583
+ const seq = stated.find((lane) => lane.name === protocol)?.seq ?? 0;
1584
+ return p.seq > Math.max(seq, durableSeqOf(s, protocol));
1585
+ })
1586
+ )
1587
+ s.reattach = true;
1550
1588
  for (const { name, seq } of lanes) {
1551
1589
  const watermark = isEphemeral(s, name)
1552
1590
  ? seq
@@ -1728,6 +1766,29 @@ const pump = async (s: Session) => {
1728
1766
  while (s.running && !isFinished(s)) {
1729
1767
  if (!s.hydrated) await s.ready;
1730
1768
  const transport = s.transport;
1769
+ if (s.reattach && s.attach === "attached" && transport) {
1770
+ const delay = s.resumeAt - Date.now();
1771
+ if (delay > 0) {
1772
+ await sleepUntilWake(s, delay);
1773
+ continue;
1774
+ }
1775
+ // Ephemeral answers ride only this attach: drain them first.
1776
+ if (
1777
+ s.pending.some(
1778
+ (p) =>
1779
+ isStamped(p) && p.attempted && isEphemeral(s, p.command.protocol),
1780
+ )
1781
+ ) {
1782
+ await waitForWake(s);
1783
+ continue;
1784
+ }
1785
+ s.reattach = false;
1786
+ const epoch = s.attachEpoch;
1787
+ transport.connection.reconnect();
1788
+ while (s.running && !isFinished(s) && epoch === s.attachEpoch)
1789
+ await waitForWake(s);
1790
+ continue;
1791
+ }
1731
1792
  const flushable = flushCandidates(s).length > 0;
1732
1793
  const queued =
1733
1794
  s.pending.some((p) => isOutboxed(p) && !p.inv.held) ||
@@ -2444,7 +2505,7 @@ export const updateOutbox = (
2444
2505
  const producedOf = (
2445
2506
  s: Session,
2446
2507
  ): NonNullable<StatewireClient.SessionCommand["produced"]> => ({
2447
- wire: s.options.wireVersion ?? { version: WIRE_VERSION },
2508
+ wire: s.options.wireVersion ?? CLIENT_WIRE,
2448
2509
  protocols:
2449
2510
  protocolOffers(s.options) === "*" && s.selection
2450
2511
  ? s.selection.protocols
@@ -2512,6 +2573,7 @@ export const insertOutbox = (
2512
2573
  return;
2513
2574
  }
2514
2575
  let base = s.record;
2576
+ let entered = stored;
2515
2577
  if (entry.from !== undefined) {
2516
2578
  // Re-inserting a settled update consumes it in the same write: a
2517
2579
  // redelivery finds it gone.
@@ -2519,16 +2581,27 @@ export const insertOutbox = (
2519
2581
  l.inbox?.some((n) => n.id === entry.from),
2520
2582
  );
2521
2583
  if (source === undefined || locate(s, id) !== undefined) return;
2584
+ const notice = source[1].inbox!.find((n) => n.id === entry.from)!;
2585
+ entered = { ...stored, ts: notice.ts };
2522
2586
  base = withLane(base, source[0], withoutNotice(source[1], entry.from));
2523
2587
  }
2524
2588
  const lane = laneOf(base, protocol);
2589
+ // A re-queued command keeps its place: ahead of what was queued after it.
2590
+ const later = outboxOf(lane).find((c) => c.ts >= entered.ts);
2591
+ const position =
2592
+ entry.from !== undefined &&
2593
+ entry.after === undefined &&
2594
+ entry.before === undefined &&
2595
+ later !== undefined
2596
+ ? { before: later.id }
2597
+ : entry;
2525
2598
  try {
2526
2599
  setRecord(
2527
2600
  s,
2528
2601
  withLane(
2529
2602
  base,
2530
2603
  protocol,
2531
- withOutbox(lane, placed(outboxOf(lane), stored, entry)),
2604
+ withOutbox(lane, placed(outboxOf(lane), entered, position)),
2532
2605
  ),
2533
2606
  );
2534
2607
  } catch (error) {
@@ -2800,7 +2873,7 @@ const loadSessionRecord = (
2800
2873
  let stored = store.load();
2801
2874
  if (stored !== null) validateLanes(stored);
2802
2875
  const current = {
2803
- wire: options.wireVersion ?? { version: WIRE_VERSION },
2876
+ wire: options.wireVersion ?? CLIENT_WIRE,
2804
2877
  protocols: protocolOffers(options),
2805
2878
  };
2806
2879
  const compatible = (record: StatewireClient.SessionRecord) =>
@@ -2933,6 +3006,7 @@ export const useStatewireSession = (
2933
3006
  durableSeq: {},
2934
3007
  ephemeralSeqs: {},
2935
3008
  resumeAt: 0,
3009
+ reattach: false,
2936
3010
  idle: false,
2937
3011
  onSessionLost: undefined,
2938
3012
  onDeprecatedPath: undefined,
package/src/host/host.ts CHANGED
@@ -182,6 +182,7 @@ export type CommandsSubmission = {
182
182
  clientId?: string;
183
183
  caller?: StatewireHost.Caller;
184
184
  context?: Statewire.Context;
185
+ version?: string;
185
186
  attach?: StatewireHost.Attach;
186
187
  onApplied?: () => void;
187
188
  onSettle?: (outcome: CommandsOutcome) => void;
@@ -206,6 +207,7 @@ export const bindCommandsHost = (commands: object, host: CommandsHost) => {
206
207
 
207
208
  type QueuedCall = {
208
209
  context?: Statewire.Context;
210
+ version?: string | undefined;
209
211
  method: string;
210
212
  params: readonly unknown[];
211
213
  ts: number;
@@ -223,6 +225,7 @@ type ActiveInvocation = CommandsInvocation & {
223
225
 
224
226
  type CommandsCell = {
225
227
  context: (() => Statewire.Context) | undefined;
228
+ version: string | undefined;
226
229
  handlers: Record<string, (...args: never[]) => unknown> | null;
227
230
  queue: QueuedCall[];
228
231
  active: Map<ActiveInvocation, QueuedCall>;
@@ -364,9 +367,16 @@ const dispatch = (cell: CommandsCell, call: QueuedCall) => {
364
367
  cell.listener?.onStart(inv);
365
368
  cell.listener?.onPosted?.(inv);
366
369
  const attach = call.origin?.attach;
370
+ const version =
371
+ call.origin !== undefined ? call.origin.version : call.version;
367
372
  const ctx: StatewireHost.Ctx = {
368
373
  caller: call.origin?.caller ?? LOCAL_CALLER,
369
374
  context: call.context ?? call.origin?.context ?? Object.freeze({}),
375
+ get version() {
376
+ if (version === undefined)
377
+ throw new Error("statewire: in-process dispatch has no version");
378
+ return version;
379
+ },
370
380
  get attach() {
371
381
  if (attach === undefined)
372
382
  throw new Error("statewire: in-process dispatch has no attach");
@@ -445,6 +455,7 @@ export const useStatewireCommands = <THandlers extends Statewire.Commands>(
445
455
  const [built] = useState(() => {
446
456
  const cell: CommandsCell = {
447
457
  context: undefined,
458
+ version: undefined,
448
459
  handlers: null,
449
460
  queue: [],
450
461
  active: new Map(),
@@ -465,6 +476,7 @@ export const useStatewireCommands = <THandlers extends Statewire.Commands>(
465
476
  }
466
477
  cell.queue.push({
467
478
  context: captureContext(cell.context?.() ?? {}),
479
+ version: cell.version,
468
480
  method,
469
481
  params,
470
482
  ts: Date.now(),
@@ -507,6 +519,7 @@ export const useStatewireCommands = <THandlers extends Statewire.Commands>(
507
519
  });
508
520
 
509
521
  built.cell.context = options?.context;
522
+ built.cell.version = options?.version;
510
523
  useEffect(() => {
511
524
  const { cell } = built;
512
525
  cell.unmounted = false;
@@ -3,7 +3,6 @@ import { MAX_CONTEXT_BYTES } from "../wire/limits";
3
3
  import { encodeDocument } from "../document";
4
4
  import {
5
5
  DEFAULT_PROTOCOL,
6
- WIRE_VERSION,
7
6
  DEFAULT_PROTOCOL_VERSION,
8
7
  GAP_FILL_VERSION,
9
8
  MIN_SERVED_WIRE_VERSION,
@@ -183,8 +182,9 @@ export type ParsedFrame = {
183
182
  res?: { protocol: string; seq: number }[];
184
183
  };
185
184
 
185
+ // This host serves through the gap-fill wire: it names no attach to retire.
186
186
  const SERVED_WIRE: StatewireProtocol.Range = {
187
- version: WIRE_VERSION,
187
+ version: GAP_FILL_VERSION,
188
188
  minVersion: MIN_SERVED_WIRE_VERSION,
189
189
  };
190
190
 
@@ -1147,6 +1147,9 @@ const admitCmd = (
1147
1147
  caller: callerOf(h, rec, conn),
1148
1148
  attach: conn.attach,
1149
1149
  context: captureContext(conn.contexts.get(statement.protocol) ?? {}),
1150
+ version: conn.selection.protocols.find(
1151
+ (p) => p.name === statement.protocol,
1152
+ )!.version,
1150
1153
  onApplied: () => {
1151
1154
  if (adm.settled || adm.applied) return;
1152
1155
  adm.applied = true;
package/src/host.ts CHANGED
@@ -45,19 +45,27 @@ export namespace StatewireHost {
45
45
  setOnDocuments: (listener: (() => void) | null) => void;
46
46
  };
47
47
 
48
+ /** What `this.context` and `this.version` read for a call made on the proxy (in-process dispatch). */
49
+ export type CommandsOptions = {
50
+ context?: () => Statewire.Context;
51
+ version?: string;
52
+ };
53
+
48
54
  /**
49
55
  * Per-invocation handle bound as `this` of a command handler: `caller` is
50
56
  * the submitting client, `attach` the admitting attach's document view
51
- * (in-process dispatch has none — reading it throws), `applied()` states the
52
- * broadcast state now contains the command's effects — the client drops its
53
- * optimistic overlay — before the handler settles (idempotent, a no-op after
54
- * settle). The command settles when the handler's returned promise does.
57
+ * (in-process dispatch has none — reading it throws), `version` the
58
+ * command protocol's version that attach negotiated (in-process dispatch
59
+ * reads `CommandsOptions.version`; without one, reading it throws),
60
+ * `applied()` states the broadcast state now contains the command's effects
61
+ * — the client drops its optimistic overlay — before the handler settles
62
+ * (idempotent, a no-op after settle). The command settles when the
63
+ * handler's returned promise does.
55
64
  */
56
- export type CommandsOptions = { context?: () => Statewire.Context };
57
-
58
65
  export type Ctx = {
59
66
  readonly caller: Caller;
60
67
  readonly context: Statewire.Context;
68
+ readonly version: string;
61
69
  readonly attach: Attach;
62
70
  readonly signal: AbortSignal;
63
71
  readonly applied: () => void;
@@ -4,6 +4,7 @@ import type { ResourceElement } from "@assistant-ui/tap";
4
4
  import { createSseParser } from "./sse-parser";
5
5
  import { timing } from "../client/timing";
6
6
  import {
7
+ replacing,
7
8
  settleFin,
8
9
  classify,
9
10
  handleEnvelope,
@@ -149,6 +150,7 @@ const attemptStream = async (
149
150
  if (!headers.has(name)) headers.set(name, value);
150
151
  }
151
152
  c.negotiation = parseNegotiation(headers);
153
+ replacing(c, headers);
152
154
  c.authStale = false;
153
155
  const lanes = flushTapSync(() => c.ctx.takeAttachLanes());
154
156
  const body = lanes === null ? undefined : JSON.stringify(lanes);
@@ -7,6 +7,7 @@ import { openSSEStream, sseFrame } from "./fake-sse";
7
7
  import type { FakeStreamHandle } from "./fake-sse";
8
8
  import {
9
9
  DEFAULT_PROTOCOL_VERSION,
10
+ GAP_FILL_VERSION,
10
11
  WIRE_VERSION,
11
12
  parseNegotiation,
12
13
  } from "../wire/protocol";
@@ -93,7 +94,19 @@ export const createMockTransport = <TState>(
93
94
  if (!clients.has(id)) clients.set(id, {});
94
95
  const rec = clients.get(id)!;
95
96
  const ephemeral = ephemeralLanes();
96
- const offers = parseNegotiation(headers).applicationProtocols;
97
+ const negotiation = parseNegotiation(headers);
98
+ const offers = negotiation.applicationProtocols;
99
+ // The newest wire this mock speaks that the client offered. It speaks
100
+ // GAP_FILL_VERSION and newer: it skips a re-sent seq instead of
101
+ // re-emitting its verdict, as older wires require.
102
+ const offered = negotiation.wireVersion;
103
+ const wireVersion =
104
+ offered.version < WIRE_VERSION ? offered.version : WIRE_VERSION;
105
+ if (
106
+ wireVersion < GAP_FILL_VERSION ||
107
+ (offered.minVersion !== undefined && wireVersion < offered.minVersion)
108
+ )
109
+ throw new Error("mock wire version was not offered");
97
110
  const applicationVersion =
98
111
  offers === "*"
99
112
  ? DEFAULT_PROTOCOL_VERSION
@@ -111,7 +124,7 @@ export const createMockTransport = <TState>(
111
124
  sseFrame({
112
125
  ops: [{ op: "mount", protocol, main: true, value: state }],
113
126
  syn: {
114
- version: WIRE_VERSION,
127
+ version: wireVersion,
115
128
  protocols: [
116
129
  {
117
130
  name: protocol,
@@ -18,9 +18,17 @@ export namespace StatewireProtocol {
18
18
  };
19
19
  }
20
20
 
21
- export const WIRE_VERSION = "2026-11-01";
21
+ export const WIRE_VERSION = "2026-11-02";
22
22
  /** Attach carries a results cursor and gets its verdicts gap-filled; a re-sent seq at or below the watermark is a protocol error. */
23
23
  export const GAP_FILL_VERSION = "2026-11-01";
24
+ /** Every attach of a connection names its attacher; a host retires the attacher's earlier attaches before the hello. */
25
+ export const FENCE_VERSION = "2026-11-02";
26
+ /** The wire a client offers by default: the gap-fill contract and newer. */
27
+ export const CLIENT_WIRE: StatewireProtocol.Range = {
28
+ version: WIRE_VERSION,
29
+ minVersion: GAP_FILL_VERSION,
30
+ };
31
+ export const ATTACHER_HEADER = "Statewire-Attacher";
24
32
  /** The oldest wire contract a host serves by default (re-sent seqs re-emit their verdicts). */
25
33
  export const MIN_SERVED_WIRE_VERSION = "2026-09-30";
26
34
  export const DEFAULT_PROTOCOL_VERSION = "2026-09-30";
@@ -81,7 +89,7 @@ export const negotiationHeaders = (
81
89
  if (offers !== "*" && !offers.length)
82
90
  invalid("protocol list must not be empty");
83
91
  return {
84
- [VERSION_HEADER]: suffix(options.wireVersion ?? { version: WIRE_VERSION }),
92
+ [VERSION_HEADER]: suffix(options.wireVersion ?? CLIENT_WIRE),
85
93
  [PROTOCOL_HEADER]:
86
94
  offers === "*"
87
95
  ? "*"
@@ -201,7 +209,7 @@ export const validateSelection = (
201
209
  return invalid("missing negotiation selection");
202
210
  const syn = value as Record<string, unknown>;
203
211
  const version = validateVersion(syn.version);
204
- if (!inRange(version, options.wireVersion ?? { version: WIRE_VERSION }))
212
+ if (!inRange(version, options.wireVersion ?? CLIENT_WIRE))
205
213
  invalid("selected wire version is outside offer");
206
214
  if (!Array.isArray(syn.protocols))
207
215
  return invalid("syn.protocols must be an array");