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.
- 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 +296 -401
- 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 +9 -19
- package/dist/host/http.js.map +1 -1
- package/dist/host/socket.d.ts +48 -25
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +399 -266
- 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 -12
- 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 -20
- 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 +54 -89
- 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 -18
- 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 +442 -517
- 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 +23 -31
- package/src/host/socket.ts +560 -339
- package/src/host/synced.ts +60 -0
- package/src/host-internal.ts +21 -7
- package/src/host.ts +40 -8
- package/src/index.ts +1 -0
- package/src/statewire.ts +8 -37
- package/src/storage.ts +90 -157
- package/src/transports/remote.ts +0 -7
- package/src/transports/sse.ts +8 -3
- package/src/transports/testing.ts +34 -18
- 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,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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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, `
|
|
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,17 +405,9 @@ 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
|
-
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 {
|
|
7
|
-
|
|
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
|
|
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
|
|
127
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
!Array.isArray(
|
|
167
|
-
!Array.isArray(value["pending"])
|
|
158
|
+
failed !== undefined &&
|
|
159
|
+
(!Array.isArray(failed) || !failed.every(isFailedCommand))
|
|
168
160
|
)
|
|
169
|
-
return
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
): StatewireClient.SessionRecord
|
|
198
|
-
if (
|
|
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
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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
|
|
596
|
-
|
|
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;
|