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.
- package/dist/client/connection.d.ts +8 -5
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +24 -13
- package/dist/client/connection.js.map +1 -1
- package/dist/client/session.d.ts +13 -15
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +291 -256
- package/dist/client/session.js.map +1 -1
- package/dist/client/timing.d.ts +2 -1
- package/dist/client/timing.d.ts.map +1 -1
- package/dist/client/timing.js +2 -1
- package/dist/client/timing.js.map +1 -1
- package/dist/host/engine.d.ts +2 -0
- package/dist/host/engine.d.ts.map +1 -1
- package/dist/host/engine.js +2 -0
- package/dist/host/engine.js.map +1 -1
- package/dist/host/host.d.ts +3 -1
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js +38 -7
- package/dist/host/host.js.map +1 -1
- package/dist/host/http.d.ts.map +1 -1
- package/dist/host/http.js +8 -17
- package/dist/host/http.js.map +1 -1
- package/dist/host/socket.d.ts +47 -23
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +389 -245
- package/dist/host/socket.js.map +1 -1
- package/dist/host/synced.d.ts +9 -0
- package/dist/host/synced.d.ts.map +1 -0
- package/dist/host/synced.js +45 -0
- package/dist/host/synced.js.map +1 -0
- package/dist/host-internal.d.ts +20 -8
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/host.d.ts +33 -9
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +2 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -1
- package/dist/statewire.d.ts +8 -10
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +0 -14
- package/dist/statewire.js.map +1 -1
- package/dist/storage.d.ts +2 -5
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +55 -41
- package/dist/storage.js.map +1 -1
- package/dist/transports/remote.d.ts.map +1 -1
- package/dist/transports/remote.js +1 -6
- package/dist/transports/remote.js.map +1 -1
- package/dist/transports/sse.d.ts +2 -5
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js.map +1 -1
- package/dist/transports/testing.d.ts +7 -5
- package/dist/transports/testing.d.ts.map +1 -1
- package/dist/transports/testing.js +19 -19
- package/dist/transports/testing.js.map +1 -1
- package/dist/types.d.ts +3 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/wire/limits.d.ts +2 -2
- package/dist/wire/limits.js +2 -2
- package/dist/wire/limits.js.map +1 -1
- package/dist/wire/protocol.d.ts +6 -1
- package/dist/wire/protocol.d.ts.map +1 -1
- package/dist/wire/protocol.js +33 -12
- package/dist/wire/protocol.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +46 -33
- package/src/client/session.ts +434 -365
- package/src/client/timing.ts +2 -0
- package/src/host/engine.ts +3 -1
- package/src/host/host.ts +50 -3
- package/src/host/http.ts +22 -23
- package/src/host/socket.ts +550 -321
- package/src/host/synced.ts +60 -0
- package/src/host-internal.ts +21 -3
- package/src/host.ts +40 -8
- package/src/index.ts +1 -0
- package/src/statewire.ts +8 -30
- package/src/storage.ts +96 -79
- package/src/transports/remote.ts +0 -7
- package/src/transports/sse.ts +8 -3
- package/src/transports/testing.ts +34 -19
- package/src/types.ts +3 -5
- package/src/wire/limits.ts +1 -1
- 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
|
+
});
|
package/src/host-internal.ts
CHANGED
|
@@ -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
|
-
|
|
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, `
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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), `
|
|
50
|
-
*
|
|
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
|
-
/**
|
|
436
|
-
export type
|
|
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 {
|
|
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
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
138
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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
|
|
512
|
-
|
|
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;
|
package/src/transports/remote.ts
CHANGED
|
@@ -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),
|
package/src/transports/sse.ts
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
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:
|
|
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;
|