statewire 0.12.2 → 0.13.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/core.d.ts +2 -0
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +21 -3
- package/dist/client/core.js.map +1 -1
- package/dist/client/session.d.ts +4 -1
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +60 -2
- package/dist/client/session.js.map +1 -1
- package/dist/statewire.d.ts +22 -0
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +31 -11
- package/dist/statewire.js.map +1 -1
- package/dist/transports/remote.d.ts.map +1 -1
- package/dist/transports/remote.js +7 -2
- package/dist/transports/remote.js.map +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/client/core.ts +31 -3
- package/src/client/session.ts +100 -0
- package/src/statewire.ts +55 -5
- package/src/transports/remote.ts +8 -0
- package/src/types.ts +6 -0
package/dist/statewire.d.ts
CHANGED
|
@@ -13,6 +13,9 @@ declare class StatewireClient<TState, TCommands extends Statewire.Commands = Sta
|
|
|
13
13
|
get commands(): Statewire.CommandsProxy<TCommands>;
|
|
14
14
|
get isBusy(): boolean;
|
|
15
15
|
setContext(value: Record<string, unknown>): void;
|
|
16
|
+
get failed(): readonly StatewireClient.FailedCommand[];
|
|
17
|
+
retryFailed(entry: StatewireClient.FailedCommand): Promise<unknown>;
|
|
18
|
+
discardFailed(entry: StatewireClient.FailedCommand): void;
|
|
16
19
|
subscribe(listener: () => void): () => void;
|
|
17
20
|
dispose(): void;
|
|
18
21
|
}
|
|
@@ -34,6 +37,8 @@ declare namespace StatewireClient {
|
|
|
34
37
|
state: NonNullable<TState>;
|
|
35
38
|
}) => void;
|
|
36
39
|
onConnectionChange?: (connection: Connection) => void;
|
|
40
|
+
/** Drops a retained failed entry once a canonical snapshot shows its effect landed; checked against each new snapshot. */
|
|
41
|
+
failureCovered?: (state: NonNullable<TState>, entry: FailedCommand) => boolean;
|
|
37
42
|
};
|
|
38
43
|
type Helpers<TState, TCommands extends Statewire.Commands = Statewire.Commands> = {
|
|
39
44
|
/** The statewire's state type — remote statewires include `undefined` until the first attach snapshot applies. */
|
|
@@ -46,6 +51,12 @@ declare namespace StatewireClient {
|
|
|
46
51
|
isBusy: boolean;
|
|
47
52
|
/** Replace this client's ambient context on the server; throws on transports without a client context channel. */
|
|
48
53
|
setContext: (value: Record<string, unknown>) => void;
|
|
54
|
+
/** Non-accepted sends retained in the session record; reload with it. */
|
|
55
|
+
failed: readonly FailedCommand[];
|
|
56
|
+
/** Re-enqueues the entry's command on fresh clocks and removes the entry. */
|
|
57
|
+
retryFailed: (entry: FailedCommand) => Promise<unknown>;
|
|
58
|
+
/** Removes the entry. */
|
|
59
|
+
discardFailed: (entry: FailedCommand) => void;
|
|
49
60
|
};
|
|
50
61
|
type ConnectionState = {
|
|
51
62
|
status: "connecting";
|
|
@@ -129,6 +140,15 @@ declare namespace StatewireClient {
|
|
|
129
140
|
seq: number;
|
|
130
141
|
clientId?: string;
|
|
131
142
|
}>) => "requeue" | "drop";
|
|
143
|
+
/** A non-accepted send retained for the user: the wire statement, its failure, and its original clocks. */
|
|
144
|
+
type FailedCommand = {
|
|
145
|
+
readonly command: string;
|
|
146
|
+
readonly params: readonly unknown[];
|
|
147
|
+
readonly meta?: unknown;
|
|
148
|
+
readonly failure: SendFailure;
|
|
149
|
+
readonly ts: number;
|
|
150
|
+
readonly seq: number;
|
|
151
|
+
};
|
|
132
152
|
/** A command as persisted while un-durable: its lane seq plus the wire statement. */
|
|
133
153
|
type SessionCommand = {
|
|
134
154
|
seq: number;
|
|
@@ -144,6 +164,8 @@ declare namespace StatewireClient {
|
|
|
144
164
|
commands: readonly SessionCommand[];
|
|
145
165
|
/** Terminal verdicts parked for the sharer that minted the seq (shared storage only). */
|
|
146
166
|
verdicts?: readonly SessionVerdict[];
|
|
167
|
+
/** Non-accepted sends retained for the user; survive record rebases and resets. */
|
|
168
|
+
failed?: readonly FailedCommand[];
|
|
147
169
|
};
|
|
148
170
|
/** A terminal verdict one sharer's connection carried, parked for the sharer awaiting the seq. */
|
|
149
171
|
type SessionVerdict = {
|
package/dist/statewire.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"statewire.d.ts","names":[],"sources":["../src/statewire.ts"],"mappings":";;;;cAgCa,eACX,QACA,kBAAkB,UAAU,WAAW,UAAU,UAEjD,SAAS,gBAAgB,QAAQ,QAAQ,eACxC,gBAAgB,QAAQ,QAAQ;
|
|
1
|
+
{"version":3,"file":"statewire.d.ts","names":[],"sources":["../src/statewire.ts"],"mappings":";;;;cAgCa,eACX,QACA,kBAAkB,UAAU,WAAW,UAAU,UAEjD,SAAS,gBAAgB,QAAQ,QAAQ,eACxC,gBAAgB,QAAQ,QAAQ;cA6DtB,oBACX,QACA,kBAAkB,UAAU,WAAW,UAAU,UAEjD,SAAS,gBAAgB,QAAQ,QAAQ,eACxC,gBAAgB,gBAAgB,QAAQ,QAAQ;cAGtC,gBACX,QACA,kBAAkB,UAAU,WAAW,UAAU;mBAEhC;UACT;EAEI,YAAA,SAAS,gBAAgB,QAAQ,QAAQ;MAKjD,SAAS;MAIT,cAAc,gBAAgB;MAI9B,YAAY,UAAU,cAAc;MAIpC;EAIJ,WAAW,OAAO;MAId,mBAAmB,gBAAgB;EAIvC,YAAY,OAAO,gBAAgB,gBAAgB;EAInD,cAAc,OAAO,gBAAgB;EAIrC,UAAU;EAIV;;kBAOe;OACH,QACV,QACA,kBAAkB,UAAU,WAAW,UAAU;IAEjD,WAAW,gBAAgB,UAAU,QAAQ;;IAE7C;MACM,UAAU,gBAAgB;;;IAGhC,eAAe;IACf,aAAa,WAAW,YAAY,SAAS;IAC7C,cAAc,YAAY,YAAY;IACtC,iBACE,OAAO,YAAY,SACnB;MAAO,SAAS,UAAU;;IAE5B,mBACE,QAAQ,UAAU,cAAc,YAChC;MAAO,OAAO,YAAY;;IAE5B,sBAAsB,YAAY;;IAElC,kBACE,OAAO,YAAY,SACnB,OAAO;;OAIC,QACV,QACA,kBAAkB,UAAU,WAAW,UAAU;;IAGjD,OAAO;;IAEP,gBAAgB;IAChB,YAAY;IACZ,UAAU,UAAU,cAAc;;IAElC;;IAEA,aAAa,OAAO;;IAEpB,iBAAiB;;IAEjB,cAAc,OAAO,kBAAkB;;IAEvC,gBAAgB,OAAO;;OAGb;IACN;;IACA;;IACA;;IAEA;IACA;IACA;IACA;IACA;IACA;;IAEA;IAAgB;IAAkB;;OAC5B,aAAa;IAAoB;;OACjC,mBAAmB;;;;;OAMnB,WACV,QACA,kBAAkB,UAAU,WAAW,UAAU,aAEjD,OAAO,QACP,SAAS,UAAU,QAAQ,YAC3B,KAAK;OAGK;IACV;IACA;IACA;;;;;;;OAQU,YAAY,WAAW,OAAO;;;;;;;;;;;;;OAc9B;IAEN;IACA;IACA;IACA;IACA;;IAEA;IAAgB;IAAiB;;IACjC;IAAiB;;IACjB;IAAiB;;IAEjB;IACA;IACA;;IAGA;IACA;;;OAIM,iBACV,SAAS;IACP;IACA;IACA;IACA;IACA;;;OAKQ;aACD;aACA;aACA;aACA,SAAS;aACT;aACA;;;OAIC;IACV;IACA;IACA;IACA;;;OAIU;IACV;IACA;IACA;IACA,mBAAmB;;IAEnB,oBAAoB;;IAEpB,kBAAkB;;;OAIR;IACV;IACA,cAAc;IACd;IACA;IACA;IACA;;;OAIU;IACV,YAAY;IACZ,OAAO,QAAQ;;IAEf,QAAQ,GAAG,UAAU,IAAI,QAAQ,OAAO,QAAQ;;IAEhD,aAAa;;;OAIH,gBACR,gBACE;IAAO;QAAyB,cAAc,QAAQ;OAEhD;IACV,eAAe;IACf;IACA,UAAU;IACV,eAAe,WAAW;IAC1B,gBAAgB;IAChB,YAAY,YAAY;;IAExB;;OAGU;IACV,eAAe;IACf;IACA,UAAU;IACV;IACA,QACI,gCACO,yBAAyB,QAAQ;IAC5C,iBAAiB,aAAa,kCAAkC;IAChE,gBAAgB;IAChB,YAAY,YAAY;;IAExB"}
|
package/dist/statewire.js
CHANGED
|
@@ -17,20 +17,31 @@ const useStatewire = (options) => {
|
|
|
17
17
|
const [subscribe] = useState(() => (listener) => core.subscribe(() => flushTapSync(listener)));
|
|
18
18
|
const state = useSyncExternalStore(subscribe, core.getState, core.getState);
|
|
19
19
|
const isBusy = useSyncExternalStore(subscribe, core.getBusy, core.getBusy);
|
|
20
|
+
const failed = useSyncExternalStore(subscribe, core.getFailed, core.getFailed);
|
|
20
21
|
const connection = value.connection ?? LIVE_CONNECTION;
|
|
21
|
-
return useMemo(() =>
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
22
|
+
return useMemo(() => {
|
|
23
|
+
const failures = () => {
|
|
24
|
+
if (!value.failures) throw new Error("statewire: this transport has no failed-send lane");
|
|
25
|
+
return value.failures;
|
|
26
|
+
};
|
|
27
|
+
return {
|
|
28
|
+
state,
|
|
29
|
+
getState: core.getState,
|
|
30
|
+
connection,
|
|
31
|
+
commands: core.commands,
|
|
32
|
+
isBusy,
|
|
33
|
+
setContext: (contextValue) => {
|
|
34
|
+
if (!value.setContext) throw new Error("statewire: this transport has no client context channel");
|
|
35
|
+
value.setContext(contextValue);
|
|
36
|
+
},
|
|
37
|
+
failed,
|
|
38
|
+
retryFailed: (entry) => failures().retry(entry),
|
|
39
|
+
discardFailed: (entry_0) => failures().discard(entry_0)
|
|
40
|
+
};
|
|
41
|
+
}, [
|
|
32
42
|
state,
|
|
33
43
|
isBusy,
|
|
44
|
+
failed,
|
|
34
45
|
connection,
|
|
35
46
|
core,
|
|
36
47
|
value
|
|
@@ -59,6 +70,15 @@ var StatewireClient = class {
|
|
|
59
70
|
setContext(value) {
|
|
60
71
|
this.root.getValue().setContext(value);
|
|
61
72
|
}
|
|
73
|
+
get failed() {
|
|
74
|
+
return this.root.getValue().failed;
|
|
75
|
+
}
|
|
76
|
+
retryFailed(entry) {
|
|
77
|
+
return this.root.getValue().retryFailed(entry);
|
|
78
|
+
}
|
|
79
|
+
discardFailed(entry) {
|
|
80
|
+
this.root.getValue().discardFailed(entry);
|
|
81
|
+
}
|
|
62
82
|
subscribe(listener) {
|
|
63
83
|
return this.root.subscribe(listener);
|
|
64
84
|
}
|
package/dist/statewire.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"statewire.js","names":["useMemo","useRef","useState","useSyncExternalStore","createTapRoot","flushTapSync","resource","useContextProvider","useResource","useResources","withKey","ResourceElement","LIVE_CONNECTION","useStatewireCore","Statewire","TERMINAL","SessionStorageContext","useSessionTransport","transport","session","StatewireClient","SessionStorage","SessionTransport","useStatewire","Commands","options","Options","TState","TCommands","Helpers","latest","current","storage","undefined","value","core","subscribe","listener","state","getState","isBusy","getBusy","connection","commands","CommandsProxy","setContext","contextValue","Record","Error","StatewireResource","root","disposed","constructor","ClientRoot","getValue","Connection","dispose","unmount","initialState","optimistic","Optimistic","NonNullable","materialize","Materialize","onStateChange","ctx","changes","Changes","onCommandChange","update","CommandUpdate","onConnectionChange","ConnectionState","status","cause","attempt","nextRetryAt","lastError","message","payload","reconnect","ConnectionStatus","draft","command","Command","OptimisticContext","ts","seq","clientId","SendFailure","fate","code","predecessorLost","OnSessionLost","pending","ReadonlyArray","method","params","SessionCommand","meta","SessionRecord","nextSeq","ctxNextSeq","verdicts","SessionVerdict","type","load","save","record","lock","fn","T","Promise","HeadersOption","HeadersInit","staleAuth","SSEOptions","sessionStore","url","headers","fetch","globalThis","onSessionLost","onChange","onDone","WSOptions","protocols","query","webSocket","WebSocket"],"sources":["../src/statewire.ts"],"sourcesContent":["import { useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport {\n createTapRoot,\n flushTapSync,\n resource,\n useContextProvider,\n useResource,\n useResources,\n withKey,\n} from \"@assistant-ui/tap\";\nimport type { ResourceElement } from \"@assistant-ui/tap\";\nimport { LIVE_CONNECTION, useStatewireCore } from \"./client/core\";\nimport type { Statewire } from \"./types\";\nimport type { TERMINAL } from \"./client/connection\";\nimport { SessionStorageContext } from \"./client/sessionStore\";\n\nconst useSessionTransport = ({\n transport,\n session,\n}: {\n transport: ResourceElement<Statewire<any, any>>;\n session: StatewireClient.SessionStorage | undefined;\n}): Statewire<unknown> =>\n useContextProvider(SessionStorageContext, session, () =>\n // oxlint-disable-next-line react-hooks/rules-of-hooks\n useResource(transport),\n );\n\n// The transport owns the session; it reads the storage off the context this\n// wrapper's own fiber provides, so plain-React callers get a provider too.\nconst SessionTransport = resource(useSessionTransport);\n\nexport const useStatewire = <\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n>(\n options: StatewireClient.Options<TState, TCommands>,\n): StatewireClient.Helpers<TState, TCommands> => {\n const latest = useRef(options);\n latest.current = options;\n\n const session = useResources(\n options.storage?.session !== undefined\n ? [withKey(\"session\", options.storage.session)]\n : [],\n )[0];\n const value = useResource(\n SessionTransport({ transport: options.transport, session }),\n );\n const core = useStatewireCore(() => latest.current, value);\n const [subscribe] = useState(\n () => (listener: () => void) =>\n core.subscribe(() => flushTapSync(listener)),\n );\n\n const state = useSyncExternalStore(\n subscribe,\n core.getState,\n core.getState,\n ) as TState;\n const isBusy = useSyncExternalStore(subscribe, core.getBusy, core.getBusy);\n const connection = value.connection ?? LIVE_CONNECTION;\n\n return useMemo(\n () => ({\n state,\n getState: core.getState as () => TState,\n connection,\n commands: core.commands as Statewire.CommandsProxy<TCommands>,\n isBusy,\n setContext: (contextValue: Record<string, unknown>) => {\n if (!value.setContext) {\n throw new Error(\n \"statewire: this transport has no client context channel\",\n );\n }\n value.setContext(contextValue);\n },\n }),\n [state, isBusy, connection, core, value],\n );\n};\n\nexport const StatewireResource = <\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n>(\n options: StatewireClient.Options<TState, TCommands>,\n): ResourceElement<StatewireClient.Helpers<TState, TCommands>> =>\n resource(useStatewire<TState, TCommands>)(options);\n\nexport class StatewireClient<\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n> {\n private readonly root;\n private disposed = false;\n\n constructor(options: StatewireClient.Options<TState, TCommands>) {\n const ClientRoot = () => useStatewire<TState, TCommands>(options);\n this.root = createTapRoot(ClientRoot);\n }\n\n get state(): TState {\n return this.root.getValue().getState();\n }\n\n get connection(): StatewireClient.Connection {\n return this.root.getValue().connection;\n }\n\n get commands(): Statewire.CommandsProxy<TCommands> {\n return this.root.getValue().commands;\n }\n\n get isBusy(): boolean {\n return this.root.getValue().isBusy;\n }\n\n setContext(value: Record<string, unknown>): void {\n this.root.getValue().setContext(value);\n }\n\n subscribe(listener: () => void): () => void {\n return this.root.subscribe(listener);\n }\n\n dispose(): void {\n if (this.disposed) return;\n this.disposed = true;\n this.root.unmount();\n }\n}\n\nexport namespace StatewireClient {\n export type Options<\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n > = {\n transport: ResourceElement<Statewire<TState, TCommands>>;\n /** Durable session: the mounted storage holds this client's lane identity and its un-durable commands, which resend on the next attach. */\n storage?:\n | { session?: ResourceElement<SessionStorage> | undefined }\n | undefined;\n /** Served as the canonical state until the attach snapshot; own commands fold over it. */\n initialState?: TState;\n optimistic?: Optimistic<NonNullable<TState>, TCommands>;\n materialize?: Materialize<NonNullable<TState>>;\n onStateChange?: (\n state: NonNullable<TState>,\n ctx: { changes: Statewire.Changes },\n ) => void;\n onCommandChange?: (\n update: Statewire.CommandUpdate<TCommands>,\n ctx: { state: NonNullable<TState> },\n ) => void;\n onConnectionChange?: (connection: Connection) => void;\n };\n\n export type Helpers<\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n > = {\n /** The statewire's state type — remote statewires include `undefined` until the first attach snapshot applies. */\n state: TState;\n /** Identity-stable across renders; reads the current canonical state without subscribing the caller. */\n getState: () => TState;\n connection: Connection;\n commands: Statewire.CommandsProxy<TCommands>;\n /** Negation of the server's broadcast idle level (a transport without idle is always busy). */\n isBusy: boolean;\n /** Replace this client's ambient context on the server; throws on transports without a client context channel. */\n setContext: (value: Record<string, unknown>) => void;\n };\n\n export type ConnectionState =\n | { status: \"connecting\" }\n | { status: \"live\" }\n | { status: \"idle\" }\n | {\n status: \"reconnecting\";\n cause: \"dropped\" | \"evicted\" | \"error\" | \"unauthorized\";\n attempt: number;\n nextRetryAt: number;\n lastError?: string;\n message?: string;\n }\n | { status: \"gone\"; message?: string; payload?: unknown };\n export type Connection = ConnectionState & { reconnect: () => void };\n export type ConnectionStatus = Connection[\"status\"];\n\n /**\n * Mutate the draft in place; the client records the next state plus the exact\n * changed paths. Re-run against the new base on every rebase.\n */\n export type Optimistic<\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n > = (\n draft: TState,\n command: Statewire.Command<TCommands>,\n ctx: OptimisticContext,\n ) => void;\n\n export type OptimisticContext = {\n ts: number;\n seq: number;\n clientId?: string;\n };\n\n /**\n * Mutate the draft to make server-accepted, still-uncommitted intents in\n * the state concrete (e.g. dispatch records into messages). Runs after the\n * optimistic folds on every published state.\n */\n export type Materialize<TState> = (draft: TState) => void;\n\n /**\n * fate: \"aborted\" = never sent, \"rejected\" = the server refused it\n * (`code` \"unknown-command\" | \"invalid-params\" for the static rejects,\n * code-less for handler rejects), \"lost\" = sent but no verdict will ever\n * arrive — the record is gone (a proven seq gap) or the channel died: it\n * may or may not have run, \"failed\" = the handler ran and threw,\n * \"crashed\" = the handler died involuntarily, \"expired\" = settled —\n * result, rejected, failed, or crashed — but the verdict outlived the\n * server's retention.\n * cause is which channel settled it: \"server\" = the server's answer;\n * the rest are channel deaths the client settled locally.\n */\n export type SendFailure =\n | {\n fate: \"rejected\";\n cause: \"server\";\n code?: string;\n message?: string;\n payload?: unknown;\n }\n | { fate: \"failed\"; cause: \"server\"; message?: string }\n | { fate: \"crashed\"; cause: \"server\" }\n | { fate: \"expired\"; cause: \"server\" }\n | {\n fate: \"aborted\";\n cause: \"unmount\" | \"stream-gone\" | \"session-lost\";\n predecessorLost?: true;\n }\n | {\n fate: \"lost\";\n cause: \"unmount\" | \"stream-gone\" | \"session-lost\";\n };\n\n /** Held commands a proven record gap settled `{fate: \"lost\", cause: \"session-lost\"}`; return \"requeue\" to re-enqueue them on the reset clocks. */\n export type OnSessionLost = (\n pending: ReadonlyArray<{\n method: string;\n params: readonly unknown[];\n ts: number;\n seq: number;\n clientId?: string;\n }>,\n ) => \"requeue\" | \"drop\";\n\n /** A command as persisted while un-durable: its lane seq plus the wire statement. */\n export type SessionCommand = {\n seq: number;\n method: string;\n params: unknown[];\n meta?: unknown;\n };\n\n /** The persisted session: the lane identity, the lane clocks, and the commands the server has not marked durable. */\n export type SessionRecord = {\n clientId: string;\n nextSeq: number;\n ctxNextSeq: number;\n commands: readonly SessionCommand[];\n /** Terminal verdicts parked for the sharer that minted the seq (shared storage only). */\n verdicts?: readonly SessionVerdict[];\n };\n\n /** A terminal verdict one sharer's connection carried, parked for the sharer awaiting the seq. */\n export type SessionVerdict = {\n seq: number;\n type: (typeof TERMINAL)[number];\n code?: string;\n message?: string;\n payload?: unknown;\n meta?: unknown;\n };\n\n /** Session storage seam; `save` must persist synchronously in the caller's flow. */\n export type SessionStorage = {\n load: () => SessionRecord | null;\n save: (record: SessionRecord) => void;\n /** Serializes record mutations across sharers; presence marks the storage shared. */\n lock?: <T>(fn: () => T | Promise<T>) => Promise<T>;\n /** Notifies of record saves by other sharers; returns unsubscribe. Required alongside `lock`. */\n subscribe?: (listener: () => void) => () => void;\n };\n\n /** Static headers, or a resolver invoked per request/attach; `staleAuth` is true right after an auth rejection. */\n export type HeadersOption =\n | HeadersInit\n | ((ctx: { staleAuth: boolean }) => HeadersInit | Promise<HeadersInit>);\n\n export type SSEOptions = {\n sessionStore?: SessionStorage | undefined;\n url: string;\n headers?: HeadersOption;\n fetch?: typeof globalThis.fetch;\n onSessionLost?: OnSessionLost;\n onChange?: (connection: Connection) => void;\n /** The broadcast idle flag's rising edge: the instance went quiet. */\n onDone?: () => void;\n };\n\n export type WSOptions = {\n sessionStore?: SessionStorage | undefined;\n url: string;\n headers?: HeadersOption;\n protocols?: string | string[];\n query?:\n | Record<string, string>\n | (() => Record<string, string> | Promise<Record<string, string>>);\n webSocket?: new (url: string, protocols?: string | string[]) => WebSocket;\n onSessionLost?: OnSessionLost;\n onChange?: (connection: Connection) => void;\n /** The broadcast idle flag's rising edge: the instance went quiet. */\n onDone?: () => void;\n };\n}\n"],"mappings":";;;;;AAgBA,MAAMiB,uBAAuB,EAC3BC,WACAC,cAKAZ,mBAAmBS,uBAAuBG,eAExCX,YAAYU,SAAS,CACvB;AAIF,MAAMI,mBAAmBhB,SAASW,mBAAmB;AAErD,MAAaM,gBAIXE,YAC+C;CAC/C,MAAMK,SAAS7B,OAAOwB,OAAO;CAC7BK,OAAOC,UAAUN;CAEjB,MAAMN,UAAUV,aACdgB,QAAQO,SAASb,YAAYc,KAAAA,IACzB,CAACvB,QAAQ,WAAWe,QAAQO,QAAQb,OAAO,CAAC,IAC5C,CAAA,CACN,CAAC,CAAC;CACF,MAAMe,QAAQ1B,YACZc,iBAAiB;EAAEJ,WAAWO,QAAQP;EAAWC;CAAQ,CAAC,CAC5D;CACA,MAAMgB,OAAOtB,uBAAuBiB,OAAOC,SAASG,KAAK;CACzD,MAAM,CAACE,aAAalC,gBACXmC,aACLF,KAAKC,gBAAgB/B,aAAagC,QAAQ,CAAC,CAC/C;CAEA,MAAMC,QAAQnC,qBACZiC,WACAD,KAAKI,UACLJ,KAAKI,QACP;CACA,MAAMC,SAASrC,qBAAqBiC,WAAWD,KAAKM,SAASN,KAAKM,OAAO;CACzE,MAAMC,aAAaR,MAAMQ,cAAc9B;CAEvC,OAAOZ,eACE;EACLsC;EACAC,UAAUJ,KAAKI;EACfG;EACAC,UAAUR,KAAKQ;EACfH;EACAK,aAAaC,iBAA0C;GACrD,IAAI,CAACZ,MAAMW,YACT,MAAM,IAAIG,MACR,yDACF;GAEFd,MAAMW,WAAWC,YAAY;EAC/B;CACF,IACA;EAACR;EAAOE;EAAQE;EAAYP;EAAMD;CAAK,CACzC;AACF;AAEA,MAAae,qBAIXxB,YAEAnB,SAASiB,YAA+B,CAAC,CAACE,OAAO;AAEnD,IAAaL,kBAAb,MAGE;CACA;CACA,WAAmB;CAEnBgC,YAAY3B,SAAqD;EAC/D,MAAM4B,mBAAmB9B,aAAgCE,OAAO;EAChE,KAAKyB,OAAO9C,cAAciD,UAAU;CACtC;CAEA,IAAIf,QAAgB;EAClB,OAAO,KAAKY,KAAKI,SAAS,CAAC,CAACf,SAAS;CACvC;CAEA,IAAIG,aAAyC;EAC3C,OAAO,KAAKQ,KAAKI,SAAS,CAAC,CAACZ;CAC9B;CAEA,IAAIC,WAA+C;EACjD,OAAO,KAAKO,KAAKI,SAAS,CAAC,CAACX;CAC9B;CAEA,IAAIH,SAAkB;EACpB,OAAO,KAAKU,KAAKI,SAAS,CAAC,CAACd;CAC9B;CAEAK,WAAWX,OAAsC;EAC/C,KAAKgB,KAAKI,SAAS,CAAC,CAACT,WAAWX,KAAK;CACvC;CAEAE,UAAUC,UAAkC;EAC1C,OAAO,KAAKa,KAAKd,UAAUC,QAAQ;CACrC;CAEAmB,UAAgB;EACd,IAAI,KAAKL,UAAU;EACnB,KAAKA,WAAW;EAChB,KAAKD,KAAKO,QAAQ;CACpB;AACF"}
|
|
1
|
+
{"version":3,"file":"statewire.js","names":["useMemo","useRef","useState","useSyncExternalStore","createTapRoot","flushTapSync","resource","useContextProvider","useResource","useResources","withKey","ResourceElement","LIVE_CONNECTION","useStatewireCore","Statewire","TERMINAL","SessionStorageContext","useSessionTransport","transport","session","StatewireClient","SessionStorage","SessionTransport","useStatewire","Commands","options","Options","TState","TCommands","Helpers","latest","current","storage","undefined","value","core","subscribe","listener","state","getState","isBusy","getBusy","failed","getFailed","connection","failures","NonNullable","Error","commands","CommandsProxy","setContext","contextValue","Record","retryFailed","entry","FailedCommand","retry","discardFailed","discard","StatewireResource","root","disposed","constructor","ClientRoot","getValue","Connection","Promise","dispose","unmount","initialState","optimistic","Optimistic","materialize","Materialize","onStateChange","ctx","changes","Changes","onCommandChange","update","CommandUpdate","onConnectionChange","failureCovered","ConnectionState","status","cause","attempt","nextRetryAt","lastError","message","payload","reconnect","ConnectionStatus","draft","command","Command","OptimisticContext","ts","seq","clientId","SendFailure","fate","code","predecessorLost","OnSessionLost","pending","ReadonlyArray","method","params","meta","failure","SessionCommand","SessionRecord","nextSeq","ctxNextSeq","verdicts","SessionVerdict","type","load","save","record","lock","fn","T","HeadersOption","HeadersInit","staleAuth","SSEOptions","sessionStore","url","headers","fetch","globalThis","onSessionLost","onChange","onDone","WSOptions","protocols","query","webSocket","WebSocket"],"sources":["../src/statewire.ts"],"sourcesContent":["import { useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport {\n createTapRoot,\n flushTapSync,\n resource,\n useContextProvider,\n useResource,\n useResources,\n withKey,\n} from \"@assistant-ui/tap\";\nimport type { ResourceElement } from \"@assistant-ui/tap\";\nimport { LIVE_CONNECTION, useStatewireCore } from \"./client/core\";\nimport type { Statewire } from \"./types\";\nimport type { TERMINAL } from \"./client/connection\";\nimport { SessionStorageContext } from \"./client/sessionStore\";\n\nconst useSessionTransport = ({\n transport,\n session,\n}: {\n transport: ResourceElement<Statewire<any, any>>;\n session: StatewireClient.SessionStorage | undefined;\n}): Statewire<unknown> =>\n useContextProvider(SessionStorageContext, session, () =>\n // oxlint-disable-next-line react-hooks/rules-of-hooks\n useResource(transport),\n );\n\n// The transport owns the session; it reads the storage off the context this\n// wrapper's own fiber provides, so plain-React callers get a provider too.\nconst SessionTransport = resource(useSessionTransport);\n\nexport const useStatewire = <\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n>(\n options: StatewireClient.Options<TState, TCommands>,\n): StatewireClient.Helpers<TState, TCommands> => {\n const latest = useRef(options);\n latest.current = options;\n\n const session = useResources(\n options.storage?.session !== undefined\n ? [withKey(\"session\", options.storage.session)]\n : [],\n )[0];\n const value = useResource(\n SessionTransport({ transport: options.transport, session }),\n );\n const core = useStatewireCore(() => latest.current, value);\n const [subscribe] = useState(\n () => (listener: () => void) =>\n core.subscribe(() => flushTapSync(listener)),\n );\n\n const state = useSyncExternalStore(\n subscribe,\n core.getState,\n core.getState,\n ) as TState;\n const isBusy = useSyncExternalStore(subscribe, core.getBusy, core.getBusy);\n const failed = useSyncExternalStore(\n subscribe,\n core.getFailed,\n core.getFailed,\n );\n const connection = value.connection ?? LIVE_CONNECTION;\n\n return useMemo(() => {\n const failures = (): NonNullable<typeof value.failures> => {\n if (!value.failures) {\n throw new Error(\"statewire: this transport has no failed-send lane\");\n }\n return value.failures;\n };\n return {\n state,\n getState: core.getState as () => TState,\n connection,\n commands: core.commands as Statewire.CommandsProxy<TCommands>,\n isBusy,\n setContext: (contextValue: Record<string, unknown>) => {\n if (!value.setContext) {\n throw new Error(\n \"statewire: this transport has no client context channel\",\n );\n }\n value.setContext(contextValue);\n },\n failed,\n retryFailed: (entry: StatewireClient.FailedCommand) =>\n failures().retry(entry),\n discardFailed: (entry: StatewireClient.FailedCommand) =>\n failures().discard(entry),\n };\n }, [state, isBusy, failed, connection, core, value]);\n};\n\nexport const StatewireResource = <\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n>(\n options: StatewireClient.Options<TState, TCommands>,\n): ResourceElement<StatewireClient.Helpers<TState, TCommands>> =>\n resource(useStatewire<TState, TCommands>)(options);\n\nexport class StatewireClient<\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n> {\n private readonly root;\n private disposed = false;\n\n constructor(options: StatewireClient.Options<TState, TCommands>) {\n const ClientRoot = () => useStatewire<TState, TCommands>(options);\n this.root = createTapRoot(ClientRoot);\n }\n\n get state(): TState {\n return this.root.getValue().getState();\n }\n\n get connection(): StatewireClient.Connection {\n return this.root.getValue().connection;\n }\n\n get commands(): Statewire.CommandsProxy<TCommands> {\n return this.root.getValue().commands;\n }\n\n get isBusy(): boolean {\n return this.root.getValue().isBusy;\n }\n\n setContext(value: Record<string, unknown>): void {\n this.root.getValue().setContext(value);\n }\n\n get failed(): readonly StatewireClient.FailedCommand[] {\n return this.root.getValue().failed;\n }\n\n retryFailed(entry: StatewireClient.FailedCommand): Promise<unknown> {\n return this.root.getValue().retryFailed(entry);\n }\n\n discardFailed(entry: StatewireClient.FailedCommand): void {\n this.root.getValue().discardFailed(entry);\n }\n\n subscribe(listener: () => void): () => void {\n return this.root.subscribe(listener);\n }\n\n dispose(): void {\n if (this.disposed) return;\n this.disposed = true;\n this.root.unmount();\n }\n}\n\nexport namespace StatewireClient {\n export type Options<\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n > = {\n transport: ResourceElement<Statewire<TState, TCommands>>;\n /** Durable session: the mounted storage holds this client's lane identity and its un-durable commands, which resend on the next attach. */\n storage?:\n | { session?: ResourceElement<SessionStorage> | undefined }\n | undefined;\n /** Served as the canonical state until the attach snapshot; own commands fold over it. */\n initialState?: TState;\n optimistic?: Optimistic<NonNullable<TState>, TCommands>;\n materialize?: Materialize<NonNullable<TState>>;\n onStateChange?: (\n state: NonNullable<TState>,\n ctx: { changes: Statewire.Changes },\n ) => void;\n onCommandChange?: (\n update: Statewire.CommandUpdate<TCommands>,\n ctx: { state: NonNullable<TState> },\n ) => void;\n onConnectionChange?: (connection: Connection) => void;\n /** Drops a retained failed entry once a canonical snapshot shows its effect landed; checked against each new snapshot. */\n failureCovered?: (\n state: NonNullable<TState>,\n entry: FailedCommand,\n ) => boolean;\n };\n\n export type Helpers<\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n > = {\n /** The statewire's state type — remote statewires include `undefined` until the first attach snapshot applies. */\n state: TState;\n /** Identity-stable across renders; reads the current canonical state without subscribing the caller. */\n getState: () => TState;\n connection: Connection;\n commands: Statewire.CommandsProxy<TCommands>;\n /** Negation of the server's broadcast idle level (a transport without idle is always busy). */\n isBusy: boolean;\n /** Replace this client's ambient context on the server; throws on transports without a client context channel. */\n setContext: (value: Record<string, unknown>) => void;\n /** Non-accepted sends retained in the session record; reload with it. */\n failed: readonly FailedCommand[];\n /** Re-enqueues the entry's command on fresh clocks and removes the entry. */\n retryFailed: (entry: FailedCommand) => Promise<unknown>;\n /** Removes the entry. */\n discardFailed: (entry: FailedCommand) => void;\n };\n\n export type ConnectionState =\n | { status: \"connecting\" }\n | { status: \"live\" }\n | { status: \"idle\" }\n | {\n status: \"reconnecting\";\n cause: \"dropped\" | \"evicted\" | \"error\" | \"unauthorized\";\n attempt: number;\n nextRetryAt: number;\n lastError?: string;\n message?: string;\n }\n | { status: \"gone\"; message?: string; payload?: unknown };\n export type Connection = ConnectionState & { reconnect: () => void };\n export type ConnectionStatus = Connection[\"status\"];\n\n /**\n * Mutate the draft in place; the client records the next state plus the exact\n * changed paths. Re-run against the new base on every rebase.\n */\n export type Optimistic<\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n > = (\n draft: TState,\n command: Statewire.Command<TCommands>,\n ctx: OptimisticContext,\n ) => void;\n\n export type OptimisticContext = {\n ts: number;\n seq: number;\n clientId?: string;\n };\n\n /**\n * Mutate the draft to make server-accepted, still-uncommitted intents in\n * the state concrete (e.g. dispatch records into messages). Runs after the\n * optimistic folds on every published state.\n */\n export type Materialize<TState> = (draft: TState) => void;\n\n /**\n * fate: \"aborted\" = never sent, \"rejected\" = the server refused it\n * (`code` \"unknown-command\" | \"invalid-params\" for the static rejects,\n * code-less for handler rejects), \"lost\" = sent but no verdict will ever\n * arrive — the record is gone (a proven seq gap) or the channel died: it\n * may or may not have run, \"failed\" = the handler ran and threw,\n * \"crashed\" = the handler died involuntarily, \"expired\" = settled —\n * result, rejected, failed, or crashed — but the verdict outlived the\n * server's retention.\n * cause is which channel settled it: \"server\" = the server's answer;\n * the rest are channel deaths the client settled locally.\n */\n export type SendFailure =\n | {\n fate: \"rejected\";\n cause: \"server\";\n code?: string;\n message?: string;\n payload?: unknown;\n }\n | { fate: \"failed\"; cause: \"server\"; message?: string }\n | { fate: \"crashed\"; cause: \"server\" }\n | { fate: \"expired\"; cause: \"server\" }\n | {\n fate: \"aborted\";\n cause: \"unmount\" | \"stream-gone\" | \"session-lost\";\n predecessorLost?: true;\n }\n | {\n fate: \"lost\";\n cause: \"unmount\" | \"stream-gone\" | \"session-lost\";\n };\n\n /** Held commands a proven record gap settled `{fate: \"lost\", cause: \"session-lost\"}`; return \"requeue\" to re-enqueue them on the reset clocks. */\n export type OnSessionLost = (\n pending: ReadonlyArray<{\n method: string;\n params: readonly unknown[];\n ts: number;\n seq: number;\n clientId?: string;\n }>,\n ) => \"requeue\" | \"drop\";\n\n /** A non-accepted send retained for the user: the wire statement, its failure, and its original clocks. */\n export type FailedCommand = {\n readonly command: string;\n readonly params: readonly unknown[];\n readonly meta?: unknown;\n readonly failure: SendFailure;\n readonly ts: number;\n readonly seq: number;\n };\n\n /** A command as persisted while un-durable: its lane seq plus the wire statement. */\n export type SessionCommand = {\n seq: number;\n method: string;\n params: unknown[];\n meta?: unknown;\n };\n\n /** The persisted session: the lane identity, the lane clocks, and the commands the server has not marked durable. */\n export type SessionRecord = {\n clientId: string;\n nextSeq: number;\n ctxNextSeq: number;\n commands: readonly SessionCommand[];\n /** Terminal verdicts parked for the sharer that minted the seq (shared storage only). */\n verdicts?: readonly SessionVerdict[];\n /** Non-accepted sends retained for the user; survive record rebases and resets. */\n failed?: readonly FailedCommand[];\n };\n\n /** A terminal verdict one sharer's connection carried, parked for the sharer awaiting the seq. */\n export type SessionVerdict = {\n seq: number;\n type: (typeof TERMINAL)[number];\n code?: string;\n message?: string;\n payload?: unknown;\n meta?: unknown;\n };\n\n /** Session storage seam; `save` must persist synchronously in the caller's flow. */\n export type SessionStorage = {\n load: () => SessionRecord | null;\n save: (record: SessionRecord) => void;\n /** Serializes record mutations across sharers; presence marks the storage shared. */\n lock?: <T>(fn: () => T | Promise<T>) => Promise<T>;\n /** Notifies of record saves by other sharers; returns unsubscribe. Required alongside `lock`. */\n subscribe?: (listener: () => void) => () => void;\n };\n\n /** Static headers, or a resolver invoked per request/attach; `staleAuth` is true right after an auth rejection. */\n export type HeadersOption =\n | HeadersInit\n | ((ctx: { staleAuth: boolean }) => HeadersInit | Promise<HeadersInit>);\n\n export type SSEOptions = {\n sessionStore?: SessionStorage | undefined;\n url: string;\n headers?: HeadersOption;\n fetch?: typeof globalThis.fetch;\n onSessionLost?: OnSessionLost;\n onChange?: (connection: Connection) => void;\n /** The broadcast idle flag's rising edge: the instance went quiet. */\n onDone?: () => void;\n };\n\n export type WSOptions = {\n sessionStore?: SessionStorage | undefined;\n url: string;\n headers?: HeadersOption;\n protocols?: string | string[];\n query?:\n | Record<string, string>\n | (() => Record<string, string> | Promise<Record<string, string>>);\n webSocket?: new (url: string, protocols?: string | string[]) => WebSocket;\n onSessionLost?: OnSessionLost;\n onChange?: (connection: Connection) => void;\n /** The broadcast idle flag's rising edge: the instance went quiet. */\n onDone?: () => void;\n };\n}\n"],"mappings":";;;;;AAgBA,MAAMiB,uBAAuB,EAC3BC,WACAC,cAKAZ,mBAAmBS,uBAAuBG,eAExCX,YAAYU,SAAS,CACvB;AAIF,MAAMI,mBAAmBhB,SAASW,mBAAmB;AAErD,MAAaM,gBAIXE,YAC+C;CAC/C,MAAMK,SAAS7B,OAAOwB,OAAO;CAC7BK,OAAOC,UAAUN;CAEjB,MAAMN,UAAUV,aACdgB,QAAQO,SAASb,YAAYc,KAAAA,IACzB,CAACvB,QAAQ,WAAWe,QAAQO,QAAQb,OAAO,CAAC,IAC5C,CAAA,CACN,CAAC,CAAC;CACF,MAAMe,QAAQ1B,YACZc,iBAAiB;EAAEJ,WAAWO,QAAQP;EAAWC;CAAQ,CAAC,CAC5D;CACA,MAAMgB,OAAOtB,uBAAuBiB,OAAOC,SAASG,KAAK;CACzD,MAAM,CAACE,aAAalC,gBACXmC,aACLF,KAAKC,gBAAgB/B,aAAagC,QAAQ,CAAC,CAC/C;CAEA,MAAMC,QAAQnC,qBACZiC,WACAD,KAAKI,UACLJ,KAAKI,QACP;CACA,MAAMC,SAASrC,qBAAqBiC,WAAWD,KAAKM,SAASN,KAAKM,OAAO;CACzE,MAAMC,SAASvC,qBACbiC,WACAD,KAAKQ,WACLR,KAAKQ,SACP;CACA,MAAMC,aAAaV,MAAMU,cAAchC;CAEvC,OAAOZ,cAAc;EACnB,MAAM6C,iBAAqD;GACzD,IAAI,CAACX,MAAMW,UACT,MAAM,IAAIE,MAAM,mDAAmD;GAErE,OAAOb,MAAMW;EACf;EACA,OAAO;GACLP;GACAC,UAAUJ,KAAKI;GACfK;GACAI,UAAUb,KAAKa;GACfR;GACAU,aAAaC,iBAA0C;IACrD,IAAI,CAACjB,MAAMgB,YACT,MAAM,IAAIH,MACR,yDACF;IAEFb,MAAMgB,WAAWC,YAAY;GAC/B;GACAT;GACAW,cAAcC,UACZT,SAAS,CAAC,CAACW,MAAMF,KAAK;GACxBG,gBAAgBH,YACdT,SAAS,CAAC,CAACa,QAAQJ,OAAK;EAC5B;CACF,GAAG;EAAChB;EAAOE;EAAQE;EAAQE;EAAYT;EAAMD;CAAK,CAAC;AACrD;AAEA,MAAayB,qBAIXlC,YAEAnB,SAASiB,YAA+B,CAAC,CAACE,OAAO;AAEnD,IAAaL,kBAAb,MAGE;CACA;CACA,WAAmB;CAEnB0C,YAAYrC,SAAqD;EAC/D,MAAMsC,mBAAmBxC,aAAgCE,OAAO;EAChE,KAAKmC,OAAOxD,cAAc2D,UAAU;CACtC;CAEA,IAAIzB,QAAgB;EAClB,OAAO,KAAKsB,KAAKI,SAAS,CAAC,CAACzB,SAAS;CACvC;CAEA,IAAIK,aAAyC;EAC3C,OAAO,KAAKgB,KAAKI,SAAS,CAAC,CAACpB;CAC9B;CAEA,IAAII,WAA+C;EACjD,OAAO,KAAKY,KAAKI,SAAS,CAAC,CAAChB;CAC9B;CAEA,IAAIR,SAAkB;EACpB,OAAO,KAAKoB,KAAKI,SAAS,CAAC,CAACxB;CAC9B;CAEAU,WAAWhB,OAAsC;EAC/C,KAAK0B,KAAKI,SAAS,CAAC,CAACd,WAAWhB,KAAK;CACvC;CAEA,IAAIQ,SAAmD;EACrD,OAAO,KAAKkB,KAAKI,SAAS,CAAC,CAACtB;CAC9B;CAEAW,YAAYC,OAAwD;EAClE,OAAO,KAAKM,KAAKI,SAAS,CAAC,CAACX,YAAYC,KAAK;CAC/C;CAEAG,cAAcH,OAA4C;EACxD,KAAKM,KAAKI,SAAS,CAAC,CAACP,cAAcH,KAAK;CAC1C;CAEAlB,UAAUC,UAAkC;EAC1C,OAAO,KAAKuB,KAAKxB,UAAUC,QAAQ;CACrC;CAEA8B,UAAgB;EACd,IAAI,KAAKN,UAAU;EACnB,KAAKA,WAAW;EAChB,KAAKD,KAAKQ,QAAQ;CACpB;AACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"remote.d.ts","names":[],"sources":["../../src/transports/remote.ts"],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"remote.d.ts","names":[],"sources":["../../src/transports/remote.ts"],"mappings":";;;;;cAca,qBACX;EACE;EACA,YAAY,YAAY,gBAAgB;EACxC;IACE,yBAEJ,SAAS,UACT,UAAU,SAAS,UAAU,KAAK,yBAAyB,eAC1D"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { setContext, setTransport, useStatewireSession } from "../client/session.js";
|
|
1
|
+
import { discardFailed, failedCommands, retryFailed, setContext, setTransport, useStatewireSession } from "../client/session.js";
|
|
2
2
|
import { useEffect } from "@assistant-ui/tap/react-shim";
|
|
3
3
|
//#region src/transports/remote.ts
|
|
4
4
|
const useStatewireRemote = (options, useLink) => {
|
|
@@ -20,7 +20,12 @@ const useStatewireRemote = (options, useLink) => {
|
|
|
20
20
|
get isBusy() {
|
|
21
21
|
return !s.idle;
|
|
22
22
|
},
|
|
23
|
-
setContext: (value) => setContext(s, value)
|
|
23
|
+
setContext: (value) => setContext(s, value),
|
|
24
|
+
failures: {
|
|
25
|
+
list: () => failedCommands(s),
|
|
26
|
+
retry: (entry) => retryFailed(s, entry),
|
|
27
|
+
discard: (entry_0) => discardFailed(s, entry_0)
|
|
28
|
+
}
|
|
24
29
|
};
|
|
25
30
|
};
|
|
26
31
|
//#endregion
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"remote.js","names":["useEffect","Statewire","setContext","setTransport","useStatewireSession","StatewireSessionOptions","StatewireClient","RemoteLink","StatewireRemoteProps","useStatewireRemote","url","onChange","connection","Connection","onDone","options","TOptions","useLink","ctx","s","sessionStore","onSessionLost","conn","state","canonical","commands","source","durableSeq","isBusy","idle","value"],"sources":["../../src/transports/remote.ts"],"sourcesContent":["import { useEffect } from \"react\";\nimport type { Statewire } from \"../types\";\nimport {\n setContext,\n setTransport,\n useStatewireSession,\n} from \"../client/session\";\nimport type { StatewireSessionOptions } from \"../client/session\";\nimport type { StatewireClient } from \"../statewire\";\nimport type { RemoteLink, StatewireRemoteProps } from \"./sse\";\n\nexport const useStatewireRemote = <\n TOptions extends {\n url: string;\n onChange?: (connection: StatewireClient.Connection) => void;\n onDone?: () => void;\n } & StatewireSessionOptions,\n>(\n options: TOptions,\n useLink: (options: TOptions, ctx: StatewireRemoteProps) => RemoteLink,\n): Statewire<any, any> => {\n const s = useStatewireSession({\n sessionStore: options.sessionStore,\n onSessionLost: options.onSessionLost,\n onDone: options.onDone,\n });\n const conn = useLink(options, s.ctx);\n useEffect(() => setTransport(s, conn), [s, conn]);\n\n return {\n state: s.canonical,\n commands: s.commands,\n connection: conn.connection,\n source: s.source,\n get durableSeq() {\n return s.durableSeq;\n },\n get isBusy() {\n return !s.idle;\n },\n setContext: (value) => setContext(s, value),\n };\n};\n"],"mappings":";;;
|
|
1
|
+
{"version":3,"file":"remote.js","names":["useEffect","Statewire","discardFailed","failedCommands","retryFailed","setContext","setTransport","useStatewireSession","StatewireSessionOptions","StatewireClient","RemoteLink","StatewireRemoteProps","useStatewireRemote","url","onChange","connection","Connection","onDone","options","TOptions","useLink","ctx","s","sessionStore","onSessionLost","conn","state","canonical","commands","source","durableSeq","isBusy","idle","value","failures","list","retry","entry","discard"],"sources":["../../src/transports/remote.ts"],"sourcesContent":["import { useEffect } from \"react\";\nimport type { Statewire } from \"../types\";\nimport {\n discardFailed,\n failedCommands,\n retryFailed,\n setContext,\n setTransport,\n useStatewireSession,\n} from \"../client/session\";\nimport type { StatewireSessionOptions } from \"../client/session\";\nimport type { StatewireClient } from \"../statewire\";\nimport type { RemoteLink, StatewireRemoteProps } from \"./sse\";\n\nexport const useStatewireRemote = <\n TOptions extends {\n url: string;\n onChange?: (connection: StatewireClient.Connection) => void;\n onDone?: () => void;\n } & StatewireSessionOptions,\n>(\n options: TOptions,\n useLink: (options: TOptions, ctx: StatewireRemoteProps) => RemoteLink,\n): Statewire<any, any> => {\n const s = useStatewireSession({\n sessionStore: options.sessionStore,\n onSessionLost: options.onSessionLost,\n onDone: options.onDone,\n });\n const conn = useLink(options, s.ctx);\n useEffect(() => setTransport(s, conn), [s, conn]);\n\n return {\n state: s.canonical,\n commands: s.commands,\n connection: conn.connection,\n source: s.source,\n get durableSeq() {\n return s.durableSeq;\n },\n get isBusy() {\n return !s.idle;\n },\n setContext: (value) => setContext(s, value),\n failures: {\n list: () => failedCommands(s),\n retry: (entry) => retryFailed(s, entry),\n discard: (entry) => discardFailed(s, entry),\n },\n };\n};\n"],"mappings":";;;AAcA,MAAaY,sBAOXM,SACAE,YACwB;CACxB,MAAME,IAAIf,oBAAoB;EAC5BgB,cAAcL,QAAQK;EACtBC,eAAeN,QAAQM;EACvBP,QAAQC,QAAQD;CAClB,CAAC;CACD,MAAMQ,OAAOL,QAAQF,SAASI,EAAED,GAAG;CACnCrB,gBAAgBM,aAAagB,GAAGG,IAAI,GAAG,CAACH,GAAGG,IAAI,CAAC;CAEhD,OAAO;EACLC,OAAOJ,EAAEK;EACTC,UAAUN,EAAEM;EACZb,YAAYU,KAAKV;EACjBc,QAAQP,EAAEO;EACV,IAAIC,aAAa;GACf,OAAOR,EAAEQ;EACX;EACA,IAAIC,SAAS;GACX,OAAO,CAACT,EAAEU;EACZ;EACA3B,aAAa4B,UAAU5B,WAAWiB,GAAGW,KAAK;EAC1CC,UAAU;GACRC,YAAYhC,eAAemB,CAAC;GAC5Bc,QAAQC,UAAUjC,YAAYkB,GAAGe,KAAK;GACtCC,UAAUD,YAAUnC,cAAcoB,GAAGe,OAAK;EAC5C;CACF;AACF"}
|
package/dist/types.d.ts
CHANGED
|
@@ -52,6 +52,12 @@ interface Statewire<TState = any, TCommands extends Statewire.Commands = Statewi
|
|
|
52
52
|
isBusy?: boolean;
|
|
53
53
|
/** Replace this client's ambient context on the server; it rides the ctx lane (no verdict). */
|
|
54
54
|
setContext?: (value: Record<string, unknown>) => void;
|
|
55
|
+
/** The retained failed-send lane; present on transports with a durable session. */
|
|
56
|
+
failures?: {
|
|
57
|
+
list: () => readonly StatewireClient.FailedCommand[];
|
|
58
|
+
retry: (entry: StatewireClient.FailedCommand) => Promise<unknown>;
|
|
59
|
+
discard: (entry: StatewireClient.FailedCommand) => void;
|
|
60
|
+
};
|
|
55
61
|
}
|
|
56
62
|
declare class StatewireSendError extends Error {
|
|
57
63
|
readonly failure: StatewireClient.SendFailure;
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","names":[],"sources":["../src/types.ts"],"mappings":";;;kBAGiB;OACH,WAAW,mBAAmB;OAE9B,QAAQ,kBAAkB,WAAW,eAC9C,WAAW;IAAc,QAAQ;IAAG,QAAQ,WAAW,UAAU;aAC5D;OAEI,cAAc,kBAAkB,eACzC,WAAW,gBACP,MAAM,WAAW,UAAU,QAC3B,QAAQ,QAAQ,WAAW,UAAU;OAGhC,cAAc,kBAAkB,WAAW,eACpD,WAAW;IACV,QAAQ;IACR,QAAQ,WAAW,UAAU;IAC7B;IACA;IACA;IACA,UAAU,gBAAgB;aAEtB;;OAGI;IACV;;IAEA,mBAAmB;;IAEnB,cAAc;;OAGJ;IACN;IAAe,MAAM;IAAe;;IACpC;IAAW,MAAM;IAAe;;IAChC;IAAc,MAAM;;OAEd;IACV,YAAY;IACZ,eAAe;;;UAIF,UACf,cACA,kBAAkB,UAAU,WAAW,UAAU;EAEjD,OAAO;EACP,UAAU,UAAU,cAAc;EAClC,aAAa,gBAAgB;EAC7B,SAAS,UAAU;;EAEnB;;EAEA;;EAEA,cAAc,OAAO;;
|
|
1
|
+
{"version":3,"file":"types.d.ts","names":[],"sources":["../src/types.ts"],"mappings":";;;kBAGiB;OACH,WAAW,mBAAmB;OAE9B,QAAQ,kBAAkB,WAAW,eAC9C,WAAW;IAAc,QAAQ;IAAG,QAAQ,WAAW,UAAU;aAC5D;OAEI,cAAc,kBAAkB,eACzC,WAAW,gBACP,MAAM,WAAW,UAAU,QAC3B,QAAQ,QAAQ,WAAW,UAAU;OAGhC,cAAc,kBAAkB,WAAW,eACpD,WAAW;IACV,QAAQ;IACR,QAAQ,WAAW,UAAU;IAC7B;IACA;IACA;IACA,UAAU,gBAAgB;aAEtB;;OAGI;IACV;;IAEA,mBAAmB;;IAEnB,cAAc;;OAGJ;IACN;IAAe,MAAM;IAAe;;IACpC;IAAW,MAAM;IAAe;;IAChC;IAAc,MAAM;;OAEd;IACV,YAAY;IACZ,eAAe;;;UAIF,UACf,cACA,kBAAkB,UAAU,WAAW,UAAU;EAEjD,OAAO;EACP,UAAU,UAAU,cAAc;EAClC,aAAa,gBAAgB;EAC7B,SAAS,UAAU;;EAEnB;;EAEA;;EAEA,cAAc,OAAO;;EAErB;IACE,qBAAqB,gBAAgB;IACrC,QAAQ,OAAO,gBAAgB,kBAAkB;IACjD,UAAU,OAAO,gBAAgB;;;cAuBxB,2BAA2B;WAC7B,SAAS,gBAAgB;EAEtB,YAAA,SAAS,gBAAgB"}
|
package/dist/types.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","names":["StatewireClient","StatewirePath","Statewire","Commands","Record","args","Command","TCommands","method","K","params","Parameters","CommandsProxy","Promise","Awaited","ReturnType","CommandUpdate","seq","status","result","failure","SendFailure","StateSource","snapshot","takeChanged","setOnChange","listener","Op","op","path","value","Changes","isChangedAt","getChangedKeys","state","TState","commands","connection","Connection","source","durableSeq","isBusy","setContext","describe","fate","code","message","cause","StatewireSendError","Error","constructor","name"],"sources":["../src/types.ts"],"sourcesContent":["import type { StatewireClient } from \"./statewire\";\nimport type { StatewirePath } from \"./wire/ops\";\n\nexport namespace Statewire {\n export type Commands = Record<string, (...args: never[]) => unknown>;\n\n export type Command<TCommands extends Commands = Commands> = {\n [K in keyof TCommands]: { method: K; params: Parameters<TCommands[K]> };\n }[keyof TCommands];\n\n export type CommandsProxy<TCommands extends Commands> = {\n [K in keyof TCommands]: (\n ...args: Parameters<TCommands[K]>\n ) => Promise<Awaited<ReturnType<TCommands[K]>>>;\n };\n\n export type CommandUpdate<TCommands extends Commands = Commands> = {\n [K in keyof TCommands]: {\n method: K;\n params: Parameters<TCommands[K]>;\n seq: number;\n status: \"queued\" | \"posted\" | \"applied\" | \"settled\";\n result?: unknown;\n failure?: StatewireClient.SendFailure;\n };\n }[keyof TCommands];\n\n /** Push-based canonical state feed a statewire hands to the client core. */\n export type StateSource = {\n snapshot: () => unknown;\n /** Drain the paths changed since the last take (`[]` entry = whole state). */\n takeChanged: () => StatewirePath[];\n /** Synchronous, per change; the consumer owns coalescing. */\n setOnChange: (listener: (() => void) | null) => void;\n };\n\n export type Op =\n | { op: \"replace\"; path: StatewirePath; value: unknown }\n | { op: \"add\"; path: StatewirePath; value: unknown }\n | { op: \"remove\"; path: StatewirePath };\n\n export type Changes = {\n isChangedAt(path: readonly (string | number)[]): boolean;\n getChangedKeys(path: readonly (string | number)[]): string[];\n };\n}\n\nexport interface Statewire<\n TState = any,\n TCommands extends Statewire.Commands = Statewire.Commands,\n> {\n state: TState;\n commands: Statewire.CommandsProxy<TCommands>;\n connection?: StatewireClient.Connection;\n source?: Statewire.StateSource;\n /** Highest command seq the server's dur flag states durably admitted — never lost, never run again — and released from the resend queue. */\n durableSeq?: number;\n /** Negation of the server's broadcast idle level; absent means always busy. */\n isBusy?: boolean;\n /** Replace this client's ambient context on the server; it rides the ctx lane (no verdict). */\n setContext?: (value: Record<string, unknown>) => void;\n}\n\nconst describe = (failure: StatewireClient.SendFailure): string => {\n switch (failure.fate) {\n case \"rejected\":\n return `statewire: command rejected${failure.code ? ` (${failure.code})` : \"\"}${failure.message ? `: ${failure.message}` : \"\"}`;\n case \"aborted\":\n return `statewire: command aborted (${failure.cause})`;\n case \"lost\":\n return `statewire: command lost (${failure.cause})`;\n case \"failed\":\n return failure.message\n ? `statewire: command failed: ${failure.message}`\n : \"statewire: command failed\";\n case \"crashed\":\n return \"statewire: command crashed\";\n case \"expired\":\n return `statewire: command result expired (${failure.cause})`;\n }\n};\n\nexport class StatewireSendError extends Error {\n readonly failure: StatewireClient.SendFailure;\n\n constructor(failure: StatewireClient.SendFailure) {\n super(describe(failure));\n this.name = \"StatewireSendError\";\n this.failure = failure;\n }\n}\n"],"mappings":";
|
|
1
|
+
{"version":3,"file":"types.js","names":["StatewireClient","StatewirePath","Statewire","Commands","Record","args","Command","TCommands","method","K","params","Parameters","CommandsProxy","Promise","Awaited","ReturnType","CommandUpdate","seq","status","result","failure","SendFailure","StateSource","snapshot","takeChanged","setOnChange","listener","Op","op","path","value","Changes","isChangedAt","getChangedKeys","state","TState","commands","connection","Connection","source","durableSeq","isBusy","setContext","failures","list","FailedCommand","retry","entry","discard","describe","fate","code","message","cause","StatewireSendError","Error","constructor","name"],"sources":["../src/types.ts"],"sourcesContent":["import type { StatewireClient } from \"./statewire\";\nimport type { StatewirePath } from \"./wire/ops\";\n\nexport namespace Statewire {\n export type Commands = Record<string, (...args: never[]) => unknown>;\n\n export type Command<TCommands extends Commands = Commands> = {\n [K in keyof TCommands]: { method: K; params: Parameters<TCommands[K]> };\n }[keyof TCommands];\n\n export type CommandsProxy<TCommands extends Commands> = {\n [K in keyof TCommands]: (\n ...args: Parameters<TCommands[K]>\n ) => Promise<Awaited<ReturnType<TCommands[K]>>>;\n };\n\n export type CommandUpdate<TCommands extends Commands = Commands> = {\n [K in keyof TCommands]: {\n method: K;\n params: Parameters<TCommands[K]>;\n seq: number;\n status: \"queued\" | \"posted\" | \"applied\" | \"settled\";\n result?: unknown;\n failure?: StatewireClient.SendFailure;\n };\n }[keyof TCommands];\n\n /** Push-based canonical state feed a statewire hands to the client core. */\n export type StateSource = {\n snapshot: () => unknown;\n /** Drain the paths changed since the last take (`[]` entry = whole state). */\n takeChanged: () => StatewirePath[];\n /** Synchronous, per change; the consumer owns coalescing. */\n setOnChange: (listener: (() => void) | null) => void;\n };\n\n export type Op =\n | { op: \"replace\"; path: StatewirePath; value: unknown }\n | { op: \"add\"; path: StatewirePath; value: unknown }\n | { op: \"remove\"; path: StatewirePath };\n\n export type Changes = {\n isChangedAt(path: readonly (string | number)[]): boolean;\n getChangedKeys(path: readonly (string | number)[]): string[];\n };\n}\n\nexport interface Statewire<\n TState = any,\n TCommands extends Statewire.Commands = Statewire.Commands,\n> {\n state: TState;\n commands: Statewire.CommandsProxy<TCommands>;\n connection?: StatewireClient.Connection;\n source?: Statewire.StateSource;\n /** Highest command seq the server's dur flag states durably admitted — never lost, never run again — and released from the resend queue. */\n durableSeq?: number;\n /** Negation of the server's broadcast idle level; absent means always busy. */\n isBusy?: boolean;\n /** Replace this client's ambient context on the server; it rides the ctx lane (no verdict). */\n setContext?: (value: Record<string, unknown>) => void;\n /** The retained failed-send lane; present on transports with a durable session. */\n failures?: {\n list: () => readonly StatewireClient.FailedCommand[];\n retry: (entry: StatewireClient.FailedCommand) => Promise<unknown>;\n discard: (entry: StatewireClient.FailedCommand) => void;\n };\n}\n\nconst describe = (failure: StatewireClient.SendFailure): string => {\n switch (failure.fate) {\n case \"rejected\":\n return `statewire: command rejected${failure.code ? ` (${failure.code})` : \"\"}${failure.message ? `: ${failure.message}` : \"\"}`;\n case \"aborted\":\n return `statewire: command aborted (${failure.cause})`;\n case \"lost\":\n return `statewire: command lost (${failure.cause})`;\n case \"failed\":\n return failure.message\n ? `statewire: command failed: ${failure.message}`\n : \"statewire: command failed\";\n case \"crashed\":\n return \"statewire: command crashed\";\n case \"expired\":\n return `statewire: command result expired (${failure.cause})`;\n }\n};\n\nexport class StatewireSendError extends Error {\n readonly failure: StatewireClient.SendFailure;\n\n constructor(failure: StatewireClient.SendFailure) {\n super(describe(failure));\n this.name = \"StatewireSendError\";\n this.failure = failure;\n }\n}\n"],"mappings":";AAqEA,MAAMiD,YAAY7B,YAAiD;CACjE,QAAQA,QAAQ8B,MAAhB;EACE,KAAK,YACH,OAAO,8BAA8B9B,QAAQ+B,OAAO,KAAK/B,QAAQ+B,KAAI,KAAM,KAAK/B,QAAQgC,UAAU,KAAKhC,QAAQgC,YAAY;EAC7H,KAAK,WACH,OAAO,+BAA+BhC,QAAQiC,MAAK;EACrD,KAAK,QACH,OAAO,4BAA4BjC,QAAQiC,MAAK;EAClD,KAAK,UACH,OAAOjC,QAAQgC,UACX,8BAA8BhC,QAAQgC,YACtC;EACN,KAAK,WACH,OAAO;EACT,KAAK,WACH,OAAO,sCAAsChC,QAAQiC,MAAK;CAC9D;AACF;AAEA,IAAaC,qBAAb,cAAwCC,MAAM;CAC5C;CAEAC,YAAYpC,SAAsC;EAChD,MAAM6B,SAAS7B,OAAO,CAAC;EACvB,KAAKqC,OAAO;EACZ,KAAKrC,UAAUA;CACjB;AACF"}
|
package/package.json
CHANGED
package/src/client/core.ts
CHANGED
|
@@ -15,6 +15,8 @@ export const LIVE_CONNECTION: StatewireClient.Connection = {
|
|
|
15
15
|
reconnect: () => {},
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
const NO_FAILED: readonly StatewireClient.FailedCommand[] = [];
|
|
19
|
+
|
|
18
20
|
const failureOf = (error: unknown): StatewireClient.SendFailure =>
|
|
19
21
|
error instanceof StatewireSendError
|
|
20
22
|
? error.failure
|
|
@@ -40,6 +42,7 @@ type Core = {
|
|
|
40
42
|
notifiedOverlayPaths: readonly StatewirePath[];
|
|
41
43
|
notifiedConnection: StatewireClient.Connection | null;
|
|
42
44
|
notifiedBusy: boolean;
|
|
45
|
+
notifiedFailed: readonly StatewireClient.FailedCommand[];
|
|
43
46
|
stale: boolean;
|
|
44
47
|
everAttached: boolean;
|
|
45
48
|
overlaysDirty: boolean;
|
|
@@ -50,6 +53,7 @@ type Core = {
|
|
|
50
53
|
commandsListener: CommandsListener;
|
|
51
54
|
getState: () => unknown;
|
|
52
55
|
getBusy: () => boolean;
|
|
56
|
+
getFailed: () => readonly StatewireClient.FailedCommand[];
|
|
53
57
|
commands: Statewire.CommandsProxy<any>;
|
|
54
58
|
subscribe: (listener: () => void) => () => void;
|
|
55
59
|
};
|
|
@@ -89,11 +93,21 @@ const drainCanonical = (c: Core) => {
|
|
|
89
93
|
}
|
|
90
94
|
};
|
|
91
95
|
|
|
96
|
+
const dropCovered = (c: Core) => {
|
|
97
|
+
const { failureCovered } = c.getOptions();
|
|
98
|
+
const failures = c.value?.failures;
|
|
99
|
+
if (!failureCovered || !failures || c.canonical === undefined) return;
|
|
100
|
+
for (const entry of failures.list()) {
|
|
101
|
+
if (failureCovered(c.canonical as never, entry)) failures.discard(entry);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
92
105
|
const recompute = (c: Core) => {
|
|
93
106
|
if (!c.stale || !c.value) return;
|
|
94
107
|
c.stale = false;
|
|
95
108
|
const prevCanonical = c.canonical;
|
|
96
109
|
drainCanonical(c);
|
|
110
|
+
if (c.canonical !== prevCanonical) dropCovered(c);
|
|
97
111
|
if (c.canonical === prevCanonical && !c.overlaysDirty) return;
|
|
98
112
|
c.overlaysDirty = false;
|
|
99
113
|
const { optimistic, materialize } = c.getOptions();
|
|
@@ -148,7 +162,7 @@ const recompute = (c: Core) => {
|
|
|
148
162
|
const getConnection = (c: Core): StatewireClient.Connection =>
|
|
149
163
|
c.value?.connection ?? LIVE_CONNECTION;
|
|
150
164
|
|
|
151
|
-
const flush = (c: Core) => {
|
|
165
|
+
const flush = (c: Core, scheduled = false) => {
|
|
152
166
|
if (!c.value) return;
|
|
153
167
|
if (c.flushing) {
|
|
154
168
|
schedule(c);
|
|
@@ -187,7 +201,19 @@ const flush = (c: Core) => {
|
|
|
187
201
|
const busy = c.getBusy();
|
|
188
202
|
const busyChanged = busy !== c.notifiedBusy;
|
|
189
203
|
c.notifiedBusy = busy;
|
|
190
|
-
|
|
204
|
+
// The failed snapshot advances only on the scheduled pass: settles land
|
|
205
|
+
// mid-unmount, where a changed snapshot re-renders the unmounting tree.
|
|
206
|
+
const failed = c.value.failures?.list() ?? NO_FAILED;
|
|
207
|
+
let failedChanged = false;
|
|
208
|
+
if (failed !== c.notifiedFailed) {
|
|
209
|
+
if (scheduled) {
|
|
210
|
+
c.notifiedFailed = failed;
|
|
211
|
+
failedChanged = true;
|
|
212
|
+
} else {
|
|
213
|
+
schedule(c);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (stateChanged || connectionChanged || busyChanged || failedChanged) {
|
|
191
217
|
for (const listener of [...c.listeners]) listener();
|
|
192
218
|
}
|
|
193
219
|
} finally {
|
|
@@ -271,6 +297,7 @@ export const useStatewireCore = (
|
|
|
271
297
|
notifiedOverlayPaths: [],
|
|
272
298
|
notifiedConnection: null,
|
|
273
299
|
notifiedBusy: true,
|
|
300
|
+
notifiedFailed: NO_FAILED,
|
|
274
301
|
stale: false,
|
|
275
302
|
everAttached: false,
|
|
276
303
|
overlaysDirty: false,
|
|
@@ -279,7 +306,7 @@ export const useStatewireCore = (
|
|
|
279
306
|
listeners: new Set(),
|
|
280
307
|
post: createMacrotaskPoster(() => {
|
|
281
308
|
c.scheduled = false;
|
|
282
|
-
flush(c);
|
|
309
|
+
flush(c, true);
|
|
283
310
|
}),
|
|
284
311
|
commandsListener: {
|
|
285
312
|
onStart: (inv) => {
|
|
@@ -313,6 +340,7 @@ export const useStatewireCore = (
|
|
|
313
340
|
return c.published;
|
|
314
341
|
},
|
|
315
342
|
getBusy: () => c.value?.isBusy ?? true,
|
|
343
|
+
getFailed: () => c.notifiedFailed,
|
|
316
344
|
commands: createMethodProxy<Statewire.CommandsProxy<any>>(
|
|
317
345
|
(method, params) => {
|
|
318
346
|
if (!c.value) {
|
package/src/client/session.ts
CHANGED
|
@@ -232,6 +232,34 @@ const cover = (s: Session, entry: Pending) => {
|
|
|
232
232
|
s.lifecycle?.onCovered?.(entry.inv);
|
|
233
233
|
};
|
|
234
234
|
|
|
235
|
+
const NO_FAILED: readonly StatewireClient.FailedCommand[] = [];
|
|
236
|
+
|
|
237
|
+
export const failedCommands = (
|
|
238
|
+
s: Session,
|
|
239
|
+
): readonly StatewireClient.FailedCommand[] => s.record.failed ?? NO_FAILED;
|
|
240
|
+
|
|
241
|
+
const retainFailed = (
|
|
242
|
+
s: Session,
|
|
243
|
+
entry: Pending,
|
|
244
|
+
failure: StatewireClient.SendFailure,
|
|
245
|
+
) => {
|
|
246
|
+
setRecord(s, {
|
|
247
|
+
...s.record,
|
|
248
|
+
failed: [
|
|
249
|
+
...(s.record.failed ?? []),
|
|
250
|
+
{
|
|
251
|
+
command: entry.command.method,
|
|
252
|
+
params: entry.command.params,
|
|
253
|
+
...(entry.command.meta !== undefined && { meta: entry.command.meta }),
|
|
254
|
+
failure,
|
|
255
|
+
ts: entry.inv.ts,
|
|
256
|
+
seq: entry.seq,
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
});
|
|
260
|
+
s.notify();
|
|
261
|
+
};
|
|
262
|
+
|
|
235
263
|
const settleEntry = (
|
|
236
264
|
s: Session,
|
|
237
265
|
entry: Pending,
|
|
@@ -247,6 +275,13 @@ const settleEntry = (
|
|
|
247
275
|
payload: outcome.payload,
|
|
248
276
|
});
|
|
249
277
|
} else {
|
|
278
|
+
// A statement the record still carries resends on the next attach; only
|
|
279
|
+
// invocations the record no longer owns become failed history.
|
|
280
|
+
const rides =
|
|
281
|
+
(outcome.failure.cause === "unmount" ||
|
|
282
|
+
outcome.failure.cause === "stream-gone") &&
|
|
283
|
+
entry.seq > s.durableSeq;
|
|
284
|
+
if (!rides) retainFailed(s, entry, outcome.failure);
|
|
250
285
|
entry.reject(new StatewireSendError(outcome.failure));
|
|
251
286
|
s.lifecycle?.onSettle(entry.inv, {
|
|
252
287
|
type: "rejected",
|
|
@@ -307,7 +342,15 @@ const rebaseRecord = (s: Session, adopt: () => void) => {
|
|
|
307
342
|
);
|
|
308
343
|
s.pending = [];
|
|
309
344
|
s.durableSeq = 0;
|
|
345
|
+
// Failed entries are user-owned history: they ride through every record replacement.
|
|
346
|
+
const failed = s.record.failed;
|
|
310
347
|
adopt();
|
|
348
|
+
if (failed !== undefined && s.record.failed !== failed) {
|
|
349
|
+
setRecord(s, {
|
|
350
|
+
...s.record,
|
|
351
|
+
failed: [...(s.record.failed ?? []), ...failed],
|
|
352
|
+
});
|
|
353
|
+
}
|
|
311
354
|
for (const old of survivors) {
|
|
312
355
|
s.lifecycle?.onCovered?.(old.inv);
|
|
313
356
|
enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
|
|
@@ -475,6 +518,7 @@ const sessionLostInner = (s: Session, seqWm: number, ctxWm: number) => {
|
|
|
475
518
|
nextSeq: seqWm + 1,
|
|
476
519
|
ctxNextSeq: ctxWm + 1,
|
|
477
520
|
commands: [],
|
|
521
|
+
...(s.record.failed !== undefined && { failed: s.record.failed }),
|
|
478
522
|
});
|
|
479
523
|
restateContext(s);
|
|
480
524
|
if (
|
|
@@ -876,6 +920,62 @@ const sendCommand = (
|
|
|
876
920
|
return result;
|
|
877
921
|
};
|
|
878
922
|
|
|
923
|
+
const takeFailed = (
|
|
924
|
+
s: Session,
|
|
925
|
+
entry: StatewireClient.FailedCommand,
|
|
926
|
+
): StatewireClient.FailedCommand => {
|
|
927
|
+
const failed = s.record.failed ?? [];
|
|
928
|
+
const found = failed.find(
|
|
929
|
+
(f) =>
|
|
930
|
+
f.seq === entry.seq && f.ts === entry.ts && f.command === entry.command,
|
|
931
|
+
);
|
|
932
|
+
if (found === undefined) {
|
|
933
|
+
throw new Error(`statewire: unknown failed command "${entry.command}"`);
|
|
934
|
+
}
|
|
935
|
+
setRecord(s, { ...s.record, failed: failed.filter((f) => f !== found) });
|
|
936
|
+
s.notify();
|
|
937
|
+
return found;
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
export const discardFailed = (
|
|
941
|
+
s: Session,
|
|
942
|
+
entry: StatewireClient.FailedCommand,
|
|
943
|
+
): void => {
|
|
944
|
+
takeFailed(s, entry);
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
export const retryFailed = (
|
|
948
|
+
s: Session,
|
|
949
|
+
entry: StatewireClient.FailedCommand,
|
|
950
|
+
): Promise<unknown> => {
|
|
951
|
+
if (!s.running) {
|
|
952
|
+
return rejectedSend({ fate: "aborted", cause: "unmount" });
|
|
953
|
+
}
|
|
954
|
+
if (isFinished(s)) {
|
|
955
|
+
return rejectedSend({ fate: "aborted", cause: "stream-gone" });
|
|
956
|
+
}
|
|
957
|
+
const found = takeFailed(s, entry);
|
|
958
|
+
const params =
|
|
959
|
+
found.meta === undefined
|
|
960
|
+
? found.params
|
|
961
|
+
: (reviveValue([...found.params], found.meta) as unknown[]);
|
|
962
|
+
const result = new Promise<unknown>((resolve, reject) => {
|
|
963
|
+
enqueue(
|
|
964
|
+
s,
|
|
965
|
+
{
|
|
966
|
+
method: found.command,
|
|
967
|
+
params: [...found.params],
|
|
968
|
+
...(found.meta !== undefined && { meta: found.meta }),
|
|
969
|
+
},
|
|
970
|
+
params,
|
|
971
|
+
resolve,
|
|
972
|
+
reject,
|
|
973
|
+
);
|
|
974
|
+
});
|
|
975
|
+
result.catch(() => {});
|
|
976
|
+
return result;
|
|
977
|
+
};
|
|
978
|
+
|
|
879
979
|
const setContext = (s: Session, value: Record<string, unknown>): void => {
|
|
880
980
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
881
981
|
throw new Error("statewire: context must be a plain object");
|