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
@@ -0,0 +1,60 @@
1
+ import { StatewireReject } from "./engine";
2
+ import { applyDocumentOps } from "../wire/documents";
3
+ import type { StatewireHost } from "../host";
4
+
5
+ const PATCH_OPS = new Set(["add", "replace", "remove"]);
6
+
7
+ const mount = (
8
+ attach: StatewireHost.Attach,
9
+ protocol: string,
10
+ value: unknown,
11
+ ) => {
12
+ try {
13
+ attach.mount(protocol, value, undefined, true);
14
+ } catch (error) {
15
+ throw new StatewireReject((error as Error).message, {
16
+ code: "oversized-document",
17
+ });
18
+ }
19
+ };
20
+
21
+ /** `set`/`patch` handlers for an ephemeral subprotocol's main document; spread into `useStatewireCommands`. */
22
+ export const syncedDocument = (protocol: string) => ({
23
+ [`${protocol}/set`]: function (this: StatewireHost.Ctx, value: unknown) {
24
+ mount(this.attach, protocol, value);
25
+ },
26
+ [`${protocol}/patch`]: function (this: StatewireHost.Ctx, ops: unknown) {
27
+ const value = this.attach.document(protocol);
28
+ if (value === undefined)
29
+ throw new StatewireReject("no document is set", { code: "no-document" });
30
+ if (
31
+ !Array.isArray(ops) ||
32
+ !ops.every(
33
+ (op) =>
34
+ typeof op === "object" &&
35
+ op !== null &&
36
+ !Array.isArray(op) &&
37
+ PATCH_OPS.has((op as { op?: unknown }).op as string) &&
38
+ !("doc" in op),
39
+ )
40
+ )
41
+ throw new StatewireReject("invalid patch", { code: "invalid-patch" });
42
+ let next: unknown;
43
+ try {
44
+ next = applyDocumentOps(
45
+ {
46
+ mode: "mounted",
47
+ documents: new Map([[0, { key: null, protocol, main: true, value }]]),
48
+ },
49
+ ops,
50
+ undefined,
51
+ true,
52
+ ).documents.get(0)!.value;
53
+ } catch (error) {
54
+ throw new StatewireReject((error as Error).message, {
55
+ code: "invalid-patch",
56
+ });
57
+ }
58
+ mount(this.attach, protocol, next);
59
+ },
60
+ });
@@ -1,6 +1,5 @@
1
1
  import type { StatewireProtocol } from "./wire/protocol";
2
2
  import type { StatewireHost } from "./host";
3
- import type { StatewireCmd } from "./client/connection";
4
3
 
5
4
  export { StatewireSocketHost } from "./host/socket";
6
5
  export { clientIdFromUrl, textFrameOrClose } from "./host/adapter";
@@ -45,11 +44,30 @@ export namespace StatewireHostInternal {
45
44
  dispose: () => void;
46
45
  };
47
46
 
47
+ export type Cmd = {
48
+ protocol: string;
49
+ seq: number;
50
+ type?: "applied" | "result" | "rejected" | "failed" | "crashed" | "expired";
51
+ code?: string;
52
+ message?: string;
53
+ payload?: unknown;
54
+ meta?: unknown;
55
+ /** Cumulative per lane: resending through this seq will never be needed. */
56
+ dur?: boolean;
57
+ };
58
+
48
59
  export type ClientRecord = {
49
60
  id: string;
50
- lastSeq: number;
51
61
  touched: number;
52
- results: readonly { seq: number; ts: number; cmd: StatewireCmd }[];
62
+ lanes: Readonly<
63
+ Record<
64
+ string,
65
+ {
66
+ lastSeq: number;
67
+ results: readonly { seq: number; ts: number; cmd: Cmd }[];
68
+ }
69
+ >
70
+ >;
53
71
  principal: unknown;
54
72
  };
55
73
 
package/src/host.ts CHANGED
@@ -8,6 +8,7 @@ export {
8
8
  useStatewireState,
9
9
  useStatewireCommands,
10
10
  } from "./host/host";
11
+ export { syncedDocument } from "./host/synced";
11
12
 
12
13
  export namespace StatewireHost {
13
14
  export type Work = {
@@ -23,20 +24,38 @@ export namespace StatewireHost {
23
24
  snapshot: () => TState;
24
25
  };
25
26
 
27
+ export type Document = {
28
+ protocol: string;
29
+ key: readonly string[] | null;
30
+ main: boolean;
31
+ value: unknown;
32
+ };
33
+
26
34
  export type StateHost = Statewire.StateSource & {
27
35
  /** Synchronous, per locally-recorded op; the host owns batching. */
28
36
  setOnOp: (listener: ((op: Statewire.Op) => void) | null) => void;
37
+ /** Mount or replace the shared document `(protocol, key)`; keyed documents reach an attach only once it `include`s them. */
38
+ mount: (
39
+ protocol: string,
40
+ value: unknown,
41
+ options?: { key?: readonly string[]; main?: boolean },
42
+ ) => void;
43
+ unmount: (protocol: string, key?: readonly string[]) => void;
44
+ documents: () => ReadonlyMap<string, Document>;
45
+ setOnDocuments: (listener: (() => void) | null) => void;
29
46
  };
30
47
 
31
48
  /**
32
49
  * Per-invocation handle bound as `this` of a command handler: `caller` is
33
- * the submitting client, `applied()` states the broadcast state now contains
34
- * the command's effects — the client drops its optimistic overlay — before
35
- * the handler settles (idempotent, a no-op after settle). The command
36
- * settles when the handler's returned promise does.
50
+ * 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.
37
55
  */
38
56
  export type Ctx = {
39
57
  readonly caller: Caller;
58
+ readonly attach: Attach;
40
59
  readonly signal: AbortSignal;
41
60
  readonly applied: () => void;
42
61
  };
@@ -46,17 +65,30 @@ export namespace StatewireHost {
46
65
  * across a long run: `clientId` is the client's `Statewire-Client-Id`
47
66
  * (in-process dispatch has none — reading it throws), `auth` is the value
48
67
  * `authorize` returned for the attach that admitted the command (undefined
49
- * for a void `authorize` and for in-process dispatch), `context` reads the
50
- * admitting attach's context live (null until that attach sends one),
51
- * `isConnected` reports whether the client has an active stream attachment.
68
+ * for a void `authorize` and for in-process dispatch), `isConnected`
69
+ * reports whether the client has an active stream attachment.
52
70
  */
53
71
  export type Caller = {
54
72
  readonly clientId: string;
55
73
  readonly auth: unknown;
56
- readonly context: Record<string, unknown> | null;
57
74
  readonly isConnected: boolean;
58
75
  };
59
76
 
77
+ /** The admitting attach's view: its ephemeral documents plus the shared documents included for it. */
78
+ export type Attach = {
79
+ document: (protocol: string, key?: readonly string[]) => unknown;
80
+ /** `protocol` must be ephemeral; the encoded value is bounded by `MAX_EPHEMERAL_DOCUMENT_BYTES`. */
81
+ mount: (
82
+ protocol: string,
83
+ value: unknown,
84
+ key?: readonly string[],
85
+ main?: boolean,
86
+ ) => void;
87
+ unmount: (protocol: string, key?: readonly string[]) => void;
88
+ include: (protocol: string, key: readonly string[]) => void;
89
+ exclude: (protocol: string, key: readonly string[]) => void;
90
+ };
91
+
60
92
  export type HandledCommands<THandlers extends Statewire.Commands> = {
61
93
  [K in keyof THandlers]: (
62
94
  ...args: Parameters<THandlers[K]>
package/src/index.ts CHANGED
@@ -7,4 +7,5 @@ export { StatewireWebsocket } from "./transports/ws";
7
7
  export { StatewireSendError } from "./types";
8
8
 
9
9
  export type { StatewireProtocol } from "./wire/protocol";
10
+ export { DEFAULT_PROTOCOL } from "./wire/protocol";
10
11
  export type { StatewireDocuments } from "./wire/documents";
package/src/statewire.ts CHANGED
@@ -127,25 +127,10 @@ export const useStatewire = <
127
127
  );
128
128
  return value.invoke(protocol, method, params);
129
129
  },
130
- updateContext: (ops: readonly unknown[]) => {
131
- if (!value.updateContext)
132
- throw new Error(
133
- "statewire: transport does not support context documents",
134
- );
135
- value.updateContext(ops);
136
- },
137
130
  getState: core.getState as () => TState,
138
131
  connection,
139
132
  commands: core.commands as Statewire.CommandsProxy<TCommands>,
140
133
  isBusy,
141
- setContext: (contextValue: Record<string, unknown>) => {
142
- if (!value.setContext) {
143
- throw new Error(
144
- "statewire: this transport has no client context channel",
145
- );
146
- }
147
- value.setContext(contextValue);
148
- },
149
134
  failed,
150
135
  retryFailed: (entry: StatewireClient.FailedCommand) =>
151
136
  failures().retry(entry),
@@ -200,18 +185,10 @@ export class StatewireClient<
200
185
  ): Promise<unknown> {
201
186
  return this.root.getValue().invoke(protocol, method, params);
202
187
  }
203
- updateContext(ops: readonly unknown[]): void {
204
- this.root.getValue().updateContext(ops);
205
- }
206
-
207
188
  get isBusy(): boolean {
208
189
  return this.root.getValue().isBusy;
209
190
  }
210
191
 
211
- setContext(value: Record<string, unknown>): void {
212
- this.root.getValue().setContext(value);
213
- }
214
-
215
192
  get failed(): readonly StatewireClient.FailedCommand[] {
216
193
  return this.root.getValue().failed;
217
194
  }
@@ -286,15 +263,12 @@ export namespace StatewireClient {
286
263
  method: string,
287
264
  params: readonly unknown[],
288
265
  ) => Promise<unknown>;
289
- updateContext: (ops: readonly unknown[]) => void;
290
266
  /** Identity-stable across renders; reads the current canonical state without subscribing the caller. */
291
267
  getState: () => TState;
292
268
  connection: Connection;
293
269
  commands: Statewire.CommandsProxy<TCommands>;
294
270
  /** Negation of the server's broadcast idle level (a transport without idle is always busy). */
295
271
  isBusy: boolean;
296
- /** Replace this client's ambient context on the server; throws on transports without a client context channel. */
297
- setContext: (value: Record<string, unknown>) => void;
298
272
  /** Non-accepted sends retained in the session record; reload with it. */
299
273
  failed: readonly FailedCommand[];
300
274
  /** Re-enqueues the entry's command on fresh clocks and removes the entry. */
@@ -422,7 +396,6 @@ export namespace StatewireClient {
422
396
  /** A command as persisted while un-durable: its lane seq plus the wire statement. */
423
397
  export type SessionCommand = {
424
398
  seq: number;
425
- protocol: string;
426
399
  produced?: {
427
400
  wire: StatewireProtocol.Range;
428
401
  protocols: readonly StatewireProtocol.Offer[] | "*";
@@ -432,9 +405,8 @@ export namespace StatewireClient {
432
405
  meta?: unknown;
433
406
  };
434
407
 
435
- /** The persisted session: the lane identity, the cmd clock, and the commands the server has not marked durable. */
436
- export type SessionRecord = {
437
- clientId: string;
408
+ /** A durable lane: its cmd clock and the commands the server has not marked durable. */
409
+ export type SessionLane = {
438
410
  nextSeq: number;
439
411
  commands: readonly SessionCommand[];
440
412
  /** Terminal verdicts parked for the sharer that minted the seq (shared storage only). */
@@ -443,6 +415,12 @@ export namespace StatewireClient {
443
415
  failed?: readonly FailedCommand[];
444
416
  };
445
417
 
418
+ /** The persisted session: the client identity plus one lane per durable protocol. */
419
+ export type SessionRecord = {
420
+ clientId: string;
421
+ lanes: Readonly<Record<string, SessionLane>>;
422
+ };
423
+
446
424
  /** A terminal verdict one sharer's connection carried, parked for the sharer awaiting the seq. */
447
425
  export type SessionVerdict = {
448
426
  seq: number;
package/src/storage.ts CHANGED
@@ -3,7 +3,11 @@ import { wrapStorageError } from "./client/storageError";
3
3
  import { resource } from "@assistant-ui/tap";
4
4
  import type { StatewireClient } from "./statewire";
5
5
  import type { StatewireProtocol } from "./wire/protocol";
6
- import { negotiationHeaders, protocolToken } from "./wire/protocol";
6
+ import {
7
+ DEFAULT_PROTOCOL,
8
+ negotiationHeaders,
9
+ protocolToken,
10
+ } from "./wire/protocol";
7
11
 
8
12
  const isRecord = (value: unknown): value is Record<string, unknown> =>
9
13
  typeof value === "object" && value !== null && !Array.isArray(value);
@@ -85,12 +89,8 @@ const isFailedCommand = (
85
89
  Number.isFinite(value["ts"]) &&
86
90
  isPositiveSafeInteger(value["seq"]);
87
91
 
88
- const isSessionRecord = (
89
- value: unknown,
90
- ): value is StatewireClient.SessionRecord =>
92
+ const isSessionLane = (value: unknown): value is StatewireClient.SessionLane =>
91
93
  isRecord(value) &&
92
- typeof value["clientId"] === "string" &&
93
- value["clientId"].length > 0 &&
94
94
  isPositiveSafeInteger(value["nextSeq"]) &&
95
95
  Array.isArray(value["commands"]) &&
96
96
  value["commands"].every(isSessionCommand) &&
@@ -100,6 +100,18 @@ const isSessionRecord = (
100
100
  (value["failed"] === undefined ||
101
101
  (Array.isArray(value["failed"]) && value["failed"].every(isFailedCommand)));
102
102
 
103
+ const isClientId = (value: unknown): value is string =>
104
+ typeof value === "string" && value.length > 0;
105
+
106
+ const isSessionRecord = (
107
+ value: unknown,
108
+ ): value is StatewireClient.SessionRecord =>
109
+ isRecord(value) &&
110
+ isClientId(value["clientId"]) &&
111
+ isRecord(value["lanes"]) &&
112
+ Object.keys(value["lanes"]).every(isProtocol) &&
113
+ Object.values(value["lanes"]).every(isSessionLane);
114
+
103
115
  const isProtocol = (value: unknown) =>
104
116
  value === undefined ||
105
117
  (typeof value === "string" && value !== "*" && protocolToken.test(value));
@@ -120,58 +132,79 @@ const isProducer = (value: unknown) => {
120
132
  }
121
133
  };
122
134
 
123
- const decode = (
124
- raw: string | null,
125
- legacyProtocol: KeyValueSessionStorage.Options["legacyProtocol"],
126
- ): StatewireClient.SessionRecord | null => {
127
- if (raw === null) return null;
128
- const parsed: unknown = JSON.parse(raw);
129
- if (!isSessionRecord(parsed))
130
- throw new Error("statewire: invalid stored session record");
135
+ const invalid = (): never => {
136
+ throw new Error("statewire: invalid stored session record");
137
+ };
138
+
139
+ // Formats 1 and 2 held one lane: it lifts under the primary protocol, failed
140
+ // entries under their own.
141
+ const liftLegacy = (
142
+ value: Record<string, unknown>,
143
+ protocol: string,
144
+ ): StatewireClient.SessionRecord => {
145
+ const { clientId, nextSeq, commands, verdicts, failed } = value;
146
+ const lane = {
147
+ nextSeq,
148
+ commands: [],
149
+ ...(verdicts !== undefined && { verdicts }),
150
+ };
151
+ if (!isClientId(clientId) || !isSessionLane(lane) || !Array.isArray(commands))
152
+ return invalid();
153
+ const legacyCommands = commands as (StatewireClient.SessionCommand & {
154
+ protocol?: string;
155
+ })[];
156
+ if (!legacyCommands.every(isSessionCommand)) return invalid();
157
+ if (
158
+ failed !== undefined &&
159
+ (!Array.isArray(failed) || !failed.every(isFailedCommand))
160
+ )
161
+ return invalid();
162
+ const lanes: Record<string, StatewireClient.SessionLane> = {
163
+ [protocol]: {
164
+ ...lane,
165
+ commands: legacyCommands
166
+ .filter((command) => (command.protocol ?? protocol) === protocol)
167
+ .map(({ protocol: _protocol, ...command }) => command),
168
+ },
169
+ };
170
+ for (const entry of failed ?? []) {
171
+ const target = lanes[entry.protocol] ?? { nextSeq: 1, commands: [] };
172
+ lanes[entry.protocol] = {
173
+ ...target,
174
+ failed: [...(target.failed ?? []), entry],
175
+ };
176
+ }
177
+ return { clientId, lanes };
178
+ };
179
+
180
+ const normalize = (
181
+ parsed: unknown,
182
+ protocol: string,
183
+ ): StatewireClient.SessionRecord => {
184
+ if (!isRecord(parsed)) return invalid();
131
185
  const {
132
186
  statewireStorageVersion: format,
133
187
  savedAt: _savedAt,
134
- ctxNextSeq: _ctxNextSeq,
135
- context: _context,
136
188
  ...value
137
- } = parsed as StatewireClient.SessionRecord & {
138
- statewireStorageVersion?: unknown;
139
- savedAt?: unknown;
140
- ctxNextSeq?: unknown;
141
- context?: unknown;
142
- };
143
- if (!isSessionRecord(value))
144
- throw new Error("statewire: invalid stored session record");
189
+ } = parsed;
190
+ if (format === 3) return isSessionRecord(value) ? value : invalid();
145
191
  if (format !== undefined && format !== 1 && format !== 2)
146
192
  throw new Error("statewire: unsupported session storage version");
147
- if (legacyProtocol === undefined || format !== undefined) return value;
148
- const produced = {
149
- wire: { version: "2026-09-07" },
150
- protocols: [legacyProtocol],
151
- };
152
- return {
153
- ...value,
154
- commands: value.commands.map((command) =>
155
- command.produced === undefined
156
- ? {
157
- ...command,
158
- protocol: command.protocol ?? legacyProtocol.name,
159
- produced,
160
- }
161
- : command,
162
- ),
163
- ...(value.failed === undefined
164
- ? {}
165
- : {
166
- failed: value.failed.map((entry) => ({
167
- ...entry,
168
- protocol: entry.protocol ?? legacyProtocol.name,
169
- produced: entry.produced ?? produced,
170
- })),
171
- }),
172
- };
193
+ const { ctxNextSeq: _ctxNextSeq, context: _context, ...legacy } = value;
194
+ return liftLegacy(legacy, protocol);
173
195
  };
174
196
 
197
+ const decode = (
198
+ raw: string | null,
199
+ protocol: string,
200
+ ): StatewireClient.SessionRecord | null =>
201
+ raw === null ? null : normalize(JSON.parse(raw), protocol);
202
+
203
+ const isEmpty = (record: StatewireClient.SessionRecord) =>
204
+ Object.values(record.lanes).every(
205
+ (lane) => lane.commands.length === 0 && !lane.failed?.length,
206
+ );
207
+
175
208
  const active = new WeakMap<
176
209
  KeyValueSessionStorage.Backend,
177
210
  Map<string, number>
@@ -193,16 +226,11 @@ const sweep = async (
193
226
  } catch {
194
227
  return undefined;
195
228
  }
196
- const format = isRecord(value)
197
- ? value["statewireStorageVersion"]
198
- : undefined;
199
- if (format !== undefined && format !== 1 && format !== 2) return undefined;
200
- if (
201
- !isSessionRecord(value) ||
202
- value.commands.length ||
203
- value.failed?.length
204
- )
229
+ try {
230
+ if (!isEmpty(normalize(value, DEFAULT_PROTOCOL))) return undefined;
231
+ } catch {
205
232
  return undefined;
233
+ }
206
234
  const savedAt =
207
235
  (value as Record<string, unknown>)["savedAt"] ??
208
236
  (value as Record<string, unknown>)["updatedAt"];
@@ -254,7 +282,7 @@ const useKeyValueSessionStorage = (
254
282
  backend,
255
283
  key,
256
284
  namespace = "",
257
- legacyProtocol: suppliedLegacyProtocol,
285
+ protocol = DEFAULT_PROTOCOL,
258
286
  migrate,
259
287
  retention: suppliedRetention,
260
288
  onError,
@@ -265,15 +293,6 @@ const useKeyValueSessionStorage = (
265
293
  () => (error: unknown) => latestErrorHandler.current?.(error),
266
294
  [],
267
295
  );
268
- const legacyName = suppliedLegacyProtocol?.name;
269
- const legacyVersion = suppliedLegacyProtocol?.version;
270
- const legacyProtocol = useMemo(
271
- () =>
272
- legacyName === undefined
273
- ? undefined
274
- : { name: legacyName, version: legacyVersion! },
275
- [legacyName, legacyVersion],
276
- );
277
296
  const retain = suppliedRetention !== undefined;
278
297
  const maxAgeMs = suppliedRetention?.maxAgeMs;
279
298
  const maxEntries = suppliedRetention?.maxEntries;
@@ -297,8 +316,6 @@ const useKeyValueSessionStorage = (
297
316
  [backend, key, namespace, retention],
298
317
  );
299
318
  const storageKey = namespace + key;
300
- if (legacyProtocol !== undefined)
301
- negotiationHeaders({ applicationProtocols: [legacyProtocol] });
302
319
  if (retention !== undefined) {
303
320
  if (!namespace)
304
321
  throw new Error("statewire: session retention requires a namespace");
@@ -336,7 +353,7 @@ const useKeyValueSessionStorage = (
336
353
  if (state.asynchronous) {
337
354
  state.ready = Promise.resolve(raw)
338
355
  .then((value) => {
339
- state.record = decode(value, legacyProtocol);
356
+ state.record = decode(value, protocol);
340
357
  state.hydrated = true;
341
358
  })
342
359
  .catch((cause: unknown) => {
@@ -348,7 +365,7 @@ const useKeyValueSessionStorage = (
348
365
  state.ready.catch(() => {});
349
366
  } else {
350
367
  try {
351
- state.record = decode(raw as string | null, legacyProtocol);
368
+ state.record = decode(raw as string | null, protocol);
352
369
  } catch (cause) {
353
370
  const error = wrapStorageError(cause);
354
371
  reportError(error);
@@ -356,7 +373,7 @@ const useKeyValueSessionStorage = (
356
373
  }
357
374
  }
358
375
  return state;
359
- }, [backend, storageKey, legacyProtocol, reportError]);
376
+ }, [backend, storageKey, protocol, reportError]);
360
377
 
361
378
  useEffect(() => {
362
379
  const entries = active.get(backend) ?? new Map<string, number>();
@@ -401,7 +418,7 @@ const useKeyValueSessionStorage = (
401
418
  "statewire: storage backend changed from synchronous to asynchronous",
402
419
  );
403
420
  try {
404
- return decode(raw, legacyProtocol);
421
+ return decode(raw, protocol);
405
422
  } catch (cause) {
406
423
  const error = wrapStorageError(cause);
407
424
  reportError(error);
@@ -418,7 +435,7 @@ const useKeyValueSessionStorage = (
418
435
  throw new Error("statewire: invalid session record");
419
436
  const encoded = JSON.stringify({
420
437
  ...record,
421
- statewireStorageVersion: 2,
438
+ statewireStorageVersion: 3,
422
439
  savedAt: Date.now(),
423
440
  });
424
441
  state.record = record;
@@ -483,7 +500,7 @@ const useKeyValueSessionStorage = (
483
500
  state,
484
501
  backend,
485
502
  storageKey,
486
- legacyProtocol,
503
+ protocol,
487
504
  migrate,
488
505
  retention,
489
506
  configured,
@@ -508,8 +525,8 @@ export namespace KeyValueSessionStorage {
508
525
  backend: Backend;
509
526
  key: string;
510
527
  namespace?: string;
511
- /** The application format used by sessions saved before version negotiation. */
512
- legacyProtocol?: { name: string; version: string };
528
+ /** The lane that sessions saved in a single-lane format lift into; defaults to the client's default protocol. */
529
+ protocol?: string;
513
530
  migrate?: StatewireClient.SessionStorage["migrate"];
514
531
  retention?: { maxAgeMs?: number; maxEntries?: number };
515
532
  onError?: (error: unknown) => void;
@@ -4,11 +4,9 @@ import type { Statewire } from "../types";
4
4
  import {
5
5
  documentSource,
6
6
  invokeProtocol,
7
- updateContext,
8
7
  discardFailed,
9
8
  failedCommands,
10
9
  retryFailed,
11
- setContext,
12
10
  setTransport,
13
11
  useStatewireSession,
14
12
  } from "../client/session";
@@ -40,17 +38,12 @@ export const useStatewireRemote = <
40
38
  },
41
39
  invoke: (protocol, method, params) =>
42
40
  invokeProtocol(s, protocol, method, params),
43
- updateContext: (ops) => updateContext(s, ops),
44
41
  commands: s.commands,
45
42
  connection: conn.connection,
46
43
  source: s.source,
47
- get durableSeq() {
48
- return s.durableSeq;
49
- },
50
44
  get isBusy() {
51
45
  return !s.idle;
52
46
  },
53
- setContext: (value) => setContext(s, value),
54
47
  failures: {
55
48
  list: () => failedCommands(s),
56
49
  retry: (entry) => retryFailed(s, entry),
@@ -25,14 +25,19 @@ import { negotiationHeaders, parseNegotiation } from "../wire/protocol";
25
25
 
26
26
  /** A client frame's lanes: stamped statements, strictly consecutive per lane. */
27
27
  export type StatewireFrame = {
28
- ctx?: { ops: unknown[]; seq: number }[];
29
- cmd?: { method: string; params: unknown[]; meta?: unknown; seq: number }[];
28
+ cmd?: {
29
+ protocol: string;
30
+ method: string;
31
+ params: unknown[];
32
+ meta?: unknown;
33
+ seq: number;
34
+ }[];
30
35
  };
31
36
 
32
37
  export type StatewireRemoteProps = {
33
38
  beforeAttach?: () => void | Promise<void>;
34
39
  clientId: string;
35
- /** Lanes to ride the attach frame: the context restatement, plus cmds on no-lease supersession; records the included seqs. */
40
+ /** Lanes to ride the attach frame: ephemeral-lane statements, plus durable cmds on no-lease supersession; records the included entries. */
36
41
  takeAttachLanes: () => StatewireFrame | null;
37
42
  onHello: (args: { ops: unknown[]; syn: StatewireSyn }) => void;
38
43
  onOps: (args: { ops: unknown[]; cmds: StatewireCmd[] }) => void;