statewire 0.0.3 → 0.0.5

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.
@@ -147,6 +147,12 @@ const rejectedSend = (
147
147
 
148
148
  type AttachState = "detached" | "attached" | "finished";
149
149
 
150
+ export type StatewireSessionOptions = StatewireSessionStoreOptions & {
151
+ onCommandSessionLost?:
152
+ | ((command: StatewireClient.SessionLostCommand) => "replay" | void)
153
+ | undefined;
154
+ };
155
+
150
156
  type Session = {
151
157
  record: StatewireSessionRecord;
152
158
  ctx: StatewireRemoteProps;
@@ -166,13 +172,14 @@ type Session = {
166
172
  transport: RemoteLink | null;
167
173
  waiters: (() => void)[];
168
174
  lifecycle: CommandsListener | null;
175
+ onCommandSessionLost: StatewireSessionOptions["onCommandSessionLost"];
169
176
  notify: () => void;
170
177
  emitEvent: (event: unknown) => void;
171
178
  send: (command: StatewireWireCommand) => Promise<unknown>;
172
179
  commands: Record<string, (...params: unknown[]) => Promise<unknown>>;
173
180
  };
174
181
 
175
- const createSession = (options: StatewireSessionStoreOptions): Session => {
182
+ const createSession = (options: StatewireSessionOptions): Session => {
176
183
  const store = options.sessionStore ?? createMemorySessionStore();
177
184
  const stored = store.load();
178
185
  const record = stored ?? { clientId: newClientId(), nextSeq: 1 };
@@ -212,6 +219,7 @@ const createSession = (options: StatewireSessionStoreOptions): Session => {
212
219
  transport: null,
213
220
  waiters: [],
214
221
  lifecycle: null,
222
+ onCommandSessionLost: options.onCommandSessionLost,
215
223
  notify: () => {},
216
224
  emitEvent: () => {},
217
225
  send: (command) => sendCommand(s, command),
@@ -274,10 +282,12 @@ const settleAll = (
274
282
  if (state.at === "awaiting-result")
275
283
  return { fate: "result-unavailable", cause };
276
284
  if (state.at === "queued") {
277
- const cascades = batch.some(
285
+ const predecessorLost = batch.some(
278
286
  (p) => p.seq < entry.seq && p.state.at !== "queued",
279
287
  );
280
- return { fate: "aborted", cause: cascades ? "cascade" : cause };
288
+ return predecessorLost
289
+ ? { fate: "aborted", cause, predecessorLost }
290
+ : { fate: "aborted", cause };
281
291
  }
282
292
  return { fate: "lost", cause };
283
293
  };
@@ -288,6 +298,27 @@ const settleAll = (
288
298
  }
289
299
  };
290
300
 
301
+ // Offers every unsettled real command for replay, in seq order. Not caught:
302
+ // a throwing callback aborts the eviction before any entry settles; the
303
+ // attach retry evicts again and re-offers.
304
+ const claimReplays = (s: Session): Pending[] => {
305
+ const handler = s.onCommandSessionLost;
306
+ if (!handler) return [];
307
+ const claimed = new Set<Pending>();
308
+ for (const entry of s.pending) {
309
+ if (!entry.inv || isResolved(entry.state)) continue;
310
+ const decision = handler({
311
+ method: entry.inv.method,
312
+ params: [...entry.inv.params],
313
+ seq: entry.seq,
314
+ });
315
+ if (decision === "replay") claimed.add(entry);
316
+ }
317
+ if (claimed.size === 0) return [];
318
+ s.pending = s.pending.filter((p) => !claimed.has(p));
319
+ return [...claimed];
320
+ };
321
+
291
322
  const evict = (
292
323
  s: Session,
293
324
  verdict?: {
@@ -295,6 +326,7 @@ const evict = (
295
326
  failure: StatewireClient.SendFailure;
296
327
  },
297
328
  ) => {
329
+ const replays = claimReplays(s);
298
330
  settleAll(s, "evicted", verdict);
299
331
  s.record = { clientId: newClientId(), nextSeq: 1 };
300
332
  s.store.save(s.record);
@@ -302,6 +334,10 @@ const evict = (
302
334
  // The server dropped this client's record and its stored context with it:
303
335
  // the new session opens with a fresh full root replace.
304
336
  if (s.context !== undefined) void s.send({ context: s.context });
337
+ for (const old of replays) {
338
+ if (old.inv) s.lifecycle?.onCovered?.(old.inv);
339
+ enqueue(s, old.command, old.resolve, old.reject);
340
+ }
305
341
  publish(s);
306
342
  wake(s);
307
343
  };
@@ -645,16 +681,12 @@ const setTransport = (s: Session, transport: RemoteLink) => {
645
681
  wake(s);
646
682
  };
647
683
 
648
- const sendCommand = (
684
+ const enqueue = (
649
685
  s: Session,
650
686
  command: StatewireWireCommand,
651
- ): Promise<unknown> => {
652
- if (!s.running) {
653
- return rejectedSend({ fate: "aborted", cause: "unmount" });
654
- }
655
- if (isFinished(s)) {
656
- return rejectedSend({ fate: "aborted", cause: "stream-gone" });
657
- }
687
+ resolve: (payload: unknown) => void,
688
+ reject: (error: StatewireSendError) => void,
689
+ ) => {
658
690
  const seq = s.record.nextSeq;
659
691
  s.record = { ...s.record, nextSeq: seq + 1 };
660
692
  s.store.save(s.record);
@@ -672,17 +704,28 @@ const sendCommand = (
672
704
  clientId: s.record.clientId,
673
705
  },
674
706
  state: { at: "queued", pendingMarked: false },
675
- resolve: () => {},
676
- reject: () => {},
707
+ resolve,
708
+ reject,
677
709
  };
678
- const result = new Promise<unknown>((resolve, reject) => {
679
- entry.resolve = resolve;
680
- entry.reject = reject;
681
- });
682
- result.catch(() => {});
683
710
  s.pending.push(entry);
684
711
  if (entry.inv) s.lifecycle?.onStart(entry.inv);
685
712
  void pump(s);
713
+ };
714
+
715
+ const sendCommand = (
716
+ s: Session,
717
+ command: StatewireWireCommand,
718
+ ): Promise<unknown> => {
719
+ if (!s.running) {
720
+ return rejectedSend({ fate: "aborted", cause: "unmount" });
721
+ }
722
+ if (isFinished(s)) {
723
+ return rejectedSend({ fate: "aborted", cause: "stream-gone" });
724
+ }
725
+ const result = new Promise<unknown>((resolve, reject) => {
726
+ enqueue(s, command, resolve, reject);
727
+ });
728
+ result.catch(() => {});
686
729
  return result;
687
730
  };
688
731
 
@@ -698,10 +741,11 @@ const setContext = (
698
741
  };
699
742
 
700
743
  export const useStatewireSession = (
701
- options: StatewireSessionStoreOptions,
744
+ options: StatewireSessionOptions,
702
745
  emit: (event: unknown) => void,
703
746
  ): Session => {
704
747
  const [s] = useState(() => createSession(options));
748
+ s.onCommandSessionLost = options.onCommandSessionLost;
705
749
  useEffect(() => {
706
750
  s.emitEvent = emit;
707
751
  return () => {
package/src/host/host.ts CHANGED
@@ -250,6 +250,9 @@ const STALL_WARN_MS = 5000;
250
250
 
251
251
  // In-process dispatch has no wire client: never any ambient context, trivially attached.
252
252
  const LOCAL_CALLER: StatewireHost.Caller = Object.freeze({
253
+ get clientId(): string {
254
+ throw new Error("statewire: in-process dispatch has no client id");
255
+ },
253
256
  context: null,
254
257
  isConnected: true,
255
258
  });
@@ -387,6 +387,7 @@ const createSocketHost = (
387
387
  ) => settleResponse(rec, adm, responseOf(adm, outcome));
388
388
 
389
389
  const callerOf = (rec: ClientRec): StatewireHost.Caller => ({
390
+ clientId: rec.id,
390
391
  get context() {
391
392
  return rec.context;
392
393
  },
package/src/host.ts CHANGED
@@ -48,11 +48,13 @@ export namespace StatewireHost {
48
48
 
49
49
  /**
50
50
  * The submitting client, bound as `this` of a command handler. Holdable
51
- * across a long run: `context` reads the client's current ambient context
52
- * live (null until the client sends one), `isConnected` reports whether the
53
- * client has an active stream attachment.
51
+ * across a long run: `clientId` is the client's `Statewire-Client-Id`
52
+ * (in-process dispatch has none — reading it throws), `context` reads the
53
+ * client's current ambient context live (null until the client sends one),
54
+ * `isConnected` reports whether the client has an active stream attachment.
54
55
  */
55
56
  export type Caller = {
57
+ readonly clientId: string;
56
58
  readonly context: Record<string, unknown> | null;
57
59
  readonly isConnected: boolean;
58
60
  };
package/src/statewire.ts CHANGED
@@ -8,7 +8,6 @@ import {
8
8
  import type { ResourceElement } from "@assistant-ui/tap";
9
9
  import { LIVE_CONNECTION, useStatewireCore } from "./client/core";
10
10
  import type { Statewire } from "./types";
11
- import type { ConnectionState } from "./transports/sse";
12
11
  import type { StatewireSessionStoreOptions } from "./client/sessionStore";
13
12
 
14
13
  export const useStatewire = <
@@ -136,6 +135,18 @@ export namespace StatewireClient {
136
135
  setContext: (value: Record<string, unknown>) => Promise<unknown>;
137
136
  };
138
137
 
138
+ export type ConnectionState =
139
+ | { status: "connecting" }
140
+ | { status: "live" }
141
+ | {
142
+ status: "reconnecting";
143
+ cause: "dropped" | "evicted" | "error";
144
+ attempt: number;
145
+ nextRetryAt: number;
146
+ lastError?: string;
147
+ message?: string;
148
+ }
149
+ | { status: "gone"; message?: string; payload?: unknown };
139
150
  export type Connection = ConnectionState & { reconnect: () => void };
140
151
  export type ConnectionStatus = Connection["status"];
141
152
 
@@ -183,23 +194,31 @@ export namespace StatewireClient {
183
194
  }
184
195
  | {
185
196
  fate: "aborted";
186
- cause:
187
- | "cascade"
188
- | "unmount"
189
- | "stream-gone"
190
- | "session-lost"
191
- | "superseded";
197
+ cause: "unmount" | "stream-gone" | "session-lost" | "superseded";
198
+ predecessorLost?: true;
192
199
  }
193
200
  | {
194
201
  fate: "lost";
195
202
  cause: "unmount" | "stream-gone" | "session-lost" | "superseded";
196
203
  };
197
204
 
205
+ /**
206
+ * A pending command offered for replay while the session is being evicted.
207
+ * Return "replay" to re-enqueue it under the rotated client id instead of
208
+ * settling it as a session-lost failure.
209
+ */
210
+ export type SessionLostCommand = {
211
+ method: string;
212
+ params: unknown[];
213
+ seq: number;
214
+ };
215
+
198
216
  export type SSEOptions = StatewireSessionStoreOptions & {
199
217
  url: string;
200
218
  headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
201
219
  fetch?: typeof globalThis.fetch;
202
220
  onChange?: (connection: Connection) => void;
221
+ onCommandSessionLost?: (command: SessionLostCommand) => "replay" | void;
203
222
  };
204
223
 
205
224
  export type WSOptions = StatewireSessionStoreOptions & {
@@ -210,5 +229,6 @@ export namespace StatewireClient {
210
229
  | (() => Record<string, string> | Promise<Record<string, string>>);
211
230
  webSocket?: new (url: string, protocols?: string | string[]) => WebSocket;
212
231
  onChange?: (connection: Connection) => void;
232
+ onCommandSessionLost?: (command: SessionLostCommand) => "replay" | void;
213
233
  };
214
234
  }
@@ -6,7 +6,7 @@ import {
6
6
  setTransport,
7
7
  useStatewireSession,
8
8
  } from "../client/session";
9
- import type { StatewireSessionStoreOptions } from "../client/sessionStore";
9
+ import type { StatewireSessionOptions } from "../client/session";
10
10
  import type { StatewireClient } from "../statewire";
11
11
  import type { RemoteLink, StatewireRemoteProps } from "./sse";
12
12
 
@@ -14,13 +14,19 @@ export const useStatewireRemote = <
14
14
  TOptions extends {
15
15
  url: string;
16
16
  onChange?: (connection: StatewireClient.Connection) => void;
17
- } & StatewireSessionStoreOptions,
17
+ } & StatewireSessionOptions,
18
18
  >(
19
19
  options: TOptions,
20
20
  useLink: (options: TOptions, ctx: StatewireRemoteProps) => RemoteLink,
21
21
  ): Statewire<any, any, any> => {
22
22
  const emit = useStatewireEmit<any>();
23
- const s = useStatewireSession({ sessionStore: options.sessionStore }, emit);
23
+ const s = useStatewireSession(
24
+ {
25
+ sessionStore: options.sessionStore,
26
+ onCommandSessionLost: options.onCommandSessionLost,
27
+ },
28
+ emit,
29
+ );
24
30
  const conn = useLink(options, s.ctx);
25
31
  useEffect(() => setTransport(s, conn), [s, conn]);
26
32
 
@@ -58,18 +58,7 @@ export type StatewireSubmitResult =
58
58
  | { type: "unknown-client" }
59
59
  | { type: "stale-lease" };
60
60
 
61
- export type ConnectionState =
62
- | { status: "connecting" }
63
- | { status: "live" }
64
- | {
65
- status: "reconnecting";
66
- cause: "dropped" | "evicted" | "error";
67
- attempt: number;
68
- nextRetryAt: number;
69
- lastError?: string;
70
- message?: string;
71
- }
72
- | { status: "gone"; message?: string; payload?: unknown };
61
+ export type ConnectionState = StatewireClient.ConnectionState;
73
62
 
74
63
  /** The live wire a remote session drives: connection status plus command POST. */
75
64
  export type RemoteLink = {