statewire 0.18.1 → 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 +296 -401
  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 +9 -19
  23. package/dist/host/http.js.map +1 -1
  24. package/dist/host/socket.d.ts +48 -25
  25. package/dist/host/socket.d.ts.map +1 -1
  26. package/dist/host/socket.js +399 -266
  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 -12
  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 -20
  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 +54 -89
  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 -18
  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 +442 -517
  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 +23 -31
  74. package/src/host/socket.ts +560 -339
  75. package/src/host/synced.ts +60 -0
  76. package/src/host-internal.ts +21 -7
  77. package/src/host.ts +40 -8
  78. package/src/index.ts +1 -0
  79. package/src/statewire.ts +8 -37
  80. package/src/storage.ts +90 -157
  81. package/src/transports/remote.ts +0 -7
  82. package/src/transports/sse.ts +8 -3
  83. package/src/transports/testing.ts +34 -18
  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,7 +1,5 @@
1
1
  import type { StatewireProtocol } from "./wire/protocol";
2
- import type { StatewireDocuments } from "./wire/documents";
3
2
  import type { StatewireHost } from "./host";
4
- import type { StatewireCmd } from "./client/connection";
5
3
 
6
4
  export { StatewireSocketHost } from "./host/socket";
7
5
  export { clientIdFromUrl, textFrameOrClose } from "./host/adapter";
@@ -46,14 +44,30 @@ export namespace StatewireHostInternal {
46
44
  dispose: () => void;
47
45
  };
48
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
+
49
59
  export type ClientRecord = {
50
60
  id: string;
51
- lastSeq: number;
52
- ctxSeq: number;
53
61
  touched: number;
54
- context: Record<string, unknown> | null;
55
- contextDocuments?: readonly [number, StatewireDocuments.Document][];
56
- 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
+ >;
57
71
  principal: unknown;
58
72
  };
59
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
- * client's current ambient context live (null until the client 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,17 +405,9 @@ export namespace StatewireClient {
432
405
  meta?: unknown;
433
406
  };
434
407
 
435
- /** The persisted session: the lane identity, the lane clocks, 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
- ctxNextSeq: number;
440
- context?: {
441
- ops: unknown[];
442
- pending: { seq: number; ops: unknown[] }[];
443
- /** Recover the original wire's unpersisted single-document context after the next hello. */
444
- recover?: "version-1";
445
- };
446
411
  commands: readonly SessionCommand[];
447
412
  /** Terminal verdicts parked for the sharer that minted the seq (shared storage only). */
448
413
  verdicts?: readonly SessionVerdict[];
@@ -450,6 +415,12 @@ export namespace StatewireClient {
450
415
  failed?: readonly FailedCommand[];
451
416
  };
452
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
+
453
424
  /** A terminal verdict one sharer's connection carried, parked for the sharer awaiting the seq. */
454
425
  export type SessionVerdict = {
455
426
  seq: number;
package/src/storage.ts CHANGED
@@ -3,8 +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";
7
- import { applyDocumentOps } from "./wire/documents";
6
+ import {
7
+ DEFAULT_PROTOCOL,
8
+ negotiationHeaders,
9
+ protocolToken,
10
+ } from "./wire/protocol";
8
11
 
9
12
  const isRecord = (value: unknown): value is Record<string, unknown> =>
10
13
  typeof value === "object" && value !== null && !Array.isArray(value);
@@ -86,15 +89,9 @@ const isFailedCommand = (
86
89
  Number.isFinite(value["ts"]) &&
87
90
  isPositiveSafeInteger(value["seq"]);
88
91
 
89
- const isSessionRecord = (
90
- value: unknown,
91
- ): value is StatewireClient.SessionRecord =>
92
+ const isSessionLane = (value: unknown): value is StatewireClient.SessionLane =>
92
93
  isRecord(value) &&
93
- typeof value["clientId"] === "string" &&
94
- value["clientId"].length > 0 &&
95
- isContext(value["context"]) &&
96
94
  isPositiveSafeInteger(value["nextSeq"]) &&
97
- isPositiveSafeInteger(value["ctxNextSeq"]) &&
98
95
  Array.isArray(value["commands"]) &&
99
96
  value["commands"].every(isSessionCommand) &&
100
97
  (value["verdicts"] === undefined ||
@@ -103,6 +100,18 @@ const isSessionRecord = (
103
100
  (value["failed"] === undefined ||
104
101
  (Array.isArray(value["failed"]) && value["failed"].every(isFailedCommand)));
105
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
+
106
115
  const isProtocol = (value: unknown) =>
107
116
  value === undefined ||
108
117
  (typeof value === "string" && value !== "*" && protocolToken.test(value));
@@ -123,138 +132,79 @@ const isProducer = (value: unknown) => {
123
132
  }
124
133
  };
125
134
 
126
- const isContextOperation = (value: unknown) => {
127
- if (!isRecord(value)) return false;
128
- if (
129
- value["doc"] !== undefined &&
130
- (typeof value["doc"] !== "number" ||
131
- !Number.isSafeInteger(value["doc"]) ||
132
- value["doc"] < 0)
133
- )
134
- return false;
135
- if (value["op"] === "mount") {
136
- try {
137
- applyDocumentOps(
138
- { mode: "mounted", documents: new Map() },
139
- [value],
140
- undefined,
141
- true,
142
- );
143
- return true;
144
- } catch {
145
- return false;
146
- }
147
- }
148
- if (value["op"] === "unmount") return true;
149
- if (!["add", "replace", "remove"].includes(value["op"] as string))
150
- return false;
151
- return (
152
- Array.isArray(value["path"]) &&
153
- value["path"].every(
154
- (part) =>
155
- typeof part === "string" ||
156
- (typeof part === "number" && Number.isSafeInteger(part) && part >= 0),
157
- ) &&
158
- (value["op"] === "remove" || "value" in value)
159
- );
135
+ const invalid = (): never => {
136
+ throw new Error("statewire: invalid stored session record");
160
137
  };
161
138
 
162
- const isContext = (value: unknown) => {
163
- if (value === undefined) return true;
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();
164
157
  if (
165
- !isRecord(value) ||
166
- !Array.isArray(value["ops"]) ||
167
- !Array.isArray(value["pending"])
158
+ failed !== undefined &&
159
+ (!Array.isArray(failed) || !failed.every(isFailedCommand))
168
160
  )
169
- return false;
170
- if (value["recover"] !== undefined && value["recover"] !== "version-1")
171
- return false;
172
- try {
173
- const documents = applyDocumentOps(
174
- { mode: "mounted", documents: new Map() },
175
- value["ops"],
176
- undefined,
177
- true,
178
- );
179
- for (const entry of value["pending"]) {
180
- if (
181
- !isRecord(entry) ||
182
- !isPositiveSafeInteger(entry["seq"]) ||
183
- !Array.isArray(entry["ops"])
184
- )
185
- return false;
186
- if (!entry["ops"].every(isContextOperation)) return false;
187
- }
188
- return documents.mode === "mounted";
189
- } catch {
190
- return false;
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
+ };
191
176
  }
177
+ return { clientId, lanes };
192
178
  };
193
179
 
194
- const decode = (
195
- raw: string | null,
196
- legacyProtocol: KeyValueSessionStorage.Options["legacyProtocol"],
197
- ): StatewireClient.SessionRecord | null => {
198
- if (raw === null) return null;
199
- const parsed: unknown = JSON.parse(raw);
200
- if (!isSessionRecord(parsed))
201
- throw new Error("statewire: invalid stored session record");
180
+ const normalize = (
181
+ parsed: unknown,
182
+ protocol: string,
183
+ ): StatewireClient.SessionRecord => {
184
+ if (!isRecord(parsed)) return invalid();
202
185
  const {
203
186
  statewireStorageVersion: format,
204
187
  savedAt: _savedAt,
205
188
  ...value
206
- } = parsed as StatewireClient.SessionRecord & {
207
- statewireStorageVersion?: unknown;
208
- savedAt?: unknown;
209
- };
210
- if (!isSessionRecord(value))
211
- throw new Error("statewire: invalid stored session record");
212
- if (format !== undefined && format !== 1)
189
+ } = parsed;
190
+ if (format === 3) return isSessionRecord(value) ? value : invalid();
191
+ if (format !== undefined && format !== 1 && format !== 2)
213
192
  throw new Error("statewire: unsupported session storage version");
214
- if (legacyProtocol === undefined || format !== undefined) return value;
215
- const produced = {
216
- wire: { version: "2026-09-07" },
217
- protocols: [legacyProtocol],
218
- };
219
- return {
220
- ...value,
221
- ...(value.context === undefined && value.ctxNextSeq > 1
222
- ? {
223
- context: {
224
- ops: [
225
- {
226
- op: "mount",
227
- protocol: legacyProtocol.name,
228
- main: true,
229
- value: {},
230
- },
231
- ],
232
- pending: [],
233
- recover: "version-1",
234
- },
235
- }
236
- : {}),
237
- commands: value.commands.map((command) =>
238
- command.produced === undefined
239
- ? {
240
- ...command,
241
- protocol: command.protocol ?? legacyProtocol.name,
242
- produced,
243
- }
244
- : command,
245
- ),
246
- ...(value.failed === undefined
247
- ? {}
248
- : {
249
- failed: value.failed.map((entry) => ({
250
- ...entry,
251
- protocol: entry.protocol ?? legacyProtocol.name,
252
- produced: entry.produced ?? produced,
253
- })),
254
- }),
255
- };
193
+ const { ctxNextSeq: _ctxNextSeq, context: _context, ...legacy } = value;
194
+ return liftLegacy(legacy, protocol);
256
195
  };
257
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
+
258
208
  const active = new WeakMap<
259
209
  KeyValueSessionStorage.Backend,
260
210
  Map<string, number>
@@ -276,17 +226,11 @@ const sweep = async (
276
226
  } catch {
277
227
  return undefined;
278
228
  }
279
- const format = isRecord(value)
280
- ? value["statewireStorageVersion"]
281
- : undefined;
282
- if (format !== undefined && format !== 1) return undefined;
283
- if (
284
- !isSessionRecord(value) ||
285
- value.commands.length ||
286
- value.failed?.length ||
287
- value.context?.pending.length
288
- )
229
+ try {
230
+ if (!isEmpty(normalize(value, DEFAULT_PROTOCOL))) return undefined;
231
+ } catch {
289
232
  return undefined;
233
+ }
290
234
  const savedAt =
291
235
  (value as Record<string, unknown>)["savedAt"] ??
292
236
  (value as Record<string, unknown>)["updatedAt"];
@@ -338,7 +282,7 @@ const useKeyValueSessionStorage = (
338
282
  backend,
339
283
  key,
340
284
  namespace = "",
341
- legacyProtocol: suppliedLegacyProtocol,
285
+ protocol = DEFAULT_PROTOCOL,
342
286
  migrate,
343
287
  retention: suppliedRetention,
344
288
  onError,
@@ -349,15 +293,6 @@ const useKeyValueSessionStorage = (
349
293
  () => (error: unknown) => latestErrorHandler.current?.(error),
350
294
  [],
351
295
  );
352
- const legacyName = suppliedLegacyProtocol?.name;
353
- const legacyVersion = suppliedLegacyProtocol?.version;
354
- const legacyProtocol = useMemo(
355
- () =>
356
- legacyName === undefined
357
- ? undefined
358
- : { name: legacyName, version: legacyVersion! },
359
- [legacyName, legacyVersion],
360
- );
361
296
  const retain = suppliedRetention !== undefined;
362
297
  const maxAgeMs = suppliedRetention?.maxAgeMs;
363
298
  const maxEntries = suppliedRetention?.maxEntries;
@@ -381,8 +316,6 @@ const useKeyValueSessionStorage = (
381
316
  [backend, key, namespace, retention],
382
317
  );
383
318
  const storageKey = namespace + key;
384
- if (legacyProtocol !== undefined)
385
- negotiationHeaders({ applicationProtocols: [legacyProtocol] });
386
319
  if (retention !== undefined) {
387
320
  if (!namespace)
388
321
  throw new Error("statewire: session retention requires a namespace");
@@ -420,7 +353,7 @@ const useKeyValueSessionStorage = (
420
353
  if (state.asynchronous) {
421
354
  state.ready = Promise.resolve(raw)
422
355
  .then((value) => {
423
- state.record = decode(value, legacyProtocol);
356
+ state.record = decode(value, protocol);
424
357
  state.hydrated = true;
425
358
  })
426
359
  .catch((cause: unknown) => {
@@ -432,7 +365,7 @@ const useKeyValueSessionStorage = (
432
365
  state.ready.catch(() => {});
433
366
  } else {
434
367
  try {
435
- state.record = decode(raw as string | null, legacyProtocol);
368
+ state.record = decode(raw as string | null, protocol);
436
369
  } catch (cause) {
437
370
  const error = wrapStorageError(cause);
438
371
  reportError(error);
@@ -440,7 +373,7 @@ const useKeyValueSessionStorage = (
440
373
  }
441
374
  }
442
375
  return state;
443
- }, [backend, storageKey, legacyProtocol, reportError]);
376
+ }, [backend, storageKey, protocol, reportError]);
444
377
 
445
378
  useEffect(() => {
446
379
  const entries = active.get(backend) ?? new Map<string, number>();
@@ -485,7 +418,7 @@ const useKeyValueSessionStorage = (
485
418
  "statewire: storage backend changed from synchronous to asynchronous",
486
419
  );
487
420
  try {
488
- return decode(raw, legacyProtocol);
421
+ return decode(raw, protocol);
489
422
  } catch (cause) {
490
423
  const error = wrapStorageError(cause);
491
424
  reportError(error);
@@ -502,7 +435,7 @@ const useKeyValueSessionStorage = (
502
435
  throw new Error("statewire: invalid session record");
503
436
  const encoded = JSON.stringify({
504
437
  ...record,
505
- statewireStorageVersion: 1,
438
+ statewireStorageVersion: 3,
506
439
  savedAt: Date.now(),
507
440
  });
508
441
  state.record = record;
@@ -567,7 +500,7 @@ const useKeyValueSessionStorage = (
567
500
  state,
568
501
  backend,
569
502
  storageKey,
570
- legacyProtocol,
503
+ protocol,
571
504
  migrate,
572
505
  retention,
573
506
  configured,
@@ -592,8 +525,8 @@ export namespace KeyValueSessionStorage {
592
525
  backend: Backend;
593
526
  key: string;
594
527
  namespace?: string;
595
- /** The application format used by sessions saved before version negotiation. */
596
- 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;
597
530
  migrate?: StatewireClient.SessionStorage["migrate"];
598
531
  retention?: { maxAgeMs?: number; maxEntries?: number };
599
532
  onError?: (error: unknown) => void;