statewire 0.24.0 → 0.24.2

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,7 +2,8 @@ import { encodeContext } from "../wire/context";
2
2
  import { wrapStorageError } from "./storageError";
3
3
  import {
4
4
  DEFAULT_PROTOCOL,
5
- WIRE_VERSION,
5
+ CLIENT_WIRE,
6
+ GAP_FILL_VERSION,
6
7
  protocolOffers,
7
8
  inRange,
8
9
  } from "../wire/protocol";
@@ -162,6 +163,8 @@ type Session = {
162
163
  /** Ephemeral lane clocks: restart at 1 on every attach. */
163
164
  ephemeralSeqs: Record<string, number>;
164
165
  resumeAt: number;
166
+ /** A refusal named sent durable stamps: a reattach's hello decides them, after `resumeAt`. */
167
+ reattach: boolean;
165
168
  idle: boolean;
166
169
  onSessionLost: StatewireClient.OnSessionLost | undefined;
167
170
  onDeprecatedPath: StatewireClient.OnDeprecatedPath | undefined;
@@ -182,6 +185,10 @@ type Session = {
182
185
  lanesOnAttach: boolean;
183
186
  attachEntries: ReadonlySet<Pending>;
184
187
  frameFailures: number;
188
+ /** A later hello decides stamps this one could not: another live sharer's stamp sat below them. */
189
+ recheck: ReturnType<typeof setTimeout> | undefined;
190
+ /** Hellos in a row that left stamps waiting at the same watermarks: the recheck backs off. */
191
+ rechecks: { watermarks: string; count: number };
185
192
  pending: Pending[];
186
193
  canonical: unknown;
187
194
  documentListeners: Set<() => void>;
@@ -1321,14 +1328,16 @@ const sessionLostInner = (s: Session, protocol: string, seqWm: number) => {
1321
1328
  },
1322
1329
  );
1323
1330
  } else {
1324
- // 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.
1325
1333
  const stamps = mergeBacklog(s, protocol);
1326
- const queued = outboxOf(lane);
1327
- 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));
1328
1337
  setRecord(
1329
1338
  s,
1330
1339
  withLane(s.record, protocol, {
1331
- ...withNotices(rest, [
1340
+ ...withNotices(withOutbox(lane, held), [
1332
1341
  ...stamps.map((c) => lostNotice(c)),
1333
1342
  ...queued.map((c) => lostNotice(c)),
1334
1343
  ]),
@@ -1345,7 +1354,9 @@ const sessionLostInner = (s: Session, protocol: string, seqWm: number) => {
1345
1354
  ),
1346
1355
  }),
1347
1356
  );
1348
- 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
+ )) {
1349
1360
  s.lifecycle?.onCovered?.(p.inv);
1350
1361
  settleEntry(s, p, { ok: false, failure }, "noticed");
1351
1362
  }
@@ -1355,14 +1366,17 @@ const sessionLostInner = (s: Session, protocol: string, seqWm: number) => {
1355
1366
  wake(s);
1356
1367
  };
1357
1368
 
1358
- // Every stamp above the watermark, whichever sharer made it, returns to the
1359
- // front of the outbox. A stamp past watermark+1, or one this client saw
1360
- // admitted, proves the server record that admitted it is gone.
1369
+ // Every stamp above the watermark returns to the front of the outbox. A stamp
1370
+ // past watermark+1, or one this client saw admitted, proves the server record
1371
+ // that admitted it is gone. Another live sharer's stamp may still be on its
1372
+ // way on that sharer's attach: it and every stamp after it wait for a later
1373
+ // hello; the stamps before it never arrived and resend under their seqs.
1361
1374
  const demote = (
1362
1375
  s: Session,
1363
1376
  protocol: string,
1364
1377
  watermark: number,
1365
1378
  demoted: Set<string>,
1379
+ waiting: Set<Pending>,
1366
1380
  ) => {
1367
1381
  const lane = laneOf(s.record, protocol);
1368
1382
  const local = laneEntries(s, protocol).filter((p) => p.seq > watermark);
@@ -1380,6 +1394,17 @@ const demote = (
1380
1394
  sessionLostInner(s, protocol, watermark);
1381
1395
  return;
1382
1396
  }
1397
+ const me = s.store.owner?.id;
1398
+ const foreign = moved.find(
1399
+ (c) =>
1400
+ c.owner !== undefined &&
1401
+ c.owner !== me &&
1402
+ !local.some((p) => p.id === c.id),
1403
+ );
1404
+ if (foreign !== undefined) {
1405
+ for (const p of local) if (p.seq > foreign.seq) waiting.add(p);
1406
+ return;
1407
+ }
1383
1408
  setRecord(
1384
1409
  s,
1385
1410
  withLane(s.record, protocol, {
@@ -1399,6 +1424,24 @@ const demote = (
1399
1424
  sortPending(s);
1400
1425
  };
1401
1426
 
1427
+ const recheckLater = (s: Session, watermarks: string) => {
1428
+ if (s.rechecks.watermarks !== watermarks)
1429
+ s.rechecks = { watermarks, count: 0 };
1430
+ s.rechecks.count += 1;
1431
+ if (s.recheck !== undefined) return;
1432
+ s.recheck = setTimeout(
1433
+ () => {
1434
+ s.recheck = undefined;
1435
+ if (s.running && s.attach === "attached")
1436
+ s.transport?.connection.reconnect();
1437
+ },
1438
+ backoff(s.rechecks.count + 1),
1439
+ );
1440
+ };
1441
+
1442
+ const gapFill = (s: Session) =>
1443
+ s.selection !== undefined && s.selection.version >= GAP_FILL_VERSION;
1444
+
1402
1445
  const selectionOf = (
1403
1446
  syn: StatewireSyn,
1404
1447
  ): StatewireProtocol.Selection | undefined => {
@@ -1414,6 +1457,21 @@ const selectionOf = (
1414
1457
  };
1415
1458
  };
1416
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
+
1417
1475
  const handleHello = (
1418
1476
  s: Session,
1419
1477
  { ops, syn }: { ops: unknown[]; syn: StatewireSyn },
@@ -1435,6 +1493,7 @@ const handleHello = (
1435
1493
  s.attachEpoch += 1;
1436
1494
  s.frameFailures = 0;
1437
1495
  s.resumeAt = 0;
1496
+ s.reattach = false;
1438
1497
  const lanes = syn.protocols ?? [];
1439
1498
  s.ephemeral =
1440
1499
  lanes.length > 0 && lanes.every((lane) => lane.record === "ephemeral");
@@ -1446,6 +1505,7 @@ const handleHello = (
1446
1505
  // verdicts (the server never re-runs them); seqs above it never arrived
1447
1506
  // and go back to the outbox for fresh ones.
1448
1507
  const demoted = new Set<string>();
1508
+ const waiting = new Set<Pending>();
1449
1509
  for (const lane of lanes) {
1450
1510
  if (lane.record === "ephemeral") {
1451
1511
  s.ephemeralSeqs[lane.name] = Math.max(
@@ -1456,17 +1516,24 @@ const handleHello = (
1456
1516
  );
1457
1517
  continue;
1458
1518
  }
1459
- demote(s, lane.name, lane.seq ?? 0, demoted);
1519
+ demote(s, lane.name, carriedThrough(lane, attached), demoted, waiting);
1460
1520
  s.durableSeq[lane.name] = lane.seq ?? 0;
1461
1521
  }
1462
1522
  for (const entry of s.pending) {
1463
1523
  entry.posted =
1464
1524
  attached.has(entry) ||
1525
+ waiting.has(entry) ||
1465
1526
  (isStamped(entry) &&
1466
1527
  entry.seq <= durableSeqOf(s, entry.command.protocol) &&
1467
1528
  !isEphemeral(s, entry.command.protocol));
1468
1529
  entry.stale = false;
1469
1530
  }
1531
+ if (waiting.size > 0)
1532
+ recheckLater(
1533
+ s,
1534
+ JSON.stringify(lanes.map((lane) => [lane.name, lane.seq])),
1535
+ );
1536
+ else s.rechecks = { watermarks: "", count: 0 };
1470
1537
  syncBacklogInner(s);
1471
1538
  s.sessionKnown = true;
1472
1539
  flushInner(s, demoted);
@@ -1498,10 +1565,26 @@ const handleOps = (
1498
1565
  // A retry-less {syn} refusal is the one session-loss verdict: a gap against
1499
1566
  // a durable lane's exact watermark settles that lane's history lost; a
1500
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.
1501
1570
  const handleRefusal = (s: Session, syn: StatewireSyn) => {
1502
- const lanes = (syn.protocols ?? []).filter(
1571
+ const stated = (syn.protocols ?? []).filter(
1503
1572
  (lane): lane is { name: string; seq: number } => lane.seq !== undefined,
1504
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;
1505
1588
  for (const { name, seq } of lanes) {
1506
1589
  const watermark = isEphemeral(s, name)
1507
1590
  ? seq
@@ -1683,21 +1766,47 @@ const pump = async (s: Session) => {
1683
1766
  while (s.running && !isFinished(s)) {
1684
1767
  if (!s.hydrated) await s.ready;
1685
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
+ }
1686
1792
  const flushable = flushCandidates(s).length > 0;
1687
- const queued = s.pending.some((p) => isOutboxed(p) && !p.inv.held);
1793
+ const queued =
1794
+ s.pending.some((p) => isOutboxed(p) && !p.inv.held) ||
1795
+ queuedInRecord(s);
1688
1796
  if (
1689
1797
  buildLanes(s) === null &&
1690
1798
  !flushable &&
1691
1799
  (!queued || s.attach === "attached")
1692
1800
  )
1693
1801
  return;
1694
- if (transport?.parked && s.pending.length === 0) return;
1802
+ const waiting = s.pending.length > 0 || queued;
1803
+ if (transport?.parked && !waiting) return;
1695
1804
  if (s.attach !== "attached" || !transport) {
1696
- if (s.pending.length === 0) return;
1805
+ if (!waiting) return;
1697
1806
  if (
1698
1807
  (transport?.connection.status === "standby" &&
1699
1808
  transport.connection.reason === "idle") ||
1700
- (transport?.parked && s.pending.length > 0)
1809
+ transport?.parked
1701
1810
  ) {
1702
1811
  transport.connection.reconnect();
1703
1812
  }
@@ -1743,6 +1852,18 @@ const pump = async (s: Session) => {
1743
1852
  : send(lanes));
1744
1853
  } catch {
1745
1854
  if (!s.running || isFinished(s)) return;
1855
+ // The frame may have arrived. Under the gap-fill wire its seqs never
1856
+ // go out again: the next hello answers what arrived and frees the rest.
1857
+ if (lanes !== null && lanes.entries.length > 0 && gapFill(s)) {
1858
+ if (epoch !== s.attachEpoch) continue;
1859
+ for (const entry of lanes.entries)
1860
+ if (s.pending.includes(entry)) entry.posted = true;
1861
+ s.frameFailures += 1;
1862
+ await sleepUntilWake(s, backoff(s.frameFailures));
1863
+ if (s.running && epoch === s.attachEpoch && s.attach === "attached")
1864
+ transport.connection.reconnect();
1865
+ continue;
1866
+ }
1746
1867
  if (lanes?.frame.ctx?.length) {
1747
1868
  s.lanesOnAttach = true;
1748
1869
  transport.connection.reconnect();
@@ -1852,6 +1973,8 @@ const start = (s: Session) => {
1852
1973
 
1853
1974
  const stop = (s: Session) => {
1854
1975
  s.running = false;
1976
+ clearTimeout(s.recheck);
1977
+ s.recheck = undefined;
1855
1978
  if (s.shared) {
1856
1979
  s.shared.unsubscribe?.();
1857
1980
  s.shared.unsubscribe = null;
@@ -2038,6 +2161,16 @@ const flushCandidates = (s: Session): Candidate[] =>
2038
2161
  return order.slice(0, free).map((stored) => ({ protocol, stored }));
2039
2162
  });
2040
2163
 
2164
+ // An entry the record holds that a flush would stamp once attached, whether
2165
+ // or not this page made the send: inserted, or kept from an earlier mount or
2166
+ // another sharer. It dials a parked or idle transport as a send does.
2167
+ const queuedInRecord = (s: Session) =>
2168
+ Object.keys(s.record.lanes).some(
2169
+ (protocol) =>
2170
+ (s.selection ? negotiated(s, protocol) : offered(s, protocol)) &&
2171
+ bubble(s, protocol, laneQueue(s, protocol)).free > 0,
2172
+ );
2173
+
2041
2174
  const reasonOf = (
2042
2175
  s: Session,
2043
2176
  stored: StatewireClient.OutboxCommand,
@@ -2372,7 +2505,7 @@ export const updateOutbox = (
2372
2505
  const producedOf = (
2373
2506
  s: Session,
2374
2507
  ): NonNullable<StatewireClient.SessionCommand["produced"]> => ({
2375
- wire: s.options.wireVersion ?? { version: WIRE_VERSION },
2508
+ wire: s.options.wireVersion ?? CLIENT_WIRE,
2376
2509
  protocols:
2377
2510
  protocolOffers(s.options) === "*" && s.selection
2378
2511
  ? s.selection.protocols
@@ -2440,6 +2573,7 @@ export const insertOutbox = (
2440
2573
  return;
2441
2574
  }
2442
2575
  let base = s.record;
2576
+ let entered = stored;
2443
2577
  if (entry.from !== undefined) {
2444
2578
  // Re-inserting a settled update consumes it in the same write: a
2445
2579
  // redelivery finds it gone.
@@ -2447,16 +2581,27 @@ export const insertOutbox = (
2447
2581
  l.inbox?.some((n) => n.id === entry.from),
2448
2582
  );
2449
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 };
2450
2586
  base = withLane(base, source[0], withoutNotice(source[1], entry.from));
2451
2587
  }
2452
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;
2453
2598
  try {
2454
2599
  setRecord(
2455
2600
  s,
2456
2601
  withLane(
2457
2602
  base,
2458
2603
  protocol,
2459
- withOutbox(lane, placed(outboxOf(lane), stored, entry)),
2604
+ withOutbox(lane, placed(outboxOf(lane), entered, position)),
2460
2605
  ),
2461
2606
  );
2462
2607
  } catch (error) {
@@ -2728,7 +2873,7 @@ const loadSessionRecord = (
2728
2873
  let stored = store.load();
2729
2874
  if (stored !== null) validateLanes(stored);
2730
2875
  const current = {
2731
- wire: options.wireVersion ?? { version: WIRE_VERSION },
2876
+ wire: options.wireVersion ?? CLIENT_WIRE,
2732
2877
  protocols: protocolOffers(options),
2733
2878
  };
2734
2879
  const compatible = (record: StatewireClient.SessionRecord) =>
@@ -2861,6 +3006,7 @@ export const useStatewireSession = (
2861
3006
  durableSeq: {},
2862
3007
  ephemeralSeqs: {},
2863
3008
  resumeAt: 0,
3009
+ reattach: false,
2864
3010
  idle: false,
2865
3011
  onSessionLost: undefined,
2866
3012
  onDeprecatedPath: undefined,
@@ -2878,6 +3024,8 @@ export const useStatewireSession = (
2878
3024
  lanesOnAttach: false,
2879
3025
  attachEntries: new Set(),
2880
3026
  frameFailures: 0,
3027
+ recheck: undefined,
3028
+ rechecks: { watermarks: "", count: 0 },
2881
3029
  pending: [],
2882
3030
  canonical: undefined,
2883
3031
  documentListeners: new Set(),
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);
@@ -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");