statewire 0.0.4 → 0.4.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.
@@ -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
@@ -102,82 +102,10 @@ export const useStatewireState = <TState>(
102
102
 
103
103
  // ─── commands ───────────────────────────────────────────
104
104
 
105
- type ExecutionSettled =
105
+ type Settled =
106
106
  | { type: "resolved"; value: unknown }
107
107
  | { type: "rejected"; error: unknown };
108
108
 
109
- type ExecutionBind = {
110
- onAck: () => void;
111
- onSettle: (settled: ExecutionSettled) => void;
112
- };
113
-
114
- type ExecutionState = {
115
- acked: boolean;
116
- settled: ExecutionSettled | null;
117
- bind: ExecutionBind | null;
118
- };
119
-
120
- const executionStates = new WeakMap<object, ExecutionState>();
121
-
122
- /**
123
- * `CommandExecution(executor)` runs the (possibly async) `executor({ ack })`
124
- * immediately: its return value resolves the execution, a throw/rejection
125
- * rejects it; `ack()` releases the client's ack watermark before the executor
126
- * finishes. `CommandExecution.withResolvers()` returns `[execution,
127
- * { ack, resolve, reject }]` instead — settle them from your own spawned work.
128
- */
129
- export const CommandExecution = Object.assign(
130
- <T = void>(
131
- executor: (resolvers: {
132
- ack: () => void;
133
- }) => T | void | PromiseLike<T | void>,
134
- ): StatewireHost.Execution<T> => {
135
- const state: ExecutionState = { acked: false, settled: null, bind: null };
136
- const execution = Object.freeze(
137
- {},
138
- ) as unknown as StatewireHost.Execution<T>;
139
- executionStates.set(execution, state);
140
- const settle = (settled: ExecutionSettled) => {
141
- if (state.settled) return;
142
- state.settled = settled;
143
- state.acked = true;
144
- state.bind?.onSettle(settled);
145
- };
146
- const ack = () => {
147
- if (state.acked) return;
148
- state.acked = true;
149
- state.bind?.onAck();
150
- };
151
- void (async () => {
152
- try {
153
- settle({ type: "resolved", value: await executor({ ack }) });
154
- } catch (error) {
155
- settle({ type: "rejected", error });
156
- }
157
- })();
158
- return execution;
159
- },
160
- {
161
- withResolvers: <T = void>(): [
162
- StatewireHost.Execution<T>,
163
- StatewireHost.ExecutionResolvers<T>,
164
- ] => {
165
- let resolve!: (value: T) => void;
166
- let reject!: (error: unknown) => void;
167
- const promise = new Promise<T>((res, rej) => {
168
- resolve = res;
169
- reject = rej;
170
- });
171
- let ack!: () => void;
172
- const execution = CommandExecution<T>(({ ack: a }) => {
173
- ack = a;
174
- return promise;
175
- });
176
- return [execution, { ack: () => ack(), resolve, reject }];
177
- },
178
- },
179
- );
180
-
181
109
  export type CommandsInvocation = {
182
110
  readonly method: string;
183
111
  readonly params: readonly unknown[];
@@ -250,6 +178,9 @@ const STALL_WARN_MS = 5000;
250
178
 
251
179
  // In-process dispatch has no wire client: never any ambient context, trivially attached.
252
180
  const LOCAL_CALLER: StatewireHost.Caller = Object.freeze({
181
+ get clientId(): string {
182
+ throw new Error("statewire: in-process dispatch has no client id");
183
+ },
253
184
  context: null,
254
185
  isConnected: true,
255
186
  });
@@ -276,7 +207,7 @@ const settleInvocation = (
276
207
  cell: CommandsCell,
277
208
  inv: ActiveInvocation,
278
209
  call: QueuedCall,
279
- settled: ExecutionSettled,
210
+ settled: Settled,
280
211
  ) => {
281
212
  ackInvocation(cell, inv);
282
213
  if (settled.type === "resolved") {
@@ -296,14 +227,13 @@ const settleInvocation = (
296
227
  }
297
228
  };
298
229
 
299
- const pump = (cell: CommandsCell) => {
300
- if (cell.running || !cell.handlers) {
301
- watchStall(cell);
302
- return;
303
- }
304
- const call = cell.queue.shift();
305
- if (!call) return;
306
- const handler = cell.handlers[call.method];
230
+ const isThenable = (value: unknown): value is PromiseLike<unknown> =>
231
+ typeof value === "object" &&
232
+ value !== null &&
233
+ typeof (value as { then?: unknown }).then === "function";
234
+
235
+ const dispatch = (cell: CommandsCell, call: QueuedCall) => {
236
+ const handler = cell.handlers![call.method];
307
237
  if (handler === undefined) {
308
238
  const error = new StatewireSendError({
309
239
  fate: "unknown-command",
@@ -311,7 +241,6 @@ const pump = (cell: CommandsCell) => {
311
241
  });
312
242
  call.origin?.onSettle?.({ type: "rejected", error });
313
243
  call.reject(error);
314
- pump(cell);
315
244
  return;
316
245
  }
317
246
  const inv: ActiveInvocation = {
@@ -325,39 +254,55 @@ const pump = (cell: CommandsCell) => {
325
254
  clientId: call.origin.clientId,
326
255
  }),
327
256
  };
328
- cell.running = true;
329
257
  cell.listener?.onStart(inv);
330
258
  cell.listener?.onPosted?.(inv);
331
259
  cell.listener?.onCovered?.(inv);
332
- void (async () => {
333
- try {
334
- const value = await (
335
- handler as (...args: readonly unknown[]) => unknown
336
- ).call(call.origin?.caller ?? LOCAL_CALLER, ...call.params);
337
- const execution =
338
- typeof value === "object" && value !== null
339
- ? executionStates.get(value)
340
- : undefined;
341
- if (execution) {
342
- if (execution.acked) ackInvocation(cell, inv);
343
- if (execution.settled) {
344
- settleInvocation(cell, inv, call, execution.settled);
345
- } else {
346
- execution.bind = {
347
- onAck: () => ackInvocation(cell, inv),
348
- onSettle: (settled) => settleInvocation(cell, inv, call, settled),
349
- };
350
- }
351
- } else {
352
- settleInvocation(cell, inv, call, { type: "resolved", value });
353
- }
354
- } catch (error) {
355
- settleInvocation(cell, inv, call, { type: "rejected", error });
356
- } finally {
357
- cell.running = false;
358
- pump(cell);
260
+ const ctx: StatewireHost.Ctx = {
261
+ caller: call.origin?.caller ?? LOCAL_CALLER,
262
+ ack: () => ackInvocation(cell, inv),
263
+ };
264
+ try {
265
+ const value = (
266
+ handler as (
267
+ this: StatewireHost.Ctx,
268
+ ...args: readonly unknown[]
269
+ ) => unknown
270
+ ).call(ctx, ...call.params);
271
+ if (isThenable(value)) {
272
+ value.then(
273
+ (resolved) =>
274
+ settleInvocation(cell, inv, call, {
275
+ type: "resolved",
276
+ value: resolved,
277
+ }),
278
+ (error: unknown) =>
279
+ settleInvocation(cell, inv, call, { type: "rejected", error }),
280
+ );
281
+ } else {
282
+ settleInvocation(cell, inv, call, { type: "resolved", value });
283
+ }
284
+ } catch (error) {
285
+ settleInvocation(cell, inv, call, { type: "rejected", error });
286
+ }
287
+ };
288
+
289
+ // Prefix-only advancement: each handler runs synchronously to its first
290
+ // await, then the pump moves on; settlement rides the returned promise.
291
+ const pump = (cell: CommandsCell) => {
292
+ if (cell.running || !cell.handlers) {
293
+ watchStall(cell);
294
+ return;
295
+ }
296
+ cell.running = true;
297
+ try {
298
+ while (cell.handlers) {
299
+ const call = cell.queue.shift();
300
+ if (!call) break;
301
+ dispatch(cell, call);
359
302
  }
360
- })();
303
+ } finally {
304
+ cell.running = false;
305
+ }
361
306
  };
362
307
 
363
308
  const watchStall = (cell: CommandsCell) => {
@@ -375,7 +320,7 @@ const watchStall = (cell: CommandsCell) => {
375
320
 
376
321
  export const useStatewireCommands = <THandlers extends Statewire.Commands>(
377
322
  commands:
378
- | (THandlers & ThisType<StatewireHost.Caller>)
323
+ | (THandlers & ThisType<StatewireHost.Ctx>)
379
324
  | undefined
380
325
  | null
381
326
  | false,
@@ -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
@@ -4,17 +4,12 @@ import type { ServerOp } from "./host/draft";
4
4
  export { StatewireReject } from "./host/engine";
5
5
  export { append } from "./host/draft";
6
6
  export {
7
- CommandExecution,
8
7
  getStateHost,
9
8
  useStatewireState,
10
9
  useStatewireCommands,
11
10
  useStatewireEmit,
12
11
  } from "./host/host";
13
12
 
14
- declare const executionResult: unique symbol;
15
-
16
- type UnwrapExecution<V> = V extends StatewireHost.Execution<infer T> ? T : V;
17
-
18
13
  export namespace StatewireHost {
19
14
  export type StateCtl<TState = unknown> = {
20
15
  /** Apply server-vocabulary ops (root replace = whole-state swap). */
@@ -31,28 +26,25 @@ export namespace StatewireHost {
31
26
  export type StateSource = Statewire.StateSource;
32
27
 
33
28
  /**
34
- * Handle a command handler returns to keep the command open past its own
35
- * return: `ack()` releases the client's optimistic overlay / ack watermark,
36
- * `resolve`/`reject` settle the command (and imply ack). `ack` is idempotent
37
- * and a no-op after settle.
29
+ * Per-invocation handle bound as `this` of a command handler: `caller` is
30
+ * the submitting client, `ack()` releases the client's optimistic overlay /
31
+ * ack watermark before the handler settles (idempotent, a no-op after
32
+ * settle). The command settles when the handler's returned promise does.
38
33
  */
39
- export type Execution<T = void> = {
40
- readonly [executionResult]: (value: T) => void;
41
- };
42
-
43
- export type ExecutionResolvers<T = void> = {
44
- ack: () => void;
45
- resolve: (value: T) => void;
46
- reject: (error: unknown) => void;
34
+ export type Ctx = {
35
+ readonly caller: Caller;
36
+ readonly ack: () => void;
47
37
  };
48
38
 
49
39
  /**
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.
40
+ * The submitting client (`this.caller` in a command handler). Holdable
41
+ * across a long run: `clientId` is the client's `Statewire-Client-Id`
42
+ * (in-process dispatch has none — reading it throws), `context` reads the
43
+ * client's current ambient context live (null until the client sends one),
44
+ * `isConnected` reports whether the client has an active stream attachment.
54
45
  */
55
46
  export type Caller = {
47
+ readonly clientId: string;
56
48
  readonly context: Record<string, unknown> | null;
57
49
  readonly isConnected: boolean;
58
50
  };
@@ -60,7 +52,7 @@ export namespace StatewireHost {
60
52
  export type HandledCommands<THandlers extends Statewire.Commands> = {
61
53
  [K in keyof THandlers]: (
62
54
  ...args: Parameters<THandlers[K]>
63
- ) => UnwrapExecution<Awaited<ReturnType<THandlers[K]>>>;
55
+ ) => Awaited<ReturnType<THandlers[K]>>;
64
56
  };
65
57
 
66
58
  export type Fin = {
package/src/statewire.ts CHANGED
@@ -194,23 +194,31 @@ export namespace StatewireClient {
194
194
  }
195
195
  | {
196
196
  fate: "aborted";
197
- cause:
198
- | "cascade"
199
- | "unmount"
200
- | "stream-gone"
201
- | "session-lost"
202
- | "superseded";
197
+ cause: "unmount" | "stream-gone" | "session-lost" | "superseded";
198
+ predecessorLost?: true;
203
199
  }
204
200
  | {
205
201
  fate: "lost";
206
202
  cause: "unmount" | "stream-gone" | "session-lost" | "superseded";
207
203
  };
208
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
+
209
216
  export type SSEOptions = StatewireSessionStoreOptions & {
210
217
  url: string;
211
218
  headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
212
219
  fetch?: typeof globalThis.fetch;
213
220
  onChange?: (connection: Connection) => void;
221
+ onCommandSessionLost?: (command: SessionLostCommand) => "replay" | void;
214
222
  };
215
223
 
216
224
  export type WSOptions = StatewireSessionStoreOptions & {
@@ -221,5 +229,6 @@ export namespace StatewireClient {
221
229
  | (() => Record<string, string> | Promise<Record<string, string>>);
222
230
  webSocket?: new (url: string, protocols?: string | string[]) => WebSocket;
223
231
  onChange?: (connection: Connection) => void;
232
+ onCommandSessionLost?: (command: SessionLostCommand) => "replay" | void;
224
233
  };
225
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