statewire 0.11.0 → 0.12.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/README.md +2 -2
- package/dist/client/connection.d.ts +6 -4
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +10 -2
- package/dist/client/connection.js.map +1 -1
- package/dist/client/core.d.ts +1 -1
- package/dist/client/session.d.ts +4 -2
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +102 -102
- package/dist/client/session.js.map +1 -1
- package/dist/host/host.d.ts +1 -1
- package/dist/host/http.js +4 -3
- package/dist/host/http.js.map +1 -1
- package/dist/host/socket.d.ts +6 -5
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +12 -20
- package/dist/host/socket.js.map +1 -1
- package/dist/host-internal.d.ts +2 -1
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/host.d.ts +2 -2
- package/dist/host.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/statewire.d.ts +15 -4
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js.map +1 -1
- package/dist/transports/fake-sse.js +4 -4
- package/dist/transports/fake-sse.js.map +1 -1
- package/dist/transports/fixtures.d.ts +1 -1
- package/dist/transports/remote.d.ts +1 -1
- package/dist/transports/remote.js +1 -0
- package/dist/transports/remote.js.map +1 -1
- package/dist/transports/sse.d.ts +2 -2
- package/dist/transports/testing.d.ts +1 -1
- package/dist/transports/testing.js +2 -4
- package/dist/transports/testing.js.map +1 -1
- package/dist/wire/meta.d.ts +5 -3
- package/dist/wire/meta.d.ts.map +1 -1
- package/dist/wire/meta.js +7 -4
- package/dist/wire/meta.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +12 -6
- package/src/client/session.ts +145 -139
- package/src/host/http.ts +10 -9
- package/src/host/socket.ts +18 -28
- package/src/host-internal.ts +2 -1
- package/src/host.ts +0 -1
- package/src/statewire.ts +18 -4
- package/src/transports/fake-sse.ts +6 -6
- package/src/transports/remote.ts +1 -0
- package/src/transports/testing.ts +2 -7
- package/src/wire/meta.ts +12 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"testing.js","names":["ResourceElement","StatewireEnvelope","StatewireSSE","StatewireFrame","Statewire","openSSEStream","sseFrame","FakeStreamHandle","tick","Promise","r","setTimeout","applyOptimistic","MockTransport","transport","TState","commands","method","params","context","setState","state","applied","seq","settle","payload","sendEnvelope","envelope","finish","reason","message","clientId","attached","mockCount","createMockTransport","initialState","finished","clients","Map","ctxSeq","streams","Set","stream","send","admit","id","frame","rec","get","statement","ctx","Math","max","root","ops","value","push","cmd","fakeFetch","input","RequestInfo","URL","init","RequestInit","Response","TypeError","url","String","headers","Headers","endsWith","
|
|
1
|
+
{"version":3,"file":"testing.js","names":["ResourceElement","StatewireEnvelope","StatewireSSE","StatewireFrame","Statewire","openSSEStream","sseFrame","FakeStreamHandle","tick","Promise","r","setTimeout","applyOptimistic","MockTransport","transport","TState","commands","method","params","context","setState","state","applied","seq","settle","payload","sendEnvelope","envelope","finish","reason","message","clientId","attached","mockCount","createMockTransport","initialState","finished","clients","Map","ctxSeq","streams","Set","stream","send","admit","id","frame","rec","get","statement","ctx","Math","max","root","ops","value","push","cmd","fakeFetch","input","RequestInfo","URL","init","RequestInit","Response","TypeError","url","String","headers","Headers","endsWith","has","set","JSON","parse","body","handle","op","path","syn","signal","undefined","json","status","fetch","next","type","fin","close","size"],"sources":["../../src/transports/testing.ts"],"sourcesContent":["import type { ResourceElement } from \"@assistant-ui/tap\";\nimport type { StatewireEnvelope } from \"../client/connection\";\nimport { StatewireSSE } from \"./sse\";\nimport type { StatewireFrame } from \"./sse\";\nimport type { Statewire } from \"../types\";\nimport { openSSEStream, sseFrame } from \"./fake-sse\";\nimport type { FakeStreamHandle } from \"./fake-sse\";\n\nexport * from \"./fixtures\";\n\n/** Let queued microtasks and op batches drain through the statewire client. */\nexport const tick = () => new Promise<void>((r) => setTimeout(r, 0));\n\nexport { applyOptimistic } from \"../util/recordMutations\";\n\nexport type MockTransport<TState> = {\n /** Pass as the `transport:` option of useStatewire / Harness. */\n transport: ResourceElement<Statewire<TState | undefined, any>>;\n /** Captured statements in arrival order: cmd as `{method, params}`, ctx as `{context}`. */\n commands: { method?: string; params?: unknown[]; context?: unknown }[];\n /** Replace the replicated state and broadcast it as a root snapshot op. */\n setState: (state: TState) => Promise<void>;\n /** Deliver a bare ack cmd packet on the stream. */\n applied: (seq: number) => Promise<void>;\n /** Deliver a terminal result cmd packet on the stream. */\n settle: (seq: number, payload?: unknown) => Promise<void>;\n /** Deliver an arbitrary envelope on the stream. */\n sendEnvelope: (envelope: StatewireEnvelope) => Promise<void>;\n /** End the stream with a fin; further attaches fail like a dead server. */\n finish: (reason: string, message?: string) => Promise<void>;\n readonly clientId: string;\n readonly attached: boolean;\n};\n\n/**\n * A mock statewire backend: serves `initialState` as the attach snapshot,\n * records submitted frames (receipt only — acks and results are delivered\n * from test code), and exposes the wire as a transport ResourceElement.\n */\nlet mockCount = 0;\n\nexport const createMockTransport = <TState>(\n initialState: TState,\n): MockTransport<TState> => {\n let state: unknown = initialState;\n let clientId = \"\";\n let finished = false;\n const clients = new Map<string, { seq: number; ctxSeq: number }>();\n const streams = new Set<FakeStreamHandle>();\n const commands: MockTransport<unknown>[\"commands\"] = [];\n\n const sendEnvelope = (envelope: StatewireEnvelope) => {\n for (const stream of streams) stream.send(sseFrame(envelope));\n return tick();\n };\n\n const admit = (id: string, frame: StatewireFrame) => {\n const rec = clients.get(id)!;\n for (const statement of frame.ctx ?? []) {\n rec.ctxSeq = Math.max(rec.ctxSeq, statement.seq);\n const root = statement.ops[0] as { value?: unknown } | undefined;\n commands.push({ context: root?.value });\n }\n for (const statement of frame.cmd ?? []) {\n rec.seq = Math.max(rec.seq, statement.seq);\n commands.push({ method: statement.method, params: statement.params });\n }\n };\n\n const fakeFetch = (async (\n input: RequestInfo | URL,\n init?: RequestInit,\n ): Promise<Response> => {\n if (finished) throw new TypeError(\"fetch failed (finished)\");\n const url = String(input);\n const headers = new Headers(init?.headers);\n const id = headers.get(\"Statewire-Client-Id\") ?? \"\";\n if (url.endsWith(\"/stream\")) {\n clientId = id;\n if (!clients.has(id)) clients.set(id, { seq: 0, ctxSeq: 0 });\n const rec = clients.get(id)!;\n const frame = JSON.parse(String(init?.body)) as StatewireFrame;\n admit(id, frame);\n return openSSEStream(\n streams,\n id,\n (handle) => {\n handle.send(\n sseFrame({\n ops: [{ op: \"replace\", path: [], value: state }],\n syn: { seq: rec.seq, ctxSeq: rec.ctxSeq },\n }),\n );\n },\n init?.signal ?? undefined,\n { \"Statewire-Lease\": \"mock-lease\" },\n );\n }\n if (url.endsWith(\"/frames\")) {\n admit(id, JSON.parse(String(init?.body)) as StatewireFrame);\n return Response.json({});\n }\n return new Response(null, { status: 404 });\n }) as typeof fetch;\n\n return {\n transport: StatewireSSE({\n url: `http://mock-${++mockCount}/api`,\n fetch: fakeFetch,\n }),\n commands,\n setState: (next) => {\n state = next;\n return sendEnvelope({ ops: [{ op: \"replace\", path: [], value: next }] });\n },\n applied: (seq) => sendEnvelope({ cmd: [{ seq, type: \"applied\" }] }),\n settle: (seq, payload) =>\n sendEnvelope({\n cmd: [\n {\n seq,\n type: \"result\",\n ...(payload !== undefined && { payload }),\n },\n ],\n }),\n sendEnvelope,\n finish: async (reason, message) => {\n await sendEnvelope({\n fin: { reason, ...(message !== undefined && { message }) },\n });\n finished = true;\n for (const stream of [...streams]) stream.close();\n await tick();\n },\n get clientId() {\n return clientId;\n },\n get attached() {\n return streams.size > 0;\n },\n };\n};\n"],"mappings":"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"}
|
package/dist/wire/meta.d.ts
CHANGED
|
@@ -3,9 +3,11 @@ type StatewireMetaEntry = {
|
|
|
3
3
|
path: (string | number)[];
|
|
4
4
|
type: string;
|
|
5
5
|
};
|
|
6
|
-
/** Revive meta-typed locations inside a wire value (in place).
|
|
7
|
-
*
|
|
8
|
-
declare const reviveValue: (value: unknown, meta: unknown
|
|
6
|
+
/** Revive meta-typed locations inside a wire value (in place). Clients skip
|
|
7
|
+
* unknown types; hosts pass `rejectUnknown` to fault on them instead. */
|
|
8
|
+
declare const reviveValue: (value: unknown, meta: unknown, { rejectUnknown }?: {
|
|
9
|
+
rejectUnknown?: boolean | undefined;
|
|
10
|
+
}) => unknown;
|
|
9
11
|
declare const reviveOps: (ops: unknown[]) => unknown[];
|
|
10
12
|
declare const reviveResponse: <T extends {
|
|
11
13
|
payload?: unknown;
|
package/dist/wire/meta.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"meta.d.ts","names":[],"sources":["../../src/wire/meta.ts"],"mappings":";KAEY;EACV;EACA;;;;cAiBW,
|
|
1
|
+
{"version":3,"file":"meta.d.ts","names":[],"sources":["../../src/wire/meta.ts"],"mappings":";KAEY;EACV;EACA;;;;cAiBW,cACX,gBACA,iBACA;;;cA6BW,YAAa;cAOb,iBAAkB;EAAY;EAAmB;GAC5D,UAAU,MACT;;;cA+BU,eACX;EACG;EAAmB,OAAO;;;cAOlB,cACX;EACG;EAAgB,OAAO"}
|
package/dist/wire/meta.js
CHANGED
|
@@ -4,14 +4,17 @@ const fault = (message) => {
|
|
|
4
4
|
throw new Error(`statewire: protocol error: ${message}`);
|
|
5
5
|
};
|
|
6
6
|
const isPath = (path) => Array.isArray(path) && path.every((seg) => typeof seg === "string" || typeof seg === "number" && Number.isInteger(seg));
|
|
7
|
-
/** Revive meta-typed locations inside a wire value (in place).
|
|
8
|
-
*
|
|
9
|
-
const reviveValue = (value, meta) => {
|
|
7
|
+
/** Revive meta-typed locations inside a wire value (in place). Clients skip
|
|
8
|
+
* unknown types; hosts pass `rejectUnknown` to fault on them instead. */
|
|
9
|
+
const reviveValue = (value, meta, { rejectUnknown = false } = {}) => {
|
|
10
10
|
if (!Array.isArray(meta)) return fault("meta must be an array");
|
|
11
11
|
for (const entry of meta) {
|
|
12
12
|
const path = typeof entry === "object" && entry !== null ? entry.path : null;
|
|
13
13
|
if (!isPath(path)) return fault("meta entry must carry a path array");
|
|
14
|
-
if (entry.type !== "date")
|
|
14
|
+
if (entry.type !== "date") {
|
|
15
|
+
if (rejectUnknown) return fault(`unknown meta type: ${String(entry.type)}`);
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
15
18
|
const walked = walkStatePath(value, path, "resolve");
|
|
16
19
|
if (walked === void 0) return fault(`meta path ${path.join(".")} does not resolve`);
|
|
17
20
|
if (typeof walked.leaf !== "string") return fault(`meta path ${path.join(".")} must name a string`);
|
package/dist/wire/meta.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"meta.js","names":["walkStatePath","StatewireMetaEntry","path","type","fault","message","Error","isPath","Array","isArray","every","seg","Number","isInteger","reviveValue","value","meta","entry","walked","undefined","join","leaf","date","Date","isNaN","getTime","parent","Record","key","reviveOps","ops","map","op","rest","reviveResponse","payload","response","T","walkDates","node","push","toISOString","item","i","Object","fromEntries","keys","encodeParams","params","encoded","param","length","encodeValue"],"sources":["../../src/wire/meta.ts"],"sourcesContent":["import { walkStatePath } from \"../util/statePath\";\n\nexport type StatewireMetaEntry = {\n path: (string | number)[];\n type: string;\n};\n\nconst fault = (message: string): never => {\n throw new Error(`statewire: protocol error: ${message}`);\n};\n\nconst isPath = (path: unknown): path is (string | number)[] =>\n Array.isArray(path) &&\n path.every(\n (seg) =>\n typeof seg === \"string\" ||\n (typeof seg === \"number\" && Number.isInteger(seg)),\n );\n\n/** Revive meta-typed locations inside a wire value (in place).
|
|
1
|
+
{"version":3,"file":"meta.js","names":["walkStatePath","StatewireMetaEntry","path","type","fault","message","Error","isPath","Array","isArray","every","seg","Number","isInteger","reviveValue","value","meta","rejectUnknown","entry","String","walked","undefined","join","leaf","date","Date","isNaN","getTime","parent","Record","key","reviveOps","ops","map","op","rest","reviveResponse","payload","response","T","walkDates","node","push","toISOString","item","i","Object","fromEntries","keys","encodeParams","params","encoded","param","length","encodeValue"],"sources":["../../src/wire/meta.ts"],"sourcesContent":["import { walkStatePath } from \"../util/statePath\";\n\nexport type StatewireMetaEntry = {\n path: (string | number)[];\n type: string;\n};\n\nconst fault = (message: string): never => {\n throw new Error(`statewire: protocol error: ${message}`);\n};\n\nconst isPath = (path: unknown): path is (string | number)[] =>\n Array.isArray(path) &&\n path.every(\n (seg) =>\n typeof seg === \"string\" ||\n (typeof seg === \"number\" && Number.isInteger(seg)),\n );\n\n/** Revive meta-typed locations inside a wire value (in place). Clients skip\n * unknown types; hosts pass `rejectUnknown` to fault on them instead. */\nexport const reviveValue = (\n value: unknown,\n meta: unknown,\n { rejectUnknown = false } = {},\n): unknown => {\n if (!Array.isArray(meta)) return fault(\"meta must be an array\");\n for (const entry of meta as { path?: unknown; type?: unknown }[]) {\n const path =\n typeof entry === \"object\" && entry !== null ? entry.path : null;\n if (!isPath(path)) return fault(\"meta entry must carry a path array\");\n if (entry.type !== \"date\") {\n if (rejectUnknown)\n return fault(`unknown meta type: ${String(entry.type)}`);\n continue;\n }\n const walked = walkStatePath(value, path, \"resolve\");\n if (walked === undefined) {\n return fault(`meta path ${path.join(\".\")} does not resolve`);\n }\n if (typeof walked.leaf !== \"string\") {\n return fault(`meta path ${path.join(\".\")} must name a string`);\n }\n const date = new Date(walked.leaf);\n if (Number.isNaN(date.getTime())) {\n return fault(`invalid date at meta path ${path.join(\".\")}`);\n }\n if (walked.parent === undefined) return date;\n (walked.parent as Record<string | number, unknown>)[walked.key] = date;\n }\n return value;\n};\n\nexport const reviveOps = (ops: unknown[]): unknown[] =>\n ops.map((op) => {\n if (typeof op !== \"object\" || op === null || !(\"meta\" in op)) return op;\n const { meta, ...rest } = op as { meta: unknown; value?: unknown };\n return { ...rest, value: reviveValue(rest.value, meta) };\n });\n\nexport const reviveResponse = <T extends { payload?: unknown; meta?: unknown }>(\n response: T,\n): T => {\n if (response.meta === undefined) return response;\n const { meta, ...rest } = response;\n return { ...rest, payload: reviveValue(rest.payload, meta) } as T;\n};\n\nconst walkDates = (\n node: unknown,\n path: (string | number)[],\n meta: StatewireMetaEntry[],\n): unknown => {\n if (node instanceof Date) {\n meta.push({ path, type: \"date\" });\n return node.toISOString();\n }\n if (Array.isArray(node)) {\n return node.map((item, i) => walkDates(item, [...path, i], meta));\n }\n if (typeof node === \"object\" && node !== null) {\n return Object.fromEntries(\n Object.keys(node).map((key) => [\n key,\n walkDates((node as Record<string, unknown>)[key], [...path, key], meta),\n ]),\n );\n }\n return node;\n};\n\n/** Encode Date instances inside command params as canonical ISO strings,\n * recording their paths (starting at the param index) in meta. */\nexport const encodeParams = (\n params: readonly unknown[],\n): { params: unknown[]; meta?: StatewireMetaEntry[] } => {\n const meta: StatewireMetaEntry[] = [];\n const encoded = params.map((param, i) => walkDates(param, [i], meta));\n return meta.length > 0 ? { params: encoded, meta } : { params: encoded };\n};\n\n/** Encode Date instances inside a wire value, recording their paths in meta. */\nexport const encodeValue = (\n value: unknown,\n): { value: unknown; meta?: StatewireMetaEntry[] } => {\n const meta: StatewireMetaEntry[] = [];\n const encoded = walkDates(value, [], meta);\n return meta.length > 0 ? { value: encoded, meta } : { value: encoded };\n};\n"],"mappings":"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"}
|
package/package.json
CHANGED
package/src/client/connection.ts
CHANGED
|
@@ -11,9 +11,18 @@ export type Episode = {
|
|
|
11
11
|
message?: string;
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
+
/** The terminal cmd verdict types; "applied" is the sole non-terminal type. */
|
|
15
|
+
export const TERMINAL = [
|
|
16
|
+
"result",
|
|
17
|
+
"rejected",
|
|
18
|
+
"failed",
|
|
19
|
+
"crashed",
|
|
20
|
+
"expired",
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
14
23
|
export type StatewireCmd = {
|
|
15
24
|
seq: number;
|
|
16
|
-
type?: "applied" |
|
|
25
|
+
type?: "applied" | (typeof TERMINAL)[number];
|
|
17
26
|
code?: string;
|
|
18
27
|
message?: string;
|
|
19
28
|
payload?: unknown;
|
|
@@ -25,7 +34,7 @@ export type StatewireCmd = {
|
|
|
25
34
|
export type StatewireSyn = {
|
|
26
35
|
seq?: number;
|
|
27
36
|
ctxSeq?: number;
|
|
28
|
-
record?: "
|
|
37
|
+
record?: "ephemeral";
|
|
29
38
|
id?: string;
|
|
30
39
|
idle?: boolean;
|
|
31
40
|
retry?: number;
|
|
@@ -344,10 +353,7 @@ const settleFin = (c: Conn, fin: StatewireHost.Fin): AttemptOutcome => {
|
|
|
344
353
|
}
|
|
345
354
|
// An auth denial gets one immediate redial; a consecutive repeat parks as gone.
|
|
346
355
|
const denial = finish.reason === "unauthorized";
|
|
347
|
-
const terminal =
|
|
348
|
-
finish.reason === "gone" ||
|
|
349
|
-
finish.reason === "superseded" ||
|
|
350
|
-
(denial && c.authDenials > 0);
|
|
356
|
+
const terminal = finish.reason === "gone" || (denial && c.authDenials > 0);
|
|
351
357
|
if (denial) c.authDenials += 1;
|
|
352
358
|
c.ctx.onFinish(terminal ? finish : { ...finish, reason: "error" });
|
|
353
359
|
if (!terminal) {
|
package/src/client/session.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useContext, useEffect, useState } from "react";
|
|
2
2
|
import { applyOps } from "../wire/ops";
|
|
3
3
|
import type { StatewirePath } from "../wire/ops";
|
|
4
|
+
import { TERMINAL } from "./connection";
|
|
4
5
|
import type { StatewireCmd, StatewireSyn } from "./connection";
|
|
5
6
|
import {
|
|
6
7
|
encodeParams,
|
|
@@ -70,6 +71,7 @@ type AttachState = "detached" | "attached" | "finished";
|
|
|
70
71
|
|
|
71
72
|
export type StatewireSessionOptions = {
|
|
72
73
|
sessionStore?: StatewireClient.SessionStorage | undefined;
|
|
74
|
+
onSessionLost?: StatewireClient.OnSessionLost | undefined;
|
|
73
75
|
onDone?: (() => void) | undefined;
|
|
74
76
|
};
|
|
75
77
|
|
|
@@ -92,6 +94,7 @@ type Session = {
|
|
|
92
94
|
durableSeq: number;
|
|
93
95
|
resumeAt: number;
|
|
94
96
|
idle: boolean;
|
|
97
|
+
onSessionLost: StatewireClient.OnSessionLost | undefined;
|
|
95
98
|
onDone: (() => void) | undefined;
|
|
96
99
|
ctxOps: unknown[] | undefined;
|
|
97
100
|
ctxPending: CtxPending[];
|
|
@@ -252,14 +255,9 @@ const settleEntry = (
|
|
|
252
255
|
}
|
|
253
256
|
};
|
|
254
257
|
|
|
255
|
-
const settleAll = (s: Session, detach: "unmount" | "gone"
|
|
256
|
-
const cause =
|
|
257
|
-
|
|
258
|
-
unmount: "unmount",
|
|
259
|
-
gone: "stream-gone",
|
|
260
|
-
superseded: "superseded",
|
|
261
|
-
} as const
|
|
262
|
-
)[detach];
|
|
258
|
+
const settleAll = (s: Session, detach: "unmount" | "gone") => {
|
|
259
|
+
const cause =
|
|
260
|
+
detach === "unmount" ? ("unmount" as const) : ("stream-gone" as const);
|
|
263
261
|
const batch = s.pending;
|
|
264
262
|
const detachFailure = (entry: Pending): StatewireClient.SendFailure => {
|
|
265
263
|
if (!entry.attempted) {
|
|
@@ -286,26 +284,30 @@ const restateContext = (s: Session) => {
|
|
|
286
284
|
s.ctxPending.push({ seq, ops: s.ctxOps, posted: false });
|
|
287
285
|
};
|
|
288
286
|
|
|
287
|
+
const settleLost = (s: Session, entries: Pending[]) => {
|
|
288
|
+
for (const entry of entries) {
|
|
289
|
+
s.lifecycle?.onCovered?.(entry.inv);
|
|
290
|
+
settleEntry(s, entry, {
|
|
291
|
+
ok: false,
|
|
292
|
+
failure: { fate: "lost", cause: "session-lost" },
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
289
297
|
// The server-side record's dedup memory is gone: history that may have
|
|
290
298
|
// reached a server settles lost — the new hello's snapshot is the only
|
|
291
299
|
// truth about what survived — and never-attempted statements re-stamp
|
|
292
|
-
//
|
|
293
|
-
const
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
failure: { fate: "lost", cause: "session-lost" },
|
|
301
|
-
});
|
|
302
|
-
} else {
|
|
303
|
-
survivors.push(entry);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
300
|
+
// on the adopted lane clocks.
|
|
301
|
+
const rebaseRecord = (s: Session, adopt: () => void) => {
|
|
302
|
+
const held = [...s.pending];
|
|
303
|
+
const survivors = held.filter((p) => !p.attempted && !p.applied);
|
|
304
|
+
settleLost(
|
|
305
|
+
s,
|
|
306
|
+
held.filter((p) => p.attempted || p.applied),
|
|
307
|
+
);
|
|
306
308
|
s.pending = [];
|
|
307
309
|
s.durableSeq = 0;
|
|
308
|
-
|
|
310
|
+
adopt();
|
|
309
311
|
for (const old of survivors) {
|
|
310
312
|
s.lifecycle?.onCovered?.(old.inv);
|
|
311
313
|
enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
|
|
@@ -316,33 +318,17 @@ const resetRecord = (s: Session, clientId: string) => {
|
|
|
316
318
|
void pump(s);
|
|
317
319
|
};
|
|
318
320
|
|
|
321
|
+
const resetRecord = (s: Session, clientId: string) =>
|
|
322
|
+
rebaseRecord(s, () =>
|
|
323
|
+
setRecord(s, { clientId, nextSeq: 1, ctxNextSeq: 1, commands: [] }),
|
|
324
|
+
);
|
|
325
|
+
|
|
319
326
|
// Another sharer replaced the record's identity: their reset settles our
|
|
320
327
|
// attempted history and our survivors re-stamp on the stored clocks.
|
|
321
|
-
const adoptReset = (s: Session, stored: StatewireClient.SessionRecord) =>
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
s.lifecycle?.onCovered?.(entry.inv);
|
|
326
|
-
settleEntry(s, entry, {
|
|
327
|
-
ok: false,
|
|
328
|
-
failure: { fate: "lost", cause: "session-lost" },
|
|
329
|
-
});
|
|
330
|
-
} else {
|
|
331
|
-
survivors.push(entry);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
s.pending = [];
|
|
335
|
-
s.durableSeq = 0;
|
|
336
|
-
s.record = stored;
|
|
337
|
-
for (const old of survivors) {
|
|
338
|
-
s.lifecycle?.onCovered?.(old.inv);
|
|
339
|
-
enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
|
|
340
|
-
}
|
|
341
|
-
restateContext(s);
|
|
342
|
-
publish(s);
|
|
343
|
-
wake(s);
|
|
344
|
-
void pump(s);
|
|
345
|
-
};
|
|
328
|
+
const adoptReset = (s: Session, stored: StatewireClient.SessionRecord) =>
|
|
329
|
+
rebaseRecord(s, () => {
|
|
330
|
+
s.record = stored;
|
|
331
|
+
});
|
|
346
332
|
|
|
347
333
|
// The record died with the attach: mint a fresh id for the next one.
|
|
348
334
|
const ephemeralDetach = (s: Session) => {
|
|
@@ -365,42 +351,31 @@ const outcomeOf = (
|
|
|
365
351
|
cmd: StatewireCmd,
|
|
366
352
|
):
|
|
367
353
|
| { ok: true; payload: unknown }
|
|
368
|
-
| { ok: false; failure: StatewireClient.SendFailure } =>
|
|
369
|
-
cmd.type
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
: cmd
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
},
|
|
394
|
-
}
|
|
395
|
-
: protocolError(`unknown cmd type: ${String(cmd.type)}`);
|
|
396
|
-
|
|
397
|
-
const TERMINAL_CMD_TYPES = new Set([
|
|
398
|
-
"result",
|
|
399
|
-
"rejected",
|
|
400
|
-
"failed",
|
|
401
|
-
"crashed",
|
|
402
|
-
"expired",
|
|
403
|
-
]);
|
|
354
|
+
| { ok: false; failure: StatewireClient.SendFailure } => {
|
|
355
|
+
switch (cmd.type) {
|
|
356
|
+
case "result":
|
|
357
|
+
return { ok: true, payload: reviveResponse(cmd).payload };
|
|
358
|
+
case "rejected":
|
|
359
|
+
return { ok: false, failure: rejectedFailure(reviveResponse(cmd)) };
|
|
360
|
+
case "failed":
|
|
361
|
+
return {
|
|
362
|
+
ok: false,
|
|
363
|
+
failure: {
|
|
364
|
+
fate: "failed",
|
|
365
|
+
cause: "server",
|
|
366
|
+
...(cmd.message !== undefined && { message: cmd.message }),
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
case "crashed":
|
|
370
|
+
return { ok: false, failure: { fate: "crashed", cause: "server" } };
|
|
371
|
+
case "expired":
|
|
372
|
+
return { ok: false, failure: { fate: "expired", cause: "server" } };
|
|
373
|
+
default:
|
|
374
|
+
return protocolError(`unknown cmd type: ${String(cmd.type)}`);
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const TERMINAL_CMD_TYPES: ReadonlySet<string> = new Set(TERMINAL);
|
|
404
379
|
|
|
405
380
|
const parkVerdict = (s: Session, cmd: StatewireCmd) => {
|
|
406
381
|
if (!s.shared || !TERMINAL_CMD_TYPES.has(cmd.type!)) return;
|
|
@@ -473,24 +448,10 @@ const publishIngested = (s: Session) => {
|
|
|
473
448
|
wake(s);
|
|
474
449
|
};
|
|
475
450
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
//
|
|
479
|
-
|
|
480
|
-
// never-admitted statements consecutively after the watermark.
|
|
481
|
-
const snapClocks = (s: Session, seqWm: number, ctxWm: number) => {
|
|
482
|
-
const tail = s.pending.filter((p) => p.seq > seqWm);
|
|
483
|
-
const cmdAligned =
|
|
484
|
-
tail.every((p, i) => p.seq === seqWm + 1 + i) &&
|
|
485
|
-
s.record.nextSeq === seqWm + tail.length + 1;
|
|
486
|
-
if (!cmdAligned) {
|
|
487
|
-
s.pending = s.pending.filter((p) => p.seq <= seqWm);
|
|
488
|
-
setRecord(s, { ...s.record, nextSeq: seqWm + 1 });
|
|
489
|
-
for (const old of tail) {
|
|
490
|
-
s.lifecycle?.onCovered?.(old.inv);
|
|
491
|
-
enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
|
|
492
|
-
}
|
|
493
|
-
}
|
|
451
|
+
// A hello's cmd watermark may understate a durable record (cold host, tray
|
|
452
|
+
// unloaded): held seqs resend as-is and the frame's answer arbitrates —
|
|
453
|
+
// absorption where the record survived, a gap refusal where it did not.
|
|
454
|
+
const snapCtxClock = (s: Session, ctxWm: number) => {
|
|
494
455
|
const ctxAligned =
|
|
495
456
|
s.ctxPending.every((c, i) => c.seq === ctxWm + 1 + i) &&
|
|
496
457
|
s.record.ctxNextSeq === ctxWm + s.ctxPending.length + 1;
|
|
@@ -500,39 +461,69 @@ const snapClocks = (s: Session, seqWm: number, ctxWm: number) => {
|
|
|
500
461
|
}
|
|
501
462
|
};
|
|
502
463
|
|
|
464
|
+
// The refusal's watermarks are exact: every held statement above them was
|
|
465
|
+
// never admitted by the live record, and the first unposted seq being beyond
|
|
466
|
+
// watermark+1 proves the record that admitted the missing prefix is gone —
|
|
467
|
+
// its history settles lost and the lane clocks restart at the watermarks.
|
|
468
|
+
const sessionLostInner = (s: Session, seqWm: number, ctxWm: number) => {
|
|
469
|
+
const held = [...s.pending];
|
|
470
|
+
settleLost(s, held);
|
|
471
|
+
s.pending = [];
|
|
472
|
+
s.durableSeq = seqWm;
|
|
473
|
+
setRecord(s, {
|
|
474
|
+
clientId: s.record.clientId,
|
|
475
|
+
nextSeq: seqWm + 1,
|
|
476
|
+
ctxNextSeq: ctxWm + 1,
|
|
477
|
+
commands: [],
|
|
478
|
+
});
|
|
479
|
+
restateContext(s);
|
|
480
|
+
if (
|
|
481
|
+
held.length > 0 &&
|
|
482
|
+
s.onSessionLost?.(held.map((p) => p.inv)) === "requeue"
|
|
483
|
+
) {
|
|
484
|
+
for (const old of held) {
|
|
485
|
+
enqueueInner(
|
|
486
|
+
s,
|
|
487
|
+
old.command,
|
|
488
|
+
old.inv.params,
|
|
489
|
+
() => {},
|
|
490
|
+
() => {},
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
publish(s);
|
|
495
|
+
wake(s);
|
|
496
|
+
};
|
|
497
|
+
|
|
503
498
|
const handleHello = (
|
|
504
499
|
s: Session,
|
|
505
500
|
{ ops, syn }: { ops: unknown[]; syn: StatewireSyn },
|
|
506
501
|
) => {
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
protocolError(`unknown record: ${String(record)}`);
|
|
502
|
+
if (syn.record !== undefined && syn.record !== "ephemeral") {
|
|
503
|
+
protocolError(`unknown record: ${String(syn.record)}`);
|
|
510
504
|
}
|
|
511
505
|
ingestOps(s, ops);
|
|
512
506
|
s.attach = "attached";
|
|
513
507
|
s.attachEpoch += 1;
|
|
514
508
|
s.frameFailures = 0;
|
|
515
509
|
s.resumeAt = 0;
|
|
516
|
-
s.ephemeral = record === "ephemeral";
|
|
510
|
+
s.ephemeral = syn.record === "ephemeral";
|
|
517
511
|
if (s.ephemeral) s.deactivated = true;
|
|
518
512
|
const [attachCmd, attachCtx] = s.attachLaneSeqs;
|
|
519
513
|
s.attachLaneSeqs = [new Set(), new Set()];
|
|
520
514
|
const reconcile = () => {
|
|
521
|
-
if (
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
}
|
|
528
|
-
const ctxWatermark = syn.ctxSeq ?? 0;
|
|
529
|
-
s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
|
|
530
|
-
for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
|
|
531
|
-
snapClocks(s, syn.seq ?? 0, ctxWatermark);
|
|
532
|
-
syncBacklogInner(s);
|
|
533
|
-
} else {
|
|
534
|
-
resetRecord(s, s.record.clientId);
|
|
515
|
+
if (s.shared) adoptStored(s);
|
|
516
|
+
// Resending unsettled statements is exactly-once-safe: absorbed replays
|
|
517
|
+
// re-emit their stored verdicts (applied while still in flight).
|
|
518
|
+
s.durableSeq = syn.seq ?? 0;
|
|
519
|
+
for (const entry of s.pending) {
|
|
520
|
+
entry.posted = attachCmd.has(entry.seq);
|
|
535
521
|
}
|
|
522
|
+
const ctxWatermark = syn.ctxSeq ?? 0;
|
|
523
|
+
s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
|
|
524
|
+
for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
|
|
525
|
+
snapCtxClock(s, ctxWatermark);
|
|
526
|
+
syncBacklogInner(s);
|
|
536
527
|
s.sessionKnown = true;
|
|
537
528
|
};
|
|
538
529
|
if (!s.shared || s.deactivated) {
|
|
@@ -557,8 +548,9 @@ const handleOps = (
|
|
|
557
548
|
publishIngested(s);
|
|
558
549
|
};
|
|
559
550
|
|
|
560
|
-
// A {syn} refusal
|
|
561
|
-
//
|
|
551
|
+
// A retry-less {syn} refusal is the one session-loss verdict: a cmd gap
|
|
552
|
+
// against its exact watermark settles the held history lost; a pushback
|
|
553
|
+
// (retry present) or a gapless refusal only reconciles the resend queues.
|
|
562
554
|
const handleRefusal = (s: Session, syn: StatewireSyn) => {
|
|
563
555
|
const { seq, ctxSeq } = syn;
|
|
564
556
|
if (typeof seq === "number") {
|
|
@@ -571,19 +563,30 @@ const handleRefusal = (s: Session, syn: StatewireSyn) => {
|
|
|
571
563
|
}
|
|
572
564
|
if (typeof syn.retry === "number") {
|
|
573
565
|
s.resumeAt = Date.now() + syn.retry;
|
|
566
|
+
wake(s);
|
|
567
|
+
void pump(s);
|
|
568
|
+
return;
|
|
574
569
|
}
|
|
575
570
|
const firstCmd = s.pending.find((p) => !p.posted);
|
|
576
571
|
const firstCtx = s.ctxPending.find((c) => !c.posted);
|
|
577
|
-
const
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
572
|
+
const cmdGap =
|
|
573
|
+
typeof seq === "number" &&
|
|
574
|
+
firstCmd !== undefined &&
|
|
575
|
+
firstCmd.seq > Math.max(seq, s.durableSeq) + 1;
|
|
576
|
+
const ctxGap =
|
|
577
|
+
typeof ctxSeq === "number" &&
|
|
578
|
+
firstCtx !== undefined &&
|
|
579
|
+
firstCtx.seq > ctxSeq + 1;
|
|
580
|
+
if (cmdGap || ctxGap) {
|
|
581
|
+
void transact(s, () => {
|
|
582
|
+
if (cmdGap) sessionLostInner(s, seq!, ctxSeq ?? 0);
|
|
583
|
+
else snapCtxClock(s, ctxSeq!);
|
|
584
|
+
}).then(() => {
|
|
585
|
+
wake(s);
|
|
586
|
+
void pump(s);
|
|
587
|
+
});
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
587
590
|
wake(s);
|
|
588
591
|
void pump(s);
|
|
589
592
|
};
|
|
@@ -597,11 +600,7 @@ const handleDetach = (s: Session) => {
|
|
|
597
600
|
const handleFinish = (s: Session, { reason }: { reason: string }) => {
|
|
598
601
|
if (reason === "evicted" || reason === "error") return;
|
|
599
602
|
s.attach = "finished";
|
|
600
|
-
|
|
601
|
-
// parks the verdicts; our promises settle from the observed record.
|
|
602
|
-
if (!(s.shared && reason === "superseded")) {
|
|
603
|
-
settleAll(s, reason === "superseded" ? "superseded" : "gone");
|
|
604
|
-
}
|
|
603
|
+
settleAll(s, "gone");
|
|
605
604
|
publish(s);
|
|
606
605
|
wake(s);
|
|
607
606
|
};
|
|
@@ -819,8 +818,8 @@ const enqueue = (
|
|
|
819
818
|
).then(() => void pump(s));
|
|
820
819
|
};
|
|
821
820
|
|
|
822
|
-
// A stored command
|
|
823
|
-
//
|
|
821
|
+
// A stored command may have reached a server: attempted settles it lost,
|
|
822
|
+
// never aborted, when a channel death ends the session.
|
|
824
823
|
const adoptStored = (s: Session) => {
|
|
825
824
|
const known = new Set(s.pending.map((p) => p.seq));
|
|
826
825
|
const stored = s.record.commands.filter((c) => !known.has(c.seq));
|
|
@@ -965,6 +964,7 @@ export const useStatewireSession = (
|
|
|
965
964
|
durableSeq: 0,
|
|
966
965
|
resumeAt: 0,
|
|
967
966
|
idle: false,
|
|
967
|
+
onSessionLost: undefined,
|
|
968
968
|
onDone: undefined,
|
|
969
969
|
ctxOps: undefined,
|
|
970
970
|
ctxPending: [],
|
|
@@ -1005,7 +1005,13 @@ export const useStatewireSession = (
|
|
|
1005
1005
|
});
|
|
1006
1006
|
return session;
|
|
1007
1007
|
});
|
|
1008
|
+
if ((store.lock !== undefined) !== (s.shared !== false)) {
|
|
1009
|
+
throw new Error(
|
|
1010
|
+
"statewire: session storage changed sharedness across renders — lock/subscribe must be present from the first render or never",
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1008
1013
|
s.store = store;
|
|
1014
|
+
s.onSessionLost = options.onSessionLost;
|
|
1009
1015
|
s.onDone = options.onDone;
|
|
1010
1016
|
useEffect(() => {
|
|
1011
1017
|
start(s);
|
package/src/host/http.ts
CHANGED
|
@@ -166,7 +166,7 @@ const getStream = async (
|
|
|
166
166
|
const attachment = attachSse(h, client.clientId);
|
|
167
167
|
const lease = attachment.registration === null ? null : mintLease();
|
|
168
168
|
if (attachment.registration !== null)
|
|
169
|
-
attachment.registration.
|
|
169
|
+
attachment.registration.conn.lease = lease;
|
|
170
170
|
return streamResponse(attachment, lease);
|
|
171
171
|
};
|
|
172
172
|
|
|
@@ -202,7 +202,7 @@ const postStream = async (
|
|
|
202
202
|
const attachment = attachSse(h, client.clientId);
|
|
203
203
|
if (attachment.registration === null) return streamResponse(attachment, null);
|
|
204
204
|
const lease = mintLease();
|
|
205
|
-
attachment.registration.
|
|
205
|
+
attachment.registration.conn.lease = lease;
|
|
206
206
|
admitAttachFrame(h, attachment.registration, frame);
|
|
207
207
|
return streamResponse(attachment, lease);
|
|
208
208
|
};
|
|
@@ -254,13 +254,14 @@ export const frames = async (
|
|
|
254
254
|
if ("response" in client) return client.response;
|
|
255
255
|
const rec = h.clients.get(client.clientId);
|
|
256
256
|
const lease = request.headers.get(LEASE_HEADER);
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
257
|
+
// A lease lives and dies with its attach: any live attach of the id accepts.
|
|
258
|
+
const live =
|
|
259
|
+
lease !== null &&
|
|
260
|
+
[...h.conns].some(
|
|
261
|
+
(conn) =>
|
|
262
|
+
conn.open && conn.clientId === client.clientId && conn.lease === lease,
|
|
263
|
+
);
|
|
264
|
+
if (h.disposed || h.finished !== null || rec === undefined || !live) {
|
|
264
265
|
return Response.json({ error: "stale-lease" }, { status: 423 });
|
|
265
266
|
}
|
|
266
267
|
let frame: ParsedFrame;
|